mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-06-20 20:51:58 +08:00
Compare commits
157 Commits
release/st
...
release/me
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0537ff3961 | ||
|
|
a99718ac60 | ||
|
|
b7635b0238 | ||
|
|
d353d8aed9 | ||
|
|
085ae3c875 | ||
|
|
649ef50e44 | ||
|
|
47fb1c4675 | ||
|
|
fa048b44ac | ||
|
|
620eca08ec | ||
|
|
d7461c739a | ||
|
|
796606a8a8 | ||
|
|
4173c7678a | ||
|
|
886650cc0e | ||
|
|
7ea813a46b | ||
|
|
9a64d0f485 | ||
|
|
07fbe6d053 | ||
|
|
c50b964fd1 | ||
|
|
a8057f6e9e | ||
|
|
8c6ca75e93 | ||
|
|
b9f6a32ad6 | ||
|
|
eae05c5715 | ||
|
|
d6e3088998 | ||
|
|
4a483eaca6 | ||
|
|
655b49450e | ||
|
|
85e1f7f963 | ||
|
|
b9a33ce809 | ||
|
|
39b05b1a09 | ||
|
|
79a3be1874 | ||
|
|
b705810ec2 | ||
|
|
a942c5312f | ||
|
|
c18d3d5682 | ||
|
|
966995fb88 | ||
|
|
bbf96fe4b4 | ||
|
|
4e4b4ceed7 | ||
|
|
fd77f2df3e | ||
|
|
79a513470b | ||
|
|
53ac52562f | ||
|
|
58236ba8b5 | ||
|
|
16ebe0a64c | ||
|
|
2cdc3d0cd8 | ||
|
|
d5fbe445e1 | ||
|
|
b8bc91f7a0 | ||
|
|
0f06f9b2a2 | ||
|
|
780bb39a92 | ||
|
|
7203655ae7 | ||
|
|
21a15f98eb | ||
|
|
1a6b88d77f | ||
|
|
ff8a4300c6 | ||
|
|
736d2d385d | ||
|
|
2314ece9d1 | ||
|
|
b5061d1b8f | ||
|
|
fcda921d41 | ||
|
|
cb31782be4 | ||
|
|
8e294a5eed | ||
|
|
6a30e11ee5 | ||
|
|
94ef009bb5 | ||
|
|
5b82b51b17 | ||
|
|
b539b90119 | ||
|
|
bdf1d2dfab | ||
|
|
9c5c976d9a | ||
|
|
6f77ed2fcb | ||
|
|
034c206874 | ||
|
|
7b9a0740c1 | ||
|
|
8406f2f998 | ||
|
|
79a21890d8 | ||
|
|
bbd72c778c | ||
|
|
73f1fe105d | ||
|
|
fa664ccae3 | ||
|
|
0210d0e4b5 | ||
|
|
27a7491cd1 | ||
|
|
3358c5a53e | ||
|
|
285053b7a4 | ||
|
|
c7039e6b4a | ||
|
|
06d6080751 | ||
|
|
64935b9d47 | ||
|
|
0000bc7f32 | ||
|
|
bdb2744845 | ||
|
|
4c525c19c6 | ||
|
|
95daa39f0b | ||
|
|
24700d3a73 | ||
|
|
ec582a6ec4 | ||
|
|
9bfa56e226 | ||
|
|
9ef67bdcd8 | ||
|
|
77d45690a6 | ||
|
|
b70b047f75 | ||
|
|
8896de2642 | ||
|
|
eb89a49b58 | ||
|
|
0b4f43d72b | ||
|
|
22f6ddc045 | ||
|
|
773857351f | ||
|
|
382e756328 | ||
|
|
87ef618b04 | ||
|
|
ca92b849cd | ||
|
|
23dbbf6bdd | ||
|
|
b32a3566e6 | ||
|
|
a5c4d35074 | ||
|
|
7b908a3e39 | ||
|
|
093d2a329d | ||
|
|
40474d258c | ||
|
|
e19e289c58 | ||
|
|
cca24e05c1 | ||
|
|
581ff57323 | ||
|
|
b137122b18 | ||
|
|
5ea9a3990e | ||
|
|
5b5199359c | ||
|
|
9dbc861cdf | ||
|
|
fcc0267a46 | ||
|
|
c9150e8273 | ||
|
|
fcaabea6cb | ||
|
|
b5716ff3cb | ||
|
|
2f54d1d4c0 | ||
|
|
0b448558ca | ||
|
|
84276f64ae | ||
|
|
5560df133e | ||
|
|
f43c63699b | ||
|
|
7b1edaa51f | ||
|
|
ed8ad63288 | ||
|
|
a7370efc2f | ||
|
|
5b384d126f | ||
|
|
1532b55d77 | ||
|
|
0e75b80d95 | ||
|
|
9c57134dfb | ||
|
|
7ce7361cef | ||
|
|
7a1643c56c | ||
|
|
7e96c5d0f2 | ||
|
|
aa6168afe3 | ||
|
|
917a16381f | ||
|
|
7db84d57d3 | ||
|
|
95486173f7 | ||
|
|
ee081ebc85 | ||
|
|
502801777f | ||
|
|
b10b9ec1a7 | ||
|
|
c1def0e2c2 | ||
|
|
705aa641b0 | ||
|
|
2f88205231 | ||
|
|
e92222950a | ||
|
|
138943d6fb | ||
|
|
b36ab27b65 | ||
|
|
5e65ae7077 | ||
|
|
c0c89d7534 | ||
|
|
3b2a3f4e76 | ||
|
|
c8458ec79c | ||
|
|
aee096ab1e | ||
|
|
165c0b1b5d | ||
|
|
4c097f7190 | ||
|
|
ea763a2471 | ||
|
|
6e6bdf1e57 | ||
|
|
f0b4913ad6 | ||
|
|
29cd79c626 | ||
|
|
7db37ade1c | ||
|
|
4804cfcb05 | ||
|
|
799d8b9c2e | ||
|
|
5c4c9cdc99 | ||
|
|
8b86d4d85c | ||
|
|
962df5c3ed | ||
|
|
9f3de6e3c0 | ||
|
|
5c9e134e99 |
@@ -52,10 +52,6 @@ TRANSPORT_ENCRYPTION=false
|
||||
# Optional: External Services
|
||||
# ===========================================
|
||||
|
||||
# Telegram notifications (optional)
|
||||
# TELEGRAM_BOT_TOKEN=your-bot-token
|
||||
# TELEGRAM_CHAT_ID=your-chat-id
|
||||
|
||||
DB_TYPE=postgres
|
||||
DB_HOST=10.
|
||||
DB_PORT=5432
|
||||
@@ -65,6 +61,6 @@ DB_NAME=nofx
|
||||
DB_SSLMODE=disable
|
||||
|
||||
|
||||
# 数据库配置 - SQLite(默认)
|
||||
# Database configuration - SQLite (default)
|
||||
DB_TYPE=sqlite
|
||||
DB_PATH=data/data.db
|
||||
242
.github/PR_TITLE_GUIDE.md
vendored
242
.github/PR_TITLE_GUIDE.md
vendored
@@ -1,16 +1,16 @@
|
||||
# PR 标题指南
|
||||
# PR Title Guide
|
||||
|
||||
## 📋 概述
|
||||
## 📋 Overview
|
||||
|
||||
我们使用 **Conventional Commits** 格式来保持 PR 标题的一致性,但这是**建议性的**,不会阻止你的 PR 被合并。
|
||||
We use the **Conventional Commits** format to maintain consistency in PR titles, but this is **recommended**, not mandatory. It will not prevent your PR from being merged.
|
||||
|
||||
## ✅ 推荐格式
|
||||
## ✅ Recommended Format
|
||||
|
||||
```
|
||||
type(scope): description
|
||||
```
|
||||
|
||||
### 示例
|
||||
### Examples
|
||||
|
||||
```
|
||||
feat(trader): add new trading strategy
|
||||
@@ -22,63 +22,63 @@ ci(workflow): improve GitHub Actions
|
||||
|
||||
---
|
||||
|
||||
## 📖 详细说明
|
||||
## 📖 Detailed Guide
|
||||
|
||||
### Type(类型)- 必需
|
||||
### Type - Required
|
||||
|
||||
描述这次变更的类型:
|
||||
Describes the type of change:
|
||||
|
||||
| Type | 说明 | 示例 |
|
||||
|------|------|------|
|
||||
| `feat` | 新功能 | `feat(trader): add stop-loss feature` |
|
||||
| `fix` | Bug 修复 | `fix(api): handle null response` |
|
||||
| `docs` | 文档变更 | `docs: update installation guide` |
|
||||
| `style` | 代码格式(不影响代码运行) | `style: format code with prettier` |
|
||||
| `refactor` | 重构(既不是新功能也不是修复) | `refactor(exchange): simplify connection logic` |
|
||||
| `perf` | 性能优化 | `perf(ai): optimize prompt processing` |
|
||||
| `test` | 添加或修改测试 | `test(trader): add unit tests` |
|
||||
| `chore` | 构建过程或辅助工具的变动 | `chore: update dependencies` |
|
||||
| `ci` | CI/CD 相关变更 | `ci: add test coverage report` |
|
||||
| `security` | 安全相关修复 | `security: update vulnerable dependencies` |
|
||||
| `build` | 构建系统或外部依赖项变更 | `build: upgrade webpack to v5` |
|
||||
| Type | Description | Example |
|
||||
|------|-------------|---------|
|
||||
| `feat` | New feature | `feat(trader): add stop-loss feature` |
|
||||
| `fix` | Bug fix | `fix(api): handle null response` |
|
||||
| `docs` | Documentation change | `docs: update installation guide` |
|
||||
| `style` | Code formatting (no functional change) | `style: format code with prettier` |
|
||||
| `refactor` | Code refactoring (neither feature nor fix) | `refactor(exchange): simplify connection logic` |
|
||||
| `perf` | Performance optimization | `perf(ai): optimize prompt processing` |
|
||||
| `test` | Add or modify tests | `test(trader): add unit tests` |
|
||||
| `chore` | Build process or auxiliary tool changes | `chore: update dependencies` |
|
||||
| `ci` | CI/CD related changes | `ci: add test coverage report` |
|
||||
| `security` | Security fixes | `security: update vulnerable dependencies` |
|
||||
| `build` | Build system or external dependency changes | `build: upgrade webpack to v5` |
|
||||
|
||||
### Scope(范围)- 可选
|
||||
### Scope - Optional
|
||||
|
||||
描述这次变更影响的范围:
|
||||
Describes the area affected by the change:
|
||||
|
||||
| Scope | 说明 |
|
||||
|-------|------|
|
||||
| `exchange` | 交易所相关 |
|
||||
| `trader` | 交易员/交易策略 |
|
||||
| `ai` | AI 模型相关 |
|
||||
| `api` | API 接口 |
|
||||
| `ui` | 用户界面 |
|
||||
| `frontend` | 前端代码 |
|
||||
| `backend` | 后端代码 |
|
||||
| `security` | 安全相关 |
|
||||
| `deps` | 依赖项 |
|
||||
| Scope | Description |
|
||||
|-------|-------------|
|
||||
| `exchange` | Exchange-related |
|
||||
| `trader` | Trader/trading strategy |
|
||||
| `ai` | AI model related |
|
||||
| `api` | API interface |
|
||||
| `ui` | User interface |
|
||||
| `frontend` | Frontend code |
|
||||
| `backend` | Backend code |
|
||||
| `security` | Security related |
|
||||
| `deps` | Dependencies |
|
||||
| `workflow` | GitHub Actions workflows |
|
||||
| `github` | GitHub 配置 |
|
||||
| `github` | GitHub configuration |
|
||||
| `actions` | GitHub Actions |
|
||||
| `config` | 配置文件 |
|
||||
| `docker` | Docker 相关 |
|
||||
| `build` | 构建相关 |
|
||||
| `release` | 发布相关 |
|
||||
| `config` | Configuration files |
|
||||
| `docker` | Docker related |
|
||||
| `build` | Build related |
|
||||
| `release` | Release related |
|
||||
|
||||
**注意:** 如果变更影响多个范围,可以省略 scope 或选择最主要的。
|
||||
**Note:** If the change affects multiple scopes, you can omit the scope or choose the most relevant one.
|
||||
|
||||
### Description(描述)- 必需
|
||||
### Description - Required
|
||||
|
||||
- 使用现在时态("add" 而不是 "added")
|
||||
- 首字母小写
|
||||
- 结尾不加句号
|
||||
- 简洁明了地描述变更内容
|
||||
- Use present tense ("add" not "added")
|
||||
- Start with lowercase
|
||||
- No period at the end
|
||||
- Concisely describe what changed
|
||||
|
||||
---
|
||||
|
||||
## 🎯 完整示例
|
||||
## 🎯 Complete Examples
|
||||
|
||||
### ✅ 好的 PR 标题
|
||||
### ✅ Good PR Titles
|
||||
|
||||
```
|
||||
feat(trader): add risk management system
|
||||
@@ -94,38 +94,38 @@ security(api): fix SQL injection vulnerability
|
||||
build(docker): optimize Docker image size
|
||||
```
|
||||
|
||||
### ⚠️ 需要改进的标题
|
||||
### ⚠️ Titles That Need Improvement
|
||||
|
||||
| 不好的标题 | 问题 | 改进后 |
|
||||
|-----------|------|--------|
|
||||
| `update code` | 太模糊 | `refactor(trader): simplify order execution logic` |
|
||||
| `Fixed bug` | 首字母大写,不够具体 | `fix(api): handle edge case in login` |
|
||||
| `Add new feature.` | 有句号,不够具体 | `feat(ui): add dark mode toggle` |
|
||||
| `changes` | 完全不符合格式 | `chore: update dependencies` |
|
||||
| `feat: Added new trading algo` | 时态错误 | `feat(trader): add new trading algorithm` |
|
||||
| Poor Title | Issue | Improved |
|
||||
|-----------|-------|----------|
|
||||
| `update code` | Too vague | `refactor(trader): simplify order execution logic` |
|
||||
| `Fixed bug` | Capitalized, not specific | `fix(api): handle edge case in login` |
|
||||
| `Add new feature.` | Has period, not specific | `feat(ui): add dark mode toggle` |
|
||||
| `changes` | Doesn't follow format | `chore: update dependencies` |
|
||||
| `feat: Added new trading algo` | Wrong tense | `feat(trader): add new trading algorithm` |
|
||||
|
||||
---
|
||||
|
||||
## 🤖 自动检查行为
|
||||
## 🤖 Automated Check Behavior
|
||||
|
||||
### 当 PR 标题不符合格式时
|
||||
### When PR Title Doesn't Follow Format
|
||||
|
||||
1. **不会阻止合并** ✅
|
||||
- 检查会标记为"建议"
|
||||
- PR 仍然可以被审查和合并
|
||||
1. **Won't block merging** ✅
|
||||
- Check is marked as "advisory"
|
||||
- PR can still be reviewed and merged
|
||||
|
||||
2. **会收到友好提示** 💬
|
||||
- 机器人会在 PR 中留言
|
||||
- 提供格式说明和示例
|
||||
- 建议如何改进标题
|
||||
2. **Provides friendly reminder** 💬
|
||||
- Bot will comment on the PR
|
||||
- Provides format guidance and examples
|
||||
- Suggests how to improve the title
|
||||
|
||||
3. **可以随时更新** 🔄
|
||||
- 更新 PR 标题后会重新检查
|
||||
- 无需关闭和重新打开 PR
|
||||
3. **Can be updated anytime** 🔄
|
||||
- Re-checks after updating PR title
|
||||
- No need to close and reopen PR
|
||||
|
||||
### 示例评论
|
||||
### Example Comment
|
||||
|
||||
如果你的 PR 标题是 `update workflow`,你会收到这样的评论:
|
||||
If your PR title is `update workflow`, you'll receive a comment like this:
|
||||
|
||||
```markdown
|
||||
## ⚠️ PR Title Format Suggestion
|
||||
@@ -157,11 +157,11 @@ Your PR can still be reviewed and merged.
|
||||
|
||||
---
|
||||
|
||||
## 🔧 配置详情
|
||||
## 🔧 Configuration Details
|
||||
|
||||
### 支持的 Types
|
||||
### Supported Types
|
||||
|
||||
在 `.github/workflows/pr-checks.yml` 中配置:
|
||||
Configured in `.github/workflows/pr-checks.yml`:
|
||||
|
||||
```yaml
|
||||
types: |
|
||||
@@ -178,7 +178,7 @@ types: |
|
||||
build
|
||||
```
|
||||
|
||||
### 支持的 Scopes
|
||||
### Supported Scopes
|
||||
|
||||
```yaml
|
||||
scopes: |
|
||||
@@ -200,38 +200,38 @@ scopes: |
|
||||
release
|
||||
```
|
||||
|
||||
### 添加新的 Scope
|
||||
### Adding New Scopes
|
||||
|
||||
如果你需要添加新的 scope,请:
|
||||
If you need to add a new scope:
|
||||
|
||||
1. 在 `.github/workflows/pr-checks.yml` 的 `scopes` 部分添加
|
||||
2. 在 `.github/workflows/pr-checks-run.yml` 更新正则表达式(可选)
|
||||
3. 更新本文档
|
||||
1. Add it to the `scopes` section in `.github/workflows/pr-checks.yml`
|
||||
2. Update the regex in `.github/workflows/pr-checks-run.yml` (optional)
|
||||
3. Update this documentation
|
||||
|
||||
---
|
||||
|
||||
## 📚 为什么使用 Conventional Commits?
|
||||
## 📚 Why Use Conventional Commits?
|
||||
|
||||
### 优点
|
||||
### Benefits
|
||||
|
||||
1. **自动化 Changelog** 📝
|
||||
- 可以自动生成版本更新日志
|
||||
- 清晰地分类各种变更
|
||||
1. **Automated Changelog** 📝
|
||||
- Automatically generate version changelogs
|
||||
- Clearly categorize different types of changes
|
||||
|
||||
2. **语义化版本** 🔢
|
||||
- `feat` → MINOR 版本(1.1.0)
|
||||
- `fix` → PATCH 版本(1.0.1)
|
||||
- `BREAKING CHANGE` → MAJOR 版本(2.0.0)
|
||||
2. **Semantic Versioning** 🔢
|
||||
- `feat` → MINOR version (1.1.0)
|
||||
- `fix` → PATCH version (1.0.1)
|
||||
- `BREAKING CHANGE` → MAJOR version (2.0.0)
|
||||
|
||||
3. **更好的可读性** 👀
|
||||
- 一眼看出 PR 的目的
|
||||
- 更容易浏览 Git 历史
|
||||
3. **Better Readability** 👀
|
||||
- Understand PR purpose at a glance
|
||||
- Easier to browse Git history
|
||||
|
||||
4. **团队协作** 🤝
|
||||
- 统一的提交风格
|
||||
- 降低沟通成本
|
||||
4. **Team Collaboration** 🤝
|
||||
- Unified commit style
|
||||
- Reduces communication overhead
|
||||
|
||||
### 示例:自动生成的 Changelog
|
||||
### Example: Auto-generated Changelog
|
||||
|
||||
```markdown
|
||||
## v1.2.0 (2025-11-02)
|
||||
@@ -250,9 +250,9 @@ scopes: |
|
||||
|
||||
---
|
||||
|
||||
## 🎓 学习资源
|
||||
## 🎓 Learning Resources
|
||||
|
||||
- **Conventional Commits 官网:** https://www.conventionalcommits.org/
|
||||
- **Conventional Commits:** https://www.conventionalcommits.org/
|
||||
- **Angular Commit Guidelines:** https://github.com/angular/angular/blob/main/CONTRIBUTING.md#commit
|
||||
- **Semantic Versioning:** https://semver.org/
|
||||
|
||||
@@ -260,33 +260,33 @@ scopes: |
|
||||
|
||||
## ❓ FAQ
|
||||
|
||||
### Q: 我必须遵循这个格式吗?
|
||||
### Q: Must I follow this format?
|
||||
|
||||
**A:** 不必须。这是建议性的,不会阻止你的 PR 被合并。但遵循格式可以提高项目的可维护性。
|
||||
**A:** No. This is recommended but not mandatory. It won't block your PR from being merged. However, following the format improves project maintainability.
|
||||
|
||||
### Q: 如果我忘记了怎么办?
|
||||
### Q: What if I forget?
|
||||
|
||||
**A:** 机器人会在 PR 中提醒你,你可以随时更新标题。
|
||||
**A:** The bot will remind you in the PR comments. You can update the title anytime.
|
||||
|
||||
### Q: 我可以在一个 PR 中做多种类型的变更吗?
|
||||
### Q: Can I make multiple types of changes in one PR?
|
||||
|
||||
**A:** 可以,但建议:
|
||||
- 选择最主要的类型
|
||||
- 或者考虑拆分成多个 PR(更易于审查)
|
||||
**A:** Yes, but it's recommended to:
|
||||
- Choose the most significant type
|
||||
- Or consider splitting into multiple PRs (easier to review)
|
||||
|
||||
### Q: Scope 可以省略吗?
|
||||
### Q: Can I omit the scope?
|
||||
|
||||
**A:** 可以。`requireScope: false` 表示 scope 是可选的。
|
||||
**A:** Yes. `requireScope: false` means scope is optional.
|
||||
|
||||
示例:`docs: update README` (没有 scope 也可以)
|
||||
Example: `docs: update README` (no scope is fine)
|
||||
|
||||
### Q: 我想添加新的 type 或 scope,怎么做?
|
||||
### Q: How do I add a new type or scope?
|
||||
|
||||
**A:** 提一个 PR 修改 `.github/workflows/pr-checks.yml`,并在本文档中说明新增项的用途。
|
||||
**A:** Submit a PR to modify `.github/workflows/pr-checks.yml` and document the purpose of the new item in this guide.
|
||||
|
||||
### Q: Breaking Changes 怎么表示?
|
||||
### Q: How do I indicate Breaking Changes?
|
||||
|
||||
**A:** 在描述中添加 `BREAKING CHANGE:` 或在 type 后加 `!`:
|
||||
**A:** Add `BREAKING CHANGE:` in the description or add `!` after the type:
|
||||
|
||||
```
|
||||
feat!: remove deprecated API
|
||||
@@ -297,9 +297,9 @@ BREAKING CHANGE: The old /auth endpoint is removed
|
||||
|
||||
---
|
||||
|
||||
## 📊 统计
|
||||
## 📊 Statistics
|
||||
|
||||
想看项目的 commit 类型分布?运行:
|
||||
Want to see the commit type distribution in your project? Run:
|
||||
|
||||
```bash
|
||||
git log --oneline --no-merges | \
|
||||
@@ -309,14 +309,14 @@ git log --oneline --no-merges | \
|
||||
|
||||
---
|
||||
|
||||
## ✅ 快速检查清单
|
||||
## ✅ Quick Checklist
|
||||
|
||||
在提交 PR 前,检查你的标题是否:
|
||||
Before submitting a PR, check if your title:
|
||||
|
||||
- [ ] 包含有效的 type(feat, fix, docs 等)
|
||||
- [ ] 使用小写字母开头
|
||||
- [ ] 使用现在时态("add" 而不是 "added")
|
||||
- [ ] 简洁明了(最好在 50 字符内)
|
||||
- [ ] 准确描述了变更内容
|
||||
- [ ] Contains a valid type (feat, fix, docs, etc.)
|
||||
- [ ] Starts with lowercase
|
||||
- [ ] Uses present tense ("add" not "added")
|
||||
- [ ] Is concise (preferably under 50 characters)
|
||||
- [ ] Accurately describes the change
|
||||
|
||||
**记住:** 这些都是建议,不是强制要求!
|
||||
**Remember:** These are recommendations, not requirements!
|
||||
|
||||
126
.github/PULL_REQUEST_TEMPLATE.md
vendored
126
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -1,104 +1,50 @@
|
||||
# Pull Request | PR 提交
|
||||
## Summary
|
||||
|
||||
> **📋 选择专用模板 | 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
|
||||
- Problem:
|
||||
- What changed:
|
||||
- What did NOT change (scope boundary):
|
||||
|
||||
---
|
||||
## Change Type
|
||||
|
||||
> **💡 提示 Tip:** 推荐 PR 标题格式 `type(scope): description`
|
||||
> 例如: `feat(trader): add new strategy` | `fix(api): resolve auth issue`
|
||||
- [ ] Bug fix
|
||||
- [ ] Feature
|
||||
- [ ] Refactoring
|
||||
- [ ] Docs
|
||||
- [ ] Security fix
|
||||
- [ ] Chore / infra
|
||||
|
||||
---
|
||||
## Scope
|
||||
|
||||
## 📝 Description | 描述
|
||||
- [ ] Trading engine / strategies
|
||||
- [ ] MCP / AI clients
|
||||
- [ ] API / server
|
||||
- [ ] Telegram bot / agent
|
||||
- [ ] Web UI / frontend
|
||||
- [ ] Config / deployment
|
||||
- [ ] CI/CD / infra
|
||||
|
||||
**English:** | **中文:**
|
||||
## Linked Issues
|
||||
|
||||
- Closes #
|
||||
- Related #
|
||||
|
||||
## Testing
|
||||
|
||||
---
|
||||
What you verified and how:
|
||||
|
||||
## 🎯 Type of Change | 变更类型
|
||||
- [ ] `go build ./...` passes
|
||||
- [ ] `go test ./...` passes
|
||||
- [ ] Manual testing done (describe below)
|
||||
|
||||
- [ ] 🐛 Bug fix | 修复 Bug
|
||||
- [ ] ✨ New feature | 新功能
|
||||
- [ ] 💥 Breaking change | 破坏性变更
|
||||
- [ ] 📝 Documentation update | 文档更新
|
||||
- [ ] 🎨 Code style update | 代码样式更新
|
||||
- [ ] ♻️ Refactoring | 重构
|
||||
- [ ] ⚡ Performance improvement | 性能优化
|
||||
- [ ] ✅ Test update | 测试更新
|
||||
- [ ] 🔧 Build/config change | 构建/配置变更
|
||||
- [ ] 🔒 Security fix | 安全修复
|
||||
## Security Impact
|
||||
|
||||
---
|
||||
- Secrets/keys handling changed? (`Yes/No`)
|
||||
- New/changed API endpoints? (`Yes/No`)
|
||||
- User input validation affected? (`Yes/No`)
|
||||
|
||||
## 🔗 Related Issues | 相关 Issue
|
||||
## Compatibility
|
||||
|
||||
- Closes # | 关闭 #
|
||||
- Related to # | 相关 #
|
||||
|
||||
---
|
||||
|
||||
## 📋 Changes Made | 具体变更
|
||||
|
||||
**English:** | **中文:**
|
||||
-
|
||||
-
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Testing | 测试
|
||||
|
||||
- [ ] Tested locally | 本地测试通过
|
||||
- [ ] Tests pass | 测试通过
|
||||
- [ ] Verified no existing functionality broke | 确认没有破坏现有功能
|
||||
|
||||
---
|
||||
|
||||
## ✅ Checklist | 检查清单
|
||||
|
||||
### Code Quality | 代码质量
|
||||
- [ ] Code follows project style | 代码遵循项目风格
|
||||
- [ ] Self-review completed | 已完成代码自查
|
||||
- [ ] Comments added for complex logic | 已添加必要注释
|
||||
|
||||
### Documentation | 文档
|
||||
- [ ] Updated relevant documentation | 已更新相关文档
|
||||
|
||||
### 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! | 感谢你的贡献!**
|
||||
- Backward compatible? (`Yes/No`)
|
||||
- Config/env changes? (`Yes/No`)
|
||||
- Migration needed? (`Yes/No`)
|
||||
- If yes, upgrade steps:
|
||||
|
||||
198
.github/PULL_REQUEST_TEMPLATE/README.md
vendored
198
.github/PULL_REQUEST_TEMPLATE/README.md
vendored
@@ -1,213 +1,177 @@
|
||||
# PR Templates | PR 模板
|
||||
# PR Templates
|
||||
|
||||
## 📋 模板概述 | Template Overview
|
||||
## 📋 Template Overview
|
||||
|
||||
我们提供了4种针对不同类型PR的专用模板,帮助贡献者快速填写PR信息:
|
||||
We offer 4 specialized templates for different types of PRs to help contributors quickly fill out PR information:
|
||||
|
||||
### 1. 🔧 Backend Template | 后端模板
|
||||
**文件:** `backend.md`
|
||||
### 1. 🔧 Backend Template
|
||||
**File:** `backend.md`
|
||||
|
||||
**适用于 | Use for:**
|
||||
- Go代码变更 | Go code changes
|
||||
- API端点开发 | API endpoint development
|
||||
- 交易逻辑实现 | Trading logic implementation
|
||||
- 后端性能优化 | Backend performance optimization
|
||||
- 数据库相关改动 | Database-related changes
|
||||
**Use for:**
|
||||
- Go code changes
|
||||
- API endpoint development
|
||||
- Trading logic implementation
|
||||
- Backend performance optimization
|
||||
- Database-related changes
|
||||
|
||||
**包含 | Includes:**
|
||||
- Go测试环境配置 | Go test environment
|
||||
- 安全考虑检查 | Security considerations
|
||||
- 性能影响评估 | Performance impact assessment
|
||||
- `go fmt` 和 `go build` 检查 | `go fmt` and `go build` checks
|
||||
**Includes:**
|
||||
- Go test environment
|
||||
- Security considerations
|
||||
- Performance impact assessment
|
||||
- `go fmt` and `go build` checks
|
||||
|
||||
### 2. 🎨 Frontend Template | 前端模板
|
||||
**文件:** `frontend.md`
|
||||
### 2. 🎨 Frontend Template
|
||||
**File:** `frontend.md`
|
||||
|
||||
**适用于 | Use for:**
|
||||
- UI/UX变更 | UI/UX changes
|
||||
- React/Vue组件开发 | React/Vue component development
|
||||
- 前端样式更新 | Frontend styling updates
|
||||
- 浏览器兼容性修复 | Browser compatibility fixes
|
||||
- 前端性能优化 | Frontend performance optimization
|
||||
**Use for:**
|
||||
- UI/UX changes
|
||||
- React/Vue component development
|
||||
- Frontend styling updates
|
||||
- Browser compatibility fixes
|
||||
- Frontend performance optimization
|
||||
|
||||
**包含 | Includes:**
|
||||
- 截图/演示要求 | Screenshots/demo requirements
|
||||
- 浏览器测试清单 | Browser testing checklist
|
||||
- 国际化检查 | Internationalization checks
|
||||
- 响应式设计验证 | Responsive design verification
|
||||
- `npm run lint` 和 `npm run build` 检查 | Linting and build checks
|
||||
**Includes:**
|
||||
- Screenshots/demo requirements
|
||||
- Browser testing checklist
|
||||
- Internationalization checks
|
||||
- Responsive design verification
|
||||
- `npm run lint` and `npm run build` checks
|
||||
|
||||
### 3. 📝 Documentation Template | 文档模板
|
||||
**文件:** `docs.md`
|
||||
### 3. 📝 Documentation Template
|
||||
**File:** `docs.md`
|
||||
|
||||
**适用于 | Use for:**
|
||||
- README更新 | README updates
|
||||
- API文档编写 | API documentation
|
||||
- 教程和指南 | Tutorials and guides
|
||||
- 代码注释改进 | Code comment improvements
|
||||
- 翻译工作 | Translation work
|
||||
**Use for:**
|
||||
- README updates
|
||||
- API documentation
|
||||
- Tutorials and guides
|
||||
- Code comment improvements
|
||||
- Translation work
|
||||
|
||||
**包含 | Includes:**
|
||||
- 文档类型分类 | Documentation type classification
|
||||
- 内容质量检查 | Content quality checks
|
||||
- 双语要求(中英文)| Bilingual requirements (EN/CN)
|
||||
- 链接有效性验证 | Link validity verification
|
||||
**Includes:**
|
||||
- Documentation type classification
|
||||
- Content quality checks
|
||||
- Bilingual requirements (EN/CN)
|
||||
- Link validity verification
|
||||
|
||||
### 4. 📦 General Template | 通用模板
|
||||
**文件:** `general.md`
|
||||
### 4. 📦 General Template
|
||||
**File:** `general.md`
|
||||
|
||||
**适用于 | Use for:**
|
||||
- 混合类型变更 | Mixed-type changes
|
||||
- 跨多个领域的PR | Cross-domain PRs
|
||||
- 构建配置变更 | Build configuration changes
|
||||
- 依赖更新 | Dependency updates
|
||||
- 不确定使用哪个模板时 | When unsure which template to use
|
||||
**Use for:**
|
||||
- Mixed-type changes
|
||||
- Cross-domain PRs
|
||||
- Build configuration changes
|
||||
- Dependency updates
|
||||
- When unsure which template to use
|
||||
|
||||
## 🤖 自动模板建议 | Automatic Template Suggestion
|
||||
## 🤖 Automatic Template Suggestion
|
||||
|
||||
我们的GitHub Action会自动分析你的PR并建议最合适的模板:
|
||||
Our GitHub Action automatically analyzes your PR and suggests the most suitable template:
|
||||
|
||||
### 工作原理 | How it works:
|
||||
### How it works:
|
||||
|
||||
1. **文件分析 | File Analysis**
|
||||
- 检测PR中所有变更的文件类型
|
||||
1. **File Analysis**
|
||||
- Detects all changed file types in the PR
|
||||
|
||||
2. **智能判断 | Smart Detection**
|
||||
- 如果 >50% 是 `.go` 文件 → 建议**后端模板**
|
||||
2. **Smart Detection**
|
||||
- If >50% are `.go` files → Suggests **Backend template**
|
||||
- 如果 >50% 是 `.js/.ts/.tsx/.vue` 文件 → 建议**前端模板**
|
||||
- If >50% are `.js/.ts/.tsx/.vue` files → Suggests **Frontend template**
|
||||
- 如果 >70% 是 `.md` 文件 → 建议**文档模板**
|
||||
- If >70% are `.md` files → Suggests **Documentation template**
|
||||
|
||||
3. **自动评论 | Auto-comment**
|
||||
- 如果检测到你使用了默认模板,但应该用专用模板
|
||||
3. **Auto-comment**
|
||||
- If it detects you're using the default template but should use a specialized one
|
||||
- 会自动添加友好的评论建议
|
||||
- It will automatically add a friendly comment suggestion
|
||||
|
||||
4. **自动标签 | Auto-labeling**
|
||||
- 自动添加对应的标签:`backend`、`frontend`、`documentation`
|
||||
4. **Auto-labeling**
|
||||
- Automatically adds corresponding labels: `backend`, `frontend`, `documentation`
|
||||
|
||||
## 📖 使用方法 | How to Use
|
||||
## 📖 How to Use
|
||||
|
||||
### 方法1: URL参数(推荐) | Method 1: URL Parameter (Recommended)
|
||||
### Method 1: URL Parameter (Recommended)
|
||||
|
||||
创建PR时,在URL末尾添加模板参数:
|
||||
When creating a PR, add the template parameter to the URL:
|
||||
|
||||
```
|
||||
https://github.com/YOUR_ORG/nofx/compare/dev...YOUR_BRANCH?template=backend.md
|
||||
```
|
||||
|
||||
替换 `backend.md` 为:
|
||||
Replace `backend.md` with:
|
||||
- `backend.md` - 后端模板 | Backend template
|
||||
- `frontend.md` - 前端模板 | Frontend template
|
||||
- `docs.md` - 文档模板 | Documentation template
|
||||
- `general.md` - 通用模板 | General template
|
||||
- `backend.md` - Backend template
|
||||
- `frontend.md` - Frontend template
|
||||
- `docs.md` - Documentation template
|
||||
- `general.md` - General template
|
||||
|
||||
### 方法2: 手动选择 | Method 2: Manual Selection
|
||||
### Method 2: Manual Selection
|
||||
|
||||
1. 创建PR时,默认模板会显示
|
||||
When creating a PR, the default template will be shown
|
||||
1. When creating a PR, the default template will be shown
|
||||
|
||||
2. 根据顶部的指引链接,点击查看对应的模板
|
||||
Follow the guidance links at the top to view the corresponding template
|
||||
2. Follow the guidance links at the top to view the corresponding template
|
||||
|
||||
3. 复制模板内容到PR描述中
|
||||
Copy the template content into the PR description
|
||||
3. Copy the template content into the PR description
|
||||
|
||||
### 方法3: 跟随自动建议 | Method 3: Follow Auto-suggestion
|
||||
### Method 3: Follow Auto-suggestion
|
||||
|
||||
1. 使用任何模板创建PR
|
||||
Create a PR with any template
|
||||
1. Create a PR with any template
|
||||
|
||||
2. GitHub Action会自动分析并评论建议
|
||||
GitHub Action will automatically analyze and comment with a suggestion
|
||||
2. GitHub Action will automatically analyze and comment with a suggestion
|
||||
|
||||
3. 根据建议更新PR描述
|
||||
Update the PR description based on the suggestion
|
||||
3. Update the PR description based on the suggestion
|
||||
|
||||
## 🎯 最佳实践 | Best Practices
|
||||
## 🎯 Best Practices
|
||||
|
||||
1. **提前选择 | Choose in Advance**
|
||||
- 在创建PR前确定变更类型
|
||||
1. **Choose in Advance**
|
||||
- Determine the change type before creating the PR
|
||||
|
||||
2. **完整填写 | Complete Filling**
|
||||
- 不要跳过必填项(标记为 required)
|
||||
2. **Complete Filling**
|
||||
- Don't skip required items
|
||||
|
||||
3. **保持简洁 | Keep it Concise**
|
||||
- 描述清晰但简洁
|
||||
3. **Keep it Concise**
|
||||
- Keep descriptions clear but concise
|
||||
|
||||
4. **添加截图 | Add Screenshots**
|
||||
- 对于UI变更,务必添加截图
|
||||
4. **Add Screenshots**
|
||||
- For UI changes, always add screenshots
|
||||
|
||||
5. **测试证明 | Test Evidence**
|
||||
- 提供测试通过的证据
|
||||
5. **Test Evidence**
|
||||
- Provide evidence that tests pass
|
||||
|
||||
## 🔧 自定义 | Customization
|
||||
## 🔧 Customization
|
||||
|
||||
如果需要修改模板或自动检测逻辑:
|
||||
If you need to modify templates or auto-detection logic:
|
||||
|
||||
1. **修改模板** | **Modify Templates**
|
||||
- 编辑 `.github/PULL_REQUEST_TEMPLATE/*.md` 文件
|
||||
1. **Modify Templates**
|
||||
- Edit `.github/PULL_REQUEST_TEMPLATE/*.md` files
|
||||
|
||||
2. **调整检测阈值** | **Adjust Detection Threshold**
|
||||
- 编辑 `.github/workflows/pr-template-suggester.yml`
|
||||
2. **Adjust Detection Threshold**
|
||||
- Edit `.github/workflows/pr-template-suggester.yml`
|
||||
- 修改文件类型占比阈值(当前:50%后端,50%前端,70%文档)
|
||||
- Modify file type percentage thresholds (current: 50% backend, 50% frontend, 70% docs)
|
||||
|
||||
3. **添加新模板** | **Add New Template**
|
||||
- 在 `PULL_REQUEST_TEMPLATE/` 目录创建新的 `.md` 文件
|
||||
3. **Add New Template**
|
||||
- Create a new `.md` file in the `PULL_REQUEST_TEMPLATE/` directory
|
||||
- 更新工作流以支持新的文件类型检测
|
||||
- Update the workflow to support new file type detection
|
||||
|
||||
## ❓ FAQ
|
||||
|
||||
**Q: 我的PR既有前端又有后端代码,用哪个模板?**
|
||||
**Q: My PR has both frontend and backend code, which template should I use?**
|
||||
|
||||
A: 使用**通用模板**(`general.md`),或选择主要变更类型的模板。
|
||||
A: Use the **General template** (`general.md`), or choose the template for the primary change type.
|
||||
|
||||
---
|
||||
|
||||
**Q: 自动建议的模板不合适怎么办?**
|
||||
**Q: What if the automatically suggested template is not suitable?**
|
||||
|
||||
A: 你可以忽略建议,继续使用当前模板。自动建议仅供参考。
|
||||
A: You can ignore the suggestion and continue using the current template. Auto-suggestions are for reference only.
|
||||
|
||||
---
|
||||
|
||||
**Q: 可以不使用任何模板吗?**
|
||||
**Q: Can I not use any template?**
|
||||
|
||||
A: 不推荐。模板帮助确保PR包含必要信息,加快审查速度。
|
||||
A: Not recommended. Templates help ensure PRs contain necessary information and speed up reviews.
|
||||
|
||||
---
|
||||
|
||||
**Q: 如何禁用自动模板建议?**
|
||||
**Q: How to disable automatic template suggestions?**
|
||||
|
||||
A: 删除或禁用 `.github/workflows/pr-template-suggester.yml` 文件。
|
||||
A: Delete or disable the `.github/workflows/pr-template-suggester.yml` file.
|
||||
|
||||
---
|
||||
|
||||
🌟 **感谢使用我们的PR模板系统!| Thank you for using our PR template system!**
|
||||
🌟 **Thank you for using our PR template system!**
|
||||
|
||||
125
.github/PULL_REQUEST_TEMPLATE/backend.md
vendored
125
.github/PULL_REQUEST_TEMPLATE/backend.md
vendored
@@ -1,121 +1,116 @@
|
||||
# Pull Request - Backend | 后端 PR
|
||||
# Pull Request - Backend
|
||||
|
||||
> **💡 提示 Tip:** 推荐 PR 标题格式 `type(scope): description`
|
||||
> 例如: `feat(trader): add new strategy` | `fix(api): resolve auth issue`
|
||||
> **💡 Tip:** Recommended PR title format `type(scope): description`
|
||||
> Example: `feat(trader): add new strategy` | `fix(api): resolve auth issue`
|
||||
|
||||
---
|
||||
|
||||
## 📝 Description | 描述
|
||||
|
||||
**English:** | **中文:**
|
||||
## 📝 Description
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Type of Change | 变更类型
|
||||
## 🎯 Type of Change
|
||||
|
||||
- [ ] 🐛 Bug fix | 修复 Bug
|
||||
- [ ] ✨ New feature | 新功能
|
||||
- [ ] 💥 Breaking change | 破坏性变更
|
||||
- [ ] ♻️ Refactoring | 重构
|
||||
- [ ] ⚡ Performance improvement | 性能优化
|
||||
- [ ] 🔒 Security fix | 安全修复
|
||||
- [ ] 🔧 Build/config change | 构建/配置变更
|
||||
- [ ] 🐛 Bug fix
|
||||
- [ ] ✨ New feature
|
||||
- [ ] 💥 Breaking change
|
||||
- [ ] ♻️ Refactoring
|
||||
- [ ] ⚡ Performance improvement
|
||||
- [ ] 🔒 Security fix
|
||||
- [ ] 🔧 Build/config change
|
||||
|
||||
---
|
||||
|
||||
## 🔗 Related Issues | 相关 Issue
|
||||
## 🔗 Related Issues
|
||||
|
||||
- Closes # | 关闭 #
|
||||
- Related to # | 相关 #
|
||||
- Closes #
|
||||
- Related to #
|
||||
|
||||
---
|
||||
|
||||
## 📋 Changes Made | 具体变更
|
||||
## 📋 Changes Made
|
||||
|
||||
**English:** | **中文:**
|
||||
-
|
||||
-
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Testing | 测试
|
||||
## 🧪 Testing
|
||||
|
||||
### Test Environment | 测试环境
|
||||
- **OS | 操作系统:**
|
||||
- **Go Version | Go 版本:**
|
||||
- **Exchange | 交易所:** [if applicable | 如适用]
|
||||
### Test Environment
|
||||
- **OS:**
|
||||
- **Go Version:**
|
||||
- **Exchange:** [if applicable]
|
||||
|
||||
### Manual Testing | 手动测试
|
||||
- [ ] Tested locally | 本地测试通过
|
||||
- [ ] Tested on testnet | 测试网测试通过(交易所集成相关)
|
||||
- [ ] Unit tests pass | 单元测试通过
|
||||
- [ ] Verified no existing functionality broke | 确认没有破坏现有功能
|
||||
### Manual Testing
|
||||
- [ ] Tested locally
|
||||
- [ ] Tested on testnet (for exchange integration)
|
||||
- [ ] Unit tests pass
|
||||
- [ ] Verified no existing functionality broke
|
||||
|
||||
### Test Results | 测试结果
|
||||
### Test Results
|
||||
```
|
||||
Test output here | 测试输出
|
||||
Test output here
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔒 Security Considerations | 安全考虑
|
||||
## 🔒 Security Considerations
|
||||
|
||||
- [ ] No API keys or secrets hardcoded | 没有硬编码 API 密钥
|
||||
- [ ] User inputs properly validated | 用户输入已正确验证
|
||||
- [ ] No SQL injection vulnerabilities | 无 SQL 注入漏洞
|
||||
- [ ] Authentication/authorization properly handled | 认证/授权正确处理
|
||||
- [ ] Sensitive data is encrypted | 敏感数据已加密
|
||||
- [ ] N/A (not security-related) | 不适用
|
||||
- [ ] No API keys or secrets hardcoded
|
||||
- [ ] User inputs properly validated
|
||||
- [ ] No SQL injection vulnerabilities
|
||||
- [ ] Authentication/authorization properly handled
|
||||
- [ ] Sensitive data is encrypted
|
||||
- [ ] N/A (not security-related)
|
||||
|
||||
---
|
||||
|
||||
## ⚡ Performance Impact | 性能影响
|
||||
## ⚡ Performance Impact
|
||||
|
||||
- [ ] No significant performance impact | 无显著性能影响
|
||||
- [ ] Performance improved | 性能提升
|
||||
- [ ] Performance may be impacted (explain below) | 性能可能受影响
|
||||
- [ ] No significant performance impact
|
||||
- [ ] Performance improved
|
||||
- [ ] Performance may be impacted (explain below)
|
||||
|
||||
**If impacted, explain | 如果受影响,请说明:**
|
||||
**If impacted, explain:**
|
||||
|
||||
|
||||
---
|
||||
|
||||
## ✅ Checklist | 检查清单
|
||||
## ✅ Checklist
|
||||
|
||||
### Code Quality | 代码质量
|
||||
- [ ] Code follows project style | 代码遵循项目风格
|
||||
- [ ] Self-review completed | 已完成代码自查
|
||||
- [ ] Comments added for complex logic | 已添加必要注释
|
||||
- [ ] Code compiles successfully | 代码编译成功 (`go build`)
|
||||
- [ ] Ran `go fmt` | 已运行 `go fmt`
|
||||
### Code Quality
|
||||
- [ ] Code follows project style
|
||||
- [ ] Self-review completed
|
||||
- [ ] Comments added for complex logic
|
||||
- [ ] Code compiles successfully (`go build`)
|
||||
- [ ] Ran `go fmt`
|
||||
|
||||
### Documentation | 文档
|
||||
- [ ] Updated relevant documentation | 已更新相关文档
|
||||
- [ ] Added inline comments where necessary | 已添加必要的代码注释
|
||||
- [ ] Updated API documentation (if applicable) | 已更新 API 文档
|
||||
### Documentation
|
||||
- [ ] Updated relevant documentation
|
||||
- [ ] Added inline comments where necessary
|
||||
- [ ] Updated API documentation (if applicable)
|
||||
|
||||
### Git
|
||||
- [ ] Commits follow conventional format | 提交遵循 Conventional Commits 格式
|
||||
- [ ] Rebased on latest `dev` branch | 已 rebase 到最新 `dev` 分支
|
||||
- [ ] No merge conflicts | 无合并冲突
|
||||
- [ ] Commits follow conventional format
|
||||
- [ ] Rebased on latest `dev` branch
|
||||
- [ ] No merge conflicts
|
||||
|
||||
---
|
||||
|
||||
## 📚 Additional Notes | 补充说明
|
||||
|
||||
**English:** | **中文:**
|
||||
## 📚 Additional Notes
|
||||
|
||||
|
||||
---
|
||||
|
||||
**By submitting this PR, I confirm | 提交此 PR,我确认:**
|
||||
**By submitting this PR, I confirm:**
|
||||
|
||||
- [ ] I have read the [Contributing Guidelines](../../CONTRIBUTING.md) | 已阅读贡献指南
|
||||
- [ ] I agree to the [Code of Conduct](../../CODE_OF_CONDUCT.md) | 同意行为准则
|
||||
- [ ] My contribution is licensed under AGPL-3.0 | 贡献遵循 AGPL-3.0 许可证
|
||||
- [ ] I have read the [Contributing Guidelines](../../CONTRIBUTING.md)
|
||||
- [ ] I agree to the [Code of Conduct](../../CODE_OF_CONDUCT.md)
|
||||
- [ ] My contribution is licensed under AGPL-3.0
|
||||
|
||||
---
|
||||
|
||||
🌟 **Thank you for your contribution! | 感谢你的贡献!**
|
||||
🌟 **Thank you for your contribution!**
|
||||
|
||||
94
.github/PULL_REQUEST_TEMPLATE/docs.md
vendored
94
.github/PULL_REQUEST_TEMPLATE/docs.md
vendored
@@ -1,97 +1,91 @@
|
||||
# Pull Request - Documentation | 文档 PR
|
||||
# Pull Request - Documentation
|
||||
|
||||
> **💡 提示 Tip:** 推荐 PR 标题格式 `docs(scope): description`
|
||||
> 例如: `docs(api): update trading endpoints` | `docs(readme): add setup guide`
|
||||
> **💡 Tip:** Recommended PR title format `docs(scope): description`
|
||||
> Example: `docs(api): update trading endpoints` | `docs(readme): add setup guide`
|
||||
|
||||
---
|
||||
|
||||
## 📝 Description | 描述
|
||||
|
||||
**English:** | **中文:**
|
||||
## 📝 Description
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 📚 Type of Documentation | 文档类型
|
||||
## 📚 Type of Documentation
|
||||
|
||||
- [ ] 📖 README update | README 更新
|
||||
- [ ] 📋 API documentation | API 文档
|
||||
- [ ] 🎓 Tutorial/Guide | 教程/指南
|
||||
- [ ] 📝 Code comments | 代码注释
|
||||
- [ ] 🔧 Configuration docs | 配置文档
|
||||
- [ ] 🐛 Fix typo/error | 修复拼写/错误
|
||||
- [ ] 🌍 Translation | 翻译
|
||||
- [ ] 📖 README update
|
||||
- [ ] 📋 API documentation
|
||||
- [ ] 🎓 Tutorial/Guide
|
||||
- [ ] 📝 Code comments
|
||||
- [ ] 🔧 Configuration docs
|
||||
- [ ] 🐛 Fix typo/error
|
||||
- [ ] 🌍 Translation
|
||||
|
||||
---
|
||||
|
||||
## 🔗 Related Issues | 相关 Issue
|
||||
## 🔗 Related Issues
|
||||
|
||||
- Closes # | 关闭 #
|
||||
- Related to # | 相关 #
|
||||
- Closes #
|
||||
- Related to #
|
||||
|
||||
---
|
||||
|
||||
## 📋 Changes Made | 具体变更
|
||||
## 📋 Changes Made
|
||||
|
||||
**English:** | **中文:**
|
||||
-
|
||||
-
|
||||
|
||||
---
|
||||
|
||||
## 📸 Screenshots (if applicable) | 截图(如适用)
|
||||
## 📸 Screenshots (if applicable)
|
||||
|
||||
<!-- For documentation with images, diagrams, or UI examples -->
|
||||
<!-- 用于包含图片、图表或 UI 示例的文档 -->
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 🌐 Internationalization | 国际化
|
||||
## 🌐 Internationalization
|
||||
|
||||
- [ ] English version complete | 英文版本完整
|
||||
- [ ] Chinese version complete | 中文版本完整
|
||||
- [ ] Both versions are consistent | 两个版本内容一致
|
||||
- [ ] N/A (only one language needed) | 不适用(只需要一种语言)
|
||||
- [ ] English version complete
|
||||
- [ ] Chinese version complete
|
||||
- [ ] Both versions are consistent
|
||||
- [ ] N/A (only one language needed)
|
||||
|
||||
---
|
||||
|
||||
## ✅ Checklist | 检查清单
|
||||
## ✅ Checklist
|
||||
|
||||
### Content Quality | 内容质量
|
||||
- [ ] Information is accurate and up-to-date | 信息准确且最新
|
||||
- [ ] Language is clear and concise | 语言清晰简洁
|
||||
- [ ] No spelling or grammar errors | 无拼写或语法错误
|
||||
- [ ] Links are valid and working | 链接有效且可用
|
||||
- [ ] Code examples are tested and working | 代码示例已测试且可用
|
||||
- [ ] Formatting is consistent | 格式一致
|
||||
### Content Quality
|
||||
- [ ] Information is accurate and up-to-date
|
||||
- [ ] Language is clear and concise
|
||||
- [ ] No spelling or grammar errors
|
||||
- [ ] Links are valid and working
|
||||
- [ ] Code examples are tested and working
|
||||
- [ ] Formatting is consistent
|
||||
|
||||
### Documentation Standards | 文档标准
|
||||
- [ ] Follows project documentation style | 遵循项目文档风格
|
||||
- [ ] Includes necessary examples | 包含必要的示例
|
||||
- [ ] Technical terms are explained | 技术术语已解释
|
||||
- [ ] Self-review completed | 已完成自查
|
||||
### Documentation Standards
|
||||
- [ ] Follows project documentation style
|
||||
- [ ] Includes necessary examples
|
||||
- [ ] Technical terms are explained
|
||||
- [ ] Self-review completed
|
||||
|
||||
### Git
|
||||
- [ ] Commits follow conventional format | 提交遵循 Conventional Commits 格式
|
||||
- [ ] Rebased on latest `dev` branch | 已 rebase 到最新 `dev` 分支
|
||||
- [ ] No merge conflicts | 无合并冲突
|
||||
- [ ] Commits follow conventional format
|
||||
- [ ] Rebased on latest `dev` branch
|
||||
- [ ] No merge conflicts
|
||||
|
||||
---
|
||||
|
||||
## 📚 Additional Notes | 补充说明
|
||||
|
||||
**English:** | **中文:**
|
||||
## 📚 Additional Notes
|
||||
|
||||
|
||||
---
|
||||
|
||||
**By submitting this PR, I confirm | 提交此 PR,我确认:**
|
||||
**By submitting this PR, I confirm:**
|
||||
|
||||
- [ ] I have read the [Contributing Guidelines](../../CONTRIBUTING.md) | 已阅读贡献指南
|
||||
- [ ] I agree to the [Code of Conduct](../../CODE_OF_CONDUCT.md) | 同意行为准则
|
||||
- [ ] My contribution is licensed under AGPL-3.0 | 贡献遵循 AGPL-3.0 许可证
|
||||
- [ ] I have read the [Contributing Guidelines](../../CONTRIBUTING.md)
|
||||
- [ ] I agree to the [Code of Conduct](../../CODE_OF_CONDUCT.md)
|
||||
- [ ] My contribution is licensed under AGPL-3.0
|
||||
|
||||
---
|
||||
|
||||
🌟 **Thank you for your contribution! | 感谢你的贡献!**
|
||||
🌟 **Thank you for your contribution!**
|
||||
|
||||
120
.github/PULL_REQUEST_TEMPLATE/frontend.md
vendored
120
.github/PULL_REQUEST_TEMPLATE/frontend.md
vendored
@@ -1,119 +1,113 @@
|
||||
# Pull Request - Frontend | 前端 PR
|
||||
# Pull Request - Frontend
|
||||
|
||||
> **💡 提示 Tip:** 推荐 PR 标题格式 `type(scope): description`
|
||||
> 例如: `feat(ui): add dark mode toggle` | `fix(form): resolve validation bug`
|
||||
> **💡 Tip:** Recommended PR title format `type(scope): description`
|
||||
> Example: `feat(ui): add dark mode toggle` | `fix(form): resolve validation bug`
|
||||
|
||||
---
|
||||
|
||||
## 📝 Description | 描述
|
||||
|
||||
**English:** | **中文:**
|
||||
## 📝 Description
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Type of Change | 变更类型
|
||||
## 🎯 Type of Change
|
||||
|
||||
- [ ] 🐛 Bug fix | 修复 Bug
|
||||
- [ ] ✨ New feature | 新功能
|
||||
- [ ] 💥 Breaking change | 破坏性变更
|
||||
- [ ] 🎨 Code style update | 代码样式更新
|
||||
- [ ] ♻️ Refactoring | 重构
|
||||
- [ ] ⚡ Performance improvement | 性能优化
|
||||
- [ ] 🐛 Bug fix
|
||||
- [ ] ✨ New feature
|
||||
- [ ] 💥 Breaking change
|
||||
- [ ] 🎨 Code style update
|
||||
- [ ] ♻️ Refactoring
|
||||
- [ ] ⚡ Performance improvement
|
||||
|
||||
---
|
||||
|
||||
## 🔗 Related Issues | 相关 Issue
|
||||
## 🔗 Related Issues
|
||||
|
||||
- Closes # | 关闭 #
|
||||
- Related to # | 相关 #
|
||||
- Closes #
|
||||
- Related to #
|
||||
|
||||
---
|
||||
|
||||
## 📋 Changes Made | 具体变更
|
||||
## 📋 Changes Made
|
||||
|
||||
**English:** | **中文:**
|
||||
-
|
||||
-
|
||||
|
||||
---
|
||||
|
||||
## 📸 Screenshots / Demo | 截图/演示
|
||||
## 📸 Screenshots / Demo
|
||||
|
||||
<!-- For UI changes, include before/after screenshots or video demo -->
|
||||
<!-- 对于 UI 变更,请包含变更前后的截图或视频演示 -->
|
||||
|
||||
**Before | 变更前:**
|
||||
**Before:**
|
||||
|
||||
|
||||
**After | 变更后:**
|
||||
**After:**
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Testing | 测试
|
||||
## 🧪 Testing
|
||||
|
||||
### Test Environment | 测试环境
|
||||
- **OS | 操作系统:**
|
||||
- **Node Version | Node 版本:**
|
||||
- **Browser(s) | 浏览器:**
|
||||
### Test Environment
|
||||
- **OS:**
|
||||
- **Node Version:**
|
||||
- **Browser(s):**
|
||||
|
||||
### Manual Testing | 手动测试
|
||||
- [ ] Tested in development mode | 开发模式测试通过
|
||||
- [ ] Tested production build | 生产构建测试通过
|
||||
- [ ] Tested on multiple browsers | 多浏览器测试通过
|
||||
- [ ] Tested responsive design | 响应式设计测试通过
|
||||
- [ ] Verified no existing functionality broke | 确认没有破坏现有功能
|
||||
### Manual Testing
|
||||
- [ ] Tested in development mode
|
||||
- [ ] Tested production build
|
||||
- [ ] Tested on multiple browsers
|
||||
- [ ] Tested responsive design
|
||||
- [ ] Verified no existing functionality broke
|
||||
|
||||
---
|
||||
|
||||
## 🌐 Internationalization | 国际化
|
||||
## 🌐 Internationalization
|
||||
|
||||
- [ ] All user-facing text supports i18n | 所有面向用户的文本支持国际化
|
||||
- [ ] Both English and Chinese versions provided | 提供了中英文版本
|
||||
- [ ] N/A | 不适用
|
||||
- [ ] All user-facing text supports i18n
|
||||
- [ ] Both English and Chinese versions provided
|
||||
- [ ] N/A
|
||||
|
||||
---
|
||||
|
||||
## ✅ Checklist | 检查清单
|
||||
## ✅ Checklist
|
||||
|
||||
### Code Quality | 代码质量
|
||||
- [ ] Code follows project style | 代码遵循项目风格
|
||||
- [ ] Self-review completed | 已完成代码自查
|
||||
- [ ] Comments added for complex logic | 已添加必要注释
|
||||
- [ ] Code builds successfully | 代码构建成功 (`npm run build`)
|
||||
- [ ] Ran `npm run lint` | 已运行 `npm run lint`
|
||||
- [ ] No console errors or warnings | 无控制台错误或警告
|
||||
### Code Quality
|
||||
- [ ] Code follows project style
|
||||
- [ ] Self-review completed
|
||||
- [ ] Comments added for complex logic
|
||||
- [ ] Code builds successfully (`npm run build`)
|
||||
- [ ] Ran `npm run lint`
|
||||
- [ ] No console errors or warnings
|
||||
|
||||
### Testing | 测试
|
||||
- [ ] Component tests added/updated | 已添加/更新组件测试
|
||||
- [ ] Tests pass locally | 测试在本地通过
|
||||
### Testing
|
||||
- [ ] Component tests added/updated
|
||||
- [ ] Tests pass locally
|
||||
|
||||
### Documentation | 文档
|
||||
- [ ] Updated relevant documentation | 已更新相关文档
|
||||
- [ ] Updated type definitions (TypeScript) | 已更新类型定义
|
||||
- [ ] Added JSDoc comments where necessary | 已添加 JSDoc 注释
|
||||
### Documentation
|
||||
- [ ] Updated relevant documentation
|
||||
- [ ] Updated type definitions (TypeScript)
|
||||
- [ ] Added JSDoc comments where necessary
|
||||
|
||||
### Git
|
||||
- [ ] Commits follow conventional format | 提交遵循 Conventional Commits 格式
|
||||
- [ ] Rebased on latest `dev` branch | 已 rebase 到最新 `dev` 分支
|
||||
- [ ] No merge conflicts | 无合并冲突
|
||||
- [ ] Commits follow conventional format
|
||||
- [ ] Rebased on latest `dev` branch
|
||||
- [ ] No merge conflicts
|
||||
|
||||
---
|
||||
|
||||
## 📚 Additional Notes | 补充说明
|
||||
|
||||
**English:** | **中文:**
|
||||
## 📚 Additional Notes
|
||||
|
||||
|
||||
---
|
||||
|
||||
**By submitting this PR, I confirm | 提交此 PR,我确认:**
|
||||
**By submitting this PR, I confirm:**
|
||||
|
||||
- [ ] I have read the [Contributing Guidelines](../../CONTRIBUTING.md) | 已阅读贡献指南
|
||||
- [ ] I agree to the [Code of Conduct](../../CODE_OF_CONDUCT.md) | 同意行为准则
|
||||
- [ ] My contribution is licensed under AGPL-3.0 | 贡献遵循 AGPL-3.0 许可证
|
||||
- [ ] I have read the [Contributing Guidelines](../../CONTRIBUTING.md)
|
||||
- [ ] I agree to the [Code of Conduct](../../CODE_OF_CONDUCT.md)
|
||||
- [ ] My contribution is licensed under AGPL-3.0
|
||||
|
||||
---
|
||||
|
||||
🌟 **Thank you for your contribution! | 感谢你的贡献!**
|
||||
🌟 **Thank you for your contribution!**
|
||||
|
||||
97
.github/PULL_REQUEST_TEMPLATE/general.md
vendored
97
.github/PULL_REQUEST_TEMPLATE/general.md
vendored
@@ -1,98 +1,93 @@
|
||||
# Pull Request - General | 通用 PR
|
||||
# Pull Request - General
|
||||
|
||||
> **💡 提示 Tip:** 推荐 PR 标题格式 `type(scope): description`
|
||||
> 例如: `feat(trader): add new strategy` | `fix(api): resolve auth issue` | `docs(readme): update`
|
||||
> **💡 Tip:** Recommended PR title format `type(scope): description`
|
||||
> Example: `feat(trader): add new strategy` | `fix(api): resolve auth issue` | `docs(readme): update`
|
||||
|
||||
---
|
||||
|
||||
## 📝 Description | 描述
|
||||
|
||||
**English:** | **中文:**
|
||||
## 📝 Description
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Type of Change | 变更类型
|
||||
## 🎯 Type of Change
|
||||
|
||||
- [ ] 🐛 Bug fix | 修复 Bug
|
||||
- [ ] ✨ New feature | 新功能
|
||||
- [ ] 💥 Breaking change | 破坏性变更
|
||||
- [ ] 📝 Documentation update | 文档更新
|
||||
- [ ] 🎨 Code style update | 代码样式更新
|
||||
- [ ] ♻️ Refactoring | 重构
|
||||
- [ ] ⚡ Performance improvement | 性能优化
|
||||
- [ ] ✅ Test update | 测试更新
|
||||
- [ ] 🔧 Build/config change | 构建/配置变更
|
||||
- [ ] 🔒 Security fix | 安全修复
|
||||
- [ ] 🐛 Bug fix
|
||||
- [ ] ✨ New feature
|
||||
- [ ] 💥 Breaking change
|
||||
- [ ] 📝 Documentation update
|
||||
- [ ] 🎨 Code style update
|
||||
- [ ] ♻️ Refactoring
|
||||
- [ ] ⚡ Performance improvement
|
||||
- [ ] ✅ Test update
|
||||
- [ ] 🔧 Build/config change
|
||||
- [ ] 🔒 Security fix
|
||||
|
||||
---
|
||||
|
||||
## 🔗 Related Issues | 相关 Issue
|
||||
## 🔗 Related Issues
|
||||
|
||||
- Closes # | 关闭 #
|
||||
- Related to # | 相关 #
|
||||
- Closes #
|
||||
- Related to #
|
||||
|
||||
---
|
||||
|
||||
## 📋 Changes Made | 具体变更
|
||||
## 📋 Changes Made
|
||||
|
||||
**English:** | **中文:**
|
||||
-
|
||||
-
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Testing | 测试
|
||||
## 🧪 Testing
|
||||
|
||||
- [ ] Tested locally | 本地测试通过
|
||||
- [ ] Tests pass | 测试通过
|
||||
- [ ] Verified no existing functionality broke | 确认没有破坏现有功能
|
||||
- [ ] Tested locally
|
||||
- [ ] Tests pass
|
||||
- [ ] Verified no existing functionality broke
|
||||
|
||||
**Test details | 测试详情:**
|
||||
**Test details:**
|
||||
|
||||
|
||||
---
|
||||
|
||||
## ✅ Checklist | 检查清单
|
||||
## ✅ Checklist
|
||||
|
||||
### Code Quality | 代码质量
|
||||
- [ ] Code follows project style | 代码遵循项目风格
|
||||
- [ ] Self-review completed | 已完成代码自查
|
||||
- [ ] Comments added for complex logic | 已添加必要注释
|
||||
- [ ] No new warnings or errors | 无新的警告或错误
|
||||
### Code Quality
|
||||
- [ ] Code follows project style
|
||||
- [ ] Self-review completed
|
||||
- [ ] Comments added for complex logic
|
||||
- [ ] No new warnings or errors
|
||||
|
||||
### Documentation | 文档
|
||||
- [ ] Updated relevant documentation | 已更新相关文档
|
||||
- [ ] Added inline comments where necessary | 已添加必要的代码注释
|
||||
### Documentation
|
||||
- [ ] Updated relevant documentation
|
||||
- [ ] Added inline comments where necessary
|
||||
|
||||
### Git
|
||||
- [ ] Commits follow conventional format | 提交遵循 Conventional Commits 格式
|
||||
- [ ] Rebased on latest `dev` branch | 已 rebase 到最新 `dev` 分支
|
||||
- [ ] No merge conflicts | 无合并冲突
|
||||
- [ ] Commits follow conventional format
|
||||
- [ ] Rebased on latest `dev` branch
|
||||
- [ ] No merge conflicts
|
||||
|
||||
---
|
||||
|
||||
## 🔒 Security (if applicable) | 安全(如适用)
|
||||
## 🔒 Security (if applicable)
|
||||
|
||||
- [ ] No API keys or secrets hardcoded | 没有硬编码 API 密钥
|
||||
- [ ] User inputs properly validated | 用户输入已正确验证
|
||||
- [ ] N/A | 不适用
|
||||
- [ ] No API keys or secrets hardcoded
|
||||
- [ ] User inputs properly validated
|
||||
- [ ] N/A
|
||||
|
||||
---
|
||||
|
||||
## 📚 Additional Notes | 补充说明
|
||||
|
||||
**English:** | **中文:**
|
||||
## 📚 Additional Notes
|
||||
|
||||
|
||||
---
|
||||
|
||||
**By submitting this PR, I confirm | 提交此 PR,我确认:**
|
||||
**By submitting this PR, I confirm:**
|
||||
|
||||
- [ ] I have read the [Contributing Guidelines](../../CONTRIBUTING.md) | 已阅读贡献指南
|
||||
- [ ] I agree to the [Code of Conduct](../../CODE_OF_CONDUCT.md) | 同意行为准则
|
||||
- [ ] My contribution is licensed under AGPL-3.0 | 贡献遵循 AGPL-3.0 许可证
|
||||
- [ ] I have read the [Contributing Guidelines](../../CONTRIBUTING.md)
|
||||
- [ ] I agree to the [Code of Conduct](../../CODE_OF_CONDUCT.md)
|
||||
- [ ] My contribution is licensed under AGPL-3.0
|
||||
|
||||
---
|
||||
|
||||
🌟 **Thank you for your contribution! | 感谢你的贡献!**
|
||||
🌟 **Thank you for your contribution!**
|
||||
|
||||
331
.github/workflows/pr-checks-advisory.yml.old
vendored
331
.github/workflows/pr-checks-advisory.yml.old
vendored
@@ -1,331 +0,0 @@
|
||||
name: PR Checks (Advisory)
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
branches: [main, dev]
|
||||
|
||||
# These checks are advisory only - they won't block PR merging
|
||||
# Results will be posted as comments to help contributors improve their PRs
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
checks: write
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
pr-info:
|
||||
name: PR Information
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check PR title format
|
||||
id: check-title
|
||||
run: |
|
||||
PR_TITLE="${{ github.event.pull_request.title }}"
|
||||
|
||||
# Check if title follows conventional commits
|
||||
if echo "$PR_TITLE" | grep -qE "^(feat|fix|docs|style|refactor|perf|test|chore|ci|security)(\(.+\))?: .+"; then
|
||||
echo "status=✅ Good" >> $GITHUB_OUTPUT
|
||||
echo "message=PR title follows Conventional Commits format" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "status=⚠️ Suggestion" >> $GITHUB_OUTPUT
|
||||
echo "message=Consider using Conventional Commits format: type(scope): description" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Calculate PR size
|
||||
id: pr-size
|
||||
run: |
|
||||
ADDITIONS=${{ github.event.pull_request.additions }}
|
||||
DELETIONS=${{ github.event.pull_request.deletions }}
|
||||
TOTAL=$((ADDITIONS + DELETIONS))
|
||||
|
||||
if [ $TOTAL -lt 100 ]; then
|
||||
echo "size=🟢 Small" >> $GITHUB_OUTPUT
|
||||
echo "label=size: small" >> $GITHUB_OUTPUT
|
||||
elif [ $TOTAL -lt 500 ]; then
|
||||
echo "size=🟡 Medium" >> $GITHUB_OUTPUT
|
||||
echo "label=size: medium" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "size=🔴 Large" >> $GITHUB_OUTPUT
|
||||
echo "label=size: large" >> $GITHUB_OUTPUT
|
||||
echo "suggestion=Consider breaking this into smaller PRs for easier review" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
echo "lines=$TOTAL" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Post advisory comment
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const titleStatus = '${{ steps.check-title.outputs.status }}';
|
||||
const titleMessage = '${{ steps.check-title.outputs.message }}';
|
||||
const prSize = '${{ steps.pr-size.outputs.size }}';
|
||||
const prLines = '${{ steps.pr-size.outputs.lines }}';
|
||||
const sizeSuggestion = '${{ steps.pr-size.outputs.suggestion }}' || '';
|
||||
|
||||
let comment = '## 🤖 PR Advisory Feedback\n\n';
|
||||
comment += 'Thank you for your contribution! Here\'s some automated feedback to help improve your PR:\n\n';
|
||||
comment += '### PR Title\n';
|
||||
comment += titleStatus + ' ' + titleMessage + '\n\n';
|
||||
comment += '### PR Size\n';
|
||||
comment += prSize + ' (' + prLines + ' lines changed)\n';
|
||||
if (sizeSuggestion) {
|
||||
comment += '\n💡 **Suggestion:** ' + sizeSuggestion + '\n';
|
||||
}
|
||||
comment += '\n---\n\n';
|
||||
comment += '### 📖 New PR Management System\n\n';
|
||||
comment += 'We\'re introducing a new PR management system! These checks are **advisory only** and won\'t block your PR.\n\n';
|
||||
comment += '**Want to check your PR against new standards?**\n';
|
||||
comment += '```bash\n';
|
||||
comment += '# Run the PR health check tool\n';
|
||||
comment += './scripts/pr-check.sh\n';
|
||||
comment += '```\n\n';
|
||||
comment += 'This tool will:\n';
|
||||
comment += '- 🔍 Analyze your PR (doesn\'t modify anything)\n';
|
||||
comment += '- ✅ Show what\'s already good\n';
|
||||
comment += '- ⚠️ Point out issues\n';
|
||||
comment += '- 💡 Give specific suggestions on how to fix\n\n';
|
||||
comment += '**Learn more:**\n';
|
||||
comment += '- [Migration Guide](https://github.com/NoFxAiOS/nofx/blob/dev/docs/community/MIGRATION_ANNOUNCEMENT.md)\n';
|
||||
comment += '- [Contributing Guidelines](https://github.com/NoFxAiOS/nofx/blob/dev/CONTRIBUTING.md)\n\n';
|
||||
comment += '**Questions?** Just ask in the comments! We\'re here to help. 🙏\n\n';
|
||||
comment += '---\n\n';
|
||||
comment += '*This is an automated message. It won\'t affect your PR being merged.*';
|
||||
|
||||
github.rest.issues.createComment({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: comment
|
||||
});
|
||||
|
||||
backend-checks:
|
||||
name: Backend Checks (Advisory)
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.21'
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libta-lib-dev || true
|
||||
go mod download || true
|
||||
|
||||
- name: Check Go formatting
|
||||
id: go-fmt
|
||||
continue-on-error: true
|
||||
run: |
|
||||
UNFORMATTED=$(gofmt -l . 2>/dev/null || echo "")
|
||||
if [ -n "$UNFORMATTED" ]; then
|
||||
echo "status=⚠️ Needs formatting" >> $GITHUB_OUTPUT
|
||||
echo "files<<EOF" >> $GITHUB_OUTPUT
|
||||
echo "$UNFORMATTED" | head -10 >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "status=✅ Good" >> $GITHUB_OUTPUT
|
||||
echo "files=" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Run go vet
|
||||
id: go-vet
|
||||
continue-on-error: true
|
||||
run: |
|
||||
if go vet ./... 2>&1 | tee vet-output.txt; then
|
||||
echo "status=✅ Good" >> $GITHUB_OUTPUT
|
||||
echo "output=" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "status=⚠️ Issues found" >> $GITHUB_OUTPUT
|
||||
echo "output<<EOF" >> $GITHUB_OUTPUT
|
||||
cat vet-output.txt | head -20 >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Run tests
|
||||
id: go-test
|
||||
continue-on-error: true
|
||||
run: |
|
||||
if go test ./... -v 2>&1 | tee test-output.txt; then
|
||||
echo "status=✅ Passed" >> $GITHUB_OUTPUT
|
||||
echo "output=" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "status=⚠️ Failed" >> $GITHUB_OUTPUT
|
||||
echo "output<<EOF" >> $GITHUB_OUTPUT
|
||||
cat test-output.txt | tail -30 >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Post backend feedback
|
||||
if: always()
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const fmtStatus = '${{ steps.go-fmt.outputs.status }}' || '⚠️ Skipped';
|
||||
const vetStatus = '${{ steps.go-vet.outputs.status }}' || '⚠️ Skipped';
|
||||
const testStatus = '${{ steps.go-test.outputs.status }}' || '⚠️ Skipped';
|
||||
const fmtFiles = `${{ steps.go-fmt.outputs.files }}`;
|
||||
const vetOutput = `${{ steps.go-vet.outputs.output }}`;
|
||||
const testOutput = `${{ steps.go-test.outputs.output }}`;
|
||||
|
||||
let comment = '## 🔧 Backend Checks (Advisory)\n\n';
|
||||
comment += '### Go Formatting\n';
|
||||
comment += fmtStatus + '\n';
|
||||
if (fmtFiles) {
|
||||
comment += '\nFiles needing formatting:\n```\n' + fmtFiles + '\n```\n';
|
||||
}
|
||||
comment += '\n### Go Vet\n';
|
||||
comment += vetStatus + '\n';
|
||||
if (vetOutput) {
|
||||
comment += '\n```\n' + vetOutput.substring(0, 500) + '\n```\n';
|
||||
}
|
||||
comment += '\n### Tests\n';
|
||||
comment += testStatus + '\n';
|
||||
if (testOutput) {
|
||||
comment += '\n```\n' + testOutput.substring(0, 1000) + '\n```\n';
|
||||
}
|
||||
comment += '\n---\n\n';
|
||||
comment += '💡 **To fix locally:**\n';
|
||||
comment += '```bash\n';
|
||||
comment += '# Format code\n';
|
||||
comment += 'go fmt ./...\n\n';
|
||||
comment += '# Check for issues\n';
|
||||
comment += 'go vet ./...\n\n';
|
||||
comment += '# Run tests\n';
|
||||
comment += 'go test ./...\n';
|
||||
comment += '```\n\n';
|
||||
comment += '*These checks are advisory and won\'t block merging. Need help? Just ask!*';
|
||||
|
||||
github.rest.issues.createComment({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: comment
|
||||
});
|
||||
|
||||
frontend-checks:
|
||||
name: Frontend Checks (Advisory)
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '18'
|
||||
|
||||
- name: Check if web directory exists
|
||||
id: check-web
|
||||
run: |
|
||||
if [ -d "web" ]; then
|
||||
echo "exists=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "exists=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Install dependencies
|
||||
if: steps.check-web.outputs.exists == 'true'
|
||||
working-directory: ./web
|
||||
continue-on-error: true
|
||||
run: npm ci
|
||||
|
||||
- name: Run linter
|
||||
if: steps.check-web.outputs.exists == 'true'
|
||||
id: lint
|
||||
working-directory: ./web
|
||||
continue-on-error: true
|
||||
run: |
|
||||
if npm run lint 2>&1 | tee lint-output.txt; then
|
||||
echo "status=✅ Good" >> $GITHUB_OUTPUT
|
||||
echo "output=" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "status=⚠️ Issues found" >> $GITHUB_OUTPUT
|
||||
echo "output<<EOF" >> $GITHUB_OUTPUT
|
||||
cat lint-output.txt | head -20 >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Type check
|
||||
if: steps.check-web.outputs.exists == 'true'
|
||||
id: typecheck
|
||||
working-directory: ./web
|
||||
continue-on-error: true
|
||||
run: |
|
||||
if npm run type-check 2>&1 | tee typecheck-output.txt; then
|
||||
echo "status=✅ Good" >> $GITHUB_OUTPUT
|
||||
echo "output=" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "status=⚠️ Issues found" >> $GITHUB_OUTPUT
|
||||
echo "output<<EOF" >> $GITHUB_OUTPUT
|
||||
cat typecheck-output.txt | head -20 >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Build
|
||||
if: steps.check-web.outputs.exists == 'true'
|
||||
id: build
|
||||
working-directory: ./web
|
||||
continue-on-error: true
|
||||
run: |
|
||||
if npm run build 2>&1 | tee build-output.txt; then
|
||||
echo "status=✅ Success" >> $GITHUB_OUTPUT
|
||||
echo "output=" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "status=⚠️ Failed" >> $GITHUB_OUTPUT
|
||||
echo "output<<EOF" >> $GITHUB_OUTPUT
|
||||
cat build-output.txt | tail -20 >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Post frontend feedback
|
||||
if: always() && steps.check-web.outputs.exists == 'true'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const lintStatus = '${{ steps.lint.outputs.status }}' || '⚠️ Skipped';
|
||||
const typecheckStatus = '${{ steps.typecheck.outputs.status }}' || '⚠️ Skipped';
|
||||
const buildStatus = '${{ steps.build.outputs.status }}' || '⚠️ Skipped';
|
||||
const lintOutput = `${{ steps.lint.outputs.output }}`;
|
||||
const typecheckOutput = `${{ steps.typecheck.outputs.output }}`;
|
||||
const buildOutput = `${{ steps.build.outputs.output }}`;
|
||||
|
||||
let comment = '## ⚛️ Frontend Checks (Advisory)\n\n';
|
||||
comment += '### Linting\n';
|
||||
comment += lintStatus + '\n';
|
||||
if (lintOutput) {
|
||||
comment += '\n```\n' + lintOutput.substring(0, 500) + '\n```\n';
|
||||
}
|
||||
comment += '\n### Type Checking\n';
|
||||
comment += typecheckStatus + '\n';
|
||||
if (typecheckOutput) {
|
||||
comment += '\n```\n' + typecheckOutput.substring(0, 500) + '\n```\n';
|
||||
}
|
||||
comment += '\n### Build\n';
|
||||
comment += buildStatus + '\n';
|
||||
if (buildOutput) {
|
||||
comment += '\n```\n' + buildOutput.substring(0, 500) + '\n```\n';
|
||||
}
|
||||
comment += '\n---\n\n';
|
||||
comment += '💡 **To fix locally:**\n';
|
||||
comment += '```bash\n';
|
||||
comment += 'cd web\n\n';
|
||||
comment += '# Fix linting issues\n';
|
||||
comment += 'npm run lint -- --fix\n\n';
|
||||
comment += '# Check types\n';
|
||||
comment += 'npm run type-check\n\n';
|
||||
comment += '# Test build\n';
|
||||
comment += 'npm run build\n';
|
||||
comment += '```\n\n';
|
||||
comment += '*These checks are advisory and won\'t block merging. Need help? Just ask!*';
|
||||
|
||||
github.rest.issues.createComment({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: comment
|
||||
});
|
||||
38
.github/workflows/pr-docker-check.yml
vendored
38
.github/workflows/pr-docker-check.yml
vendored
@@ -1,7 +1,7 @@
|
||||
name: PR Docker Build Check
|
||||
|
||||
# PR 时只做轻量级构建检查,不推送镜像
|
||||
# 策略: 快速验证 amd64 + 抽样检查 arm64 (backend only)
|
||||
# Lightweight build check on PR only, no image push
|
||||
# Strategy: Quick verify amd64 + spot check arm64 (backend only)
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
@@ -18,7 +18,7 @@ on:
|
||||
- '.github/workflows/pr-docker-check.yml'
|
||||
|
||||
jobs:
|
||||
# 快速检查: 所有镜像的 amd64 版本
|
||||
# Quick check: amd64 builds for all images
|
||||
docker-build-amd64:
|
||||
name: Build Check (amd64)
|
||||
runs-on: ubuntu-22.04
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
include:
|
||||
- name: backend
|
||||
dockerfile: ./docker/Dockerfile.backend
|
||||
test_run: true # 需要测试运行
|
||||
test_run: true # Needs test run
|
||||
- name: frontend
|
||||
dockerfile: ./docker/Dockerfile.frontend
|
||||
test_run: true
|
||||
@@ -51,7 +51,7 @@ jobs:
|
||||
file: ${{ matrix.dockerfile }}
|
||||
platforms: linux/amd64
|
||||
push: false
|
||||
load: true # 加载到本地 Docker,用于测试运行
|
||||
load: true # Load into local Docker for test run
|
||||
tags: nofx-${{ matrix.name }}:pr-test
|
||||
cache-from: type=gha,scope=${{ matrix.name }}-amd64
|
||||
cache-to: type=gha,mode=max,scope=${{ matrix.name }}-amd64
|
||||
@@ -66,12 +66,12 @@ jobs:
|
||||
run: |
|
||||
echo "🧪 Testing container startup..."
|
||||
|
||||
# 启动容器
|
||||
# Start container
|
||||
docker run -d --name test-${{ matrix.name }} \
|
||||
--health-cmd="exit 0" \
|
||||
nofx-${{ matrix.name }}:pr-test
|
||||
|
||||
# 等待容器启动 (最多 30 秒)
|
||||
# Wait for container to start (up to 30 seconds)
|
||||
for i in {1..30}; do
|
||||
if docker ps | grep -q test-${{ matrix.name }}; then
|
||||
echo "✅ Container started successfully"
|
||||
@@ -93,7 +93,7 @@ jobs:
|
||||
|
||||
echo "📦 Image size: ${SIZE_MB} MB"
|
||||
|
||||
# 警告阈值
|
||||
# Warning thresholds
|
||||
if [ "${{ matrix.name }}" = "backend" ] && [ $SIZE_MB -gt 500 ]; then
|
||||
echo "⚠️ Warning: Backend image is larger than 500MB"
|
||||
elif [ "${{ matrix.name }}" = "frontend" ] && [ $SIZE_MB -gt 200 ]; then
|
||||
@@ -102,10 +102,10 @@ jobs:
|
||||
echo "✅ Image size is reasonable"
|
||||
fi
|
||||
|
||||
# ARM64 原生构建检查: 使用 GitHub 原生 ARM64 runner (快速!)
|
||||
# ARM64 native build check: Uses GitHub native ARM64 runner (fast!)
|
||||
docker-build-arm64-native:
|
||||
name: Build Check (arm64 native - backend)
|
||||
runs-on: ubuntu-22.04-arm # 原生 ARM64 runner
|
||||
runs-on: ubuntu-22.04-arm # Native ARM64 runner
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -113,19 +113,19 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# 原生 ARM64 不需要 QEMU,直接构建
|
||||
# Native ARM64 does not need QEMU, builds directly
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build backend image (arm64 native)
|
||||
uses: docker/build-push-action@v5
|
||||
timeout-minutes: 15 # 原生构建更快!
|
||||
timeout-minutes: 15 # Native builds are faster!
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/Dockerfile.backend
|
||||
platforms: linux/arm64
|
||||
push: false
|
||||
load: true # 加载到本地,用于测试
|
||||
load: true # Load locally for testing
|
||||
tags: nofx-backend:pr-test-arm64
|
||||
cache-from: type=gha,scope=backend-arm64
|
||||
cache-to: type=gha,mode=max,scope=backend-arm64
|
||||
@@ -139,12 +139,12 @@ jobs:
|
||||
run: |
|
||||
echo "🧪 Testing ARM64 container startup..."
|
||||
|
||||
# 启动容器
|
||||
# Start container
|
||||
docker run -d --name test-backend-arm64 \
|
||||
--health-cmd="exit 0" \
|
||||
nofx-backend:pr-test-arm64
|
||||
|
||||
# 等待启动
|
||||
# Wait for startup
|
||||
for i in {1..30}; do
|
||||
if docker ps | grep -q test-backend-arm64; then
|
||||
echo "✅ ARM64 container started successfully"
|
||||
@@ -165,14 +165,14 @@ jobs:
|
||||
echo "Using GitHub native ARM64 runner - no QEMU needed!"
|
||||
echo "Build time is ~3x faster than emulation"
|
||||
|
||||
# 汇总检查结果
|
||||
# Aggregate check results
|
||||
check-summary:
|
||||
name: Docker Build Summary
|
||||
needs: [docker-build-amd64, docker-build-arm64-native]
|
||||
runs-on: ubuntu-22.04
|
||||
if: always()
|
||||
permissions:
|
||||
pull-requests: write # 用于发布评论
|
||||
pull-requests: write # For posting comments
|
||||
steps:
|
||||
- name: Check build results
|
||||
id: check
|
||||
@@ -180,7 +180,7 @@ jobs:
|
||||
echo "## 🐳 Docker Build Check Results" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# 检查 amd64 构建
|
||||
# Check amd64 build
|
||||
if [[ "${{ needs.docker-build-amd64.result }}" == "success" ]]; then
|
||||
echo "✅ **AMD64 builds**: All passed" >> $GITHUB_STEP_SUMMARY
|
||||
AMD64_OK=true
|
||||
@@ -189,7 +189,7 @@ jobs:
|
||||
AMD64_OK=false
|
||||
fi
|
||||
|
||||
# 检查 arm64 构建
|
||||
# Check arm64 build
|
||||
if [[ "${{ needs.docker-build-arm64-native.result }}" == "success" ]]; then
|
||||
echo "✅ **ARM64 build** (native): Backend passed (frontend will be verified after merge)" >> $GITHUB_STEP_SUMMARY
|
||||
ARM64_OK=true
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
name: PR Docker Compose Healthcheck
|
||||
|
||||
# 驗證 docker-compose.yml 的 healthcheck 配置在 Alpine 容器中正常工作
|
||||
# Verify docker-compose.yml healthcheck config works correctly in Alpine containers
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
|
||||
210
.github/workflows/pr-template-suggester.yml
vendored
210
.github/workflows/pr-template-suggester.yml
vendored
@@ -1,22 +1,18 @@
|
||||
name: PR Template Suggester
|
||||
name: PR Labeler
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, edited, synchronize]
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
issues: write
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
suggest-template:
|
||||
label-pr:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Analyze PR files and auto-apply template
|
||||
- name: Analyze PR and apply labels
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -25,166 +21,72 @@ jobs:
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: context.issue.number,
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
let goFiles = 0, jsFiles = 0, tsFiles = 0, mdFiles = 0, otherFiles = 0;
|
||||
let additions = 0, deletions = 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++;
|
||||
const name = file.filename.toLowerCase();
|
||||
additions += file.additions || 0;
|
||||
deletions += file.deletions || 0;
|
||||
if (name.endsWith('.go')) goFiles++;
|
||||
else if (name.endsWith('.js') || name.endsWith('.jsx')) jsFiles++;
|
||||
else if (name.endsWith('.ts') || name.endsWith('.tsx') || name.endsWith('.vue')) tsFiles++;
|
||||
else if (name.endsWith('.md')) mdFiles++;
|
||||
else otherFiles++;
|
||||
}
|
||||
|
||||
const totalFiles = goFiles + jsFiles + tsFiles + mdFiles + otherFiles;
|
||||
if (totalFiles === 0) { console.log('No files changed'); return; }
|
||||
if (totalFiles === 0) return;
|
||||
|
||||
let suggestedTemplate = null, templateEmoji = '', templateLabel = '';
|
||||
// --- Scope label ---
|
||||
const labels = [];
|
||||
if (goFiles / totalFiles > 0.5) labels.push('backend');
|
||||
else if ((jsFiles + tsFiles) / totalFiles > 0.5) labels.push('frontend');
|
||||
else if (mdFiles / totalFiles > 0.7) labels.push('documentation');
|
||||
else labels.push('fullstack');
|
||||
|
||||
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';
|
||||
// --- Size label (like OpenClaw) ---
|
||||
const totalChanged = additions + deletions;
|
||||
const sizeLabels = ['size: XS', 'size: S', 'size: M', 'size: L', 'size: XL'];
|
||||
let sizeLabel = 'size: XL';
|
||||
if (totalChanged < 50) sizeLabel = 'size: XS';
|
||||
else if (totalChanged < 200) sizeLabel = 'size: S';
|
||||
else if (totalChanged < 500) sizeLabel = 'size: M';
|
||||
else if (totalChanged < 1000) sizeLabel = 'size: L';
|
||||
labels.push(sizeLabel);
|
||||
|
||||
// Ensure size labels exist
|
||||
for (const sl of sizeLabels) {
|
||||
try {
|
||||
await github.rest.issues.getLabel({ owner: context.repo.owner, repo: context.repo.repo, name: sl });
|
||||
} catch (e) {
|
||||
if (e.status === 404) {
|
||||
await github.rest.issues.createLabel({ owner: context.repo.owner, repo: context.repo.repo, name: sl, color: 'b76e79' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const { data: pr } = await github.rest.pulls.get({
|
||||
// Remove stale size labels
|
||||
const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({
|
||||
owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number,
|
||||
});
|
||||
for (const cl of currentLabels) {
|
||||
if (sizeLabels.includes(cl.name) && cl.name !== sizeLabel) {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number, name: cl.name,
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
// Apply labels
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: context.issue.number,
|
||||
issue_number: context.issue.number,
|
||||
labels: labels,
|
||||
});
|
||||
|
||||
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');
|
||||
}
|
||||
console.log(`Applied labels: ${labels.join(', ')} (${totalChanged} lines changed)`);
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -16,6 +16,7 @@ nofx_test
|
||||
# Go 相关
|
||||
*.test
|
||||
*.out
|
||||
.gocache/
|
||||
|
||||
# 操作系统
|
||||
.DS_Store
|
||||
@@ -26,6 +27,8 @@ Thumbs.db
|
||||
*.tmp
|
||||
*.bak
|
||||
*.backup
|
||||
.cache/
|
||||
.gh-config/
|
||||
|
||||
# 环境变量
|
||||
.env
|
||||
@@ -78,7 +81,6 @@ dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
|
||||
40
Dockerfile.railway
Normal file
40
Dockerfile.railway
Normal file
@@ -0,0 +1,40 @@
|
||||
# Railway All-in-One: Reuse existing GHCR images
|
||||
# Extract content from existing images and merge into a single container
|
||||
|
||||
# Extract binary from backend image
|
||||
FROM ghcr.io/nofxaios/nofx/nofx-backend:latest AS backend
|
||||
|
||||
# Extract static files from frontend image
|
||||
FROM ghcr.io/nofxaios/nofx/nofx-frontend:latest AS frontend
|
||||
|
||||
# Final image
|
||||
FROM alpine:latest
|
||||
|
||||
RUN apk add --no-cache ca-certificates tzdata sqlite nginx openssl gettext
|
||||
|
||||
# Copy backend binary
|
||||
COPY --from=backend /app/nofx /app/nofx
|
||||
|
||||
# Copy TA-Lib libraries
|
||||
COPY --from=backend /usr/local/lib/libta_lib* /usr/local/lib/
|
||||
RUN ldconfig /usr/local/lib 2>/dev/null || true
|
||||
|
||||
# Copy frontend static files
|
||||
COPY --from=frontend /usr/share/nginx/html /usr/share/nginx/html
|
||||
|
||||
WORKDIR /app
|
||||
RUN mkdir -p /app/data
|
||||
|
||||
# Startup script (includes nginx config generation)
|
||||
COPY railway/start.sh /app/start.sh
|
||||
RUN chmod +x /app/start.sh
|
||||
|
||||
ENV DB_PATH=/app/data/data.db
|
||||
|
||||
# Railway automatically sets the PORT environment variable
|
||||
EXPOSE 8080
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:${PORT:-8080}/health || exit 1
|
||||
|
||||
CMD ["/app/start.sh"]
|
||||
37
README.ja.md
37
README.ja.md
@@ -103,6 +103,43 @@ Binance互換の分散型無期限先物取引所!
|
||||
|
||||
---
|
||||
|
||||
## 対応取引所
|
||||
|
||||
### CEX(中央集権型取引所)
|
||||
|
||||
| 取引所 | ステータス | 登録(手数料割引) |
|
||||
|:-------|:----------:|:-------------------|
|
||||
| <img src="web/public/exchange-icons/binance.jpg" width="20" height="20" style="vertical-align: middle;"/> **Binance** | ✅ | [登録](https://www.binance.com/join?ref=NOFXENG) |
|
||||
| <img src="web/public/exchange-icons/bybit.png" width="20" height="20" style="vertical-align: middle;"/> **Bybit** | ✅ | [登録](https://partner.bybit.com/b/83856) |
|
||||
| <img src="web/public/exchange-icons/okx.svg" width="20" height="20" style="vertical-align: middle;"/> **OKX** | ✅ | [登録](https://www.okx.com/join/1865360) |
|
||||
| <img src="web/public/exchange-icons/bitget.svg" width="20" height="20" style="vertical-align: middle;"/> **Bitget** | ✅ | [登録](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
|
||||
| <img src="web/public/exchange-icons/kucoin.svg" width="20" height="20" style="vertical-align: middle;"/> **KuCoin** | ✅ | [登録](https://www.kucoin.com/r/broker/CXEV7XKK) |
|
||||
| <img src="web/public/exchange-icons/gate.svg" width="20" height="20" style="vertical-align: middle;"/> **Gate** | ✅ | [登録](https://www.gatenode.xyz/share/VQBGUAxY) |
|
||||
|
||||
### Perp-DEX(分散型無期限取引所)
|
||||
|
||||
| 取引所 | ステータス | 登録(手数料割引) |
|
||||
|:-------|:----------:|:-------------------|
|
||||
| <img src="web/public/exchange-icons/hyperliquid.png" width="20" height="20" style="vertical-align: middle;"/> **Hyperliquid** | ✅ | [登録](https://app.hyperliquid.xyz/join/AITRADING) |
|
||||
| <img src="web/public/exchange-icons/aster.svg" width="20" height="20" style="vertical-align: middle;"/> **Aster DEX** | ✅ | [登録](https://www.asterdex.com/en/referral/fdfc0e) |
|
||||
| <img src="web/public/exchange-icons/lighter.png" width="20" height="20" style="vertical-align: middle;"/> **Lighter** | ✅ | [登録](https://app.lighter.xyz/?referral=68151432) |
|
||||
|
||||
---
|
||||
|
||||
## 対応AIモデル
|
||||
|
||||
| AIモデル | ステータス | APIキー取得 |
|
||||
|:---------|:----------:|:------------|
|
||||
| <img src="web/public/icons/deepseek.svg" width="20" height="20" style="vertical-align: middle;"/> **DeepSeek** | ✅ | [APIキー取得](https://platform.deepseek.com) |
|
||||
| <img src="web/public/icons/qwen.svg" width="20" height="20" style="vertical-align: middle;"/> **Qwen** | ✅ | [APIキー取得](https://dashscope.console.aliyun.com) |
|
||||
| <img src="web/public/icons/openai.svg" width="20" height="20" style="vertical-align: middle;"/> **OpenAI (GPT)** | ✅ | [APIキー取得](https://platform.openai.com) |
|
||||
| <img src="web/public/icons/claude.svg" width="20" height="20" style="vertical-align: middle;"/> **Claude** | ✅ | [APIキー取得](https://console.anthropic.com) |
|
||||
| <img src="web/public/icons/gemini.svg" width="20" height="20" style="vertical-align: middle;"/> **Gemini** | ✅ | [APIキー取得](https://aistudio.google.com) |
|
||||
| <img src="web/public/icons/grok.svg" width="20" height="20" style="vertical-align: middle;"/> **Grok** | ✅ | [APIキー取得](https://console.x.ai) |
|
||||
| <img src="web/public/icons/kimi.svg" width="20" height="20" style="vertical-align: middle;"/> **Kimi** | ✅ | [APIキー取得](https://platform.moonshot.cn) |
|
||||
|
||||
---
|
||||
|
||||
## 📸 スクリーンショット
|
||||
|
||||
### 🏆 競争モード - リアルタイムAIバトル
|
||||
|
||||
607
README.md
607
README.md
@@ -1,470 +1,339 @@
|
||||
# NOFX - Agentic Trading OS
|
||||
<h1 align="center">NOFX</h1>
|
||||
|
||||
[](https://golang.org/)
|
||||
[](https://reactjs.org/)
|
||||
[](https://www.typescriptlang.org/)
|
||||
[](LICENSE)
|
||||
<p align="center">
|
||||
<strong>Your personal AI trading assistant.</strong><br/>
|
||||
<strong>Any market. Any model. Pay with USDC, not API keys.</strong>
|
||||
</p>
|
||||
|
||||
| CONTRIBUTOR AIRDROP PROGRAM |
|
||||
|:----------------------------------:|
|
||||
| Code · Bug Fixes · Issues → Airdrop |
|
||||
| [Learn More](#contributor-airdrop-program) |
|
||||
<p align="center">
|
||||
<a href="https://github.com/NoFxAiOS/nofx/stargazers"><img src="https://img.shields.io/github/stars/NoFxAiOS/nofx?style=for-the-badge" alt="Stars"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/releases"><img src="https://img.shields.io/github/v/release/NoFxAiOS/nofx?style=for-the-badge" alt="Release"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-AGPL--3.0-blue.svg?style=for-the-badge" alt="License"></a>
|
||||
<a href="https://t.me/nofx_dev_community"><img src="https://img.shields.io/badge/Telegram-Community-blue?style=for-the-badge&logo=telegram" alt="Telegram"></a>
|
||||
</p>
|
||||
|
||||
**Languages:** [English](README.md) | [中文](docs/i18n/zh-CN/README.md) | [日本語](docs/i18n/ja/README.md) | [한국어](docs/i18n/ko/README.md) | [Русский](docs/i18n/ru/README.md) | [Українська](docs/i18n/uk/README.md) | [Tiếng Việt](docs/i18n/vi/README.md)
|
||||
<p align="center">
|
||||
<a href="https://golang.org/"><img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go" alt="Go"></a>
|
||||
<a href="https://reactjs.org/"><img src="https://img.shields.io/badge/React-18+-61DAFB?style=flat&logo=react" alt="React"></a>
|
||||
<a href="https://x402.org"><img src="https://img.shields.io/badge/x402-USDC%20Payments-2775CA?style=flat" alt="x402"></a>
|
||||
<a href="https://claw402.ai"><img src="https://img.shields.io/badge/Claw402-AI%20Gateway-FF6B35?style=flat" alt="Claw402"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="README.md">English</a> ·
|
||||
<a href="docs/i18n/zh-CN/README.md">中文</a> ·
|
||||
<a href="docs/i18n/ja/README.md">日本語</a> ·
|
||||
<a href="docs/i18n/ko/README.md">한국어</a> ·
|
||||
<a href="docs/i18n/ru/README.md">Русский</a> ·
|
||||
<a href="docs/i18n/uk/README.md">Українська</a> ·
|
||||
<a href="docs/i18n/vi/README.md">Tiếng Việt</a>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## AI-Powered Multi-Asset Trading Platform
|
||||
NOFX is an open-source **autonomous** AI trading assistant. Unlike traditional AI tools that require you to manually configure models, manage API keys, and wire up data sources — NOFX's AI **perceives markets, selects models, and fetches data entirely on its own**. Zero human intervention. You set the strategy, the AI handles everything else.
|
||||
|
||||
**NOFX** is an open-source AI trading system that lets you run multiple AI models to trade automatically. Configure strategies through a web interface, monitor performance in real-time, and let AI agents compete to find the best trading approach.
|
||||
**Fully autonomous**: The AI decides which model to use, what market data to pull, when to trade — all by itself. No manual model configuration. No juggling API keys for different services. Just fund a USDC wallet and let it run.
|
||||
|
||||
### Supported Markets
|
||||
What makes it different: **built-in [x402](https://x402.org) micropayments**. No API keys. Fund a USDC wallet and pay per request. Your wallet is your identity.
|
||||
|
||||
| Market | Trading | Status |
|
||||
|--------|---------|--------|
|
||||
| 🪙 **Crypto** | BTC, ETH, Altcoins | ✅ Supported |
|
||||
| 📈 **US Stocks** | AAPL, TSLA, NVDA, etc. | ✅ Supported |
|
||||
| 💱 **Forex** | EUR/USD, GBP/USD, etc. | ✅ Supported |
|
||||
| 🥇 **Metals** | Gold, Silver | ✅ Supported |
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
```
|
||||
|
||||
### Core Features
|
||||
Open **http://127.0.0.1:3000**. Done.
|
||||
|
||||
- **Multi-AI Support**: Run DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi - switch models anytime
|
||||
- **Multi-Exchange**: Trade on Binance, Bybit, OKX, Bitget, Hyperliquid, Aster DEX, Lighter from one platform
|
||||
- **Strategy Studio**: Visual strategy builder with coin sources, indicators, and risk controls
|
||||
- **AI Debate Arena**: Multiple AI models debate trading decisions with different roles (Bull, Bear, Analyst)
|
||||
- **AI Competition Mode**: Multiple AI traders compete in real-time, track performance side by side
|
||||
- **Web-Based Config**: No JSON editing - configure everything through the web interface
|
||||
- **Real-Time Dashboard**: Live positions, P/L tracking, AI decision logs with Chain of Thought
|
||||
---
|
||||
|
||||
### Core Team
|
||||
## How x402 Works
|
||||
|
||||
- **Tinkle** - [@Web3Tinkle](https://x.com/Web3Tinkle)
|
||||
- **Official Twitter** - [@nofx_official](https://x.com/nofx_official)
|
||||
Traditional flow: register account → buy credits → get API key → manage quota → rotate keys.
|
||||
|
||||
> **Risk Warning**: This system is experimental. AI auto-trading carries significant risks. Strongly recommended for learning/research purposes or testing with small amounts only!
|
||||
x402 flow:
|
||||
|
||||
## Developer Community
|
||||
```
|
||||
Request → 402 (here's the price) → wallet signs USDC → retry → done
|
||||
```
|
||||
|
||||
Join our Telegram developer community: **[NOFX Developer Community](https://t.me/nofx_dev_community)**
|
||||
No accounts. No API keys. No prepaid credits. One wallet, every model.
|
||||
|
||||
### Built-in x402 Providers
|
||||
|
||||
| Provider | Chain | Models |
|
||||
|:---------|:------|:-------|
|
||||
| <img src="web/public/icons/claw402.png" width="20" height="20" style="vertical-align: middle;"/> **[Claw402](https://claw402.ai)** | Base | GPT-5.4, Claude Opus, DeepSeek, Qwen, Grok, Gemini, Kimi — 15+ models |
|
||||
|
||||
---
|
||||
|
||||
## What It Does
|
||||
|
||||
| Feature | Description |
|
||||
|:--------|:------------|
|
||||
| **Multi-AI** | DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi, MiniMax — switch anytime |
|
||||
| **Multi-Exchange** | Binance, Bybit, OKX, Bitget, KuCoin, Gate, Hyperliquid, Aster, Lighter |
|
||||
| **Strategy Studio** | Visual builder — coin sources, indicators, risk controls |
|
||||
| **AI Competition** | AIs compete in real-time, leaderboard ranks performance |
|
||||
| **Telegram Agent** | Chat with your trading assistant — streaming, tool calling, memory |
|
||||
| **Dashboard** | Live positions, P/L, AI decision logs with Chain of Thought |
|
||||
|
||||
### Markets
|
||||
|
||||
Crypto · US Stocks · Forex · Metals
|
||||
|
||||
### Exchanges (CEX)
|
||||
|
||||
| Exchange | Status | Register (Fee Discount) |
|
||||
|:---------|:------:|:------------------------|
|
||||
| <img src="web/public/exchange-icons/binance.jpg" width="20" height="20" style="vertical-align: middle;"/> **Binance** | ✅ | [Register](https://www.binance.com/join?ref=NOFXENG) |
|
||||
| <img src="web/public/exchange-icons/bybit.png" width="20" height="20" style="vertical-align: middle;"/> **Bybit** | ✅ | [Register](https://partner.bybit.com/b/83856) |
|
||||
| <img src="web/public/exchange-icons/okx.svg" width="20" height="20" style="vertical-align: middle;"/> **OKX** | ✅ | [Register](https://www.okx.com/join/1865360) |
|
||||
| <img src="web/public/exchange-icons/bitget.svg" width="20" height="20" style="vertical-align: middle;"/> **Bitget** | ✅ | [Register](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
|
||||
| <img src="web/public/exchange-icons/kucoin.svg" width="20" height="20" style="vertical-align: middle;"/> **KuCoin** | ✅ | [Register](https://www.kucoin.com/r/broker/CXEV7XKK) |
|
||||
| <img src="web/public/exchange-icons/gate.svg" width="20" height="20" style="vertical-align: middle;"/> **Gate** | ✅ | [Register](https://www.gatenode.xyz/share/VQBGUAxY) |
|
||||
|
||||
### Exchanges (Perp-DEX)
|
||||
|
||||
| Exchange | Status | Register (Fee Discount) |
|
||||
|:---------|:------:|:------------------------|
|
||||
| <img src="web/public/exchange-icons/hyperliquid.png" width="20" height="20" style="vertical-align: middle;"/> **Hyperliquid** | ✅ | [Register](https://app.hyperliquid.xyz/join/AITRADING) |
|
||||
| <img src="web/public/exchange-icons/aster.svg" width="20" height="20" style="vertical-align: middle;"/> **Aster DEX** | ✅ | [Register](https://www.asterdex.com/en/referral/fdfc0e) |
|
||||
| <img src="web/public/exchange-icons/lighter.png" width="20" height="20" style="vertical-align: middle;"/> **Lighter** | ✅ | [Register](https://app.lighter.xyz/?referral=68151432) |
|
||||
|
||||
### AI Models (API Key Mode)
|
||||
|
||||
| AI Model | Status | Get API Key |
|
||||
|:---------|:------:|:------------|
|
||||
| <img src="web/public/icons/deepseek.svg" width="20" height="20" style="vertical-align: middle;"/> **DeepSeek** | ✅ | [Get API Key](https://platform.deepseek.com) |
|
||||
| <img src="web/public/icons/qwen.svg" width="20" height="20" style="vertical-align: middle;"/> **Qwen** | ✅ | [Get API Key](https://dashscope.console.aliyun.com) |
|
||||
| <img src="web/public/icons/openai.svg" width="20" height="20" style="vertical-align: middle;"/> **OpenAI (GPT)** | ✅ | [Get API Key](https://platform.openai.com) |
|
||||
| <img src="web/public/icons/claude.svg" width="20" height="20" style="vertical-align: middle;"/> **Claude** | ✅ | [Get API Key](https://console.anthropic.com) |
|
||||
| <img src="web/public/icons/gemini.svg" width="20" height="20" style="vertical-align: middle;"/> **Gemini** | ✅ | [Get API Key](https://aistudio.google.com) |
|
||||
| <img src="web/public/icons/grok.svg" width="20" height="20" style="vertical-align: middle;"/> **Grok** | ✅ | [Get API Key](https://console.x.ai) |
|
||||
| <img src="web/public/icons/kimi.svg" width="20" height="20" style="vertical-align: middle;"/> **Kimi** | ✅ | [Get API Key](https://platform.moonshot.cn) |
|
||||
| <img src="web/public/icons/minimax.svg" width="20" height="20" style="vertical-align: middle;"/> **MiniMax** | ✅ | [Get API Key](https://platform.minimaxi.com) |
|
||||
|
||||
### AI Models (x402 Mode — No API Key)
|
||||
|
||||
15+ models via [Claw402](https://claw402.ai) — just a USDC wallet
|
||||
|
||||
---
|
||||
|
||||
## Screenshots
|
||||
|
||||
### Config Page
|
||||
<details>
|
||||
<summary><b>Config Page</b></summary>
|
||||
|
||||
| AI Models & Exchanges | Traders List |
|
||||
|:---:|:---:|
|
||||
| <img src="screenshots/config-ai-exchanges.png" width="400" alt="Config - AI Models & Exchanges"/> | <img src="screenshots/config-traders-list.png" width="400" alt="Config - Traders List"/> |
|
||||
| <img src="screenshots/config-ai-exchanges.png" width="400"/> | <img src="screenshots/config-traders-list.png" width="400"/> |
|
||||
</details>
|
||||
|
||||
### Competition & Backtest
|
||||
| Competition Mode | Backtest Lab |
|
||||
|:---:|:---:|
|
||||
| <img src="screenshots/competition-page.png" width="400" alt="Competition Page"/> | <img src="screenshots/backtest-lab.png" width="400" alt="Backtest Lab"/> |
|
||||
<details>
|
||||
<summary><b>Dashboard</b></summary>
|
||||
|
||||
### Dashboard
|
||||
| Overview | Market Chart |
|
||||
|:---:|:---:|
|
||||
| <img src="screenshots/dashboard-page.png" width="400" alt="Dashboard Overview"/> | <img src="screenshots/dashboard-market-chart.png" width="400" alt="Dashboard Market Chart"/> |
|
||||
| <img src="screenshots/dashboard-page.png" width="400"/> | <img src="screenshots/dashboard-market-chart.png" width="400"/> |
|
||||
|
||||
| Trading Stats | Position History |
|
||||
|:---:|:---:|
|
||||
| <img src="screenshots/dashboard-trading-stats.png" width="400" alt="Trading Stats"/> | <img src="screenshots/dashboard-position-history.png" width="400" alt="Position History"/> |
|
||||
| <img src="screenshots/dashboard-trading-stats.png" width="400"/> | <img src="screenshots/dashboard-position-history.png" width="400"/> |
|
||||
|
||||
| Positions | Trader Details |
|
||||
|:---:|:---:|
|
||||
| <img src="screenshots/dashboard-positions.png" width="400" alt="Dashboard Positions"/> | <img src="screenshots/details-page.png" width="400" alt="Trader Details"/> |
|
||||
| <img src="screenshots/dashboard-positions.png" width="400"/> | <img src="screenshots/details-page.png" width="400"/> |
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Strategy Studio</b></summary>
|
||||
|
||||
### Strategy Studio
|
||||
| Strategy Editor | Indicators Config |
|
||||
|:---:|:---:|
|
||||
| <img src="screenshots/strategy-studio.png" width="400" alt="Strategy Studio"/> | <img src="screenshots/strategy-indicators.png" width="400" alt="Strategy Indicators"/> |
|
||||
| <img src="screenshots/strategy-studio.png" width="400"/> | <img src="screenshots/strategy-indicators.png" width="400"/> |
|
||||
</details>
|
||||
|
||||
### Debate Arena
|
||||
| AI Debate Session | Create Debate |
|
||||
|:---:|:---:|
|
||||
| <img src="screenshots/debate-arena.png" width="400" alt="Debate Arena"/> | <img src="screenshots/debate-create.png" width="400" alt="Create Debate"/> |
|
||||
<details>
|
||||
<summary><b>Competition</b></summary>
|
||||
|
||||
| Competition Mode |
|
||||
|:---:|
|
||||
| <img src="screenshots/competition-page.png" width="400"/> |
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## Supported Exchanges
|
||||
## Install
|
||||
|
||||
### CEX (Centralized Exchanges)
|
||||
### Linux / macOS
|
||||
|
||||
| Exchange | Status | Register (Fee Discount) |
|
||||
|----------|--------|-------------------------|
|
||||
| **Binance** | ✅ Supported | [Register](https://www.binance.com/join?ref=NOFXENG) |
|
||||
| **Bybit** | ✅ Supported | [Register](https://partner.bybit.com/b/83856) |
|
||||
| **OKX** | ✅ Supported | [Register](https://www.okx.com/join/1865360) |
|
||||
| **Bitget** | ✅ Supported | [Register](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
|
||||
|
||||
### Perp-DEX (Decentralized Perpetual Exchanges)
|
||||
|
||||
| Exchange | Status | Register (Fee Discount) |
|
||||
|----------|--------|-------------------------|
|
||||
| **Hyperliquid** | ✅ Supported | [Register](https://app.hyperliquid.xyz/join/AITRADING) |
|
||||
| **Aster DEX** | ✅ Supported | [Register](https://www.asterdex.com/en/referral/fdfc0e) |
|
||||
| **Lighter** | ✅ Supported | [Register](https://app.lighter.xyz/?referral=68151432) |
|
||||
|
||||
---
|
||||
|
||||
## Supported AI Models
|
||||
|
||||
| AI Model | Status | Get API Key |
|
||||
|----------|--------|-------------|
|
||||
| **DeepSeek** | ✅ Supported | [Get API Key](https://platform.deepseek.com) |
|
||||
| **Qwen** | ✅ Supported | [Get API Key](https://dashscope.console.aliyun.com) |
|
||||
| **OpenAI (GPT)** | ✅ Supported | [Get API Key](https://platform.openai.com) |
|
||||
| **Claude** | ✅ Supported | [Get API Key](https://console.anthropic.com) |
|
||||
| **Gemini** | ✅ Supported | [Get API Key](https://aistudio.google.com) |
|
||||
| **Grok** | ✅ Supported | [Get API Key](https://console.x.ai) |
|
||||
| **Kimi** | ✅ Supported | [Get API Key](https://platform.moonshot.cn) |
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
### One-Click Install (Recommended)
|
||||
|
||||
**Linux / macOS:**
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
```
|
||||
|
||||
That's it! Open **http://127.0.0.1:3000** in your browser.
|
||||
### Railway (Cloud)
|
||||
|
||||
### Docker Compose (Manual)
|
||||
[](https://railway.com/deploy/nofx?referralCode=nofx)
|
||||
|
||||
### Docker
|
||||
|
||||
```bash
|
||||
# Download and start
|
||||
curl -O https://raw.githubusercontent.com/NoFxAiOS/nofx/main/docker-compose.prod.yml
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
Access Web Interface: **http://127.0.0.1:3000**
|
||||
### Windows
|
||||
|
||||
```bash
|
||||
# Management commands
|
||||
docker compose -f docker-compose.prod.yml logs -f # View logs
|
||||
docker compose -f docker-compose.prod.yml restart # Restart
|
||||
docker compose -f docker-compose.prod.yml down # Stop
|
||||
docker compose -f docker-compose.prod.yml pull && docker compose -f docker-compose.prod.yml up -d # Update
|
||||
Install [Docker Desktop](https://www.docker.com/products/docker-desktop/), then:
|
||||
|
||||
```powershell
|
||||
curl -o docker-compose.prod.yml https://raw.githubusercontent.com/NoFxAiOS/nofx/main/docker-compose.prod.yml
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
### Keeping Updated
|
||||
### From Source
|
||||
|
||||
> **💡 Updates are frequent.** Run this command daily to stay current with the latest features and fixes:
|
||||
```bash
|
||||
# Prerequisites: Go 1.21+, Node.js 18+, TA-Lib
|
||||
# macOS: brew install ta-lib
|
||||
# Ubuntu: sudo apt-get install libta-lib0-dev
|
||||
|
||||
git clone https://github.com/NoFxAiOS/nofx.git && cd nofx
|
||||
go build -o nofx && ./nofx # backend
|
||||
cd web && npm install && npm run dev # frontend (new terminal)
|
||||
```
|
||||
|
||||
### Update
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
```
|
||||
|
||||
This one-liner pulls the latest official images and restarts services automatically.
|
||||
---
|
||||
|
||||
### Manual Installation (For Developers)
|
||||
## Setup
|
||||
|
||||
#### Prerequisites
|
||||
**Beginner mode**: First-time users get a guided onboarding flow — select beginner mode at registration and the system walks you through AI, exchange, and strategy setup step by step.
|
||||
|
||||
- **Go 1.21+**
|
||||
- **Node.js 18+**
|
||||
- **TA-Lib** (technical indicator library)
|
||||
**Advanced mode**:
|
||||
|
||||
```bash
|
||||
# Install TA-Lib
|
||||
# macOS
|
||||
brew install ta-lib
|
||||
1. **AI** — Add API keys or configure x402 wallet
|
||||
2. **Exchange** — Connect exchange API credentials
|
||||
3. **Strategy** — Build in Strategy Studio
|
||||
4. **Trader** — Combine AI + Exchange + Strategy
|
||||
5. **Trade** — Launch from the dashboard
|
||||
|
||||
# Ubuntu/Debian
|
||||
sudo apt-get install libta-lib0-dev
|
||||
```
|
||||
|
||||
#### Installation Steps
|
||||
|
||||
```bash
|
||||
# 1. Clone the repository
|
||||
git clone https://github.com/NoFxAiOS/nofx.git
|
||||
cd nofx
|
||||
|
||||
# 2. Install backend dependencies
|
||||
go mod download
|
||||
|
||||
# 3. Install frontend dependencies
|
||||
cd web
|
||||
npm install
|
||||
cd ..
|
||||
|
||||
# 4. Build and start backend
|
||||
go build -o nofx
|
||||
./nofx
|
||||
|
||||
# 5. Start frontend (new terminal)
|
||||
cd web
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Access Web Interface: **http://127.0.0.1:3000**
|
||||
Everything through the web UI at **http://127.0.0.1:3000**.
|
||||
|
||||
---
|
||||
|
||||
## Windows Installation
|
||||
|
||||
### Method 1: Docker Desktop (Recommended)
|
||||
|
||||
1. **Install Docker Desktop**
|
||||
- Download from [docker.com/products/docker-desktop](https://www.docker.com/products/docker-desktop/)
|
||||
- Run the installer and restart your computer
|
||||
- Start Docker Desktop and wait for it to be ready
|
||||
|
||||
2. **Run NOFX**
|
||||
```powershell
|
||||
# Open PowerShell and run:
|
||||
curl -o docker-compose.prod.yml https://raw.githubusercontent.com/NoFxAiOS/nofx/main/docker-compose.prod.yml
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
3. **Access**: Open **http://127.0.0.1:3000** in your browser
|
||||
|
||||
### Method 2: WSL2 (For Development)
|
||||
|
||||
1. **Install WSL2**
|
||||
```powershell
|
||||
# Open PowerShell as Administrator
|
||||
wsl --install
|
||||
```
|
||||
Restart your computer after installation.
|
||||
|
||||
2. **Install Ubuntu from Microsoft Store**
|
||||
- Open Microsoft Store
|
||||
- Search "Ubuntu 22.04" and install
|
||||
- Launch Ubuntu and set up username/password
|
||||
|
||||
3. **Install Dependencies in WSL2**
|
||||
```bash
|
||||
# Update system
|
||||
sudo apt update && sudo apt upgrade -y
|
||||
|
||||
# Install Go
|
||||
wget https://go.dev/dl/go1.21.5.linux-amd64.tar.gz
|
||||
sudo tar -C /usr/local -xzf go1.21.5.linux-amd64.tar.gz
|
||||
echo 'export PATH=$PATH:/usr/local/go/bin' >> ~/.bashrc
|
||||
source ~/.bashrc
|
||||
|
||||
# Install Node.js
|
||||
curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash -
|
||||
sudo apt-get install -y nodejs
|
||||
|
||||
# Install TA-Lib
|
||||
sudo apt-get install -y libta-lib0-dev
|
||||
|
||||
# Install Git
|
||||
sudo apt-get install -y git
|
||||
```
|
||||
|
||||
4. **Clone and Run NOFX**
|
||||
```bash
|
||||
git clone https://github.com/NoFxAiOS/nofx.git
|
||||
cd nofx
|
||||
|
||||
# Build and run backend
|
||||
go build -o nofx && ./nofx
|
||||
|
||||
# In another terminal, run frontend
|
||||
cd web && npm install && npm run dev
|
||||
```
|
||||
|
||||
5. **Access**: Open **http://127.0.0.1:3000** in Windows browser
|
||||
|
||||
### Method 3: Docker in WSL2 (Best of Both Worlds)
|
||||
|
||||
1. **Install Docker Desktop with WSL2 backend**
|
||||
- During Docker Desktop installation, enable "Use WSL 2 based engine"
|
||||
- In Docker Desktop Settings → Resources → WSL Integration, enable your Linux distro
|
||||
|
||||
2. **Run from WSL2 terminal**
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Server Deployment
|
||||
|
||||
### Quick Deploy (HTTP via IP)
|
||||
|
||||
By default, transport encryption is **disabled**, allowing you to access NOFX via IP address without HTTPS:
|
||||
## Deploy to Server
|
||||
|
||||
**HTTP (quick):**
|
||||
```bash
|
||||
# Deploy to your server
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
# Access via http://YOUR_IP:3000
|
||||
```
|
||||
|
||||
Access via `http://YOUR_SERVER_IP:3000` - works immediately.
|
||||
**HTTPS (Cloudflare):**
|
||||
1. Add domain to [Cloudflare](https://dash.cloudflare.com) (free plan)
|
||||
2. A record → your server IP (Proxied)
|
||||
3. SSL/TLS → Flexible
|
||||
4. Set `TRANSPORT_ENCRYPTION=true` in `.env`
|
||||
|
||||
### Enhanced Security (HTTPS)
|
||||
---
|
||||
|
||||
For enhanced security, enable transport encryption in `.env`:
|
||||
## Architecture
|
||||
|
||||
```bash
|
||||
TRANSPORT_ENCRYPTION=true
|
||||
```
|
||||
NOFX
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ Web Dashboard │
|
||||
│ React + TypeScript + TradingView │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ API Server (Go) │
|
||||
├──────────┬──────────┬──────────┬────────────────┤
|
||||
│ Strategy │ Telegram │
|
||||
│ Engine │ Agent │
|
||||
├──────────┴──────────┴──────────┴────────────────┤
|
||||
│ MCP AI Client Layer │
|
||||
│ ┌───────────┐ ┌───────────┐ ┌───────────┐ │
|
||||
│ │ API Key │ │ x402 │ │ │ │
|
||||
│ │ DeepSeek │ │ Claw402 │ │ │ │
|
||||
│ │ GPT,Claude │ │ │ │ │ │
|
||||
│ └───────────┘ └───────────┘ └───────────┘ │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ Exchange Connectors │
|
||||
│ Binance · Bybit · OKX · Bitget · KuCoin · Gate │
|
||||
│ Hyperliquid · Aster DEX · Lighter │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
When enabled, browser uses Web Crypto API to encrypt API keys before transmission. This requires:
|
||||
- `https://` - Any domain with SSL
|
||||
- `http://localhost` - Local development
|
||||
|
||||
### Quick HTTPS Setup with Cloudflare
|
||||
|
||||
1. **Add your domain to Cloudflare** (free plan works)
|
||||
- Go to [dash.cloudflare.com](https://dash.cloudflare.com)
|
||||
- Add your domain and update nameservers
|
||||
|
||||
2. **Create DNS record**
|
||||
- Type: `A`
|
||||
- Name: `nofx` (or your subdomain)
|
||||
- Content: Your server IP
|
||||
- Proxy status: **Proxied** (orange cloud)
|
||||
|
||||
3. **Configure SSL/TLS**
|
||||
- Go to SSL/TLS settings
|
||||
- Set encryption mode to **Flexible**
|
||||
|
||||
```
|
||||
User ──[HTTPS]──→ Cloudflare ──[HTTP]──→ Your Server:3000
|
||||
```
|
||||
|
||||
4. **Enable transport encryption**
|
||||
```bash
|
||||
# Edit .env and set
|
||||
TRANSPORT_ENCRYPTION=true
|
||||
```
|
||||
|
||||
5. **Done!** Access via `https://nofx.yourdomain.com`
|
||||
|
||||
---
|
||||
|
||||
## Initial Setup (Web Interface)
|
||||
## Docs
|
||||
|
||||
After starting the system, configure through the web interface:
|
||||
|
||||
1. **Configure AI Models** - Add your AI API keys (DeepSeek, OpenAI, etc.)
|
||||
2. **Configure Exchanges** - Set up exchange API credentials
|
||||
3. **Create Strategy** - Configure trading strategy in Strategy Studio
|
||||
4. **Create Trader** - Combine AI model + Exchange + Strategy
|
||||
5. **Start Trading** - Launch your configured traders
|
||||
|
||||
All configuration is done through the web interface - no JSON file editing required.
|
||||
|
||||
---
|
||||
|
||||
## Web Interface Features
|
||||
|
||||
### Competition Page
|
||||
- Real-time ROI leaderboard
|
||||
- Multi-AI performance comparison charts
|
||||
- Live P/L tracking and rankings
|
||||
|
||||
### Dashboard
|
||||
- TradingView-style candlestick charts
|
||||
- Real-time position management
|
||||
- AI decision logs with Chain of Thought reasoning
|
||||
- Equity curve tracking
|
||||
|
||||
### Strategy Studio
|
||||
- Coin source configuration (Static list, AI500 pool, OI Top)
|
||||
- Technical indicators (EMA, MACD, RSI, ATR, Volume, OI, Funding Rate)
|
||||
- Risk control settings (leverage, position limits, margin usage)
|
||||
- AI test with real-time prompt preview
|
||||
|
||||
### Debate Arena
|
||||
- Multi-AI debate sessions for trading decisions
|
||||
- Configurable AI roles (Bull, Bear, Analyst, Contrarian, Risk Manager)
|
||||
- Multiple rounds of debate with consensus voting
|
||||
- Auto-execute consensus trades
|
||||
|
||||
### Backtest Lab
|
||||
- 3-step wizard configuration (Model → Parameters → Confirm)
|
||||
- Real-time progress visualization with animated ring
|
||||
- Equity curve chart with trade markers
|
||||
- Trade timeline with card-style display
|
||||
- Performance metrics (Return, Max DD, Sharpe, Win Rate)
|
||||
- AI decision trail with Chain of Thought
|
||||
|
||||
---
|
||||
|
||||
## Common Issues
|
||||
|
||||
### TA-Lib not found
|
||||
```bash
|
||||
# macOS
|
||||
brew install ta-lib
|
||||
|
||||
# Ubuntu
|
||||
sudo apt-get install libta-lib0-dev
|
||||
```
|
||||
|
||||
### AI API timeout
|
||||
- Check if API key is correct
|
||||
- Check network connection
|
||||
- System timeout is 120 seconds
|
||||
|
||||
### Frontend can't connect to backend
|
||||
- Ensure backend is running on http://localhost:8080
|
||||
- Check if port is occupied
|
||||
|
||||
---
|
||||
|
||||
## Documentation
|
||||
|
||||
| Document | Description |
|
||||
|----------|-------------|
|
||||
| **[Architecture Overview](docs/architecture/README.md)** | System design and module index |
|
||||
| **[Strategy Module](docs/architecture/STRATEGY_MODULE.md)** | Coin selection, data assembly, AI prompts, execution |
|
||||
| **[Backtest Module](docs/architecture/BACKTEST_MODULE.md)** | Historical simulation, metrics, checkpoint/resume |
|
||||
| **[Debate Module](docs/architecture/DEBATE_MODULE.md)** | Multi-AI debate, voting consensus, auto-execution |
|
||||
| **[FAQ](docs/faq/README.md)** | Frequently asked questions |
|
||||
| **[Getting Started](docs/getting-started/README.md)** | Deployment guide |
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under **GNU Affero General Public License v3.0 (AGPL-3.0)** - See [LICENSE](LICENSE) file.
|
||||
| | |
|
||||
|:--|:--|
|
||||
| [Architecture](docs/architecture/README.md) | System design and module index |
|
||||
| [Strategy Module](docs/architecture/STRATEGY_MODULE.md) | Coin selection, AI prompts, execution |
|
||||
| [FAQ](docs/faq/README.md) | Common questions |
|
||||
| [Getting Started](docs/getting-started/README.md) | Deployment guide |
|
||||
|
||||
---
|
||||
|
||||
## Contributing
|
||||
|
||||
We welcome contributions! See:
|
||||
- **[Contributing Guide](CONTRIBUTING.md)** - Development workflow and PR process
|
||||
- **[Code of Conduct](CODE_OF_CONDUCT.md)** - Community guidelines
|
||||
- **[Security Policy](SECURITY.md)** - Report vulnerabilities
|
||||
See [Contributing Guide](CONTRIBUTING.md) · [Code of Conduct](CODE_OF_CONDUCT.md) · [Security Policy](SECURITY.md)
|
||||
|
||||
### Contributor Airdrop Program
|
||||
|
||||
All contributions are tracked. When NOFX generates revenue, contributors receive airdrops.
|
||||
|
||||
**[Pinned Issues](https://github.com/NoFxAiOS/nofx/issues) get the highest rewards.**
|
||||
|
||||
| Contribution | Weight |
|
||||
|:-------------|:------:|
|
||||
| Pinned Issue PRs | ★★★★★★ |
|
||||
| Code (Merged PRs) | ★★★★★ |
|
||||
| Bug Fixes | ★★★★ |
|
||||
| Feature Ideas | ★★★ |
|
||||
| Bug Reports | ★★ |
|
||||
| Documentation | ★★ |
|
||||
|
||||
---
|
||||
|
||||
## Contributor Airdrop Program
|
||||
## Links
|
||||
|
||||
All contributions are tracked on GitHub. When NOFX generates revenue, contributors will receive airdrops based on their contributions.
|
||||
| | |
|
||||
|:--|:--|
|
||||
| Website | [nofxai.com](https://nofxai.com) |
|
||||
| Dashboard | [nofxos.ai/dashboard](https://nofxos.ai/dashboard) |
|
||||
| API Docs | [nofxos.ai/api-docs](https://nofxos.ai/api-docs) |
|
||||
| Telegram | [nofx_dev_community](https://t.me/nofx_dev_community) |
|
||||
| Twitter | [@nofx_official](https://x.com/nofx_official) |
|
||||
|
||||
**PRs that resolve [Pinned Issues](https://github.com/NoFxAiOS/nofx/issues) receive the HIGHEST rewards!**
|
||||
|
||||
| Contribution Type | Weight |
|
||||
|------------------|:------:|
|
||||
| **Pinned Issue PRs** | ⭐⭐⭐⭐⭐⭐ |
|
||||
| **Code Commits** (Merged PRs) | ⭐⭐⭐⭐⭐ |
|
||||
| **Bug Fixes** | ⭐⭐⭐⭐ |
|
||||
| **Feature Suggestions** | ⭐⭐⭐ |
|
||||
| **Bug Reports** | ⭐⭐ |
|
||||
| **Documentation** | ⭐⭐ |
|
||||
> **Risk Warning**: AI auto-trading carries significant risks. Recommended for learning/research or small amounts only.
|
||||
|
||||
---
|
||||
|
||||
## Contact
|
||||
## Sponsors
|
||||
|
||||
- **GitHub Issues**: [Submit an Issue](https://github.com/NoFxAiOS/nofx/issues)
|
||||
- **Developer Community**: [Telegram Group](https://t.me/nofx_dev_community)
|
||||
<a href="https://github.com/pjl914335852-ux"><img src="https://github.com/pjl914335852-ux.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/cat9999aaa"><img src="https://github.com/cat9999aaa.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/1733055465"><img src="https://github.com/1733055465.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/kolal2020"><img src="https://github.com/kolal2020.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/CyberFFarm"><img src="https://github.com/CyberFFarm.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/vip3001003"><img src="https://github.com/vip3001003.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/mrtluh"><img src="https://github.com/mrtluh.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/cpcp1117-source"><img src="https://github.com/cpcp1117-source.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/match-007"><img src="https://github.com/match-007.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/leiwuhen1715"><img src="https://github.com/leiwuhen1715.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/SHAOXIA1991"><img src="https://github.com/SHAOXIA1991.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
|
||||
---
|
||||
[Become a sponsor](https://github.com/sponsors/NoFxAiOS)
|
||||
|
||||
## Star History
|
||||
## License
|
||||
|
||||
[AGPL-3.0](LICENSE)
|
||||
|
||||
[](https://star-history.com/#NoFxAiOS/nofx&Date)
|
||||
|
||||
861
api/backtest.go
861
api/backtest.go
@@ -1,861 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"nofx/backtest"
|
||||
"nofx/logger"
|
||||
"nofx/market"
|
||||
"nofx/provider/nofxos"
|
||||
"nofx/store"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func (s *Server) registerBacktestRoutes(router *gin.RouterGroup) {
|
||||
router.POST("/start", s.handleBacktestStart)
|
||||
router.POST("/pause", s.handleBacktestPause)
|
||||
router.POST("/resume", s.handleBacktestResume)
|
||||
router.POST("/stop", s.handleBacktestStop)
|
||||
router.POST("/label", s.handleBacktestLabel)
|
||||
router.POST("/delete", s.handleBacktestDelete)
|
||||
router.GET("/status", s.handleBacktestStatus)
|
||||
router.GET("/runs", s.handleBacktestRuns)
|
||||
router.GET("/equity", s.handleBacktestEquity)
|
||||
router.GET("/trades", s.handleBacktestTrades)
|
||||
router.GET("/metrics", s.handleBacktestMetrics)
|
||||
router.GET("/trace", s.handleBacktestTrace)
|
||||
router.GET("/decisions", s.handleBacktestDecisions)
|
||||
router.GET("/export", s.handleBacktestExport)
|
||||
router.GET("/klines", s.handleBacktestKlines)
|
||||
}
|
||||
|
||||
type backtestStartRequest struct {
|
||||
Config backtest.BacktestConfig `json:"config"`
|
||||
}
|
||||
|
||||
type runIDRequest struct {
|
||||
RunID string `json:"run_id"`
|
||||
}
|
||||
|
||||
type labelRequest struct {
|
||||
RunID string `json:"run_id"`
|
||||
Label string `json:"label"`
|
||||
}
|
||||
|
||||
func (s *Server) handleBacktestStart(c *gin.Context) {
|
||||
if s.backtestManager == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "backtest manager unavailable"})
|
||||
return
|
||||
}
|
||||
|
||||
var req backtestStartRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
SafeBadRequest(c, "Invalid request parameters")
|
||||
return
|
||||
}
|
||||
|
||||
cfg := req.Config
|
||||
if cfg.RunID == "" {
|
||||
cfg.RunID = "bt_" + time.Now().UTC().Format("20060102_150405")
|
||||
}
|
||||
cfg.CustomPrompt = strings.TrimSpace(cfg.CustomPrompt)
|
||||
cfg.UserID = normalizeUserID(c.GetString("user_id"))
|
||||
|
||||
logger.Infof("📊 Backtest request - symbols from request: %v (count=%d), strategyID: %s",
|
||||
cfg.Symbols, len(cfg.Symbols), cfg.StrategyID)
|
||||
|
||||
// Load strategy config if strategy_id is provided
|
||||
if cfg.StrategyID != "" {
|
||||
strategy, err := s.store.Strategy().Get(cfg.UserID, cfg.StrategyID)
|
||||
if err != nil {
|
||||
SafeBadRequest(c, "Failed to load strategy")
|
||||
return
|
||||
}
|
||||
if strategy == nil {
|
||||
SafeBadRequest(c, "Strategy not found")
|
||||
return
|
||||
}
|
||||
var strategyConfig store.StrategyConfig
|
||||
if err := json.Unmarshal([]byte(strategy.Config), &strategyConfig); err != nil {
|
||||
SafeBadRequest(c, "Failed to parse strategy config")
|
||||
return
|
||||
}
|
||||
cfg.SetLoadedStrategy(&strategyConfig)
|
||||
logger.Infof("📊 Backtest using saved strategy: %s (%s)", strategy.Name, strategy.ID)
|
||||
logger.Infof("📊 Strategy coin source: type=%s, use_ai500=%v, use_oi_top=%v, static_coins=%v",
|
||||
strategyConfig.CoinSource.SourceType,
|
||||
strategyConfig.CoinSource.UseAI500,
|
||||
strategyConfig.CoinSource.UseOITop,
|
||||
strategyConfig.CoinSource.StaticCoins)
|
||||
|
||||
// If no symbols provided, fetch from strategy's coin source
|
||||
if len(cfg.Symbols) == 0 {
|
||||
symbols, err := s.resolveStrategyCoins(&strategyConfig)
|
||||
if err != nil {
|
||||
SafeBadRequest(c, "Failed to resolve coins from strategy")
|
||||
return
|
||||
}
|
||||
cfg.Symbols = symbols
|
||||
logger.Infof("📊 Resolved %d coins from strategy: %v", len(symbols), symbols)
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.hydrateBacktestAIConfig(&cfg); err != nil {
|
||||
SafeBadRequest(c, "Failed to configure AI model")
|
||||
return
|
||||
}
|
||||
|
||||
logger.Infof("📊 Starting backtest with final config: runID=%s, symbols=%v (count=%d), strategyID=%s",
|
||||
cfg.RunID, cfg.Symbols, len(cfg.Symbols), cfg.StrategyID)
|
||||
|
||||
runner, err := s.backtestManager.Start(context.Background(), cfg)
|
||||
if err != nil {
|
||||
SafeError(c, http.StatusBadRequest, "Failed to start backtest", err)
|
||||
return
|
||||
}
|
||||
|
||||
meta := runner.CurrentMetadata()
|
||||
c.JSON(http.StatusOK, meta)
|
||||
}
|
||||
|
||||
func (s *Server) handleBacktestPause(c *gin.Context) {
|
||||
s.handleBacktestControl(c, s.backtestManager.Pause)
|
||||
}
|
||||
|
||||
func (s *Server) handleBacktestResume(c *gin.Context) {
|
||||
s.handleBacktestControl(c, s.backtestManager.Resume)
|
||||
}
|
||||
|
||||
func (s *Server) handleBacktestStop(c *gin.Context) {
|
||||
s.handleBacktestControl(c, s.backtestManager.Stop)
|
||||
}
|
||||
|
||||
func (s *Server) handleBacktestControl(c *gin.Context, fn func(string) error) {
|
||||
if s.backtestManager == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "backtest manager unavailable"})
|
||||
return
|
||||
}
|
||||
userID := normalizeUserID(c.GetString("user_id"))
|
||||
|
||||
var req runIDRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
SafeBadRequest(c, "Invalid request parameters")
|
||||
return
|
||||
}
|
||||
if req.RunID == "" {
|
||||
SafeBadRequest(c, "run_id is required")
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := s.ensureBacktestRunOwnership(req.RunID, userID); writeBacktestAccessError(c, err) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := fn(req.RunID); err != nil {
|
||||
SafeError(c, http.StatusBadRequest, "Failed to execute backtest operation", err)
|
||||
return
|
||||
}
|
||||
|
||||
meta, err := s.backtestManager.LoadMetadata(req.RunID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "ok"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, meta)
|
||||
}
|
||||
|
||||
func (s *Server) handleBacktestLabel(c *gin.Context) {
|
||||
if s.backtestManager == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "backtest manager unavailable"})
|
||||
return
|
||||
}
|
||||
var req labelRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
SafeBadRequest(c, "Invalid request parameters")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.RunID) == "" {
|
||||
SafeBadRequest(c, "run_id is required")
|
||||
return
|
||||
}
|
||||
userID := normalizeUserID(c.GetString("user_id"))
|
||||
if _, err := s.ensureBacktestRunOwnership(req.RunID, userID); writeBacktestAccessError(c, err) {
|
||||
return
|
||||
}
|
||||
meta, err := s.backtestManager.UpdateLabel(req.RunID, req.Label)
|
||||
if err != nil {
|
||||
SafeInternalError(c, "Update backtest label", err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, meta)
|
||||
}
|
||||
|
||||
func (s *Server) handleBacktestDelete(c *gin.Context) {
|
||||
if s.backtestManager == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "backtest manager unavailable"})
|
||||
return
|
||||
}
|
||||
var req runIDRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
SafeBadRequest(c, "Invalid request parameters")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.RunID) == "" {
|
||||
SafeBadRequest(c, "run_id is required")
|
||||
return
|
||||
}
|
||||
userID := normalizeUserID(c.GetString("user_id"))
|
||||
if _, err := s.ensureBacktestRunOwnership(req.RunID, userID); writeBacktestAccessError(c, err) {
|
||||
return
|
||||
}
|
||||
if err := s.backtestManager.Delete(req.RunID); err != nil {
|
||||
SafeInternalError(c, "Delete backtest run", err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "deleted"})
|
||||
}
|
||||
|
||||
func (s *Server) handleBacktestStatus(c *gin.Context) {
|
||||
if s.backtestManager == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "backtest manager unavailable"})
|
||||
return
|
||||
}
|
||||
|
||||
userID := normalizeUserID(c.GetString("user_id"))
|
||||
|
||||
runID := c.Query("run_id")
|
||||
if runID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "run_id is required"})
|
||||
return
|
||||
}
|
||||
|
||||
meta, err := s.ensureBacktestRunOwnership(runID, userID)
|
||||
if writeBacktestAccessError(c, err) {
|
||||
return
|
||||
}
|
||||
|
||||
status := s.backtestManager.Status(runID)
|
||||
if status != nil {
|
||||
c.JSON(http.StatusOK, status)
|
||||
return
|
||||
}
|
||||
|
||||
payload := backtest.StatusPayload{
|
||||
RunID: meta.RunID,
|
||||
State: meta.State,
|
||||
ProgressPct: meta.Summary.ProgressPct,
|
||||
ProcessedBars: meta.Summary.ProcessedBars,
|
||||
CurrentTime: 0,
|
||||
DecisionCycle: meta.Summary.ProcessedBars,
|
||||
Equity: meta.Summary.EquityLast,
|
||||
UnrealizedPnL: 0,
|
||||
RealizedPnL: 0,
|
||||
Note: meta.Summary.LiquidationNote,
|
||||
LastUpdatedIso: meta.UpdatedAt.Format(time.RFC3339),
|
||||
}
|
||||
c.JSON(http.StatusOK, payload)
|
||||
}
|
||||
|
||||
func (s *Server) handleBacktestRuns(c *gin.Context) {
|
||||
if s.backtestManager == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "backtest manager unavailable"})
|
||||
return
|
||||
}
|
||||
rawUserID := strings.TrimSpace(c.GetString("user_id"))
|
||||
userID := normalizeUserID(rawUserID)
|
||||
filterByUser := rawUserID != "" && rawUserID != "admin"
|
||||
|
||||
metas, err := s.backtestManager.ListRuns()
|
||||
if err != nil {
|
||||
SafeInternalError(c, "List backtest runs", err)
|
||||
return
|
||||
}
|
||||
stateFilter := strings.ToLower(strings.TrimSpace(c.Query("state")))
|
||||
search := strings.ToLower(strings.TrimSpace(c.Query("search")))
|
||||
limit := queryInt(c, "limit", 50)
|
||||
offset := queryInt(c, "offset", 0)
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
|
||||
filtered := make([]*backtest.RunMetadata, 0, len(metas))
|
||||
for _, meta := range metas {
|
||||
if stateFilter != "" && !strings.EqualFold(string(meta.State), stateFilter) {
|
||||
continue
|
||||
}
|
||||
if search != "" {
|
||||
target := strings.ToLower(meta.RunID + " " + meta.Summary.DecisionTF + " " + meta.Label + " " + meta.LastError)
|
||||
if !strings.Contains(target, search) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if filterByUser {
|
||||
owner := strings.TrimSpace(meta.UserID)
|
||||
if owner != "" && owner != userID {
|
||||
continue
|
||||
}
|
||||
}
|
||||
filtered = append(filtered, meta)
|
||||
}
|
||||
|
||||
total := len(filtered)
|
||||
start := offset
|
||||
if start > total {
|
||||
start = total
|
||||
}
|
||||
end := offset + limit
|
||||
if end > total {
|
||||
end = total
|
||||
}
|
||||
page := filtered[start:end]
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"total": total,
|
||||
"items": page,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleBacktestEquity(c *gin.Context) {
|
||||
if s.backtestManager == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "backtest manager unavailable"})
|
||||
return
|
||||
}
|
||||
|
||||
userID := normalizeUserID(c.GetString("user_id"))
|
||||
|
||||
runID := c.Query("run_id")
|
||||
if runID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "run_id is required"})
|
||||
return
|
||||
}
|
||||
if _, err := s.ensureBacktestRunOwnership(runID, userID); writeBacktestAccessError(c, err) {
|
||||
return
|
||||
}
|
||||
timeframe := c.Query("tf")
|
||||
limit := queryInt(c, "limit", 1000)
|
||||
|
||||
points, err := s.backtestManager.LoadEquity(runID, timeframe, limit)
|
||||
if err != nil {
|
||||
SafeError(c, http.StatusBadRequest, "Failed to load equity data", err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, points)
|
||||
}
|
||||
|
||||
func (s *Server) handleBacktestTrades(c *gin.Context) {
|
||||
if s.backtestManager == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "backtest manager unavailable"})
|
||||
return
|
||||
}
|
||||
|
||||
userID := normalizeUserID(c.GetString("user_id"))
|
||||
|
||||
runID := c.Query("run_id")
|
||||
if runID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "run_id is required"})
|
||||
return
|
||||
}
|
||||
if _, err := s.ensureBacktestRunOwnership(runID, userID); writeBacktestAccessError(c, err) {
|
||||
return
|
||||
}
|
||||
limit := queryInt(c, "limit", 1000)
|
||||
|
||||
events, err := s.backtestManager.LoadTrades(runID, limit)
|
||||
if err != nil {
|
||||
SafeError(c, http.StatusBadRequest, "Failed to load trades", err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, events)
|
||||
}
|
||||
|
||||
func (s *Server) handleBacktestMetrics(c *gin.Context) {
|
||||
if s.backtestManager == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "backtest manager unavailable"})
|
||||
return
|
||||
}
|
||||
|
||||
userID := normalizeUserID(c.GetString("user_id"))
|
||||
|
||||
runID := c.Query("run_id")
|
||||
if runID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "run_id is required"})
|
||||
return
|
||||
}
|
||||
if _, err := s.ensureBacktestRunOwnership(runID, userID); writeBacktestAccessError(c, err) {
|
||||
return
|
||||
}
|
||||
|
||||
metrics, err := s.backtestManager.GetMetrics(runID)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) || errors.Is(err, os.ErrNotExist) {
|
||||
c.JSON(http.StatusAccepted, gin.H{"error": "metrics not ready yet"})
|
||||
return
|
||||
}
|
||||
SafeError(c, http.StatusBadRequest, "Failed to load metrics", err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, metrics)
|
||||
}
|
||||
|
||||
func (s *Server) handleBacktestTrace(c *gin.Context) {
|
||||
if s.backtestManager == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "backtest manager unavailable"})
|
||||
return
|
||||
}
|
||||
userID := normalizeUserID(c.GetString("user_id"))
|
||||
runID := c.Query("run_id")
|
||||
if runID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "run_id is required"})
|
||||
return
|
||||
}
|
||||
if _, err := s.ensureBacktestRunOwnership(runID, userID); writeBacktestAccessError(c, err) {
|
||||
return
|
||||
}
|
||||
cycle := queryInt(c, "cycle", 0)
|
||||
record, err := s.backtestManager.GetTrace(runID, cycle)
|
||||
if err != nil {
|
||||
SafeNotFound(c, "Trace record")
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, record)
|
||||
}
|
||||
|
||||
func (s *Server) handleBacktestDecisions(c *gin.Context) {
|
||||
if s.backtestManager == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "backtest manager unavailable"})
|
||||
return
|
||||
}
|
||||
userID := normalizeUserID(c.GetString("user_id"))
|
||||
runID := c.Query("run_id")
|
||||
if runID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "run_id is required"})
|
||||
return
|
||||
}
|
||||
if _, err := s.ensureBacktestRunOwnership(runID, userID); writeBacktestAccessError(c, err) {
|
||||
return
|
||||
}
|
||||
limit := queryInt(c, "limit", 20)
|
||||
offset := queryInt(c, "offset", 0)
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
if limit > 200 {
|
||||
limit = 200
|
||||
}
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
|
||||
records, err := backtest.LoadDecisionRecords(runID, limit, offset)
|
||||
if err != nil {
|
||||
SafeInternalError(c, "Load decision records", err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, records)
|
||||
}
|
||||
|
||||
func (s *Server) handleBacktestExport(c *gin.Context) {
|
||||
if s.backtestManager == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "backtest manager unavailable"})
|
||||
return
|
||||
}
|
||||
userID := normalizeUserID(c.GetString("user_id"))
|
||||
runID := c.Query("run_id")
|
||||
if runID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "run_id is required"})
|
||||
return
|
||||
}
|
||||
if _, err := s.ensureBacktestRunOwnership(runID, userID); writeBacktestAccessError(c, err) {
|
||||
return
|
||||
}
|
||||
path, err := s.backtestManager.ExportRun(runID)
|
||||
if err != nil {
|
||||
SafeError(c, http.StatusBadRequest, "Failed to export backtest", err)
|
||||
return
|
||||
}
|
||||
defer os.Remove(path)
|
||||
filename := fmt.Sprintf("%s_export.zip", runID)
|
||||
c.FileAttachment(path, filename)
|
||||
}
|
||||
|
||||
func (s *Server) handleBacktestKlines(c *gin.Context) {
|
||||
if s.backtestManager == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "backtest manager unavailable"})
|
||||
return
|
||||
}
|
||||
userID := normalizeUserID(c.GetString("user_id"))
|
||||
runID := c.Query("run_id")
|
||||
symbol := c.Query("symbol")
|
||||
timeframe := c.Query("timeframe")
|
||||
|
||||
if runID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "run_id is required"})
|
||||
return
|
||||
}
|
||||
if symbol == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "symbol is required"})
|
||||
return
|
||||
}
|
||||
|
||||
meta, err := s.ensureBacktestRunOwnership(runID, userID)
|
||||
if writeBacktestAccessError(c, err) {
|
||||
return
|
||||
}
|
||||
|
||||
// Load config to get time range
|
||||
cfg, err := backtest.LoadConfig(runID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "failed to load backtest config"})
|
||||
return
|
||||
}
|
||||
|
||||
// Use decision timeframe if not specified
|
||||
if timeframe == "" {
|
||||
timeframe = cfg.DecisionTimeframe
|
||||
if timeframe == "" {
|
||||
timeframe = "15m"
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch klines for the backtest time range
|
||||
startTime := time.Unix(cfg.StartTS, 0)
|
||||
endTime := time.Unix(cfg.EndTS, 0)
|
||||
|
||||
klines, err := market.GetKlinesRange(symbol, timeframe, startTime, endTime)
|
||||
if err != nil {
|
||||
SafeInternalError(c, "Fetch klines", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Convert to response format
|
||||
type KlineResponse struct {
|
||||
Time int64 `json:"time"`
|
||||
Open float64 `json:"open"`
|
||||
High float64 `json:"high"`
|
||||
Low float64 `json:"low"`
|
||||
Close float64 `json:"close"`
|
||||
Volume float64 `json:"volume"`
|
||||
}
|
||||
|
||||
result := make([]KlineResponse, len(klines))
|
||||
for i, k := range klines {
|
||||
result[i] = KlineResponse{
|
||||
Time: k.OpenTime / 1000, // Convert to seconds for lightweight-charts
|
||||
Open: k.Open,
|
||||
High: k.High,
|
||||
Low: k.Low,
|
||||
Close: k.Close,
|
||||
Volume: k.Volume,
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"symbol": symbol,
|
||||
"timeframe": timeframe,
|
||||
"start_ts": cfg.StartTS,
|
||||
"end_ts": cfg.EndTS,
|
||||
"count": len(result),
|
||||
"klines": result,
|
||||
"run_id": meta.RunID,
|
||||
})
|
||||
}
|
||||
|
||||
func queryInt(c *gin.Context, name string, fallback int) int {
|
||||
if value := c.Query(name); value != "" {
|
||||
if v, err := strconv.Atoi(value); err == nil {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
var errBacktestForbidden = errors.New("backtest run forbidden")
|
||||
|
||||
func normalizeUserID(id string) string {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
return "default"
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func (s *Server) ensureBacktestRunOwnership(runID, userID string) (*backtest.RunMetadata, error) {
|
||||
if s.backtestManager == nil {
|
||||
return nil, fmt.Errorf("backtest manager unavailable")
|
||||
}
|
||||
meta, err := s.backtestManager.LoadMetadata(runID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if userID == "" || userID == "admin" {
|
||||
return meta, nil
|
||||
}
|
||||
owner := strings.TrimSpace(meta.UserID)
|
||||
if owner == "" {
|
||||
return meta, nil
|
||||
}
|
||||
if owner != userID {
|
||||
return nil, errBacktestForbidden
|
||||
}
|
||||
return meta, nil
|
||||
}
|
||||
|
||||
func writeBacktestAccessError(c *gin.Context, err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
switch {
|
||||
case errors.Is(err, errBacktestForbidden):
|
||||
SafeForbidden(c, "No permission to access this backtest task")
|
||||
case errors.Is(err, os.ErrNotExist), errors.Is(err, sql.ErrNoRows):
|
||||
SafeNotFound(c, "Backtest task")
|
||||
default:
|
||||
SafeInternalError(c, "Access backtest", err)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// resolveStrategyCoins fetches coins based on strategy's coin source configuration
|
||||
func (s *Server) resolveStrategyCoins(strategyConfig *store.StrategyConfig) ([]string, error) {
|
||||
if strategyConfig == nil {
|
||||
return nil, fmt.Errorf("strategy config is nil")
|
||||
}
|
||||
|
||||
coinSource := strategyConfig.CoinSource
|
||||
var symbols []string
|
||||
symbolSet := make(map[string]bool)
|
||||
|
||||
// Handle empty source_type - check flags for backward compatibility
|
||||
sourceType := coinSource.SourceType
|
||||
if sourceType == "" {
|
||||
if coinSource.UseAI500 && coinSource.UseOITop {
|
||||
sourceType = "mixed"
|
||||
} else if coinSource.UseAI500 {
|
||||
sourceType = "ai500"
|
||||
} else if coinSource.UseOITop {
|
||||
sourceType = "oi_top"
|
||||
} else if len(coinSource.StaticCoins) > 0 {
|
||||
sourceType = "static"
|
||||
} else {
|
||||
return nil, fmt.Errorf("strategy has no coin source configured")
|
||||
}
|
||||
logger.Infof("📊 Inferred source_type=%s from flags", sourceType)
|
||||
}
|
||||
|
||||
switch sourceType {
|
||||
case "static":
|
||||
for _, sym := range coinSource.StaticCoins {
|
||||
sym = market.Normalize(sym)
|
||||
if !symbolSet[sym] {
|
||||
symbols = append(symbols, sym)
|
||||
symbolSet[sym] = true
|
||||
}
|
||||
}
|
||||
|
||||
case "ai500":
|
||||
limit := coinSource.AI500Limit
|
||||
if limit <= 0 {
|
||||
limit = 30
|
||||
}
|
||||
logger.Infof("📊 Fetching AI500 coins with limit=%d", limit)
|
||||
coins, err := nofxos.DefaultClient().GetTopRatedCoins(limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get AI500 coins: %w", err)
|
||||
}
|
||||
logger.Infof("📊 Got %d coins from AI500: %v", len(coins), coins)
|
||||
for _, sym := range coins {
|
||||
sym = market.Normalize(sym)
|
||||
if !symbolSet[sym] {
|
||||
symbols = append(symbols, sym)
|
||||
symbolSet[sym] = true
|
||||
}
|
||||
}
|
||||
|
||||
case "oi_top":
|
||||
coins, err := nofxos.DefaultClient().GetOITopSymbols()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get OI Top coins: %w", err)
|
||||
}
|
||||
limit := coinSource.OITopLimit
|
||||
if limit <= 0 || limit > len(coins) {
|
||||
limit = len(coins)
|
||||
}
|
||||
for i, sym := range coins {
|
||||
if i >= limit {
|
||||
break
|
||||
}
|
||||
sym = market.Normalize(sym)
|
||||
if !symbolSet[sym] {
|
||||
symbols = append(symbols, sym)
|
||||
symbolSet[sym] = true
|
||||
}
|
||||
}
|
||||
|
||||
case "mixed":
|
||||
// Get from AI500
|
||||
if coinSource.UseAI500 {
|
||||
limit := coinSource.AI500Limit
|
||||
if limit <= 0 {
|
||||
limit = 30
|
||||
}
|
||||
coins, err := nofxos.DefaultClient().GetTopRatedCoins(limit)
|
||||
if err != nil {
|
||||
logger.Warnf("Failed to get AI500 coins: %v", err)
|
||||
} else {
|
||||
for _, sym := range coins {
|
||||
sym = market.Normalize(sym)
|
||||
if !symbolSet[sym] {
|
||||
symbols = append(symbols, sym)
|
||||
symbolSet[sym] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get from OI Top
|
||||
if coinSource.UseOITop {
|
||||
coins, err := nofxos.DefaultClient().GetOITopSymbols()
|
||||
if err != nil {
|
||||
logger.Warnf("Failed to get OI Top coins: %v", err)
|
||||
} else {
|
||||
limit := coinSource.OITopLimit
|
||||
if limit <= 0 || limit > len(coins) {
|
||||
limit = len(coins)
|
||||
}
|
||||
for i, sym := range coins {
|
||||
if i >= limit {
|
||||
break
|
||||
}
|
||||
sym = market.Normalize(sym)
|
||||
if !symbolSet[sym] {
|
||||
symbols = append(symbols, sym)
|
||||
symbolSet[sym] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add static coins
|
||||
for _, sym := range coinSource.StaticCoins {
|
||||
sym = market.Normalize(sym)
|
||||
if !symbolSet[sym] {
|
||||
symbols = append(symbols, sym)
|
||||
symbolSet[sym] = true
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown coin source type: %s", sourceType)
|
||||
}
|
||||
|
||||
if len(symbols) == 0 {
|
||||
return nil, fmt.Errorf("no coins resolved from strategy")
|
||||
}
|
||||
|
||||
logger.Infof("📊 Final resolved symbols: %d coins - %v", len(symbols), symbols)
|
||||
return symbols, nil
|
||||
}
|
||||
|
||||
func (s *Server) resolveBacktestAIConfig(cfg *backtest.BacktestConfig, userID string) error {
|
||||
if cfg == nil {
|
||||
return fmt.Errorf("config is nil")
|
||||
}
|
||||
if s.store == nil {
|
||||
return fmt.Errorf("System database not ready, cannot load AI model configuration")
|
||||
}
|
||||
|
||||
cfg.UserID = normalizeUserID(userID)
|
||||
|
||||
return s.hydrateBacktestAIConfig(cfg)
|
||||
}
|
||||
|
||||
func (s *Server) hydrateBacktestAIConfig(cfg *backtest.BacktestConfig) error {
|
||||
if cfg == nil {
|
||||
return fmt.Errorf("config is nil")
|
||||
}
|
||||
if s.store == nil {
|
||||
return fmt.Errorf("System database not ready, cannot load AI model configuration")
|
||||
}
|
||||
|
||||
cfg.UserID = normalizeUserID(cfg.UserID)
|
||||
modelID := strings.TrimSpace(cfg.AIModelID)
|
||||
|
||||
var (
|
||||
model *store.AIModel
|
||||
err error
|
||||
)
|
||||
|
||||
if modelID != "" {
|
||||
model, err = s.store.AIModel().Get(cfg.UserID, modelID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to load AI model: %w", err)
|
||||
}
|
||||
} else {
|
||||
model, err = s.store.AIModel().GetDefault(cfg.UserID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("No available AI model found: %w", err)
|
||||
}
|
||||
cfg.AIModelID = model.ID
|
||||
}
|
||||
|
||||
if !model.Enabled {
|
||||
return fmt.Errorf("AI model %s is not enabled yet", model.Name)
|
||||
}
|
||||
|
||||
apiKey := strings.TrimSpace(string(model.APIKey))
|
||||
if apiKey == "" {
|
||||
return fmt.Errorf("AI model %s is missing API Key, please configure it in the system first", model.Name)
|
||||
}
|
||||
|
||||
provider := strings.ToLower(strings.TrimSpace(model.Provider))
|
||||
// Ensure provider is never empty or "inherit" - infer from model name if needed
|
||||
if provider == "" || provider == "inherit" {
|
||||
modelNameLower := strings.ToLower(model.Name)
|
||||
if strings.Contains(modelNameLower, "claude") || strings.Contains(modelNameLower, "anthropic") {
|
||||
provider = "anthropic"
|
||||
} else if strings.Contains(modelNameLower, "gpt") || strings.Contains(modelNameLower, "openai") {
|
||||
provider = "openai"
|
||||
} else if strings.Contains(modelNameLower, "gemini") || strings.Contains(modelNameLower, "google") {
|
||||
provider = "google"
|
||||
} else if strings.Contains(modelNameLower, "deepseek") {
|
||||
provider = "deepseek"
|
||||
} else if model.CustomAPIURL != "" {
|
||||
provider = "custom"
|
||||
} else {
|
||||
provider = "openai" // default fallback
|
||||
}
|
||||
logger.Infof("📊 Inferred AI provider '%s' from model name '%s'", provider, model.Name)
|
||||
}
|
||||
cfg.AICfg.Provider = provider
|
||||
cfg.AICfg.APIKey = apiKey
|
||||
cfg.AICfg.BaseURL = strings.TrimSpace(model.CustomAPIURL)
|
||||
modelName := strings.TrimSpace(model.CustomModelName)
|
||||
if cfg.AICfg.Model == "" {
|
||||
cfg.AICfg.Model = modelName
|
||||
}
|
||||
cfg.AICfg.Model = strings.TrimSpace(cfg.AICfg.Model)
|
||||
|
||||
if cfg.AICfg.Provider == "custom" {
|
||||
if cfg.AICfg.BaseURL == "" {
|
||||
return fmt.Errorf("Custom AI model requires API URL configuration")
|
||||
}
|
||||
if cfg.AICfg.Model == "" {
|
||||
return fmt.Errorf("Custom AI model requires model name configuration")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
635
api/debate.go
635
api/debate.go
@@ -1,635 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync"
|
||||
|
||||
"nofx/debate"
|
||||
"nofx/logger"
|
||||
"nofx/provider/nofxos"
|
||||
"nofx/store"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// DebateHandler handles debate-related API requests
|
||||
type DebateHandler struct {
|
||||
debateStore *store.DebateStore
|
||||
strategyStore *store.StrategyStore
|
||||
aiModelStore *store.AIModelStore
|
||||
engine *debate.DebateEngine
|
||||
|
||||
// Trader manager for execution
|
||||
traderManager DebateTraderManager
|
||||
|
||||
// SSE subscribers
|
||||
subscribers map[string]map[chan []byte]bool // sessionID -> channels
|
||||
subscribersMu sync.RWMutex
|
||||
}
|
||||
|
||||
// DebateTraderManager interface for getting trader executors
|
||||
type DebateTraderManager interface {
|
||||
GetTraderExecutor(traderID string) (debate.TraderExecutor, error)
|
||||
}
|
||||
|
||||
// NewDebateHandler creates a new DebateHandler
|
||||
func NewDebateHandler(debateStore *store.DebateStore, strategyStore *store.StrategyStore, aiModelStore *store.AIModelStore) *DebateHandler {
|
||||
handler := &DebateHandler{
|
||||
debateStore: debateStore,
|
||||
strategyStore: strategyStore,
|
||||
aiModelStore: aiModelStore,
|
||||
subscribers: make(map[string]map[chan []byte]bool),
|
||||
}
|
||||
|
||||
// Create debate engine with event callbacks
|
||||
handler.engine = debate.NewDebateEngine(debateStore, strategyStore, aiModelStore)
|
||||
handler.engine.OnRoundStart = handler.broadcastRoundStart
|
||||
handler.engine.OnMessage = handler.broadcastMessage
|
||||
handler.engine.OnRoundEnd = handler.broadcastRoundEnd
|
||||
handler.engine.OnVote = handler.broadcastVote
|
||||
handler.engine.OnConsensus = handler.broadcastConsensus
|
||||
handler.engine.OnError = handler.broadcastError
|
||||
|
||||
return handler
|
||||
}
|
||||
|
||||
// CreateDebateRequest represents a request to create a new debate
|
||||
type CreateDebateRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
StrategyID string `json:"strategy_id" binding:"required"`
|
||||
Symbol string `json:"symbol"` // Optional: auto-selected based on strategy if empty
|
||||
MaxRounds int `json:"max_rounds"`
|
||||
IntervalMinutes int `json:"interval_minutes"`
|
||||
PromptVariant string `json:"prompt_variant"`
|
||||
AutoExecute bool `json:"auto_execute"`
|
||||
TraderID string `json:"trader_id"`
|
||||
Participants []ParticipantConfig `json:"participants" binding:"required,min=2"`
|
||||
// OI Ranking data options
|
||||
EnableOIRanking bool `json:"enable_oi_ranking"` // Whether to include OI ranking data
|
||||
OIRankingLimit int `json:"oi_ranking_limit"` // Number of OI ranking entries (default 10)
|
||||
OIDuration string `json:"oi_duration"` // Duration for OI data (1h, 4h, 24h, etc.)
|
||||
}
|
||||
|
||||
// ParticipantConfig represents a participant configuration
|
||||
type ParticipantConfig struct {
|
||||
AIModelID string `json:"ai_model_id" binding:"required"`
|
||||
Personality string `json:"personality" binding:"required"`
|
||||
}
|
||||
|
||||
// HandleListDebates lists all debates for a user
|
||||
func (h *DebateHandler) HandleListDebates(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
if userID == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
||||
return
|
||||
}
|
||||
|
||||
sessions, err := h.debateStore.GetSessionsByUser(userID)
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to get debates for user %s: %v", userID, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get debates"})
|
||||
return
|
||||
}
|
||||
|
||||
// Return empty array instead of null
|
||||
if sessions == nil {
|
||||
sessions = []*store.DebateSession{}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, sessions)
|
||||
}
|
||||
|
||||
// HandleGetDebate gets a specific debate with all details
|
||||
func (h *DebateHandler) HandleGetDebate(c *gin.Context) {
|
||||
debateID := c.Param("id")
|
||||
userID := c.GetString("user_id")
|
||||
|
||||
session, err := h.debateStore.GetSessionWithDetails(debateID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "debate not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Check ownership
|
||||
if session.UserID != userID {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "access denied"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, session)
|
||||
}
|
||||
|
||||
// HandleCreateDebate creates a new debate
|
||||
func (h *DebateHandler) HandleCreateDebate(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
if userID == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
||||
return
|
||||
}
|
||||
|
||||
var req CreateDebateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
SafeBadRequest(c, "Invalid request parameters")
|
||||
return
|
||||
}
|
||||
|
||||
// Validate strategy exists
|
||||
strategy, err := h.strategyStore.Get(userID, req.StrategyID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "strategy not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate strategy belongs to user or is default
|
||||
if strategy.UserID != userID && !strategy.IsDefault {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "strategy access denied"})
|
||||
return
|
||||
}
|
||||
|
||||
// Auto-select symbol based on strategy if not provided
|
||||
if req.Symbol == "" {
|
||||
req.Symbol = "BTCUSDT" // default fallback
|
||||
if strategyConfig, err := strategy.ParseConfig(); err == nil {
|
||||
coinSource := strategyConfig.CoinSource
|
||||
switch coinSource.SourceType {
|
||||
case "static":
|
||||
if len(coinSource.StaticCoins) > 0 {
|
||||
req.Symbol = coinSource.StaticCoins[0]
|
||||
}
|
||||
case "ai500":
|
||||
// Fetch from AI500 API
|
||||
if coins, err := nofxos.DefaultClient().GetTopRatedCoins(1); err == nil && len(coins) > 0 {
|
||||
req.Symbol = coins[0]
|
||||
logger.Infof("Fetched coin from AI500 API: %s", req.Symbol)
|
||||
}
|
||||
case "oi_top":
|
||||
// Fetch from OI top API
|
||||
if coins, err := nofxos.DefaultClient().GetOITopSymbols(); err == nil && len(coins) > 0 {
|
||||
req.Symbol = coins[0]
|
||||
logger.Infof("Fetched coin from OI Top API: %s", req.Symbol)
|
||||
}
|
||||
case "mixed":
|
||||
// Try AI500 first, then OI top
|
||||
if coinSource.UseAI500 {
|
||||
if coins, err := nofxos.DefaultClient().GetTopRatedCoins(1); err == nil && len(coins) > 0 {
|
||||
req.Symbol = coins[0]
|
||||
logger.Infof("Fetched coin from AI500 API (mixed): %s", req.Symbol)
|
||||
}
|
||||
} else if coinSource.UseOITop {
|
||||
if coins, err := nofxos.DefaultClient().GetOITopSymbols(); err == nil && len(coins) > 0 {
|
||||
req.Symbol = coins[0]
|
||||
logger.Infof("Fetched coin from OI Top API (mixed): %s", req.Symbol)
|
||||
}
|
||||
}
|
||||
}
|
||||
logger.Infof("Auto-selected symbol %s for debate based on strategy %s (source_type=%s)",
|
||||
req.Symbol, strategy.Name, coinSource.SourceType)
|
||||
}
|
||||
}
|
||||
|
||||
// Set defaults
|
||||
if req.MaxRounds <= 0 || req.MaxRounds > 5 {
|
||||
req.MaxRounds = 3
|
||||
}
|
||||
if req.IntervalMinutes <= 0 {
|
||||
req.IntervalMinutes = 5
|
||||
}
|
||||
if req.PromptVariant == "" {
|
||||
req.PromptVariant = "balanced"
|
||||
}
|
||||
|
||||
// Create session
|
||||
session := &store.DebateSession{
|
||||
UserID: userID,
|
||||
Name: req.Name,
|
||||
StrategyID: req.StrategyID,
|
||||
Symbol: req.Symbol,
|
||||
MaxRounds: req.MaxRounds,
|
||||
IntervalMinutes: req.IntervalMinutes,
|
||||
PromptVariant: req.PromptVariant,
|
||||
AutoExecute: req.AutoExecute,
|
||||
TraderID: req.TraderID,
|
||||
EnableOIRanking: req.EnableOIRanking,
|
||||
OIRankingLimit: req.OIRankingLimit,
|
||||
OIDuration: req.OIDuration,
|
||||
}
|
||||
|
||||
if err := h.debateStore.CreateSession(session); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create debate"})
|
||||
return
|
||||
}
|
||||
|
||||
// Add participants
|
||||
for i, p := range req.Participants {
|
||||
// Validate AI model exists and belongs to user
|
||||
aiModel, err := h.aiModelStore.GetByID(p.AIModelID)
|
||||
if err != nil {
|
||||
logger.Warnf("AI model not found: %s", p.AIModelID)
|
||||
continue
|
||||
}
|
||||
if aiModel.UserID != userID {
|
||||
logger.Warnf("AI model %s does not belong to user", p.AIModelID)
|
||||
continue
|
||||
}
|
||||
|
||||
// Validate personality
|
||||
personality := store.DebatePersonality(p.Personality)
|
||||
if _, ok := store.PersonalityColors[personality]; !ok {
|
||||
personality = store.PersonalityAnalyst
|
||||
}
|
||||
|
||||
participant := &store.DebateParticipant{
|
||||
SessionID: session.ID,
|
||||
AIModelID: p.AIModelID,
|
||||
AIModelName: aiModel.Name,
|
||||
Provider: aiModel.Provider,
|
||||
Personality: personality,
|
||||
Color: store.PersonalityColors[personality],
|
||||
SpeakOrder: i,
|
||||
}
|
||||
|
||||
if err := h.debateStore.AddParticipant(participant); err != nil {
|
||||
logger.Errorf("Failed to add participant: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Get full session with participants
|
||||
fullSession, _ := h.debateStore.GetSessionWithDetails(session.ID)
|
||||
|
||||
c.JSON(http.StatusCreated, fullSession)
|
||||
}
|
||||
|
||||
// HandleStartDebate starts a debate
|
||||
func (h *DebateHandler) HandleStartDebate(c *gin.Context) {
|
||||
debateID := c.Param("id")
|
||||
userID := c.GetString("user_id")
|
||||
|
||||
session, err := h.debateStore.GetSession(debateID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "debate not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if session.UserID != userID {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "access denied"})
|
||||
return
|
||||
}
|
||||
|
||||
if session.Status != store.DebateStatusPending {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "debate is not in pending status"})
|
||||
return
|
||||
}
|
||||
|
||||
// Start debate asynchronously
|
||||
if err := h.engine.StartDebate(debateID); err != nil {
|
||||
SafeInternalError(c, "Start debate", err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "debate started", "id": debateID})
|
||||
}
|
||||
|
||||
// HandleCancelDebate cancels a running debate
|
||||
func (h *DebateHandler) HandleCancelDebate(c *gin.Context) {
|
||||
debateID := c.Param("id")
|
||||
userID := c.GetString("user_id")
|
||||
|
||||
session, err := h.debateStore.GetSession(debateID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "debate not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if session.UserID != userID {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "access denied"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.engine.CancelDebate(debateID); err != nil {
|
||||
SafeInternalError(c, "Cancel debate", err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "debate cancelled"})
|
||||
}
|
||||
|
||||
// HandleDeleteDebate deletes a debate
|
||||
func (h *DebateHandler) HandleDeleteDebate(c *gin.Context) {
|
||||
debateID := c.Param("id")
|
||||
userID := c.GetString("user_id")
|
||||
|
||||
session, err := h.debateStore.GetSession(debateID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "debate not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if session.UserID != userID {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "access denied"})
|
||||
return
|
||||
}
|
||||
|
||||
// Don't allow deleting running debates
|
||||
if session.Status == store.DebateStatusRunning || session.Status == store.DebateStatusVoting {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "cannot delete running debate"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.debateStore.DeleteSession(debateID); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete debate"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "debate deleted"})
|
||||
}
|
||||
|
||||
// HandleGetMessages gets all messages for a debate
|
||||
func (h *DebateHandler) HandleGetMessages(c *gin.Context) {
|
||||
debateID := c.Param("id")
|
||||
userID := c.GetString("user_id")
|
||||
|
||||
session, err := h.debateStore.GetSession(debateID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "debate not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if session.UserID != userID {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "access denied"})
|
||||
return
|
||||
}
|
||||
|
||||
messages, err := h.debateStore.GetMessages(debateID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get messages"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, messages)
|
||||
}
|
||||
|
||||
// HandleGetVotes gets all votes for a debate
|
||||
func (h *DebateHandler) HandleGetVotes(c *gin.Context) {
|
||||
debateID := c.Param("id")
|
||||
userID := c.GetString("user_id")
|
||||
|
||||
session, err := h.debateStore.GetSession(debateID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "debate not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if session.UserID != userID {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "access denied"})
|
||||
return
|
||||
}
|
||||
|
||||
votes, err := h.debateStore.GetVotes(debateID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get votes"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, votes)
|
||||
}
|
||||
|
||||
// HandleDebateStream handles SSE streaming for live debate updates
|
||||
func (h *DebateHandler) HandleDebateStream(c *gin.Context) {
|
||||
debateID := c.Param("id")
|
||||
userID := c.GetString("user_id")
|
||||
|
||||
session, err := h.debateStore.GetSession(debateID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "debate not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if session.UserID != userID {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "access denied"})
|
||||
return
|
||||
}
|
||||
|
||||
// Set SSE headers
|
||||
c.Header("Content-Type", "text/event-stream")
|
||||
c.Header("Cache-Control", "no-cache")
|
||||
c.Header("Connection", "keep-alive")
|
||||
c.Header("Transfer-Encoding", "chunked")
|
||||
|
||||
// Create channel for this subscriber
|
||||
ch := make(chan []byte, 100)
|
||||
h.addSubscriber(debateID, ch)
|
||||
defer h.removeSubscriber(debateID, ch)
|
||||
|
||||
// Send initial state
|
||||
initialState, _ := h.debateStore.GetSessionWithDetails(debateID)
|
||||
initialData, _ := json.Marshal(map[string]interface{}{
|
||||
"event": "initial",
|
||||
"data": initialState,
|
||||
})
|
||||
c.Writer.Write([]byte(fmt.Sprintf("event: initial\ndata: %s\n\n", initialData)))
|
||||
c.Writer.Flush()
|
||||
|
||||
// Stream updates
|
||||
clientGone := c.Request.Context().Done()
|
||||
for {
|
||||
select {
|
||||
case <-clientGone:
|
||||
return
|
||||
case msg := <-ch:
|
||||
c.Writer.Write(msg)
|
||||
c.Writer.Flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SetTraderManager sets the trader manager for executing trades
|
||||
func (h *DebateHandler) SetTraderManager(tm DebateTraderManager) {
|
||||
h.traderManager = tm
|
||||
}
|
||||
|
||||
// ExecuteDebateRequest represents a request to execute a debate's consensus
|
||||
type ExecuteDebateRequest struct {
|
||||
TraderID string `json:"trader_id" binding:"required"`
|
||||
}
|
||||
|
||||
// HandleExecuteDebate executes the consensus decision from a completed debate
|
||||
func (h *DebateHandler) HandleExecuteDebate(c *gin.Context) {
|
||||
debateID := c.Param("id")
|
||||
userID := c.GetString("user_id")
|
||||
|
||||
// Check trader manager is available
|
||||
if h.traderManager == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "trading service not available"})
|
||||
return
|
||||
}
|
||||
|
||||
// Get debate session
|
||||
session, err := h.debateStore.GetSession(debateID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "debate not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Check ownership
|
||||
if session.UserID != userID {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "access denied"})
|
||||
return
|
||||
}
|
||||
|
||||
// Check status
|
||||
if session.Status != store.DebateStatusCompleted {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "debate is not completed"})
|
||||
return
|
||||
}
|
||||
|
||||
// Parse request
|
||||
var req ExecuteDebateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
SafeBadRequest(c, "Invalid request parameters")
|
||||
return
|
||||
}
|
||||
|
||||
// Get trader executor
|
||||
executor, err := h.traderManager.GetTraderExecutor(req.TraderID)
|
||||
if err != nil {
|
||||
SafeError(c, http.StatusBadRequest, "Trader not available", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Execute consensus
|
||||
if err := h.engine.ExecuteConsensus(debateID, executor); err != nil {
|
||||
SafeInternalError(c, "Execute consensus", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Get updated session
|
||||
updatedSession, _ := h.debateStore.GetSessionWithDetails(debateID)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "consensus executed successfully",
|
||||
"session": updatedSession,
|
||||
})
|
||||
}
|
||||
|
||||
// GetPersonalities returns available AI personalities
|
||||
func (h *DebateHandler) HandleGetPersonalities(c *gin.Context) {
|
||||
personalities := []map[string]interface{}{
|
||||
{
|
||||
"id": "bull",
|
||||
"name": "Aggressive Bull",
|
||||
"emoji": "🐂",
|
||||
"color": store.PersonalityColors[store.PersonalityBull],
|
||||
"description": "Looks for long opportunities, optimistic about market",
|
||||
},
|
||||
{
|
||||
"id": "bear",
|
||||
"name": "Cautious Bear",
|
||||
"emoji": "🐻",
|
||||
"color": store.PersonalityColors[store.PersonalityBear],
|
||||
"description": "Skeptical, focuses on risks and short opportunities",
|
||||
},
|
||||
{
|
||||
"id": "analyst",
|
||||
"name": "Data Analyst",
|
||||
"emoji": "📊",
|
||||
"color": store.PersonalityColors[store.PersonalityAnalyst],
|
||||
"description": "Pure technical analysis, neutral and data-driven",
|
||||
},
|
||||
{
|
||||
"id": "contrarian",
|
||||
"name": "Contrarian",
|
||||
"emoji": "🔄",
|
||||
"color": store.PersonalityColors[store.PersonalityContrarian],
|
||||
"description": "Challenges majority opinion, looks for overlooked opportunities",
|
||||
},
|
||||
{
|
||||
"id": "risk_manager",
|
||||
"name": "Risk Manager",
|
||||
"emoji": "🛡️",
|
||||
"color": store.PersonalityColors[store.PersonalityRiskManager],
|
||||
"description": "Focuses on position sizing, stop losses, and risk control",
|
||||
},
|
||||
}
|
||||
c.JSON(http.StatusOK, personalities)
|
||||
}
|
||||
|
||||
// SSE broadcast helpers
|
||||
func (h *DebateHandler) addSubscriber(sessionID string, ch chan []byte) {
|
||||
h.subscribersMu.Lock()
|
||||
defer h.subscribersMu.Unlock()
|
||||
|
||||
if h.subscribers[sessionID] == nil {
|
||||
h.subscribers[sessionID] = make(map[chan []byte]bool)
|
||||
}
|
||||
h.subscribers[sessionID][ch] = true
|
||||
}
|
||||
|
||||
func (h *DebateHandler) removeSubscriber(sessionID string, ch chan []byte) {
|
||||
h.subscribersMu.Lock()
|
||||
defer h.subscribersMu.Unlock()
|
||||
|
||||
if h.subscribers[sessionID] != nil {
|
||||
delete(h.subscribers[sessionID], ch)
|
||||
close(ch)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *DebateHandler) broadcast(sessionID string, event string, data interface{}) {
|
||||
h.subscribersMu.RLock()
|
||||
defer h.subscribersMu.RUnlock()
|
||||
|
||||
subs := h.subscribers[sessionID]
|
||||
if subs == nil {
|
||||
return
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
msg := []byte(fmt.Sprintf("event: %s\ndata: %s\n\n", event, jsonData))
|
||||
for ch := range subs {
|
||||
select {
|
||||
case ch <- msg:
|
||||
default:
|
||||
// Channel full, skip
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *DebateHandler) broadcastRoundStart(sessionID string, round int) {
|
||||
h.broadcast(sessionID, "round_start", map[string]interface{}{
|
||||
"round": round,
|
||||
"status": "running",
|
||||
})
|
||||
}
|
||||
|
||||
func (h *DebateHandler) broadcastMessage(sessionID string, msg *store.DebateMessage) {
|
||||
h.broadcast(sessionID, "message", msg)
|
||||
}
|
||||
|
||||
func (h *DebateHandler) broadcastRoundEnd(sessionID string, round int) {
|
||||
h.broadcast(sessionID, "round_end", map[string]interface{}{
|
||||
"round": round,
|
||||
"status": "completed",
|
||||
})
|
||||
}
|
||||
|
||||
func (h *DebateHandler) broadcastVote(sessionID string, vote *store.DebateVote) {
|
||||
h.broadcast(sessionID, "vote", vote)
|
||||
}
|
||||
|
||||
func (h *DebateHandler) broadcastConsensus(sessionID string, decision *store.DebateDecision) {
|
||||
h.broadcast(sessionID, "consensus", decision)
|
||||
}
|
||||
|
||||
func (h *DebateHandler) broadcastError(sessionID string, err error) {
|
||||
// Sanitize error message before broadcasting to client
|
||||
safeMsg := SanitizeError(err, "An error occurred during debate")
|
||||
h.broadcast(sessionID, "error", map[string]interface{}{
|
||||
"error": safeMsg,
|
||||
})
|
||||
}
|
||||
@@ -8,6 +8,25 @@ import (
|
||||
"nofx/logger"
|
||||
)
|
||||
|
||||
type APIErrorResponse struct {
|
||||
Error string `json:"error"`
|
||||
ErrorKey string `json:"error_key,omitempty"`
|
||||
ErrorParams map[string]string `json:"error_params,omitempty"`
|
||||
}
|
||||
|
||||
func writeAPIError(c *gin.Context, statusCode int, publicMsg, errorKey string, errorParams map[string]string) {
|
||||
resp := APIErrorResponse{
|
||||
Error: publicMsg,
|
||||
}
|
||||
if errorKey != "" {
|
||||
resp.ErrorKey = errorKey
|
||||
}
|
||||
if len(errorParams) > 0 {
|
||||
resp.ErrorParams = errorParams
|
||||
}
|
||||
c.JSON(statusCode, resp)
|
||||
}
|
||||
|
||||
// SafeError returns a safe error message without exposing internal details
|
||||
// It logs the actual error for debugging but returns a generic message to the client
|
||||
func SafeError(c *gin.Context, statusCode int, publicMsg string, internalErr error) {
|
||||
@@ -16,34 +35,46 @@ func SafeError(c *gin.Context, statusCode int, publicMsg string, internalErr err
|
||||
logger.Errorf("[API Error] %s: %v", publicMsg, internalErr)
|
||||
}
|
||||
|
||||
c.JSON(statusCode, gin.H{"error": publicMsg})
|
||||
writeAPIError(c, statusCode, publicMsg, "", nil)
|
||||
}
|
||||
|
||||
func SafeErrorWithDetails(c *gin.Context, statusCode int, publicMsg, errorKey string, errorParams map[string]string, internalErr error) {
|
||||
if internalErr != nil {
|
||||
logger.Errorf("[API Error] %s: %v", publicMsg, internalErr)
|
||||
}
|
||||
|
||||
writeAPIError(c, statusCode, publicMsg, errorKey, errorParams)
|
||||
}
|
||||
|
||||
// SafeInternalError logs internal error and returns a generic message
|
||||
func SafeInternalError(c *gin.Context, operation string, err error) {
|
||||
logger.Errorf("[Internal Error] %s: %v", operation, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": operation + " failed"})
|
||||
writeAPIError(c, http.StatusInternalServerError, operation+" failed", "", nil)
|
||||
}
|
||||
|
||||
// SafeBadRequest returns a safe bad request error
|
||||
// For validation errors, we can be more specific since they're about user input
|
||||
func SafeBadRequest(c *gin.Context, msg string) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": msg})
|
||||
writeAPIError(c, http.StatusBadRequest, msg, "", nil)
|
||||
}
|
||||
|
||||
func SafeBadRequestWithDetails(c *gin.Context, msg, errorKey string, errorParams map[string]string) {
|
||||
writeAPIError(c, http.StatusBadRequest, msg, errorKey, errorParams)
|
||||
}
|
||||
|
||||
// SafeNotFound returns a generic not found error
|
||||
func SafeNotFound(c *gin.Context, resource string) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": resource + " not found"})
|
||||
writeAPIError(c, http.StatusNotFound, resource+" not found", "", nil)
|
||||
}
|
||||
|
||||
// SafeUnauthorized returns unauthorized error
|
||||
func SafeUnauthorized(c *gin.Context) {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
||||
writeAPIError(c, http.StatusUnauthorized, "Unauthorized", "", nil)
|
||||
}
|
||||
|
||||
// SafeForbidden returns forbidden error
|
||||
func SafeForbidden(c *gin.Context, msg string) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": msg})
|
||||
writeAPIError(c, http.StatusForbidden, msg, "", nil)
|
||||
}
|
||||
|
||||
// IsSensitiveError checks if an error message contains sensitive information
|
||||
|
||||
381
api/exchange_account_state.go
Normal file
381
api/exchange_account_state.go
Normal file
@@ -0,0 +1,381 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"nofx/logger"
|
||||
"nofx/store"
|
||||
"nofx/trader"
|
||||
"nofx/trader/aster"
|
||||
"nofx/trader/binance"
|
||||
"nofx/trader/bitget"
|
||||
"nofx/trader/bybit"
|
||||
"nofx/trader/gate"
|
||||
hyperliquidtrader "nofx/trader/hyperliquid"
|
||||
"nofx/trader/indodax"
|
||||
"nofx/trader/kucoin"
|
||||
"nofx/trader/lighter"
|
||||
"nofx/trader/okx"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const exchangeAccountStateCacheTTL = 30 * time.Second
|
||||
|
||||
const (
|
||||
exchangeAccountStatusOK = "ok"
|
||||
exchangeAccountStatusDisabled = "disabled"
|
||||
exchangeAccountStatusMissingCredentials = "missing_credentials"
|
||||
exchangeAccountStatusInvalidCredentials = "invalid_credentials"
|
||||
exchangeAccountStatusPermissionDenied = "permission_denied"
|
||||
exchangeAccountStatusUnavailable = "unavailable"
|
||||
)
|
||||
|
||||
type ExchangeAccountState struct {
|
||||
ExchangeID string `json:"exchange_id"`
|
||||
Status string `json:"status"`
|
||||
DisplayBalance string `json:"display_balance,omitempty"`
|
||||
Asset string `json:"asset,omitempty"`
|
||||
TotalEquity float64 `json:"total_equity,omitempty"`
|
||||
AvailableBalance float64 `json:"available_balance,omitempty"`
|
||||
CheckedAt time.Time `json:"checked_at"`
|
||||
ErrorCode string `json:"error_code,omitempty"`
|
||||
ErrorMessage string `json:"error_message,omitempty"`
|
||||
}
|
||||
|
||||
type cachedExchangeAccountStates struct {
|
||||
states map[string]ExchangeAccountState
|
||||
cachedAt time.Time
|
||||
}
|
||||
|
||||
type ExchangeAccountStateCache struct {
|
||||
entries map[string]cachedExchangeAccountStates
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
func NewExchangeAccountStateCache() *ExchangeAccountStateCache {
|
||||
return &ExchangeAccountStateCache{
|
||||
entries: make(map[string]cachedExchangeAccountStates),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *ExchangeAccountStateCache) Get(userID string) (map[string]ExchangeAccountState, bool) {
|
||||
c.mu.RLock()
|
||||
entry, ok := c.entries[userID]
|
||||
c.mu.RUnlock()
|
||||
if !ok || time.Since(entry.cachedAt) >= exchangeAccountStateCacheTTL {
|
||||
return nil, false
|
||||
}
|
||||
return cloneExchangeAccountStates(entry.states), true
|
||||
}
|
||||
|
||||
func (c *ExchangeAccountStateCache) Set(userID string, states map[string]ExchangeAccountState) {
|
||||
c.mu.Lock()
|
||||
c.entries[userID] = cachedExchangeAccountStates{
|
||||
states: cloneExchangeAccountStates(states),
|
||||
cachedAt: time.Now(),
|
||||
}
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
func (c *ExchangeAccountStateCache) Invalidate(userID string) {
|
||||
c.mu.Lock()
|
||||
delete(c.entries, userID)
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
func cloneExchangeAccountStates(states map[string]ExchangeAccountState) map[string]ExchangeAccountState {
|
||||
cloned := make(map[string]ExchangeAccountState, len(states))
|
||||
for id, state := range states {
|
||||
cloned[id] = state
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func (s *Server) handleGetExchangeAccountStates(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
|
||||
states, err := s.getExchangeAccountStates(userID)
|
||||
if err != nil {
|
||||
SafeInternalError(c, "Failed to get exchange account states", err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"states": states})
|
||||
}
|
||||
|
||||
func (s *Server) getExchangeAccountStates(userID string) (map[string]ExchangeAccountState, error) {
|
||||
if cached, ok := s.exchangeAccountStateCache.Get(userID); ok {
|
||||
return cached, nil
|
||||
}
|
||||
|
||||
exchanges, err := s.store.Exchange().List(userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
states := make(map[string]ExchangeAccountState, len(exchanges))
|
||||
if len(exchanges) == 0 {
|
||||
return states, nil
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
var mu sync.Mutex
|
||||
|
||||
for _, exchangeCfg := range exchanges {
|
||||
exchangeCfg := exchangeCfg
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
state := probeExchangeAccountState(exchangeCfg, userID)
|
||||
mu.Lock()
|
||||
states[exchangeCfg.ID] = state
|
||||
mu.Unlock()
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
s.exchangeAccountStateCache.Set(userID, states)
|
||||
|
||||
return cloneExchangeAccountStates(states), nil
|
||||
}
|
||||
|
||||
func probeExchangeAccountState(exchangeCfg *store.Exchange, userID string) ExchangeAccountState {
|
||||
state := ExchangeAccountState{
|
||||
ExchangeID: exchangeCfg.ID,
|
||||
CheckedAt: time.Now().UTC(),
|
||||
Asset: accountAssetForExchange(exchangeCfg.ExchangeType),
|
||||
}
|
||||
|
||||
if !exchangeCfg.Enabled {
|
||||
state.Status = exchangeAccountStatusDisabled
|
||||
state.ErrorCode = "EXCHANGE_DISABLED"
|
||||
state.ErrorMessage = "Exchange account is disabled"
|
||||
return state
|
||||
}
|
||||
|
||||
if status, code, message, missing := missingExchangeCredentials(exchangeCfg); missing {
|
||||
state.Status = status
|
||||
state.ErrorCode = code
|
||||
state.ErrorMessage = message
|
||||
return state
|
||||
}
|
||||
|
||||
tempTrader, err := buildExchangeProbeTrader(exchangeCfg, userID)
|
||||
if err != nil {
|
||||
status, code, message := classifyExchangeProbeError(err)
|
||||
state.Status = status
|
||||
state.ErrorCode = code
|
||||
state.ErrorMessage = message
|
||||
return state
|
||||
}
|
||||
|
||||
balanceInfo, err := tempTrader.GetBalance()
|
||||
if err != nil {
|
||||
status, code, message := classifyExchangeProbeError(err)
|
||||
state.Status = status
|
||||
state.ErrorCode = code
|
||||
state.ErrorMessage = message
|
||||
logger.Infof("⚠️ Failed to probe exchange account %s (%s): %v", exchangeCfg.ID, exchangeCfg.ExchangeType, err)
|
||||
return state
|
||||
}
|
||||
|
||||
totalEquity, totalFound := extractFirstNumeric(balanceInfo,
|
||||
"total_equity", "totalEquity", "totalWalletBalance", "wallet_balance", "totalEq", "balance")
|
||||
availableBalance, availableFound := extractFirstNumeric(balanceInfo,
|
||||
"available_balance", "availableBalance", "available")
|
||||
|
||||
if !totalFound && availableFound {
|
||||
totalEquity = availableBalance
|
||||
totalFound = true
|
||||
}
|
||||
|
||||
if !availableFound && totalFound {
|
||||
availableBalance = totalEquity
|
||||
availableFound = true
|
||||
}
|
||||
|
||||
if !totalFound && !availableFound {
|
||||
state.Status = exchangeAccountStatusUnavailable
|
||||
state.ErrorCode = "BALANCE_NOT_FOUND"
|
||||
state.ErrorMessage = "Connected but no balance fields were returned"
|
||||
return state
|
||||
}
|
||||
|
||||
state.Status = exchangeAccountStatusOK
|
||||
if totalFound {
|
||||
state.TotalEquity = totalEquity
|
||||
state.DisplayBalance = formatDisplayBalance(totalEquity, state.Asset)
|
||||
}
|
||||
if availableFound {
|
||||
state.AvailableBalance = availableBalance
|
||||
if state.DisplayBalance == "" {
|
||||
state.DisplayBalance = formatDisplayBalance(availableBalance, state.Asset)
|
||||
}
|
||||
}
|
||||
|
||||
return state
|
||||
}
|
||||
|
||||
func buildExchangeProbeTrader(exchangeCfg *store.Exchange, userID string) (trader.Trader, error) {
|
||||
switch exchangeCfg.ExchangeType {
|
||||
case "binance":
|
||||
return binance.NewFuturesTrader(string(exchangeCfg.APIKey), string(exchangeCfg.SecretKey), userID), nil
|
||||
case "bybit":
|
||||
return bybit.NewBybitTrader(string(exchangeCfg.APIKey), string(exchangeCfg.SecretKey)), nil
|
||||
case "okx":
|
||||
return okx.NewOKXTrader(string(exchangeCfg.APIKey), string(exchangeCfg.SecretKey), string(exchangeCfg.Passphrase)), nil
|
||||
case "bitget":
|
||||
return bitget.NewBitgetTrader(string(exchangeCfg.APIKey), string(exchangeCfg.SecretKey), string(exchangeCfg.Passphrase)), nil
|
||||
case "gate":
|
||||
return gate.NewGateTrader(string(exchangeCfg.APIKey), string(exchangeCfg.SecretKey)), nil
|
||||
case "kucoin":
|
||||
return kucoin.NewKuCoinTrader(string(exchangeCfg.APIKey), string(exchangeCfg.SecretKey), string(exchangeCfg.Passphrase)), nil
|
||||
case "indodax":
|
||||
return indodax.NewIndodaxTrader(string(exchangeCfg.APIKey), string(exchangeCfg.SecretKey)), nil
|
||||
case "hyperliquid":
|
||||
return hyperliquidtrader.NewHyperliquidTrader(
|
||||
string(exchangeCfg.APIKey),
|
||||
exchangeCfg.HyperliquidWalletAddr,
|
||||
exchangeCfg.Testnet,
|
||||
exchangeCfg.HyperliquidUnifiedAcct,
|
||||
)
|
||||
case "aster":
|
||||
return aster.NewAsterTrader(
|
||||
exchangeCfg.AsterUser,
|
||||
exchangeCfg.AsterSigner,
|
||||
string(exchangeCfg.AsterPrivateKey),
|
||||
)
|
||||
case "lighter":
|
||||
return lighter.NewLighterTraderV2(
|
||||
exchangeCfg.LighterWalletAddr,
|
||||
string(exchangeCfg.LighterAPIKeyPrivateKey),
|
||||
exchangeCfg.LighterAPIKeyIndex,
|
||||
false,
|
||||
)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported exchange type: %s", exchangeCfg.ExchangeType)
|
||||
}
|
||||
}
|
||||
|
||||
func extractExchangeTotalEquity(balanceInfo map[string]interface{}) (float64, bool) {
|
||||
return extractFirstNumeric(balanceInfo,
|
||||
"total_equity", "totalEquity", "totalWalletBalance", "wallet_balance", "totalEq", "balance")
|
||||
}
|
||||
|
||||
func extractFirstNumeric(values map[string]interface{}, keys ...string) (float64, bool) {
|
||||
for _, key := range keys {
|
||||
raw, ok := values[key]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
switch v := raw.(type) {
|
||||
case float64:
|
||||
return v, true
|
||||
case float32:
|
||||
return float64(v), true
|
||||
case int:
|
||||
return float64(v), true
|
||||
case int64:
|
||||
return float64(v), true
|
||||
case int32:
|
||||
return float64(v), true
|
||||
case string:
|
||||
parsed, err := strconv.ParseFloat(v, 64)
|
||||
if err == nil {
|
||||
return parsed, true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func formatDisplayBalance(value float64, asset string) string {
|
||||
formatted := strconv.FormatFloat(value, 'f', 4, 64)
|
||||
formatted = strings.TrimRight(strings.TrimRight(formatted, "0"), ".")
|
||||
if formatted == "" {
|
||||
formatted = "0"
|
||||
}
|
||||
if asset == "" {
|
||||
return formatted
|
||||
}
|
||||
return fmt.Sprintf("%s %s", formatted, asset)
|
||||
}
|
||||
|
||||
func accountAssetForExchange(exchangeType string) string {
|
||||
switch exchangeType {
|
||||
case "hyperliquid", "aster", "lighter":
|
||||
return "USDC"
|
||||
default:
|
||||
return "USDT"
|
||||
}
|
||||
}
|
||||
|
||||
func missingExchangeCredentials(exchangeCfg *store.Exchange) (status string, code string, message string, missing bool) {
|
||||
switch exchangeCfg.ExchangeType {
|
||||
case "binance", "bybit", "gate", "indodax":
|
||||
if exchangeCfg.APIKey == "" || exchangeCfg.SecretKey == "" {
|
||||
return exchangeAccountStatusMissingCredentials, "MISSING_REQUIRED_FIELDS", "API key and secret key are required", true
|
||||
}
|
||||
case "okx", "bitget", "kucoin":
|
||||
if exchangeCfg.APIKey == "" || exchangeCfg.SecretKey == "" || exchangeCfg.Passphrase == "" {
|
||||
return exchangeAccountStatusMissingCredentials, "MISSING_REQUIRED_FIELDS", "API key, secret key, and passphrase are required", true
|
||||
}
|
||||
case "hyperliquid":
|
||||
if exchangeCfg.APIKey == "" || exchangeCfg.HyperliquidWalletAddr == "" {
|
||||
return exchangeAccountStatusMissingCredentials, "MISSING_REQUIRED_FIELDS", "Private key and wallet address are required", true
|
||||
}
|
||||
case "aster":
|
||||
if exchangeCfg.AsterUser == "" || exchangeCfg.AsterSigner == "" || exchangeCfg.AsterPrivateKey == "" {
|
||||
return exchangeAccountStatusMissingCredentials, "MISSING_REQUIRED_FIELDS", "Aster user, signer, and private key are required", true
|
||||
}
|
||||
case "lighter":
|
||||
if exchangeCfg.LighterWalletAddr == "" || exchangeCfg.LighterAPIKeyPrivateKey == "" {
|
||||
return exchangeAccountStatusMissingCredentials, "MISSING_REQUIRED_FIELDS", "Wallet address and API key private key are required", true
|
||||
}
|
||||
default:
|
||||
return exchangeAccountStatusUnavailable, "UNSUPPORTED_EXCHANGE", "Unsupported exchange type", true
|
||||
}
|
||||
|
||||
return "", "", "", false
|
||||
}
|
||||
|
||||
func classifyExchangeProbeError(err error) (status string, code string, message string) {
|
||||
if err == nil {
|
||||
return exchangeAccountStatusOK, "", ""
|
||||
}
|
||||
|
||||
rawMessage := err.Error()
|
||||
msg := strings.ToLower(rawMessage)
|
||||
|
||||
switch {
|
||||
case strings.Contains(msg, "unsupported exchange type"):
|
||||
return exchangeAccountStatusUnavailable, "UNSUPPORTED_EXCHANGE", "Unsupported exchange type"
|
||||
case strings.Contains(msg, "requires ") || strings.Contains(msg, "missing") || strings.Contains(msg, "empty"):
|
||||
return exchangeAccountStatusMissingCredentials, "MISSING_REQUIRED_FIELDS", "Exchange credentials are incomplete"
|
||||
case strings.Contains(msg, "permission") || strings.Contains(msg, "forbidden") || strings.Contains(msg, "no authority") || strings.Contains(msg, "not allowed"):
|
||||
return exchangeAccountStatusPermissionDenied, "PERMISSION_DENIED", "Exchange account has no permission to read balances"
|
||||
case strings.Contains(msg, "invalid") || strings.Contains(msg, "signature") || strings.Contains(msg, "unauthorized") || strings.Contains(msg, "api key") || strings.Contains(msg, "api-key") || strings.Contains(msg, "auth"):
|
||||
return exchangeAccountStatusInvalidCredentials, "INVALID_CREDENTIALS", "Exchange credentials are invalid"
|
||||
default:
|
||||
return exchangeAccountStatusUnavailable, "EXCHANGE_UNAVAILABLE", limitErrorMessage(rawMessage)
|
||||
}
|
||||
}
|
||||
|
||||
func limitErrorMessage(message string) string {
|
||||
message = strings.TrimSpace(message)
|
||||
if message == "" {
|
||||
return "Unable to fetch exchange balance right now"
|
||||
}
|
||||
if len(message) <= 160 {
|
||||
return message
|
||||
}
|
||||
return message[:157] + "..."
|
||||
}
|
||||
43
api/handler_ai_cost.go
Normal file
43
api/handler_ai_cost.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// handleGetAICosts returns AI charges for a specific trader
|
||||
func (s *Server) handleGetAICosts(c *gin.Context) {
|
||||
traderID := c.Query("trader_id")
|
||||
period := c.DefaultQuery("period", "today")
|
||||
|
||||
if traderID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "trader_id is required"})
|
||||
return
|
||||
}
|
||||
|
||||
charges, total, err := s.store.AICharge().GetCharges(traderID, period)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"charges": charges,
|
||||
"total": total,
|
||||
"count": len(charges),
|
||||
})
|
||||
}
|
||||
|
||||
// handleGetAICostsSummary returns AI cost summary across all traders
|
||||
func (s *Server) handleGetAICostsSummary(c *gin.Context) {
|
||||
period := c.DefaultQuery("period", "today")
|
||||
|
||||
total, count, byModel := s.store.AICharge().GetSummary(period)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"total": total,
|
||||
"count": count,
|
||||
"by_model": byModel,
|
||||
})
|
||||
}
|
||||
225
api/handler_ai_model.go
Normal file
225
api/handler_ai_model.go
Normal file
@@ -0,0 +1,225 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"nofx/config"
|
||||
"nofx/crypto"
|
||||
"nofx/logger"
|
||||
"nofx/security"
|
||||
"nofx/wallet"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type ModelConfig struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Provider string `json:"provider"`
|
||||
Enabled bool `json:"enabled"`
|
||||
APIKey string `json:"apiKey,omitempty"`
|
||||
CustomAPIURL string `json:"customApiUrl,omitempty"`
|
||||
}
|
||||
|
||||
// SafeModelConfig Safe model configuration structure (does not contain sensitive information)
|
||||
type SafeModelConfig struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Provider string `json:"provider"`
|
||||
Enabled bool `json:"enabled"`
|
||||
CustomAPIURL string `json:"customApiUrl"` // Custom API URL (usually not sensitive)
|
||||
CustomModelName string `json:"customModelName"` // Custom model name (not sensitive)
|
||||
WalletAddress string `json:"walletAddress,omitempty"`
|
||||
BalanceUSDC string `json:"balanceUsdc,omitempty"`
|
||||
}
|
||||
|
||||
type UpdateModelConfigRequest struct {
|
||||
Models map[string]struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
APIKey string `json:"api_key"`
|
||||
CustomAPIURL string `json:"custom_api_url"`
|
||||
CustomModelName string `json:"custom_model_name"`
|
||||
} `json:"models"`
|
||||
}
|
||||
|
||||
// handleGetModelConfigs Get AI model configurations
|
||||
func (s *Server) handleGetModelConfigs(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
logger.Infof("🔍 Querying AI model configs for user %s", userID)
|
||||
models, err := s.store.AIModel().List(userID)
|
||||
if err != nil {
|
||||
logger.Infof("❌ Failed to get AI model configs: %v", err)
|
||||
SafeInternalError(c, "Failed to get AI model configs", err)
|
||||
return
|
||||
}
|
||||
|
||||
// If no models in database, return default models
|
||||
if len(models) == 0 {
|
||||
logger.Infof("⚠️ No AI models in database, returning defaults")
|
||||
defaultModels := []SafeModelConfig{
|
||||
{ID: "deepseek", Name: "DeepSeek AI", Provider: "deepseek", Enabled: false},
|
||||
{ID: "qwen", Name: "Qwen AI", Provider: "qwen", Enabled: false},
|
||||
{ID: "openai", Name: "OpenAI", Provider: "openai", Enabled: false},
|
||||
{ID: "claude", Name: "Claude AI", Provider: "claude", Enabled: false},
|
||||
{ID: "gemini", Name: "Gemini AI", Provider: "gemini", Enabled: false},
|
||||
{ID: "grok", Name: "Grok AI", Provider: "grok", Enabled: false},
|
||||
{ID: "kimi", Name: "Kimi AI", Provider: "kimi", Enabled: false},
|
||||
{ID: "minimax", Name: "MiniMax AI", Provider: "minimax", Enabled: false},
|
||||
}
|
||||
c.JSON(http.StatusOK, defaultModels)
|
||||
return
|
||||
}
|
||||
|
||||
logger.Infof("✅ Found %d AI model configs", len(models))
|
||||
|
||||
// Convert to safe response structure, remove sensitive information
|
||||
safeModels := make([]SafeModelConfig, len(models))
|
||||
for i, model := range models {
|
||||
safeModel := SafeModelConfig{
|
||||
ID: model.ID,
|
||||
Name: model.Name,
|
||||
Provider: model.Provider,
|
||||
Enabled: model.Enabled,
|
||||
CustomAPIURL: model.CustomAPIURL,
|
||||
CustomModelName: model.CustomModelName,
|
||||
}
|
||||
|
||||
if model.Provider == "claw402" {
|
||||
if privateKey := strings.TrimSpace(model.APIKey.String()); privateKey != "" {
|
||||
if walletAddress, addrErr := walletAddressFromPrivateKey(privateKey); addrErr == nil {
|
||||
safeModel.WalletAddress = walletAddress
|
||||
safeModel.BalanceUSDC = wallet.QueryUSDCBalanceStr(walletAddress)
|
||||
} else {
|
||||
logger.Warnf("⚠️ Failed to derive claw402 wallet address for model %s: %v", model.ID, addrErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
safeModels[i] = safeModel
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, safeModels)
|
||||
}
|
||||
|
||||
// handleUpdateModelConfigs Update AI model configurations (supports both encrypted and plain text based on config)
|
||||
func (s *Server) handleUpdateModelConfigs(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
cfg := config.Get()
|
||||
|
||||
// Read raw request body
|
||||
bodyBytes, err := c.GetRawData()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to read request body"})
|
||||
return
|
||||
}
|
||||
|
||||
var req UpdateModelConfigRequest
|
||||
|
||||
// Check if transport encryption is enabled
|
||||
if !cfg.TransportEncryption {
|
||||
// Transport encryption disabled, accept plain JSON
|
||||
if err := json.Unmarshal(bodyBytes, &req); err != nil {
|
||||
logger.Infof("❌ Failed to parse plain JSON request: %v", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request format"})
|
||||
return
|
||||
}
|
||||
logger.Infof("📝 Received plain text model config (UserID: %s)", userID)
|
||||
} else {
|
||||
// Transport encryption enabled, require encrypted payload
|
||||
var encryptedPayload crypto.EncryptedPayload
|
||||
if err := json.Unmarshal(bodyBytes, &encryptedPayload); err != nil {
|
||||
logger.Infof("❌ Failed to parse encrypted payload: %v", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request format, encrypted transmission required"})
|
||||
return
|
||||
}
|
||||
|
||||
// Verify encrypted data
|
||||
if encryptedPayload.WrappedKey == "" {
|
||||
logger.Infof("❌ Detected unencrypted request (UserID: %s)", userID)
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "This endpoint only supports encrypted transmission, please use encrypted client",
|
||||
"code": "ENCRYPTION_REQUIRED",
|
||||
"message": "Encrypted transmission is required for security reasons",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Decrypt data
|
||||
decrypted, err := s.cryptoHandler.cryptoService.DecryptSensitiveData(&encryptedPayload)
|
||||
if err != nil {
|
||||
logger.Infof("❌ Failed to decrypt model config (UserID: %s): %v", userID, err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to decrypt data"})
|
||||
return
|
||||
}
|
||||
|
||||
// Parse decrypted data
|
||||
if err := json.Unmarshal([]byte(decrypted), &req); err != nil {
|
||||
logger.Infof("❌ Failed to parse decrypted data: %v", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to parse decrypted data"})
|
||||
return
|
||||
}
|
||||
logger.Infof("🔓 Decrypted model config data (UserID: %s)", userID)
|
||||
}
|
||||
|
||||
// Update each model's configuration and track traders that need reload
|
||||
tradersToReload := make(map[string]bool)
|
||||
for modelID, modelData := range req.Models {
|
||||
// SSRF protection: validate custom_api_url before storing
|
||||
if modelData.CustomAPIURL != "" {
|
||||
cleanURL := strings.TrimSuffix(modelData.CustomAPIURL, "#")
|
||||
if err := security.ValidateURL(cleanURL); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid custom_api_url for model %s: %s", modelID, err.Error())})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Find traders using this AI model BEFORE updating
|
||||
traders, _ := s.store.Trader().ListByAIModelID(userID, modelID)
|
||||
for _, t := range traders {
|
||||
tradersToReload[t.ID] = true
|
||||
}
|
||||
|
||||
err := s.store.AIModel().Update(userID, modelID, modelData.Enabled, modelData.APIKey, modelData.CustomAPIURL, modelData.CustomModelName)
|
||||
if err != nil {
|
||||
SafeInternalError(c, fmt.Sprintf("Update model %s", modelID), err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Remove affected traders from memory BEFORE reloading to pick up new config
|
||||
for traderID := range tradersToReload {
|
||||
logger.Infof("🔄 Removing trader %s from memory to reload with new AI model config", traderID)
|
||||
s.traderManager.RemoveTrader(traderID)
|
||||
}
|
||||
|
||||
// Reload all traders for this user to make new config take effect immediately
|
||||
err = s.traderManager.LoadUserTradersFromStore(s.store, userID)
|
||||
if err != nil {
|
||||
logger.Infof("⚠️ Failed to reload user traders into memory: %v", err)
|
||||
// Don't return error here since model config was successfully updated to database
|
||||
}
|
||||
|
||||
logger.Infof("✓ AI model config updated: %+v", req.Models)
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Model configuration updated"})
|
||||
}
|
||||
|
||||
// handleGetSupportedModels Get list of AI models supported by the system
|
||||
func (s *Server) handleGetSupportedModels(c *gin.Context) {
|
||||
// Return static list of supported AI models with default versions
|
||||
supportedModels := []map[string]interface{}{
|
||||
{"id": "deepseek", "name": "DeepSeek", "provider": "deepseek", "defaultModel": "deepseek-chat"},
|
||||
{"id": "qwen", "name": "Qwen", "provider": "qwen", "defaultModel": "qwen3-max"},
|
||||
{"id": "openai", "name": "OpenAI", "provider": "openai", "defaultModel": "gpt-5.1"},
|
||||
{"id": "claude", "name": "Claude", "provider": "claude", "defaultModel": "claude-opus-4-6"},
|
||||
{"id": "gemini", "name": "Google Gemini", "provider": "gemini", "defaultModel": "gemini-3-pro-preview"},
|
||||
{"id": "grok", "name": "Grok (xAI)", "provider": "grok", "defaultModel": "grok-3-latest"},
|
||||
{"id": "kimi", "name": "Kimi (Moonshot)", "provider": "kimi", "defaultModel": "moonshot-v1-auto"},
|
||||
{"id": "minimax", "name": "MiniMax", "provider": "minimax", "defaultModel": "MiniMax-M2.7"},
|
||||
{"id": "claw402", "name": "Claw402 (Base USDC)", "provider": "claw402", "defaultModel": "glm-5"},
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, supportedModels)
|
||||
}
|
||||
469
api/handler_competition.go
Normal file
469
api/handler_competition.go
Normal file
@@ -0,0 +1,469 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"nofx/logger"
|
||||
"nofx/store"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// handleDecisions Decision log list
|
||||
func (s *Server) handleDecisions(c *gin.Context) {
|
||||
_, traderID, err := s.getTraderFromQuery(c)
|
||||
if err != nil {
|
||||
SafeBadRequest(c, "Invalid trader ID")
|
||||
return
|
||||
}
|
||||
|
||||
trader, err := s.traderManager.GetTrader(traderID)
|
||||
if err != nil {
|
||||
SafeNotFound(c, "Trader")
|
||||
return
|
||||
}
|
||||
|
||||
// Get all historical decision records (unlimited)
|
||||
records, err := trader.GetStore().Decision().GetLatestRecords(trader.GetID(), 10000)
|
||||
if err != nil {
|
||||
SafeInternalError(c, "Get decision log", err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, records)
|
||||
}
|
||||
|
||||
// handleLatestDecisions Latest decision logs (newest first, supports limit parameter)
|
||||
func (s *Server) handleLatestDecisions(c *gin.Context) {
|
||||
_, traderID, err := s.getTraderFromQuery(c)
|
||||
if err != nil {
|
||||
SafeBadRequest(c, "Invalid trader ID")
|
||||
return
|
||||
}
|
||||
|
||||
trader, err := s.traderManager.GetTrader(traderID)
|
||||
if err != nil {
|
||||
SafeNotFound(c, "Trader")
|
||||
return
|
||||
}
|
||||
|
||||
// Get limit from query parameter, default to 5
|
||||
limit := 5
|
||||
if limitStr := c.Query("limit"); limitStr != "" {
|
||||
if parsedLimit, err := strconv.Atoi(limitStr); err == nil && parsedLimit > 0 {
|
||||
limit = parsedLimit
|
||||
if limit > 100 {
|
||||
limit = 100 // Max 100 to prevent abuse
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
records, err := trader.GetStore().Decision().GetLatestRecords(trader.GetID(), limit)
|
||||
if err != nil {
|
||||
SafeInternalError(c, "Get decision log", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Reverse array to put newest first (for list display)
|
||||
// GetLatestRecords returns oldest to newest (for charts), here we need newest to oldest
|
||||
for i, j := 0, len(records)-1; i < j; i, j = i+1, j-1 {
|
||||
records[i], records[j] = records[j], records[i]
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, records)
|
||||
}
|
||||
|
||||
// handleStatistics Statistics information
|
||||
func (s *Server) handleStatistics(c *gin.Context) {
|
||||
_, traderID, err := s.getTraderFromQuery(c)
|
||||
if err != nil {
|
||||
SafeBadRequest(c, "Invalid trader ID")
|
||||
return
|
||||
}
|
||||
|
||||
trader, err := s.traderManager.GetTrader(traderID)
|
||||
if err != nil {
|
||||
SafeNotFound(c, "Trader")
|
||||
return
|
||||
}
|
||||
|
||||
stats, err := trader.GetStore().Decision().GetStatistics(trader.GetID())
|
||||
if err != nil {
|
||||
SafeInternalError(c, "Get statistics", err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, stats)
|
||||
}
|
||||
|
||||
// handleCompetition Competition overview (compare all traders)
|
||||
func (s *Server) handleCompetition(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
|
||||
// Ensure user's traders are loaded into memory
|
||||
err := s.traderManager.LoadUserTradersFromStore(s.store, userID)
|
||||
if err != nil {
|
||||
logger.Infof("⚠️ Failed to load traders for user %s: %v", userID, err)
|
||||
}
|
||||
|
||||
competition, err := s.traderManager.GetCompetitionData()
|
||||
if err != nil {
|
||||
SafeInternalError(c, "Get competition data", err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, competition)
|
||||
}
|
||||
|
||||
// handleEquityHistory Return rate historical data
|
||||
// Query directly from database, not dependent on trader in memory (so historical data can be retrieved after restart)
|
||||
func (s *Server) handleEquityHistory(c *gin.Context) {
|
||||
_, traderID, err := s.getTraderFromQuery(c)
|
||||
if err != nil {
|
||||
SafeBadRequest(c, "Invalid trader ID")
|
||||
return
|
||||
}
|
||||
|
||||
// Get equity historical data from new equity table
|
||||
// Every 3 minutes per cycle: 10000 records = about 20 days of data
|
||||
snapshots, err := s.store.Equity().GetLatest(traderID, 10000)
|
||||
if err != nil {
|
||||
SafeInternalError(c, "Get historical data", err)
|
||||
return
|
||||
}
|
||||
|
||||
if len(snapshots) == 0 {
|
||||
c.JSON(http.StatusOK, []interface{}{})
|
||||
return
|
||||
}
|
||||
|
||||
// Build return rate historical data points
|
||||
type EquityPoint struct {
|
||||
Timestamp string `json:"timestamp"`
|
||||
TotalEquity float64 `json:"total_equity"` // Account equity (wallet + unrealized)
|
||||
AvailableBalance float64 `json:"available_balance"` // Available balance
|
||||
TotalPnL float64 `json:"total_pnl"` // Total PnL (unrealized PnL)
|
||||
TotalPnLPct float64 `json:"total_pnl_pct"` // Total PnL percentage
|
||||
PositionCount int `json:"position_count"` // Position count
|
||||
MarginUsedPct float64 `json:"margin_used_pct"` // Margin used percentage
|
||||
}
|
||||
|
||||
// Use the balance of the first record as initial balance to calculate return rate
|
||||
initialBalance := snapshots[0].Balance
|
||||
if initialBalance == 0 {
|
||||
initialBalance = 1 // Avoid division by zero
|
||||
}
|
||||
|
||||
var history []EquityPoint
|
||||
for _, snap := range snapshots {
|
||||
// Calculate PnL percentage
|
||||
totalPnLPct := 0.0
|
||||
if initialBalance > 0 {
|
||||
totalPnLPct = (snap.UnrealizedPnL / initialBalance) * 100
|
||||
}
|
||||
|
||||
history = append(history, EquityPoint{
|
||||
Timestamp: snap.Timestamp.Format("2006-01-02 15:04:05"),
|
||||
TotalEquity: snap.TotalEquity,
|
||||
AvailableBalance: snap.Balance,
|
||||
TotalPnL: snap.UnrealizedPnL,
|
||||
TotalPnLPct: totalPnLPct,
|
||||
PositionCount: snap.PositionCount,
|
||||
MarginUsedPct: snap.MarginUsedPct,
|
||||
})
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, history)
|
||||
}
|
||||
|
||||
// handlePublicTraderList Get public trader list (no authentication required)
|
||||
func (s *Server) handlePublicTraderList(c *gin.Context) {
|
||||
// Get trader information from all users
|
||||
competition, err := s.traderManager.GetCompetitionData()
|
||||
if err != nil {
|
||||
SafeInternalError(c, "Get trader list", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Get traders array
|
||||
tradersData, exists := competition["traders"]
|
||||
if !exists {
|
||||
c.JSON(http.StatusOK, []map[string]interface{}{})
|
||||
return
|
||||
}
|
||||
|
||||
traders, ok := tradersData.([]map[string]interface{})
|
||||
if !ok {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Trader data format error",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Return trader basic information, filter sensitive information
|
||||
result := make([]map[string]interface{}, 0, len(traders))
|
||||
for _, trader := range traders {
|
||||
result = append(result, map[string]interface{}{
|
||||
"trader_id": trader["trader_id"],
|
||||
"trader_name": trader["trader_name"],
|
||||
"ai_model": trader["ai_model"],
|
||||
"exchange": trader["exchange"],
|
||||
"is_running": trader["is_running"],
|
||||
"total_equity": trader["total_equity"],
|
||||
"total_pnl": trader["total_pnl"],
|
||||
"total_pnl_pct": trader["total_pnl_pct"],
|
||||
"position_count": trader["position_count"],
|
||||
"margin_used_pct": trader["margin_used_pct"],
|
||||
})
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
// handlePublicCompetition Get public competition data (no authentication required)
|
||||
func (s *Server) handlePublicCompetition(c *gin.Context) {
|
||||
competition, err := s.traderManager.GetCompetitionData()
|
||||
if err != nil {
|
||||
SafeInternalError(c, "Get competition data", err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, competition)
|
||||
}
|
||||
|
||||
// handleTopTraders Get top 5 trader data (no authentication required, for performance comparison)
|
||||
func (s *Server) handleTopTraders(c *gin.Context) {
|
||||
topTraders, err := s.traderManager.GetTopTradersData()
|
||||
if err != nil {
|
||||
SafeInternalError(c, "Get top traders data", err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, topTraders)
|
||||
}
|
||||
|
||||
// handleEquityHistoryBatch Batch get return rate historical data for multiple traders (no authentication required, for performance comparison)
|
||||
// Supports optional 'hours' parameter to filter data by time range (e.g., hours=24 for last 24 hours)
|
||||
func (s *Server) handleEquityHistoryBatch(c *gin.Context) {
|
||||
var requestBody struct {
|
||||
TraderIDs []string `json:"trader_ids"`
|
||||
Hours int `json:"hours"` // Optional: filter by last N hours (0 = all data)
|
||||
}
|
||||
|
||||
// Try to parse POST request JSON body
|
||||
if err := c.ShouldBindJSON(&requestBody); err != nil {
|
||||
// If JSON parse fails, try to get from query parameters (compatible with GET request)
|
||||
traderIDsParam := c.Query("trader_ids")
|
||||
if traderIDsParam == "" {
|
||||
// If no trader_ids specified, return historical data for top 5
|
||||
topTraders, err := s.traderManager.GetTopTradersData()
|
||||
if err != nil {
|
||||
SafeInternalError(c, "Get top traders", err)
|
||||
return
|
||||
}
|
||||
|
||||
traders, ok := topTraders["traders"].([]map[string]interface{})
|
||||
if !ok {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Trader data format error"})
|
||||
return
|
||||
}
|
||||
|
||||
// Extract trader IDs
|
||||
traderIDs := make([]string, 0, len(traders))
|
||||
for _, trader := range traders {
|
||||
if traderID, ok := trader["trader_id"].(string); ok {
|
||||
traderIDs = append(traderIDs, traderID)
|
||||
}
|
||||
}
|
||||
|
||||
// Parse hours parameter from query
|
||||
hoursParam := c.Query("hours")
|
||||
hours := 0
|
||||
if hoursParam != "" {
|
||||
fmt.Sscanf(hoursParam, "%d", &hours)
|
||||
}
|
||||
|
||||
result := s.getEquityHistoryForTraders(traderIDs, hours)
|
||||
c.JSON(http.StatusOK, result)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse comma-separated trader IDs
|
||||
requestBody.TraderIDs = strings.Split(traderIDsParam, ",")
|
||||
for i := range requestBody.TraderIDs {
|
||||
requestBody.TraderIDs[i] = strings.TrimSpace(requestBody.TraderIDs[i])
|
||||
}
|
||||
|
||||
// Parse hours parameter from query
|
||||
hoursParam := c.Query("hours")
|
||||
if hoursParam != "" {
|
||||
fmt.Sscanf(hoursParam, "%d", &requestBody.Hours)
|
||||
}
|
||||
}
|
||||
|
||||
// Limit to maximum 20 traders to prevent oversized requests
|
||||
if len(requestBody.TraderIDs) > 20 {
|
||||
requestBody.TraderIDs = requestBody.TraderIDs[:20]
|
||||
}
|
||||
|
||||
result := s.getEquityHistoryForTraders(requestBody.TraderIDs, requestBody.Hours)
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
// getEquityHistoryForTraders Get historical data for multiple traders
|
||||
// Query directly from database, not dependent on trader in memory (so historical data can be retrieved after restart)
|
||||
// Also appends current real-time data point to ensure chart matches leaderboard
|
||||
// hours: filter by last N hours (0 = use default limit of 500 records)
|
||||
func (s *Server) getEquityHistoryForTraders(traderIDs []string, hours int) map[string]interface{} {
|
||||
result := make(map[string]interface{})
|
||||
histories := make(map[string]interface{})
|
||||
errors := make(map[string]string)
|
||||
|
||||
// Use a single consistent timestamp for all real-time data points
|
||||
now := time.Now()
|
||||
|
||||
// Pre-fetch initial balances for all traders
|
||||
initialBalances := make(map[string]float64)
|
||||
for _, traderID := range traderIDs {
|
||||
if traderID == "" {
|
||||
continue
|
||||
}
|
||||
// Get trader's initial balance from database (use GetByID which doesn't require userID)
|
||||
trader, err := s.store.Trader().GetByID(traderID)
|
||||
if err == nil && trader != nil && trader.InitialBalance > 0 {
|
||||
initialBalances[traderID] = trader.InitialBalance
|
||||
}
|
||||
}
|
||||
|
||||
for _, traderID := range traderIDs {
|
||||
if traderID == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Get equity historical data from new equity table
|
||||
var snapshots []*store.EquitySnapshot
|
||||
var err error
|
||||
|
||||
if hours > 0 {
|
||||
// Filter by time range
|
||||
startTime := now.Add(-time.Duration(hours) * time.Hour)
|
||||
snapshots, err = s.store.Equity().GetByTimeRange(traderID, startTime, now)
|
||||
} else {
|
||||
// Default: get latest 500 records
|
||||
snapshots, err = s.store.Equity().GetLatest(traderID, 500)
|
||||
}
|
||||
if err != nil {
|
||||
logger.Errorf("[API] Failed to get equity history for %s: %v", traderID, err)
|
||||
errors[traderID] = "Failed to get historical data"
|
||||
continue
|
||||
}
|
||||
|
||||
// Get initial balance for calculating PnL percentage
|
||||
initialBalance := initialBalances[traderID]
|
||||
if initialBalance <= 0 && len(snapshots) > 0 {
|
||||
// If no initial balance configured, use the first snapshot's equity as baseline
|
||||
initialBalance = snapshots[0].TotalEquity
|
||||
}
|
||||
|
||||
// Build return rate historical data with PnL percentage
|
||||
history := make([]map[string]interface{}, 0, len(snapshots)+1)
|
||||
var lastSnapshotTime time.Time
|
||||
for _, snap := range snapshots {
|
||||
// Calculate PnL percentage: (current_equity - initial_balance) / initial_balance * 100
|
||||
pnlPct := 0.0
|
||||
if initialBalance > 0 {
|
||||
pnlPct = (snap.TotalEquity - initialBalance) / initialBalance * 100
|
||||
}
|
||||
|
||||
history = append(history, map[string]interface{}{
|
||||
"timestamp": snap.Timestamp,
|
||||
"total_equity": snap.TotalEquity,
|
||||
"total_pnl": snap.UnrealizedPnL,
|
||||
"total_pnl_pct": pnlPct,
|
||||
"balance": snap.Balance,
|
||||
})
|
||||
if snap.Timestamp.After(lastSnapshotTime) {
|
||||
lastSnapshotTime = snap.Timestamp
|
||||
}
|
||||
}
|
||||
|
||||
// Append current real-time data point to ensure chart matches leaderboard
|
||||
// This ensures the latest point is always current, not from a potentially stale snapshot
|
||||
if trader, err := s.traderManager.GetTrader(traderID); err == nil {
|
||||
if accountInfo, err := trader.GetAccountInfo(); err == nil {
|
||||
// Only append if it's been more than 30 seconds since last snapshot
|
||||
if now.Sub(lastSnapshotTime) > 30*time.Second {
|
||||
totalEquity := 0.0
|
||||
if v, ok := accountInfo["total_equity"].(float64); ok {
|
||||
totalEquity = v
|
||||
}
|
||||
totalPnL := 0.0
|
||||
if v, ok := accountInfo["total_pnl"].(float64); ok {
|
||||
totalPnL = v
|
||||
}
|
||||
walletBalance := 0.0
|
||||
if v, ok := accountInfo["wallet_balance"].(float64); ok {
|
||||
walletBalance = v
|
||||
}
|
||||
pnlPct := 0.0
|
||||
if initialBalance > 0 {
|
||||
pnlPct = (totalEquity - initialBalance) / initialBalance * 100
|
||||
}
|
||||
|
||||
history = append(history, map[string]interface{}{
|
||||
"timestamp": now,
|
||||
"total_equity": totalEquity,
|
||||
"total_pnl": totalPnL,
|
||||
"total_pnl_pct": pnlPct,
|
||||
"balance": walletBalance,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
histories[traderID] = history
|
||||
}
|
||||
|
||||
result["histories"] = histories
|
||||
result["count"] = len(histories)
|
||||
if len(errors) > 0 {
|
||||
result["errors"] = errors
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// handleGetPublicTraderConfig Get public trader configuration information (no authentication required, does not include sensitive information)
|
||||
func (s *Server) handleGetPublicTraderConfig(c *gin.Context) {
|
||||
traderID := c.Param("id")
|
||||
if traderID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Trader ID cannot be empty"})
|
||||
return
|
||||
}
|
||||
|
||||
trader, err := s.traderManager.GetTrader(traderID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Trader does not exist"})
|
||||
return
|
||||
}
|
||||
|
||||
// Get trader status information
|
||||
status := trader.GetStatus()
|
||||
|
||||
// Only return public configuration information, not including sensitive data like API keys
|
||||
result := map[string]interface{}{
|
||||
"trader_id": trader.GetID(),
|
||||
"trader_name": trader.GetName(),
|
||||
"ai_model": trader.GetAIModel(),
|
||||
"exchange": trader.GetExchange(),
|
||||
"is_running": status["is_running"],
|
||||
"ai_provider": status["ai_provider"],
|
||||
"start_time": status["start_time"],
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
359
api/handler_exchange.go
Normal file
359
api/handler_exchange.go
Normal file
@@ -0,0 +1,359 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"nofx/config"
|
||||
"nofx/crypto"
|
||||
"nofx/logger"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type ExchangeConfig struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"` // "cex" or "dex"
|
||||
Enabled bool `json:"enabled"`
|
||||
APIKey string `json:"apiKey,omitempty"`
|
||||
SecretKey string `json:"secretKey,omitempty"`
|
||||
Testnet bool `json:"testnet,omitempty"`
|
||||
}
|
||||
|
||||
// SafeExchangeConfig Safe exchange configuration structure (does not contain sensitive information)
|
||||
type SafeExchangeConfig struct {
|
||||
ID string `json:"id"` // UUID
|
||||
ExchangeType string `json:"exchange_type"` // "binance", "bybit", "okx", "hyperliquid", "aster", "lighter"
|
||||
AccountName string `json:"account_name"` // User-defined account name
|
||||
Name string `json:"name"` // Display name
|
||||
Type string `json:"type"` // "cex" or "dex"
|
||||
Enabled bool `json:"enabled"`
|
||||
Testnet bool `json:"testnet,omitempty"`
|
||||
HyperliquidWalletAddr string `json:"hyperliquidWalletAddr"` // Hyperliquid wallet address (not sensitive)
|
||||
AsterUser string `json:"asterUser"` // Aster username (not sensitive)
|
||||
AsterSigner string `json:"asterSigner"` // Aster signer (not sensitive)
|
||||
LighterWalletAddr string `json:"lighterWalletAddr"` // LIGHTER wallet address (not sensitive)
|
||||
}
|
||||
|
||||
type UpdateExchangeConfigRequest struct {
|
||||
Exchanges map[string]struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
APIKey string `json:"api_key"`
|
||||
SecretKey string `json:"secret_key"`
|
||||
Passphrase string `json:"passphrase"` // OKX specific
|
||||
Testnet bool `json:"testnet"`
|
||||
HyperliquidWalletAddr string `json:"hyperliquid_wallet_addr"`
|
||||
HyperliquidUnifiedAcct bool `json:"hyperliquid_unified_account"` // Unified Account mode
|
||||
AsterUser string `json:"aster_user"`
|
||||
AsterSigner string `json:"aster_signer"`
|
||||
AsterPrivateKey string `json:"aster_private_key"`
|
||||
LighterWalletAddr string `json:"lighter_wallet_addr"`
|
||||
LighterPrivateKey string `json:"lighter_private_key"`
|
||||
LighterAPIKeyPrivateKey string `json:"lighter_api_key_private_key"`
|
||||
LighterAPIKeyIndex int `json:"lighter_api_key_index"`
|
||||
} `json:"exchanges"`
|
||||
}
|
||||
|
||||
// CreateExchangeRequest request structure for creating a new exchange account
|
||||
type CreateExchangeRequest struct {
|
||||
ExchangeType string `json:"exchange_type" binding:"required"` // "binance", "bybit", "okx", "hyperliquid", "aster", "lighter"
|
||||
AccountName string `json:"account_name"` // User-defined account name
|
||||
Enabled bool `json:"enabled"`
|
||||
APIKey string `json:"api_key"`
|
||||
SecretKey string `json:"secret_key"`
|
||||
Passphrase string `json:"passphrase"`
|
||||
Testnet bool `json:"testnet"`
|
||||
HyperliquidWalletAddr string `json:"hyperliquid_wallet_addr"`
|
||||
HyperliquidUnifiedAcct bool `json:"hyperliquid_unified_account"` // Unified Account mode: Spot as Perp collateral
|
||||
AsterUser string `json:"aster_user"`
|
||||
AsterSigner string `json:"aster_signer"`
|
||||
AsterPrivateKey string `json:"aster_private_key"`
|
||||
LighterWalletAddr string `json:"lighter_wallet_addr"`
|
||||
LighterPrivateKey string `json:"lighter_private_key"`
|
||||
LighterAPIKeyPrivateKey string `json:"lighter_api_key_private_key"`
|
||||
LighterAPIKeyIndex int `json:"lighter_api_key_index"`
|
||||
}
|
||||
|
||||
// handleGetExchangeConfigs Get exchange configurations
|
||||
func (s *Server) handleGetExchangeConfigs(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
logger.Infof("🔍 Querying exchange configs for user %s", userID)
|
||||
exchanges, err := s.store.Exchange().List(userID)
|
||||
if err != nil {
|
||||
SafeInternalError(c, "Failed to get exchange configs", err)
|
||||
return
|
||||
}
|
||||
|
||||
// If no exchanges in database, return empty array (user needs to create accounts)
|
||||
if len(exchanges) == 0 {
|
||||
logger.Infof("⚠️ No exchanges in database for user %s", userID)
|
||||
c.JSON(http.StatusOK, []SafeExchangeConfig{})
|
||||
return
|
||||
}
|
||||
|
||||
logger.Infof("✅ Found %d exchange configs", len(exchanges))
|
||||
|
||||
// Convert to safe response structure, remove sensitive information
|
||||
safeExchanges := make([]SafeExchangeConfig, len(exchanges))
|
||||
for i, exchange := range exchanges {
|
||||
safeExchanges[i] = SafeExchangeConfig{
|
||||
ID: exchange.ID,
|
||||
ExchangeType: exchange.ExchangeType,
|
||||
AccountName: exchange.AccountName,
|
||||
Name: exchange.Name,
|
||||
Type: exchange.Type,
|
||||
Enabled: exchange.Enabled,
|
||||
Testnet: exchange.Testnet,
|
||||
HyperliquidWalletAddr: exchange.HyperliquidWalletAddr,
|
||||
AsterUser: exchange.AsterUser,
|
||||
AsterSigner: exchange.AsterSigner,
|
||||
LighterWalletAddr: exchange.LighterWalletAddr,
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, safeExchanges)
|
||||
}
|
||||
|
||||
// handleUpdateExchangeConfigs Update exchange configurations (supports both encrypted and plain text based on config)
|
||||
func (s *Server) handleUpdateExchangeConfigs(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
cfg := config.Get()
|
||||
|
||||
// Read raw request body
|
||||
bodyBytes, err := c.GetRawData()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to read request body"})
|
||||
return
|
||||
}
|
||||
|
||||
var req UpdateExchangeConfigRequest
|
||||
|
||||
// Check if transport encryption is enabled
|
||||
if !cfg.TransportEncryption {
|
||||
// Transport encryption disabled, accept plain JSON
|
||||
if err := json.Unmarshal(bodyBytes, &req); err != nil {
|
||||
logger.Infof("❌ Failed to parse plain JSON request: %v", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request format"})
|
||||
return
|
||||
}
|
||||
logger.Infof("📝 Received plain text exchange config (UserID: %s)", userID)
|
||||
} else {
|
||||
// Transport encryption enabled, require encrypted payload
|
||||
var encryptedPayload crypto.EncryptedPayload
|
||||
if err := json.Unmarshal(bodyBytes, &encryptedPayload); err != nil {
|
||||
logger.Infof("❌ Failed to parse encrypted payload: %v", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request format, encrypted transmission required"})
|
||||
return
|
||||
}
|
||||
|
||||
// Verify encrypted data
|
||||
if encryptedPayload.WrappedKey == "" {
|
||||
logger.Infof("❌ Detected unencrypted request (UserID: %s)", userID)
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "This endpoint only supports encrypted transmission, please use encrypted client",
|
||||
"code": "ENCRYPTION_REQUIRED",
|
||||
"message": "Encrypted transmission is required for security reasons",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Decrypt data
|
||||
decrypted, err := s.cryptoHandler.cryptoService.DecryptSensitiveData(&encryptedPayload)
|
||||
if err != nil {
|
||||
logger.Infof("❌ Failed to decrypt exchange config (UserID: %s): %v", userID, err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to decrypt data"})
|
||||
return
|
||||
}
|
||||
|
||||
// Parse decrypted data
|
||||
if err := json.Unmarshal([]byte(decrypted), &req); err != nil {
|
||||
logger.Infof("❌ Failed to parse decrypted data: %v", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to parse decrypted data"})
|
||||
return
|
||||
}
|
||||
logger.Infof("🔓 Decrypted exchange config data (UserID: %s)", userID)
|
||||
}
|
||||
|
||||
// Update each exchange's configuration and track traders that need reload
|
||||
tradersToReload := make(map[string]bool)
|
||||
for exchangeID, exchangeData := range req.Exchanges {
|
||||
// Find traders using this exchange BEFORE updating
|
||||
traders, _ := s.store.Trader().ListByExchangeID(userID, exchangeID)
|
||||
for _, t := range traders {
|
||||
tradersToReload[t.ID] = true
|
||||
}
|
||||
|
||||
err := s.store.Exchange().Update(userID, exchangeID, exchangeData.Enabled, exchangeData.APIKey, exchangeData.SecretKey, exchangeData.Passphrase, exchangeData.Testnet, exchangeData.HyperliquidWalletAddr, exchangeData.HyperliquidUnifiedAcct, exchangeData.AsterUser, exchangeData.AsterSigner, exchangeData.AsterPrivateKey, exchangeData.LighterWalletAddr, exchangeData.LighterPrivateKey, exchangeData.LighterAPIKeyPrivateKey, exchangeData.LighterAPIKeyIndex)
|
||||
if err != nil {
|
||||
SafeInternalError(c, fmt.Sprintf("Update exchange %s", exchangeID), err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
s.exchangeAccountStateCache.Invalidate(userID)
|
||||
|
||||
// Remove affected traders from memory BEFORE reloading to pick up new config
|
||||
for traderID := range tradersToReload {
|
||||
logger.Infof("🔄 Removing trader %s from memory to reload with new exchange config", traderID)
|
||||
s.traderManager.RemoveTrader(traderID)
|
||||
}
|
||||
|
||||
// Reload all traders for this user to make new config take effect immediately
|
||||
err = s.traderManager.LoadUserTradersFromStore(s.store, userID)
|
||||
if err != nil {
|
||||
logger.Infof("⚠️ Failed to reload user traders into memory: %v", err)
|
||||
// Don't return error here since exchange config was successfully updated to database
|
||||
}
|
||||
|
||||
logger.Infof("✓ Exchange config updated: %+v", req.Exchanges)
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Exchange configuration updated"})
|
||||
}
|
||||
|
||||
// handleCreateExchange Create a new exchange account
|
||||
func (s *Server) handleCreateExchange(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
cfg := config.Get()
|
||||
|
||||
// Read raw request body
|
||||
bodyBytes, err := c.GetRawData()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to read request body"})
|
||||
return
|
||||
}
|
||||
|
||||
var req CreateExchangeRequest
|
||||
|
||||
// Check if transport encryption is enabled
|
||||
if !cfg.TransportEncryption {
|
||||
// Transport encryption disabled, accept plain JSON
|
||||
if err := json.Unmarshal(bodyBytes, &req); err != nil {
|
||||
logger.Infof("❌ Failed to parse plain JSON request: %v", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request format"})
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// Transport encryption enabled, require encrypted payload
|
||||
var encryptedPayload crypto.EncryptedPayload
|
||||
if err := json.Unmarshal(bodyBytes, &encryptedPayload); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request format, encrypted transmission required"})
|
||||
return
|
||||
}
|
||||
|
||||
if encryptedPayload.WrappedKey == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "This endpoint only supports encrypted transmission",
|
||||
"code": "ENCRYPTION_REQUIRED",
|
||||
"message": "Encrypted transmission is required for security reasons",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
decrypted, err := s.cryptoHandler.cryptoService.DecryptSensitiveData(&encryptedPayload)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to decrypt data"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(decrypted), &req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to parse decrypted data"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Validate exchange type
|
||||
validTypes := map[string]bool{
|
||||
"binance": true, "bybit": true, "okx": true, "bitget": true,
|
||||
"hyperliquid": true, "aster": true, "lighter": true, "gate": true, "kucoin": true, "indodax": true,
|
||||
}
|
||||
if !validTypes[req.ExchangeType] {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid exchange type: %s", req.ExchangeType)})
|
||||
return
|
||||
}
|
||||
|
||||
// Create new exchange account
|
||||
id, err := s.store.Exchange().Create(
|
||||
userID, req.ExchangeType, req.AccountName, req.Enabled,
|
||||
req.APIKey, req.SecretKey, req.Passphrase, req.Testnet,
|
||||
req.HyperliquidWalletAddr, req.HyperliquidUnifiedAcct,
|
||||
req.AsterUser, req.AsterSigner, req.AsterPrivateKey,
|
||||
req.LighterWalletAddr, req.LighterPrivateKey, req.LighterAPIKeyPrivateKey, req.LighterAPIKeyIndex,
|
||||
)
|
||||
if err != nil {
|
||||
logger.Infof("❌ Failed to create exchange account: %v", err)
|
||||
SafeInternalError(c, "Failed to create exchange account", err)
|
||||
return
|
||||
}
|
||||
|
||||
s.exchangeAccountStateCache.Invalidate(userID)
|
||||
|
||||
logger.Infof("✓ Created exchange account: type=%s, name=%s, id=%s", req.ExchangeType, req.AccountName, id)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "Exchange account created",
|
||||
"id": id,
|
||||
})
|
||||
}
|
||||
|
||||
// handleDeleteExchange Delete an exchange account
|
||||
func (s *Server) handleDeleteExchange(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
exchangeID := c.Param("id")
|
||||
|
||||
if exchangeID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Exchange ID is required"})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if any traders are using this exchange
|
||||
traders, err := s.store.Trader().List(userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check traders"})
|
||||
return
|
||||
}
|
||||
|
||||
for _, trader := range traders {
|
||||
if trader.ExchangeID == exchangeID {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Cannot delete exchange account that is in use by traders",
|
||||
"trader_id": trader.ID,
|
||||
"trader_name": trader.Name,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Delete exchange account
|
||||
err = s.store.Exchange().Delete(userID, exchangeID)
|
||||
if err != nil {
|
||||
logger.Infof("❌ Failed to delete exchange account: %v", err)
|
||||
SafeInternalError(c, "Failed to delete exchange account", err)
|
||||
return
|
||||
}
|
||||
|
||||
s.exchangeAccountStateCache.Invalidate(userID)
|
||||
|
||||
logger.Infof("✓ Deleted exchange account: id=%s", exchangeID)
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Exchange account deleted"})
|
||||
}
|
||||
|
||||
// handleGetSupportedExchanges Get list of exchanges supported by the system
|
||||
func (s *Server) handleGetSupportedExchanges(c *gin.Context) {
|
||||
// Return static list of supported exchange types
|
||||
// Note: ID is empty for supported exchanges (they are templates, not actual accounts)
|
||||
supportedExchanges := []SafeExchangeConfig{
|
||||
{ExchangeType: "binance", Name: "Binance Futures", Type: "cex"},
|
||||
{ExchangeType: "bybit", Name: "Bybit Futures", Type: "cex"},
|
||||
{ExchangeType: "okx", Name: "OKX Futures", Type: "cex"},
|
||||
{ExchangeType: "gate", Name: "Gate.io Futures", Type: "cex"},
|
||||
{ExchangeType: "kucoin", Name: "KuCoin Futures", Type: "cex"},
|
||||
{ExchangeType: "hyperliquid", Name: "Hyperliquid", Type: "dex"},
|
||||
{ExchangeType: "aster", Name: "Aster DEX", Type: "dex"},
|
||||
{ExchangeType: "lighter", Name: "LIGHTER DEX", Type: "dex"},
|
||||
{ExchangeType: "alpaca", Name: "Alpaca (US Stocks)", Type: "stock"},
|
||||
{ExchangeType: "forex", Name: "Forex (TwelveData)", Type: "forex"},
|
||||
{ExchangeType: "metals", Name: "Metals (TwelveData)", Type: "metals"},
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, supportedExchanges)
|
||||
}
|
||||
392
api/handler_klines.go
Normal file
392
api/handler_klines.go
Normal file
@@ -0,0 +1,392 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"nofx/logger"
|
||||
"nofx/market"
|
||||
"nofx/provider/alpaca"
|
||||
"nofx/provider/coinank/coinank_api"
|
||||
"nofx/provider/coinank/coinank_enum"
|
||||
"nofx/provider/hyperliquid"
|
||||
"nofx/provider/twelvedata"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// handleKlines K-line data (supports multiple exchanges via coinank)
|
||||
func (s *Server) handleKlines(c *gin.Context) {
|
||||
// Get query parameters
|
||||
symbol := c.Query("symbol")
|
||||
if symbol == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "symbol parameter is required"})
|
||||
return
|
||||
}
|
||||
|
||||
interval := c.DefaultQuery("interval", "5m")
|
||||
exchange := c.DefaultQuery("exchange", "binance") // Default to binance for backward compatibility
|
||||
limitStr := c.DefaultQuery("limit", "1000")
|
||||
limit, err := strconv.Atoi(limitStr)
|
||||
if err != nil || limit <= 0 {
|
||||
limit = 1000
|
||||
}
|
||||
|
||||
// Coinank API has a maximum limit of 1500 klines per request
|
||||
if limit > 1500 {
|
||||
limit = 1500
|
||||
}
|
||||
|
||||
var klines []market.Kline
|
||||
exchangeLower := strings.ToLower(exchange)
|
||||
|
||||
// Route to appropriate data source based on exchange type
|
||||
switch exchangeLower {
|
||||
case "alpaca":
|
||||
// US Stocks via Alpaca
|
||||
klines, err = s.getKlinesFromAlpaca(symbol, interval, limit)
|
||||
if err != nil {
|
||||
SafeInternalError(c, "Get klines from Alpaca", err)
|
||||
return
|
||||
}
|
||||
case "forex", "metals":
|
||||
// Forex and Metals via Twelve Data
|
||||
klines, err = s.getKlinesFromTwelveData(symbol, interval, limit)
|
||||
if err != nil {
|
||||
SafeInternalError(c, "Get klines from TwelveData", err)
|
||||
return
|
||||
}
|
||||
case "hyperliquid", "hyperliquid-xyz", "xyz":
|
||||
// Hyperliquid native API - supports both crypto perps and stock perps (xyz dex)
|
||||
klines, err = s.getKlinesFromHyperliquid(symbol, interval, limit)
|
||||
if err != nil {
|
||||
SafeInternalError(c, "Get klines from Hyperliquid", err)
|
||||
return
|
||||
}
|
||||
default:
|
||||
// Crypto exchanges via CoinAnk
|
||||
symbol = market.Normalize(symbol)
|
||||
klines, err = s.getKlinesFromCoinank(symbol, interval, exchange, limit)
|
||||
if err != nil {
|
||||
SafeInternalError(c, "Get klines from CoinAnk", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, klines)
|
||||
}
|
||||
|
||||
// getKlinesFromCoinank fetches kline data from coinank free/open API for multiple exchanges
|
||||
func (s *Server) getKlinesFromCoinank(symbol, interval, exchange string, limit int) ([]market.Kline, error) {
|
||||
// Map exchange string to coinank enum
|
||||
var coinankExchange coinank_enum.Exchange
|
||||
switch strings.ToLower(exchange) {
|
||||
case "binance":
|
||||
coinankExchange = coinank_enum.Binance
|
||||
case "bybit":
|
||||
coinankExchange = coinank_enum.Bybit
|
||||
case "okx":
|
||||
coinankExchange = coinank_enum.Okex
|
||||
case "bitget":
|
||||
coinankExchange = coinank_enum.Bitget
|
||||
case "gate":
|
||||
coinankExchange = coinank_enum.Gate
|
||||
case "aster":
|
||||
coinankExchange = coinank_enum.Aster
|
||||
case "lighter":
|
||||
// Lighter doesn't have direct CoinAnk support, use Binance data as fallback
|
||||
coinankExchange = coinank_enum.Binance
|
||||
case "kucoin":
|
||||
// KuCoin doesn't have direct CoinAnk support, use Binance data as fallback
|
||||
coinankExchange = coinank_enum.Binance
|
||||
default:
|
||||
// For any unknown exchange, default to Binance
|
||||
logger.Warnf("⚠️ Unknown exchange '%s', defaulting to Binance for CoinAnk", exchange)
|
||||
coinankExchange = coinank_enum.Binance
|
||||
}
|
||||
|
||||
// Map interval string to coinank enum
|
||||
var coinankInterval coinank_enum.Interval
|
||||
switch interval {
|
||||
case "1s":
|
||||
coinankInterval = coinank_enum.Second1
|
||||
case "5s":
|
||||
coinankInterval = coinank_enum.Second5
|
||||
case "10s":
|
||||
coinankInterval = coinank_enum.Second10
|
||||
case "30s":
|
||||
coinankInterval = coinank_enum.Second30
|
||||
case "1m":
|
||||
coinankInterval = coinank_enum.Minute1
|
||||
case "3m":
|
||||
coinankInterval = coinank_enum.Minute3
|
||||
case "5m":
|
||||
coinankInterval = coinank_enum.Minute5
|
||||
case "10m":
|
||||
coinankInterval = coinank_enum.Minute10
|
||||
case "15m":
|
||||
coinankInterval = coinank_enum.Minute15
|
||||
case "30m":
|
||||
coinankInterval = coinank_enum.Minute30
|
||||
case "1h":
|
||||
coinankInterval = coinank_enum.Hour1
|
||||
case "2h":
|
||||
coinankInterval = coinank_enum.Hour2
|
||||
case "4h":
|
||||
coinankInterval = coinank_enum.Hour4
|
||||
case "6h":
|
||||
coinankInterval = coinank_enum.Hour6
|
||||
case "8h":
|
||||
coinankInterval = coinank_enum.Hour8
|
||||
case "12h":
|
||||
coinankInterval = coinank_enum.Hour12
|
||||
case "1d":
|
||||
coinankInterval = coinank_enum.Day1
|
||||
case "3d":
|
||||
coinankInterval = coinank_enum.Day3
|
||||
case "1w":
|
||||
coinankInterval = coinank_enum.Week1
|
||||
case "1M":
|
||||
coinankInterval = coinank_enum.Month1
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported interval for coinank: %s", interval)
|
||||
}
|
||||
|
||||
// Convert symbol format for different exchanges
|
||||
// OKX uses "BTC-USDT-SWAP" format instead of "BTCUSDT"
|
||||
apiSymbol := symbol
|
||||
if coinankExchange == coinank_enum.Okex {
|
||||
// Convert BTCUSDT -> BTC-USDT-SWAP
|
||||
if strings.HasSuffix(symbol, "USDT") {
|
||||
base := strings.TrimSuffix(symbol, "USDT")
|
||||
apiSymbol = fmt.Sprintf("%s-USDT-SWAP", base)
|
||||
}
|
||||
}
|
||||
|
||||
// Call coinank free/open API (no authentication required)
|
||||
ctx := context.Background()
|
||||
ts := time.Now().UnixMilli()
|
||||
// Use "To" side to search backward from current time (get historical klines)
|
||||
coinankKlines, err := coinank_api.Kline(ctx, apiSymbol, coinankExchange, ts, coinank_enum.To, limit, coinankInterval)
|
||||
if err != nil {
|
||||
// Free API doesn't support all exchanges (e.g., OKX, Bitget)
|
||||
// Fallback to Binance data as reference
|
||||
if coinankExchange != coinank_enum.Binance {
|
||||
logger.Warnf("⚠️ CoinAnk free API doesn't support %s, falling back to Binance data", coinankExchange)
|
||||
coinankKlines, err = coinank_api.Kline(ctx, symbol, coinank_enum.Binance, ts, coinank_enum.To, limit, coinankInterval)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("coinank API error (fallback): %w", err)
|
||||
}
|
||||
} else {
|
||||
return nil, fmt.Errorf("coinank API error: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Convert coinank kline format to market.Kline format
|
||||
// Coinank: Volume = BTC quantity, Quantity = USDT turnover
|
||||
klines := make([]market.Kline, len(coinankKlines))
|
||||
for i, ck := range coinankKlines {
|
||||
klines[i] = market.Kline{
|
||||
OpenTime: ck.StartTime,
|
||||
Open: ck.Open,
|
||||
High: ck.High,
|
||||
Low: ck.Low,
|
||||
Close: ck.Close,
|
||||
Volume: ck.Volume, // BTC quantity
|
||||
QuoteVolume: ck.Quantity, // USDT turnover
|
||||
CloseTime: ck.EndTime,
|
||||
}
|
||||
}
|
||||
|
||||
return klines, nil
|
||||
}
|
||||
|
||||
// getKlinesFromAlpaca fetches kline data from Alpaca API for US stocks
|
||||
func (s *Server) getKlinesFromAlpaca(symbol, interval string, limit int) ([]market.Kline, error) {
|
||||
// Create Alpaca client
|
||||
client := alpaca.NewClient()
|
||||
|
||||
// Map interval to Alpaca timeframe format
|
||||
timeframe := alpaca.MapTimeframe(interval)
|
||||
|
||||
// Fetch bars from Alpaca
|
||||
ctx := context.Background()
|
||||
bars, err := client.GetBars(ctx, symbol, timeframe, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("alpaca API error: %w", err)
|
||||
}
|
||||
|
||||
// Convert Alpaca bars to market.Kline format
|
||||
klines := make([]market.Kline, len(bars))
|
||||
for i, bar := range bars {
|
||||
klines[i] = market.Kline{
|
||||
OpenTime: bar.Timestamp.UnixMilli(),
|
||||
Open: bar.Open,
|
||||
High: bar.High,
|
||||
Low: bar.Low,
|
||||
Close: bar.Close,
|
||||
Volume: float64(bar.Volume), // share count
|
||||
QuoteVolume: float64(bar.Volume) * bar.Close, // turnover = shares * close price (USD)
|
||||
CloseTime: bar.Timestamp.UnixMilli(),
|
||||
}
|
||||
}
|
||||
|
||||
return klines, nil
|
||||
}
|
||||
|
||||
// getKlinesFromTwelveData fetches kline data from Twelve Data API for forex and metals
|
||||
func (s *Server) getKlinesFromTwelveData(symbol, interval string, limit int) ([]market.Kline, error) {
|
||||
// Create Twelve Data client
|
||||
client := twelvedata.NewClient()
|
||||
|
||||
// Map interval to Twelve Data timeframe format
|
||||
timeframe := twelvedata.MapTimeframe(interval)
|
||||
|
||||
// Fetch time series from Twelve Data
|
||||
ctx := context.Background()
|
||||
result, err := client.GetTimeSeries(ctx, symbol, timeframe, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("twelvedata API error: %w", err)
|
||||
}
|
||||
|
||||
// Convert Twelve Data bars to market.Kline format
|
||||
// Note: Twelve Data returns bars in reverse order (newest first)
|
||||
klines := make([]market.Kline, len(result.Values))
|
||||
for i, bar := range result.Values {
|
||||
open, high, low, close, volume, timestamp, err := twelvedata.ParseBar(bar)
|
||||
if err != nil {
|
||||
logger.Warnf("⚠️ Failed to parse TwelveData bar: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Reverse order: put oldest first
|
||||
idx := len(result.Values) - 1 - i
|
||||
klines[idx] = market.Kline{
|
||||
OpenTime: timestamp,
|
||||
Open: open,
|
||||
High: high,
|
||||
Low: low,
|
||||
Close: close,
|
||||
Volume: volume,
|
||||
CloseTime: timestamp,
|
||||
}
|
||||
}
|
||||
|
||||
return klines, nil
|
||||
}
|
||||
|
||||
// getKlinesFromHyperliquid fetches kline data from Hyperliquid API
|
||||
// Supports both crypto perps (default dex) and stock perps/forex/commodities (xyz dex)
|
||||
func (s *Server) getKlinesFromHyperliquid(symbol, interval string, limit int) ([]market.Kline, error) {
|
||||
// Create Hyperliquid client
|
||||
client := hyperliquid.NewClient()
|
||||
|
||||
// Map interval to Hyperliquid format
|
||||
timeframe := hyperliquid.MapTimeframe(interval)
|
||||
|
||||
// Fetch candles from Hyperliquid
|
||||
// FormatCoinForAPI will automatically add xyz: prefix for stock perps
|
||||
ctx := context.Background()
|
||||
candles, err := client.GetCandles(ctx, symbol, timeframe, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("hyperliquid API error: %w", err)
|
||||
}
|
||||
|
||||
// Convert Hyperliquid candles to market.Kline format
|
||||
klines := make([]market.Kline, len(candles))
|
||||
for i, candle := range candles {
|
||||
open, _ := strconv.ParseFloat(candle.Open, 64)
|
||||
high, _ := strconv.ParseFloat(candle.High, 64)
|
||||
low, _ := strconv.ParseFloat(candle.Low, 64)
|
||||
close, _ := strconv.ParseFloat(candle.Close, 64)
|
||||
volume, _ := strconv.ParseFloat(candle.Volume, 64)
|
||||
|
||||
klines[i] = market.Kline{
|
||||
OpenTime: candle.OpenTime,
|
||||
Open: open,
|
||||
High: high,
|
||||
Low: low,
|
||||
Close: close,
|
||||
Volume: volume, // contract quantity
|
||||
QuoteVolume: volume * close, // turnover (USD)
|
||||
CloseTime: candle.CloseTime,
|
||||
}
|
||||
}
|
||||
|
||||
return klines, nil
|
||||
}
|
||||
|
||||
// handleSymbols returns available symbols for a given exchange
|
||||
func (s *Server) handleSymbols(c *gin.Context) {
|
||||
exchange := c.DefaultQuery("exchange", "hyperliquid")
|
||||
|
||||
type SymbolInfo struct {
|
||||
Symbol string `json:"symbol"`
|
||||
Name string `json:"name"`
|
||||
Category string `json:"category"` // crypto, stock, forex, commodity, index
|
||||
MaxLeverage int `json:"maxLeverage,omitempty"`
|
||||
}
|
||||
|
||||
var symbols []SymbolInfo
|
||||
|
||||
switch strings.ToLower(exchange) {
|
||||
case "hyperliquid", "hyperliquid-xyz", "xyz":
|
||||
// Fetch symbols from Hyperliquid
|
||||
client := hyperliquid.NewClient()
|
||||
ctx := context.Background()
|
||||
|
||||
// Get crypto perps from default dex
|
||||
if exchange == "hyperliquid" || exchange == "hyperliquid-xyz" {
|
||||
mids, err := client.GetAllMids(ctx)
|
||||
if err == nil {
|
||||
for symbol := range mids {
|
||||
// Skip spot tokens (start with @)
|
||||
if strings.HasPrefix(symbol, "@") {
|
||||
continue
|
||||
}
|
||||
symbols = append(symbols, SymbolInfo{
|
||||
Symbol: symbol,
|
||||
Name: symbol,
|
||||
Category: "crypto",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get xyz dex symbols (stocks, forex, commodities)
|
||||
xyzMids, err := client.GetAllMidsXYZ(ctx)
|
||||
if err == nil {
|
||||
for symbol := range xyzMids {
|
||||
// Remove xyz: prefix for display
|
||||
displaySymbol := strings.TrimPrefix(symbol, "xyz:")
|
||||
category := "stock"
|
||||
if displaySymbol == "GOLD" || displaySymbol == "SILVER" {
|
||||
category = "commodity"
|
||||
} else if displaySymbol == "EUR" || displaySymbol == "JPY" {
|
||||
category = "forex"
|
||||
} else if displaySymbol == "XYZ100" {
|
||||
category = "index"
|
||||
}
|
||||
symbols = append(symbols, SymbolInfo{
|
||||
Symbol: displaySymbol,
|
||||
Name: displaySymbol,
|
||||
Category: category,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Unsupported exchange for symbol listing"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"exchange": exchange,
|
||||
"symbols": symbols,
|
||||
"count": len(symbols),
|
||||
})
|
||||
}
|
||||
343
api/handler_onboarding.go
Normal file
343
api/handler_onboarding.go
Normal file
@@ -0,0 +1,343 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"nofx/logger"
|
||||
"nofx/wallet"
|
||||
|
||||
gethcrypto "github.com/ethereum/go-ethereum/crypto"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type beginnerOnboardingResponse struct {
|
||||
Address string `json:"address"`
|
||||
PrivateKey string `json:"private_key"`
|
||||
Chain string `json:"chain"`
|
||||
Asset string `json:"asset"`
|
||||
Provider string `json:"provider"`
|
||||
DefaultModel string `json:"default_model"`
|
||||
ConfiguredModelID string `json:"configured_model_id"`
|
||||
BalanceUSDC string `json:"balance_usdc"`
|
||||
EnvSaved bool `json:"env_saved"`
|
||||
EnvPath string `json:"env_path,omitempty"`
|
||||
ReusedExisting bool `json:"reused_existing"`
|
||||
EnvWarning string `json:"env_warning,omitempty"`
|
||||
}
|
||||
|
||||
type currentBeginnerWalletResponse struct {
|
||||
Found bool `json:"found"`
|
||||
Address string `json:"address,omitempty"`
|
||||
BalanceUSDC string `json:"balance_usdc,omitempty"`
|
||||
Source string `json:"source,omitempty"`
|
||||
Claw402Status string `json:"claw402_status"`
|
||||
}
|
||||
|
||||
func (s *Server) handleBeginnerOnboarding(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
if userID == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing user context"})
|
||||
return
|
||||
}
|
||||
|
||||
privateKey, address, configuredModelID, reusedExisting, err := s.resolveBeginnerWallet(userID)
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to resolve beginner wallet for user %s: %v", userID, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to prepare beginner wallet"})
|
||||
return
|
||||
}
|
||||
|
||||
if !reusedExisting {
|
||||
if err := s.store.AIModel().Update(userID, "claw402", true, privateKey, "", "glm-5"); err != nil {
|
||||
logger.Errorf("Failed to save beginner claw402 config for user %s: %v", userID, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save beginner model configuration"})
|
||||
return
|
||||
}
|
||||
|
||||
configuredModelID, err = s.findConfiguredClaw402ModelID(userID)
|
||||
if err != nil {
|
||||
logger.Warnf("Could not resolve configured claw402 model id for user %s: %v", userID, err)
|
||||
}
|
||||
}
|
||||
|
||||
os.Setenv("CLAW402_WALLET_KEY", privateKey)
|
||||
os.Setenv("CLAW402_WALLET_ADDRESS", address)
|
||||
os.Setenv("CLAW402_DEFAULT_MODEL", "glm-5")
|
||||
|
||||
envSaved, envPath, envErr := persistBeginnerWalletEnv(privateKey, address)
|
||||
resp := beginnerOnboardingResponse{
|
||||
Address: address,
|
||||
PrivateKey: privateKey,
|
||||
Chain: "base",
|
||||
Asset: "USDC",
|
||||
Provider: "claw402",
|
||||
DefaultModel: "glm-5",
|
||||
ConfiguredModelID: configuredModelID,
|
||||
BalanceUSDC: wallet.QueryUSDCBalanceStr(address),
|
||||
EnvSaved: envSaved,
|
||||
EnvPath: envPath,
|
||||
ReusedExisting: reusedExisting,
|
||||
}
|
||||
if envErr != nil {
|
||||
resp.EnvWarning = envErr.Error()
|
||||
logger.Warnf("Beginner wallet env persistence warning for user %s: %v", userID, envErr)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func (s *Server) handleCurrentBeginnerWallet(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
if userID == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing user context"})
|
||||
return
|
||||
}
|
||||
claw402Status := checkClaw402Health()
|
||||
|
||||
models, err := s.store.AIModel().List(userID)
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to load current beginner wallet for user %s: %v", userID, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load current wallet"})
|
||||
return
|
||||
}
|
||||
|
||||
for _, model := range models {
|
||||
if model == nil || model.Provider != "claw402" {
|
||||
continue
|
||||
}
|
||||
|
||||
privateKey := strings.TrimSpace(model.APIKey.String())
|
||||
if privateKey == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
address, addrErr := walletAddressFromPrivateKey(privateKey)
|
||||
if addrErr != nil {
|
||||
logger.Warnf("Failed to derive current beginner wallet for user %s: %v", userID, addrErr)
|
||||
continue
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, currentBeginnerWalletResponse{
|
||||
Found: true,
|
||||
Address: address,
|
||||
BalanceUSDC: wallet.QueryUSDCBalanceStr(address),
|
||||
Source: "model",
|
||||
Claw402Status: claw402Status,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
address := strings.TrimSpace(os.Getenv("CLAW402_WALLET_ADDRESS"))
|
||||
if address != "" {
|
||||
c.JSON(http.StatusOK, currentBeginnerWalletResponse{
|
||||
Found: true,
|
||||
Address: address,
|
||||
BalanceUSDC: wallet.QueryUSDCBalanceStr(address),
|
||||
Source: "env",
|
||||
Claw402Status: claw402Status,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, currentBeginnerWalletResponse{
|
||||
Found: false,
|
||||
Claw402Status: claw402Status,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) resolveBeginnerWallet(userID string) (privateKey string, address string, configuredModelID string, reused bool, err error) {
|
||||
// 1. Check if current user already has a claw402 wallet
|
||||
models, err := s.store.AIModel().List(userID)
|
||||
if err != nil {
|
||||
return "", "", "", false, err
|
||||
}
|
||||
|
||||
for _, model := range models {
|
||||
if model == nil || model.Provider != "claw402" {
|
||||
continue
|
||||
}
|
||||
existingKey := strings.TrimSpace(model.APIKey.String())
|
||||
if existingKey == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
addr, addrErr := walletAddressFromPrivateKey(existingKey)
|
||||
if addrErr != nil {
|
||||
logger.Warnf("Existing claw402 key for user %s is invalid, regenerating: %v", userID, addrErr)
|
||||
break
|
||||
}
|
||||
|
||||
return existingKey, addr, model.ID, true, nil
|
||||
}
|
||||
|
||||
// 2. Check for orphan claw402 wallet from a previous account (e.g. after account reset).
|
||||
// Adopt it to preserve funds.
|
||||
orphan, orphanErr := s.store.AIModel().FindOrphanClaw402()
|
||||
if orphanErr == nil && orphan != nil {
|
||||
existingKey := strings.TrimSpace(orphan.APIKey.String())
|
||||
if existingKey != "" {
|
||||
addr, addrErr := walletAddressFromPrivateKey(existingKey)
|
||||
if addrErr == nil {
|
||||
if adoptErr := s.store.AIModel().AdoptModel(orphan.ID, userID); adoptErr != nil {
|
||||
logger.Warnf("Failed to adopt orphan claw402 wallet for user %s: %v", userID, adoptErr)
|
||||
} else {
|
||||
logger.Infof("✓ Adopted orphan claw402 wallet %s for new user %s (address: %s)", orphan.ID, userID, addr)
|
||||
return existingKey, addr, orphan.ID, true, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. No existing wallet found — generate a new one
|
||||
privateKeyObj, genErr := gethcrypto.GenerateKey()
|
||||
if genErr != nil {
|
||||
return "", "", "", false, genErr
|
||||
}
|
||||
|
||||
addr := gethcrypto.PubkeyToAddress(privateKeyObj.PublicKey)
|
||||
keyHex := "0x" + hex.EncodeToString(gethcrypto.FromECDSA(privateKeyObj))
|
||||
return keyHex, addr.Hex(), "", false, nil
|
||||
}
|
||||
|
||||
func (s *Server) findConfiguredClaw402ModelID(userID string) (string, error) {
|
||||
models, err := s.store.AIModel().List(userID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
for _, model := range models {
|
||||
if model != nil && model.Provider == "claw402" {
|
||||
return model.ID, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("claw402 model not found")
|
||||
}
|
||||
|
||||
func walletAddressFromPrivateKey(privateKey string) (string, error) {
|
||||
key := strings.TrimSpace(privateKey)
|
||||
if !strings.HasPrefix(key, "0x") {
|
||||
return "", fmt.Errorf("private key must start with 0x")
|
||||
}
|
||||
if len(key) != 66 {
|
||||
return "", fmt.Errorf("private key must be 66 characters")
|
||||
}
|
||||
|
||||
privateKeyObj, err := gethcrypto.HexToECDSA(strings.TrimPrefix(key, "0x"))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return gethcrypto.PubkeyToAddress(privateKeyObj.PublicKey).Hex(), nil
|
||||
}
|
||||
|
||||
func persistBeginnerWalletEnv(privateKey string, address string) (bool, string, error) {
|
||||
paths := uniqueEnvPaths([]string{
|
||||
".env",
|
||||
filepath.Join(".", ".env"),
|
||||
"/app/.env",
|
||||
})
|
||||
|
||||
var lastErr error
|
||||
for _, path := range paths {
|
||||
if path == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if err := upsertEnvFile(path, map[string]string{
|
||||
"CLAW402_WALLET_KEY": privateKey,
|
||||
"CLAW402_WALLET_ADDRESS": address,
|
||||
"CLAW402_DEFAULT_MODEL": "glm-5",
|
||||
}); err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
|
||||
return true, path, nil
|
||||
}
|
||||
|
||||
if lastErr == nil {
|
||||
lastErr = fmt.Errorf("no writable .env path found")
|
||||
}
|
||||
return false, "", lastErr
|
||||
}
|
||||
|
||||
func uniqueEnvPaths(paths []string) []string {
|
||||
seen := make(map[string]struct{}, len(paths))
|
||||
result := make([]string, 0, len(paths))
|
||||
for _, path := range paths {
|
||||
clean := filepath.Clean(path)
|
||||
if _, ok := seen[clean]; ok {
|
||||
continue
|
||||
}
|
||||
seen[clean] = struct{}{}
|
||||
result = append(result, clean)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func upsertEnvFile(path string, values map[string]string) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
existingLines := make([]string, 0)
|
||||
if file, err := os.Open(path); err == nil {
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
existingLines = append(existingLines, scanner.Text())
|
||||
}
|
||||
file.Close()
|
||||
if err := scanner.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
|
||||
remaining := make(map[string]string, len(values))
|
||||
for key, value := range values {
|
||||
remaining[key] = value
|
||||
}
|
||||
|
||||
updatedLines := make([]string, 0, len(existingLines)+len(values))
|
||||
for _, line := range existingLines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" || strings.HasPrefix(trimmed, "#") || !strings.Contains(line, "=") {
|
||||
updatedLines = append(updatedLines, line)
|
||||
continue
|
||||
}
|
||||
|
||||
parts := strings.SplitN(line, "=", 2)
|
||||
key := strings.TrimSpace(parts[0])
|
||||
value, ok := remaining[key]
|
||||
if !ok {
|
||||
updatedLines = append(updatedLines, line)
|
||||
continue
|
||||
}
|
||||
|
||||
updatedLines = append(updatedLines, fmt.Sprintf("%s=%s", key, value))
|
||||
delete(remaining, key)
|
||||
}
|
||||
|
||||
for key, value := range remaining {
|
||||
updatedLines = append(updatedLines, fmt.Sprintf("%s=%s", key, value))
|
||||
}
|
||||
|
||||
content := strings.Join(updatedLines, "\n")
|
||||
if content != "" && !strings.HasSuffix(content, "\n") {
|
||||
content += "\n"
|
||||
}
|
||||
|
||||
if err := os.WriteFile(path, []byte(content), 0600); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
402
api/handler_order.go
Normal file
402
api/handler_order.go
Normal file
@@ -0,0 +1,402 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"nofx/logger"
|
||||
"nofx/market"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// handleTraderList Trader list
|
||||
func (s *Server) handleTraderList(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
traders, err := s.store.Trader().List(userID)
|
||||
if err != nil {
|
||||
SafeInternalError(c, "Failed to get trader list", err)
|
||||
return
|
||||
}
|
||||
|
||||
result := make([]map[string]interface{}, 0, len(traders))
|
||||
for _, trader := range traders {
|
||||
// Get real-time running status
|
||||
isRunning := trader.IsRunning
|
||||
if at, err := s.traderManager.GetTrader(trader.ID); err == nil {
|
||||
status := at.GetStatus()
|
||||
if running, ok := status["is_running"].(bool); ok {
|
||||
isRunning = running
|
||||
}
|
||||
}
|
||||
|
||||
// Get strategy name if strategy_id is set
|
||||
var strategyName string
|
||||
if trader.StrategyID != "" {
|
||||
if strategy, err := s.store.Strategy().Get(userID, trader.StrategyID); err == nil {
|
||||
strategyName = strategy.Name
|
||||
}
|
||||
}
|
||||
|
||||
// Return complete AIModelID (e.g. "admin_deepseek"), don't truncate
|
||||
// Frontend needs complete ID to verify model exists (consistent with handleGetTraderConfig)
|
||||
result = append(result, map[string]interface{}{
|
||||
"trader_id": trader.ID,
|
||||
"trader_name": trader.Name,
|
||||
"ai_model": trader.AIModelID, // Use complete ID
|
||||
"exchange_id": trader.ExchangeID,
|
||||
"is_running": isRunning,
|
||||
"show_in_competition": trader.ShowInCompetition,
|
||||
"initial_balance": trader.InitialBalance,
|
||||
"strategy_id": trader.StrategyID,
|
||||
"strategy_name": strategyName,
|
||||
})
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
// handleGetTraderConfig Get trader detailed configuration
|
||||
func (s *Server) handleGetTraderConfig(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
traderID := c.Param("id")
|
||||
|
||||
if traderID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Trader ID cannot be empty"})
|
||||
return
|
||||
}
|
||||
|
||||
fullCfg, err := s.store.Trader().GetFullConfig(userID, traderID)
|
||||
if err != nil {
|
||||
SafeNotFound(c, "Trader config")
|
||||
return
|
||||
}
|
||||
traderConfig := fullCfg.Trader
|
||||
|
||||
// Get real-time running status
|
||||
isRunning := traderConfig.IsRunning
|
||||
if at, err := s.traderManager.GetTrader(traderID); err == nil {
|
||||
status := at.GetStatus()
|
||||
if running, ok := status["is_running"].(bool); ok {
|
||||
isRunning = running
|
||||
}
|
||||
}
|
||||
|
||||
// Return complete model ID without conversion, consistent with frontend model list
|
||||
aiModelID := traderConfig.AIModelID
|
||||
|
||||
result := map[string]interface{}{
|
||||
"trader_id": traderConfig.ID,
|
||||
"trader_name": traderConfig.Name,
|
||||
"ai_model": aiModelID,
|
||||
"exchange_id": traderConfig.ExchangeID,
|
||||
"strategy_id": traderConfig.StrategyID,
|
||||
"initial_balance": traderConfig.InitialBalance,
|
||||
"scan_interval_minutes": traderConfig.ScanIntervalMinutes,
|
||||
"btc_eth_leverage": traderConfig.BTCETHLeverage,
|
||||
"altcoin_leverage": traderConfig.AltcoinLeverage,
|
||||
"trading_symbols": traderConfig.TradingSymbols,
|
||||
"custom_prompt": traderConfig.CustomPrompt,
|
||||
"override_base_prompt": traderConfig.OverrideBasePrompt,
|
||||
"is_cross_margin": traderConfig.IsCrossMargin,
|
||||
"use_ai500": traderConfig.UseAI500,
|
||||
"use_oi_top": traderConfig.UseOITop,
|
||||
"is_running": isRunning,
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
// handleStatus System status
|
||||
func (s *Server) handleStatus(c *gin.Context) {
|
||||
_, traderID, err := s.getTraderFromQuery(c)
|
||||
if err != nil {
|
||||
SafeBadRequest(c, "Invalid trader ID")
|
||||
return
|
||||
}
|
||||
|
||||
trader, err := s.traderManager.GetTrader(traderID)
|
||||
if err != nil {
|
||||
SafeNotFound(c, "Trader")
|
||||
return
|
||||
}
|
||||
|
||||
status := trader.GetStatus()
|
||||
c.JSON(http.StatusOK, status)
|
||||
}
|
||||
|
||||
// handleAccount Account information
|
||||
func (s *Server) handleAccount(c *gin.Context) {
|
||||
_, traderID, err := s.getTraderFromQuery(c)
|
||||
if err != nil {
|
||||
SafeBadRequest(c, "Invalid trader ID")
|
||||
return
|
||||
}
|
||||
|
||||
trader, err := s.traderManager.GetTrader(traderID)
|
||||
if err != nil {
|
||||
SafeNotFound(c, "Trader")
|
||||
return
|
||||
}
|
||||
|
||||
logger.Infof("📊 Received account info request [%s]", trader.GetName())
|
||||
account, err := trader.GetAccountInfo()
|
||||
if err != nil {
|
||||
SafeInternalError(c, "Get account info", err)
|
||||
return
|
||||
}
|
||||
|
||||
logger.Infof("✓ Returning account info [%s]: equity=%.2f, available=%.2f, pnl=%.2f (%.2f%%)",
|
||||
trader.GetName(),
|
||||
account["total_equity"],
|
||||
account["available_balance"],
|
||||
account["total_pnl"],
|
||||
account["total_pnl_pct"])
|
||||
c.JSON(http.StatusOK, account)
|
||||
}
|
||||
|
||||
// handlePositions Position list
|
||||
func (s *Server) handlePositions(c *gin.Context) {
|
||||
_, traderID, err := s.getTraderFromQuery(c)
|
||||
if err != nil {
|
||||
SafeBadRequest(c, "Invalid trader ID")
|
||||
return
|
||||
}
|
||||
|
||||
trader, err := s.traderManager.GetTrader(traderID)
|
||||
if err != nil {
|
||||
SafeNotFound(c, "Trader")
|
||||
return
|
||||
}
|
||||
|
||||
positions, err := trader.GetPositions()
|
||||
if err != nil {
|
||||
SafeInternalError(c, "Get positions", err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, positions)
|
||||
}
|
||||
|
||||
// handlePositionHistory Historical closed positions with statistics
|
||||
func (s *Server) handlePositionHistory(c *gin.Context) {
|
||||
_, traderID, err := s.getTraderFromQuery(c)
|
||||
if err != nil {
|
||||
SafeBadRequest(c, "Invalid trader ID")
|
||||
return
|
||||
}
|
||||
|
||||
trader, err := s.traderManager.GetTrader(traderID)
|
||||
if err != nil {
|
||||
SafeNotFound(c, "Trader")
|
||||
return
|
||||
}
|
||||
|
||||
// Get optional query parameters
|
||||
limitStr := c.DefaultQuery("limit", "100")
|
||||
limit := 100
|
||||
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 500 {
|
||||
limit = l
|
||||
}
|
||||
|
||||
// Get store
|
||||
store := trader.GetStore()
|
||||
if store == nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Store not available"})
|
||||
return
|
||||
}
|
||||
|
||||
// Get closed positions
|
||||
positions, err := store.Position().GetClosedPositions(trader.GetID(), limit)
|
||||
if err != nil {
|
||||
SafeInternalError(c, "Get position history", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Get statistics
|
||||
stats, _ := store.Position().GetFullStats(trader.GetID())
|
||||
|
||||
// Get symbol stats
|
||||
symbolStats, _ := store.Position().GetSymbolStats(trader.GetID(), 10)
|
||||
|
||||
// Get direction stats
|
||||
directionStats, _ := store.Position().GetDirectionStats(trader.GetID())
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"positions": positions,
|
||||
"stats": stats,
|
||||
"symbol_stats": symbolStats,
|
||||
"direction_stats": directionStats,
|
||||
})
|
||||
}
|
||||
|
||||
// handleTrades Historical trades list
|
||||
func (s *Server) handleTrades(c *gin.Context) {
|
||||
_, traderID, err := s.getTraderFromQuery(c)
|
||||
if err != nil {
|
||||
SafeBadRequest(c, "Invalid trader ID")
|
||||
return
|
||||
}
|
||||
|
||||
trader, err := s.traderManager.GetTrader(traderID)
|
||||
if err != nil {
|
||||
SafeNotFound(c, "Trader")
|
||||
return
|
||||
}
|
||||
|
||||
// Get optional query parameters
|
||||
symbol := c.Query("symbol")
|
||||
limitStr := c.DefaultQuery("limit", "100")
|
||||
limit := 100
|
||||
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 {
|
||||
limit = l
|
||||
}
|
||||
|
||||
// Normalize symbol (add USDT suffix if not present)
|
||||
if symbol != "" {
|
||||
symbol = market.Normalize(symbol)
|
||||
}
|
||||
|
||||
// Get trades from store
|
||||
store := trader.GetStore()
|
||||
if store == nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Store not available"})
|
||||
return
|
||||
}
|
||||
|
||||
allTrades, err := store.Position().GetRecentTrades(trader.GetID(), limit)
|
||||
if err != nil {
|
||||
SafeInternalError(c, "Get trades", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Filter by symbol if specified
|
||||
if symbol != "" {
|
||||
var result []interface{}
|
||||
for _, trade := range allTrades {
|
||||
if trade.Symbol == symbol {
|
||||
result = append(result, trade)
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, result)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, allTrades)
|
||||
}
|
||||
|
||||
// handleOrders Order list (all orders including open, close, stop loss, take profit, etc.)
|
||||
func (s *Server) handleOrders(c *gin.Context) {
|
||||
_, traderID, err := s.getTraderFromQuery(c)
|
||||
if err != nil {
|
||||
SafeBadRequest(c, "Invalid trader ID")
|
||||
return
|
||||
}
|
||||
|
||||
trader, err := s.traderManager.GetTrader(traderID)
|
||||
if err != nil {
|
||||
SafeNotFound(c, "Trader")
|
||||
return
|
||||
}
|
||||
|
||||
// Get optional query parameters
|
||||
symbol := c.Query("symbol")
|
||||
statusFilter := c.Query("status") // NEW, FILLED, CANCELED, etc.
|
||||
limitStr := c.DefaultQuery("limit", "100")
|
||||
limit := 100
|
||||
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 {
|
||||
limit = l
|
||||
}
|
||||
|
||||
// Normalize symbol (add USDT suffix if not present)
|
||||
if symbol != "" {
|
||||
symbol = market.Normalize(symbol)
|
||||
}
|
||||
|
||||
// Get orders from store
|
||||
store := trader.GetStore()
|
||||
if store == nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Store not available"})
|
||||
return
|
||||
}
|
||||
|
||||
// Get orders with filters applied at database level
|
||||
orders, err := store.Order().GetTraderOrdersFiltered(trader.GetID(), symbol, statusFilter, limit)
|
||||
if err != nil {
|
||||
SafeInternalError(c, "Get orders", err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, orders)
|
||||
}
|
||||
|
||||
// handleOrderFills Order fill details (all fills for a specific order)
|
||||
func (s *Server) handleOrderFills(c *gin.Context) {
|
||||
orderIDStr := c.Param("id")
|
||||
orderID, err := strconv.ParseInt(orderIDStr, 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid order ID"})
|
||||
return
|
||||
}
|
||||
|
||||
_, traderID, err := s.getTraderFromQuery(c)
|
||||
if err != nil {
|
||||
SafeBadRequest(c, "Invalid trader ID")
|
||||
return
|
||||
}
|
||||
|
||||
trader, err := s.traderManager.GetTrader(traderID)
|
||||
if err != nil {
|
||||
SafeNotFound(c, "Trader")
|
||||
return
|
||||
}
|
||||
|
||||
store := trader.GetStore()
|
||||
if store == nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Store not available"})
|
||||
return
|
||||
}
|
||||
|
||||
// Get fills for this order
|
||||
fills, err := store.Order().GetOrderFills(orderID)
|
||||
if err != nil {
|
||||
SafeInternalError(c, "Get order fills", err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, fills)
|
||||
}
|
||||
|
||||
// handleOpenOrders Get open orders (pending SL/TP) from exchange
|
||||
func (s *Server) handleOpenOrders(c *gin.Context) {
|
||||
_, traderID, err := s.getTraderFromQuery(c)
|
||||
if err != nil {
|
||||
SafeBadRequest(c, "Invalid trader ID")
|
||||
return
|
||||
}
|
||||
|
||||
trader, err := s.traderManager.GetTrader(traderID)
|
||||
if err != nil {
|
||||
SafeNotFound(c, "Trader")
|
||||
return
|
||||
}
|
||||
|
||||
// Get symbol parameter (required for exchange query)
|
||||
symbol := c.Query("symbol")
|
||||
if symbol == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "symbol parameter is required"})
|
||||
return
|
||||
}
|
||||
|
||||
// Normalize symbol
|
||||
symbol = market.Normalize(symbol)
|
||||
|
||||
// Get open orders from exchange
|
||||
openOrders, err := trader.GetOpenOrders(symbol)
|
||||
if err != nil {
|
||||
SafeInternalError(c, "Get open orders", err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, openOrders)
|
||||
}
|
||||
105
api/handler_telegram.go
Normal file
105
api/handler_telegram.go
Normal file
@@ -0,0 +1,105 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// handleGetTelegramConfig returns current Telegram bot configuration and binding status
|
||||
func (s *Server) handleGetTelegramConfig(c *gin.Context) {
|
||||
cfg, err := s.store.TelegramConfig().Get()
|
||||
if err != nil {
|
||||
// Not configured yet - return empty state
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"configured": false,
|
||||
"is_bound": false,
|
||||
"token_masked": "",
|
||||
"username": "",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Mask bot token for security (show only last 6 chars)
|
||||
tokenMasked := ""
|
||||
if cfg.BotToken != "" {
|
||||
if len(cfg.BotToken) > 6 {
|
||||
tokenMasked = "***" + cfg.BotToken[len(cfg.BotToken)-6:]
|
||||
} else {
|
||||
tokenMasked = "***"
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"configured": cfg.BotToken != "",
|
||||
"is_bound": cfg.ChatID != 0,
|
||||
"username": cfg.Username,
|
||||
"bound_at": cfg.BoundAt,
|
||||
"token_masked": tokenMasked,
|
||||
"model_id": cfg.ModelID,
|
||||
})
|
||||
}
|
||||
|
||||
// handleUpdateTelegramConfig saves bot token (+ optional model ID) and triggers bot hot-reload
|
||||
func (s *Server) handleUpdateTelegramConfig(c *gin.Context) {
|
||||
var req struct {
|
||||
BotToken string `json:"bot_token"`
|
||||
ModelID string `json:"model_id"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
||||
return
|
||||
}
|
||||
if req.BotToken == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "bot_token is required"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.store.TelegramConfig().Save(req.BotToken, req.ModelID); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save config"})
|
||||
return
|
||||
}
|
||||
|
||||
// Signal bot hot-reload if channel is available
|
||||
if s.telegramReloadCh != nil {
|
||||
select {
|
||||
case s.telegramReloadCh <- struct{}{}:
|
||||
default: // non-blocking
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "Bot token saved. Bot will reload automatically."})
|
||||
}
|
||||
|
||||
// handleUnbindTelegram removes Telegram user binding
|
||||
func (s *Server) handleUnbindTelegram(c *gin.Context) {
|
||||
if err := s.store.TelegramConfig().Unbind(); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to unbind"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "Telegram binding removed"})
|
||||
}
|
||||
|
||||
// handleUpdateTelegramModel updates only the AI model used for Telegram replies (no token re-entry needed)
|
||||
func (s *Server) handleUpdateTelegramModel(c *gin.Context) {
|
||||
var req struct {
|
||||
ModelID string `json:"model_id"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
||||
return
|
||||
}
|
||||
|
||||
cfg, err := s.store.TelegramConfig().Get()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no Telegram config found, save a bot token first"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.store.TelegramConfig().Save(cfg.BotToken, req.ModelID); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save model config"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "model_id": req.ModelID})
|
||||
}
|
||||
871
api/handler_trader.go
Normal file
871
api/handler_trader.go
Normal file
@@ -0,0 +1,871 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"nofx/logger"
|
||||
"nofx/store"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// AI trader management related structures
|
||||
type CreateTraderRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
AIModelID string `json:"ai_model_id" binding:"required"`
|
||||
ExchangeID string `json:"exchange_id" binding:"required"`
|
||||
StrategyID string `json:"strategy_id"` // Strategy ID (new version)
|
||||
InitialBalance float64 `json:"initial_balance"`
|
||||
ScanIntervalMinutes int `json:"scan_interval_minutes"`
|
||||
IsCrossMargin *bool `json:"is_cross_margin"` // Pointer type, nil means use default value true
|
||||
ShowInCompetition *bool `json:"show_in_competition"` // Pointer type, nil means use default value true
|
||||
// The following fields are kept for backward compatibility, new version uses strategy config
|
||||
BTCETHLeverage int `json:"btc_eth_leverage"`
|
||||
AltcoinLeverage int `json:"altcoin_leverage"`
|
||||
TradingSymbols string `json:"trading_symbols"`
|
||||
CustomPrompt string `json:"custom_prompt"`
|
||||
OverrideBasePrompt bool `json:"override_base_prompt"`
|
||||
SystemPromptTemplate string `json:"system_prompt_template"` // System prompt template name
|
||||
UseAI500 bool `json:"use_ai500"`
|
||||
UseOITop bool `json:"use_oi_top"`
|
||||
}
|
||||
|
||||
// UpdateTraderRequest Update trader request
|
||||
type UpdateTraderRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
AIModelID string `json:"ai_model_id" binding:"required"`
|
||||
ExchangeID string `json:"exchange_id" binding:"required"`
|
||||
StrategyID string `json:"strategy_id"` // Strategy ID (new version)
|
||||
InitialBalance float64 `json:"initial_balance"`
|
||||
ScanIntervalMinutes int `json:"scan_interval_minutes"`
|
||||
IsCrossMargin *bool `json:"is_cross_margin"`
|
||||
ShowInCompetition *bool `json:"show_in_competition"`
|
||||
// The following fields are kept for backward compatibility, new version uses strategy config
|
||||
BTCETHLeverage int `json:"btc_eth_leverage"`
|
||||
AltcoinLeverage int `json:"altcoin_leverage"`
|
||||
TradingSymbols string `json:"trading_symbols"`
|
||||
CustomPrompt string `json:"custom_prompt"`
|
||||
OverrideBasePrompt bool `json:"override_base_prompt"`
|
||||
SystemPromptTemplate string `json:"system_prompt_template"`
|
||||
}
|
||||
|
||||
func formatTraderCreationError(reason, nextStep string) string {
|
||||
if nextStep == "" {
|
||||
return fmt.Sprintf("这次未能创建机器人:%s。", reason)
|
||||
}
|
||||
return fmt.Sprintf("这次未能创建机器人:%s。%s。", reason, nextStep)
|
||||
}
|
||||
|
||||
func traderCreationRequestError(reason string) string {
|
||||
return formatTraderCreationError(reason, "请检查你刚刚填写的内容后,再重新提交")
|
||||
}
|
||||
|
||||
func exchangeDisplayName(exchange *store.Exchange) string {
|
||||
if exchange == nil {
|
||||
return "所选交易所账户"
|
||||
}
|
||||
if exchange.AccountName != "" {
|
||||
return fmt.Sprintf("%s(%s)", exchange.Name, exchange.AccountName)
|
||||
}
|
||||
if exchange.Name != "" {
|
||||
return exchange.Name
|
||||
}
|
||||
return "所选交易所账户"
|
||||
}
|
||||
|
||||
func missingExchangeFields(exchange *store.Exchange) []string {
|
||||
if exchange == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var missing []string
|
||||
switch exchange.ExchangeType {
|
||||
case "binance", "bybit", "gate", "indodax":
|
||||
if exchange.APIKey == "" {
|
||||
missing = append(missing, "API Key")
|
||||
}
|
||||
if exchange.SecretKey == "" {
|
||||
missing = append(missing, "Secret Key")
|
||||
}
|
||||
case "okx", "bitget", "kucoin":
|
||||
if exchange.APIKey == "" {
|
||||
missing = append(missing, "API Key")
|
||||
}
|
||||
if exchange.SecretKey == "" {
|
||||
missing = append(missing, "Secret Key")
|
||||
}
|
||||
if exchange.Passphrase == "" {
|
||||
missing = append(missing, "Passphrase")
|
||||
}
|
||||
case "hyperliquid":
|
||||
if exchange.APIKey == "" {
|
||||
missing = append(missing, "私钥")
|
||||
}
|
||||
if strings.TrimSpace(exchange.HyperliquidWalletAddr) == "" {
|
||||
missing = append(missing, "钱包地址")
|
||||
}
|
||||
case "aster":
|
||||
if strings.TrimSpace(exchange.AsterUser) == "" {
|
||||
missing = append(missing, "Aster User")
|
||||
}
|
||||
if strings.TrimSpace(exchange.AsterSigner) == "" {
|
||||
missing = append(missing, "Aster Signer")
|
||||
}
|
||||
if exchange.AsterPrivateKey == "" {
|
||||
missing = append(missing, "Aster Private Key")
|
||||
}
|
||||
case "lighter":
|
||||
if strings.TrimSpace(exchange.LighterWalletAddr) == "" {
|
||||
missing = append(missing, "钱包地址")
|
||||
}
|
||||
if exchange.LighterAPIKeyPrivateKey == "" {
|
||||
missing = append(missing, "API Key Private Key")
|
||||
}
|
||||
}
|
||||
|
||||
return missing
|
||||
}
|
||||
|
||||
func mapStringPairs(kv ...string) map[string]string {
|
||||
if len(kv) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
params := make(map[string]string, len(kv)/2)
|
||||
for i := 0; i+1 < len(kv); i += 2 {
|
||||
params[kv[i]] = kv[i+1]
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
func validateExchangeForTraderCreation(exchange *store.Exchange) (string, string, map[string]string) {
|
||||
if exchange == nil {
|
||||
return formatTraderCreationError("还没有找到你选择的交易所账户", "请前往「设置 > 交易所配置」先添加一个可用账户,再回来创建机器人"),
|
||||
"trader.create.exchange_not_found", nil
|
||||
}
|
||||
if !exchange.Enabled {
|
||||
return formatTraderCreationError(
|
||||
fmt.Sprintf("交易所账户「%s」目前处于未启用状态", exchangeDisplayName(exchange)),
|
||||
"请前往「设置 > 交易所配置」启用该账户后,再重新创建机器人",
|
||||
), "trader.create.exchange_disabled", mapStringPairs("exchange_name", exchangeDisplayName(exchange))
|
||||
}
|
||||
|
||||
missing := missingExchangeFields(exchange)
|
||||
if len(missing) > 0 {
|
||||
return formatTraderCreationError(
|
||||
fmt.Sprintf("交易所账户「%s」的配置还不完整,缺少 %s", exchangeDisplayName(exchange), strings.Join(missing, "、")),
|
||||
"请前往「设置 > 交易所配置」补全该账户的必填信息后,再重新创建机器人",
|
||||
), "trader.create.exchange_missing_fields", mapStringPairs(
|
||||
"exchange_name", exchangeDisplayName(exchange),
|
||||
"missing_fields", strings.Join(missing, ", "),
|
||||
)
|
||||
}
|
||||
|
||||
switch exchange.ExchangeType {
|
||||
case "binance", "bybit", "okx", "bitget", "gate", "kucoin", "hyperliquid", "aster", "lighter", "indodax":
|
||||
return "", "", nil
|
||||
default:
|
||||
return formatTraderCreationError(
|
||||
fmt.Sprintf("交易所账户「%s」使用了当前版本暂不支持的类型 %s", exchangeDisplayName(exchange), exchange.ExchangeType),
|
||||
"请改用当前版本支持的交易所账户后,再重新创建机器人",
|
||||
), "trader.create.exchange_unsupported", mapStringPairs(
|
||||
"exchange_name", exchangeDisplayName(exchange),
|
||||
"exchange_type", exchange.ExchangeType,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func classifyTraderSetupReason(reason string) (string, string) {
|
||||
trimmed := strings.TrimSpace(reason)
|
||||
if trimmed == "" {
|
||||
return "", ""
|
||||
}
|
||||
|
||||
lower := strings.ToLower(trimmed)
|
||||
|
||||
switch {
|
||||
case strings.Contains(lower, "failed to parse strategy config"),
|
||||
strings.Contains(lower, "failed to parse strategy configuration"):
|
||||
return "trader.reason.strategy_config_invalid", "当前策略配置内容已损坏,系统暂时无法解析"
|
||||
case strings.Contains(lower, "has no strategy configured"):
|
||||
return "trader.reason.strategy_missing", "当前机器人缺少有效的交易策略配置"
|
||||
case strings.Contains(lower, "failed to parse private key"),
|
||||
(strings.Contains(lower, "invalid hex character") && strings.Contains(lower, "private key")):
|
||||
return "trader.reason.private_key_invalid", "私钥格式不正确,系统无法识别"
|
||||
case strings.Contains(lower, "failed to initialize hyperliquid trader"):
|
||||
return "trader.reason.hyperliquid_init_failed", "Hyperliquid 账户初始化失败,请确认私钥、主钱包地址和 Agent Wallet 配置是否正确"
|
||||
case strings.Contains(lower, "failed to initialize aster trader"):
|
||||
return "trader.reason.aster_init_failed", "Aster 账户初始化失败,请确认 Aster User、Signer 和私钥是否正确"
|
||||
case strings.Contains(lower, "failed to get meta information"):
|
||||
return "trader.reason.exchange_meta_unavailable", "系统暂时无法从交易所读取账户元信息"
|
||||
case strings.Contains(lower, "security check failed") && strings.Contains(lower, "agent wallet balance too high"):
|
||||
return "trader.reason.hyperliquid_agent_balance_too_high", "Hyperliquid Agent Wallet 余额过高,不符合当前安全要求"
|
||||
case strings.Contains(lower, "failed to initialize account"):
|
||||
return "trader.reason.exchange_account_init_failed", "交易所账户初始化失败,请确认钱包地址和 API Key 是否匹配"
|
||||
case strings.Contains(lower, "unsupported trading platform"):
|
||||
return "trader.reason.exchange_unsupported", "当前交易所类型暂不支持机器人初始化"
|
||||
case strings.Contains(lower, "initial balance not set and unable to fetch balance from exchange"):
|
||||
return "trader.reason.exchange_balance_unavailable", "系统暂时无法从交易所读取账户余额"
|
||||
case strings.Contains(lower, "timeout"), strings.Contains(lower, "no such host"), strings.Contains(lower, "connection refused"):
|
||||
return "trader.reason.exchange_service_unreachable", "系统暂时无法连接交易所服务"
|
||||
default:
|
||||
return "trader.reason.unknown", trimmed
|
||||
}
|
||||
}
|
||||
|
||||
func humanizeTraderSetupReason(reason string) string {
|
||||
_, message := classifyTraderSetupReason(reason)
|
||||
return message
|
||||
}
|
||||
|
||||
func traderSetupReasonParams(err error, fallback string, kv ...string) map[string]string {
|
||||
params := mapStringPairs(kv...)
|
||||
rawReason := SanitizeError(err, fallback)
|
||||
reasonKey, reasonMessage := classifyTraderSetupReason(rawReason)
|
||||
if reasonMessage == "" && fallback != "" {
|
||||
reasonMessage = fallback
|
||||
}
|
||||
if reasonMessage != "" {
|
||||
if params == nil {
|
||||
params = map[string]string{}
|
||||
}
|
||||
params["reason"] = reasonMessage
|
||||
}
|
||||
if reasonKey != "" {
|
||||
if params == nil {
|
||||
params = map[string]string{}
|
||||
}
|
||||
params["reason_key"] = reasonKey
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
func describeTraderLoadError(traderName string, err error) string {
|
||||
if err == nil {
|
||||
return formatTraderCreationError("机器人配置虽然保存了,但运行实例没有成功初始化", "请检查模型、策略和交易所配置是否完整,然后再试一次")
|
||||
}
|
||||
|
||||
reason := humanizeTraderSetupReason(SanitizeError(err, ""))
|
||||
if reason == "" {
|
||||
return formatTraderCreationError(
|
||||
fmt.Sprintf("机器人「%s」在初始化运行实例时没有成功启动", traderName),
|
||||
"请检查模型、策略和交易所配置是否完整,然后再试一次",
|
||||
)
|
||||
}
|
||||
|
||||
return formatTraderCreationError(
|
||||
fmt.Sprintf("机器人「%s」在初始化运行实例时没有成功启动,原因是:%s", traderName, reason),
|
||||
"请检查模型、策略和交易所配置是否完整,然后再试一次",
|
||||
)
|
||||
}
|
||||
|
||||
func describeTraderCreationWarning(traderName string, err error) string {
|
||||
if err == nil {
|
||||
return fmt.Sprintf("机器人「%s」已经保存,但当前还没有通过启动前校验。请先检查模型、策略和交易所配置,修正后再点击启动。", traderName)
|
||||
}
|
||||
|
||||
reason := humanizeTraderSetupReason(SanitizeError(err, ""))
|
||||
if reason == "" {
|
||||
return fmt.Sprintf("机器人「%s」已经保存,但当前暂时还不能启动。请先检查模型、策略和交易所配置,修正后再点击启动。", traderName)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("机器人「%s」已经保存,但当前暂时还不能启动,原因是:%s。请先检查模型、策略和交易所配置,修正后再点击启动。", traderName, reason)
|
||||
}
|
||||
|
||||
func describeTraderStartError(traderName string, err error) string {
|
||||
if err == nil {
|
||||
return fmt.Sprintf("这次未能启动机器人:机器人「%s」暂时还不能启动。请检查模型、策略和交易所配置后,再重新点击启动。", traderName)
|
||||
}
|
||||
|
||||
reason := humanizeTraderSetupReason(SanitizeError(err, ""))
|
||||
if reason == "" {
|
||||
return fmt.Sprintf("这次未能启动机器人:机器人「%s」暂时还不能启动。请检查模型、策略和交易所配置后,再重新点击启动。", traderName)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("这次未能启动机器人:机器人「%s」暂时还不能启动,原因是:%s。请检查模型、策略和交易所配置后,再重新点击启动。", traderName, reason)
|
||||
}
|
||||
|
||||
func formatTraderStartError(reason, nextStep string) string {
|
||||
if nextStep == "" {
|
||||
return fmt.Sprintf("这次未能启动机器人:%s。", reason)
|
||||
}
|
||||
return fmt.Sprintf("这次未能启动机器人:%s。%s。", reason, nextStep)
|
||||
}
|
||||
|
||||
// handleCreateTrader Create new AI trader
|
||||
func (s *Server) handleCreateTrader(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
var req CreateTraderRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
SafeBadRequestWithDetails(c, traderCreationRequestError("提交的信息不完整,或者格式不正确"), "trader.create.invalid_request", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate leverage values
|
||||
if req.BTCETHLeverage < 0 || req.BTCETHLeverage > 50 {
|
||||
SafeBadRequestWithDetails(c, traderCreationRequestError("BTC/ETH 杠杆倍数需要在 1 到 50 倍之间"), "trader.create.invalid_btc_eth_leverage", nil)
|
||||
return
|
||||
}
|
||||
if req.AltcoinLeverage < 0 || req.AltcoinLeverage > 20 {
|
||||
SafeBadRequestWithDetails(c, traderCreationRequestError("山寨币杠杆倍数需要在 1 到 20 倍之间"), "trader.create.invalid_altcoin_leverage", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate trading symbol format
|
||||
if req.TradingSymbols != "" {
|
||||
symbols := strings.Split(req.TradingSymbols, ",")
|
||||
for _, symbol := range symbols {
|
||||
symbol = strings.TrimSpace(symbol)
|
||||
if symbol != "" && !strings.HasSuffix(strings.ToUpper(symbol), "USDT") {
|
||||
SafeBadRequestWithDetails(c, traderCreationRequestError(
|
||||
fmt.Sprintf("交易对 %s 的格式不正确,目前只支持以 USDT 结尾的合约交易对", symbol),
|
||||
), "trader.create.invalid_symbol", mapStringPairs("symbol", symbol))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
model, err := s.store.AIModel().Get(userID, req.AIModelID)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
SafeBadRequestWithDetails(c, formatTraderCreationError("还没有找到你选择的 AI 模型", "请前往「设置 > 模型配置」先添加并启用一个可用模型,再回来创建机器人"), "trader.create.model_not_found", nil)
|
||||
return
|
||||
}
|
||||
SafeError(c, http.StatusInternalServerError,
|
||||
formatTraderCreationError("暂时无法读取你的 AI 模型配置", "请稍后重试;如果问题持续,再检查本地服务是否正常"),
|
||||
err,
|
||||
)
|
||||
return
|
||||
}
|
||||
if !model.Enabled {
|
||||
SafeBadRequestWithDetails(c, formatTraderCreationError(
|
||||
fmt.Sprintf("AI 模型「%s」目前还没有启用", model.Name),
|
||||
"请前往「设置 > 模型配置」启用它后,再重新创建机器人",
|
||||
), "trader.create.model_disabled", mapStringPairs("model_name", model.Name))
|
||||
return
|
||||
}
|
||||
if model.APIKey == "" {
|
||||
SafeBadRequestWithDetails(c, formatTraderCreationError(
|
||||
fmt.Sprintf("AI 模型「%s」缺少 API Key 或支付凭证", model.Name),
|
||||
"请前往「设置 > 模型配置」补全模型凭证后,再重新创建机器人",
|
||||
), "trader.create.model_missing_credentials", mapStringPairs("model_name", model.Name))
|
||||
return
|
||||
}
|
||||
|
||||
if req.StrategyID == "" {
|
||||
SafeBadRequestWithDetails(c, formatTraderCreationError("你还没有选择交易策略", "请先选择一个策略,再继续创建机器人"), "trader.create.strategy_required", nil)
|
||||
return
|
||||
}
|
||||
|
||||
if req.StrategyID != "" {
|
||||
_, err = s.store.Strategy().Get(userID, req.StrategyID)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
SafeBadRequestWithDetails(c, formatTraderCreationError("你选择的策略不存在,或者已经被删除了", "请重新选择一个可用策略后,再继续创建机器人"), "trader.create.strategy_not_found", nil)
|
||||
return
|
||||
}
|
||||
SafeError(c, http.StatusInternalServerError,
|
||||
formatTraderCreationError("暂时无法读取你选择的策略配置", "请稍后重试;如果问题持续,再检查本地服务是否正常"),
|
||||
err,
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Generate trader ID (use short UUID prefix for readability)
|
||||
exchangeIDShort := req.ExchangeID
|
||||
if len(exchangeIDShort) > 8 {
|
||||
exchangeIDShort = exchangeIDShort[:8]
|
||||
}
|
||||
traderID := fmt.Sprintf("%s_%s_%d", exchangeIDShort, req.AIModelID, time.Now().Unix())
|
||||
|
||||
// Set default values
|
||||
isCrossMargin := true // Default to cross margin mode
|
||||
if req.IsCrossMargin != nil {
|
||||
isCrossMargin = *req.IsCrossMargin
|
||||
}
|
||||
|
||||
showInCompetition := true // Default to show in competition
|
||||
if req.ShowInCompetition != nil {
|
||||
showInCompetition = *req.ShowInCompetition
|
||||
}
|
||||
|
||||
// Set leverage default values
|
||||
btcEthLeverage := 10 // Default value
|
||||
altcoinLeverage := 5 // Default value
|
||||
if req.BTCETHLeverage > 0 {
|
||||
btcEthLeverage = req.BTCETHLeverage
|
||||
}
|
||||
if req.AltcoinLeverage > 0 {
|
||||
altcoinLeverage = req.AltcoinLeverage
|
||||
}
|
||||
|
||||
// Set system prompt template default value
|
||||
systemPromptTemplate := "default"
|
||||
if req.SystemPromptTemplate != "" {
|
||||
systemPromptTemplate = req.SystemPromptTemplate
|
||||
}
|
||||
|
||||
// Set scan interval default value
|
||||
scanIntervalMinutes := req.ScanIntervalMinutes
|
||||
if scanIntervalMinutes < 3 {
|
||||
scanIntervalMinutes = 3 // Default 3 minutes, not allowed to be less than 3
|
||||
}
|
||||
|
||||
// Query exchange actual balance, override user input
|
||||
actualBalance := req.InitialBalance // Default to use user input
|
||||
exchanges, err := s.store.Exchange().List(userID)
|
||||
if err != nil {
|
||||
SafeError(c, http.StatusInternalServerError,
|
||||
formatTraderCreationError("暂时无法读取你的交易所配置", "请稍后重试;如果问题持续,再检查本地服务是否正常"),
|
||||
err,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Find matching exchange configuration
|
||||
var exchangeCfg *store.Exchange
|
||||
for _, ex := range exchanges {
|
||||
if ex.ID == req.ExchangeID {
|
||||
exchangeCfg = ex
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if exchangeMsg, exchangeErrorKey, exchangeErrorParams := validateExchangeForTraderCreation(exchangeCfg); exchangeMsg != "" {
|
||||
SafeBadRequestWithDetails(c, exchangeMsg, exchangeErrorKey, exchangeErrorParams)
|
||||
return
|
||||
}
|
||||
|
||||
{
|
||||
tempTrader, createErr := buildExchangeProbeTrader(exchangeCfg, userID)
|
||||
if createErr != nil {
|
||||
SafeBadRequestWithDetails(c, formatTraderCreationError(
|
||||
fmt.Sprintf("交易所账户「%s」没有通过初始化校验,原因是:%s", exchangeDisplayName(exchangeCfg), humanizeTraderSetupReason(SanitizeError(createErr, "配置校验未通过"))),
|
||||
"请前往「设置 > 交易所配置」检查这个账户的密钥、地址和账户信息是否填写正确",
|
||||
), "trader.create.exchange_probe_failed", traderSetupReasonParams(createErr, "配置校验未通过",
|
||||
"exchange_name", exchangeDisplayName(exchangeCfg),
|
||||
))
|
||||
return
|
||||
} else if tempTrader != nil {
|
||||
// Query actual balance
|
||||
balanceInfo, balanceErr := tempTrader.GetBalance()
|
||||
if balanceErr != nil {
|
||||
logger.Infof("⚠️ Failed to query exchange balance, using user input for initial balance: %v", balanceErr)
|
||||
} else {
|
||||
if extractedBalance, found := extractExchangeTotalEquity(balanceInfo); found {
|
||||
actualBalance = extractedBalance
|
||||
logger.Infof("✓ Queried exchange total equity: %.2f %s (user input: %.2f)",
|
||||
actualBalance, accountAssetForExchange(exchangeCfg.ExchangeType), req.InitialBalance)
|
||||
} else {
|
||||
logger.Infof("⚠️ Unable to extract total equity from balance info, balanceInfo=%v, using user input for initial balance", balanceInfo)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create trader configuration (database entity)
|
||||
logger.Infof("🔧 DEBUG: Starting to create trader config, ID=%s, Name=%s, AIModel=%s, Exchange=%s, StrategyID=%s", traderID, req.Name, req.AIModelID, req.ExchangeID, req.StrategyID)
|
||||
traderRecord := &store.Trader{
|
||||
ID: traderID,
|
||||
UserID: userID,
|
||||
Name: req.Name,
|
||||
AIModelID: req.AIModelID,
|
||||
ExchangeID: req.ExchangeID,
|
||||
StrategyID: req.StrategyID, // Associated strategy ID (new version)
|
||||
InitialBalance: actualBalance, // Use actual queried balance
|
||||
BTCETHLeverage: btcEthLeverage,
|
||||
AltcoinLeverage: altcoinLeverage,
|
||||
TradingSymbols: req.TradingSymbols,
|
||||
UseAI500: req.UseAI500,
|
||||
UseOITop: req.UseOITop,
|
||||
CustomPrompt: req.CustomPrompt,
|
||||
OverrideBasePrompt: req.OverrideBasePrompt,
|
||||
SystemPromptTemplate: systemPromptTemplate,
|
||||
IsCrossMargin: isCrossMargin,
|
||||
ShowInCompetition: showInCompetition,
|
||||
ScanIntervalMinutes: scanIntervalMinutes,
|
||||
IsRunning: false,
|
||||
}
|
||||
|
||||
// Save to database
|
||||
logger.Infof("🔧 DEBUG: Preparing to call CreateTrader")
|
||||
err = s.store.Trader().Create(traderRecord)
|
||||
if err != nil {
|
||||
logger.Infof("❌ Failed to create trader: %v", err)
|
||||
publicMsg := SanitizeError(err, formatTraderCreationError("机器人配置没有保存成功", "请检查名称、模型、策略和交易所配置后,再试一次"))
|
||||
statusCode := http.StatusBadRequest
|
||||
if publicMsg == formatTraderCreationError("机器人配置没有保存成功", "请检查名称、模型、策略和交易所配置后,再试一次") {
|
||||
statusCode = http.StatusInternalServerError
|
||||
}
|
||||
SafeError(c, statusCode, publicMsg, err)
|
||||
return
|
||||
}
|
||||
logger.Infof("🔧 DEBUG: CreateTrader succeeded")
|
||||
|
||||
// Immediately load new trader into TraderManager
|
||||
logger.Infof("🔧 DEBUG: Preparing to call LoadUserTraders")
|
||||
startupWarning := ""
|
||||
err = s.traderManager.LoadUserTradersFromStore(s.store, userID)
|
||||
if err != nil {
|
||||
logger.Infof("⚠️ Failed to load user traders into memory: %v", err)
|
||||
startupWarning = describeTraderCreationWarning(req.Name, err)
|
||||
}
|
||||
logger.Infof("🔧 DEBUG: LoadUserTraders completed")
|
||||
|
||||
if startupWarning == "" {
|
||||
if loadErr := s.traderManager.GetLoadError(traderID); loadErr != nil {
|
||||
logger.Infof("⚠️ Trader %s failed to load after creation: %v", traderID, loadErr)
|
||||
startupWarning = describeTraderCreationWarning(req.Name, loadErr)
|
||||
}
|
||||
}
|
||||
|
||||
if startupWarning == "" {
|
||||
if _, getErr := s.traderManager.GetTrader(traderID); getErr != nil {
|
||||
logger.Infof("⚠️ Trader %s not found in memory after creation: %v", traderID, getErr)
|
||||
startupWarning = describeTraderCreationWarning(req.Name, getErr)
|
||||
}
|
||||
}
|
||||
|
||||
logger.Infof("✓ Trader created successfully: %s (model: %s, exchange: %s)", req.Name, req.AIModelID, req.ExchangeID)
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"trader_id": traderID,
|
||||
"trader_name": req.Name,
|
||||
"ai_model": req.AIModelID,
|
||||
"is_running": false,
|
||||
"startup_warning": startupWarning,
|
||||
})
|
||||
}
|
||||
|
||||
// handleUpdateTrader Update trader configuration
|
||||
func (s *Server) handleUpdateTrader(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
traderID := c.Param("id")
|
||||
|
||||
var req UpdateTraderRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
SafeBadRequest(c, "Invalid request parameters")
|
||||
return
|
||||
}
|
||||
|
||||
// Check if trader exists and belongs to current user
|
||||
traders, err := s.store.Trader().List(userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get trader list"})
|
||||
return
|
||||
}
|
||||
|
||||
var existingTrader *store.Trader
|
||||
for _, t := range traders {
|
||||
if t.ID == traderID {
|
||||
existingTrader = t
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if existingTrader == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Trader does not exist"})
|
||||
return
|
||||
}
|
||||
|
||||
// Set default values
|
||||
isCrossMargin := existingTrader.IsCrossMargin // Keep original value
|
||||
if req.IsCrossMargin != nil {
|
||||
isCrossMargin = *req.IsCrossMargin
|
||||
}
|
||||
|
||||
showInCompetition := existingTrader.ShowInCompetition // Keep original value
|
||||
if req.ShowInCompetition != nil {
|
||||
showInCompetition = *req.ShowInCompetition
|
||||
}
|
||||
|
||||
// Set leverage default values
|
||||
btcEthLeverage := req.BTCETHLeverage
|
||||
altcoinLeverage := req.AltcoinLeverage
|
||||
if btcEthLeverage <= 0 {
|
||||
btcEthLeverage = existingTrader.BTCETHLeverage // Keep original value
|
||||
}
|
||||
if altcoinLeverage <= 0 {
|
||||
altcoinLeverage = existingTrader.AltcoinLeverage // Keep original value
|
||||
}
|
||||
|
||||
// Set scan interval, allow updates
|
||||
scanIntervalMinutes := req.ScanIntervalMinutes
|
||||
logger.Infof("📊 Update trader scan_interval: req=%d, existing=%d", req.ScanIntervalMinutes, existingTrader.ScanIntervalMinutes)
|
||||
if scanIntervalMinutes <= 0 {
|
||||
scanIntervalMinutes = existingTrader.ScanIntervalMinutes // Keep original value
|
||||
} else if scanIntervalMinutes < 3 {
|
||||
scanIntervalMinutes = 3
|
||||
}
|
||||
logger.Infof("📊 Final scan_interval_minutes: %d", scanIntervalMinutes)
|
||||
|
||||
// Set system prompt template
|
||||
systemPromptTemplate := req.SystemPromptTemplate
|
||||
if systemPromptTemplate == "" {
|
||||
systemPromptTemplate = existingTrader.SystemPromptTemplate // Keep original value
|
||||
}
|
||||
|
||||
// Handle strategy ID (if not provided, keep original value)
|
||||
strategyID := req.StrategyID
|
||||
if strategyID == "" {
|
||||
strategyID = existingTrader.StrategyID
|
||||
}
|
||||
|
||||
exchangeChanged := req.ExchangeID != "" && req.ExchangeID != existingTrader.ExchangeID
|
||||
resetInitialBalance := exchangeChanged && req.InitialBalance <= 0
|
||||
|
||||
initialBalance := existingTrader.InitialBalance
|
||||
if req.InitialBalance > 0 {
|
||||
initialBalance = req.InitialBalance
|
||||
}
|
||||
if resetInitialBalance {
|
||||
initialBalance = 0
|
||||
}
|
||||
|
||||
// Update trader configuration
|
||||
traderRecord := &store.Trader{
|
||||
ID: traderID,
|
||||
UserID: userID,
|
||||
Name: req.Name,
|
||||
AIModelID: req.AIModelID,
|
||||
ExchangeID: req.ExchangeID,
|
||||
StrategyID: strategyID, // Associated strategy ID
|
||||
InitialBalance: initialBalance,
|
||||
BTCETHLeverage: btcEthLeverage,
|
||||
AltcoinLeverage: altcoinLeverage,
|
||||
TradingSymbols: req.TradingSymbols,
|
||||
CustomPrompt: req.CustomPrompt,
|
||||
OverrideBasePrompt: req.OverrideBasePrompt,
|
||||
SystemPromptTemplate: systemPromptTemplate,
|
||||
IsCrossMargin: isCrossMargin,
|
||||
ShowInCompetition: showInCompetition,
|
||||
ScanIntervalMinutes: scanIntervalMinutes,
|
||||
IsRunning: existingTrader.IsRunning, // Keep original value
|
||||
}
|
||||
|
||||
// Check if trader was running before update (we'll restart it after)
|
||||
wasRunning := false
|
||||
if existingMemTrader, memErr := s.traderManager.GetTrader(traderID); memErr == nil {
|
||||
status := existingMemTrader.GetStatus()
|
||||
if running, ok := status["is_running"].(bool); ok && running {
|
||||
wasRunning = true
|
||||
logger.Infof("🔄 Trader %s was running, will restart with new config after update", traderID)
|
||||
}
|
||||
}
|
||||
|
||||
// Update database
|
||||
logger.Infof("🔄 Updating trader: ID=%s, Name=%s, AIModelID=%s, StrategyID=%s, ScanInterval=%d min",
|
||||
traderRecord.ID, traderRecord.Name, traderRecord.AIModelID, traderRecord.StrategyID, scanIntervalMinutes)
|
||||
err = s.store.Trader().Update(traderRecord)
|
||||
if err != nil {
|
||||
SafeInternalError(c, "Failed to update trader", err)
|
||||
return
|
||||
}
|
||||
|
||||
if resetInitialBalance {
|
||||
logger.Infof("🔄 Exchange changed for trader %s, resetting stale initial_balance to 0", traderID)
|
||||
if err := s.store.Trader().UpdateInitialBalance(userID, traderID, 0); err != nil {
|
||||
SafeInternalError(c, "Failed to reset trader initial balance", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Remove old trader from memory first (this also stops if running)
|
||||
s.traderManager.RemoveTrader(traderID)
|
||||
|
||||
// Reload traders into memory with fresh config
|
||||
err = s.traderManager.LoadUserTradersFromStore(s.store, userID)
|
||||
if err != nil {
|
||||
logger.Infof("⚠️ Failed to reload user traders into memory: %v", err)
|
||||
}
|
||||
|
||||
// If trader was running before, restart it with new config
|
||||
if wasRunning {
|
||||
if reloadedTrader, getErr := s.traderManager.GetTrader(traderID); getErr == nil {
|
||||
go func() {
|
||||
logger.Infof("▶️ Restarting trader %s with new config...", traderID)
|
||||
if runErr := reloadedTrader.Run(); runErr != nil {
|
||||
logger.Infof("❌ Trader %s runtime error: %v", traderID, runErr)
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
logger.Infof("✓ Trader updated successfully: %s (model: %s, exchange: %s, strategy: %s)", req.Name, req.AIModelID, req.ExchangeID, strategyID)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"trader_id": traderID,
|
||||
"trader_name": req.Name,
|
||||
"ai_model": req.AIModelID,
|
||||
"message": "Trader updated successfully",
|
||||
})
|
||||
}
|
||||
|
||||
// handleDeleteTrader Delete trader
|
||||
func (s *Server) handleDeleteTrader(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
traderID := c.Param("id")
|
||||
|
||||
// Delete from database
|
||||
err := s.store.Trader().Delete(userID, traderID)
|
||||
if err != nil {
|
||||
SafeInternalError(c, "Failed to delete trader", err)
|
||||
return
|
||||
}
|
||||
|
||||
// If trader is running, stop it first
|
||||
if trader, err := s.traderManager.GetTrader(traderID); err == nil {
|
||||
status := trader.GetStatus()
|
||||
if isRunning, ok := status["is_running"].(bool); ok && isRunning {
|
||||
trader.Stop()
|
||||
logger.Infof("⏹ Stopped running trader: %s", traderID)
|
||||
}
|
||||
}
|
||||
|
||||
// Remove trader from memory
|
||||
s.traderManager.RemoveTrader(traderID)
|
||||
|
||||
logger.Infof("✓ Trader deleted: %s", traderID)
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Trader deleted"})
|
||||
}
|
||||
|
||||
// handleStartTrader Start trader
|
||||
func (s *Server) handleStartTrader(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
traderID := c.Param("id")
|
||||
|
||||
// Verify trader belongs to current user
|
||||
fullCfg, err := s.store.Trader().GetFullConfig(userID, traderID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Trader does not exist or no access permission"})
|
||||
return
|
||||
}
|
||||
traderName := traderID
|
||||
if fullCfg != nil && fullCfg.Trader != nil && fullCfg.Trader.Name != "" {
|
||||
traderName = fullCfg.Trader.Name
|
||||
}
|
||||
|
||||
// Check if trader exists in memory and if it's running
|
||||
existingTrader, _ := s.traderManager.GetTrader(traderID)
|
||||
if existingTrader != nil {
|
||||
status := existingTrader.GetStatus()
|
||||
if isRunning, ok := status["is_running"].(bool); ok && isRunning {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Trader is already running"})
|
||||
return
|
||||
}
|
||||
// Trader exists but is stopped - remove from memory to reload fresh config
|
||||
logger.Infof("🔄 Removing stopped trader %s from memory to reload config...", traderID)
|
||||
s.traderManager.RemoveTrader(traderID)
|
||||
}
|
||||
|
||||
// Load trader from database (always reload to get latest config)
|
||||
logger.Infof("🔄 Loading trader %s from database...", traderID)
|
||||
if loadErr := s.traderManager.LoadUserTradersFromStore(s.store, userID); loadErr != nil {
|
||||
logger.Infof("❌ Failed to load user traders: %v", loadErr)
|
||||
SafeErrorWithDetails(c, http.StatusInternalServerError, describeTraderStartError(traderName, loadErr), "trader.start.load_failed", traderSetupReasonParams(loadErr, "", "trader_name", traderName), loadErr)
|
||||
return
|
||||
}
|
||||
|
||||
trader, err := s.traderManager.GetTrader(traderID)
|
||||
if err != nil {
|
||||
if fullCfg != nil && fullCfg.Trader != nil {
|
||||
// Check strategy
|
||||
if fullCfg.Strategy == nil {
|
||||
SafeBadRequestWithDetails(c, describeTraderStartError(traderName, fmt.Errorf("trader has no strategy configured")), "trader.start.strategy_missing", mapStringPairs("trader_name", traderName))
|
||||
return
|
||||
}
|
||||
// Check AI model
|
||||
if fullCfg.AIModel == nil {
|
||||
SafeBadRequestWithDetails(c, formatTraderStartError("这个机器人关联的 AI 模型不存在", "请前往「设置 > 模型配置」检查后,再重新点击启动"), "trader.start.model_not_found", mapStringPairs("trader_name", traderName))
|
||||
return
|
||||
}
|
||||
if !fullCfg.AIModel.Enabled {
|
||||
SafeBadRequestWithDetails(c, formatTraderStartError(
|
||||
fmt.Sprintf("机器人「%s」关联的 AI 模型「%s」目前还没有启用", traderName, fullCfg.AIModel.Name),
|
||||
"请前往「设置 > 模型配置」启用它后,再重新点击启动",
|
||||
), "trader.start.model_disabled", mapStringPairs("trader_name", traderName, "model_name", fullCfg.AIModel.Name))
|
||||
return
|
||||
}
|
||||
// Check exchange
|
||||
if fullCfg.Exchange == nil {
|
||||
SafeBadRequestWithDetails(c, formatTraderStartError("这个机器人关联的交易所账户不存在", "请前往「设置 > 交易所配置」检查后,再重新点击启动"), "trader.start.exchange_not_found", mapStringPairs("trader_name", traderName))
|
||||
return
|
||||
}
|
||||
if !fullCfg.Exchange.Enabled {
|
||||
SafeBadRequestWithDetails(c, formatTraderStartError(
|
||||
fmt.Sprintf("机器人「%s」关联的交易所账户「%s」目前还没有启用", traderName, exchangeDisplayName(fullCfg.Exchange)),
|
||||
"请前往「设置 > 交易所配置」启用它后,再重新点击启动",
|
||||
), "trader.start.exchange_disabled", mapStringPairs("trader_name", traderName, "exchange_name", exchangeDisplayName(fullCfg.Exchange)))
|
||||
return
|
||||
}
|
||||
}
|
||||
// Check if there's a specific load error
|
||||
if loadErr := s.traderManager.GetLoadError(traderID); loadErr != nil {
|
||||
SafeBadRequestWithDetails(c, describeTraderStartError(traderName, loadErr), "trader.start.load_failed", traderSetupReasonParams(loadErr, "", "trader_name", traderName))
|
||||
return
|
||||
}
|
||||
SafeBadRequestWithDetails(c, describeTraderStartError(traderName, err), "trader.start.setup_invalid", traderSetupReasonParams(err, "", "trader_name", traderName))
|
||||
return
|
||||
}
|
||||
|
||||
// Start trader
|
||||
go func() {
|
||||
logger.Infof("▶️ Starting trader %s (%s)", traderID, trader.GetName())
|
||||
if err := trader.Run(); err != nil {
|
||||
logger.Infof("❌ Trader %s runtime error: %v", trader.GetName(), err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Update running status in database
|
||||
err = s.store.Trader().UpdateStatus(userID, traderID, true)
|
||||
if err != nil {
|
||||
logger.Infof("⚠️ Failed to update trader status: %v", err)
|
||||
}
|
||||
|
||||
logger.Infof("✓ Trader %s started", trader.GetName())
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Trader started"})
|
||||
}
|
||||
|
||||
// handleStopTrader Stop trader
|
||||
func (s *Server) handleStopTrader(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
traderID := c.Param("id")
|
||||
|
||||
// Verify trader belongs to current user
|
||||
_, err := s.store.Trader().GetFullConfig(userID, traderID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Trader does not exist or no access permission"})
|
||||
return
|
||||
}
|
||||
|
||||
trader, err := s.traderManager.GetTrader(traderID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Trader does not exist"})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if trader is running
|
||||
status := trader.GetStatus()
|
||||
if isRunning, ok := status["is_running"].(bool); ok && !isRunning {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Trader is already stopped"})
|
||||
return
|
||||
}
|
||||
|
||||
// Stop trader
|
||||
trader.Stop()
|
||||
|
||||
// Update running status in database
|
||||
err = s.store.Trader().UpdateStatus(userID, traderID, false)
|
||||
if err != nil {
|
||||
logger.Infof("⚠️ Failed to update trader status: %v", err)
|
||||
}
|
||||
|
||||
logger.Infof("⏹ Trader %s stopped", trader.GetName())
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Trader stopped"})
|
||||
}
|
||||
79
api/handler_trader_config.go
Normal file
79
api/handler_trader_config.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"nofx/logger"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// handleUpdateTraderPrompt Update trader custom prompt
|
||||
func (s *Server) handleUpdateTraderPrompt(c *gin.Context) {
|
||||
traderID := c.Param("id")
|
||||
userID := c.GetString("user_id")
|
||||
|
||||
var req struct {
|
||||
CustomPrompt string `json:"custom_prompt"`
|
||||
OverrideBasePrompt bool `json:"override_base_prompt"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
SafeBadRequest(c, "Invalid request parameters")
|
||||
return
|
||||
}
|
||||
|
||||
// Update database
|
||||
err := s.store.Trader().UpdateCustomPrompt(userID, traderID, req.CustomPrompt, req.OverrideBasePrompt)
|
||||
if err != nil {
|
||||
SafeInternalError(c, "Failed to update custom prompt", err)
|
||||
return
|
||||
}
|
||||
|
||||
// If trader is in memory, update its custom prompt and override settings
|
||||
trader, err := s.traderManager.GetTrader(traderID)
|
||||
if err == nil {
|
||||
trader.SetCustomPrompt(req.CustomPrompt)
|
||||
trader.SetOverrideBasePrompt(req.OverrideBasePrompt)
|
||||
logger.Infof("✓ Updated trader %s custom prompt (override base=%v)", trader.GetName(), req.OverrideBasePrompt)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Custom prompt updated"})
|
||||
}
|
||||
|
||||
// handleToggleCompetition Toggle trader competition visibility
|
||||
func (s *Server) handleToggleCompetition(c *gin.Context) {
|
||||
traderID := c.Param("id")
|
||||
userID := c.GetString("user_id")
|
||||
|
||||
var req struct {
|
||||
ShowInCompetition bool `json:"show_in_competition"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
SafeBadRequest(c, "Invalid request parameters")
|
||||
return
|
||||
}
|
||||
|
||||
// Update database
|
||||
err := s.store.Trader().UpdateShowInCompetition(userID, traderID, req.ShowInCompetition)
|
||||
if err != nil {
|
||||
SafeInternalError(c, "Update competition visibility", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Update in-memory trader if it exists
|
||||
if trader, err := s.traderManager.GetTrader(traderID); err == nil {
|
||||
trader.SetShowInCompetition(req.ShowInCompetition)
|
||||
}
|
||||
|
||||
status := "shown"
|
||||
if !req.ShowInCompetition {
|
||||
status = "hidden"
|
||||
}
|
||||
logger.Infof("✓ Trader %s competition visibility updated: %s", traderID, status)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "Competition visibility updated",
|
||||
"show_in_competition": req.ShowInCompetition,
|
||||
})
|
||||
}
|
||||
493
api/handler_trader_status.go
Normal file
493
api/handler_trader_status.go
Normal file
@@ -0,0 +1,493 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"nofx/logger"
|
||||
"nofx/store"
|
||||
"nofx/trader"
|
||||
"nofx/trader/aster"
|
||||
"nofx/trader/binance"
|
||||
"nofx/trader/bitget"
|
||||
"nofx/trader/bybit"
|
||||
"nofx/trader/gate"
|
||||
hyperliquidtrader "nofx/trader/hyperliquid"
|
||||
"nofx/trader/kucoin"
|
||||
"nofx/trader/lighter"
|
||||
"nofx/trader/okx"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// handleGetGridRiskInfo returns current risk information for a grid trader
|
||||
func (s *Server) handleGetGridRiskInfo(c *gin.Context) {
|
||||
traderID := c.Param("id")
|
||||
|
||||
autoTrader, err := s.traderManager.GetTrader(traderID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "trader not found"})
|
||||
return
|
||||
}
|
||||
|
||||
riskInfo := autoTrader.GetGridRiskInfo()
|
||||
c.JSON(http.StatusOK, riskInfo)
|
||||
}
|
||||
|
||||
// handleSyncBalance Sync exchange balance to initial_balance (Option B: Manual Sync + Option C: Smart Detection)
|
||||
func (s *Server) handleSyncBalance(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
traderID := c.Param("id")
|
||||
|
||||
logger.Infof("🔄 User %s requested balance sync for trader %s", userID, traderID)
|
||||
|
||||
// Get trader configuration from database (including exchange info)
|
||||
fullConfig, err := s.store.Trader().GetFullConfig(userID, traderID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Trader does not exist"})
|
||||
return
|
||||
}
|
||||
|
||||
traderConfig := fullConfig.Trader
|
||||
exchangeCfg := fullConfig.Exchange
|
||||
|
||||
if exchangeCfg == nil || !exchangeCfg.Enabled {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Exchange not configured or not enabled"})
|
||||
return
|
||||
}
|
||||
|
||||
tempTrader, createErr := buildExchangeProbeTrader(exchangeCfg, userID)
|
||||
if createErr != nil {
|
||||
logger.Infof("⚠️ Failed to create temporary trader: %v", createErr)
|
||||
SafeInternalError(c, "Failed to connect to exchange", createErr)
|
||||
return
|
||||
}
|
||||
|
||||
// Query actual balance
|
||||
balanceInfo, balanceErr := tempTrader.GetBalance()
|
||||
if balanceErr != nil {
|
||||
logger.Infof("⚠️ Failed to query exchange balance: %v", balanceErr)
|
||||
SafeInternalError(c, "Failed to query balance", balanceErr)
|
||||
return
|
||||
}
|
||||
|
||||
// Extract total equity (for P&L calculation, we need total account value, not available balance)
|
||||
actualBalance, found := extractExchangeTotalEquity(balanceInfo)
|
||||
if !found {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Unable to get total equity"})
|
||||
return
|
||||
}
|
||||
|
||||
s.exchangeAccountStateCache.Invalidate(userID)
|
||||
|
||||
oldBalance := traderConfig.InitialBalance
|
||||
|
||||
// Smart balance change detection
|
||||
changePercent := ((actualBalance - oldBalance) / oldBalance) * 100
|
||||
changeType := "increase"
|
||||
if changePercent < 0 {
|
||||
changeType = "decrease"
|
||||
}
|
||||
|
||||
logger.Infof("✓ Queried actual exchange balance: %.2f USDT (current config: %.2f USDT, change: %.2f%%)",
|
||||
actualBalance, oldBalance, changePercent)
|
||||
|
||||
// Update initial_balance in database
|
||||
err = s.store.Trader().UpdateInitialBalance(userID, traderID, actualBalance)
|
||||
if err != nil {
|
||||
logger.Infof("❌ Failed to update initial_balance: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update balance"})
|
||||
return
|
||||
}
|
||||
|
||||
// Reload traders into memory
|
||||
err = s.traderManager.LoadUserTradersFromStore(s.store, userID)
|
||||
if err != nil {
|
||||
logger.Infof("⚠️ Failed to reload user traders into memory: %v", err)
|
||||
}
|
||||
|
||||
logger.Infof("✅ Synced balance: %.2f → %.2f USDT (%s %.2f%%)", oldBalance, actualBalance, changeType, changePercent)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "Balance synced successfully",
|
||||
"old_balance": oldBalance,
|
||||
"new_balance": actualBalance,
|
||||
"change_percent": changePercent,
|
||||
"change_type": changeType,
|
||||
})
|
||||
}
|
||||
|
||||
// handleClosePosition One-click close position
|
||||
func (s *Server) handleClosePosition(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
traderID := c.Param("id")
|
||||
|
||||
var req struct {
|
||||
Symbol string `json:"symbol" binding:"required"`
|
||||
Side string `json:"side" binding:"required"` // "LONG" or "SHORT"
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Parameter error: symbol and side are required"})
|
||||
return
|
||||
}
|
||||
|
||||
logger.Infof("🔻 User %s requested position close: trader=%s, symbol=%s, side=%s", userID, traderID, req.Symbol, req.Side)
|
||||
|
||||
// Get trader configuration from database (including exchange info)
|
||||
fullConfig, err := s.store.Trader().GetFullConfig(userID, traderID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Trader does not exist"})
|
||||
return
|
||||
}
|
||||
|
||||
exchangeCfg := fullConfig.Exchange
|
||||
|
||||
if exchangeCfg == nil || !exchangeCfg.Enabled {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Exchange not configured or not enabled"})
|
||||
return
|
||||
}
|
||||
|
||||
// Create temporary trader to execute close position
|
||||
var tempTrader trader.Trader
|
||||
var createErr error
|
||||
|
||||
// Use ExchangeType (e.g., "binance") instead of ExchangeID (which is now UUID)
|
||||
// Convert EncryptedString fields to string
|
||||
switch exchangeCfg.ExchangeType {
|
||||
case "binance":
|
||||
tempTrader = binance.NewFuturesTrader(string(exchangeCfg.APIKey), string(exchangeCfg.SecretKey), userID)
|
||||
case "hyperliquid":
|
||||
tempTrader, createErr = hyperliquidtrader.NewHyperliquidTrader(
|
||||
string(exchangeCfg.APIKey),
|
||||
exchangeCfg.HyperliquidWalletAddr,
|
||||
exchangeCfg.Testnet,
|
||||
exchangeCfg.HyperliquidUnifiedAcct,
|
||||
)
|
||||
case "aster":
|
||||
tempTrader, createErr = aster.NewAsterTrader(
|
||||
exchangeCfg.AsterUser,
|
||||
exchangeCfg.AsterSigner,
|
||||
string(exchangeCfg.AsterPrivateKey),
|
||||
)
|
||||
case "bybit":
|
||||
tempTrader = bybit.NewBybitTrader(
|
||||
string(exchangeCfg.APIKey),
|
||||
string(exchangeCfg.SecretKey),
|
||||
)
|
||||
case "okx":
|
||||
tempTrader = okx.NewOKXTrader(
|
||||
string(exchangeCfg.APIKey),
|
||||
string(exchangeCfg.SecretKey),
|
||||
string(exchangeCfg.Passphrase),
|
||||
)
|
||||
case "bitget":
|
||||
tempTrader = bitget.NewBitgetTrader(
|
||||
string(exchangeCfg.APIKey),
|
||||
string(exchangeCfg.SecretKey),
|
||||
string(exchangeCfg.Passphrase),
|
||||
)
|
||||
case "gate":
|
||||
tempTrader = gate.NewGateTrader(
|
||||
string(exchangeCfg.APIKey),
|
||||
string(exchangeCfg.SecretKey),
|
||||
)
|
||||
case "kucoin":
|
||||
tempTrader = kucoin.NewKuCoinTrader(
|
||||
string(exchangeCfg.APIKey),
|
||||
string(exchangeCfg.SecretKey),
|
||||
string(exchangeCfg.Passphrase),
|
||||
)
|
||||
case "lighter":
|
||||
if exchangeCfg.LighterWalletAddr != "" && string(exchangeCfg.LighterAPIKeyPrivateKey) != "" {
|
||||
// Lighter only supports mainnet
|
||||
tempTrader, createErr = lighter.NewLighterTraderV2(
|
||||
exchangeCfg.LighterWalletAddr,
|
||||
string(exchangeCfg.LighterAPIKeyPrivateKey),
|
||||
exchangeCfg.LighterAPIKeyIndex,
|
||||
false, // Always use mainnet for Lighter
|
||||
)
|
||||
} else {
|
||||
createErr = fmt.Errorf("Lighter requires wallet address and API Key private key")
|
||||
}
|
||||
default:
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Unsupported exchange type"})
|
||||
return
|
||||
}
|
||||
|
||||
if createErr != nil {
|
||||
logger.Infof("⚠️ Failed to create temporary trader: %v", createErr)
|
||||
SafeInternalError(c, "Failed to connect to exchange", createErr)
|
||||
return
|
||||
}
|
||||
|
||||
// Get current position info BEFORE closing (to get quantity and price)
|
||||
positions, err := tempTrader.GetPositions()
|
||||
if err != nil {
|
||||
logger.Infof("⚠️ Failed to get positions: %v", err)
|
||||
}
|
||||
|
||||
var posQty float64
|
||||
var entryPrice float64
|
||||
for _, pos := range positions {
|
||||
if pos["symbol"] == req.Symbol && pos["side"] == strings.ToLower(req.Side) {
|
||||
if amt, ok := pos["positionAmt"].(float64); ok {
|
||||
posQty = amt
|
||||
if posQty < 0 {
|
||||
posQty = -posQty // Make positive
|
||||
}
|
||||
}
|
||||
if price, ok := pos["entryPrice"].(float64); ok {
|
||||
entryPrice = price
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Execute close position operation
|
||||
var result map[string]interface{}
|
||||
var closeErr error
|
||||
|
||||
if req.Side == "LONG" {
|
||||
result, closeErr = tempTrader.CloseLong(req.Symbol, 0) // 0 means close all
|
||||
} else if req.Side == "SHORT" {
|
||||
result, closeErr = tempTrader.CloseShort(req.Symbol, 0) // 0 means close all
|
||||
} else {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "side must be LONG or SHORT"})
|
||||
return
|
||||
}
|
||||
|
||||
if closeErr != nil {
|
||||
logger.Infof("❌ Close position failed: symbol=%s, side=%s, error=%v", req.Symbol, req.Side, closeErr)
|
||||
SafeInternalError(c, "Close position", closeErr)
|
||||
return
|
||||
}
|
||||
|
||||
logger.Infof("✅ Position closed successfully: symbol=%s, side=%s, qty=%.6f, result=%v", req.Symbol, req.Side, posQty, result)
|
||||
|
||||
// Record order to database (for chart markers and history)
|
||||
s.recordClosePositionOrder(traderID, exchangeCfg.ID, exchangeCfg.ExchangeType, req.Symbol, req.Side, posQty, entryPrice, result)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "Position closed successfully",
|
||||
"symbol": req.Symbol,
|
||||
"side": req.Side,
|
||||
"result": result,
|
||||
})
|
||||
}
|
||||
|
||||
// recordClosePositionOrder Record close position order to database (Lighter version - direct FILLED status)
|
||||
func (s *Server) recordClosePositionOrder(traderID, exchangeID, exchangeType, symbol, side string, quantity, exitPrice float64, result map[string]interface{}) {
|
||||
// Skip for exchanges with OrderSync - let the background sync handle it to avoid duplicates
|
||||
switch exchangeType {
|
||||
case "binance", "lighter", "hyperliquid", "bybit", "okx", "bitget", "aster", "gate":
|
||||
logger.Infof(" 📝 Close order will be synced by OrderSync, skipping immediate record")
|
||||
return
|
||||
}
|
||||
|
||||
// Check if order was placed (skip if NO_POSITION)
|
||||
status, _ := result["status"].(string)
|
||||
if status == "NO_POSITION" {
|
||||
logger.Infof(" ⚠️ No position to close, skipping order record")
|
||||
return
|
||||
}
|
||||
|
||||
// Get order ID from result
|
||||
var orderID string
|
||||
switch v := result["orderId"].(type) {
|
||||
case int64:
|
||||
orderID = fmt.Sprintf("%d", v)
|
||||
case float64:
|
||||
orderID = fmt.Sprintf("%.0f", v)
|
||||
case string:
|
||||
orderID = v
|
||||
default:
|
||||
orderID = fmt.Sprintf("%v", v)
|
||||
}
|
||||
|
||||
if orderID == "" || orderID == "0" {
|
||||
logger.Infof(" ⚠️ Order ID is empty, skipping record")
|
||||
return
|
||||
}
|
||||
|
||||
// Determine order action based on side
|
||||
var orderAction string
|
||||
if side == "LONG" {
|
||||
orderAction = "close_long"
|
||||
} else {
|
||||
orderAction = "close_short"
|
||||
}
|
||||
|
||||
// Use entry price if exit price not available
|
||||
if exitPrice == 0 {
|
||||
exitPrice = quantity * 100 // Rough estimate if we don't have price
|
||||
}
|
||||
|
||||
// Estimate fee (0.04% for Lighter taker)
|
||||
fee := exitPrice * quantity * 0.0004
|
||||
|
||||
// Create order record - DIRECTLY as FILLED (Lighter market orders fill immediately)
|
||||
orderRecord := &store.TraderOrder{
|
||||
TraderID: traderID,
|
||||
ExchangeID: exchangeID,
|
||||
ExchangeType: exchangeType,
|
||||
ExchangeOrderID: orderID,
|
||||
Symbol: symbol,
|
||||
PositionSide: side,
|
||||
OrderAction: orderAction,
|
||||
Type: "MARKET",
|
||||
Side: getSideFromAction(orderAction),
|
||||
Quantity: quantity,
|
||||
Price: 0, // Market order
|
||||
Status: "FILLED",
|
||||
FilledQuantity: quantity,
|
||||
AvgFillPrice: exitPrice,
|
||||
Commission: fee,
|
||||
FilledAt: time.Now().UTC().UnixMilli(),
|
||||
CreatedAt: time.Now().UTC().UnixMilli(),
|
||||
UpdatedAt: time.Now().UTC().UnixMilli(),
|
||||
}
|
||||
|
||||
if err := s.store.Order().CreateOrder(orderRecord); err != nil {
|
||||
logger.Infof(" ⚠️ Failed to record order: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
logger.Infof(" ✅ Order recorded as FILLED: %s [%s] %s qty=%.6f price=%.6f", orderID, orderAction, symbol, quantity, exitPrice)
|
||||
|
||||
// Create fill record immediately
|
||||
tradeID := fmt.Sprintf("%s-%d", orderID, time.Now().UnixNano())
|
||||
fillRecord := &store.TraderFill{
|
||||
TraderID: traderID,
|
||||
ExchangeID: exchangeID,
|
||||
ExchangeType: exchangeType,
|
||||
OrderID: orderRecord.ID,
|
||||
ExchangeOrderID: orderID,
|
||||
ExchangeTradeID: tradeID,
|
||||
Symbol: symbol,
|
||||
Side: getSideFromAction(orderAction),
|
||||
Price: exitPrice,
|
||||
Quantity: quantity,
|
||||
QuoteQuantity: exitPrice * quantity,
|
||||
Commission: fee,
|
||||
CommissionAsset: "USDT",
|
||||
RealizedPnL: 0,
|
||||
IsMaker: false,
|
||||
CreatedAt: time.Now().UTC().UnixMilli(),
|
||||
}
|
||||
|
||||
if err := s.store.Order().CreateFill(fillRecord); err != nil {
|
||||
logger.Infof(" ⚠️ Failed to record fill: %v", err)
|
||||
} else {
|
||||
logger.Infof(" ✅ Fill record created: price=%.6f qty=%.6f", exitPrice, quantity)
|
||||
}
|
||||
}
|
||||
|
||||
// pollAndUpdateOrderStatus Poll order status and update with fill data
|
||||
func (s *Server) pollAndUpdateOrderStatus(orderRecordID int64, traderID, exchangeID, exchangeType, orderID, symbol, orderAction string, tempTrader trader.Trader) {
|
||||
var actualPrice float64
|
||||
var actualQty float64
|
||||
var fee float64
|
||||
|
||||
// Wait a bit for order to be filled
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
// For Lighter, use GetTrades instead of GetOrderStatus (market orders are filled immediately)
|
||||
if exchangeType == "lighter" {
|
||||
s.pollLighterTradeHistory(orderRecordID, traderID, exchangeID, exchangeType, orderID, symbol, orderAction, tempTrader)
|
||||
return
|
||||
}
|
||||
|
||||
// For other exchanges, poll GetOrderStatus
|
||||
for i := 0; i < 5; i++ {
|
||||
status, err := tempTrader.GetOrderStatus(symbol, orderID)
|
||||
if err != nil {
|
||||
logger.Infof(" ⚠️ GetOrderStatus failed (attempt %d/5): %v", i+1, err)
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
continue
|
||||
}
|
||||
if err == nil {
|
||||
statusStr, _ := status["status"].(string)
|
||||
if statusStr == "FILLED" {
|
||||
// Get actual fill price
|
||||
if avgPrice, ok := status["avgPrice"].(float64); ok && avgPrice > 0 {
|
||||
actualPrice = avgPrice
|
||||
}
|
||||
// Get actual executed quantity
|
||||
if execQty, ok := status["executedQty"].(float64); ok && execQty > 0 {
|
||||
actualQty = execQty
|
||||
}
|
||||
// Get commission/fee
|
||||
if commission, ok := status["commission"].(float64); ok {
|
||||
fee = commission
|
||||
}
|
||||
|
||||
logger.Infof(" ✅ Order filled: avgPrice=%.6f, qty=%.6f, fee=%.6f", actualPrice, actualQty, fee)
|
||||
|
||||
// Update order status to FILLED
|
||||
if err := s.store.Order().UpdateOrderStatus(orderRecordID, "FILLED", actualQty, actualPrice, fee); err != nil {
|
||||
logger.Infof(" ⚠️ Failed to update order status: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Record fill details
|
||||
tradeID := fmt.Sprintf("%s-%d", orderID, time.Now().UnixNano())
|
||||
fillRecord := &store.TraderFill{
|
||||
TraderID: traderID,
|
||||
ExchangeID: exchangeID,
|
||||
ExchangeType: exchangeType,
|
||||
OrderID: orderRecordID,
|
||||
ExchangeOrderID: orderID,
|
||||
ExchangeTradeID: tradeID,
|
||||
Symbol: symbol,
|
||||
Side: getSideFromAction(orderAction),
|
||||
Price: actualPrice,
|
||||
Quantity: actualQty,
|
||||
QuoteQuantity: actualPrice * actualQty,
|
||||
Commission: fee,
|
||||
CommissionAsset: "USDT",
|
||||
RealizedPnL: 0,
|
||||
IsMaker: false,
|
||||
CreatedAt: time.Now().UTC().UnixMilli(),
|
||||
}
|
||||
|
||||
if err := s.store.Order().CreateFill(fillRecord); err != nil {
|
||||
logger.Infof(" ⚠️ Failed to record fill: %v", err)
|
||||
} else {
|
||||
logger.Infof(" 📝 Fill recorded: price=%.6f, qty=%.6f", actualPrice, actualQty)
|
||||
}
|
||||
|
||||
return
|
||||
} else if statusStr == "CANCELED" || statusStr == "EXPIRED" || statusStr == "REJECTED" {
|
||||
logger.Infof(" ⚠️ Order %s, updating status", statusStr)
|
||||
s.store.Order().UpdateOrderStatus(orderRecordID, statusStr, 0, 0, 0)
|
||||
return
|
||||
}
|
||||
}
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
}
|
||||
|
||||
logger.Infof(" ⚠️ Failed to confirm order fill after polling, order may still be pending")
|
||||
}
|
||||
|
||||
// pollLighterTradeHistory No longer used - Lighter orders are marked as FILLED immediately
|
||||
// Keeping this function stub for compatibility with other exchanges
|
||||
func (s *Server) pollLighterTradeHistory(orderRecordID int64, traderID, exchangeID, exchangeType, orderID, symbol, orderAction string, tempTrader trader.Trader) {
|
||||
// For Lighter, orders are now recorded as FILLED immediately in recordClosePositionOrder
|
||||
// This function is no longer called for Lighter exchange
|
||||
logger.Infof(" ℹ️ pollLighterTradeHistory called but not needed (order already marked FILLED)")
|
||||
}
|
||||
|
||||
// getSideFromAction Get order side (BUY/SELL) from order action
|
||||
func getSideFromAction(action string) string {
|
||||
switch action {
|
||||
case "open_long", "close_short":
|
||||
return "BUY"
|
||||
case "open_short", "close_long":
|
||||
return "SELL"
|
||||
default:
|
||||
return "BUY"
|
||||
}
|
||||
}
|
||||
397
api/handler_user.go
Normal file
397
api/handler_user.go
Normal file
@@ -0,0 +1,397 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"nofx/auth"
|
||||
"nofx/logger"
|
||||
"nofx/store"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// handleLogout Add current token to blacklist
|
||||
func (s *Server) handleLogout(c *gin.Context) {
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Missing Authorization header"})
|
||||
return
|
||||
}
|
||||
parts := strings.Split(authHeader, " ")
|
||||
if len(parts) != 2 || parts[0] != "Bearer" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid Authorization format"})
|
||||
return
|
||||
}
|
||||
tokenString := parts[1]
|
||||
claims, err := auth.ValidateJWT(tokenString)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"})
|
||||
return
|
||||
}
|
||||
var exp time.Time
|
||||
if claims.ExpiresAt != nil {
|
||||
exp = claims.ExpiresAt.Time
|
||||
} else {
|
||||
exp = time.Now().Add(24 * time.Hour)
|
||||
}
|
||||
auth.BlacklistToken(tokenString, exp)
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Logged out"})
|
||||
}
|
||||
|
||||
// handleRegister Handle user registration request.
|
||||
// handleRegister allows registration only when no users exist yet (first-time setup).
|
||||
// This is a single-user system; subsequent registrations are permanently closed.
|
||||
func (s *Server) handleRegister(c *gin.Context) {
|
||||
userCount, err := s.store.User().Count()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check user count"})
|
||||
return
|
||||
}
|
||||
|
||||
if userCount > 0 {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "System already initialized"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Password string `json:"password" binding:"required,min=6"`
|
||||
Lang string `json:"lang"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
SafeBadRequest(c, "Invalid request parameters")
|
||||
return
|
||||
}
|
||||
|
||||
lang := req.Lang
|
||||
if lang != "zh" && lang != "id" {
|
||||
lang = "en"
|
||||
}
|
||||
|
||||
// Check if email already exists
|
||||
_, err = s.store.User().GetByEmail(req.Email)
|
||||
if err == nil {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "Email already registered"})
|
||||
return
|
||||
}
|
||||
|
||||
// Generate password hash
|
||||
passwordHash, err := auth.HashPassword(req.Password)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Password processing failed"})
|
||||
return
|
||||
}
|
||||
|
||||
// Create user
|
||||
userID := uuid.New().String()
|
||||
user := &store.User{
|
||||
ID: userID,
|
||||
Email: req.Email,
|
||||
PasswordHash: passwordHash,
|
||||
}
|
||||
|
||||
err = s.store.User().Create(user)
|
||||
if err != nil {
|
||||
SafeInternalError(c, "Failed to create user", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Adopt orphan records from previous account (e.g. after account reset)
|
||||
// This preserves wallet keys and exchange configs so funds are not lost.
|
||||
s.adoptOrphanRecords(userID)
|
||||
|
||||
// Generate JWT token
|
||||
token, err := auth.GenerateJWT(user.ID, user.Email)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"})
|
||||
return
|
||||
}
|
||||
|
||||
// Initialize default model and exchange configs for user
|
||||
err = s.initUserDefaultConfigs(user.ID, lang)
|
||||
if err != nil {
|
||||
logger.Infof("Failed to initialize user default configs: %v", err)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"token": token,
|
||||
"user_id": user.ID,
|
||||
"email": user.Email,
|
||||
"message": "Registration successful",
|
||||
})
|
||||
}
|
||||
|
||||
// handleLogin Handle user login request
|
||||
func (s *Server) handleLogin(c *gin.Context) {
|
||||
var req struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
SafeBadRequest(c, "Invalid request parameters")
|
||||
return
|
||||
}
|
||||
|
||||
// Get user information
|
||||
user, err := s.store.User().GetByEmail(req.Email)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Email or password incorrect"})
|
||||
return
|
||||
}
|
||||
|
||||
// Verify password
|
||||
if !auth.CheckPassword(req.Password, user.PasswordHash) {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Email or password incorrect"})
|
||||
return
|
||||
}
|
||||
|
||||
// Issue token directly after password verification.
|
||||
token, err := auth.GenerateJWT(user.ID, user.Email)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"token": token,
|
||||
"user_id": user.ID,
|
||||
"email": user.Email,
|
||||
"message": "Login successful",
|
||||
})
|
||||
}
|
||||
|
||||
// handleChangePassword changes the password for the currently authenticated user.
|
||||
func (s *Server) handleChangePassword(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
var req struct {
|
||||
NewPassword string `json:"new_password" binding:"required,min=8"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
SafeBadRequest(c, "new_password is required (min 8 chars)")
|
||||
return
|
||||
}
|
||||
hash, err := auth.HashPassword(req.NewPassword)
|
||||
if err != nil {
|
||||
SafeInternalError(c, "Password processing failed", err)
|
||||
return
|
||||
}
|
||||
if err := s.store.User().UpdatePassword(userID, hash); err != nil {
|
||||
SafeInternalError(c, "Failed to update password", err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Password updated"})
|
||||
}
|
||||
|
||||
// handleResetPassword Reset password via email and new password
|
||||
func (s *Server) handleResetPassword(c *gin.Context) {
|
||||
var req struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
NewPassword string `json:"new_password" binding:"required,min=6"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
SafeBadRequest(c, "Invalid request parameters")
|
||||
return
|
||||
}
|
||||
|
||||
// Query user
|
||||
user, err := s.store.User().GetByEmail(req.Email)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Email does not exist"})
|
||||
return
|
||||
}
|
||||
|
||||
// Generate new password hash
|
||||
newPasswordHash, err := auth.HashPassword(req.NewPassword)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Password processing failed"})
|
||||
return
|
||||
}
|
||||
|
||||
// Update password
|
||||
err = s.store.User().UpdatePassword(user.ID, newPasswordHash)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Password update failed"})
|
||||
return
|
||||
}
|
||||
|
||||
logger.Infof("✓ User %s password has been reset", user.Email)
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Password reset successful, please login with new password"})
|
||||
}
|
||||
|
||||
// handleResetAccount clears user authentication data so the system returns to
|
||||
// uninitialized state for re-registration. Wallet keys (ai_models) are preserved
|
||||
// so funds are not lost — they will be adopted by the new account during onboarding.
|
||||
func (s *Server) handleResetAccount(c *gin.Context) {
|
||||
err := s.store.Transaction(func(tx *gorm.DB) error {
|
||||
// Delete traders and strategies (config, not funds)
|
||||
tx.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&store.Trader{})
|
||||
tx.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&store.Strategy{})
|
||||
// Delete users — ai_models and exchanges are intentionally kept
|
||||
// so wallet private keys and exchange configs survive re-registration
|
||||
if err := tx.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&store.User{}).Error; err != nil {
|
||||
return fmt.Errorf("failed to delete users: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
SafeInternalError(c, "Failed to reset account", err)
|
||||
return
|
||||
}
|
||||
|
||||
logger.Infof("✓ User accounts cleared (wallets preserved) — system reset to uninitialized")
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Account reset successful, you can now register a new account"})
|
||||
}
|
||||
|
||||
// adoptOrphanRecords re-assigns ai_models and exchanges whose user_id no longer
|
||||
// exists in the users table. This happens after account reset so the new user
|
||||
// inherits the previous wallet keys and exchange configurations.
|
||||
func (s *Server) adoptOrphanRecords(newUserID string) {
|
||||
db := s.store.GormDB()
|
||||
result := db.Model(&store.AIModel{}).
|
||||
Where("user_id NOT IN (SELECT id FROM users)").
|
||||
Update("user_id", newUserID)
|
||||
if result.RowsAffected > 0 {
|
||||
logger.Infof("✓ Adopted %d orphan ai_model(s) for new user %s", result.RowsAffected, newUserID)
|
||||
}
|
||||
|
||||
result = db.Model(&store.Exchange{}).
|
||||
Where("user_id NOT IN (SELECT id FROM users)").
|
||||
Update("user_id", newUserID)
|
||||
if result.RowsAffected > 0 {
|
||||
logger.Infof("✓ Adopted %d orphan exchange(s) for new user %s", result.RowsAffected, newUserID)
|
||||
}
|
||||
}
|
||||
|
||||
// initUserDefaultConfigs Initialize default configs for new user
|
||||
func (s *Server) initUserDefaultConfigs(userID string, lang string) error {
|
||||
if err := s.createDefaultStrategies(userID, lang); err != nil {
|
||||
logger.Warnf("Failed to create default strategies for user %s: %v", userID, err)
|
||||
// Non-fatal: user can create strategies manually
|
||||
}
|
||||
logger.Infof("✓ User %s registration completed with default strategies", userID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) createDefaultStrategies(userID string, lang string) error {
|
||||
type strategyI18n struct {
|
||||
name, description string
|
||||
}
|
||||
type strategyLocale struct {
|
||||
balanced, conservative, aggressive strategyI18n
|
||||
}
|
||||
locales := map[string]strategyLocale{
|
||||
"zh": {
|
||||
balanced: strategyI18n{"均衡策略", "系统默认策略。均衡风险收益,适合大多数市场环境。5倍杠杆,最多3个仓位。"},
|
||||
conservative: strategyI18n{"稳健策略", "系统默认策略。低杠杆保守操作,优先保护本金。3倍杠杆,专注主流资产。"},
|
||||
aggressive: strategyI18n{"积极策略", "系统默认策略。高杠杆主动交易,更广泛的币种选择,适合经验丰富的交易者。10倍杠杆,最多5个仓位。"},
|
||||
},
|
||||
"en": {
|
||||
balanced: strategyI18n{"Balanced Strategy", "System default strategy. Balanced risk-reward, suitable for most market conditions. 5x leverage, up to 3 positions."},
|
||||
conservative: strategyI18n{"Conservative Strategy", "System default strategy. Low-leverage conservative trading, capital preservation first. 3x leverage, focused on major assets."},
|
||||
aggressive: strategyI18n{"Aggressive Strategy", "System default strategy. High-leverage active trading, wider asset selection, for experienced traders. 10x leverage, up to 5 positions."},
|
||||
},
|
||||
"id": {
|
||||
balanced: strategyI18n{"Strategi Seimbang", "Strategi default sistem. Risiko-reward seimbang, cocok untuk sebagian besar kondisi pasar. Leverage 5x, hingga 3 posisi."},
|
||||
conservative: strategyI18n{"Strategi Konservatif", "Strategi default sistem. Trading konservatif leverage rendah, utamakan perlindungan modal. Leverage 3x, fokus aset utama."},
|
||||
aggressive: strategyI18n{"Strategi Agresif", "Strategi default sistem. Trading aktif leverage tinggi, pilihan aset lebih luas, untuk trader berpengalaman. Leverage 10x, hingga 5 posisi."},
|
||||
},
|
||||
}
|
||||
locale, ok := locales[lang]
|
||||
if !ok {
|
||||
locale = locales["en"]
|
||||
}
|
||||
|
||||
type strategyDef struct {
|
||||
name string
|
||||
description string
|
||||
isActive bool
|
||||
applyConfig func(*store.StrategyConfig)
|
||||
}
|
||||
|
||||
definitions := []strategyDef{
|
||||
{
|
||||
name: locale.balanced.name,
|
||||
description: locale.balanced.description,
|
||||
isActive: true,
|
||||
applyConfig: func(c *store.StrategyConfig) {
|
||||
// Uses default config as-is
|
||||
},
|
||||
},
|
||||
{
|
||||
name: locale.conservative.name,
|
||||
description: locale.conservative.description,
|
||||
isActive: false,
|
||||
applyConfig: func(c *store.StrategyConfig) {
|
||||
c.RiskControl.BTCETHMaxLeverage = 3
|
||||
c.RiskControl.AltcoinMaxLeverage = 3
|
||||
c.RiskControl.BTCETHMaxPositionValueRatio = 3.0
|
||||
c.RiskControl.AltcoinMaxPositionValueRatio = 0.5
|
||||
c.RiskControl.MinConfidence = 80
|
||||
c.RiskControl.MinRiskRewardRatio = 4.0
|
||||
c.Indicators.Klines.SelectedTimeframes = []string{"15m", "1h", "4h"}
|
||||
c.Indicators.Klines.PrimaryTimeframe = "15m"
|
||||
},
|
||||
},
|
||||
{
|
||||
name: locale.aggressive.name,
|
||||
description: locale.aggressive.description,
|
||||
isActive: false,
|
||||
applyConfig: func(c *store.StrategyConfig) {
|
||||
c.RiskControl.BTCETHMaxLeverage = 10
|
||||
c.RiskControl.AltcoinMaxLeverage = 7
|
||||
c.RiskControl.MaxPositions = 5
|
||||
c.RiskControl.AltcoinMaxPositionValueRatio = 2.0
|
||||
c.RiskControl.MinConfidence = 70
|
||||
c.CoinSource.AI500Limit = 5
|
||||
c.CoinSource.UseOITop = true
|
||||
c.CoinSource.OITopLimit = 5
|
||||
c.Indicators.Klines.SelectedTimeframes = []string{"3m", "15m", "1h"}
|
||||
c.Indicators.Klines.PrimaryTimeframe = "3m"
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// GetDefaultStrategyConfig only supports zh/en; map id -> en
|
||||
configLang := lang
|
||||
if lang == "id" {
|
||||
configLang = "en"
|
||||
}
|
||||
|
||||
// Pre-build all strategy objects before opening the transaction
|
||||
var strategies []*store.Strategy
|
||||
for _, def := range definitions {
|
||||
config := store.GetDefaultStrategyConfig(configLang)
|
||||
def.applyConfig(&config)
|
||||
|
||||
strategy := &store.Strategy{
|
||||
ID: uuid.New().String(),
|
||||
UserID: userID,
|
||||
Name: def.name,
|
||||
Description: def.description,
|
||||
IsActive: def.isActive,
|
||||
IsDefault: false,
|
||||
}
|
||||
if err := strategy.SetConfig(&config); err != nil {
|
||||
return fmt.Errorf("failed to set config for strategy %q: %w", def.name, err)
|
||||
}
|
||||
strategies = append(strategies, strategy)
|
||||
}
|
||||
|
||||
return s.store.Transaction(func(tx *gorm.DB) error {
|
||||
for _, strategy := range strategies {
|
||||
if err := tx.Create(strategy).Error; err != nil {
|
||||
return fmt.Errorf("failed to create strategy %q: %w", strategy.Name, err)
|
||||
}
|
||||
logger.Infof(" ✓ Created default strategy: %s (active=%v)", strategy.Name, strategy.IsActive)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
130
api/handler_wallet.go
Normal file
130
api/handler_wallet.go
Normal file
@@ -0,0 +1,130 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"nofx/wallet"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ethereum/go-ethereum/crypto"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type walletValidateRequest struct {
|
||||
PrivateKey string `json:"private_key"`
|
||||
}
|
||||
|
||||
type walletValidateResponse struct {
|
||||
Valid bool `json:"valid"`
|
||||
Address string `json:"address,omitempty"`
|
||||
BalanceUSDC string `json:"balance_usdc,omitempty"`
|
||||
Claw402Status string `json:"claw402_status"` // "ok", "unreachable", "error"
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
|
||||
|
||||
func (s *Server) handleWalletValidate(c *gin.Context) {
|
||||
var req walletValidateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, walletValidateResponse{
|
||||
Valid: false,
|
||||
Error: "invalid request body",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
pk := req.PrivateKey
|
||||
|
||||
// Validate format
|
||||
if !strings.HasPrefix(pk, "0x") {
|
||||
c.JSON(http.StatusOK, walletValidateResponse{
|
||||
Valid: false,
|
||||
Error: "missing 0x prefix",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if len(pk) != 66 {
|
||||
c.JSON(http.StatusOK, walletValidateResponse{
|
||||
Valid: false,
|
||||
Error: fmt.Sprintf("should be 66 characters, got %d", len(pk)),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
hexPart := pk[2:]
|
||||
if _, err := hex.DecodeString(hexPart); err != nil {
|
||||
c.JSON(http.StatusOK, walletValidateResponse{
|
||||
Valid: false,
|
||||
Error: "contains invalid hex characters",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Derive address
|
||||
privateKey, err := crypto.HexToECDSA(hexPart)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, walletValidateResponse{
|
||||
Valid: false,
|
||||
Error: "invalid private key",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
address := crypto.PubkeyToAddress(privateKey.PublicKey)
|
||||
addrHex := address.Hex()
|
||||
|
||||
// Query USDC balance (async-ish, but sequential for simplicity)
|
||||
balanceStr := wallet.QueryUSDCBalanceStr(addrHex)
|
||||
|
||||
// Check claw402 health
|
||||
claw402Status := checkClaw402Health()
|
||||
|
||||
c.JSON(http.StatusOK, walletValidateResponse{
|
||||
Valid: true,
|
||||
Address: addrHex,
|
||||
BalanceUSDC: balanceStr,
|
||||
Claw402Status: claw402Status,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
|
||||
type walletGenerateResponse struct {
|
||||
Address string `json:"address"`
|
||||
PrivateKey string `json:"private_key"`
|
||||
}
|
||||
|
||||
func (s *Server) handleWalletGenerate(c *gin.Context) {
|
||||
// Generate new EVM wallet
|
||||
privateKey, err := crypto.GenerateKey()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate wallet"})
|
||||
return
|
||||
}
|
||||
|
||||
address := crypto.PubkeyToAddress(privateKey.PublicKey)
|
||||
privKeyHex := "0x" + hex.EncodeToString(crypto.FromECDSA(privateKey))
|
||||
|
||||
c.JSON(http.StatusOK, walletGenerateResponse{
|
||||
Address: address.Hex(),
|
||||
PrivateKey: privKeyHex,
|
||||
})
|
||||
}
|
||||
|
||||
func checkClaw402Health() string {
|
||||
client := &http.Client{Timeout: 5 * time.Second}
|
||||
resp, err := client.Get("https://claw402.ai/health")
|
||||
if err != nil {
|
||||
return "unreachable"
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||
return "ok"
|
||||
}
|
||||
return "error"
|
||||
}
|
||||
@@ -1,252 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// MockUser Mock user structure
|
||||
type MockUser struct {
|
||||
ID int
|
||||
Email string
|
||||
OTPSecret string
|
||||
OTPVerified bool
|
||||
}
|
||||
|
||||
// TestOTPRefetchLogic Test OTP refetch logic
|
||||
func TestOTPRefetchLogic(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
existingUser *MockUser
|
||||
userExists bool
|
||||
expectedAction string // "allow_refetch", "reject_duplicate", "create_new"
|
||||
expectedMessage string
|
||||
}{
|
||||
{
|
||||
name: "New user registration - email does not exist",
|
||||
existingUser: nil,
|
||||
userExists: false,
|
||||
expectedAction: "create_new",
|
||||
expectedMessage: "Create new user",
|
||||
},
|
||||
{
|
||||
name: "Incomplete OTP verification - allow refetch",
|
||||
existingUser: &MockUser{
|
||||
ID: 1,
|
||||
Email: "test@example.com",
|
||||
OTPSecret: "SECRET123",
|
||||
OTPVerified: false,
|
||||
},
|
||||
userExists: true,
|
||||
expectedAction: "allow_refetch",
|
||||
expectedMessage: "Incomplete registration detected, please continue OTP setup",
|
||||
},
|
||||
{
|
||||
name: "Completed OTP verification - reject duplicate registration",
|
||||
existingUser: &MockUser{
|
||||
ID: 2,
|
||||
Email: "verified@example.com",
|
||||
OTPSecret: "SECRET456",
|
||||
OTPVerified: true,
|
||||
},
|
||||
userExists: true,
|
||||
expectedAction: "reject_duplicate",
|
||||
expectedMessage: "Email already registered",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Simulate logic processing flow
|
||||
var actualAction string
|
||||
var actualMessage string
|
||||
|
||||
if !tt.userExists {
|
||||
// User does not exist, create new user
|
||||
actualAction = "create_new"
|
||||
actualMessage = "Create new user"
|
||||
} else {
|
||||
// User exists, check OTP verification status
|
||||
if !tt.existingUser.OTPVerified {
|
||||
// OTP verification incomplete, allow refetch
|
||||
actualAction = "allow_refetch"
|
||||
actualMessage = "Incomplete registration detected, please continue OTP setup"
|
||||
} else {
|
||||
// Verification completed, reject duplicate registration
|
||||
actualAction = "reject_duplicate"
|
||||
actualMessage = "Email already registered"
|
||||
}
|
||||
}
|
||||
|
||||
// Verify results
|
||||
if actualAction != tt.expectedAction {
|
||||
t.Errorf("Action mismatch: got %s, want %s", actualAction, tt.expectedAction)
|
||||
}
|
||||
if actualMessage != tt.expectedMessage {
|
||||
t.Errorf("Message mismatch: got %s, want %s", actualMessage, tt.expectedMessage)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestOTPVerificationStates Test OTP verification state determination
|
||||
func TestOTPVerificationStates(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
otpVerified bool
|
||||
shouldAllowRefetch bool
|
||||
}{
|
||||
{
|
||||
name: "OTP verified - disallow refetch",
|
||||
otpVerified: true,
|
||||
shouldAllowRefetch: false,
|
||||
},
|
||||
{
|
||||
name: "OTP not verified - allow refetch",
|
||||
otpVerified: false,
|
||||
shouldAllowRefetch: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Simulate verification logic
|
||||
allowRefetch := !tt.otpVerified
|
||||
|
||||
if allowRefetch != tt.shouldAllowRefetch {
|
||||
t.Errorf("Refetch logic error: OTPVerified=%v, allowRefetch=%v, expected=%v",
|
||||
tt.otpVerified, allowRefetch, tt.shouldAllowRefetch)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegistrationFlow Test complete registration flow logic branches
|
||||
func TestRegistrationFlow(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
scenario string
|
||||
userExists bool
|
||||
otpVerified bool
|
||||
expectHTTPCode int // Simulated HTTP status code
|
||||
expectResponse string
|
||||
}{
|
||||
{
|
||||
name: "Scenario 1: New user first registration",
|
||||
scenario: "New user first accesses registration endpoint",
|
||||
userExists: false,
|
||||
otpVerified: false,
|
||||
expectHTTPCode: 200,
|
||||
expectResponse: "Create user and return OTP setup information",
|
||||
},
|
||||
{
|
||||
name: "Scenario 2: User re-accesses after interrupting registration",
|
||||
scenario: "User registered previously but did not complete OTP setup, now re-accessing",
|
||||
userExists: true,
|
||||
otpVerified: false,
|
||||
expectHTTPCode: 200,
|
||||
expectResponse: "Return existing user's OTP information, allow continuation",
|
||||
},
|
||||
{
|
||||
name: "Scenario 3: Registered user attempts duplicate registration",
|
||||
scenario: "User already completed registration, attempts to register again with same email",
|
||||
userExists: true,
|
||||
otpVerified: true,
|
||||
expectHTTPCode: 409, // Conflict
|
||||
expectResponse: "Email already registered",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Simulate registration flow logic
|
||||
var actualHTTPCode int
|
||||
var actualResponse string
|
||||
|
||||
if !tt.userExists {
|
||||
// New user, create and return OTP information
|
||||
actualHTTPCode = 200
|
||||
actualResponse = "Create user and return OTP setup information"
|
||||
} else {
|
||||
// User exists
|
||||
if !tt.otpVerified {
|
||||
// OTP verification incomplete, allow refetch
|
||||
actualHTTPCode = 200
|
||||
actualResponse = "Return existing user's OTP information, allow continuation"
|
||||
} else {
|
||||
// Verification completed, reject duplicate registration
|
||||
actualHTTPCode = 409
|
||||
actualResponse = "Email already registered"
|
||||
}
|
||||
}
|
||||
|
||||
// Verify
|
||||
if actualHTTPCode != tt.expectHTTPCode {
|
||||
t.Errorf("HTTP code mismatch: got %d, want %d (scenario: %s)",
|
||||
actualHTTPCode, tt.expectHTTPCode, tt.scenario)
|
||||
}
|
||||
if actualResponse != tt.expectResponse {
|
||||
t.Errorf("Response mismatch: got %s, want %s (scenario: %s)",
|
||||
actualResponse, tt.expectResponse, tt.scenario)
|
||||
}
|
||||
|
||||
t.Logf("✓ %s: HTTP %d, %s", tt.scenario, actualHTTPCode, actualResponse)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestEdgeCases Test edge cases
|
||||
func TestEdgeCases(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
user *MockUser
|
||||
expectAllow bool
|
||||
description string
|
||||
}{
|
||||
{
|
||||
name: "User ID is 0 - treated as new user",
|
||||
user: &MockUser{
|
||||
ID: 0,
|
||||
Email: "new@example.com",
|
||||
OTPVerified: false,
|
||||
},
|
||||
expectAllow: true,
|
||||
description: "ID of 0 usually indicates user has not been created yet",
|
||||
},
|
||||
{
|
||||
name: "OTPSecret is empty - still can refetch",
|
||||
user: &MockUser{
|
||||
ID: 1,
|
||||
Email: "test@example.com",
|
||||
OTPSecret: "",
|
||||
OTPVerified: false,
|
||||
},
|
||||
expectAllow: true,
|
||||
description: "Even if OTPSecret is empty, as long as not verified, refetch is allowed",
|
||||
},
|
||||
{
|
||||
name: "OTPSecret exists but already verified - not allowed",
|
||||
user: &MockUser{
|
||||
ID: 2,
|
||||
Email: "verified@example.com",
|
||||
OTPSecret: "SECRET789",
|
||||
OTPVerified: true,
|
||||
},
|
||||
expectAllow: false,
|
||||
description: "Users with verified OTP cannot refetch",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Core logic: as long as OTPVerified is false, refetch is allowed
|
||||
allowRefetch := !tt.user.OTPVerified
|
||||
|
||||
if allowRefetch != tt.expectAllow {
|
||||
t.Errorf("Edge case failed: %s\nUser: ID=%d, OTPVerified=%v\nExpected allow=%v, got=%v",
|
||||
tt.description, tt.user.ID, tt.user.OTPVerified, tt.expectAllow, allowRefetch)
|
||||
}
|
||||
|
||||
t.Logf("✓ %s", tt.description)
|
||||
})
|
||||
}
|
||||
}
|
||||
66
api/route_registry.go
Normal file
66
api/route_registry.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// RouteDoc holds documentation for a single API route.
|
||||
type RouteDoc struct {
|
||||
Method string
|
||||
Path string
|
||||
Description string
|
||||
Schema string // optional: full parameter/body schema documentation
|
||||
}
|
||||
|
||||
// routeRegistry stores all documented routes. Populated via s.route() calls in setupRoutes.
|
||||
var routeRegistry []RouteDoc
|
||||
|
||||
// route registers an HTTP route with a one-line description.
|
||||
func (s *Server) route(g *gin.RouterGroup, method, path, description string, h gin.HandlerFunc) {
|
||||
s.routeWithSchema(g, method, path, description, "", h)
|
||||
}
|
||||
|
||||
// routeWithSchema registers an HTTP route with full parameter schema documentation.
|
||||
// schema is injected verbatim into the API docs seen by the LLM.
|
||||
func (s *Server) routeWithSchema(g *gin.RouterGroup, method, path, description, schema string, h gin.HandlerFunc) {
|
||||
fullPath := strings.TrimSuffix(g.BasePath(), "/") + "/" + strings.TrimPrefix(path, "/")
|
||||
routeRegistry = append(routeRegistry, RouteDoc{
|
||||
Method: method,
|
||||
Path: fullPath,
|
||||
Description: description,
|
||||
Schema: schema,
|
||||
})
|
||||
switch method {
|
||||
case "GET":
|
||||
g.GET(path, h)
|
||||
case "POST":
|
||||
g.POST(path, h)
|
||||
case "PUT":
|
||||
g.PUT(path, h)
|
||||
case "DELETE":
|
||||
g.DELETE(path, h)
|
||||
}
|
||||
}
|
||||
|
||||
// GetAPIDocs returns formatted API documentation for injection into the LLM system prompt.
|
||||
// Routes with schema documentation include full parameter details.
|
||||
func GetAPIDocs() string {
|
||||
var sb strings.Builder
|
||||
for _, r := range routeRegistry {
|
||||
sb.WriteString(fmt.Sprintf("%-8s %s\n", r.Method, r.Path))
|
||||
sb.WriteString(fmt.Sprintf(" %s\n", r.Description))
|
||||
if r.Schema != "" {
|
||||
// Indent each schema line for readability
|
||||
for _, line := range strings.Split(strings.TrimSpace(r.Schema), "\n") {
|
||||
sb.WriteString(" ")
|
||||
sb.WriteString(line)
|
||||
sb.WriteByte('\n')
|
||||
}
|
||||
}
|
||||
sb.WriteByte('\n')
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
3510
api/server.go
3510
api/server.go
File diff suppressed because it is too large
Load Diff
157
api/strategy.go
157
api/strategy.go
@@ -8,6 +8,8 @@ import (
|
||||
"nofx/logger"
|
||||
"nofx/market"
|
||||
"nofx/mcp"
|
||||
_ "nofx/mcp/payment"
|
||||
_ "nofx/mcp/provider"
|
||||
"nofx/store"
|
||||
"time"
|
||||
|
||||
@@ -29,6 +31,20 @@ func validateStrategyConfig(config *store.StrategyConfig) []string {
|
||||
return warnings
|
||||
}
|
||||
|
||||
// handleEstimateTokens estimates token usage for a strategy config (no auth required, pure computation)
|
||||
func (s *Server) handleEstimateTokens(c *gin.Context) {
|
||||
var req struct {
|
||||
Config store.StrategyConfig `json:"config" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
SafeBadRequest(c, "Invalid request parameters")
|
||||
return
|
||||
}
|
||||
|
||||
estimate := req.Config.EstimateTokens()
|
||||
c.JSON(http.StatusOK, estimate)
|
||||
}
|
||||
|
||||
// handlePublicStrategies Get public strategies for strategy market (no auth required)
|
||||
func (s *Server) handlePublicStrategies(c *gin.Context) {
|
||||
strategies, err := s.store.Strategy().ListPublic()
|
||||
@@ -136,7 +152,8 @@ func (s *Server) handleGetStrategy(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// handleCreateStrategy Create strategy
|
||||
// handleCreateStrategy Create strategy.
|
||||
// If "config" is omitted from the request body, the system default config is used automatically.
|
||||
func (s *Server) handleCreateStrategy(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
if userID == "" {
|
||||
@@ -145,9 +162,10 @@ func (s *Server) handleCreateStrategy(c *gin.Context) {
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
Config store.StrategyConfig `json:"config" binding:"required"`
|
||||
Name string `json:"name" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
Lang string `json:"lang"` // "zh" or "en", used when config is omitted
|
||||
Config *store.StrategyConfig `json:"config"` // optional — uses default if omitted
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
@@ -155,6 +173,16 @@ func (s *Server) handleCreateStrategy(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Use default config when none provided
|
||||
if req.Config == nil {
|
||||
lang := req.Lang
|
||||
if lang == "" {
|
||||
lang = "zh"
|
||||
}
|
||||
defaultCfg := store.GetDefaultStrategyConfig(lang)
|
||||
req.Config = &defaultCfg
|
||||
}
|
||||
|
||||
// Serialize configuration
|
||||
configJSON, err := json.Marshal(req.Config)
|
||||
if err != nil {
|
||||
@@ -178,7 +206,7 @@ func (s *Server) handleCreateStrategy(c *gin.Context) {
|
||||
}
|
||||
|
||||
// Validate configuration and collect warnings
|
||||
warnings := validateStrategyConfig(&req.Config)
|
||||
warnings := validateStrategyConfig(req.Config)
|
||||
|
||||
response := gin.H{
|
||||
"id": strategy.ID,
|
||||
@@ -191,7 +219,10 @@ func (s *Server) handleCreateStrategy(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// handleUpdateStrategy Update strategy
|
||||
// handleUpdateStrategy Update strategy.
|
||||
// The incoming config is merged with the existing one: top-level sections present in the
|
||||
// request overwrite the corresponding existing sections; absent sections are preserved.
|
||||
// This prevents partial updates from zeroing out unmentioned fields.
|
||||
func (s *Server) handleUpdateStrategy(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
strategyID := c.Param("id")
|
||||
@@ -213,11 +244,11 @@ func (s *Server) handleUpdateStrategy(c *gin.Context) {
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Config store.StrategyConfig `json:"config"`
|
||||
IsPublic bool `json:"is_public"`
|
||||
ConfigVisible bool `json:"config_visible"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Config json.RawMessage `json:"config"` // raw JSON so we can merge
|
||||
IsPublic bool `json:"is_public"`
|
||||
ConfigVisible bool `json:"config_visible"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
@@ -225,8 +256,33 @@ func (s *Server) handleUpdateStrategy(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Serialize configuration
|
||||
configJSON, err := json.Marshal(req.Config)
|
||||
// Start with the existing config as base — preserves all unmentioned fields.
|
||||
var mergedConfig store.StrategyConfig
|
||||
if err := json.Unmarshal([]byte(existing.Config), &mergedConfig); err != nil {
|
||||
// If existing config is corrupt, start from zero
|
||||
mergedConfig = store.StrategyConfig{}
|
||||
}
|
||||
|
||||
// Apply incoming config on top: top-level sections present in the request overwrite
|
||||
// their corresponding existing section; absent sections remain unchanged.
|
||||
if len(req.Config) > 0 && string(req.Config) != "null" {
|
||||
if err := json.Unmarshal(req.Config, &mergedConfig); err != nil {
|
||||
SafeBadRequest(c, "Invalid config JSON")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Preserve existing name/description when not supplied
|
||||
name := req.Name
|
||||
if name == "" {
|
||||
name = existing.Name
|
||||
}
|
||||
description := req.Description
|
||||
if description == "" {
|
||||
description = existing.Description
|
||||
}
|
||||
|
||||
configJSON, err := json.Marshal(mergedConfig)
|
||||
if err != nil {
|
||||
SafeInternalError(c, "Serialize configuration", err)
|
||||
return
|
||||
@@ -235,8 +291,8 @@ func (s *Server) handleUpdateStrategy(c *gin.Context) {
|
||||
strategy := &store.Strategy{
|
||||
ID: strategyID,
|
||||
UserID: userID,
|
||||
Name: req.Name,
|
||||
Description: req.Description,
|
||||
Name: name,
|
||||
Description: description,
|
||||
Config: string(configJSON),
|
||||
IsPublic: req.IsPublic,
|
||||
ConfigVisible: req.ConfigVisible,
|
||||
@@ -247,8 +303,27 @@ func (s *Server) handleUpdateStrategy(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Validate configuration and collect warnings
|
||||
warnings := validateStrategyConfig(&req.Config)
|
||||
// Token overflow check — block save if all models exceed context limits
|
||||
if mergedConfig.StrategyType == "" || mergedConfig.StrategyType == "ai_trading" {
|
||||
estimate := mergedConfig.EstimateTokens()
|
||||
allExceed := true
|
||||
for _, ml := range estimate.ModelLimits {
|
||||
if ml.UsagePct <= 100 {
|
||||
allExceed = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if allExceed && len(estimate.ModelLimits) > 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": fmt.Sprintf("Estimated %d tokens exceeds all known model context limits. Reduce coins, timeframes, or K-line count.", estimate.Total),
|
||||
"token_estimate": estimate,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Validate merged configuration and collect warnings
|
||||
warnings := validateStrategyConfig(&mergedConfig)
|
||||
|
||||
response := gin.H{"message": "Strategy updated successfully"}
|
||||
if len(warnings) > 0 {
|
||||
@@ -269,7 +344,7 @@ func (s *Server) handleDeleteStrategy(c *gin.Context) {
|
||||
}
|
||||
|
||||
if err := s.store.Strategy().Delete(userID, strategyID); err != nil {
|
||||
SafeInternalError(c, "Failed to delete strategy", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": SanitizeError(err, "Failed to delete strategy")})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -377,9 +452,9 @@ func (s *Server) handlePreviewPrompt(c *gin.Context) {
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Config store.StrategyConfig `json:"config" binding:"required"`
|
||||
AccountEquity float64 `json:"account_equity"`
|
||||
PromptVariant string `json:"prompt_variant"`
|
||||
Config store.StrategyConfig `json:"config" binding:"required"`
|
||||
AccountEquity float64 `json:"account_equity"`
|
||||
PromptVariant string `json:"prompt_variant"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
@@ -597,37 +672,20 @@ func (s *Server) runRealAITest(userID, modelID, systemPrompt, userPrompt string)
|
||||
return "", fmt.Errorf("AI model %s is missing API Key", model.Name)
|
||||
}
|
||||
|
||||
// Create AI client
|
||||
var aiClient mcp.AIClient
|
||||
// Create AI client via registry
|
||||
provider := model.Provider
|
||||
|
||||
// Convert EncryptedString to string for API key
|
||||
apiKey := string(model.APIKey)
|
||||
switch provider {
|
||||
case "qwen":
|
||||
aiClient = mcp.NewQwenClient()
|
||||
aiClient.SetAPIKey(apiKey, model.CustomAPIURL, model.CustomModelName)
|
||||
case "deepseek":
|
||||
aiClient = mcp.NewDeepSeekClient()
|
||||
aiClient.SetAPIKey(apiKey, model.CustomAPIURL, model.CustomModelName)
|
||||
case "claude":
|
||||
aiClient = mcp.NewClaudeClient()
|
||||
aiClient.SetAPIKey(apiKey, model.CustomAPIURL, model.CustomModelName)
|
||||
case "kimi":
|
||||
aiClient = mcp.NewKimiClient()
|
||||
aiClient.SetAPIKey(apiKey, model.CustomAPIURL, model.CustomModelName)
|
||||
case "gemini":
|
||||
aiClient = mcp.NewGeminiClient()
|
||||
aiClient.SetAPIKey(apiKey, model.CustomAPIURL, model.CustomModelName)
|
||||
case "grok":
|
||||
aiClient = mcp.NewGrokClient()
|
||||
aiClient.SetAPIKey(apiKey, model.CustomAPIURL, model.CustomModelName)
|
||||
case "openai":
|
||||
aiClient = mcp.NewOpenAIClient()
|
||||
aiClient.SetAPIKey(apiKey, model.CustomAPIURL, model.CustomModelName)
|
||||
default:
|
||||
// Use generic client
|
||||
|
||||
aiClient := mcp.NewAIClientByProvider(provider)
|
||||
if aiClient == nil {
|
||||
aiClient = mcp.NewClient()
|
||||
}
|
||||
|
||||
// Payment providers ignore custom URL
|
||||
switch provider {
|
||||
case "claw402":
|
||||
aiClient.SetAPIKey(apiKey, "", model.CustomModelName)
|
||||
default:
|
||||
aiClient.SetAPIKey(apiKey, model.CustomAPIURL, model.CustomModelName)
|
||||
}
|
||||
|
||||
@@ -639,4 +697,3 @@ func (s *Server) runRealAITest(userID, modelID, systemPrompt, userPrompt string)
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
|
||||
35
auth/auth.go
35
auth/auth.go
@@ -1,15 +1,12 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/google/uuid"
|
||||
"github.com/pquerna/otp/totp"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
@@ -25,9 +22,6 @@ var tokenBlacklist = struct {
|
||||
// maxBlacklistEntries is the maximum capacity threshold for blacklist
|
||||
const maxBlacklistEntries = 100_000
|
||||
|
||||
// OTPIssuer is the OTP issuer name
|
||||
const OTPIssuer = "nofxAI"
|
||||
|
||||
// SetJWTSecret sets the JWT secret key
|
||||
func SetJWTSecret(secret string) {
|
||||
JWTSecret = []byte(secret)
|
||||
@@ -87,30 +81,6 @@ func CheckPassword(password, hash string) bool {
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// GenerateOTPSecret generates OTP secret
|
||||
func GenerateOTPSecret() (string, error) {
|
||||
secret := make([]byte, 20)
|
||||
_, err := rand.Read(secret)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
key, err := totp.Generate(totp.GenerateOpts{
|
||||
Issuer: OTPIssuer,
|
||||
AccountName: uuid.New().String(),
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return key.Secret(), nil
|
||||
}
|
||||
|
||||
// VerifyOTP verifies OTP code
|
||||
func VerifyOTP(secret, code string) bool {
|
||||
return totp.Validate(code, secret)
|
||||
}
|
||||
|
||||
// GenerateJWT generates JWT token
|
||||
func GenerateJWT(userID, email string) (string, error) {
|
||||
claims := Claims{
|
||||
@@ -147,8 +117,3 @@ func ValidateJWT(tokenString string) (*Claims, error) {
|
||||
|
||||
return nil, fmt.Errorf("invalid token")
|
||||
}
|
||||
|
||||
// GetOTPQRCodeURL gets OTP QR code URL
|
||||
func GetOTPQRCodeURL(secret, email string) string {
|
||||
return fmt.Sprintf("otpauth://totp/%s:%s?secret=%s&issuer=%s", OTPIssuer, email, secret, OTPIssuer)
|
||||
}
|
||||
|
||||
@@ -1,263 +0,0 @@
|
||||
package backtest
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const epsilon = 1e-8
|
||||
|
||||
type position struct {
|
||||
Symbol string
|
||||
Side string
|
||||
Quantity float64
|
||||
EntryPrice float64
|
||||
Leverage int
|
||||
Margin float64
|
||||
Notional float64
|
||||
LiquidationPrice float64
|
||||
OpenTime int64
|
||||
AccumulatedFee float64 // Total fees paid (opening + any additions)
|
||||
}
|
||||
|
||||
type BacktestAccount struct {
|
||||
initialBalance float64
|
||||
cash float64
|
||||
feeRate float64
|
||||
slippageRate float64
|
||||
positions map[string]*position
|
||||
realizedPnL float64
|
||||
}
|
||||
|
||||
func NewBacktestAccount(initialBalance, feeBps, slippageBps float64) *BacktestAccount {
|
||||
return &BacktestAccount{
|
||||
initialBalance: initialBalance,
|
||||
cash: initialBalance,
|
||||
feeRate: feeBps / 10000.0,
|
||||
slippageRate: slippageBps / 10000.0,
|
||||
positions: make(map[string]*position),
|
||||
}
|
||||
}
|
||||
|
||||
func positionKey(symbol, side string) string {
|
||||
return strings.ToUpper(symbol) + ":" + side
|
||||
}
|
||||
|
||||
func (acc *BacktestAccount) ensurePosition(symbol, side string) *position {
|
||||
key := positionKey(symbol, side)
|
||||
if pos, ok := acc.positions[key]; ok {
|
||||
return pos
|
||||
}
|
||||
pos := &position{Symbol: strings.ToUpper(symbol), Side: side}
|
||||
acc.positions[key] = pos
|
||||
return pos
|
||||
}
|
||||
|
||||
func (acc *BacktestAccount) removePosition(pos *position) {
|
||||
key := positionKey(pos.Symbol, pos.Side)
|
||||
delete(acc.positions, key)
|
||||
}
|
||||
|
||||
func (acc *BacktestAccount) Open(symbol, side string, quantity float64, leverage int, price float64, ts int64) (*position, float64, float64, error) {
|
||||
if quantity <= 0 {
|
||||
return nil, 0, 0, fmt.Errorf("quantity must be positive")
|
||||
}
|
||||
if leverage <= 0 {
|
||||
return nil, 0, 0, fmt.Errorf("leverage must be positive")
|
||||
}
|
||||
|
||||
execPrice := applySlippage(price, acc.slippageRate, side, true)
|
||||
notional := execPrice * quantity
|
||||
margin := notional / float64(leverage)
|
||||
fee := notional * acc.feeRate
|
||||
|
||||
if margin+fee > acc.cash+epsilon {
|
||||
return nil, 0, 0, fmt.Errorf("insufficient cash: need %.2f", margin+fee)
|
||||
}
|
||||
|
||||
acc.cash -= margin + fee
|
||||
|
||||
pos := acc.ensurePosition(symbol, side)
|
||||
|
||||
if pos.Quantity < epsilon {
|
||||
pos.Quantity = quantity
|
||||
pos.EntryPrice = execPrice
|
||||
pos.Leverage = leverage
|
||||
pos.Margin = margin
|
||||
pos.Notional = notional
|
||||
pos.OpenTime = ts
|
||||
pos.LiquidationPrice = computeLiquidation(execPrice, leverage, side)
|
||||
pos.AccumulatedFee = fee // Track opening fee
|
||||
} else {
|
||||
if leverage != pos.Leverage {
|
||||
// Use weighted average leverage (approximate)
|
||||
weightedMargin := pos.Margin + margin
|
||||
pos.Leverage = int(math.Round((pos.Notional + notional) / weightedMargin))
|
||||
}
|
||||
pos.Notional += notional
|
||||
pos.Margin += margin
|
||||
pos.EntryPrice = ((pos.EntryPrice * pos.Quantity) + execPrice*quantity) / (pos.Quantity + quantity)
|
||||
pos.Quantity += quantity
|
||||
pos.LiquidationPrice = computeLiquidation(pos.EntryPrice, pos.Leverage, side)
|
||||
pos.AccumulatedFee += fee // Add to accumulated fee for position additions
|
||||
}
|
||||
|
||||
return pos, fee, execPrice, nil
|
||||
}
|
||||
|
||||
func (acc *BacktestAccount) Close(symbol, side string, quantity float64, price float64) (float64, float64, float64, error) {
|
||||
key := positionKey(symbol, side)
|
||||
pos, ok := acc.positions[key]
|
||||
if !ok || pos.Quantity <= epsilon {
|
||||
return 0, 0, 0, fmt.Errorf("no active %s position for %s", side, symbol)
|
||||
}
|
||||
|
||||
if quantity <= 0 || quantity > pos.Quantity+epsilon {
|
||||
if math.Abs(quantity) <= epsilon {
|
||||
quantity = pos.Quantity
|
||||
} else {
|
||||
return 0, 0, 0, fmt.Errorf("invalid close quantity")
|
||||
}
|
||||
}
|
||||
|
||||
execPrice := applySlippage(price, acc.slippageRate, side, false)
|
||||
notional := execPrice * quantity
|
||||
closingFee := notional * acc.feeRate
|
||||
|
||||
// Calculate proportional opening fee for the quantity being closed
|
||||
closePortion := quantity / pos.Quantity
|
||||
openingFeePortion := pos.AccumulatedFee * closePortion
|
||||
totalFee := closingFee + openingFeePortion
|
||||
|
||||
realized := realizedPnL(pos, quantity, execPrice)
|
||||
|
||||
marginPortion := pos.Margin * closePortion
|
||||
// Note: Opening fee was already deducted from cash when opening, so we only deduct closing fee here
|
||||
acc.cash += marginPortion + realized - closingFee
|
||||
// But for realized P&L tracking, we include both fees
|
||||
acc.realizedPnL += realized - totalFee
|
||||
|
||||
pos.Quantity -= quantity
|
||||
pos.Notional -= notional
|
||||
pos.Margin -= marginPortion
|
||||
pos.AccumulatedFee -= openingFeePortion // Reduce tracked opening fee
|
||||
|
||||
if pos.Quantity <= epsilon {
|
||||
acc.removePosition(pos)
|
||||
}
|
||||
|
||||
// Return total fee (opening + closing) so caller can calculate accurate P&L
|
||||
return realized, totalFee, execPrice, nil
|
||||
}
|
||||
|
||||
func (acc *BacktestAccount) TotalEquity(priceMap map[string]float64) (float64, float64, map[string]float64) {
|
||||
unrealized := 0.0
|
||||
margin := 0.0
|
||||
perSymbol := make(map[string]float64)
|
||||
for _, pos := range acc.positions {
|
||||
price := priceMap[pos.Symbol]
|
||||
pnl := unrealizedPnL(pos, price)
|
||||
unrealized += pnl
|
||||
margin += pos.Margin
|
||||
perSymbol[pos.Symbol+":"+pos.Side] = pnl
|
||||
}
|
||||
return acc.cash + margin + unrealized, unrealized, perSymbol
|
||||
}
|
||||
|
||||
func applySlippage(price float64, rate float64, side string, isOpen bool) float64 {
|
||||
if rate <= 0 {
|
||||
return price
|
||||
}
|
||||
adjust := 1.0
|
||||
if side == "long" {
|
||||
if isOpen {
|
||||
adjust += rate
|
||||
} else {
|
||||
adjust -= rate
|
||||
}
|
||||
} else {
|
||||
if isOpen {
|
||||
adjust -= rate
|
||||
} else {
|
||||
adjust += rate
|
||||
}
|
||||
}
|
||||
return price * adjust
|
||||
}
|
||||
|
||||
func computeLiquidation(entry float64, leverage int, side string) float64 {
|
||||
if leverage <= 0 {
|
||||
return 0
|
||||
}
|
||||
lev := float64(leverage)
|
||||
if side == "long" {
|
||||
return entry * (1.0 - 1.0/lev)
|
||||
}
|
||||
return entry * (1.0 + 1.0/lev)
|
||||
}
|
||||
|
||||
func realizedPnL(pos *position, qty, price float64) float64 {
|
||||
if pos.Side == "long" {
|
||||
return (price - pos.EntryPrice) * qty
|
||||
}
|
||||
return (pos.EntryPrice - price) * qty
|
||||
}
|
||||
|
||||
func unrealizedPnL(pos *position, price float64) float64 {
|
||||
if pos.Side == "long" {
|
||||
return (price - pos.EntryPrice) * pos.Quantity
|
||||
}
|
||||
return (pos.EntryPrice - price) * pos.Quantity
|
||||
}
|
||||
|
||||
func (acc *BacktestAccount) Positions() []*position {
|
||||
list := make([]*position, 0, len(acc.positions))
|
||||
for _, pos := range acc.positions {
|
||||
list = append(list, pos)
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
func (acc *BacktestAccount) positionLeverage(symbol, side string) int {
|
||||
key := positionKey(symbol, side)
|
||||
if pos, ok := acc.positions[key]; ok && pos.Quantity > epsilon {
|
||||
return pos.Leverage
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (acc *BacktestAccount) Cash() float64 {
|
||||
return acc.cash
|
||||
}
|
||||
|
||||
func (acc *BacktestAccount) InitialBalance() float64 {
|
||||
return acc.initialBalance
|
||||
}
|
||||
|
||||
func (acc *BacktestAccount) RealizedPnL() float64 {
|
||||
return acc.realizedPnL
|
||||
}
|
||||
|
||||
// RestoreFromSnapshots restores account state from checkpoint.
|
||||
func (acc *BacktestAccount) RestoreFromSnapshots(cash float64, realized float64, snaps []PositionSnapshot) {
|
||||
acc.cash = cash
|
||||
acc.realizedPnL = realized
|
||||
acc.positions = make(map[string]*position)
|
||||
for _, snap := range snaps {
|
||||
pos := &position{
|
||||
Symbol: snap.Symbol,
|
||||
Side: snap.Side,
|
||||
Quantity: snap.Quantity,
|
||||
EntryPrice: snap.AvgPrice,
|
||||
Leverage: snap.Leverage,
|
||||
Margin: snap.MarginUsed,
|
||||
Notional: snap.Quantity * snap.AvgPrice,
|
||||
LiquidationPrice: snap.LiquidationPrice,
|
||||
OpenTime: snap.OpenTime,
|
||||
AccumulatedFee: snap.AccumulatedFee,
|
||||
}
|
||||
key := positionKey(pos.Symbol, pos.Side)
|
||||
acc.positions[key] = pos
|
||||
}
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
package backtest
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"nofx/mcp"
|
||||
)
|
||||
|
||||
// configureMCPClient creates/clones an MCP client based on configuration (returns mcp.AIClient interface).
|
||||
// Note: mcp.New() returns an interface type; here we convert to concrete implementation before copying to avoid concurrent shared state.
|
||||
func configureMCPClient(cfg BacktestConfig, base mcp.AIClient) (mcp.AIClient, error) {
|
||||
provider := strings.ToLower(strings.TrimSpace(cfg.AICfg.Provider))
|
||||
|
||||
// DeepSeek
|
||||
if provider == "" || provider == "inherit" || provider == "default" {
|
||||
client := cloneBaseClient(base)
|
||||
if cfg.AICfg.APIKey != "" || cfg.AICfg.BaseURL != "" || cfg.AICfg.Model != "" {
|
||||
client.SetAPIKey(cfg.AICfg.APIKey, cfg.AICfg.BaseURL, cfg.AICfg.Model)
|
||||
}
|
||||
return client, nil
|
||||
}
|
||||
|
||||
switch provider {
|
||||
case "deepseek":
|
||||
if cfg.AICfg.APIKey == "" {
|
||||
return nil, fmt.Errorf("deepseek provider requires api key")
|
||||
}
|
||||
ds := mcp.NewDeepSeekClientWithOptions()
|
||||
ds.(*mcp.DeepSeekClient).SetAPIKey(cfg.AICfg.APIKey, cfg.AICfg.BaseURL, cfg.AICfg.Model)
|
||||
return ds, nil
|
||||
case "qwen":
|
||||
if cfg.AICfg.APIKey == "" {
|
||||
return nil, fmt.Errorf("qwen provider requires api key")
|
||||
}
|
||||
qc := mcp.NewQwenClientWithOptions()
|
||||
qc.(*mcp.QwenClient).SetAPIKey(cfg.AICfg.APIKey, cfg.AICfg.BaseURL, cfg.AICfg.Model)
|
||||
return qc, nil
|
||||
case "claude":
|
||||
if cfg.AICfg.APIKey == "" {
|
||||
return nil, fmt.Errorf("claude provider requires api key")
|
||||
}
|
||||
cc := mcp.NewClaudeClientWithOptions()
|
||||
cc.(*mcp.ClaudeClient).SetAPIKey(cfg.AICfg.APIKey, cfg.AICfg.BaseURL, cfg.AICfg.Model)
|
||||
return cc, nil
|
||||
case "kimi":
|
||||
if cfg.AICfg.APIKey == "" {
|
||||
return nil, fmt.Errorf("kimi provider requires api key")
|
||||
}
|
||||
kc := mcp.NewKimiClientWithOptions()
|
||||
kc.(*mcp.KimiClient).SetAPIKey(cfg.AICfg.APIKey, cfg.AICfg.BaseURL, cfg.AICfg.Model)
|
||||
return kc, nil
|
||||
case "gemini":
|
||||
if cfg.AICfg.APIKey == "" {
|
||||
return nil, fmt.Errorf("gemini provider requires api key")
|
||||
}
|
||||
gc := mcp.NewGeminiClientWithOptions()
|
||||
gc.(*mcp.GeminiClient).SetAPIKey(cfg.AICfg.APIKey, cfg.AICfg.BaseURL, cfg.AICfg.Model)
|
||||
return gc, nil
|
||||
case "grok":
|
||||
if cfg.AICfg.APIKey == "" {
|
||||
return nil, fmt.Errorf("grok provider requires api key")
|
||||
}
|
||||
grokC := mcp.NewGrokClientWithOptions()
|
||||
grokC.(*mcp.GrokClient).SetAPIKey(cfg.AICfg.APIKey, cfg.AICfg.BaseURL, cfg.AICfg.Model)
|
||||
return grokC, nil
|
||||
case "openai":
|
||||
if cfg.AICfg.APIKey == "" {
|
||||
return nil, fmt.Errorf("openai provider requires api key")
|
||||
}
|
||||
oaiC := mcp.NewOpenAIClientWithOptions()
|
||||
oaiC.(*mcp.OpenAIClient).SetAPIKey(cfg.AICfg.APIKey, cfg.AICfg.BaseURL, cfg.AICfg.Model)
|
||||
return oaiC, nil
|
||||
case "custom":
|
||||
if cfg.AICfg.BaseURL == "" || cfg.AICfg.APIKey == "" || cfg.AICfg.Model == "" {
|
||||
return nil, fmt.Errorf("custom provider requires base_url, api key and model")
|
||||
}
|
||||
client := cloneBaseClient(base)
|
||||
client.SetAPIKey(cfg.AICfg.APIKey, cfg.AICfg.BaseURL, cfg.AICfg.Model)
|
||||
return client, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported ai provider %s", cfg.AICfg.Provider)
|
||||
}
|
||||
}
|
||||
|
||||
// cloneBaseClient copies the base client to avoid shared mutable state.
|
||||
func cloneBaseClient(base mcp.AIClient) *mcp.Client {
|
||||
// Prefer to reuse the passed-in base client (deep copy)
|
||||
switch c := base.(type) {
|
||||
case *mcp.Client:
|
||||
cp := *c
|
||||
return &cp
|
||||
case *mcp.DeepSeekClient:
|
||||
if c != nil && c.Client != nil {
|
||||
cp := *c.Client
|
||||
return &cp
|
||||
}
|
||||
case *mcp.QwenClient:
|
||||
if c != nil && c.Client != nil {
|
||||
cp := *c.Client
|
||||
return &cp
|
||||
}
|
||||
case *mcp.ClaudeClient:
|
||||
if c != nil && c.Client != nil {
|
||||
cp := *c.Client
|
||||
return &cp
|
||||
}
|
||||
case *mcp.KimiClient:
|
||||
if c != nil && c.Client != nil {
|
||||
cp := *c.Client
|
||||
return &cp
|
||||
}
|
||||
case *mcp.GeminiClient:
|
||||
if c != nil && c.Client != nil {
|
||||
cp := *c.Client
|
||||
return &cp
|
||||
}
|
||||
case *mcp.GrokClient:
|
||||
if c != nil && c.Client != nil {
|
||||
cp := *c.Client
|
||||
return &cp
|
||||
}
|
||||
case *mcp.OpenAIClient:
|
||||
if c != nil && c.Client != nil {
|
||||
cp := *c.Client
|
||||
return &cp
|
||||
}
|
||||
}
|
||||
// Fall back to a new default client
|
||||
return mcp.NewClient().(*mcp.Client)
|
||||
}
|
||||
@@ -1,168 +0,0 @@
|
||||
package backtest
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"nofx/kernel"
|
||||
"nofx/market"
|
||||
)
|
||||
|
||||
type cachedDecision struct {
|
||||
Key string `json:"key"`
|
||||
PromptVariant string `json:"prompt_variant"`
|
||||
Timestamp int64 `json:"ts"`
|
||||
Decision *kernel.FullDecision `json:"decision"`
|
||||
}
|
||||
|
||||
// AICache persists AI decisions for repeated backtesting or replay.
|
||||
type AICache struct {
|
||||
mu sync.RWMutex
|
||||
path string
|
||||
Entries map[string]cachedDecision `json:"entries"`
|
||||
}
|
||||
|
||||
func LoadAICache(path string) (*AICache, error) {
|
||||
if path == "" {
|
||||
return nil, fmt.Errorf("ai cache path is empty")
|
||||
}
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cache := &AICache{
|
||||
path: path,
|
||||
Entries: make(map[string]cachedDecision),
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return cache, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return cache, nil
|
||||
}
|
||||
if err := json.Unmarshal(data, cache); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if cache.Entries == nil {
|
||||
cache.Entries = make(map[string]cachedDecision)
|
||||
}
|
||||
return cache, nil
|
||||
}
|
||||
|
||||
func (c *AICache) Path() string {
|
||||
if c == nil {
|
||||
return ""
|
||||
}
|
||||
return c.path
|
||||
}
|
||||
|
||||
func (c *AICache) Get(key string) (*kernel.FullDecision, bool) {
|
||||
if c == nil || key == "" {
|
||||
return nil, false
|
||||
}
|
||||
c.mu.RLock()
|
||||
entry, ok := c.Entries[key]
|
||||
c.mu.RUnlock()
|
||||
if !ok || entry.Decision == nil {
|
||||
return nil, false
|
||||
}
|
||||
return cloneDecision(entry.Decision), true
|
||||
}
|
||||
|
||||
func (c *AICache) Put(key string, variant string, ts int64, decision *kernel.FullDecision) error {
|
||||
if c == nil || key == "" || decision == nil {
|
||||
return nil
|
||||
}
|
||||
entry := cachedDecision{
|
||||
Key: key,
|
||||
PromptVariant: variant,
|
||||
Timestamp: ts,
|
||||
Decision: cloneDecision(decision),
|
||||
}
|
||||
c.mu.Lock()
|
||||
c.Entries[key] = entry
|
||||
c.mu.Unlock()
|
||||
return c.save()
|
||||
}
|
||||
|
||||
func (c *AICache) save() error {
|
||||
if c == nil || c.path == "" {
|
||||
return nil
|
||||
}
|
||||
c.mu.RLock()
|
||||
data, err := json.MarshalIndent(c, "", " ")
|
||||
c.mu.RUnlock()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeFileAtomic(c.path, data, 0o644)
|
||||
}
|
||||
|
||||
func cloneDecision(src *kernel.FullDecision) *kernel.FullDecision {
|
||||
if src == nil {
|
||||
return nil
|
||||
}
|
||||
data, err := json.Marshal(src)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var dst kernel.FullDecision
|
||||
if err := json.Unmarshal(data, &dst); err != nil {
|
||||
return nil
|
||||
}
|
||||
return &dst
|
||||
}
|
||||
|
||||
func computeCacheKey(ctx *kernel.Context, variant string, ts int64) (string, error) {
|
||||
if ctx == nil {
|
||||
return "", fmt.Errorf("context is nil")
|
||||
}
|
||||
payload := struct {
|
||||
Variant string `json:"variant"`
|
||||
Timestamp int64 `json:"ts"`
|
||||
CurrentTime string `json:"current_time"`
|
||||
Account kernel.AccountInfo `json:"account"`
|
||||
Positions []kernel.PositionInfo `json:"positions"`
|
||||
CandidateCoins []kernel.CandidateCoin `json:"candidate_coins"`
|
||||
MarketData map[string]market.Data `json:"market"`
|
||||
MarginUsedPct float64 `json:"margin_used_pct"`
|
||||
Runtime int `json:"runtime_minutes"`
|
||||
CallCount int `json:"call_count"`
|
||||
}{
|
||||
Variant: variant,
|
||||
Timestamp: ts,
|
||||
CurrentTime: ctx.CurrentTime,
|
||||
Account: ctx.Account,
|
||||
Positions: ctx.Positions,
|
||||
CandidateCoins: ctx.CandidateCoins,
|
||||
MarginUsedPct: ctx.Account.MarginUsedPct,
|
||||
Runtime: ctx.RuntimeMinutes,
|
||||
CallCount: ctx.CallCount,
|
||||
MarketData: make(map[string]market.Data, len(ctx.MarketDataMap)),
|
||||
}
|
||||
|
||||
for symbol, data := range ctx.MarketDataMap {
|
||||
if data == nil {
|
||||
continue
|
||||
}
|
||||
payload.MarketData[symbol] = *data
|
||||
}
|
||||
|
||||
bytes, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
sum := sha256.Sum256(bytes)
|
||||
return hex.EncodeToString(sum[:]), nil
|
||||
}
|
||||
@@ -1,285 +0,0 @@
|
||||
package backtest
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"nofx/market"
|
||||
"nofx/store"
|
||||
)
|
||||
|
||||
// AIConfig defines the AI client configuration used in backtesting.
|
||||
type AIConfig struct {
|
||||
Provider string `json:"provider"`
|
||||
Model string `json:"model"`
|
||||
APIKey string `json:"key"`
|
||||
SecretKey string `json:"secret_key,omitempty"`
|
||||
BaseURL string `json:"base_url,omitempty"`
|
||||
Temperature float64 `json:"temperature,omitempty"`
|
||||
}
|
||||
|
||||
type LeverageConfig struct {
|
||||
BTCETHLeverage int `json:"btc_eth_leverage"`
|
||||
AltcoinLeverage int `json:"altcoin_leverage"`
|
||||
}
|
||||
|
||||
// BacktestConfig describes the input configuration for a backtest run.
|
||||
type BacktestConfig struct {
|
||||
RunID string `json:"run_id"`
|
||||
UserID string `json:"user_id,omitempty"`
|
||||
AIModelID string `json:"ai_model_id,omitempty"`
|
||||
StrategyID string `json:"strategy_id,omitempty"` // Optional: use saved strategy from Strategy Studio
|
||||
Symbols []string `json:"symbols"`
|
||||
Timeframes []string `json:"timeframes"`
|
||||
DecisionTimeframe string `json:"decision_timeframe"`
|
||||
DecisionCadenceNBars int `json:"decision_cadence_nbars"`
|
||||
StartTS int64 `json:"start_ts"`
|
||||
EndTS int64 `json:"end_ts"`
|
||||
InitialBalance float64 `json:"initial_balance"`
|
||||
FeeBps float64 `json:"fee_bps"`
|
||||
SlippageBps float64 `json:"slippage_bps"`
|
||||
FillPolicy string `json:"fill_policy"`
|
||||
PromptVariant string `json:"prompt_variant"`
|
||||
PromptTemplate string `json:"prompt_template"`
|
||||
CustomPrompt string `json:"custom_prompt"`
|
||||
OverrideBasePrompt bool `json:"override_prompt"`
|
||||
CacheAI bool `json:"cache_ai"`
|
||||
ReplayOnly bool `json:"replay_only"`
|
||||
|
||||
AICfg AIConfig `json:"ai"`
|
||||
Leverage LeverageConfig `json:"leverage"`
|
||||
|
||||
SharedAICachePath string `json:"ai_cache_path,omitempty"`
|
||||
CheckpointIntervalBars int `json:"checkpoint_interval_bars,omitempty"`
|
||||
CheckpointIntervalSeconds int `json:"checkpoint_interval_seconds,omitempty"`
|
||||
ReplayDecisionDir string `json:"replay_decision_dir,omitempty"`
|
||||
|
||||
// Internal: loaded strategy config (set by Manager when StrategyID is provided)
|
||||
loadedStrategy *store.StrategyConfig `json:"-"`
|
||||
}
|
||||
|
||||
// Validate performs validity checks on the configuration and fills in default values.
|
||||
func (cfg *BacktestConfig) Validate() error {
|
||||
if cfg == nil {
|
||||
return fmt.Errorf("config is nil")
|
||||
}
|
||||
cfg.RunID = strings.TrimSpace(cfg.RunID)
|
||||
if cfg.RunID == "" {
|
||||
return fmt.Errorf("run_id cannot be empty")
|
||||
}
|
||||
cfg.UserID = strings.TrimSpace(cfg.UserID)
|
||||
if cfg.UserID == "" {
|
||||
cfg.UserID = "default"
|
||||
}
|
||||
cfg.AIModelID = strings.TrimSpace(cfg.AIModelID)
|
||||
|
||||
if len(cfg.Symbols) == 0 {
|
||||
return fmt.Errorf("at least one symbol is required")
|
||||
}
|
||||
for i, sym := range cfg.Symbols {
|
||||
cfg.Symbols[i] = market.Normalize(sym)
|
||||
}
|
||||
|
||||
if len(cfg.Timeframes) == 0 {
|
||||
cfg.Timeframes = []string{"3m", "15m", "4h"}
|
||||
}
|
||||
normTF := make([]string, 0, len(cfg.Timeframes))
|
||||
for _, tf := range cfg.Timeframes {
|
||||
normalized, err := market.NormalizeTimeframe(tf)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid timeframe '%s': %w", tf, err)
|
||||
}
|
||||
normTF = append(normTF, normalized)
|
||||
}
|
||||
cfg.Timeframes = normTF
|
||||
|
||||
if cfg.DecisionTimeframe == "" {
|
||||
cfg.DecisionTimeframe = cfg.Timeframes[0]
|
||||
}
|
||||
normalizedDecision, err := market.NormalizeTimeframe(cfg.DecisionTimeframe)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid decision_timeframe: %w", err)
|
||||
}
|
||||
cfg.DecisionTimeframe = normalizedDecision
|
||||
|
||||
if cfg.DecisionCadenceNBars <= 0 {
|
||||
cfg.DecisionCadenceNBars = 20
|
||||
}
|
||||
|
||||
if cfg.StartTS <= 0 || cfg.EndTS <= 0 || cfg.EndTS <= cfg.StartTS {
|
||||
return fmt.Errorf("invalid start_ts/end_ts")
|
||||
}
|
||||
|
||||
if cfg.InitialBalance <= 0 {
|
||||
cfg.InitialBalance = 1000
|
||||
}
|
||||
|
||||
if cfg.FillPolicy == "" {
|
||||
cfg.FillPolicy = FillPolicyNextOpen
|
||||
}
|
||||
if err := validateFillPolicy(cfg.FillPolicy); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if cfg.CheckpointIntervalBars <= 0 {
|
||||
cfg.CheckpointIntervalBars = 20
|
||||
}
|
||||
if cfg.CheckpointIntervalSeconds <= 0 {
|
||||
cfg.CheckpointIntervalSeconds = 2
|
||||
}
|
||||
|
||||
cfg.PromptVariant = strings.TrimSpace(cfg.PromptVariant)
|
||||
if cfg.PromptVariant == "" {
|
||||
cfg.PromptVariant = "baseline"
|
||||
}
|
||||
cfg.PromptTemplate = strings.TrimSpace(cfg.PromptTemplate)
|
||||
if cfg.PromptTemplate == "" {
|
||||
cfg.PromptTemplate = "default"
|
||||
}
|
||||
cfg.CustomPrompt = strings.TrimSpace(cfg.CustomPrompt)
|
||||
|
||||
if cfg.AICfg.Provider == "" {
|
||||
cfg.AICfg.Provider = "inherit"
|
||||
}
|
||||
if cfg.AICfg.Temperature == 0 {
|
||||
cfg.AICfg.Temperature = 0.4
|
||||
}
|
||||
|
||||
if cfg.Leverage.BTCETHLeverage <= 0 {
|
||||
cfg.Leverage.BTCETHLeverage = 5
|
||||
}
|
||||
if cfg.Leverage.AltcoinLeverage <= 0 {
|
||||
cfg.Leverage.AltcoinLeverage = 5
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Duration returns the backtest interval duration.
|
||||
func (cfg *BacktestConfig) Duration() time.Duration {
|
||||
if cfg == nil {
|
||||
return 0
|
||||
}
|
||||
return time.Unix(cfg.EndTS, 0).Sub(time.Unix(cfg.StartTS, 0))
|
||||
}
|
||||
|
||||
const (
|
||||
// FillPolicyNextOpen uses the open price of the next bar for execution.
|
||||
FillPolicyNextOpen = "next_open"
|
||||
// FillPolicyBarVWAP uses the approximate VWAP of the current bar for execution.
|
||||
FillPolicyBarVWAP = "bar_vwap"
|
||||
// FillPolicyMidPrice uses the mid-price (high+low)/2 for execution.
|
||||
FillPolicyMidPrice = "mid"
|
||||
)
|
||||
|
||||
func validateFillPolicy(policy string) error {
|
||||
switch policy {
|
||||
case FillPolicyNextOpen, FillPolicyBarVWAP, FillPolicyMidPrice:
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("unsupported fill_policy '%s'", policy)
|
||||
}
|
||||
}
|
||||
|
||||
// SetLoadedStrategy sets the loaded strategy config from database.
|
||||
func (cfg *BacktestConfig) SetLoadedStrategy(strategy *store.StrategyConfig) {
|
||||
cfg.loadedStrategy = strategy
|
||||
}
|
||||
|
||||
// ToStrategyConfig converts BacktestConfig to StrategyConfig for unified prompt generation.
|
||||
// This ensures backtest uses the same StrategyEngine logic as live trading.
|
||||
// If a strategy was loaded from database (via StrategyID), it will be used with overrides.
|
||||
func (cfg *BacktestConfig) ToStrategyConfig() *store.StrategyConfig {
|
||||
// If a strategy was loaded from database, use it with some overrides
|
||||
if cfg.loadedStrategy != nil {
|
||||
result := *cfg.loadedStrategy // Make a copy
|
||||
|
||||
// Override coin source with backtest symbols (回测指定的币对优先)
|
||||
if len(cfg.Symbols) > 0 {
|
||||
result.CoinSource.SourceType = "static"
|
||||
result.CoinSource.StaticCoins = cfg.Symbols
|
||||
result.CoinSource.UseAI500 = false
|
||||
result.CoinSource.UseOITop = false
|
||||
}
|
||||
|
||||
// Override timeframes with backtest config
|
||||
if len(cfg.Timeframes) > 0 {
|
||||
result.Indicators.Klines.SelectedTimeframes = cfg.Timeframes
|
||||
result.Indicators.Klines.PrimaryTimeframe = cfg.Timeframes[0]
|
||||
if len(cfg.Timeframes) > 1 {
|
||||
result.Indicators.Klines.LongerTimeframe = cfg.Timeframes[len(cfg.Timeframes)-1]
|
||||
}
|
||||
result.Indicators.Klines.EnableMultiTimeframe = len(cfg.Timeframes) > 1
|
||||
}
|
||||
|
||||
// Override leverage with backtest config
|
||||
if cfg.Leverage.BTCETHLeverage > 0 {
|
||||
result.RiskControl.BTCETHMaxLeverage = cfg.Leverage.BTCETHLeverage
|
||||
}
|
||||
if cfg.Leverage.AltcoinLeverage > 0 {
|
||||
result.RiskControl.AltcoinMaxLeverage = cfg.Leverage.AltcoinLeverage
|
||||
}
|
||||
|
||||
// Override custom prompt if provided in backtest config
|
||||
if cfg.CustomPrompt != "" {
|
||||
result.CustomPrompt = cfg.CustomPrompt
|
||||
}
|
||||
|
||||
return &result
|
||||
}
|
||||
|
||||
// Fallback: build strategy config from backtest config (original logic)
|
||||
primaryTF := "5m"
|
||||
longerTF := "4h"
|
||||
if len(cfg.Timeframes) > 0 {
|
||||
primaryTF = cfg.Timeframes[0]
|
||||
}
|
||||
if len(cfg.Timeframes) > 1 {
|
||||
longerTF = cfg.Timeframes[len(cfg.Timeframes)-1]
|
||||
}
|
||||
|
||||
return &store.StrategyConfig{
|
||||
CoinSource: store.CoinSourceConfig{
|
||||
SourceType: "static",
|
||||
StaticCoins: cfg.Symbols,
|
||||
UseAI500: false,
|
||||
AI500Limit: len(cfg.Symbols),
|
||||
UseOITop: false,
|
||||
OITopLimit: 0,
|
||||
},
|
||||
Indicators: store.IndicatorConfig{
|
||||
Klines: store.KlineConfig{
|
||||
PrimaryTimeframe: primaryTF,
|
||||
PrimaryCount: 30,
|
||||
LongerTimeframe: longerTF,
|
||||
LongerCount: 10,
|
||||
EnableMultiTimeframe: len(cfg.Timeframes) > 1,
|
||||
SelectedTimeframes: cfg.Timeframes,
|
||||
},
|
||||
EnableRawKlines: true,
|
||||
EnableEMA: true,
|
||||
EnableMACD: true,
|
||||
EnableRSI: true,
|
||||
EnableATR: true,
|
||||
EnableVolume: true,
|
||||
EnableOI: true,
|
||||
EnableFundingRate: true,
|
||||
EMAPeriods: []int{20, 50},
|
||||
RSIPeriods: []int{7, 14},
|
||||
ATRPeriods: []int{14},
|
||||
},
|
||||
CustomPrompt: cfg.CustomPrompt,
|
||||
RiskControl: store.RiskControlConfig{
|
||||
MaxPositions: 3,
|
||||
BTCETHMaxLeverage: cfg.Leverage.BTCETHLeverage,
|
||||
AltcoinMaxLeverage: cfg.Leverage.AltcoinLeverage,
|
||||
BTCETHMaxPositionValueRatio: 5.0,
|
||||
AltcoinMaxPositionValueRatio: 1.0,
|
||||
MaxMarginUsage: 0.9,
|
||||
MinPositionSize: 12,
|
||||
MinRiskRewardRatio: 3.0,
|
||||
MinConfidence: 75,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,194 +0,0 @@
|
||||
package backtest
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"nofx/market"
|
||||
)
|
||||
|
||||
type timeframeSeries struct {
|
||||
klines []market.Kline
|
||||
closeTimes []int64
|
||||
}
|
||||
|
||||
type symbolSeries struct {
|
||||
byTF map[string]*timeframeSeries
|
||||
}
|
||||
|
||||
// DataFeed manages historical kline data and provides time-progressive snapshots for backtesting.
|
||||
type DataFeed struct {
|
||||
cfg BacktestConfig
|
||||
symbols []string
|
||||
timeframes []string
|
||||
symbolSeries map[string]*symbolSeries
|
||||
decisionTimes []int64
|
||||
primaryTF string
|
||||
longerTF string
|
||||
}
|
||||
|
||||
func NewDataFeed(cfg BacktestConfig) (*DataFeed, error) {
|
||||
df := &DataFeed{
|
||||
cfg: cfg,
|
||||
symbols: make([]string, len(cfg.Symbols)),
|
||||
timeframes: append([]string(nil), cfg.Timeframes...),
|
||||
symbolSeries: make(map[string]*symbolSeries),
|
||||
primaryTF: cfg.DecisionTimeframe,
|
||||
}
|
||||
copy(df.symbols, cfg.Symbols)
|
||||
|
||||
if err := df.loadAll(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return df, nil
|
||||
}
|
||||
|
||||
func (df *DataFeed) loadAll() error {
|
||||
start := time.Unix(df.cfg.StartTS, 0)
|
||||
end := time.Unix(df.cfg.EndTS, 0)
|
||||
|
||||
// longest timeframe used for auxiliary indicators
|
||||
var longestDur time.Duration
|
||||
for _, tf := range df.timeframes {
|
||||
dur, err := market.TFDuration(tf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if dur > longestDur {
|
||||
longestDur = dur
|
||||
df.longerTF = tf
|
||||
}
|
||||
}
|
||||
|
||||
for _, symbol := range df.symbols {
|
||||
ss := &symbolSeries{byTF: make(map[string]*timeframeSeries)}
|
||||
for _, tf := range df.timeframes {
|
||||
dur, _ := market.TFDuration(tf)
|
||||
buffer := dur * 200
|
||||
fetchStart := start.Add(-buffer)
|
||||
if fetchStart.Before(time.Unix(0, 0)) {
|
||||
fetchStart = time.Unix(0, 0)
|
||||
}
|
||||
fetchEnd := end.Add(dur)
|
||||
|
||||
klines, err := market.GetKlinesRange(symbol, tf, fetchStart, fetchEnd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("fetch klines for %s %s: %w", symbol, tf, err)
|
||||
}
|
||||
if len(klines) == 0 {
|
||||
return fmt.Errorf("no klines for %s %s", symbol, tf)
|
||||
}
|
||||
|
||||
series := &timeframeSeries{
|
||||
klines: klines,
|
||||
closeTimes: make([]int64, len(klines)),
|
||||
}
|
||||
for i, k := range klines {
|
||||
series.closeTimes[i] = k.CloseTime
|
||||
}
|
||||
ss.byTF[tf] = series
|
||||
}
|
||||
df.symbolSeries[symbol] = ss
|
||||
}
|
||||
|
||||
// Generate backtest progress timeline using the primary timeframe of the first symbol
|
||||
firstSymbol := df.symbols[0]
|
||||
primarySeries := df.symbolSeries[firstSymbol].byTF[df.primaryTF]
|
||||
startMs := start.UnixMilli()
|
||||
endMs := end.UnixMilli()
|
||||
for _, ts := range primarySeries.closeTimes {
|
||||
if ts < startMs {
|
||||
continue
|
||||
}
|
||||
if ts > endMs {
|
||||
break
|
||||
}
|
||||
df.decisionTimes = append(df.decisionTimes, ts)
|
||||
// Align other symbols; report error early if data is missing
|
||||
for _, symbol := range df.symbols[1:] {
|
||||
if _, ok := df.symbolSeries[symbol].byTF[df.primaryTF]; !ok {
|
||||
return fmt.Errorf("symbol %s missing timeframe %s", symbol, df.primaryTF)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(df.decisionTimes) == 0 {
|
||||
return fmt.Errorf("no decision bars in range")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (df *DataFeed) DecisionBarCount() int {
|
||||
return len(df.decisionTimes)
|
||||
}
|
||||
|
||||
func (df *DataFeed) DecisionTimestamp(index int) int64 {
|
||||
return df.decisionTimes[index]
|
||||
}
|
||||
|
||||
func (df *DataFeed) sliceUpTo(symbol, tf string, ts int64) []market.Kline {
|
||||
series := df.symbolSeries[symbol].byTF[tf]
|
||||
idx := sort.Search(len(series.closeTimes), func(i int) bool {
|
||||
return series.closeTimes[i] > ts
|
||||
})
|
||||
if idx <= 0 {
|
||||
return nil
|
||||
}
|
||||
return series.klines[:idx]
|
||||
}
|
||||
|
||||
func (df *DataFeed) BuildMarketData(ts int64) (map[string]*market.Data, map[string]map[string]*market.Data, error) {
|
||||
result := make(map[string]*market.Data, len(df.symbols))
|
||||
multi := make(map[string]map[string]*market.Data, len(df.symbols))
|
||||
|
||||
for _, symbol := range df.symbols {
|
||||
perTF := make(map[string]*market.Data, len(df.timeframes))
|
||||
for _, tf := range df.timeframes {
|
||||
series := df.sliceUpTo(symbol, tf, ts)
|
||||
if len(series) == 0 {
|
||||
continue
|
||||
}
|
||||
var longer []market.Kline
|
||||
if df.longerTF != "" && df.longerTF != tf {
|
||||
longer = df.sliceUpTo(symbol, df.longerTF, ts)
|
||||
}
|
||||
data, err := market.BuildDataFromKlines(symbol, series, longer)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
perTF[tf] = data
|
||||
if tf == df.primaryTF {
|
||||
result[symbol] = data
|
||||
}
|
||||
}
|
||||
if _, ok := perTF[df.primaryTF]; !ok {
|
||||
return nil, nil, fmt.Errorf("no primary data for %s at %d", symbol, ts)
|
||||
}
|
||||
multi[symbol] = perTF
|
||||
}
|
||||
return result, multi, nil
|
||||
}
|
||||
|
||||
func (df *DataFeed) decisionBarSnapshot(symbol string, ts int64) (*market.Kline, *market.Kline) {
|
||||
ss, ok := df.symbolSeries[symbol]
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
series, ok := ss.byTF[df.primaryTF]
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
idx := sort.Search(len(series.closeTimes), func(i int) bool {
|
||||
return series.closeTimes[i] >= ts
|
||||
})
|
||||
if idx >= len(series.closeTimes) || series.closeTimes[idx] != ts {
|
||||
return nil, nil
|
||||
}
|
||||
curr := &series.klines[idx]
|
||||
var next *market.Kline
|
||||
if idx+1 < len(series.klines) {
|
||||
next = &series.klines[idx+1]
|
||||
}
|
||||
return curr, next
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
package backtest
|
||||
|
||||
import (
|
||||
"math"
|
||||
"sort"
|
||||
|
||||
"nofx/market"
|
||||
)
|
||||
|
||||
// ResampleEquity resamples equity curve based on timeframe.
|
||||
func ResampleEquity(points []EquityPoint, timeframe string) ([]EquityPoint, error) {
|
||||
if timeframe == "" {
|
||||
return points, nil
|
||||
}
|
||||
dur, err := market.TFDuration(timeframe)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(points) == 0 {
|
||||
return points, nil
|
||||
}
|
||||
|
||||
durMs := dur.Milliseconds()
|
||||
if durMs <= 0 {
|
||||
return points, nil
|
||||
}
|
||||
|
||||
bucketMap := make(map[int64]EquityPoint)
|
||||
bucketKeys := make([]int64, 0)
|
||||
for _, pt := range points {
|
||||
bucket := (pt.Timestamp / durMs) * durMs
|
||||
if _, exists := bucketMap[bucket]; !exists {
|
||||
bucketKeys = append(bucketKeys, bucket)
|
||||
}
|
||||
bucketPoint := pt
|
||||
bucketPoint.Timestamp = bucket
|
||||
bucketMap[bucket] = bucketPoint
|
||||
}
|
||||
|
||||
sort.Slice(bucketKeys, func(i, j int) bool {
|
||||
return bucketKeys[i] < bucketKeys[j]
|
||||
})
|
||||
|
||||
resampled := make([]EquityPoint, 0, len(bucketKeys))
|
||||
for _, key := range bucketKeys {
|
||||
resampled = append(resampled, bucketMap[key])
|
||||
}
|
||||
|
||||
return resampled, nil
|
||||
}
|
||||
|
||||
// LimitEquityPoints limits the number of data points within a given range (uniform sampling).
|
||||
func LimitEquityPoints(points []EquityPoint, limit int) []EquityPoint {
|
||||
if limit <= 0 || len(points) <= limit {
|
||||
return points
|
||||
}
|
||||
|
||||
step := float64(len(points)) / float64(limit)
|
||||
result := make([]EquityPoint, 0, limit)
|
||||
for i := 0; i < limit; i++ {
|
||||
idx := int(math.Round(step * float64(i)))
|
||||
if idx >= len(points) {
|
||||
idx = len(points) - 1
|
||||
}
|
||||
result = append(result, points[idx])
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// LimitTradeEvents applies uniform sampling to trade events.
|
||||
func LimitTradeEvents(events []TradeEvent, limit int) []TradeEvent {
|
||||
if limit <= 0 || len(events) <= limit {
|
||||
return events
|
||||
}
|
||||
|
||||
step := float64(len(events)) / float64(limit)
|
||||
result := make([]TradeEvent, 0, limit)
|
||||
for i := 0; i < limit; i++ {
|
||||
idx := int(math.Round(step * float64(i)))
|
||||
if idx >= len(events) {
|
||||
idx = len(events) - 1
|
||||
}
|
||||
result = append(result, events[idx])
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// AlignEquityTimestamps ensures timestamps are sorted in ascending order.
|
||||
func AlignEquityTimestamps(points []EquityPoint) []EquityPoint {
|
||||
sort.Slice(points, func(i, j int) bool {
|
||||
return points[i].Timestamp < points[j].Timestamp
|
||||
})
|
||||
return points
|
||||
}
|
||||
100
backtest/lock.go
100
backtest/lock.go
@@ -1,100 +0,0 @@
|
||||
package backtest
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
lockFileName = "lock"
|
||||
lockHeartbeatInterval = 2 * time.Second
|
||||
lockStaleAfter = 10 * time.Second
|
||||
)
|
||||
|
||||
// RunLockInfo represents the lock file structure for a backtest run.
|
||||
type RunLockInfo struct {
|
||||
RunID string `json:"run_id"`
|
||||
PID int `json:"pid"`
|
||||
Host string `json:"host"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
LastHeartbeat time.Time `json:"last_heartbeat"`
|
||||
}
|
||||
|
||||
func lockFilePath(runID string) string {
|
||||
return filepath.Join(runDir(runID), lockFileName)
|
||||
}
|
||||
|
||||
func loadRunLock(runID string) (*RunLockInfo, error) {
|
||||
path := lockFilePath(runID)
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var info RunLockInfo
|
||||
if err := json.Unmarshal(data, &info); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &info, nil
|
||||
}
|
||||
|
||||
func saveRunLock(info *RunLockInfo) error {
|
||||
if info == nil {
|
||||
return fmt.Errorf("lock info nil")
|
||||
}
|
||||
return writeJSONAtomic(lockFilePath(info.RunID), info)
|
||||
}
|
||||
|
||||
func deleteRunLock(runID string) error {
|
||||
err := os.Remove(lockFilePath(runID))
|
||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func lockIsStale(info *RunLockInfo) bool {
|
||||
if info == nil {
|
||||
return true
|
||||
}
|
||||
return time.Since(info.LastHeartbeat) > lockStaleAfter
|
||||
}
|
||||
|
||||
func acquireRunLock(runID string) (*RunLockInfo, error) {
|
||||
if err := ensureRunDir(runID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if existing, err := loadRunLock(runID); err == nil {
|
||||
if !lockIsStale(existing) {
|
||||
return nil, fmt.Errorf("run %s is locked by pid %d", runID, existing.PID)
|
||||
}
|
||||
} else if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
host, _ := os.Hostname()
|
||||
info := &RunLockInfo{
|
||||
RunID: runID,
|
||||
PID: os.Getpid(),
|
||||
Host: host,
|
||||
StartedAt: time.Now().UTC(),
|
||||
LastHeartbeat: time.Now().UTC(),
|
||||
}
|
||||
|
||||
if err := saveRunLock(info); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return info, nil
|
||||
}
|
||||
|
||||
func updateRunLockHeartbeat(info *RunLockInfo) error {
|
||||
if info == nil {
|
||||
return fmt.Errorf("lock info nil")
|
||||
}
|
||||
info.LastHeartbeat = time.Now().UTC()
|
||||
return saveRunLock(info)
|
||||
}
|
||||
@@ -1,493 +0,0 @@
|
||||
package backtest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"nofx/logger"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"nofx/mcp"
|
||||
"nofx/store"
|
||||
)
|
||||
|
||||
type Manager struct {
|
||||
mu sync.RWMutex
|
||||
runners map[string]*Runner
|
||||
metadata map[string]*RunMetadata
|
||||
cancels map[string]context.CancelFunc
|
||||
mcpClient mcp.AIClient
|
||||
aiResolver AIConfigResolver
|
||||
}
|
||||
|
||||
type AIConfigResolver func(*BacktestConfig) error
|
||||
|
||||
func NewManager(defaultClient mcp.AIClient) *Manager {
|
||||
return &Manager{
|
||||
runners: make(map[string]*Runner),
|
||||
metadata: make(map[string]*RunMetadata),
|
||||
cancels: make(map[string]context.CancelFunc),
|
||||
mcpClient: defaultClient,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) SetAIResolver(resolver AIConfigResolver) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.aiResolver = resolver
|
||||
}
|
||||
|
||||
func (m *Manager) Start(ctx context.Context, cfg BacktestConfig) (*Runner, error) {
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := m.resolveAIConfig(&cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
if existing, ok := m.runners[cfg.RunID]; ok {
|
||||
state := existing.Status()
|
||||
if state == RunStateRunning || state == RunStatePaused {
|
||||
m.mu.Unlock()
|
||||
return nil, fmt.Errorf("run %s is already active", cfg.RunID)
|
||||
}
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
persistCfg := cfg
|
||||
persistCfg.AICfg.APIKey = ""
|
||||
if err := SaveConfig(cfg.RunID, &persistCfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
runner, err := NewRunner(cfg, m.client())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
runCtx, cancel := context.WithCancel(ctx)
|
||||
|
||||
m.mu.Lock()
|
||||
if _, exists := m.runners[cfg.RunID]; exists {
|
||||
m.mu.Unlock()
|
||||
cancel()
|
||||
return nil, fmt.Errorf("run %s is already active", cfg.RunID)
|
||||
}
|
||||
m.runners[cfg.RunID] = runner
|
||||
m.cancels[cfg.RunID] = cancel
|
||||
meta := runner.CurrentMetadata()
|
||||
m.metadata[cfg.RunID] = meta
|
||||
m.mu.Unlock()
|
||||
|
||||
if err := runner.Start(runCtx); err != nil {
|
||||
cancel()
|
||||
m.mu.Lock()
|
||||
delete(m.runners, cfg.RunID)
|
||||
delete(m.cancels, cfg.RunID)
|
||||
delete(m.metadata, cfg.RunID)
|
||||
m.mu.Unlock()
|
||||
runner.releaseLock()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
m.storeMetadata(cfg.RunID, meta)
|
||||
m.launchWatcher(cfg.RunID, runner)
|
||||
return runner, nil
|
||||
}
|
||||
|
||||
func (m *Manager) client() mcp.AIClient {
|
||||
if m.mcpClient != nil {
|
||||
return m.mcpClient
|
||||
}
|
||||
return mcp.New()
|
||||
}
|
||||
|
||||
func (m *Manager) GetRunner(runID string) (*Runner, bool) {
|
||||
m.mu.RLock()
|
||||
runner, ok := m.runners[runID]
|
||||
m.mu.RUnlock()
|
||||
return runner, ok
|
||||
}
|
||||
|
||||
func (m *Manager) ListRuns() ([]*RunMetadata, error) {
|
||||
m.mu.RLock()
|
||||
localCopy := make(map[string]*RunMetadata, len(m.metadata))
|
||||
for k, v := range m.metadata {
|
||||
localCopy[k] = v
|
||||
}
|
||||
m.mu.RUnlock()
|
||||
|
||||
runIDs, err := LoadRunIDs()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ordered := make([]string, 0, len(runIDs))
|
||||
if entries, err := listIndexEntries(); err == nil {
|
||||
seen := make(map[string]bool, len(runIDs))
|
||||
for _, entry := range entries {
|
||||
if contains(runIDs, entry.RunID) {
|
||||
ordered = append(ordered, entry.RunID)
|
||||
seen[entry.RunID] = true
|
||||
}
|
||||
}
|
||||
for _, id := range runIDs {
|
||||
if !seen[id] {
|
||||
ordered = append(ordered, id)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ordered = append(ordered, runIDs...)
|
||||
}
|
||||
|
||||
metas := make([]*RunMetadata, 0, len(runIDs))
|
||||
for _, runID := range ordered {
|
||||
if meta, ok := localCopy[runID]; ok {
|
||||
metas = append(metas, meta)
|
||||
continue
|
||||
}
|
||||
meta, err := LoadRunMetadata(runID)
|
||||
if err == nil {
|
||||
metas = append(metas, meta)
|
||||
}
|
||||
}
|
||||
|
||||
sort.Slice(metas, func(i, j int) bool {
|
||||
return metas[i].UpdatedAt.After(metas[j].UpdatedAt)
|
||||
})
|
||||
|
||||
return metas, nil
|
||||
}
|
||||
|
||||
func contains(list []string, target string) bool {
|
||||
for _, item := range list {
|
||||
if item == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (m *Manager) Pause(runID string) error {
|
||||
runner, ok := m.GetRunner(runID)
|
||||
if !ok {
|
||||
return fmt.Errorf("run %s not found", runID)
|
||||
}
|
||||
runner.Pause()
|
||||
m.refreshMetadata(runID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) Resume(runID string) error {
|
||||
if runID == "" {
|
||||
return fmt.Errorf("run_id is required")
|
||||
}
|
||||
|
||||
runner, ok := m.GetRunner(runID)
|
||||
if ok {
|
||||
runner.Resume()
|
||||
m.refreshMetadata(runID)
|
||||
return nil
|
||||
}
|
||||
|
||||
cfg, err := LoadConfig(runID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfgCopy := *cfg
|
||||
if err := cfgCopy.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := m.resolveAIConfig(&cfgCopy); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
restored, err := NewRunner(cfgCopy, m.client())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := restored.RestoreFromCheckpoint(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
m.mu.Lock()
|
||||
if _, exists := m.runners[runID]; exists {
|
||||
m.mu.Unlock()
|
||||
cancel()
|
||||
return fmt.Errorf("run %s is already active", runID)
|
||||
}
|
||||
m.runners[runID] = restored
|
||||
m.cancels[runID] = cancel
|
||||
m.metadata[runID] = restored.CurrentMetadata()
|
||||
m.mu.Unlock()
|
||||
|
||||
if err := restored.Start(ctx); err != nil {
|
||||
cancel()
|
||||
m.mu.Lock()
|
||||
delete(m.runners, runID)
|
||||
delete(m.cancels, runID)
|
||||
delete(m.metadata, runID)
|
||||
m.mu.Unlock()
|
||||
restored.releaseLock()
|
||||
return err
|
||||
}
|
||||
|
||||
m.storeMetadata(runID, restored.CurrentMetadata())
|
||||
m.launchWatcher(runID, restored)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) Stop(runID string) error {
|
||||
runner, ok := m.GetRunner(runID)
|
||||
if ok {
|
||||
runner.Stop()
|
||||
err := runner.Wait()
|
||||
m.refreshMetadata(runID)
|
||||
return err
|
||||
}
|
||||
meta, err := m.LoadMetadata(runID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if meta.State == RunStateStopped || meta.State == RunStateCompleted {
|
||||
return nil
|
||||
}
|
||||
meta.State = RunStateStopped
|
||||
m.storeMetadata(runID, meta)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) Wait(runID string) error {
|
||||
runner, ok := m.GetRunner(runID)
|
||||
if !ok {
|
||||
return fmt.Errorf("run %s not found", runID)
|
||||
}
|
||||
err := runner.Wait()
|
||||
m.refreshMetadata(runID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (m *Manager) UpdateLabel(runID, label string) (*RunMetadata, error) {
|
||||
meta, err := m.LoadMetadata(runID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
clean := strings.TrimSpace(label)
|
||||
metaCopy := *meta
|
||||
metaCopy.Label = clean
|
||||
m.storeMetadata(runID, &metaCopy)
|
||||
return &metaCopy, nil
|
||||
}
|
||||
|
||||
func (m *Manager) Delete(runID string) error {
|
||||
runner, ok := m.GetRunner(runID)
|
||||
if ok {
|
||||
runner.Stop()
|
||||
_ = runner.Wait()
|
||||
}
|
||||
m.mu.Lock()
|
||||
if cancel, ok := m.cancels[runID]; ok {
|
||||
cancel()
|
||||
delete(m.cancels, runID)
|
||||
}
|
||||
delete(m.runners, runID)
|
||||
delete(m.metadata, runID)
|
||||
m.mu.Unlock()
|
||||
if err := removeFromRunIndex(runID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := deleteRunLock(runID); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) LoadMetadata(runID string) (*RunMetadata, error) {
|
||||
runner, ok := m.GetRunner(runID)
|
||||
if ok {
|
||||
meta := runner.CurrentMetadata()
|
||||
m.storeMetadata(runID, meta)
|
||||
return meta, nil
|
||||
}
|
||||
meta, err := LoadRunMetadata(runID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m.storeMetadata(runID, meta)
|
||||
return meta, nil
|
||||
}
|
||||
|
||||
func (m *Manager) LoadEquity(runID string, timeframe string, limit int) ([]EquityPoint, error) {
|
||||
points, err := LoadEquityPoints(runID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if timeframe != "" {
|
||||
points, err = ResampleEquity(points, timeframe)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
points = AlignEquityTimestamps(points)
|
||||
points = LimitEquityPoints(points, limit)
|
||||
return points, nil
|
||||
}
|
||||
|
||||
func (m *Manager) LoadTrades(runID string, limit int) ([]TradeEvent, error) {
|
||||
events, err := LoadTradeEvents(runID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return LimitTradeEvents(events, limit), nil
|
||||
}
|
||||
|
||||
func (m *Manager) GetMetrics(runID string) (*Metrics, error) {
|
||||
return LoadMetrics(runID)
|
||||
}
|
||||
|
||||
func (m *Manager) Cleanup(runID string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
delete(m.runners, runID)
|
||||
if cancel, ok := m.cancels[runID]; ok {
|
||||
cancel()
|
||||
delete(m.cancels, runID)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) Status(runID string) *StatusPayload {
|
||||
runner, ok := m.GetRunner(runID)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
payload := runner.StatusPayload()
|
||||
m.storeMetadata(runID, runner.CurrentMetadata())
|
||||
return &payload
|
||||
}
|
||||
|
||||
func (m *Manager) launchWatcher(runID string, runner *Runner) {
|
||||
go func() {
|
||||
if err := runner.Wait(); err != nil {
|
||||
logger.Infof("backtest run %s finished with error: %v", runID, err)
|
||||
}
|
||||
runner.PersistMetadata()
|
||||
meta := runner.CurrentMetadata()
|
||||
m.storeMetadata(runID, meta)
|
||||
|
||||
m.mu.Lock()
|
||||
if cancel, ok := m.cancels[runID]; ok {
|
||||
cancel()
|
||||
delete(m.cancels, runID)
|
||||
}
|
||||
delete(m.runners, runID)
|
||||
m.mu.Unlock()
|
||||
}()
|
||||
}
|
||||
|
||||
func (m *Manager) refreshMetadata(runID string) {
|
||||
runner, ok := m.GetRunner(runID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
meta := runner.CurrentMetadata()
|
||||
m.storeMetadata(runID, meta)
|
||||
}
|
||||
|
||||
func (m *Manager) storeMetadata(runID string, meta *RunMetadata) {
|
||||
if meta == nil {
|
||||
return
|
||||
}
|
||||
m.mu.Lock()
|
||||
if existing, ok := m.metadata[runID]; ok {
|
||||
if meta.Label == "" && existing.Label != "" {
|
||||
meta.Label = existing.Label
|
||||
}
|
||||
if meta.LastError == "" && existing.LastError != "" {
|
||||
meta.LastError = existing.LastError
|
||||
}
|
||||
}
|
||||
m.metadata[runID] = meta
|
||||
m.mu.Unlock()
|
||||
_ = SaveRunMetadata(meta)
|
||||
if err := updateRunIndex(meta, nil); err != nil {
|
||||
logger.Infof("failed to update run index for %s: %v", runID, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) resolveAIConfig(cfg *BacktestConfig) error {
|
||||
if cfg == nil {
|
||||
return fmt.Errorf("ai config missing")
|
||||
}
|
||||
provider := strings.TrimSpace(cfg.AICfg.Provider)
|
||||
apiKey := strings.TrimSpace(cfg.AICfg.APIKey)
|
||||
if provider != "" && !strings.EqualFold(provider, "inherit") && apiKey != "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
m.mu.RLock()
|
||||
resolver := m.aiResolver
|
||||
m.mu.RUnlock()
|
||||
if resolver == nil {
|
||||
if apiKey == "" {
|
||||
return fmt.Errorf("AI configuration missing key and no resolver configured")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return resolver(cfg)
|
||||
}
|
||||
|
||||
func (m *Manager) GetTrace(runID string, cycle int) (*store.DecisionRecord, error) {
|
||||
return LoadDecisionTrace(runID, cycle)
|
||||
}
|
||||
|
||||
func (m *Manager) ExportRun(runID string) (string, error) {
|
||||
return CreateRunExport(runID)
|
||||
}
|
||||
|
||||
// RestoreRuns scans the backtests directory and restores metadata for existing runs (service restart scenario).
|
||||
func (m *Manager) RestoreRuns() error {
|
||||
runIDs, err := LoadRunIDs()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, runID := range runIDs {
|
||||
meta, err := LoadRunMetadata(runID)
|
||||
if err != nil {
|
||||
logger.Infof("skip run %s: %v", runID, err)
|
||||
continue
|
||||
}
|
||||
if meta.State == RunStateRunning {
|
||||
lock, err := loadRunLock(runID)
|
||||
if err != nil || lockIsStale(lock) {
|
||||
if err := deleteRunLock(runID); err != nil {
|
||||
logger.Infof("failed to cleanup lock for %s: %v", runID, err)
|
||||
}
|
||||
meta.State = RunStatePaused
|
||||
if err := SaveRunMetadata(meta); err != nil {
|
||||
logger.Infof("failed to mark %s paused: %v", runID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
m.mu.Lock()
|
||||
m.metadata[runID] = meta
|
||||
m.mu.Unlock()
|
||||
if err := updateRunIndex(meta, nil); err != nil {
|
||||
logger.Infof("failed to sync index for %s: %v", runID, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RestoreRunsFromDisk retains the old method name for backward compatibility.
|
||||
func (m *Manager) RestoreRunsFromDisk() error {
|
||||
return m.RestoreRuns()
|
||||
}
|
||||
@@ -1,225 +0,0 @@
|
||||
package backtest
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// CalculateMetrics reads existing logs and calculates summary metrics. state is optional, used to supplement information not yet persisted.
|
||||
func CalculateMetrics(runID string, cfg *BacktestConfig, state *BacktestState) (*Metrics, error) {
|
||||
if cfg == nil {
|
||||
return nil, fmt.Errorf("config is nil")
|
||||
}
|
||||
|
||||
points, err := LoadEquityPoints(runID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load equity points: %w", err)
|
||||
}
|
||||
|
||||
events, err := LoadTradeEvents(runID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load trade events: %w", err)
|
||||
}
|
||||
|
||||
metrics := &Metrics{
|
||||
SymbolStats: make(map[string]SymbolMetrics),
|
||||
}
|
||||
|
||||
metrics.Liquidated = determineLiquidation(events, state)
|
||||
|
||||
initialBalance := cfg.InitialBalance
|
||||
if initialBalance <= 0 {
|
||||
initialBalance = 1
|
||||
}
|
||||
|
||||
lastEquity := initialBalance
|
||||
if len(points) > 0 && points[len(points)-1].Equity > 0 {
|
||||
lastEquity = points[len(points)-1].Equity
|
||||
} else if state != nil && state.Equity > 0 {
|
||||
lastEquity = state.Equity
|
||||
}
|
||||
metrics.TotalReturnPct = ((lastEquity - initialBalance) / initialBalance) * 100
|
||||
|
||||
metrics.MaxDrawdownPct = maxDrawdown(points, state)
|
||||
metrics.SharpeRatio = sharpeRatio(points)
|
||||
|
||||
fillTradeMetrics(metrics, events)
|
||||
|
||||
return metrics, nil
|
||||
}
|
||||
|
||||
func determineLiquidation(events []TradeEvent, state *BacktestState) bool {
|
||||
if state != nil && state.Liquidated {
|
||||
return true
|
||||
}
|
||||
for i := len(events) - 1; i >= 0; i-- {
|
||||
if events[i].LiquidationFlag {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func maxDrawdown(points []EquityPoint, state *BacktestState) float64 {
|
||||
if len(points) == 0 {
|
||||
if state != nil {
|
||||
return state.MaxDrawdownPct
|
||||
}
|
||||
return 0
|
||||
}
|
||||
peak := points[0].Equity
|
||||
if peak <= 0 {
|
||||
peak = 1
|
||||
}
|
||||
maxDD := 0.0
|
||||
for _, pt := range points {
|
||||
if pt.Equity > peak {
|
||||
peak = pt.Equity
|
||||
}
|
||||
if peak <= 0 {
|
||||
continue
|
||||
}
|
||||
dd := (peak - pt.Equity) / peak * 100
|
||||
if dd > maxDD {
|
||||
maxDD = dd
|
||||
}
|
||||
}
|
||||
if state != nil && state.MaxDrawdownPct > maxDD {
|
||||
maxDD = state.MaxDrawdownPct
|
||||
}
|
||||
return maxDD
|
||||
}
|
||||
|
||||
func sharpeRatio(points []EquityPoint) float64 {
|
||||
if len(points) < 2 {
|
||||
return 0
|
||||
}
|
||||
|
||||
returns := make([]float64, 0, len(points)-1)
|
||||
prev := points[0].Equity
|
||||
for i := 1; i < len(points); i++ {
|
||||
curr := points[i].Equity
|
||||
if prev <= 0 {
|
||||
prev = curr
|
||||
continue
|
||||
}
|
||||
ret := (curr - prev) / prev
|
||||
returns = append(returns, ret)
|
||||
prev = curr
|
||||
}
|
||||
if len(returns) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
mean := 0.0
|
||||
for _, r := range returns {
|
||||
mean += r
|
||||
}
|
||||
mean /= float64(len(returns))
|
||||
|
||||
variance := 0.0
|
||||
for _, r := range returns {
|
||||
diff := r - mean
|
||||
variance += diff * diff
|
||||
}
|
||||
variance /= float64(len(returns))
|
||||
|
||||
std := math.Sqrt(variance)
|
||||
if std == 0 {
|
||||
if mean > 0 {
|
||||
return 999
|
||||
}
|
||||
if mean < 0 {
|
||||
return -999
|
||||
}
|
||||
return 0
|
||||
}
|
||||
return mean / std
|
||||
}
|
||||
|
||||
func fillTradeMetrics(metrics *Metrics, events []TradeEvent) {
|
||||
if metrics == nil {
|
||||
return
|
||||
}
|
||||
|
||||
totalTrades := 0
|
||||
winTrades := 0
|
||||
lossTrades := 0
|
||||
totalWinAmount := 0.0
|
||||
totalLossAmount := 0.0
|
||||
|
||||
for _, evt := range events {
|
||||
include := evt.LiquidationFlag || strings.HasPrefix(evt.Action, "close")
|
||||
if evt.RealizedPnL != 0 {
|
||||
include = true
|
||||
}
|
||||
if !include {
|
||||
continue
|
||||
}
|
||||
totalTrades++
|
||||
|
||||
stats := metrics.SymbolStats[evt.Symbol]
|
||||
stats.TotalTrades++
|
||||
stats.TotalPnL += evt.RealizedPnL
|
||||
|
||||
if evt.RealizedPnL > 0 {
|
||||
winTrades++
|
||||
totalWinAmount += evt.RealizedPnL
|
||||
stats.WinningTrades++
|
||||
} else if evt.RealizedPnL < 0 {
|
||||
lossTrades++
|
||||
totalLossAmount += -evt.RealizedPnL
|
||||
stats.LosingTrades++
|
||||
}
|
||||
|
||||
metrics.SymbolStats[evt.Symbol] = stats
|
||||
}
|
||||
|
||||
metrics.Trades = totalTrades
|
||||
if totalTrades > 0 {
|
||||
metrics.WinRate = (float64(winTrades) / float64(totalTrades)) * 100
|
||||
}
|
||||
if winTrades > 0 {
|
||||
metrics.AvgWin = totalWinAmount / float64(winTrades)
|
||||
}
|
||||
if lossTrades > 0 {
|
||||
metrics.AvgLoss = -(totalLossAmount / float64(lossTrades))
|
||||
}
|
||||
if totalLossAmount > 0 {
|
||||
metrics.ProfitFactor = totalWinAmount / totalLossAmount
|
||||
} else if totalWinAmount > 0 {
|
||||
metrics.ProfitFactor = 999
|
||||
}
|
||||
|
||||
bestSymbol := ""
|
||||
bestPnL := math.Inf(-1)
|
||||
worstSymbol := ""
|
||||
worstPnL := math.Inf(1)
|
||||
|
||||
for symbol, stats := range metrics.SymbolStats {
|
||||
if stats.TotalTrades > 0 {
|
||||
if stats.TotalPnL > bestPnL {
|
||||
bestPnL = stats.TotalPnL
|
||||
bestSymbol = symbol
|
||||
}
|
||||
if stats.TotalPnL < worstPnL {
|
||||
worstPnL = stats.TotalPnL
|
||||
worstSymbol = symbol
|
||||
}
|
||||
|
||||
stats.AvgPnL = stats.TotalPnL / float64(stats.TotalTrades)
|
||||
stats.WinRate = (float64(stats.WinningTrades) / float64(stats.TotalTrades)) * 100
|
||||
}
|
||||
metrics.SymbolStats[symbol] = stats
|
||||
}
|
||||
|
||||
metrics.BestSymbol = bestSymbol
|
||||
if math.IsInf(bestPnL, -1) {
|
||||
metrics.BestSymbol = ""
|
||||
}
|
||||
metrics.WorstSymbol = worstSymbol
|
||||
if math.IsInf(worstPnL, 1) {
|
||||
metrics.WorstSymbol = ""
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
package backtest
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
var persistenceDB *sql.DB
|
||||
|
||||
// UseDatabase enables database-backed persistence for all backtest storage operations.
|
||||
func UseDatabase(db *sql.DB) {
|
||||
persistenceDB = db
|
||||
}
|
||||
|
||||
func usingDB() bool {
|
||||
return persistenceDB != nil
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
package backtest
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
const runIndexFile = "index.json"
|
||||
|
||||
type RunIndexEntry struct {
|
||||
RunID string `json:"run_id"`
|
||||
State RunState `json:"state"`
|
||||
Symbols []string `json:"symbols"`
|
||||
DecisionTF string `json:"decision_tf"`
|
||||
StartTS int64 `json:"start_ts"`
|
||||
EndTS int64 `json:"end_ts"`
|
||||
EquityLast float64 `json:"equity_last"`
|
||||
MaxDrawdownPct float64 `json:"max_dd_pct"`
|
||||
CreatedAtISO string `json:"created_at"`
|
||||
UpdatedAtISO string `json:"updated_at"`
|
||||
}
|
||||
|
||||
type RunIndex struct {
|
||||
Runs map[string]RunIndexEntry `json:"runs"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
func runIndexPath() string {
|
||||
return filepath.Join(backtestsRootDir, runIndexFile)
|
||||
}
|
||||
|
||||
func loadRunIndex() (*RunIndex, error) {
|
||||
if usingDB() {
|
||||
entries, err := listIndexEntriesDB()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
idx := &RunIndex{
|
||||
Runs: make(map[string]RunIndexEntry),
|
||||
UpdatedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
for _, entry := range entries {
|
||||
idx.Runs[entry.RunID] = entry
|
||||
}
|
||||
return idx, nil
|
||||
}
|
||||
path := runIndexPath()
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return &RunIndex{Runs: make(map[string]RunIndexEntry)}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
var idx RunIndex
|
||||
if err := json.Unmarshal(data, &idx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if idx.Runs == nil {
|
||||
idx.Runs = make(map[string]RunIndexEntry)
|
||||
}
|
||||
return &idx, nil
|
||||
}
|
||||
|
||||
func saveRunIndex(idx *RunIndex) error {
|
||||
if usingDB() {
|
||||
return nil
|
||||
}
|
||||
if idx == nil {
|
||||
return fmt.Errorf("index is nil")
|
||||
}
|
||||
idx.UpdatedAt = time.Now().UTC().Format(time.RFC3339)
|
||||
return writeJSONAtomic(runIndexPath(), idx)
|
||||
}
|
||||
|
||||
func updateRunIndex(meta *RunMetadata, cfg *BacktestConfig) error {
|
||||
if usingDB() {
|
||||
enforceRetention(maxCompletedRuns)
|
||||
return nil
|
||||
}
|
||||
if meta == nil {
|
||||
return fmt.Errorf("meta nil")
|
||||
}
|
||||
if cfg == nil {
|
||||
var err error
|
||||
cfg, err = LoadConfig(meta.RunID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
idx, err := loadRunIndex()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
entry := RunIndexEntry{
|
||||
RunID: meta.RunID,
|
||||
State: meta.State,
|
||||
Symbols: append([]string(nil), cfg.Symbols...),
|
||||
DecisionTF: meta.Summary.DecisionTF,
|
||||
StartTS: cfg.StartTS,
|
||||
EndTS: cfg.EndTS,
|
||||
EquityLast: meta.Summary.EquityLast,
|
||||
MaxDrawdownPct: meta.Summary.MaxDrawdownPct,
|
||||
CreatedAtISO: meta.CreatedAt.Format(time.RFC3339),
|
||||
UpdatedAtISO: meta.UpdatedAt.Format(time.RFC3339),
|
||||
}
|
||||
|
||||
if idx.Runs == nil {
|
||||
idx.Runs = make(map[string]RunIndexEntry)
|
||||
}
|
||||
idx.Runs[meta.RunID] = entry
|
||||
if err := saveRunIndex(idx); err != nil {
|
||||
return err
|
||||
}
|
||||
enforceRetention(maxCompletedRuns)
|
||||
return nil
|
||||
}
|
||||
|
||||
func removeFromRunIndex(runID string) error {
|
||||
if usingDB() {
|
||||
if err := deleteRunDB(runID); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.RemoveAll(runDir(runID))
|
||||
}
|
||||
idx, err := loadRunIndex()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if idx.Runs == nil {
|
||||
return nil
|
||||
}
|
||||
delete(idx.Runs, runID)
|
||||
return saveRunIndex(idx)
|
||||
}
|
||||
|
||||
func listIndexEntries() ([]RunIndexEntry, error) {
|
||||
if usingDB() {
|
||||
return listIndexEntriesDB()
|
||||
}
|
||||
idx, err := loadRunIndex()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entries := make([]RunIndexEntry, 0, len(idx.Runs))
|
||||
for _, entry := range idx.Runs {
|
||||
entries = append(entries, entry)
|
||||
}
|
||||
sort.Slice(entries, func(i, j int) bool {
|
||||
return entries[i].UpdatedAtISO > entries[j].UpdatedAtISO
|
||||
})
|
||||
return entries, nil
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
package backtest
|
||||
|
||||
import (
|
||||
"nofx/logger"
|
||||
"os"
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
const maxCompletedRuns = 100
|
||||
|
||||
func enforceRetention(maxRuns int) {
|
||||
if maxRuns <= 0 {
|
||||
return
|
||||
}
|
||||
if usingDB() {
|
||||
enforceRetentionDB(maxRuns)
|
||||
return
|
||||
}
|
||||
idx, err := loadRunIndex()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
type wrapped struct {
|
||||
entry RunIndexEntry
|
||||
updated time.Time
|
||||
}
|
||||
finalStates := map[RunState]bool{
|
||||
RunStateCompleted: true,
|
||||
RunStateStopped: true,
|
||||
RunStateFailed: true,
|
||||
RunStateLiquidated: true,
|
||||
}
|
||||
|
||||
candidates := make([]wrapped, 0)
|
||||
for _, entry := range idx.Runs {
|
||||
if !finalStates[entry.State] {
|
||||
continue
|
||||
}
|
||||
ts, err := time.Parse(time.RFC3339, entry.UpdatedAtISO)
|
||||
if err != nil {
|
||||
ts = time.Now()
|
||||
}
|
||||
candidates = append(candidates, wrapped{entry: entry, updated: ts})
|
||||
}
|
||||
if len(candidates) <= maxRuns {
|
||||
return
|
||||
}
|
||||
|
||||
sort.Slice(candidates, func(i, j int) bool {
|
||||
return candidates[i].updated.Before(candidates[j].updated)
|
||||
})
|
||||
|
||||
toRemove := len(candidates) - maxRuns
|
||||
for i := 0; i < toRemove; i++ {
|
||||
runID := candidates[i].entry.RunID
|
||||
if err := os.RemoveAll(runDir(runID)); err != nil {
|
||||
logger.Infof("failed to prune run %s: %v", runID, err)
|
||||
continue
|
||||
}
|
||||
delete(idx.Runs, runID)
|
||||
}
|
||||
if err := saveRunIndex(idx); err != nil {
|
||||
logger.Infof("failed to save index after pruning: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func enforceRetentionDB(maxRuns int) {
|
||||
finalStates := []RunState{
|
||||
RunStateCompleted,
|
||||
RunStateStopped,
|
||||
RunStateFailed,
|
||||
RunStateLiquidated,
|
||||
}
|
||||
query := `
|
||||
SELECT run_id FROM backtest_runs
|
||||
WHERE state IN (?, ?, ?, ?)
|
||||
ORDER BY updated_at DESC
|
||||
OFFSET ?
|
||||
`
|
||||
rows, err := persistenceDB.Query(query,
|
||||
finalStates[0], finalStates[1], finalStates[2], finalStates[3], maxRuns)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var runID string
|
||||
if err := rows.Scan(&runID); err != nil {
|
||||
continue
|
||||
}
|
||||
if err := deleteRunDB(runID); err != nil {
|
||||
logger.Infof("failed to remove run %s: %v", runID, err)
|
||||
continue
|
||||
}
|
||||
if err := os.RemoveAll(runDir(runID)); err != nil {
|
||||
logger.Infof("failed to remove run dir %s: %v", runID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
1484
backtest/runner.go
1484
backtest/runner.go
File diff suppressed because it is too large
Load Diff
@@ -1,561 +0,0 @@
|
||||
package backtest
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"nofx/store"
|
||||
)
|
||||
|
||||
const (
|
||||
backtestsRootDir = "backtests"
|
||||
)
|
||||
|
||||
type progressPayload struct {
|
||||
BarIndex int `json:"bar_index"`
|
||||
Equity float64 `json:"equity"`
|
||||
ProgressPct float64 `json:"progress_pct"`
|
||||
Liquidated bool `json:"liquidated"`
|
||||
UpdatedAtISO string `json:"updated_at_iso"`
|
||||
}
|
||||
|
||||
func runDir(runID string) string {
|
||||
return filepath.Join(backtestsRootDir, runID)
|
||||
}
|
||||
|
||||
func ensureRunDir(runID string) error {
|
||||
dir := runDir(runID)
|
||||
return os.MkdirAll(dir, 0o755)
|
||||
}
|
||||
|
||||
func checkpointPath(runID string) string {
|
||||
return filepath.Join(runDir(runID), "checkpoint.json")
|
||||
}
|
||||
|
||||
func runMetadataPath(runID string) string {
|
||||
return filepath.Join(runDir(runID), "run.json")
|
||||
}
|
||||
|
||||
func equityLogPath(runID string) string {
|
||||
return filepath.Join(runDir(runID), "equity.jsonl")
|
||||
}
|
||||
|
||||
func tradesLogPath(runID string) string {
|
||||
return filepath.Join(runDir(runID), "trades.jsonl")
|
||||
}
|
||||
|
||||
func metricsPath(runID string) string {
|
||||
return filepath.Join(runDir(runID), "metrics.json")
|
||||
}
|
||||
|
||||
func progressPath(runID string) string {
|
||||
return filepath.Join(runDir(runID), "progress.json")
|
||||
}
|
||||
|
||||
func decisionLogDir(runID string) string {
|
||||
return filepath.Join(runDir(runID), "decision_logs")
|
||||
}
|
||||
|
||||
func writeJSONAtomic(path string, v any) error {
|
||||
data, err := json.MarshalIndent(v, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeFileAtomic(path, data, 0o644)
|
||||
}
|
||||
|
||||
func writeFileAtomic(path string, data []byte, perm os.FileMode) error {
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
tmpFile, err := os.CreateTemp(dir, ".tmp-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmpPath := tmpFile.Name()
|
||||
if _, err := tmpFile.Write(data); err != nil {
|
||||
tmpFile.Close()
|
||||
os.Remove(tmpPath)
|
||||
return err
|
||||
}
|
||||
if err := tmpFile.Sync(); err != nil {
|
||||
tmpFile.Close()
|
||||
os.Remove(tmpPath)
|
||||
return err
|
||||
}
|
||||
if err := tmpFile.Close(); err != nil {
|
||||
os.Remove(tmpPath)
|
||||
return err
|
||||
}
|
||||
if err := os.Chmod(tmpPath, perm); err != nil {
|
||||
os.Remove(tmpPath)
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmpPath, path)
|
||||
}
|
||||
|
||||
func appendJSONLine(path string, payload any) error {
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
writer := bufio.NewWriter(f)
|
||||
if _, err := writer.Write(data); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writer.WriteByte('\n'); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writer.Flush(); err != nil {
|
||||
return err
|
||||
}
|
||||
return f.Sync()
|
||||
}
|
||||
|
||||
// SaveCheckpoint writes the checkpoint to disk.
|
||||
func SaveCheckpoint(runID string, ckpt *Checkpoint) error {
|
||||
if ckpt == nil {
|
||||
return fmt.Errorf("checkpoint is nil")
|
||||
}
|
||||
if usingDB() {
|
||||
return saveCheckpointDB(runID, ckpt)
|
||||
}
|
||||
return writeJSONAtomic(checkpointPath(runID), ckpt)
|
||||
}
|
||||
|
||||
// LoadCheckpoint reads the most recent checkpoint.
|
||||
func LoadCheckpoint(runID string) (*Checkpoint, error) {
|
||||
if usingDB() {
|
||||
return loadCheckpointDB(runID)
|
||||
}
|
||||
path := checkpointPath(runID)
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var ckpt Checkpoint
|
||||
if err := json.Unmarshal(data, &ckpt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ckpt, nil
|
||||
}
|
||||
|
||||
// SaveRunMetadata writes to run.json.
|
||||
func SaveRunMetadata(meta *RunMetadata) error {
|
||||
if meta == nil {
|
||||
return fmt.Errorf("run metadata is nil")
|
||||
}
|
||||
if meta.Version == 0 {
|
||||
meta.Version = 1
|
||||
}
|
||||
if meta.CreatedAt.IsZero() {
|
||||
meta.CreatedAt = time.Now().UTC()
|
||||
}
|
||||
meta.UpdatedAt = time.Now().UTC()
|
||||
if usingDB() {
|
||||
return saveRunMetadataDB(meta)
|
||||
}
|
||||
return writeJSONAtomic(runMetadataPath(meta.RunID), meta)
|
||||
}
|
||||
|
||||
// LoadRunMetadata reads run.json.
|
||||
func LoadRunMetadata(runID string) (*RunMetadata, error) {
|
||||
if usingDB() {
|
||||
return loadRunMetadataDB(runID)
|
||||
}
|
||||
path := runMetadataPath(runID)
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var meta RunMetadata
|
||||
if err := json.Unmarshal(data, &meta); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &meta, nil
|
||||
}
|
||||
|
||||
func appendEquityPoint(runID string, point EquityPoint) error {
|
||||
if usingDB() {
|
||||
return appendEquityPointDB(runID, point)
|
||||
}
|
||||
return appendJSONLine(equityLogPath(runID), point)
|
||||
}
|
||||
|
||||
func appendTradeEvent(runID string, event TradeEvent) error {
|
||||
if usingDB() {
|
||||
return appendTradeEventDB(runID, event)
|
||||
}
|
||||
return appendJSONLine(tradesLogPath(runID), event)
|
||||
}
|
||||
|
||||
func saveMetrics(runID string, metrics *Metrics) error {
|
||||
if metrics == nil {
|
||||
return fmt.Errorf("metrics is nil")
|
||||
}
|
||||
if usingDB() {
|
||||
return saveMetricsDB(runID, metrics)
|
||||
}
|
||||
return writeJSONAtomic(metricsPath(runID), metrics)
|
||||
}
|
||||
|
||||
func saveProgress(runID string, state *BacktestState, cfg *BacktestConfig) error {
|
||||
if state == nil || cfg == nil {
|
||||
return fmt.Errorf("state or config nil")
|
||||
}
|
||||
dur := cfg.Duration()
|
||||
progress := 0.0
|
||||
if dur > 0 {
|
||||
current := time.UnixMilli(state.BarTimestamp)
|
||||
start := time.Unix(cfg.StartTS, 0)
|
||||
if current.After(start) {
|
||||
elapsed := current.Sub(start)
|
||||
progress = float64(elapsed) / float64(dur)
|
||||
}
|
||||
}
|
||||
payload := progressPayload{
|
||||
BarIndex: state.BarIndex,
|
||||
Equity: state.Equity,
|
||||
ProgressPct: progress * 100,
|
||||
Liquidated: state.Liquidated,
|
||||
|
||||
UpdatedAtISO: time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
if usingDB() {
|
||||
return saveProgressDB(runID, payload)
|
||||
}
|
||||
return writeJSONAtomic(progressPath(runID), payload)
|
||||
}
|
||||
|
||||
func SaveConfig(runID string, cfg *BacktestConfig) error {
|
||||
if cfg == nil {
|
||||
return fmt.Errorf("config is nil")
|
||||
}
|
||||
persist := *cfg
|
||||
persist.AICfg.APIKey = ""
|
||||
if usingDB() {
|
||||
return saveConfigDB(runID, &persist)
|
||||
}
|
||||
if err := ensureRunDir(runID); err != nil {
|
||||
return err
|
||||
}
|
||||
return writeJSONAtomic(filepath.Join(runDir(runID), "config.json"), &persist)
|
||||
}
|
||||
|
||||
func LoadConfig(runID string) (*BacktestConfig, error) {
|
||||
if usingDB() {
|
||||
return loadConfigDB(runID)
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(runDir(runID), "config.json"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var cfg BacktestConfig
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
func LoadEquityPoints(runID string) ([]EquityPoint, error) {
|
||||
if usingDB() {
|
||||
return loadEquityPointsDB(runID)
|
||||
}
|
||||
points, err := loadJSONLines[EquityPoint](equityLogPath(runID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sort.Slice(points, func(i, j int) bool {
|
||||
return points[i].Timestamp < points[j].Timestamp
|
||||
})
|
||||
return points, nil
|
||||
}
|
||||
|
||||
func LoadTradeEvents(runID string) ([]TradeEvent, error) {
|
||||
if usingDB() {
|
||||
return loadTradeEventsDB(runID)
|
||||
}
|
||||
events, err := loadJSONLines[TradeEvent](tradesLogPath(runID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sort.Slice(events, func(i, j int) bool {
|
||||
if events[i].Timestamp == events[j].Timestamp {
|
||||
return events[i].Symbol < events[j].Symbol
|
||||
}
|
||||
return events[i].Timestamp < events[j].Timestamp
|
||||
})
|
||||
return events, nil
|
||||
}
|
||||
|
||||
func LoadMetrics(runID string) (*Metrics, error) {
|
||||
if usingDB() {
|
||||
return loadMetricsDB(runID)
|
||||
}
|
||||
data, err := os.ReadFile(metricsPath(runID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var metrics Metrics
|
||||
if err := json.Unmarshal(data, &metrics); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &metrics, nil
|
||||
}
|
||||
|
||||
func LoadRunIDs() ([]string, error) {
|
||||
if usingDB() {
|
||||
return loadRunIDsDB()
|
||||
}
|
||||
entries, err := os.ReadDir(backtestsRootDir)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return []string{}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
runIDs := make([]string, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
runIDs = append(runIDs, entry.Name())
|
||||
}
|
||||
}
|
||||
sort.Strings(runIDs)
|
||||
return runIDs, nil
|
||||
}
|
||||
|
||||
func loadJSONLines[T any](path string) ([]T, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return []T{}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
scanner := bufio.NewScanner(file)
|
||||
scanner.Buffer(make([]byte, 0, 64*1024), 16*1024*1024)
|
||||
|
||||
var result []T
|
||||
for scanner.Scan() {
|
||||
line := scanner.Bytes()
|
||||
if len(line) == 0 {
|
||||
continue
|
||||
}
|
||||
var item T
|
||||
if err := json.Unmarshal(line, &item); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, item)
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
func PersistMetrics(runID string, metrics *Metrics) error {
|
||||
return saveMetrics(runID, metrics)
|
||||
}
|
||||
|
||||
func LoadDecisionTrace(runID string, cycle int) (*store.DecisionRecord, error) {
|
||||
if usingDB() {
|
||||
return loadDecisionTraceDB(runID, cycle)
|
||||
}
|
||||
dir := decisionLogDir(runID)
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
type candidate struct {
|
||||
path string
|
||||
info os.DirEntry
|
||||
}
|
||||
cands := make([]candidate, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := entry.Name()
|
||||
if !strings.HasPrefix(name, "decision_") || !strings.HasSuffix(name, ".json") {
|
||||
continue
|
||||
}
|
||||
cands = append(cands, candidate{path: filepath.Join(dir, name), info: entry})
|
||||
}
|
||||
sort.Slice(cands, func(i, j int) bool {
|
||||
infoI, _ := cands[i].info.Info()
|
||||
infoJ, _ := cands[j].info.Info()
|
||||
if infoI == nil || infoJ == nil {
|
||||
return cands[i].path > cands[j].path
|
||||
}
|
||||
return infoI.ModTime().After(infoJ.ModTime())
|
||||
})
|
||||
|
||||
for _, cand := range cands {
|
||||
data, err := os.ReadFile(cand.path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var record store.DecisionRecord
|
||||
if err := json.Unmarshal(data, &record); err != nil {
|
||||
continue
|
||||
}
|
||||
if cycle <= 0 || record.CycleNumber == cycle {
|
||||
return &record, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("decision trace not found for run %s cycle %d", runID, cycle)
|
||||
}
|
||||
|
||||
func LoadDecisionRecords(runID string, limit, offset int) ([]*store.DecisionRecord, error) {
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
if usingDB() {
|
||||
return loadDecisionRecordsDB(runID, limit, offset)
|
||||
}
|
||||
dir := decisionLogDir(runID)
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return []*store.DecisionRecord{}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
type fileEntry struct {
|
||||
path string
|
||||
info os.DirEntry
|
||||
}
|
||||
files := make([]fileEntry, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := entry.Name()
|
||||
if !strings.HasPrefix(name, "decision_") || !strings.HasSuffix(name, ".json") {
|
||||
continue
|
||||
}
|
||||
files = append(files, fileEntry{path: filepath.Join(dir, name), info: entry})
|
||||
}
|
||||
sort.Slice(files, func(i, j int) bool {
|
||||
infoI, _ := files[i].info.Info()
|
||||
infoJ, _ := files[j].info.Info()
|
||||
if infoI == nil || infoJ == nil {
|
||||
return files[i].path > files[j].path
|
||||
}
|
||||
return infoI.ModTime().After(infoJ.ModTime())
|
||||
})
|
||||
if offset >= len(files) {
|
||||
return []*store.DecisionRecord{}, nil
|
||||
}
|
||||
end := offset + limit
|
||||
if end > len(files) {
|
||||
end = len(files)
|
||||
}
|
||||
records := make([]*store.DecisionRecord, 0, end-offset)
|
||||
for _, file := range files[offset:end] {
|
||||
data, err := os.ReadFile(file.path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var record store.DecisionRecord
|
||||
if err := json.Unmarshal(data, &record); err != nil {
|
||||
continue
|
||||
}
|
||||
records = append(records, &record)
|
||||
}
|
||||
return records, nil
|
||||
}
|
||||
|
||||
func CreateRunExport(runID string) (string, error) {
|
||||
if usingDB() {
|
||||
return createRunExportDB(runID)
|
||||
}
|
||||
root := runDir(runID)
|
||||
if _, err := os.Stat(root); err != nil {
|
||||
return "", err
|
||||
}
|
||||
tmpFile, err := os.CreateTemp("", fmt.Sprintf("%s-*.zip", runID))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer tmpFile.Close()
|
||||
|
||||
zipWriter := zip.NewWriter(tmpFile)
|
||||
err = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
rel, err := filepath.Rel(root, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
info, err := d.Info()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
header, err := zip.FileInfoHeader(info)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
header.Name = rel
|
||||
header.Method = zip.Deflate
|
||||
writer, err := zipWriter.CreateHeader(header)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
src, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := io.Copy(writer, src); err != nil {
|
||||
src.Close()
|
||||
return err
|
||||
}
|
||||
src.Close()
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
zipWriter.Close()
|
||||
return "", err
|
||||
}
|
||||
if err := zipWriter.Close(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return tmpFile.Name(), nil
|
||||
}
|
||||
|
||||
func persistDecisionRecord(runID string, record *store.DecisionRecord) {
|
||||
if !usingDB() || record == nil {
|
||||
return
|
||||
}
|
||||
_ = saveDecisionRecordDB(runID, record)
|
||||
}
|
||||
@@ -1,499 +0,0 @@
|
||||
package backtest
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"nofx/store"
|
||||
)
|
||||
|
||||
func saveCheckpointDB(runID string, ckpt *Checkpoint) error {
|
||||
data, err := json.Marshal(ckpt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = persistenceDB.Exec(`
|
||||
INSERT INTO backtest_checkpoints (run_id, payload, updated_at)
|
||||
VALUES (?, ?, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(run_id) DO UPDATE SET payload=excluded.payload, updated_at=CURRENT_TIMESTAMP
|
||||
`, runID, data)
|
||||
return err
|
||||
}
|
||||
|
||||
func loadCheckpointDB(runID string) (*Checkpoint, error) {
|
||||
var payload []byte
|
||||
err := persistenceDB.QueryRow(`SELECT payload FROM backtest_checkpoints WHERE run_id = ?`, runID).Scan(&payload)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, os.ErrNotExist
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
var ckpt Checkpoint
|
||||
if err := json.Unmarshal(payload, &ckpt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ckpt, nil
|
||||
}
|
||||
|
||||
func saveConfigDB(runID string, cfg *BacktestConfig) error {
|
||||
persist := *cfg
|
||||
persist.AICfg.APIKey = ""
|
||||
data, err := json.Marshal(&persist)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
template := cfg.PromptTemplate
|
||||
if template == "" {
|
||||
template = "default"
|
||||
}
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
userID := cfg.UserID
|
||||
if userID == "" {
|
||||
userID = "default"
|
||||
}
|
||||
_, err = persistenceDB.Exec(`
|
||||
INSERT INTO backtest_runs (run_id, user_id, config_json, prompt_template, custom_prompt, override_prompt, ai_provider, ai_model, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(run_id) DO NOTHING
|
||||
`, runID, userID, data, template, cfg.CustomPrompt, cfg.OverrideBasePrompt, cfg.AICfg.Provider, cfg.AICfg.Model, now, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = persistenceDB.Exec(`
|
||||
UPDATE backtest_runs
|
||||
SET user_id = ?, config_json = ?, prompt_template = ?, custom_prompt = ?, override_prompt = ?, ai_provider = ?, ai_model = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE run_id = ?
|
||||
`, userID, data, template, cfg.CustomPrompt, cfg.OverrideBasePrompt, cfg.AICfg.Provider, cfg.AICfg.Model, runID)
|
||||
return err
|
||||
}
|
||||
|
||||
func loadConfigDB(runID string) (*BacktestConfig, error) {
|
||||
var payload []byte
|
||||
err := persistenceDB.QueryRow(`SELECT config_json FROM backtest_runs WHERE run_id = ?`, runID).Scan(&payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(payload) == 0 {
|
||||
return nil, fmt.Errorf("config missing for %s", runID)
|
||||
}
|
||||
var cfg BacktestConfig
|
||||
if err := json.Unmarshal(payload, &cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
func saveRunMetadataDB(meta *RunMetadata) error {
|
||||
created := meta.CreatedAt.UTC().Format(time.RFC3339)
|
||||
updated := meta.UpdatedAt.UTC().Format(time.RFC3339)
|
||||
userID := meta.UserID
|
||||
if userID == "" {
|
||||
userID = "default"
|
||||
}
|
||||
if _, err := persistenceDB.Exec(`
|
||||
INSERT INTO backtest_runs (run_id, user_id, label, last_error, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(run_id) DO NOTHING
|
||||
`, meta.RunID, userID, meta.Label, meta.LastError, created, updated); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := persistenceDB.Exec(`
|
||||
UPDATE backtest_runs
|
||||
SET user_id = ?, state = ?, symbol_count = ?, decision_tf = ?, processed_bars = ?, progress_pct = ?, equity_last = ?, max_drawdown_pct = ?, liquidated = ?, liquidation_note = ?, label = ?, last_error = ?, updated_at = ?
|
||||
WHERE run_id = ?
|
||||
`, userID, string(meta.State), meta.Summary.SymbolCount, meta.Summary.DecisionTF, meta.Summary.ProcessedBars, meta.Summary.ProgressPct, meta.Summary.EquityLast, meta.Summary.MaxDrawdownPct, meta.Summary.Liquidated, meta.Summary.LiquidationNote, meta.Label, meta.LastError, updated, meta.RunID)
|
||||
return err
|
||||
}
|
||||
|
||||
func loadRunMetadataDB(runID string) (*RunMetadata, error) {
|
||||
var (
|
||||
userID string
|
||||
state string
|
||||
label string
|
||||
lastErr string
|
||||
symbolCount int
|
||||
decisionTF string
|
||||
processedBars int
|
||||
progressPct float64
|
||||
equityLast float64
|
||||
maxDD float64
|
||||
liquidated bool
|
||||
liquidationNote string
|
||||
createdISO string
|
||||
updatedISO string
|
||||
)
|
||||
err := persistenceDB.QueryRow(`
|
||||
SELECT user_id, state, label, last_error, symbol_count, decision_tf, processed_bars, progress_pct, equity_last, max_drawdown_pct, liquidated, liquidation_note, created_at, updated_at
|
||||
FROM backtest_runs WHERE run_id = ?
|
||||
`, runID).Scan(&userID, &state, &label, &lastErr, &symbolCount, &decisionTF, &processedBars, &progressPct, &equityLast, &maxDD, &liquidated, &liquidationNote, &createdISO, &updatedISO)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
meta := &RunMetadata{
|
||||
RunID: runID,
|
||||
UserID: userID,
|
||||
Version: 1,
|
||||
State: RunState(state),
|
||||
Label: label,
|
||||
LastError: lastErr,
|
||||
Summary: RunSummary{
|
||||
SymbolCount: symbolCount,
|
||||
DecisionTF: decisionTF,
|
||||
ProcessedBars: processedBars,
|
||||
ProgressPct: progressPct,
|
||||
EquityLast: equityLast,
|
||||
MaxDrawdownPct: maxDD,
|
||||
Liquidated: liquidated,
|
||||
LiquidationNote: liquidationNote,
|
||||
},
|
||||
}
|
||||
if meta.UserID == "" {
|
||||
meta.UserID = "default"
|
||||
}
|
||||
if t, err := time.Parse(time.RFC3339, createdISO); err == nil {
|
||||
meta.CreatedAt = t
|
||||
}
|
||||
if t, err := time.Parse(time.RFC3339, updatedISO); err == nil {
|
||||
meta.UpdatedAt = t
|
||||
}
|
||||
return meta, nil
|
||||
}
|
||||
|
||||
func loadRunIDsDB() ([]string, error) {
|
||||
rows, err := persistenceDB.Query(`SELECT run_id FROM backtest_runs ORDER BY updated_at DESC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var ids []string
|
||||
for rows.Next() {
|
||||
var runID string
|
||||
if err := rows.Scan(&runID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids = append(ids, runID)
|
||||
}
|
||||
return ids, rows.Err()
|
||||
}
|
||||
|
||||
func appendEquityPointDB(runID string, point EquityPoint) error {
|
||||
_, err := persistenceDB.Exec(`
|
||||
INSERT INTO backtest_equity (run_id, ts, equity, available, pnl, pnl_pct, dd_pct, cycle)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, runID, point.Timestamp, point.Equity, point.Available, point.PnL, point.PnLPct, point.DrawdownPct, point.Cycle)
|
||||
return err
|
||||
}
|
||||
|
||||
func loadEquityPointsDB(runID string) ([]EquityPoint, error) {
|
||||
rows, err := persistenceDB.Query(`
|
||||
SELECT ts, equity, available, pnl, pnl_pct, dd_pct, cycle
|
||||
FROM backtest_equity WHERE run_id = ? ORDER BY ts ASC
|
||||
`, runID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
points := make([]EquityPoint, 0)
|
||||
for rows.Next() {
|
||||
var point EquityPoint
|
||||
if err := rows.Scan(&point.Timestamp, &point.Equity, &point.Available, &point.PnL, &point.PnLPct, &point.DrawdownPct, &point.Cycle); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
points = append(points, point)
|
||||
}
|
||||
return points, rows.Err()
|
||||
}
|
||||
|
||||
func appendTradeEventDB(runID string, event TradeEvent) error {
|
||||
_, err := persistenceDB.Exec(`
|
||||
INSERT INTO backtest_trades (run_id, ts, symbol, action, side, qty, price, fee, slippage, order_value, realized_pnl, leverage, cycle, position_after, liquidation, note)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, runID, event.Timestamp, event.Symbol, event.Action, event.Side, event.Quantity, event.Price, event.Fee, event.Slippage, event.OrderValue, event.RealizedPnL, event.Leverage, event.Cycle, event.PositionAfter, event.LiquidationFlag, event.Note)
|
||||
return err
|
||||
}
|
||||
|
||||
func loadTradeEventsDB(runID string) ([]TradeEvent, error) {
|
||||
rows, err := persistenceDB.Query(`
|
||||
SELECT ts, symbol, action, side, qty, price, fee, slippage, order_value, realized_pnl, leverage, cycle, position_after, liquidation, note
|
||||
FROM backtest_trades WHERE run_id = ? ORDER BY ts ASC
|
||||
`, runID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
events := make([]TradeEvent, 0)
|
||||
for rows.Next() {
|
||||
var event TradeEvent
|
||||
if err := rows.Scan(&event.Timestamp, &event.Symbol, &event.Action, &event.Side, &event.Quantity, &event.Price, &event.Fee, &event.Slippage, &event.OrderValue, &event.RealizedPnL, &event.Leverage, &event.Cycle, &event.PositionAfter, &event.LiquidationFlag, &event.Note); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
events = append(events, event)
|
||||
}
|
||||
return events, rows.Err()
|
||||
}
|
||||
|
||||
func saveMetricsDB(runID string, metrics *Metrics) error {
|
||||
data, err := json.Marshal(metrics)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = persistenceDB.Exec(`
|
||||
INSERT INTO backtest_metrics (run_id, payload, updated_at)
|
||||
VALUES (?, ?, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(run_id) DO UPDATE SET payload=excluded.payload, updated_at=CURRENT_TIMESTAMP
|
||||
`, runID, data)
|
||||
return err
|
||||
}
|
||||
|
||||
func loadMetricsDB(runID string) (*Metrics, error) {
|
||||
var payload []byte
|
||||
err := persistenceDB.QueryRow(`SELECT payload FROM backtest_metrics WHERE run_id = ?`, runID).Scan(&payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var metrics Metrics
|
||||
if err := json.Unmarshal(payload, &metrics); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &metrics, nil
|
||||
}
|
||||
|
||||
func saveProgressDB(runID string, payload progressPayload) error {
|
||||
_, err := persistenceDB.Exec(`
|
||||
UPDATE backtest_runs
|
||||
SET progress_pct = ?, equity_last = ?, processed_bars = ?, liquidated = ?, updated_at = ?
|
||||
WHERE run_id = ?
|
||||
`, payload.ProgressPct, payload.Equity, payload.BarIndex, payload.Liquidated, payload.UpdatedAtISO, runID)
|
||||
return err
|
||||
}
|
||||
|
||||
func loadDecisionTraceDB(runID string, cycle int) (*store.DecisionRecord, error) {
|
||||
query := `SELECT payload FROM backtest_decisions WHERE run_id = ?`
|
||||
var rows *sql.Rows
|
||||
var err error
|
||||
if cycle > 0 {
|
||||
rows, err = persistenceDB.Query(query+` AND cycle = ? ORDER BY created_at DESC LIMIT 1`, runID, cycle)
|
||||
} else {
|
||||
rows, err = persistenceDB.Query(query+` ORDER BY created_at DESC LIMIT 1`, runID)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
if !rows.Next() {
|
||||
return nil, fmt.Errorf("decision trace not found for %s", runID)
|
||||
}
|
||||
var payload []byte
|
||||
if err := rows.Scan(&payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var record store.DecisionRecord
|
||||
if err := json.Unmarshal(payload, &record); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &record, nil
|
||||
}
|
||||
|
||||
func saveDecisionRecordDB(runID string, record *store.DecisionRecord) error {
|
||||
if record == nil {
|
||||
return nil
|
||||
}
|
||||
data, err := json.Marshal(record)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = persistenceDB.Exec(`
|
||||
INSERT INTO backtest_decisions (run_id, cycle, payload)
|
||||
VALUES (?, ?, ?)
|
||||
`, runID, record.CycleNumber, data)
|
||||
return err
|
||||
}
|
||||
|
||||
func loadDecisionRecordsDB(runID string, limit, offset int) ([]*store.DecisionRecord, error) {
|
||||
rows, err := persistenceDB.Query(`
|
||||
SELECT payload FROM backtest_decisions
|
||||
WHERE run_id = ?
|
||||
ORDER BY id DESC
|
||||
LIMIT ? OFFSET ?
|
||||
`, runID, limit, offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
records := make([]*store.DecisionRecord, 0, limit)
|
||||
for rows.Next() {
|
||||
var payload []byte
|
||||
if err := rows.Scan(&payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var record store.DecisionRecord
|
||||
if err := json.Unmarshal(payload, &record); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
records = append(records, &record)
|
||||
}
|
||||
return records, rows.Err()
|
||||
}
|
||||
|
||||
func createRunExportDB(runID string) (string, error) {
|
||||
tmpFile, err := os.CreateTemp("", fmt.Sprintf("%s-*.zip", runID))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer tmpFile.Close()
|
||||
|
||||
zipWriter := zip.NewWriter(tmpFile)
|
||||
defer zipWriter.Close()
|
||||
|
||||
if meta, err := loadRunMetadataDB(runID); err == nil {
|
||||
if err := writeJSONToZip(zipWriter, "run.json", meta); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
if cfg, err := loadConfigDB(runID); err == nil {
|
||||
if err := writeJSONToZip(zipWriter, "config.json", cfg); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
if ckpt, err := loadCheckpointDB(runID); err == nil {
|
||||
if err := writeJSONToZip(zipWriter, "checkpoint.json", ckpt); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
if metrics, err := loadMetricsDB(runID); err == nil {
|
||||
if err := writeJSONToZip(zipWriter, "metrics.json", metrics); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
if points, err := loadEquityPointsDB(runID); err == nil && len(points) > 0 {
|
||||
if err := writeJSONLinesToZip(zipWriter, "equity.jsonl", points); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
if trades, err := loadTradeEventsDB(runID); err == nil && len(trades) > 0 {
|
||||
if err := writeJSONLinesToZip(zipWriter, "trades.jsonl", trades); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
if err := writeDecisionLogsToZip(zipWriter, runID); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if err := zipWriter.Close(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := tmpFile.Sync(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return tmpFile.Name(), nil
|
||||
}
|
||||
|
||||
func writeJSONToZip(z *zip.Writer, name string, value any) error {
|
||||
data, err := json.MarshalIndent(value, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
w, err := z.Create(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = w.Write(data)
|
||||
return err
|
||||
}
|
||||
|
||||
func writeJSONLinesToZip[T any](z *zip.Writer, name string, items []T) error {
|
||||
w, err := z.Create(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, item := range items {
|
||||
data, err := json.Marshal(item)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := w.Write(data); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := w.Write([]byte("\n")); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeDecisionLogsToZip(z *zip.Writer, runID string) error {
|
||||
rows, err := persistenceDB.Query(`
|
||||
SELECT id, cycle, payload FROM backtest_decisions
|
||||
WHERE run_id = ? ORDER BY id ASC
|
||||
`, runID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var (
|
||||
id int64
|
||||
cycle int
|
||||
payload []byte
|
||||
)
|
||||
if err := rows.Scan(&id, &cycle, &payload); err != nil {
|
||||
return err
|
||||
}
|
||||
name := fmt.Sprintf("decision_logs/decision_%d_cycle%d.json", id, cycle)
|
||||
w, err := z.Create(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := w.Write(payload); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return rows.Err()
|
||||
}
|
||||
|
||||
func listIndexEntriesDB() ([]RunIndexEntry, error) {
|
||||
rows, err := persistenceDB.Query(`
|
||||
SELECT run_id, state, symbol_count, decision_tf, equity_last, max_drawdown_pct, created_at, updated_at, config_json
|
||||
FROM backtest_runs
|
||||
ORDER BY updated_at DESC
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var entries []RunIndexEntry
|
||||
for rows.Next() {
|
||||
var (
|
||||
entry RunIndexEntry
|
||||
createdISO string
|
||||
updatedISO string
|
||||
cfgJSON []byte
|
||||
symbolCnt int
|
||||
)
|
||||
if err := rows.Scan(&entry.RunID, &entry.State, &symbolCnt, &entry.DecisionTF, &entry.EquityLast, &entry.MaxDrawdownPct, &createdISO, &updatedISO, &cfgJSON); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entry.CreatedAtISO = createdISO
|
||||
entry.UpdatedAtISO = updatedISO
|
||||
entry.Symbols = make([]string, 0, symbolCnt)
|
||||
var cfg BacktestConfig
|
||||
if len(cfgJSON) > 0 && json.Unmarshal(cfgJSON, &cfg) == nil {
|
||||
entry.Symbols = append([]string(nil), cfg.Symbols...)
|
||||
entry.StartTS = cfg.StartTS
|
||||
entry.EndTS = cfg.EndTS
|
||||
}
|
||||
entries = append(entries, entry)
|
||||
}
|
||||
return entries, rows.Err()
|
||||
}
|
||||
|
||||
func deleteRunDB(runID string) error {
|
||||
_, err := persistenceDB.Exec(`DELETE FROM backtest_runs WHERE run_id = ?`, runID)
|
||||
return err
|
||||
}
|
||||
@@ -1,179 +0,0 @@
|
||||
package backtest
|
||||
|
||||
import "time"
|
||||
|
||||
// RunState represents the current state of a backtest run.
|
||||
type RunState string
|
||||
|
||||
const (
|
||||
RunStateCreated RunState = "created"
|
||||
RunStateRunning RunState = "running"
|
||||
RunStatePaused RunState = "paused"
|
||||
RunStateStopped RunState = "stopped"
|
||||
RunStateCompleted RunState = "completed"
|
||||
RunStateFailed RunState = "failed"
|
||||
RunStateLiquidated RunState = "liquidated"
|
||||
)
|
||||
|
||||
// PositionSnapshot represents core position data for backtest state and persistence.
|
||||
type PositionSnapshot struct {
|
||||
Symbol string `json:"symbol"`
|
||||
Side string `json:"side"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
AvgPrice float64 `json:"avg_price"`
|
||||
Leverage int `json:"leverage"`
|
||||
LiquidationPrice float64 `json:"liquidation_price"`
|
||||
MarginUsed float64 `json:"margin_used"`
|
||||
OpenTime int64 `json:"open_time"`
|
||||
AccumulatedFee float64 `json:"accumulated_fee,omitempty"` // Opening fees accumulated
|
||||
}
|
||||
|
||||
// BacktestState represents the real-time state during execution (in-memory state).
|
||||
type BacktestState struct {
|
||||
BarIndex int
|
||||
BarTimestamp int64
|
||||
DecisionCycle int
|
||||
|
||||
Cash float64
|
||||
Equity float64
|
||||
UnrealizedPnL float64
|
||||
RealizedPnL float64
|
||||
MaxEquity float64
|
||||
MinEquity float64
|
||||
MaxDrawdownPct float64
|
||||
Positions map[string]PositionSnapshot
|
||||
LastUpdate time.Time
|
||||
Liquidated bool
|
||||
LiquidationNote string
|
||||
}
|
||||
|
||||
// EquityPoint represents a single point on the equity curve.
|
||||
type EquityPoint struct {
|
||||
Timestamp int64 `json:"ts"`
|
||||
Equity float64 `json:"equity"`
|
||||
Available float64 `json:"available"`
|
||||
PnL float64 `json:"pnl"`
|
||||
PnLPct float64 `json:"pnl_pct"`
|
||||
DrawdownPct float64 `json:"dd_pct"`
|
||||
Cycle int `json:"cycle"`
|
||||
}
|
||||
|
||||
// TradeEvent records a trade execution result or special event (such as liquidation).
|
||||
type TradeEvent struct {
|
||||
Timestamp int64 `json:"ts"`
|
||||
Symbol string `json:"symbol"`
|
||||
Action string `json:"action"`
|
||||
Side string `json:"side,omitempty"`
|
||||
Quantity float64 `json:"qty"`
|
||||
Price float64 `json:"price"`
|
||||
Fee float64 `json:"fee"`
|
||||
Slippage float64 `json:"slippage"`
|
||||
OrderValue float64 `json:"order_value"`
|
||||
RealizedPnL float64 `json:"realized_pnl"`
|
||||
Leverage int `json:"leverage,omitempty"`
|
||||
Cycle int `json:"cycle"`
|
||||
PositionAfter float64 `json:"position_after"`
|
||||
LiquidationFlag bool `json:"liquidation"`
|
||||
Note string `json:"note,omitempty"`
|
||||
}
|
||||
|
||||
// Metrics summarizes backtest performance metrics.
|
||||
type Metrics struct {
|
||||
TotalReturnPct float64 `json:"total_return_pct"`
|
||||
MaxDrawdownPct float64 `json:"max_drawdown_pct"`
|
||||
SharpeRatio float64 `json:"sharpe_ratio"`
|
||||
ProfitFactor float64 `json:"profit_factor"`
|
||||
WinRate float64 `json:"win_rate"`
|
||||
Trades int `json:"trades"`
|
||||
AvgWin float64 `json:"avg_win"`
|
||||
AvgLoss float64 `json:"avg_loss"`
|
||||
BestSymbol string `json:"best_symbol"`
|
||||
WorstSymbol string `json:"worst_symbol"`
|
||||
SymbolStats map[string]SymbolMetrics `json:"symbol_stats"`
|
||||
Liquidated bool `json:"liquidated"`
|
||||
}
|
||||
|
||||
// SymbolMetrics records performance for a single symbol.
|
||||
type SymbolMetrics struct {
|
||||
TotalTrades int `json:"total_trades"`
|
||||
WinningTrades int `json:"winning_trades"`
|
||||
LosingTrades int `json:"losing_trades"`
|
||||
TotalPnL float64 `json:"total_pnl"`
|
||||
AvgPnL float64 `json:"avg_pnl"`
|
||||
WinRate float64 `json:"win_rate"`
|
||||
}
|
||||
|
||||
// Checkpoint represents checkpoint information saved to disk for pause, resume, and crash recovery.
|
||||
type Checkpoint struct {
|
||||
BarIndex int `json:"bar_index"`
|
||||
BarTimestamp int64 `json:"bar_ts"`
|
||||
Cash float64 `json:"cash"`
|
||||
Equity float64 `json:"equity"`
|
||||
MaxEquity float64 `json:"max_equity"`
|
||||
MinEquity float64 `json:"min_equity"`
|
||||
MaxDrawdownPct float64 `json:"max_drawdown_pct"`
|
||||
UnrealizedPnL float64 `json:"unrealized_pnl"`
|
||||
RealizedPnL float64 `json:"realized_pnl"`
|
||||
Positions []PositionSnapshot `json:"positions"`
|
||||
DecisionCycle int `json:"decision_cycle"`
|
||||
IndicatorsState map[string]map[string]any `json:"indicators_state,omitempty"`
|
||||
RNGSeed int64 `json:"rng_seed,omitempty"`
|
||||
AICacheRef string `json:"ai_cache_ref,omitempty"`
|
||||
Liquidated bool `json:"liquidated"`
|
||||
LiquidationNote string `json:"liquidation_note,omitempty"`
|
||||
}
|
||||
|
||||
// RunMetadata records the summary required for run.json.
|
||||
type RunMetadata struct {
|
||||
RunID string `json:"run_id"`
|
||||
Label string `json:"label,omitempty"`
|
||||
UserID string `json:"user_id,omitempty"`
|
||||
LastError string `json:"last_error,omitempty"`
|
||||
Version int `json:"version"`
|
||||
State RunState `json:"state"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Summary RunSummary `json:"summary"`
|
||||
}
|
||||
|
||||
// RunSummary represents the summary field in run.json.
|
||||
type RunSummary struct {
|
||||
SymbolCount int `json:"symbol_count"`
|
||||
DecisionTF string `json:"decision_tf"`
|
||||
ProcessedBars int `json:"processed_bars"`
|
||||
ProgressPct float64 `json:"progress_pct"`
|
||||
EquityLast float64 `json:"equity_last"`
|
||||
MaxDrawdownPct float64 `json:"max_drawdown_pct"`
|
||||
Liquidated bool `json:"liquidated"`
|
||||
LiquidationNote string `json:"liquidation_note,omitempty"`
|
||||
}
|
||||
|
||||
// StatusPayload is used for /status API responses.
|
||||
type StatusPayload struct {
|
||||
RunID string `json:"run_id"`
|
||||
State RunState `json:"state"`
|
||||
ProgressPct float64 `json:"progress_pct"`
|
||||
ProcessedBars int `json:"processed_bars"`
|
||||
CurrentTime int64 `json:"current_time"`
|
||||
DecisionCycle int `json:"decision_cycle"`
|
||||
Equity float64 `json:"equity"`
|
||||
UnrealizedPnL float64 `json:"unrealized_pnl"`
|
||||
RealizedPnL float64 `json:"realized_pnl"`
|
||||
Positions []PositionStatus `json:"positions,omitempty"`
|
||||
Note string `json:"note,omitempty"`
|
||||
LastError string `json:"last_error,omitempty"`
|
||||
LastUpdatedIso string `json:"last_updated_iso"`
|
||||
}
|
||||
|
||||
// PositionStatus represents a position with unrealized P&L for status display.
|
||||
type PositionStatus struct {
|
||||
Symbol string `json:"symbol"`
|
||||
Side string `json:"side"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
EntryPrice float64 `json:"entry_price"`
|
||||
MarkPrice float64 `json:"mark_price"`
|
||||
Leverage int `json:"leverage"`
|
||||
UnrealizedPnL float64 `json:"unrealized_pnl"`
|
||||
UnrealizedPnLPct float64 `json:"unrealized_pnl_pct"`
|
||||
MarginUsed float64 `json:"margin_used"`
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"nofx/experience"
|
||||
"nofx/telemetry"
|
||||
"nofx/mcp"
|
||||
"os"
|
||||
"strconv"
|
||||
@@ -15,10 +15,8 @@ var global *Config
|
||||
// Only contains truly global config, trading related config is at trader/strategy level
|
||||
type Config struct {
|
||||
// Service configuration
|
||||
APIServerPort int
|
||||
JWTSecret string
|
||||
RegistrationEnabled bool
|
||||
MaxUsers int // Maximum number of users allowed (0 = unlimited, default = 10)
|
||||
APIServerPort int
|
||||
JWTSecret string
|
||||
|
||||
// Database configuration
|
||||
DBType string // sqlite or postgres
|
||||
@@ -44,14 +42,13 @@ type Config struct {
|
||||
AlpacaAPIKey string // Alpaca API key for US stocks
|
||||
AlpacaSecretKey string // Alpaca secret key
|
||||
TwelveDataKey string // TwelveData API key for forex & metals
|
||||
|
||||
}
|
||||
|
||||
// Init initializes global configuration (from .env)
|
||||
func Init() {
|
||||
cfg := &Config{
|
||||
APIServerPort: 8080,
|
||||
RegistrationEnabled: true,
|
||||
MaxUsers: 10, // Default: 10 users allowed
|
||||
ExperienceImprovement: true, // Default: enabled to help improve the product
|
||||
// Database defaults
|
||||
DBType: "sqlite",
|
||||
@@ -71,16 +68,6 @@ func Init() {
|
||||
cfg.JWTSecret = "default-jwt-secret-change-in-production"
|
||||
}
|
||||
|
||||
if v := os.Getenv("REGISTRATION_ENABLED"); v != "" {
|
||||
cfg.RegistrationEnabled = strings.ToLower(v) == "true"
|
||||
}
|
||||
|
||||
if v := os.Getenv("MAX_USERS"); v != "" {
|
||||
if maxUsers, err := strconv.Atoi(v); err == nil && maxUsers >= 0 {
|
||||
cfg.MaxUsers = maxUsers
|
||||
}
|
||||
}
|
||||
|
||||
if v := os.Getenv("API_SERVER_PORT"); v != "" {
|
||||
if port, err := strconv.Atoi(v); err == nil && port > 0 {
|
||||
cfg.APIServerPort = port
|
||||
@@ -135,13 +122,14 @@ func Init() {
|
||||
global = cfg
|
||||
|
||||
// Initialize experience improvement (installation ID will be set after database init)
|
||||
experience.Init(cfg.ExperienceImprovement, "")
|
||||
telemetry.Init(cfg.ExperienceImprovement, "")
|
||||
|
||||
// Set up AI token usage tracking callback
|
||||
mcp.TokenUsageCallback = func(usage mcp.TokenUsage) {
|
||||
experience.TrackAIUsage(experience.AIUsageEvent{
|
||||
telemetry.TrackAIUsage(telemetry.AIUsageEvent{
|
||||
ModelProvider: usage.Provider,
|
||||
ModelName: usage.Model,
|
||||
Channel: usage.Channel(),
|
||||
InputTokens: usage.PromptTokens,
|
||||
OutputTokens: usage.CompletionTokens,
|
||||
})
|
||||
|
||||
1416
debate/engine.go
1416
debate/engine.go
File diff suppressed because it is too large
Load Diff
@@ -6,11 +6,12 @@ services:
|
||||
dockerfile: ./docker/Dockerfile.backend
|
||||
container_name: nofx-trading
|
||||
restart: unless-stopped
|
||||
stop_grace_period: 30s # 允许应用有 30 秒时间优雅关闭
|
||||
stop_grace_period: 30s # Allow the app 30 seconds for graceful shutdown
|
||||
ports:
|
||||
- "${NOFX_BACKEND_PORT:-8080}:8080"
|
||||
- "6060:6060" # pprof profiling
|
||||
volumes:
|
||||
- ./.env:/app/.env
|
||||
- ./data:/app/data
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
env_file:
|
||||
@@ -49,4 +50,4 @@ services:
|
||||
|
||||
networks:
|
||||
nofx-network:
|
||||
driver: bridge
|
||||
driver: bridge
|
||||
|
||||
@@ -1,624 +0,0 @@
|
||||
# NOFX Backtest Module - Technical Documentation
|
||||
|
||||
**Language:** [English](BACKTEST_MODULE.md) | [中文](BACKTEST_MODULE.zh-CN.md)
|
||||
|
||||
## Overview
|
||||
|
||||
This document describes the complete technical implementation of the NOFX backtest module, including configuration, historical data loading, simulation engine, AI decision making, performance metrics calculation, and result storage.
|
||||
|
||||
---
|
||||
|
||||
## Complete Backtest Flow
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Backtest Execution Flow │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
|
||||
1. API Request: /backtest/start
|
||||
↓
|
||||
2. Manager.Start()
|
||||
├─ Validate config
|
||||
├─ Parse AI model
|
||||
├─ Create Runner instance
|
||||
└─ Start runner.Start() (goroutine)
|
||||
↓
|
||||
3. Runner.Start() → Runner.loop()
|
||||
└─ Iterate each decision time point:
|
||||
├─ DataFeed.BuildMarketData() [Build market data]
|
||||
├─ Check decision trigger [Every N bars]
|
||||
├─ buildDecisionContext() [Build decision context]
|
||||
├─ invokeAIWithRetry() [Call AI + cache]
|
||||
├─ executeDecision() [Execute trades]
|
||||
├─ checkLiquidation() [Check liquidation]
|
||||
├─ updateState() [Update state]
|
||||
├─ appendEquityPoint() [Record equity]
|
||||
├─ appendTradeEvent() [Record trades]
|
||||
├─ maybeCheckpoint() [Save checkpoint]
|
||||
└─ persistMetrics() [Persist metrics]
|
||||
↓
|
||||
4. Complete/Failed
|
||||
├─ Calculate final metrics
|
||||
├─ Persist all results
|
||||
└─ Release lock
|
||||
↓
|
||||
5. API Query: /backtest/metrics, /backtest/equity, /backtest/trades
|
||||
└─ Load and return results
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. Configuration
|
||||
|
||||
**Core File:** `backtest/config.go`
|
||||
|
||||
### 1.1 Config Parameters
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `RunID` | string | (required) | Unique backtest run ID |
|
||||
| `UserID` | string | "default" | User ID |
|
||||
| `Symbols` | []string | (required) | Trading symbols list |
|
||||
| `Timeframes` | []string | ["3m", "15m", "4h"] | K-line timeframes |
|
||||
| `DecisionTimeframe` | string | Symbols[0] | Primary decision timeframe |
|
||||
| `DecisionCadenceNBars` | int | 20 | Trigger decision every N bars |
|
||||
| `StartTS`, `EndTS` | int64 | (required) | Backtest time range (Unix timestamp) |
|
||||
| `InitialBalance` | float64 | 1000 | Initial balance (USD) |
|
||||
| `FeeBps` | float64 | 5 | Trading fee (basis points) |
|
||||
| `SlippageBps` | float64 | 2 | Slippage (basis points) |
|
||||
| `FillPolicy` | string | "next_open" | Fill policy |
|
||||
| `PromptVariant` | string | "baseline" | AI prompt variant |
|
||||
| `CacheAI` | bool | false | Cache AI decisions |
|
||||
| `Leverage` | LeverageConfig | BTC/ETH:5, Altcoin:5 | Leverage settings |
|
||||
|
||||
### 1.2 Fill Policy
|
||||
|
||||
```go
|
||||
// backtest/config.go:163-179
|
||||
switch fillPolicy {
|
||||
case "next_open": // Next bar open price
|
||||
case "bar_vwap": // Current bar VWAP
|
||||
case "mid": // Current bar (High+Low)/2
|
||||
default: // Mark Price
|
||||
}
|
||||
```
|
||||
|
||||
### 1.3 Config Example
|
||||
|
||||
```go
|
||||
cfg := backtest.BacktestConfig{
|
||||
RunID: "bt_20231215_150405",
|
||||
Symbols: []string{"BTCUSDT", "ETHUSDT"},
|
||||
Timeframes: []string{"3m", "15m", "4h"},
|
||||
DecisionTimeframe: "3m",
|
||||
DecisionCadenceNBars: 20,
|
||||
StartTS: 1702566000,
|
||||
EndTS: 1702652400,
|
||||
InitialBalance: 10000,
|
||||
FeeBps: 5,
|
||||
SlippageBps: 2,
|
||||
FillPolicy: "next_open",
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Data Loading
|
||||
|
||||
**Core File:** `backtest/datafeed.go`
|
||||
|
||||
### 2.1 Data Loading Flow
|
||||
|
||||
```
|
||||
1. NewDataFeed() - Initialize
|
||||
↓
|
||||
2. loadAll() - Load all historical data
|
||||
├─ Calculate buffer (200 bars before StartTS)
|
||||
├─ Call market.GetKlinesRange() to fetch data
|
||||
├─ Store in symbolSeries map
|
||||
└─ Build decision timeline from primary timeframe
|
||||
↓
|
||||
3. BuildMarketData() - Build market data snapshot
|
||||
├─ Slice K-line data to current timestamp
|
||||
├─ Calculate technical indicators (EMA, MACD, RSI, ATR)
|
||||
└─ Return market.Data structure
|
||||
```
|
||||
|
||||
### 2.2 Data Structure
|
||||
|
||||
```go
|
||||
// DataFeed core structure
|
||||
type DataFeed struct {
|
||||
decisionTimes []int64 // Decision time points list
|
||||
symbolSeries map[string]*symbolSeries // Data stored by symbol
|
||||
}
|
||||
|
||||
// Single symbol time series
|
||||
type symbolSeries struct {
|
||||
timeframes map[string]*timeframeSeries // Stored by timeframe
|
||||
}
|
||||
|
||||
// Single timeframe data
|
||||
type timeframeSeries struct {
|
||||
klines []market.Kline // K-line data
|
||||
closeTimes []int64 // Close time index
|
||||
}
|
||||
```
|
||||
|
||||
### 2.3 Key Code References
|
||||
|
||||
- Data fetching: `backtest/datafeed.go:48-93`
|
||||
- Timeline generation: `backtest/datafeed.go:96-115`
|
||||
- Market data assembly: `backtest/datafeed.go:141-171`
|
||||
|
||||
---
|
||||
|
||||
## 3. Simulation Engine
|
||||
|
||||
**Core File:** `backtest/runner.go`
|
||||
|
||||
### 3.1 Main Loop
|
||||
|
||||
```go
|
||||
// backtest/runner.go:232-264
|
||||
func (r *Runner) loop() {
|
||||
for _, ts := range r.feed.DecisionTimes() {
|
||||
if r.isPaused() {
|
||||
break
|
||||
}
|
||||
r.stepOnce(ts)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 Single Step Execution
|
||||
|
||||
```go
|
||||
// backtest/runner.go:266-471
|
||||
func (r *Runner) stepOnce(ts int64) {
|
||||
// 1. Get current bar timestamp
|
||||
// 2. Build market data
|
||||
// 3. Check decision trigger (every N bars)
|
||||
// 4. Execute decision cycle (if triggered)
|
||||
// 5. Check liquidation
|
||||
// 6. Update state and record
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 State Management
|
||||
|
||||
```go
|
||||
// backtest/types.go:31-47
|
||||
type BacktestState struct {
|
||||
BarIndex int // Current bar index
|
||||
Cash float64 // Available balance
|
||||
Equity float64 // Total equity
|
||||
UnrealizedPnL float64 // Unrealized PnL
|
||||
RealizedPnL float64 // Realized PnL
|
||||
MaxEquity float64 // Peak equity
|
||||
MinEquity float64 // Trough equity
|
||||
MaxDrawdownPct float64 // Max drawdown
|
||||
Positions map[string]*position // Positions
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. AI Decision Making
|
||||
|
||||
**Core File:** `backtest/runner.go`
|
||||
|
||||
### 4.1 Decision Context Building
|
||||
|
||||
```go
|
||||
// backtest/runner.go:473-532
|
||||
func (r *Runner) buildDecisionContext() *decision.Context {
|
||||
return &decision.Context{
|
||||
CurrentTime: "2023-12-15 10:30:00 UTC",
|
||||
RuntimeMinutes: elapsed,
|
||||
CallCount: cycleNumber,
|
||||
Account: {
|
||||
TotalEquity, AvailableBalance, TotalPnL, MarginUsedPct
|
||||
},
|
||||
Positions: []PositionInfo{...},
|
||||
CandidateCoins: []string{symbols...},
|
||||
MarketDataMap: map[symbol]*market.Data{...},
|
||||
MultiTFMarket: map[symbol]map[timeframe]*market.Data{...},
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 AI Invocation
|
||||
|
||||
```go
|
||||
// backtest/runner.go:544-563
|
||||
func (r *Runner) invokeAIWithRetry() (*decision.FullDecision, error) {
|
||||
// Max 3 retries
|
||||
// Exponential backoff: 500ms, 1000ms, 1500ms
|
||||
// Uses decision.GetFullDecisionWithStrategy() for unified prompt generation
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 AI Cache
|
||||
|
||||
```go
|
||||
// backtest/aicache.go:127-168
|
||||
// Cache key: SHA256(context payload)
|
||||
// Contains: variant, timestamp, account, positions, market data
|
||||
```
|
||||
|
||||
### 4.4 Supported AI Models
|
||||
|
||||
| Model | Client File |
|
||||
|-------|-------------|
|
||||
| DeepSeek | `mcp/deepseek_client.go` |
|
||||
| Qwen | `mcp/qwen_client.go` |
|
||||
| Claude | `mcp/claude_client.go` |
|
||||
| Gemini | `mcp/gemini_client.go` |
|
||||
| Grok | `mcp/grok_client.go` |
|
||||
| OpenAI | `mcp/openai_client.go` |
|
||||
| Kimi | `mcp/kimi_client.go` |
|
||||
|
||||
---
|
||||
|
||||
## 5. Performance Metrics
|
||||
|
||||
**Core File:** `backtest/metrics.go`
|
||||
|
||||
### 5.1 Metrics Calculation
|
||||
|
||||
| Metric | Formula | Code Location |
|
||||
|--------|---------|---------------|
|
||||
| **Total Return** | (Final Equity - Initial) / Initial × 100 | metrics.go:36-42 |
|
||||
| **Max Drawdown** | max((Peak - Current) / Peak × 100) | metrics.go:64-91 |
|
||||
| **Sharpe Ratio** | Avg Return / Return StdDev | metrics.go:94-138 |
|
||||
| **Win Rate** | Winning Trades / Total Trades × 100 | metrics.go:180-181 |
|
||||
| **Profit Factor** | Total Profit / Total Loss | metrics.go:189-193 |
|
||||
|
||||
### 5.2 Trade Statistics
|
||||
|
||||
```go
|
||||
// backtest/metrics.go:141-225
|
||||
type TradeMetrics struct {
|
||||
TotalTrades int
|
||||
WinningTrades int
|
||||
LosingTrades int
|
||||
AvgWin float64
|
||||
AvgLoss float64
|
||||
BestSymbol string
|
||||
WorstSymbol string
|
||||
SymbolStats map[string]*SymbolStat
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Equity Curve
|
||||
|
||||
**Core File:** `backtest/equity.go`
|
||||
|
||||
### 6.1 Equity Point Structure
|
||||
|
||||
```json
|
||||
{
|
||||
"ts": 1702566000000,
|
||||
"equity": 10500.50,
|
||||
"available": 8000.00,
|
||||
"pnl": 500.50,
|
||||
"pnl_pct": 5.005,
|
||||
"dd_pct": 2.34,
|
||||
"cycle": 42
|
||||
}
|
||||
```
|
||||
|
||||
### 6.2 Equity Update
|
||||
|
||||
```go
|
||||
// backtest/runner.go:829-872
|
||||
func (r *Runner) updateState() {
|
||||
// 1. Calculate total equity: cash + margin + unrealized PnL
|
||||
// 2. Track peak (MaxEquity)
|
||||
// 3. Track trough (MinEquity)
|
||||
// 4. Recalculate drawdown: (MaxEquity - Equity) / MaxEquity × 100
|
||||
}
|
||||
```
|
||||
|
||||
### 6.3 Data Resampling
|
||||
|
||||
```go
|
||||
// backtest/equity.go:10-50
|
||||
func ResampleEquity(points []EquityPoint, timeframe string) []EquityPoint {
|
||||
// Bucket by timeframe
|
||||
// Keep last point in each bucket
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Result Storage
|
||||
|
||||
**Core Files:** `backtest/storage.go`, `store/backtest.go`
|
||||
|
||||
### 7.1 File Storage Structure
|
||||
|
||||
```
|
||||
backtests/
|
||||
├── <run_id>/
|
||||
│ ├── run.json # Run metadata
|
||||
│ ├── checkpoint.json # Checkpoint (for resume)
|
||||
│ ├── equity.jsonl # Equity curve (line-delimited JSON)
|
||||
│ ├── trades.jsonl # Trade records (line-delimited JSON)
|
||||
│ ├── metrics.json # Performance metrics
|
||||
│ ├── progress.json # Progress info
|
||||
│ ├── ai_cache.json # AI decision cache
|
||||
│ └── decision_logs/ # Decision logs
|
||||
│ ├── 0.json
|
||||
│ ├── 1.json
|
||||
│ └── ...
|
||||
```
|
||||
|
||||
### 7.2 Database Schema
|
||||
|
||||
```sql
|
||||
-- Backtest run metadata
|
||||
CREATE TABLE backtest_runs (
|
||||
run_id TEXT PRIMARY KEY,
|
||||
user_id TEXT,
|
||||
config_json TEXT,
|
||||
state TEXT, -- pending, running, completed, failed
|
||||
processed_bars INTEGER,
|
||||
progress_pct REAL,
|
||||
equity_last REAL,
|
||||
max_drawdown_pct REAL,
|
||||
liquidated BOOLEAN,
|
||||
ai_provider TEXT,
|
||||
ai_model TEXT,
|
||||
created_at DATETIME,
|
||||
updated_at DATETIME
|
||||
);
|
||||
|
||||
-- Equity curve
|
||||
CREATE TABLE backtest_equity (
|
||||
id INTEGER PRIMARY KEY,
|
||||
run_id TEXT,
|
||||
ts INTEGER,
|
||||
equity REAL,
|
||||
available REAL,
|
||||
pnl REAL,
|
||||
pnl_pct REAL,
|
||||
dd_pct REAL,
|
||||
cycle INTEGER
|
||||
);
|
||||
|
||||
-- Trade records
|
||||
CREATE TABLE backtest_trades (
|
||||
id INTEGER PRIMARY KEY,
|
||||
run_id TEXT,
|
||||
ts INTEGER,
|
||||
symbol TEXT,
|
||||
action TEXT,
|
||||
side TEXT,
|
||||
qty REAL,
|
||||
price REAL,
|
||||
fee REAL,
|
||||
slippage REAL,
|
||||
realized_pnl REAL,
|
||||
leverage INTEGER,
|
||||
liquidation BOOLEAN
|
||||
);
|
||||
|
||||
-- Performance metrics
|
||||
CREATE TABLE backtest_metrics (
|
||||
run_id TEXT PRIMARY KEY,
|
||||
payload BLOB,
|
||||
updated_at DATETIME
|
||||
);
|
||||
|
||||
-- Checkpoints (pause/resume)
|
||||
CREATE TABLE backtest_checkpoints (
|
||||
run_id TEXT PRIMARY KEY,
|
||||
payload BLOB,
|
||||
updated_at DATETIME
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. API Endpoints
|
||||
|
||||
**Core File:** `api/backtest.go`
|
||||
|
||||
### 8.1 Endpoint List
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
|----------|--------|-------------|
|
||||
| `/backtest/start` | POST | Start backtest |
|
||||
| `/backtest/pause` | POST | Pause backtest |
|
||||
| `/backtest/resume` | POST | Resume backtest |
|
||||
| `/backtest/stop` | POST | Stop backtest |
|
||||
| `/backtest/status` | GET | Get status |
|
||||
| `/backtest/runs` | GET | List all backtests |
|
||||
| `/backtest/equity` | GET | Get equity curve |
|
||||
| `/backtest/trades` | GET | Get trade records |
|
||||
| `/backtest/metrics` | GET | Get performance metrics |
|
||||
| `/backtest/trace` | GET | Get decision logs |
|
||||
| `/backtest/export` | GET | Export ZIP |
|
||||
| `/backtest/delete` | POST | Delete backtest |
|
||||
|
||||
### 8.2 Request Examples
|
||||
|
||||
```bash
|
||||
# Start backtest
|
||||
POST /backtest/start
|
||||
{
|
||||
"config": {
|
||||
"run_id": "bt_20231215",
|
||||
"symbols": ["BTCUSDT", "ETHUSDT"],
|
||||
"timeframes": ["3m", "15m", "4h"],
|
||||
"start_ts": 1702566000,
|
||||
"end_ts": 1702652400,
|
||||
"initial_balance": 10000,
|
||||
"ai_model_id": "model_001"
|
||||
}
|
||||
}
|
||||
|
||||
# Get equity curve
|
||||
GET /backtest/equity?run_id=bt_20231215&tf=1h&limit=1000
|
||||
|
||||
# Get metrics
|
||||
GET /backtest/metrics?run_id=bt_20231215
|
||||
```
|
||||
|
||||
### 8.3 Response Examples
|
||||
|
||||
```json
|
||||
// Status response
|
||||
{
|
||||
"run_id": "bt_20231215",
|
||||
"state": "running",
|
||||
"progress_pct": 45.5,
|
||||
"processed_bars": 1234,
|
||||
"equity": 10234.50,
|
||||
"unrealized_pnl": 234.50
|
||||
}
|
||||
|
||||
// Metrics response
|
||||
{
|
||||
"total_return_pct": 12.34,
|
||||
"max_drawdown_pct": 5.67,
|
||||
"sharpe_ratio": 1.89,
|
||||
"profit_factor": 2.34,
|
||||
"win_rate": 65.5,
|
||||
"trades": 123
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Account & Position Management
|
||||
|
||||
**Core File:** `backtest/account.go`
|
||||
|
||||
### 9.1 Position Structure
|
||||
|
||||
```go
|
||||
type position struct {
|
||||
Symbol string
|
||||
Side string // "long" or "short"
|
||||
Quantity float64
|
||||
EntryPrice float64
|
||||
Leverage int
|
||||
Margin float64 // Margin
|
||||
Notional float64 // Notional value
|
||||
LiquidationPrice float64 // Liquidation price
|
||||
OpenTime int64
|
||||
}
|
||||
```
|
||||
|
||||
### 9.2 Open Position Logic
|
||||
|
||||
```go
|
||||
// backtest/account.go:61-104
|
||||
func (a *BacktestAccount) Open(symbol, side string, qty, price float64, leverage int) {
|
||||
// 1. Apply slippage
|
||||
// 2. Calculate notional value (qty × price)
|
||||
// 3. Calculate margin (notional / leverage)
|
||||
// 4. Deduct margin + fees
|
||||
// 5. Create/add to position
|
||||
// 6. Calculate liquidation price
|
||||
}
|
||||
```
|
||||
|
||||
### 9.3 Close Position Logic
|
||||
|
||||
```go
|
||||
// backtest/account.go:106-140
|
||||
func (a *BacktestAccount) Close(symbol, side string, qty, price float64) {
|
||||
// 1. Verify position exists
|
||||
// 2. Apply slippage (reverse direction)
|
||||
// 3. Calculate realized PnL
|
||||
// long: (exit - entry) × qty
|
||||
// short: (entry - exit) × qty
|
||||
// 4. Return margin + PnL - fees
|
||||
// 5. Update/delete position
|
||||
}
|
||||
```
|
||||
|
||||
### 9.4 Liquidation Price Calculation
|
||||
|
||||
```go
|
||||
// backtest/account.go:177-186
|
||||
func computeLiquidation(entry float64, leverage int, side string) float64 {
|
||||
if side == "long" {
|
||||
return entry * (1 - 1.0/float64(leverage)) // Long: liquidate on drop
|
||||
}
|
||||
return entry * (1 + 1.0/float64(leverage)) // Short: liquidate on rise
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Checkpoint & Resume
|
||||
|
||||
**Core File:** `backtest/runner.go`
|
||||
|
||||
### 10.1 Checkpoint Structure
|
||||
|
||||
```json
|
||||
{
|
||||
"bar_index": 1234,
|
||||
"bar_ts": 1702609200000,
|
||||
"cash": 8000.00,
|
||||
"equity": 10234.50,
|
||||
"max_equity": 10500.00,
|
||||
"max_drawdown_pct": 5.67,
|
||||
"positions": [...],
|
||||
"decision_cycle": 62,
|
||||
"liquidated": false
|
||||
}
|
||||
```
|
||||
|
||||
### 10.2 Checkpoint Trigger
|
||||
|
||||
```go
|
||||
// backtest/runner.go:874-898
|
||||
func (r *Runner) maybeCheckpoint() {
|
||||
// Save every N bars
|
||||
// Or save every N seconds
|
||||
}
|
||||
```
|
||||
|
||||
### 10.3 Resume Flow
|
||||
|
||||
```go
|
||||
func (r *Runner) RestoreFromCheckpoint() {
|
||||
// 1. Load checkpoint
|
||||
// 2. Restore account state
|
||||
// 3. Restore bar index (continue from next bar)
|
||||
// 4. Restore equity curve, trade records
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Core File Index
|
||||
|
||||
| Module | File | Key Methods |
|
||||
|--------|------|-------------|
|
||||
| **Config** | `backtest/config.go` | `BacktestConfig`, `Validate()` |
|
||||
| **Data Loading** | `backtest/datafeed.go` | `NewDataFeed()`, `loadAll()`, `BuildMarketData()` |
|
||||
| **Sim Engine** | `backtest/runner.go` | `Start()`, `loop()`, `stepOnce()` |
|
||||
| **Decision** | `backtest/runner.go` | `buildDecisionContext()`, `invokeAIWithRetry()` |
|
||||
| **Execution** | `backtest/runner.go` | `executeDecision()` |
|
||||
| **Account** | `backtest/account.go` | `Open()`, `Close()`, `TotalEquity()` |
|
||||
| **Metrics** | `backtest/metrics.go` | `CalculateMetrics()` |
|
||||
| **Equity** | `backtest/equity.go` | `ResampleEquity()`, `LimitEquityPoints()` |
|
||||
| **Storage** | `backtest/storage.go` | `SaveCheckpoint()`, `appendEquityPoint()` |
|
||||
| **Database** | `store/backtest.go` | Schema and CRUD operations |
|
||||
| **API** | `api/backtest.go` | HTTP handlers |
|
||||
| **AI Cache** | `backtest/aicache.go` | `Get()`, `Put()`, `save()` |
|
||||
|
||||
---
|
||||
|
||||
**Document Version:** 1.0.0
|
||||
**Last Updated:** 2025-01-15
|
||||
@@ -1,624 +0,0 @@
|
||||
# NOFX 回测模块技术文档
|
||||
|
||||
**语言:** [English](BACKTEST_MODULE.md) | [中文](BACKTEST_MODULE.zh-CN.md)
|
||||
|
||||
## 概述
|
||||
|
||||
本文档详细描述 NOFX 回测模块的完整技术实现,包括配置、历史数据加载、模拟引擎、AI 决策、性能指标计算和结果存储。
|
||||
|
||||
---
|
||||
|
||||
## 完整回测流程图
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 回测执行流程 │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
|
||||
1. API 请求: /backtest/start
|
||||
↓
|
||||
2. Manager.Start()
|
||||
├─ 验证配置
|
||||
├─ 解析 AI 模型
|
||||
├─ 创建 Runner 实例
|
||||
└─ 启动 runner.Start() (goroutine)
|
||||
↓
|
||||
3. Runner.Start() → Runner.loop()
|
||||
└─ 遍历每个决策时间点:
|
||||
├─ DataFeed.BuildMarketData() [构建市场数据]
|
||||
├─ 检查决策触发条件 [每 N 根 K 线]
|
||||
├─ buildDecisionContext() [构建决策上下文]
|
||||
├─ invokeAIWithRetry() [调用 AI + 缓存]
|
||||
├─ executeDecision() [执行交易]
|
||||
├─ checkLiquidation() [检查爆仓]
|
||||
├─ updateState() [更新状态]
|
||||
├─ appendEquityPoint() [记录权益]
|
||||
├─ appendTradeEvent() [记录交易]
|
||||
├─ maybeCheckpoint() [保存检查点]
|
||||
└─ persistMetrics() [持久化指标]
|
||||
↓
|
||||
4. 完成/失败
|
||||
├─ 计算最终指标
|
||||
├─ 持久化所有结果
|
||||
└─ 释放锁
|
||||
↓
|
||||
5. API 查询: /backtest/metrics, /backtest/equity, /backtest/trades
|
||||
└─ 加载并返回结果
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. 回测配置 (Configuration)
|
||||
|
||||
**核心文件:** `backtest/config.go`
|
||||
|
||||
### 1.1 配置参数
|
||||
|
||||
| 参数 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| `RunID` | string | (必填) | 回测运行唯一标识 |
|
||||
| `UserID` | string | "default" | 用户 ID |
|
||||
| `Symbols` | []string | (必填) | 交易币种列表 |
|
||||
| `Timeframes` | []string | ["3m", "15m", "4h"] | K 线周期 |
|
||||
| `DecisionTimeframe` | string | Symbols[0] | 主决策周期 |
|
||||
| `DecisionCadenceNBars` | int | 20 | 每 N 根 K 线触发一次决策 |
|
||||
| `StartTS`, `EndTS` | int64 | (必填) | 回测时间范围 (Unix 时间戳) |
|
||||
| `InitialBalance` | float64 | 1000 | 初始资金 (USD) |
|
||||
| `FeeBps` | float64 | 5 | 手续费 (基点) |
|
||||
| `SlippageBps` | float64 | 2 | 滑点 (基点) |
|
||||
| `FillPolicy` | string | "next_open" | 成交策略 |
|
||||
| `PromptVariant` | string | "baseline" | AI 提示词变体 |
|
||||
| `CacheAI` | bool | false | 是否缓存 AI 决策 |
|
||||
| `Leverage` | LeverageConfig | BTC/ETH:5, Altcoin:5 | 杠杆设置 |
|
||||
|
||||
### 1.2 成交策略 (Fill Policy)
|
||||
|
||||
```go
|
||||
// backtest/config.go:163-179
|
||||
switch fillPolicy {
|
||||
case "next_open": // 下一根 K 线开盘价
|
||||
case "bar_vwap": // 当前 K 线 VWAP
|
||||
case "mid": // 当前 K 线 (High+Low)/2
|
||||
default: // Mark Price
|
||||
}
|
||||
```
|
||||
|
||||
### 1.3 配置示例
|
||||
|
||||
```go
|
||||
cfg := backtest.BacktestConfig{
|
||||
RunID: "bt_20231215_150405",
|
||||
Symbols: []string{"BTCUSDT", "ETHUSDT"},
|
||||
Timeframes: []string{"3m", "15m", "4h"},
|
||||
DecisionTimeframe: "3m",
|
||||
DecisionCadenceNBars: 20,
|
||||
StartTS: 1702566000,
|
||||
EndTS: 1702652400,
|
||||
InitialBalance: 10000,
|
||||
FeeBps: 5,
|
||||
SlippageBps: 2,
|
||||
FillPolicy: "next_open",
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 历史数据加载 (Data Loading)
|
||||
|
||||
**核心文件:** `backtest/datafeed.go`
|
||||
|
||||
### 2.1 数据加载流程
|
||||
|
||||
```
|
||||
1. NewDataFeed() - 初始化
|
||||
↓
|
||||
2. loadAll() - 加载所有历史数据
|
||||
├─ 计算缓冲区 (StartTS 前 200 根 K 线)
|
||||
├─ 调用 market.GetKlinesRange() 获取数据
|
||||
├─ 存储到 symbolSeries map
|
||||
└─ 从主周期构建决策时间线
|
||||
↓
|
||||
3. BuildMarketData() - 构建市场数据快照
|
||||
├─ 切片 K 线数据到当前时间戳
|
||||
├─ 计算技术指标 (EMA, MACD, RSI, ATR)
|
||||
└─ 返回 market.Data 结构
|
||||
```
|
||||
|
||||
### 2.2 数据结构
|
||||
|
||||
```go
|
||||
// DataFeed 核心结构
|
||||
type DataFeed struct {
|
||||
decisionTimes []int64 // 决策时间点列表
|
||||
symbolSeries map[string]*symbolSeries // 按币种存储的数据
|
||||
}
|
||||
|
||||
// 单币种时间序列
|
||||
type symbolSeries struct {
|
||||
timeframes map[string]*timeframeSeries // 按周期存储
|
||||
}
|
||||
|
||||
// 单周期数据
|
||||
type timeframeSeries struct {
|
||||
klines []market.Kline // K 线数据
|
||||
closeTimes []int64 // 收盘时间索引
|
||||
}
|
||||
```
|
||||
|
||||
### 2.3 关键代码引用
|
||||
|
||||
- 数据获取: `backtest/datafeed.go:48-93`
|
||||
- 时间线生成: `backtest/datafeed.go:96-115`
|
||||
- 市场数据组装: `backtest/datafeed.go:141-171`
|
||||
|
||||
---
|
||||
|
||||
## 3. 模拟引擎 (Simulation Engine)
|
||||
|
||||
**核心文件:** `backtest/runner.go`
|
||||
|
||||
### 3.1 主循环
|
||||
|
||||
```go
|
||||
// backtest/runner.go:232-264
|
||||
func (r *Runner) loop() {
|
||||
for _, ts := range r.feed.DecisionTimes() {
|
||||
if r.isPaused() {
|
||||
break
|
||||
}
|
||||
r.stepOnce(ts)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 单步执行
|
||||
|
||||
```go
|
||||
// backtest/runner.go:266-471
|
||||
func (r *Runner) stepOnce(ts int64) {
|
||||
// 1. 获取当前 K 线时间戳
|
||||
// 2. 构建市场数据
|
||||
// 3. 检查决策触发条件 (每 N 根 K 线)
|
||||
// 4. 执行决策周期 (如果触发)
|
||||
// 5. 检查爆仓
|
||||
// 6. 更新状态并记录
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 状态管理
|
||||
|
||||
```go
|
||||
// backtest/types.go:31-47
|
||||
type BacktestState struct {
|
||||
BarIndex int // 当前 K 线索引
|
||||
Cash float64 // 可用余额
|
||||
Equity float64 // 总权益
|
||||
UnrealizedPnL float64 // 未实现盈亏
|
||||
RealizedPnL float64 // 已实现盈亏
|
||||
MaxEquity float64 // 最高权益
|
||||
MinEquity float64 // 最低权益
|
||||
MaxDrawdownPct float64 // 最大回撤
|
||||
Positions map[string]*position // 持仓
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. AI 决策 (AI Decision Making)
|
||||
|
||||
**核心文件:** `backtest/runner.go`
|
||||
|
||||
### 4.1 决策上下文构建
|
||||
|
||||
```go
|
||||
// backtest/runner.go:473-532
|
||||
func (r *Runner) buildDecisionContext() *decision.Context {
|
||||
return &decision.Context{
|
||||
CurrentTime: "2023-12-15 10:30:00 UTC",
|
||||
RuntimeMinutes: elapsed,
|
||||
CallCount: cycleNumber,
|
||||
Account: {
|
||||
TotalEquity, AvailableBalance, TotalPnL, MarginUsedPct
|
||||
},
|
||||
Positions: []PositionInfo{...},
|
||||
CandidateCoins: []string{symbols...},
|
||||
MarketDataMap: map[symbol]*market.Data{...},
|
||||
MultiTFMarket: map[symbol]map[timeframe]*market.Data{...},
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 AI 调用
|
||||
|
||||
```go
|
||||
// backtest/runner.go:544-563
|
||||
func (r *Runner) invokeAIWithRetry() (*decision.FullDecision, error) {
|
||||
// 最多重试 3 次
|
||||
// 指数退避: 500ms, 1000ms, 1500ms
|
||||
// 使用 decision.GetFullDecisionWithStrategy() 统一提示词生成
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 AI 缓存
|
||||
|
||||
```go
|
||||
// backtest/aicache.go:127-168
|
||||
// 缓存键: SHA256(context payload)
|
||||
// 包含: variant, timestamp, account, positions, market data
|
||||
```
|
||||
|
||||
### 4.4 支持的 AI 模型
|
||||
|
||||
| 模型 | 客户端文件 |
|
||||
|------|-----------|
|
||||
| DeepSeek | `mcp/deepseek_client.go` |
|
||||
| Qwen | `mcp/qwen_client.go` |
|
||||
| Claude | `mcp/claude_client.go` |
|
||||
| Gemini | `mcp/gemini_client.go` |
|
||||
| Grok | `mcp/grok_client.go` |
|
||||
| OpenAI | `mcp/openai_client.go` |
|
||||
| Kimi | `mcp/kimi_client.go` |
|
||||
|
||||
---
|
||||
|
||||
## 5. 性能指标 (Performance Metrics)
|
||||
|
||||
**核心文件:** `backtest/metrics.go`
|
||||
|
||||
### 5.1 指标计算
|
||||
|
||||
| 指标 | 公式 | 代码位置 |
|
||||
|------|------|----------|
|
||||
| **总收益率** | (最终权益 - 初始资金) / 初始资金 × 100 | metrics.go:36-42 |
|
||||
| **最大回撤** | max((峰值 - 当前) / 峰值 × 100) | metrics.go:64-91 |
|
||||
| **夏普比率** | 平均收益 / 收益标准差 | metrics.go:94-138 |
|
||||
| **胜率** | 盈利交易数 / 总交易数 × 100 | metrics.go:180-181 |
|
||||
| **盈亏比** | 总盈利 / 总亏损 | metrics.go:189-193 |
|
||||
|
||||
### 5.2 交易统计
|
||||
|
||||
```go
|
||||
// backtest/metrics.go:141-225
|
||||
type TradeMetrics struct {
|
||||
TotalTrades int
|
||||
WinningTrades int
|
||||
LosingTrades int
|
||||
AvgWin float64
|
||||
AvgLoss float64
|
||||
BestSymbol string
|
||||
WorstSymbol string
|
||||
SymbolStats map[string]*SymbolStat
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 权益曲线 (Equity Curve)
|
||||
|
||||
**核心文件:** `backtest/equity.go`
|
||||
|
||||
### 6.1 权益点结构
|
||||
|
||||
```json
|
||||
{
|
||||
"ts": 1702566000000,
|
||||
"equity": 10500.50,
|
||||
"available": 8000.00,
|
||||
"pnl": 500.50,
|
||||
"pnl_pct": 5.005,
|
||||
"dd_pct": 2.34,
|
||||
"cycle": 42
|
||||
}
|
||||
```
|
||||
|
||||
### 6.2 权益更新
|
||||
|
||||
```go
|
||||
// backtest/runner.go:829-872
|
||||
func (r *Runner) updateState() {
|
||||
// 1. 计算总权益: cash + margin + 未实现盈亏
|
||||
// 2. 追踪峰值 (MaxEquity)
|
||||
// 3. 追踪谷值 (MinEquity)
|
||||
// 4. 重新计算回撤: (MaxEquity - Equity) / MaxEquity × 100
|
||||
}
|
||||
```
|
||||
|
||||
### 6.3 数据重采样
|
||||
|
||||
```go
|
||||
// backtest/equity.go:10-50
|
||||
func ResampleEquity(points []EquityPoint, timeframe string) []EquityPoint {
|
||||
// 按时间周期分桶
|
||||
// 保留每个桶的最后一个点
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 结果存储 (Result Storage)
|
||||
|
||||
**核心文件:** `backtest/storage.go`, `store/backtest.go`
|
||||
|
||||
### 7.1 文件存储结构
|
||||
|
||||
```
|
||||
backtests/
|
||||
├── <run_id>/
|
||||
│ ├── run.json # 运行元数据
|
||||
│ ├── checkpoint.json # 检查点 (用于恢复)
|
||||
│ ├── equity.jsonl # 权益曲线 (逐行 JSON)
|
||||
│ ├── trades.jsonl # 交易记录 (逐行 JSON)
|
||||
│ ├── metrics.json # 性能指标
|
||||
│ ├── progress.json # 进度信息
|
||||
│ ├── ai_cache.json # AI 决策缓存
|
||||
│ └── decision_logs/ # 决策日志
|
||||
│ ├── 0.json
|
||||
│ ├── 1.json
|
||||
│ └── ...
|
||||
```
|
||||
|
||||
### 7.2 数据库表结构
|
||||
|
||||
```sql
|
||||
-- 回测运行元数据
|
||||
CREATE TABLE backtest_runs (
|
||||
run_id TEXT PRIMARY KEY,
|
||||
user_id TEXT,
|
||||
config_json TEXT,
|
||||
state TEXT, -- pending, running, completed, failed
|
||||
processed_bars INTEGER,
|
||||
progress_pct REAL,
|
||||
equity_last REAL,
|
||||
max_drawdown_pct REAL,
|
||||
liquidated BOOLEAN,
|
||||
ai_provider TEXT,
|
||||
ai_model TEXT,
|
||||
created_at DATETIME,
|
||||
updated_at DATETIME
|
||||
);
|
||||
|
||||
-- 权益曲线
|
||||
CREATE TABLE backtest_equity (
|
||||
id INTEGER PRIMARY KEY,
|
||||
run_id TEXT,
|
||||
ts INTEGER,
|
||||
equity REAL,
|
||||
available REAL,
|
||||
pnl REAL,
|
||||
pnl_pct REAL,
|
||||
dd_pct REAL,
|
||||
cycle INTEGER
|
||||
);
|
||||
|
||||
-- 交易记录
|
||||
CREATE TABLE backtest_trades (
|
||||
id INTEGER PRIMARY KEY,
|
||||
run_id TEXT,
|
||||
ts INTEGER,
|
||||
symbol TEXT,
|
||||
action TEXT,
|
||||
side TEXT,
|
||||
qty REAL,
|
||||
price REAL,
|
||||
fee REAL,
|
||||
slippage REAL,
|
||||
realized_pnl REAL,
|
||||
leverage INTEGER,
|
||||
liquidation BOOLEAN
|
||||
);
|
||||
|
||||
-- 性能指标
|
||||
CREATE TABLE backtest_metrics (
|
||||
run_id TEXT PRIMARY KEY,
|
||||
payload BLOB,
|
||||
updated_at DATETIME
|
||||
);
|
||||
|
||||
-- 检查点 (暂停/恢复)
|
||||
CREATE TABLE backtest_checkpoints (
|
||||
run_id TEXT PRIMARY KEY,
|
||||
payload BLOB,
|
||||
updated_at DATETIME
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. API 接口
|
||||
|
||||
**核心文件:** `api/backtest.go`
|
||||
|
||||
### 8.1 接口列表
|
||||
|
||||
| 接口 | 方法 | 说明 |
|
||||
|------|------|------|
|
||||
| `/backtest/start` | POST | 开始回测 |
|
||||
| `/backtest/pause` | POST | 暂停回测 |
|
||||
| `/backtest/resume` | POST | 恢复回测 |
|
||||
| `/backtest/stop` | POST | 停止回测 |
|
||||
| `/backtest/status` | GET | 获取状态 |
|
||||
| `/backtest/runs` | GET | 列出所有回测 |
|
||||
| `/backtest/equity` | GET | 获取权益曲线 |
|
||||
| `/backtest/trades` | GET | 获取交易记录 |
|
||||
| `/backtest/metrics` | GET | 获取性能指标 |
|
||||
| `/backtest/trace` | GET | 获取决策日志 |
|
||||
| `/backtest/export` | GET | 导出 ZIP |
|
||||
| `/backtest/delete` | POST | 删除回测 |
|
||||
|
||||
### 8.2 请求示例
|
||||
|
||||
```bash
|
||||
# 开始回测
|
||||
POST /backtest/start
|
||||
{
|
||||
"config": {
|
||||
"run_id": "bt_20231215",
|
||||
"symbols": ["BTCUSDT", "ETHUSDT"],
|
||||
"timeframes": ["3m", "15m", "4h"],
|
||||
"start_ts": 1702566000,
|
||||
"end_ts": 1702652400,
|
||||
"initial_balance": 10000,
|
||||
"ai_model_id": "model_001"
|
||||
}
|
||||
}
|
||||
|
||||
# 获取权益曲线
|
||||
GET /backtest/equity?run_id=bt_20231215&tf=1h&limit=1000
|
||||
|
||||
# 获取指标
|
||||
GET /backtest/metrics?run_id=bt_20231215
|
||||
```
|
||||
|
||||
### 8.3 响应示例
|
||||
|
||||
```json
|
||||
// 状态响应
|
||||
{
|
||||
"run_id": "bt_20231215",
|
||||
"state": "running",
|
||||
"progress_pct": 45.5,
|
||||
"processed_bars": 1234,
|
||||
"equity": 10234.50,
|
||||
"unrealized_pnl": 234.50
|
||||
}
|
||||
|
||||
// 指标响应
|
||||
{
|
||||
"total_return_pct": 12.34,
|
||||
"max_drawdown_pct": 5.67,
|
||||
"sharpe_ratio": 1.89,
|
||||
"profit_factor": 2.34,
|
||||
"win_rate": 65.5,
|
||||
"trades": 123
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. 账户与持仓管理
|
||||
|
||||
**核心文件:** `backtest/account.go`
|
||||
|
||||
### 9.1 持仓结构
|
||||
|
||||
```go
|
||||
type position struct {
|
||||
Symbol string
|
||||
Side string // "long" 或 "short"
|
||||
Quantity float64
|
||||
EntryPrice float64
|
||||
Leverage int
|
||||
Margin float64 // 保证金
|
||||
Notional float64 // 名义价值
|
||||
LiquidationPrice float64 // 爆仓价格
|
||||
OpenTime int64
|
||||
}
|
||||
```
|
||||
|
||||
### 9.2 开仓逻辑
|
||||
|
||||
```go
|
||||
// backtest/account.go:61-104
|
||||
func (a *BacktestAccount) Open(symbol, side string, qty, price float64, leverage int) {
|
||||
// 1. 应用滑点
|
||||
// 2. 计算名义价值 (qty × price)
|
||||
// 3. 计算保证金 (notional / leverage)
|
||||
// 4. 扣除保证金 + 手续费
|
||||
// 5. 创建/加仓
|
||||
// 6. 计算爆仓价格
|
||||
}
|
||||
```
|
||||
|
||||
### 9.3 平仓逻辑
|
||||
|
||||
```go
|
||||
// backtest/account.go:106-140
|
||||
func (a *BacktestAccount) Close(symbol, side string, qty, price float64) {
|
||||
// 1. 验证持仓存在
|
||||
// 2. 应用滑点 (反向)
|
||||
// 3. 计算已实现盈亏
|
||||
// long: (exit - entry) × qty
|
||||
// short: (entry - exit) × qty
|
||||
// 4. 返还保证金 + 盈亏 - 手续费
|
||||
// 5. 更新/删除持仓
|
||||
}
|
||||
```
|
||||
|
||||
### 9.4 爆仓价格计算
|
||||
|
||||
```go
|
||||
// backtest/account.go:177-186
|
||||
func computeLiquidation(entry float64, leverage int, side string) float64 {
|
||||
if side == "long" {
|
||||
return entry * (1 - 1.0/float64(leverage)) // 做多: 下跌爆仓
|
||||
}
|
||||
return entry * (1 + 1.0/float64(leverage)) // 做空: 上涨爆仓
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. 检查点与恢复
|
||||
|
||||
**核心文件:** `backtest/runner.go`
|
||||
|
||||
### 10.1 检查点结构
|
||||
|
||||
```json
|
||||
{
|
||||
"bar_index": 1234,
|
||||
"bar_ts": 1702609200000,
|
||||
"cash": 8000.00,
|
||||
"equity": 10234.50,
|
||||
"max_equity": 10500.00,
|
||||
"max_drawdown_pct": 5.67,
|
||||
"positions": [...],
|
||||
"decision_cycle": 62,
|
||||
"liquidated": false
|
||||
}
|
||||
```
|
||||
|
||||
### 10.2 检查点触发
|
||||
|
||||
```go
|
||||
// backtest/runner.go:874-898
|
||||
func (r *Runner) maybeCheckpoint() {
|
||||
// 每 N 根 K 线保存
|
||||
// 或每 N 秒保存
|
||||
}
|
||||
```
|
||||
|
||||
### 10.3 恢复流程
|
||||
|
||||
```go
|
||||
func (r *Runner) RestoreFromCheckpoint() {
|
||||
// 1. 加载检查点
|
||||
// 2. 恢复账户状态
|
||||
// 3. 恢复 K 线索引 (从下一根继续)
|
||||
// 4. 恢复权益曲线、交易记录
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 核心文件索引
|
||||
|
||||
| 模块 | 文件 | 关键方法 |
|
||||
|------|------|----------|
|
||||
| **配置** | `backtest/config.go` | `BacktestConfig`, `Validate()` |
|
||||
| **数据加载** | `backtest/datafeed.go` | `NewDataFeed()`, `loadAll()`, `BuildMarketData()` |
|
||||
| **模拟引擎** | `backtest/runner.go` | `Start()`, `loop()`, `stepOnce()` |
|
||||
| **决策** | `backtest/runner.go` | `buildDecisionContext()`, `invokeAIWithRetry()` |
|
||||
| **执行** | `backtest/runner.go` | `executeDecision()` |
|
||||
| **账户** | `backtest/account.go` | `Open()`, `Close()`, `TotalEquity()` |
|
||||
| **指标** | `backtest/metrics.go` | `CalculateMetrics()` |
|
||||
| **权益** | `backtest/equity.go` | `ResampleEquity()`, `LimitEquityPoints()` |
|
||||
| **存储** | `backtest/storage.go` | `SaveCheckpoint()`, `appendEquityPoint()` |
|
||||
| **数据库** | `store/backtest.go` | 表结构和 CRUD 操作 |
|
||||
| **API** | `api/backtest.go` | HTTP 处理器 |
|
||||
| **AI 缓存** | `backtest/aicache.go` | `Get()`, `Put()`, `save()` |
|
||||
|
||||
---
|
||||
|
||||
**文档版本:** 1.0.0
|
||||
**最后更新:** 2025-01-15
|
||||
@@ -1,909 +0,0 @@
|
||||
# Debate Arena Module - Technical Documentation
|
||||
|
||||
**Language:** [English](DEBATE_MODULE.md) | [中文](DEBATE_MODULE.zh-CN.md)
|
||||
|
||||
## Overview
|
||||
|
||||
The Debate Arena is a collaborative AI decision-making system where multiple AI models with different personalities debate market conditions and reach consensus on trading decisions. The system supports multi-round debates, real-time streaming, voting mechanisms, and automatic trade execution.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Architecture Overview](#1-architecture-overview)
|
||||
2. [Backend Components](#2-backend-components)
|
||||
3. [Debate Execution Flow](#3-debate-execution-flow)
|
||||
4. [Personality System](#4-personality-system)
|
||||
5. [Consensus Algorithm](#5-consensus-algorithm)
|
||||
6. [Auto-Execution](#6-auto-execution)
|
||||
7. [API Reference](#7-api-reference)
|
||||
8. [Real-Time Updates (SSE)](#8-real-time-updates-sse)
|
||||
9. [Database Schema](#9-database-schema)
|
||||
10. [Frontend Components](#10-frontend-components)
|
||||
11. [Integration Points](#11-integration-points)
|
||||
12. [Error Handling](#12-error-handling)
|
||||
|
||||
---
|
||||
|
||||
## 1. Architecture Overview
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ Debate Arena System │
|
||||
├─────────────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
|
||||
│ │ Bull AI │ │ Bear AI │ │ Analyst AI │ │ Risk Mgr AI │ │
|
||||
│ │ 🐂 │ │ 🐻 │ │ 📊 │ │ 🛡️ │ │
|
||||
│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │
|
||||
│ │ │ │ │ │
|
||||
│ └──────────────────┴──────────────────┴──────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌─────────▼─────────┐ │
|
||||
│ │ Debate Engine │ │
|
||||
│ │ (debate/engine) │ │
|
||||
│ └─────────┬─────────┘ │
|
||||
│ │ │
|
||||
│ ┌──────────────────────────┼──────────────────────────┐ │
|
||||
│ │ │ │ │
|
||||
│ ┌──────▼──────┐ ┌─────────▼─────────┐ ┌────────▼────────┐ │
|
||||
│ │ Market Data │ │ Voting System │ │ Auto-Executor │ │
|
||||
│ │ Assembly │ │ & Consensus │ │ (optional) │ │
|
||||
│ └─────────────┘ └───────────────────┘ └─────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### File Structure
|
||||
|
||||
```
|
||||
├── debate/
|
||||
│ └── engine.go # Core debate engine logic
|
||||
├── api/
|
||||
│ └── debate.go # HTTP handlers and SSE streaming
|
||||
├── store/
|
||||
│ └── debate.go # Database operations and schema
|
||||
└── web/src/pages/
|
||||
└── DebateArenaPage.tsx # Frontend UI
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Backend Components
|
||||
|
||||
### 2.1 Core Files
|
||||
|
||||
| File | Purpose | Key Functions |
|
||||
|------|---------|---------------|
|
||||
| `debate/engine.go` | Core debate logic | `StartDebate()`, `runDebate()`, `collectVotes()`, `determineConsensus()` |
|
||||
| `api/debate.go` | HTTP handlers | `HandleCreateDebate()`, `HandleStartDebate()`, `HandleDebateStream()` |
|
||||
| `store/debate.go` | Database ops | `CreateSession()`, `AddMessage()`, `AddVote()`, `GetSessionWithDetails()` |
|
||||
|
||||
### 2.2 Debate Engine Structure
|
||||
|
||||
```go
|
||||
// debate/engine.go
|
||||
|
||||
type DebateEngine struct {
|
||||
store *store.DebateStore
|
||||
aiClients map[string]ai.Client
|
||||
strategyEngine *strategy.Engine
|
||||
subscribers map[string]map[chan []byte]bool
|
||||
}
|
||||
|
||||
// Event callbacks for real-time updates
|
||||
var OnRoundStart func(sessionID string, round int)
|
||||
var OnMessage func(sessionID string, msg *DebateMessage)
|
||||
var OnVote func(sessionID string, vote *DebateVote)
|
||||
var OnConsensus func(sessionID string, decision *DebateDecision)
|
||||
var OnError func(sessionID string, err error)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Debate Execution Flow
|
||||
|
||||
### 3.1 Session Creation
|
||||
|
||||
```
|
||||
POST /api/debates
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 1. Validate user authentication │
|
||||
│ 2. Parse CreateDebateRequest: │
|
||||
│ - name, strategy_id, symbol, max_rounds, participants │
|
||||
│ - interval_minutes, prompt_variant, auto_execute │
|
||||
│ 3. Validate strategy ownership │
|
||||
│ 4. Auto-select symbol if not provided: │
|
||||
│ - Static coins → Use first coin from strategy │
|
||||
│ - CoinPool → Fetch from AI500 API │
|
||||
│ - OI Top → Fetch from OI ranking API │
|
||||
│ - Mixed → Try pool first, fallback to OI │
|
||||
│ 5. Set defaults: │
|
||||
│ - max_rounds: 3 (range 2-5) │
|
||||
│ - interval_minutes: 5 │
|
||||
│ - prompt_variant: "balanced" │
|
||||
│ 6. Create DebateSession in database │
|
||||
│ 7. Add participants with AI models and personalities │
|
||||
│ 8. Return full session with participants │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 3.2 Debate Start
|
||||
|
||||
**Location:** `debate/engine.go:StartDebate()` (Lines 114-154)
|
||||
|
||||
```
|
||||
POST /api/debates/:id/start
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 1. Validate session status (must be pending) │
|
||||
│ 2. Validate participants (minimum 2) │
|
||||
│ 3. Initialize AI clients for all participants │
|
||||
│ 4. Get strategy configuration │
|
||||
│ 5. Update status to "running" │
|
||||
│ 6. Launch goroutine: runDebate() │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 3.3 Market Context Building
|
||||
|
||||
**Location:** `debate/engine.go:buildMarketContext()` (Lines 292-362)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ buildMarketContext() │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ 1. Get candidate coins from strategy engine │
|
||||
│ 2. Fetch market data for each candidate: │
|
||||
│ - Multiple timeframes (15m, 1h, 4h) │
|
||||
│ - K-line count from strategy config │
|
||||
│ - OHLCV data, indicators │
|
||||
│ 3. Fetch quantitative data batch: │
|
||||
│ - Capital flow │
|
||||
│ - Position changes │
|
||||
│ 4. Fetch OI ranking data (market-wide) │
|
||||
│ 5. Build Context object with: │
|
||||
│ - Account info (simulated: $1000 equity) │
|
||||
│ - Candidate coins │
|
||||
│ - Market data map │
|
||||
│ - Quant data map │
|
||||
│ - OI ranking data │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 3.4 Debate Rounds
|
||||
|
||||
**Location:** `debate/engine.go:runDebate()` (Lines 157-289)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ For each round (1 to max_rounds): │
|
||||
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||
│ │ 1. Broadcast "round_start" event │ │
|
||||
│ │ 2. For each participant (in speak_order): │ │
|
||||
│ │ ┌─────────────────────────────────────────────────┐ │ │
|
||||
│ │ │ a. Build personality-enhanced system prompt │ │ │
|
||||
│ │ │ b. Build user prompt with: │ │ │
|
||||
│ │ │ - Market data (from strategy engine) │ │ │
|
||||
│ │ │ - Previous debate messages (if round > 1) │ │ │
|
||||
│ │ │ c. Call AI model with 60s timeout │ │ │
|
||||
│ │ │ d. Parse multi-coin decisions from response │ │ │
|
||||
│ │ │ e. Save message to database │ │ │
|
||||
│ │ │ f. Broadcast "message" event │ │ │
|
||||
│ │ └─────────────────────────────────────────────────┘ │ │
|
||||
│ │ 3. Broadcast "round_end" event │ │
|
||||
│ └─────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ After all rounds: │
|
||||
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||
│ │ 1. Enter voting phase (status = "voting") │ │
|
||||
│ │ 2. Collect final votes from all participants │ │
|
||||
│ │ 3. Determine multi-coin consensus │ │
|
||||
│ │ 4. Store final decisions │ │
|
||||
│ │ 5. Update status to "completed" │ │
|
||||
│ │ 6. Broadcast "consensus" event │ │
|
||||
│ └─────────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Personality System
|
||||
|
||||
### 4.1 Available Personalities
|
||||
|
||||
| Personality | Emoji | Name | Description | Trading Bias |
|
||||
|------------|-------|------|-------------|--------------|
|
||||
| Bull | 🐂 | Aggressive Bull | Looks for long opportunities | Optimistic, trend-following |
|
||||
| Bear | 🐻 | Cautious Bear | Skeptical, focuses on risks | Pessimistic, short bias |
|
||||
| Analyst | 📊 | Data Analyst | Neutral, purely data-driven | No bias, objective analysis |
|
||||
| Contrarian | 🔄 | Contrarian | Challenges majority view | Alternative perspectives |
|
||||
| Risk Manager | 🛡️ | Risk Manager | Focus on risk control | Position sizing, stop loss |
|
||||
|
||||
### 4.2 Personality Prompt Enhancement
|
||||
|
||||
**Location:** `debate/engine.go:buildDebateSystemPrompt()` (Lines 365-426)
|
||||
|
||||
```
|
||||
## DEBATE MODE - ROUND {round}/{max_rounds}
|
||||
|
||||
You are participating as {emoji} {personality}.
|
||||
|
||||
### Your Debate Role:
|
||||
{personality_description}
|
||||
|
||||
### Debate Rules:
|
||||
1. Analyze ALL candidate coins
|
||||
2. Support arguments with specific data
|
||||
3. Respond to other participants (round > 1)
|
||||
4. Be persuasive but data-driven
|
||||
5. Can recommend multiple coins with different actions
|
||||
|
||||
### Output Format (STRICT JSON):
|
||||
<reasoning>
|
||||
- Market analysis with data references
|
||||
- Main trading thesis
|
||||
- Response to others (if round > 1)
|
||||
</reasoning>
|
||||
|
||||
<decision>
|
||||
[
|
||||
{"symbol": "BTCUSDT", "action": "open_long", "confidence": 75, ...},
|
||||
{"symbol": "ETHUSDT", "action": "open_short", "confidence": 80, ...}
|
||||
]
|
||||
</decision>
|
||||
```
|
||||
|
||||
### 4.3 Personality-Specific Prompts
|
||||
|
||||
**Bull (🐂):**
|
||||
```
|
||||
As a bull, you are optimistic about market trends.
|
||||
Look for long opportunities, identify bullish patterns,
|
||||
and support your thesis with technical and fundamental data.
|
||||
Focus on: breakout patterns, momentum, support levels.
|
||||
```
|
||||
|
||||
**Bear (🐻):**
|
||||
```
|
||||
As a bear, you are cautious and skeptical.
|
||||
Look for short opportunities, identify bearish patterns,
|
||||
and highlight risks and potential downside.
|
||||
Focus on: resistance levels, divergences, overbought conditions.
|
||||
```
|
||||
|
||||
**Analyst (📊):**
|
||||
```
|
||||
As a data analyst, you are completely neutral.
|
||||
Provide objective analysis based purely on data.
|
||||
No emotional bias - let the numbers speak.
|
||||
Focus on: key metrics, statistical patterns, historical comparisons.
|
||||
```
|
||||
|
||||
**Contrarian (🔄):**
|
||||
```
|
||||
As a contrarian, challenge the majority view.
|
||||
Look for overlooked opportunities and hidden risks.
|
||||
Play devil's advocate to strengthen the debate.
|
||||
Focus on: crowd positioning, sentiment extremes, neglected signals.
|
||||
```
|
||||
|
||||
**Risk Manager (🛡️):**
|
||||
```
|
||||
As a risk manager, focus on capital preservation.
|
||||
Evaluate position sizing, stop loss levels, and risk/reward ratios.
|
||||
Ensure all decisions have appropriate risk controls.
|
||||
Focus on: max drawdown, position limits, volatility-adjusted sizing.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Consensus Algorithm
|
||||
|
||||
### 5.1 Vote Collection
|
||||
|
||||
**Location:** `debate/engine.go:collectVotes()` (Lines 542-567)
|
||||
|
||||
```
|
||||
For each participant:
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 1. Build voting system prompt │
|
||||
│ 2. Build voting user prompt with debate summary │
|
||||
│ 3. Call AI model for final vote │
|
||||
│ 4. Parse multi-coin decisions │
|
||||
│ 5. Validate/fix symbols against session.Symbol │
|
||||
│ 6. Save vote to database │
|
||||
│ 7. Broadcast "vote" event │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 5.2 Multi-Coin Consensus Determination
|
||||
|
||||
**Location:** `debate/engine.go:determineMultiCoinConsensus()` (Lines 752-924)
|
||||
|
||||
**Algorithm:**
|
||||
|
||||
```
|
||||
1. Collect all coin decisions from all votes
|
||||
2. Group by: symbol → action → aggregated data
|
||||
|
||||
3. For each vote decision:
|
||||
weight = confidence / 100.0
|
||||
Accumulate:
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ score += weight │
|
||||
│ total_confidence += confidence │
|
||||
│ total_leverage += leverage │
|
||||
│ total_position_pct += position_pct │
|
||||
│ total_stop_loss += stop_loss │
|
||||
│ total_take_profit += take_profit │
|
||||
│ count++ │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
|
||||
4. For each symbol:
|
||||
Find winning action (max score)
|
||||
Calculate averages:
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ avg_confidence = total_confidence / count │
|
||||
│ avg_leverage = clamp(total_leverage / count, 1, 20) │
|
||||
│ avg_position_pct = clamp(total_pct / count, 0.1, 1.0) │
|
||||
│ avg_stop_loss = default 3% if not set │
|
||||
│ avg_take_profit = default 6% if not set │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
|
||||
5. Return array of consensus decisions
|
||||
```
|
||||
|
||||
### 5.3 Consensus Example
|
||||
|
||||
**Input Votes:**
|
||||
```
|
||||
AI1 (Bull): BTC open_long (conf=80, lev=10, pos=0.3)
|
||||
AI2 (Bear): BTC open_short (conf=60, lev=5, pos=0.2)
|
||||
AI3 (Analyst): BTC open_long (conf=70, lev=8, pos=0.25)
|
||||
```
|
||||
|
||||
**Calculation:**
|
||||
```
|
||||
open_long:
|
||||
score = 0.80 + 0.70 = 1.50
|
||||
avg_conf = (80 + 70) / 2 = 75
|
||||
avg_lev = (10 + 8) / 2 = 9
|
||||
avg_pos = (0.3 + 0.25) / 2 = 0.275
|
||||
|
||||
open_short:
|
||||
score = 0.60
|
||||
avg_conf = 60
|
||||
avg_lev = 5
|
||||
avg_pos = 0.2
|
||||
|
||||
Winner: open_long (score 1.50 > 0.60)
|
||||
```
|
||||
|
||||
**Output:**
|
||||
```json
|
||||
{
|
||||
"symbol": "BTCUSDT",
|
||||
"action": "open_long",
|
||||
"confidence": 75,
|
||||
"leverage": 9,
|
||||
"position_pct": 0.275,
|
||||
"stop_loss": 0.03,
|
||||
"take_profit": 0.06
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Auto-Execution
|
||||
|
||||
### 6.1 Execution Flow
|
||||
|
||||
**Location:** `debate/engine.go:ExecuteConsensus()` (Lines 932-1052)
|
||||
|
||||
```
|
||||
POST /api/debates/:id/execute
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 1. Validate session status = completed │
|
||||
│ 2. Validate final_decision exists and not executed │
|
||||
│ 3. Validate action is open_long or open_short │
|
||||
│ 4. Get current market price │
|
||||
│ 5. Get account balance: │
|
||||
│ - Try available_balance │
|
||||
│ - Fallback to total_equity or wallet_balance │
|
||||
│ 6. Calculate position size: │
|
||||
│ position_size_usd = available_balance × position_pct │
|
||||
│ (minimum $12 to meet exchange requirements) │
|
||||
│ 7. Calculate stop loss and take profit prices: │
|
||||
│ ┌───────────────────────────────────────────────────┐ │
|
||||
│ │ open_long: │ │
|
||||
│ │ SL = price × (1 - stop_loss_pct) │ │
|
||||
│ │ TP = price × (1 + take_profit_pct) │ │
|
||||
│ │ open_short: │ │
|
||||
│ │ SL = price × (1 + stop_loss_pct) │ │
|
||||
│ │ TP = price × (1 - take_profit_pct) │ │
|
||||
│ └───────────────────────────────────────────────────┘ │
|
||||
│ 8. Create Decision object │
|
||||
│ 9. Call executor.ExecuteDecision() │
|
||||
│ 10. Update final_decision: │
|
||||
│ - executed = true/false │
|
||||
│ - executed_at = timestamp │
|
||||
│ - error message if failed │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 6.2 Position Size Calculation
|
||||
|
||||
```go
|
||||
// Calculate position value
|
||||
position_size_usd := available_balance * position_pct
|
||||
|
||||
// Ensure minimum size for exchange
|
||||
if position_size_usd < 12 {
|
||||
position_size_usd = 12
|
||||
}
|
||||
|
||||
// Calculate quantity
|
||||
quantity := position_size_usd / market_price
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. API Reference
|
||||
|
||||
### 7.1 Endpoints
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/debates` | List all debates for user |
|
||||
| GET | `/api/debates/personalities` | Get AI personality configs |
|
||||
| GET | `/api/debates/:id` | Get debate with full details |
|
||||
| POST | `/api/debates` | Create new debate |
|
||||
| POST | `/api/debates/:id/start` | Start debate execution |
|
||||
| POST | `/api/debates/:id/cancel` | Cancel running debate |
|
||||
| POST | `/api/debates/:id/execute` | Execute consensus trade |
|
||||
| DELETE | `/api/debates/:id` | Delete debate |
|
||||
| GET | `/api/debates/:id/messages` | Get all messages |
|
||||
| GET | `/api/debates/:id/votes` | Get all votes |
|
||||
| GET | `/api/debates/:id/stream` | SSE live stream |
|
||||
|
||||
### 7.2 Create Debate Request
|
||||
|
||||
```json
|
||||
POST /api/debates
|
||||
{
|
||||
"name": "BTC Market Debate",
|
||||
"strategy_id": "strategy-uuid",
|
||||
"symbol": "BTCUSDT",
|
||||
"max_rounds": 3,
|
||||
"interval_minutes": 5,
|
||||
"prompt_variant": "balanced",
|
||||
"auto_execute": false,
|
||||
"trader_id": "trader-uuid",
|
||||
"enable_oi_ranking": true,
|
||||
"oi_ranking_limit": 10,
|
||||
"oi_duration": "1h",
|
||||
"participants": [
|
||||
{"ai_model_id": "deepseek-v3", "personality": "bull"},
|
||||
{"ai_model_id": "qwen-max", "personality": "bear"},
|
||||
{"ai_model_id": "gpt-5.2", "personality": "analyst"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 7.3 Create Debate Response
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "debate-uuid",
|
||||
"user_id": "user-uuid",
|
||||
"name": "BTC Market Debate",
|
||||
"strategy_id": "strategy-uuid",
|
||||
"status": "pending",
|
||||
"symbol": "BTCUSDT",
|
||||
"max_rounds": 3,
|
||||
"current_round": 0,
|
||||
"participants": [
|
||||
{
|
||||
"id": "participant-uuid",
|
||||
"ai_model_id": "deepseek-v3",
|
||||
"ai_model_name": "DeepSeek V3",
|
||||
"provider": "deepseek",
|
||||
"personality": "bull",
|
||||
"color": "#22C55E",
|
||||
"speak_order": 0
|
||||
}
|
||||
],
|
||||
"messages": [],
|
||||
"votes": [],
|
||||
"created_at": "2025-12-15T12:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
### 7.4 Execute Consensus Request
|
||||
|
||||
```json
|
||||
POST /api/debates/:id/execute
|
||||
{
|
||||
"trader_id": "trader-uuid"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Real-Time Updates (SSE)
|
||||
|
||||
### 8.1 SSE Endpoint
|
||||
|
||||
**Location:** `api/debate.go:HandleDebateStream()` (Lines 407-453)
|
||||
|
||||
```
|
||||
GET /api/debates/:id/stream
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 1. Validate user ownership │
|
||||
│ 2. Set SSE headers: │
|
||||
│ Content-Type: text/event-stream │
|
||||
│ Cache-Control: no-cache │
|
||||
│ Connection: keep-alive │
|
||||
│ 3. Send initial state │
|
||||
│ 4. Subscribe to events │
|
||||
│ 5. Stream updates until client disconnects │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 8.2 Event Types
|
||||
|
||||
| Event | Trigger | Data |
|
||||
|-------|---------|------|
|
||||
| `initial` | Connection start | Full session state |
|
||||
| `round_start` | Round begins | `{round, status}` |
|
||||
| `message` | AI speaks | DebateMessage object |
|
||||
| `round_end` | Round complete | `{round, status}` |
|
||||
| `vote` | AI votes | DebateVote object |
|
||||
| `consensus` | Debate complete | DebateDecision object |
|
||||
| `error` | Error occurs | `{error: string}` |
|
||||
|
||||
### 8.3 SSE Message Format
|
||||
|
||||
```
|
||||
event: message
|
||||
data: {"id":"msg-uuid","session_id":"session-uuid","round":1,"ai_model_name":"DeepSeek V3","personality":"bull","content":"...","decision":{"action":"open_long","symbol":"BTCUSDT","confidence":75}}
|
||||
|
||||
event: vote
|
||||
data: {"id":"vote-uuid","session_id":"session-uuid","ai_model_name":"DeepSeek V3","action":"open_long","symbol":"BTCUSDT","confidence":80,"reasoning":"..."}
|
||||
|
||||
event: consensus
|
||||
data: {"action":"open_long","symbol":"BTCUSDT","confidence":75,"leverage":8,"position_pct":0.25,"stop_loss":0.03,"take_profit":0.06}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Database Schema
|
||||
|
||||
### 9.1 Tables
|
||||
|
||||
**debate_sessions:**
|
||||
```sql
|
||||
CREATE TABLE debate_sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
strategy_id TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
symbol TEXT NOT NULL,
|
||||
max_rounds INTEGER DEFAULT 3,
|
||||
current_round INTEGER DEFAULT 0,
|
||||
interval_minutes INTEGER DEFAULT 5,
|
||||
prompt_variant TEXT DEFAULT 'balanced',
|
||||
final_decision TEXT,
|
||||
final_decisions TEXT,
|
||||
auto_execute BOOLEAN DEFAULT 0,
|
||||
trader_id TEXT,
|
||||
enable_oi_ranking BOOLEAN DEFAULT 0,
|
||||
oi_ranking_limit INTEGER DEFAULT 10,
|
||||
oi_duration TEXT DEFAULT '1h',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
```
|
||||
|
||||
**debate_participants:**
|
||||
```sql
|
||||
CREATE TABLE debate_participants (
|
||||
id TEXT PRIMARY KEY,
|
||||
session_id TEXT NOT NULL,
|
||||
ai_model_id TEXT NOT NULL,
|
||||
ai_model_name TEXT NOT NULL,
|
||||
provider TEXT NOT NULL,
|
||||
personality TEXT NOT NULL,
|
||||
color TEXT NOT NULL,
|
||||
speak_order INTEGER DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (session_id) REFERENCES debate_sessions(id) ON DELETE CASCADE
|
||||
);
|
||||
```
|
||||
|
||||
**debate_messages:**
|
||||
```sql
|
||||
CREATE TABLE debate_messages (
|
||||
id TEXT PRIMARY KEY,
|
||||
session_id TEXT NOT NULL,
|
||||
round INTEGER NOT NULL,
|
||||
ai_model_id TEXT NOT NULL,
|
||||
ai_model_name TEXT NOT NULL,
|
||||
provider TEXT NOT NULL,
|
||||
personality TEXT NOT NULL,
|
||||
message_type TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
decision TEXT,
|
||||
decisions TEXT,
|
||||
confidence INTEGER DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (session_id) REFERENCES debate_sessions(id) ON DELETE CASCADE
|
||||
);
|
||||
```
|
||||
|
||||
**debate_votes:**
|
||||
```sql
|
||||
CREATE TABLE debate_votes (
|
||||
id TEXT PRIMARY KEY,
|
||||
session_id TEXT NOT NULL,
|
||||
ai_model_id TEXT NOT NULL,
|
||||
ai_model_name TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
symbol TEXT NOT NULL,
|
||||
confidence INTEGER DEFAULT 0,
|
||||
leverage INTEGER DEFAULT 5,
|
||||
position_pct REAL DEFAULT 0.2,
|
||||
stop_loss_pct REAL DEFAULT 0.03,
|
||||
take_profit_pct REAL DEFAULT 0.06,
|
||||
reasoning TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (session_id) REFERENCES debate_sessions(id) ON DELETE CASCADE
|
||||
);
|
||||
```
|
||||
|
||||
### 9.2 Key Store Methods
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `CreateSession()` | Create new debate session |
|
||||
| `GetSession()` | Get session by ID |
|
||||
| `GetSessionWithDetails()` | Get session with participants, messages, votes |
|
||||
| `UpdateSessionStatus()` | Update session status |
|
||||
| `UpdateSessionRound()` | Update current round |
|
||||
| `UpdateSessionFinalDecisions()` | Store consensus decisions |
|
||||
| `AddParticipant()` | Add AI participant |
|
||||
| `AddMessage()` | Store debate message |
|
||||
| `AddVote()` | Store final vote |
|
||||
|
||||
---
|
||||
|
||||
## 10. Frontend Components
|
||||
|
||||
### 10.1 Page Structure
|
||||
|
||||
**Location:** `web/src/pages/DebateArenaPage.tsx`
|
||||
|
||||
```
|
||||
DebateArenaPage
|
||||
├── Left Sidebar (w-56)
|
||||
│ ├── New Debate Button
|
||||
│ ├── Debate Sessions List
|
||||
│ │ └── SessionItem (status, name, timestamp)
|
||||
│ └── Online Traders List
|
||||
│ └── TraderItem (name, status, AI model)
|
||||
│
|
||||
├── Main Content
|
||||
│ ├── Header Bar
|
||||
│ │ ├── Session Info (name, status, symbol)
|
||||
│ │ ├── Participants Avatars
|
||||
│ │ └── Vote Summary
|
||||
│ │
|
||||
│ ├── Content Area (two-column)
|
||||
│ │ ├── Left: Discussion Records
|
||||
│ │ │ ├── Round Headers
|
||||
│ │ │ └── MessageCards (expandable)
|
||||
│ │ │
|
||||
│ │ └── Right: Final Votes
|
||||
│ │ └── VoteCards (action, confidence, reasoning)
|
||||
│ │
|
||||
│ └── Consensus Bar
|
||||
│ ├── Final Decision Display
|
||||
│ └── Execute Button (if auto_execute disabled)
|
||||
│
|
||||
└── Modals
|
||||
├── CreateModal
|
||||
│ ├── Name Input
|
||||
│ ├── Strategy Selector
|
||||
│ ├── Symbol Input (auto-filled)
|
||||
│ ├── Max Rounds Selector
|
||||
│ └── Participant Picker (AI model + personality)
|
||||
│
|
||||
└── ExecuteModal
|
||||
└── Trader Selector
|
||||
```
|
||||
|
||||
### 10.2 UI Components
|
||||
|
||||
**MessageCard:**
|
||||
- Expandable message display
|
||||
- Shows AI avatar, personality emoji, decision
|
||||
- Parses reasoning/analysis sections from content
|
||||
- Displays decision details (leverage, position, SL/TP)
|
||||
- Supports multi-coin decisions
|
||||
|
||||
**VoteCard:**
|
||||
- Confidence bar visualization
|
||||
- Action indicator (long/short/hold/wait)
|
||||
- Leverage and position size display
|
||||
- Stop loss and take profit display
|
||||
- Reasoning preview
|
||||
|
||||
### 10.3 Status Colors
|
||||
|
||||
```typescript
|
||||
const STATUS_COLOR = {
|
||||
pending: 'bg-gray-500',
|
||||
running: 'bg-blue-500 animate-pulse',
|
||||
voting: 'bg-yellow-500 animate-pulse',
|
||||
completed: 'bg-green-500',
|
||||
cancelled: 'bg-red-500',
|
||||
}
|
||||
```
|
||||
|
||||
### 10.4 Action Styling
|
||||
|
||||
```typescript
|
||||
const ACT = {
|
||||
open_long: {
|
||||
color: 'text-green-400',
|
||||
bg: 'bg-green-500/20',
|
||||
icon: <TrendingUp />,
|
||||
label: 'LONG'
|
||||
},
|
||||
open_short: {
|
||||
color: 'text-red-400',
|
||||
bg: 'bg-red-500/20',
|
||||
icon: <TrendingDown />,
|
||||
label: 'SHORT'
|
||||
},
|
||||
hold: {
|
||||
color: 'text-blue-400',
|
||||
bg: 'bg-blue-500/20',
|
||||
icon: <Minus />,
|
||||
label: 'HOLD'
|
||||
},
|
||||
wait: {
|
||||
color: 'text-gray-400',
|
||||
bg: 'bg-gray-500/20',
|
||||
icon: <Clock />,
|
||||
label: 'WAIT'
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### 10.5 Personality Colors
|
||||
|
||||
```typescript
|
||||
const PERS = {
|
||||
bull: { emoji: '🐂', color: '#22C55E', name: '多头', nameEn: 'Bull' },
|
||||
bear: { emoji: '🐻', color: '#EF4444', name: '空头', nameEn: 'Bear' },
|
||||
analyst: { emoji: '📊', color: '#3B82F6', name: '分析', nameEn: 'Analyst' },
|
||||
contrarian: { emoji: '🔄', color: '#F59E0B', name: '逆势', nameEn: 'Contrarian' },
|
||||
risk_manager: { emoji: '🛡️', color: '#8B5CF6', name: '风控', nameEn: 'Risk Mgr' },
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. Integration Points
|
||||
|
||||
### 11.1 Strategy System
|
||||
|
||||
Debate sessions depend on saved strategies for:
|
||||
- **Coin source configuration:** static/pool/OI top
|
||||
- **Market data indicators:** K-lines, timeframes, technical indicators
|
||||
- **Risk control parameters:** leverage limits, position sizing
|
||||
- **Custom prompts:** role definition, trading rules
|
||||
|
||||
### 11.2 AI Model System
|
||||
|
||||
Each participant requires:
|
||||
- AI model configuration (provider, API key, custom URL)
|
||||
- Supported providers: deepseek, qwen, openai, claude, gemini, grok, kimi
|
||||
- Client initialization with timeout handling (60s per call)
|
||||
|
||||
### 11.3 Trader System
|
||||
|
||||
For auto-execution:
|
||||
- Requires active trader with running status
|
||||
- Trader must have valid exchange connection
|
||||
- Executor interface: `ExecuteDecision()`, `GetBalance()`
|
||||
|
||||
### 11.4 Market Data
|
||||
|
||||
Market context building uses:
|
||||
- Market data service (K-lines, OHLCV)
|
||||
- Quantitative data (capital flow, position changes)
|
||||
- OI ranking data (market-wide position changes)
|
||||
|
||||
---
|
||||
|
||||
## 12. Error Handling
|
||||
|
||||
### 12.1 Cleanup on Startup
|
||||
|
||||
**Location:** `debate/engine.go:cleanupStaleDebates()` (Lines 58-71)
|
||||
|
||||
```go
|
||||
// On server restart, cancel all running/voting debates
|
||||
func cleanupStaleDebates() {
|
||||
sessions := debateStore.ListAllSessions()
|
||||
for _, session := range sessions {
|
||||
if session.Status == running || session.Status == voting {
|
||||
debateStore.UpdateSessionStatus(session.ID, cancelled)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 12.2 AI Call Timeout
|
||||
|
||||
```go
|
||||
// 60 seconds per participant response
|
||||
select {
|
||||
case res := <-resultCh:
|
||||
response = res.response
|
||||
case <-time.After(60 * time.Second):
|
||||
return nil, fmt.Errorf("AI call timeout")
|
||||
}
|
||||
```
|
||||
|
||||
### 12.3 Symbol Validation
|
||||
|
||||
```go
|
||||
// Force all decisions to use session symbol if specified
|
||||
if session.Symbol != "" {
|
||||
for _, d := range decisions {
|
||||
if d.Symbol == "" || d.Symbol != session.Symbol {
|
||||
logger.Warnf("Fixing invalid symbol '%s' -> '%s'", d.Symbol, session.Symbol)
|
||||
d.Symbol = session.Symbol
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 12.4 Panic Recovery
|
||||
|
||||
```go
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
logger.Errorf("Debate panic: %v", r)
|
||||
debateStore.UpdateSessionStatus(sessionID, cancelled)
|
||||
if OnError != nil {
|
||||
OnError(sessionID, fmt.Errorf("panic: %v", r))
|
||||
}
|
||||
}
|
||||
}()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
The Debate Arena module provides a sophisticated multi-AI collaborative decision system with:
|
||||
|
||||
- **Multi-Personality Debate:** 5 distinct AI personalities (Bull, Bear, Analyst, Contrarian, Risk Manager) with unique trading biases
|
||||
- **Consensus Mechanism:** Weighted voting based on confidence levels to determine final decisions
|
||||
- **Real-Time Updates:** SSE streaming for live debate progress
|
||||
- **Auto-Execution:** Optional automatic trade execution based on consensus
|
||||
- **Strategy Integration:** Deep integration with strategy configuration for market data and risk parameters
|
||||
- **Multi-Coin Support:** Ability to analyze and decide on multiple coins simultaneously
|
||||
|
||||
The system enables users to leverage multiple AI perspectives for more robust trading decisions while maintaining full control over execution.
|
||||
@@ -1,606 +0,0 @@
|
||||
# NOFX 辩论竞技场模块 - 技术文档
|
||||
|
||||
**语言:** [English](DEBATE_MODULE.md) | [中文](DEBATE_MODULE.zh-CN.md)
|
||||
|
||||
## 概述
|
||||
|
||||
辩论竞技场是一个多 AI 协作决策系统,多个具有不同性格的 AI 模型对市场状况进行辩论并达成交易决策共识。系统支持多轮辩论、实时流推送、投票机制和自动交易执行。
|
||||
|
||||
---
|
||||
|
||||
## 1. 架构概览
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 辩论竞技场系统 │
|
||||
├─────────────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
|
||||
│ │ 多头 AI │ │ 空头 AI │ │ 分析 AI │ │ 风控 AI │ │
|
||||
│ │ 🐂 │ │ 🐻 │ │ 📊 │ │ 🛡️ │ │
|
||||
│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │
|
||||
│ │ │ │ │ │
|
||||
│ └──────────────────┴──────────────────┴──────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌─────────▼─────────┐ │
|
||||
│ │ 辩论引擎 │ │
|
||||
│ │ (debate/engine) │ │
|
||||
│ └─────────┬─────────┘ │
|
||||
│ │ │
|
||||
│ ┌──────────────────────────┼──────────────────────────┐ │
|
||||
│ │ │ │ │
|
||||
│ ┌──────▼──────┐ ┌─────────▼─────────┐ ┌────────▼────────┐ │
|
||||
│ │ 市场数据 │ │ 投票系统 │ │ 自动执行器 │ │
|
||||
│ │ 组装 │ │ 与共识机制 │ │ (可选) │ │
|
||||
│ └─────────────┘ └───────────────────┘ └─────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 文件结构
|
||||
|
||||
```
|
||||
├── debate/
|
||||
│ └── engine.go # 核心辩论引擎逻辑
|
||||
├── api/
|
||||
│ └── debate.go # HTTP 处理器和 SSE 流
|
||||
├── store/
|
||||
│ └── debate.go # 数据库操作和模式
|
||||
└── web/src/pages/
|
||||
└── DebateArenaPage.tsx # 前端 UI
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 性格系统
|
||||
|
||||
### 2.1 可用性格
|
||||
|
||||
| 性格 | 图标 | 名称 | 描述 | 交易偏向 |
|
||||
|------|------|------|------|----------|
|
||||
| Bull | 🐂 | 激进多头 | 寻找做多机会 | 乐观,趋势跟随 |
|
||||
| Bear | 🐻 | 谨慎空头 | 关注风险 | 悲观,做空偏向 |
|
||||
| Analyst | 📊 | 数据分析师 | 纯数据驱动 | 无偏见,客观分析 |
|
||||
| Contrarian | 🔄 | 逆势者 | 挑战多数观点 | 另类视角 |
|
||||
| Risk Manager | 🛡️ | 风控经理 | 关注风险控制 | 仓位管理,止损 |
|
||||
|
||||
### 2.2 性格提示词增强
|
||||
|
||||
**文件位置:** `debate/engine.go:buildDebateSystemPrompt()` (365-426行)
|
||||
|
||||
```
|
||||
## 辩论模式 - 第 {round}/{max_rounds} 轮
|
||||
|
||||
你作为 {emoji} {personality} 参与辩论。
|
||||
|
||||
### 你的辩论角色:
|
||||
{personality_description}
|
||||
|
||||
### 辩论规则:
|
||||
1. 分析所有候选币种
|
||||
2. 用具体数据支持论点
|
||||
3. 回应其他参与者 (第2轮起)
|
||||
4. 有说服力但基于数据
|
||||
5. 可以推荐多个不同操作的币种
|
||||
|
||||
### 输出格式 (严格 JSON):
|
||||
<reasoning>
|
||||
- 带数据引用的市场分析
|
||||
- 主要交易论点
|
||||
- 对他人的回应 (第2轮起)
|
||||
</reasoning>
|
||||
|
||||
<decision>
|
||||
[
|
||||
{"symbol": "BTCUSDT", "action": "open_long", "confidence": 75, ...},
|
||||
{"symbol": "ETHUSDT", "action": "open_short", "confidence": 80, ...}
|
||||
]
|
||||
</decision>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 辩论执行流程
|
||||
|
||||
### 3.1 会话创建
|
||||
|
||||
```
|
||||
POST /api/debates
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 1. 验证用户认证 │
|
||||
│ 2. 解析 CreateDebateRequest: │
|
||||
│ - name, strategy_id, symbol, max_rounds, participants │
|
||||
│ - interval_minutes, prompt_variant, auto_execute │
|
||||
│ 3. 验证策略所有权 │
|
||||
│ 4. 自动选择币种 (如未提供): │
|
||||
│ - 静态币种 → 使用策略第一个币种 │
|
||||
│ - CoinPool → 从 AI500 API 获取 │
|
||||
│ - OI Top → 从 OI 排行 API 获取 │
|
||||
│ - Mixed → 先尝试池,回退到 OI │
|
||||
│ 5. 设置默认值: │
|
||||
│ - max_rounds: 3 (范围 2-5) │
|
||||
│ - interval_minutes: 5 │
|
||||
│ - prompt_variant: "balanced" │
|
||||
│ 6. 在数据库创建 DebateSession │
|
||||
│ 7. 添加带 AI 模型和性格的参与者 │
|
||||
│ 8. 返回完整会话及参与者 │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 3.2 辩论轮次执行
|
||||
|
||||
**文件位置:** `debate/engine.go:runDebate()` (157-289行)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 每轮 (1 到 max_rounds): │
|
||||
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||
│ │ 1. 广播 "round_start" 事件 │ │
|
||||
│ │ 2. 每个参与者 (按 speak_order): │ │
|
||||
│ │ ┌─────────────────────────────────────────────────┐ │ │
|
||||
│ │ │ a. 构建性格增强的系统提示词 │ │ │
|
||||
│ │ │ b. 构建用户提示词: │ │ │
|
||||
│ │ │ - 市场数据 (来自策略引擎) │ │ │
|
||||
│ │ │ - 之前的辩论消息 (第2轮起) │ │ │
|
||||
│ │ │ c. 调用 AI 模型,60秒超时 │ │ │
|
||||
│ │ │ d. 从响应解析多币种决策 │ │ │
|
||||
│ │ │ e. 保存消息到数据库 │ │ │
|
||||
│ │ │ f. 广播 "message" 事件 │ │ │
|
||||
│ │ └─────────────────────────────────────────────────┘ │ │
|
||||
│ │ 3. 广播 "round_end" 事件 │ │
|
||||
│ └─────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ 所有轮次后: │
|
||||
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||
│ │ 1. 进入投票阶段 (status = "voting") │ │
|
||||
│ │ 2. 收集所有参与者的最终投票 │ │
|
||||
│ │ 3. 确定多币种共识 │ │
|
||||
│ │ 4. 存储最终决策 │ │
|
||||
│ │ 5. 更新状态为 "completed" │ │
|
||||
│ │ 6. 广播 "consensus" 事件 │ │
|
||||
│ └─────────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 共识算法
|
||||
|
||||
### 4.1 投票收集
|
||||
|
||||
**文件位置:** `debate/engine.go:collectVotes()` (542-567行)
|
||||
|
||||
```
|
||||
每个参与者:
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 1. 构建投票系统提示词 │
|
||||
│ 2. 构建带辩论摘要的投票用户提示词 │
|
||||
│ 3. 调用 AI 模型获取最终投票 │
|
||||
│ 4. 解析多币种决策 │
|
||||
│ 5. 验证/修复币种与 session.Symbol 一致 │
|
||||
│ 6. 保存投票到数据库 │
|
||||
│ 7. 广播 "vote" 事件 │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 4.2 多币种共识确定
|
||||
|
||||
**文件位置:** `debate/engine.go:determineMultiCoinConsensus()` (752-924行)
|
||||
|
||||
**算法:**
|
||||
|
||||
```
|
||||
1. 收集所有投票中的所有币种决策
|
||||
2. 按 symbol → action → 聚合数据 分组
|
||||
|
||||
3. 对每个投票决策:
|
||||
weight = confidence / 100.0
|
||||
累加:
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ score += weight │
|
||||
│ total_confidence += confidence │
|
||||
│ total_leverage += leverage │
|
||||
│ total_position_pct += position_pct │
|
||||
│ total_stop_loss += stop_loss │
|
||||
│ total_take_profit += take_profit │
|
||||
│ count++ │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
|
||||
4. 对每个币种:
|
||||
找到胜出操作 (最高 score)
|
||||
计算平均值:
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ avg_confidence = total_confidence / count │
|
||||
│ avg_leverage = clamp(total_leverage / count, 1, 20) │
|
||||
│ avg_position_pct = clamp(total_pct / count, 0.1, 1.0) │
|
||||
│ avg_stop_loss = 默认 3% (如未设置) │
|
||||
│ avg_take_profit = 默认 6% (如未设置) │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
|
||||
5. 返回共识决策数组
|
||||
```
|
||||
|
||||
### 4.3 共识示例
|
||||
|
||||
**输入投票:**
|
||||
```
|
||||
AI1 (多头): BTC open_long (conf=80, lev=10, pos=0.3)
|
||||
AI2 (空头): BTC open_short (conf=60, lev=5, pos=0.2)
|
||||
AI3 (分析): BTC open_long (conf=70, lev=8, pos=0.25)
|
||||
```
|
||||
|
||||
**计算:**
|
||||
```
|
||||
open_long:
|
||||
score = 0.80 + 0.70 = 1.50
|
||||
avg_conf = (80 + 70) / 2 = 75
|
||||
avg_lev = (10 + 8) / 2 = 9
|
||||
avg_pos = (0.3 + 0.25) / 2 = 0.275
|
||||
|
||||
open_short:
|
||||
score = 0.60
|
||||
avg_conf = 60
|
||||
avg_lev = 5
|
||||
avg_pos = 0.2
|
||||
|
||||
胜出: open_long (score 1.50 > 0.60)
|
||||
```
|
||||
|
||||
**输出:**
|
||||
```json
|
||||
{
|
||||
"symbol": "BTCUSDT",
|
||||
"action": "open_long",
|
||||
"confidence": 75,
|
||||
"leverage": 9,
|
||||
"position_pct": 0.275,
|
||||
"stop_loss": 0.03,
|
||||
"take_profit": 0.06
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 自动执行
|
||||
|
||||
### 5.1 执行流程
|
||||
|
||||
**文件位置:** `debate/engine.go:ExecuteConsensus()` (932-1052行)
|
||||
|
||||
```
|
||||
POST /api/debates/:id/execute
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 1. 验证会话状态 = completed │
|
||||
│ 2. 验证 final_decision 存在且未执行 │
|
||||
│ 3. 验证操作是 open_long 或 open_short │
|
||||
│ 4. 获取当前市场价格 │
|
||||
│ 5. 获取账户余额: │
|
||||
│ - 尝试 available_balance │
|
||||
│ - 回退到 total_equity 或 wallet_balance │
|
||||
│ 6. 计算仓位大小: │
|
||||
│ position_size_usd = available_balance × position_pct │
|
||||
│ (最小 $12 以满足交易所要求) │
|
||||
│ 7. 计算止损和止盈价格: │
|
||||
│ ┌───────────────────────────────────────────────────┐ │
|
||||
│ │ open_long: │ │
|
||||
│ │ SL = price × (1 - stop_loss_pct) │ │
|
||||
│ │ TP = price × (1 + take_profit_pct) │ │
|
||||
│ │ open_short: │ │
|
||||
│ │ SL = price × (1 + stop_loss_pct) │ │
|
||||
│ │ TP = price × (1 - take_profit_pct) │ │
|
||||
│ └───────────────────────────────────────────────────┘ │
|
||||
│ 8. 创建 Decision 对象 │
|
||||
│ 9. 调用 executor.ExecuteDecision() │
|
||||
│ 10. 更新 final_decision: │
|
||||
│ - executed = true/false │
|
||||
│ - executed_at = 时间戳 │
|
||||
│ - error 消息 (如失败) │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. API 接口
|
||||
|
||||
### 6.1 接口列表
|
||||
|
||||
| 接口 | 方法 | 描述 |
|
||||
|------|------|------|
|
||||
| `/api/debates` | GET | 列出用户所有辩论 |
|
||||
| `/api/debates/personalities` | GET | 获取 AI 性格配置 |
|
||||
| `/api/debates/:id` | GET | 获取辩论详情 |
|
||||
| `/api/debates` | POST | 创建新辩论 |
|
||||
| `/api/debates/:id/start` | POST | 开始辩论执行 |
|
||||
| `/api/debates/:id/cancel` | POST | 取消运行中的辩论 |
|
||||
| `/api/debates/:id/execute` | POST | 执行共识交易 |
|
||||
| `/api/debates/:id` | DELETE | 删除辩论 |
|
||||
| `/api/debates/:id/messages` | GET | 获取所有消息 |
|
||||
| `/api/debates/:id/votes` | GET | 获取所有投票 |
|
||||
| `/api/debates/:id/stream` | GET | SSE 实时流 |
|
||||
|
||||
### 6.2 创建辩论请求
|
||||
|
||||
```json
|
||||
POST /api/debates
|
||||
{
|
||||
"name": "BTC 市场辩论",
|
||||
"strategy_id": "strategy-uuid",
|
||||
"symbol": "BTCUSDT",
|
||||
"max_rounds": 3,
|
||||
"interval_minutes": 5,
|
||||
"prompt_variant": "balanced",
|
||||
"auto_execute": false,
|
||||
"trader_id": "trader-uuid",
|
||||
"enable_oi_ranking": true,
|
||||
"oi_ranking_limit": 10,
|
||||
"oi_duration": "1h",
|
||||
"participants": [
|
||||
{"ai_model_id": "deepseek-v3", "personality": "bull"},
|
||||
{"ai_model_id": "qwen-max", "personality": "bear"},
|
||||
{"ai_model_id": "gpt-5.2", "personality": "analyst"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 实时更新 (SSE)
|
||||
|
||||
### 7.1 SSE 接口
|
||||
|
||||
**文件位置:** `api/debate.go:HandleDebateStream()` (407-453行)
|
||||
|
||||
```
|
||||
GET /api/debates/:id/stream
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 1. 验证用户所有权 │
|
||||
│ 2. 设置 SSE 头: │
|
||||
│ Content-Type: text/event-stream │
|
||||
│ Cache-Control: no-cache │
|
||||
│ Connection: keep-alive │
|
||||
│ 3. 发送初始状态 │
|
||||
│ 4. 订阅事件 │
|
||||
│ 5. 流式推送更新直到客户端断开 │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 7.2 事件类型
|
||||
|
||||
| 事件 | 触发时机 | 数据 |
|
||||
|------|----------|------|
|
||||
| `initial` | 连接开始 | 完整会话状态 |
|
||||
| `round_start` | 轮次开始 | `{round, status}` |
|
||||
| `message` | AI 发言 | DebateMessage 对象 |
|
||||
| `round_end` | 轮次结束 | `{round, status}` |
|
||||
| `vote` | AI 投票 | DebateVote 对象 |
|
||||
| `consensus` | 辩论完成 | DebateDecision 对象 |
|
||||
| `error` | 发生错误 | `{error: string}` |
|
||||
|
||||
---
|
||||
|
||||
## 8. 数据库模式
|
||||
|
||||
### 8.1 表结构
|
||||
|
||||
**debate_sessions:**
|
||||
```sql
|
||||
CREATE TABLE debate_sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
strategy_id TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
symbol TEXT NOT NULL,
|
||||
max_rounds INTEGER DEFAULT 3,
|
||||
current_round INTEGER DEFAULT 0,
|
||||
interval_minutes INTEGER DEFAULT 5,
|
||||
prompt_variant TEXT DEFAULT 'balanced',
|
||||
final_decision TEXT,
|
||||
final_decisions TEXT,
|
||||
auto_execute BOOLEAN DEFAULT 0,
|
||||
trader_id TEXT,
|
||||
enable_oi_ranking BOOLEAN DEFAULT 0,
|
||||
oi_ranking_limit INTEGER DEFAULT 10,
|
||||
oi_duration TEXT DEFAULT '1h',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
```
|
||||
|
||||
**debate_participants:**
|
||||
```sql
|
||||
CREATE TABLE debate_participants (
|
||||
id TEXT PRIMARY KEY,
|
||||
session_id TEXT NOT NULL,
|
||||
ai_model_id TEXT NOT NULL,
|
||||
ai_model_name TEXT NOT NULL,
|
||||
provider TEXT NOT NULL,
|
||||
personality TEXT NOT NULL,
|
||||
color TEXT NOT NULL,
|
||||
speak_order INTEGER DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (session_id) REFERENCES debate_sessions(id) ON DELETE CASCADE
|
||||
);
|
||||
```
|
||||
|
||||
**debate_messages:**
|
||||
```sql
|
||||
CREATE TABLE debate_messages (
|
||||
id TEXT PRIMARY KEY,
|
||||
session_id TEXT NOT NULL,
|
||||
round INTEGER NOT NULL,
|
||||
ai_model_id TEXT NOT NULL,
|
||||
ai_model_name TEXT NOT NULL,
|
||||
provider TEXT NOT NULL,
|
||||
personality TEXT NOT NULL,
|
||||
message_type TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
decision TEXT,
|
||||
decisions TEXT,
|
||||
confidence INTEGER DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (session_id) REFERENCES debate_sessions(id) ON DELETE CASCADE
|
||||
);
|
||||
```
|
||||
|
||||
**debate_votes:**
|
||||
```sql
|
||||
CREATE TABLE debate_votes (
|
||||
id TEXT PRIMARY KEY,
|
||||
session_id TEXT NOT NULL,
|
||||
ai_model_id TEXT NOT NULL,
|
||||
ai_model_name TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
symbol TEXT NOT NULL,
|
||||
confidence INTEGER DEFAULT 0,
|
||||
leverage INTEGER DEFAULT 5,
|
||||
position_pct REAL DEFAULT 0.2,
|
||||
stop_loss_pct REAL DEFAULT 0.03,
|
||||
take_profit_pct REAL DEFAULT 0.06,
|
||||
reasoning TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (session_id) REFERENCES debate_sessions(id) ON DELETE CASCADE
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. 前端组件
|
||||
|
||||
### 9.1 页面结构
|
||||
|
||||
**文件位置:** `web/src/pages/DebateArenaPage.tsx`
|
||||
|
||||
```
|
||||
DebateArenaPage
|
||||
├── 左侧边栏 (w-56)
|
||||
│ ├── 新建辩论按钮
|
||||
│ ├── 辩论会话列表
|
||||
│ │ └── SessionItem (状态, 名称, 时间戳)
|
||||
│ └── 在线交易员列表
|
||||
│ └── TraderItem (名称, 状态, AI 模型)
|
||||
│
|
||||
├── 主内容区
|
||||
│ ├── 头部栏
|
||||
│ │ ├── 会话信息 (名称, 状态, 币种)
|
||||
│ │ ├── 参与者头像
|
||||
│ │ └── 投票摘要
|
||||
│ │
|
||||
│ ├── 内容区 (双栏)
|
||||
│ │ ├── 左: 讨论记录
|
||||
│ │ │ ├── 轮次标题
|
||||
│ │ │ └── MessageCards (可展开)
|
||||
│ │ │
|
||||
│ │ └── 右: 最终投票
|
||||
│ │ └── VoteCards (操作, 置信度, 理由)
|
||||
│ │
|
||||
│ └── 共识栏
|
||||
│ ├── 最终决策显示
|
||||
│ └── 执行按钮 (如果 auto_execute 禁用)
|
||||
│
|
||||
└── 弹窗
|
||||
├── CreateModal
|
||||
│ ├── 名称输入
|
||||
│ ├── 策略选择器
|
||||
│ ├── 币种输入 (自动填充)
|
||||
│ ├── 最大轮数选择器
|
||||
│ └── 参与者选择器 (AI 模型 + 性格)
|
||||
│
|
||||
└── ExecuteModal
|
||||
└── 交易员选择器
|
||||
```
|
||||
|
||||
### 9.2 状态颜色
|
||||
|
||||
```typescript
|
||||
const STATUS_COLOR = {
|
||||
pending: 'bg-gray-500',
|
||||
running: 'bg-blue-500 animate-pulse',
|
||||
voting: 'bg-yellow-500 animate-pulse',
|
||||
completed: 'bg-green-500',
|
||||
cancelled: 'bg-red-500',
|
||||
}
|
||||
```
|
||||
|
||||
### 9.3 操作样式
|
||||
|
||||
```typescript
|
||||
const ACT = {
|
||||
open_long: {
|
||||
color: 'text-green-400',
|
||||
bg: 'bg-green-500/20',
|
||||
icon: <TrendingUp />,
|
||||
label: 'LONG'
|
||||
},
|
||||
open_short: {
|
||||
color: 'text-red-400',
|
||||
bg: 'bg-red-500/20',
|
||||
icon: <TrendingDown />,
|
||||
label: 'SHORT'
|
||||
},
|
||||
hold: {
|
||||
color: 'text-blue-400',
|
||||
bg: 'bg-blue-500/20',
|
||||
icon: <Minus />,
|
||||
label: 'HOLD'
|
||||
},
|
||||
wait: {
|
||||
color: 'text-gray-400',
|
||||
bg: 'bg-gray-500/20',
|
||||
icon: <Clock />,
|
||||
label: 'WAIT'
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. 集成点
|
||||
|
||||
### 10.1 策略系统
|
||||
|
||||
辩论会话依赖保存的策略:
|
||||
- **币种来源配置:** static/pool/OI top
|
||||
- **市场数据指标:** K线、时间周期、技术指标
|
||||
- **风控参数:** 杠杆限制、仓位大小
|
||||
- **自定义提示词:** 角色定义、交易规则
|
||||
|
||||
### 10.2 AI 模型系统
|
||||
|
||||
每个参与者需要:
|
||||
- AI 模型配置 (provider, API key, 自定义 URL)
|
||||
- 支持的 providers: deepseek, qwen, openai, claude, gemini, grok, kimi
|
||||
- 客户端初始化带超时处理 (每次调用 60s)
|
||||
|
||||
### 10.3 交易员系统
|
||||
|
||||
自动执行需要:
|
||||
- 运行中状态的活跃交易员
|
||||
- 交易员必须有有效的交易所连接
|
||||
- 执行器接口: `ExecuteDecision()`, `GetBalance()`
|
||||
|
||||
---
|
||||
|
||||
## 总结
|
||||
|
||||
辩论竞技场模块提供了一个复杂的多 AI 协作决策系统:
|
||||
|
||||
- **多性格辩论:** 5 种独特的 AI 性格 (多头、空头、分析师、逆势者、风控经理),具有独特的交易偏向
|
||||
- **共识机制:** 基于置信度的加权投票来确定最终决策
|
||||
- **实时更新:** SSE 流推送实时辩论进度
|
||||
- **自动执行:** 可选的基于共识的自动交易执行
|
||||
- **策略集成:** 与策略配置深度集成,用于市场数据和风控参数
|
||||
- **多币种支持:** 能够同时分析和决策多个币种
|
||||
|
||||
该系统使用户能够利用多个 AI 视角做出更稳健的交易决策,同时保持对执行的完全控制。
|
||||
|
||||
---
|
||||
|
||||
**文档版本:** 1.0.0
|
||||
**最后更新:** 2025-01-15
|
||||
@@ -24,12 +24,12 @@ NOFX is a full-stack AI trading platform for cryptocurrency and US stock markets
|
||||
│ NOFX Platform │
|
||||
├─────────────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐│
|
||||
│ │ Strategy │ │ Backtest │ │ Debate │ │ Live Trading ││
|
||||
│ │ Studio │ │ Engine │ │ Arena │ │ (Auto Trader) ││
|
||||
│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ └──────────┬──────────┘│
|
||||
│ │ │ │ │ │
|
||||
│ └────────────────┴────────────────┴────────────────────┘ │
|
||||
│ ┌─────────────┐ ┌─────────────────────────────────────┐│
|
||||
│ │ Strategy │ │ Live Trading ││
|
||||
│ │ Studio │ │ (Auto Trader) ││
|
||||
│ └──────┬──────┘ └──────────────────┬──────────────────┘│
|
||||
│ │ │ │
|
||||
│ └────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌─────────▼─────────┐ │
|
||||
│ │ Core Services │ │
|
||||
@@ -57,8 +57,6 @@ NOFX is a full-stack AI trading platform for cryptocurrency and US stock markets
|
||||
| Module | Description | Documentation |
|
||||
|--------|-------------|---------------|
|
||||
| **Strategy Studio** | Strategy configuration, coin selection, data assembly, AI prompts | [STRATEGY_MODULE.md](STRATEGY_MODULE.md) |
|
||||
| **Backtest Engine** | Historical simulation, performance metrics, AI decision replay | [BACKTEST_MODULE.md](BACKTEST_MODULE.md) |
|
||||
| **Debate Arena** | Multi-AI collaborative decision making with voting consensus | [DEBATE_MODULE.md](DEBATE_MODULE.md) |
|
||||
|
||||
### Module Overview
|
||||
|
||||
@@ -72,26 +70,6 @@ Complete strategy configuration system including:
|
||||
|
||||
**[Read Full Documentation →](STRATEGY_MODULE.md)**
|
||||
|
||||
#### Backtest Module
|
||||
Historical trading simulation engine:
|
||||
- Multi-symbol, multi-timeframe backtesting
|
||||
- AI decision replay with caching
|
||||
- Performance metrics (Sharpe, drawdown, win rate)
|
||||
- Real-time progress streaming via SSE
|
||||
- Checkpoint and resume support
|
||||
|
||||
**[Read Full Documentation →](BACKTEST_MODULE.md)**
|
||||
|
||||
#### Debate Module
|
||||
Multi-AI collaborative decision system:
|
||||
- 5 AI personalities (Bull, Bear, Analyst, Contrarian, Risk Manager)
|
||||
- Multi-round debate with market context
|
||||
- Weighted voting and consensus algorithm
|
||||
- Auto-execution to live trading
|
||||
- Real-time SSE streaming
|
||||
|
||||
**[Read Full Documentation →](DEBATE_MODULE.md)**
|
||||
|
||||
---
|
||||
|
||||
## Project Structure
|
||||
@@ -102,8 +80,6 @@ nofx/
|
||||
├── api/ # HTTP API (Gin framework)
|
||||
├── trader/ # Trading execution layer
|
||||
├── strategy/ # Strategy engine
|
||||
├── backtest/ # Backtest simulation engine
|
||||
├── debate/ # Debate arena engine
|
||||
├── market/ # Market data service
|
||||
├── mcp/ # AI model clients
|
||||
├── store/ # Database operations
|
||||
@@ -143,8 +119,6 @@ nofx/
|
||||
## Quick Links
|
||||
|
||||
- [Strategy Module](STRATEGY_MODULE.md) - How strategies work
|
||||
- [Backtest Module](BACKTEST_MODULE.md) - How backtesting works
|
||||
- [Debate Module](DEBATE_MODULE.md) - How AI debates work
|
||||
- [Getting Started](../getting-started/README.md) - Setup guide
|
||||
- [FAQ](../faq/README.md) - Frequently asked questions
|
||||
|
||||
|
||||
@@ -24,12 +24,12 @@ NOFX 是一个支持加密货币和美股市场的全栈 AI 交易平台:
|
||||
│ NOFX 平台 │
|
||||
├─────────────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐│
|
||||
│ │ 策略 │ │ 回测 │ │ 辩论 │ │ 实盘交易 ││
|
||||
│ │ 工作室 │ │ 引擎 │ │ 竞技场 │ │ (自动交易员) ││
|
||||
│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ └──────────┬──────────┘│
|
||||
│ │ │ │ │ │
|
||||
│ └────────────────┴────────────────┴────────────────────┘ │
|
||||
│ ┌─────────────┐ ┌─────────────────────────────────────┐│
|
||||
│ │ 策略 │ │ 实盘交易 ││
|
||||
│ │ 工作室 │ │ (自动交易员) ││
|
||||
│ └──────┬──────┘ └──────────────────┬──────────────────┘│
|
||||
│ │ │ │
|
||||
│ └────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌─────────▼─────────┐ │
|
||||
│ │ 核心服务 │ │
|
||||
@@ -57,8 +57,6 @@ NOFX 是一个支持加密货币和美股市场的全栈 AI 交易平台:
|
||||
| 模块 | 描述 | 文档 |
|
||||
|------|------|------|
|
||||
| **策略工作室** | 策略配置、币种选择、数据组装、AI 提示词 | [STRATEGY_MODULE.md](STRATEGY_MODULE.md) |
|
||||
| **回测引擎** | 历史模拟、性能指标、AI 决策回放 | [BACKTEST_MODULE.md](BACKTEST_MODULE.md) |
|
||||
| **辩论竞技场** | 多 AI 协作决策,投票共识机制 | [DEBATE_MODULE.md](DEBATE_MODULE.md) |
|
||||
|
||||
### 模块概览
|
||||
|
||||
@@ -72,26 +70,6 @@ NOFX 是一个支持加密货币和美股市场的全栈 AI 交易平台:
|
||||
|
||||
**[阅读完整文档 →](STRATEGY_MODULE.md)**
|
||||
|
||||
#### 回测模块
|
||||
历史交易模拟引擎:
|
||||
- 多币种、多时间周期回测
|
||||
- AI 决策回放与缓存
|
||||
- 性能指标(夏普比率、最大回撤、胜率)
|
||||
- SSE 实时进度推送
|
||||
- 断点续测支持
|
||||
|
||||
**[阅读完整文档 →](BACKTEST_MODULE.md)**
|
||||
|
||||
#### 辩论模块
|
||||
多 AI 协作决策系统:
|
||||
- 5 种 AI 性格(多头、空头、分析师、逆势者、风控)
|
||||
- 多轮辩论与市场数据上下文
|
||||
- 加权投票与共识算法
|
||||
- 自动执行到实盘交易
|
||||
- SSE 实时流推送
|
||||
|
||||
**[阅读完整文档 →](DEBATE_MODULE.md)**
|
||||
|
||||
---
|
||||
|
||||
## 项目结构
|
||||
@@ -102,8 +80,6 @@ nofx/
|
||||
├── api/ # HTTP API (Gin 框架)
|
||||
├── trader/ # 交易执行层
|
||||
├── strategy/ # 策略引擎
|
||||
├── backtest/ # 回测模拟引擎
|
||||
├── debate/ # 辩论竞技场引擎
|
||||
├── market/ # 行情数据服务
|
||||
├── mcp/ # AI 模型客户端
|
||||
├── store/ # 数据库操作
|
||||
@@ -143,8 +119,6 @@ nofx/
|
||||
## 快速链接
|
||||
|
||||
- [策略模块](STRATEGY_MODULE.md) - 策略如何运作
|
||||
- [回测模块](BACKTEST_MODULE.md) - 回测如何运作
|
||||
- [辩论模块](DEBATE_MODULE.md) - AI 辩论如何运作
|
||||
- [快速开始](../getting-started/README.zh-CN.md) - 部署指南
|
||||
- [常见问题](../faq/README.md) - FAQ
|
||||
|
||||
|
||||
282
docs/architecture/X402_STREAMING_PAYMENT.md
Normal file
282
docs/architecture/X402_STREAMING_PAYMENT.md
Normal file
@@ -0,0 +1,282 @@
|
||||
# x402 Streaming Payment Architecture
|
||||
|
||||
## Overview
|
||||
|
||||
NOFX calls AI models (DeepSeek, GPT, Claude, etc.) through the claw402 gateway, using the [x402 protocol](https://github.com/coinbase/x402) to pay per request with USDC on Base L2.
|
||||
|
||||
This document describes the full implementation of the SSE streaming call mode, including client, server, and billing logic.
|
||||
|
||||
## Why Streaming Is Needed
|
||||
|
||||
```
|
||||
NOFX (client) ──→ Cloudflare (100s idle limit) ──→ claw402 (gateway) ──→ AI upstream
|
||||
```
|
||||
|
||||
- DeepSeek inference takes 60–180 seconds (up to 5 minutes)
|
||||
- Cloudflare enforces a **100-second hard limit** on idle connections, returning 520/EOF on timeout
|
||||
- Non-streaming mode: the client receives no data until inference completes — Cloudflare disconnects after 100 seconds
|
||||
- Streaming mode: the first byte arrives within seconds, subsequent chunks flow continuously, keeping Cloudflare alive
|
||||
|
||||
## End-to-End Request Flow
|
||||
|
||||
```
|
||||
NOFX Client claw402 Gateway AI Upstream
|
||||
│ │ │
|
||||
── Phase 1: Payment ──────────────────────────────────────────────────────────────────────────────
|
||||
│ │ │
|
||||
1. POST /api/v1/ai/... │ ─── body + stream:true ──────────→ │ │
|
||||
(no payment header) │ │ │
|
||||
│ ←── 402 + Payment-Required ────── │ │
|
||||
│ (base64 JSON: price/chain/asset) │
|
||||
│ │ │
|
||||
2. EIP-712 signing │ │ │
|
||||
(USDC TransferWithAuth)│ │ │
|
||||
│ │ │
|
||||
3. POST + X-Payment hdr │ ─── body + signature ────────────→ │ │
|
||||
│ │ ── verify signature → Facilitator
|
||||
│ │ ←── OK ──────────── Facilitator
|
||||
│ │ ── settle USDC ───→ Facilitator
|
||||
│ │ ←── tx hash ─────── Facilitator
|
||||
│ │ │
|
||||
── Phase 2: Streaming Response ───────────────────────────────────────────────────────────────────
|
||||
│ │ │
|
||||
│ ←── 200 OK ────────────────────── │ ─── POST stream:true ────────→ │
|
||||
│ ←── data: {"choices":[...]} ───── │ ←── SSE chunk ──────────────── │
|
||||
│ ←── data: {"choices":[...]} ───── │ ←── SSE chunk ──────────────── │
|
||||
│ ←── ... (continuous) ──────────── │ ←── ... ─────────────────────── │
|
||||
│ ←── data: [DONE] ──────────────── │ ←── data: [DONE] ────────────── │
|
||||
```
|
||||
|
||||
## Client Implementation (NOFX)
|
||||
|
||||
### File Structure
|
||||
|
||||
| File | Responsibility |
|
||||
|------|----------------|
|
||||
| `mcp/payment/claw402.go` | Claw402Client — model routing, wallet management |
|
||||
| `mcp/payment/x402.go` | x402 payment flow core — DoX402RequestStream, X402CallStream |
|
||||
| `mcp/client.go` | ParseSSEStream — shared SSE parsing function |
|
||||
|
||||
### Call Chain
|
||||
|
||||
```
|
||||
Claw402Client.Call()
|
||||
└→ X402CallStream() // x402.go:380
|
||||
├→ Build request body + inject stream:true
|
||||
├→ DoX402RequestStream() // x402.go:239
|
||||
│ ├→ Send initial request (no payment header)
|
||||
│ ├→ Receive 402 → parse Payment-Required header
|
||||
│ ├→ signFn() → EIP-712 signature
|
||||
│ └→ Send retry request with X-Payment header → return open *http.Response
|
||||
│
|
||||
├→ Start idle timeout watchdog (90s with no data → disconnect)
|
||||
├→ TeeReader: simultaneous SSE parsing + raw byte buffering
|
||||
├→ ParseSSEStream() // client.go:703
|
||||
│ ├→ bufio.Scanner line-by-line read
|
||||
│ ├→ Parse "data: {...}" → OpenAI chunk format
|
||||
│ └→ Accumulate text + call onChunk callback
|
||||
│
|
||||
└→ Fallback: if SSE yields nothing, try JSON parsing on buffered bodyBuf
|
||||
```
|
||||
|
||||
### Request Identification
|
||||
|
||||
Every request carries an `X-Client-ID: nofx` header (`x402.go:473`), allowing claw402 to identify the request source for logging and monitoring.
|
||||
|
||||
### Model Routing
|
||||
|
||||
`claw402ModelEndpoints` maps user-friendly model names to API paths:
|
||||
|
||||
```go
|
||||
"deepseek" → "/api/v1/ai/deepseek/chat"
|
||||
"gpt-5.4" → "/api/v1/ai/openai/chat/5.4"
|
||||
"claude-opus" → "/api/v1/ai/anthropic/messages/opus"
|
||||
"qwen-max" → "/api/v1/ai/qwen/chat/max"
|
||||
// ... more
|
||||
```
|
||||
|
||||
Anthropic endpoints (containing `/anthropic/`) automatically switch to the Messages API wire format.
|
||||
|
||||
## Server Implementation (claw402)
|
||||
|
||||
### Core Problem: ginmw Is Incompatible with SSE
|
||||
|
||||
Coinbase's standard Gin middleware `ginmw.PaymentMiddlewareFromConfig` internally works as follows:
|
||||
|
||||
```
|
||||
1. Wrap c.Writer with responseCapture (all writes go to buffer)
|
||||
2. c.Next() — handler runs, SSE chunks all go into buffer
|
||||
3. Settle payment after handler completes
|
||||
4. Write buffered content to client only after successful settlement
|
||||
```
|
||||
|
||||
Problems:
|
||||
- SSE chunks are buffered — the client receives no data for minutes
|
||||
- Cloudflare disconnects after 100 seconds → 520 error
|
||||
- Handler runs too long (5 min), settlement context expires
|
||||
|
||||
### Solution: streamAwareX402Middleware
|
||||
|
||||
Dual-path design (`internal/gateway/x402.go`):
|
||||
|
||||
```go
|
||||
func streamAwareX402Middleware(streamServer, standardMW) {
|
||||
return func(c *gin.Context) {
|
||||
if !isStreamingBody(c) {
|
||||
standardMW(c) // Non-streaming → standard ginmw (battle-tested)
|
||||
return
|
||||
}
|
||||
// Streaming → custom path
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Non-Streaming Path
|
||||
|
||||
Delegates entirely to `ginmw.PaymentMiddlewareFromConfig` with no custom logic.
|
||||
|
||||
#### Streaming Path (Pre-Settlement)
|
||||
|
||||
```
|
||||
1. isStreamingBody(c) — read body to check for {"stream": true}, restore body
|
||||
2. streamServer.RequiresPayment(reqCtx) — does this route require payment?
|
||||
3. streamServer.ProcessHTTPRequest() — verify X-Payment signature
|
||||
4. handleStreamingPayment():
|
||||
a. ProcessSettlement() — settle USDC on-chain (collect payment first)
|
||||
b. c.Next() — pass to HandleAPIKeyStream
|
||||
c. SSE chunks write directly to c.Writer (no responseCapture buffer)
|
||||
```
|
||||
|
||||
Key differences:
|
||||
|
||||
| | Standard ginmw (non-streaming) | Custom path (streaming) |
|
||||
|---|---|---|
|
||||
| Settlement timing | **After** handler completes | **Before** handler starts |
|
||||
| Response buffer | `responseCapture` buffers everything | No buffer, writes directly to client |
|
||||
| Timeout risk | Slow handler causes context expiry | Settlement uses `context.Background()` |
|
||||
| SSE compatible | No | Yes |
|
||||
|
||||
## Billing Logic
|
||||
|
||||
### x402 Protocol Flow
|
||||
|
||||
x402 is an HTTP 402 payment protocol proposed by Coinbase. Core roles:
|
||||
|
||||
- **Resource Server** (claw402) — provides paid APIs
|
||||
- **Client** (NOFX) — consumer, holds an EVM wallet
|
||||
- **Facilitator** (Coinbase CDP) — verifies signatures, executes on-chain settlement
|
||||
|
||||
### Payment Signing (EIP-712)
|
||||
|
||||
Client signature type: USDC `TransferWithAuthorization`
|
||||
|
||||
```
|
||||
1. Receive Payment-Required header from 402 response (base64 JSON)
|
||||
2. Decode to get:
|
||||
- scheme: "exact"
|
||||
- network: "eip155:8453" (Base L2)
|
||||
- amount: USDC amount (e.g., "3000" = $0.003)
|
||||
- asset: USDC contract address
|
||||
- payTo: claw402 recipient address
|
||||
3. Sign with wallet private key using EIP-712, authorizing USDC transfer from user wallet to payTo
|
||||
4. Place signature in X-Payment + Payment-Signature headers
|
||||
```
|
||||
|
||||
### Pricing Models
|
||||
|
||||
Each AI model route has its own price configured in claw402:
|
||||
|
||||
| Mode | Description | Example |
|
||||
|------|-------------|---------|
|
||||
| Fixed price | Specified directly via `user_price` field | `$0.003` per request |
|
||||
| Token-based dynamic pricing | Calculated from request token count | `$0.001` per 1K tokens |
|
||||
| Dispatch fallback | Default price for SDK-compatible routes | `$0.01` per request |
|
||||
|
||||
```go
|
||||
// Fixed price
|
||||
price := fmt.Sprintf("$%s", route.UserPrice)
|
||||
|
||||
// Dynamic pricing
|
||||
price = DynamicPriceFunc(func(ctx, reqCtx) (Price, error) {
|
||||
return resolveDynamicPrice(ctx, reqCtx, rule)
|
||||
})
|
||||
```
|
||||
|
||||
### Retry Logic and Double-Charge Prevention
|
||||
|
||||
```go
|
||||
const X402MaxPaymentRetries = 5
|
||||
const X402RetryBaseWait = 3 * time.Second
|
||||
```
|
||||
|
||||
- **5xx errors** → exponential backoff retry (3s, 6s, 9s...), no re-signing (same payment authorization)
|
||||
- **Another 402** → previous signature expired, re-sign and retry (on-chain authorization auto-invalidates, **no double charge**)
|
||||
- **4xx (non-402)** → non-retryable, fail immediately
|
||||
- Outer retry is set to 1 (`WithMaxRetries(1)`) to prevent outer retries from causing duplicate payments
|
||||
|
||||
### Settlement Timing: Streaming vs Non-Streaming
|
||||
|
||||
| | Non-Streaming | Streaming |
|
||||
|---|---|---|
|
||||
| Settlement timing | After receiving full response | Before streaming begins |
|
||||
| Risk | Low (content confirmed before charge) | Slightly higher (charge before seeing content) |
|
||||
| Necessity | Standard mode | Must charge first, otherwise SSE is buffered |
|
||||
|
||||
## Timeout Configuration
|
||||
|
||||
| Location | Timeout | Purpose |
|
||||
|----------|---------|---------|
|
||||
| NOFX `X402Timeout` | 5 min | HTTP client overall timeout |
|
||||
| NOFX `x402StreamIdleTimeout` | 90s | SSE idle disconnect (prevent hangs) |
|
||||
| NOFX `CallWithRequestStream` idle | 60s | Idle timeout for non-x402 streaming |
|
||||
| claw402 `ResponseHeaderTimeout` | 120s | Wait for first byte from AI upstream |
|
||||
| claw402 `streamingHTTP.Timeout` | 0 (unlimited) | SSE stream can last indefinitely |
|
||||
| claw402 `standardMW WithTimeout` | 10 min | Non-streaming ginmw overall timeout |
|
||||
| claw402 `x402PaymentTimeout` | 30s | Payment verification/settlement timeout |
|
||||
|
||||
## SSE Fault Tolerance
|
||||
|
||||
### TeeReader Dual Parsing
|
||||
|
||||
```go
|
||||
var bodyBuf bytes.Buffer
|
||||
tee := io.TeeReader(resp.Body, &bodyBuf)
|
||||
text, sseErr := ParseSSEStream(tee, onChunk, onLine)
|
||||
|
||||
if text != "" {
|
||||
return text, nil // SSE succeeded
|
||||
}
|
||||
// SSE yielded nothing → try JSON parsing on bodyBuf (server may have returned non-streaming JSON)
|
||||
jsonText, _ := ParseMCPResponse(bodyBuf.Bytes())
|
||||
```
|
||||
|
||||
### Idle Timeout Watchdog
|
||||
|
||||
```go
|
||||
go func() {
|
||||
t := time.NewTimer(90s)
|
||||
for {
|
||||
select {
|
||||
case <-t.C:
|
||||
cancel() // timeout → cancel context → close TCP → body.Read() returns error
|
||||
case <-resetCh:
|
||||
t.Reset(90s) // received SSE line → reset timer
|
||||
}
|
||||
}
|
||||
}()
|
||||
```
|
||||
|
||||
Every incoming SSE line resets the timer. If no data arrives for 90 seconds, the context is cancelled and the TCP connection is closed, preventing indefinite blocking.
|
||||
|
||||
## Related Files
|
||||
|
||||
### NOFX (Client)
|
||||
- `mcp/payment/claw402.go` — Claw402Client entry point
|
||||
- `mcp/payment/x402.go` — x402 payment flow (DoX402Request, DoX402RequestStream, X402CallStream)
|
||||
- `mcp/payment/x402_sign.go` — EIP-712 signing implementation
|
||||
- `mcp/client.go` — ParseSSEStream, CallWithRequestStream
|
||||
|
||||
### claw402 (Server)
|
||||
- `internal/gateway/x402.go` — x402 middleware (streamAwareX402Middleware)
|
||||
- `internal/gateway/proxy/stream.go` — SSE proxy (HandleAPIKeyStream)
|
||||
- `internal/config/` — Route configuration (pricing, model mapping)
|
||||
50
docs/community/OFFICIAL_ACCOUNTS.md
Normal file
50
docs/community/OFFICIAL_ACCOUNTS.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# ⚠️ Official Accounts & Anti-Impersonation Notice
|
||||
|
||||
## Legal Entity
|
||||
|
||||
| Field | Details |
|
||||
|-------|---------|
|
||||
| Company Name | **Cryonic Holdings Limited** |
|
||||
| Company No. | 2193977 |
|
||||
| Jurisdiction | British Virgin Islands |
|
||||
| Address | Mandar House, 3rd Floor, P.O. Box 2196, Johnson's Ghut, Tortola, BVI |
|
||||
| Contact Email | 0xccfelix@gmail.com |
|
||||
|
||||
## Official Social Media & Channels
|
||||
|
||||
| Platform | Official Account | Link | Status |
|
||||
|----------|-----------------|------|--------|
|
||||
| Twitter/X | **@nofx_official** | https://x.com/nofx_official | ✅ Official |
|
||||
| Twitter/X | **@Web3Tinkle** | https://x.com/Web3Tinkle | ✅ Founder |
|
||||
| GitHub | **NoFxAiOS** | https://github.com/NoFxAiOS | ✅ Official |
|
||||
| Website | **nofxai.com** | https://nofxai.com | ✅ Official |
|
||||
| Dashboard | **nofxos.ai** | https://nofxos.ai | ✅ Official |
|
||||
|
||||
## ⛔ Known Impersonation Accounts
|
||||
|
||||
The following accounts are **NOT affiliated** with the NoFx project:
|
||||
|
||||
| Platform | Account | Status |
|
||||
|----------|---------|--------|
|
||||
| Twitter/X | @nofx_ai | ❌ **NOT OFFICIAL** — Not affiliated with this project |
|
||||
|
||||
> **Warning:** Any account claiming to represent NoFx that is not listed above is unauthorized. Please verify through this page before trusting any account claiming to be associated with NoFx.
|
||||
|
||||
## How to Verify Authenticity
|
||||
|
||||
1. Check this page (OFFICIAL_ACCOUNTS.md) in our official GitHub repository
|
||||
2. Our GitHub repository sidebar links directly to our official Twitter
|
||||
3. Our README.md lists all official accounts under "Core Team" and "Official Links"
|
||||
4. Our operating entity is Cryonic Holdings Limited (BVI No. 2193977)
|
||||
5. Official contact email: 0xccfelix@gmail.com
|
||||
|
||||
## Report Impersonation
|
||||
|
||||
If you encounter accounts impersonating NoFx, please:
|
||||
1. Report them on the respective platform
|
||||
2. Open an issue in this repository to notify our team
|
||||
|
||||
---
|
||||
|
||||
*Last updated: 2026-03-01*
|
||||
*This document is maintained by Cryonic Holdings Limited in the official NoFx GitHub repository (10,500+ ⭐)*
|
||||
@@ -241,6 +241,7 @@ NOFX offers bounties for valuable contributions:
|
||||
- **Want to claim bounty?** → [Bounty Guide](bounty-guide.md)
|
||||
- **Found a security issue?** → [Security Policy](../../SECURITY.md)
|
||||
- **Have questions?** → [Telegram Community](https://t.me/nofx_dev_community)
|
||||
- **Verify official accounts?** → [Official Accounts & Anti-Impersonation](OFFICIAL_ACCOUNTS.md)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,258 +1,173 @@
|
||||
# NOFX - AI トレーディングシステム
|
||||
<h1 align="center">NOFX</h1>
|
||||
|
||||
[](https://golang.org/)
|
||||
[](https://reactjs.org/)
|
||||
[](https://www.typescriptlang.org/)
|
||||
[](LICENSE)
|
||||
<p align="center">
|
||||
<strong>あなた専属の AI トレーディングアシスタント。</strong><br/>
|
||||
<strong>あらゆる市場。あらゆるモデル。API キー不要、USDC で支払い。</strong>
|
||||
</p>
|
||||
|
||||
**言語:** [English](../../../README.md) | [中文](../zh-CN/README.md) | [日本語](README.md)
|
||||
<p align="center">
|
||||
<a href="https://github.com/NoFxAiOS/nofx/stargazers"><img src="https://img.shields.io/github/stars/NoFxAiOS/nofx?style=for-the-badge" alt="Stars"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/releases"><img src="https://img.shields.io/github/v/release/NoFxAiOS/nofx?style=for-the-badge" alt="Release"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-AGPL--3.0-blue.svg?style=for-the-badge" alt="License"></a>
|
||||
<a href="https://t.me/nofx_dev_community"><img src="https://img.shields.io/badge/Telegram-Community-blue?style=for-the-badge&logo=telegram" alt="Telegram"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://golang.org/"><img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go" alt="Go"></a>
|
||||
<a href="https://reactjs.org/"><img src="https://img.shields.io/badge/React-18+-61DAFB?style=flat&logo=react" alt="React"></a>
|
||||
<a href="https://x402.org"><img src="https://img.shields.io/badge/x402-USDC%20Payments-2775CA?style=flat" alt="x402"></a>
|
||||
<a href="https://claw402.ai"><img src="https://img.shields.io/badge/Claw402-AI%20Gateway-FF6B35?style=flat" alt="Claw402"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="../../../README.md">English</a> ·
|
||||
<a href="../zh-CN/README.md">中文</a> ·
|
||||
<a href="README.md">日本語</a> ·
|
||||
<a href="../ko/README.md">한국어</a> ·
|
||||
<a href="../ru/README.md">Русский</a> ·
|
||||
<a href="../uk/README.md">Українська</a> ·
|
||||
<a href="../vi/README.md">Tiếng Việt</a>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## AI 駆動の暗号通貨取引プラットフォーム
|
||||
NOFX はオープンソースの**自律型** AI トレーディングアシスタントです。従来の AI ツールのように手動でモデルを設定し、API キーを管理し、データソースを接続する必要はありません — NOFX の AI は**市場を自ら認識し、モデルを自ら選択し、データを自ら取得します**。人間の介入はゼロ。あなたは戦略を設定するだけ、残りは AI が処理します。
|
||||
|
||||
**NOFX** は、複数の AI モデルを使用して暗号通貨先物を自動取引できるオープンソースの AI 取引システムです。Web インターフェースで戦略を設定し、リアルタイムでパフォーマンスを監視し、AI エージェントを競わせて最適な取引アプローチを見つけます。
|
||||
**完全自律**: AI がどのモデルを使うか、どの市場データを取得するか、いつ取引するかを自ら判断します。手動のモデル設定不要。複数サービスの API キー管理不要。USDC ウォレットに入金して実行するだけ。
|
||||
|
||||
### コア機能
|
||||
|
||||
- **マルチ AI サポート**: DeepSeek、Qwen、GPT、Claude、Gemini、Grok、Kimi を実行 - いつでもモデルを切り替え可能
|
||||
- **マルチ取引所**: Binance、Bybit、OKX、Hyperliquid、Aster DEX、Lighter で統一取引
|
||||
- **ストラテジースタジオ**: コインソース、インジケーター、リスク管理を設定するビジュアル戦略ビルダー
|
||||
- **AI 競争モード**: 複数の AI トレーダーがリアルタイムで競争、パフォーマンスを並べて追跡
|
||||
- **Web ベース設定**: JSON 編集不要 - Web インターフェースですべて設定
|
||||
- **リアルタイムダッシュボード**: ライブポジション、損益追跡、思考連鎖付き AI 決定ログ
|
||||
|
||||
> **リスク警告**: このシステムは実験的です。AI 自動取引には重大なリスクがあります。学習/研究目的または少額でのテストのみを強くお勧めします!
|
||||
|
||||
## 開発者コミュニティ
|
||||
|
||||
Telegram 開発者コミュニティに参加: **[NOFX 開発者コミュニティ](https://t.me/nofx_dev_community)**
|
||||
|
||||
---
|
||||
|
||||
## クイックスタート
|
||||
|
||||
### オプション 1: Docker デプロイ(推奨)
|
||||
|
||||
```bash
|
||||
git clone https://github.com/NoFxAiOS/nofx.git
|
||||
cd nofx
|
||||
chmod +x ./start.sh
|
||||
./start.sh start --build
|
||||
```
|
||||
|
||||
Web インターフェースにアクセス: **http://localhost:3000**
|
||||
|
||||
### 最新版への更新
|
||||
|
||||
> **💡 更新は頻繁です。** 最新の機能と修正を取得するために、毎日このコマンドを実行してください:
|
||||
他との違い:**[x402](https://x402.org) マイクロペイメント内蔵**。API キー不要。USDC ウォレットに入金してリクエストごとに支払い。ウォレットがあなたの身分証明。
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
```
|
||||
|
||||
このコマンドは最新の公式イメージを取得し、サービスを自動的に再起動します。
|
||||
**http://127.0.0.1:3000** を開く。完了。
|
||||
|
||||
### オプション 2: 手動インストール
|
||||
---
|
||||
|
||||
## x402 の仕組み
|
||||
|
||||
従来のフロー:アカウント登録 → クレジット購入 → API キー取得 → クォータ管理 → キーのローテーション。
|
||||
|
||||
x402 フロー:
|
||||
|
||||
```
|
||||
リクエスト → 402(価格提示)→ ウォレットが USDC を署名 → リトライ → 完了
|
||||
```
|
||||
|
||||
アカウント不要。API キー不要。前払いクレジット不要。ウォレット1つで全モデル。
|
||||
|
||||
### 内蔵 x402 プロバイダー
|
||||
|
||||
| プロバイダー | チェーン | モデル |
|
||||
|:---------|:------|:-------|
|
||||
| <img src="../../../web/public/icons/claw402.png" width="20" height="20" style="vertical-align: middle;"/> **[Claw402](https://claw402.ai)** | Base | GPT-5.4, Claude Opus, DeepSeek, Qwen, Grok, Gemini, Kimi — 15+ モデル |
|
||||
|
||||
---
|
||||
|
||||
## 機能
|
||||
|
||||
| 機能 | 説明 |
|
||||
|:--------|:------------|
|
||||
| **マルチ AI** | DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi, MiniMax — いつでも切替 |
|
||||
| **マルチ取引所** | Binance, Bybit, OKX, Bitget, KuCoin, Gate, Hyperliquid, Aster, Lighter |
|
||||
| **ストラテジースタジオ** | ビジュアルビルダー — コインソース、インジケーター、リスク管理 |
|
||||
| **AI ディベートアリーナ** | 複数 AI が取引を議論(ブル vs ベア vs アナリスト)、投票、実行 |
|
||||
| **AI 競争** | AI がリアルタイムで競争、リーダーボードで成績ランキング |
|
||||
| **Telegram エージェント** | トレーディングアシスタントとチャット — ストリーミング、ツール呼び出し、メモリ |
|
||||
| **バックテストラボ** | 過去データシミュレーション、エクイティカーブと成績指標 |
|
||||
| **ダッシュボード** | ライブポジション、損益、Chain of Thought 付き AI 判断ログ |
|
||||
|
||||
### 市場
|
||||
|
||||
暗号通貨 · 米国株 · FX · 貴金属
|
||||
|
||||
### 取引所 (CEX)
|
||||
|
||||
| 取引所 | ステータス | 登録 (手数料割引) |
|
||||
|:---------|:------:|:------------------------|
|
||||
| <img src="../../../web/public/exchange-icons/binance.jpg" width="20" height="20" style="vertical-align: middle;"/> **Binance** | ✅ | [登録](https://www.binance.com/join?ref=NOFXENG) |
|
||||
| <img src="../../../web/public/exchange-icons/bybit.png" width="20" height="20" style="vertical-align: middle;"/> **Bybit** | ✅ | [登録](https://partner.bybit.com/b/83856) |
|
||||
| <img src="../../../web/public/exchange-icons/okx.svg" width="20" height="20" style="vertical-align: middle;"/> **OKX** | ✅ | [登録](https://www.okx.com/join/1865360) |
|
||||
| <img src="../../../web/public/exchange-icons/bitget.svg" width="20" height="20" style="vertical-align: middle;"/> **Bitget** | ✅ | [登録](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
|
||||
| <img src="../../../web/public/exchange-icons/kucoin.svg" width="20" height="20" style="vertical-align: middle;"/> **KuCoin** | ✅ | [登録](https://www.kucoin.com/r/broker/CXEV7XKK) |
|
||||
| <img src="../../../web/public/exchange-icons/gate.svg" width="20" height="20" style="vertical-align: middle;"/> **Gate** | ✅ | [登録](https://www.gatenode.xyz/share/VQBGUAxY) |
|
||||
|
||||
### 取引所 (Perp-DEX)
|
||||
|
||||
| 取引所 | ステータス | 登録 (手数料割引) |
|
||||
|:---------|:------:|:------------------------|
|
||||
| <img src="../../../web/public/exchange-icons/hyperliquid.png" width="20" height="20" style="vertical-align: middle;"/> **Hyperliquid** | ✅ | [登録](https://app.hyperliquid.xyz/join/AITRADING) |
|
||||
| <img src="../../../web/public/exchange-icons/aster.svg" width="20" height="20" style="vertical-align: middle;"/> **Aster DEX** | ✅ | [登録](https://www.asterdex.com/en/referral/fdfc0e) |
|
||||
| <img src="../../../web/public/exchange-icons/lighter.png" width="20" height="20" style="vertical-align: middle;"/> **Lighter** | ✅ | [登録](https://app.lighter.xyz/?referral=68151432) |
|
||||
|
||||
### AI モデル (API キーモード)
|
||||
|
||||
| AI モデル | ステータス | API キー取得 |
|
||||
|:---------|:------:|:------------|
|
||||
| <img src="../../../web/public/icons/deepseek.svg" width="20" height="20" style="vertical-align: middle;"/> **DeepSeek** | ✅ | [API キー取得](https://platform.deepseek.com) |
|
||||
| <img src="../../../web/public/icons/qwen.svg" width="20" height="20" style="vertical-align: middle;"/> **Qwen** | ✅ | [API キー取得](https://dashscope.console.aliyun.com) |
|
||||
| <img src="../../../web/public/icons/openai.svg" width="20" height="20" style="vertical-align: middle;"/> **OpenAI (GPT)** | ✅ | [API キー取得](https://platform.openai.com) |
|
||||
| <img src="../../../web/public/icons/claude.svg" width="20" height="20" style="vertical-align: middle;"/> **Claude** | ✅ | [API キー取得](https://console.anthropic.com) |
|
||||
| <img src="../../../web/public/icons/gemini.svg" width="20" height="20" style="vertical-align: middle;"/> **Gemini** | ✅ | [API キー取得](https://aistudio.google.com) |
|
||||
| <img src="../../../web/public/icons/grok.svg" width="20" height="20" style="vertical-align: middle;"/> **Grok** | ✅ | [API キー取得](https://console.x.ai) |
|
||||
| <img src="../../../web/public/icons/kimi.svg" width="20" height="20" style="vertical-align: middle;"/> **Kimi** | ✅ | [API キー取得](https://platform.moonshot.cn) |
|
||||
| <img src="../../../web/public/icons/minimax.svg" width="20" height="20" style="vertical-align: middle;"/> **MiniMax** | ✅ | [API キー取得](https://platform.minimaxi.com) |
|
||||
|
||||
### AI モデル (x402 モード — API キー不要)
|
||||
|
||||
15+ モデルを [Claw402](https://claw402.ai) 経由で利用 — USDC ウォレットのみ
|
||||
|
||||
---
|
||||
|
||||
## インストール
|
||||
|
||||
### Linux / macOS
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
```
|
||||
|
||||
### Railway (クラウド)
|
||||
|
||||
[](https://railway.com/deploy/nofx?referralCode=nofx)
|
||||
|
||||
### Docker
|
||||
|
||||
```bash
|
||||
curl -O https://raw.githubusercontent.com/NoFxAiOS/nofx/main/docker-compose.prod.yml
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
### ソースから
|
||||
|
||||
```bash
|
||||
# 前提条件: Go 1.21+, Node.js 18+, TA-Lib
|
||||
# macOS: brew install ta-lib
|
||||
|
||||
# TA-Lib インストール (macOS)
|
||||
brew install ta-lib
|
||||
|
||||
# クローンとセットアップ
|
||||
git clone https://github.com/NoFxAiOS/nofx.git
|
||||
cd nofx
|
||||
go mod download
|
||||
cd web && npm install && cd ..
|
||||
|
||||
# バックエンド起動
|
||||
go build -o nofx && ./nofx
|
||||
|
||||
# フロントエンド起動(新しいターミナル)
|
||||
cd web && npm run dev
|
||||
git clone https://github.com/NoFxAiOS/nofx.git && cd nofx
|
||||
go build -o nofx && ./nofx # バックエンド
|
||||
cd web && npm install && npm run dev # フロントエンド(新しいターミナル)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 初期設定
|
||||
## リンク
|
||||
|
||||
1. **AI モデル設定** - AI API キーを追加
|
||||
2. **取引所設定** - 取引所 API 認証情報を設定
|
||||
3. **戦略作成** - ストラテジースタジオで取引戦略を設定
|
||||
4. **トレーダー作成** - AI モデル + 取引所 + 戦略を組み合わせ
|
||||
5. **取引開始** - 設定したトレーダーを起動
|
||||
| | |
|
||||
|:--|:--|
|
||||
| ウェブサイト | [nofxai.com](https://nofxai.com) |
|
||||
| ダッシュボード | [nofxos.ai/dashboard](https://nofxos.ai/dashboard) |
|
||||
| API ドキュメント | [nofxos.ai/api-docs](https://nofxos.ai/api-docs) |
|
||||
| Telegram | [nofx_dev_community](https://t.me/nofx_dev_community) |
|
||||
| Twitter | [@nofx_official](https://x.com/nofx_official) |
|
||||
|
||||
> **リスク警告**: AI 自動取引には重大なリスクがあります。学習/研究目的または少額でのテストのみを推奨します。
|
||||
|
||||
---
|
||||
|
||||
## リスク警告
|
||||
## License
|
||||
|
||||
1. 暗号通貨市場は非常に変動が激しい - AI の決定は利益を保証しない
|
||||
2. 先物取引はレバレッジを使用 - 損失は元本を超える可能性がある
|
||||
3. 極端な市場状況では清算リスクがある
|
||||
|
||||
---
|
||||
|
||||
## サーバー展開
|
||||
|
||||
### クイックデプロイ (HTTP経由のIP)
|
||||
|
||||
デフォルトでは、トランスポート暗号化は**無効**になっており、HTTPSなしでIPアドレス経由でNOFXにアクセスできます:
|
||||
|
||||
```bash
|
||||
# サーバーにデプロイ
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
```
|
||||
|
||||
`http://YOUR_SERVER_IP:3000` 経由でアクセス - すぐに動作します。
|
||||
|
||||
### セキュリティ強化 (HTTPS)
|
||||
|
||||
セキュリティを強化するには、`.env`でトランスポート暗号化を有効にします:
|
||||
|
||||
```bash
|
||||
TRANSPORT_ENCRYPTION=true
|
||||
```
|
||||
|
||||
有効にすると、ブラウザはWeb Crypto APIを使用して転送前にAPIキーを暗号化します。これには以下が必要です:
|
||||
- `https://` - SSLを備えた任意のドメイン
|
||||
- `http://localhost` - ローカル開発
|
||||
|
||||
### Cloudflareを使用した簡単なHTTPSセットアップ
|
||||
|
||||
1. **ドメインをCloudflareに追加** (無料プランでOK)
|
||||
- [dash.cloudflare.com](https://dash.cloudflare.com) にアクセス
|
||||
- ドメインを追加してネームサーバーを更新
|
||||
|
||||
2. **DNSレコードを作成**
|
||||
- タイプ: `A`
|
||||
- 名前: `nofx` (またはサブドメイン)
|
||||
- コンテンツ: サーバーのIP
|
||||
- プロキシ状態: **Proxied** (オレンジ色の雲)
|
||||
|
||||
3. **SSL/TLSを設定**
|
||||
- SSL/TLS設定に移動
|
||||
- 暗号化モードを **Flexible** に設定
|
||||
|
||||
```
|
||||
User ──[HTTPS]──→ Cloudflare ──[HTTP]──→ Your Server:3000
|
||||
```
|
||||
|
||||
4. **トランスポート暗号化を有効化**
|
||||
```bash
|
||||
# .envを編集して設定
|
||||
TRANSPORT_ENCRYPTION=true
|
||||
```
|
||||
|
||||
5. **完了!** `https://nofx.yourdomain.com` 経由でアクセス
|
||||
|
||||
---
|
||||
|
||||
## 初期設定 (Webインターフェース)
|
||||
|
||||
システムを起動した後、Webインターフェースを通じて設定します:
|
||||
|
||||
1. **AIモデルの設定** - AI APIキーを追加 (DeepSeek、OpenAI など)
|
||||
2. **取引所の設定** - 取引所API認証情報を設定
|
||||
3. **戦略の作成** - ストラテジースタジオで取引戦略を設定
|
||||
4. **トレーダーの作成** - AIモデル + 取引所 + 戦略を組み合わせ
|
||||
5. **取引開始** - 設定したトレーダーを起動
|
||||
|
||||
すべての設定はWebインターフェースで完了 - JSONファイルの編集は不要です。
|
||||
|
||||
---
|
||||
|
||||
## Webインターフェース機能
|
||||
|
||||
### 競争ページ
|
||||
- リアルタイムROIリーダーボード
|
||||
- マルチAIパフォーマンス比較チャート
|
||||
- ライブ損益追跡とランキング
|
||||
|
||||
### ダッシュボード
|
||||
- TradingViewスタイルのローソク足チャート
|
||||
- リアルタイムポジション管理
|
||||
- Chain of Thought推論付きAI決定ログ
|
||||
- エクイティカーブ追跡
|
||||
|
||||
### ストラテジースタジオ
|
||||
- コインソース設定 (静的リスト、AI500プール、OI Top)
|
||||
- テクニカル指標 (EMA、MACD、RSI、ATR、出来高、OI、資金調達率)
|
||||
- リスク管理設定 (レバレッジ、ポジション制限、証拠金使用率)
|
||||
- リアルタイムプロンプトプレビュー付きAIテスト
|
||||
|
||||
---
|
||||
|
||||
## よくある問題
|
||||
|
||||
### TA-Libが見つからない
|
||||
```bash
|
||||
# macOS
|
||||
brew install ta-lib
|
||||
|
||||
# Ubuntu
|
||||
sudo apt-get install libta-lib0-dev
|
||||
```
|
||||
|
||||
### AI APIタイムアウト
|
||||
- APIキーが正しいか確認
|
||||
- ネットワーク接続を確認
|
||||
- システムタイムアウトは120秒
|
||||
|
||||
### フロントエンドがバックエンドに接続できない
|
||||
- バックエンドが http://localhost:8080 で実行されているか確認
|
||||
- ポートが占有されていないか確認
|
||||
|
||||
---
|
||||
|
||||
## ライセンス
|
||||
|
||||
このプロジェクトは **GNU Affero General Public License v3.0 (AGPL-3.0)** の下でライセンスされています - [LICENSE](LICENSE) ファイルを参照してください。
|
||||
|
||||
---
|
||||
|
||||
## 貢献
|
||||
|
||||
貢献を歓迎します!以下を参照してください:
|
||||
- **[貢献ガイド](CONTRIBUTING.md)** - 開発ワークフローとPRプロセス
|
||||
- **[行動規範](CODE_OF_CONDUCT.md)** - コミュニティガイドライン
|
||||
- **[セキュリティポリシー](SECURITY.md)** - 脆弱性の報告
|
||||
|
||||
---
|
||||
|
||||
## 貢献者エアドロッププログラム
|
||||
|
||||
すべての貢献はGitHubで追跡されます。NOFXが収益を生み出すと、貢献者は貢献に基づいてエアドロップを受け取ります。
|
||||
|
||||
**[ピン留めされたIssue](https://github.com/NoFxAiOS/nofx/issues)を解決するPRは最高報酬を受け取ります!**
|
||||
|
||||
| 貢献タイプ | 重み |
|
||||
|------------------|:------:|
|
||||
| **ピン留めIssue PR** | ⭐⭐⭐⭐⭐⭐ |
|
||||
| **コードコミット** (マージされたPR) | ⭐⭐⭐⭐⭐ |
|
||||
| **バグ修正** | ⭐⭐⭐⭐ |
|
||||
| **機能提案** | ⭐⭐⭐ |
|
||||
| **バグ報告** | ⭐⭐ |
|
||||
| **ドキュメント** | ⭐⭐ |
|
||||
|
||||
---
|
||||
|
||||
## リスク警告
|
||||
|
||||
1. 暗号通貨市場は非常に変動が激しい - AIの決定は利益を保証しない
|
||||
2. 先物取引はレバレッジを使用 - 損失は元本を超える可能性がある
|
||||
3. 極端な市場状況では清算リスクがある
|
||||
|
||||
|
||||
|
||||
## コンタクト
|
||||
|
||||
- **GitHub Issues**: [Issue を提出](https://github.com/NoFxAiOS/nofx/issues)
|
||||
- **開発者コミュニティ**: [Telegram グループ](https://t.me/nofx_dev_community)
|
||||
|
||||
---
|
||||
|
||||
## Star History
|
||||
[AGPL-3.0](../../../LICENSE)
|
||||
|
||||
[](https://star-history.com/#NoFxAiOS/nofx&Date)
|
||||
|
||||
@@ -1,259 +1,173 @@
|
||||
# NOFX - AI 트레이딩 시스템
|
||||
<h1 align="center">NOFX</h1>
|
||||
|
||||
[](https://golang.org/)
|
||||
[](https://reactjs.org/)
|
||||
[](https://www.typescriptlang.org/)
|
||||
[](LICENSE)
|
||||
<p align="center">
|
||||
<strong>당신만의 AI 트레이딩 어시스턴트.</strong><br/>
|
||||
<strong>모든 시장. 모든 모델. API 키 없이 USDC로 결제.</strong>
|
||||
</p>
|
||||
|
||||
**언어:** [English](../../../README.md) | [中文](../zh-CN/README.md) | [한국어](README.md)
|
||||
<p align="center">
|
||||
<a href="https://github.com/NoFxAiOS/nofx/stargazers"><img src="https://img.shields.io/github/stars/NoFxAiOS/nofx?style=for-the-badge" alt="Stars"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/releases"><img src="https://img.shields.io/github/v/release/NoFxAiOS/nofx?style=for-the-badge" alt="Release"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-AGPL--3.0-blue.svg?style=for-the-badge" alt="License"></a>
|
||||
<a href="https://t.me/nofx_dev_community"><img src="https://img.shields.io/badge/Telegram-Community-blue?style=for-the-badge&logo=telegram" alt="Telegram"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://golang.org/"><img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go" alt="Go"></a>
|
||||
<a href="https://reactjs.org/"><img src="https://img.shields.io/badge/React-18+-61DAFB?style=flat&logo=react" alt="React"></a>
|
||||
<a href="https://x402.org"><img src="https://img.shields.io/badge/x402-USDC%20Payments-2775CA?style=flat" alt="x402"></a>
|
||||
<a href="https://claw402.ai"><img src="https://img.shields.io/badge/Claw402-AI%20Gateway-FF6B35?style=flat" alt="Claw402"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="../../../README.md">English</a> ·
|
||||
<a href="../zh-CN/README.md">中文</a> ·
|
||||
<a href="../ja/README.md">日本語</a> ·
|
||||
<a href="README.md">한국어</a> ·
|
||||
<a href="../ru/README.md">Русский</a> ·
|
||||
<a href="../uk/README.md">Українська</a> ·
|
||||
<a href="../vi/README.md">Tiếng Việt</a>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## AI 기반 암호화폐 거래 플랫폼
|
||||
NOFX는 오픈소스 **자율형** AI 트레이딩 어시스턴트입니다. 수동으로 모델을 설정하고, API 키를 관리하고, 데이터 소스를 연결해야 하는 기존 AI 도구와 달리 — NOFX의 AI는 **시장을 스스로 인식하고, 모델을 스스로 선택하고, 데이터를 스스로 가져옵니다**. 인간 개입 제로. 전략만 설정하면 나머지는 AI가 처리합니다.
|
||||
|
||||
**NOFX**는 여러 AI 모델을 실행하여 암호화폐 선물을 자동으로 거래할 수 있는 오픈소스 AI 거래 시스템입니다. 웹 인터페이스를 통해 전략을 구성하고, 실시간으로 성과를 모니터링하며, AI 에이전트들이 최적의 거래 방식을 찾도록 경쟁시킵니다.
|
||||
**완전 자율**: AI가 어떤 모델을 사용할지, 어떤 시장 데이터를 가져올지, 언제 거래할지를 스스로 결정합니다. 수동 모델 설정 불필요. 여러 서비스의 API 키 관리 불필요. USDC 지갑에 충전하고 실행하기만 하면 됩니다.
|
||||
|
||||
### 핵심 기능
|
||||
|
||||
- **다중 AI 지원**: DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi 실행 - 언제든 모델 전환 가능
|
||||
- **다중 거래소**: Binance, Bybit, OKX, Hyperliquid, Aster DEX, Lighter에서 통합 거래
|
||||
- **전략 스튜디오**: 코인 소스, 지표, 리스크 제어를 설정하는 시각적 전략 빌더
|
||||
- **AI 경쟁 모드**: 여러 AI 트레이더가 실시간으로 경쟁, 성과를 나란히 추적
|
||||
- **웹 기반 설정**: JSON 편집 불필요 - 웹 인터페이스에서 모든 설정 완료
|
||||
- **실시간 대시보드**: 실시간 포지션, 손익 추적, 사고의 연쇄가 포함된 AI 결정 로그
|
||||
|
||||
> **위험 경고**: 이 시스템은 실험적입니다. AI 자동 거래에는 상당한 위험이 있습니다. 학습/연구 목적 또는 소액 테스트만 강력히 권장합니다!
|
||||
|
||||
## 개발자 커뮤니티
|
||||
|
||||
Telegram 개발자 커뮤니티 참여: **[NOFX 개발자 커뮤니티](https://t.me/nofx_dev_community)**
|
||||
|
||||
---
|
||||
|
||||
## 빠른 시작
|
||||
|
||||
### 옵션 1: Docker 배포 (권장)
|
||||
|
||||
```bash
|
||||
git clone https://github.com/NoFxAiOS/nofx.git
|
||||
cd nofx
|
||||
chmod +x ./start.sh
|
||||
./start.sh start --build
|
||||
```
|
||||
|
||||
웹 인터페이스 접속: **http://localhost:3000**
|
||||
|
||||
### 최신 버전 유지
|
||||
|
||||
> **💡 업데이트가 빈번합니다.** 최신 기능과 수정 사항을 받으려면 매일 이 명령을 실행하세요:
|
||||
차별점: **[x402](https://x402.org) 마이크로 결제 내장**. API 키 불필요. USDC 지갑에 충전하고 요청마다 결제. 지갑이 곧 신원.
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
```
|
||||
|
||||
이 명령은 최신 공식 이미지를 가져오고 서비스를 자동으로 다시 시작합니다.
|
||||
**http://127.0.0.1:3000** 을 열면 완료.
|
||||
|
||||
### 옵션 2: 수동 설치
|
||||
---
|
||||
|
||||
## x402 작동 방식
|
||||
|
||||
기존 플로우: 계정 등록 → 크레딧 구매 → API 키 받기 → 쿼터 관리 → 키 교체.
|
||||
|
||||
x402 플로우:
|
||||
|
||||
```
|
||||
요청 → 402 (가격 제시) → 지갑이 USDC 서명 → 재시도 → 완료
|
||||
```
|
||||
|
||||
계정 불필요. API 키 불필요. 선불 크레딧 불필요. 지갑 하나로 모든 모델.
|
||||
|
||||
### 내장 x402 프로바이더
|
||||
|
||||
| 프로바이더 | 체인 | 모델 |
|
||||
|:---------|:------|:-------|
|
||||
| <img src="../../../web/public/icons/claw402.png" width="20" height="20" style="vertical-align: middle;"/> **[Claw402](https://claw402.ai)** | Base | GPT-5.4, Claude Opus, DeepSeek, Qwen, Grok, Gemini, Kimi — 15+ 모델 |
|
||||
|
||||
---
|
||||
|
||||
## 기능
|
||||
|
||||
| 기능 | 설명 |
|
||||
|:--------|:------------|
|
||||
| **멀티 AI** | DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi, MiniMax — 언제든 전환 |
|
||||
| **멀티 거래소** | Binance, Bybit, OKX, Bitget, KuCoin, Gate, Hyperliquid, Aster, Lighter |
|
||||
| **전략 스튜디오** | 비주얼 빌더 — 코인 소스, 지표, 리스크 관리 |
|
||||
| **AI 토론 아레나** | 여러 AI가 거래 토론 (강세 vs 약세 vs 분석가), 투표, 실행 |
|
||||
| **AI 경쟁** | AI가 실시간 경쟁, 리더보드 순위 |
|
||||
| **Telegram 에이전트** | 트레이딩 어시스턴트와 채팅 — 스트리밍, 도구 호출, 메모리 |
|
||||
| **백테스트 랩** | 과거 시뮬레이션, 자산 곡선 및 성과 지표 |
|
||||
| **대시보드** | 실시간 포지션, 손익, Chain of Thought AI 결정 로그 |
|
||||
|
||||
### 시장
|
||||
|
||||
암호화폐 · 미국 주식 · 외환 · 귀금속
|
||||
|
||||
### 거래소 (CEX)
|
||||
|
||||
| 거래소 | 상태 | 등록 (수수료 할인) |
|
||||
|:---------|:------:|:------------------------|
|
||||
| <img src="../../../web/public/exchange-icons/binance.jpg" width="20" height="20" style="vertical-align: middle;"/> **Binance** | ✅ | [등록](https://www.binance.com/join?ref=NOFXENG) |
|
||||
| <img src="../../../web/public/exchange-icons/bybit.png" width="20" height="20" style="vertical-align: middle;"/> **Bybit** | ✅ | [등록](https://partner.bybit.com/b/83856) |
|
||||
| <img src="../../../web/public/exchange-icons/okx.svg" width="20" height="20" style="vertical-align: middle;"/> **OKX** | ✅ | [등록](https://www.okx.com/join/1865360) |
|
||||
| <img src="../../../web/public/exchange-icons/bitget.svg" width="20" height="20" style="vertical-align: middle;"/> **Bitget** | ✅ | [등록](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
|
||||
| <img src="../../../web/public/exchange-icons/kucoin.svg" width="20" height="20" style="vertical-align: middle;"/> **KuCoin** | ✅ | [등록](https://www.kucoin.com/r/broker/CXEV7XKK) |
|
||||
| <img src="../../../web/public/exchange-icons/gate.svg" width="20" height="20" style="vertical-align: middle;"/> **Gate** | ✅ | [등록](https://www.gatenode.xyz/share/VQBGUAxY) |
|
||||
|
||||
### 거래소 (Perp-DEX)
|
||||
|
||||
| 거래소 | 상태 | 등록 (수수료 할인) |
|
||||
|:---------|:------:|:------------------------|
|
||||
| <img src="../../../web/public/exchange-icons/hyperliquid.png" width="20" height="20" style="vertical-align: middle;"/> **Hyperliquid** | ✅ | [등록](https://app.hyperliquid.xyz/join/AITRADING) |
|
||||
| <img src="../../../web/public/exchange-icons/aster.svg" width="20" height="20" style="vertical-align: middle;"/> **Aster DEX** | ✅ | [등록](https://www.asterdex.com/en/referral/fdfc0e) |
|
||||
| <img src="../../../web/public/exchange-icons/lighter.png" width="20" height="20" style="vertical-align: middle;"/> **Lighter** | ✅ | [등록](https://app.lighter.xyz/?referral=68151432) |
|
||||
|
||||
### AI 모델 (API 키 모드)
|
||||
|
||||
| AI 모델 | 상태 | API 키 받기 |
|
||||
|:---------|:------:|:------------|
|
||||
| <img src="../../../web/public/icons/deepseek.svg" width="20" height="20" style="vertical-align: middle;"/> **DeepSeek** | ✅ | [API 키 받기](https://platform.deepseek.com) |
|
||||
| <img src="../../../web/public/icons/qwen.svg" width="20" height="20" style="vertical-align: middle;"/> **Qwen** | ✅ | [API 키 받기](https://dashscope.console.aliyun.com) |
|
||||
| <img src="../../../web/public/icons/openai.svg" width="20" height="20" style="vertical-align: middle;"/> **OpenAI (GPT)** | ✅ | [API 키 받기](https://platform.openai.com) |
|
||||
| <img src="../../../web/public/icons/claude.svg" width="20" height="20" style="vertical-align: middle;"/> **Claude** | ✅ | [API 키 받기](https://console.anthropic.com) |
|
||||
| <img src="../../../web/public/icons/gemini.svg" width="20" height="20" style="vertical-align: middle;"/> **Gemini** | ✅ | [API 키 받기](https://aistudio.google.com) |
|
||||
| <img src="../../../web/public/icons/grok.svg" width="20" height="20" style="vertical-align: middle;"/> **Grok** | ✅ | [API 키 받기](https://console.x.ai) |
|
||||
| <img src="../../../web/public/icons/kimi.svg" width="20" height="20" style="vertical-align: middle;"/> **Kimi** | ✅ | [API 키 받기](https://platform.moonshot.cn) |
|
||||
| <img src="../../../web/public/icons/minimax.svg" width="20" height="20" style="vertical-align: middle;"/> **MiniMax** | ✅ | [API 키 받기](https://platform.minimaxi.com) |
|
||||
|
||||
### AI 모델 (x402 모드 — API 키 불필요)
|
||||
|
||||
15+ 모델을 [Claw402](https://claw402.ai)로 이용 — USDC 지갑만 있으면 됩니다
|
||||
|
||||
---
|
||||
|
||||
## 설치
|
||||
|
||||
### Linux / macOS
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
```
|
||||
|
||||
### Railway (클라우드)
|
||||
|
||||
[](https://railway.com/deploy/nofx?referralCode=nofx)
|
||||
|
||||
### Docker
|
||||
|
||||
```bash
|
||||
curl -O https://raw.githubusercontent.com/NoFxAiOS/nofx/main/docker-compose.prod.yml
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
### 소스에서
|
||||
|
||||
```bash
|
||||
# 필수 조건: Go 1.21+, Node.js 18+, TA-Lib
|
||||
# macOS: brew install ta-lib
|
||||
|
||||
# TA-Lib 설치 (macOS)
|
||||
brew install ta-lib
|
||||
|
||||
# 클론 및 설정
|
||||
git clone https://github.com/NoFxAiOS/nofx.git
|
||||
cd nofx
|
||||
go mod download
|
||||
cd web && npm install && cd ..
|
||||
|
||||
# 백엔드 시작
|
||||
go build -o nofx && ./nofx
|
||||
|
||||
# 프론트엔드 시작 (새 터미널)
|
||||
cd web && npm run dev
|
||||
git clone https://github.com/NoFxAiOS/nofx.git && cd nofx
|
||||
go build -o nofx && ./nofx # 백엔드
|
||||
cd web && npm install && npm run dev # 프론트엔드 (새 터미널)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 초기 설정
|
||||
## 링크
|
||||
|
||||
1. **AI 모델 설정** - AI API 키 추가
|
||||
2. **거래소 설정** - 거래소 API 자격 증명 설정
|
||||
3. **전략 생성** - 전략 스튜디오에서 거래 전략 구성
|
||||
4. **트레이더 생성** - AI 모델 + 거래소 + 전략 조합
|
||||
5. **거래 시작** - 설정된 트레이더 시작
|
||||
| | |
|
||||
|:--|:--|
|
||||
| 웹사이트 | [nofxai.com](https://nofxai.com) |
|
||||
| 대시보드 | [nofxos.ai/dashboard](https://nofxos.ai/dashboard) |
|
||||
| API 문서 | [nofxos.ai/api-docs](https://nofxos.ai/api-docs) |
|
||||
| Telegram | [nofx_dev_community](https://t.me/nofx_dev_community) |
|
||||
| Twitter | [@nofx_official](https://x.com/nofx_official) |
|
||||
|
||||
> **위험 경고**: AI 자동 거래에는 상당한 위험이 있습니다. 학습/연구 또는 소액 테스트만 권장합니다.
|
||||
|
||||
---
|
||||
|
||||
## 위험 경고
|
||||
## License
|
||||
|
||||
1. 암호화폐 시장은 매우 변동성이 높음 - AI 결정이 수익을 보장하지 않음
|
||||
2. 선물 거래는 레버리지 사용 - 손실이 원금을 초과할 수 있음
|
||||
3. 극단적인 시장 상황에서 청산 위험 있음
|
||||
|
||||
---
|
||||
|
||||
## 서버 배포
|
||||
|
||||
### 빠른 배포 (IP를 통한 HTTP)
|
||||
|
||||
기본적으로 전송 암호화가 **비활성화**되어 HTTPS 없이 IP 주소를 통해 NOFX에 액세스할 수 있습니다:
|
||||
|
||||
```bash
|
||||
# 서버에 배포
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
```
|
||||
|
||||
`http://YOUR_SERVER_IP:3000`을 통해 액세스 - 즉시 작동합니다.
|
||||
|
||||
### 향상된 보안 (HTTPS)
|
||||
|
||||
보안을 강화하려면 `.env`에서 전송 암호화를 활성화하세요:
|
||||
|
||||
```bash
|
||||
TRANSPORT_ENCRYPTION=true
|
||||
```
|
||||
|
||||
활성화되면 브라우저는 Web Crypto API를 사용하여 전송 전에 API 키를 암호화합니다. 이를 위해 필요한 것:
|
||||
- `https://` - SSL이 있는 모든 도메인
|
||||
- `http://localhost` - 로컬 개발
|
||||
|
||||
### Cloudflare를 사용한 빠른 HTTPS 설정
|
||||
|
||||
1. **Cloudflare에 도메인 추가** (무료 플랜 가능)
|
||||
- [dash.cloudflare.com](https://dash.cloudflare.com) 방문
|
||||
- 도메인 추가 및 네임서버 업데이트
|
||||
|
||||
2. **DNS 레코드 생성**
|
||||
- 유형: `A`
|
||||
- 이름: `nofx` (또는 서브도메인)
|
||||
- 콘텐츠: 서버 IP
|
||||
- 프록시 상태: **Proxied** (주황색 구름)
|
||||
|
||||
3. **SSL/TLS 구성**
|
||||
- SSL/TLS 설정으로 이동
|
||||
- 암호화 모드를 **Flexible**로 설정
|
||||
|
||||
```
|
||||
User ──[HTTPS]──→ Cloudflare ──[HTTP]──→ Your Server:3000
|
||||
```
|
||||
|
||||
4. **전송 암호화 활성화**
|
||||
```bash
|
||||
# .env 편집 및 설정
|
||||
TRANSPORT_ENCRYPTION=true
|
||||
```
|
||||
|
||||
5. **완료!** `https://nofx.yourdomain.com`을 통해 액세스
|
||||
|
||||
---
|
||||
|
||||
## 초기 설정 (웹 인터페이스)
|
||||
|
||||
시스템을 시작한 후 웹 인터페이스를 통해 구성합니다:
|
||||
|
||||
1. **AI 모델 구성** - AI API 키 추가 (DeepSeek, OpenAI 등)
|
||||
2. **거래소 구성** - 거래소 API 자격 증명 설정
|
||||
3. **전략 생성** - 전략 스튜디오에서 거래 전략 구성
|
||||
4. **트레이더 생성** - AI 모델 + 거래소 + 전략 결합
|
||||
5. **거래 시작** - 구성된 트레이더 시작
|
||||
|
||||
모든 구성은 웹 인터페이스를 통해 완료 - JSON 파일 편집 불필요.
|
||||
|
||||
---
|
||||
|
||||
## 웹 인터페이스 기능
|
||||
|
||||
### 경쟁 페이지
|
||||
- 실시간 ROI 리더보드
|
||||
- 다중 AI 성능 비교 차트
|
||||
- 실시간 손익 추적 및 순위
|
||||
|
||||
### 대시보드
|
||||
- TradingView 스타일 캔들스틱 차트
|
||||
- 실시간 포지션 관리
|
||||
- Chain of Thought 추론이 포함된 AI 결정 로그
|
||||
- 자본 곡선 추적
|
||||
|
||||
### 전략 스튜디오
|
||||
- 코인 소스 구성 (정적 목록, AI500 풀, OI Top)
|
||||
- 기술 지표 (EMA, MACD, RSI, ATR, 거래량, OI, 펀딩 비율)
|
||||
- 리스크 제어 설정 (레버리지, 포지션 한도, 마진 사용)
|
||||
- 실시간 프롬프트 미리보기를 포함한 AI 테스트
|
||||
|
||||
---
|
||||
|
||||
## 일반적인 문제
|
||||
|
||||
### TA-Lib을 찾을 수 없음
|
||||
```bash
|
||||
# macOS
|
||||
brew install ta-lib
|
||||
|
||||
# Ubuntu
|
||||
sudo apt-get install libta-lib0-dev
|
||||
```
|
||||
|
||||
### AI API 타임아웃
|
||||
- API 키가 올바른지 확인
|
||||
- 네트워크 연결 확인
|
||||
- 시스템 타임아웃은 120초
|
||||
|
||||
### 프론트엔드가 백엔드에 연결할 수 없음
|
||||
- 백엔드가 http://localhost:8080에서 실행 중인지 확인
|
||||
- 포트가 점유되어 있지 않은지 확인
|
||||
|
||||
---
|
||||
|
||||
## 라이선스
|
||||
|
||||
이 프로젝트는 **GNU Affero General Public License v3.0 (AGPL-3.0)** 라이선스에 따라 제공됩니다 - [LICENSE](LICENSE) 파일을 참조하세요.
|
||||
|
||||
---
|
||||
|
||||
## 기여
|
||||
|
||||
기여를 환영합니다! 다음을 참조하세요:
|
||||
- **[기여 가이드](CONTRIBUTING.md)** - 개발 워크플로 및 PR 프로세스
|
||||
- **[행동 강령](CODE_OF_CONDUCT.md)** - 커뮤니티 가이드라인
|
||||
- **[보안 정책](SECURITY.md)** - 취약점 보고
|
||||
|
||||
---
|
||||
|
||||
## 기여자 에어드롭 프로그램
|
||||
|
||||
모든 기여는 GitHub에서 추적됩니다. NOFX가 수익을 창출하면 기여자는 기여도에 따라 에어드롭을 받게 됩니다.
|
||||
|
||||
**[고정된 Issue](https://github.com/NoFxAiOS/nofx/issues)를 해결하는 PR은 최고 보상을 받습니다!**
|
||||
|
||||
| 기여 유형 | 가중치 |
|
||||
|------------------|:------:|
|
||||
| **고정된 Issue PR** | ⭐⭐⭐⭐⭐⭐ |
|
||||
| **코드 커밋** (병합된 PR) | ⭐⭐⭐⭐⭐ |
|
||||
| **버그 수정** | ⭐⭐⭐⭐ |
|
||||
| **기능 제안** | ⭐⭐⭐ |
|
||||
| **버그 보고** | ⭐⭐ |
|
||||
| **문서** | ⭐⭐ |
|
||||
|
||||
---
|
||||
|
||||
## 위험 경고
|
||||
|
||||
1. 암호화폐 시장은 매우 변동성이 높음 - AI 결정이 수익을 보장하지 않음
|
||||
2. 선물 거래는 레버리지 사용 - 손실이 원금을 초과할 수 있음
|
||||
3. 극단적인 시장 상황에서 청산 위험 있음
|
||||
|
||||
|
||||
|
||||
|
||||
## 연락처
|
||||
|
||||
- **GitHub Issues**: [Issue 제출](https://github.com/NoFxAiOS/nofx/issues)
|
||||
- **개발자 커뮤니티**: [Telegram 그룹](https://t.me/nofx_dev_community)
|
||||
|
||||
---
|
||||
|
||||
## Star History
|
||||
[AGPL-3.0](../../../LICENSE)
|
||||
|
||||
[](https://star-history.com/#NoFxAiOS/nofx&Date)
|
||||
|
||||
@@ -1,106 +1,174 @@
|
||||
# NOFX - AI Торговая Система
|
||||
<h1 align="center">NOFX</h1>
|
||||
|
||||
[](https://golang.org/)
|
||||
[](https://reactjs.org/)
|
||||
[](https://www.typescriptlang.org/)
|
||||
[](LICENSE)
|
||||
<p align="center">
|
||||
<strong>Ваш персональный AI торговый ассистент.</strong><br/>
|
||||
<strong>Любой рынок. Любая модель. Оплата USDC, без API ключей.</strong>
|
||||
</p>
|
||||
|
||||
**Языки:** [English](../../../README.md) | [中文](../zh-CN/README.md) | [Русский](README.md)
|
||||
<p align="center">
|
||||
<a href="https://github.com/NoFxAiOS/nofx/stargazers"><img src="https://img.shields.io/github/stars/NoFxAiOS/nofx?style=for-the-badge" alt="Stars"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/releases"><img src="https://img.shields.io/github/v/release/NoFxAiOS/nofx?style=for-the-badge" alt="Release"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-AGPL--3.0-blue.svg?style=for-the-badge" alt="License"></a>
|
||||
<a href="https://t.me/nofx_dev_community"><img src="https://img.shields.io/badge/Telegram-Community-blue?style=for-the-badge&logo=telegram" alt="Telegram"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://golang.org/"><img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go" alt="Go"></a>
|
||||
<a href="https://reactjs.org/"><img src="https://img.shields.io/badge/React-18+-61DAFB?style=flat&logo=react" alt="React"></a>
|
||||
<a href="https://x402.org"><img src="https://img.shields.io/badge/x402-USDC%20Payments-2775CA?style=flat" alt="x402"></a>
|
||||
<a href="https://claw402.ai"><img src="https://img.shields.io/badge/Claw402-AI%20Gateway-FF6B35?style=flat" alt="Claw402"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="../../../README.md">English</a> ·
|
||||
<a href="../zh-CN/README.md">中文</a> ·
|
||||
<a href="../ja/README.md">日本語</a> ·
|
||||
<a href="../ko/README.md">한국어</a> ·
|
||||
<a href="README.md">Русский</a> ·
|
||||
<a href="../uk/README.md">Українська</a> ·
|
||||
<a href="../vi/README.md">Tiếng Việt</a>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## Криптовалютная торговая платформа на базе ИИ
|
||||
NOFX — это **автономный** AI торговый ассистент с открытым исходным кодом. В отличие от традиционных AI инструментов, где нужно вручную настраивать модели, управлять API ключами и подключать источники данных — AI в NOFX **сам анализирует рынки, выбирает модели и получает данные**. Нулевое вмешательство человека. Вы задаёте стратегию, AI делает всё остальное.
|
||||
|
||||
**NOFX** — это open-source AI торговая система, позволяющая запускать несколько AI моделей для автоматической торговли криптовалютными фьючерсами. Настраивайте стратегии через веб-интерфейс, отслеживайте эффективность в реальном времени и позвольте AI агентам конкурировать за лучший торговый подход.
|
||||
**Полная автономность**: AI сам решает, какую модель использовать, какие рыночные данные получить, когда торговать. Без ручной настройки моделей. Без жонглирования API ключами разных сервисов. Просто пополните USDC кошелёк и запустите.
|
||||
|
||||
### Основные функции
|
||||
|
||||
- **Мульти-AI поддержка**: Запускайте DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi — переключайтесь между моделями в любое время
|
||||
- **Мульти-биржа**: Торгуйте на Binance, Bybit, OKX, Hyperliquid, Aster DEX, Lighter с единой платформы
|
||||
- **Студия стратегий**: Визуальный конструктор стратегий с источниками монет, индикаторами и контролем рисков
|
||||
- **Режим AI-соревнования**: Несколько AI трейдеров соревнуются в реальном времени, отслеживание эффективности бок о бок
|
||||
- **Веб-конфигурация**: Без редактирования JSON — настройка всего через веб-интерфейс
|
||||
- **Панель реального времени**: Живые позиции, отслеживание P/L, логи решений AI с цепочкой рассуждений
|
||||
|
||||
> **Предупреждение о рисках**: Эта система экспериментальная. AI автоторговля несёт значительные риски. Настоятельно рекомендуется использовать только для обучения/исследований или тестирования с небольшими суммами!
|
||||
|
||||
## Сообщество разработчиков
|
||||
|
||||
Присоединяйтесь к Telegram сообществу: **[NOFX Developer Community](https://t.me/nofx_dev_community)**
|
||||
|
||||
---
|
||||
|
||||
## Быстрый старт
|
||||
|
||||
### Вариант 1: Docker развёртывание (рекомендуется)
|
||||
|
||||
```bash
|
||||
git clone https://github.com/NoFxAiOS/nofx.git
|
||||
cd nofx
|
||||
chmod +x ./start.sh
|
||||
./start.sh start --build
|
||||
```
|
||||
|
||||
Доступ к веб-интерфейсу: **http://localhost:3000**
|
||||
|
||||
### Обновление до последней версии
|
||||
|
||||
> **💡 Обновления выходят часто.** Запускайте эту команду ежедневно для получения последних функций и исправлений:
|
||||
Ключевое отличие: **встроенные [x402](https://x402.org) микроплатежи**. Без API ключей. Пополните USDC кошелёк и платите за каждый запрос. Кошелёк — это ваша идентификация.
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
```
|
||||
|
||||
Эта команда загружает последние официальные образы и автоматически перезапускает сервисы.
|
||||
Откройте **http://127.0.0.1:3000**. Готово.
|
||||
|
||||
### Вариант 2: Ручная установка
|
||||
---
|
||||
|
||||
## Как работает x402
|
||||
|
||||
Традиционный процесс: регистрация → покупка кредитов → получение API ключа → управление квотой → ротация ключей.
|
||||
|
||||
x402 процесс:
|
||||
|
||||
```
|
||||
Запрос → 402 (вот цена) → кошелёк подписывает USDC → повтор → готово
|
||||
```
|
||||
|
||||
Без аккаунтов. Без API ключей. Без предоплаты. Один кошелёк, все модели.
|
||||
|
||||
### Встроенные x402 провайдеры
|
||||
|
||||
| Провайдер | Сеть | Модели |
|
||||
|:---------|:------|:-------|
|
||||
| <img src="../../../web/public/icons/claw402.png" width="20" height="20" style="vertical-align: middle;"/> **[Claw402](https://claw402.ai)** | Base | GPT-5.4, Claude Opus, DeepSeek, Qwen, Grok, Gemini, Kimi — 15+ моделей |
|
||||
|
||||
---
|
||||
|
||||
## Возможности
|
||||
|
||||
| Функция | Описание |
|
||||
|:--------|:------------|
|
||||
| **Мульти-AI** | DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi, MiniMax — переключение в любой момент |
|
||||
| **Мульти-биржа** | Binance, Bybit, OKX, Bitget, KuCoin, Gate, Hyperliquid, Aster, Lighter |
|
||||
| **Студия стратегий** | Визуальный конструктор — источники монет, индикаторы, контроль рисков |
|
||||
| **AI Арена дебатов** | Несколько AI обсуждают сделки (Бык vs Медведь vs Аналитик), голосуют, исполняют |
|
||||
| **AI Соревнование** | AI соревнуются в реальном времени, рейтинг в таблице лидеров |
|
||||
| **Telegram Агент** | Чат с торговым ассистентом — стриминг, вызов инструментов, память |
|
||||
| **Лаборатория бэктеста** | Историческая симуляция с кривой капитала и метриками |
|
||||
| **Панель управления** | Позиции в реальном времени, P/L, логи AI решений с Chain of Thought |
|
||||
|
||||
### Рынки
|
||||
|
||||
Криптовалюта · Акции США · Форекс · Металлы
|
||||
|
||||
### Биржи (CEX)
|
||||
|
||||
| Биржа | Статус | Регистрация (скидка) |
|
||||
|:---------|:------:|:------------------------|
|
||||
| <img src="../../../web/public/exchange-icons/binance.jpg" width="20" height="20" style="vertical-align: middle;"/> **Binance** | ✅ | [Регистрация](https://www.binance.com/join?ref=NOFXENG) |
|
||||
| <img src="../../../web/public/exchange-icons/bybit.png" width="20" height="20" style="vertical-align: middle;"/> **Bybit** | ✅ | [Регистрация](https://partner.bybit.com/b/83856) |
|
||||
| <img src="../../../web/public/exchange-icons/okx.svg" width="20" height="20" style="vertical-align: middle;"/> **OKX** | ✅ | [Регистрация](https://www.okx.com/join/1865360) |
|
||||
| <img src="../../../web/public/exchange-icons/bitget.svg" width="20" height="20" style="vertical-align: middle;"/> **Bitget** | ✅ | [Регистрация](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
|
||||
| <img src="../../../web/public/exchange-icons/kucoin.svg" width="20" height="20" style="vertical-align: middle;"/> **KuCoin** | ✅ | [Регистрация](https://www.kucoin.com/r/broker/CXEV7XKK) |
|
||||
| <img src="../../../web/public/exchange-icons/gate.svg" width="20" height="20" style="vertical-align: middle;"/> **Gate** | ✅ | [Регистрация](https://www.gatenode.xyz/share/VQBGUAxY) |
|
||||
|
||||
### Биржи (Perp-DEX)
|
||||
|
||||
| Биржа | Статус | Регистрация (скидка) |
|
||||
|:---------|:------:|:------------------------|
|
||||
| <img src="../../../web/public/exchange-icons/hyperliquid.png" width="20" height="20" style="vertical-align: middle;"/> **Hyperliquid** | ✅ | [Регистрация](https://app.hyperliquid.xyz/join/AITRADING) |
|
||||
| <img src="../../../web/public/exchange-icons/aster.svg" width="20" height="20" style="vertical-align: middle;"/> **Aster DEX** | ✅ | [Регистрация](https://www.asterdex.com/en/referral/fdfc0e) |
|
||||
| <img src="../../../web/public/exchange-icons/lighter.png" width="20" height="20" style="vertical-align: middle;"/> **Lighter** | ✅ | [Регистрация](https://app.lighter.xyz/?referral=68151432) |
|
||||
|
||||
### AI Модели (Режим API ключей)
|
||||
|
||||
| AI Модель | Статус | Получить API ключ |
|
||||
|:---------|:------:|:------------|
|
||||
| <img src="../../../web/public/icons/deepseek.svg" width="20" height="20" style="vertical-align: middle;"/> **DeepSeek** | ✅ | [Получить](https://platform.deepseek.com) |
|
||||
| <img src="../../../web/public/icons/qwen.svg" width="20" height="20" style="vertical-align: middle;"/> **Qwen** | ✅ | [Получить](https://dashscope.console.aliyun.com) |
|
||||
| <img src="../../../web/public/icons/openai.svg" width="20" height="20" style="vertical-align: middle;"/> **OpenAI (GPT)** | ✅ | [Получить](https://platform.openai.com) |
|
||||
| <img src="../../../web/public/icons/claude.svg" width="20" height="20" style="vertical-align: middle;"/> **Claude** | ✅ | [Получить](https://console.anthropic.com) |
|
||||
| <img src="../../../web/public/icons/gemini.svg" width="20" height="20" style="vertical-align: middle;"/> **Gemini** | ✅ | [Получить](https://aistudio.google.com) |
|
||||
| <img src="../../../web/public/icons/grok.svg" width="20" height="20" style="vertical-align: middle;"/> **Grok** | ✅ | [Получить](https://console.x.ai) |
|
||||
| <img src="../../../web/public/icons/kimi.svg" width="20" height="20" style="vertical-align: middle;"/> **Kimi** | ✅ | [Получить](https://platform.moonshot.cn) |
|
||||
| <img src="../../../web/public/icons/minimax.svg" width="20" height="20" style="vertical-align: middle;"/> **MiniMax** | ✅ | [Получить](https://platform.minimaxi.com) |
|
||||
|
||||
### AI Модели (Режим x402 — без API ключей)
|
||||
|
||||
15+ моделей через [Claw402](https://claw402.ai) — только USDC кошелёк
|
||||
|
||||
---
|
||||
|
||||
## Установка
|
||||
|
||||
### Linux / macOS
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
```
|
||||
|
||||
### Railway (Облако)
|
||||
|
||||
[](https://railway.com/deploy/nofx?referralCode=nofx)
|
||||
|
||||
### Docker
|
||||
|
||||
```bash
|
||||
curl -O https://raw.githubusercontent.com/NoFxAiOS/nofx/main/docker-compose.prod.yml
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
### Из исходников
|
||||
|
||||
```bash
|
||||
# Требования: Go 1.21+, Node.js 18+, TA-Lib
|
||||
# macOS: brew install ta-lib
|
||||
# Ubuntu: sudo apt-get install libta-lib0-dev
|
||||
|
||||
# Установка TA-Lib (macOS)
|
||||
brew install ta-lib
|
||||
|
||||
# Клонирование и настройка
|
||||
git clone https://github.com/NoFxAiOS/nofx.git
|
||||
cd nofx
|
||||
go mod download
|
||||
cd web && npm install && cd ..
|
||||
|
||||
# Запуск бэкенда
|
||||
go build -o nofx && ./nofx
|
||||
|
||||
# Запуск фронтенда (новый терминал)
|
||||
cd web && npm run dev
|
||||
git clone https://github.com/NoFxAiOS/nofx.git && cd nofx
|
||||
go build -o nofx && ./nofx # бэкенд
|
||||
cd web && npm install && npm run dev # фронтенд (новый терминал)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Начальная настройка
|
||||
## Ссылки
|
||||
|
||||
1. **Настройка AI моделей** — Добавьте API ключи AI
|
||||
2. **Настройка бирж** — Установите API учётные данные бирж
|
||||
3. **Создание стратегии** — Настройте торговую стратегию в Студии стратегий
|
||||
4. **Создание трейдера** — Объедините AI модель + Биржу + Стратегию
|
||||
5. **Начало торговли** — Запустите настроенных трейдеров
|
||||
| | |
|
||||
|:--|:--|
|
||||
| Сайт | [nofxai.com](https://nofxai.com) |
|
||||
| Панель | [nofxos.ai/dashboard](https://nofxos.ai/dashboard) |
|
||||
| API Документация | [nofxos.ai/api-docs](https://nofxos.ai/api-docs) |
|
||||
| Telegram | [nofx_dev_community](https://t.me/nofx_dev_community) |
|
||||
| Twitter | [@nofx_official](https://x.com/nofx_official) |
|
||||
|
||||
---
|
||||
|
||||
## Предупреждения о рисках
|
||||
|
||||
1. Криптовалютные рынки крайне волатильны — AI решения не гарантируют прибыль
|
||||
2. Торговля фьючерсами использует плечо — убытки могут превысить депозит
|
||||
3. Экстремальные рыночные условия могут привести к ликвидации
|
||||
> **Предупреждение**: AI автоторговля несёт значительные риски. Рекомендуется только для обучения/исследований или тестирования малых сумм.
|
||||
|
||||
---
|
||||
|
||||
## Лицензия
|
||||
|
||||
**GNU Affero General Public License v3.0 (AGPL-3.0)**
|
||||
[AGPL-3.0](../../../LICENSE)
|
||||
|
||||
---
|
||||
|
||||
## Контакты
|
||||
|
||||
- **GitHub Issues**: [Создать Issue](https://github.com/NoFxAiOS/nofx/issues)
|
||||
- **Сообщество разработчиков**: [Telegram группа](https://t.me/nofx_dev_community)
|
||||
[](https://star-history.com/#NoFxAiOS/nofx&Date)
|
||||
|
||||
@@ -1,106 +1,174 @@
|
||||
# NOFX - AI Торгова Система
|
||||
<h1 align="center">NOFX</h1>
|
||||
|
||||
[](https://golang.org/)
|
||||
[](https://reactjs.org/)
|
||||
[](https://www.typescriptlang.org/)
|
||||
[](LICENSE)
|
||||
<p align="center">
|
||||
<strong>Ваш персональний AI торговий асистент.</strong><br/>
|
||||
<strong>Будь-який ринок. Будь-яка модель. Оплата USDC, без API ключів.</strong>
|
||||
</p>
|
||||
|
||||
**Мови:** [English](../../../README.md) | [中文](../zh-CN/README.md) | [Українська](README.md)
|
||||
<p align="center">
|
||||
<a href="https://github.com/NoFxAiOS/nofx/stargazers"><img src="https://img.shields.io/github/stars/NoFxAiOS/nofx?style=for-the-badge" alt="Stars"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/releases"><img src="https://img.shields.io/github/v/release/NoFxAiOS/nofx?style=for-the-badge" alt="Release"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-AGPL--3.0-blue.svg?style=for-the-badge" alt="License"></a>
|
||||
<a href="https://t.me/nofx_dev_community"><img src="https://img.shields.io/badge/Telegram-Community-blue?style=for-the-badge&logo=telegram" alt="Telegram"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://golang.org/"><img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go" alt="Go"></a>
|
||||
<a href="https://reactjs.org/"><img src="https://img.shields.io/badge/React-18+-61DAFB?style=flat&logo=react" alt="React"></a>
|
||||
<a href="https://x402.org"><img src="https://img.shields.io/badge/x402-USDC%20Payments-2775CA?style=flat" alt="x402"></a>
|
||||
<a href="https://claw402.ai"><img src="https://img.shields.io/badge/Claw402-AI%20Gateway-FF6B35?style=flat" alt="Claw402"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="../../../README.md">English</a> ·
|
||||
<a href="../zh-CN/README.md">中文</a> ·
|
||||
<a href="../ja/README.md">日本語</a> ·
|
||||
<a href="../ko/README.md">한국어</a> ·
|
||||
<a href="../ru/README.md">Русский</a> ·
|
||||
<a href="README.md">Українська</a> ·
|
||||
<a href="../vi/README.md">Tiếng Việt</a>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## Криптовалютна торгова платформа на базі ШІ
|
||||
NOFX — це **автономний** AI торговий асистент з відкритим кодом. На відміну від традиційних AI інструментів, де потрібно вручну налаштовувати моделі, керувати API ключами та підключати джерела даних — AI у NOFX **сам аналізує ринки, обирає моделі та отримує дані**. Нульове втручання людини. Ви задаєте стратегію, AI робить все інше.
|
||||
|
||||
**NOFX** — це open-source AI торгова система, що дозволяє запускати кілька AI моделей для автоматичної торгівлі криптовалютними ф'ючерсами. Налаштовуйте стратегії через веб-інтерфейс, відстежуйте ефективність у реальному часі та дозвольте AI агентам конкурувати за найкращий торговий підхід.
|
||||
**Повна автономність**: AI сам вирішує, яку модель використовувати, які ринкові дані отримати, коли торгувати. Без ручного налаштування моделей. Без жонглювання API ключами різних сервісів. Просто поповніть USDC гаманець і запустіть.
|
||||
|
||||
### Основні функції
|
||||
|
||||
- **Мульти-AI підтримка**: Запускайте DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi — перемикайтеся між моделями будь-коли
|
||||
- **Мульти-біржа**: Торгуйте на Binance, Bybit, OKX, Hyperliquid, Aster DEX, Lighter з єдиної платформи
|
||||
- **Студія стратегій**: Візуальний конструктор стратегій з джерелами монет, індикаторами та контролем ризиків
|
||||
- **Режим AI-змагання**: Кілька AI трейдерів змагаються в реальному часі, відстеження ефективності пліч-о-пліч
|
||||
- **Веб-конфігурація**: Без редагування JSON — налаштування всього через веб-інтерфейс
|
||||
- **Панель реального часу**: Живі позиції, відстеження P/L, логи рішень AI з ланцюжком міркувань
|
||||
|
||||
> **Попередження про ризики**: Ця система експериментальна. AI автоторгівля несе значні ризики. Наполегливо рекомендується використовувати лише для навчання/досліджень або тестування з невеликими сумами!
|
||||
|
||||
## Спільнота розробників
|
||||
|
||||
Приєднуйтесь до Telegram спільноти: **[NOFX Developer Community](https://t.me/nofx_dev_community)**
|
||||
|
||||
---
|
||||
|
||||
## Швидкий старт
|
||||
|
||||
### Варіант 1: Docker розгортання (рекомендовано)
|
||||
|
||||
```bash
|
||||
git clone https://github.com/NoFxAiOS/nofx.git
|
||||
cd nofx
|
||||
chmod +x ./start.sh
|
||||
./start.sh start --build
|
||||
```
|
||||
|
||||
Доступ до веб-інтерфейсу: **http://localhost:3000**
|
||||
|
||||
### Оновлення до останньої версії
|
||||
|
||||
> **💡 Оновлення виходять часто.** Запускайте цю команду щодня для отримання останніх функцій та виправлень:
|
||||
Ключова відмінність: **вбудовані [x402](https://x402.org) мікроплатежі**. Без API ключів. Поповніть USDC гаманець і платіть за кожен запит. Гаманець — це ваша ідентифікація.
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
```
|
||||
|
||||
Ця команда завантажує останні офіційні образи та автоматично перезапускає сервіси.
|
||||
Відкрийте **http://127.0.0.1:3000**. Готово.
|
||||
|
||||
### Варіант 2: Ручна установка
|
||||
---
|
||||
|
||||
## Як працює x402
|
||||
|
||||
Традиційний процес: реєстрація → купівля кредитів → отримання API ключа → управління квотою → ротація ключів.
|
||||
|
||||
x402 процес:
|
||||
|
||||
```
|
||||
Запит → 402 (ось ціна) → гаманець підписує USDC → повтор → готово
|
||||
```
|
||||
|
||||
Без акаунтів. Без API ключів. Без передоплати. Один гаманець, усі моделі.
|
||||
|
||||
### Вбудовані x402 провайдери
|
||||
|
||||
| Провайдер | Мережа | Моделі |
|
||||
|:---------|:------|:-------|
|
||||
| <img src="../../../web/public/icons/claw402.png" width="20" height="20" style="vertical-align: middle;"/> **[Claw402](https://claw402.ai)** | Base | GPT-5.4, Claude Opus, DeepSeek, Qwen, Grok, Gemini, Kimi — 15+ моделей |
|
||||
|
||||
---
|
||||
|
||||
## Можливості
|
||||
|
||||
| Функція | Опис |
|
||||
|:--------|:------------|
|
||||
| **Мульти-AI** | DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi, MiniMax — перемикання будь-коли |
|
||||
| **Мульти-біржа** | Binance, Bybit, OKX, Bitget, KuCoin, Gate, Hyperliquid, Aster, Lighter |
|
||||
| **Студія стратегій** | Візуальний конструктор — джерела монет, індикатори, контроль ризиків |
|
||||
| **AI Арена дебатів** | Кілька AI обговорюють угоди, голосують, виконують |
|
||||
| **AI Змагання** | AI змагаються в реальному часі, рейтинг у таблиці лідерів |
|
||||
| **Telegram Агент** | Чат з торговим асистентом — стрімінг, виклик інструментів, пам'ять |
|
||||
| **Лабораторія бектесту** | Історична симуляція з кривою капіталу та метриками |
|
||||
| **Панель управління** | Позиції в реальному часі, P/L, логи AI рішень з Chain of Thought |
|
||||
|
||||
### Ринки
|
||||
|
||||
Криптовалюта · Акції США · Форекс · Метали
|
||||
|
||||
### Біржі (CEX)
|
||||
|
||||
| Біржа | Статус | Реєстрація (знижка) |
|
||||
|:---------|:------:|:------------------------|
|
||||
| <img src="../../../web/public/exchange-icons/binance.jpg" width="20" height="20" style="vertical-align: middle;"/> **Binance** | ✅ | [Реєстрація](https://www.binance.com/join?ref=NOFXENG) |
|
||||
| <img src="../../../web/public/exchange-icons/bybit.png" width="20" height="20" style="vertical-align: middle;"/> **Bybit** | ✅ | [Реєстрація](https://partner.bybit.com/b/83856) |
|
||||
| <img src="../../../web/public/exchange-icons/okx.svg" width="20" height="20" style="vertical-align: middle;"/> **OKX** | ✅ | [Реєстрація](https://www.okx.com/join/1865360) |
|
||||
| <img src="../../../web/public/exchange-icons/bitget.svg" width="20" height="20" style="vertical-align: middle;"/> **Bitget** | ✅ | [Реєстрація](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
|
||||
| <img src="../../../web/public/exchange-icons/kucoin.svg" width="20" height="20" style="vertical-align: middle;"/> **KuCoin** | ✅ | [Реєстрація](https://www.kucoin.com/r/broker/CXEV7XKK) |
|
||||
| <img src="../../../web/public/exchange-icons/gate.svg" width="20" height="20" style="vertical-align: middle;"/> **Gate** | ✅ | [Реєстрація](https://www.gatenode.xyz/share/VQBGUAxY) |
|
||||
|
||||
### Біржі (Perp-DEX)
|
||||
|
||||
| Біржа | Статус | Реєстрація (знижка) |
|
||||
|:---------|:------:|:------------------------|
|
||||
| <img src="../../../web/public/exchange-icons/hyperliquid.png" width="20" height="20" style="vertical-align: middle;"/> **Hyperliquid** | ✅ | [Реєстрація](https://app.hyperliquid.xyz/join/AITRADING) |
|
||||
| <img src="../../../web/public/exchange-icons/aster.svg" width="20" height="20" style="vertical-align: middle;"/> **Aster DEX** | ✅ | [Реєстрація](https://www.asterdex.com/en/referral/fdfc0e) |
|
||||
| <img src="../../../web/public/exchange-icons/lighter.png" width="20" height="20" style="vertical-align: middle;"/> **Lighter** | ✅ | [Реєстрація](https://app.lighter.xyz/?referral=68151432) |
|
||||
|
||||
### AI Моделі (Режим API ключів)
|
||||
|
||||
| AI Модель | Статус | Отримати API ключ |
|
||||
|:---------|:------:|:------------|
|
||||
| <img src="../../../web/public/icons/deepseek.svg" width="20" height="20" style="vertical-align: middle;"/> **DeepSeek** | ✅ | [Отримати](https://platform.deepseek.com) |
|
||||
| <img src="../../../web/public/icons/qwen.svg" width="20" height="20" style="vertical-align: middle;"/> **Qwen** | ✅ | [Отримати](https://dashscope.console.aliyun.com) |
|
||||
| <img src="../../../web/public/icons/openai.svg" width="20" height="20" style="vertical-align: middle;"/> **OpenAI (GPT)** | ✅ | [Отримати](https://platform.openai.com) |
|
||||
| <img src="../../../web/public/icons/claude.svg" width="20" height="20" style="vertical-align: middle;"/> **Claude** | ✅ | [Отримати](https://console.anthropic.com) |
|
||||
| <img src="../../../web/public/icons/gemini.svg" width="20" height="20" style="vertical-align: middle;"/> **Gemini** | ✅ | [Отримати](https://aistudio.google.com) |
|
||||
| <img src="../../../web/public/icons/grok.svg" width="20" height="20" style="vertical-align: middle;"/> **Grok** | ✅ | [Отримати](https://console.x.ai) |
|
||||
| <img src="../../../web/public/icons/kimi.svg" width="20" height="20" style="vertical-align: middle;"/> **Kimi** | ✅ | [Отримати](https://platform.moonshot.cn) |
|
||||
| <img src="../../../web/public/icons/minimax.svg" width="20" height="20" style="vertical-align: middle;"/> **MiniMax** | ✅ | [Отримати](https://platform.minimaxi.com) |
|
||||
|
||||
### AI Моделі (Режим x402 — без API ключів)
|
||||
|
||||
15+ моделей через [Claw402](https://claw402.ai) — лише USDC гаманець
|
||||
|
||||
---
|
||||
|
||||
## Встановлення
|
||||
|
||||
### Linux / macOS
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
```
|
||||
|
||||
### Railway (Хмара)
|
||||
|
||||
[](https://railway.com/deploy/nofx?referralCode=nofx)
|
||||
|
||||
### Docker
|
||||
|
||||
```bash
|
||||
curl -O https://raw.githubusercontent.com/NoFxAiOS/nofx/main/docker-compose.prod.yml
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
### З вихідного коду
|
||||
|
||||
```bash
|
||||
# Вимоги: Go 1.21+, Node.js 18+, TA-Lib
|
||||
# macOS: brew install ta-lib
|
||||
# Ubuntu: sudo apt-get install libta-lib0-dev
|
||||
|
||||
# Встановлення TA-Lib (macOS)
|
||||
brew install ta-lib
|
||||
|
||||
# Клонування та налаштування
|
||||
git clone https://github.com/NoFxAiOS/nofx.git
|
||||
cd nofx
|
||||
go mod download
|
||||
cd web && npm install && cd ..
|
||||
|
||||
# Запуск бекенду
|
||||
go build -o nofx && ./nofx
|
||||
|
||||
# Запуск фронтенду (новий термінал)
|
||||
cd web && npm run dev
|
||||
git clone https://github.com/NoFxAiOS/nofx.git && cd nofx
|
||||
go build -o nofx && ./nofx # бекенд
|
||||
cd web && npm install && npm run dev # фронтенд (новий термінал)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Початкове налаштування
|
||||
## Посилання
|
||||
|
||||
1. **Налаштування AI моделей** — Додайте API ключі AI
|
||||
2. **Налаштування бірж** — Встановіть API облікові дані бірж
|
||||
3. **Створення стратегії** — Налаштуйте торгову стратегію в Студії стратегій
|
||||
4. **Створення трейдера** — Об'єднайте AI модель + Біржу + Стратегію
|
||||
5. **Початок торгівлі** — Запустіть налаштованих трейдерів
|
||||
| | |
|
||||
|:--|:--|
|
||||
| Сайт | [nofxai.com](https://nofxai.com) |
|
||||
| Панель | [nofxos.ai/dashboard](https://nofxos.ai/dashboard) |
|
||||
| API Документація | [nofxos.ai/api-docs](https://nofxos.ai/api-docs) |
|
||||
| Telegram | [nofx_dev_community](https://t.me/nofx_dev_community) |
|
||||
| Twitter | [@nofx_official](https://x.com/nofx_official) |
|
||||
|
||||
---
|
||||
|
||||
## Попередження про ризики
|
||||
|
||||
1. Криптовалютні ринки надзвичайно волатильні — AI рішення не гарантують прибуток
|
||||
2. Торгівля ф'ючерсами використовує плече — збитки можуть перевищити депозит
|
||||
3. Екстремальні ринкові умови можуть призвести до ліквідації
|
||||
> **Попередження**: AI автоторгівля несе значні ризики. Рекомендується лише для навчання/досліджень або тестування малих сум.
|
||||
|
||||
---
|
||||
|
||||
## Ліцензія
|
||||
|
||||
**GNU Affero General Public License v3.0 (AGPL-3.0)**
|
||||
[AGPL-3.0](../../../LICENSE)
|
||||
|
||||
---
|
||||
|
||||
## Контакти
|
||||
|
||||
- **GitHub Issues**: [Створити Issue](https://github.com/NoFxAiOS/nofx/issues)
|
||||
- **Спільнота розробників**: [Telegram група](https://t.me/nofx_dev_community)
|
||||
[](https://star-history.com/#NoFxAiOS/nofx&Date)
|
||||
|
||||
@@ -1,106 +1,172 @@
|
||||
# NOFX - Hệ Thống Giao Dịch AI
|
||||
<h1 align="center">NOFX</h1>
|
||||
|
||||
[](https://golang.org/)
|
||||
[](https://reactjs.org/)
|
||||
[](https://www.typescriptlang.org/)
|
||||
[](LICENSE)
|
||||
<p align="center">
|
||||
<strong>Trợ lý giao dịch AI cá nhân của bạn.</strong><br/>
|
||||
<strong>Mọi thị trường. Mọi mô hình. Thanh toán USDC, không cần API key.</strong>
|
||||
</p>
|
||||
|
||||
**Ngôn ngữ:** [English](../../../README.md) | [中文](../zh-CN/README.md) | [Tiếng Việt](README.md)
|
||||
<p align="center">
|
||||
<a href="https://github.com/NoFxAiOS/nofx/stargazers"><img src="https://img.shields.io/github/stars/NoFxAiOS/nofx?style=for-the-badge" alt="Stars"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/releases"><img src="https://img.shields.io/github/v/release/NoFxAiOS/nofx?style=for-the-badge" alt="Release"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-AGPL--3.0-blue.svg?style=for-the-badge" alt="License"></a>
|
||||
<a href="https://t.me/nofx_dev_community"><img src="https://img.shields.io/badge/Telegram-Community-blue?style=for-the-badge&logo=telegram" alt="Telegram"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://golang.org/"><img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go" alt="Go"></a>
|
||||
<a href="https://reactjs.org/"><img src="https://img.shields.io/badge/React-18+-61DAFB?style=flat&logo=react" alt="React"></a>
|
||||
<a href="https://x402.org"><img src="https://img.shields.io/badge/x402-USDC%20Payments-2775CA?style=flat" alt="x402"></a>
|
||||
<a href="https://claw402.ai"><img src="https://img.shields.io/badge/Claw402-AI%20Gateway-FF6B35?style=flat" alt="Claw402"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="../../../README.md">English</a> ·
|
||||
<a href="../zh-CN/README.md">中文</a> ·
|
||||
<a href="../ja/README.md">日本語</a> ·
|
||||
<a href="../ko/README.md">한국어</a> ·
|
||||
<a href="../ru/README.md">Русский</a> ·
|
||||
<a href="../uk/README.md">Українська</a> ·
|
||||
<a href="README.md">Tiếng Việt</a>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## Nền Tảng Giao Dịch Crypto Sử Dụng AI
|
||||
NOFX là trợ lý giao dịch AI **tự chủ** mã nguồn mở. Không giống các công cụ AI truyền thống yêu cầu bạn cấu hình mô hình thủ công, quản lý API key và kết nối nguồn dữ liệu — AI của NOFX **tự nhận diện thị trường, tự chọn mô hình và tự lấy dữ liệu**. Không cần con người can thiệp. Bạn chỉ cần đặt chiến lược, AI xử lý mọi thứ còn lại.
|
||||
|
||||
**NOFX** là hệ thống giao dịch AI mã nguồn mở cho phép bạn chạy nhiều mô hình AI để tự động giao dịch hợp đồng tương lai crypto. Cấu hình chiến lược qua giao diện web, theo dõi hiệu suất theo thời gian thực, và để các AI agent cạnh tranh tìm ra phương pháp giao dịch tốt nhất.
|
||||
**Hoàn toàn tự chủ**: AI tự quyết định sử dụng mô hình nào, lấy dữ liệu thị trường gì, khi nào giao dịch. Không cần cấu hình mô hình thủ công. Không cần quản lý API key của nhiều dịch vụ. Chỉ cần nạp ví USDC và chạy.
|
||||
|
||||
### Tính Năng Chính
|
||||
|
||||
- **Hỗ trợ Đa AI**: Chạy DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi - chuyển đổi mô hình bất cứ lúc nào
|
||||
- **Đa Sàn Giao Dịch**: Giao dịch trên Binance, Bybit, OKX, Hyperliquid, Aster DEX, Lighter từ một nền tảng
|
||||
- **Strategy Studio**: Trình tạo chiến lược trực quan với nguồn coin, chỉ báo và kiểm soát rủi ro
|
||||
- **Chế Độ Thi Đấu AI**: Nhiều AI trader cạnh tranh theo thời gian thực, theo dõi hiệu suất song song
|
||||
- **Cấu Hình Web**: Không cần chỉnh sửa JSON - cấu hình mọi thứ qua giao diện web
|
||||
- **Dashboard Thời Gian Thực**: Vị thế trực tiếp, theo dõi P/L, nhật ký quyết định AI với chuỗi suy luận
|
||||
|
||||
> **Cảnh Báo Rủi Ro**: Hệ thống này mang tính thử nghiệm. Giao dịch tự động AI có rủi ro đáng kể. Chỉ nên sử dụng cho mục đích học tập/nghiên cứu hoặc kiểm tra với số tiền nhỏ!
|
||||
|
||||
## Cộng Đồng Nhà Phát Triển
|
||||
|
||||
Tham gia cộng đồng Telegram: **[NOFX Developer Community](https://t.me/nofx_dev_community)**
|
||||
|
||||
---
|
||||
|
||||
## Bắt Đầu Nhanh
|
||||
|
||||
### Tùy chọn 1: Triển khai Docker (Khuyến nghị)
|
||||
|
||||
```bash
|
||||
git clone https://github.com/NoFxAiOS/nofx.git
|
||||
cd nofx
|
||||
chmod +x ./start.sh
|
||||
./start.sh start --build
|
||||
```
|
||||
|
||||
Truy cập giao diện Web: **http://localhost:3000**
|
||||
|
||||
### Cập Nhật Phiên Bản Mới
|
||||
|
||||
> **💡 Cập nhật thường xuyên.** Chạy lệnh này hàng ngày để nhận các tính năng và bản sửa lỗi mới nhất:
|
||||
Điểm khác biệt: **tích hợp thanh toán vi mô [x402](https://x402.org)**. Không cần API key. Nạp ví USDC và thanh toán theo yêu cầu. Ví chính là danh tính của bạn.
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
```
|
||||
|
||||
Lệnh này tải về image chính thức mới nhất và tự động khởi động lại dịch vụ.
|
||||
Mở **http://127.0.0.1:3000**. Xong.
|
||||
|
||||
### Tùy chọn 2: Cài đặt Thủ công
|
||||
---
|
||||
|
||||
## x402 hoạt động như thế nào
|
||||
|
||||
Quy trình truyền thống: đăng ký tài khoản → mua credits → lấy API key → quản lý quota → xoay key.
|
||||
|
||||
Quy trình x402:
|
||||
|
||||
```
|
||||
Yêu cầu → 402 (đây là giá) → ví ký USDC → thử lại → xong
|
||||
```
|
||||
|
||||
Không tài khoản. Không API key. Không trả trước. Một ví, tất cả mô hình.
|
||||
|
||||
### Nhà cung cấp x402 tích hợp
|
||||
|
||||
| Nhà cung cấp | Chain | Mô hình |
|
||||
|:---------|:------|:-------|
|
||||
| <img src="../../../web/public/icons/claw402.png" width="20" height="20" style="vertical-align: middle;"/> **[Claw402](https://claw402.ai)** | Base | GPT-5.4, Claude Opus, DeepSeek, Qwen, Grok, Gemini, Kimi — 15+ mô hình |
|
||||
|
||||
---
|
||||
|
||||
## Tính năng
|
||||
|
||||
| Tính năng | Mô tả |
|
||||
|:--------|:------------|
|
||||
| **Đa AI** | DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi, MiniMax — chuyển đổi bất cứ lúc nào |
|
||||
| **Đa Sàn** | Binance, Bybit, OKX, Bitget, KuCoin, Gate, Hyperliquid, Aster, Lighter |
|
||||
| **Strategy Studio** | Trình xây dựng trực quan — nguồn coin, chỉ báo, kiểm soát rủi ro |
|
||||
| **AI Competition** | AI cạnh tranh thời gian thực, bảng xếp hạng hiệu suất |
|
||||
| **Telegram Agent** | Chat với trợ lý giao dịch — streaming, gọi công cụ, bộ nhớ |
|
||||
| **Dashboard** | Vị thế trực tiếp, P/L, nhật ký quyết định AI với Chain of Thought |
|
||||
|
||||
### Thị trường
|
||||
|
||||
Crypto · Cổ phiếu Mỹ · Forex · Kim loại
|
||||
|
||||
### Sàn giao dịch (CEX)
|
||||
|
||||
| Sàn | Trạng thái | Đăng ký (Giảm phí) |
|
||||
|:---------|:------:|:------------------------|
|
||||
| <img src="../../../web/public/exchange-icons/binance.jpg" width="20" height="20" style="vertical-align: middle;"/> **Binance** | ✅ | [Đăng ký](https://www.binance.com/join?ref=NOFXENG) |
|
||||
| <img src="../../../web/public/exchange-icons/bybit.png" width="20" height="20" style="vertical-align: middle;"/> **Bybit** | ✅ | [Đăng ký](https://partner.bybit.com/b/83856) |
|
||||
| <img src="../../../web/public/exchange-icons/okx.svg" width="20" height="20" style="vertical-align: middle;"/> **OKX** | ✅ | [Đăng ký](https://www.okx.com/join/1865360) |
|
||||
| <img src="../../../web/public/exchange-icons/bitget.svg" width="20" height="20" style="vertical-align: middle;"/> **Bitget** | ✅ | [Đăng ký](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
|
||||
| <img src="../../../web/public/exchange-icons/kucoin.svg" width="20" height="20" style="vertical-align: middle;"/> **KuCoin** | ✅ | [Đăng ký](https://www.kucoin.com/r/broker/CXEV7XKK) |
|
||||
| <img src="../../../web/public/exchange-icons/gate.svg" width="20" height="20" style="vertical-align: middle;"/> **Gate** | ✅ | [Đăng ký](https://www.gatenode.xyz/share/VQBGUAxY) |
|
||||
|
||||
### Sàn giao dịch (Perp-DEX)
|
||||
|
||||
| Sàn | Trạng thái | Đăng ký (Giảm phí) |
|
||||
|:---------|:------:|:------------------------|
|
||||
| <img src="../../../web/public/exchange-icons/hyperliquid.png" width="20" height="20" style="vertical-align: middle;"/> **Hyperliquid** | ✅ | [Đăng ký](https://app.hyperliquid.xyz/join/AITRADING) |
|
||||
| <img src="../../../web/public/exchange-icons/aster.svg" width="20" height="20" style="vertical-align: middle;"/> **Aster DEX** | ✅ | [Đăng ký](https://www.asterdex.com/en/referral/fdfc0e) |
|
||||
| <img src="../../../web/public/exchange-icons/lighter.png" width="20" height="20" style="vertical-align: middle;"/> **Lighter** | ✅ | [Đăng ký](https://app.lighter.xyz/?referral=68151432) |
|
||||
|
||||
### Mô hình AI (Chế độ API Key)
|
||||
|
||||
| Mô hình AI | Trạng thái | Lấy API Key |
|
||||
|:---------|:------:|:------------|
|
||||
| <img src="../../../web/public/icons/deepseek.svg" width="20" height="20" style="vertical-align: middle;"/> **DeepSeek** | ✅ | [Lấy API Key](https://platform.deepseek.com) |
|
||||
| <img src="../../../web/public/icons/qwen.svg" width="20" height="20" style="vertical-align: middle;"/> **Qwen** | ✅ | [Lấy API Key](https://dashscope.console.aliyun.com) |
|
||||
| <img src="../../../web/public/icons/openai.svg" width="20" height="20" style="vertical-align: middle;"/> **OpenAI (GPT)** | ✅ | [Lấy API Key](https://platform.openai.com) |
|
||||
| <img src="../../../web/public/icons/claude.svg" width="20" height="20" style="vertical-align: middle;"/> **Claude** | ✅ | [Lấy API Key](https://console.anthropic.com) |
|
||||
| <img src="../../../web/public/icons/gemini.svg" width="20" height="20" style="vertical-align: middle;"/> **Gemini** | ✅ | [Lấy API Key](https://aistudio.google.com) |
|
||||
| <img src="../../../web/public/icons/grok.svg" width="20" height="20" style="vertical-align: middle;"/> **Grok** | ✅ | [Lấy API Key](https://console.x.ai) |
|
||||
| <img src="../../../web/public/icons/kimi.svg" width="20" height="20" style="vertical-align: middle;"/> **Kimi** | ✅ | [Lấy API Key](https://platform.moonshot.cn) |
|
||||
| <img src="../../../web/public/icons/minimax.svg" width="20" height="20" style="vertical-align: middle;"/> **MiniMax** | ✅ | [Lấy API Key](https://platform.minimaxi.com) |
|
||||
|
||||
### Mô hình AI (Chế độ x402 — Không cần API Key)
|
||||
|
||||
15+ mô hình qua [Claw402](https://claw402.ai) — chỉ cần ví USDC
|
||||
|
||||
---
|
||||
|
||||
## Cài đặt
|
||||
|
||||
### Linux / macOS
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
```
|
||||
|
||||
### Railway (Cloud)
|
||||
|
||||
[](https://railway.com/deploy/nofx?referralCode=nofx)
|
||||
|
||||
### Docker
|
||||
|
||||
```bash
|
||||
curl -O https://raw.githubusercontent.com/NoFxAiOS/nofx/main/docker-compose.prod.yml
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
### Từ mã nguồn
|
||||
|
||||
```bash
|
||||
# Yêu cầu: Go 1.21+, Node.js 18+, TA-Lib
|
||||
# macOS: brew install ta-lib
|
||||
# Ubuntu: sudo apt-get install libta-lib0-dev
|
||||
|
||||
# Cài đặt TA-Lib (macOS)
|
||||
brew install ta-lib
|
||||
|
||||
# Clone và thiết lập
|
||||
git clone https://github.com/NoFxAiOS/nofx.git
|
||||
cd nofx
|
||||
go mod download
|
||||
cd web && npm install && cd ..
|
||||
|
||||
# Khởi động backend
|
||||
go build -o nofx && ./nofx
|
||||
|
||||
# Khởi động frontend (terminal mới)
|
||||
cd web && npm run dev
|
||||
git clone https://github.com/NoFxAiOS/nofx.git && cd nofx
|
||||
go build -o nofx && ./nofx # backend
|
||||
cd web && npm install && npm run dev # frontend (terminal mới)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Thiết Lập Ban Đầu
|
||||
## Liên kết
|
||||
|
||||
1. **Cấu hình Mô hình AI** — Thêm API key AI
|
||||
2. **Cấu hình Sàn giao dịch** — Thiết lập thông tin API sàn
|
||||
3. **Tạo Chiến lược** — Cấu hình chiến lược giao dịch trong Strategy Studio
|
||||
4. **Tạo Trader** — Kết hợp Mô hình AI + Sàn + Chiến lược
|
||||
5. **Bắt đầu Giao dịch** — Khởi động các trader đã cấu hình
|
||||
| | |
|
||||
|:--|:--|
|
||||
| Website | [nofxai.com](https://nofxai.com) |
|
||||
| Dashboard | [nofxos.ai/dashboard](https://nofxos.ai/dashboard) |
|
||||
| API Docs | [nofxos.ai/api-docs](https://nofxos.ai/api-docs) |
|
||||
| Telegram | [nofx_dev_community](https://t.me/nofx_dev_community) |
|
||||
| Twitter | [@nofx_official](https://x.com/nofx_official) |
|
||||
|
||||
> **Cảnh báo rủi ro**: Giao dịch tự động AI có rủi ro đáng kể. Chỉ nên sử dụng cho mục đích học tập/nghiên cứu hoặc số tiền nhỏ.
|
||||
|
||||
---
|
||||
|
||||
## Cảnh Báo Rủi Ro
|
||||
## License
|
||||
|
||||
1. Thị trường crypto biến động cực kỳ mạnh — Quyết định AI không đảm bảo lợi nhuận
|
||||
2. Giao dịch hợp đồng tương lai sử dụng đòn bẩy — Thua lỗ có thể vượt quá vốn
|
||||
3. Điều kiện thị trường cực đoan có thể dẫn đến thanh lý
|
||||
[AGPL-3.0](../../../LICENSE)
|
||||
|
||||
---
|
||||
|
||||
## Giấy Phép
|
||||
|
||||
**GNU Affero General Public License v3.0 (AGPL-3.0)**
|
||||
|
||||
---
|
||||
|
||||
## Liên Hệ
|
||||
|
||||
- **GitHub Issues**: [Gửi Issue](https://github.com/NoFxAiOS/nofx/issues)
|
||||
- **Cộng đồng Nhà phát triển**: [Nhóm Telegram](https://t.me/nofx_dev_community)
|
||||
[](https://star-history.com/#NoFxAiOS/nofx&Date)
|
||||
|
||||
@@ -1,426 +1,238 @@
|
||||
# NOFX - AI 交易系统
|
||||
<h1 align="center">NOFX</h1>
|
||||
|
||||
[](https://golang.org/)
|
||||
[](https://reactjs.org/)
|
||||
[](https://www.typescriptlang.org/)
|
||||
[](LICENSE)
|
||||
<p align="center">
|
||||
<strong>你的个人 AI 交易助手。</strong><br/>
|
||||
<strong>任何市场。任何模型。用 USDC 付费,无需 API Key。</strong>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/NoFxAiOS/nofx/stargazers"><img src="https://img.shields.io/github/stars/NoFxAiOS/nofx?style=for-the-badge" alt="Stars"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/releases"><img src="https://img.shields.io/github/v/release/NoFxAiOS/nofx?style=for-the-badge" alt="Release"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-AGPL--3.0-blue.svg?style=for-the-badge" alt="License"></a>
|
||||
<a href="https://t.me/nofx_dev_community"><img src="https://img.shields.io/badge/Telegram-Community-blue?style=for-the-badge&logo=telegram" alt="Telegram"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://golang.org/"><img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go" alt="Go"></a>
|
||||
<a href="https://reactjs.org/"><img src="https://img.shields.io/badge/React-18+-61DAFB?style=flat&logo=react" alt="React"></a>
|
||||
<a href="https://x402.org"><img src="https://img.shields.io/badge/x402-USDC%20Payments-2775CA?style=flat" alt="x402"></a>
|
||||
<a href="https://claw402.ai"><img src="https://img.shields.io/badge/Claw402-AI%20Gateway-FF6B35?style=flat" alt="Claw402"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="../../../README.md">English</a> ·
|
||||
<a href="README.md">中文</a> ·
|
||||
<a href="../ja/README.md">日本語</a> ·
|
||||
<a href="../ko/README.md">한국어</a> ·
|
||||
<a href="../ru/README.md">Русский</a> ·
|
||||
<a href="../uk/README.md">Українська</a> ·
|
||||
<a href="../vi/README.md">Tiếng Việt</a>
|
||||
</p>
|
||||
|
||||
> **语言声明:** 本中文版本文档仅为方便海外华人社区阅读而提供,不代表本软件面向中国大陆、香港、澳门或台湾地区用户开放。如您位于上述地区,请勿使用本软件。
|
||||
|
||||
| 贡献者空投计划 |
|
||||
|:----------------------------------:|
|
||||
| 代码 · Bug修复 · Issue → 空投奖励 |
|
||||
| [了解更多](#贡献者空投计划) |
|
||||
|
||||
**语言:** [English](../../../README.md) | [中文](README.md) | [日本語](../ja/README.md) | [한국어](../ko/README.md) | [Русский](../ru/README.md) | [Українська](../uk/README.md) | [Tiếng Việt](../vi/README.md)
|
||||
|
||||
---
|
||||
|
||||
## AI 驱动的加密货币交易平台
|
||||
NOFX 是一个开源的**自主式** AI 交易助手。与需要手动配置模型、管理 API Key、接入数据源的传统 AI 工具不同 —— NOFX 的 AI **自主感知市场、自选模型、自动获取数据**。零人工干预。你只需设定策略,AI 负责一切。
|
||||
|
||||
**NOFX** 是一个开源的 AI 交易系统,让你可以运行多个 AI 模型自动交易加密货币期货。通过 Web 界面配置策略,实时监控表现,让多个 AI 代理竞争找出最佳交易方案。
|
||||
**完全自主**:AI 自行决定使用哪个模型、获取什么市场数据、何时交易。无需手动配置模型,无需管理各种服务的 API Key。只需充值 USDC 钱包,一键启动。
|
||||
|
||||
### 核心功能
|
||||
核心差异:**内置 [x402](https://x402.org) 微支付协议**。无需 API Key,充值 USDC 钱包即可按需付费。钱包就是你的身份。
|
||||
|
||||
- **多 AI 支持**: 运行 DeepSeek、通义千问、GPT、Claude、Gemini、Grok、Kimi - 随时切换模型
|
||||
- **多交易所**: 在 Binance、Bybit、OKX、Hyperliquid、Aster DEX、Lighter 统一交易
|
||||
- **策略工作室**: 可视化策略构建器,配置币种来源、指标和风控参数
|
||||
- **AI 竞赛模式**: 多个 AI 交易员实时竞争,并排追踪表现
|
||||
- **Web 配置**: 无需编辑 JSON - 通过 Web 界面完成所有配置
|
||||
- **实时仪表板**: 实时持仓、盈亏追踪、AI 决策日志与思维链
|
||||
|
||||
### 核心团队
|
||||
|
||||
- **Tinkle** - [@Web3Tinkle](https://x.com/Web3Tinkle)
|
||||
- **官方 Twitter** - [@nofx_official](https://x.com/nofx_official)
|
||||
|
||||
> **风险提示**: 本系统为实验性质。AI 自动交易存在重大风险。强烈建议仅用于学习/研究目的或小额测试!
|
||||
|
||||
## 开发者社区
|
||||
|
||||
加入我们的 Telegram 开发者社区: **[NOFX 开发者社区](https://t.me/nofx_dev_community)**
|
||||
|
||||
---
|
||||
|
||||
## 截图
|
||||
|
||||
### 竞赛模式 - 实时 AI 对战
|
||||

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

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

|
||||
*多数据源策略配置与 AI 测试*
|
||||
|
||||
---
|
||||
|
||||
## 支持的交易所
|
||||
|
||||
### CEX (中心化交易所)
|
||||
|
||||
| 交易所 | 状态 | 注册 (手续费折扣) |
|
||||
|----------|--------|-------------------------|
|
||||
| **Binance** | ✅ 已支持 | [注册](https://www.binance.com/join?ref=NOFXENG) |
|
||||
| **Bybit** | ✅ 已支持 | [注册](https://partner.bybit.com/b/83856) |
|
||||
| **OKX** | ✅ 已支持 | [注册](https://www.okx.com/join/1865360) |
|
||||
|
||||
### Perp-DEX (去中心化永续交易所)
|
||||
|
||||
| 交易所 | 状态 | 注册 (手续费折扣) |
|
||||
|----------|--------|-------------------------|
|
||||
| **Hyperliquid** | ✅ 已支持 | [注册](https://app.hyperliquid.xyz/join/AITRADING) |
|
||||
| **Aster DEX** | ✅ 已支持 | [注册](https://www.asterdex.com/en/referral/fdfc0e) |
|
||||
| **Lighter** | ✅ 已支持 | [注册](https://app.lighter.xyz/?referral=68151432) |
|
||||
|
||||
---
|
||||
|
||||
## 支持的 AI 模型
|
||||
|
||||
| AI 模型 | 状态 | 获取 API Key |
|
||||
|----------|--------|-------------|
|
||||
| **DeepSeek** | ✅ 已支持 | [获取 API Key](https://platform.deepseek.com) |
|
||||
| **通义千问** | ✅ 已支持 | [获取 API Key](https://dashscope.console.aliyun.com) |
|
||||
| **OpenAI (GPT)** | ✅ 已支持 | [获取 API Key](https://platform.openai.com) |
|
||||
| **Claude** | ✅ 已支持 | [获取 API Key](https://console.anthropic.com) |
|
||||
| **Gemini** | ✅ 已支持 | [获取 API Key](https://aistudio.google.com) |
|
||||
| **Grok** | ✅ 已支持 | [获取 API Key](https://console.x.ai) |
|
||||
| **Kimi** | ✅ 已支持 | [获取 API Key](https://platform.moonshot.cn) |
|
||||
|
||||
---
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 一键安装 (推荐)
|
||||
|
||||
**Linux / macOS:**
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
```
|
||||
|
||||
完成!打开浏览器访问 **http://127.0.0.1:3000**
|
||||
打开 **http://127.0.0.1:3000**,完成。
|
||||
|
||||
### Docker Compose (手动)
|
||||
---
|
||||
|
||||
## x402 如何工作
|
||||
|
||||
传统流程:注册账号 → 购买额度 → 获取 API Key → 管理配额 → 轮换密钥。
|
||||
|
||||
x402 流程:
|
||||
|
||||
```
|
||||
请求 → 402(返回价格)→ 钱包签名 USDC → 重试 → 完成
|
||||
```
|
||||
|
||||
无需注册。无需 API Key。无需预付费。一个钱包,所有模型。
|
||||
|
||||
### 内置 x402 提供商
|
||||
|
||||
| 提供商 | 链 | 模型 |
|
||||
|:---------|:------|:-------|
|
||||
| <img src="../../../web/public/icons/claw402.png" width="20" height="20" style="vertical-align: middle;"/> **[Claw402](https://claw402.ai)** | Base | GPT-5.4、Claude Opus、DeepSeek、Qwen、Grok、Gemini、Kimi — 15+ 模型 |
|
||||
|
||||
---
|
||||
|
||||
## 功能概览
|
||||
|
||||
| 功能 | 描述 |
|
||||
|:--------|:------------|
|
||||
| **多 AI** | DeepSeek、Qwen、GPT、Claude、Gemini、Grok、Kimi、MiniMax — 随时切换 |
|
||||
| **多交易所** | Binance、Bybit、OKX、Bitget、KuCoin、Gate、Hyperliquid、Aster、Lighter |
|
||||
| **策略工作室** | 可视化构建器 — 币种来源、指标、风控 |
|
||||
| **AI 竞赛** | AI 实时竞争,排行榜排名 |
|
||||
| **Telegram Agent** | 与交易助手对话 — 流式输出、工具调用、记忆 |
|
||||
| **回测实验室** | 历史模拟,权益曲线和性能指标 |
|
||||
| **仪表板** | 实时持仓、盈亏、AI 决策日志与思维链 |
|
||||
|
||||
### 市场
|
||||
|
||||
加密货币 · 美股 · 外汇 · 贵金属
|
||||
|
||||
### 交易所 (CEX)
|
||||
|
||||
| 交易所 | 状态 | 注册 (手续费折扣) |
|
||||
|:---------|:------:|:------------------------|
|
||||
| <img src="../../../web/public/exchange-icons/binance.jpg" width="20" height="20" style="vertical-align: middle;"/> **Binance** | ✅ | [注册](https://www.binance.com/join?ref=NOFXENG) |
|
||||
| <img src="../../../web/public/exchange-icons/bybit.png" width="20" height="20" style="vertical-align: middle;"/> **Bybit** | ✅ | [注册](https://partner.bybit.com/b/83856) |
|
||||
| <img src="../../../web/public/exchange-icons/okx.svg" width="20" height="20" style="vertical-align: middle;"/> **OKX** | ✅ | [注册](https://www.okx.com/join/1865360) |
|
||||
| <img src="../../../web/public/exchange-icons/bitget.svg" width="20" height="20" style="vertical-align: middle;"/> **Bitget** | ✅ | [注册](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
|
||||
| <img src="../../../web/public/exchange-icons/kucoin.svg" width="20" height="20" style="vertical-align: middle;"/> **KuCoin** | ✅ | [注册](https://www.kucoin.com/r/broker/CXEV7XKK) |
|
||||
| <img src="../../../web/public/exchange-icons/gate.svg" width="20" height="20" style="vertical-align: middle;"/> **Gate** | ✅ | [注册](https://www.gatenode.xyz/share/VQBGUAxY) |
|
||||
|
||||
### 交易所 (Perp-DEX)
|
||||
|
||||
| 交易所 | 状态 | 注册 (手续费折扣) |
|
||||
|:---------|:------:|:------------------------|
|
||||
| <img src="../../../web/public/exchange-icons/hyperliquid.png" width="20" height="20" style="vertical-align: middle;"/> **Hyperliquid** | ✅ | [注册](https://app.hyperliquid.xyz/join/AITRADING) |
|
||||
| <img src="../../../web/public/exchange-icons/aster.svg" width="20" height="20" style="vertical-align: middle;"/> **Aster DEX** | ✅ | [注册](https://www.asterdex.com/en/referral/fdfc0e) |
|
||||
| <img src="../../../web/public/exchange-icons/lighter.png" width="20" height="20" style="vertical-align: middle;"/> **Lighter** | ✅ | [注册](https://app.lighter.xyz/?referral=68151432) |
|
||||
|
||||
### AI 模型 (API Key 模式)
|
||||
|
||||
| AI 模型 | 状态 | 获取 API Key |
|
||||
|:---------|:------:|:------------|
|
||||
| <img src="../../../web/public/icons/deepseek.svg" width="20" height="20" style="vertical-align: middle;"/> **DeepSeek** | ✅ | [获取 API Key](https://platform.deepseek.com) |
|
||||
| <img src="../../../web/public/icons/qwen.svg" width="20" height="20" style="vertical-align: middle;"/> **通义千问** | ✅ | [获取 API Key](https://dashscope.console.aliyun.com) |
|
||||
| <img src="../../../web/public/icons/openai.svg" width="20" height="20" style="vertical-align: middle;"/> **OpenAI (GPT)** | ✅ | [获取 API Key](https://platform.openai.com) |
|
||||
| <img src="../../../web/public/icons/claude.svg" width="20" height="20" style="vertical-align: middle;"/> **Claude** | ✅ | [获取 API Key](https://console.anthropic.com) |
|
||||
| <img src="../../../web/public/icons/gemini.svg" width="20" height="20" style="vertical-align: middle;"/> **Gemini** | ✅ | [获取 API Key](https://aistudio.google.com) |
|
||||
| <img src="../../../web/public/icons/grok.svg" width="20" height="20" style="vertical-align: middle;"/> **Grok** | ✅ | [获取 API Key](https://console.x.ai) |
|
||||
| <img src="../../../web/public/icons/kimi.svg" width="20" height="20" style="vertical-align: middle;"/> **Kimi** | ✅ | [获取 API Key](https://platform.moonshot.cn) |
|
||||
| <img src="../../../web/public/icons/minimax.svg" width="20" height="20" style="vertical-align: middle;"/> **MiniMax** | ✅ | [获取 API Key](https://platform.minimaxi.com) |
|
||||
|
||||
### AI 模型 (x402 模式 — 无需 API Key)
|
||||
|
||||
15+ 模型通过 [Claw402](https://claw402.ai) 接入 — 只需一个 USDC 钱包
|
||||
|
||||
---
|
||||
|
||||
## 安装
|
||||
|
||||
### Linux / macOS
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
```
|
||||
|
||||
### Railway (云部署)
|
||||
|
||||
[](https://railway.com/deploy/nofx?referralCode=nofx)
|
||||
|
||||
### Docker
|
||||
|
||||
```bash
|
||||
# 下载并启动
|
||||
curl -O https://raw.githubusercontent.com/NoFxAiOS/nofx/main/docker-compose.prod.yml
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
访问 Web 界面: **http://127.0.0.1:3000**
|
||||
### Windows
|
||||
|
||||
```bash
|
||||
# 管理命令
|
||||
docker compose -f docker-compose.prod.yml logs -f # 查看日志
|
||||
docker compose -f docker-compose.prod.yml restart # 重启
|
||||
docker compose -f docker-compose.prod.yml down # 停止
|
||||
docker compose -f docker-compose.prod.yml pull && docker compose -f docker-compose.prod.yml up -d # 更新
|
||||
安装 [Docker Desktop](https://www.docker.com/products/docker-desktop/),然后:
|
||||
|
||||
```powershell
|
||||
curl -o docker-compose.prod.yml https://raw.githubusercontent.com/NoFxAiOS/nofx/main/docker-compose.prod.yml
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
### 保持更新
|
||||
### 从源码构建
|
||||
|
||||
> **💡 更新频繁。** 每天运行以下命令以获取最新功能和修复:
|
||||
```bash
|
||||
# 前置条件: Go 1.21+, Node.js 18+, TA-Lib
|
||||
# macOS: brew install ta-lib
|
||||
# Ubuntu: sudo apt-get install libta-lib0-dev
|
||||
|
||||
git clone https://github.com/NoFxAiOS/nofx.git && cd nofx
|
||||
go build -o nofx && ./nofx # 后端
|
||||
cd web && npm install && npm run dev # 前端 (新终端)
|
||||
```
|
||||
|
||||
### 更新
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
```
|
||||
|
||||
此命令会拉取最新官方镜像并自动重启服务。
|
||||
|
||||
### 手动安装 (开发者)
|
||||
|
||||
#### 前置条件
|
||||
|
||||
- **Go 1.21+**
|
||||
- **Node.js 18+**
|
||||
- **TA-Lib** (技术指标库)
|
||||
|
||||
```bash
|
||||
# 安装 TA-Lib
|
||||
# macOS
|
||||
brew install ta-lib
|
||||
|
||||
# Ubuntu/Debian
|
||||
sudo apt-get install libta-lib0-dev
|
||||
```
|
||||
|
||||
#### 安装步骤
|
||||
|
||||
```bash
|
||||
# 1. 克隆仓库
|
||||
git clone https://github.com/NoFxAiOS/nofx.git
|
||||
cd nofx
|
||||
|
||||
# 2. 安装后端依赖
|
||||
go mod download
|
||||
|
||||
# 3. 安装前端依赖
|
||||
cd web
|
||||
npm install
|
||||
cd ..
|
||||
|
||||
# 4. 构建并启动后端
|
||||
go build -o nofx
|
||||
./nofx
|
||||
|
||||
# 5. 启动前端 (新终端)
|
||||
cd web
|
||||
npm run dev
|
||||
```
|
||||
|
||||
访问 Web 界面: **http://127.0.0.1:3000**
|
||||
|
||||
---
|
||||
|
||||
## Windows 安装
|
||||
## 配置
|
||||
|
||||
### 方法一:Docker Desktop(推荐)
|
||||
**新手模式**:首次使用的用户可以在注册时选择新手模式,系统会引导你逐步完成 AI、交易所和策略的配置。
|
||||
|
||||
1. **安装 Docker Desktop**
|
||||
- 从 [docker.com/products/docker-desktop](https://www.docker.com/products/docker-desktop/) 下载
|
||||
- 运行安装程序并重启电脑
|
||||
- 启动 Docker Desktop 并等待就绪
|
||||
**进阶模式**:
|
||||
|
||||
2. **运行 NOFX**
|
||||
```powershell
|
||||
# 打开 PowerShell 运行:
|
||||
curl -o docker-compose.prod.yml https://raw.githubusercontent.com/NoFxAiOS/nofx/main/docker-compose.prod.yml
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
1. **AI** — 添加 API Key 或配置 x402 钱包
|
||||
2. **交易所** — 连接交易所 API 凭证
|
||||
3. **策略** — 在策略工作室构建
|
||||
4. **交易员** — 组合 AI + 交易所 + 策略
|
||||
5. **交易** — 从仪表板启动
|
||||
|
||||
3. **访问**:在浏览器打开 **http://127.0.0.1:3000**
|
||||
|
||||
### 方法二:WSL2(适合开发)
|
||||
|
||||
1. **安装 WSL2**
|
||||
```powershell
|
||||
# 以管理员身份打开 PowerShell
|
||||
wsl --install
|
||||
```
|
||||
安装完成后重启电脑。
|
||||
|
||||
2. **从 Microsoft Store 安装 Ubuntu**
|
||||
- 打开 Microsoft Store
|
||||
- 搜索 "Ubuntu 22.04" 并安装
|
||||
- 启动 Ubuntu 并设置用户名/密码
|
||||
|
||||
3. **在 WSL2 中安装依赖**
|
||||
```bash
|
||||
# 更新系统
|
||||
sudo apt update && sudo apt upgrade -y
|
||||
|
||||
# 安装 Go
|
||||
wget https://go.dev/dl/go1.21.5.linux-amd64.tar.gz
|
||||
sudo tar -C /usr/local -xzf go1.21.5.linux-amd64.tar.gz
|
||||
echo 'export PATH=$PATH:/usr/local/go/bin' >> ~/.bashrc
|
||||
source ~/.bashrc
|
||||
|
||||
# 安装 Node.js
|
||||
curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash -
|
||||
sudo apt-get install -y nodejs
|
||||
|
||||
# 安装 TA-Lib
|
||||
sudo apt-get install -y libta-lib0-dev
|
||||
|
||||
# 安装 Git
|
||||
sudo apt-get install -y git
|
||||
```
|
||||
|
||||
4. **克隆并运行 NOFX**
|
||||
```bash
|
||||
git clone https://github.com/NoFxAiOS/nofx.git
|
||||
cd nofx
|
||||
|
||||
# 构建并运行后端
|
||||
go build -o nofx && ./nofx
|
||||
|
||||
# 在另一个终端运行前端
|
||||
cd web && npm install && npm run dev
|
||||
```
|
||||
|
||||
5. **访问**:在 Windows 浏览器打开 **http://127.0.0.1:3000**
|
||||
|
||||
### 方法三:WSL2 + Docker(两全其美)
|
||||
|
||||
1. **安装 Docker Desktop 并启用 WSL2 后端**
|
||||
- Docker Desktop 安装时勾选 "Use WSL 2 based engine"
|
||||
- 在 Docker Desktop 设置 → Resources → WSL Integration 中启用你的 Linux 发行版
|
||||
|
||||
2. **在 WSL2 终端运行**
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 服务器部署
|
||||
|
||||
### 快速部署 (HTTP/IP 访问)
|
||||
|
||||
默认情况下,传输加密已**禁用**,可直接通过 IP 地址访问 NOFX:
|
||||
|
||||
```bash
|
||||
# 部署到你的服务器
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
```
|
||||
|
||||
通过 `http://你的服务器IP:3000` 访问 - 立即可用。
|
||||
|
||||
### 增强安全 (HTTPS)
|
||||
|
||||
如需增强安全性,在 `.env` 中启用传输加密:
|
||||
|
||||
```bash
|
||||
TRANSPORT_ENCRYPTION=true
|
||||
```
|
||||
|
||||
启用后,浏览器会使用 Web Crypto API 在传输前加密 API 密钥。此功能需要:
|
||||
- `https://` - 任何有 SSL 证书的域名
|
||||
- `http://localhost` - 本地开发
|
||||
|
||||
### Cloudflare 快速配置 HTTPS
|
||||
|
||||
1. **添加域名到 Cloudflare** (免费计划即可)
|
||||
- 访问 [dash.cloudflare.com](https://dash.cloudflare.com)
|
||||
- 添加域名并更新 DNS 服务器
|
||||
|
||||
2. **创建 DNS 记录**
|
||||
- 类型: `A`
|
||||
- 名称: `nofx` (或你的子域名)
|
||||
- 内容: 你的服务器 IP
|
||||
- 代理状态: **已代理** (橙色云朵)
|
||||
|
||||
3. **配置 SSL/TLS**
|
||||
- 进入 SSL/TLS 设置
|
||||
- 加密模式选择 **灵活**
|
||||
|
||||
```
|
||||
用户 ──[HTTPS]──→ Cloudflare ──[HTTP]──→ 你的服务器:3000
|
||||
```
|
||||
|
||||
4. **启用传输加密**
|
||||
```bash
|
||||
# 编辑 .env 并设置
|
||||
TRANSPORT_ENCRYPTION=true
|
||||
```
|
||||
|
||||
5. **完成!** 通过 `https://nofx.你的域名.com` 访问
|
||||
|
||||
---
|
||||
|
||||
## 初始配置 (Web 界面)
|
||||
|
||||
启动系统后,通过 Web 界面进行配置:
|
||||
|
||||
1. **配置 AI 模型** - 添加你的 AI API 密钥 (DeepSeek, OpenAI 等)
|
||||
2. **配置交易所** - 设置交易所 API 凭证
|
||||
3. **创建策略** - 在策略工作室配置交易策略
|
||||
4. **创建交易员** - 组合 AI 模型 + 交易所 + 策略
|
||||
5. **开始交易** - 启动你配置的交易员
|
||||
|
||||
所有配置都通过 Web 界面完成 - 无需编辑 JSON 文件。
|
||||
|
||||
---
|
||||
|
||||
## Web 界面功能
|
||||
|
||||
### 竞赛页面
|
||||
- 实时 ROI 排行榜
|
||||
- 多 AI 性能对比图表
|
||||
- 实时盈亏追踪和排名
|
||||
|
||||
### 仪表板
|
||||
- TradingView 风格 K 线图
|
||||
- 实时持仓管理
|
||||
- AI 决策日志与思维链推理
|
||||
- 权益曲线追踪
|
||||
|
||||
### 策略工作室
|
||||
- 币种来源配置 (静态列表、AI500 池、OI Top)
|
||||
- 技术指标 (EMA, MACD, RSI, ATR, 成交量, OI, 资金费率)
|
||||
- 风控设置 (杠杆、仓位限制、保证金使用率)
|
||||
- AI 测试与实时提示词预览
|
||||
|
||||
---
|
||||
|
||||
## 常见问题
|
||||
|
||||
### TA-Lib 未找到
|
||||
```bash
|
||||
# macOS
|
||||
brew install ta-lib
|
||||
|
||||
# Ubuntu
|
||||
sudo apt-get install libta-lib0-dev
|
||||
```
|
||||
|
||||
### AI API 超时
|
||||
- 检查 API 密钥是否正确
|
||||
- 检查网络连接
|
||||
- 系统超时时间为 120 秒
|
||||
|
||||
### 前端无法连接后端
|
||||
- 确保后端运行在 http://localhost:8080
|
||||
- 检查端口是否被占用
|
||||
所有操作通过 Web 界面完成:**http://127.0.0.1:3000**
|
||||
|
||||
---
|
||||
|
||||
## 文档
|
||||
|
||||
| 文档 | 描述 |
|
||||
|------|------|
|
||||
| **[架构概览](../../architecture/README.zh-CN.md)** | 系统设计和模块索引 |
|
||||
| **[策略模块](../../architecture/STRATEGY_MODULE.md)** | 币种选择、数据组装、AI 提示词、执行 |
|
||||
| **[回测模块](../../architecture/BACKTEST_MODULE.md)** | 历史模拟、指标计算、断点续测 |
|
||||
| **[辩论模块](../../architecture/DEBATE_MODULE.md)** | 多 AI 辩论、投票共识、自动执行 |
|
||||
| **[常见问题](../../faq/README.md)** | FAQ |
|
||||
| **[快速开始](../../getting-started/README.zh-CN.md)** | 部署指南 |
|
||||
|
||||
---
|
||||
|
||||
## 许可证
|
||||
|
||||
本项目采用 **GNU Affero General Public License v3.0 (AGPL-3.0)** 许可 - 详见 [LICENSE](../../../LICENSE) 文件。
|
||||
| | |
|
||||
|:--|:--|
|
||||
| [架构概览](../../architecture/README.md) | 系统设计和模块索引 |
|
||||
| [策略模块](../../architecture/STRATEGY_MODULE.md) | 币种选择、AI 提示词、执行 |
|
||||
| [常见问题](../../faq/README.md) | FAQ |
|
||||
| [快速开始](../../getting-started/README.md) | 部署指南 |
|
||||
|
||||
---
|
||||
|
||||
## 贡献
|
||||
|
||||
欢迎贡献!查看:
|
||||
- **[贡献指南](../../../CONTRIBUTING.md)** - 开发流程和 PR 流程
|
||||
- **[行为准则](../../../CODE_OF_CONDUCT.md)** - 社区准则
|
||||
- **[安全政策](../../../SECURITY.md)** - 报告漏洞
|
||||
查看 [贡献指南](../../../CONTRIBUTING.md) · [行为准则](../../../CODE_OF_CONDUCT.md) · [安全政策](../../../SECURITY.md)
|
||||
|
||||
---
|
||||
### 贡献者空投计划
|
||||
|
||||
## 贡献者空投计划
|
||||
|
||||
所有贡献都在 GitHub 上追踪。当 NOFX 产生收入时,贡献者将根据其贡献获得空投。
|
||||
所有贡献在 GitHub 上追踪。当 NOFX 产生收入时,贡献者将获得空投。
|
||||
|
||||
**解决 [置顶 Issue](https://github.com/NoFxAiOS/nofx/issues) 的 PR 获得最高奖励!**
|
||||
|
||||
| 贡献类型 | 权重 |
|
||||
|------------------|:------:|
|
||||
| **置顶 Issue PR** | ⭐⭐⭐⭐⭐⭐ |
|
||||
| **代码提交** (合并的 PR) | ⭐⭐⭐⭐⭐ |
|
||||
| **Bug 修复** | ⭐⭐⭐⭐ |
|
||||
| **功能建议** | ⭐⭐⭐ |
|
||||
| **Bug 报告** | ⭐⭐ |
|
||||
| **文档** | ⭐⭐ |
|
||||
|:-------------|:------:|
|
||||
| 置顶 Issue PR | ★★★★★★ |
|
||||
| 代码提交 (合并的 PR) | ★★★★★ |
|
||||
| Bug 修复 | ★★★★ |
|
||||
| 功能建议 | ★★★ |
|
||||
| Bug 报告 | ★★ |
|
||||
| 文档 | ★★ |
|
||||
|
||||
---
|
||||
|
||||
## 联系方式
|
||||
## 链接
|
||||
|
||||
- **GitHub Issues**: [提交 Issue](https://github.com/NoFxAiOS/nofx/issues)
|
||||
- **开发者社区**: [Telegram 群组](https://t.me/nofx_dev_community)
|
||||
| | |
|
||||
|:--|:--|
|
||||
| 官网 | [nofxai.com](https://nofxai.com) |
|
||||
| 数据面板 | [nofxos.ai/dashboard](https://nofxos.ai/dashboard) |
|
||||
| API 文档 | [nofxos.ai/api-docs](https://nofxos.ai/api-docs) |
|
||||
| Telegram | [nofx_dev_community](https://t.me/nofx_dev_community) |
|
||||
| Twitter | [@nofx_official](https://x.com/nofx_official) |
|
||||
|
||||
> **风险提示**: AI 自动交易存在重大风险。建议仅用于学习/研究或小额测试。
|
||||
|
||||
---
|
||||
|
||||
## Star 历史
|
||||
## License
|
||||
|
||||
[AGPL-3.0](../../../LICENSE)
|
||||
|
||||
[](https://star-history.com/#NoFxAiOS/nofx&Date)
|
||||
|
||||
281
docs/market-regime-classification-en.md
Normal file
281
docs/market-regime-classification-en.md
Normal file
@@ -0,0 +1,281 @@
|
||||
# Market Regime Classification Framework
|
||||
|
||||
> A comprehensive market state identification system for quantitative trading strategy matching
|
||||
|
||||
---
|
||||
|
||||
## 1. Classification Dimensions Overview
|
||||
|
||||
Market state identification requires analysis across multiple dimensions:
|
||||
|
||||
| Dimension | Sub-dimensions | Description |
|
||||
|-----------|---------------|-------------|
|
||||
| **Trend** | Direction, Strength | Determine market movement direction and momentum |
|
||||
| **Volatility** | Amplitude, Frequency | Measure price fluctuation characteristics |
|
||||
| **Structure** | Pattern, Phase | Identify market structure and cycle position |
|
||||
|
||||
---
|
||||
|
||||
## 2. Primary Classification (5 Categories)
|
||||
|
||||
### 2.1 Classification Overview
|
||||
|
||||
| Code | Name | Key Characteristics | Suitable Strategies |
|
||||
|------|------|---------------------|---------------------|
|
||||
| `TREND_UP` | Uptrend | Higher highs & higher lows | Trend following, Breakout |
|
||||
| `TREND_DOWN` | Downtrend | Lower highs & lower lows | Trend following, Short selling |
|
||||
| `RANGE` | Range-bound | Price oscillates within bounds | Grid trading, Mean reversion |
|
||||
| `TRANSITION` | Transition | Uncertain directional period | Wait & watch, Small positions |
|
||||
| `BREAKOUT` | Breakout | Price breaks key levels | Breakout trading |
|
||||
|
||||
### 2.2 Identification Indicators
|
||||
|
||||
- **ADX (Average Directional Index)**: Measures trend strength
|
||||
- ADX > 25: Clear trend exists
|
||||
- ADX < 20: Range-bound market
|
||||
- **EMA Alignment**: Determines trend direction
|
||||
- EMA20 > EMA50 > EMA200: Bullish alignment
|
||||
- EMA20 < EMA50 < EMA200: Bearish alignment
|
||||
|
||||
---
|
||||
|
||||
## 3. Secondary Classification (18 Sub-categories)
|
||||
|
||||
### 3.1 Uptrend Sub-categories (5 Types)
|
||||
|
||||
| Code | Name | Technical Features | Quantitative Indicators |
|
||||
|------|------|-------------------|------------------------|
|
||||
| `TU_STRONG_LOW_VOL` | Strong Uptrend · Low Vol | Steady rise, shallow pullbacks | ADX>40, ATR%<2%, Pullback<38.2% |
|
||||
| `TU_STRONG_HIGH_VOL` | Strong Uptrend · High Vol | Rapid surge, high volatility | ADX>40, ATR%>4%, MACD histogram expanding |
|
||||
| `TU_WEAK_CHOPPY` | Weak Uptrend · Choppy | Two steps forward, one back | ADX 20-30, RSI oscillating 50-70 |
|
||||
| `TU_PARABOLIC` | Parabolic Acceleration | Exponential price increase | Price far from MA, RSI>80, Volume surge |
|
||||
| `TU_EXHAUSTION` | Uptrend Exhaustion | New highs but weakening momentum | Price new high + MACD/RSI divergence |
|
||||
|
||||
**Strategy Matching:**
|
||||
- Strong Low Vol: Heavy trend following, pyramid adding
|
||||
- Strong High Vol: Medium position, trailing stops
|
||||
- Weak Choppy: Light swing trading
|
||||
- Parabolic: Cautious, prepare to exit
|
||||
- Exhaustion: Reduce positions, prepare for reversal
|
||||
|
||||
### 3.2 Downtrend Sub-categories (5 Types)
|
||||
|
||||
| Code | Name | Technical Features | Quantitative Indicators |
|
||||
|------|------|-------------------|------------------------|
|
||||
| `TD_STRONG_LOW_VOL` | Strong Downtrend · Low Vol | Steady decline, weak bounces | ADX>40, ATR%<2%, Bounce<38.2% |
|
||||
| `TD_STRONG_HIGH_VOL` | Strong Downtrend · High Vol | Panic selling, wild swings | ADX>40, ATR%>5%, VIX spike |
|
||||
| `TD_WEAK_CHOPPY` | Weak Downtrend · Choppy | Grinding lower with bounces | ADX 20-30, RSI oscillating 30-50 |
|
||||
| `TD_CAPITULATION` | Capitulation | High volume crash, extreme fear | RSI<20, Volume>3x average |
|
||||
| `TD_EXHAUSTION` | Downtrend Exhaustion | New lows but selling pressure fading | Price new low + MACD/RSI divergence |
|
||||
|
||||
**Strategy Matching:**
|
||||
- Strong Low Vol: Short trend following
|
||||
- Strong High Vol: Stay flat or light hedge
|
||||
- Weak Choppy: Wait for stabilization
|
||||
- Capitulation: Light bottom fishing possible
|
||||
- Exhaustion: Gradually build long positions
|
||||
|
||||
### 3.3 Range Sub-categories (4 Types)
|
||||
|
||||
| Code | Name | Technical Features | Quantitative Indicators |
|
||||
|------|------|-------------------|------------------------|
|
||||
| `RG_TIGHT_LOW_VOL` | Tight Range · Low Vol | Extreme contraction, coiling | BB Width<2%, ATR at new lows |
|
||||
| `RG_TIGHT_HIGH_VOL` | Tight Range · High Vol | Violent swings within range | BB Width<3%, ATR%>3% |
|
||||
| `RG_WIDE_LOW_VOL` | Wide Range · Low Vol | Large range, slow movement | BB Width>5%, ATR%<2% |
|
||||
| `RG_WIDE_HIGH_VOL` | Wide Range · High Vol | Large range, fast movement | BB Width>5%, ATR%>3% |
|
||||
|
||||
**Strategy Matching:**
|
||||
- Tight Low Vol: Dense grid, wait for breakout
|
||||
- Tight High Vol: Fast grid, small frequent profits
|
||||
- Wide Low Vol: Sparse grid, patient holding
|
||||
- Wide High Vol: Swing trading, high profit targets
|
||||
|
||||
### 3.4 Transition (2 Types)
|
||||
|
||||
| Code | Name | Technical Features | Quantitative Indicators |
|
||||
|------|------|-------------------|------------------------|
|
||||
| `TR_BOTTOM_FORMING` | Bottom Forming | Decline slowing, testing support | Price stabilizing + Volume drying up + RSI divergence |
|
||||
| `TR_TOP_FORMING` | Top Forming | Rally slowing, testing resistance | Price stalling + Volume drying up + RSI divergence |
|
||||
|
||||
### 3.5 Breakout (2 Types)
|
||||
|
||||
| Code | Name | Technical Features | Quantitative Indicators |
|
||||
|------|------|-------------------|------------------------|
|
||||
| `BK_UPWARD` | Upward Breakout | Breaking resistance with volume | Price>Previous high, Volume>2x, BB breakout |
|
||||
| `BK_DOWNWARD` | Downward Breakout | Breaking support with volume | Price<Previous low, Volume>2x, BB breakdown |
|
||||
|
||||
---
|
||||
|
||||
## 4. Tertiary Classification (36 Ultra-fine Categories)
|
||||
|
||||
### 4.1 Trend Phase Classification
|
||||
|
||||
Uptrend lifecycle consists of 5 phases:
|
||||
|
||||
| Phase Code | Name | Description | Quantitative Criteria |
|
||||
|------------|------|-------------|----------------------|
|
||||
| `TU_S1_INITIATION` | Uptrend Initiation | First break above MA or previous high | MACD bullish cross, Price>EMA20 |
|
||||
| `TU_S2_ACCELERATION` | Uptrend Acceleration | Momentum increasing, slope steepening | MACD histogram expanding, ADX rising |
|
||||
| `TU_S3_MAIN_WAVE` | Main Wave | Sustained rise, shallow pullbacks | RSI 60-80, Pullbacks hold EMA20 |
|
||||
| `TU_S4_EXHAUSTION` | Uptrend Exhaustion | Slowing momentum, divergences appearing | RSI divergence, MACD divergence |
|
||||
| `TU_S5_REVERSAL` | Trend Reversal | Breakdown, trend ending | Break below EMA50, MACD bearish cross |
|
||||
|
||||
Downtrend phases follow same pattern: `TD_S1` through `TD_S5`
|
||||
|
||||
### 4.2 Range Position Classification
|
||||
|
||||
| Position Code | Name | Description | Strategy Suggestion |
|
||||
|---------------|------|-------------|---------------------|
|
||||
| `RG_UPPER` | Upper Range | Price near resistance | Bias toward short |
|
||||
| `RG_MIDDLE` | Mid Range | Price near middle band | Neutral grid trading |
|
||||
| `RG_LOWER` | Lower Range | Price near support | Bias toward long |
|
||||
| `RG_SQUEEZE` | Squeeze Pattern | Highs and lows converging | Wait for direction |
|
||||
| `RG_EXPAND` | Expanding Pattern | Highs and lows diverging | Boundary reversal |
|
||||
|
||||
### 4.3 Volatility Grades
|
||||
|
||||
| Code | Name | ATR% | BB Width | Strategy Suggestion |
|
||||
|------|------|------|----------|---------------------|
|
||||
| `VOL_EXTREME_LOW` | Extreme Low Vol | <1% | <1.5% | Option selling |
|
||||
| `VOL_LOW` | Low Volatility | 1-2% | 1.5-2.5% | Grid / Mean reversion |
|
||||
| `VOL_NORMAL` | Normal Volatility | 2-3% | 2.5-4% | Trend following |
|
||||
| `VOL_HIGH` | High Volatility | 3-5% | 4-6% | Momentum / Breakout |
|
||||
| `VOL_EXTREME_HIGH` | Extreme High Vol | >5% | >6% | Reduce exposure / Hedge |
|
||||
|
||||
---
|
||||
|
||||
## 5. Complete State Encoding Rules
|
||||
|
||||
### 5.1 Encoding Format
|
||||
|
||||
```
|
||||
{Primary}_{Volatility}_{Phase}_{Position}
|
||||
```
|
||||
|
||||
### 5.2 Encoding Examples
|
||||
|
||||
| Full Code | Interpretation |
|
||||
|-----------|----------------|
|
||||
| `TU_LV_S3_M` | Uptrend_LowVol_MainWave_Middle |
|
||||
| `TD_HV_S2_L` | Downtrend_HighVol_Acceleration_Lower |
|
||||
| `RG_NV_SQ_U` | Range_NormalVol_Squeeze_Upper |
|
||||
| `BK_HV_UP_M` | Breakout_HighVol_Upward_Middle |
|
||||
|
||||
---
|
||||
|
||||
## 6. Core Identification Indicators
|
||||
|
||||
### 6.1 Trend Indicators
|
||||
|
||||
| Indicator | Calculation | Criteria |
|
||||
|-----------|-------------|----------|
|
||||
| ADX | 14-period Average Directional Index | >40 Strong, 25-40 Medium, <25 Weak/Range |
|
||||
| Trend Score | Composite EMA/MACD/Price structure | -100 to +100, Positive=Bullish, Negative=Bearish |
|
||||
| EMA Alignment | Relative position of EMA20/50/200 | Bullish/Bearish/Mixed alignment |
|
||||
|
||||
### 6.2 Volatility Indicators
|
||||
|
||||
| Indicator | Calculation | Purpose |
|
||||
|-----------|-------------|---------|
|
||||
| ATR Percent | ATR(14) / Current Price × 100% | Measure relative volatility |
|
||||
| BB Width | (Upper - Lower) / Middle × 100% | Measure price range |
|
||||
| Volatility Rank | Current vol percentile in history | Determine vol level |
|
||||
|
||||
### 6.3 Momentum Indicators
|
||||
|
||||
| Indicator | Calculation | Criteria |
|
||||
|-----------|-------------|----------|
|
||||
| RSI | 14-period Relative Strength Index | >70 Overbought, <30 Oversold, 50 Neutral |
|
||||
| MACD Histogram | MACD - Signal | Positive=Bullish momentum, Negative=Bearish |
|
||||
| Momentum Score | Composite RSI/MACD/Volume | Measure current momentum |
|
||||
|
||||
### 6.4 Structure Indicators
|
||||
|
||||
| Indicator | Description | Purpose |
|
||||
|-----------|-------------|---------|
|
||||
| Swing Structure | HH/HL/LH/LL sequence | Determine trend structure |
|
||||
| Support/Resistance | Key price levels | Define trading range |
|
||||
| Volume Profile | Volume-price relationship | Validate price action |
|
||||
|
||||
---
|
||||
|
||||
## 7. Strategy Matching Matrix
|
||||
|
||||
### 7.1 Regime-Strategy Mapping
|
||||
|
||||
| Regime Type | Recommended Strategy | Position Size | Stop Loss |
|
||||
|-------------|---------------------|---------------|-----------|
|
||||
| Strong Uptrend · Low Vol | Trend following + Pyramid | 60-80% | ATR×2 |
|
||||
| Strong Uptrend · High Vol | Momentum + Quick profit | 40-60% | ATR×1.5 |
|
||||
| Uptrend Exhaustion | Reduce + Reversal short | 20-30% | Previous high |
|
||||
| Panic Decline | Wait or light bottom fish | 10-20% | Wide stop |
|
||||
| Low Vol Range | Grid trading | 50-70% | Range boundary |
|
||||
| High Vol Range | Swing trading | 30-50% | ATR×2 |
|
||||
| Squeeze Pattern | Wait for breakout | 10-20% | - |
|
||||
| Upward Breakout | Chase + Add on pullback | 50-70% | Breakout level |
|
||||
| Bottom Formation | Scale in gradually | 20-40% | New low |
|
||||
|
||||
### 7.2 Grid Strategy Parameter Matching
|
||||
|
||||
| Range Type | Grid Levels | Grid Spacing | Other Parameters |
|
||||
|------------|-------------|--------------|------------------|
|
||||
| Tight Low Vol | 30-50 levels | Small spacing | Enable Maker Only |
|
||||
| Tight High Vol | 15-25 levels | Small spacing | Fast execution mode |
|
||||
| Wide Low Vol | 10-20 levels | Large spacing | Patient execution |
|
||||
| Wide High Vol | 15-25 levels | Large spacing | High profit targets |
|
||||
| Squeeze Pattern | Pause grid | - | Wait for breakout signal |
|
||||
| Upper Range | Short bias | Medium | Increase sell weight |
|
||||
| Lower Range | Long bias | Medium | Increase buy weight |
|
||||
|
||||
---
|
||||
|
||||
## 8. Real-time Monitoring Guidelines
|
||||
|
||||
### 8.1 State Transition Triggers
|
||||
|
||||
| Current State | Trigger Condition | Transitions To |
|
||||
|---------------|-------------------|----------------|
|
||||
| Range | Price breakout + Volume + ADX rising | Breakout |
|
||||
| Uptrend | RSI divergence + Volume decline | Exhaustion |
|
||||
| Downtrend | RSI divergence + Volume decline | Exhaustion |
|
||||
| Breakout | Failed breakout, price returns | Range |
|
||||
| Exhaustion | Confirmed reversal breakout | Opposite trend |
|
||||
|
||||
### 8.2 Risk Control Rules
|
||||
|
||||
| Regime State | Max Position | Risk Per Trade | Special Rules |
|
||||
|--------------|--------------|----------------|---------------|
|
||||
| Strong Trend | 80% | 2% | Adding allowed |
|
||||
| Weak Trend | 50% | 1.5% | No adding |
|
||||
| Range | 60% | 1% | Diversified holding |
|
||||
| Transition | 30% | 1% | Reduce activity |
|
||||
| High Volatility | 40% | 0.5% | Wide stops |
|
||||
|
||||
---
|
||||
|
||||
## 9. Appendix
|
||||
|
||||
### 9.1 Abbreviation Reference
|
||||
|
||||
| Abbrev | Full Form | Description |
|
||||
|--------|-----------|-------------|
|
||||
| TU | Trend Up | Upward trend |
|
||||
| TD | Trend Down | Downward trend |
|
||||
| RG | Range | Range-bound market |
|
||||
| TR | Transition | Trend transition |
|
||||
| BK | Breakout | Breakout pattern |
|
||||
| LV | Low Volatility | Low volatility regime |
|
||||
| HV | High Volatility | High volatility regime |
|
||||
| NV | Normal Volatility | Normal volatility regime |
|
||||
| XLV | Extreme Low Vol | Extremely low volatility |
|
||||
| XHV | Extreme High Vol | Extremely high volatility |
|
||||
|
||||
### 9.2 Document Information
|
||||
|
||||
- Version: v1.0
|
||||
- Created: January 2026
|
||||
- Applicable: Cryptocurrency, Forex, Stocks, and other financial markets
|
||||
|
||||
---
|
||||
|
||||
*This document is designed for market state identification and strategy matching in quantitative trading systems*
|
||||
281
docs/market-regime-classification-zh.md
Normal file
281
docs/market-regime-classification-zh.md
Normal file
@@ -0,0 +1,281 @@
|
||||
# 市场行情精细分类体系
|
||||
|
||||
> 用于量化交易策略匹配的市场状态识别框架
|
||||
|
||||
---
|
||||
|
||||
## 一、分类维度概览
|
||||
|
||||
市场状态识别需要从多个维度进行分析:
|
||||
|
||||
| 维度 | 子维度 | 说明 |
|
||||
|------|--------|------|
|
||||
| **趋势维度** | 方向、强度 | 判断市场运动方向和力度 |
|
||||
| **波动维度** | 幅度、频率 | 衡量价格波动特征 |
|
||||
| **结构维度** | 形态、阶段 | 识别市场结构和所处周期 |
|
||||
|
||||
---
|
||||
|
||||
## 二、一级分类(5大类)
|
||||
|
||||
### 2.1 分类总览
|
||||
|
||||
| 代码 | 名称 | 核心特征 | 适合策略 |
|
||||
|------|------|----------|----------|
|
||||
| `TREND_UP` | 上涨趋势 | 高点/低点持续抬升 | 趋势跟踪、突破追涨 |
|
||||
| `TREND_DOWN` | 下跌趋势 | 高点/低点持续降低 | 趋势跟踪、做空策略 |
|
||||
| `RANGE` | 震荡区间 | 价格在区间内波动 | 网格交易、均值回归 |
|
||||
| `TRANSITION` | 趋势转换 | 方向不明确的过渡期 | 观望、小仓位试探 |
|
||||
| `BREAKOUT` | 突破行情 | 价格突破关键位置 | 突破追踪策略 |
|
||||
|
||||
### 2.2 识别指标
|
||||
|
||||
- **ADX(平均方向指数)**:衡量趋势强度
|
||||
- ADX > 25:存在明确趋势
|
||||
- ADX < 20:震荡市场
|
||||
- **EMA排列**:判断趋势方向
|
||||
- EMA20 > EMA50 > EMA200:多头排列
|
||||
- EMA20 < EMA50 < EMA200:空头排列
|
||||
|
||||
---
|
||||
|
||||
## 三、二级分类(18细分类)
|
||||
|
||||
### 3.1 上涨趋势细分(5种)
|
||||
|
||||
| 代码 | 名称 | 技术特征 | 量化指标 |
|
||||
|------|------|----------|----------|
|
||||
| `TU_STRONG_LOW_VOL` | 强势上涨·低波动 | 稳步上涨,回调幅度小 | ADX>40, ATR%<2%, 回调<38.2% |
|
||||
| `TU_STRONG_HIGH_VOL` | 强势上涨·高波动 | 快速拉升,波动剧烈 | ADX>40, ATR%>4%, MACD柱放大 |
|
||||
| `TU_WEAK_CHOPPY` | 弱势上涨·震荡 | 涨三退二,反复磨蹭 | ADX 20-30, RSI在50-70震荡 |
|
||||
| `TU_PARABOLIC` | 抛物线加速 | 指数级加速上涨 | 价格远离均线, RSI>80, 成交量放大 |
|
||||
| `TU_EXHAUSTION` | 上涨衰竭 | 创新高但动能减弱 | 价格新高 + MACD/RSI顶背离 |
|
||||
|
||||
**策略匹配:**
|
||||
- 强势低波动:重仓趋势跟踪,金字塔加仓
|
||||
- 强势高波动:中等仓位,设置移动止盈
|
||||
- 弱势震荡:轻仓波段,高抛低吸
|
||||
- 抛物线加速:谨慎追涨,准备离场
|
||||
- 上涨衰竭:减仓观望,准备反转做空
|
||||
|
||||
### 3.2 下跌趋势细分(5种)
|
||||
|
||||
| 代码 | 名称 | 技术特征 | 量化指标 |
|
||||
|------|------|----------|----------|
|
||||
| `TD_STRONG_LOW_VOL` | 强势下跌·低波动 | 稳步下跌,反弹无力 | ADX>40, ATR%<2%, 反弹<38.2% |
|
||||
| `TD_STRONG_HIGH_VOL` | 强势下跌·高波动 | 恐慌抛售,波动剧烈 | ADX>40, ATR%>5%, 恐慌指数飙升 |
|
||||
| `TD_WEAK_CHOPPY` | 弱势下跌·震荡 | 跌跌涨涨,磨底过程 | ADX 20-30, RSI在30-50震荡 |
|
||||
| `TD_CAPITULATION` | 恐慌投降 | 放量暴跌,情绪极端 | RSI<20, 成交量>3倍均量 |
|
||||
| `TD_EXHAUSTION` | 下跌衰竭 | 创新低但卖压减弱 | 价格新低 + MACD/RSI底背离 |
|
||||
|
||||
**策略匹配:**
|
||||
- 强势低波动:空头趋势跟踪
|
||||
- 强势高波动:观望或轻仓对冲
|
||||
- 弱势震荡:等待企稳信号
|
||||
- 恐慌投降:极端情况可轻仓抄底
|
||||
- 下跌衰竭:逐步建立多头仓位
|
||||
|
||||
### 3.3 震荡区间细分(4种)
|
||||
|
||||
| 代码 | 名称 | 技术特征 | 量化指标 |
|
||||
|------|------|----------|----------|
|
||||
| `RG_TIGHT_LOW_VOL` | 窄幅震荡·低波动 | 极度收敛,蓄势待发 | 布林带宽度<2%, ATR创新低 |
|
||||
| `RG_TIGHT_HIGH_VOL` | 窄幅震荡·高波动 | 区间内剧烈波动 | 布林带宽度<3%, ATR%>3% |
|
||||
| `RG_WIDE_LOW_VOL` | 宽幅震荡·低波动 | 大区间慢速波动 | 布林带宽度>5%, ATR%<2% |
|
||||
| `RG_WIDE_HIGH_VOL` | 宽幅震荡·高波动 | 大区间快速波动 | 布林带宽度>5%, ATR%>3% |
|
||||
|
||||
**策略匹配:**
|
||||
- 窄幅低波动:密集网格,等待突破
|
||||
- 窄幅高波动:快速网格,小利润多次
|
||||
- 宽幅低波动:稀疏网格,耐心持有
|
||||
- 宽幅高波动:波段交易,高利润目标
|
||||
|
||||
### 3.4 转换过渡(2种)
|
||||
|
||||
| 代码 | 名称 | 技术特征 | 量化指标 |
|
||||
|------|------|----------|----------|
|
||||
| `TR_BOTTOM_FORMING` | 底部形成中 | 下跌放缓,试探支撑 | 价格止跌 + 成交量萎缩 + RSI底背离 |
|
||||
| `TR_TOP_FORMING` | 顶部形成中 | 上涨放缓,试探压力 | 价格滞涨 + 成交量萎缩 + RSI顶背离 |
|
||||
|
||||
### 3.5 突破行情(2种)
|
||||
|
||||
| 代码 | 名称 | 技术特征 | 量化指标 |
|
||||
|------|------|----------|----------|
|
||||
| `BK_UPWARD` | 向上突破 | 突破阻力位并放量 | 价格>前高, 成交量>2倍, 布林带突破 |
|
||||
| `BK_DOWNWARD` | 向下突破 | 跌破支撑位并放量 | 价格<前低, 成交量>2倍, 布林带跌破 |
|
||||
|
||||
---
|
||||
|
||||
## 四、三级分类(36超细分类)
|
||||
|
||||
### 4.1 趋势阶段细分
|
||||
|
||||
上涨趋势生命周期分为5个阶段:
|
||||
|
||||
| 阶段代码 | 名称 | 特征描述 | 量化判断标准 |
|
||||
|----------|------|----------|--------------|
|
||||
| `TU_S1_INITIATION` | 上涨启动期 | 首次突破均线或前高 | MACD金叉, 价格突破EMA20 |
|
||||
| `TU_S2_ACCELERATION` | 上涨加速期 | 动能增强,斜率加大 | MACD柱持续增大, ADX上升 |
|
||||
| `TU_S3_MAIN_WAVE` | 主升浪阶段 | 持续上涨,回调幅度浅 | RSI维持60-80, 回调不破EMA20 |
|
||||
| `TU_S4_EXHAUSTION` | 上涨衰竭期 | 涨速放缓,出现背离 | RSI顶背离, MACD顶背离 |
|
||||
| `TU_S5_REVERSAL` | 趋势反转期 | 破位下跌,趋势结束 | 跌破EMA50, MACD死叉 |
|
||||
|
||||
下跌趋势同理,代码为 `TD_S1` 至 `TD_S5`
|
||||
|
||||
### 4.2 震荡位置细分
|
||||
|
||||
| 位置代码 | 名称 | 特征描述 | 策略建议 |
|
||||
|----------|------|----------|----------|
|
||||
| `RG_UPPER` | 区间上沿震荡 | 价格接近阻力位 | 偏空操作为主 |
|
||||
| `RG_MIDDLE` | 区间中部震荡 | 价格在中轨附近 | 双向网格交易 |
|
||||
| `RG_LOWER` | 区间下沿震荡 | 价格接近支撑位 | 偏多操作为主 |
|
||||
| `RG_SQUEEZE` | 收敛三角震荡 | 高低点逐渐收窄 | 等待方向选择 |
|
||||
| `RG_EXPAND` | 扩散三角震荡 | 高低点逐渐扩张 | 边界反转操作 |
|
||||
|
||||
### 4.3 波动率等级
|
||||
|
||||
| 代码 | 名称 | ATR百分比 | 布林带宽度 | 策略建议 |
|
||||
|------|------|-----------|------------|----------|
|
||||
| `VOL_EXTREME_LOW` | 极低波动 | <1% | <1.5% | 期权卖方策略 |
|
||||
| `VOL_LOW` | 低波动 | 1-2% | 1.5-2.5% | 网格/均值回归 |
|
||||
| `VOL_NORMAL` | 正常波动 | 2-3% | 2.5-4% | 趋势跟踪 |
|
||||
| `VOL_HIGH` | 高波动 | 3-5% | 4-6% | 动量/突破 |
|
||||
| `VOL_EXTREME_HIGH` | 极高波动 | >5% | >6% | 减仓/对冲 |
|
||||
|
||||
---
|
||||
|
||||
## 五、完整状态编码规则
|
||||
|
||||
### 5.1 编码格式
|
||||
|
||||
```
|
||||
{一级分类}_{波动等级}_{阶段}_{位置}
|
||||
```
|
||||
|
||||
### 5.2 编码示例
|
||||
|
||||
| 完整代码 | 含义解释 |
|
||||
|----------|----------|
|
||||
| `TU_LV_S3_M` | 上涨趋势_低波动_主升浪_中部位置 |
|
||||
| `TD_HV_S2_L` | 下跌趋势_高波动_加速期_下部位置 |
|
||||
| `RG_NV_SQ_U` | 震荡区间_正常波动_收敛形态_上沿位置 |
|
||||
| `BK_HV_UP_M` | 突破行情_高波动_向上突破_中部位置 |
|
||||
|
||||
---
|
||||
|
||||
## 六、核心识别指标
|
||||
|
||||
### 6.1 趋势指标
|
||||
|
||||
| 指标 | 计算方法 | 判断标准 |
|
||||
|------|----------|----------|
|
||||
| ADX | 14周期平均方向指数 | >40强趋势, 25-40中等, <25弱/震荡 |
|
||||
| 趋势评分 | 综合EMA/MACD/价格结构 | -100到+100, 正数多头,负数空头 |
|
||||
| EMA排列 | EMA20/50/200相对位置 | 多头排列/空头排列/混乱 |
|
||||
|
||||
### 6.2 波动指标
|
||||
|
||||
| 指标 | 计算方法 | 用途 |
|
||||
|------|----------|------|
|
||||
| ATR百分比 | ATR(14) / 当前价格 × 100% | 衡量相对波动幅度 |
|
||||
| 布林带宽度 | (上轨-下轨) / 中轨 × 100% | 衡量价格波动区间 |
|
||||
| 波动率排名 | 当前波动在历史中的分位 | 判断波动率高低 |
|
||||
|
||||
### 6.3 动量指标
|
||||
|
||||
| 指标 | 计算方法 | 判断标准 |
|
||||
|------|----------|----------|
|
||||
| RSI | 14周期相对强弱指数 | >70超买, <30超卖, 50中性 |
|
||||
| MACD柱 | MACD - Signal | 正数多头动能,负数空头动能 |
|
||||
| 动量评分 | 综合RSI/MACD/成交量 | 衡量当前动能强弱 |
|
||||
|
||||
### 6.4 结构指标
|
||||
|
||||
| 指标 | 说明 | 用途 |
|
||||
|------|------|------|
|
||||
| 高低点结构 | HH/HL/LH/LL序列 | 判断趋势结构 |
|
||||
| 支撑阻力位 | 关键价格水平 | 确定交易区间 |
|
||||
| 成交量形态 | 量价配合关系 | 验证价格走势 |
|
||||
|
||||
---
|
||||
|
||||
## 七、策略匹配矩阵
|
||||
|
||||
### 7.1 行情类型与策略对应
|
||||
|
||||
| 行情类型 | 推荐策略 | 建议仓位 | 止损设置 |
|
||||
|----------|----------|----------|----------|
|
||||
| 强势上涨·低波动 | 趋势跟踪+金字塔加仓 | 60-80% | ATR×2 |
|
||||
| 强势上涨·高波动 | 动量突破+快速止盈 | 40-60% | ATR×1.5 |
|
||||
| 上涨衰竭期 | 减仓+反转信号做空 | 20-30% | 前高 |
|
||||
| 恐慌下跌 | 观望或轻仓抄底 | 10-20% | 宽止损 |
|
||||
| 低波动震荡 | 网格交易 | 50-70% | 区间边界 |
|
||||
| 高波动震荡 | 波段高抛低吸 | 30-50% | ATR×2 |
|
||||
| 收敛等待 | 蓄势等突破 | 10-20% | - |
|
||||
| 向上突破 | 追涨+回踩加仓 | 50-70% | 突破位 |
|
||||
| 底部形成 | 分批建仓 | 20-40% | 新低 |
|
||||
|
||||
### 7.2 网格策略参数匹配
|
||||
|
||||
| 震荡类型 | 网格层数 | 网格间距 | 其他参数 |
|
||||
|----------|----------|----------|----------|
|
||||
| 窄幅低波动 | 30-50层 | 小间距 | 启用Maker Only |
|
||||
| 窄幅高波动 | 15-25层 | 小间距 | 快速成交模式 |
|
||||
| 宽幅低波动 | 10-20层 | 大间距 | 耐心等待成交 |
|
||||
| 宽幅高波动 | 15-25层 | 大间距 | 高利润目标 |
|
||||
| 收敛形态 | 暂停网格 | - | 等待突破信号 |
|
||||
| 区间上沿 | 偏空配置 | 中等 | 卖单权重增加 |
|
||||
| 区间下沿 | 偏多配置 | 中等 | 买单权重增加 |
|
||||
|
||||
---
|
||||
|
||||
## 八、实时监控建议
|
||||
|
||||
### 8.1 状态转换触发条件
|
||||
|
||||
| 当前状态 | 触发条件 | 转换到 |
|
||||
|----------|----------|--------|
|
||||
| 震荡区间 | 价格突破+放量+ADX上升 | 突破行情 |
|
||||
| 上涨趋势 | RSI顶背离+成交量萎缩 | 上涨衰竭 |
|
||||
| 下跌趋势 | RSI底背离+成交量萎缩 | 下跌衰竭 |
|
||||
| 突破行情 | 突破失败回落 | 震荡区间 |
|
||||
| 趋势衰竭 | 反向突破确认 | 反向趋势 |
|
||||
|
||||
### 8.2 风险控制规则
|
||||
|
||||
| 行情状态 | 最大仓位 | 单笔风险 | 特殊规则 |
|
||||
|----------|----------|----------|----------|
|
||||
| 强趋势 | 80% | 2% | 可加仓 |
|
||||
| 弱趋势 | 50% | 1.5% | 不加仓 |
|
||||
| 震荡 | 60% | 1% | 分散持仓 |
|
||||
| 转换期 | 30% | 1% | 减少操作 |
|
||||
| 高波动 | 40% | 0.5% | 宽止损 |
|
||||
|
||||
---
|
||||
|
||||
## 九、附录
|
||||
|
||||
### 9.1 缩写对照表
|
||||
|
||||
| 缩写 | 英文全称 | 中文含义 |
|
||||
|------|----------|----------|
|
||||
| TU | Trend Up | 上涨趋势 |
|
||||
| TD | Trend Down | 下跌趋势 |
|
||||
| RG | Range | 震荡区间 |
|
||||
| TR | Transition | 趋势转换 |
|
||||
| BK | Breakout | 突破行情 |
|
||||
| LV | Low Volatility | 低波动 |
|
||||
| HV | High Volatility | 高波动 |
|
||||
| NV | Normal Volatility | 正常波动 |
|
||||
| XLV | Extreme Low Vol | 极低波动 |
|
||||
| XHV | Extreme High Vol | 极高波动 |
|
||||
|
||||
### 9.2 版本信息
|
||||
|
||||
- 文档版本:v1.0
|
||||
- 创建日期:2026年1月
|
||||
- 适用范围:加密货币、外汇、股票等金融市场
|
||||
|
||||
---
|
||||
|
||||
*本文档用于量化交易系统的市场状态识别和策略匹配*
|
||||
1072
docs/plans/2026-01-14-grid-trading-fixes.md
Normal file
1072
docs/plans/2026-01-14-grid-trading-fixes.md
Normal file
File diff suppressed because it is too large
Load Diff
151
docs/plans/2026-01-17-grid-market-regime-design.md
Normal file
151
docs/plans/2026-01-17-grid-market-regime-design.md
Normal file
@@ -0,0 +1,151 @@
|
||||
# 网格策略市场状态识别与风控设计
|
||||
|
||||
## 概述
|
||||
|
||||
增强网格策略的市场状态识别能力,实现震荡/趋势的精准判断,并根据不同震荡级别自动调整网格参数和风控策略。
|
||||
|
||||
---
|
||||
|
||||
## 一、市场状态识别
|
||||
|
||||
### 1.1 识别维度(3个)
|
||||
|
||||
| 维度 | 指标 | 作用 |
|
||||
|------|------|------|
|
||||
| 价格波动 | ATR14 + Bollinger带宽 | 判断震荡幅度 |
|
||||
| 趋势强度 | EMA20/50距离 + MACD | 判断是否有趋势 |
|
||||
| 动量 | RSI14 + 1h/4h涨跌幅 | 判断超买超卖 |
|
||||
|
||||
### 1.2 箱体指标(新增)
|
||||
|
||||
基于1小时K线的多周期Donchian通道:
|
||||
|
||||
| 箱体级别 | 周期 | 覆盖时间 | 用途 |
|
||||
|----------|------|----------|------|
|
||||
| 短期箱体 | 72根1小时 | 3天 | 日内波动边界 |
|
||||
| 中期箱体 | 240根1小时 | 10天 | 周级别震荡区间 |
|
||||
| 长期箱体 | 500根1小时 | ~21天 | 大级别趋势边界 |
|
||||
|
||||
### 1.3 判断方式
|
||||
|
||||
由AI综合分析以上指标 + 原始K线序列 + 箱体位置,输出市场状态判断。
|
||||
|
||||
---
|
||||
|
||||
## 二、震荡分级与网格策略
|
||||
|
||||
### 2.1 四级震荡分类
|
||||
|
||||
| 级别 | 特征 | 判断依据 |
|
||||
|------|------|----------|
|
||||
| 窄幅震荡 | 价格在短期箱体内小幅波动 | Bollinger带宽 < 2%,ATR低 |
|
||||
| 标准震荡 | 价格在中期箱体内正常波动 | Bollinger带宽 2-3%,ATR正常 |
|
||||
| 宽幅震荡 | 价格接近中期箱体边缘 | Bollinger带宽 3-4%,ATR较高 |
|
||||
| 剧烈震荡 | 价格接近长期箱体边缘 | Bollinger带宽 > 4%,ATR高 |
|
||||
|
||||
### 2.2 各级别对应的网格策略
|
||||
|
||||
| 级别 | 网格密度 | 网格范围 | 单格仓位 | 总仓位上限 | 有效杠杆上限 |
|
||||
|------|----------|----------|----------|------------|--------------|
|
||||
| 窄幅震荡 | 密集 | 窄 | 小 | 30-40% | 2x |
|
||||
| 标准震荡 | 正常 | 中等 | 正常 | 60-70% | 3-4x |
|
||||
| 宽幅震荡 | 稀疏 | 宽 | 正常 | 50-60% | 3x |
|
||||
| 剧烈震荡 | 最稀疏 | 最宽 | 小 | 30-40% | 2x |
|
||||
|
||||
**核心原则:**
|
||||
- 窄幅震荡:单格仓位小 + 总仓位上限低(防击穿风险)
|
||||
- 剧烈震荡:同样保守(随时可能变趋势)
|
||||
- 标准震荡:才是放量的最佳时机
|
||||
|
||||
---
|
||||
|
||||
## 三、突破处理与恢复机制
|
||||
|
||||
### 3.1 突破判断与处理
|
||||
|
||||
**确认方式:** 收盘价突破箱体后,持续3根1小时K线不回箱体
|
||||
|
||||
| 箱体级别 | 突破处理 |
|
||||
|----------|----------|
|
||||
| 短期箱体突破 | 降低仓位到 50% |
|
||||
| 中期箱体突破 | 暂停网格 + 取消挂单 |
|
||||
| 长期箱体突破 | 暂停网格 + 取消挂单 + 平掉所有持仓 |
|
||||
|
||||
### 3.2 假突破恢复
|
||||
|
||||
**价格回到箱体内 → 以50%仓位恢复网格**
|
||||
|
||||
---
|
||||
|
||||
## 四、前端风控面板
|
||||
|
||||
### 4.1 需要展示的信息
|
||||
|
||||
| 类别 | 显示内容 |
|
||||
|------|----------|
|
||||
| 杠杆信息 | 当前杠杆、有效杠杆、系统推荐杠杆 |
|
||||
| 仓位信息 | 当前仓位、最大仓位、仓位占比 |
|
||||
| 爆仓信息 | 爆仓价格、爆仓距离(%) |
|
||||
| 市场状态 | 当前震荡级别(窄幅/标准/宽幅/剧烈) |
|
||||
| 箱体状态 | 短期/中期/长期箱体上下沿、当前价格位置 |
|
||||
|
||||
---
|
||||
|
||||
## 五、实现要点
|
||||
|
||||
### 5.1 后端新增
|
||||
|
||||
1. **箱体指标计算** (`market/data.go`)
|
||||
- 新增 `calculateDonchian(klines, period)` 函数
|
||||
- 返回 upper(最高价), lower(最低价)
|
||||
- 支持72/240/500三个周期
|
||||
|
||||
2. **市场状态评估** (`kernel/grid_engine.go`)
|
||||
- 更新AI prompt,加入箱体指标和K线序列
|
||||
- AI输出震荡级别判断
|
||||
|
||||
3. **网格参数动态调整** (`trader/auto_trader_grid.go`)
|
||||
- 根据震荡级别自动调整:网格密度、范围、仓位、杠杆
|
||||
- 实现有效杠杆上限控制
|
||||
|
||||
4. **突破处理逻辑** (`trader/auto_trader_grid.go`)
|
||||
- 实现三级箱体突破检测
|
||||
- 实现3根K线确认逻辑
|
||||
- 实现降级恢复机制
|
||||
|
||||
### 5.2 前端新增
|
||||
|
||||
1. **风控面板组件**
|
||||
- 杠杆信息展示
|
||||
- 仓位信息展示
|
||||
- 爆仓信息展示
|
||||
- 市场状态展示
|
||||
- 箱体状态可视化
|
||||
|
||||
### 5.3 数据模型更新
|
||||
|
||||
1. **GridConfigModel** 新增字段:
|
||||
- `EffectiveLeverageLimit` - 有效杠杆上限
|
||||
- `ShortBoxPeriod` - 短期箱体周期 (默认72)
|
||||
- `MidBoxPeriod` - 中期箱体周期 (默认240)
|
||||
- `LongBoxPeriod` - 长期箱体周期 (默认500)
|
||||
|
||||
2. **GridInstanceModel** 新增字段:
|
||||
- `CurrentRegimeLevel` - 当前震荡级别 (narrow/standard/wide/volatile)
|
||||
- `ShortBoxUpper/Lower` - 短期箱体上下沿
|
||||
- `MidBoxUpper/Lower` - 中期箱体上下沿
|
||||
- `LongBoxUpper/Lower` - 长期箱体上下沿
|
||||
- `BreakoutStatus` - 突破状态 (none/short/mid/long)
|
||||
- `BreakoutConfirmCount` - 突破确认K线计数
|
||||
|
||||
---
|
||||
|
||||
## 六、风险控制总结
|
||||
|
||||
| 控制点 | 机制 |
|
||||
|--------|------|
|
||||
| 仓位控制 | 根据震荡级别限制总仓位上限 (30-70%) |
|
||||
| 杠杆控制 | 根据震荡级别限制有效杠杆 (2-4x) |
|
||||
| 突破保护 | 三级箱体突破分级处理 |
|
||||
| 假突破恢复 | 50%仓位降级恢复 |
|
||||
| 爆仓预防 | 前端展示爆仓距离,系统自动限制杠杆 |
|
||||
1655
docs/plans/2026-01-17-grid-market-regime-impl.md
Normal file
1655
docs/plans/2026-01-17-grid-market-regime-impl.md
Normal file
File diff suppressed because it is too large
Load Diff
1038
docs/plans/2026-03-06-telegram-agent-redesign.md
Normal file
1038
docs/plans/2026-03-06-telegram-agent-redesign.md
Normal file
File diff suppressed because it is too large
Load Diff
1218
docs/plans/2026-03-06-telegram-bot.md
Normal file
1218
docs/plans/2026-03-06-telegram-bot.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -2488,7 +2488,6 @@ KNOWN_ISSUES = [
|
||||
|
||||
3. **金融量化**
|
||||
- Lo, Andrew W. "The Adaptive Markets Hypothesis." Journal of Portfolio Management, 2004.
|
||||
- Bailey et al. "The Probability of Backtest Overfitting." Journal of Computational Finance, 2014.
|
||||
|
||||
### 13.3 代码示例索引
|
||||
|
||||
|
||||
137
docs/token-estimation.zh-CN.md
Normal file
137
docs/token-estimation.zh-CN.md
Normal file
@@ -0,0 +1,137 @@
|
||||
# 📊 Token 估算分析与候选币种上限指南
|
||||
|
||||
> 版本:v1.0 | 更新:2026-03-27
|
||||
> 适用:策略配置 · 模型选择 · 候选币种数量决策
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
- [Token 估算公式](#-token-估算公式)
|
||||
- [系统提示词的准确性分析](#-系统提示词的准确性分析)
|
||||
- [典型配置下的安全币种数量](#-典型配置下的安全币种数量)
|
||||
- [模型上限参考](#-模型上限参考)
|
||||
- [MaxCandidateCoins 常量说明](#-maxcandidatecoins-常量说明)
|
||||
|
||||
---
|
||||
|
||||
## 📐 Token 估算公式
|
||||
|
||||
代码入口:`store/strategy.go` → `EstimateTokens()`
|
||||
|
||||
整体结构:
|
||||
|
||||
```
|
||||
total = (staticTokens + N × perCoinTokens) × 1.15
|
||||
```
|
||||
|
||||
其中 `1.15` 为 15% 安全边际。
|
||||
|
||||
### 静态部分(与候选币数量无关)
|
||||
|
||||
```
|
||||
SystemPrompt = baseChars / 2(zh)或 / 4(en)
|
||||
baseChars ≈ 3000(zh)/ 4000(en)+ 自定义提示段落长度
|
||||
|
||||
FixedOverhead = 200 tokens(时间戳、账户信息、章节标题)
|
||||
|
||||
RankingData = (OILimit × 60 + NetFlowLimit × 80 + PriceLimit × durations × 40) / 4
|
||||
|
||||
staticTokens = SystemPrompt + FixedOverhead + RankingData
|
||||
≈ 1500 + 200 + 650 = 2350 tokens(默认中文配置)
|
||||
```
|
||||
|
||||
### 每枚币的 Token 开销
|
||||
|
||||
```
|
||||
# 每行指标额外字符数(I)
|
||||
I = EnableEMA×20 + EnableMACD×30 + EnableRSI×15
|
||||
+ EnableATR×15 + EnableBOLL×25 + EnableVolume×10
|
||||
|
||||
# 每枚币的市场数据 token
|
||||
marketPerCoin = (T × K × (80 + I) + 100) / 4
|
||||
↑ T=时间框架数 K=每TF K线数
|
||||
↑ 100 = OI + 资金费率固定开销
|
||||
|
||||
# 每枚币的量化数据 token
|
||||
quantPerCoin = (EnableQuantOI×300 + EnableQuantNetflow×300) / 4
|
||||
|
||||
perCoinTokens = marketPerCoin + quantPerCoin
|
||||
```
|
||||
|
||||
### 反向公式:最大安全币数
|
||||
|
||||
```
|
||||
budget = modelContextLimit × 0.80 / 1.15
|
||||
maxSafeCoins = floor((budget - staticTokens) / perCoinTokens)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 典型配置下的安全币种数量
|
||||
|
||||
**基准:131K 模型(DeepSeek / Grok / Qwen)**,80% 警戒线
|
||||
|
||||
### 三种配置的 perCoinTokens
|
||||
|
||||
| 配置 | T | K | I | quantPerCoin | perCoinTokens |
|
||||
| ------------------------------------------ | --- | --- | --- | ------------ | ------------- |
|
||||
| **最小**(单TF,无指标,无量化) | 1 | 10 | 0 | 0 | **225** |
|
||||
| **默认**(3TF,仅Volume,QuantOI+Netflow) | 3 | 20 | 10 | 600 | **1525** |
|
||||
| **最大**(4TF,全部指标,全量化) | 4 | 30 | 115 | 600 | **6025** |
|
||||
|
||||
### 各模型下的最大安全币数
|
||||
|
||||
| 模型上限 | 最小配置 | 默认配置 | 最大配置 |
|
||||
| ------------------------------ | ------------ | ------------ | ----------- |
|
||||
| 131K(DeepSeek / Grok / Qwen) | ≥10(封顶) | ≥10(封顶) | **14** |
|
||||
| 128K(OpenAI GPT-4) | ≥10(封顶) | ≥10(封顶) | **14** |
|
||||
| 200K(Claude) | ≥10(封顶) | ≥10(封顶) | ≥10(封顶) |
|
||||
| 1M(Gemini / Minimax) | ≥10(封顶) | ≥10(封顶) | ≥10(封顶) |
|
||||
|
||||
---
|
||||
|
||||
## 🤖 模型上限参考
|
||||
|
||||
来源:`store/strategy.go` → `ModelContextLimits`
|
||||
|
||||
| 模型 | Context 上限 | 80% 警戒线 |
|
||||
| -------- | ------------ | ---------- |
|
||||
| deepseek | 131,072 | 104,858 |
|
||||
| openai | 128,000 | 102,400 |
|
||||
| claude | 200,000 | 160,000 |
|
||||
| qwen | 131,072 | 104,858 |
|
||||
| gemini | 1,000,000 | 800,000 |
|
||||
| grok | 131,072 | 104,858 |
|
||||
| kimi | 131,072 | 104,858 |
|
||||
| minimax | 1,000,000 | 800,000 |
|
||||
|
||||
---
|
||||
|
||||
## 🔒 MaxCandidateCoins 常量说明
|
||||
|
||||
来源:`store/strategy.go` 第 14-20 行
|
||||
|
||||
```go
|
||||
const (
|
||||
MaxCandidateCoins = 10 // UI 硬限制:用户最多设定的候选币数量
|
||||
MaxPositions = 3 // 最大同时持仓数
|
||||
MaxTimeframes = 4 // 最大时间框架数
|
||||
MinKlineCount = 10 // 最少 K 线数
|
||||
MaxKlineCount = 30 // 最多 K 线数
|
||||
)
|
||||
```
|
||||
|
||||
### 为什么 MaxCandidateCoins = 10?
|
||||
|
||||
- **默认配置**下 10 枚币约用 **~20,000 tokens**(~15% of 131K),完全安全
|
||||
- **极端配置**(4TF + 全指标)10 枚币约用 **~72,000 tokens**(~55% of 131K),仍有充足余量
|
||||
- 因此 10 是保守且安全的 UI 上限:在所有模型和配置组合下均不会触发 token 限制
|
||||
|
||||
### 建议使用范围
|
||||
|
||||
| 用户类型 | 建议配置 | 最大建议币数 |
|
||||
| ------------------- | ----------------------- | ------------ |
|
||||
| 新手 / 使用默认配置 | 3TF, K=20, 仅 Volume | 10-20 枚 |
|
||||
| 进阶 / 启用部分指标 | 3TF, K=20, EMA+MACD+RSI | 10-15 枚 |
|
||||
| 高级 / 全部指标 | 3-4TF, K=20-30, 全指标 | 5-10 枚 |
|
||||
23
go.mod
23
go.mod
@@ -12,7 +12,6 @@ require (
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/pquerna/otp v1.4.0
|
||||
github.com/rs/zerolog v1.34.0
|
||||
github.com/sirupsen/logrus v1.9.3
|
||||
github.com/sonirico/go-hyperliquid v0.26.0
|
||||
@@ -22,10 +21,14 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.0.0-rc.1 // indirect
|
||||
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6 // indirect
|
||||
github.com/andres-erbsen/clock v0.0.0-20160526145045-9e14626cd129 // indirect
|
||||
github.com/antihax/optional v1.0.0 // indirect
|
||||
github.com/armon/go-radix v1.0.0 // indirect
|
||||
github.com/bitly/go-simplejson v0.5.1 // indirect
|
||||
github.com/bits-and-blooms/bitset v1.24.0 // indirect
|
||||
github.com/blendle/zapdriver v1.3.1 // indirect
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
|
||||
github.com/bybit-exchange/bybit.go.api v0.0.0-20250727214011-c9347d6804d6 // indirect
|
||||
github.com/bytedance/sonic v1.14.0 // indirect
|
||||
@@ -43,7 +46,12 @@ require (
|
||||
github.com/elliottech/poseidon_crypto v0.0.11 // indirect
|
||||
github.com/ethereum/c-kzg-4844/v2 v2.1.5 // indirect
|
||||
github.com/ethereum/go-verkle v0.2.2 // indirect
|
||||
github.com/fatih/color v1.16.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
|
||||
github.com/gagliardetto/binary v0.8.0 // indirect
|
||||
github.com/gagliardetto/solana-go v1.14.0 // indirect
|
||||
github.com/gagliardetto/treeout v0.1.4 // indirect
|
||||
github.com/gateio/gateapi-go/v6 v6.104.3 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
@@ -60,15 +68,20 @@ require (
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/jpillora/backoff v1.0.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/compress v1.16.0 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/lib/pq v1.10.9 // indirect
|
||||
github.com/logrusorgru/aurora v2.0.3+incompatible // indirect
|
||||
github.com/mailru/easyjson v0.9.1 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.32 // indirect
|
||||
github.com/mitchellh/go-testing-interface v1.14.1 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/mostynb/zstdpool-freelist v0.0.0-20201229113212-927304c0c3b1 // indirect
|
||||
github.com/mr-tron/base58 v1.2.0 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
@@ -80,6 +93,7 @@ require (
|
||||
github.com/shopspring/decimal v1.4.0 // indirect
|
||||
github.com/sonirico/vago v0.10.0 // indirect
|
||||
github.com/sonirico/vago/lol v0.0.0-20250901170347-2d1d82c510bd // indirect
|
||||
github.com/streamingfast/logging v0.0.0-20230608130331-f22c91403091 // indirect
|
||||
github.com/supranational/blst v0.3.16 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.0 // indirect
|
||||
@@ -89,14 +103,21 @@ require (
|
||||
go.elastic.co/apm/module/apmzerolog/v2 v2.7.1 // indirect
|
||||
go.elastic.co/apm/v2 v2.7.1 // indirect
|
||||
go.elastic.co/fastjson v1.5.1 // indirect
|
||||
go.mongodb.org/mongo-driver v1.12.2 // indirect
|
||||
go.uber.org/atomic v1.7.0 // indirect
|
||||
go.uber.org/mock v0.5.0 // indirect
|
||||
go.uber.org/multierr v1.6.0 // indirect
|
||||
go.uber.org/ratelimit v0.2.0 // indirect
|
||||
go.uber.org/zap v1.21.0 // indirect
|
||||
golang.org/x/arch v0.20.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
|
||||
golang.org/x/mod v0.27.0 // indirect
|
||||
golang.org/x/net v0.43.0 // indirect
|
||||
golang.org/x/sync v0.17.0 // indirect
|
||||
golang.org/x/sys v0.36.0 // indirect
|
||||
golang.org/x/term v0.35.0 // indirect
|
||||
golang.org/x/text v0.29.0 // indirect
|
||||
golang.org/x/time v0.9.0 // indirect
|
||||
golang.org/x/tools v0.36.0 // indirect
|
||||
google.golang.org/protobuf v1.36.9 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
|
||||
113
go.sum
113
go.sum
@@ -1,3 +1,5 @@
|
||||
filippo.io/edwards25519 v1.0.0-rc.1 h1:m0VOOB23frXZvAOK44usCgLWvtsxIoMCTBGJZlpmGfU=
|
||||
filippo.io/edwards25519 v1.0.0-rc.1/go.mod h1:N1IkdkCkiLB6tki+MYJoSx2JTY9NUlxZE7eHn5EwJns=
|
||||
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6 h1:1zYrtlhrZ6/b6SAjLSfKzWtdgqK0U+HtH/VcBWh1BaU=
|
||||
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI=
|
||||
github.com/StackExchange/wmi v1.2.1 h1:VIkavFPXSjcnS+O8yTq7NI32k0R5Aj+v39y29VYDOSA=
|
||||
@@ -8,14 +10,21 @@ github.com/adshao/go-binance/v2 v2.8.9 h1:NX+4u/LgEmrjTS7OMWU+9ZgfHKFM61RPhnr9/S
|
||||
github.com/adshao/go-binance/v2 v2.8.9/go.mod h1:XkkuecSyJKPolaCGf/q4ovJYB3t0P+7RUYTbGr+LMGM=
|
||||
github.com/agiledragon/gomonkey/v2 v2.13.0 h1:B24Jg6wBI1iB8EFR1c+/aoTg7QN/Cum7YffG8KMIyYo=
|
||||
github.com/agiledragon/gomonkey/v2 v2.13.0/go.mod h1:ap1AmDzcVOAz1YpeJ3TCzIgstoaWLA6jbbgxfB4w2iY=
|
||||
github.com/andres-erbsen/clock v0.0.0-20160526145045-9e14626cd129 h1:MzBOUgng9orim59UnfUTLRjMpd09C5uEVQ6RPGeCaVI=
|
||||
github.com/andres-erbsen/clock v0.0.0-20160526145045-9e14626cd129/go.mod h1:rFgpPQZYZ8vdbc+48xibu8ALc3yeyd64IhHS+PU6Yyg=
|
||||
github.com/antihax/optional v1.0.0 h1:xK2lYat7ZLaVVcIuj82J8kIro4V6kDe0AUDFboUCwcg=
|
||||
github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
|
||||
github.com/armon/go-radix v1.0.0 h1:F4z6KzEeeQIMeLFa97iZU6vupzoecKdU5TX24SNppXI=
|
||||
github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
|
||||
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
|
||||
github.com/bitly/go-simplejson v0.5.0 h1:6IH+V8/tVMab511d5bn4M7EwGXZf9Hj6i2xSwkNEM+Y=
|
||||
github.com/bitly/go-simplejson v0.5.0/go.mod h1:cXHtHw4XUPsvGaxgjIAn8PhEWG9NfngEKAMDJEczWVA=
|
||||
github.com/bitly/go-simplejson v0.5.1 h1:xgwPbetQScXt1gh9BmoJ6j9JMr3TElvuIyjR8pgdoow=
|
||||
github.com/bitly/go-simplejson v0.5.1/go.mod h1:YOPVLzCfwK14b4Sff3oP1AmGhI9T9Vsg84etUnlyp+Q=
|
||||
github.com/bits-and-blooms/bitset v1.24.0 h1:H4x4TuulnokZKvHLfzVRTHJfFfnHEeSYJizujEZvmAM=
|
||||
github.com/bits-and-blooms/bitset v1.24.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
|
||||
github.com/blendle/zapdriver v1.3.1 h1:C3dydBOWYRiOk+B8X9IVZ5IOe+7cl+tGOexN4QqHfpE=
|
||||
github.com/blendle/zapdriver v1.3.1/go.mod h1:mdXfREi6u5MArG4j9fewC+FGnXaBR+T4Ox4J2u4eHCc=
|
||||
github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 h1:DDGfHa7BWjL4YnC6+E63dPcxHo2sUxDIu8g3QgEJdRY=
|
||||
github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4=
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
|
||||
@@ -64,10 +73,20 @@ github.com/ethereum/go-ethereum v1.16.7 h1:qeM4TvbrWK0UC0tgkZ7NiRsmBGwsjqc64BHo2
|
||||
github.com/ethereum/go-ethereum v1.16.7/go.mod h1:Fs6QebQbavneQTYcA39PEKv2+zIjX7rPUZ14DER46wk=
|
||||
github.com/ethereum/go-verkle v0.2.2 h1:I2W0WjnrFUIzzVPwm8ykY+7pL2d4VhlsePn4j7cnFk8=
|
||||
github.com/ethereum/go-verkle v0.2.2/go.mod h1:M3b90YRnzqKyyzBEWJGqj8Qff4IDeXnzFw0P9bFw3uk=
|
||||
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
|
||||
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
|
||||
github.com/ferranbt/fastssz v0.1.4 h1:OCDB+dYDEQDvAgtAGnTSidK1Pe2tW3nFV40XyMkTeDY=
|
||||
github.com/ferranbt/fastssz v0.1.4/go.mod h1:Ea3+oeoRGGLGm5shYAeDgu6PGUlcvQhE2fILyD9+tGg=
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
|
||||
github.com/gagliardetto/binary v0.8.0 h1:U9ahc45v9HW0d15LoN++vIXSJyqR/pWw8DDlhd7zvxg=
|
||||
github.com/gagliardetto/binary v0.8.0/go.mod h1:2tfj51g5o9dnvsc+fL3Jxr22MuWzYXwx9wEoN0XQ7/c=
|
||||
github.com/gagliardetto/solana-go v1.14.0 h1:3WfAi70jOOjAJ0deFMjdhFYlLXATF4tOQXsDNWJtOLw=
|
||||
github.com/gagliardetto/solana-go v1.14.0/go.mod h1:l/qqqIN6qJJPtxW/G1PF4JtcE3Zg2vD2EliZrr9Gn5k=
|
||||
github.com/gagliardetto/treeout v0.1.4 h1:ozeYerrLCmCubo1TcIjFiOWTTGteOOHND1twdFpgwaw=
|
||||
github.com/gagliardetto/treeout v0.1.4/go.mod h1:loUefvXTrlRG5rYmJmExNryyBRh8f89VZhmMOyCyqok=
|
||||
github.com/gateio/gateapi-go/v6 v6.104.3 h1:JQ2+s1pG4bL+JeLQyGy9c7YLr7hxRI8g7vkAuQYl75k=
|
||||
github.com/gateio/gateapi-go/v6 v6.104.3/go.mod h1:racCcjrdyOUbRDO5eCUGUiyDPrF/ZmwBj/bupPZTVLY=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
|
||||
@@ -95,8 +114,10 @@ github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1
|
||||
github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc=
|
||||
github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs=
|
||||
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
|
||||
github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
@@ -133,10 +154,18 @@ github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
|
||||
github.com/klauspost/compress v1.11.4/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
|
||||
github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
|
||||
github.com/klauspost/compress v1.16.0 h1:iULayQNOReoYUe+1qtKOqw9CwJv3aNQu8ivo7lw1HU4=
|
||||
github.com/klauspost/compress v1.16.0/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
@@ -147,6 +176,8 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/logrusorgru/aurora v2.0.3+incompatible h1:tOpm7WcpBTn4fjmVfgpQq0EfczGlG91VSDkswnjF5A8=
|
||||
github.com/logrusorgru/aurora v2.0.3+incompatible/go.mod h1:7rIyQOR62GCctdiQpZ/zOJlFyk6y+94wXzv6RNZgaR4=
|
||||
github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8=
|
||||
github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
@@ -162,6 +193,8 @@ github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuE
|
||||
github.com/mattn/go-sqlite3 v1.14.32/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/go-testing-interface v1.14.1 h1:jrgshOhYAUVNMAJiKbEu7EqAwgJJ2JqpQmpLJOu07cU=
|
||||
github.com/mitchellh/go-testing-interface v1.14.1/go.mod h1:gfgS7OtZj6MA4U1UrDRp04twqAjfvlZyCfX3sDjEym8=
|
||||
github.com/mitchellh/mapstructure v1.4.1 h1:CpVNEelQCZBooIPDn+AR3NpivK/TIKU8bDxdASFVQag=
|
||||
github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=
|
||||
@@ -170,20 +203,24 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc=
|
||||
github.com/mostynb/zstdpool-freelist v0.0.0-20201229113212-927304c0c3b1 h1:mPMvm6X6tf4w8y7j9YIt6V9jfWhL6QlbEc7CCmeQlWk=
|
||||
github.com/mostynb/zstdpool-freelist v0.0.0-20201229113212-927304c0c3b1/go.mod h1:ye2e/VUEtE2BHE+G/QcKkcLQVAEJoYRFj5VUOQatCRE=
|
||||
github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o=
|
||||
github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc=
|
||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
|
||||
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pquerna/otp v1.4.0 h1:wZvl1TIVxKRThZIBiwOOHOGP/1+nZyWBil9Y2XNEDzg=
|
||||
github.com/pquerna/otp v1.4.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
|
||||
github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0=
|
||||
github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw=
|
||||
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
|
||||
@@ -201,6 +238,7 @@ github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
|
||||
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
|
||||
github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible h1:Bn1aCHHRnjv4Bl16T8rcaFjYSrGrIZvpiGO6P3Q4GpU=
|
||||
github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA=
|
||||
github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
|
||||
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=
|
||||
@@ -217,12 +255,15 @@ github.com/sonirico/vago v0.10.0 h1:y+4Wo56tK+88a5lUwVrZUO2RRLaPcBgjI5cupKpT1Oc=
|
||||
github.com/sonirico/vago v0.10.0/go.mod h1:HCfnyPHId7V+zBZ5BLfIsdHIO+ewo6+uhF1N0hxlldc=
|
||||
github.com/sonirico/vago/lol v0.0.0-20250901170347-2d1d82c510bd h1:rbvNORW8/0AtH/8W/SUwUykbuh2SeQBrNgFLqYpGTWY=
|
||||
github.com/sonirico/vago/lol v0.0.0-20250901170347-2d1d82c510bd/go.mod h1:pteYccB32seEf19i0TPk7DKdEZdWJ/n9K9DF8AFeXGU=
|
||||
github.com/streamingfast/logging v0.0.0-20230608130331-f22c91403091 h1:RN5mrigyirb8anBEtdjtHFIufXdacyTi6i4KBfeNXeo=
|
||||
github.com/streamingfast/logging v0.0.0-20230608130331-f22c91403091/go.mod h1:VlduQ80JcGJSargkRU4Sg9Xo63wZD/l8A5NC/Uo1/uU=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
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.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
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=
|
||||
@@ -231,6 +272,7 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/supranational/blst v0.3.16 h1:bTDadT+3fK497EvLdWRQEjiGnUtzJ7jjIUMF0jqwYhE=
|
||||
github.com/supranational/blst v0.3.16/go.mod h1:jZJtfjgudtNl4en1tzwPIV3KjUnQUvG3/j+w+fVonLw=
|
||||
github.com/test-go/testify v1.1.4/go.mod h1:rH7cfJo/47vWGdi4GPj16x3/t1xGOj2YxzmNQzk2ghU=
|
||||
github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
|
||||
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
|
||||
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
|
||||
@@ -247,53 +289,120 @@ github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IU
|
||||
github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok=
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
|
||||
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
|
||||
github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4=
|
||||
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
|
||||
github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA=
|
||||
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
go.elastic.co/apm/module/apmzerolog/v2 v2.7.1 h1:C9+KrlqS8F4SZFu+ct0Jmv2YLmzDhWsI8htK6exd3vg=
|
||||
go.elastic.co/apm/module/apmzerolog/v2 v2.7.1/go.mod h1:wXViB7paxMUrERgZrmUb+0FCqgb13Dull1JOOd8Hcj0=
|
||||
go.elastic.co/apm/v2 v2.7.1 h1:OFjARuESjBsxw7wHrEAnfSVNCHGBATXSI/kPvBARY/A=
|
||||
go.elastic.co/apm/v2 v2.7.1/go.mod h1:tQhBAjwh93b2leuAdzGwta/sP7Yc7QoKTSjeIHHDuog=
|
||||
go.elastic.co/fastjson v1.5.1 h1:zeh1xHrFH79aQ6Xsw7YxixvnOdAl3OSv0xch/jRDzko=
|
||||
go.elastic.co/fastjson v1.5.1/go.mod h1:WtvH5wz8z9pDOPqNYSYKoLLv/9zCWZLeejHWuvdL/EM=
|
||||
go.mongodb.org/mongo-driver v1.12.2 h1:gbWY1bJkkmUB9jjZzcdhOL8O85N9H+Vvsf2yFN0RDws=
|
||||
go.mongodb.org/mongo-driver v1.12.2/go.mod h1:/rGBTebI3XYboVmgz+Wv3Bcbl3aD0QF9zl6kDDw18rQ=
|
||||
go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
|
||||
go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw=
|
||||
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
||||
go.uber.org/goleak v1.1.11/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
|
||||
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
|
||||
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
|
||||
go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=
|
||||
go.uber.org/multierr v1.6.0 h1:y6IPFStTAIT5Ytl7/XYmHvzXQ7S3g/IeZW9hyZ5thw4=
|
||||
go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
|
||||
go.uber.org/ratelimit v0.2.0 h1:UQE2Bgi7p2B85uP5dC2bbRtig0C+OeNRnNEafLjsLPA=
|
||||
go.uber.org/ratelimit v0.2.0/go.mod h1:YYBV4e4naJvhpitQrWJu1vCpgB7CboMe0qhltKt6mUg=
|
||||
go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
|
||||
go.uber.org/zap v1.21.0 h1:WefMeulhovoZ2sYXz7st6K0sLj7bBhpiFaud4r4zST8=
|
||||
go.uber.org/zap v1.21.0/go.mod h1:wjWOCqI0f2ZZrJF/UufIOkiC8ii6tm1iqIsLo76RfJw=
|
||||
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
|
||||
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.0.0-20220214200702-86341886e292/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
|
||||
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
|
||||
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
|
||||
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
|
||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
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.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
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-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/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=
|
||||
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
|
||||
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ=
|
||||
golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
||||
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
||||
golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY=
|
||||
golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
|
||||
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
|
||||
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/dnaeon/go-vcr.v4 v4.0.5 h1:I0hpTIvD5rII+8LgYGrHMA2d4SQPoL6u7ZvJakWKsiA=
|
||||
gopkg.in/dnaeon/go-vcr.v4 v4.0.5/go.mod h1:dRos81TkW9C1WJt6tTaE+uV2Lo8qJT3AG2b35+CB/nQ=
|
||||
gopkg.in/dnaeon/go-vcr.v4 v4.0.6 h1:PiJkrakkmzc5s7EfBnZOnyiLwi7o7A9fwPzN0X2uwe0=
|
||||
gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg=
|
||||
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
|
||||
|
||||
1452
kernel/engine.go
1452
kernel/engine.go
File diff suppressed because it is too large
Load Diff
401
kernel/engine_analysis.go
Normal file
401
kernel/engine_analysis.go
Normal file
@@ -0,0 +1,401 @@
|
||||
package kernel
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"nofx/logger"
|
||||
"nofx/market"
|
||||
"nofx/mcp"
|
||||
"nofx/store"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
// Pre-compiled regular expressions (performance optimization)
|
||||
// ============================================================================
|
||||
|
||||
var (
|
||||
// Safe regex: precisely match ```json code blocks
|
||||
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]")
|
||||
|
||||
// XML tag extraction (supports any characters in reasoning chain)
|
||||
reReasoningTag = regexp.MustCompile(`(?s)<reasoning>(.*?)</reasoning>`)
|
||||
reDecisionTag = regexp.MustCompile(`(?s)<decision>(.*?)</decision>`)
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
// Entry Functions - Main API
|
||||
// ============================================================================
|
||||
|
||||
// GetFullDecision gets AI's complete trading decision (batch analysis of all coins and positions)
|
||||
// Uses default strategy configuration - for production use GetFullDecisionWithStrategy with explicit config
|
||||
func GetFullDecision(ctx *Context, mcpClient mcp.AIClient) (*FullDecision, error) {
|
||||
defaultConfig := store.GetDefaultStrategyConfig("en")
|
||||
engine := NewStrategyEngine(&defaultConfig)
|
||||
return GetFullDecisionWithStrategy(ctx, mcpClient, engine, "")
|
||||
}
|
||||
|
||||
// GetFullDecisionWithStrategy uses StrategyEngine to get AI decision (unified prompt generation)
|
||||
func GetFullDecisionWithStrategy(ctx *Context, mcpClient mcp.AIClient, engine *StrategyEngine, variant string) (*FullDecision, error) {
|
||||
if ctx == nil {
|
||||
return nil, fmt.Errorf("context is nil")
|
||||
}
|
||||
if engine == nil {
|
||||
defaultConfig := store.GetDefaultStrategyConfig("en")
|
||||
engine = NewStrategyEngine(&defaultConfig)
|
||||
}
|
||||
|
||||
// Clamp strategy limits to prevent token overflow
|
||||
engineConfig := engine.GetConfig()
|
||||
engineConfig.ClampLimits()
|
||||
|
||||
// Token estimation check — block if exceeding the specific model's context limit
|
||||
estimate := engineConfig.EstimateTokens()
|
||||
|
||||
// Determine context limit for the specific model being used
|
||||
contextLimit := 131072 // safe default (strictest common limit)
|
||||
var providerName string
|
||||
if embedder, ok := mcpClient.(mcp.ClientEmbedder); ok {
|
||||
base := embedder.BaseClient()
|
||||
providerName = base.Provider
|
||||
contextLimit = store.GetContextLimitForClient(base.Provider, base.Model)
|
||||
}
|
||||
|
||||
if estimate.Total > contextLimit {
|
||||
logger.Errorf("🚫 Token estimate %d exceeds %s context limit %d — blocking analysis",
|
||||
estimate.Total, providerName, contextLimit)
|
||||
return nil, fmt.Errorf("estimated %d tokens exceeds model context limit of %d; reduce coins, timeframes, or K-line count",
|
||||
estimate.Total, contextLimit)
|
||||
}
|
||||
if estimate.Total*100/contextLimit >= 80 {
|
||||
logger.Infof("⚠️ Token estimate %d — approaching %s context limit %d",
|
||||
estimate.Total, providerName, contextLimit)
|
||||
}
|
||||
|
||||
// 1. Fetch market data using strategy config
|
||||
if len(ctx.MarketDataMap) == 0 {
|
||||
if err := fetchMarketDataWithStrategy(ctx, engine); err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch market data: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure OITopDataMap is initialized
|
||||
if ctx.OITopDataMap == nil {
|
||||
ctx.OITopDataMap = make(map[string]*OITopData)
|
||||
oiPositions, err := engine.nofxosClient.GetOITopPositions()
|
||||
if err == nil {
|
||||
for _, pos := range oiPositions {
|
||||
ctx.OITopDataMap[pos.Symbol] = &OITopData{
|
||||
Rank: pos.Rank,
|
||||
OIDeltaPercent: pos.OIDeltaPercent,
|
||||
OIDeltaValue: pos.OIDeltaValue,
|
||||
PriceDeltaPercent: pos.PriceDeltaPercent,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Build System Prompt using strategy engine
|
||||
riskConfig := engine.GetRiskControlConfig()
|
||||
systemPrompt := engine.BuildSystemPrompt(ctx.Account.TotalEquity, variant)
|
||||
|
||||
// 3. Build User Prompt using strategy engine
|
||||
userPrompt := engine.BuildUserPrompt(ctx)
|
||||
|
||||
// 4. Call AI API
|
||||
aiCallStart := time.Now()
|
||||
aiResponse, err := mcpClient.CallWithMessages(systemPrompt, userPrompt)
|
||||
aiCallDuration := time.Since(aiCallStart)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("AI API call failed: %w", err)
|
||||
}
|
||||
|
||||
// 5. Parse AI response
|
||||
decision, err := parseFullDecisionResponse(
|
||||
aiResponse,
|
||||
ctx.Account.TotalEquity,
|
||||
riskConfig.BTCETHMaxLeverage,
|
||||
riskConfig.AltcoinMaxLeverage,
|
||||
riskConfig.BTCETHMaxPositionValueRatio,
|
||||
riskConfig.AltcoinMaxPositionValueRatio,
|
||||
)
|
||||
|
||||
if decision != nil {
|
||||
decision.Timestamp = time.Now()
|
||||
decision.SystemPrompt = systemPrompt
|
||||
decision.UserPrompt = userPrompt
|
||||
decision.AIRequestDurationMs = aiCallDuration.Milliseconds()
|
||||
decision.RawResponse = aiResponse
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return decision, fmt.Errorf("failed to parse AI response: %w", err)
|
||||
}
|
||||
|
||||
return decision, nil
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Market Data Fetching
|
||||
// ============================================================================
|
||||
|
||||
// fetchMarketDataWithStrategy fetches market data using strategy config (multiple timeframes)
|
||||
func fetchMarketDataWithStrategy(ctx *Context, engine *StrategyEngine) error {
|
||||
config := engine.GetConfig()
|
||||
ctx.MarketDataMap = make(map[string]*market.Data)
|
||||
|
||||
timeframes := config.Indicators.Klines.SelectedTimeframes
|
||||
primaryTimeframe := config.Indicators.Klines.PrimaryTimeframe
|
||||
klineCount := config.Indicators.Klines.PrimaryCount
|
||||
|
||||
// Compatible with old configuration
|
||||
if len(timeframes) == 0 {
|
||||
if primaryTimeframe != "" {
|
||||
timeframes = append(timeframes, primaryTimeframe)
|
||||
} else {
|
||||
timeframes = append(timeframes, "3m")
|
||||
}
|
||||
if config.Indicators.Klines.LongerTimeframe != "" {
|
||||
timeframes = append(timeframes, config.Indicators.Klines.LongerTimeframe)
|
||||
}
|
||||
}
|
||||
if primaryTimeframe == "" {
|
||||
primaryTimeframe = timeframes[0]
|
||||
}
|
||||
if klineCount <= 0 {
|
||||
klineCount = 30
|
||||
}
|
||||
|
||||
logger.Infof("📊 Strategy timeframes: %v, Primary: %s, Kline count: %d", timeframes, primaryTimeframe, klineCount)
|
||||
|
||||
// 1. First fetch data for position coins (must fetch)
|
||||
for _, pos := range ctx.Positions {
|
||||
data, err := market.GetWithTimeframes(pos.Symbol, timeframes, primaryTimeframe, klineCount)
|
||||
if err != nil {
|
||||
logger.Infof("⚠️ Failed to fetch market data for position %s: %v", pos.Symbol, err)
|
||||
continue
|
||||
}
|
||||
ctx.MarketDataMap[pos.Symbol] = data
|
||||
}
|
||||
|
||||
// 2. Fetch data for all candidate coins
|
||||
positionSymbols := make(map[string]bool)
|
||||
for _, pos := range ctx.Positions {
|
||||
positionSymbols[pos.Symbol] = true
|
||||
}
|
||||
|
||||
const minOIThresholdMillions = 15.0 // 15M USD minimum open interest value
|
||||
|
||||
for _, coin := range ctx.CandidateCoins {
|
||||
if _, exists := ctx.MarketDataMap[coin.Symbol]; exists {
|
||||
continue
|
||||
}
|
||||
|
||||
data, err := market.GetWithTimeframes(coin.Symbol, timeframes, primaryTimeframe, klineCount)
|
||||
if err != nil {
|
||||
logger.Infof("⚠️ Failed to fetch market data for %s: %v", coin.Symbol, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Liquidity filter (skip for xyz dex assets - they don't have OI data from Binance)
|
||||
isExistingPosition := positionSymbols[coin.Symbol]
|
||||
isXyzAsset := market.IsXyzDexAsset(coin.Symbol)
|
||||
if !isExistingPosition && !isXyzAsset && data.OpenInterest != nil && data.CurrentPrice > 0 {
|
||||
oiValue := data.OpenInterest.Latest * data.CurrentPrice
|
||||
oiValueInMillions := oiValue / 1_000_000
|
||||
if oiValueInMillions < minOIThresholdMillions {
|
||||
logger.Infof("⚠️ %s OI value too low (%.2fM USD < %.1fM), skipping coin",
|
||||
coin.Symbol, oiValueInMillions, minOIThresholdMillions)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
ctx.MarketDataMap[coin.Symbol] = data
|
||||
}
|
||||
|
||||
logger.Infof("📊 Successfully fetched multi-timeframe market data for %d coins", len(ctx.MarketDataMap))
|
||||
return nil
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// AI Response Parsing
|
||||
// ============================================================================
|
||||
|
||||
func parseFullDecisionResponse(aiResponse string, accountEquity float64, btcEthLeverage, altcoinLeverage int, btcEthPosRatio, altcoinPosRatio float64) (*FullDecision, error) {
|
||||
cotTrace := extractCoTTrace(aiResponse)
|
||||
|
||||
decisions, err := extractDecisions(aiResponse)
|
||||
if err != nil {
|
||||
return &FullDecision{
|
||||
CoTTrace: cotTrace,
|
||||
Decisions: []Decision{},
|
||||
}, fmt.Errorf("failed to extract decisions: %w", err)
|
||||
}
|
||||
|
||||
if err := validateDecisions(decisions, accountEquity, btcEthLeverage, altcoinLeverage, btcEthPosRatio, altcoinPosRatio); err != nil {
|
||||
return &FullDecision{
|
||||
CoTTrace: cotTrace,
|
||||
Decisions: decisions,
|
||||
}, fmt.Errorf("decision validation failed: %w", err)
|
||||
}
|
||||
|
||||
return &FullDecision{
|
||||
CoTTrace: cotTrace,
|
||||
Decisions: decisions,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func extractCoTTrace(response string) string {
|
||||
if match := reReasoningTag.FindStringSubmatch(response); match != nil && len(match) > 1 {
|
||||
logger.Infof("✓ Extracted reasoning chain using <reasoning> tag")
|
||||
return strings.TrimSpace(match[1])
|
||||
}
|
||||
|
||||
if decisionIdx := strings.Index(response, "<decision>"); decisionIdx > 0 {
|
||||
logger.Infof("✓ Extracted content before <decision> tag as reasoning chain")
|
||||
return strings.TrimSpace(response[:decisionIdx])
|
||||
}
|
||||
|
||||
jsonStart := strings.Index(response, "[")
|
||||
if jsonStart > 0 {
|
||||
logger.Infof("⚠️ Extracted reasoning chain using old format ([ character separator)")
|
||||
return strings.TrimSpace(response[:jsonStart])
|
||||
}
|
||||
|
||||
return strings.TrimSpace(response)
|
||||
}
|
||||
|
||||
func extractDecisions(response string) ([]Decision, error) {
|
||||
s := removeInvisibleRunes(response)
|
||||
s = strings.TrimSpace(s)
|
||||
s = fixMissingQuotes(s)
|
||||
|
||||
var jsonPart string
|
||||
if match := reDecisionTag.FindStringSubmatch(s); match != nil && len(match) > 1 {
|
||||
jsonPart = strings.TrimSpace(match[1])
|
||||
logger.Infof("✓ Extracted JSON using <decision> tag")
|
||||
} else {
|
||||
jsonPart = s
|
||||
logger.Infof("⚠️ <decision> tag not found, searching JSON in full text")
|
||||
}
|
||||
|
||||
jsonPart = fixMissingQuotes(jsonPart)
|
||||
|
||||
if m := reJSONFence.FindStringSubmatch(jsonPart); m != nil && len(m) > 1 {
|
||||
jsonContent := strings.TrimSpace(m[1])
|
||||
jsonContent = compactArrayOpen(jsonContent)
|
||||
jsonContent = fixMissingQuotes(jsonContent)
|
||||
if err := validateJSONFormat(jsonContent); err != nil {
|
||||
return nil, fmt.Errorf("JSON format validation failed: %w\nJSON content: %s\nFull response:\n%s", err, jsonContent, response)
|
||||
}
|
||||
var decisions []Decision
|
||||
if err := json.Unmarshal([]byte(jsonContent), &decisions); err != nil {
|
||||
return nil, fmt.Errorf("JSON parsing failed: %w\nJSON content: %s", err, jsonContent)
|
||||
}
|
||||
return decisions, nil
|
||||
}
|
||||
|
||||
jsonContent := strings.TrimSpace(reJSONArray.FindString(jsonPart))
|
||||
if jsonContent == "" {
|
||||
logger.Infof("⚠️ [SafeFallback] AI didn't output JSON decision, entering safe wait mode")
|
||||
|
||||
cotSummary := jsonPart
|
||||
if len(cotSummary) > 240 {
|
||||
cotSummary = cotSummary[:240] + "..."
|
||||
}
|
||||
|
||||
fallbackDecision := Decision{
|
||||
Symbol: "ALL",
|
||||
Action: "wait",
|
||||
Reasoning: fmt.Sprintf("Model didn't output structured JSON decision, entering safe wait; summary: %s", cotSummary),
|
||||
}
|
||||
|
||||
return []Decision{fallbackDecision}, nil
|
||||
}
|
||||
|
||||
jsonContent = compactArrayOpen(jsonContent)
|
||||
jsonContent = fixMissingQuotes(jsonContent)
|
||||
|
||||
if err := validateJSONFormat(jsonContent); err != nil {
|
||||
return nil, fmt.Errorf("JSON format validation failed: %w\nJSON content: %s\nFull response:\n%s", err, jsonContent, response)
|
||||
}
|
||||
|
||||
var decisions []Decision
|
||||
if err := json.Unmarshal([]byte(jsonContent), &decisions); err != nil {
|
||||
return nil, fmt.Errorf("JSON parsing failed: %w\nJSON content: %s", err, jsonContent)
|
||||
}
|
||||
|
||||
return decisions, nil
|
||||
}
|
||||
|
||||
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", "'")
|
||||
|
||||
jsonStr = strings.ReplaceAll(jsonStr, "[", "[")
|
||||
jsonStr = strings.ReplaceAll(jsonStr, "]", "]")
|
||||
jsonStr = strings.ReplaceAll(jsonStr, "{", "{")
|
||||
jsonStr = strings.ReplaceAll(jsonStr, "}", "}")
|
||||
jsonStr = strings.ReplaceAll(jsonStr, ":", ":")
|
||||
jsonStr = strings.ReplaceAll(jsonStr, ",", ",")
|
||||
|
||||
jsonStr = strings.ReplaceAll(jsonStr, "【", "[")
|
||||
jsonStr = strings.ReplaceAll(jsonStr, "】", "]")
|
||||
jsonStr = strings.ReplaceAll(jsonStr, "〔", "[")
|
||||
jsonStr = strings.ReplaceAll(jsonStr, "〕", "]")
|
||||
jsonStr = strings.ReplaceAll(jsonStr, "、", ",")
|
||||
|
||||
jsonStr = strings.ReplaceAll(jsonStr, " ", " ")
|
||||
|
||||
return jsonStr
|
||||
}
|
||||
|
||||
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("not a valid decision array (must contain objects {}), actual content: %s", trimmed[:min(50, len(trimmed))])
|
||||
}
|
||||
return fmt.Errorf("JSON must start with [{ (whitespace allowed), actual: %s", trimmed[:min(20, len(trimmed))])
|
||||
}
|
||||
|
||||
if strings.Contains(jsonStr, "~") {
|
||||
return fmt.Errorf("JSON cannot contain range symbol ~, all numbers must be precise single values")
|
||||
}
|
||||
|
||||
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 numbers cannot contain thousand separator comma, found: %s", jsonStr[i:min(i+10, len(jsonStr))])
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func removeInvisibleRunes(s string) string {
|
||||
return reInvisibleRunes.ReplaceAllString(s, "")
|
||||
}
|
||||
|
||||
func compactArrayOpen(s string) string {
|
||||
return reArrayOpenSpace.ReplaceAllString(strings.TrimSpace(s), "[{")
|
||||
}
|
||||
121
kernel/engine_position.go
Normal file
121
kernel/engine_position.go
Normal file
@@ -0,0 +1,121 @@
|
||||
package kernel
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"nofx/logger"
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
// Decision Validation
|
||||
// ============================================================================
|
||||
|
||||
func validateDecisions(decisions []Decision, accountEquity float64, btcEthLeverage, altcoinLeverage int, btcEthPosRatio, altcoinPosRatio float64) error {
|
||||
for i := range decisions {
|
||||
if err := validateDecision(&decisions[i], accountEquity, btcEthLeverage, altcoinLeverage, btcEthPosRatio, altcoinPosRatio); err != nil {
|
||||
return fmt.Errorf("decision #%d validation failed: %w", i+1, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateDecision(d *Decision, accountEquity float64, btcEthLeverage, altcoinLeverage int, btcEthPosRatio, altcoinPosRatio float64) error {
|
||||
validActions := map[string]bool{
|
||||
"open_long": true,
|
||||
"open_short": true,
|
||||
"close_long": true,
|
||||
"close_short": true,
|
||||
"hold": true,
|
||||
"wait": true,
|
||||
}
|
||||
|
||||
if !validActions[d.Action] {
|
||||
return fmt.Errorf("invalid action: %s", d.Action)
|
||||
}
|
||||
|
||||
if d.Action == "open_long" || d.Action == "open_short" {
|
||||
maxLeverage := altcoinLeverage
|
||||
posRatio := altcoinPosRatio
|
||||
maxPositionValue := accountEquity * posRatio
|
||||
if d.Symbol == "BTCUSDT" || d.Symbol == "ETHUSDT" {
|
||||
maxLeverage = btcEthLeverage
|
||||
posRatio = btcEthPosRatio
|
||||
maxPositionValue = accountEquity * posRatio
|
||||
}
|
||||
|
||||
if d.Leverage <= 0 {
|
||||
return fmt.Errorf("leverage must be greater than 0: %d", d.Leverage)
|
||||
}
|
||||
if d.Leverage > maxLeverage {
|
||||
logger.Infof("⚠️ [Leverage Fallback] %s leverage exceeded (%dx > %dx), auto-adjusting to limit %dx",
|
||||
d.Symbol, d.Leverage, maxLeverage, maxLeverage)
|
||||
d.Leverage = maxLeverage
|
||||
}
|
||||
if d.PositionSizeUSD <= 0 {
|
||||
return fmt.Errorf("position size must be greater than 0: %.2f", d.PositionSizeUSD)
|
||||
}
|
||||
|
||||
const minPositionSizeGeneral = 12.0
|
||||
const minPositionSizeBTCETH = 60.0
|
||||
|
||||
if d.Symbol == "BTCUSDT" || d.Symbol == "ETHUSDT" {
|
||||
if d.PositionSizeUSD < minPositionSizeBTCETH {
|
||||
return fmt.Errorf("%s opening amount too small (%.2f USDT), must be ≥%.2f USDT", d.Symbol, d.PositionSizeUSD, minPositionSizeBTCETH)
|
||||
}
|
||||
} else {
|
||||
if d.PositionSizeUSD < minPositionSizeGeneral {
|
||||
return fmt.Errorf("opening amount too small (%.2f USDT), must be ≥%.2f USDT", d.PositionSizeUSD, minPositionSizeGeneral)
|
||||
}
|
||||
}
|
||||
|
||||
tolerance := maxPositionValue * 0.01
|
||||
if d.PositionSizeUSD > maxPositionValue+tolerance {
|
||||
if d.Symbol == "BTCUSDT" || d.Symbol == "ETHUSDT" {
|
||||
return fmt.Errorf("BTC/ETH single coin position value cannot exceed %.0f USDT (%.1fx account equity), actual: %.0f", maxPositionValue, posRatio, d.PositionSizeUSD)
|
||||
} else {
|
||||
return fmt.Errorf("altcoin single coin position value cannot exceed %.0f USDT (%.1fx account equity), actual: %.0f", maxPositionValue, posRatio, d.PositionSizeUSD)
|
||||
}
|
||||
}
|
||||
if d.StopLoss <= 0 || d.TakeProfit <= 0 {
|
||||
return fmt.Errorf("stop loss and take profit must be greater than 0")
|
||||
}
|
||||
|
||||
if d.Action == "open_long" {
|
||||
if d.StopLoss >= d.TakeProfit {
|
||||
return fmt.Errorf("for long positions, stop loss price must be less than take profit price")
|
||||
}
|
||||
} else {
|
||||
if d.StopLoss <= d.TakeProfit {
|
||||
return fmt.Errorf("for short positions, stop loss price must be greater than take profit price")
|
||||
}
|
||||
}
|
||||
|
||||
var entryPrice float64
|
||||
if d.Action == "open_long" {
|
||||
entryPrice = d.StopLoss + (d.TakeProfit-d.StopLoss)*0.2
|
||||
} else {
|
||||
entryPrice = d.StopLoss - (d.StopLoss-d.TakeProfit)*0.2
|
||||
}
|
||||
|
||||
var riskPercent, rewardPercent, riskRewardRatio float64
|
||||
if d.Action == "open_long" {
|
||||
riskPercent = (entryPrice - d.StopLoss) / entryPrice * 100
|
||||
rewardPercent = (d.TakeProfit - entryPrice) / entryPrice * 100
|
||||
if riskPercent > 0 {
|
||||
riskRewardRatio = rewardPercent / riskPercent
|
||||
}
|
||||
} else {
|
||||
riskPercent = (d.StopLoss - entryPrice) / entryPrice * 100
|
||||
rewardPercent = (entryPrice - d.TakeProfit) / entryPrice * 100
|
||||
if riskPercent > 0 {
|
||||
riskRewardRatio = rewardPercent / riskPercent
|
||||
}
|
||||
}
|
||||
|
||||
if riskRewardRatio < 3.0 {
|
||||
return fmt.Errorf("risk/reward ratio too low (%.2f:1), must be ≥3.0:1 [risk: %.2f%% reward: %.2f%%] [stop loss: %.2f take profit: %.2f]",
|
||||
riskRewardRatio, riskPercent, rewardPercent, d.StopLoss, d.TakeProfit)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
779
kernel/engine_prompt.go
Normal file
779
kernel/engine_prompt.go
Normal file
@@ -0,0 +1,779 @@
|
||||
package kernel
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"nofx/market"
|
||||
"nofx/provider/nofxos"
|
||||
"nofx/store"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
// Prompt Building - System Prompt
|
||||
// ============================================================================
|
||||
|
||||
// BuildSystemPrompt builds System Prompt according to strategy configuration
|
||||
func (e *StrategyEngine) BuildSystemPrompt(accountEquity float64, variant string) string {
|
||||
var sb strings.Builder
|
||||
riskControl := e.config.RiskControl
|
||||
promptSections := e.config.PromptSections
|
||||
|
||||
// 0. Data Dictionary & Schema (ensure AI understands all fields)
|
||||
lang := e.GetLanguage()
|
||||
schemaPrompt := GetSchemaPrompt(lang)
|
||||
sb.WriteString(schemaPrompt)
|
||||
sb.WriteString("\n\n")
|
||||
sb.WriteString("---\n\n")
|
||||
|
||||
// 1. Role definition (editable)
|
||||
if promptSections.RoleDefinition != "" {
|
||||
sb.WriteString(promptSections.RoleDefinition)
|
||||
sb.WriteString("\n\n")
|
||||
} else {
|
||||
sb.WriteString("# You are a professional cryptocurrency trading AI\n\n")
|
||||
sb.WriteString("Your task is to make trading decisions based on provided market data.\n\n")
|
||||
}
|
||||
|
||||
// 2. Trading mode variant
|
||||
switch strings.ToLower(strings.TrimSpace(variant)) {
|
||||
case "aggressive":
|
||||
sb.WriteString("## Mode: Aggressive\n- Prioritize capturing trend breakouts, can build positions in batches when confidence ≥ 70\n- Allow higher positions, but must strictly set stop-loss and explain risk-reward ratio\n\n")
|
||||
case "conservative":
|
||||
sb.WriteString("## Mode: Conservative\n- Only open positions when multiple signals resonate\n- Prioritize cash preservation, must pause for multiple periods after consecutive losses\n\n")
|
||||
case "scalping":
|
||||
sb.WriteString("## Mode: Scalping\n- Focus on short-term momentum, smaller profit targets but require quick action\n- If price doesn't move as expected within two bars, immediately reduce position or stop-loss\n\n")
|
||||
}
|
||||
|
||||
// 3. Hard constraints (risk control)
|
||||
btcEthPosValueRatio := riskControl.BTCETHMaxPositionValueRatio
|
||||
if btcEthPosValueRatio <= 0 {
|
||||
btcEthPosValueRatio = 5.0
|
||||
}
|
||||
altcoinPosValueRatio := riskControl.AltcoinMaxPositionValueRatio
|
||||
if altcoinPosValueRatio <= 0 {
|
||||
altcoinPosValueRatio = 1.0
|
||||
}
|
||||
|
||||
sb.WriteString("# Hard Constraints (Risk Control)\n\n")
|
||||
sb.WriteString("## CODE ENFORCED (Backend validation, cannot be bypassed):\n")
|
||||
sb.WriteString(fmt.Sprintf("- Max Positions: %d coins simultaneously\n", riskControl.MaxPositions))
|
||||
sb.WriteString(fmt.Sprintf("- Position Value Limit (Altcoins): max %.0f USDT (= equity %.0f × %.1fx)\n",
|
||||
accountEquity*altcoinPosValueRatio, accountEquity, altcoinPosValueRatio))
|
||||
sb.WriteString(fmt.Sprintf("- Position Value Limit (BTC/ETH): max %.0f USDT (= equity %.0f × %.1fx)\n",
|
||||
accountEquity*btcEthPosValueRatio, accountEquity, btcEthPosValueRatio))
|
||||
sb.WriteString(fmt.Sprintf("- Max Margin Usage: ≤%.0f%%\n", riskControl.MaxMarginUsage*100))
|
||||
sb.WriteString(fmt.Sprintf("- Min Position Size: ≥%.0f USDT\n\n", riskControl.MinPositionSize))
|
||||
|
||||
sb.WriteString("## AI GUIDED (Recommended, you should follow):\n")
|
||||
sb.WriteString(fmt.Sprintf("- Trading Leverage: Altcoins max %dx | BTC/ETH max %dx\n",
|
||||
riskControl.AltcoinMaxLeverage, riskControl.BTCETHMaxLeverage))
|
||||
sb.WriteString(fmt.Sprintf("- Risk-Reward Ratio: ≥1:%.1f (take_profit / stop_loss)\n", riskControl.MinRiskRewardRatio))
|
||||
sb.WriteString(fmt.Sprintf("- Min Confidence: ≥%d to open position\n\n", riskControl.MinConfidence))
|
||||
|
||||
// Position sizing guidance
|
||||
sb.WriteString("## Position Sizing Guidance\n")
|
||||
sb.WriteString("Calculate `position_size_usd` based on your confidence and the Position Value Limits above:\n")
|
||||
sb.WriteString("- High confidence (≥85): Use 80-100%% of max position value limit\n")
|
||||
sb.WriteString("- Medium confidence (70-84): Use 50-80%% of max position value limit\n")
|
||||
sb.WriteString("- Low confidence (60-69): Use 30-50%% of max position value limit\n")
|
||||
sb.WriteString(fmt.Sprintf("- Example: With equity %.0f and BTC/ETH ratio %.1fx, max is %.0f USDT\n",
|
||||
accountEquity, btcEthPosValueRatio, accountEquity*btcEthPosValueRatio))
|
||||
sb.WriteString("- **DO NOT** just use available_balance as position_size_usd. Use the Position Value Limits!\n\n")
|
||||
|
||||
// 4. Trading frequency (editable)
|
||||
if promptSections.TradingFrequency != "" {
|
||||
sb.WriteString(promptSections.TradingFrequency)
|
||||
sb.WriteString("\n\n")
|
||||
} else {
|
||||
sb.WriteString("# ⏱️ Trading Frequency Awareness\n\n")
|
||||
sb.WriteString("- Excellent traders: 2-4 trades/day ≈ 0.1-0.2 trades/hour\n")
|
||||
sb.WriteString("- >2 trades/hour = Overtrading\n")
|
||||
sb.WriteString("- Single position hold time ≥ 30-60 minutes\n")
|
||||
sb.WriteString("If you find yourself trading every period → standards too low; if closing positions < 30 minutes → too impatient.\n\n")
|
||||
}
|
||||
|
||||
// 5. Entry standards (editable)
|
||||
if promptSections.EntryStandards != "" {
|
||||
sb.WriteString(promptSections.EntryStandards)
|
||||
sb.WriteString("\n\nYou have the following indicator data:\n")
|
||||
e.writeAvailableIndicators(&sb)
|
||||
sb.WriteString(fmt.Sprintf("\n**Confidence ≥ %d** required to open positions.\n\n", riskControl.MinConfidence))
|
||||
} else {
|
||||
sb.WriteString("# 🎯 Entry Standards (Strict)\n\n")
|
||||
sb.WriteString("Only open positions when multiple signals resonate. You have:\n")
|
||||
e.writeAvailableIndicators(&sb)
|
||||
sb.WriteString(fmt.Sprintf("\nFeel free to use any effective analysis method, but **confidence ≥ %d** required to open positions; avoid low-quality behaviors such as single indicators, contradictory signals, sideways consolidation, reopening immediately after closing, etc.\n\n", riskControl.MinConfidence))
|
||||
}
|
||||
|
||||
// 6. Decision process (editable)
|
||||
if promptSections.DecisionProcess != "" {
|
||||
sb.WriteString(promptSections.DecisionProcess)
|
||||
sb.WriteString("\n\n")
|
||||
} else {
|
||||
sb.WriteString("# 📋 Decision Process\n\n")
|
||||
sb.WriteString("1. Check positions → Should we take profit/stop-loss\n")
|
||||
sb.WriteString("2. Scan candidate coins + multi-timeframe → Are there strong signals\n")
|
||||
sb.WriteString("3. Write chain of thought first, then output structured JSON\n\n")
|
||||
}
|
||||
|
||||
// 7. Output format
|
||||
sb.WriteString("# Output Format (Strictly Follow)\n\n")
|
||||
sb.WriteString("**Must use XML tags <reasoning> and <decision> to separate chain of thought and decision JSON, avoiding parsing errors**\n\n")
|
||||
sb.WriteString("## Format Requirements\n\n")
|
||||
sb.WriteString("<reasoning>\n")
|
||||
sb.WriteString("Your chain of thought analysis...\n")
|
||||
sb.WriteString("- Briefly analyze your thinking process \n")
|
||||
sb.WriteString("</reasoning>\n\n")
|
||||
sb.WriteString("<decision>\n")
|
||||
sb.WriteString("Step 2: JSON decision array\n\n")
|
||||
sb.WriteString("```json\n[\n")
|
||||
// Use the actual configured position value ratio for BTC/ETH in the example
|
||||
examplePositionSize := accountEquity * btcEthPosValueRatio
|
||||
sb.WriteString(fmt.Sprintf(" {\"symbol\": \"BTCUSDT\", \"action\": \"open_short\", \"leverage\": %d, \"position_size_usd\": %.0f, \"stop_loss\": 97000, \"take_profit\": 91000, \"confidence\": 85, \"risk_usd\": 300},\n",
|
||||
riskControl.BTCETHMaxLeverage, examplePositionSize))
|
||||
sb.WriteString(" {\"symbol\": \"ETHUSDT\", \"action\": \"close_long\"}\n")
|
||||
sb.WriteString("]\n```\n")
|
||||
sb.WriteString("</decision>\n\n")
|
||||
sb.WriteString("## Field Description\n\n")
|
||||
sb.WriteString("- `action`: open_long | open_short | close_long | close_short | hold | wait\n")
|
||||
sb.WriteString(fmt.Sprintf("- `confidence`: 0-100 (opening recommended ≥ %d)\n", riskControl.MinConfidence))
|
||||
sb.WriteString("- Required when opening: leverage, position_size_usd, stop_loss, take_profit, confidence, risk_usd\n")
|
||||
sb.WriteString("- **IMPORTANT**: All numeric values must be calculated numbers, NOT formulas/expressions (e.g., use `27.76` not `3000 * 0.01`)\n\n")
|
||||
|
||||
// 8. Custom Prompt
|
||||
if e.config.CustomPrompt != "" {
|
||||
sb.WriteString("# 📌 Personalized Trading Strategy\n\n")
|
||||
sb.WriteString(e.config.CustomPrompt)
|
||||
sb.WriteString("\n\n")
|
||||
sb.WriteString("Note: The above personalized strategy is a supplement to the basic rules and cannot violate the basic risk control principles.\n")
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func (e *StrategyEngine) writeAvailableIndicators(sb *strings.Builder) {
|
||||
indicators := e.config.Indicators
|
||||
kline := indicators.Klines
|
||||
|
||||
sb.WriteString(fmt.Sprintf("- %s price series", kline.PrimaryTimeframe))
|
||||
if kline.EnableMultiTimeframe {
|
||||
sb.WriteString(fmt.Sprintf(" + %s K-line series\n", kline.LongerTimeframe))
|
||||
} else {
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
if indicators.EnableEMA {
|
||||
sb.WriteString("- EMA indicators")
|
||||
if len(indicators.EMAPeriods) > 0 {
|
||||
sb.WriteString(fmt.Sprintf(" (periods: %v)", indicators.EMAPeriods))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
if indicators.EnableMACD {
|
||||
sb.WriteString("- MACD indicators\n")
|
||||
}
|
||||
|
||||
if indicators.EnableRSI {
|
||||
sb.WriteString("- RSI indicators")
|
||||
if len(indicators.RSIPeriods) > 0 {
|
||||
sb.WriteString(fmt.Sprintf(" (periods: %v)", indicators.RSIPeriods))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
if indicators.EnableATR {
|
||||
sb.WriteString("- ATR indicators")
|
||||
if len(indicators.ATRPeriods) > 0 {
|
||||
sb.WriteString(fmt.Sprintf(" (periods: %v)", indicators.ATRPeriods))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
if indicators.EnableBOLL {
|
||||
sb.WriteString("- Bollinger Bands (BOLL) - Upper/Middle/Lower bands")
|
||||
if len(indicators.BOLLPeriods) > 0 {
|
||||
sb.WriteString(fmt.Sprintf(" (periods: %v)", indicators.BOLLPeriods))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
if indicators.EnableVolume {
|
||||
sb.WriteString("- Volume data\n")
|
||||
}
|
||||
|
||||
if indicators.EnableOI {
|
||||
sb.WriteString("- Open Interest (OI) data\n")
|
||||
}
|
||||
|
||||
if indicators.EnableFundingRate {
|
||||
sb.WriteString("- Funding rate\n")
|
||||
}
|
||||
|
||||
if len(e.config.CoinSource.StaticCoins) > 0 || e.config.CoinSource.UseAI500 || e.config.CoinSource.UseOITop {
|
||||
sb.WriteString("- AI500 / OI_Top filter tags (if available)\n")
|
||||
}
|
||||
|
||||
if indicators.EnableQuantData {
|
||||
sb.WriteString("- Quantitative data (institutional/retail fund flow, position changes, multi-period price changes)\n")
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Prompt Building - User Prompt
|
||||
// ============================================================================
|
||||
|
||||
// BuildUserPrompt builds User Prompt based on strategy configuration
|
||||
func (e *StrategyEngine) BuildUserPrompt(ctx *Context) string {
|
||||
var sb strings.Builder
|
||||
|
||||
// System status
|
||||
sb.WriteString(fmt.Sprintf("Time: %s | Period: #%d | Runtime: %d minutes\n\n",
|
||||
ctx.CurrentTime, ctx.CallCount, ctx.RuntimeMinutes))
|
||||
|
||||
// BTC market
|
||||
if btcData, hasBTC := ctx.MarketDataMap["BTCUSDT"]; hasBTC {
|
||||
sb.WriteString(fmt.Sprintf("BTC: %.2f (1h: %+.2f%%, 4h: %+.2f%%) | MACD: %.4f | RSI: %.2f\n\n",
|
||||
btcData.CurrentPrice, btcData.PriceChange1h, btcData.PriceChange4h,
|
||||
btcData.CurrentMACD, btcData.CurrentRSI7))
|
||||
}
|
||||
|
||||
// Account information
|
||||
sb.WriteString(fmt.Sprintf("Account: Equity %.2f | Balance %.2f (%.1f%%) | PnL %+.2f%% | Margin %.1f%% | Positions %d\n\n",
|
||||
ctx.Account.TotalEquity,
|
||||
ctx.Account.AvailableBalance,
|
||||
(ctx.Account.AvailableBalance/ctx.Account.TotalEquity)*100,
|
||||
ctx.Account.TotalPnLPct,
|
||||
ctx.Account.MarginUsedPct,
|
||||
ctx.Account.PositionCount))
|
||||
|
||||
// Recently completed orders (placed before positions to ensure visibility)
|
||||
if len(ctx.RecentOrders) > 0 {
|
||||
sb.WriteString("## Recent Completed Trades\n")
|
||||
for i, order := range ctx.RecentOrders {
|
||||
resultStr := "Profit"
|
||||
if order.RealizedPnL < 0 {
|
||||
resultStr = "Loss"
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("%d. %s %s | Entry %.4f Exit %.4f | %s: %+.2f USDT (%+.2f%%) | %s→%s (%s)\n",
|
||||
i+1, order.Symbol, order.Side,
|
||||
order.EntryPrice, order.ExitPrice,
|
||||
resultStr, order.RealizedPnL, order.PnLPct,
|
||||
order.EntryTime, order.ExitTime, order.HoldDuration))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
// Historical trading statistics (helps AI understand past performance)
|
||||
if ctx.TradingStats != nil && ctx.TradingStats.TotalTrades > 0 {
|
||||
// Get language from strategy config
|
||||
lang := e.GetLanguage()
|
||||
|
||||
// Win/Loss ratio
|
||||
var winLossRatio float64
|
||||
if ctx.TradingStats.AvgLoss > 0 {
|
||||
winLossRatio = ctx.TradingStats.AvgWin / ctx.TradingStats.AvgLoss
|
||||
}
|
||||
|
||||
if lang == LangChinese {
|
||||
sb.WriteString("## 历史交易统计\n")
|
||||
sb.WriteString(fmt.Sprintf("总交易: %d 笔 | 盈利因子: %.2f | 夏普比率: %.2f | 盈亏比: %.2f\n",
|
||||
ctx.TradingStats.TotalTrades,
|
||||
ctx.TradingStats.ProfitFactor,
|
||||
ctx.TradingStats.SharpeRatio,
|
||||
winLossRatio))
|
||||
sb.WriteString(fmt.Sprintf("总盈亏: %+.2f USDT | 平均盈利: +%.2f | 平均亏损: -%.2f | 最大回撤: %.1f%%\n",
|
||||
ctx.TradingStats.TotalPnL,
|
||||
ctx.TradingStats.AvgWin,
|
||||
ctx.TradingStats.AvgLoss,
|
||||
ctx.TradingStats.MaxDrawdownPct))
|
||||
|
||||
// Performance hints based on profit factor, sharpe, and drawdown
|
||||
if ctx.TradingStats.ProfitFactor >= 1.5 && ctx.TradingStats.SharpeRatio >= 1 {
|
||||
sb.WriteString("表现: 良好 - 保持当前策略\n")
|
||||
} else if ctx.TradingStats.ProfitFactor < 1 {
|
||||
sb.WriteString("表现: 需改进 - 提高盈亏比,优化止盈止损\n")
|
||||
} else if ctx.TradingStats.MaxDrawdownPct > 30 {
|
||||
sb.WriteString("表现: 风险偏高 - 减少仓位,控制回撤\n")
|
||||
} else {
|
||||
sb.WriteString("表现: 正常 - 有优化空间\n")
|
||||
}
|
||||
} else {
|
||||
sb.WriteString("## Historical Trading Statistics\n")
|
||||
sb.WriteString(fmt.Sprintf("Total Trades: %d | Profit Factor: %.2f | Sharpe: %.2f | Win/Loss Ratio: %.2f\n",
|
||||
ctx.TradingStats.TotalTrades,
|
||||
ctx.TradingStats.ProfitFactor,
|
||||
ctx.TradingStats.SharpeRatio,
|
||||
winLossRatio))
|
||||
sb.WriteString(fmt.Sprintf("Total PnL: %+.2f USDT | Avg Win: +%.2f | Avg Loss: -%.2f | Max Drawdown: %.1f%%\n",
|
||||
ctx.TradingStats.TotalPnL,
|
||||
ctx.TradingStats.AvgWin,
|
||||
ctx.TradingStats.AvgLoss,
|
||||
ctx.TradingStats.MaxDrawdownPct))
|
||||
|
||||
// Performance hints based on profit factor, sharpe, and drawdown
|
||||
if ctx.TradingStats.ProfitFactor >= 1.5 && ctx.TradingStats.SharpeRatio >= 1 {
|
||||
sb.WriteString("Performance: GOOD - maintain current strategy\n")
|
||||
} else if ctx.TradingStats.ProfitFactor < 1 {
|
||||
sb.WriteString("Performance: NEEDS IMPROVEMENT - improve win/loss ratio, optimize TP/SL\n")
|
||||
} else if ctx.TradingStats.MaxDrawdownPct > 30 {
|
||||
sb.WriteString("Performance: HIGH RISK - reduce position size, control drawdown\n")
|
||||
} else {
|
||||
sb.WriteString("Performance: NORMAL - room for optimization\n")
|
||||
}
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
// Position information
|
||||
if len(ctx.Positions) > 0 {
|
||||
sb.WriteString("## Current Positions\n")
|
||||
for i, pos := range ctx.Positions {
|
||||
sb.WriteString(e.formatPositionInfo(i+1, pos, ctx))
|
||||
}
|
||||
} else {
|
||||
sb.WriteString("Current Positions: None\n\n")
|
||||
}
|
||||
|
||||
// Candidate coins (exclude coins already in positions to avoid duplicate data)
|
||||
positionSymbols := make(map[string]bool)
|
||||
for _, pos := range ctx.Positions {
|
||||
// Normalize symbol to handle both "ETH" and "ETHUSDT" formats
|
||||
normalizedSymbol := market.Normalize(pos.Symbol)
|
||||
positionSymbols[normalizedSymbol] = true
|
||||
}
|
||||
|
||||
sb.WriteString(fmt.Sprintf("## Candidate Coins (%d coins)\n\n", len(ctx.MarketDataMap)))
|
||||
displayedCount := 0
|
||||
for _, coin := range ctx.CandidateCoins {
|
||||
// Skip if this coin is already a position (data already shown in positions section)
|
||||
normalizedCoinSymbol := market.Normalize(coin.Symbol)
|
||||
if positionSymbols[normalizedCoinSymbol] {
|
||||
continue
|
||||
}
|
||||
|
||||
marketData, hasData := ctx.MarketDataMap[coin.Symbol]
|
||||
if !hasData {
|
||||
continue
|
||||
}
|
||||
displayedCount++
|
||||
|
||||
sourceTags := e.formatCoinSourceTag(coin.Sources)
|
||||
sb.WriteString(fmt.Sprintf("### %d. %s%s\n\n", displayedCount, coin.Symbol, sourceTags))
|
||||
sb.WriteString(e.formatMarketData(marketData))
|
||||
|
||||
if ctx.QuantDataMap != nil {
|
||||
if quantData, hasQuant := ctx.QuantDataMap[coin.Symbol]; hasQuant {
|
||||
sb.WriteString(e.formatQuantData(quantData))
|
||||
}
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
|
||||
// Get language for market data formatting
|
||||
nofxosLang := nofxos.LangEnglish
|
||||
if e.GetLanguage() == LangChinese {
|
||||
nofxosLang = nofxos.LangChinese
|
||||
}
|
||||
|
||||
// OI Ranking data (market-wide open interest changes)
|
||||
if ctx.OIRankingData != nil {
|
||||
sb.WriteString(nofxos.FormatOIRankingForAI(ctx.OIRankingData, nofxosLang))
|
||||
}
|
||||
|
||||
// NetFlow Ranking data (market-wide fund flow)
|
||||
if ctx.NetFlowRankingData != nil {
|
||||
sb.WriteString(nofxos.FormatNetFlowRankingForAI(ctx.NetFlowRankingData, nofxosLang))
|
||||
}
|
||||
|
||||
// Price Ranking data (market-wide gainers/losers)
|
||||
if ctx.PriceRankingData != nil {
|
||||
sb.WriteString(nofxos.FormatPriceRankingForAI(ctx.PriceRankingData, nofxosLang))
|
||||
}
|
||||
|
||||
sb.WriteString("---\n\n")
|
||||
sb.WriteString("Now please analyze and output your decision (Chain of Thought + JSON)\n")
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func (e *StrategyEngine) formatPositionInfo(index int, pos PositionInfo, ctx *Context) string {
|
||||
var sb strings.Builder
|
||||
|
||||
holdingDuration := ""
|
||||
if pos.UpdateTime > 0 {
|
||||
durationMs := time.Now().UnixMilli() - pos.UpdateTime
|
||||
durationMin := durationMs / (1000 * 60)
|
||||
if durationMin < 60 {
|
||||
holdingDuration = fmt.Sprintf(" | Holding Duration %d min", durationMin)
|
||||
} else {
|
||||
durationHour := durationMin / 60
|
||||
durationMinRemainder := durationMin % 60
|
||||
holdingDuration = fmt.Sprintf(" | Holding Duration %dh %dm", durationHour, durationMinRemainder)
|
||||
}
|
||||
}
|
||||
|
||||
positionValue := pos.Quantity * pos.MarkPrice
|
||||
if positionValue < 0 {
|
||||
positionValue = -positionValue
|
||||
}
|
||||
|
||||
sb.WriteString(fmt.Sprintf("%d. %s %s | Entry %.4f Current %.4f | Qty %.4f | Position Value %.2f USDT | PnL%+.2f%% | PnL Amount%+.2f USDT | Peak PnL%.2f%% | Leverage %dx | Margin %.0f | Liq Price %.4f%s\n\n",
|
||||
index, pos.Symbol, strings.ToUpper(pos.Side),
|
||||
pos.EntryPrice, pos.MarkPrice, pos.Quantity, positionValue, pos.UnrealizedPnLPct, pos.UnrealizedPnL, pos.PeakPnLPct,
|
||||
pos.Leverage, pos.MarginUsed, pos.LiquidationPrice, holdingDuration))
|
||||
|
||||
if marketData, ok := ctx.MarketDataMap[pos.Symbol]; ok {
|
||||
sb.WriteString(e.formatMarketData(marketData))
|
||||
|
||||
if ctx.QuantDataMap != nil {
|
||||
if quantData, hasQuant := ctx.QuantDataMap[pos.Symbol]; hasQuant {
|
||||
sb.WriteString(e.formatQuantData(quantData))
|
||||
}
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func (e *StrategyEngine) formatCoinSourceTag(sources []string) string {
|
||||
if len(sources) > 1 {
|
||||
// Multiple signal source combination
|
||||
hasAI500 := false
|
||||
hasOITop := false
|
||||
hasOILow := false
|
||||
hasHyperAll := false
|
||||
hasHyperMain := false
|
||||
for _, s := range sources {
|
||||
switch s {
|
||||
case "ai500":
|
||||
hasAI500 = true
|
||||
case "oi_top":
|
||||
hasOITop = true
|
||||
case "oi_low":
|
||||
hasOILow = true
|
||||
case "hyper_all":
|
||||
hasHyperAll = true
|
||||
case "hyper_main":
|
||||
hasHyperMain = true
|
||||
}
|
||||
}
|
||||
if hasAI500 && hasOITop {
|
||||
return " (AI500+OI_Top dual signal)"
|
||||
}
|
||||
if hasAI500 && hasOILow {
|
||||
return " (AI500+OI_Low dual signal)"
|
||||
}
|
||||
if hasOITop && hasOILow {
|
||||
return " (OI_Top+OI_Low)"
|
||||
}
|
||||
if hasHyperMain && hasAI500 {
|
||||
return " (HyperMain+AI500)"
|
||||
}
|
||||
if hasHyperAll || hasHyperMain {
|
||||
return " (Hyperliquid)"
|
||||
}
|
||||
return " (Multiple sources)"
|
||||
} else if len(sources) == 1 {
|
||||
switch sources[0] {
|
||||
case "ai500":
|
||||
return " (AI500)"
|
||||
case "oi_top":
|
||||
return " (OI_Top OI increase)"
|
||||
case "oi_low":
|
||||
return " (OI_Low OI decrease)"
|
||||
case "static":
|
||||
return " (Manual selection)"
|
||||
case "hyper_all":
|
||||
return " (Hyperliquid All)"
|
||||
case "hyper_main":
|
||||
return " (Hyperliquid Top20)"
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Market Data Formatting
|
||||
// ============================================================================
|
||||
|
||||
func (e *StrategyEngine) formatMarketData(data *market.Data) string {
|
||||
var sb strings.Builder
|
||||
indicators := e.config.Indicators
|
||||
|
||||
// Clearly label the coin symbol
|
||||
sb.WriteString(fmt.Sprintf("=== %s Market Data ===\n\n", data.Symbol))
|
||||
sb.WriteString(fmt.Sprintf("current_price = %.4f", data.CurrentPrice))
|
||||
|
||||
if indicators.EnableEMA {
|
||||
sb.WriteString(fmt.Sprintf(", current_ema20 = %.3f", data.CurrentEMA20))
|
||||
}
|
||||
|
||||
if indicators.EnableMACD {
|
||||
sb.WriteString(fmt.Sprintf(", current_macd = %.3f", data.CurrentMACD))
|
||||
}
|
||||
|
||||
if indicators.EnableRSI {
|
||||
sb.WriteString(fmt.Sprintf(", current_rsi7 = %.3f", data.CurrentRSI7))
|
||||
}
|
||||
|
||||
sb.WriteString("\n\n")
|
||||
|
||||
if indicators.EnableOI || indicators.EnableFundingRate {
|
||||
sb.WriteString(fmt.Sprintf("Additional data for %s:\n\n", data.Symbol))
|
||||
|
||||
if indicators.EnableOI && data.OpenInterest != nil {
|
||||
sb.WriteString(fmt.Sprintf("Open Interest: Latest: %.2f Average: %.2f\n\n",
|
||||
data.OpenInterest.Latest, data.OpenInterest.Average))
|
||||
}
|
||||
|
||||
if indicators.EnableFundingRate {
|
||||
sb.WriteString(fmt.Sprintf("Funding Rate: %.2e\n\n", data.FundingRate))
|
||||
}
|
||||
}
|
||||
|
||||
if len(data.TimeframeData) > 0 {
|
||||
timeframeOrder := []string{"1m", "3m", "5m", "15m", "30m", "1h", "2h", "4h", "6h", "8h", "12h", "1d", "3d", "1w"}
|
||||
for _, tf := range timeframeOrder {
|
||||
if tfData, ok := data.TimeframeData[tf]; ok {
|
||||
sb.WriteString(fmt.Sprintf("=== %s Timeframe (oldest → latest) ===\n\n", strings.ToUpper(tf)))
|
||||
e.formatTimeframeSeriesData(&sb, tfData, indicators)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Compatible with old data format
|
||||
if data.IntradaySeries != nil {
|
||||
klineConfig := indicators.Klines
|
||||
sb.WriteString(fmt.Sprintf("Intraday series (%s intervals, oldest → latest):\n\n", klineConfig.PrimaryTimeframe))
|
||||
|
||||
if len(data.IntradaySeries.MidPrices) > 0 {
|
||||
sb.WriteString(fmt.Sprintf("Mid prices: %s\n\n", formatFloatSlice(data.IntradaySeries.MidPrices)))
|
||||
}
|
||||
|
||||
if indicators.EnableEMA && len(data.IntradaySeries.EMA20Values) > 0 {
|
||||
sb.WriteString(fmt.Sprintf("EMA indicators (20-period): %s\n\n", formatFloatSlice(data.IntradaySeries.EMA20Values)))
|
||||
}
|
||||
|
||||
if indicators.EnableMACD && len(data.IntradaySeries.MACDValues) > 0 {
|
||||
sb.WriteString(fmt.Sprintf("MACD indicators: %s\n\n", formatFloatSlice(data.IntradaySeries.MACDValues)))
|
||||
}
|
||||
|
||||
if indicators.EnableRSI {
|
||||
if len(data.IntradaySeries.RSI7Values) > 0 {
|
||||
sb.WriteString(fmt.Sprintf("RSI indicators (7-Period): %s\n\n", formatFloatSlice(data.IntradaySeries.RSI7Values)))
|
||||
}
|
||||
if len(data.IntradaySeries.RSI14Values) > 0 {
|
||||
sb.WriteString(fmt.Sprintf("RSI indicators (14-Period): %s\n\n", formatFloatSlice(data.IntradaySeries.RSI14Values)))
|
||||
}
|
||||
}
|
||||
|
||||
if indicators.EnableVolume && len(data.IntradaySeries.Volume) > 0 {
|
||||
sb.WriteString(fmt.Sprintf("Volume: %s\n\n", formatFloatSlice(data.IntradaySeries.Volume)))
|
||||
}
|
||||
|
||||
if indicators.EnableATR {
|
||||
sb.WriteString(fmt.Sprintf("3m ATR (14-period): %.3f\n\n", data.IntradaySeries.ATR14))
|
||||
}
|
||||
}
|
||||
|
||||
if data.LongerTermContext != nil && indicators.Klines.EnableMultiTimeframe {
|
||||
sb.WriteString(fmt.Sprintf("Longer-term context (%s timeframe):\n\n", indicators.Klines.LongerTimeframe))
|
||||
|
||||
if indicators.EnableEMA {
|
||||
sb.WriteString(fmt.Sprintf("20-Period EMA: %.3f vs. 50-Period EMA: %.3f\n\n",
|
||||
data.LongerTermContext.EMA20, data.LongerTermContext.EMA50))
|
||||
}
|
||||
|
||||
if indicators.EnableATR {
|
||||
sb.WriteString(fmt.Sprintf("3-Period ATR: %.3f vs. 14-Period ATR: %.3f\n\n",
|
||||
data.LongerTermContext.ATR3, data.LongerTermContext.ATR14))
|
||||
}
|
||||
|
||||
if indicators.EnableVolume {
|
||||
sb.WriteString(fmt.Sprintf("Current Volume: %.3f vs. Average Volume: %.3f\n\n",
|
||||
data.LongerTermContext.CurrentVolume, data.LongerTermContext.AverageVolume))
|
||||
}
|
||||
|
||||
if indicators.EnableMACD && len(data.LongerTermContext.MACDValues) > 0 {
|
||||
sb.WriteString(fmt.Sprintf("MACD indicators: %s\n\n", formatFloatSlice(data.LongerTermContext.MACDValues)))
|
||||
}
|
||||
|
||||
if indicators.EnableRSI && len(data.LongerTermContext.RSI14Values) > 0 {
|
||||
sb.WriteString(fmt.Sprintf("RSI indicators (14-Period): %s\n\n", formatFloatSlice(data.LongerTermContext.RSI14Values)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func (e *StrategyEngine) formatTimeframeSeriesData(sb *strings.Builder, data *market.TimeframeSeriesData, indicators store.IndicatorConfig) {
|
||||
if len(data.Klines) > 0 {
|
||||
sb.WriteString("Time(UTC) Open High Low Close Volume\n")
|
||||
for i, k := range data.Klines {
|
||||
t := time.Unix(k.Time/1000, 0).UTC()
|
||||
timeStr := t.Format("01-02 15:04")
|
||||
marker := ""
|
||||
if i == len(data.Klines)-1 {
|
||||
marker = " <- current"
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("%-14s %-9.4f %-9.4f %-9.4f %-9.4f %-12.2f%s\n",
|
||||
timeStr, k.Open, k.High, k.Low, k.Close, k.Volume, marker))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
} else if len(data.MidPrices) > 0 {
|
||||
sb.WriteString(fmt.Sprintf("Mid prices: %s\n\n", formatFloatSlice(data.MidPrices)))
|
||||
if indicators.EnableVolume && len(data.Volume) > 0 {
|
||||
sb.WriteString(fmt.Sprintf("Volume: %s\n\n", formatFloatSlice(data.Volume)))
|
||||
}
|
||||
}
|
||||
|
||||
if indicators.EnableEMA {
|
||||
if len(data.EMA20Values) > 0 {
|
||||
sb.WriteString(fmt.Sprintf("EMA20: %s\n", formatFloatSlice(data.EMA20Values)))
|
||||
}
|
||||
if len(data.EMA50Values) > 0 {
|
||||
sb.WriteString(fmt.Sprintf("EMA50: %s\n", formatFloatSlice(data.EMA50Values)))
|
||||
}
|
||||
}
|
||||
|
||||
if indicators.EnableMACD && len(data.MACDValues) > 0 {
|
||||
sb.WriteString(fmt.Sprintf("MACD: %s\n", formatFloatSlice(data.MACDValues)))
|
||||
}
|
||||
|
||||
if indicators.EnableRSI {
|
||||
if len(data.RSI7Values) > 0 {
|
||||
sb.WriteString(fmt.Sprintf("RSI7: %s\n", formatFloatSlice(data.RSI7Values)))
|
||||
}
|
||||
if len(data.RSI14Values) > 0 {
|
||||
sb.WriteString(fmt.Sprintf("RSI14: %s\n", formatFloatSlice(data.RSI14Values)))
|
||||
}
|
||||
}
|
||||
|
||||
if indicators.EnableATR && data.ATR14 > 0 {
|
||||
sb.WriteString(fmt.Sprintf("ATR14: %.4f\n", data.ATR14))
|
||||
}
|
||||
|
||||
if indicators.EnableBOLL && len(data.BOLLUpper) > 0 {
|
||||
sb.WriteString(fmt.Sprintf("BOLL Upper: %s\n", formatFloatSlice(data.BOLLUpper)))
|
||||
sb.WriteString(fmt.Sprintf("BOLL Middle: %s\n", formatFloatSlice(data.BOLLMiddle)))
|
||||
sb.WriteString(fmt.Sprintf("BOLL Lower: %s\n", formatFloatSlice(data.BOLLLower)))
|
||||
}
|
||||
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
func (e *StrategyEngine) formatQuantData(data *QuantData) string {
|
||||
if data == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
indicators := e.config.Indicators
|
||||
if !indicators.EnableQuantOI && !indicators.EnableQuantNetflow {
|
||||
return ""
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("📊 %s Quantitative Data:\n", data.Symbol))
|
||||
|
||||
if len(data.PriceChange) > 0 {
|
||||
sb.WriteString("Price Change: ")
|
||||
timeframes := []string{"5m", "15m", "1h", "4h", "12h", "24h"}
|
||||
parts := []string{}
|
||||
for _, tf := range timeframes {
|
||||
if v, ok := data.PriceChange[tf]; ok {
|
||||
parts = append(parts, fmt.Sprintf("%s: %+.4f%%", tf, v*100))
|
||||
}
|
||||
}
|
||||
sb.WriteString(strings.Join(parts, " | "))
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
if indicators.EnableQuantNetflow && data.Netflow != nil {
|
||||
sb.WriteString("Fund Flow (Netflow):\n")
|
||||
timeframes := []string{"5m", "15m", "1h", "4h", "12h", "24h"}
|
||||
|
||||
if data.Netflow.Institution != nil {
|
||||
if data.Netflow.Institution.Future != nil && len(data.Netflow.Institution.Future) > 0 {
|
||||
sb.WriteString(" Institutional Futures:\n")
|
||||
for _, tf := range timeframes {
|
||||
if v, ok := data.Netflow.Institution.Future[tf]; ok {
|
||||
sb.WriteString(fmt.Sprintf(" %s: %s\n", tf, formatFlowValue(v)))
|
||||
}
|
||||
}
|
||||
}
|
||||
if data.Netflow.Institution.Spot != nil && len(data.Netflow.Institution.Spot) > 0 {
|
||||
sb.WriteString(" Institutional Spot:\n")
|
||||
for _, tf := range timeframes {
|
||||
if v, ok := data.Netflow.Institution.Spot[tf]; ok {
|
||||
sb.WriteString(fmt.Sprintf(" %s: %s\n", tf, formatFlowValue(v)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if data.Netflow.Personal != nil {
|
||||
if data.Netflow.Personal.Future != nil && len(data.Netflow.Personal.Future) > 0 {
|
||||
sb.WriteString(" Retail Futures:\n")
|
||||
for _, tf := range timeframes {
|
||||
if v, ok := data.Netflow.Personal.Future[tf]; ok {
|
||||
sb.WriteString(fmt.Sprintf(" %s: %s\n", tf, formatFlowValue(v)))
|
||||
}
|
||||
}
|
||||
}
|
||||
if data.Netflow.Personal.Spot != nil && len(data.Netflow.Personal.Spot) > 0 {
|
||||
sb.WriteString(" Retail Spot:\n")
|
||||
for _, tf := range timeframes {
|
||||
if v, ok := data.Netflow.Personal.Spot[tf]; ok {
|
||||
sb.WriteString(fmt.Sprintf(" %s: %s\n", tf, formatFlowValue(v)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if indicators.EnableQuantOI && len(data.OI) > 0 {
|
||||
for exchange, oiData := range data.OI {
|
||||
if len(oiData.Delta) > 0 {
|
||||
sb.WriteString(fmt.Sprintf("Open Interest (%s):\n", exchange))
|
||||
for _, tf := range []string{"5m", "15m", "1h", "4h", "12h", "24h"} {
|
||||
if d, ok := oiData.Delta[tf]; ok {
|
||||
sb.WriteString(fmt.Sprintf(" %s: %+.4f%% (%s)\n", tf, d.OIDeltaPercent, formatFlowValue(d.OIDeltaValue)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func formatFlowValue(v float64) string {
|
||||
sign := ""
|
||||
if v >= 0 {
|
||||
sign = "+"
|
||||
}
|
||||
absV := v
|
||||
if absV < 0 {
|
||||
absV = -absV
|
||||
}
|
||||
if absV >= 1e9 {
|
||||
return fmt.Sprintf("%s%.2fB", sign, v/1e9)
|
||||
} else if absV >= 1e6 {
|
||||
return fmt.Sprintf("%s%.2fM", sign, v/1e6)
|
||||
} else if absV >= 1e3 {
|
||||
return fmt.Sprintf("%s%.2fK", sign, v/1e3)
|
||||
}
|
||||
return fmt.Sprintf("%s%.2f", sign, v)
|
||||
}
|
||||
|
||||
func formatFloatSlice(values []float64) string {
|
||||
strValues := make([]string, len(values))
|
||||
for i, v := range values {
|
||||
strValues[i] = fmt.Sprintf("%.4f", v)
|
||||
}
|
||||
return "[" + strings.Join(strValues, ", ") + "]"
|
||||
}
|
||||
@@ -10,49 +10,50 @@ import (
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
// AI Data Formatter - AI数据格式化器
|
||||
// AI Data Formatter
|
||||
// ============================================================================
|
||||
// 将交易上下文转换为AI友好的格式,确保AI能够100%理解数据
|
||||
// Converts trading context into AI-friendly format, ensuring AI fully
|
||||
// understands the data regardless of language.
|
||||
// ============================================================================
|
||||
|
||||
// FormatContextForAI 将交易上下文格式化为AI可理解的文本(包含Schema)
|
||||
// FormatContextForAI formats trading context into AI-readable text (including schema)
|
||||
func FormatContextForAI(ctx *Context, lang Language) string {
|
||||
var sb strings.Builder
|
||||
|
||||
// 1. 添加Schema说明(让AI理解数据格式)
|
||||
// 1. Add schema description (so AI understands data format)
|
||||
sb.WriteString(GetSchemaPrompt(lang))
|
||||
sb.WriteString("\n---\n\n")
|
||||
|
||||
// 2. 当前状态概览
|
||||
// 2. Current state overview
|
||||
sb.WriteString(formatContextData(ctx, lang))
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// FormatContextDataOnly 仅格式化上下文数据,不包含Schema(用于已有Schema的场景)
|
||||
// FormatContextDataOnly formats context data only, without schema (for use when schema is already present)
|
||||
func FormatContextDataOnly(ctx *Context, lang Language) string {
|
||||
return formatContextData(ctx, lang)
|
||||
}
|
||||
|
||||
// formatContextData 格式化核心数据部分
|
||||
// formatContextData formats the core data section
|
||||
func formatContextData(ctx *Context, lang Language) string {
|
||||
var sb strings.Builder
|
||||
|
||||
// 1. 当前状态概览
|
||||
// 1. Current state overview
|
||||
if lang == LangChinese {
|
||||
sb.WriteString(formatHeaderZH(ctx))
|
||||
} else {
|
||||
sb.WriteString(formatHeaderEN(ctx))
|
||||
}
|
||||
|
||||
// 3. 账户信息
|
||||
// 3. Account information
|
||||
if lang == LangChinese {
|
||||
sb.WriteString(formatAccountZH(ctx))
|
||||
} else {
|
||||
sb.WriteString(formatAccountEN(ctx))
|
||||
}
|
||||
|
||||
// 4. 历史交易统计
|
||||
// 4. Historical trading statistics
|
||||
if ctx.TradingStats != nil && ctx.TradingStats.TotalTrades > 0 {
|
||||
if lang == LangChinese {
|
||||
sb.WriteString(formatTradingStatsZH(ctx.TradingStats))
|
||||
@@ -61,7 +62,7 @@ func formatContextData(ctx *Context, lang Language) string {
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 最近交易记录
|
||||
// 5. Recent trade records
|
||||
if len(ctx.RecentOrders) > 0 {
|
||||
if lang == LangChinese {
|
||||
sb.WriteString(formatRecentTradesZH(ctx.RecentOrders))
|
||||
@@ -70,7 +71,7 @@ func formatContextData(ctx *Context, lang Language) string {
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 当前持仓
|
||||
// 5. Current positions
|
||||
if len(ctx.Positions) > 0 {
|
||||
if lang == LangChinese {
|
||||
sb.WriteString(formatCurrentPositionsZH(ctx))
|
||||
@@ -79,7 +80,7 @@ func formatContextData(ctx *Context, lang Language) string {
|
||||
}
|
||||
}
|
||||
|
||||
// 6. 候选币种(带市场数据)
|
||||
// 6. Candidate coins (with market data)
|
||||
if len(ctx.CandidateCoins) > 0 {
|
||||
if lang == LangChinese {
|
||||
sb.WriteString(formatCandidateCoinsZH(ctx))
|
||||
@@ -88,7 +89,7 @@ func formatContextData(ctx *Context, lang Language) string {
|
||||
}
|
||||
}
|
||||
|
||||
// 7. OI排名数据(如果有)
|
||||
// 7. OI ranking data (if available)
|
||||
if ctx.OIRankingData != nil {
|
||||
nofxosLang := nofxos.LangEnglish
|
||||
if lang == LangChinese {
|
||||
@@ -100,15 +101,15 @@ func formatContextData(ctx *Context, lang Language) string {
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// ========== 中文格式化函数 ==========
|
||||
// ========== Chinese Formatting Functions ==========
|
||||
|
||||
// formatHeaderZH 格式化头部信息(中文)
|
||||
// formatHeaderZH formats header information (Chinese)
|
||||
func formatHeaderZH(ctx *Context) string {
|
||||
return fmt.Sprintf("# 📊 交易决策请求\n\n时间: %s | 周期: #%d | 运行时长: %d 分钟\n\n",
|
||||
ctx.CurrentTime, ctx.CallCount, ctx.RuntimeMinutes)
|
||||
}
|
||||
|
||||
// formatAccountZH 格式化账户信息(中文)
|
||||
// formatAccountZH formats account information (Chinese)
|
||||
func formatAccountZH(ctx *Context) string {
|
||||
acc := ctx.Account
|
||||
var sb strings.Builder
|
||||
@@ -120,7 +121,7 @@ func formatAccountZH(ctx *Context) string {
|
||||
sb.WriteString(fmt.Sprintf("保证金使用率: %.1f%% | ", acc.MarginUsedPct))
|
||||
sb.WriteString(fmt.Sprintf("持仓数: %d\n\n", acc.PositionCount))
|
||||
|
||||
// 添加风险提示
|
||||
// Add risk warnings
|
||||
if acc.MarginUsedPct > 70 {
|
||||
sb.WriteString("⚠️ **风险警告**: 保证金使用率 > 70%,处于高风险状态!\n\n")
|
||||
} else if acc.MarginUsedPct > 50 {
|
||||
@@ -130,25 +131,25 @@ func formatAccountZH(ctx *Context) string {
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// formatTradingStatsZH 格式化历史交易统计(中文)
|
||||
// formatTradingStatsZH formats historical trading statistics (Chinese)
|
||||
func formatTradingStatsZH(stats *TradingStats) string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString("## 历史交易统计\n\n")
|
||||
|
||||
// 盈亏比计算
|
||||
// Win/loss ratio calculation
|
||||
var winLossRatio float64
|
||||
if stats.AvgLoss > 0 {
|
||||
winLossRatio = stats.AvgWin / stats.AvgLoss
|
||||
}
|
||||
|
||||
// 指标定义说明(去掉胜率,聚焦核心指标)
|
||||
// Metric definitions (focusing on core metrics, excluding win rate)
|
||||
sb.WriteString("**指标说明**:\n")
|
||||
sb.WriteString("- 盈利因子: 总盈利 ÷ 总亏损(>1表示盈利,>1.5为良好,>2为优秀)\n")
|
||||
sb.WriteString("- 夏普比率: (平均收益 - 无风险收益) ÷ 收益标准差(>1良好,>2优秀)\n")
|
||||
sb.WriteString("- 盈亏比: 平均盈利 ÷ 平均亏损(>1.5为良好,>2为优秀)\n")
|
||||
sb.WriteString("- 最大回撤: 资金曲线从峰值到谷底的最大跌幅(<20%为低风险)\n\n")
|
||||
|
||||
// 数据值
|
||||
// Data values
|
||||
sb.WriteString("**当前数据**:\n")
|
||||
sb.WriteString(fmt.Sprintf("- 总交易: %d 笔\n", stats.TotalTrades))
|
||||
sb.WriteString(fmt.Sprintf("- 盈利因子: %.2f\n", stats.ProfitFactor))
|
||||
@@ -159,10 +160,10 @@ func formatTradingStatsZH(stats *TradingStats) string {
|
||||
sb.WriteString(fmt.Sprintf("- 平均亏损: -%.2f USDT\n", stats.AvgLoss))
|
||||
sb.WriteString(fmt.Sprintf("- 最大回撤: %.1f%%\n\n", stats.MaxDrawdownPct))
|
||||
|
||||
// 综合分析和决策建议
|
||||
// Comprehensive analysis and decision guidance
|
||||
sb.WriteString("**决策参考**:\n")
|
||||
|
||||
// 根据统计数据给出具体建议
|
||||
// Provide specific recommendations based on statistics
|
||||
if stats.TotalTrades < 10 {
|
||||
sb.WriteString("- 样本量较小(<10笔),统计结果参考意义有限\n")
|
||||
}
|
||||
@@ -191,13 +192,13 @@ func formatTradingStatsZH(stats *TradingStats) string {
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// formatRecentTradesZH 格式化最近交易(中文)
|
||||
// formatRecentTradesZH formats recent trades (Chinese)
|
||||
func formatRecentTradesZH(orders []RecentOrder) string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString("## 最近完成的交易\n\n")
|
||||
|
||||
for i, order := range orders {
|
||||
// 判断盈亏
|
||||
// Determine profit or loss
|
||||
profitOrLoss := "盈利"
|
||||
if order.RealizedPnL < 0 {
|
||||
profitOrLoss = "亏损"
|
||||
@@ -222,13 +223,13 @@ func formatRecentTradesZH(orders []RecentOrder) string {
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// formatCurrentPositionsZH 格式化当前持仓(中文)
|
||||
// formatCurrentPositionsZH formats current positions (Chinese)
|
||||
func formatCurrentPositionsZH(ctx *Context) string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString("## 当前持仓\n\n")
|
||||
|
||||
for i, pos := range ctx.Positions {
|
||||
// 计算回撤
|
||||
// Calculate drawdown
|
||||
drawdown := pos.UnrealizedPnLPct - pos.PeakPnLPct
|
||||
|
||||
sb.WriteString(fmt.Sprintf("%d. %s %s | ", i+1, pos.Symbol, strings.ToUpper(pos.Side)))
|
||||
@@ -242,7 +243,7 @@ func formatCurrentPositionsZH(ctx *Context) string {
|
||||
sb.WriteString(fmt.Sprintf("保证金 %.0f USDT | ", pos.MarginUsed))
|
||||
sb.WriteString(fmt.Sprintf("强平价 %.4f\n", pos.LiquidationPrice))
|
||||
|
||||
// 添加分析提示
|
||||
// Add analysis hints
|
||||
if drawdown < -0.30*pos.PeakPnLPct && pos.PeakPnLPct > 0.02 {
|
||||
sb.WriteString(fmt.Sprintf(" ⚠️ **止盈提示**: 当前盈亏从峰值 %.2f%% 回撤到 %.2f%%,回撤幅度 %.2f%%,建议考虑止盈\n",
|
||||
pos.PeakPnLPct, pos.UnrealizedPnLPct, (drawdown/pos.PeakPnLPct)*100))
|
||||
@@ -252,7 +253,7 @@ func formatCurrentPositionsZH(ctx *Context) string {
|
||||
sb.WriteString(" ⚠️ **止损提示**: 亏损接近-5%止损线,建议考虑止损\n")
|
||||
}
|
||||
|
||||
// 显示当前价格(如果有市场数据)
|
||||
// Show current price (if market data available)
|
||||
if ctx.MarketDataMap != nil {
|
||||
if mdata, ok := ctx.MarketDataMap[pos.Symbol]; ok {
|
||||
sb.WriteString(fmt.Sprintf(" 📈 当前价格: %.4f\n", mdata.CurrentPrice))
|
||||
@@ -265,7 +266,7 @@ func formatCurrentPositionsZH(ctx *Context) string {
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// formatCandidateCoinsZH 格式化候选币种(中文)
|
||||
// formatCandidateCoinsZH formats candidate coins (Chinese)
|
||||
func formatCandidateCoinsZH(ctx *Context) string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString("## 候选币种\n\n")
|
||||
@@ -273,19 +274,19 @@ func formatCandidateCoinsZH(ctx *Context) string {
|
||||
for i, coin := range ctx.CandidateCoins {
|
||||
sb.WriteString(fmt.Sprintf("### %d. %s\n\n", i+1, coin.Symbol))
|
||||
|
||||
// 当前价格
|
||||
// Current price
|
||||
if ctx.MarketDataMap != nil {
|
||||
if mdata, ok := ctx.MarketDataMap[coin.Symbol]; ok {
|
||||
sb.WriteString(fmt.Sprintf("当前价格: %.4f\n\n", mdata.CurrentPrice))
|
||||
|
||||
// K线数据(多时间框架)
|
||||
// Kline data (multi-timeframe)
|
||||
if mdata.TimeframeData != nil {
|
||||
sb.WriteString(formatKlineDataZH(coin.Symbol, mdata.TimeframeData, ctx.Timeframes))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// OI数据(如果有)
|
||||
// OI data (if available)
|
||||
if ctx.OITopDataMap != nil {
|
||||
if oiData, ok := ctx.OITopDataMap[coin.Symbol]; ok {
|
||||
sb.WriteString(fmt.Sprintf("**持仓量变化**: OI排名 #%d | 变化 %+.2f%% (%+.2fM USDT) | 价格变化 %+.2f%%\n\n",
|
||||
@@ -295,7 +296,7 @@ func formatCandidateCoinsZH(ctx *Context) string {
|
||||
oiData.PriceDeltaPercent,
|
||||
))
|
||||
|
||||
// OI解读
|
||||
// OI interpretation
|
||||
oiChange := "增加"
|
||||
if oiData.OIDeltaPercent < 0 {
|
||||
oiChange = "减少"
|
||||
@@ -314,7 +315,7 @@ func formatCandidateCoinsZH(ctx *Context) string {
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// formatKlineDataZH 格式化K线数据(中文)
|
||||
// formatKlineDataZH formats kline data (Chinese)
|
||||
func formatKlineDataZH(symbol string, tfData map[string]*market.TimeframeSeriesData, timeframes []string) string {
|
||||
var sb strings.Builder
|
||||
|
||||
@@ -324,7 +325,7 @@ func formatKlineDataZH(symbol string, tfData map[string]*market.TimeframeSeriesD
|
||||
sb.WriteString("```\n")
|
||||
sb.WriteString("时间(UTC) 开盘 最高 最低 收盘 成交量\n")
|
||||
|
||||
// 只显示最近30根K线
|
||||
// Only show the latest 30 klines
|
||||
startIdx := 0
|
||||
if len(data.Klines) > 30 {
|
||||
startIdx = len(data.Klines) - 30
|
||||
@@ -343,7 +344,7 @@ func formatKlineDataZH(symbol string, tfData map[string]*market.TimeframeSeriesD
|
||||
))
|
||||
}
|
||||
|
||||
// 标记最后一根K线
|
||||
// Mark the last kline
|
||||
if len(data.Klines) > 0 {
|
||||
sb.WriteString(" <- 当前\n")
|
||||
}
|
||||
@@ -356,7 +357,7 @@ func formatKlineDataZH(symbol string, tfData map[string]*market.TimeframeSeriesD
|
||||
}
|
||||
|
||||
|
||||
// getOIInterpretationZH 获取OI变化解读(中文)
|
||||
// getOIInterpretationZH returns OI change interpretation (Chinese)
|
||||
func getOIInterpretationZH(oiChange, priceChange string) string {
|
||||
if oiChange == "增加" && priceChange == "上涨" {
|
||||
return OIInterpretation.OIUp_PriceUp.ZH
|
||||
@@ -369,15 +370,15 @@ func getOIInterpretationZH(oiChange, priceChange string) string {
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 英文格式化函数 ==========
|
||||
// ========== English Formatting Functions ==========
|
||||
|
||||
// formatHeaderEN 格式化头部信息(英文)
|
||||
// formatHeaderEN formats header information (English)
|
||||
func formatHeaderEN(ctx *Context) string {
|
||||
return fmt.Sprintf("# 📊 Trading Decision Request\n\nTime: %s | Period: #%d | Runtime: %d minutes\n\n",
|
||||
ctx.CurrentTime, ctx.CallCount, ctx.RuntimeMinutes)
|
||||
}
|
||||
|
||||
// formatAccountEN 格式化账户信息(英文)
|
||||
// formatAccountEN formats account information (English)
|
||||
func formatAccountEN(ctx *Context) string {
|
||||
acc := ctx.Account
|
||||
var sb strings.Builder
|
||||
@@ -399,7 +400,7 @@ func formatAccountEN(ctx *Context) string {
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// formatTradingStatsEN 格式化历史交易统计(英文)
|
||||
// formatTradingStatsEN formats historical trading statistics (English)
|
||||
func formatTradingStatsEN(stats *TradingStats) string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString("## Historical Trading Statistics\n\n")
|
||||
@@ -460,7 +461,7 @@ func formatTradingStatsEN(stats *TradingStats) string {
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// formatRecentTradesEN 格式化最近交易(英文)
|
||||
// formatRecentTradesEN formats recent trades (English)
|
||||
func formatRecentTradesEN(orders []RecentOrder) string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString("## Recent Completed Trades\n\n")
|
||||
@@ -490,7 +491,7 @@ func formatRecentTradesEN(orders []RecentOrder) string {
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// formatCurrentPositionsEN 格式化当前持仓(英文)
|
||||
// formatCurrentPositionsEN formats current positions (English)
|
||||
func formatCurrentPositionsEN(ctx *Context) string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString("## Current Positions\n\n")
|
||||
@@ -531,7 +532,7 @@ func formatCurrentPositionsEN(ctx *Context) string {
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// formatCandidateCoinsEN 格式化候选币种(英文)
|
||||
// formatCandidateCoinsEN formats candidate coins (English)
|
||||
func formatCandidateCoinsEN(ctx *Context) string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString("## Candidate Coins\n\n")
|
||||
@@ -576,7 +577,7 @@ func formatCandidateCoinsEN(ctx *Context) string {
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// formatKlineDataEN 格式化K线数据(英文)
|
||||
// formatKlineDataEN formats kline data (English)
|
||||
func formatKlineDataEN(symbol string, tfData map[string]*market.TimeframeSeriesData, timeframes []string) string {
|
||||
var sb strings.Builder
|
||||
|
||||
@@ -621,7 +622,7 @@ func formatKlineDataEN(symbol string, tfData map[string]*market.TimeframeSeriesD
|
||||
}
|
||||
|
||||
|
||||
// getOIInterpretationEN 获取OI变化解读(英文)
|
||||
// getOIInterpretationEN returns OI change interpretation (English)
|
||||
func getOIInterpretationEN(oiChange, priceChange string) string {
|
||||
if oiChange == "increase" && priceChange == "up" {
|
||||
return OIInterpretation.OIUp_PriceUp.EN
|
||||
|
||||
618
kernel/grid_engine.go
Normal file
618
kernel/grid_engine.go
Normal file
@@ -0,0 +1,618 @@
|
||||
package kernel
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"nofx/logger"
|
||||
"nofx/market"
|
||||
"nofx/mcp"
|
||||
"nofx/store"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
// Grid Trading Context and Types
|
||||
// ============================================================================
|
||||
|
||||
// GridLevelInfo represents a single grid level's current state
|
||||
type GridLevelInfo struct {
|
||||
Index int `json:"index"` // Level index (0 = lowest)
|
||||
Price float64 `json:"price"` // Target price for this level
|
||||
State string `json:"state"` // "empty", "pending", "filled"
|
||||
Side string `json:"side"` // "buy" or "sell"
|
||||
OrderID string `json:"order_id"` // Current order ID (if pending)
|
||||
OrderQuantity float64 `json:"order_quantity"` // Order quantity
|
||||
PositionSize float64 `json:"position_size"` // Position size (if filled)
|
||||
PositionEntry float64 `json:"position_entry"` // Entry price (if filled)
|
||||
AllocatedUSD float64 `json:"allocated_usd"` // USD allocated to this level
|
||||
UnrealizedPnL float64 `json:"unrealized_pnl"` // Unrealized P&L (if filled)
|
||||
}
|
||||
|
||||
// GridContext contains all information needed for AI grid decision making
|
||||
type GridContext struct {
|
||||
// Basic info
|
||||
Symbol string `json:"symbol"`
|
||||
CurrentTime string `json:"current_time"`
|
||||
CurrentPrice float64 `json:"current_price"`
|
||||
|
||||
// Grid configuration
|
||||
GridCount int `json:"grid_count"`
|
||||
TotalInvestment float64 `json:"total_investment"`
|
||||
Leverage int `json:"leverage"`
|
||||
UpperPrice float64 `json:"upper_price"`
|
||||
LowerPrice float64 `json:"lower_price"`
|
||||
GridSpacing float64 `json:"grid_spacing"`
|
||||
Distribution string `json:"distribution"`
|
||||
|
||||
// Grid state
|
||||
Levels []GridLevelInfo `json:"levels"`
|
||||
ActiveOrderCount int `json:"active_order_count"`
|
||||
FilledLevelCount int `json:"filled_level_count"`
|
||||
IsPaused bool `json:"is_paused"`
|
||||
|
||||
// Market data
|
||||
ATR14 float64 `json:"atr14"`
|
||||
BollingerUpper float64 `json:"bollinger_upper"`
|
||||
BollingerMiddle float64 `json:"bollinger_middle"`
|
||||
BollingerLower float64 `json:"bollinger_lower"`
|
||||
BollingerWidth float64 `json:"bollinger_width"` // Percentage
|
||||
EMA20 float64 `json:"ema20"`
|
||||
EMA50 float64 `json:"ema50"`
|
||||
EMADistance float64 `json:"ema_distance"` // Percentage
|
||||
RSI14 float64 `json:"rsi14"`
|
||||
MACD float64 `json:"macd"`
|
||||
MACDSignal float64 `json:"macd_signal"`
|
||||
MACDHistogram float64 `json:"macd_histogram"`
|
||||
FundingRate float64 `json:"funding_rate"`
|
||||
Volume24h float64 `json:"volume_24h"`
|
||||
PriceChange1h float64 `json:"price_change_1h"`
|
||||
PriceChange4h float64 `json:"price_change_4h"`
|
||||
|
||||
// Account info
|
||||
TotalEquity float64 `json:"total_equity"`
|
||||
AvailableBalance float64 `json:"available_balance"`
|
||||
CurrentPosition float64 `json:"current_position"` // Net position size
|
||||
UnrealizedPnL float64 `json:"unrealized_pnl"`
|
||||
|
||||
// Performance
|
||||
TotalProfit float64 `json:"total_profit"`
|
||||
TotalTrades int `json:"total_trades"`
|
||||
WinningTrades int `json:"winning_trades"`
|
||||
MaxDrawdown float64 `json:"max_drawdown"`
|
||||
DailyPnL float64 `json:"daily_pnl"`
|
||||
|
||||
// Box indicators (Donchian Channels)
|
||||
BoxData *market.BoxData `json:"box_data,omitempty"`
|
||||
|
||||
// Grid direction (neutral, long, short, long_bias, short_bias)
|
||||
CurrentDirection string `json:"current_direction,omitempty"`
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Grid Prompt Building
|
||||
// ============================================================================
|
||||
|
||||
// BuildGridSystemPrompt builds the system prompt for grid trading AI
|
||||
func BuildGridSystemPrompt(config *store.GridStrategyConfig, lang string) string {
|
||||
if lang == "zh" {
|
||||
return buildGridSystemPromptZh(config)
|
||||
}
|
||||
return buildGridSystemPromptEn(config)
|
||||
}
|
||||
|
||||
func buildGridSystemPromptZh(config *store.GridStrategyConfig) string {
|
||||
return fmt.Sprintf(`# 你是一个专业的网格交易AI
|
||||
|
||||
## 角色定义
|
||||
你是一个经验丰富的网格交易专家,负责管理 %s 的网格交易策略。你的任务是:
|
||||
1. 判断当前市场状态(震荡/趋势/高波动)
|
||||
2. 决定是否需要调整网格或暂停交易
|
||||
3. 管理每个网格层级的订单
|
||||
|
||||
## 网格配置
|
||||
- 交易对: %s
|
||||
- 网格层数: %d
|
||||
- 总投资: %.2f USDT
|
||||
- 杠杆: %dx
|
||||
- 价格分布: %s
|
||||
|
||||
## 决策规则
|
||||
|
||||
### 市场状态判断
|
||||
- **震荡市场** (适合网格): 布林带宽度 < 3%%, EMA20/50 距离 < 1%%, 价格在布林带中轨附近
|
||||
- **趋势市场** (暂停网格): 布林带宽度 > 4%%, EMA20/50 距离 > 2%%, 价格持续突破布林带
|
||||
- **高波动市场** (谨慎): ATR异常放大, 价格剧烈波动
|
||||
|
||||
### 可执行的操作
|
||||
- place_buy_limit: 在指定价格下买入限价单
|
||||
- place_sell_limit: 在指定价格下卖出限价单
|
||||
- cancel_order: 取消指定订单
|
||||
- cancel_all_orders: 取消所有订单
|
||||
- pause_grid: 暂停网格交易(趋势市场时)
|
||||
- resume_grid: 恢复网格交易(震荡市场时)
|
||||
- adjust_grid: 调整网格边界
|
||||
- hold: 保持当前状态不操作
|
||||
|
||||
## 输出格式
|
||||
输出JSON数组,每个决策包含:
|
||||
- symbol: 交易对
|
||||
- action: 操作类型
|
||||
- price: 价格(限价单用)
|
||||
- quantity: 数量
|
||||
- level_index: 网格层级索引
|
||||
- order_id: 订单ID(取消订单用)
|
||||
- confidence: 置信度 0-100
|
||||
- reasoning: 决策理由
|
||||
|
||||
示例:
|
||||
[
|
||||
{"symbol": "BTCUSDT", "action": "place_buy_limit", "price": 94000, "quantity": 0.01, "level_index": 2, "confidence": 85, "reasoning": "第2层价格接近,下买单"},
|
||||
{"symbol": "BTCUSDT", "action": "hold", "confidence": 90, "reasoning": "市场震荡,保持当前网格"}
|
||||
]
|
||||
`, config.Symbol, config.Symbol, config.GridCount, config.TotalInvestment, config.Leverage, config.Distribution)
|
||||
}
|
||||
|
||||
func buildGridSystemPromptEn(config *store.GridStrategyConfig) string {
|
||||
return fmt.Sprintf(`# You are a Professional Grid Trading AI
|
||||
|
||||
## Role Definition
|
||||
You are an experienced grid trading expert managing a grid strategy for %s. Your tasks are:
|
||||
1. Assess current market regime (ranging/trending/volatile)
|
||||
2. Decide whether to adjust grid or pause trading
|
||||
3. Manage orders at each grid level
|
||||
|
||||
## Grid Configuration
|
||||
- Symbol: %s
|
||||
- Grid Levels: %d
|
||||
- Total Investment: %.2f USDT
|
||||
- Leverage: %dx
|
||||
- Distribution: %s
|
||||
|
||||
## Decision Rules
|
||||
|
||||
### Market Regime Assessment
|
||||
- **Ranging Market** (ideal for grid): Bollinger width < 3%%, EMA20/50 distance < 1%%, price near middle band
|
||||
- **Trending Market** (pause grid): Bollinger width > 4%%, EMA20/50 distance > 2%%, price breaking bands
|
||||
- **High Volatility** (caution): ATR spike, erratic price movement
|
||||
|
||||
### Available Actions
|
||||
- place_buy_limit: Place buy limit order at specified price
|
||||
- place_sell_limit: Place sell limit order at specified price
|
||||
- cancel_order: Cancel specific order
|
||||
- cancel_all_orders: Cancel all orders
|
||||
- pause_grid: Pause grid trading (in trending market)
|
||||
- resume_grid: Resume grid trading (in ranging market)
|
||||
- adjust_grid: Adjust grid boundaries
|
||||
- hold: Maintain current state
|
||||
|
||||
## Output Format
|
||||
Output JSON array, each decision contains:
|
||||
- symbol: Trading pair
|
||||
- action: Action type
|
||||
- price: Price (for limit orders)
|
||||
- quantity: Quantity
|
||||
- level_index: Grid level index
|
||||
- order_id: Order ID (for cancel)
|
||||
- confidence: Confidence 0-100
|
||||
- reasoning: Decision reason
|
||||
|
||||
Example:
|
||||
[
|
||||
{"symbol": "BTCUSDT", "action": "place_buy_limit", "price": 94000, "quantity": 0.01, "level_index": 2, "confidence": 85, "reasoning": "Level 2 price approaching, place buy order"},
|
||||
{"symbol": "BTCUSDT", "action": "hold", "confidence": 90, "reasoning": "Market ranging, maintain current grid"}
|
||||
]
|
||||
`, config.Symbol, config.Symbol, config.GridCount, config.TotalInvestment, config.Leverage, config.Distribution)
|
||||
}
|
||||
|
||||
// BuildGridUserPrompt builds the user prompt with current grid context
|
||||
func BuildGridUserPrompt(ctx *GridContext, lang string) string {
|
||||
if lang == "zh" {
|
||||
return buildGridUserPromptZh(ctx)
|
||||
}
|
||||
return buildGridUserPromptEn(ctx)
|
||||
}
|
||||
|
||||
func buildGridUserPromptZh(ctx *GridContext) string {
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString(fmt.Sprintf("## 当前时间: %s\n\n", ctx.CurrentTime))
|
||||
|
||||
// Market data section
|
||||
sb.WriteString("## 市场数据\n")
|
||||
sb.WriteString(fmt.Sprintf("- 当前价格: $%.2f\n", ctx.CurrentPrice))
|
||||
sb.WriteString(fmt.Sprintf("- 1小时涨跌: %.2f%%\n", ctx.PriceChange1h))
|
||||
sb.WriteString(fmt.Sprintf("- 4小时涨跌: %.2f%%\n", ctx.PriceChange4h))
|
||||
sb.WriteString(fmt.Sprintf("- ATR14: $%.2f (%.2f%%)\n", ctx.ATR14, ctx.ATR14/ctx.CurrentPrice*100))
|
||||
sb.WriteString(fmt.Sprintf("- 布林带: 上轨 $%.2f, 中轨 $%.2f, 下轨 $%.2f\n", ctx.BollingerUpper, ctx.BollingerMiddle, ctx.BollingerLower))
|
||||
sb.WriteString(fmt.Sprintf("- 布林带宽度: %.2f%%\n", ctx.BollingerWidth))
|
||||
sb.WriteString(fmt.Sprintf("- EMA20: $%.2f, EMA50: $%.2f, 距离: %.2f%%\n", ctx.EMA20, ctx.EMA50, ctx.EMADistance))
|
||||
sb.WriteString(fmt.Sprintf("- RSI14: %.1f\n", ctx.RSI14))
|
||||
sb.WriteString(fmt.Sprintf("- MACD: %.4f, Signal: %.4f, Histogram: %.4f\n", ctx.MACD, ctx.MACDSignal, ctx.MACDHistogram))
|
||||
sb.WriteString(fmt.Sprintf("- 资金费率: %.4f%%\n", ctx.FundingRate*100))
|
||||
sb.WriteString("\n")
|
||||
|
||||
// Box Indicator Section
|
||||
if ctx.BoxData != nil {
|
||||
sb.WriteString("## 箱体指标 (唐奇安通道)\n\n")
|
||||
sb.WriteString("| 箱体级别 | 上轨 | 下轨 | 宽度 |\n")
|
||||
sb.WriteString("|----------|------|------|------|\n")
|
||||
|
||||
shortWidth := 0.0
|
||||
midWidth := 0.0
|
||||
longWidth := 0.0
|
||||
|
||||
if ctx.BoxData.CurrentPrice > 0 {
|
||||
shortWidth = (ctx.BoxData.ShortUpper - ctx.BoxData.ShortLower) / ctx.BoxData.CurrentPrice * 100
|
||||
midWidth = (ctx.BoxData.MidUpper - ctx.BoxData.MidLower) / ctx.BoxData.CurrentPrice * 100
|
||||
longWidth = (ctx.BoxData.LongUpper - ctx.BoxData.LongLower) / ctx.BoxData.CurrentPrice * 100
|
||||
}
|
||||
|
||||
sb.WriteString(fmt.Sprintf("| 短期 (3天) | %.2f | %.2f | %.2f%% |\n",
|
||||
ctx.BoxData.ShortUpper, ctx.BoxData.ShortLower, shortWidth))
|
||||
sb.WriteString(fmt.Sprintf("| 中期 (10天) | %.2f | %.2f | %.2f%% |\n",
|
||||
ctx.BoxData.MidUpper, ctx.BoxData.MidLower, midWidth))
|
||||
sb.WriteString(fmt.Sprintf("| 长期 (21天) | %.2f | %.2f | %.2f%% |\n",
|
||||
ctx.BoxData.LongUpper, ctx.BoxData.LongLower, longWidth))
|
||||
|
||||
sb.WriteString(fmt.Sprintf("\n当前价格: %.2f\n", ctx.BoxData.CurrentPrice))
|
||||
|
||||
// Check position relative to boxes
|
||||
price := ctx.BoxData.CurrentPrice
|
||||
if price > ctx.BoxData.LongUpper || price < ctx.BoxData.LongLower {
|
||||
sb.WriteString("⚠️ 突破: 价格突破长期箱体!\n")
|
||||
} else if price > ctx.BoxData.MidUpper || price < ctx.BoxData.MidLower {
|
||||
sb.WriteString("⚠️ 警告: 价格接近长期箱体边界\n")
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
// Account section
|
||||
sb.WriteString("## 账户状态\n")
|
||||
sb.WriteString(fmt.Sprintf("- 总权益: $%.2f\n", ctx.TotalEquity))
|
||||
sb.WriteString(fmt.Sprintf("- 可用余额: $%.2f\n", ctx.AvailableBalance))
|
||||
sb.WriteString(fmt.Sprintf("- 当前持仓: %.4f (净头寸)\n", ctx.CurrentPosition))
|
||||
sb.WriteString(fmt.Sprintf("- 未实现盈亏: $%.2f\n", ctx.UnrealizedPnL))
|
||||
sb.WriteString("\n")
|
||||
|
||||
// Grid state section
|
||||
sb.WriteString("## 网格状态\n")
|
||||
sb.WriteString(fmt.Sprintf("- 网格范围: $%.2f - $%.2f\n", ctx.LowerPrice, ctx.UpperPrice))
|
||||
sb.WriteString(fmt.Sprintf("- 网格间距: $%.2f\n", ctx.GridSpacing))
|
||||
sb.WriteString(fmt.Sprintf("- 活跃订单数: %d\n", ctx.ActiveOrderCount))
|
||||
sb.WriteString(fmt.Sprintf("- 已成交层数: %d\n", ctx.FilledLevelCount))
|
||||
sb.WriteString(fmt.Sprintf("- 网格已暂停: %v\n", ctx.IsPaused))
|
||||
if ctx.CurrentDirection != "" {
|
||||
directionDescZh := map[string]string{
|
||||
"neutral": "中性 (50%买+50%卖)",
|
||||
"long": "做多 (100%买)",
|
||||
"short": "做空 (100%卖)",
|
||||
"long_bias": "偏多 (70%买+30%卖)",
|
||||
"short_bias": "偏空 (30%买+70%卖)",
|
||||
}
|
||||
desc := directionDescZh[ctx.CurrentDirection]
|
||||
if desc == "" {
|
||||
desc = ctx.CurrentDirection
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("- 网格方向: %s\n", desc))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
|
||||
// Grid levels detail
|
||||
sb.WriteString("## 网格层级详情\n")
|
||||
sb.WriteString("| 层级 | 价格 | 状态 | 方向 | 订单数量 | 持仓数量 | 未实现盈亏 |\n")
|
||||
sb.WriteString("|------|------|------|------|----------|----------|------------|\n")
|
||||
for _, level := range ctx.Levels {
|
||||
sb.WriteString(fmt.Sprintf("| %d | $%.2f | %s | %s | %.4f | %.4f | $%.2f |\n",
|
||||
level.Index, level.Price, level.State, level.Side,
|
||||
level.OrderQuantity, level.PositionSize, level.UnrealizedPnL))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
|
||||
// Performance section
|
||||
sb.WriteString("## 绩效统计\n")
|
||||
sb.WriteString(fmt.Sprintf("- 总利润: $%.2f\n", ctx.TotalProfit))
|
||||
sb.WriteString(fmt.Sprintf("- 总交易次数: %d\n", ctx.TotalTrades))
|
||||
sb.WriteString(fmt.Sprintf("- 胜率: %.1f%%\n", float64(ctx.WinningTrades)/float64(max(ctx.TotalTrades, 1))*100))
|
||||
sb.WriteString(fmt.Sprintf("- 最大回撤: %.2f%%\n", ctx.MaxDrawdown))
|
||||
sb.WriteString(fmt.Sprintf("- 今日盈亏: $%.2f\n", ctx.DailyPnL))
|
||||
sb.WriteString("\n")
|
||||
|
||||
sb.WriteString("## 请分析以上数据,做出网格交易决策\n")
|
||||
sb.WriteString("输出JSON数组格式的决策列表。\n")
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func buildGridUserPromptEn(ctx *GridContext) string {
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString(fmt.Sprintf("## Current Time: %s\n\n", ctx.CurrentTime))
|
||||
|
||||
// Market data section
|
||||
sb.WriteString("## Market Data\n")
|
||||
sb.WriteString(fmt.Sprintf("- Current Price: $%.2f\n", ctx.CurrentPrice))
|
||||
sb.WriteString(fmt.Sprintf("- 1h Change: %.2f%%\n", ctx.PriceChange1h))
|
||||
sb.WriteString(fmt.Sprintf("- 4h Change: %.2f%%\n", ctx.PriceChange4h))
|
||||
sb.WriteString(fmt.Sprintf("- ATR14: $%.2f (%.2f%%)\n", ctx.ATR14, ctx.ATR14/ctx.CurrentPrice*100))
|
||||
sb.WriteString(fmt.Sprintf("- Bollinger Bands: Upper $%.2f, Middle $%.2f, Lower $%.2f\n", ctx.BollingerUpper, ctx.BollingerMiddle, ctx.BollingerLower))
|
||||
sb.WriteString(fmt.Sprintf("- Bollinger Width: %.2f%%\n", ctx.BollingerWidth))
|
||||
sb.WriteString(fmt.Sprintf("- EMA20: $%.2f, EMA50: $%.2f, Distance: %.2f%%\n", ctx.EMA20, ctx.EMA50, ctx.EMADistance))
|
||||
sb.WriteString(fmt.Sprintf("- RSI14: %.1f\n", ctx.RSI14))
|
||||
sb.WriteString(fmt.Sprintf("- MACD: %.4f, Signal: %.4f, Histogram: %.4f\n", ctx.MACD, ctx.MACDSignal, ctx.MACDHistogram))
|
||||
sb.WriteString(fmt.Sprintf("- Funding Rate: %.4f%%\n", ctx.FundingRate*100))
|
||||
sb.WriteString("\n")
|
||||
|
||||
// Box Indicator Section
|
||||
if ctx.BoxData != nil {
|
||||
sb.WriteString("## Box Indicators (Donchian Channels)\n\n")
|
||||
sb.WriteString("| Box Level | Upper | Lower | Width |\n")
|
||||
sb.WriteString("|-----------|-------|-------|-------|\n")
|
||||
|
||||
shortWidth := 0.0
|
||||
midWidth := 0.0
|
||||
longWidth := 0.0
|
||||
|
||||
if ctx.BoxData.CurrentPrice > 0 {
|
||||
shortWidth = (ctx.BoxData.ShortUpper - ctx.BoxData.ShortLower) / ctx.BoxData.CurrentPrice * 100
|
||||
midWidth = (ctx.BoxData.MidUpper - ctx.BoxData.MidLower) / ctx.BoxData.CurrentPrice * 100
|
||||
longWidth = (ctx.BoxData.LongUpper - ctx.BoxData.LongLower) / ctx.BoxData.CurrentPrice * 100
|
||||
}
|
||||
|
||||
sb.WriteString(fmt.Sprintf("| Short (3d) | %.2f | %.2f | %.2f%% |\n",
|
||||
ctx.BoxData.ShortUpper, ctx.BoxData.ShortLower, shortWidth))
|
||||
sb.WriteString(fmt.Sprintf("| Mid (10d) | %.2f | %.2f | %.2f%% |\n",
|
||||
ctx.BoxData.MidUpper, ctx.BoxData.MidLower, midWidth))
|
||||
sb.WriteString(fmt.Sprintf("| Long (21d) | %.2f | %.2f | %.2f%% |\n",
|
||||
ctx.BoxData.LongUpper, ctx.BoxData.LongLower, longWidth))
|
||||
|
||||
sb.WriteString(fmt.Sprintf("\nCurrent Price: %.2f\n", ctx.BoxData.CurrentPrice))
|
||||
|
||||
// Check position relative to boxes
|
||||
price := ctx.BoxData.CurrentPrice
|
||||
if price > ctx.BoxData.LongUpper || price < ctx.BoxData.LongLower {
|
||||
sb.WriteString("⚠️ BREAKOUT: Price outside long-term box!\n")
|
||||
} else if price > ctx.BoxData.MidUpper || price < ctx.BoxData.MidLower {
|
||||
sb.WriteString("⚠️ WARNING: Price approaching long-term box boundary\n")
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
// Account section
|
||||
sb.WriteString("## Account Status\n")
|
||||
sb.WriteString(fmt.Sprintf("- Total Equity: $%.2f\n", ctx.TotalEquity))
|
||||
sb.WriteString(fmt.Sprintf("- Available Balance: $%.2f\n", ctx.AvailableBalance))
|
||||
sb.WriteString(fmt.Sprintf("- Current Position: %.4f (net)\n", ctx.CurrentPosition))
|
||||
sb.WriteString(fmt.Sprintf("- Unrealized PnL: $%.2f\n", ctx.UnrealizedPnL))
|
||||
sb.WriteString("\n")
|
||||
|
||||
// Grid state section
|
||||
sb.WriteString("## Grid Status\n")
|
||||
sb.WriteString(fmt.Sprintf("- Grid Range: $%.2f - $%.2f\n", ctx.LowerPrice, ctx.UpperPrice))
|
||||
sb.WriteString(fmt.Sprintf("- Grid Spacing: $%.2f\n", ctx.GridSpacing))
|
||||
sb.WriteString(fmt.Sprintf("- Active Orders: %d\n", ctx.ActiveOrderCount))
|
||||
sb.WriteString(fmt.Sprintf("- Filled Levels: %d\n", ctx.FilledLevelCount))
|
||||
sb.WriteString(fmt.Sprintf("- Grid Paused: %v\n", ctx.IsPaused))
|
||||
if ctx.CurrentDirection != "" {
|
||||
directionDescEn := map[string]string{
|
||||
"neutral": "Neutral (50% buy + 50% sell)",
|
||||
"long": "Long (100% buy)",
|
||||
"short": "Short (100% sell)",
|
||||
"long_bias": "Long Bias (70% buy + 30% sell)",
|
||||
"short_bias": "Short Bias (30% buy + 70% sell)",
|
||||
}
|
||||
desc := directionDescEn[ctx.CurrentDirection]
|
||||
if desc == "" {
|
||||
desc = ctx.CurrentDirection
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("- Grid Direction: %s\n", desc))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
|
||||
// Grid levels detail
|
||||
sb.WriteString("## Grid Levels Detail\n")
|
||||
sb.WriteString("| Level | Price | State | Side | Order Qty | Position | Unrealized PnL |\n")
|
||||
sb.WriteString("|-------|-------|-------|------|-----------|----------|----------------|\n")
|
||||
for _, level := range ctx.Levels {
|
||||
sb.WriteString(fmt.Sprintf("| %d | $%.2f | %s | %s | %.4f | %.4f | $%.2f |\n",
|
||||
level.Index, level.Price, level.State, level.Side,
|
||||
level.OrderQuantity, level.PositionSize, level.UnrealizedPnL))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
|
||||
// Performance section
|
||||
sb.WriteString("## Performance Stats\n")
|
||||
sb.WriteString(fmt.Sprintf("- Total Profit: $%.2f\n", ctx.TotalProfit))
|
||||
sb.WriteString(fmt.Sprintf("- Total Trades: %d\n", ctx.TotalTrades))
|
||||
sb.WriteString(fmt.Sprintf("- Win Rate: %.1f%%\n", float64(ctx.WinningTrades)/float64(max(ctx.TotalTrades, 1))*100))
|
||||
sb.WriteString(fmt.Sprintf("- Max Drawdown: %.2f%%\n", ctx.MaxDrawdown))
|
||||
sb.WriteString(fmt.Sprintf("- Daily PnL: $%.2f\n", ctx.DailyPnL))
|
||||
sb.WriteString("\n")
|
||||
|
||||
sb.WriteString("## Please analyze the data above and make grid trading decisions\n")
|
||||
sb.WriteString("Output a JSON array of decisions.\n")
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Grid Decision Functions
|
||||
// ============================================================================
|
||||
|
||||
// GetGridDecisions gets AI decisions for grid trading
|
||||
func GetGridDecisions(ctx *GridContext, mcpClient mcp.AIClient, config *store.GridStrategyConfig, lang string) (*FullDecision, error) {
|
||||
startTime := time.Now()
|
||||
|
||||
// Build prompts
|
||||
systemPrompt := BuildGridSystemPrompt(config, lang)
|
||||
userPrompt := BuildGridUserPrompt(ctx, lang)
|
||||
|
||||
logger.Infof("🤖 [Grid] Calling AI for grid decisions...")
|
||||
|
||||
// Call AI
|
||||
response, err := mcpClient.CallWithMessages(systemPrompt, userPrompt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("AI call failed: %w", err)
|
||||
}
|
||||
|
||||
// Parse decisions from response
|
||||
decisions, err := parseGridDecisions(response, ctx.Symbol)
|
||||
if err != nil {
|
||||
logger.Warnf("Failed to parse grid decisions: %v", err)
|
||||
// Return hold decision as fallback
|
||||
decisions = []Decision{{
|
||||
Symbol: ctx.Symbol,
|
||||
Action: "hold",
|
||||
Confidence: 50,
|
||||
Reasoning: "Failed to parse AI response, holding current state",
|
||||
}}
|
||||
}
|
||||
|
||||
duration := time.Since(startTime).Milliseconds()
|
||||
logger.Infof("⏱️ [Grid] AI call duration: %d ms, decisions: %d", duration, len(decisions))
|
||||
|
||||
// Extract chain of thought from response
|
||||
cotTrace := extractCoTTrace(response)
|
||||
|
||||
return &FullDecision{
|
||||
SystemPrompt: systemPrompt,
|
||||
UserPrompt: userPrompt,
|
||||
CoTTrace: cotTrace,
|
||||
Decisions: decisions,
|
||||
RawResponse: response,
|
||||
AIRequestDurationMs: duration,
|
||||
Timestamp: time.Now(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// parseGridDecisions parses AI response into grid decisions
|
||||
func parseGridDecisions(response string, symbol string) ([]Decision, error) {
|
||||
// Try to find JSON array in response
|
||||
jsonStr := extractJSONArray(response)
|
||||
if jsonStr == "" {
|
||||
return nil, fmt.Errorf("no JSON array found in response")
|
||||
}
|
||||
|
||||
var decisions []Decision
|
||||
if err := json.Unmarshal([]byte(jsonStr), &decisions); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse JSON: %w", err)
|
||||
}
|
||||
|
||||
// Validate and set default symbol
|
||||
for i := range decisions {
|
||||
if decisions[i].Symbol == "" {
|
||||
decisions[i].Symbol = symbol
|
||||
}
|
||||
// Validate action
|
||||
if !isValidGridAction(decisions[i].Action) {
|
||||
logger.Warnf("Invalid grid action: %s", decisions[i].Action)
|
||||
}
|
||||
}
|
||||
|
||||
return decisions, nil
|
||||
}
|
||||
|
||||
// extractJSONArray extracts JSON array from AI response
|
||||
func extractJSONArray(response string) string {
|
||||
// Try to find ```json code block first
|
||||
matches := reJSONFence.FindStringSubmatch(response)
|
||||
if len(matches) > 1 {
|
||||
return matches[1]
|
||||
}
|
||||
|
||||
// Try to find raw JSON array
|
||||
matches = reJSONArray.FindStringSubmatch(response)
|
||||
if len(matches) > 0 {
|
||||
return matches[0]
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// isValidGridAction checks if action is a valid grid action
|
||||
func isValidGridAction(action string) bool {
|
||||
validActions := map[string]bool{
|
||||
"place_buy_limit": true,
|
||||
"place_sell_limit": true,
|
||||
"cancel_order": true,
|
||||
"cancel_all_orders": true,
|
||||
"pause_grid": true,
|
||||
"resume_grid": true,
|
||||
"adjust_grid": true,
|
||||
"hold": true,
|
||||
// Also support standard actions for compatibility
|
||||
"open_long": true,
|
||||
"open_short": true,
|
||||
"close_long": true,
|
||||
"close_short": true,
|
||||
}
|
||||
return validActions[action]
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Grid Context Builder Helpers
|
||||
// ============================================================================
|
||||
|
||||
// BuildGridContextFromMarketData builds grid context from market data
|
||||
func BuildGridContextFromMarketData(mktData *market.Data, config *store.GridStrategyConfig) *GridContext {
|
||||
ctx := &GridContext{
|
||||
Symbol: config.Symbol,
|
||||
CurrentTime: time.Now().Format("2006-01-02 15:04:05"),
|
||||
CurrentPrice: mktData.CurrentPrice,
|
||||
|
||||
// Grid config
|
||||
GridCount: config.GridCount,
|
||||
TotalInvestment: config.TotalInvestment,
|
||||
Leverage: config.Leverage,
|
||||
Distribution: config.Distribution,
|
||||
|
||||
// Market data
|
||||
PriceChange1h: mktData.PriceChange1h,
|
||||
PriceChange4h: mktData.PriceChange4h,
|
||||
FundingRate: mktData.FundingRate,
|
||||
}
|
||||
|
||||
// Extract indicators from timeframe data
|
||||
if mktData.TimeframeData != nil {
|
||||
if tf5m, ok := mktData.TimeframeData["5m"]; ok {
|
||||
if len(tf5m.BOLLUpper) > 0 {
|
||||
ctx.BollingerUpper = tf5m.BOLLUpper[len(tf5m.BOLLUpper)-1]
|
||||
ctx.BollingerMiddle = tf5m.BOLLMiddle[len(tf5m.BOLLMiddle)-1]
|
||||
ctx.BollingerLower = tf5m.BOLLLower[len(tf5m.BOLLLower)-1]
|
||||
if ctx.BollingerMiddle > 0 {
|
||||
ctx.BollingerWidth = (ctx.BollingerUpper - ctx.BollingerLower) / ctx.BollingerMiddle * 100
|
||||
}
|
||||
}
|
||||
ctx.ATR14 = tf5m.ATR14
|
||||
if len(tf5m.RSI14Values) > 0 {
|
||||
ctx.RSI14 = tf5m.RSI14Values[len(tf5m.RSI14Values)-1]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract longer term context
|
||||
if mktData.LongerTermContext != nil {
|
||||
if ctx.ATR14 == 0 {
|
||||
ctx.ATR14 = mktData.LongerTermContext.ATR14
|
||||
}
|
||||
ctx.EMA50 = mktData.LongerTermContext.EMA50
|
||||
}
|
||||
|
||||
ctx.EMA20 = mktData.CurrentEMA20
|
||||
ctx.MACD = mktData.CurrentMACD
|
||||
|
||||
// Calculate EMA distance
|
||||
if ctx.EMA50 > 0 {
|
||||
ctx.EMADistance = (ctx.EMA20 - ctx.EMA50) / ctx.EMA50 * 100
|
||||
}
|
||||
|
||||
return ctx
|
||||
}
|
||||
|
||||
// Helper function for max
|
||||
func max(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -6,22 +6,22 @@ import (
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
// AI Prompt Builder - AI提示词构建器
|
||||
// AI Prompt Builder
|
||||
// ============================================================================
|
||||
// 构建完整的AI提示词,包括系统提示词和用户提示词
|
||||
// Builds complete AI prompts including system prompts and user prompts.
|
||||
// ============================================================================
|
||||
|
||||
// PromptBuilder 提示词构建器
|
||||
// PromptBuilder builds AI prompts in the configured language
|
||||
type PromptBuilder struct {
|
||||
lang Language
|
||||
}
|
||||
|
||||
// NewPromptBuilder 创建提示词构建器
|
||||
// NewPromptBuilder creates a new prompt builder for the given language
|
||||
func NewPromptBuilder(lang Language) *PromptBuilder {
|
||||
return &PromptBuilder{lang: lang}
|
||||
}
|
||||
|
||||
// BuildSystemPrompt 构建系统提示词
|
||||
// BuildSystemPrompt builds the system prompt
|
||||
func (pb *PromptBuilder) BuildSystemPrompt() string {
|
||||
if pb.lang == LangChinese {
|
||||
return pb.buildSystemPromptZH()
|
||||
@@ -29,19 +29,19 @@ func (pb *PromptBuilder) BuildSystemPrompt() string {
|
||||
return pb.buildSystemPromptEN()
|
||||
}
|
||||
|
||||
// BuildUserPrompt 构建用户提示词(包含完整的交易上下文)
|
||||
// BuildUserPrompt builds the user prompt with full trading context
|
||||
func (pb *PromptBuilder) BuildUserPrompt(ctx *Context) string {
|
||||
// 使用Formatter格式化交易上下文
|
||||
// Use Formatter to format the trading context
|
||||
formattedData := FormatContextForAI(ctx, pb.lang)
|
||||
|
||||
// 添加决策要求
|
||||
// Append decision requirements
|
||||
if pb.lang == LangChinese {
|
||||
return formattedData + pb.getDecisionRequirementsZH()
|
||||
}
|
||||
return formattedData + pb.getDecisionRequirementsEN()
|
||||
}
|
||||
|
||||
// ========== 中文提示词 ==========
|
||||
// ========== Chinese Prompts ==========
|
||||
|
||||
func (pb *PromptBuilder) buildSystemPromptZH() string {
|
||||
return `你是一个专业的量化交易AI助手,负责分析市场数据并做出交易决策。
|
||||
@@ -176,7 +176,7 @@ func (pb *PromptBuilder) getDecisionRequirementsZH() string {
|
||||
**请立即输出你的决策(JSON格式)**:`
|
||||
}
|
||||
|
||||
// ========== 英文提示词 ==========
|
||||
// ========== English Prompts ==========
|
||||
|
||||
func (pb *PromptBuilder) buildSystemPromptEN() string {
|
||||
return `You are a professional quantitative trading AI assistant responsible for analyzing market data and making trading decisions.
|
||||
@@ -311,9 +311,9 @@ func (pb *PromptBuilder) getDecisionRequirementsEN() string {
|
||||
**Please output your decision (JSON format) immediately**:`
|
||||
}
|
||||
|
||||
// ========== 辅助函数 ==========
|
||||
// ========== Helper Functions ==========
|
||||
|
||||
// FormatDecisionExample 格式化决策示例(用于文档)
|
||||
// FormatDecisionExample formats a decision example (for documentation)
|
||||
func FormatDecisionExample(lang Language) string {
|
||||
example := Decision{
|
||||
Symbol: "BTCUSDT",
|
||||
@@ -323,32 +323,32 @@ func FormatDecisionExample(lang Language) string {
|
||||
StopLoss: 42000,
|
||||
TakeProfit: 48000,
|
||||
Confidence: 85,
|
||||
Reasoning: "详细的推理过程...",
|
||||
Reasoning: "Detailed reasoning process...",
|
||||
}
|
||||
|
||||
data, _ := json.MarshalIndent([]Decision{example}, "", " ")
|
||||
return string(data)
|
||||
}
|
||||
|
||||
// ValidateDecisionFormat 验证决策格式是否正确
|
||||
// ValidateDecisionFormat validates that the decision format is correct
|
||||
func ValidateDecisionFormat(decisions []Decision) error {
|
||||
if len(decisions) == 0 {
|
||||
return fmt.Errorf("决策列表不能为空")
|
||||
return fmt.Errorf("decision list cannot be empty")
|
||||
}
|
||||
|
||||
for i, d := range decisions {
|
||||
// 必需字段检查
|
||||
// Required field checks
|
||||
if d.Symbol == "" {
|
||||
return fmt.Errorf("决策#%d: symbol不能为空", i+1)
|
||||
return fmt.Errorf("decision #%d: symbol cannot be empty", i+1)
|
||||
}
|
||||
if d.Action == "" {
|
||||
return fmt.Errorf("决策#%d: action不能为空", i+1)
|
||||
return fmt.Errorf("decision #%d: action cannot be empty", i+1)
|
||||
}
|
||||
if d.Reasoning == "" {
|
||||
return fmt.Errorf("决策#%d: reasoning不能为空", i+1)
|
||||
return fmt.Errorf("decision #%d: reasoning cannot be empty", i+1)
|
||||
}
|
||||
|
||||
// 动作类型检查
|
||||
// Action type validation
|
||||
validActions := map[string]bool{
|
||||
"HOLD": true,
|
||||
"PARTIAL_CLOSE": true,
|
||||
@@ -358,16 +358,16 @@ func ValidateDecisionFormat(decisions []Decision) error {
|
||||
"WAIT": true,
|
||||
}
|
||||
if !validActions[d.Action] {
|
||||
return fmt.Errorf("决策#%d: 无效的action类型: %s", i+1, d.Action)
|
||||
return fmt.Errorf("decision #%d: invalid action type: %s", i+1, d.Action)
|
||||
}
|
||||
|
||||
// 开新仓位的必需参数检查
|
||||
// Required parameters for opening new positions
|
||||
if d.Action == "OPEN_NEW" {
|
||||
if d.Leverage == 0 {
|
||||
return fmt.Errorf("决策#%d: OPEN_NEW动作需要提供leverage", i+1)
|
||||
return fmt.Errorf("decision #%d: OPEN_NEW action requires leverage", i+1)
|
||||
}
|
||||
if d.PositionSizeUSD == 0 {
|
||||
return fmt.Errorf("决策#%d: OPEN_NEW动作需要提供position_size_usd", i+1)
|
||||
return fmt.Errorf("decision #%d: OPEN_NEW action requires position_size_usd", i+1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,8 +170,8 @@ func TestValidateDecisionFormat(t *testing.T) {
|
||||
t.Error("Empty decisions should return error")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "不能为空") {
|
||||
t.Errorf("Error message should mention '不能为空', got: %v", err)
|
||||
if !strings.Contains(err.Error(), "cannot be empty") {
|
||||
t.Errorf("Error message should mention 'cannot be empty', got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -238,8 +238,8 @@ func TestValidateDecisionFormat(t *testing.T) {
|
||||
t.Error("Invalid action should return error")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "无效的action") {
|
||||
t.Errorf("Error should mention '无效的action', got: %v", err)
|
||||
if !strings.Contains(err.Error(), "invalid action") {
|
||||
t.Errorf("Error should mention 'invalid action', got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
120
kernel/schema.go
120
kernel/schema.go
@@ -1,19 +1,17 @@
|
||||
package kernel
|
||||
|
||||
import "fmt"
|
||||
|
||||
// ============================================================================
|
||||
// Trading Data Schema - 交易数据字典
|
||||
// Trading Data Schema
|
||||
// ============================================================================
|
||||
// 双语数据字典,支持中文和英文
|
||||
// 确保AI能够100%理解数据格式,无论使用哪种语言
|
||||
// Bilingual data dictionary supporting Chinese and English.
|
||||
// Ensures AI can fully understand data formats regardless of language.
|
||||
// ============================================================================
|
||||
|
||||
const (
|
||||
SchemaVersion = "1.0.0"
|
||||
)
|
||||
|
||||
// Language 语言类型
|
||||
// Language represents the language type
|
||||
type Language string
|
||||
|
||||
const (
|
||||
@@ -21,20 +19,20 @@ const (
|
||||
LangEnglish Language = "en-US"
|
||||
)
|
||||
|
||||
// ========== 双语字段定义 ==========
|
||||
// ========== Bilingual Field Definitions ==========
|
||||
|
||||
// BilingualFieldDef 双语字段定义
|
||||
// BilingualFieldDef defines a field with bilingual name, formula, and description
|
||||
type BilingualFieldDef struct {
|
||||
NameZH string // 中文名称
|
||||
NameZH string // Chinese name
|
||||
NameEN string // English name
|
||||
Unit string // 单位
|
||||
FormulaZH string // 中文公式
|
||||
Unit string // unit of measurement
|
||||
FormulaZH string // Chinese formula
|
||||
FormulaEN string // English formula
|
||||
DescZH string // 中文描述
|
||||
DescZH string // Chinese description
|
||||
DescEN string // English description
|
||||
}
|
||||
|
||||
// GetName 获取字段名称(根据语言)
|
||||
// GetName returns the field name based on language
|
||||
func (d BilingualFieldDef) GetName(lang Language) string {
|
||||
if lang == LangChinese {
|
||||
return d.NameZH
|
||||
@@ -42,7 +40,7 @@ func (d BilingualFieldDef) GetName(lang Language) string {
|
||||
return d.NameEN
|
||||
}
|
||||
|
||||
// GetFormula 获取公式(根据语言)
|
||||
// GetFormula returns the formula based on language
|
||||
func (d BilingualFieldDef) GetFormula(lang Language) string {
|
||||
if lang == LangChinese {
|
||||
return d.FormulaZH
|
||||
@@ -50,7 +48,7 @@ func (d BilingualFieldDef) GetFormula(lang Language) string {
|
||||
return d.FormulaEN
|
||||
}
|
||||
|
||||
// GetDesc 获取描述(根据语言)
|
||||
// GetDesc returns the description based on language
|
||||
func (d BilingualFieldDef) GetDesc(lang Language) string {
|
||||
if lang == LangChinese {
|
||||
return d.DescZH
|
||||
@@ -58,9 +56,9 @@ func (d BilingualFieldDef) GetDesc(lang Language) string {
|
||||
return d.DescEN
|
||||
}
|
||||
|
||||
// ========== 数据字典 ==========
|
||||
// ========== Data Dictionary ==========
|
||||
|
||||
// DataDictionary 数据字典:定义所有字段的含义
|
||||
// DataDictionary defines the meaning of all fields
|
||||
var DataDictionary = map[string]map[string]BilingualFieldDef{
|
||||
"AccountMetrics": {
|
||||
"Equity": {
|
||||
@@ -219,18 +217,18 @@ var DataDictionary = map[string]map[string]BilingualFieldDef{
|
||||
},
|
||||
}
|
||||
|
||||
// ========== 双语规则定义 ==========
|
||||
// ========== Bilingual Rule Definitions ==========
|
||||
|
||||
// BilingualRuleDef 双语规则定义
|
||||
// BilingualRuleDef defines a trading rule with bilingual description and reason
|
||||
type BilingualRuleDef struct {
|
||||
Value interface{} // 规则值
|
||||
DescZH string // 中文描述
|
||||
Value interface{} // rule value
|
||||
DescZH string // Chinese description
|
||||
DescEN string // English description
|
||||
ReasonZH string // 中文原因
|
||||
ReasonZH string // Chinese reason
|
||||
ReasonEN string // English reason
|
||||
}
|
||||
|
||||
// GetDesc 获取描述(根据语言)
|
||||
// GetDesc returns the description based on language
|
||||
func (d BilingualRuleDef) GetDesc(lang Language) string {
|
||||
if lang == LangChinese {
|
||||
return d.DescZH
|
||||
@@ -238,7 +236,7 @@ func (d BilingualRuleDef) GetDesc(lang Language) string {
|
||||
return d.DescEN
|
||||
}
|
||||
|
||||
// GetReason 获取原因(根据语言)
|
||||
// GetReason returns the reason based on language
|
||||
func (d BilingualRuleDef) GetReason(lang Language) string {
|
||||
if lang == LangChinese {
|
||||
return d.ReasonZH
|
||||
@@ -246,9 +244,9 @@ func (d BilingualRuleDef) GetReason(lang Language) string {
|
||||
return d.ReasonEN
|
||||
}
|
||||
|
||||
// ========== 交易规则 ==========
|
||||
// ========== Trading Rules ==========
|
||||
|
||||
// TradingRules 交易规则定义
|
||||
// TradingRules defines the trading rules
|
||||
var TradingRules = struct {
|
||||
RiskManagement map[string]BilingualRuleDef
|
||||
EntrySignals map[string]BilingualRuleDef
|
||||
@@ -342,9 +340,9 @@ var TradingRules = struct {
|
||||
},
|
||||
}
|
||||
|
||||
// ========== OI解读 ==========
|
||||
// ========== OI Interpretation ==========
|
||||
|
||||
// OIInterpretation OI变化的市场解读(双语)
|
||||
// OIInterpretation defines bilingual market interpretations for OI changes
|
||||
type OIInterpretationType struct {
|
||||
OIUp_PriceUp struct {
|
||||
ZH string
|
||||
@@ -395,9 +393,9 @@ var OIInterpretation = OIInterpretationType{
|
||||
},
|
||||
}
|
||||
|
||||
// ========== 常见错误 ==========
|
||||
// ========== Common Mistakes ==========
|
||||
|
||||
// CommonMistake 常见错误定义
|
||||
// CommonMistake defines a common mistake with bilingual fields
|
||||
type CommonMistake struct {
|
||||
ErrorZH string
|
||||
ErrorEN string
|
||||
@@ -442,9 +440,9 @@ var CommonMistakes = []CommonMistake{
|
||||
},
|
||||
}
|
||||
|
||||
// ========== Prompt生成函数 ==========
|
||||
// ========== Prompt Generation Functions ==========
|
||||
|
||||
// GetSchemaPrompt 生成Schema说明文本,用于AI Prompt
|
||||
// GetSchemaPrompt generates schema description text for AI prompts
|
||||
func GetSchemaPrompt(lang Language) string {
|
||||
if lang == LangChinese {
|
||||
return getSchemaPromptZH()
|
||||
@@ -452,66 +450,46 @@ func GetSchemaPrompt(lang Language) string {
|
||||
return getSchemaPromptEN()
|
||||
}
|
||||
|
||||
// getSchemaPromptZH 生成中文Prompt
|
||||
// getSchemaPromptZH generates the Chinese prompt
|
||||
func getSchemaPromptZH() string {
|
||||
prompt := "# 📖 数据字典与交易规则\n\n"
|
||||
prompt += "## 📊 字段含义说明\n\n"
|
||||
|
||||
// 账户指标
|
||||
// Account metrics
|
||||
prompt += "### 账户指标\n"
|
||||
for key, field := range DataDictionary["AccountMetrics"] {
|
||||
prompt += formatFieldDefZH(key, field)
|
||||
}
|
||||
|
||||
// 交易指标
|
||||
// Trade metrics
|
||||
prompt += "\n### 交易指标\n"
|
||||
for key, field := range DataDictionary["TradeMetrics"] {
|
||||
prompt += formatFieldDefZH(key, field)
|
||||
}
|
||||
|
||||
// 持仓指标
|
||||
// Position metrics
|
||||
prompt += "\n### 持仓指标\n"
|
||||
for key, field := range DataDictionary["PositionMetrics"] {
|
||||
prompt += formatFieldDefZH(key, field)
|
||||
}
|
||||
|
||||
// 市场数据
|
||||
// Market data
|
||||
prompt += "\n### 市场数据\n"
|
||||
for key, field := range DataDictionary["MarketData"] {
|
||||
prompt += formatFieldDefZH(key, field)
|
||||
}
|
||||
|
||||
// 交易规则
|
||||
prompt += "\n## ⚖️ 交易规则\n\n"
|
||||
prompt += "### 风险管理\n"
|
||||
for name, rule := range TradingRules.RiskManagement {
|
||||
prompt += "- **" + name + "**: " + rule.DescZH + "\n 理由:" + rule.ReasonZH + "\n"
|
||||
}
|
||||
|
||||
prompt += "\n### 出场信号\n"
|
||||
for name, rule := range TradingRules.ExitSignals {
|
||||
prompt += "- **" + name + "**: " + rule.DescZH + "\n 理由:" + rule.ReasonZH + "\n"
|
||||
}
|
||||
|
||||
// OI解读
|
||||
// OI interpretation
|
||||
prompt += "\n## 💹 持仓量(OI)变化解读\n\n"
|
||||
prompt += "- **OI增加 + 价格上涨**: " + OIInterpretation.OIUp_PriceUp.ZH + "\n"
|
||||
prompt += "- **OI增加 + 价格下跌**: " + OIInterpretation.OIUp_PriceDown.ZH + "\n"
|
||||
prompt += "- **OI减少 + 价格上涨**: " + OIInterpretation.OIDown_PriceUp.ZH + "\n"
|
||||
prompt += "- **OI减少 + 价格下跌**: " + OIInterpretation.OIDown_PriceDown.ZH + "\n"
|
||||
|
||||
// 常见错误
|
||||
prompt += "\n## ⚠️ 常见错误(请避免)\n\n"
|
||||
for i, mistake := range CommonMistakes {
|
||||
prompt += fmt.Sprintf("**错误%d**: %s\n", i+1, mistake.ErrorZH)
|
||||
prompt += "- 错误示例:" + mistake.ExampleZH + "\n"
|
||||
prompt += "- 正确做法:" + mistake.CorrectZH + "\n\n"
|
||||
}
|
||||
|
||||
return prompt
|
||||
}
|
||||
|
||||
// getSchemaPromptEN 生成英文Prompt
|
||||
// getSchemaPromptEN generates the English prompt
|
||||
func getSchemaPromptEN() string {
|
||||
prompt := "# 📖 Data Dictionary & Trading Rules\n\n"
|
||||
prompt += "## 📊 Field Definitions\n\n"
|
||||
@@ -540,18 +518,6 @@ func getSchemaPromptEN() string {
|
||||
prompt += formatFieldDefEN(key, field)
|
||||
}
|
||||
|
||||
// Trading Rules
|
||||
prompt += "\n## ⚖️ Trading Rules\n\n"
|
||||
prompt += "### Risk Management\n"
|
||||
for name, rule := range TradingRules.RiskManagement {
|
||||
prompt += "- **" + name + "**: " + rule.DescEN + "\n Reason: " + rule.ReasonEN + "\n"
|
||||
}
|
||||
|
||||
prompt += "\n### Exit Signals\n"
|
||||
for name, rule := range TradingRules.ExitSignals {
|
||||
prompt += "- **" + name + "**: " + rule.DescEN + "\n Reason: " + rule.ReasonEN + "\n"
|
||||
}
|
||||
|
||||
// OI Interpretation
|
||||
prompt += "\n## 💹 Open Interest (OI) Change Interpretation\n\n"
|
||||
prompt += "- **OI Up + Price Up**: " + OIInterpretation.OIUp_PriceUp.EN + "\n"
|
||||
@@ -559,18 +525,10 @@ func getSchemaPromptEN() string {
|
||||
prompt += "- **OI Down + Price Up**: " + OIInterpretation.OIDown_PriceUp.EN + "\n"
|
||||
prompt += "- **OI Down + Price Down**: " + OIInterpretation.OIDown_PriceDown.EN + "\n"
|
||||
|
||||
// Common Mistakes
|
||||
prompt += "\n## ⚠️ Common Mistakes to Avoid\n\n"
|
||||
for i, mistake := range CommonMistakes {
|
||||
prompt += fmt.Sprintf("**Mistake %d**: %s\n", i+1, mistake.ErrorEN)
|
||||
prompt += "- Bad Example: " + mistake.ExampleEN + "\n"
|
||||
prompt += "- Correct Approach: " + mistake.CorrectEN + "\n\n"
|
||||
}
|
||||
|
||||
return prompt
|
||||
}
|
||||
|
||||
// formatFieldDefZH 格式化中文字段定义
|
||||
// formatFieldDefZH formats a field definition in Chinese
|
||||
func formatFieldDefZH(key string, field BilingualFieldDef) string {
|
||||
result := "- **" + key + "**(" + field.NameZH + "): " + field.DescZH
|
||||
if field.FormulaZH != "" {
|
||||
@@ -583,7 +541,7 @@ func formatFieldDefZH(key string, field BilingualFieldDef) string {
|
||||
return result
|
||||
}
|
||||
|
||||
// formatFieldDefEN 格式化英文字段定义
|
||||
// formatFieldDefEN formats a field definition in English
|
||||
func formatFieldDefEN(key string, field BilingualFieldDef) string {
|
||||
result := "- **" + key + "** (" + field.NameEN + "): " + field.DescEN
|
||||
if field.FormulaEN != "" {
|
||||
|
||||
@@ -1,284 +0,0 @@
|
||||
package kernel
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestDataDictionary 测试数据字典定义
|
||||
func TestDataDictionary(t *testing.T) {
|
||||
// 测试账户指标字典
|
||||
t.Run("AccountMetrics", func(t *testing.T) {
|
||||
equity := DataDictionary["AccountMetrics"]["Equity"]
|
||||
|
||||
if equity.NameZH != "总权益" {
|
||||
t.Errorf("Expected NameZH='总权益', got '%s'", equity.NameZH)
|
||||
}
|
||||
|
||||
if equity.NameEN != "Total Equity" {
|
||||
t.Errorf("Expected NameEN='Total Equity', got '%s'", equity.NameEN)
|
||||
}
|
||||
|
||||
if equity.Unit != "USDT" {
|
||||
t.Errorf("Expected Unit='USDT', got '%s'", equity.Unit)
|
||||
}
|
||||
|
||||
if equity.GetName(LangChinese) != "总权益" {
|
||||
t.Errorf("GetName(Chinese) failed")
|
||||
}
|
||||
|
||||
if equity.GetName(LangEnglish) != "Total Equity" {
|
||||
t.Errorf("GetName(English) failed")
|
||||
}
|
||||
})
|
||||
|
||||
// 测试持仓指标字典
|
||||
t.Run("PositionMetrics", func(t *testing.T) {
|
||||
peakPnL := DataDictionary["PositionMetrics"]["PeakPnL%"]
|
||||
|
||||
if peakPnL.NameZH == "" {
|
||||
t.Error("PeakPnL% NameZH is empty")
|
||||
}
|
||||
|
||||
if peakPnL.NameEN == "" {
|
||||
t.Error("PeakPnL% NameEN is empty")
|
||||
}
|
||||
|
||||
if !strings.Contains(peakPnL.DescZH, "峰值") {
|
||||
t.Error("PeakPnL% DescZH should contain '峰值'")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestTradingRules 测试交易规则定义
|
||||
func TestTradingRules(t *testing.T) {
|
||||
t.Run("RiskManagement", func(t *testing.T) {
|
||||
maxMargin := TradingRules.RiskManagement["MaxMarginUsage"]
|
||||
|
||||
if maxMargin.Value != 0.30 {
|
||||
t.Errorf("Expected MaxMarginUsage=0.30, got %v", maxMargin.Value)
|
||||
}
|
||||
|
||||
if maxMargin.GetDesc(LangChinese) == "" {
|
||||
t.Error("MaxMarginUsage DescZH is empty")
|
||||
}
|
||||
|
||||
if maxMargin.GetDesc(LangEnglish) == "" {
|
||||
t.Error("MaxMarginUsage DescEN is empty")
|
||||
}
|
||||
|
||||
if !strings.Contains(maxMargin.DescZH, "30%") {
|
||||
t.Error("MaxMarginUsage DescZH should mention 30%")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ExitSignals", func(t *testing.T) {
|
||||
trailing := TradingRules.ExitSignals["TrailingStop"]
|
||||
|
||||
if trailing.Value != 0.30 {
|
||||
t.Errorf("Expected TrailingStop=0.30, got %v", trailing.Value)
|
||||
}
|
||||
|
||||
if !strings.Contains(trailing.ReasonZH, "止盈") {
|
||||
t.Error("TrailingStop ReasonZH should mention '止盈'")
|
||||
}
|
||||
|
||||
if !strings.Contains(trailing.ReasonEN, "profit") {
|
||||
t.Error("TrailingStop ReasonEN should mention 'profit'")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestOIInterpretation 测试OI解读
|
||||
func TestOIInterpretation(t *testing.T) {
|
||||
t.Run("OI_Up_Price_Up", func(t *testing.T) {
|
||||
if OIInterpretation.OIUp_PriceUp.ZH == "" {
|
||||
t.Error("OI Up + Price Up ZH is empty")
|
||||
}
|
||||
|
||||
if OIInterpretation.OIUp_PriceUp.EN == "" {
|
||||
t.Error("OI Up + Price Up EN is empty")
|
||||
}
|
||||
|
||||
if !strings.Contains(OIInterpretation.OIUp_PriceUp.ZH, "多头") {
|
||||
t.Error("OI Up + Price Up should indicate bullish trend")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestCommonMistakes 测试常见错误定义
|
||||
func TestCommonMistakes(t *testing.T) {
|
||||
if len(CommonMistakes) == 0 {
|
||||
t.Error("CommonMistakes should not be empty")
|
||||
}
|
||||
|
||||
for i, mistake := range CommonMistakes {
|
||||
if mistake.ErrorZH == "" {
|
||||
t.Errorf("Mistake #%d ErrorZH is empty", i+1)
|
||||
}
|
||||
|
||||
if mistake.ErrorEN == "" {
|
||||
t.Errorf("Mistake #%d ErrorEN is empty", i+1)
|
||||
}
|
||||
|
||||
if mistake.CorrectZH == "" {
|
||||
t.Errorf("Mistake #%d CorrectZH is empty", i+1)
|
||||
}
|
||||
|
||||
if mistake.CorrectEN == "" {
|
||||
t.Errorf("Mistake #%d CorrectEN is empty", i+1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetSchemaPrompt 测试Schema提示词生成
|
||||
func TestGetSchemaPrompt(t *testing.T) {
|
||||
t.Run("Chinese", func(t *testing.T) {
|
||||
prompt := GetSchemaPrompt(LangChinese)
|
||||
|
||||
if prompt == "" {
|
||||
t.Fatal("Chinese schema prompt is empty")
|
||||
}
|
||||
|
||||
// 验证包含关键内容
|
||||
mustContain := []string{
|
||||
"数据字典",
|
||||
"账户指标",
|
||||
"交易指标",
|
||||
"持仓指标",
|
||||
"市场数据",
|
||||
"交易规则",
|
||||
"风险管理",
|
||||
"持仓量(OI)变化解读",
|
||||
"常见错误",
|
||||
}
|
||||
|
||||
for _, keyword := range mustContain {
|
||||
if !strings.Contains(prompt, keyword) {
|
||||
t.Errorf("Chinese prompt should contain '%s'", keyword)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("English", func(t *testing.T) {
|
||||
prompt := GetSchemaPrompt(LangEnglish)
|
||||
|
||||
if prompt == "" {
|
||||
t.Fatal("English schema prompt is empty")
|
||||
}
|
||||
|
||||
// 验证包含关键内容
|
||||
mustContain := []string{
|
||||
"Data Dictionary",
|
||||
"Account Metrics",
|
||||
"Trade Metrics",
|
||||
"Position Metrics",
|
||||
"Market Data",
|
||||
"Trading Rules",
|
||||
"Risk Management",
|
||||
"Open Interest",
|
||||
"Common Mistakes",
|
||||
}
|
||||
|
||||
for _, keyword := range mustContain {
|
||||
if !strings.Contains(prompt, keyword) {
|
||||
t.Errorf("English prompt should contain '%s'", keyword)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Consistency", func(t *testing.T) {
|
||||
promptZH := GetSchemaPrompt(LangChinese)
|
||||
promptEN := GetSchemaPrompt(LangEnglish)
|
||||
|
||||
// 两个版本都应该包含相同数量的字段定义
|
||||
// 虽然内容不同,但结构应该相似
|
||||
|
||||
zhLines := strings.Split(promptZH, "\n")
|
||||
enLines := strings.Split(promptEN, "\n")
|
||||
|
||||
// 行数应该大致相当(允许10%的差异)
|
||||
ratio := float64(len(zhLines)) / float64(len(enLines))
|
||||
if ratio < 0.9 || ratio > 1.1 {
|
||||
t.Logf("Warning: Line count difference is significant (ZH: %d, EN: %d)",
|
||||
len(zhLines), len(enLines))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// BenchmarkGetSchemaPrompt 性能测试
|
||||
func BenchmarkGetSchemaPrompt(b *testing.B) {
|
||||
b.Run("Chinese", func(b *testing.B) {
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = GetSchemaPrompt(LangChinese)
|
||||
}
|
||||
})
|
||||
|
||||
b.Run("English", func(b *testing.B) {
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = GetSchemaPrompt(LangEnglish)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestFieldDefinitionMethods 测试字段定义方法
|
||||
func TestFieldDefinitionMethods(t *testing.T) {
|
||||
field := BilingualFieldDef{
|
||||
NameZH: "测试字段",
|
||||
NameEN: "Test Field",
|
||||
Unit: "USDT",
|
||||
FormulaZH: "中文公式",
|
||||
FormulaEN: "English formula",
|
||||
DescZH: "中文描述",
|
||||
DescEN: "English description",
|
||||
}
|
||||
|
||||
// 测试GetName
|
||||
if field.GetName(LangChinese) != "测试字段" {
|
||||
t.Error("GetName(Chinese) failed")
|
||||
}
|
||||
if field.GetName(LangEnglish) != "Test Field" {
|
||||
t.Error("GetName(English) failed")
|
||||
}
|
||||
|
||||
// 测试GetFormula
|
||||
if field.GetFormula(LangChinese) != "中文公式" {
|
||||
t.Error("GetFormula(Chinese) failed")
|
||||
}
|
||||
if field.GetFormula(LangEnglish) != "English formula" {
|
||||
t.Error("GetFormula(English) failed")
|
||||
}
|
||||
|
||||
// 测试GetDesc
|
||||
if field.GetDesc(LangChinese) != "中文描述" {
|
||||
t.Error("GetDesc(Chinese) failed")
|
||||
}
|
||||
if field.GetDesc(LangEnglish) != "English description" {
|
||||
t.Error("GetDesc(English) failed")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRuleDefinitionMethods 测试规则定义方法
|
||||
func TestRuleDefinitionMethods(t *testing.T) {
|
||||
rule := BilingualRuleDef{
|
||||
Value: 0.30,
|
||||
DescZH: "中文描述",
|
||||
DescEN: "English description",
|
||||
ReasonZH: "中文原因",
|
||||
ReasonEN: "English reason",
|
||||
}
|
||||
|
||||
if rule.GetDesc(LangChinese) != "中文描述" {
|
||||
t.Error("GetDesc(Chinese) failed")
|
||||
}
|
||||
if rule.GetDesc(LangEnglish) != "English description" {
|
||||
t.Error("GetDesc(English) failed")
|
||||
}
|
||||
|
||||
if rule.GetReason(LangChinese) != "中文原因" {
|
||||
t.Error("GetReason(Chinese) failed")
|
||||
}
|
||||
if rule.GetReason(LangEnglish) != "English reason" {
|
||||
t.Error("GetReason(English) failed")
|
||||
}
|
||||
}
|
||||
@@ -1,351 +0,0 @@
|
||||
package llm
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 阿里云 API 配置
|
||||
const (
|
||||
DefaultQwenBaseURL = "https://dashscope.aliyuncs.com/api/v1/apps"
|
||||
// 标准 OpenAI 兼容模式 API
|
||||
QwenCompatibleURL = "https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions"
|
||||
)
|
||||
|
||||
// QwenAgent 阿里云百炼智能体客户端
|
||||
type QwenAgent struct {
|
||||
AppID string
|
||||
APIKey string
|
||||
BaseURL string
|
||||
SessionID string
|
||||
Client *http.Client
|
||||
}
|
||||
|
||||
// QwenRequest 请求结构
|
||||
type QwenRequest struct {
|
||||
Input QwenInput `json:"input"`
|
||||
Parameters QwenParameters `json:"parameters,omitempty"`
|
||||
}
|
||||
|
||||
// QwenInput 输入结构
|
||||
type QwenInput struct {
|
||||
Prompt string `json:"prompt"`
|
||||
BizParams map[string]interface{} `json:"biz_params,omitempty"`
|
||||
}
|
||||
|
||||
// QwenParameters 参数结构
|
||||
type QwenParameters struct {
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
IncrementalOutput bool `json:"incremental_output,omitempty"`
|
||||
}
|
||||
|
||||
// QwenResponse 响应结构
|
||||
type QwenResponse struct {
|
||||
Output QwenOutput `json:"output"`
|
||||
Usage QwenUsage `json:"usage,omitempty"`
|
||||
RequestID string `json:"request_id"`
|
||||
Code string `json:"code,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
// QwenOutput 输出结构
|
||||
type QwenOutput struct {
|
||||
Text string `json:"text"`
|
||||
FinishReason string `json:"finish_reason,omitempty"`
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
}
|
||||
|
||||
// QwenUsage 用量统计
|
||||
type QwenUsage struct {
|
||||
InputTokens int `json:"input_tokens"`
|
||||
OutputTokens int `json:"output_tokens"`
|
||||
TotalTokens int `json:"total_tokens"`
|
||||
}
|
||||
|
||||
// NewQwenAgent 创建新的智能体客户端
|
||||
func NewQwenAgent(appID, apiKey string) *QwenAgent {
|
||||
return &QwenAgent{
|
||||
AppID: appID,
|
||||
APIKey: apiKey,
|
||||
BaseURL: DefaultQwenBaseURL,
|
||||
Client: &http.Client{
|
||||
Timeout: 180 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Chat 同步对话
|
||||
func (a *QwenAgent) Chat(ctx context.Context, prompt string) (*QwenResponse, error) {
|
||||
reqBody := QwenRequest{
|
||||
Input: QwenInput{
|
||||
Prompt: prompt,
|
||||
},
|
||||
Parameters: QwenParameters{
|
||||
SessionID: a.SessionID,
|
||||
},
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal request failed: %w", err)
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("%s/%s/completion", a.BaseURL, a.AppID)
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create request failed: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+a.APIKey)
|
||||
|
||||
resp, err := a.Client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read response failed: %w", err)
|
||||
}
|
||||
|
||||
var result QwenResponse
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal response failed: %w, body: %s", err, string(body))
|
||||
}
|
||||
|
||||
// 更新 session_id 用于多轮对话
|
||||
if result.Output.SessionID != "" {
|
||||
a.SessionID = result.Output.SessionID
|
||||
}
|
||||
|
||||
// 检查 API 错误
|
||||
if result.Code != "" {
|
||||
return &result, fmt.Errorf("API error: code=%s, message=%s", result.Code, result.Message)
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// ChatStream 流式对话
|
||||
func (a *QwenAgent) ChatStream(ctx context.Context, prompt string, callback func(chunk string)) error {
|
||||
reqBody := QwenRequest{
|
||||
Input: QwenInput{
|
||||
Prompt: prompt,
|
||||
},
|
||||
Parameters: QwenParameters{
|
||||
SessionID: a.SessionID,
|
||||
IncrementalOutput: true,
|
||||
},
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal request failed: %w", err)
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("%s/%s/completion", a.BaseURL, a.AppID)
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
return fmt.Errorf("create request failed: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+a.APIKey)
|
||||
req.Header.Set("X-DashScope-SSE", "enable")
|
||||
|
||||
resp, err := a.Client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
reader := bufio.NewReader(resp.Body)
|
||||
for {
|
||||
line, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
return fmt.Errorf("read stream failed: %w", err)
|
||||
}
|
||||
|
||||
line = strings.TrimSpace(line)
|
||||
if !strings.HasPrefix(line, "data:") {
|
||||
continue
|
||||
}
|
||||
|
||||
data := strings.TrimPrefix(line, "data:")
|
||||
var chunk QwenResponse
|
||||
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// 更新 session_id
|
||||
if chunk.Output.SessionID != "" {
|
||||
a.SessionID = chunk.Output.SessionID
|
||||
}
|
||||
|
||||
// 回调输出文本
|
||||
if chunk.Output.Text != "" {
|
||||
callback(chunk.Output.Text)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ChatWithBizParams 带业务参数的对话
|
||||
func (a *QwenAgent) ChatWithBizParams(ctx context.Context, prompt string, bizParams map[string]interface{}) (*QwenResponse, error) {
|
||||
reqBody := QwenRequest{
|
||||
Input: QwenInput{
|
||||
Prompt: prompt,
|
||||
BizParams: bizParams,
|
||||
},
|
||||
Parameters: QwenParameters{
|
||||
SessionID: a.SessionID,
|
||||
},
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal request failed: %w", err)
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("%s/%s/completion", a.BaseURL, a.AppID)
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create request failed: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+a.APIKey)
|
||||
|
||||
resp, err := a.Client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read response failed: %w", err)
|
||||
}
|
||||
|
||||
var result QwenResponse
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal response failed: %w, body: %s", err, string(body))
|
||||
}
|
||||
|
||||
if result.Output.SessionID != "" {
|
||||
a.SessionID = result.Output.SessionID
|
||||
}
|
||||
|
||||
if result.Code != "" {
|
||||
return &result, fmt.Errorf("API error: code=%s, message=%s", result.Code, result.Message)
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// ResetSession 重置会话
|
||||
func (a *QwenAgent) ResetSession() {
|
||||
a.SessionID = ""
|
||||
}
|
||||
|
||||
// ========== 标准 OpenAI 兼容 API ==========
|
||||
|
||||
// ChatCompletionRequest OpenAI 兼容格式请求
|
||||
type ChatCompletionRequest struct {
|
||||
Model string `json:"model"`
|
||||
Messages []ChatCompletionMessage `json:"messages"`
|
||||
}
|
||||
|
||||
// ChatCompletionMessage 消息结构
|
||||
type ChatCompletionMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
// ChatCompletionResponse OpenAI 兼容格式响应
|
||||
type ChatCompletionResponse struct {
|
||||
ID string `json:"id"`
|
||||
Model string `json:"model"`
|
||||
Choices []struct {
|
||||
Message struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
} `json:"message"`
|
||||
FinishReason string `json:"finish_reason"`
|
||||
} `json:"choices"`
|
||||
Usage struct {
|
||||
PromptTokens int `json:"prompt_tokens"`
|
||||
CompletionTokens int `json:"completion_tokens"`
|
||||
TotalTokens int `json:"total_tokens"`
|
||||
} `json:"usage"`
|
||||
Error *struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
} `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// ChatWithModel 使用标准 OpenAI 兼容 API 调用指定模型
|
||||
func (a *QwenAgent) ChatWithModel(ctx context.Context, model, prompt string) (*ChatCompletionResponse, error) {
|
||||
reqBody := ChatCompletionRequest{
|
||||
Model: model,
|
||||
Messages: []ChatCompletionMessage{
|
||||
{Role: "user", Content: prompt},
|
||||
},
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal request failed: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", QwenCompatibleURL, bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create request failed: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+a.APIKey)
|
||||
|
||||
resp, err := a.Client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read response failed: %w", err)
|
||||
}
|
||||
|
||||
var result ChatCompletionResponse
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal response failed: %w, body: %s", err, string(body))
|
||||
}
|
||||
|
||||
if result.Error != nil {
|
||||
return &result, fmt.Errorf("API error: code=%s, message=%s", result.Error.Code, result.Error.Message)
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// GetContent 从响应中获取内容
|
||||
func (r *ChatCompletionResponse) GetContent() string {
|
||||
if len(r.Choices) > 0 {
|
||||
return r.Choices[0].Message.Content
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -1,425 +0,0 @@
|
||||
package llm
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 阿里云百炼平台配置 (从环境变量获取)
|
||||
var (
|
||||
QwenAppID = os.Getenv("QWEN_APP_ID")
|
||||
QwenAPIKey = os.Getenv("QWEN_API_KEY")
|
||||
)
|
||||
|
||||
// ============== 测试用例 ==============
|
||||
|
||||
// TestQwenBasicChat 测试基本同步对话
|
||||
func TestQwenBasicChat(t *testing.T) {
|
||||
agent := NewQwenAgent(QwenAppID, QwenAPIKey)
|
||||
ctx := context.Background()
|
||||
|
||||
prompt := "你好,请用一句话介绍你自己"
|
||||
t.Logf("用户: %s", prompt)
|
||||
|
||||
start := time.Now()
|
||||
resp, err := agent.Chat(ctx, prompt)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Chat failed: %v", err)
|
||||
}
|
||||
|
||||
if resp.Output.Text == "" {
|
||||
t.Fatal("Empty response text")
|
||||
}
|
||||
|
||||
t.Logf("助手: %s", resp.Output.Text)
|
||||
t.Logf("耗时: %v, Token: %d", elapsed, resp.Usage.TotalTokens)
|
||||
}
|
||||
|
||||
// TestQwenStreamChat 测试流式输出
|
||||
func TestQwenStreamChat(t *testing.T) {
|
||||
agent := NewQwenAgent(QwenAppID, QwenAPIKey)
|
||||
ctx := context.Background()
|
||||
|
||||
prompt := "请用3句话解释什么是量化交易"
|
||||
t.Logf("用户: %s", prompt)
|
||||
|
||||
var fullText strings.Builder
|
||||
start := time.Now()
|
||||
|
||||
err := agent.ChatStream(ctx, prompt, func(chunk string) {
|
||||
fullText.WriteString(chunk)
|
||||
})
|
||||
|
||||
elapsed := time.Since(start)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("ChatStream failed: %v", err)
|
||||
}
|
||||
|
||||
if fullText.Len() == 0 {
|
||||
t.Fatal("Empty stream response")
|
||||
}
|
||||
|
||||
t.Logf("助手: %s", fullText.String())
|
||||
t.Logf("耗时: %v, 字符数: %d", elapsed, fullText.Len())
|
||||
}
|
||||
|
||||
// TestQwenMultiTurn 测试多轮对话(上下文记忆)
|
||||
func TestQwenMultiTurn(t *testing.T) {
|
||||
agent := NewQwenAgent(QwenAppID, QwenAPIKey)
|
||||
ctx := context.Background()
|
||||
|
||||
// 第一轮:设置上下文
|
||||
resp1, err := agent.Chat(ctx, "我叫小明,我是一名 Go 程序员,请记住这些信息")
|
||||
if err != nil {
|
||||
t.Fatalf("Round 1 failed: %v", err)
|
||||
}
|
||||
t.Logf("[Round 1] 用户: 我叫小明,我是一名 Go 程序员")
|
||||
t.Logf("[Round 1] 助手: %s", resp1.Output.Text)
|
||||
t.Logf("[Round 1] SessionID: %s", agent.SessionID)
|
||||
|
||||
// 第二轮:验证记忆
|
||||
resp2, err := agent.Chat(ctx, "请问我叫什么名字?我是做什么的?")
|
||||
if err != nil {
|
||||
t.Fatalf("Round 2 failed: %v", err)
|
||||
}
|
||||
t.Logf("[Round 2] 用户: 请问我叫什么名字?我是做什么的?")
|
||||
t.Logf("[Round 2] 助手: %s", resp2.Output.Text)
|
||||
|
||||
// 检查是否记住了信息
|
||||
text := strings.ToLower(resp2.Output.Text)
|
||||
if !strings.Contains(text, "小明") && !strings.Contains(text, "go") {
|
||||
t.Logf("警告: 模型可能没有正确记住上下文")
|
||||
}
|
||||
}
|
||||
|
||||
// TestQwenResetSession 测试重置会话
|
||||
func TestQwenResetSession(t *testing.T) {
|
||||
agent := NewQwenAgent(QwenAppID, QwenAPIKey)
|
||||
ctx := context.Background()
|
||||
|
||||
// 建立上下文
|
||||
resp1, err := agent.Chat(ctx, "记住这个密码: ABC123XYZ")
|
||||
if err != nil {
|
||||
t.Fatalf("Setup context failed: %v", err)
|
||||
}
|
||||
t.Logf("设置上下文: %s", resp1.Output.Text)
|
||||
|
||||
oldSession := agent.SessionID
|
||||
t.Logf("原 SessionID: %s", oldSession)
|
||||
|
||||
// 重置会话
|
||||
agent.ResetSession()
|
||||
t.Log("会话已重置")
|
||||
|
||||
// 新对话 - 应该不记得之前的内容
|
||||
resp2, err := agent.Chat(ctx, "我之前告诉你的密码是什么?")
|
||||
if err != nil {
|
||||
t.Fatalf("New session chat failed: %v", err)
|
||||
}
|
||||
t.Logf("新对话回复: %s", resp2.Output.Text)
|
||||
t.Logf("新 SessionID: %s", agent.SessionID)
|
||||
|
||||
if oldSession == agent.SessionID {
|
||||
t.Error("Session was not reset properly")
|
||||
}
|
||||
}
|
||||
|
||||
// TestQwenCodeGeneration 测试代码生成能力
|
||||
func TestQwenCodeGeneration(t *testing.T) {
|
||||
agent := NewQwenAgent(QwenAppID, QwenAPIKey)
|
||||
ctx := context.Background()
|
||||
|
||||
prompt := "请用 Go 语言写一个计算移动平均线(MA)的函数,输入是 []float64 价格切片和 int 周期"
|
||||
t.Logf("用户: %s", prompt)
|
||||
|
||||
resp, err := agent.Chat(ctx, prompt)
|
||||
if err != nil {
|
||||
t.Fatalf("Code generation failed: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("助手:\n%s", resp.Output.Text)
|
||||
|
||||
// 检查是否包含代码特征
|
||||
text := resp.Output.Text
|
||||
if !strings.Contains(text, "func") || !strings.Contains(text, "float64") {
|
||||
t.Log("警告: 响应可能不包含有效的 Go 代码")
|
||||
}
|
||||
}
|
||||
|
||||
// TestQwenJSONOutput 测试 JSON 格式输出
|
||||
func TestQwenJSONOutput(t *testing.T) {
|
||||
agent := NewQwenAgent(QwenAppID, QwenAPIKey)
|
||||
ctx := context.Background()
|
||||
|
||||
prompt := `请分析 BTC 的基本信息,以纯 JSON 格式返回(不要 markdown 代码块),包含以下字段:
|
||||
{"name": "资产名称", "type": "资产类型", "risk": 1-10的风险等级数字}
|
||||
只返回 JSON 对象,不要任何其他文字`
|
||||
|
||||
t.Logf("用户: %s", prompt)
|
||||
|
||||
resp, err := agent.Chat(ctx, prompt)
|
||||
if err != nil {
|
||||
t.Fatalf("JSON output test failed: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("助手: %s", resp.Output.Text)
|
||||
|
||||
// 尝试解析 JSON
|
||||
text := resp.Output.Text
|
||||
// 提取 JSON 部分
|
||||
start := strings.Index(text, "{")
|
||||
end := strings.LastIndex(text, "}")
|
||||
if start != -1 && end != -1 && end > start {
|
||||
jsonStr := text[start : end+1]
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(jsonStr), &result); err != nil {
|
||||
t.Logf("JSON 解析失败: %v", err)
|
||||
} else {
|
||||
t.Logf("JSON 解析成功: %+v", result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestQwenLongResponse 测试长文本生成
|
||||
func TestQwenLongResponse(t *testing.T) {
|
||||
agent := NewQwenAgent(QwenAppID, QwenAPIKey)
|
||||
ctx := context.Background()
|
||||
|
||||
prompt := "请详细介绍加密货币永续合约交易中的风险管理策略,包括止损设置、仓位管理、杠杆选择、资金费率考虑等方面,至少500字"
|
||||
t.Logf("用户: %s", prompt)
|
||||
|
||||
start := time.Now()
|
||||
resp, err := agent.Chat(ctx, prompt)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Long response test failed: %v", err)
|
||||
}
|
||||
|
||||
text := resp.Output.Text
|
||||
t.Logf("响应长度: %d 字符", len(text))
|
||||
t.Logf("耗时: %v", elapsed)
|
||||
t.Logf("Token 使用: input=%d, output=%d, total=%d",
|
||||
resp.Usage.InputTokens, resp.Usage.OutputTokens, resp.Usage.TotalTokens)
|
||||
|
||||
// 只显示前500字符
|
||||
if len(text) > 500 {
|
||||
t.Logf("助手(前500字): %s...", text[:500])
|
||||
} else {
|
||||
t.Logf("助手: %s", text)
|
||||
}
|
||||
}
|
||||
|
||||
// TestQwenTradingScenario 测试交易场景问答
|
||||
func TestQwenTradingScenario(t *testing.T) {
|
||||
agent := NewQwenAgent(QwenAppID, QwenAPIKey)
|
||||
ctx := context.Background()
|
||||
|
||||
questions := []string{
|
||||
"BTC 当前价格 95000 美元,RSI 在 75 附近,MACD 金叉,你建议现在开多还是开空?简短回答",
|
||||
"如果我有 10000 USDT,想用 10 倍杠杆做多 ETH,建议开多大仓位?",
|
||||
"什么是资金费率?正的资金费率对多头有什么影响?",
|
||||
}
|
||||
|
||||
for i, q := range questions {
|
||||
agent.ResetSession() // 每个问题独立
|
||||
|
||||
t.Logf("\n[问题%d] %s", i+1, q)
|
||||
resp, err := agent.Chat(ctx, q)
|
||||
if err != nil {
|
||||
t.Errorf("Question %d failed: %v", i+1, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// 截取显示
|
||||
text := resp.Output.Text
|
||||
if len(text) > 300 {
|
||||
text = text[:300] + "..."
|
||||
}
|
||||
t.Logf("[回答%d] %s", i+1, text)
|
||||
}
|
||||
}
|
||||
|
||||
// TestQwenErrorHandling 测试错误处理
|
||||
func TestQwenErrorHandling(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// 测试无效 API Key
|
||||
t.Run("InvalidAPIKey", func(t *testing.T) {
|
||||
agent := NewQwenAgent(QwenAppID, "invalid-api-key")
|
||||
_, err := agent.Chat(ctx, "测试")
|
||||
if err == nil {
|
||||
t.Log("警告: 无效 API Key 没有返回错误")
|
||||
} else {
|
||||
t.Logf("预期错误: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
// 测试无效 App ID
|
||||
t.Run("InvalidAppID", func(t *testing.T) {
|
||||
agent := NewQwenAgent("invalid-app-id", QwenAPIKey)
|
||||
_, err := agent.Chat(ctx, "测试")
|
||||
if err == nil {
|
||||
t.Log("警告: 无效 App ID 没有返回错误")
|
||||
} else {
|
||||
t.Logf("预期错误: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestQwenSpecialCharacters 测试特殊字符处理
|
||||
func TestQwenSpecialCharacters(t *testing.T) {
|
||||
agent := NewQwenAgent(QwenAppID, QwenAPIKey)
|
||||
ctx := context.Background()
|
||||
|
||||
testCases := []string{
|
||||
"请解释这个表情: 😀🎉🚀",
|
||||
"中英文混合: Hello世界!",
|
||||
"特殊符号: <>&\"'",
|
||||
}
|
||||
|
||||
for _, prompt := range testCases {
|
||||
agent.ResetSession()
|
||||
t.Logf("用户: %s", prompt)
|
||||
|
||||
resp, err := agent.Chat(ctx, prompt)
|
||||
if err != nil {
|
||||
t.Errorf("特殊字符测试失败: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
if len(resp.Output.Text) > 100 {
|
||||
t.Logf("助手: %s...", resp.Output.Text[:100])
|
||||
} else {
|
||||
t.Logf("助手: %s", resp.Output.Text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestQwenConcurrentSessions 测试并发会话
|
||||
func TestQwenConcurrentSessions(t *testing.T) {
|
||||
agent1 := NewQwenAgent(QwenAppID, QwenAPIKey)
|
||||
agent2 := NewQwenAgent(QwenAppID, QwenAPIKey)
|
||||
ctx := context.Background()
|
||||
|
||||
// Agent1 对话
|
||||
resp1, err := agent1.Chat(ctx, "我是 Alice,请记住")
|
||||
if err != nil {
|
||||
t.Fatalf("Agent1 chat failed: %v", err)
|
||||
}
|
||||
t.Logf("[Agent1] 设置: 我是 Alice -> %s", resp1.Output.Text[:min(100, len(resp1.Output.Text))])
|
||||
|
||||
// Agent2 对话
|
||||
resp2, err := agent2.Chat(ctx, "我是 Bob,请记住")
|
||||
if err != nil {
|
||||
t.Fatalf("Agent2 chat failed: %v", err)
|
||||
}
|
||||
t.Logf("[Agent2] 设置: 我是 Bob -> %s", resp2.Output.Text[:min(100, len(resp2.Output.Text))])
|
||||
|
||||
// 验证会话隔离
|
||||
resp1Check, _ := agent1.Chat(ctx, "我叫什么?")
|
||||
resp2Check, _ := agent2.Chat(ctx, "我叫什么?")
|
||||
|
||||
t.Logf("[Agent1] 验证: %s", resp1Check.Output.Text[:min(100, len(resp1Check.Output.Text))])
|
||||
t.Logf("[Agent2] 验证: %s", resp2Check.Output.Text[:min(100, len(resp2Check.Output.Text))])
|
||||
|
||||
if agent1.SessionID == agent2.SessionID {
|
||||
t.Error("两个 Agent 的 SessionID 不应该相同")
|
||||
} else {
|
||||
t.Logf("Session 隔离正常: Agent1=%s..., Agent2=%s...",
|
||||
agent1.SessionID[:min(20, len(agent1.SessionID))],
|
||||
agent2.SessionID[:min(20, len(agent2.SessionID))])
|
||||
}
|
||||
}
|
||||
|
||||
// TestQwenTimeout 测试超时处理
|
||||
func TestQwenTimeout(t *testing.T) {
|
||||
agent := NewQwenAgent(QwenAppID, QwenAPIKey)
|
||||
agent.Client.Timeout = 1 * time.Millisecond // 极短超时
|
||||
|
||||
ctx := context.Background()
|
||||
_, err := agent.Chat(ctx, "测试超时")
|
||||
|
||||
if err == nil {
|
||||
t.Log("警告: 极短超时没有触发错误")
|
||||
} else {
|
||||
t.Logf("预期超时错误: %v", err)
|
||||
}
|
||||
|
||||
// 恢复正常超时
|
||||
agent.Client.Timeout = 120 * time.Second
|
||||
}
|
||||
|
||||
// TestQwenContextCancel 测试上下文取消
|
||||
func TestQwenContextCancel(t *testing.T) {
|
||||
agent := NewQwenAgent(QwenAppID, QwenAPIKey)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel() // 立即取消
|
||||
|
||||
_, err := agent.Chat(ctx, "测试取消")
|
||||
if err == nil {
|
||||
t.Error("取消的上下文应该返回错误")
|
||||
} else {
|
||||
t.Logf("预期取消错误: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestQwenWithBizParams 测试带业务参数的调用
|
||||
func TestQwenWithBizParams(t *testing.T) {
|
||||
agent := NewQwenAgent(QwenAppID, QwenAPIKey)
|
||||
ctx := context.Background()
|
||||
|
||||
// 构造带业务参数的请求
|
||||
reqBody := QwenRequest{
|
||||
Input: QwenInput{
|
||||
Prompt: "根据提供的用户信息,给出个性化的投资建议",
|
||||
BizParams: map[string]interface{}{
|
||||
"user_risk_level": "moderate",
|
||||
"capital": 10000,
|
||||
"experience": "intermediate",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
jsonData, _ := json.Marshal(reqBody)
|
||||
url := fmt.Sprintf("%s/%s/completion", agent.BaseURL, agent.AppID)
|
||||
|
||||
req, _ := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(jsonData))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+agent.APIKey)
|
||||
|
||||
resp, err := agent.Client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Request with biz params failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
var result QwenResponse
|
||||
json.Unmarshal(body, &result)
|
||||
|
||||
if result.Output.Text != "" {
|
||||
t.Logf("带业务参数响应: %s", result.Output.Text[:min(200, len(result.Output.Text))])
|
||||
} else {
|
||||
t.Logf("响应: %s", string(body))
|
||||
}
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -1,737 +0,0 @@
|
||||
package llm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"nofx/market"
|
||||
"nofx/provider/coinank"
|
||||
"nofx/provider/coinank/coinank_api"
|
||||
"nofx/provider/coinank/coinank_enum"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// IndicatorResult AI 计算的指标结果
|
||||
type IndicatorResult struct {
|
||||
EMA12 float64 `json:"ema12"`
|
||||
EMA26 float64 `json:"ema26"`
|
||||
MACD float64 `json:"macd"`
|
||||
RSI14 float64 `json:"rsi14"`
|
||||
BOLLUp float64 `json:"boll_upper"`
|
||||
BOLLMid float64 `json:"boll_middle"`
|
||||
BOLLLow float64 `json:"boll_lower"`
|
||||
ATR14 float64 `json:"atr14"`
|
||||
SMA20 float64 `json:"sma20"`
|
||||
}
|
||||
|
||||
// 本地计算指标(使用 market 包的函数)
|
||||
func calculateLocalIndicators(klines []market.Kline) IndicatorResult {
|
||||
result := IndicatorResult{}
|
||||
|
||||
if len(klines) >= 12 {
|
||||
result.EMA12 = market.ExportCalculateEMA(klines, 12)
|
||||
}
|
||||
if len(klines) >= 26 {
|
||||
result.EMA26 = market.ExportCalculateEMA(klines, 26)
|
||||
result.MACD = market.ExportCalculateMACD(klines)
|
||||
}
|
||||
if len(klines) > 14 {
|
||||
result.RSI14 = market.ExportCalculateRSI(klines, 14)
|
||||
}
|
||||
if len(klines) >= 20 {
|
||||
result.BOLLUp, result.BOLLMid, result.BOLLLow = market.ExportCalculateBOLL(klines, 20, 2.0)
|
||||
// SMA20 就是 BOLL 中轨
|
||||
result.SMA20 = result.BOLLMid
|
||||
}
|
||||
if len(klines) > 14 {
|
||||
result.ATR14 = market.ExportCalculateATR(klines, 14)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// 格式化 K 线数据为文本,发给 AI
|
||||
func formatKlinesForAI(klines []market.Kline) string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString("以下是K线数据(从旧到新排列):\n")
|
||||
sb.WriteString("序号 | 时间 | 开盘价 | 最高价 | 最低价 | 收盘价 | 成交量\n")
|
||||
sb.WriteString("-----|------|--------|--------|--------|--------|--------\n")
|
||||
|
||||
for i, k := range klines {
|
||||
t := time.UnixMilli(k.OpenTime)
|
||||
sb.WriteString(fmt.Sprintf("%d | %s | %.2f | %.2f | %.2f | %.2f | %.2f\n",
|
||||
i+1, t.Format("01-02 15:04"), k.Open, k.High, k.Low, k.Close, k.Volume))
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// 构建 AI 计算指标的 prompt
|
||||
func buildIndicatorPrompt(klines []market.Kline) string {
|
||||
klinesText := formatKlinesForAI(klines)
|
||||
|
||||
prompt := fmt.Sprintf(`%s
|
||||
|
||||
请根据以上 %d 根K线数据,计算以下技术指标(使用标准算法):
|
||||
|
||||
1. EMA12(12周期指数移动平均线)
|
||||
2. EMA26(26周期指数移动平均线)
|
||||
3. MACD(EMA12 - EMA26)
|
||||
4. RSI14(14周期相对强弱指标,使用Wilder平滑法)
|
||||
5. BOLL布林带(20周期,2倍标准差):上轨、中轨、下轨
|
||||
6. ATR14(14周期平均真实波幅,使用Wilder平滑法)
|
||||
7. SMA20(20周期简单移动平均线)
|
||||
|
||||
请严格按照以下 JSON 格式返回结果,不要添加任何其他文字:
|
||||
{
|
||||
"ema12": 数值,
|
||||
"ema26": 数值,
|
||||
"macd": 数值,
|
||||
"rsi14": 数值,
|
||||
"boll_upper": 数值,
|
||||
"boll_middle": 数值,
|
||||
"boll_lower": 数值,
|
||||
"atr14": 数值,
|
||||
"sma20": 数值
|
||||
}
|
||||
|
||||
注意:
|
||||
- 所有数值保留2位小数
|
||||
- EMA计算使用SMA作为初始值,乘数为 2/(period+1)
|
||||
- RSI使用Wilder平滑法
|
||||
- 只返回JSON,不要解释过程`, klinesText, len(klines))
|
||||
|
||||
return prompt
|
||||
}
|
||||
|
||||
// 从 AI 响应中提取 JSON
|
||||
func extractJSONFromResponse(text string) (IndicatorResult, error) {
|
||||
var result IndicatorResult
|
||||
|
||||
// 尝试直接解析
|
||||
if err := json.Unmarshal([]byte(text), &result); err == nil {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// 提取 JSON 部分
|
||||
re := regexp.MustCompile(`\{[^{}]*"ema12"[^{}]*\}`)
|
||||
match := re.FindString(text)
|
||||
if match == "" {
|
||||
// 尝试更宽松的匹配
|
||||
start := strings.Index(text, "{")
|
||||
end := strings.LastIndex(text, "}")
|
||||
if start != -1 && end != -1 && end > start {
|
||||
match = text[start : end+1]
|
||||
}
|
||||
}
|
||||
|
||||
if match == "" {
|
||||
return result, fmt.Errorf("no JSON found in response: %s", text[:min(200, len(text))])
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(match), &result); err != nil {
|
||||
return result, fmt.Errorf("parse JSON failed: %w, json: %s", err, match)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// 比较两个指标结果,返回误差百分比
|
||||
func compareIndicators(local, ai IndicatorResult) map[string]float64 {
|
||||
errors := make(map[string]float64)
|
||||
|
||||
calcError := func(name string, localVal, aiVal float64) {
|
||||
if localVal == 0 {
|
||||
if aiVal == 0 {
|
||||
errors[name] = 0
|
||||
} else {
|
||||
errors[name] = 100 // 本地为0但AI不为0
|
||||
}
|
||||
return
|
||||
}
|
||||
errors[name] = math.Abs(localVal-aiVal) / math.Abs(localVal) * 100
|
||||
}
|
||||
|
||||
calcError("EMA12", local.EMA12, ai.EMA12)
|
||||
calcError("EMA26", local.EMA26, ai.EMA26)
|
||||
calcError("MACD", local.MACD, ai.MACD)
|
||||
calcError("RSI14", local.RSI14, ai.RSI14)
|
||||
calcError("BOLL_UP", local.BOLLUp, ai.BOLLUp)
|
||||
calcError("BOLL_MID", local.BOLLMid, ai.BOLLMid)
|
||||
calcError("BOLL_LOW", local.BOLLLow, ai.BOLLLow)
|
||||
calcError("ATR14", local.ATR14, ai.ATR14)
|
||||
calcError("SMA20", local.SMA20, ai.SMA20)
|
||||
|
||||
return errors
|
||||
}
|
||||
|
||||
// 生成测试用 K 线数据
|
||||
func generateTestKlines(count int, basePrice float64) []market.Kline {
|
||||
klines := make([]market.Kline, count)
|
||||
price := basePrice
|
||||
now := time.Now()
|
||||
|
||||
for i := 0; i < count; i++ {
|
||||
// 模拟价格波动
|
||||
change := (float64(i%7) - 3) * 0.5 // -1.5 到 +1.5 的波动
|
||||
price = price + change
|
||||
|
||||
open := price
|
||||
high := price + math.Abs(change)*0.5 + 0.5
|
||||
low := price - math.Abs(change)*0.5 - 0.3
|
||||
close := price + (change * 0.3)
|
||||
|
||||
klines[i] = market.Kline{
|
||||
OpenTime: now.Add(time.Duration(-count+i) * time.Hour).UnixMilli(),
|
||||
Open: open,
|
||||
High: high,
|
||||
Low: low,
|
||||
Close: close,
|
||||
Volume: 1000 + float64(i*100),
|
||||
CloseTime: now.Add(time.Duration(-count+i+1) * time.Hour).UnixMilli(),
|
||||
}
|
||||
}
|
||||
|
||||
return klines
|
||||
}
|
||||
|
||||
// TestQwenIndicatorCalculation 测试 AI 计算技术指标
|
||||
func TestQwenIndicatorCalculation(t *testing.T) {
|
||||
agent := NewQwenAgent(QwenAppID, QwenAPIKey)
|
||||
ctx := context.Background()
|
||||
|
||||
// 生成 30 根测试 K 线
|
||||
klines := generateTestKlines(30, 95000)
|
||||
|
||||
t.Log("===== K线数据 (最后5根) =====")
|
||||
for i := len(klines) - 5; i < len(klines); i++ {
|
||||
k := klines[i]
|
||||
t.Logf(" [%d] O:%.2f H:%.2f L:%.2f C:%.2f", i+1, k.Open, k.High, k.Low, k.Close)
|
||||
}
|
||||
|
||||
// 本地计算
|
||||
t.Log("\n===== 本地计算结果 =====")
|
||||
localResult := calculateLocalIndicators(klines)
|
||||
t.Logf(" EMA12: %.2f", localResult.EMA12)
|
||||
t.Logf(" EMA26: %.2f", localResult.EMA26)
|
||||
t.Logf(" MACD: %.2f", localResult.MACD)
|
||||
t.Logf(" RSI14: %.2f", localResult.RSI14)
|
||||
t.Logf(" BOLL上轨: %.2f", localResult.BOLLUp)
|
||||
t.Logf(" BOLL中轨: %.2f", localResult.BOLLMid)
|
||||
t.Logf(" BOLL下轨: %.2f", localResult.BOLLLow)
|
||||
t.Logf(" ATR14: %.2f", localResult.ATR14)
|
||||
t.Logf(" SMA20: %.2f", localResult.SMA20)
|
||||
|
||||
// AI 计算
|
||||
t.Log("\n===== 调用 AI 计算 =====")
|
||||
prompt := buildIndicatorPrompt(klines)
|
||||
t.Logf("Prompt 长度: %d 字符", len(prompt))
|
||||
|
||||
start := time.Now()
|
||||
resp, err := agent.Chat(ctx, prompt)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("AI 调用失败: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("AI 响应耗时: %v", elapsed)
|
||||
t.Logf("AI 原始响应:\n%s", resp.Output.Text)
|
||||
|
||||
// 解析 AI 结果
|
||||
aiResult, err := extractJSONFromResponse(resp.Output.Text)
|
||||
if err != nil {
|
||||
t.Fatalf("解析 AI 结果失败: %v", err)
|
||||
}
|
||||
|
||||
t.Log("\n===== AI 计算结果 =====")
|
||||
t.Logf(" EMA12: %.2f", aiResult.EMA12)
|
||||
t.Logf(" EMA26: %.2f", aiResult.EMA26)
|
||||
t.Logf(" MACD: %.2f", aiResult.MACD)
|
||||
t.Logf(" RSI14: %.2f", aiResult.RSI14)
|
||||
t.Logf(" BOLL上轨: %.2f", aiResult.BOLLUp)
|
||||
t.Logf(" BOLL中轨: %.2f", aiResult.BOLLMid)
|
||||
t.Logf(" BOLL下轨: %.2f", aiResult.BOLLLow)
|
||||
t.Logf(" ATR14: %.2f", aiResult.ATR14)
|
||||
t.Logf(" SMA20: %.2f", aiResult.SMA20)
|
||||
|
||||
// 对比结果
|
||||
t.Log("\n===== 误差对比 (%) =====")
|
||||
errors := compareIndicators(localResult, aiResult)
|
||||
|
||||
totalError := 0.0
|
||||
for name, errPct := range errors {
|
||||
status := "✓"
|
||||
if errPct > 5 {
|
||||
status = "⚠"
|
||||
}
|
||||
if errPct > 10 {
|
||||
status = "✗"
|
||||
}
|
||||
t.Logf(" %s %s: %.2f%%", status, name, errPct)
|
||||
totalError += errPct
|
||||
}
|
||||
|
||||
avgError := totalError / float64(len(errors))
|
||||
t.Logf("\n 平均误差: %.2f%%", avgError)
|
||||
|
||||
if avgError > 10 {
|
||||
t.Logf("警告: AI 计算误差较大,可能算法理解有差异")
|
||||
} else if avgError < 5 {
|
||||
t.Log("AI 计算精度良好!")
|
||||
}
|
||||
}
|
||||
|
||||
// TestQwenIndicatorWithRealKlines 使用真实 K 线测试
|
||||
func TestQwenIndicatorWithRealKlines(t *testing.T) {
|
||||
// 尝试获取真实 K 线数据
|
||||
client := market.NewAPIClient()
|
||||
klines, err := client.GetKlines("BTC", "1h", 30)
|
||||
if err != nil {
|
||||
t.Skipf("获取真实 K 线失败,跳过测试: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if len(klines) < 26 {
|
||||
t.Skipf("K 线数量不足: %d", len(klines))
|
||||
return
|
||||
}
|
||||
|
||||
agent := NewQwenAgent(QwenAppID, QwenAPIKey)
|
||||
ctx := context.Background()
|
||||
|
||||
t.Logf("获取到 %d 根 BTC 1h K线", len(klines))
|
||||
t.Log("最新价格:", klines[len(klines)-1].Close)
|
||||
|
||||
// 本地计算
|
||||
localResult := calculateLocalIndicators(klines)
|
||||
t.Log("\n===== 本地计算 =====")
|
||||
t.Logf(" EMA12: %.2f, EMA26: %.2f, MACD: %.2f", localResult.EMA12, localResult.EMA26, localResult.MACD)
|
||||
t.Logf(" RSI14: %.2f", localResult.RSI14)
|
||||
t.Logf(" BOLL: %.2f / %.2f / %.2f", localResult.BOLLUp, localResult.BOLLMid, localResult.BOLLLow)
|
||||
|
||||
// AI 计算
|
||||
prompt := buildIndicatorPrompt(klines)
|
||||
resp, err := agent.Chat(ctx, prompt)
|
||||
if err != nil {
|
||||
t.Fatalf("AI 调用失败: %v", err)
|
||||
}
|
||||
|
||||
t.Log("\n===== AI 响应 =====")
|
||||
t.Log(resp.Output.Text)
|
||||
|
||||
aiResult, err := extractJSONFromResponse(resp.Output.Text)
|
||||
if err != nil {
|
||||
t.Logf("解析失败: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// 对比
|
||||
errors := compareIndicators(localResult, aiResult)
|
||||
t.Log("\n===== 误差 =====")
|
||||
for name, errPct := range errors {
|
||||
t.Logf(" %s: %.2f%%", name, errPct)
|
||||
}
|
||||
}
|
||||
|
||||
// TestQwenIndicatorMultiTimeframe 测试多个时间周期
|
||||
func TestQwenIndicatorMultiTimeframe(t *testing.T) {
|
||||
agent := NewQwenAgent(QwenAppID, QwenAPIKey)
|
||||
ctx := context.Background()
|
||||
|
||||
timeframes := []struct {
|
||||
name string
|
||||
count int
|
||||
price float64
|
||||
}{
|
||||
{"5m周期", 30, 95000},
|
||||
{"1h周期", 50, 95000},
|
||||
{"4h周期", 40, 95000},
|
||||
}
|
||||
|
||||
for _, tf := range timeframes {
|
||||
t.Run(tf.name, func(t *testing.T) {
|
||||
klines := generateTestKlines(tf.count, tf.price)
|
||||
|
||||
localResult := calculateLocalIndicators(klines)
|
||||
|
||||
// 简化的 prompt
|
||||
prompt := buildSimpleIndicatorPrompt(klines)
|
||||
|
||||
resp, err := agent.Chat(ctx, prompt)
|
||||
if err != nil {
|
||||
t.Fatalf("AI 调用失败: %v", err)
|
||||
}
|
||||
|
||||
aiResult, err := extractJSONFromResponse(resp.Output.Text)
|
||||
if err != nil {
|
||||
t.Logf("解析失败: %v", err)
|
||||
t.Logf("AI 响应: %s", resp.Output.Text[:min(500, len(resp.Output.Text))])
|
||||
return
|
||||
}
|
||||
|
||||
errors := compareIndicators(localResult, aiResult)
|
||||
|
||||
// 计算平均误差
|
||||
total := 0.0
|
||||
for _, e := range errors {
|
||||
total += e
|
||||
}
|
||||
avgErr := total / float64(len(errors))
|
||||
|
||||
t.Logf("本地 MACD: %.2f, AI MACD: %.2f, 误差: %.2f%%", localResult.MACD, aiResult.MACD, errors["MACD"])
|
||||
t.Logf("本地 RSI: %.2f, AI RSI: %.2f, 误差: %.2f%%", localResult.RSI14, aiResult.RSI14, errors["RSI14"])
|
||||
t.Logf("平均误差: %.2f%%", avgErr)
|
||||
})
|
||||
|
||||
time.Sleep(2 * time.Second) // 避免请求过快
|
||||
}
|
||||
}
|
||||
|
||||
// 简化的 prompt
|
||||
func buildSimpleIndicatorPrompt(klines []market.Kline) string {
|
||||
// 只提供收盘价序列,减少 token
|
||||
var prices []string
|
||||
for _, k := range klines {
|
||||
prices = append(prices, fmt.Sprintf("%.2f", k.Close))
|
||||
}
|
||||
|
||||
return fmt.Sprintf(`收盘价序列(从旧到新): [%s]
|
||||
|
||||
请计算技术指标并返回 JSON:
|
||||
- ema12: 12周期EMA
|
||||
- ema26: 26周期EMA
|
||||
- macd: EMA12-EMA26
|
||||
- rsi14: 14周期RSI(Wilder平滑)
|
||||
- boll_upper, boll_middle, boll_lower: 20周期BOLL(2倍标准差)
|
||||
- atr14: 0 (无高低价数据)
|
||||
- sma20: 20周期SMA
|
||||
|
||||
只返回JSON格式:{"ema12":数值,"ema26":数值,...}`, strings.Join(prices, ","))
|
||||
}
|
||||
|
||||
// TestQwenIndicatorAccuracy 精度测试:使用简单数据验证算法
|
||||
func TestQwenIndicatorAccuracy(t *testing.T) {
|
||||
agent := NewQwenAgent(QwenAppID, QwenAPIKey)
|
||||
ctx := context.Background()
|
||||
|
||||
// 使用简单递增数据,便于验证
|
||||
prices := []float64{
|
||||
100, 101, 102, 103, 104, 105, 106, 107, 108, 109, // 1-10
|
||||
110, 111, 112, 113, 114, 115, 116, 117, 118, 119, // 11-20
|
||||
120, 121, 122, 123, 124, 125, 126, 127, 128, 129, // 21-30
|
||||
}
|
||||
|
||||
// 构建 K 线
|
||||
klines := make([]market.Kline, len(prices))
|
||||
for i, p := range prices {
|
||||
klines[i] = market.Kline{
|
||||
Open: p - 0.5,
|
||||
High: p + 1,
|
||||
Low: p - 1,
|
||||
Close: p,
|
||||
}
|
||||
}
|
||||
|
||||
// 本地计算
|
||||
localResult := calculateLocalIndicators(klines)
|
||||
|
||||
t.Log("===== 简单递增数据测试 =====")
|
||||
t.Logf("价格序列: %v", prices)
|
||||
t.Logf("本地计算:")
|
||||
t.Logf(" SMA20 = %.4f (理论值: 119.5)", localResult.SMA20)
|
||||
t.Logf(" EMA12 = %.4f", localResult.EMA12)
|
||||
t.Logf(" RSI14 = %.4f (持续上涨应接近100)", localResult.RSI14)
|
||||
|
||||
// AI 计算
|
||||
var priceStrs []string
|
||||
for _, p := range prices {
|
||||
priceStrs = append(priceStrs, strconv.FormatFloat(p, 'f', 0, 64))
|
||||
}
|
||||
|
||||
prompt := fmt.Sprintf(`收盘价序列: [%s]
|
||||
|
||||
请计算:
|
||||
1. SMA20 (20周期简单移动平均)
|
||||
2. EMA12 (12周期指数移动平均,初始值用SMA,乘数=2/13)
|
||||
3. RSI14 (14周期RSI,Wilder平滑法)
|
||||
|
||||
返回JSON: {"sma20":数值,"ema12":数值,"rsi14":数值}
|
||||
只返回JSON`, strings.Join(priceStrs, ","))
|
||||
|
||||
resp, err := agent.Chat(ctx, prompt)
|
||||
if err != nil {
|
||||
t.Fatalf("AI 调用失败: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("\nAI 响应: %s", resp.Output.Text)
|
||||
|
||||
// 简单解析
|
||||
var aiSimple struct {
|
||||
SMA20 float64 `json:"sma20"`
|
||||
EMA12 float64 `json:"ema12"`
|
||||
RSI14 float64 `json:"rsi14"`
|
||||
}
|
||||
|
||||
text := resp.Output.Text
|
||||
start := strings.Index(text, "{")
|
||||
end := strings.LastIndex(text, "}")
|
||||
if start != -1 && end > start {
|
||||
json.Unmarshal([]byte(text[start:end+1]), &aiSimple)
|
||||
}
|
||||
|
||||
t.Logf("\nAI 计算:")
|
||||
t.Logf(" SMA20 = %.4f", aiSimple.SMA20)
|
||||
t.Logf(" EMA12 = %.4f", aiSimple.EMA12)
|
||||
t.Logf(" RSI14 = %.4f", aiSimple.RSI14)
|
||||
|
||||
// 验证 SMA20 (理论值应该是 110+...+129 的平均 = 119.5)
|
||||
expectedSMA := 119.5
|
||||
if math.Abs(aiSimple.SMA20-expectedSMA) < 0.1 {
|
||||
t.Log("\n✓ AI 的 SMA20 计算正确!")
|
||||
} else {
|
||||
t.Logf("\n✗ AI 的 SMA20 有误差,期望 %.2f", expectedSMA)
|
||||
}
|
||||
}
|
||||
|
||||
// coinankKlinesToMarket 将 coinank K线转换为 market.Kline
|
||||
func coinankKlinesToMarket(klines []coinank.KlineResult) []market.Kline {
|
||||
result := make([]market.Kline, len(klines))
|
||||
for i, k := range klines {
|
||||
result[i] = market.Kline{
|
||||
OpenTime: k.StartTime,
|
||||
Open: k.Open,
|
||||
High: k.High,
|
||||
Low: k.Low,
|
||||
Close: k.Close,
|
||||
Volume: k.Volume,
|
||||
CloseTime: k.EndTime,
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// TestQwenETHMultiTimeframe 使用 Coinank 免费 API 获取真实 ETH 数据测试多周期指标
|
||||
func TestQwenETHMultiTimeframe(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
agent := NewQwenAgent(QwenAppID, QwenAPIKey)
|
||||
|
||||
// 测试多个时间周期
|
||||
timeframes := []struct {
|
||||
name string
|
||||
interval coinank_enum.Interval
|
||||
size int
|
||||
}{
|
||||
{"5分钟", coinank_enum.Minute5, 50},
|
||||
{"1小时", coinank_enum.Hour1, 50},
|
||||
{"4小时", coinank_enum.Hour4, 50},
|
||||
{"日线", coinank_enum.Day1, 30},
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
|
||||
for _, tf := range timeframes {
|
||||
t.Run(tf.name, func(t *testing.T) {
|
||||
// 使用 coinank 免费 API 获取 ETH K线数据
|
||||
coinankKlines, err := coinank_api.Kline(ctx, "ETHUSDT", coinank_enum.Binance,
|
||||
now.UnixMilli(), coinank_enum.To, tf.size, tf.interval)
|
||||
if err != nil {
|
||||
t.Fatalf("获取 %s K线失败: %v", tf.name, err)
|
||||
}
|
||||
|
||||
if len(coinankKlines) < 26 {
|
||||
t.Skipf("K线数量不足: %d", len(coinankKlines))
|
||||
return
|
||||
}
|
||||
|
||||
// 转换为 market.Kline
|
||||
klines := coinankKlinesToMarket(coinankKlines)
|
||||
|
||||
t.Logf("获取到 %d 根 ETH %s K线", len(klines), tf.name)
|
||||
t.Logf("最新收盘价: %.2f, 时间: %s",
|
||||
klines[len(klines)-1].Close,
|
||||
time.UnixMilli(klines[len(klines)-1].CloseTime).Format("2006-01-02 15:04"))
|
||||
|
||||
// 本地计算
|
||||
localResult := calculateLocalIndicators(klines)
|
||||
t.Log("\n===== 本地计算 =====")
|
||||
t.Logf(" EMA12: %.2f, EMA26: %.2f, MACD: %.4f",
|
||||
localResult.EMA12, localResult.EMA26, localResult.MACD)
|
||||
t.Logf(" RSI14: %.2f", localResult.RSI14)
|
||||
t.Logf(" BOLL: %.2f / %.2f / %.2f",
|
||||
localResult.BOLLUp, localResult.BOLLMid, localResult.BOLLLow)
|
||||
t.Logf(" ATR14: %.4f", localResult.ATR14)
|
||||
|
||||
// AI 计算 - 使用简化 prompt(只发收盘价)
|
||||
prompt := buildSimpleIndicatorPrompt(klines)
|
||||
t.Logf("\nPrompt 长度: %d 字符", len(prompt))
|
||||
|
||||
start := time.Now()
|
||||
resp, err := agent.Chat(ctx, prompt)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("AI 调用失败: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("AI 响应耗时: %v", elapsed)
|
||||
|
||||
// 解析 AI 结果
|
||||
aiResult, err := extractJSONFromResponse(resp.Output.Text)
|
||||
if err != nil {
|
||||
t.Logf("AI 原始响应:\n%s", resp.Output.Text[:min(500, len(resp.Output.Text))])
|
||||
t.Fatalf("解析失败: %v", err)
|
||||
}
|
||||
|
||||
t.Log("\n===== AI 计算 =====")
|
||||
t.Logf(" EMA12: %.2f, EMA26: %.2f, MACD: %.4f",
|
||||
aiResult.EMA12, aiResult.EMA26, aiResult.MACD)
|
||||
t.Logf(" RSI14: %.2f", aiResult.RSI14)
|
||||
t.Logf(" BOLL: %.2f / %.2f / %.2f",
|
||||
aiResult.BOLLUp, aiResult.BOLLMid, aiResult.BOLLLow)
|
||||
|
||||
// 对比误差
|
||||
t.Log("\n===== 误差对比 =====")
|
||||
errors := compareIndicators(localResult, aiResult)
|
||||
totalErr := 0.0
|
||||
for name, errPct := range errors {
|
||||
status := "✓"
|
||||
if errPct > 1 {
|
||||
status = "⚠"
|
||||
}
|
||||
if errPct > 5 {
|
||||
status = "✗"
|
||||
}
|
||||
t.Logf(" %s %-10s: %.2f%%", status, name, errPct)
|
||||
totalErr += errPct
|
||||
}
|
||||
|
||||
avgErr := totalErr / float64(len(errors))
|
||||
t.Logf("\n 平均误差: %.2f%%", avgErr)
|
||||
|
||||
if avgErr < 1 {
|
||||
t.Log(" ✓ AI 计算精度优秀!")
|
||||
} else if avgErr < 5 {
|
||||
t.Log(" ⚠ AI 计算精度良好")
|
||||
} else {
|
||||
t.Log(" ✗ AI 计算误差较大")
|
||||
}
|
||||
|
||||
// 等待避免请求过快
|
||||
time.Sleep(2 * time.Second)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestQwenETHIndicatorComparison ETH 指标对比:使用 Coinank 免费 API + Qwen 标准 API
|
||||
func TestQwenETHIndicatorComparison(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
agent := NewQwenAgent(QwenAppID, QwenAPIKey)
|
||||
|
||||
// 使用 coinank 免费 API 获取 ETH 1小时 K线
|
||||
now := time.Now()
|
||||
coinankKlines, err := coinank_api.Kline(ctx, "ETHUSDT", coinank_enum.Binance,
|
||||
now.UnixMilli(), coinank_enum.To, 30, coinank_enum.Hour1)
|
||||
if err != nil {
|
||||
t.Fatalf("获取 K线失败: %v", err)
|
||||
}
|
||||
|
||||
// 转换为 market.Kline
|
||||
klines := coinankKlinesToMarket(coinankKlines)
|
||||
|
||||
t.Logf("获取到 %d 根 ETH 1h K线", len(klines))
|
||||
|
||||
// 只用收盘价,简化 prompt
|
||||
var prices []string
|
||||
for _, k := range klines {
|
||||
prices = append(prices, fmt.Sprintf("%.2f", k.Close))
|
||||
}
|
||||
|
||||
// 本地计算
|
||||
localResult := calculateLocalIndicators(klines)
|
||||
|
||||
t.Log("\n===== 本地计算结果 =====")
|
||||
t.Logf("SMA20: %.2f", localResult.SMA20)
|
||||
t.Logf("EMA12: %.2f", localResult.EMA12)
|
||||
t.Logf("EMA26: %.2f", localResult.EMA26)
|
||||
t.Logf("MACD: %.4f", localResult.MACD)
|
||||
t.Logf("RSI14: %.2f", localResult.RSI14)
|
||||
|
||||
// 简化的 AI prompt
|
||||
prompt := fmt.Sprintf(`ETH 最近30根1小时K线收盘价(从旧到新):
|
||||
[%s]
|
||||
|
||||
请计算以下指标并返回纯 JSON:
|
||||
1. sma20: 最后20个价格的简单移动平均
|
||||
2. ema12: 12周期EMA(初始值用前12个价格的SMA,乘数=2/13)
|
||||
3. ema26: 26周期EMA(初始值用前26个价格的SMA,乘数=2/27)
|
||||
4. macd: EMA12 - EMA26
|
||||
5. rsi14: 14周期RSI(Wilder平滑法)
|
||||
|
||||
只返回JSON格式: {"sma20":数值,"ema12":数值,"ema26":数值,"macd":数值,"rsi14":数值}
|
||||
不要任何解释文字`, strings.Join(prices, ", "))
|
||||
|
||||
t.Logf("\n发送 Prompt (%d 字符)", len(prompt))
|
||||
|
||||
// 使用标准 API
|
||||
resp, err := agent.ChatWithModel(ctx, "qwen-max", prompt)
|
||||
if err != nil {
|
||||
t.Fatalf("AI 调用失败: %v", err)
|
||||
}
|
||||
|
||||
aiText := resp.GetContent()
|
||||
t.Logf("\nAI 响应:\n%s", aiText)
|
||||
|
||||
// 解析
|
||||
var aiResult struct {
|
||||
SMA20 float64 `json:"sma20"`
|
||||
EMA12 float64 `json:"ema12"`
|
||||
EMA26 float64 `json:"ema26"`
|
||||
MACD float64 `json:"macd"`
|
||||
RSI14 float64 `json:"rsi14"`
|
||||
}
|
||||
|
||||
start := strings.Index(aiText, "{")
|
||||
end := strings.LastIndex(aiText, "}")
|
||||
if start != -1 && end > start {
|
||||
if err := json.Unmarshal([]byte(aiText[start:end+1]), &aiResult); err != nil {
|
||||
t.Logf("JSON 解析失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
t.Log("\n===== AI 计算结果 =====")
|
||||
t.Logf("SMA20: %.2f", aiResult.SMA20)
|
||||
t.Logf("EMA12: %.2f", aiResult.EMA12)
|
||||
t.Logf("EMA26: %.2f", aiResult.EMA26)
|
||||
t.Logf("MACD: %.4f", aiResult.MACD)
|
||||
t.Logf("RSI14: %.2f", aiResult.RSI14)
|
||||
|
||||
// 计算误差
|
||||
t.Log("\n===== 误差 =====")
|
||||
calcErr := func(name string, local, ai float64) {
|
||||
if local == 0 {
|
||||
t.Logf(" %s: 本地=0, AI=%.2f", name, ai)
|
||||
return
|
||||
}
|
||||
errPct := math.Abs(local-ai) / math.Abs(local) * 100
|
||||
status := "✓"
|
||||
if errPct > 1 {
|
||||
status = "⚠"
|
||||
}
|
||||
if errPct > 5 {
|
||||
status = "✗"
|
||||
}
|
||||
t.Logf(" %s %s: 本地=%.2f, AI=%.2f, 误差=%.2f%%", status, name, local, ai, errPct)
|
||||
}
|
||||
|
||||
calcErr("SMA20", localResult.SMA20, aiResult.SMA20)
|
||||
calcErr("EMA12", localResult.EMA12, aiResult.EMA12)
|
||||
calcErr("EMA26", localResult.EMA26, aiResult.EMA26)
|
||||
calcErr("MACD", localResult.MACD, aiResult.MACD)
|
||||
calcErr("RSI14", localResult.RSI14, aiResult.RSI14)
|
||||
}
|
||||
46
main.go
46
main.go
@@ -3,14 +3,15 @@ package main
|
||||
import (
|
||||
"nofx/api"
|
||||
"nofx/auth"
|
||||
"nofx/backtest"
|
||||
"nofx/config"
|
||||
"nofx/crypto"
|
||||
"nofx/experience"
|
||||
"nofx/telemetry"
|
||||
"nofx/logger"
|
||||
"nofx/manager"
|
||||
"nofx/mcp"
|
||||
_ "nofx/mcp/payment"
|
||||
_ "nofx/mcp/provider"
|
||||
"nofx/store"
|
||||
"nofx/telegram"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
@@ -78,7 +79,6 @@ func main() {
|
||||
logger.Fatalf("❌ Failed to initialize database: %v", err)
|
||||
}
|
||||
defer st.Close()
|
||||
backtest.UseDatabase(st.DB())
|
||||
|
||||
// Initialize installation ID for experience improvement (anonymous statistics)
|
||||
initInstallationID(st)
|
||||
@@ -95,13 +95,8 @@ func main() {
|
||||
// time.Sleep(500 * time.Millisecond)
|
||||
logger.Info("📊 Using CoinAnk API for all market data (WebSocket cache disabled)")
|
||||
|
||||
// Create TraderManager and BacktestManager
|
||||
// Create TraderManager
|
||||
traderManager := manager.NewTraderManager()
|
||||
mcpClient := newSharedMCPClient()
|
||||
backtestManager := backtest.NewManager(mcpClient)
|
||||
if err := backtestManager.RestoreRuns(); err != nil {
|
||||
logger.Warnf("⚠️ Failed to restore backtest history: %v", err)
|
||||
}
|
||||
|
||||
// Load all traders from database to memory (may auto-start traders with IsRunning=true)
|
||||
if err := traderManager.LoadTradersFromStore(st); err != nil {
|
||||
@@ -123,19 +118,32 @@ func main() {
|
||||
if t.IsRunning {
|
||||
status = "✅ Running"
|
||||
}
|
||||
logger.Infof(" • %s [%s] %s - AI Model: %s, Exchange: %s",
|
||||
t.Name, t.ID[:8], status, t.AIModelID, t.ExchangeID)
|
||||
idShort := t.ID
|
||||
if len(idShort) > 8 {
|
||||
idShort = idShort[:8]
|
||||
}
|
||||
logger.Infof(" • %s [%s] %s - AI Model: %s, Exchange: %s",
|
||||
t.Name, idShort, status, t.AIModelID, t.ExchangeID)
|
||||
}
|
||||
}
|
||||
|
||||
// Start API server
|
||||
server := api.NewServer(traderManager, st, cryptoService, backtestManager, cfg.APIServerPort)
|
||||
server := api.NewServer(traderManager, st, cryptoService, cfg.APIServerPort)
|
||||
|
||||
// Create hot-reload channel for Telegram bot; wire it to the API server
|
||||
// so that POST /api/telegram can trigger a bot restart when the token changes.
|
||||
telegramReloadCh := make(chan struct{}, 1)
|
||||
server.SetTelegramReloadCh(telegramReloadCh)
|
||||
|
||||
go func() {
|
||||
if err := server.Start(); err != nil {
|
||||
logger.Fatalf("❌ Failed to start API server: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Start Telegram bot (if TELEGRAM_BOT_TOKEN is configured)
|
||||
go telegram.Start(cfg, st, telegramReloadCh)
|
||||
|
||||
// Wait for interrupt signal
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||
@@ -151,16 +159,6 @@ func main() {
|
||||
logger.Info("✅ System shut down safely")
|
||||
}
|
||||
|
||||
// newSharedMCPClient creates a shared MCP AI client (for backtesting)
|
||||
func newSharedMCPClient() mcp.AIClient {
|
||||
apiKey := os.Getenv("DEEPSEEK_API_KEY")
|
||||
if apiKey == "" {
|
||||
logger.Warn("⚠️ DEEPSEEK_API_KEY not set, AI features will be unavailable")
|
||||
return nil
|
||||
}
|
||||
return mcp.NewDeepSeekClient()
|
||||
}
|
||||
|
||||
// initInstallationID initializes the anonymous installation ID for experience improvement
|
||||
// This ID is persisted in database and used for anonymous usage statistics
|
||||
func initInstallationID(st *store.Store) {
|
||||
@@ -182,5 +180,5 @@ func initInstallationID(st *store.Store) {
|
||||
}
|
||||
|
||||
// Set installation ID in experience module
|
||||
experience.SetInstallationID(installationID)
|
||||
telemetry.SetInstallationID(installationID)
|
||||
}
|
||||
|
||||
@@ -3,8 +3,6 @@ package manager
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"nofx/debate"
|
||||
"nofx/kernel"
|
||||
"nofx/logger"
|
||||
"nofx/store"
|
||||
"nofx/trader"
|
||||
@@ -13,27 +11,6 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// TraderExecutorAdapter wraps AutoTrader to implement debate.TraderExecutor
|
||||
type TraderExecutorAdapter struct {
|
||||
autoTrader *trader.AutoTrader
|
||||
}
|
||||
|
||||
// ExecuteDecision executes a trading decision
|
||||
func (a *TraderExecutorAdapter) ExecuteDecision(d *kernel.Decision) error {
|
||||
return a.autoTrader.ExecuteDecision(d)
|
||||
}
|
||||
|
||||
// GetBalance returns account balance
|
||||
func (a *TraderExecutorAdapter) GetBalance() (map[string]interface{}, error) {
|
||||
info, err := a.autoTrader.GetAccountInfo()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get account info: %w", err)
|
||||
}
|
||||
// Log the balance for debugging
|
||||
logger.Infof("[Debate] GetBalance for trader, result: %+v", info)
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// CompetitionCache competition data cache
|
||||
type CompetitionCache struct {
|
||||
data map[string]interface{}
|
||||
@@ -292,8 +269,8 @@ func (tm *TraderManager) getConcurrentTraderData(traders []*trader.AutoTrader) [
|
||||
// Concurrently fetch data for each trader
|
||||
for i, t := range traders {
|
||||
go func(index int, trader *trader.AutoTrader) {
|
||||
// Set timeout to 3 seconds for single trader
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
// Set timeout to 10 seconds for single trader (increased from 3s for DEX reliability)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Use channel for timeout control
|
||||
@@ -330,7 +307,7 @@ func (tm *TraderManager) getConcurrentTraderData(traders []*trader.AutoTrader) [
|
||||
}
|
||||
case err := <-errorChan:
|
||||
// Failed to get account info
|
||||
logger.Infof("⚠️ Failed to get account info for trader %s: %v", trader.GetID(), err)
|
||||
logger.Infof("⚠️ Failed to get account info for trader %s (%s/%s): %v", trader.GetName(), trader.GetID(), trader.GetExchange(), err)
|
||||
traderData = map[string]interface{}{
|
||||
"trader_id": trader.GetID(),
|
||||
"trader_name": trader.GetName(),
|
||||
@@ -347,7 +324,7 @@ func (tm *TraderManager) getConcurrentTraderData(traders []*trader.AutoTrader) [
|
||||
}
|
||||
case <-ctx.Done():
|
||||
// Timeout
|
||||
logger.Infof("⏰ Timeout getting account info for trader %s", trader.GetID())
|
||||
logger.Infof("⏰ Timeout (10s) getting account info for trader %s (%s/%s)", trader.GetName(), trader.GetID(), trader.GetExchange())
|
||||
traderData = map[string]interface{}{
|
||||
"trader_id": trader.GetID(),
|
||||
"trader_name": trader.GetName(),
|
||||
@@ -407,7 +384,6 @@ func (tm *TraderManager) GetTopTradersData() (map[string]interface{}, error) {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
|
||||
// RemoveTrader removes a trader from memory (does not affect database)
|
||||
// Used to force reload when updating trader configuration
|
||||
// If the trader is running, it will be stopped first
|
||||
@@ -664,11 +640,11 @@ func (tm *TraderManager) addTraderFromStore(traderCfg *store.Trader, aiModelCfg
|
||||
QwenKey: "",
|
||||
CustomAPIURL: aiModelCfg.CustomAPIURL,
|
||||
CustomModelName: aiModelCfg.CustomModelName,
|
||||
ScanInterval: time.Duration(traderCfg.ScanIntervalMinutes) * time.Minute,
|
||||
InitialBalance: traderCfg.InitialBalance,
|
||||
IsCrossMargin: traderCfg.IsCrossMargin,
|
||||
ShowInCompetition: traderCfg.ShowInCompetition,
|
||||
StrategyConfig: strategyConfig,
|
||||
ScanInterval: time.Duration(traderCfg.ScanIntervalMinutes) * time.Minute,
|
||||
InitialBalance: traderCfg.InitialBalance,
|
||||
IsCrossMargin: traderCfg.IsCrossMargin,
|
||||
ShowInCompetition: traderCfg.ShowInCompetition,
|
||||
StrategyConfig: strategyConfig,
|
||||
}
|
||||
|
||||
logger.Infof("📊 Loading trader %s: ScanIntervalMinutes=%d (from DB), ScanInterval=%v",
|
||||
@@ -690,9 +666,17 @@ func (tm *TraderManager) addTraderFromStore(traderCfg *store.Trader, aiModelCfg
|
||||
traderConfig.BitgetAPIKey = string(exchangeCfg.APIKey)
|
||||
traderConfig.BitgetSecretKey = string(exchangeCfg.SecretKey)
|
||||
traderConfig.BitgetPassphrase = string(exchangeCfg.Passphrase)
|
||||
case "gate":
|
||||
traderConfig.GateAPIKey = string(exchangeCfg.APIKey)
|
||||
traderConfig.GateSecretKey = string(exchangeCfg.SecretKey)
|
||||
case "kucoin":
|
||||
traderConfig.KuCoinAPIKey = string(exchangeCfg.APIKey)
|
||||
traderConfig.KuCoinSecretKey = string(exchangeCfg.SecretKey)
|
||||
traderConfig.KuCoinPassphrase = string(exchangeCfg.Passphrase)
|
||||
case "hyperliquid":
|
||||
traderConfig.HyperliquidPrivateKey = string(exchangeCfg.APIKey)
|
||||
traderConfig.HyperliquidWalletAddr = exchangeCfg.HyperliquidWalletAddr
|
||||
traderConfig.HyperliquidUnifiedAcct = exchangeCfg.HyperliquidUnifiedAcct
|
||||
case "aster":
|
||||
traderConfig.AsterUser = exchangeCfg.AsterUser
|
||||
traderConfig.AsterSigner = exchangeCfg.AsterSigner
|
||||
@@ -703,6 +687,9 @@ func (tm *TraderManager) addTraderFromStore(traderCfg *store.Trader, aiModelCfg
|
||||
traderConfig.LighterAPIKeyPrivateKey = string(exchangeCfg.LighterAPIKeyPrivateKey)
|
||||
traderConfig.LighterAPIKeyIndex = exchangeCfg.LighterAPIKeyIndex
|
||||
traderConfig.LighterTestnet = exchangeCfg.Testnet
|
||||
case "indodax":
|
||||
traderConfig.IndodaxAPIKey = string(exchangeCfg.APIKey)
|
||||
traderConfig.IndodaxSecretKey = string(exchangeCfg.SecretKey)
|
||||
}
|
||||
|
||||
// Set API keys based on AI model (convert EncryptedString to string)
|
||||
@@ -754,12 +741,3 @@ func (tm *TraderManager) addTraderFromStore(traderCfg *store.Trader, aiModelCfg
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetTraderExecutor returns a TraderExecutor for the given trader ID
|
||||
// This is used by the debate module to execute consensus trades
|
||||
func (tm *TraderManager) GetTraderExecutor(traderID string) (debate.TraderExecutor, error) {
|
||||
at, err := tm.GetTrader(traderID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &TraderExecutorAdapter{autoTrader: at}, nil
|
||||
}
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
package manager
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestRemoveTrader tests removing trader from memory
|
||||
func TestRemoveTrader(t *testing.T) {
|
||||
tm := NewTraderManager()
|
||||
|
||||
// Create a mock trader and add it to map
|
||||
traderID := "test-trader-123"
|
||||
tm.traders[traderID] = nil // Use nil as placeholder, only need to verify deletion logic in test
|
||||
|
||||
// Verify trader exists
|
||||
if _, exists := tm.traders[traderID]; !exists {
|
||||
t.Fatal("trader should exist in map")
|
||||
}
|
||||
|
||||
// Call RemoveTrader
|
||||
tm.RemoveTrader(traderID)
|
||||
|
||||
// Verify trader has been removed
|
||||
if _, exists := tm.traders[traderID]; exists {
|
||||
t.Error("trader should be removed from map")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRemoveTrader_NonExistent tests that removing non-existent trader doesn't error
|
||||
func TestRemoveTrader_NonExistent(t *testing.T) {
|
||||
tm := NewTraderManager()
|
||||
|
||||
// Trying to remove non-existent trader should not panic
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Errorf("removing non-existent trader should not panic: %v", r)
|
||||
}
|
||||
}()
|
||||
|
||||
tm.RemoveTrader("non-existent-trader")
|
||||
}
|
||||
|
||||
// TestRemoveTrader_Concurrent tests concurrent removal of trader safety
|
||||
func TestRemoveTrader_Concurrent(t *testing.T) {
|
||||
tm := NewTraderManager()
|
||||
traderID := "test-trader-concurrent"
|
||||
|
||||
// Add trader
|
||||
tm.traders[traderID] = nil
|
||||
|
||||
// Concurrently call RemoveTrader
|
||||
done := make(chan bool, 10)
|
||||
for i := 0; i < 10; i++ {
|
||||
go func() {
|
||||
tm.RemoveTrader(traderID)
|
||||
done <- true
|
||||
}()
|
||||
}
|
||||
|
||||
// Wait for all goroutines to complete
|
||||
for i := 0; i < 10; i++ {
|
||||
<-done
|
||||
}
|
||||
|
||||
// Verify trader has been removed
|
||||
if _, exists := tm.traders[traderID]; exists {
|
||||
t.Error("trader should be removed from map")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetTrader_AfterRemove tests that getting trader after removal returns error
|
||||
func TestGetTrader_AfterRemove(t *testing.T) {
|
||||
tm := NewTraderManager()
|
||||
traderID := "test-trader-get"
|
||||
|
||||
// Add trader
|
||||
tm.traders[traderID] = nil
|
||||
|
||||
// Remove trader
|
||||
tm.RemoveTrader(traderID)
|
||||
|
||||
// Try to get removed trader
|
||||
_, err := tm.GetTrader(traderID)
|
||||
if err == nil {
|
||||
t.Error("getting removed trader should return error")
|
||||
}
|
||||
}
|
||||
564
market/data.go
564
market/data.go
@@ -1,15 +1,11 @@
|
||||
package market
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"nofx/logger"
|
||||
"nofx/provider/coinank/coinank_api"
|
||||
"nofx/provider/coinank/coinank_enum"
|
||||
"nofx/provider/hyperliquid"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -28,114 +24,13 @@ var (
|
||||
frCacheTTL = 1 * time.Hour
|
||||
)
|
||||
|
||||
// Note: Kline data now uses free/open API (coinank_api.Kline) which doesn't require authentication
|
||||
|
||||
// getKlinesFromCoinAnk fetches kline data from CoinAnk API (replacement for WSMonitorCli)
|
||||
func getKlinesFromCoinAnk(symbol, interval string, limit int) ([]Kline, error) {
|
||||
// Map interval string to coinank enum
|
||||
var coinankInterval coinank_enum.Interval
|
||||
switch interval {
|
||||
case "1m":
|
||||
coinankInterval = coinank_enum.Minute1
|
||||
case "3m":
|
||||
coinankInterval = coinank_enum.Minute3
|
||||
case "5m":
|
||||
coinankInterval = coinank_enum.Minute5
|
||||
case "15m":
|
||||
coinankInterval = coinank_enum.Minute15
|
||||
case "30m":
|
||||
coinankInterval = coinank_enum.Minute30
|
||||
case "1h":
|
||||
coinankInterval = coinank_enum.Hour1
|
||||
case "2h":
|
||||
coinankInterval = coinank_enum.Hour2
|
||||
case "4h":
|
||||
coinankInterval = coinank_enum.Hour4
|
||||
case "6h":
|
||||
coinankInterval = coinank_enum.Hour6
|
||||
case "8h":
|
||||
coinankInterval = coinank_enum.Hour8
|
||||
case "12h":
|
||||
coinankInterval = coinank_enum.Hour12
|
||||
case "1d":
|
||||
coinankInterval = coinank_enum.Day1
|
||||
case "3d":
|
||||
coinankInterval = coinank_enum.Day3
|
||||
case "1w":
|
||||
coinankInterval = coinank_enum.Week1
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported interval: %s", interval)
|
||||
}
|
||||
|
||||
// Call CoinAnk free/open API (no authentication required)
|
||||
ctx := context.Background()
|
||||
ts := time.Now().UnixMilli()
|
||||
// Use "To" side to search backward from current time (get historical klines)
|
||||
coinankKlines, err := coinank_api.Kline(ctx, symbol, coinank_enum.Binance, ts, coinank_enum.To, limit, coinankInterval)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("CoinAnk API error: %w", err)
|
||||
}
|
||||
|
||||
// Convert coinank kline format to market.Kline format
|
||||
klines := make([]Kline, len(coinankKlines))
|
||||
for i, ck := range coinankKlines {
|
||||
klines[i] = Kline{
|
||||
OpenTime: ck.StartTime,
|
||||
Open: ck.Open,
|
||||
High: ck.High,
|
||||
Low: ck.Low,
|
||||
Close: ck.Close,
|
||||
Volume: ck.Volume,
|
||||
CloseTime: ck.EndTime,
|
||||
}
|
||||
}
|
||||
|
||||
return klines, nil
|
||||
}
|
||||
|
||||
// getKlinesFromHyperliquid fetches kline data from Hyperliquid API for xyz dex assets
|
||||
func getKlinesFromHyperliquid(symbol, interval string, limit int) ([]Kline, error) {
|
||||
// Remove xyz: prefix if present for the API call
|
||||
baseCoin := strings.TrimPrefix(symbol, "xyz:")
|
||||
|
||||
// Map interval to Hyperliquid format
|
||||
hlInterval := hyperliquid.MapTimeframe(interval)
|
||||
|
||||
// Create Hyperliquid client
|
||||
client := hyperliquid.NewClient()
|
||||
|
||||
// Fetch candles
|
||||
ctx := context.Background()
|
||||
candles, err := client.GetCandles(ctx, baseCoin, hlInterval, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Hyperliquid API error: %w", err)
|
||||
}
|
||||
|
||||
// Convert to market.Kline format
|
||||
klines := make([]Kline, len(candles))
|
||||
for i, c := range candles {
|
||||
open, _ := strconv.ParseFloat(c.Open, 64)
|
||||
high, _ := strconv.ParseFloat(c.High, 64)
|
||||
low, _ := strconv.ParseFloat(c.Low, 64)
|
||||
closePrice, _ := strconv.ParseFloat(c.Close, 64)
|
||||
volume, _ := strconv.ParseFloat(c.Volume, 64)
|
||||
|
||||
klines[i] = Kline{
|
||||
OpenTime: c.OpenTime,
|
||||
Open: open,
|
||||
High: high,
|
||||
Low: low,
|
||||
Close: closePrice,
|
||||
Volume: volume,
|
||||
CloseTime: c.CloseTime,
|
||||
}
|
||||
}
|
||||
|
||||
return klines, nil
|
||||
}
|
||||
|
||||
// Get retrieves market data for the specified token
|
||||
// Get retrieves market data for the specified token (uses Binance data by default)
|
||||
func Get(symbol string) (*Data, error) {
|
||||
return GetWithExchange(symbol, "binance")
|
||||
}
|
||||
|
||||
// GetWithExchange retrieves market data for the specified token using exchange-specific data
|
||||
func GetWithExchange(symbol, exchange string) (*Data, error) {
|
||||
var klines3m, klines4h []Kline
|
||||
var err error
|
||||
// Normalize symbol
|
||||
@@ -144,18 +39,21 @@ func Get(symbol string) (*Data, error) {
|
||||
// Check if this is an xyz dex asset (use Hyperliquid API)
|
||||
isXyzAsset := IsXyzDexAsset(symbol)
|
||||
|
||||
// For hyperliquid exchange, also use Hyperliquid API
|
||||
useHyperliquidAPI := isXyzAsset || strings.ToLower(exchange) == "hyperliquid"
|
||||
|
||||
// Get 3-minute K-line data (or 5-minute for xyz assets as 3m may not be available)
|
||||
if isXyzAsset {
|
||||
if useHyperliquidAPI {
|
||||
// Use Hyperliquid API for xyz dex assets (use 5m since 3m may not be available)
|
||||
klines3m, err = getKlinesFromHyperliquid(symbol, "5m", 100)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Failed to get 5-minute K-line from Hyperliquid: %v", err)
|
||||
}
|
||||
} else {
|
||||
// Use CoinAnk for regular crypto assets
|
||||
klines3m, err = getKlinesFromCoinAnk(symbol, "3m", 100)
|
||||
// Use CoinAnk for regular crypto assets with exchange-specific data
|
||||
klines3m, err = getKlinesFromCoinAnk(symbol, "3m", exchange, 100)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Failed to get 3-minute K-line from CoinAnk: %v", err)
|
||||
return nil, fmt.Errorf("Failed to get 3-minute K-line from CoinAnk (%s): %v", exchange, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,15 +64,15 @@ func Get(symbol string) (*Data, error) {
|
||||
}
|
||||
|
||||
// Get 4-hour K-line data
|
||||
if isXyzAsset {
|
||||
if useHyperliquidAPI {
|
||||
klines4h, err = getKlinesFromHyperliquid(symbol, "4h", 100)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Failed to get 4-hour K-line from Hyperliquid: %v", err)
|
||||
}
|
||||
} else {
|
||||
klines4h, err = getKlinesFromCoinAnk(symbol, "4h", 100)
|
||||
klines4h, err = getKlinesFromCoinAnk(symbol, "4h", exchange, 100)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Failed to get 4-hour K-line from CoinAnk: %v", err)
|
||||
return nil, fmt.Errorf("Failed to get 4-hour K-line from CoinAnk (%s): %v", exchange, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -290,8 +188,8 @@ func GetWithTimeframes(symbol string, timeframes []string, primaryTimeframe stri
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
// Use CoinAnk for regular crypto assets
|
||||
klines, err = getKlinesFromCoinAnk(symbol, tf, 200)
|
||||
// Use CoinAnk for regular crypto assets (default to Binance)
|
||||
klines, err = getKlinesFromCoinAnk(symbol, tf, "binance", 200)
|
||||
if err != nil {
|
||||
logger.Infof("⚠️ Failed to get %s %s K-line from CoinAnk: %v", symbol, tf, err)
|
||||
continue
|
||||
@@ -357,398 +255,6 @@ func GetWithTimeframes(symbol string, timeframes []string, primaryTimeframe stri
|
||||
}, nil
|
||||
}
|
||||
|
||||
// calculateTimeframeSeries calculates series data for a single timeframe
|
||||
func calculateTimeframeSeries(klines []Kline, timeframe string, count int) *TimeframeSeriesData {
|
||||
if count <= 0 {
|
||||
count = 10 // default
|
||||
}
|
||||
|
||||
data := &TimeframeSeriesData{
|
||||
Timeframe: timeframe,
|
||||
Klines: make([]KlineBar, 0, count),
|
||||
MidPrices: make([]float64, 0, count),
|
||||
EMA20Values: make([]float64, 0, count),
|
||||
EMA50Values: make([]float64, 0, count),
|
||||
MACDValues: make([]float64, 0, count),
|
||||
RSI7Values: make([]float64, 0, count),
|
||||
RSI14Values: make([]float64, 0, count),
|
||||
Volume: make([]float64, 0, count),
|
||||
BOLLUpper: make([]float64, 0, count),
|
||||
BOLLMiddle: make([]float64, 0, count),
|
||||
BOLLLower: make([]float64, 0, count),
|
||||
}
|
||||
|
||||
// Get latest N data points based on count from config
|
||||
start := len(klines) - count
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
|
||||
for i := start; i < len(klines); i++ {
|
||||
// Store full OHLCV kline data
|
||||
data.Klines = append(data.Klines, KlineBar{
|
||||
Time: klines[i].OpenTime,
|
||||
Open: klines[i].Open,
|
||||
High: klines[i].High,
|
||||
Low: klines[i].Low,
|
||||
Close: klines[i].Close,
|
||||
Volume: klines[i].Volume,
|
||||
})
|
||||
|
||||
// Keep MidPrices and Volume for backward compatibility
|
||||
data.MidPrices = append(data.MidPrices, klines[i].Close)
|
||||
data.Volume = append(data.Volume, klines[i].Volume)
|
||||
|
||||
// Calculate EMA20 for each point
|
||||
if i >= 19 {
|
||||
ema20 := calculateEMA(klines[:i+1], 20)
|
||||
data.EMA20Values = append(data.EMA20Values, ema20)
|
||||
}
|
||||
|
||||
// Calculate EMA50 for each point
|
||||
if i >= 49 {
|
||||
ema50 := calculateEMA(klines[:i+1], 50)
|
||||
data.EMA50Values = append(data.EMA50Values, ema50)
|
||||
}
|
||||
|
||||
// Calculate MACD for each point
|
||||
if i >= 25 {
|
||||
macd := calculateMACD(klines[:i+1])
|
||||
data.MACDValues = append(data.MACDValues, macd)
|
||||
}
|
||||
|
||||
// Calculate RSI for each point
|
||||
if i >= 7 {
|
||||
rsi7 := calculateRSI(klines[:i+1], 7)
|
||||
data.RSI7Values = append(data.RSI7Values, rsi7)
|
||||
}
|
||||
if i >= 14 {
|
||||
rsi14 := calculateRSI(klines[:i+1], 14)
|
||||
data.RSI14Values = append(data.RSI14Values, rsi14)
|
||||
}
|
||||
|
||||
// Calculate Bollinger Bands (period 20, std dev multiplier 2)
|
||||
if i >= 19 {
|
||||
upper, middle, lower := calculateBOLL(klines[:i+1], 20, 2.0)
|
||||
data.BOLLUpper = append(data.BOLLUpper, upper)
|
||||
data.BOLLMiddle = append(data.BOLLMiddle, middle)
|
||||
data.BOLLLower = append(data.BOLLLower, lower)
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate ATR14
|
||||
data.ATR14 = calculateATR(klines, 14)
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
// calculatePriceChangeByBars calculates how many K-lines to look back for price change based on timeframe
|
||||
func calculatePriceChangeByBars(klines []Kline, timeframe string, targetMinutes int) float64 {
|
||||
if len(klines) < 2 {
|
||||
return 0
|
||||
}
|
||||
|
||||
// Parse timeframe to minutes
|
||||
tfMinutes := parseTimeframeToMinutes(timeframe)
|
||||
if tfMinutes <= 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
// Calculate how many K-lines to look back
|
||||
barsBack := targetMinutes / tfMinutes
|
||||
if barsBack < 1 {
|
||||
barsBack = 1
|
||||
}
|
||||
|
||||
currentPrice := klines[len(klines)-1].Close
|
||||
idx := len(klines) - 1 - barsBack
|
||||
if idx < 0 {
|
||||
idx = 0
|
||||
}
|
||||
|
||||
oldPrice := klines[idx].Close
|
||||
if oldPrice > 0 {
|
||||
return ((currentPrice - oldPrice) / oldPrice) * 100
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// parseTimeframeToMinutes parses timeframe string to minutes
|
||||
func parseTimeframeToMinutes(tf string) int {
|
||||
switch tf {
|
||||
case "1m":
|
||||
return 1
|
||||
case "3m":
|
||||
return 3
|
||||
case "5m":
|
||||
return 5
|
||||
case "15m":
|
||||
return 15
|
||||
case "30m":
|
||||
return 30
|
||||
case "1h":
|
||||
return 60
|
||||
case "2h":
|
||||
return 120
|
||||
case "4h":
|
||||
return 240
|
||||
case "6h":
|
||||
return 360
|
||||
case "8h":
|
||||
return 480
|
||||
case "12h":
|
||||
return 720
|
||||
case "1d":
|
||||
return 1440
|
||||
case "3d":
|
||||
return 4320
|
||||
case "1w":
|
||||
return 10080
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// calculateEMA calculates EMA
|
||||
func calculateEMA(klines []Kline, period int) float64 {
|
||||
if len(klines) < period {
|
||||
return 0
|
||||
}
|
||||
|
||||
// Calculate SMA as initial EMA
|
||||
sum := 0.0
|
||||
for i := 0; i < period; i++ {
|
||||
sum += klines[i].Close
|
||||
}
|
||||
ema := sum / float64(period)
|
||||
|
||||
// Calculate EMA
|
||||
multiplier := 2.0 / float64(period+1)
|
||||
for i := period; i < len(klines); i++ {
|
||||
ema = (klines[i].Close-ema)*multiplier + ema
|
||||
}
|
||||
|
||||
return ema
|
||||
}
|
||||
|
||||
// calculateMACD calculates MACD
|
||||
func calculateMACD(klines []Kline) float64 {
|
||||
if len(klines) < 26 {
|
||||
return 0
|
||||
}
|
||||
|
||||
// Calculate 12-period and 26-period EMA
|
||||
ema12 := calculateEMA(klines, 12)
|
||||
ema26 := calculateEMA(klines, 26)
|
||||
|
||||
// MACD = EMA12 - EMA26
|
||||
return ema12 - ema26
|
||||
}
|
||||
|
||||
// calculateRSI calculates RSI
|
||||
func calculateRSI(klines []Kline, period int) float64 {
|
||||
if len(klines) <= period {
|
||||
return 0
|
||||
}
|
||||
|
||||
gains := 0.0
|
||||
losses := 0.0
|
||||
|
||||
// Calculate initial average gain/loss
|
||||
for i := 1; i <= period; i++ {
|
||||
change := klines[i].Close - klines[i-1].Close
|
||||
if change > 0 {
|
||||
gains += change
|
||||
} else {
|
||||
losses += -change
|
||||
}
|
||||
}
|
||||
|
||||
avgGain := gains / float64(period)
|
||||
avgLoss := losses / float64(period)
|
||||
|
||||
// Use Wilder smoothing method to calculate subsequent RSI
|
||||
for i := period + 1; i < len(klines); i++ {
|
||||
change := klines[i].Close - klines[i-1].Close
|
||||
if change > 0 {
|
||||
avgGain = (avgGain*float64(period-1) + change) / float64(period)
|
||||
avgLoss = (avgLoss * float64(period-1)) / float64(period)
|
||||
} else {
|
||||
avgGain = (avgGain * float64(period-1)) / float64(period)
|
||||
avgLoss = (avgLoss*float64(period-1) + (-change)) / float64(period)
|
||||
}
|
||||
}
|
||||
|
||||
if avgLoss == 0 {
|
||||
return 100
|
||||
}
|
||||
|
||||
rs := avgGain / avgLoss
|
||||
rsi := 100 - (100 / (1 + rs))
|
||||
|
||||
return rsi
|
||||
}
|
||||
|
||||
// calculateATR calculates ATR
|
||||
func calculateATR(klines []Kline, period int) float64 {
|
||||
if len(klines) <= period {
|
||||
return 0
|
||||
}
|
||||
|
||||
trs := make([]float64, len(klines))
|
||||
for i := 1; i < len(klines); i++ {
|
||||
high := klines[i].High
|
||||
low := klines[i].Low
|
||||
prevClose := klines[i-1].Close
|
||||
|
||||
tr1 := high - low
|
||||
tr2 := math.Abs(high - prevClose)
|
||||
tr3 := math.Abs(low - prevClose)
|
||||
|
||||
trs[i] = math.Max(tr1, math.Max(tr2, tr3))
|
||||
}
|
||||
|
||||
// Calculate initial ATR
|
||||
sum := 0.0
|
||||
for i := 1; i <= period; i++ {
|
||||
sum += trs[i]
|
||||
}
|
||||
atr := sum / float64(period)
|
||||
|
||||
// Wilder smoothing
|
||||
for i := period + 1; i < len(klines); i++ {
|
||||
atr = (atr*float64(period-1) + trs[i]) / float64(period)
|
||||
}
|
||||
|
||||
return atr
|
||||
}
|
||||
|
||||
// calculateBOLL calculates Bollinger Bands (upper, middle, lower)
|
||||
// period: typically 20, multiplier: typically 2
|
||||
func calculateBOLL(klines []Kline, period int, multiplier float64) (upper, middle, lower float64) {
|
||||
if len(klines) < period {
|
||||
return 0, 0, 0
|
||||
}
|
||||
|
||||
// Calculate SMA (middle band)
|
||||
sum := 0.0
|
||||
for i := len(klines) - period; i < len(klines); i++ {
|
||||
sum += klines[i].Close
|
||||
}
|
||||
sma := sum / float64(period)
|
||||
|
||||
// Calculate standard deviation
|
||||
variance := 0.0
|
||||
for i := len(klines) - period; i < len(klines); i++ {
|
||||
diff := klines[i].Close - sma
|
||||
variance += diff * diff
|
||||
}
|
||||
stdDev := math.Sqrt(variance / float64(period))
|
||||
|
||||
// Calculate bands
|
||||
middle = sma
|
||||
upper = sma + multiplier*stdDev
|
||||
lower = sma - multiplier*stdDev
|
||||
|
||||
return upper, middle, lower
|
||||
}
|
||||
|
||||
// calculateIntradaySeries calculates intraday series data
|
||||
func calculateIntradaySeries(klines []Kline) *IntradayData {
|
||||
data := &IntradayData{
|
||||
MidPrices: make([]float64, 0, 10),
|
||||
EMA20Values: make([]float64, 0, 10),
|
||||
MACDValues: make([]float64, 0, 10),
|
||||
RSI7Values: make([]float64, 0, 10),
|
||||
RSI14Values: make([]float64, 0, 10),
|
||||
Volume: make([]float64, 0, 10),
|
||||
}
|
||||
|
||||
// Get latest 10 data points
|
||||
start := len(klines) - 10
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
|
||||
for i := start; i < len(klines); i++ {
|
||||
data.MidPrices = append(data.MidPrices, klines[i].Close)
|
||||
data.Volume = append(data.Volume, klines[i].Volume)
|
||||
|
||||
// Calculate EMA20 for each point
|
||||
if i >= 19 {
|
||||
ema20 := calculateEMA(klines[:i+1], 20)
|
||||
data.EMA20Values = append(data.EMA20Values, ema20)
|
||||
}
|
||||
|
||||
// Calculate MACD for each point
|
||||
if i >= 25 {
|
||||
macd := calculateMACD(klines[:i+1])
|
||||
data.MACDValues = append(data.MACDValues, macd)
|
||||
}
|
||||
|
||||
// Calculate RSI for each point
|
||||
if i >= 7 {
|
||||
rsi7 := calculateRSI(klines[:i+1], 7)
|
||||
data.RSI7Values = append(data.RSI7Values, rsi7)
|
||||
}
|
||||
if i >= 14 {
|
||||
rsi14 := calculateRSI(klines[:i+1], 14)
|
||||
data.RSI14Values = append(data.RSI14Values, rsi14)
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate 3m ATR14
|
||||
data.ATR14 = calculateATR(klines, 14)
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
// calculateLongerTermData calculates longer-term data
|
||||
func calculateLongerTermData(klines []Kline) *LongerTermData {
|
||||
data := &LongerTermData{
|
||||
MACDValues: make([]float64, 0, 10),
|
||||
RSI14Values: make([]float64, 0, 10),
|
||||
}
|
||||
|
||||
// Calculate EMA
|
||||
data.EMA20 = calculateEMA(klines, 20)
|
||||
data.EMA50 = calculateEMA(klines, 50)
|
||||
|
||||
// Calculate ATR
|
||||
data.ATR3 = calculateATR(klines, 3)
|
||||
data.ATR14 = calculateATR(klines, 14)
|
||||
|
||||
// Calculate volume
|
||||
if len(klines) > 0 {
|
||||
data.CurrentVolume = klines[len(klines)-1].Volume
|
||||
// Calculate average volume
|
||||
sum := 0.0
|
||||
for _, k := range klines {
|
||||
sum += k.Volume
|
||||
}
|
||||
data.AverageVolume = sum / float64(len(klines))
|
||||
}
|
||||
|
||||
// Calculate MACD and RSI series
|
||||
start := len(klines) - 10
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
|
||||
for i := start; i < len(klines); i++ {
|
||||
if i >= 25 {
|
||||
macd := calculateMACD(klines[:i+1])
|
||||
data.MACDValues = append(data.MACDValues, macd)
|
||||
}
|
||||
if i >= 14 {
|
||||
rsi14 := calculateRSI(klines[:i+1], 14)
|
||||
data.RSI14Values = append(data.RSI14Values, rsi14)
|
||||
}
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
// getOpenInterestData retrieves OI data
|
||||
func getOpenInterestData(symbol string) (*OIData, error) {
|
||||
url := fmt.Sprintf("https://fapi.binance.com/fapi/v1/openInterest?symbol=%s", symbol)
|
||||
@@ -1068,6 +574,11 @@ func Normalize(symbol string) string {
|
||||
return "xyz:" + base
|
||||
}
|
||||
|
||||
// Remove exchange-specific separators (Gate uses BTC_USDT, OKX uses BTC-USDT-SWAP)
|
||||
symbol = strings.ReplaceAll(symbol, "_", "")
|
||||
symbol = strings.ReplaceAll(symbol, "-SWAP", "")
|
||||
symbol = strings.ReplaceAll(symbol, "-", "")
|
||||
|
||||
// For regular crypto assets
|
||||
if strings.HasSuffix(symbol, "USDT") {
|
||||
return symbol
|
||||
@@ -1091,7 +602,7 @@ func parseFloat(v interface{}) (float64, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// BuildDataFromKlines constructs market data snapshot from preloaded K-line series (for backtesting/simulation).
|
||||
// BuildDataFromKlines constructs market data snapshot from preloaded K-line series.
|
||||
func BuildDataFromKlines(symbol string, primary []Kline, longer []Kline) (*Data, error) {
|
||||
if len(primary) == 0 {
|
||||
return nil, fmt.Errorf("primary series is empty")
|
||||
@@ -1183,30 +694,3 @@ func isStaleData(klines []Kline, symbol string) bool {
|
||||
logger.Infof("⚠️ %s detected extreme price stability (no fluctuation for %d consecutive periods), but volume is normal", symbol, stalePriceThreshold)
|
||||
return false
|
||||
}
|
||||
|
||||
// ========== 导出的指标计算函数(供测试使用) ==========
|
||||
|
||||
// ExportCalculateEMA exports calculateEMA for testing
|
||||
func ExportCalculateEMA(klines []Kline, period int) float64 {
|
||||
return calculateEMA(klines, period)
|
||||
}
|
||||
|
||||
// ExportCalculateMACD exports calculateMACD for testing
|
||||
func ExportCalculateMACD(klines []Kline) float64 {
|
||||
return calculateMACD(klines)
|
||||
}
|
||||
|
||||
// ExportCalculateRSI exports calculateRSI for testing
|
||||
func ExportCalculateRSI(klines []Kline, period int) float64 {
|
||||
return calculateRSI(klines, period)
|
||||
}
|
||||
|
||||
// ExportCalculateATR exports calculateATR for testing
|
||||
func ExportCalculateATR(klines []Kline, period int) float64 {
|
||||
return calculateATR(klines, period)
|
||||
}
|
||||
|
||||
// ExportCalculateBOLL exports calculateBOLL for testing
|
||||
func ExportCalculateBOLL(klines []Kline, period int, multiplier float64) (upper, middle, lower float64) {
|
||||
return calculateBOLL(klines, period, multiplier)
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user