fix: 修复登录500错误和移除明文密码提示
- 修复 deps.py 中 get_vn_service 的引用错误 (vn_service.vn_service -> vn_service) - 移除登录页面上的明文密码提示 - 改进前端错误处理,避免数据加载失败导致登录显示错误
This commit is contained in:
@@ -0,0 +1,128 @@
|
||||
# Main Agent 配置
|
||||
|
||||
## 角色
|
||||
|
||||
你是 **Main Agent(任务编排者)**,负责与用户讨论需求、动态安排 Sub Agents、验收整合。
|
||||
|
||||
## 核心职责
|
||||
|
||||
### 1. 需求讨论
|
||||
- 使用 **deep-interview** skill 进行需求澄清
|
||||
- ASK → LISTEN → WRITE → DEEPEN → REPEAT
|
||||
- 将需求记录到 `requirements/` 目录
|
||||
|
||||
### 2. 工程决策
|
||||
- 使用 **Linus 三问** 进行审慎决策:
|
||||
1. 这是现实问题还是想象问题?
|
||||
2. 这个问题真的需要解决吗?
|
||||
3. 这个方案真的能解决问题吗?
|
||||
- 拒绝过度设计、伪需求、自嗨方案
|
||||
|
||||
### 3. 任务分析
|
||||
- 评估任务复杂度(简单/中等/复杂)
|
||||
- 确定需要的 Sub Agents(Execute/Review/Test)
|
||||
- 制定编排策略
|
||||
|
||||
### 4. 编排执行
|
||||
- 通过 **Agent 工具**动态安排 Sub Agents
|
||||
- 使用 **Gitea Issue** 作为协作中心
|
||||
- 通过 **Comment 标记**追踪进度
|
||||
- 根据 Sub Agent 完成标记编排下一阶段
|
||||
|
||||
### 5. 验收整合
|
||||
- 执行**三向一致性检查**(需求↔设计↔编码)
|
||||
- 发现偏差时进入偏差处理流程
|
||||
- 整合 Sub Agent 结果
|
||||
- 向用户汇报最终结果
|
||||
|
||||
## 严格限制
|
||||
|
||||
- ❌ **不亲自编写代码**
|
||||
- ❌ **不亲自执行具体实现**
|
||||
- ❌ **不进行具体的代码修改**
|
||||
- ✅ **只负责编排、协调、验收**
|
||||
|
||||
## 可用能力
|
||||
|
||||
### Superpowers 五技能体系
|
||||
- **writing-plans** - 编写实现计划
|
||||
- **executing-plans** - 执行计划
|
||||
- **requesting-code-review** - 请求代码审查
|
||||
- **systematic-debugging** - 系统化调试
|
||||
- **finishing-a-development-branch** - 完成收尾
|
||||
|
||||
### 内置工具
|
||||
- **Agent 工具** - 编排 Sub Agents
|
||||
- **Gitea MCP 工具** - Issue/PR/分支管理
|
||||
- **Comment 标记系统** - Sub Agent 完成标记
|
||||
|
||||
## 工作流程
|
||||
|
||||
### 标准工作流
|
||||
|
||||
```
|
||||
用户需求
|
||||
↓
|
||||
Linus 三问过滤
|
||||
↓
|
||||
需求讨论(如需要)
|
||||
↓
|
||||
任务分析
|
||||
↓
|
||||
创建 Gitea Issue
|
||||
↓
|
||||
编排 Sub Agents
|
||||
↓
|
||||
等待 Sub Agent 完成标记
|
||||
↓
|
||||
三向一致性检查
|
||||
↓
|
||||
验收整合
|
||||
↓
|
||||
向用户汇报
|
||||
```
|
||||
|
||||
### Sub Agent 完成标记
|
||||
|
||||
| Sub Agent | 完成标记格式 |
|
||||
|-----------|-------------|
|
||||
| Execute | `@main-agent ✅ EXECUTE_DONE` |
|
||||
| Review | `@main-agent ✅ REVIEW_DONE verdict=approved` |
|
||||
| Test | `@main-agent ✅ TEST_DONE result=passed` |
|
||||
| Main | `@main-agent ✅ VERIFICATION_PASSED` |
|
||||
|
||||
### 编排策略
|
||||
|
||||
| 复杂度 | 策略 |
|
||||
|--------|------|
|
||||
| **简单** | Execute → 验收 |
|
||||
| **中等** | Execute → Review → 验收 |
|
||||
| **复杂** | 需求讨论 → 规划 → Execute → Review → Test → 验收 |
|
||||
| **调试** | 定位 → Execute → Test → 验收 |
|
||||
|
||||
## 偏差处理
|
||||
|
||||
发现偏差时:
|
||||
1. 发布 `CONSISTENCY_ISSUE` 标记
|
||||
2. 通知相关 Sub Agent
|
||||
3. 重新进入 Superpowers 工作流
|
||||
4. 重新验收
|
||||
|
||||
## Gitea 协作
|
||||
|
||||
### Issue 结构
|
||||
- 标题格式:`[sanguo_vnpy_v2] 功能描述`
|
||||
- 包含工作清单
|
||||
- 包含状态表
|
||||
- 包含完成标记约定
|
||||
|
||||
### Comment 约定
|
||||
- 进度更新:`@main-agent 📝 进度更新`
|
||||
- 完成标记:`@main-agent ✅ 阶段_DONE`
|
||||
- 偏差报告:`@main-agent ❌ CONSISTENCY_ISSUE`
|
||||
|
||||
## 参考文档
|
||||
|
||||
- 项目参考:`sanguo_moziplus_v3` 设计文档
|
||||
- Gitea 配置:`.claude/gitea-config.json`
|
||||
- 工作流脚本:`.claude/workflows/`
|
||||
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* Feature Development Workflow
|
||||
*
|
||||
* 使用 Main + Sub Agent 架构完成功能开发
|
||||
*
|
||||
* 用法: Agent({ scriptPath: ".claude/workflows/feature-development.js", args: "实现用户认证功能" })
|
||||
*/
|
||||
|
||||
export const meta = {
|
||||
name: 'feature-development',
|
||||
description: 'Main Agent 协调 Sub Agent 完成功能开发',
|
||||
phases: [
|
||||
{ title: '分析需求', detail: 'Main Agent 分析并拆分任务' },
|
||||
{ title: '执行开发', detail: 'Executor Sub Agent 编写代码和测试' },
|
||||
{ title: '代码审查', detail: 'Reviewer Sub Agent 审查代码质量' },
|
||||
{ title: '整合验收', detail: 'Main Agent 整合结果并验收' }
|
||||
]
|
||||
}
|
||||
|
||||
// 工作流主逻辑
|
||||
export default async function (feature) {
|
||||
phase('分析需求')
|
||||
|
||||
log(`📋 功能需求: ${feature}`)
|
||||
|
||||
// Main Agent 分析需求
|
||||
const analysis = await agent(`
|
||||
作为架构师,分析以下功能需求:
|
||||
|
||||
"${feature}"
|
||||
|
||||
请提供:
|
||||
1. 技术方案概述
|
||||
2. 需要的文件列表
|
||||
3. Executor 任务描述
|
||||
4. Reviewer 审查点
|
||||
|
||||
返回结构化 JSON:
|
||||
{
|
||||
"approach": "技术方案概述",
|
||||
"files": ["预计需要的文件"],
|
||||
"executorTask": "详细任务描述",
|
||||
"reviewPoints": ["审查要点"]
|
||||
}
|
||||
`, { schema: AnalysisSchema })
|
||||
|
||||
log(`✅ 分析完成: ${analysis.approach}`)
|
||||
|
||||
phase('执行开发')
|
||||
|
||||
// 指派 Executor Sub Agent
|
||||
const executorResult = await agent(`
|
||||
作为后端开发专家,执行以下任务:
|
||||
|
||||
${analysis.executorTask}
|
||||
|
||||
技术要求:
|
||||
- 使用项目现有代码风格
|
||||
- 编写完整的单元测试
|
||||
- 测试覆盖率 > 80%
|
||||
|
||||
完成后返回结构化 JSON:
|
||||
{
|
||||
"files": ["修改的文件列表"],
|
||||
"tests": "测试结果",
|
||||
"changes": "主要改动说明",
|
||||
"status": "完成|部分完成|失败"
|
||||
}
|
||||
`, {
|
||||
label: 'Executor',
|
||||
schema: ExecutorResultSchema
|
||||
})
|
||||
|
||||
log(`✅ Executor 完成: ${executorResult.status}`)
|
||||
log(`📁 修改文件: ${executorResult.files.join(', ')}`)
|
||||
|
||||
phase('代码审查')
|
||||
|
||||
// 指派 Reviewer Sub Agent
|
||||
const reviewResult = await agent(`
|
||||
作为代码审查专家,审查以下改动:
|
||||
|
||||
文件: ${executorResult.files.join(', ')}
|
||||
改动: ${executorResult.changes}
|
||||
|
||||
审查要点:
|
||||
${analysis.reviewPoints.map(p => `- ${p}`).join('\n')}
|
||||
|
||||
检查项目:
|
||||
1. 代码质量和可读性
|
||||
2. 测试覆盖是否充分
|
||||
3. 是否有潜在的 bug
|
||||
4. 安全性考虑
|
||||
5. 性能影响
|
||||
|
||||
返回结构化 JSON:
|
||||
{
|
||||
"findings": ["发现的问题"],
|
||||
"severity": "low|medium|high|critical",
|
||||
"approval": true|false,
|
||||
"suggestions": ["改进建议"]
|
||||
}
|
||||
`, {
|
||||
label: 'Reviewer',
|
||||
schema: ReviewResultSchema
|
||||
})
|
||||
|
||||
log(`📊 审查结果: ${reviewResult.approval ? '✅ 通过' : '❌ 需修改'}`)
|
||||
|
||||
if (reviewResult.findings.length > 0) {
|
||||
log(`⚠️ 发现 ${reviewResult.findings.length} 个问题:`)
|
||||
reviewResult.findings.forEach(f => log(` - ${f}`))
|
||||
}
|
||||
|
||||
phase('整合验收')
|
||||
|
||||
// Main Agent 整合结果
|
||||
const final = {
|
||||
feature,
|
||||
approach: analysis.approach,
|
||||
files: executorResult.files,
|
||||
tests: executorResult.tests,
|
||||
review: reviewResult,
|
||||
status: reviewResult.approval ? '完成' : '需修改'
|
||||
}
|
||||
|
||||
log(`🎯 最终状态: ${final.status}`)
|
||||
|
||||
return final
|
||||
}
|
||||
|
||||
// JSON Schema 定义
|
||||
const AnalysisSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
approach: { type: "string", description: "技术方案概述" },
|
||||
files: { type: "array", items: { type: "string" }, description: "预计需要的文件" },
|
||||
executorTask: { type: "string", description: "详细任务描述" },
|
||||
reviewPoints: { type: "array", items: { type: "string" }, description: "审查要点" }
|
||||
},
|
||||
required: ["approach", "files", "executorTask", "reviewPoints"]
|
||||
}
|
||||
|
||||
const ExecutorResultSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
files: { type: "array", items: { type: "string" }, description: "修改的文件列表" },
|
||||
tests: { type: "string", description: "测试结果" },
|
||||
changes: { type: "string", description: "主要改动说明" },
|
||||
status: { type: "string", enum: ["完成", "部分完成", "失败"], description: "执行状态" }
|
||||
},
|
||||
required: ["files", "tests", "changes", "status"]
|
||||
}
|
||||
|
||||
const ReviewResultSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
findings: { type: "array", items: { type: "string" }, description: "发现的问题" },
|
||||
severity: { type: "string", enum: ["low", "medium", "high", "critical"], description: "严重程度" },
|
||||
approval: { type: "boolean", description: "是否通过审查" },
|
||||
suggestions: { type: "array", items: { type: "string" }, description: "改进建议" }
|
||||
},
|
||||
required: ["findings", "severity", "approval", "suggestions"]
|
||||
}
|
||||
|
||||
// 导出 phase 函数供脚本使用
|
||||
function phase(title) {
|
||||
// 在实际使用中,这会被 Workflow 工具的 phase() 替换
|
||||
console.log(`\n=== ${title} ===`)
|
||||
}
|
||||
|
||||
function log(message) {
|
||||
console.log(message)
|
||||
}
|
||||
@@ -0,0 +1,550 @@
|
||||
/**
|
||||
* Gitea 协作工作流
|
||||
*
|
||||
* Main Agent 通过 Gitea 协调 Sub Agents 完成任务
|
||||
*/
|
||||
|
||||
import { generateGiteaRules, getRepoUrl, getBranchName, getCommitTemplate } from '../helpers/gitea-rules-generator.js'
|
||||
import { createGiteaAdapter } from '../helpers/gitea-mcp-adapter.js'
|
||||
import { generateProgressUpdate, generateExecuteMarker, generateReviewMarker, generateTestMarker, generateVerificationMarker } from '../helpers/comment-markers.js'
|
||||
import { findLatestMarker, MarkerType } from '../helpers/comment-parser.js'
|
||||
|
||||
export const meta = {
|
||||
name: 'gitea-orchestration',
|
||||
description: '通过 Gitea 协调 Sub Agents 完成开发任务',
|
||||
phases: [
|
||||
{ title: '分析', detail: 'Main Agent 分析任务并创建 Issue' },
|
||||
{ title: '执行', detail: 'Execute Sub Agent 实现并创建 PR' },
|
||||
{ title: '审查', detail: 'Review Sub Agent 审查代码' },
|
||||
{ title: '修复', detail: '如需要,Execute Sub Agent 修复问题' },
|
||||
{ title: '验收', detail: 'Main Agent 验收并合并 PR' }
|
||||
]
|
||||
}
|
||||
|
||||
export default async function giteaOrchestration(task, options = {}) {
|
||||
// 生成 Gitea 规则(从配置文件读取)
|
||||
const giteaRules = generateGiteaRules()
|
||||
const repoUrl = getRepoUrl()
|
||||
|
||||
// 初始化 Gitea 适配器
|
||||
const gitea = createGiteaAdapter()
|
||||
|
||||
log(`📦 仓库: ${repoUrl}`)
|
||||
log(`🔌 已连接 Gitea: ${gitea.getRepoInfo().baseUrl}`)
|
||||
|
||||
// ===== Phase 1: 分析 =====
|
||||
phase('分析')
|
||||
|
||||
// 创建 Issue 记录任务
|
||||
const issue = await createIssue(task, giteaRules, gitea)
|
||||
log(`✅ Issue 创建: ${issue.url}`)
|
||||
|
||||
// 分析任务
|
||||
const analysis = await analyzeTask(task)
|
||||
log(`📊 任务复杂度: ${analysis.complexity}`)
|
||||
log(`👥 需要的 Sub Agents: ${analysis.requiredAgents.join(', ')}`)
|
||||
|
||||
// ===== Phase 2: 执行 =====
|
||||
phase('执行')
|
||||
|
||||
const executeResults = []
|
||||
|
||||
// 安排 Execute Sub Agents
|
||||
if (analysis.requiredAgents.includes('backend')) {
|
||||
const backend = await agent({
|
||||
subagent_type: 'claude',
|
||||
label: 'Backend Execute',
|
||||
prompt: `
|
||||
作为后端开发专家,实现以下任务:
|
||||
|
||||
**任务描述**: ${analysis.backendTask}
|
||||
|
||||
**Issue**: ${issue.url}
|
||||
|
||||
${giteaRules}
|
||||
|
||||
执行步骤:
|
||||
1. Clone 仓库
|
||||
2. 创建分支: ${getBranchName('feature', 'backend-' + task.slug)}
|
||||
3. 实现代码
|
||||
4. 编写测试
|
||||
5. Commit 并 Push
|
||||
6. 创建 PR
|
||||
|
||||
完成后返回 PR URL。
|
||||
`
|
||||
})
|
||||
executeResults.push({ type: 'backend', ...backend })
|
||||
log(`✅ Backend Execute 完成: ${backend.prUrl}`)
|
||||
}
|
||||
|
||||
if (analysis.requiredAgents.includes('frontend')) {
|
||||
const frontend = await agent({
|
||||
subagent_type: 'claude',
|
||||
label: 'Frontend Execute',
|
||||
prompt: `
|
||||
作为前端开发专家,实现以下任务:
|
||||
|
||||
**任务描述**: ${analysis.frontendTask}
|
||||
|
||||
**Issue**: ${issue.url}
|
||||
|
||||
${giteaRules}
|
||||
|
||||
执行步骤:
|
||||
1. Clone 仓库
|
||||
2. 创建分支: ${getBranchName('feature', 'frontend-' + task.slug)}
|
||||
3. 实现代码
|
||||
4. 编写测试
|
||||
5. Commit 并 Push
|
||||
6. 创建 PR
|
||||
|
||||
完成后返回 PR URL。
|
||||
`
|
||||
})
|
||||
executeResults.push({ type: 'frontend', ...frontend })
|
||||
log(`✅ Frontend Execute 完成: ${frontend.prUrl}`)
|
||||
}
|
||||
|
||||
// ===== Phase 3: 审查 =====
|
||||
phase('审查')
|
||||
|
||||
if (analysis.requiredAgents.includes('review')) {
|
||||
for (const execute of executeResults) {
|
||||
const review = await agent({
|
||||
subagent_type: 'claude',
|
||||
label: `${execute.type} Review`,
|
||||
prompt: `
|
||||
作为代码审查专家,审查以下 PR:
|
||||
|
||||
**PR URL**: ${execute.prUrl}
|
||||
**Issue**: ${issue.url}
|
||||
|
||||
${giteaRules}
|
||||
|
||||
审查检查:
|
||||
- 代码质量
|
||||
- 安全性
|
||||
- 性能
|
||||
- 测试覆盖
|
||||
|
||||
在 PR 中添加 Review 评论,标注问题代码行。
|
||||
|
||||
完成后返回 Review 结果:
|
||||
{
|
||||
"passed": true|false,
|
||||
"findings": ["问题1", "问题2"],
|
||||
"suggestions": ["建议1", "建议2"]
|
||||
}
|
||||
`
|
||||
})
|
||||
|
||||
execute.review = review
|
||||
log(`${review.passed ? '✅' : '❌'} ${execute.type} Review: ${review.passed ? '通过' : '需要修复'}`)
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Phase 4: 修复 (如需要) =====
|
||||
phase('修复')
|
||||
|
||||
for (const execute of executeResults) {
|
||||
let maxRetries = 3
|
||||
let retryCount = 0
|
||||
|
||||
while (!execute.review?.passed && retryCount < maxRetries) {
|
||||
log(`🔄 ${execute.type} 需要修复 (尝试 ${retryCount + 1}/${maxRetries})`)
|
||||
|
||||
const fix = await agent({
|
||||
subagent_type: 'claude',
|
||||
label: `${execute.type} Fix`,
|
||||
prompt: `
|
||||
**你的 PR 有审查意见,请修复**
|
||||
|
||||
**PR URL**: ${execute.prUrl}
|
||||
**Review 问题**: ${execute.review.findings.join('\n')}
|
||||
|
||||
${giteaRules}
|
||||
|
||||
修复步骤:
|
||||
1. 在 PR 分支上修复问题
|
||||
2. Commit: ${getCommitTemplate('fix').replace('{description}', '修复 Review 问题')}
|
||||
3. Push 更新
|
||||
4. 回复 Review 评论说明修复内容
|
||||
|
||||
修复完成后返回更新后的状态。
|
||||
`
|
||||
})
|
||||
|
||||
log(`✅ ${execute.type} 修复完成`)
|
||||
|
||||
// 重新审查
|
||||
const reReview = await agent({
|
||||
subagent_type: 'claude',
|
||||
label: `${execute.type} Re-review`,
|
||||
prompt: `
|
||||
重新审查 PR: ${execute.prUrl}
|
||||
|
||||
${giteaRules}
|
||||
|
||||
检查之前的 Review 问题是否已修复。
|
||||
|
||||
返回 Review 结果:
|
||||
{
|
||||
"passed": true|false,
|
||||
"findings": ["剩余问题"],
|
||||
"suggestions": ["建议"]
|
||||
}
|
||||
`
|
||||
})
|
||||
|
||||
execute.review = reReview
|
||||
retryCount++
|
||||
|
||||
if (reReview.passed) {
|
||||
log(`✅ ${execute.type} 重新审查通过`)
|
||||
}
|
||||
}
|
||||
|
||||
if (!execute.review?.passed && retryCount >= maxRetries) {
|
||||
log(`⚠️ ${execute.type} 达到最大重试次数,需要人工介入`)
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Phase 5: 验收 =====
|
||||
phase('验收')
|
||||
|
||||
// 安排 Test Sub Agent 验证
|
||||
if (analysis.requiredAgents.includes('test')) {
|
||||
for (const execute of executeResults) {
|
||||
const test = await agent({
|
||||
subagent_type: 'claude',
|
||||
label: `${execute.type} Test`,
|
||||
prompt: `
|
||||
作为测试工程师,验证以下 PR 的功能:
|
||||
|
||||
**PR URL**: ${execute.prUrl}
|
||||
**Issue**: ${issue.url}
|
||||
|
||||
${giteaRules}
|
||||
|
||||
测试步骤:
|
||||
1. 拉取 PR 代码
|
||||
2. 安装依赖
|
||||
3. 运行测试
|
||||
4. 检查测试覆盖率
|
||||
|
||||
返回测试结果:
|
||||
{
|
||||
"passed": true|false,
|
||||
"coverage": "85%",
|
||||
"failures": [],
|
||||
"summary": "测试摘要"
|
||||
}
|
||||
`
|
||||
})
|
||||
|
||||
execute.test = test
|
||||
log(`${test.passed ? '✅' : '❌'} ${execute.type} Test: ${test.passed ? '通过' : '失败'}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 最终验收
|
||||
const allPassed = executeResults.every(e =>
|
||||
e.review?.passed && (!e.test || e.test?.passed)
|
||||
)
|
||||
|
||||
if (allPassed) {
|
||||
// 合并所有 PR
|
||||
for (const execute of executeResults) {
|
||||
await mergePR(execute.prUrl, gitea)
|
||||
log(`✅ 已合并 PR: ${execute.prUrl}`)
|
||||
}
|
||||
|
||||
// 关闭 Issue
|
||||
await closeIssue(issue.number, gitea)
|
||||
log(`✅ 已关闭 Issue: ${issue.url}`)
|
||||
|
||||
return {
|
||||
status: '完成',
|
||||
issue: issue.url,
|
||||
prs: executeResults.map(e => e.prUrl),
|
||||
message: '任务完成并已合并'
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
status: '需要人工介入',
|
||||
issue: issue.url,
|
||||
prs: executeResults.map(e => ({
|
||||
url: e.prUrl,
|
||||
review: e.review,
|
||||
test: e.test
|
||||
})),
|
||||
message: '部分 PR 未通过审查或测试,需要人工处理'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 辅助函数
|
||||
*/
|
||||
async function createIssue(task, rules, gitea) {
|
||||
const repoInfo = gitea.getRepoInfo()
|
||||
|
||||
// 生成 Issue 标题(包含项目标识)
|
||||
const title = `[${repoInfo.repo}] ${task.title || task}`
|
||||
|
||||
// 生成 Issue 正文
|
||||
const body = generateIssueBody(task, rules)
|
||||
|
||||
// 生成标签
|
||||
const labels = generateIssueLabels(task)
|
||||
|
||||
try {
|
||||
const issue = await gitea.withRetry(async () => {
|
||||
return await gitea.createIssue({
|
||||
title,
|
||||
body,
|
||||
labels
|
||||
})
|
||||
})
|
||||
|
||||
log(`✅ Issue 已创建: ${issue.url}`)
|
||||
return issue
|
||||
} catch (error) {
|
||||
log(`❌ 创建 Issue 失败: ${error.message}`)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 Issue 正文
|
||||
*/
|
||||
function generateIssueBody(task, rules) {
|
||||
return `
|
||||
## 项目信息
|
||||
- 项目: ${task.project || 'sanguo_moziplus_v3'}
|
||||
- 需求编号: ${task.id || 'D-PENDING'}
|
||||
- 复杂度: ${task.complexity || '待评估'}
|
||||
|
||||
## 需求描述
|
||||
${task.description || task}
|
||||
|
||||
## 执行清单
|
||||
|
||||
### 📋 Execute Sub Agent
|
||||
> **负责**: Execute Sub Agent
|
||||
> **状态**: 🔄 进行中
|
||||
|
||||
- [ ] 任务分析和规划
|
||||
- [ ] 代码实现
|
||||
- [ ] 单元测试编写
|
||||
- [ ] 本地验证
|
||||
|
||||
**完成时标记**: \`@main-agent ✅ EXECUTE_DONE\`
|
||||
|
||||
### 📋 Review Sub Agent
|
||||
> **负责**: Review Sub Agent
|
||||
> **状态**: ⏳ 等待中
|
||||
|
||||
- [ ] 代码质量检查
|
||||
- [ ] 安全性审查
|
||||
- [ ] 性能评估
|
||||
|
||||
**完成时标记**: \`@main-agent ✅ REVIEW_DONE verdict=approved\`
|
||||
|
||||
### 📋 Test Sub Agent
|
||||
> **负责**: Test Sub Agent
|
||||
> **状态**: ⏳ 等待中
|
||||
|
||||
- [ ] 测试用例编写
|
||||
- [ ] 测试执行
|
||||
- [ ] 覆盖率检查
|
||||
|
||||
**完成时标记**: \`@main-agent ✅ TEST_DONE result=passed\`
|
||||
|
||||
### 📋 Main Agent 验收
|
||||
> **负责**: Main Agent
|
||||
> **状态**: ⏳ 等待中
|
||||
|
||||
- [ ] 需求 → 设计 一致性
|
||||
- [ ] 设计 → 编码 一致性
|
||||
- [ ] 需求 → 编码 一致性
|
||||
|
||||
**完成时标记**: \`@main-agent ✅ VERIFICATION_PASSED\`
|
||||
|
||||
## 执行状态
|
||||
| 阶段 | 负责者 | 状态 | 更新时间 |
|
||||
|------|-------|------|---------|
|
||||
| Execute | Execute Sub Agent | 🔄 进行中 | - |
|
||||
| Review | Review Sub Agent | ⏳ 等待 | - |
|
||||
| Test | Test Sub Agent | ⏳ 等待 | - |
|
||||
| 验收 | Main Agent | ⏳ 等待 | - |
|
||||
`
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 Issue 标签
|
||||
*/
|
||||
function generateIssueLabels(task) {
|
||||
const labels = []
|
||||
|
||||
// 添加复杂度标签
|
||||
if (task.complexity) {
|
||||
labels.push(`complexity:${task.complexity}`)
|
||||
}
|
||||
|
||||
// 添加类型标签
|
||||
if (task.type) {
|
||||
labels.push(task.type)
|
||||
}
|
||||
|
||||
return labels
|
||||
}
|
||||
|
||||
async function analyzeTask(task) {
|
||||
return await agent({
|
||||
subagent_type: 'Plan',
|
||||
prompt: `
|
||||
分析任务: "${task}"
|
||||
|
||||
返回 JSON:
|
||||
{
|
||||
"complexity": "low|medium|high",
|
||||
"requiredAgents": ["backend", "frontend", "review", "test"],
|
||||
"backendTask": "后端任务描述",
|
||||
"frontendTask": "前端任务描述",
|
||||
"slug": "task-slug"
|
||||
}
|
||||
`,
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
complexity: { type: "string", enum: ["low", "medium", "high"] },
|
||||
requiredAgents: { type: "array", items: { type: "string" } },
|
||||
backendTask: { type: "string" },
|
||||
frontendTask: { type: "string" },
|
||||
slug: { type: "string" }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function mergePR(prUrl, gitea) {
|
||||
try {
|
||||
// 从 PR URL 中提取 PR 编号
|
||||
const prMatch = prUrl.match(/\/pulls\/(\d+)/)
|
||||
if (!prMatch) {
|
||||
throw new Error(`无效的 PR URL: ${prUrl}`)
|
||||
}
|
||||
|
||||
const prNumber = parseInt(prMatch[1], 10)
|
||||
|
||||
await gitea.withRetry(async () => {
|
||||
return await gitea.mergePullRequest(prNumber, {
|
||||
deleteBranch: true,
|
||||
mergeStyle: 'merge'
|
||||
})
|
||||
})
|
||||
|
||||
log(`✅ PR 已合并: ${prUrl}`)
|
||||
} catch (error) {
|
||||
log(`❌ 合并 PR 失败: ${error.message}`)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function closeIssue(issueId, gitea) {
|
||||
try {
|
||||
await gitea.withRetry(async () => {
|
||||
return await gitea.closeIssue(issueId)
|
||||
})
|
||||
|
||||
log(`✅ Issue 已关闭: #${issueId}`)
|
||||
} catch (error) {
|
||||
log(`❌ 关闭 Issue 失败: ${error.message}`)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 等待 Sub Agent 完成并返回标记数据
|
||||
*
|
||||
* @param {Object} gitea - Gitea 适配器实例
|
||||
* @param {number} issueNumber - Issue 编号
|
||||
* @param {string} markerType - 期望的标记类型
|
||||
* @param {Object} options - 选项
|
||||
* @returns {Promise<Object>} 标记数据
|
||||
*/
|
||||
async function waitForSubAgentCompletion(gitea, issueNumber, markerType, options = {}) {
|
||||
const {
|
||||
timeout = 30 * 60 * 1000, // 30 分钟超时
|
||||
interval = 10 * 1000, // 10 秒检查间隔
|
||||
onProgress = null // 进度回调
|
||||
} = options
|
||||
|
||||
const startTime = Date.now()
|
||||
let checkCount = 0
|
||||
|
||||
log(`⏳ 等待 ${markerType} 标记...`)
|
||||
|
||||
while (Date.now() - startTime < timeout) {
|
||||
checkCount++
|
||||
|
||||
try {
|
||||
// 读取 Issue Comments
|
||||
const comments = await gitea.getIssueComments(issueNumber)
|
||||
|
||||
// 查找目标标记
|
||||
const marker = findLatestMarker(comments, markerType)
|
||||
|
||||
if (marker) {
|
||||
log(`✅ 检测到 ${markerType} 标记 (检查 ${checkCount} 次)`)
|
||||
return marker.data
|
||||
}
|
||||
|
||||
// 进度回调
|
||||
if (onProgress) {
|
||||
onProgress({
|
||||
elapsed: Date.now() - startTime,
|
||||
checkCount,
|
||||
markerType: null
|
||||
})
|
||||
}
|
||||
|
||||
// 等待下次检查
|
||||
await new Promise(resolve => setTimeout(resolve, interval))
|
||||
} catch (error) {
|
||||
log(`⚠️ 检查标记时出错: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`等待 ${markerType} 标记超时 (${timeout / 1000} 秒)`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 发布进度更新 Comment
|
||||
*
|
||||
* @param {Object} gitea - Gitea 适配器实例
|
||||
* @param {number} issueNumber - Issue 编号
|
||||
* @param {string} stage - 当前阶段
|
||||
* @param {Array<string>} completed - 已完成任务
|
||||
* @param {Array<string>} inProgress - 进行中任务
|
||||
*/
|
||||
async function publishProgressUpdate(gitea, issueNumber, stage, completed, inProgress) {
|
||||
const content = generateProgressUpdate({
|
||||
stage,
|
||||
completed,
|
||||
inProgress
|
||||
})
|
||||
|
||||
await gitea.createIssueComment(issueNumber, content)
|
||||
log(`📝 进度更新已发布: ${stage} 阶段`)
|
||||
}
|
||||
|
||||
function log(message) {
|
||||
console.log(`[${new Date().toISOString()}] ${message}`)
|
||||
}
|
||||
|
||||
function phase(title) {
|
||||
console.log(`\n=== ${title} ===`)
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
/**
|
||||
* Comment 标记生成器
|
||||
*
|
||||
* 根据 v0.5 设计文档中的 Comment 标记约定,生成标准的完成标记
|
||||
*/
|
||||
|
||||
/**
|
||||
* 生成 Execute Sub Agent 完成标记
|
||||
*
|
||||
* @param {Object} data - 完成数据
|
||||
* @param {Array<string>} data.deliverables - 交付物列表
|
||||
* @param {Object} data.testStatus - 测试状态
|
||||
* @param {string} data.executor - 执行者名称
|
||||
* @returns {string} 完成标记内容
|
||||
*/
|
||||
export function generateExecuteMarker(data) {
|
||||
const {
|
||||
deliverables = [],
|
||||
testStatus = { local: 'PASS', coverage: '0%' },
|
||||
executor = 'Execute Sub Agent',
|
||||
notes = ''
|
||||
} = data
|
||||
|
||||
const timestamp = new Date().toISOString()
|
||||
|
||||
return `@main-agent ✅ **EXECUTE_DONE**
|
||||
|
||||
## 完成总结
|
||||
|
||||
### 交付物
|
||||
${deliverables.map((item, i) => `${i + 1}. \`${item}\``).join('\n')}
|
||||
|
||||
### 测试状态
|
||||
- 本地运行: ${testStatus.local ? '✅ ' + testStatus.local : '❌ FAIL'}
|
||||
${testStatus.coverage ? `- 覆盖率: ${testStatus.coverage}` : ''}
|
||||
|
||||
${notes ? `### 备注\n${notes}\n` : ''}
|
||||
|
||||
---
|
||||
**执行者**: ${executor}
|
||||
**完成时间**: ${timestamp}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 Review Sub Agent 完成标记
|
||||
*
|
||||
* @param {Object} data - 审查数据
|
||||
* @param {string} data.verdict - 审查结论 (approved/rejected)
|
||||
* @param {Array<string>} data.findings - 发现的问题
|
||||
* @param {Array<string>} data.suggestions - 改进建议
|
||||
* @param {string} data.reviewer - 审查者名称
|
||||
* @returns {string} 完成标记内容
|
||||
*/
|
||||
export function generateReviewMarker(data) {
|
||||
const {
|
||||
verdict = 'approved',
|
||||
findings = [],
|
||||
suggestions = [],
|
||||
reviewer = 'Review Sub Agent',
|
||||
notes = ''
|
||||
} = data
|
||||
|
||||
const timestamp = new Date().toISOString()
|
||||
const verdictIcon = verdict === 'approved' ? '✅' : '❌'
|
||||
const verdictText = verdict.toUpperCase()
|
||||
|
||||
return `@main-agent ${verdictIcon} **REVIEW_DONE verdict=${verdictText}**
|
||||
|
||||
## 审查结果
|
||||
|
||||
### 检查项
|
||||
- 代码质量: ${verdict === 'approved' ? '✅' : '❌'}
|
||||
- 安全性: ${verdict === 'approved' ? '✅' : '❌'}
|
||||
- 性能: ${verdict === 'approved' ? '✅' : '❌'}
|
||||
- 测试覆盖: ${verdict === 'approved' ? '✅' : '❌'}
|
||||
|
||||
${findings.length > 0 ? `### 发现的问题
|
||||
${findings.map((f, i) => `${i + 1}. ${f}`).join('\n')}
|
||||
|
||||
` : ''}${suggestions.length > 0 ? `### 改进建议
|
||||
${suggestions.map((s, i) => `${i + 1}. ${s}`).join('\n')}
|
||||
|
||||
` : ''}${notes ? `### 备注\n${notes}\n` : ''}---
|
||||
**审查者**: ${reviewer}
|
||||
**结论**: ${verdictIcon} ${verdictText}
|
||||
**完成时间**: ${timestamp}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 Test Sub Agent 完成标记
|
||||
*
|
||||
* @param {Object} data - 测试数据
|
||||
* @param {string} data.result - 测试结果 (passed/failed)
|
||||
* @param {string} data.coverage - 测试覆盖率
|
||||
* @param {Array<string>} data.failures - 失败的测试
|
||||
* @param {string} data.summary - 测试摘要
|
||||
* @param {string} data.tester - 测试者名称
|
||||
* @returns {string} 完成标记内容
|
||||
*/
|
||||
export function generateTestMarker(data) {
|
||||
const {
|
||||
result = 'passed',
|
||||
coverage = '0%',
|
||||
failures = [],
|
||||
summary = '',
|
||||
tester = 'Test Sub Agent',
|
||||
notes = ''
|
||||
} = data
|
||||
|
||||
const timestamp = new Date().toISOString()
|
||||
const resultIcon = result === 'passed' ? '✅' : '❌'
|
||||
const resultText = result.toUpperCase()
|
||||
|
||||
return `@main-agent ${resultIcon} **TEST_DONE result=${resultText} coverage=${coverage}**
|
||||
|
||||
## 测试结果
|
||||
|
||||
### 测试摘要
|
||||
${summary || '无'}
|
||||
|
||||
${failures.length > 0 ? `### 失败的测试
|
||||
${failures.map((f, i) => `${i + 1}. ${f}`).join('\n')}
|
||||
|
||||
` : ''}${notes ? `### 备注\n${notes}\n` : ''}---
|
||||
**测试者**: ${tester}
|
||||
**覆盖率**: ${coverage}
|
||||
**完成时间**: ${timestamp}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 Main Agent 验收完成标记
|
||||
*
|
||||
* @param {Object} data - 验收数据
|
||||
* @param {Object} data.requirementDesign - 需求→设计检查结果
|
||||
* @param {Object} data.designCode - 设计→编码检查结果
|
||||
* @param {Object} data.requirementCode - 需求→编码检查结果
|
||||
* @param {string} data.verdict - 最终结论 (approved/rejected)
|
||||
* @returns {string} 完成标记内容
|
||||
*/
|
||||
export function generateVerificationMarker(data) {
|
||||
const {
|
||||
requirementDesign = { passed: true, items: [] },
|
||||
designCode = { passed: true, items: [] },
|
||||
requirementCode = { passed: true, items: [] },
|
||||
verdict = 'approved'
|
||||
} = data
|
||||
|
||||
const timestamp = new Date().toISOString()
|
||||
const verdictIcon = verdict === 'approved' ? '✅' : '❌'
|
||||
const verdictText = verdict.toUpperCase()
|
||||
|
||||
// 检查是否全部通过
|
||||
const allPassed = requirementDesign.passed && designCode.passed && requirementCode.passed
|
||||
const finalVerdict = allPassed ? 'approved' : 'rejected'
|
||||
|
||||
return `@main-agent ✅ **VERIFICATION_${finalVerdict.toUpperCase()}**
|
||||
|
||||
## 三向一致性检查
|
||||
|
||||
### 1️⃣ 需求 → 设计
|
||||
${renderCheckTable(requirementDesign.items)}
|
||||
${requirementDesign.passed ? '✅ **需求→设计: 通过**' : '❌ **需求→设计: 未通过**'}
|
||||
|
||||
### 2️⃣ 设计 → 编码
|
||||
${renderCheckTable(designCode.items)}
|
||||
${designCode.passed ? '✅ **设计→编码: 通过**' : '❌ **设计→编码: 未通过**'}
|
||||
|
||||
### 3️⃣ 需求 → 编码
|
||||
${renderCheckTable(requirementCode.items)}
|
||||
${requirementCode.passed ? '✅ **需求→编码: 通过**' : '❌ **需求→编码: 未通过**'}
|
||||
|
||||
## 最终结论
|
||||
${allPassed ? '✅ **三向一致性检查全部通过**' : '❌ **三向一致性检查未通过,存在偏差**'}
|
||||
**VERDICT**: ${verdictIcon} ${finalVerdict === 'approved' ? 'APPROVED FOR MERGE' : 'NEEDS FIX'}
|
||||
|
||||
---
|
||||
**执行者**: Main Agent
|
||||
**完成时间**: ${timestamp}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 辅助函数:渲染检查表格
|
||||
*/
|
||||
function renderCheckTable(items) {
|
||||
if (!items || items.length === 0) {
|
||||
return '无检查项'
|
||||
}
|
||||
|
||||
const header = '| 检查项 | 实际情况 | 状态 |\n|--------|----------|------|'
|
||||
const rows = items.map(item => {
|
||||
const status = item.passed ? '✅' : '❌'
|
||||
return `| ${item.check} | ${item.actual || '-'} | ${status} |`
|
||||
}).join('\n')
|
||||
|
||||
return `${header}\n${rows}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成进度更新标记
|
||||
*
|
||||
* @param {Object} data - 进度数据
|
||||
* @param {string} data.stage - 当前阶段
|
||||
* @param {Array<string>} data.completed - 已完成的任务
|
||||
* @param {Array<string>} data.inProgress - 进行中的任务
|
||||
* @returns {string} 进度更新内容
|
||||
*/
|
||||
export function generateProgressUpdate(data) {
|
||||
const {
|
||||
stage = 'Execute',
|
||||
completed = [],
|
||||
inProgress = []
|
||||
} = data
|
||||
|
||||
const total = completed.length + inProgress.length
|
||||
const progress = total > 0 ? Math.round((completed.length / total) * 100) : 0
|
||||
|
||||
return `@main-agent 📝 **进度更新: ${stage} 阶段**
|
||||
|
||||
## 已完成
|
||||
${completed.map((item, i) => `- ✅ ${item}`).join('\n') || '- 无'}
|
||||
|
||||
## 进行中
|
||||
${inProgress.map((item, i) => `- 🔄 ${item}`).join('\n') || '- 无'}
|
||||
|
||||
---
|
||||
**进度**: ${progress}% (${completed.length}/${total} 完成)`
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成偏差报告标记
|
||||
*
|
||||
* @param {Object} data - 偏差数据
|
||||
* @param {string} data.type - 偏差类型 (requirement-design/design-code/requirement-code)
|
||||
* @param {string} data.issue - 偏差描述
|
||||
* @param {string} data.expected - 预期情况
|
||||
* @param {string} data.actual - 实际情况
|
||||
* @returns {string} 偏差报告内容
|
||||
*/
|
||||
export function generateDeviationMarker(data) {
|
||||
const {
|
||||
type = 'unknown',
|
||||
issue = '',
|
||||
expected = '',
|
||||
actual = '',
|
||||
actionRequired = []
|
||||
} = data
|
||||
|
||||
const typeLabels = {
|
||||
'requirement-design': '需求 → 设计 偏差',
|
||||
'design-code': '设计 → 编码 偏差',
|
||||
'requirement-code': '需求 → 编码 偏差'
|
||||
}
|
||||
|
||||
const timestamp = new Date().toISOString()
|
||||
|
||||
return `@main-agent ❌ **CONSISTENCY_ISSUE**
|
||||
|
||||
## 发现偏差
|
||||
|
||||
### 问题: ${typeLabels[type] || type}
|
||||
**问题描述**: ${issue}
|
||||
|
||||
${expected ? `**预期**: ${expected}\n` : ''}${actual ? `**实际**: ${actual}\n` : ''}${actionRequired.length > 0 ? `### 处理要求
|
||||
${actionRequired.map((a, i) => `${i + 1}. ${a}`).join('\n')}
|
||||
|
||||
` : ''}---
|
||||
**标签**: needs-consistency-fix 🔴
|
||||
**报告时间**: ${timestamp}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出所有标记生成函数
|
||||
*/
|
||||
export default {
|
||||
generateExecuteMarker,
|
||||
generateReviewMarker,
|
||||
generateTestMarker,
|
||||
generateVerificationMarker,
|
||||
generateProgressUpdate,
|
||||
generateDeviationMarker
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
/**
|
||||
* Comment 解析器
|
||||
*
|
||||
* 解析 Issue Comments 中的标记,提取关键信息
|
||||
*/
|
||||
|
||||
/**
|
||||
* 标记类型枚举
|
||||
*/
|
||||
export const MarkerType = {
|
||||
EXECUTE_DONE: 'EXECUTE_DONE',
|
||||
REVIEW_DONE: 'REVIEW_DONE',
|
||||
TEST_DONE: 'TEST_DONE',
|
||||
VERIFICATION_PASSED: 'VERIFICATION_PASSED',
|
||||
VERIFICATION_REJECTED: 'VERIFICATION_REJECTED',
|
||||
PROGRESS_UPDATE: 'PROGRESS_UPDATE',
|
||||
CONSISTENCY_ISSUE: 'CONSISTENCY_ISSUE'
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析完成标记
|
||||
*
|
||||
* @param {string} commentBody - Comment 内容
|
||||
* @returns {Object|null} 解析结果
|
||||
*/
|
||||
export function parseCompletionMarker(commentBody) {
|
||||
// 检测 EXECUTE_DONE
|
||||
if (commentBody.includes('EXECUTE_DONE')) {
|
||||
return {
|
||||
type: MarkerType.EXECUTE_DONE,
|
||||
status: 'completed',
|
||||
data: extractExecuteData(commentBody)
|
||||
}
|
||||
}
|
||||
|
||||
// 检测 REVIEW_DONE
|
||||
if (commentBody.includes('REVIEW_DONE')) {
|
||||
return {
|
||||
type: MarkerType.REVIEW_DONE,
|
||||
status: 'completed',
|
||||
data: extractReviewData(commentBody)
|
||||
}
|
||||
}
|
||||
|
||||
// 检测 TEST_DONE
|
||||
if (commentBody.includes('TEST_DONE')) {
|
||||
return {
|
||||
type: MarkerType.TEST_DONE,
|
||||
status: 'completed',
|
||||
data: extractTestData(commentBody)
|
||||
}
|
||||
}
|
||||
|
||||
// 检测 VERIFICATION_PASSED
|
||||
if (commentBody.includes('VERIFICATION_PASSED')) {
|
||||
return {
|
||||
type: MarkerType.VERIFICATION_PASSED,
|
||||
status: 'completed',
|
||||
data: extractVerificationData(commentBody)
|
||||
}
|
||||
}
|
||||
|
||||
// 检测 VERIFICATION_REJECTED
|
||||
if (commentBody.includes('VERIFICATION_REJECTED')) {
|
||||
return {
|
||||
type: MarkerType.VERIFICATION_REJECTED,
|
||||
status: 'failed',
|
||||
data: extractVerificationData(commentBody)
|
||||
}
|
||||
}
|
||||
|
||||
// 检测 PROGRESS_UPDATE
|
||||
if (commentBody.includes('进度更新')) {
|
||||
return {
|
||||
type: MarkerType.PROGRESS_UPDATE,
|
||||
status: 'in_progress',
|
||||
data: extractProgressData(commentBody)
|
||||
}
|
||||
}
|
||||
|
||||
// 检测 CONSISTENCY_ISSUE
|
||||
if (commentBody.includes('CONSISTENCY_ISSUE')) {
|
||||
return {
|
||||
type: MarkerType.CONSISTENCY_ISSUE,
|
||||
status: 'failed',
|
||||
data: extractDeviationData(commentBody)
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取 EXECUTE_DONE 数据
|
||||
*/
|
||||
function extractExecuteData(commentBody) {
|
||||
const deliverables = []
|
||||
const deliverableMatch = commentBody.match(/### 交付物\n([\s\S]*?)\n\n/)
|
||||
if (deliverableMatch) {
|
||||
const lines = deliverableMatch[1].split('\n')
|
||||
lines.forEach(line => {
|
||||
const match = line.match(/\d+\.\s*`([^`]+)`/)
|
||||
if (match) {
|
||||
deliverables.push(match[1])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const testLocalMatch = commentBody.match(/本地运行:\s*([✅❌])\s*(\w+)/)
|
||||
const coverageMatch = commentBody.match(/覆盖率:\s*([\d%]+)/)
|
||||
const executorMatch = commentBody.match(/\*\*执行者\*\*:\s*([^\n]+)/)
|
||||
const completedAtMatch = commentBody.match(/\*\*完成时间\*\*:\s*([^\n]+)/)
|
||||
|
||||
return {
|
||||
deliverables,
|
||||
testStatus: {
|
||||
local: testLocalMatch ? `${testLocalMatch[1]} ${testLocalMatch[2]}` : 'UNKNOWN',
|
||||
coverage: coverageMatch ? coverageMatch[1] : '0%'
|
||||
},
|
||||
executor: executorMatch ? executorMatch[1].trim() : 'Execute Sub Agent',
|
||||
completedAt: completedAtMatch ? completedAtMatch[1].trim() : null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取 REVIEW_DONE 数据
|
||||
*/
|
||||
function extractReviewData(commentBody) {
|
||||
const verdictMatch = commentBody.match(/verdict=(\w+)/)
|
||||
const verdict = verdictMatch ? verdictMatch[1].toLowerCase() : 'unknown'
|
||||
|
||||
const findings = []
|
||||
const findingsMatch = commentBody.match(/### 发现的问题\n([\s\S]*?)\n\n/)
|
||||
if (findingsMatch) {
|
||||
const lines = findingsMatch[1].split('\n')
|
||||
lines.forEach(line => {
|
||||
const match = line.match(/\d+\.\s*(.+)/)
|
||||
if (match) {
|
||||
findings.push(match[1].trim())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const suggestions = []
|
||||
const suggestionsMatch = commentBody.match(/### 改进建议\n([\s\S]*?)\n\n/)
|
||||
if (suggestionsMatch) {
|
||||
const lines = suggestionsMatch[1].split('\n')
|
||||
lines.forEach(line => {
|
||||
const match = line.match(/\d+\.\s*(.+)/)
|
||||
if (match) {
|
||||
suggestions.push(match[1].trim())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const reviewerMatch = commentBody.match(/\*\*审查者\*\*:\s*([^\n]+)/)
|
||||
const completedAtMatch = commentBody.match(/\*\*完成时间\*\*:\s*([^\n]+)/)
|
||||
|
||||
return {
|
||||
verdict: verdict === 'approved' ? 'approved' : 'rejected',
|
||||
findings,
|
||||
suggestions,
|
||||
reviewer: reviewerMatch ? reviewerMatch[1].trim() : 'Review Sub Agent',
|
||||
completedAt: completedAtMatch ? completedAtMatch[1].trim() : null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取 TEST_DONE 数据
|
||||
*/
|
||||
function extractTestData(commentBody) {
|
||||
const resultMatch = commentBody.match(/result=(\w+)/)
|
||||
const coverageMatch = commentBody.match(/coverage=([\d%]+)/)
|
||||
const summaryMatch = commentBody.match(/### 测试摘要\n([\s\S]*?)\n\n/)
|
||||
|
||||
const failures = []
|
||||
const failuresMatch = commentBody.match(/### 失败的测试\n([\s\S]*?)\n\n/)
|
||||
if (failuresMatch) {
|
||||
const lines = failuresMatch[1].split('\n')
|
||||
lines.forEach(line => {
|
||||
const match = line.match(/\d+\.\s*(.+)/)
|
||||
if (match) {
|
||||
failures.push(match[1].trim())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const testerMatch = commentBody.match(/\*\*测试者\*\*:\s*([^\n]+)/)
|
||||
const completedAtMatch = commentBody.match(/\*\*完成时间\*\*:\s*([^\n]+)/)
|
||||
|
||||
return {
|
||||
result: resultMatch ? resultMatch[1].toLowerCase() : 'unknown',
|
||||
coverage: coverageMatch ? coverageMatch[1] : '0%',
|
||||
summary: summaryMatch ? summaryMatch[1].trim() : '',
|
||||
failures,
|
||||
tester: testerMatch ? testerMatch[1].trim() : 'Test Sub Agent',
|
||||
completedAt: completedAtMatch ? completedAtMatch[1].trim() : null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取 VERIFICATION 数据
|
||||
*/
|
||||
function extractVerificationData(commentBody) {
|
||||
// 提取三个检查结果
|
||||
const requirementDesignPassed = commentBody.includes('需求→设计: 通过')
|
||||
const designCodePassed = commentBody.includes('设计→编码: 通过')
|
||||
const requirementCodePassed = commentBody.includes('需求→编码: 通过')
|
||||
|
||||
const allPassed = requirementDesignPassed && designCodePassed && requirementCodePassed
|
||||
|
||||
const completedAtMatch = commentBody.match(/\*\*完成时间\*\*:\s*([^\n]+)/)
|
||||
|
||||
return {
|
||||
requirementDesign: { passed: requirementDesignPassed },
|
||||
designCode: { passed: designCodePassed },
|
||||
requirementCode: { passed: requirementCodePassed },
|
||||
allPassed,
|
||||
verdict: allPassed ? 'approved' : 'rejected',
|
||||
completedAt: completedAtMatch ? completedAtMatch[1].trim() : null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取 PROGRESS_UPDATE 数据
|
||||
*/
|
||||
function extractProgressData(commentBody) {
|
||||
const progressMatch = commentBody.match(/\*\*进度\*\*:\s*(\d+)%/)
|
||||
const stageMatch = commentBody.match(/进度更新:\s*(\w+)\s*阶段/)
|
||||
|
||||
const completed = []
|
||||
const completedMatch = commentBody.match(/## 已完成\n([\s\S]*?)\n\n/)
|
||||
if (completedMatch) {
|
||||
const lines = completedMatch[1].split('\n')
|
||||
lines.forEach(line => {
|
||||
const match = line.match/- ✅ (.+)/)
|
||||
if (match) {
|
||||
completed.push(match[1].trim())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const inProgress = []
|
||||
const inProgressMatch = commentBody.match(/## 进行中\n([\s\S]*?)\n\n/)
|
||||
if (inProgressMatch) {
|
||||
const lines = inProgressMatch[1].split('\n')
|
||||
lines.forEach(line => {
|
||||
const match = line.match/- 🔄 (.+)/)
|
||||
if (match) {
|
||||
inProgress.push(match[1].trim())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
stage: stageMatch ? stageMatch[1] : 'Unknown',
|
||||
progress: progressMatch ? parseInt(progressMatch[1], 10) : 0,
|
||||
completed,
|
||||
inProgress
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取 CONSISTENCY_ISSUE 数据
|
||||
*/
|
||||
function extractDeviationData(commentBody) {
|
||||
const typeMatch = commentBody.match(/问题:\s*(.+)\s*偏差/)
|
||||
const issueMatch = commentBody.match(/\*\*问题描述\*\*:\s*([^\n]+)/)
|
||||
const expectedMatch = commentBody.match(/\*\*预期\*\*:\s*([^\n]+)/)
|
||||
const actualMatch = commentBody.match(/\*\*实际\*\*:\s*([^\n]+)/)
|
||||
|
||||
const actionRequired = []
|
||||
const actionMatch = commentBody.match(/### 处理要求\n([\s\S]*?)\n\n/)
|
||||
if (actionMatch) {
|
||||
const lines = actionMatch[1].split('\n')
|
||||
lines.forEach(line => {
|
||||
const match = line.match(/\d+\.\s*(.+)/)
|
||||
if (match) {
|
||||
actionRequired.push(match[1].trim())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
type: typeMatch ? typeMatch[1].trim() : 'unknown',
|
||||
issue: issueMatch ? issueMatch[1].trim() : '',
|
||||
expected: expectedMatch ? expectedMatch[1].trim() : '',
|
||||
actual: actualMatch ? actualMatch[1].trim() : '',
|
||||
actionRequired
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找最新的指定类型标记
|
||||
*
|
||||
* @param {Array<Object>} comments - Comments 数组
|
||||
* @param {string} markerType - 标记类型
|
||||
* @returns {Object|null} 最新的标记数据
|
||||
*/
|
||||
export function findLatestMarker(comments, markerType) {
|
||||
if (!comments || comments.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
// 从后往前遍历(最新的在前)
|
||||
for (let i = comments.length - 1; i >= 0; i--) {
|
||||
const comment = comments[i]
|
||||
const parsed = parseCompletionMarker(comment.body || comment.content || '')
|
||||
|
||||
if (parsed && parsed.type === markerType) {
|
||||
return {
|
||||
...parsed,
|
||||
commentId: comment.id,
|
||||
createdAt: comment.created_at || comment.timestamp,
|
||||
author: comment.user?.login || comment.author || 'Unknown'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查指定标记是否存在
|
||||
*
|
||||
* @param {Array<Object>} comments - Comments 数组
|
||||
* @param {string} markerType - 标记类型
|
||||
* @returns {boolean} 是否存在
|
||||
*/
|
||||
export function hasMarker(comments, markerType) {
|
||||
return findLatestMarker(comments, markerType) !== null
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有标记的摘要
|
||||
*
|
||||
* @param {Array<Object>} comments - Comments 数组
|
||||
* @returns {Object} 标记摘要
|
||||
*/
|
||||
export function getMarkersSummary(comments) {
|
||||
const summary = {
|
||||
executeDone: findLatestMarker(comments, MarkerType.EXECUTE_DONE),
|
||||
reviewDone: findLatestMarker(comments, MarkerType.REVIEW_DONE),
|
||||
testDone: findLatestMarker(comments, MarkerType.TEST_DONE),
|
||||
verificationPassed: findLatestMarker(comments, MarkerType.VERIFICATION_PASSED),
|
||||
verificationRejected: findLatestMarker(comments, MarkerType.VERIFICATION_REJECTED),
|
||||
progressUpdate: findLatestMarker(comments, MarkerType.PROGRESS_UPDATE),
|
||||
consistencyIssue: findLatestMarker(comments, MarkerType.CONSISTENCY_ISSUE)
|
||||
}
|
||||
|
||||
// 计算当前阶段状态
|
||||
summary.currentPhase = determineCurrentPhase(summary)
|
||||
|
||||
return summary
|
||||
}
|
||||
|
||||
/**
|
||||
* 确定当前阶段
|
||||
*/
|
||||
function determineCurrentPhase(summary) {
|
||||
if (summary.verificationPassed || summary.verificationRejected) {
|
||||
return 'completed'
|
||||
}
|
||||
|
||||
if (summary.reviewDone && summary.testDone) {
|
||||
return 'verification'
|
||||
}
|
||||
|
||||
if (summary.executeDone) {
|
||||
return 'review_and_test'
|
||||
}
|
||||
|
||||
if (summary.progressUpdate) {
|
||||
return 'executing'
|
||||
}
|
||||
|
||||
return 'pending'
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出所有解析函数
|
||||
*/
|
||||
export default {
|
||||
MarkerType,
|
||||
parseCompletionMarker,
|
||||
extractMarkerData: parseCompletionMarker,
|
||||
findLatestMarker,
|
||||
hasMarker,
|
||||
getMarkersSummary,
|
||||
determineCurrentPhase
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
/**
|
||||
* 一致性检查模块
|
||||
*
|
||||
* 实现需求-设计-编码三向一致性检查逻辑
|
||||
*/
|
||||
|
||||
/**
|
||||
* 需求→设计一致性检查
|
||||
*
|
||||
* @param {Object} requirements - 需求列表
|
||||
* @param {Object} design - 设计文档
|
||||
* @returns {Object} 检查结果
|
||||
*/
|
||||
export function checkRequirementDesign(requirements, design) {
|
||||
const items = []
|
||||
let allPassed = true
|
||||
|
||||
// 如果 requirements 是数组,转换为对象
|
||||
const reqMap = Array.isArray(requirements)
|
||||
? requirements.reduce((acc, r, i) => {
|
||||
acc[`R${i + 1}`] = r
|
||||
return acc
|
||||
}, {})
|
||||
: requirements
|
||||
|
||||
// 遍历每个需求,检查设计是否覆盖
|
||||
for (const [reqId, requirement] of Object.entries(reqMap)) {
|
||||
const reqText = typeof requirement === 'string' ? requirement : requirement.description || requirement.text || JSON.stringify(requirement)
|
||||
const isCovered = checkDesignCoverage(reqText, design)
|
||||
|
||||
items.push({
|
||||
check: `需求 ${reqId}`,
|
||||
expected: reqText,
|
||||
actual: isCovered ? '已覆盖' : '未覆盖',
|
||||
passed: isCovered
|
||||
})
|
||||
|
||||
if (!isCovered) {
|
||||
allPassed = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'requirement-design',
|
||||
passed: allPassed,
|
||||
items,
|
||||
summary: allPassed ? '需求完全被设计覆盖' : '部分需求未被设计覆盖'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设计→编码一致性检查
|
||||
*
|
||||
* @param {Object} design - 设计文档
|
||||
* @param {Object} codeChanges - 代码变更
|
||||
* @returns {Object} 检查结果
|
||||
*/
|
||||
export function checkDesignCode(design, codeChanges) {
|
||||
const items = []
|
||||
let allPassed = true
|
||||
|
||||
// 提取设计中的关键组件/模块
|
||||
const designComponents = extractDesignComponents(design)
|
||||
|
||||
// 提取代码变更中的文件/模块
|
||||
const codeFiles = extractCodeFiles(codeChanges)
|
||||
|
||||
// 检查每个设计组件是否都有对应代码
|
||||
for (const component of designComponents) {
|
||||
const isImplemented = codeFiles.some(file =>
|
||||
file.name.includes(component.name) ||
|
||||
file.path.includes(component.path || component.name)
|
||||
)
|
||||
|
||||
items.push({
|
||||
check: `组件: ${component.name}`,
|
||||
expected: `设计: ${component.description || component.type || '组件'}`,
|
||||
actual: isImplemented ? `已实现: ${component.name}` : '未实现',
|
||||
passed: isImplemented
|
||||
})
|
||||
|
||||
if (!isImplemented) {
|
||||
allPassed = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'design-code',
|
||||
passed: allPassed,
|
||||
items,
|
||||
summary: allPassed ? '设计完全被编码实现' : '部分设计未在编码中实现'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 需求→编码一致性检查
|
||||
*
|
||||
* @param {Object} requirements - 需求列表
|
||||
* @param {Object} codeChanges - 代码变更
|
||||
* @returns {Object} 检查结果
|
||||
*/
|
||||
export function checkRequirementCode(requirements, codeChanges) {
|
||||
const items = []
|
||||
let allPassed = true
|
||||
|
||||
// 如果 requirements 是数组,转换为对象
|
||||
const reqMap = Array.isArray(requirements)
|
||||
? requirements.reduce((acc, r, i) => {
|
||||
acc[`R${i + 1}`] = r
|
||||
return acc
|
||||
}, {})
|
||||
: requirements
|
||||
|
||||
// 提取代码实现的功能点
|
||||
const codeFeatures = extractCodeFeatures(codeChanges)
|
||||
|
||||
// 检查每个需求是否在代码中实现
|
||||
for (const [reqId, requirement] of Object.entries(reqMap)) {
|
||||
const reqText = typeof requirement === 'string' ? requirement : requirement.description || requirement.text || JSON.stringify(requirement)
|
||||
const isImplemented = checkCodeImplementation(reqText, codeFeatures)
|
||||
|
||||
items.push({
|
||||
check: `需求 ${reqId}`,
|
||||
expected: reqText,
|
||||
actual: isImplemented ? '已实现' : '未实现',
|
||||
passed: isImplemented
|
||||
})
|
||||
|
||||
if (!isImplemented) {
|
||||
allPassed = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'requirement-code',
|
||||
passed: allPassed,
|
||||
items,
|
||||
summary: allPassed ? '需求完全在编码中实现' : '部分需求未在编码中实现'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 完整的三向一致性检查
|
||||
*
|
||||
* @param {Object} requirements - 需求列表
|
||||
* @param {Object} design - 设计文档
|
||||
* @param {Object} codeChanges - 代码变更
|
||||
* @returns {Object} 完整检查结果
|
||||
*/
|
||||
export function runThreeWayCheck(requirements, design, codeChanges) {
|
||||
const requirementDesign = checkRequirementDesign(requirements, design)
|
||||
const designCode = checkDesignCode(design, codeChanges)
|
||||
const requirementCode = checkRequirementCode(requirements, codeChanges)
|
||||
|
||||
const allPassed = requirementDesign.passed && designCode.passed && requirementCode.passed
|
||||
|
||||
return {
|
||||
allPassed,
|
||||
requirementDesign,
|
||||
designCode,
|
||||
requirementCode,
|
||||
summary: allPassed ? '三向一致性检查全部通过' : '三向一致性检查未通过',
|
||||
deviations: findDeviations(requirementDesign, designCode, requirementCode)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找偏差
|
||||
*/
|
||||
function findDeviations(requirementDesign, designCode, requirementCode) {
|
||||
const deviations = []
|
||||
|
||||
// 需求→设计偏差
|
||||
if (!requirementDesign.passed) {
|
||||
deviations.push({
|
||||
type: 'requirement-design',
|
||||
issues: requirementDesign.items.filter(i => !i.passed)
|
||||
})
|
||||
}
|
||||
|
||||
// 设计→编码偏差
|
||||
if (!designCode.passed) {
|
||||
deviations.push({
|
||||
type: 'design-code',
|
||||
issues: designCode.items.filter(i => !i.passed)
|
||||
})
|
||||
}
|
||||
|
||||
// 需求→编码偏差
|
||||
if (!requirementCode.passed) {
|
||||
deviations.push({
|
||||
type: 'requirement-code',
|
||||
issues: requirementCode.items.filter(i => !i.passed)
|
||||
})
|
||||
}
|
||||
|
||||
return deviations
|
||||
}
|
||||
|
||||
/**
|
||||
* 辅助函数:检查设计是否覆盖需求
|
||||
*/
|
||||
function checkDesignCoverage(requirementText, design) {
|
||||
const designText = typeof design === 'string' ? design : JSON.stringify(design)
|
||||
const reqKeywords = extractKeywords(requirementText)
|
||||
|
||||
// 至少有一个关键词在设计中被提及
|
||||
return reqKeywords.some(keyword =>
|
||||
designText.toLowerCase().includes(keyword.toLowerCase())
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 辅助函数:检查代码是否实现需求
|
||||
*/
|
||||
function checkCodeImplementation(requirementText, codeFeatures) {
|
||||
const reqKeywords = extractKeywords(requirementText)
|
||||
|
||||
// 至少有一个关键词在代码特性中被实现
|
||||
return reqKeywords.some(keyword =>
|
||||
codeFeatures.some(feature =>
|
||||
feature.toLowerCase().includes(keyword.toLowerCase())
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 辅助函数:提取关键词
|
||||
*/
|
||||
function extractKeywords(text) {
|
||||
// 简单的关键词提取:过滤常见停用词
|
||||
const stopWords = new Set(['的', '了', '是', '在', '和', '与', '或', '但', '如果', '那么', '这样', '那样', 'the', 'a', 'an', 'is', 'are', 'and', 'or', 'but', 'if', 'then', 'this', 'that'])
|
||||
|
||||
return text
|
||||
.toLowerCase()
|
||||
.split(/[\s,,。.!!??;;::]/)
|
||||
.filter(word => word.length > 1 && !stopWords.has(word))
|
||||
.slice(0, 5) // 取前5个关键词
|
||||
}
|
||||
|
||||
/**
|
||||
* 辅助函数:从设计中提取组件
|
||||
*/
|
||||
function extractDesignComponents(design) {
|
||||
const components = []
|
||||
|
||||
// 如果 design 是字符串,尝试解析
|
||||
if (typeof design === 'string') {
|
||||
// 简单的提取逻辑:查找常见的组件模式
|
||||
const patterns = [
|
||||
/表:\s*(\w+)/g,
|
||||
/接口:\s*(\w+)/g,
|
||||
/模块:\s*(\w+)/g,
|
||||
/组件:\s*(\w+)/g,
|
||||
/文件:\s*([^\s]+)/g
|
||||
]
|
||||
|
||||
for (const pattern of patterns) {
|
||||
let match
|
||||
while ((match = pattern.exec(design)) !== null) {
|
||||
components.push({
|
||||
name: match[1],
|
||||
type: 'unknown',
|
||||
description: match[0]
|
||||
})
|
||||
}
|
||||
}
|
||||
} else if (typeof design === 'object' && design !== null) {
|
||||
// 如果 design 是对象,直接提取
|
||||
for (const [key, value] of Object.entries(design)) {
|
||||
if (value && typeof value === 'object' && value.name) {
|
||||
components.push(value)
|
||||
} else if (typeof value === 'string') {
|
||||
components.push({
|
||||
name: key,
|
||||
type: 'unknown',
|
||||
description: value
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return components
|
||||
}
|
||||
|
||||
/**
|
||||
* 辅助函数:从代码变更中提取文件
|
||||
*/
|
||||
function extractCodeFiles(codeChanges) {
|
||||
const files = []
|
||||
|
||||
if (typeof codeChanges === 'string') {
|
||||
// 简单的文件路径提取
|
||||
const lines = codeChanges.split('\n')
|
||||
for (const line of lines) {
|
||||
const match = line.match(/([a-zA-Z0-9_\/]+\.[a-zA-Z0-9]+)/)
|
||||
if (match) {
|
||||
files.push({
|
||||
name: match[1].split('/').pop(),
|
||||
path: match[1]
|
||||
})
|
||||
}
|
||||
}
|
||||
} else if (Array.isArray(codeChanges)) {
|
||||
// 如果是数组,假设每个元素都是文件信息
|
||||
for (const change of codeChanges) {
|
||||
if (typeof change === 'string') {
|
||||
files.push({
|
||||
name: change.split('/').pop(),
|
||||
path: change
|
||||
})
|
||||
} else if (change.file || change.path || change.name) {
|
||||
files.push({
|
||||
name: change.file || change.name || change.path?.split('/').pop(),
|
||||
path: change.path || change.file
|
||||
})
|
||||
}
|
||||
}
|
||||
} else if (typeof codeChanges === 'object' && codeChanges !== null) {
|
||||
// 如果是对象,提取文件信息
|
||||
if (codeChanges.files) {
|
||||
return extractCodeFiles(codeChanges.files)
|
||||
}
|
||||
if (codeChanges.changes) {
|
||||
return extractCodeFiles(codeChanges.changes)
|
||||
}
|
||||
}
|
||||
|
||||
return files
|
||||
}
|
||||
|
||||
/**
|
||||
* 辅助函数:从代码中提取功能点
|
||||
*/
|
||||
function extractCodeFeatures(codeChanges) {
|
||||
const features = []
|
||||
|
||||
// 提取函数名、类名、变量名等作为功能特征
|
||||
const files = extractCodeFiles(codeChanges)
|
||||
|
||||
for (const file of files) {
|
||||
const name = file.name || file.path || ''
|
||||
// 从文件名推断功能
|
||||
if (name.includes('.')) {
|
||||
const baseName = name.substring(0, name.lastIndexOf('.'))
|
||||
features.push(baseName)
|
||||
}
|
||||
features.push(name)
|
||||
}
|
||||
|
||||
// 如果 codeChanges 包含 description 或 summary
|
||||
if (typeof codeChanges === 'object' && codeChanges !== null) {
|
||||
if (codeChanges.description) {
|
||||
features.push(codeChanges.description)
|
||||
}
|
||||
if (codeChanges.summary) {
|
||||
features.push(codeChanges.summary)
|
||||
}
|
||||
if (codeChanges.changes) {
|
||||
features.push(codeChanges.changes)
|
||||
}
|
||||
}
|
||||
|
||||
return features
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出所有检查函数
|
||||
*/
|
||||
export default {
|
||||
checkRequirementDesign,
|
||||
checkDesignCode,
|
||||
checkRequirementCode,
|
||||
runThreeWayCheck,
|
||||
findDeviations
|
||||
}
|
||||
@@ -0,0 +1,445 @@
|
||||
/**
|
||||
* Gitea MCP 适配层
|
||||
*
|
||||
* 封装 Gitea MCP 工具调用,提供统一的接口
|
||||
*/
|
||||
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
/**
|
||||
* 加载 Gitea 配置
|
||||
*/
|
||||
function loadConfig() {
|
||||
const configPath = path.join(process.cwd(), '.claude', 'gitea-config.json')
|
||||
try {
|
||||
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'))
|
||||
return config
|
||||
} catch (error) {
|
||||
throw new Error(`无法加载配置文件: ${configPath}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取仓库信息
|
||||
*/
|
||||
function getRepoInfo() {
|
||||
const config = loadConfig()
|
||||
return {
|
||||
baseUrl: config.gitea.baseUrl,
|
||||
owner: config.gitea.owner,
|
||||
repo: config.gitea.repo,
|
||||
defaultBranch: config.gitea.defaultBranch
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gitea MCP 适配器类
|
||||
*/
|
||||
export class GiteaMCPAdapter {
|
||||
constructor() {
|
||||
this.config = loadConfig()
|
||||
this.repoInfo = getRepoInfo()
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取仓库信息
|
||||
*/
|
||||
getRepoInfo() {
|
||||
return this.repoInfo
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 Issue
|
||||
*
|
||||
* @param {Object} options - Issue 选项
|
||||
* @param {string} options.title - Issue 标题
|
||||
* @param {string} options.body - Issue 正文
|
||||
* @param {string[]} options.labels - Issue 标签
|
||||
* @returns {Promise<Object>} Issue 信息
|
||||
*/
|
||||
async createIssue(options) {
|
||||
const { title, body, labels = [] } = options
|
||||
const { owner, repo } = this.repoInfo
|
||||
|
||||
try {
|
||||
// 调用 Gitea MCP 工具创建 Issue
|
||||
const result = await mcp__gitea__issue_write({
|
||||
method: 'create',
|
||||
owner,
|
||||
repo,
|
||||
title,
|
||||
body,
|
||||
labels
|
||||
})
|
||||
|
||||
return {
|
||||
id: result.id,
|
||||
number: result.number,
|
||||
url: `${this.repoInfo.baseUrl}/${owner}/${repo}/issues/${result.number}`,
|
||||
title: result.title,
|
||||
body: result.body
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error(`创建 Issue 失败: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新 Issue
|
||||
*
|
||||
* @param {number} issueNumber - Issue 编号
|
||||
* @param {Object} options - 更新选项
|
||||
* @returns {Promise<Object>} 更新后的 Issue 信息
|
||||
*/
|
||||
async updateIssue(issueNumber, options) {
|
||||
const { owner, repo } = this.repoInfo
|
||||
const { state, body } = options
|
||||
|
||||
try {
|
||||
const result = await mcp__gitea__issue_write({
|
||||
method: 'update',
|
||||
owner,
|
||||
repo,
|
||||
issue_number: issueNumber,
|
||||
state,
|
||||
body
|
||||
})
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
throw new Error(`更新 Issue 失败: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭 Issue
|
||||
*
|
||||
* @param {number} issueNumber - Issue 编号
|
||||
* @returns {Promise<Object>} 关闭后的 Issue 信息
|
||||
*/
|
||||
async closeIssue(issueNumber) {
|
||||
return this.updateIssue(issueNumber, { state: 'closed' })
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取 Issue Comments
|
||||
*
|
||||
* @param {number} issueNumber - Issue 编号
|
||||
* @returns {Promise<Array>} Comments 列表
|
||||
*/
|
||||
async getIssueComments(issueNumber) {
|
||||
const { owner, repo } = this.repoInfo
|
||||
|
||||
try {
|
||||
const comments = await mcp__gitea__issue_read({
|
||||
method: 'get_comments',
|
||||
owner,
|
||||
repo,
|
||||
issue_number: issueNumber
|
||||
})
|
||||
|
||||
return comments
|
||||
} catch (error) {
|
||||
throw new Error(`读取 Issue Comments 失败: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加 Issue Comment
|
||||
*
|
||||
* @param {number} issueNumber - Issue 编号
|
||||
* @param {string} body - Comment 内容
|
||||
* @returns {Promise<Object>} Comment 信息
|
||||
*/
|
||||
async createIssueComment(issueNumber, body) {
|
||||
const { owner, repo } = this.repoInfo
|
||||
|
||||
try {
|
||||
const comment = await mcp__gitea__issue_write({
|
||||
method: 'add_comment',
|
||||
owner,
|
||||
repo,
|
||||
issue_number: issueNumber,
|
||||
body
|
||||
})
|
||||
|
||||
return comment
|
||||
} catch (error) {
|
||||
throw new Error(`添加 Comment 失败: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建分支
|
||||
*
|
||||
* @param {string} branchName - 分支名称
|
||||
* @param {string} oldBranch - 源分支(默认为主分支)
|
||||
* @returns {Promise<Object>} 分支信息
|
||||
*/
|
||||
async createBranch(branchName, oldBranch = null) {
|
||||
const { owner, repo, defaultBranch } = this.repoInfo
|
||||
const sourceBranch = oldBranch || defaultBranch
|
||||
|
||||
try {
|
||||
const result = await mcp__gitea__create_branch({
|
||||
owner,
|
||||
repo,
|
||||
branch: branchName,
|
||||
old_branch: sourceBranch
|
||||
})
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
throw new Error(`创建分支失败: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出分支
|
||||
*
|
||||
* @returns {Promise<Array>} 分支列表
|
||||
*/
|
||||
async listBranches() {
|
||||
const { owner, repo } = this.repoInfo
|
||||
|
||||
try {
|
||||
const branches = await mcp__gitea__list_branches({
|
||||
owner,
|
||||
repo
|
||||
})
|
||||
|
||||
return branches
|
||||
} catch (error) {
|
||||
throw new Error(`列出分支失败: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 Pull Request
|
||||
*
|
||||
* @param {Object} options - PR 选项
|
||||
* @param {string} options.title - PR 标题
|
||||
* @param {string} options.body - PR 正文
|
||||
* @param {string} options.head - 源分支
|
||||
* @param {string} options.base - 目标分支
|
||||
* @returns {Promise<Object>} PR 信息
|
||||
*/
|
||||
async createPullRequest(options) {
|
||||
const { title, body, head, base } = options
|
||||
const { owner, repo, defaultBranch } = this.repoInfo
|
||||
const targetBranch = base || defaultBranch
|
||||
|
||||
try {
|
||||
const result = await mcp__gitea__pull_request_write({
|
||||
method: 'create',
|
||||
owner,
|
||||
repo,
|
||||
title,
|
||||
body,
|
||||
head,
|
||||
base: targetBranch
|
||||
})
|
||||
|
||||
return {
|
||||
id: result.id,
|
||||
number: result.number,
|
||||
url: `${this.repoInfo.baseUrl}/${owner}/${repo}/pulls/${result.number}`,
|
||||
title: result.title,
|
||||
body: result.body,
|
||||
head: result.head,
|
||||
base: result.base
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error(`创建 PR 失败: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并 Pull Request
|
||||
*
|
||||
* @param {number} pullNumber - PR 编号
|
||||
* @param {Object} options - 合并选项
|
||||
* @returns {Promise<Object>} 合并后的 PR 信息
|
||||
*/
|
||||
async mergePullRequest(pullNumber, options = {}) {
|
||||
const { owner, repo } = this.repoInfo
|
||||
const { deleteBranch = true, mergeStyle = 'merge' } = options
|
||||
|
||||
try {
|
||||
const result = await mcp__gitea__pull_request_write({
|
||||
method: 'merge',
|
||||
owner,
|
||||
repo,
|
||||
pull_number: pullNumber,
|
||||
delete_branch_after_merge: deleteBranch,
|
||||
merge_style: mergeStyle
|
||||
})
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
throw new Error(`合并 PR 失败: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Pull Request
|
||||
*
|
||||
* @param {number} pullNumber - PR 编号
|
||||
* @returns {Promise<Object>} PR 信息
|
||||
*/
|
||||
async getPullRequest(pullNumber) {
|
||||
const { owner, repo } = this.repoInfo
|
||||
|
||||
try {
|
||||
const pr = await mcp__gitea__pull_request_read({
|
||||
method: 'get',
|
||||
owner,
|
||||
repo,
|
||||
pull_number: pullNumber
|
||||
})
|
||||
|
||||
return pr
|
||||
} catch (error) {
|
||||
throw new Error(`获取 PR 失败: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出 Pull Request
|
||||
*
|
||||
* @param {Object} options - 查询选项
|
||||
* @returns {Promise<Array>} PR 列表
|
||||
*/
|
||||
async listPullRequests(options = {}) {
|
||||
const { owner, repo } = this.repoInfo
|
||||
const { state = 'open' } = options
|
||||
|
||||
try {
|
||||
const prs = await mcp__gitea__list_pull_requests({
|
||||
owner,
|
||||
repo,
|
||||
state
|
||||
})
|
||||
|
||||
return prs
|
||||
} catch (error) {
|
||||
throw new Error(`列出 PR 失败: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建或更新文件
|
||||
*
|
||||
* @param {Object} options - 文件选项
|
||||
* @param {string} options.path - 文件路径
|
||||
* @param {string} options.content - 文件内容
|
||||
* @param {string} options.message - Commit 消息
|
||||
* @param {string} options.branch - 分支名称
|
||||
* @param {string} options.sha - 文件 SHA(更新时需要)
|
||||
* @returns {Promise<Object>} 文件信息
|
||||
*/
|
||||
async createOrUpdateFile(options) {
|
||||
const { path: filePath, content, message, branch, sha } = options
|
||||
const { owner, repo } = this.repoInfo
|
||||
|
||||
try {
|
||||
const result = await mcp__gitea__create_or_update_file({
|
||||
owner,
|
||||
repo,
|
||||
path: filePath,
|
||||
content: Buffer.from(content).toString('base64'),
|
||||
message,
|
||||
branch_name: branch,
|
||||
sha
|
||||
})
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
throw new Error(`创建/更新文件失败: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件内容
|
||||
*
|
||||
* @param {string} filePath - 文件路径
|
||||
* @param {string} ref - 分支或 commit
|
||||
* @returns {Promise<Object>} 文件内容
|
||||
*/
|
||||
async getFileContents(filePath, ref = null) {
|
||||
const { owner, repo, defaultBranch } = this.repoInfo
|
||||
const targetRef = ref || defaultBranch
|
||||
|
||||
try {
|
||||
const file = await mcp__gitea__get_file_contents({
|
||||
owner,
|
||||
repo,
|
||||
path: filePath,
|
||||
ref: targetRef
|
||||
})
|
||||
|
||||
return file
|
||||
} catch (error) {
|
||||
throw new Error(`获取文件内容失败: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出提交
|
||||
*
|
||||
* @param {Object} options - 查询选项
|
||||
* @returns {Promise<Array>} 提交列表
|
||||
*/
|
||||
async listCommits(options = {}) {
|
||||
const { owner, repo } = this.repoInfo
|
||||
const { sha, path } = options
|
||||
|
||||
try {
|
||||
const commits = await mcp__gitea__list_commits({
|
||||
owner,
|
||||
repo,
|
||||
sha,
|
||||
path
|
||||
})
|
||||
|
||||
return commits
|
||||
} catch (error) {
|
||||
throw new Error(`列出提交失败: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 带重试的操作
|
||||
*
|
||||
* @param {Function} operation - 操作函数
|
||||
* @param {number} maxRetries - 最大重试次数
|
||||
* @returns {Promise<any>} 操作结果
|
||||
*/
|
||||
async withRetry(operation, maxRetries = 3) {
|
||||
let lastError
|
||||
for (let i = 0; i < maxRetries; i++) {
|
||||
try {
|
||||
return await operation()
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
console.warn(`操作失败,重试 ${i + 1}/${maxRetries}: ${error.message}`)
|
||||
// 等待后重试
|
||||
await new Promise(resolve => setTimeout(resolve, 1000 * (i + 1)))
|
||||
}
|
||||
}
|
||||
throw lastError
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建适配器实例
|
||||
*/
|
||||
export function createGiteaAdapter() {
|
||||
return new GiteaMCPAdapter()
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出单例
|
||||
*/
|
||||
export default createGiteaAdapter()
|
||||
@@ -0,0 +1,249 @@
|
||||
/**
|
||||
* Gitea 规则生成器
|
||||
*
|
||||
* 根据配置文件动态生成 Gitea 协作规则
|
||||
*/
|
||||
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
/**
|
||||
* 读取配置文件
|
||||
*/
|
||||
function loadConfig() {
|
||||
const configPath = path.join(process.cwd(), '.claude', 'gitea-config.json')
|
||||
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'))
|
||||
return config
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 Gitea URL
|
||||
*/
|
||||
function getGiteaUrl(config) {
|
||||
const { baseUrl, owner, repo } = config.gitea
|
||||
return `${baseUrl}/${owner}/${repo}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 Gitea 规则字符串
|
||||
*/
|
||||
export function generateGiteaRules(config = null) {
|
||||
if (!config) {
|
||||
config = loadConfig()
|
||||
}
|
||||
|
||||
const { baseUrl, owner, repo, defaultBranch } = config.gitea
|
||||
const { branchNaming, commitNaming, prTemplate } = config.rules
|
||||
const { checklist, approval } = config.review
|
||||
|
||||
return `
|
||||
# Gitea 协作规则
|
||||
|
||||
## 仓库信息
|
||||
- **Base URL**: ${baseUrl}
|
||||
- **Owner**: ${owner}
|
||||
- **Repository**: ${repo}
|
||||
- **URL**: ${getGiteaUrl(config)}
|
||||
- **主分支**: ${defaultBranch}
|
||||
|
||||
## 分支命名规则
|
||||
|
||||
| 类型 | 格式 | 示例 |
|
||||
|------|------|------|
|
||||
| 功能开发 | ${branchNaming.feature} | feature/login-api |
|
||||
| 问题修复 | ${branchNaming.fix} | fix/123-auth-error |
|
||||
| 紧急修复 | ${branchNaming.hotfix} | hotfix/security-patch |
|
||||
|
||||
## Commit 命名规则
|
||||
|
||||
| 类型 | 格式 | 示例 |
|
||||
|------|------|------|
|
||||
| 功能 | ${commitNaming.feature} | feat: 添加用户登录 |
|
||||
| 修复 | ${commitNaming.fix} | fix: 修复认证 bug |
|
||||
| 重构 | ${commitNaming.refactor} | refactor: 优化 API 结构 |
|
||||
| 文档 | ${commitNaming.docs} | docs: 更新 API 文档 |
|
||||
| 测试 | ${commitNaming.test} | test: 添加认证测试 |
|
||||
|
||||
## Execute Sub Agent 工作流程
|
||||
|
||||
### 1. 准备阶段
|
||||
\`\`\`bash
|
||||
# Clone 仓库
|
||||
git clone ${getGiteaUrl(config)}.git
|
||||
cd ${repo}
|
||||
|
||||
# 创建功能分支
|
||||
git checkout -b ${branchNaming.feature}
|
||||
\`\`\`
|
||||
|
||||
### 2. 实现阶段
|
||||
- 根据任务要求实现代码
|
||||
- 编写单元测试
|
||||
- 本地测试验证
|
||||
|
||||
### 3. 提交阶段
|
||||
\`\`\`bash
|
||||
git add .
|
||||
git commit -m "${commitNaming.feature}"
|
||||
git push -u origin ${branchNaming.feature}
|
||||
\`\`\`
|
||||
|
||||
### 4. PR 阶段
|
||||
- 在 Gitea 上创建 Pull Request
|
||||
- 标题格式: \`${prTemplate.title}\`
|
||||
- 描述模板:
|
||||
\`\`\`
|
||||
${prTemplate.description}
|
||||
\`\`\`
|
||||
|
||||
## Review Sub Agent 工作流程
|
||||
|
||||
### 1. 审查准备
|
||||
- 阅读 PR 代码变更
|
||||
- 检查以下项目:
|
||||
${checklist.map((item, i) => ` ${i + 1}. ${item}`).join('\n')}
|
||||
|
||||
### 2. 添加 Review
|
||||
- 在 Gitea PR 页面添加 Review 评论
|
||||
- 标注有问题的代码行
|
||||
- 提供具体的修改建议
|
||||
- 返回 Review 结果:
|
||||
|
||||
\`\`\`json
|
||||
{
|
||||
"passed": true|false,
|
||||
"findings": ["问题1", "问题2"],
|
||||
"suggestions": ["建议1", "建议2"],
|
||||
"approval": "${approval}"
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
## Test Sub Agent 工作流程
|
||||
|
||||
### 1. 测试准备
|
||||
- 读取 PR 代码
|
||||
- 理解功能需求
|
||||
|
||||
### 2. 编写测试
|
||||
- 单元测试
|
||||
- 集成测试
|
||||
- E2E 测试(如需要)
|
||||
|
||||
### 3. 执行测试
|
||||
\`\`\`bash
|
||||
npm test
|
||||
# 或
|
||||
pytest
|
||||
\`\`\`
|
||||
|
||||
### 4. 报告结果
|
||||
- 返回测试结果:
|
||||
|
||||
\`\`\`json
|
||||
{
|
||||
"passed": true|false,
|
||||
"coverage": "85%",
|
||||
"failures": [],
|
||||
"summary": "测试结果摘要"
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
## 修复工作流程
|
||||
|
||||
### 1. 接收 Review
|
||||
- 阅读 PR 中的 Review 评论
|
||||
- 理解问题所在
|
||||
|
||||
### 2. 修复问题
|
||||
\`\`\`bash
|
||||
# 在 PR 分支上修复
|
||||
git checkout ${branchNaming.feature}
|
||||
# 进行修复
|
||||
\`\`\`
|
||||
|
||||
### 3. 提交修复
|
||||
\`\`\`bash
|
||||
git add .
|
||||
git commit -m "${commitNaming.fix}"
|
||||
git push
|
||||
\`\`\`
|
||||
|
||||
### 4. 回复 Review
|
||||
- 在 PR 中回复 Review 评论
|
||||
- 说明修复内容
|
||||
|
||||
## 所有 Agent 共享规则
|
||||
|
||||
### 通过 Gitea 读取上下文
|
||||
- Commit 历史 = 上下文历史
|
||||
- PR 变更 = 代码变更
|
||||
- Review 评论 = 审查意见
|
||||
- Issues = 任务记录
|
||||
|
||||
### 通过 Gitea 传递结果
|
||||
- Push 代码 = 传递实现结果
|
||||
- 创建 PR = 请求审查
|
||||
- Review 评论 = 传递审查结果
|
||||
- 合并 PR = 验收完成
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **分支隔离**: 每个任务在独立分支上开发
|
||||
2. **提交清晰**: Commit 信息要清晰描述变更
|
||||
3. **PR 完整**: PR 描述要包含任务、方案、测试
|
||||
4. **Review 及时**: 及时响应 Review 评论
|
||||
5. **测试充分**: 确保测试覆盖充分
|
||||
|
||||
## 配置来源
|
||||
|
||||
本规则由以下配置生成:
|
||||
- 配置文件: .claude/gitea-config.json
|
||||
- 生成时间: ${new Date().toISOString()}
|
||||
`
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取仓库 URL
|
||||
*/
|
||||
export function getRepoUrl(config = null) {
|
||||
if (!config) {
|
||||
config = loadConfig()
|
||||
}
|
||||
return getGiteaUrl(config)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取分支名称
|
||||
*/
|
||||
export function getBranchName(type, name, config = null) {
|
||||
if (!config) {
|
||||
config = loadConfig()
|
||||
}
|
||||
const template = config.rules.branchNaming[type] || 'feature/{name}'
|
||||
return template.replace('{name}', name).replace('{task-name}', name)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Commit 模板
|
||||
*/
|
||||
export function getCommitTemplate(type, config = null) {
|
||||
if (!config) {
|
||||
config = loadConfig()
|
||||
}
|
||||
return config.rules.commitNaming[type] || '{description}'
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 PR 模板
|
||||
*/
|
||||
export function getPrTemplate(config = null) {
|
||||
if (!config) {
|
||||
config = loadConfig()
|
||||
}
|
||||
return config.rules.prTemplate
|
||||
}
|
||||
|
||||
// 导出默认函数
|
||||
export default function generateRules(config = null) {
|
||||
return generateGiteaRules(config)
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
/**
|
||||
* Linus 三问决策框架
|
||||
*
|
||||
* 实现工程审慎决策逻辑
|
||||
* 来源: Linus Torvalds "Talk Like a Kernel Developer"
|
||||
*/
|
||||
|
||||
/**
|
||||
* 三问枚举
|
||||
*/
|
||||
export const QuestionType = {
|
||||
REAL_PROBLEM: '这是现实问题还是想象问题?',
|
||||
NEEDS_SOLUTION: '这个问题真的需要解决吗?',
|
||||
SOLUTION_VIABLE: '这个方案真的能解决问题吗?'
|
||||
}
|
||||
|
||||
/**
|
||||
* Linus 三问决策器类
|
||||
*/
|
||||
export class LinusTriadDecision {
|
||||
constructor() {
|
||||
this.questions = [
|
||||
QuestionType.REAL_PROBLEM,
|
||||
QuestionType.NEEDS_SOLUTION,
|
||||
QuestionType.SOLUTION_VIABLE
|
||||
]
|
||||
this.answers = []
|
||||
this.reasoning = []
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行完整的 Linus 三问
|
||||
*
|
||||
* @param {string} taskDescription - 任务描述
|
||||
* @param {Object} options - 选项
|
||||
* @returns {Promise<Object>} 决策结果
|
||||
*/
|
||||
async ask(taskDescription, options = {}) {
|
||||
const { interactive = false, context = null } = options
|
||||
|
||||
// 清空之前的答案
|
||||
this.answers = []
|
||||
this.reasoning = []
|
||||
|
||||
log('🤔 开始 Linus 三问决策...')
|
||||
log(`📋 任务: ${taskDescription}`)
|
||||
|
||||
// 第一问:现实问题还是想象问题?
|
||||
const q1Result = await this.isRealProblem(taskDescription, context)
|
||||
this.answers.push(q1Result.answer)
|
||||
this.reasoning.push(q1Result.reasoning)
|
||||
|
||||
if (!q1Result.answer) {
|
||||
log('❌ 第一问未通过:这不是现实问题')
|
||||
return this.createResult(false, '第一问未通过:这不是现实问题', q1Result.reasoning)
|
||||
}
|
||||
log('✅ 第一问通过:这是现实问题')
|
||||
|
||||
// 第二问:这个问题真的需要解决吗?
|
||||
const q2Result = await this.needsSolution(taskDescription, context)
|
||||
this.answers.push(q2Result.answer)
|
||||
this.reasoning.push(q2Result.reasoning)
|
||||
|
||||
if (!q2Result.answer) {
|
||||
log('❌ 第二问未通过:这个问题不需要解决')
|
||||
return this.createResult(false, '第二问未通过:这个问题不需要解决', [
|
||||
this.reasoning[0],
|
||||
q2Result.reasoning
|
||||
])
|
||||
}
|
||||
log('✅ 第二问通过:这个问题需要解决')
|
||||
|
||||
// 第三问:这个方案真的能解决问题吗?
|
||||
const q3Result = await this.solutionViable(taskDescription, context)
|
||||
this.answers.push(q3Result.answer)
|
||||
this.reasoning.push(q3Result.reasoning)
|
||||
|
||||
if (!q3Result.answer) {
|
||||
log('❌ 第三问未通过:这个方案不能解决问题')
|
||||
return this.createResult(false, '第三问未通过:这个方案不能解决问题', [
|
||||
this.reasoning[0],
|
||||
this.reasoning[1],
|
||||
q3Result.reasoning
|
||||
])
|
||||
}
|
||||
log('✅ 第三问通过:这个方案能解决问题')
|
||||
|
||||
log('✅ Linus 三问全部通过!')
|
||||
|
||||
return this.createResult(true, 'Linus 三问全部通过', this.reasoning)
|
||||
}
|
||||
|
||||
/**
|
||||
* 第一问:这是现实问题还是想象问题?
|
||||
*
|
||||
* 拒绝条件:
|
||||
* - "可能需要"、"也许将来"
|
||||
* - 纯粹的假设性场景
|
||||
* - "以防万一"
|
||||
*/
|
||||
async isRealProblem(taskDescription, context) {
|
||||
log(`\n📌 第一问:${QuestionType.REAL_PROBLEM}`)
|
||||
|
||||
const answer = await this.evaluateQuestion(taskDescription, context, {
|
||||
positiveIndicators: [
|
||||
'用户反馈',
|
||||
'生产问题',
|
||||
'线上错误',
|
||||
'性能瓶颈',
|
||||
'安全漏洞',
|
||||
'bug',
|
||||
'错误',
|
||||
'失败',
|
||||
'崩溃',
|
||||
'无法',
|
||||
'必须',
|
||||
'紧急',
|
||||
'关键'
|
||||
],
|
||||
negativeIndicators: [
|
||||
'可能需要',
|
||||
'也许将来',
|
||||
'以防万一',
|
||||
'考虑未来',
|
||||
'如果发生',
|
||||
'假设',
|
||||
'理论上',
|
||||
'或许',
|
||||
'可能'
|
||||
],
|
||||
analysisPrompt: `判断以下任务是否是现实问题:
|
||||
"${taskDescription}"
|
||||
|
||||
回答 JSON:
|
||||
{
|
||||
"isRealProblem": true|false,
|
||||
"reasoning": "判断理由",
|
||||
"evidence": ["证据1", "证据2"],
|
||||
"verdict": "这是现实问题/这是想象问题"
|
||||
}`
|
||||
})
|
||||
|
||||
return {
|
||||
answer: answer.isRealProblem,
|
||||
reasoning: answer.reasoning,
|
||||
evidence: answer.evidence,
|
||||
verdict: answer.verdict
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 第二问:这个问题真的需要解决吗?
|
||||
*
|
||||
* 拒绝条件:
|
||||
* - 边缘场景
|
||||
* - 伪需求
|
||||
* - 性价比极低
|
||||
*/
|
||||
async needsSolution(taskDescription, context) {
|
||||
log(`\n📌 第二问:${QuestionType.NEEDS_SOLUTION}`)
|
||||
|
||||
const answer = await this.evaluateQuestion(taskDescription, context, {
|
||||
positiveIndicators: [
|
||||
'影响用户体验',
|
||||
'影响性能',
|
||||
'影响安全',
|
||||
'影响稳定性',
|
||||
'阻塞功能',
|
||||
'影响核心',
|
||||
'高频',
|
||||
'重要',
|
||||
'必要',
|
||||
'必须'
|
||||
],
|
||||
negativeIndicators: [
|
||||
'边缘',
|
||||
'小众',
|
||||
'罕见',
|
||||
'偶尔',
|
||||
'理论上',
|
||||
'可以接受',
|
||||
'不是必需',
|
||||
'可选项',
|
||||
'优化项',
|
||||
'锦上添花'
|
||||
],
|
||||
analysisPrompt: `判断以下问题是否真的需要解决:
|
||||
"${taskDescription}"
|
||||
|
||||
回答 JSON:
|
||||
{
|
||||
"needsSolution": true|false,
|
||||
"reasoning": "判断理由",
|
||||
"impact": "高/中/低",
|
||||
"verdict": "这个问题需要解决/这个问题不需要解决"
|
||||
}`
|
||||
})
|
||||
|
||||
return {
|
||||
answer: answer.needsSolution,
|
||||
reasoning: answer.reasoning,
|
||||
impact: answer.impact,
|
||||
verdict: answer.verdict
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 第三问:这个方案真的能解决问题吗?
|
||||
*
|
||||
* 拒绝条件:
|
||||
* - 理论上可行但无验证
|
||||
* - 缺少实施路径
|
||||
* - 技术栈不匹配
|
||||
*/
|
||||
async solutionViable(taskDescription, context) {
|
||||
log(`\n📌 第三问:${QuestionType.SOLUTION_VIABLE}`)
|
||||
|
||||
const answer = await this.evaluateQuestion(taskDescription, context, {
|
||||
positiveIndicators: [
|
||||
'明确方案',
|
||||
'具体步骤',
|
||||
'已有先例',
|
||||
'可验证',
|
||||
'可测试',
|
||||
'有计划',
|
||||
'有路径',
|
||||
'可行'
|
||||
],
|
||||
negativeIndicators: [
|
||||
'理论上',
|
||||
'应该可以',
|
||||
'可能需要',
|
||||
'待研究',
|
||||
'不确定',
|
||||
'猜测',
|
||||
'假设',
|
||||
'无从验证'
|
||||
],
|
||||
analysisPrompt: `判断以下方案是否真的能解决问题:
|
||||
"${taskDescription}"
|
||||
|
||||
如果有具体方案,请评估其可行性。如果没有方案,请判断是否可以制定有效方案。
|
||||
|
||||
回答 JSON:
|
||||
{
|
||||
"solutionViable": true|false,
|
||||
"reasoning": "判断理由",
|
||||
"hasConcretePlan": true|false,
|
||||
"verdict": "这个方案能解决问题/这个方案不能解决问题"
|
||||
}`
|
||||
})
|
||||
|
||||
return {
|
||||
answer: answer.solutionViable,
|
||||
reasoning: answer.reasoning,
|
||||
hasConcretePlan: answer.hasConcretePlan,
|
||||
verdict: answer.verdict
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 评估问题(使用 Agent 进行分析)
|
||||
*/
|
||||
async evaluateQuestion(taskDescription, context, config) {
|
||||
// 如果有上下文信息,使用它
|
||||
const fullContext = context ? `上下文: ${context}\n` : ''
|
||||
|
||||
// 使用 Plan Agent 进行分析
|
||||
try {
|
||||
const analysis = await agent({
|
||||
subagent_type: 'Plan',
|
||||
prompt: `${fullContext}${config.analysisPrompt}`,
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
isRealProblem: { type: 'boolean' },
|
||||
needsSolution: { type: 'boolean' },
|
||||
solutionViable: { type: 'boolean' },
|
||||
reasoning: { type: 'string' },
|
||||
evidence: { type: 'array', items: { type: 'string' } },
|
||||
impact: { type: 'string' },
|
||||
hasConcretePlan: { type: 'boolean' },
|
||||
verdict: { type: 'string' }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return analysis
|
||||
} catch (error) {
|
||||
log(`⚠️ Agent 分析失败: ${error.message}`)
|
||||
// 回退到简单关键词检测
|
||||
return this.fallbackEvaluation(taskDescription, config)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 回退评估(基于关键词)
|
||||
*/
|
||||
fallbackEvaluation(taskDescription, config) {
|
||||
const text = taskDescription.toLowerCase()
|
||||
const { positiveIndicators, negativeIndicators } = config
|
||||
|
||||
let positiveScore = 0
|
||||
let negativeScore = 0
|
||||
|
||||
for (const indicator of positiveIndicators) {
|
||||
if (text.includes(indicator.toLowerCase())) {
|
||||
positiveScore++
|
||||
}
|
||||
}
|
||||
|
||||
for (const indicator of negativeIndicators) {
|
||||
if (text.includes(indicator.toLowerCase())) {
|
||||
negativeScore++
|
||||
}
|
||||
}
|
||||
|
||||
const answer = positiveScore > negativeScore
|
||||
const reasoning = `基于关键词检测:正面指标 ${positiveScore} 个,负面指标 ${negativeScore} 个`
|
||||
|
||||
return {
|
||||
isRealProblem: answer,
|
||||
needsSolution: answer,
|
||||
solutionViable: answer,
|
||||
reasoning,
|
||||
verdict: answer ? '通过关键词检测' : '未通过关键词检测'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建决策结果
|
||||
*/
|
||||
createResult(passed, message, reasoning) {
|
||||
return {
|
||||
passed,
|
||||
message,
|
||||
reasoning: Array.isArray(reasoning) ? reasoning : [reasoning],
|
||||
answers: this.answers,
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 快速函数:执行 Linus 三问
|
||||
*
|
||||
* @param {string} taskDescription - 任务描述
|
||||
* @param {Object} options - 选项
|
||||
* @returns {Promise<Object>} 决策结果
|
||||
*/
|
||||
export async function askLinusTriad(taskDescription, options = {}) {
|
||||
const decision = new LinusTriadDecision()
|
||||
return await decision.ask(taskDescription, options)
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查任务是否通过 Linus 三问
|
||||
*
|
||||
* @param {string} taskDescription - 任务描述
|
||||
* @returns {Promise<boolean>} 是否通过
|
||||
*/
|
||||
export async function passesLinusTriad(taskDescription) {
|
||||
const result = await askLinusTriad(taskDescription)
|
||||
return result.passed
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成拒绝响应
|
||||
*
|
||||
* @param {Object} decisionResult - 决策结果
|
||||
* @returns {string} 拒绝消息
|
||||
*/
|
||||
export function generateRejectionMessage(decisionResult) {
|
||||
let message = `## ❌ 任务未通过 Linus 三问决策\n\n`
|
||||
message += `**原因**: ${decisionResult.message}\n\n`
|
||||
message += `### 决策过程\n\n`
|
||||
|
||||
for (let i = 0; i < decisionResult.reasoning.length; i++) {
|
||||
message += `**${i + 1}. ${decisionResult.reasoning[i]}**\n\n`
|
||||
}
|
||||
|
||||
message += `### 建议\n\n`
|
||||
message += `- 请重新审视任务需求\n`
|
||||
message += `- 确保这是需要解决的现实问题\n`
|
||||
message += `- 提供明确的解决方案\n`
|
||||
message += `- 如有疑问,请使用 deep-interview 进行需求澄清\n\n`
|
||||
|
||||
message += `---\n`
|
||||
message += `**决策时间**: ${decisionResult.timestamp}\n`
|
||||
|
||||
return message
|
||||
}
|
||||
|
||||
/**
|
||||
* 辅助函数:日志输出
|
||||
*/
|
||||
function log(message) {
|
||||
console.log(`[LinusTriad] ${message}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出
|
||||
*/
|
||||
export default {
|
||||
LinusTriadDecision,
|
||||
askLinusTriad,
|
||||
passesLinusTriad,
|
||||
generateRejectionMessage,
|
||||
QuestionType
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
/**
|
||||
* 需求追踪模块
|
||||
*
|
||||
* 追踪需求到设计和编码的映射,生成一致性检查报告
|
||||
*/
|
||||
|
||||
/**
|
||||
* 需求追踪器类
|
||||
*/
|
||||
export class RequirementTracker {
|
||||
constructor() {
|
||||
this.requirements = new Map() // id -> requirement
|
||||
this.designMapping = new Map() // requirementId -> designItems
|
||||
this.codeMapping = new Map() // requirementId -> codeItems
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加需求
|
||||
*/
|
||||
addRequirement(id, requirement) {
|
||||
this.requirements.set(id, {
|
||||
id,
|
||||
description: typeof requirement === 'string' ? requirement : requirement.description || requirement.text || JSON.stringify(requirement),
|
||||
status: 'pending',
|
||||
...requirement
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加设计映射
|
||||
*/
|
||||
addDesignMapping(requirementId, designItems) {
|
||||
this.designMapping.set(requirementId, Array.isArray(designItems) ? designItems : [designItems])
|
||||
|
||||
// 更新需求状态
|
||||
const req = this.requirements.get(requirementId)
|
||||
if (req) {
|
||||
req.status = 'designed'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加编码映射
|
||||
*/
|
||||
addCodeMapping(requirementId, codeItems) {
|
||||
this.codeMapping.set(requirementId, Array.isArray(codeItems) ? codeItems : [codeItems])
|
||||
|
||||
// 更新需求状态
|
||||
const req = this.requirements.get(requirementId)
|
||||
if (req) {
|
||||
req.status = 'implemented'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取需求
|
||||
*/
|
||||
getRequirement(id) {
|
||||
return this.requirements.get(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有需求
|
||||
*/
|
||||
getAllRequirements() {
|
||||
return Array.from(this.requirements.values())
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取需求的设计映射
|
||||
*/
|
||||
getDesignMapping(requirementId) {
|
||||
return this.designMapping.get(requirementId) || []
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取需求的编码映射
|
||||
*/
|
||||
getCodeMapping(requirementId) {
|
||||
return this.codeMapping.get(requirementId) || []
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查需求的完整性
|
||||
*/
|
||||
checkCompleteness(requirementId) {
|
||||
const req = this.getRequirement(requirementId)
|
||||
if (!req) {
|
||||
return { complete: false, reason: '需求不存在' }
|
||||
}
|
||||
|
||||
const hasDesign = this.designMapping.has(requirementId)
|
||||
const hasCode = this.codeMapping.has(requirementId)
|
||||
|
||||
if (!hasDesign) {
|
||||
return { complete: false, reason: '缺少设计映射' }
|
||||
}
|
||||
|
||||
if (!hasCode) {
|
||||
return { complete: false, reason: '缺少编码映射' }
|
||||
}
|
||||
|
||||
return { complete: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成追踪报告
|
||||
*/
|
||||
generateReport() {
|
||||
const requirements = this.getAllRequirements()
|
||||
const report = {
|
||||
total: requirements.length,
|
||||
pending: 0,
|
||||
designed: 0,
|
||||
implemented: 0,
|
||||
items: []
|
||||
}
|
||||
|
||||
for (const req of requirements) {
|
||||
const completeness = this.checkCompleteness(req.id)
|
||||
const designItems = this.getDesignMapping(req.id)
|
||||
const codeItems = this.getCodeMapping(req.id)
|
||||
|
||||
if (req.status === 'pending') {
|
||||
report.pending++
|
||||
} else if (req.status === 'designed') {
|
||||
report.designed++
|
||||
} else if (req.status === 'implemented') {
|
||||
report.implemented++
|
||||
}
|
||||
|
||||
report.items.push({
|
||||
id: req.id,
|
||||
description: req.description,
|
||||
status: req.status,
|
||||
complete: completeness.complete,
|
||||
designCount: designItems.length,
|
||||
codeCount: codeItems.length,
|
||||
designItems,
|
||||
codeItems
|
||||
})
|
||||
}
|
||||
|
||||
return report
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出为 Markdown 格式
|
||||
*/
|
||||
toMarkdown() {
|
||||
const report = this.generateReport()
|
||||
let md = '# 需求追踪报告\n\n'
|
||||
md += `## 概述\n\n`
|
||||
md += `- 总需求数: ${report.total}\n`
|
||||
md += `- 待处理: ${report.pending}\n`
|
||||
md += `- 已设计: ${report.designed}\n`
|
||||
md += `- 已实现: ${report.implemented}\n\n`
|
||||
|
||||
md += `## 详细追踪\n\n`
|
||||
|
||||
for (const item of report.items) {
|
||||
md += `### ${item.id}: ${item.description}\n\n`
|
||||
md += `- **状态**: ${item.status}\n`
|
||||
md += `- **完整**: ${item.complete ? '✅' : '❌'}\n`
|
||||
md += `- **设计映射**: ${item.designCount} 项\n`
|
||||
md += `- **编码映射**: ${item.codeCount} 项\n\n`
|
||||
|
||||
if (item.designItems.length > 0) {
|
||||
md += `#### 设计映射\n\n`
|
||||
for (const design of item.designItems) {
|
||||
md += `- ${typeof design === 'string' ? design : JSON.stringify(design)}\n`
|
||||
}
|
||||
md += `\n`
|
||||
}
|
||||
|
||||
if (item.codeItems.length > 0) {
|
||||
md += `#### 编码映射\n\n`
|
||||
for (const code of item.codeItems) {
|
||||
md += `- ${typeof code === 'string' ? code : JSON.stringify(code)}\n`
|
||||
}
|
||||
md += `\n`
|
||||
}
|
||||
}
|
||||
|
||||
return md
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建需求追踪器
|
||||
*/
|
||||
export function createRequirementTracker() {
|
||||
return new RequirementTracker()
|
||||
}
|
||||
|
||||
/**
|
||||
* 从一致性检查结果生成追踪数据
|
||||
*
|
||||
* @param {Object} checkResult - 一致性检查结果
|
||||
* @returns {Object} 追踪数据
|
||||
*/
|
||||
export function extractTrackingData(checkResult) {
|
||||
const tracker = new RequirementTracker()
|
||||
|
||||
// 从需求→设计检查中提取需求
|
||||
if (checkResult.requirementDesign) {
|
||||
for (const item of checkResult.requirementDesign.items) {
|
||||
const id = item.check.replace('需求 ', '')
|
||||
tracker.addRequirement(id, item.expected)
|
||||
if (item.passed) {
|
||||
tracker.addDesignMapping(id, [item.actual])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 从需求→编码检查中提取编码映射
|
||||
if (checkResult.requirementCode) {
|
||||
for (const item of checkResult.requirementCode.items) {
|
||||
const id = item.check.replace('需求 ', '')
|
||||
if (item.passed) {
|
||||
tracker.addCodeMapping(id, [item.actual])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tracker.generateReport()
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成一致性检查报告
|
||||
*
|
||||
* @param {Object} checkResult - 一致性检查结果
|
||||
* @returns {string} Markdown 报告
|
||||
*/
|
||||
export function generateConsistencyReport(checkResult) {
|
||||
let md = '# 三向一致性检查报告\n\n'
|
||||
|
||||
// 概述
|
||||
md += `## 概述\n\n`
|
||||
md += `- **总体结果**: ${checkResult.allPassed ? '✅ 通过' : '❌ 未通过'}\n`
|
||||
md += `- **需求→设计**: ${checkResult.requirementDesign.passed ? '✅ 通过' : '❌ 未通过'}\n`
|
||||
md += `- **设计→编码**: ${checkResult.designCode.passed ? '✅ 通过' : '❌ 未通过'}\n`
|
||||
md += `- **需求→编码**: ${checkResult.requirementCode.passed ? '✅ 通过' : '❌ 未通过'}\n\n`
|
||||
|
||||
// 需求→设计详细结果
|
||||
md += `## 1️⃣ 需求 → 设计\n\n`
|
||||
md += generateCheckTable(checkResult.requirementDesign.items)
|
||||
md += `\n${checkResult.requirementDesign.summary}\n\n`
|
||||
|
||||
// 设计→编码详细结果
|
||||
md += `## 2️⃣ 设计 → 编码\n\n`
|
||||
md += generateCheckTable(checkResult.designCode.items)
|
||||
md += `\n${checkResult.designCode.summary}\n\n`
|
||||
|
||||
// 需求→编码详细结果
|
||||
md += `## 3️⃣ 需求 → 编码\n\n`
|
||||
md += generateCheckTable(checkResult.requirementCode.items)
|
||||
md += `\n${checkResult.requirementCode.summary}\n\n`
|
||||
|
||||
// 偏差列表
|
||||
if (checkResult.deviations && checkResult.deviations.length > 0) {
|
||||
md += `## ⚠️ 发现的偏差\n\n`
|
||||
for (const deviation of checkResult.deviations) {
|
||||
md += `### ${deviation.type}\n\n`
|
||||
for (const issue of deviation.issues) {
|
||||
md += `- **${issue.check}**: ${issue.expected} ≠ ${issue.actual}\n`
|
||||
}
|
||||
md += `\n`
|
||||
}
|
||||
}
|
||||
|
||||
return md
|
||||
}
|
||||
|
||||
/**
|
||||
* 辅助函数:生成检查表格
|
||||
*/
|
||||
function generateCheckTable(items) {
|
||||
if (!items || items.length === 0) {
|
||||
return '无检查项\n'
|
||||
}
|
||||
|
||||
let table = '| 检查项 | 预期 | 实际 | 状态 |\n'
|
||||
table += '|--------|------|------|------|\n'
|
||||
|
||||
for (const item of items) {
|
||||
const status = item.passed ? '✅' : '❌'
|
||||
table += `| ${item.check} | ${item.expected} | ${item.actual} | ${status} |\n`
|
||||
}
|
||||
|
||||
return table
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出默认函数
|
||||
*/
|
||||
export default {
|
||||
RequirementTracker,
|
||||
createRequirementTracker,
|
||||
extractTrackingData,
|
||||
generateConsistencyReport
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
/**
|
||||
* Superpowers 集成层
|
||||
*
|
||||
* 实现 Superpowers 五技能体系的调用集成
|
||||
*/
|
||||
|
||||
/**
|
||||
* Superpowers 五阶段枚举
|
||||
*/
|
||||
export const SuperpowersPhase = {
|
||||
WRITING_PLANS: 'writing-plans',
|
||||
EXECUTING_PLANS: 'executing-plans',
|
||||
REQUESTING_CODE_REVIEW: 'requesting-code-review',
|
||||
SYSTEMATIC_DEBUGGING: 'systematic-debugging',
|
||||
FINISHING_DEVELOPMENT: 'finishing-a-development-branch'
|
||||
}
|
||||
|
||||
/**
|
||||
* Superpowers 集成类
|
||||
*/
|
||||
export class SuperpowersIntegration {
|
||||
constructor() {
|
||||
this.currentPhase = null
|
||||
this.phaseHistory = []
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用 writing-plans
|
||||
*
|
||||
* @param {Object} context - 上下文信息
|
||||
* @returns {Promise<Object>} 规划结果
|
||||
*/
|
||||
async invokeWritingPlans(context) {
|
||||
this.setPhase(SuperpowersPhase.WRITING_PLANS)
|
||||
log('📝 调用 writing-plans...')
|
||||
|
||||
try {
|
||||
const prompt = this.generateWritingPlansPrompt(context)
|
||||
const plan = await this.callSkill('writing-plans', prompt, context)
|
||||
|
||||
log('✅ writing-plans 完成')
|
||||
return plan
|
||||
} catch (error) {
|
||||
log(`❌ writing-plans 失败: ${error.message}`)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用 executing-plans
|
||||
*
|
||||
* @param {Object} plan - 执行计划
|
||||
* @returns {Promise<Object>} 执行结果
|
||||
*/
|
||||
async invokeExecutingPlans(plan) {
|
||||
this.setPhase(SuperpowersPhase.EXECUTING_PLANS)
|
||||
log('⚡ 调用 executing-plans...')
|
||||
|
||||
try {
|
||||
const prompt = this.generateExecutingPlansPrompt(plan)
|
||||
const result = await this.callSkill('executing-plans', prompt, { plan })
|
||||
|
||||
log('✅ executing-plans 完成')
|
||||
return result
|
||||
} catch (error) {
|
||||
log(`❌ executing-plans 失败: ${error.message}`)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用 requesting-code-review
|
||||
*
|
||||
* @param {Object} code - 代码信息
|
||||
* @returns {Promise<Object>} 审查结果
|
||||
*/
|
||||
async invokeRequestingCodeReview(code) {
|
||||
this.setPhase(SuperpowersPhase.REQUESTING_CODE_REVIEW)
|
||||
log('🔍 调用 requesting-code-review...')
|
||||
|
||||
try {
|
||||
const prompt = this.generateCodeReviewPrompt(code)
|
||||
const review = await this.callSkill('requesting-code-review', prompt, { code })
|
||||
|
||||
log('✅ requesting-code-review 完成')
|
||||
return review
|
||||
} catch (error) {
|
||||
log(`❌ requesting-code-review 失败: ${error.message}`)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用 systematic-debugging
|
||||
*
|
||||
* @param {Object} issue - 问题信息
|
||||
* @returns {Promise<Object>} 调试结果
|
||||
*/
|
||||
async invokeSystematicDebugging(issue) {
|
||||
this.setPhase(SuperpowersPhase.SYSTEMATIC_DEBUGGING)
|
||||
log('🐛 调用 systematic-debugging...')
|
||||
|
||||
try {
|
||||
const prompt = this.generateDebuggingPrompt(issue)
|
||||
const result = await this.callSkill('systematic-debugging', prompt, { issue })
|
||||
|
||||
log('✅ systematic-debugging 完成')
|
||||
return result
|
||||
} catch (error) {
|
||||
log(`❌ systematic-debugging 失败: ${error.message}`)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用 finishing-a-development-branch
|
||||
*
|
||||
* @param {Object} context - 上下文信息
|
||||
* @returns {Promise<Object>} 完成结果
|
||||
*/
|
||||
async invokeFinishingDevelopment(context) {
|
||||
this.setPhase(SuperpowersPhase.FINISHING_DEVELOPMENT)
|
||||
log('🏁 调用 finishing-a-development-branch...')
|
||||
|
||||
try {
|
||||
const prompt = this.generateFinishingPrompt(context)
|
||||
const result = await this.callSkill('finishing-a-development-branch', prompt, context)
|
||||
|
||||
log('✅ finishing-a-development-branch 完成')
|
||||
return result
|
||||
} catch (error) {
|
||||
log(`❌ finishing-a-development-branch 失败: ${error.message}`)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行完整的五阶段工作流
|
||||
*
|
||||
* @param {Object} context - 初始上下文
|
||||
* @returns {Promise<Object>} 完整工作流结果
|
||||
*/
|
||||
async runFullWorkflow(context) {
|
||||
log('🚀 开始 Superpowers 五阶段工作流...')
|
||||
|
||||
const results = {}
|
||||
|
||||
try {
|
||||
// 阶段 1: 规划
|
||||
results.plan = await this.invokeWritingPlans(context)
|
||||
|
||||
// 阶段 2: 执行
|
||||
results.execution = await this.invokeExecutingPlans(results.plan)
|
||||
|
||||
// 阶段 3: 审查
|
||||
results.review = await this.invokeRequestingCodeReview(results.execution)
|
||||
|
||||
// 如果审查未通过,进入调试
|
||||
if (results.review && !results.review.passed) {
|
||||
log('⚠️ 审查未通过,进入调试阶段...')
|
||||
results.debugging = await this.invokeSystematicDebugging({
|
||||
review: results.review,
|
||||
execution: results.execution
|
||||
})
|
||||
}
|
||||
|
||||
// 阶段 5: 完成
|
||||
results.finishing = await this.invokeFinishingDevelopment({
|
||||
...context,
|
||||
...results
|
||||
})
|
||||
|
||||
log('✅ Superpowers 五阶段工作流完成')
|
||||
return results
|
||||
} catch (error) {
|
||||
log(`❌ 工作流中断: ${error.message}`)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置当前阶段
|
||||
*/
|
||||
setPhase(phase) {
|
||||
if (this.currentPhase) {
|
||||
this.phaseHistory.push(this.currentPhase)
|
||||
}
|
||||
this.currentPhase = phase
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前阶段
|
||||
*/
|
||||
getPhase() {
|
||||
return this.currentPhase
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取阶段历史
|
||||
*/
|
||||
getPhaseHistory() {
|
||||
return [...this.phaseHistory, this.currentPhase].filter(Boolean)
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 writing-plans 提示
|
||||
*/
|
||||
generateWritingPlansPrompt(context) {
|
||||
return `
|
||||
请编写一份综合实现计划,假设工程师对代码库零上下文。
|
||||
|
||||
## 上下文
|
||||
${this.formatContext(context)}
|
||||
|
||||
## 要求
|
||||
1. 任务背景和目标
|
||||
2. 技术方案选择
|
||||
3. 实施步骤
|
||||
4. 验收标准
|
||||
|
||||
请提供详细的实现计划。
|
||||
`
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 executing-plans 提示
|
||||
*/
|
||||
generateExecutingPlansPrompt(plan) {
|
||||
return `
|
||||
请执行以下实现计划:
|
||||
|
||||
## 计划
|
||||
${this.formatContext(plan)}
|
||||
|
||||
## 要求
|
||||
1. 按照计划实施
|
||||
2. 编写完整的单元测试
|
||||
3. 确保代码质量
|
||||
4. 提供实施结果
|
||||
|
||||
请开始执行。
|
||||
`
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成代码审查提示
|
||||
*/
|
||||
generateCodeReviewPrompt(code) {
|
||||
return `
|
||||
请审查以下代码:
|
||||
|
||||
## 代码信息
|
||||
${this.formatContext(code)}
|
||||
|
||||
## 审查维度
|
||||
1. 逻辑正确性和边界情况
|
||||
2. 安全漏洞
|
||||
3. 性能影响
|
||||
4. 测试覆盖率
|
||||
5. 错误处理
|
||||
|
||||
请提供详细的审查结果。
|
||||
`
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成调试提示
|
||||
*/
|
||||
generateDebuggingPrompt(issue) {
|
||||
return `
|
||||
请系统化调试以下问题:
|
||||
|
||||
## 问题描述
|
||||
${this.formatContext(issue)}
|
||||
|
||||
## 要求
|
||||
1. 系统化定位问题(而非随机尝试)
|
||||
2. 分析根本原因
|
||||
3. 设计验证方案
|
||||
4. 提供修复建议
|
||||
|
||||
请开始调试。
|
||||
`
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成完成提示
|
||||
*/
|
||||
generateFinishingPrompt(context) {
|
||||
return `
|
||||
请完成开发分支的收尾工作:
|
||||
|
||||
## 上下文
|
||||
${this.formatContext(context)}
|
||||
|
||||
## 要求
|
||||
1. 确认所有测试通过
|
||||
2. 验证代码质量
|
||||
3. 准备提交
|
||||
4. 清理临时文件
|
||||
|
||||
请完成收尾工作。
|
||||
`
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化上下文
|
||||
*/
|
||||
formatContext(context) {
|
||||
if (typeof context === 'string') {
|
||||
return context
|
||||
}
|
||||
return JSON.stringify(context, null, 2)
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用 Skill
|
||||
*/
|
||||
async callSkill(skillName, prompt, context) {
|
||||
try {
|
||||
// 使用 Skill 工具调用
|
||||
const result = await Skill({
|
||||
skill: skillName,
|
||||
args: JSON.stringify({ prompt, context })
|
||||
})
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
// 如果 Skill 工具不可用,使用 Agent 作为回退
|
||||
log(`⚠️ Skill 工具不可用,使用 Agent 作为回退`)
|
||||
return await this.fallbackToAgent(skillName, prompt, context)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 回退到 Agent
|
||||
*/
|
||||
async fallbackToAgent(skillName, prompt, context) {
|
||||
const fullPrompt = `
|
||||
作为 ${skillName} 专家,执行以下任务:
|
||||
|
||||
${prompt}
|
||||
|
||||
请返回结构化的 JSON 结果。
|
||||
`
|
||||
|
||||
return await agent({
|
||||
subagent_type: 'general-purpose',
|
||||
prompt: fullPrompt
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 Superpowers 集成实例
|
||||
*/
|
||||
export function createSuperpowersIntegration() {
|
||||
return new SuperpowersIntegration()
|
||||
}
|
||||
|
||||
/**
|
||||
* 快速函数:调用 writing-plans
|
||||
*/
|
||||
export async function invokeWritingPlans(context) {
|
||||
const integration = createSuperpowersIntegration()
|
||||
return await integration.invokeWritingPlans(context)
|
||||
}
|
||||
|
||||
/**
|
||||
* 快速函数:调用 executing-plans
|
||||
*/
|
||||
export async function invokeExecutingPlans(plan) {
|
||||
const integration = createSuperpowersIntegration()
|
||||
return await integration.invokeExecutingPlans(plan)
|
||||
}
|
||||
|
||||
/**
|
||||
* 快速函数:调用 requesting-code-review
|
||||
*/
|
||||
export async function invokeRequestingCodeReview(code) {
|
||||
const integration = createSuperpowersIntegration()
|
||||
return await integration.invokeRequestingCodeReview(code)
|
||||
}
|
||||
|
||||
/**
|
||||
* 快速函数:调用 systematic-debugging
|
||||
*/
|
||||
export async function invokeSystematicDebugging(issue) {
|
||||
const integration = createSuperpowersIntegration()
|
||||
return await integration.invokeSystematicDebugging(issue)
|
||||
}
|
||||
|
||||
/**
|
||||
* 快速函数:调用 finishing-a-development-branch
|
||||
*/
|
||||
export async function invokeFinishingDevelopment(context) {
|
||||
const integration = createSuperpowersIntegration()
|
||||
return await integration.invokeFinishingDevelopment(context)
|
||||
}
|
||||
|
||||
/**
|
||||
* 辅助函数:日志输出
|
||||
*/
|
||||
function log(message) {
|
||||
console.log(`[Superpowers] ${message}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出
|
||||
*/
|
||||
export default {
|
||||
SuperpowersIntegration,
|
||||
createSuperpowersIntegration,
|
||||
invokeWritingPlans,
|
||||
invokeExecutingPlans,
|
||||
invokeRequestingCodeReview,
|
||||
invokeSystematicDebugging,
|
||||
invokeFinishingDevelopment,
|
||||
SuperpowersPhase
|
||||
}
|
||||
+7
-2
@@ -76,8 +76,9 @@ logs/
|
||||
temp/
|
||||
tmp/
|
||||
|
||||
# VeighNa upstream (don't modify directly)
|
||||
vnpy_v4.4.0/
|
||||
# VeighNa upstream (included for version reference)
|
||||
# Managed as version-controlled reference
|
||||
# vnpy_v4.4.0/.git is excluded to avoid nested repo
|
||||
|
||||
# Knowledge base repositories
|
||||
knowledge_base/
|
||||
@@ -94,3 +95,7 @@ setting/
|
||||
*.json
|
||||
!pyproject.toml
|
||||
!package.json
|
||||
|
||||
# Docker deployment files (may contain sensitive paths)
|
||||
docker/.env
|
||||
!docker/.env.example
|
||||
|
||||
+39
-2
@@ -8,8 +8,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- Initial project structure setup
|
||||
- Development workflow documentation
|
||||
- Web 服务模块 (sanguo_web/)
|
||||
- FastAPI REST API 框架
|
||||
- WebSocket 实时数据推送
|
||||
- JWT 认证系统
|
||||
- 完整的交易 API (下单、撤单、查询)
|
||||
- 网关管理 API
|
||||
- 行情订阅 API
|
||||
- 策略管理 API
|
||||
- Docker 容器化支持
|
||||
- Dockerfile 构建配置
|
||||
- docker-compose.yml 编排配置
|
||||
- Synology NAS 部署脚本
|
||||
- 完整的部署文档
|
||||
- 测试套件
|
||||
- API 端点测试 (test_api.py)
|
||||
- WebSocket 测试 (test_websocket.py)
|
||||
- 验证脚本 (verify_websocket.py)
|
||||
- 部署包 (sanguo_vnpy_v2_deployment/)
|
||||
- 一键部署脚本
|
||||
- 配置模板
|
||||
- 部署文档
|
||||
|
||||
### Changed
|
||||
- 更新 README.md,增加 Web 服务和 Docker 部署说明
|
||||
- 统一 Dockerfile 配置,移除冗余的 Dockerfile.nas
|
||||
- 优化项目结构,新增 docker/ 目录
|
||||
|
||||
### Fixed
|
||||
- 修复 entrypoint.sh 中的环境变量处理
|
||||
- 修复 docker-compose.yml 中的路径引用
|
||||
|
||||
## [0.1.0] - 2025-06-25
|
||||
|
||||
@@ -24,3 +52,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
- **Unreleased**: 正在开发中的功能
|
||||
- **[版本号] - 日期**: 已发布版本
|
||||
|
||||
## [2.0.0] - 2026-07-02 (即将发布)
|
||||
|
||||
### 主要特性
|
||||
- 🌐 Web 界面支持
|
||||
- 🐳 Docker 容器化部署
|
||||
- 📡 WebSocket 实时数据推送
|
||||
- 🔐 JWT 认证系统
|
||||
- 📊 完整的交易 API
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
# Sanguo VeighNa 量化交易平台
|
||||
# Sanguo VeighNa 量化交易平台 v2.0
|
||||
|
||||
基于 VeighNa 4.4.0 的二次开发量化交易平台。
|
||||
基于 VeighNa 4.4.0 的二次开发量化交易平台,支持 Web 界面和 Docker 容器化部署。
|
||||
|
||||
## 特性
|
||||
|
||||
- 🚀 **基于 VeighNa 4.4.0** - 最新稳定版本
|
||||
- 🌐 **Web 界面** - FastAPI + WebSocket 实时数据推送
|
||||
- 🐳 **Docker 部署** - 支持 Synology NAS 等容器环境
|
||||
- 📊 **完整功能** - 行情订阅、交易下单、策略管理
|
||||
- 🔐 **安全认证** - JWT Token 认证机制
|
||||
- 📡 **实时推送** - WebSocket 行情、成交、持仓实时更新
|
||||
|
||||
## 项目结构
|
||||
|
||||
@@ -11,6 +20,15 @@ sanguo_vnpy_v2/
|
||||
├── sanguo_research/ # 量化投研模块
|
||||
├── sanguo_data/ # 数据管理模块
|
||||
├── sanguo_common/ # 公共模块
|
||||
├── sanguo_web/ # Web 服务模块
|
||||
│ ├── api/ # REST API
|
||||
│ ├── websocket/ # WebSocket 服务
|
||||
│ └── services/ # 业务服务
|
||||
├── docker/ # Docker 配置
|
||||
│ ├── Dockerfile # 镜像构建
|
||||
│ ├── docker-compose.yml # 容器编排
|
||||
│ ├── entrypoint.sh # 启动脚本
|
||||
│ └── deploy-synology.sh # NAS 部署脚本
|
||||
├── tests/ # 测试代码
|
||||
├── docs/ # 文档
|
||||
└── examples/ # 示例代码
|
||||
@@ -18,12 +36,43 @@ sanguo_vnpy_v2/
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 环境要求
|
||||
### 方式 1: Docker 部署(推荐)
|
||||
|
||||
#### 本地开发
|
||||
|
||||
```bash
|
||||
# 构建镜像
|
||||
cd docker
|
||||
docker-compose build
|
||||
|
||||
# 启动服务
|
||||
docker-compose up -d
|
||||
|
||||
# 访问 Web 界面
|
||||
open http://localhost:8000
|
||||
```
|
||||
|
||||
#### Synology NAS 部署
|
||||
|
||||
1. 开启 SSH 服务
|
||||
2. 上传部署包到 NAS
|
||||
3. 运行部署脚本:
|
||||
|
||||
```bash
|
||||
cd /volume1/docker/containers/sanguo_vnpy_v2/docker
|
||||
sudo bash deploy-synology.sh
|
||||
```
|
||||
|
||||
详细说明请参考 [Synology NAS 部署指南](docs/deployment/synology-nas.md)
|
||||
|
||||
### 方式 2: 本地开发
|
||||
|
||||
#### 环境要求
|
||||
|
||||
- Python 3.10+
|
||||
- 推荐使用 Python 3.13
|
||||
- 推荐 Python 3.13
|
||||
|
||||
### 安装
|
||||
#### 安装步骤
|
||||
|
||||
```bash
|
||||
# 创建虚拟环境
|
||||
@@ -33,11 +82,128 @@ source venv/bin/activate # Linux/Mac
|
||||
|
||||
# 安装依赖
|
||||
pip install -r requirements/base.txt
|
||||
|
||||
# 启动 Web 服务
|
||||
python run_web.py
|
||||
```
|
||||
|
||||
#### Web 服务启动
|
||||
|
||||
```bash
|
||||
# 开发模式(端口 8002)
|
||||
python run_web.py
|
||||
|
||||
# 生产模式
|
||||
uvicorn sanguo_web.api:app --host 0.0.0.0 --port 8000 --workers 2
|
||||
```
|
||||
|
||||
## API 文档
|
||||
|
||||
启动服务后访问:
|
||||
|
||||
- Swagger UI: `http://localhost:8000/docs`
|
||||
- ReDoc: `http://localhost:8000/redoc`
|
||||
|
||||
## 认证
|
||||
|
||||
默认登录凭据:
|
||||
|
||||
- **用户名**: `admin`
|
||||
- **密码**: `admin123`
|
||||
|
||||
**⚠️ 重要**: 首次登录后请立即修改密码!
|
||||
|
||||
## WebSocket 连接
|
||||
|
||||
```javascript
|
||||
// 连接 WebSocket
|
||||
const ws = new WebSocket('ws://localhost:8080');
|
||||
|
||||
// 订阅行情
|
||||
ws.send(JSON.stringify({
|
||||
type: 'subscribe',
|
||||
data: { subscription: ['tick', 'order', 'trade'] }
|
||||
}));
|
||||
|
||||
// 接收消息
|
||||
ws.onmessage = (event) => {
|
||||
const message = JSON.parse(event.data);
|
||||
console.log('Received:', message);
|
||||
};
|
||||
```
|
||||
|
||||
## 测试
|
||||
|
||||
```bash
|
||||
# 运行 API 测试
|
||||
pytest tests/test_api.py -v
|
||||
|
||||
# 运行 WebSocket 测试
|
||||
python tests/test_websocket.py
|
||||
|
||||
# 运行所有测试
|
||||
pytest tests/ -v
|
||||
```
|
||||
|
||||
## 开发指南
|
||||
|
||||
请查看 [docs/development.md](docs/development.md) 获取详细开发指南。
|
||||
详细开发指南请查看:
|
||||
|
||||
- [开发文档](docs/development.md)
|
||||
- [API 设计文档](docs/api/README.md)
|
||||
- [部署文档](docs/deployment/README.md)
|
||||
- [用户指南](docs/user_guide/README.md)
|
||||
|
||||
## 主要功能模块
|
||||
|
||||
### 1. 认证系统 (`/api/v1/auth`)
|
||||
- 用户登录/登出
|
||||
- Token 验证
|
||||
- 用户信息获取
|
||||
|
||||
### 2. 网关管理 (`/api/v1/gateway`)
|
||||
- 可用网关列表
|
||||
- 网关连接/断开
|
||||
- 网关配置管理
|
||||
|
||||
### 3. 行情数据 (`/api/v1/market`)
|
||||
- 实时行情查询
|
||||
- 行情订阅/取消订阅
|
||||
- 合约信息查询
|
||||
|
||||
### 4. 交易功能 (`/api/v1/trading`)
|
||||
- 账户信息查询
|
||||
- 持仓查询
|
||||
- 订单管理(下单、撤单)
|
||||
- 成交记录查询
|
||||
|
||||
### 5. 策略管理 (`/api/v1/strategy`)
|
||||
- 策略列表
|
||||
- 策略启动/停止
|
||||
- 策略参数配置
|
||||
|
||||
## 环境变量
|
||||
|
||||
主要环境变量(见 `docker/.env.example`):
|
||||
|
||||
```env
|
||||
# 数据目录
|
||||
DATA_DIR=/volume1/docker/stock/sanguo_vnpy
|
||||
|
||||
# 端口配置
|
||||
HTTP_PORT=8000
|
||||
WS_PORT=8080
|
||||
|
||||
# 时区
|
||||
TZ=Asia/Shanghai
|
||||
|
||||
# JWT 密钥
|
||||
JWT_SECRET_KEY=your-secret-key-here
|
||||
|
||||
# 数据库
|
||||
DB_TYPE=sqlite
|
||||
DB_PATH=/app/data/vnpy.db
|
||||
```
|
||||
|
||||
## 分支策略
|
||||
|
||||
@@ -50,6 +216,16 @@ pip install -r requirements/base.txt
|
||||
|
||||
查看 [CHANGELOG.md](CHANGELOG.md)
|
||||
|
||||
## 技术栈
|
||||
|
||||
- **后端框架**: FastAPI 0.100+
|
||||
- **WebSocket**: websockets 12.0+
|
||||
- **数据库**: SQLite (可扩展 PostgreSQL)
|
||||
- **缓存**: Redis 7+
|
||||
- **容器**: Docker + Docker Compose
|
||||
- **认证**: JWT (python-jose)
|
||||
- **测试**: pytest + httpx
|
||||
|
||||
## 许可证
|
||||
|
||||
MIT License
|
||||
@@ -57,3 +233,8 @@ MIT License
|
||||
## 致谢
|
||||
|
||||
基于 [VeighNa](https://github.com/vnpy/vnpy) 框架开发
|
||||
|
||||
## 联系方式
|
||||
|
||||
- 问题反馈: GitHub Issues
|
||||
- 文档: `docs/` 目录
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
# Sanguo VeighNa 环境变量配置示例
|
||||
# 复制此文件为 .env 并根据需要修改
|
||||
|
||||
# ============================================
|
||||
# 数据目录配置(NAS 部署关键配置)
|
||||
# ============================================
|
||||
# Synology DSM 路径通常是 /volume1/...
|
||||
# 请根据实际情况修改此路径
|
||||
DATA_DIR=/volume1/docker/stock/sanguo_vnpy
|
||||
|
||||
# ============================================
|
||||
# 端口配置
|
||||
# ============================================
|
||||
HTTP_PORT=8000
|
||||
WS_PORT=8080
|
||||
NGINX_HTTP_PORT=80
|
||||
NGINX_HTTPS_PORT=443
|
||||
|
||||
# ============================================
|
||||
# 时区配置
|
||||
# ============================================
|
||||
TZ=Asia/Shanghai
|
||||
|
||||
# ============================================
|
||||
# VeighNa 日志配置
|
||||
# ============================================
|
||||
VNPY_LOG_LEVEL=INFO
|
||||
VNPY_LOG_FILE=/app/logs/vnpy.log
|
||||
|
||||
# ============================================
|
||||
# 数据库配置
|
||||
# ============================================
|
||||
DB_TYPE=sqlite
|
||||
DB_PATH=/app/data/vnpy.db
|
||||
|
||||
POSTGRES_DB=vnpy
|
||||
POSTGRES_USER=vnpy
|
||||
POSTGRES_PASSWORD=vnpy_password_change_me
|
||||
POSTGRES_HOST=postgres
|
||||
POSTGRES_PORT=5432
|
||||
|
||||
# ============================================
|
||||
# Web 服务配置
|
||||
# ============================================
|
||||
WEB_HOST=0.0.0.0
|
||||
WEB_PORT=8000
|
||||
WEB_WORKERS=2
|
||||
|
||||
# ============================================
|
||||
# 安全配置
|
||||
# ============================================
|
||||
JWT_SECRET_KEY=change-this-to-a-random-secret-key
|
||||
SESSION_TIMEOUT=3600
|
||||
|
||||
# ============================================
|
||||
# Redis 配置
|
||||
# ============================================
|
||||
REDIS_HOST=redis
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASSWORD=
|
||||
|
||||
# ============================================
|
||||
# Nginx 配置
|
||||
# ============================================
|
||||
ENABLE_NGINX=false
|
||||
|
||||
# ============================================
|
||||
# 资源限制
|
||||
# ============================================
|
||||
VNPY_CPU_LIMIT=2.0
|
||||
VNPY_MEMORY_LIMIT=2G
|
||||
VNPY_CPU_RESERVATION=0.5
|
||||
VNPY_MEMORY_RESERVATION=512M
|
||||
|
||||
REDIS_CPU_LIMIT=0.5
|
||||
REDIS_MEMORY_LIMIT=256M
|
||||
|
||||
NGINX_CPU_LIMIT=0.5
|
||||
NGINX_MEMORY_LIMIT=256M
|
||||
|
||||
# ============================================
|
||||
# 开发选项
|
||||
# ============================================
|
||||
SKIP_REDIS_CHECK=0
|
||||
USE_POSTGRES=0
|
||||
DEBUG=0
|
||||
@@ -0,0 +1,71 @@
|
||||
# Sanguo VeighNa Docker 镜像 - NAS 适配版
|
||||
# 使用 Python 3.10(Synology NAS 已有镜像)
|
||||
# 单阶段构建,简化部署流程
|
||||
|
||||
FROM python:3.10-slim
|
||||
|
||||
# 设置环境变量
|
||||
ENV TZ=Asia/Shanghai \
|
||||
DEBIAN_FRONTEND=noninteractive \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
VNPY_LOG_LEVEL=INFO \
|
||||
TA_LIBRARY_PATH=/usr/local/lib \
|
||||
TA_INCLUDE_PATH=/usr/local/include
|
||||
|
||||
# 安装运行时和编译依赖
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
curl \
|
||||
build-essential \
|
||||
wget \
|
||||
gcc \
|
||||
g++ \
|
||||
make \
|
||||
libc6-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# 下载并编译 TA-Lib
|
||||
RUN wget -q http://prdownloads.sourceforge.net/ta-lib/ta-lib-0.4.0-src.tar.gz \
|
||||
&& tar -xzf ta-lib-0.4.0-src.tar.gz \
|
||||
&& cd ta-lib \
|
||||
&& ./configure --prefix=/usr \
|
||||
&& make \
|
||||
&& make install \
|
||||
&& cd .. \
|
||||
&& rm -rf ta-lib ta-lib-0.4.0-src.tar.gz
|
||||
|
||||
# 更新 ldconfig
|
||||
RUN ldconfig
|
||||
|
||||
# 设置工作目录
|
||||
WORKDIR /app
|
||||
|
||||
# 创建数据目录
|
||||
RUN mkdir -p /app/data /app/logs /app/config /app/temp
|
||||
|
||||
# 复制 requirements 并安装依赖
|
||||
COPY requirements-docker.txt .
|
||||
RUN pip install --no-cache-dir --upgrade pip && \
|
||||
pip install --no-cache-dir -r requirements-docker.txt
|
||||
|
||||
# 复制应用代码(包含所有 sanguo 模块)
|
||||
COPY sanguo_web/ /app/sanguo_web/
|
||||
COPY vnpy_v4.4.0/vnpy/ /app/vnpy/
|
||||
COPY sanguo_trader/ /app/sanguo_trader/
|
||||
COPY sanguo_research/ /app/sanguo_research/
|
||||
COPY sanguo_data/ /app/sanguo_data/
|
||||
COPY sanguo_common/ /app/sanguo_common/
|
||||
|
||||
# 复制启动脚本
|
||||
COPY docker/entrypoint.sh /app/
|
||||
RUN chmod +x /app/entrypoint.sh
|
||||
|
||||
# 暴露端口
|
||||
EXPOSE 8000 8080
|
||||
|
||||
# 健康检查
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
|
||||
CMD curl -f http://localhost:8000/health || exit 1
|
||||
|
||||
# 启动命令
|
||||
ENTRYPOINT ["/app/entrypoint.sh"]
|
||||
@@ -0,0 +1,59 @@
|
||||
# Sanguo VeighNa 基础镜像
|
||||
# 包含所有 Python 依赖,仅在依赖变更时重建
|
||||
FROM python:3.11-slim
|
||||
|
||||
LABEL maintainer="sanguo"
|
||||
LABEL description="Sanguo VeighNa Base Image with all dependencies"
|
||||
|
||||
# 设置工作目录
|
||||
WORKDIR /build
|
||||
|
||||
# 安装系统依赖(编译时需要)
|
||||
RUN apt-get update && apt-get install -y \
|
||||
gcc g++ make wget \
|
||||
build-essential \
|
||||
libssl-dev libffi-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# 安装 TA-Lib
|
||||
ENV TA_LIBRARY_PATH=/usr/local/lib
|
||||
ENV TA_HEADER_PATH=/usr/include
|
||||
RUN wget -q http://prdownloads.sourceforge.net/ta-lib/ta-lib-0.4.0-src.tar.gz && \
|
||||
tar -xzf ta-lib-0.4.0-src.tar.gz && \
|
||||
cd ta-lib && \
|
||||
./configure --prefix=/usr && \
|
||||
make && make install && \
|
||||
cd .. && \
|
||||
rm -rf ta-lib ta-lib-0.4.0-src.tar.gz
|
||||
|
||||
# 使用清华镜像加速
|
||||
ARG PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple
|
||||
ARG PIP_TRUSTED_HOST=pypi.tuna.tsinghua.edu.cn
|
||||
ENV PIP_INDEX_URL=${PIP_INDEX_URL}
|
||||
ENV PIP_TRUSTED_HOST=${PIP_TRUSTED_HOST}
|
||||
|
||||
# CPU 兼容性配置
|
||||
ENV POLARS_SKIP_CPU_CHECK=1
|
||||
|
||||
# 复制依赖文件
|
||||
COPY requirements-docker.txt .
|
||||
|
||||
# 安装所有 Python 依赖
|
||||
# --root-user-action=ignore 抑制 "running as root" 警告
|
||||
RUN pip install --no-cache-dir --root-user-action=ignore -r requirements-docker.txt
|
||||
|
||||
# 清理编译工具以减小镜像体积
|
||||
RUN apt-get purge -y gcc g++ make wget && \
|
||||
apt-get autoremove -y && \
|
||||
rm -rf /var/lib/apt/lists/* /tmp/*
|
||||
|
||||
# 设置最终工作目录
|
||||
WORKDIR /app
|
||||
|
||||
# 安装 Web 服务基础依赖
|
||||
RUN pip install --no-cache-dir --root-user-action=ignore uvicorn[standard] fastapi
|
||||
|
||||
# 元数据标签
|
||||
LABEL build_date="2025-07-02"
|
||||
LABEL python_version="3.11"
|
||||
LABEL description="Sanguo VeighNa Base - Ready for application layer"
|
||||
@@ -0,0 +1,43 @@
|
||||
# Sanguo VeighNa Docker 镜像 - 本地构建版
|
||||
# 使用国内 PyPI 镜像源,跳过 apt-get 和 TA-Lib 编译
|
||||
|
||||
FROM python:3.10-slim
|
||||
|
||||
# 设置环境变量
|
||||
ENV TZ=Asia/Shanghai \
|
||||
DEBIAN_FRONTEND=noninteractive \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
VNPY_LOG_LEVEL=INFO \
|
||||
TA_LIBRARY_PATH=/usr/local/lib \
|
||||
TA_INCLUDE_PATH=/usr/local/include \
|
||||
PIP_DEFAULT_TIMEOUT=300 \
|
||||
# 使用清华 PyPI 镜像
|
||||
PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple \
|
||||
PIP_TRUSTED_HOST=pypi.tuna.tsinghua.edu.cn
|
||||
|
||||
# 设置工作目录
|
||||
WORKDIR /app
|
||||
|
||||
# 创建数据目录
|
||||
RUN mkdir -p /app/data /app/logs /app/config /app/temp
|
||||
|
||||
# 复制 requirements 并安装依赖
|
||||
COPY requirements-docker.txt .
|
||||
RUN pip install --no-cache-dir --upgrade pip && \
|
||||
pip install --no-cache-dir -r requirements-docker.txt
|
||||
|
||||
# 复制应用代码
|
||||
COPY sanguo_web/ /app/sanguo_web/
|
||||
COPY vnpy_v4.4.0/vnpy/ /app/vnpy/
|
||||
COPY run_web.py /app/
|
||||
|
||||
# 复制启动脚本
|
||||
COPY docker/entrypoint.sh /app/
|
||||
RUN chmod +x /app/entrypoint.sh
|
||||
|
||||
# 暴露端口
|
||||
EXPOSE 8000 8080
|
||||
|
||||
# 启动命令
|
||||
ENTRYPOINT ["/app/entrypoint.sh"]
|
||||
@@ -0,0 +1,35 @@
|
||||
# Sanguo VeighNa 应用镜像
|
||||
# 基于基础镜像,只包含应用代码
|
||||
FROM sanguo_vnpy:base
|
||||
|
||||
LABEL maintainer="sanguo"
|
||||
LABEL description="Sanguo VeighNa Application Layer"
|
||||
|
||||
# 设置工作目录
|
||||
WORKDIR /app
|
||||
|
||||
# 复制应用代码
|
||||
COPY sanguo_trader/ ./sanguo_trader/
|
||||
COPY sanguo_research/ ./sanguo_research/
|
||||
COPY sanguo_data/ ./sanguo_data/
|
||||
COPY sanguo_common/ ./sanguo_common/
|
||||
COPY sanguo_web/ ./sanguo_web/
|
||||
COPY vnpy_v4.4.0/vnpy/ ./vnpy/
|
||||
COPY config/ ./config/
|
||||
|
||||
# 复制启动脚本
|
||||
COPY docker/entrypoint.sh .
|
||||
RUN chmod +x entrypoint.sh
|
||||
|
||||
# 创建数据目录
|
||||
RUN mkdir -p /app/data /app/logs
|
||||
|
||||
# 暴露端口
|
||||
EXPOSE 8000 8080
|
||||
|
||||
# 健康检查
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD curl -f http://localhost:8000/health || exit 1
|
||||
|
||||
# 启动
|
||||
ENTRYPOINT ["./entrypoint.sh"]
|
||||
Executable
+160
@@ -0,0 +1,160 @@
|
||||
#!/bin/bash
|
||||
# Sanguo VeighNa 分层构建脚本
|
||||
# 用法: ./docker/build.sh [base|app|all|clean]
|
||||
|
||||
set -e
|
||||
|
||||
# 配置
|
||||
PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
REGISTRY="${REGISTRY:-sanguo_vnpy}"
|
||||
BASE_TAG="${REGISTRY}:base"
|
||||
APP_TAG="${REGISTRY}:latest"
|
||||
|
||||
# 颜色输出
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
RED='\033[0;31m'
|
||||
NC='\033[0m'
|
||||
|
||||
log_info() {
|
||||
echo -e "${GREEN}[INFO]${NC} $1"
|
||||
}
|
||||
|
||||
log_warn() {
|
||||
echo -e "${YELLOW}[WARN]${NC} $1"
|
||||
}
|
||||
|
||||
log_error() {
|
||||
echo -e "${RED}[ERROR]${NC} $1"
|
||||
}
|
||||
|
||||
# 检查 Docker
|
||||
check_docker() {
|
||||
if ! command -v docker &> /dev/null; then
|
||||
log_error "Docker 未安装"
|
||||
exit 1
|
||||
fi
|
||||
log_info "Docker 版本: $(docker --version)"
|
||||
}
|
||||
|
||||
# 构建基础镜像
|
||||
build_base() {
|
||||
log_info "开始构建基础镜像..."
|
||||
log_info "预计时间: 20-30 分钟"
|
||||
|
||||
cd "$PROJECT_DIR"
|
||||
|
||||
docker build \
|
||||
-f docker/Dockerfile.base \
|
||||
-t "$BASE_TAG" \
|
||||
--build-arg PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple \
|
||||
--build-arg PIP_TRUSTED_HOST=pypi.tuna.tsinghua.edu.cn \
|
||||
.
|
||||
|
||||
log_info "基础镜像构建完成: $BASE_TAG"
|
||||
docker images "$BASE_TAG"
|
||||
}
|
||||
|
||||
# 构建应用镜像
|
||||
build_app() {
|
||||
log_info "开始构建应用镜像..."
|
||||
log_info "预计时间: 3-5 分钟"
|
||||
|
||||
cd "$PROJECT_DIR"
|
||||
|
||||
# 检查基础镜像
|
||||
if ! docker image inspect "$BASE_TAG" &> /dev/null; then
|
||||
log_warn "基础镜像不存在,先构建基础镜像"
|
||||
build_base
|
||||
fi
|
||||
|
||||
docker build \
|
||||
-f docker/Dockerfile.nas \
|
||||
-t "$APP_TAG" \
|
||||
.
|
||||
|
||||
log_info "应用镜像构建完成: $APP_TAG"
|
||||
docker images "$APP_TAG"
|
||||
}
|
||||
|
||||
# 全量构建
|
||||
build_all() {
|
||||
log_info "开始全量构建..."
|
||||
build_base
|
||||
build_app
|
||||
}
|
||||
|
||||
# 清理旧镜像
|
||||
clean_images() {
|
||||
log_info "清理旧镜像..."
|
||||
|
||||
# 删除悬空镜像
|
||||
docker image prune -f
|
||||
|
||||
# 询问是否删除旧版本
|
||||
read -p "是否删除旧版本镜像? (y/N) " -n 1 -r
|
||||
echo
|
||||
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||
docker images "$REGISTRY" --format "{{.ID}} {{.Tag}}" | \
|
||||
grep -v "$BASE_TAG\|$APP_TAG" | \
|
||||
awk '{print $1}' | \
|
||||
xargs -r docker rmi
|
||||
log_info "旧镜像已清理"
|
||||
fi
|
||||
}
|
||||
|
||||
# 显示帮助
|
||||
show_help() {
|
||||
cat << EOF
|
||||
Sanguo VeighNa 分层构建脚本
|
||||
|
||||
用法: $0 [命令]
|
||||
|
||||
命令:
|
||||
base 构建基础镜像 (依赖层,约30分钟)
|
||||
app 构建应用镜像 (应用层,约5分钟)
|
||||
all 全量构建 (base + app)
|
||||
clean 清理旧镜像
|
||||
help 显示此帮助
|
||||
|
||||
示例:
|
||||
$0 base # 首次构建或依赖更新时
|
||||
$0 app # 代码更新后快速重建
|
||||
$0 all # 完整构建
|
||||
$0 clean # 清理旧镜像
|
||||
|
||||
环境变量:
|
||||
REGISTRY 镜像前缀 (默认: sanguo_vnpy)
|
||||
|
||||
EOF
|
||||
}
|
||||
|
||||
# 主逻辑
|
||||
main() {
|
||||
check_docker
|
||||
|
||||
case "${1:-help}" in
|
||||
base)
|
||||
build_base
|
||||
;;
|
||||
app)
|
||||
build_app
|
||||
;;
|
||||
all)
|
||||
build_all
|
||||
;;
|
||||
clean)
|
||||
clean_images
|
||||
;;
|
||||
help|--help|-h)
|
||||
show_help
|
||||
;;
|
||||
*)
|
||||
log_error "未知命令: $1"
|
||||
show_help
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
main "$@"
|
||||
Executable
+282
@@ -0,0 +1,282 @@
|
||||
#!/bin/bash
|
||||
# Sanguo VeighNa Synology NAS 快速部署脚本
|
||||
# 用于在 Synology NAS 上快速部署和配置
|
||||
|
||||
set -e
|
||||
|
||||
# ============================================
|
||||
# 颜色定义
|
||||
# ============================================
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# ============================================
|
||||
# 日志函数
|
||||
# ============================================
|
||||
log_info() {
|
||||
echo -e "${GREEN}[INFO]${NC} $1"
|
||||
}
|
||||
|
||||
log_warn() {
|
||||
echo -e "${YELLOW}[WARN]${NC} $1"
|
||||
}
|
||||
|
||||
log_error() {
|
||||
echo -e "${RED}[ERROR]${NC} $1"
|
||||
}
|
||||
|
||||
log_step() {
|
||||
echo -e "${BLUE}[STEP]${NC} $1"
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# 打印 Banner
|
||||
# ============================================
|
||||
print_banner() {
|
||||
cat << "EOF"
|
||||
╔════════════════════════════════════════════════════════════╗
|
||||
║ ║
|
||||
║ _____ ____ ____ _ _____ _ ___ _ ║
|
||||
║ | __ \ / __ \ / __ \ | ||_ _|__ _| | |_ _|___| | ║
|
||||
║ | |__) | | | | | | |_| | |/ _` | |_____|| | _ \ | ║
|
||||
║ | ___/| | | | | | |_ | | (_| | |_____| | || ||| ║
|
||||
║ |_| | |__| | \___/\__| |\_____|_| |___||_||_| ║
|
||||
║ \____/ ║
|
||||
║ ║
|
||||
║ Synology NAS 部署脚本 v1.0.0 ║
|
||||
║ ║
|
||||
╚════════════════════════════════════════════════════════════╝
|
||||
EOF
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# 检查权限
|
||||
# ============================================
|
||||
check_permission() {
|
||||
log_step "检查执行权限..."
|
||||
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
log_error "请使用 sudo 运行此脚本"
|
||||
echo "用法: sudo bash deploy-synology.sh"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log_info "权限检查通过"
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# 加载配置
|
||||
# ============================================
|
||||
load_config() {
|
||||
log_step "加载配置..."
|
||||
|
||||
# 获取脚本所在目录
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
# 设置默认值
|
||||
DATA_DIR=${DATA_DIR:-/volume1/docker/stock/sanguo_vnpy}
|
||||
HTTP_PORT=${HTTP_PORT:-8000}
|
||||
WS_PORT=${WS_PORT:-8080}
|
||||
TZ=${TZ:-Asia/Shanghai}
|
||||
|
||||
# 尝试加载 .env 文件
|
||||
if [ -f "$SCRIPT_DIR/.env" ]; then
|
||||
log_info "从 .env 文件加载配置..."
|
||||
source "$SCRIPT_DIR/.env"
|
||||
else
|
||||
log_warn ".env 文件不存在,使用默认配置"
|
||||
if [ -f "$SCRIPT_DIR/.env.example" ]; then
|
||||
log_info "从 .env.example 创建 .env 文件..."
|
||||
cp "$SCRIPT_DIR/.env.example" "$SCRIPT_DIR/.env"
|
||||
log_warn "请编辑 .env 文件配置您的环境"
|
||||
log_info "配置完成后重新运行此脚本"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
log_info "配置加载完成"
|
||||
log_info "数据目录: $DATA_DIR"
|
||||
log_info "HTTP 端口: $HTTP_PORT"
|
||||
log_info "WebSocket 端口: $WS_PORT"
|
||||
log_info "时区: $TZ"
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# 检查 Docker
|
||||
# ============================================
|
||||
check_docker() {
|
||||
log_step "检查 Docker 环境..."
|
||||
|
||||
if ! command -v docker &> /dev/null; then
|
||||
log_error "Docker 未安装"
|
||||
log_info "请在 DSM 中安装 Container Manager 套件"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v docker-compose &> /dev/null; then
|
||||
log_error "Docker Compose 未安装"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log_info "Docker 环境检查通过"
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# 创建数据目录
|
||||
# ============================================
|
||||
create_directories() {
|
||||
log_step "创建数据目录..."
|
||||
|
||||
# 主目录
|
||||
mkdir -p "$DATA_DIR"
|
||||
|
||||
# 子目录
|
||||
mkdir -p "$DATA_DIR/data"
|
||||
mkdir -p "$DATA_DIR/logs"
|
||||
mkdir -p "$DATA_DIR/config"
|
||||
mkdir -p "$DATA_DIR/temp"
|
||||
mkdir -p "$DATA_DIR/redis"
|
||||
mkdir -p "$DATA_DIR/nginx"
|
||||
mkdir -p "$DATA_DIR/nginx/ssl"
|
||||
|
||||
# 设置权限
|
||||
chmod -R 755 "$DATA_DIR"
|
||||
|
||||
log_info "数据目录创建完成: $DATA_DIR"
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# 备份现有数据
|
||||
# ============================================
|
||||
backup_data() {
|
||||
log_step "检查是否需要备份..."
|
||||
|
||||
if [ -d "$DATA_DIR/data" ] && [ "$(ls -A $DATA_DIR/data 2>/dev/null)" ]; then
|
||||
BACKUP_DIR="/volume1/Backup"
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
|
||||
BACKUP_FILE="$BACKUP_DIR/vnpy_backup_$(date +%Y%m%d_%H%M%S).tar.gz"
|
||||
|
||||
log_warn "检测到现有数据,创建备份..."
|
||||
tar czf "$BACKUP_FILE" -C "$DATA_DIR" .
|
||||
|
||||
log_info "备份已创建: $BACKUP_FILE"
|
||||
log_info "备份大小: $(du -h $BACKUP_FILE | cut -f1)"
|
||||
else
|
||||
log_info "未检测到现有数据,跳过备份"
|
||||
fi
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# 构建 Docker 镜像
|
||||
# ============================================
|
||||
build_image() {
|
||||
log_step "构建 Docker 镜像..."
|
||||
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
log_info "开始构建(这可能需要几分钟)..."
|
||||
docker-compose build
|
||||
|
||||
log_info "Docker 镜像构建完成"
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# 启动服务
|
||||
# ============================================
|
||||
start_services() {
|
||||
log_step "启动服务..."
|
||||
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
# 停止现有服务(如果有)
|
||||
if docker-compose ps | grep -q "Up"; then
|
||||
log_warn "检测到运行中的服务,先停止..."
|
||||
docker-compose down
|
||||
fi
|
||||
|
||||
# 启动服务
|
||||
log_info "启动服务..."
|
||||
docker-compose up -d
|
||||
|
||||
log_info "服务启动完成"
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# 检查服务状态
|
||||
# ============================================
|
||||
check_status() {
|
||||
log_step "检查服务状态..."
|
||||
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
# 等待服务启动
|
||||
sleep 5
|
||||
|
||||
# 检查容器状态
|
||||
docker-compose ps
|
||||
|
||||
echo ""
|
||||
log_info "检查服务日志(最后 20 行):"
|
||||
docker-compose logs --tail=20 vnpy
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# 显示访问信息
|
||||
# ============================================
|
||||
show_access_info() {
|
||||
log_step "部署完成!"
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
log_info "访问地址:"
|
||||
echo ""
|
||||
echo " HTTP: http://$(hostname -I | awk '{print $1}'):$HTTP_PORT"
|
||||
echo " WebSocket: ws://$(hostname -I | awk '{print $1}'):$WS_PORT"
|
||||
echo ""
|
||||
log_info "默认登录凭据:"
|
||||
echo ""
|
||||
echo " 用户名: admin"
|
||||
echo " 密码: admin123"
|
||||
echo ""
|
||||
log_warn "重要提示:"
|
||||
echo " 1. 请立即修改默认密码"
|
||||
echo " 2. 数据目录: $DATA_DIR"
|
||||
echo " 3. 查看日志: docker-compose logs -f"
|
||||
echo " 4. 停止服务: docker-compose down"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# 主函数
|
||||
# ============================================
|
||||
main() {
|
||||
print_banner
|
||||
echo ""
|
||||
|
||||
check_permission
|
||||
load_config
|
||||
check_docker
|
||||
create_directories
|
||||
backup_data
|
||||
build_image
|
||||
start_services
|
||||
check_status
|
||||
show_access_info
|
||||
|
||||
log_info "部署脚本执行完成!"
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# 信号处理
|
||||
# ============================================
|
||||
trap 'log_error "脚本被中断"; exit 1' SIGINT SIGTERM
|
||||
|
||||
# ============================================
|
||||
# 执行主函数
|
||||
# ============================================
|
||||
main "$@"
|
||||
@@ -0,0 +1,262 @@
|
||||
version: '3.8'
|
||||
|
||||
# Sanguo VeighNa Docker 编排配置
|
||||
# 用于 NAS 部署和开发环境
|
||||
# 支持环境变量配置和绑定挂载
|
||||
|
||||
# 加载环境变量
|
||||
# 使用前请复制 .env.example 为 .env 并修改配置
|
||||
|
||||
services:
|
||||
# ============================================
|
||||
# VeighNa 核心服务
|
||||
# ============================================
|
||||
vnpy:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: docker/Dockerfile
|
||||
container_name: sanguo-vnpy
|
||||
restart: unless-stopped
|
||||
|
||||
# 容器名称
|
||||
hostname: vnpy-server
|
||||
|
||||
# 环境变量
|
||||
environment:
|
||||
# Python 配置
|
||||
- PYTHONUNBUFFERED=1
|
||||
- PYTHONDONTWRITEBYTECODE=1
|
||||
- TZ=${TZ:-Asia/Shanghai}
|
||||
|
||||
# VeighNa 配置
|
||||
- VNPY_LOG_LEVEL=${VNPY_LOG_LEVEL:-INFO}
|
||||
- VNPY_LOG_FILE=${VNPY_LOG_FILE:-/app/logs/vnpy.log}
|
||||
|
||||
# 数据库配置
|
||||
- DB_TYPE=${DB_TYPE:-sqlite}
|
||||
- DB_PATH=${DB_PATH:-/app/data/vnpy.db}
|
||||
|
||||
# Web 服务配置
|
||||
- WEB_HOST=${WEB_HOST:-0.0.0.0}
|
||||
- WEB_PORT=${WEB_PORT:-8000}
|
||||
- WEB_WORKERS=${WEB_WORKERS:-2}
|
||||
|
||||
# 安全配置
|
||||
- JWT_SECRET_KEY=${JWT_SECRET_KEY:-change-this-to-a-random-secret-key}
|
||||
- SESSION_TIMEOUT=${SESSION_TIMEOUT:-3600}
|
||||
|
||||
# Redis 配置
|
||||
- REDIS_HOST=${REDIS_HOST:-redis}
|
||||
- REDIS_PORT=${REDIS_PORT:-6379}
|
||||
- REDIS_PASSWORD=${REDIS_PASSWORD:-}
|
||||
|
||||
# PostgreSQL 配置
|
||||
- POSTGRES_DB=${POSTGRES_DB:-vnpy}
|
||||
- POSTGRES_USER=${POSTGRES_USER:-vnpy}
|
||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-vnpy_password_change_me}
|
||||
- POSTGRES_HOST=${POSTGRES_HOST:-postgres}
|
||||
- POSTGRES_PORT=${POSTGRES_PORT:-5432}
|
||||
|
||||
# 开发选项
|
||||
- SKIP_REDIS_CHECK=${SKIP_REDIS_CHECK:-0}
|
||||
- USE_POSTGRES=${USE_POSTGRES:-0}
|
||||
- DEBUG=${DEBUG:-0}
|
||||
|
||||
# 端口映射
|
||||
ports:
|
||||
- "${HTTP_PORT:-8000}:8000" # HTTP API
|
||||
- "${WS_PORT:-8080}:8080" # WebSocket
|
||||
|
||||
# 数据卷 - 使用绑定挂载到 NAS stock 目录
|
||||
volumes:
|
||||
# 数据持久化目录 - 挂载到 NAS stock 目录
|
||||
- ${DATA_DIR}/data:/app/data
|
||||
- ${DATA_DIR}/logs:/app/logs
|
||||
- ${DATA_DIR}/config:/app/config
|
||||
- ${DATA_DIR}/temp:/app/temp
|
||||
|
||||
# 配置文件(只读)- 如果有外部配置
|
||||
- ../config:/app/config:ro
|
||||
|
||||
# 网络
|
||||
networks:
|
||||
- vnpy-network
|
||||
|
||||
# 依赖项
|
||||
depends_on:
|
||||
- redis
|
||||
|
||||
# 资源限制
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '${VNPY_CPU_LIMIT:-2.0}'
|
||||
memory: ${VNPY_MEMORY_LIMIT:-2G}
|
||||
reservations:
|
||||
cpus: '${VNPY_CPU_RESERVATION:-0.5}'
|
||||
memory: ${VNPY_MEMORY_RESERVATION:-512M}
|
||||
|
||||
# ============================================
|
||||
# Redis 服务(缓存和消息队列)
|
||||
# ============================================
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: sanguo-redis
|
||||
restart: unless-stopped
|
||||
|
||||
# 命令参数
|
||||
command: >
|
||||
sh -c '
|
||||
if [ -n "${REDIS_PASSWORD}" ]; then
|
||||
redis-server --appendonly yes --requirepass ${REDIS_PASSWORD}
|
||||
else
|
||||
redis-server --appendonly yes
|
||||
fi
|
||||
'
|
||||
|
||||
# 环境变量
|
||||
environment:
|
||||
- TZ=${TZ:-Asia/Shanghai}
|
||||
|
||||
# 数据卷 - 挂载到 NAS stock 目录
|
||||
volumes:
|
||||
- ${DATA_DIR}/redis:/data
|
||||
|
||||
# 网络
|
||||
networks:
|
||||
- vnpy-network
|
||||
|
||||
# 资源限制
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '${REDIS_CPU_LIMIT:-0.5}'
|
||||
memory: ${REDIS_MEMORY_LIMIT:-256M}
|
||||
|
||||
# ============================================
|
||||
# Nginx 反向代理(可选)
|
||||
# ============================================
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
container_name: sanguo-nginx
|
||||
restart: unless-stopped
|
||||
profiles:
|
||||
- nginx
|
||||
- ${ENABLE_NGINX:+nginx}
|
||||
|
||||
# 环境变量
|
||||
environment:
|
||||
- TZ=${TZ:-Asia/Shanghai}
|
||||
|
||||
# 端口映射
|
||||
ports:
|
||||
- "${NGINX_HTTP_PORT:-80}:80"
|
||||
- "${NGINX_HTTPS_PORT:-443}:443"
|
||||
|
||||
# 配置文件
|
||||
volumes:
|
||||
- ../docker/nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
- ${DATA_DIR}/nginx/ssl:/etc/nginx/ssl:ro
|
||||
- ${DATA_DIR}/nginx/cache:/var/cache/nginx
|
||||
- ${DATA_DIR}/logs/nginx:/var/log/nginx
|
||||
|
||||
# 依赖项
|
||||
depends_on:
|
||||
- vnpy
|
||||
|
||||
# 网络
|
||||
networks:
|
||||
- vnpy-network
|
||||
|
||||
# 资源限制
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '${NGINX_CPU_LIMIT:-0.5}'
|
||||
memory: ${NGINX_MEMORY_LIMIT:-256M}
|
||||
|
||||
# ============================================
|
||||
# PostgreSQL 数据库(可选,升级用)
|
||||
# ============================================
|
||||
postgres:
|
||||
image: postgres:15-alpine
|
||||
container_name: sanguo-postgres
|
||||
restart: unless-stopped
|
||||
profiles:
|
||||
- postgres
|
||||
- ${USE_POSTGRES:+postgres}
|
||||
|
||||
# 环境变量
|
||||
environment:
|
||||
- POSTGRES_DB=${POSTGRES_DB:-vnpy}
|
||||
- POSTGRES_USER=${POSTGRES_USER:-vnpy}
|
||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-vnpy_password_change_me}
|
||||
- TZ=${TZ:-Asia/Shanghai}
|
||||
- PGDATA=/var/lib/postgresql/data/pgdata
|
||||
|
||||
# 数据卷 - 挂载到 NAS stock 目录
|
||||
volumes:
|
||||
- ${DATA_DIR}/postgres:/var/lib/postgresql/data
|
||||
|
||||
# 网络
|
||||
networks:
|
||||
- vnpy-network
|
||||
|
||||
# 资源限制
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '1.0'
|
||||
memory: 512M
|
||||
|
||||
# ============================================
|
||||
# 网络定义
|
||||
# ============================================
|
||||
networks:
|
||||
vnpy-network:
|
||||
name: sanguo_vnpy_network
|
||||
driver: bridge
|
||||
|
||||
# ============================================
|
||||
# 使用说明
|
||||
# ============================================
|
||||
#
|
||||
# 基本使用:
|
||||
#
|
||||
# 1. 首次使用前,配置环境变量:
|
||||
# cp .env.example .env
|
||||
# vi .env # 修改 DATA_DIR 等配置
|
||||
#
|
||||
# 2. 创建数据目录:
|
||||
# mkdir -p ${DATA_DIR}/{data,logs,config,temp,redis,nginx}
|
||||
#
|
||||
# 3. 启动核心服务:
|
||||
# docker-compose up -d
|
||||
#
|
||||
# 4. 启动所有服务(含 Nginx):
|
||||
# docker-compose --profile nginx up -d
|
||||
#
|
||||
# 5. 启动所有服务(含 PostgreSQL):
|
||||
# docker-compose --profile postgres up -d
|
||||
#
|
||||
# 管理命令:
|
||||
#
|
||||
# 查看日志:
|
||||
# docker-compose logs -f vnpy
|
||||
#
|
||||
# 停止服务:
|
||||
# docker-compose down
|
||||
#
|
||||
# 重启服务:
|
||||
# docker-compose restart vnpy
|
||||
#
|
||||
# 进入容器:
|
||||
# docker-compose exec vnpy bash
|
||||
#
|
||||
# 更新并重启:
|
||||
# docker-compose up -d --build
|
||||
#
|
||||
# 数据备份:
|
||||
# tar czf vnpy_backup_$(date +%Y%m%d).tar.gz ${DATA_DIR}
|
||||
#
|
||||
# ============================================
|
||||
@@ -0,0 +1,340 @@
|
||||
#!/bin/bash
|
||||
# Sanguo VeighNa Docker 容器启动脚本
|
||||
|
||||
set -e
|
||||
|
||||
# ============================================
|
||||
# 颜色定义
|
||||
# ============================================
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# ============================================
|
||||
# 日志函数
|
||||
# ============================================
|
||||
log_info() {
|
||||
echo -e "${GREEN}[INFO]${NC} $1"
|
||||
}
|
||||
|
||||
log_warn() {
|
||||
echo -e "${YELLOW}[WARN]${NC} $1"
|
||||
}
|
||||
|
||||
log_error() {
|
||||
echo -e "${RED}[ERROR]${NC} $1"
|
||||
}
|
||||
|
||||
log_step() {
|
||||
echo -e "${BLUE}[STEP]${NC} $1"
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# 打印启动信息
|
||||
# ============================================
|
||||
print_banner() {
|
||||
cat << "EOF"
|
||||
╔════════════════════════════════════════════════════════════╗
|
||||
║ ║
|
||||
║ _____ ____ ____ _ _____ _ ___ _ ║
|
||||
║ | __ \ / __ \ / __ \ | ||_ _|__ _| | |_ _|___| | ║
|
||||
║ | |__) | | | | | | |_| | |/ _` | |_____|| | _ \ | ║
|
||||
║ | ___/| | | | | | |_ | | (_| | |_____| | || ||| ║
|
||||
║ |_| | |__| | \___/\__| |\_____|_| |___||_||_| ║
|
||||
║ \____/ ║
|
||||
║ ║
|
||||
║ Docker Web 版本 v1.0.0 ║
|
||||
║ 基于 VeighNa 4.4.0 构建 ║
|
||||
║ ║
|
||||
╚════════════════════════════════════════════════════════════╝
|
||||
EOF
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# 检查环境变量
|
||||
# ============================================
|
||||
check_env() {
|
||||
log_step "检查环境变量..."
|
||||
|
||||
# 设置默认值
|
||||
export TZ="${TZ:-Asia/Shanghai}"
|
||||
export PYTHONUNBUFFERED="${PYTHONUNBUFFERED:-1}"
|
||||
export PYTHONDONTWRITEBYTECODE="${PYTHONDONTWRITEBYTECODE:-1}"
|
||||
|
||||
# VeighNa 配置
|
||||
export VNPY_LOG_LEVEL="${VNPY_LOG_LEVEL:-INFO}"
|
||||
export VNPY_LOG_FILE="${VNPY_LOG_FILE:-/app/logs/vnpy.log}"
|
||||
|
||||
# 数据库配置
|
||||
export DB_TYPE="${DB_TYPE:-sqlite}"
|
||||
export DB_PATH="${DB_PATH:-/app/data/vnpy.db}"
|
||||
|
||||
# Web 服务配置
|
||||
export WEB_HOST="${WEB_HOST:-0.0.0.0}"
|
||||
export WEB_PORT="${WEB_PORT:-8000}"
|
||||
export WEB_WORKERS="${WEB_WORKERS:-2}"
|
||||
|
||||
# 安全配置
|
||||
export JWT_SECRET_KEY="${JWT_SECRET_KEY:-change-this-to-a-random-secret-key}"
|
||||
export SESSION_TIMEOUT="${SESSION_TIMEOUT:-3600}"
|
||||
|
||||
# Redis 配置
|
||||
export REDIS_HOST="${REDIS_HOST:-redis}"
|
||||
export REDIS_PORT="${REDIS_PORT:-6379}"
|
||||
|
||||
# 开发选项
|
||||
export SKIP_REDIS_CHECK="${SKIP_REDIS_CHECK:-0}"
|
||||
export USE_POSTGRES="${USE_POSTGRES:-0}"
|
||||
export DEBUG="${DEBUG:-0}"
|
||||
|
||||
# 打印关键配置
|
||||
log_info "时区: $TZ"
|
||||
log_info "日志级别: $VNPY_LOG_LEVEL"
|
||||
log_info "数据库类型: $DB_TYPE"
|
||||
log_info "Web 端口: $WEB_PORT"
|
||||
log_info "Workers: $WEB_WORKERS"
|
||||
|
||||
log_info "环境变量检查完成"
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# 创建数据目录
|
||||
# ============================================
|
||||
create_directories() {
|
||||
log_step "创建数据目录..."
|
||||
|
||||
# 创建主要目录
|
||||
mkdir -p /app/data
|
||||
mkdir -p /app/logs
|
||||
mkdir -p /app/temp
|
||||
mkdir -p /app/config
|
||||
|
||||
# 创建日志子目录
|
||||
mkdir -p /app/logs/web
|
||||
mkdir -p /app/logs/vnpy
|
||||
|
||||
# 创建数据子目录
|
||||
mkdir -p /app/data/database
|
||||
mkdir -p /app/data/storage
|
||||
|
||||
# 设置权限
|
||||
chmod -R 755 /app/data
|
||||
chmod -R 755 /app/logs
|
||||
chmod -R 755 /app/temp
|
||||
|
||||
log_info "数据目录创建完成"
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# 初始化数据库
|
||||
# ============================================
|
||||
init_database() {
|
||||
log_step "初始化数据库..."
|
||||
|
||||
if [ "$DB_TYPE" = "sqlite" ]; then
|
||||
if [ ! -f /app/data/vnpy.db ]; then
|
||||
log_info "创建 SQLite 数据库目录..."
|
||||
mkdir -p /app/data/database
|
||||
|
||||
# 数据库初始化将在 Web 服务启动时完成
|
||||
log_info "SQLite 数据库准备完成"
|
||||
else
|
||||
log_info "SQLite 数据库已存在"
|
||||
fi
|
||||
elif [ "$DB_TYPE" = "postgres" ] && [ "$USE_POSTGRES" = "1" ]; then
|
||||
log_info "使用 PostgreSQL 数据库"
|
||||
# PostgreSQL 配置由环境变量提供
|
||||
else
|
||||
log_warn "未知的数据库类型: $DB_TYPE,使用默认 SQLite"
|
||||
export DB_TYPE=sqlite
|
||||
fi
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# 生成 SSL 证书(自签名,用于测试)
|
||||
# ============================================
|
||||
generate_ssl_cert() {
|
||||
if [ ! -f /etc/nginx/ssl/cert.pem ]; then
|
||||
log_warn "SSL 证书不存在,生成自签名证书..."
|
||||
|
||||
mkdir -p /etc/nginx/ssl
|
||||
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
|
||||
-keyout /etc/nginx/ssl/key.pem \
|
||||
-out /etc/nginx/ssl/cert.pem \
|
||||
-subj "/C=CN/ST=Beijing/L=Beijing/O=Sanguo/CN=localhost" \
|
||||
2>/dev/null
|
||||
|
||||
log_warn "自签名证书已生成,请勿用于生产环境"
|
||||
fi
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# 等待依赖服务
|
||||
# ============================================
|
||||
wait_for_services() {
|
||||
log_step "等待依赖服务启动..."
|
||||
|
||||
# 等待 Redis
|
||||
if [ "$SKIP_REDIS_CHECK" != "1" ]; then
|
||||
log_info "等待 Redis 服务..."
|
||||
|
||||
REDIS_MAX_WAIT=30
|
||||
REDIS_WAIT_COUNT=0
|
||||
|
||||
while ! nc -z ${REDIS_HOST:-redis} ${REDIS_PORT:-6379} 2>/dev/null; do
|
||||
REDIS_WAIT_COUNT=$((REDIS_WAIT_COUNT + 1))
|
||||
if [ $REDIS_WAIT_COUNT -ge $REDIS_MAX_WAIT ]; then
|
||||
log_error "Redis 连接超时"
|
||||
log_info "设置 SKIP_REDIS_CHECK=1 可跳过此检查(开发环境)"
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
log_info "Redis 服务已就绪 (${REDIS_HOST}:${REDIS_PORT})"
|
||||
else
|
||||
log_warn "跳过 Redis 检查"
|
||||
fi
|
||||
|
||||
# 等待 PostgreSQL(如果启用)
|
||||
if [ "$USE_POSTGRES" = "1" ]; then
|
||||
log_info "等待 PostgreSQL 服务..."
|
||||
|
||||
POSTGRES_MAX_WAIT=30
|
||||
POSTGRES_WAIT_COUNT=0
|
||||
|
||||
while ! nc -z ${POSTGRES_HOST:-postgres} ${POSTGRES_PORT:-5432} 2>/dev/null; do
|
||||
POSTGRES_WAIT_COUNT=$((POSTGRES_WAIT_COUNT + 1))
|
||||
if [ $POSTGRES_WAIT_COUNT -ge $POSTGRES_MAX_WAIT ]; then
|
||||
log_error "PostgreSQL 连接超时"
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
log_info "PostgreSQL 服务已就绪"
|
||||
fi
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# 健康检查
|
||||
# ============================================
|
||||
health_check() {
|
||||
log_step "执行健康检查..."
|
||||
|
||||
# 检查 Python 环境
|
||||
if ! python --version &>/dev/null; then
|
||||
log_error "Python 环境检查失败"
|
||||
exit 1
|
||||
fi
|
||||
log_info "Python 版本: $(python --version)"
|
||||
|
||||
# 检查关键模块
|
||||
python -c "import sys; print(f'Python 路径: {sys.executable}')" || {
|
||||
log_error "Python 系统检查失败"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# 检查数据目录
|
||||
if [ ! -d "/app/data" ]; then
|
||||
log_error "数据目录不存在: /app/data"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log_info "健康检查完成"
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# 启动 Web 服务
|
||||
# ============================================
|
||||
start_web_service() {
|
||||
log_step "启动 Web 服务..."
|
||||
|
||||
# 切换到工作目录
|
||||
cd /app
|
||||
|
||||
# 确定 uvicorn 参数
|
||||
UVICORN_ARGS="--host ${WEB_HOST} --port ${WEB_PORT}"
|
||||
|
||||
# Worker 配置
|
||||
if [ "${WEB_WORKERS:-2}" -gt 1 ]; then
|
||||
log_info "使用多 Worker 模式: ${WEB_WORKERS} workers"
|
||||
UVICORN_ARGS="$UVICORN_ARGS --workers ${WEB_WORKERS}"
|
||||
else
|
||||
log_info "使用单 Worker 模式"
|
||||
fi
|
||||
|
||||
# 日志级别
|
||||
UVICORN_ARGS="$UVICORN_ARGS --log-level ${VNPY_LOG_LEVEL:-info}"
|
||||
|
||||
# 访问日志
|
||||
UVICORN_ARGS="$UVICORN_ARGS --access-log"
|
||||
|
||||
# 调试模式(仅开发环境)
|
||||
if [ "$DEBUG" = "1" ]; then
|
||||
log_warn "启用调试模式(不应在生产环境使用)"
|
||||
UVICORN_ARGS="$UVICORN_ARGS --reload"
|
||||
fi
|
||||
|
||||
# 启动服务
|
||||
log_info "启动命令: uvicorn sanguo_web.api:app $UVICORN_ARGS"
|
||||
exec uvicorn sanguo_web.api:app $UVICORN_ARGS
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# 显示访问信息
|
||||
# ============================================
|
||||
show_access_info() {
|
||||
echo ""
|
||||
log_info "=========================================="
|
||||
log_info "服务启动中..."
|
||||
log_info "=========================================="
|
||||
echo ""
|
||||
log_info "访问地址:"
|
||||
echo ""
|
||||
echo " HTTP: http://localhost:${WEB_PORT:-8000}"
|
||||
echo " WebSocket: ws://localhost:${WS_PORT:-8080}"
|
||||
echo ""
|
||||
log_info "默认登录:"
|
||||
echo ""
|
||||
echo " 用户名: admin"
|
||||
echo " 密码: admin123"
|
||||
echo ""
|
||||
log_warn "请在首次登录后修改默认密码!"
|
||||
echo ""
|
||||
log_info "=========================================="
|
||||
echo ""
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# 主函数
|
||||
# ============================================
|
||||
main() {
|
||||
print_banner
|
||||
echo ""
|
||||
|
||||
check_env
|
||||
create_directories
|
||||
init_database
|
||||
generate_ssl_cert
|
||||
wait_for_services
|
||||
health_check
|
||||
show_access_info
|
||||
|
||||
log_info "所有检查完成,启动服务..."
|
||||
echo ""
|
||||
|
||||
start_web_service
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# 信号处理
|
||||
# ============================================
|
||||
trap 'log_info "收到退出信号,优雅关闭..."; exit 0' SIGTERM SIGINT
|
||||
|
||||
# ============================================
|
||||
# 执行主函数
|
||||
# ============================================
|
||||
main "$@"
|
||||
@@ -0,0 +1,196 @@
|
||||
# Sanguo VeighNa Nginx 配置
|
||||
# 用于反向代理、HTTPS 和静态文件服务
|
||||
|
||||
user nginx;
|
||||
worker_processes auto;
|
||||
error_log /var/log/nginx/error.log warn;
|
||||
pid /var/run/nginx.pid;
|
||||
|
||||
events {
|
||||
worker_connections 1024;
|
||||
use epoll;
|
||||
}
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
# 日志格式
|
||||
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
||||
'$status $body_bytes_sent "$http_referer" '
|
||||
'"$http_user_agent" "$http_x_forwarded_for"';
|
||||
|
||||
log_format json '$remote_addr - $remote_user [$time_local] "$request" '
|
||||
'$status $body_bytes_sent "$http_referer" '
|
||||
'"$http_user_agent" "$http_x_forwarded_for"';
|
||||
|
||||
access_log /var/log/nginx/access.log json;
|
||||
|
||||
# 性能优化
|
||||
sendfile on;
|
||||
tcp_nopush on;
|
||||
tcp_nodelay on;
|
||||
keepalive_timeout 65;
|
||||
types_hash_max_size 2048;
|
||||
client_max_body_size 100M;
|
||||
|
||||
# Gzip 压缩
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_proxied any;
|
||||
gzip_comp_level 6;
|
||||
gzip_types text/plain text/css text/xml text/javascript
|
||||
application/json application/javascript application/xml+rss
|
||||
application/rss+xml font/truetype font/opentype
|
||||
application/vnd.ms-fontobject image/svg+xml;
|
||||
|
||||
# 缓存配置
|
||||
proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=vnpy_cache:10m max_size=1g inactive=60m use_temp_path=off;
|
||||
|
||||
# ============================================
|
||||
# HTTP 服务器 - 重定向到 HTTPS
|
||||
# ============================================
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
# 允许健康检查
|
||||
location /health {
|
||||
access_log off;
|
||||
return 200 "healthy\n";
|
||||
add_header Content-Type text/plain;
|
||||
}
|
||||
|
||||
# 其他请求重定向到 HTTPS
|
||||
location / {
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# HTTPS 服务器
|
||||
# ============================================
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name _;
|
||||
|
||||
# SSL 证书配置
|
||||
ssl_certificate /etc/nginx/ssl/cert.pem;
|
||||
ssl_certificate_key /etc/nginx/ssl/key.pem;
|
||||
|
||||
# SSL 安全配置
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
ssl_prefer_server_ciphers on;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_timeout 10m;
|
||||
|
||||
# 安全头
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
|
||||
# ============================================
|
||||
# API 代理
|
||||
# ============================================
|
||||
location /api/ {
|
||||
proxy_pass http://vnpy:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# 超时配置
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_read_timeout 60s;
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# WebSocket 代理
|
||||
# ============================================
|
||||
location /ws/ {
|
||||
proxy_pass http://vnpy:8080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# WebSocket 超时配置
|
||||
proxy_connect_timeout 7d;
|
||||
proxy_send_timeout 7d;
|
||||
proxy_read_timeout 7d;
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# 健康检查
|
||||
# ============================================
|
||||
location /health {
|
||||
proxy_pass http://vnpy:8000/health;
|
||||
access_log off;
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# 静态文件
|
||||
# ============================================
|
||||
location /static/ {
|
||||
alias /app/static/;
|
||||
expires 7d;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
location /favicon.ico {
|
||||
access_log off;
|
||||
log_not_found off;
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# 根路径 - 前端应用
|
||||
# ============================================
|
||||
location / {
|
||||
proxy_pass http://vnpy:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# 安全:拒绝访问敏感文件
|
||||
# ============================================
|
||||
location ~ /\. {
|
||||
deny all;
|
||||
access_log off;
|
||||
log_not_found off;
|
||||
}
|
||||
|
||||
location ~ ~\.(git|svn|env)$ {
|
||||
deny all;
|
||||
access_log off;
|
||||
log_not_found off;
|
||||
}
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# 流控制(可选)
|
||||
# ============================================
|
||||
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s;
|
||||
limit_req_zone $binary_remote_addr zone=ws_limit:10m rate=5r/s;
|
||||
|
||||
limit_conn_zone $binary_remote_addr zone=conn_limit:10m;
|
||||
|
||||
# 应用限流
|
||||
location /api/ {
|
||||
limit_req zone=api_limit burst=20 nodelay;
|
||||
limit_conn conn_limit 10;
|
||||
}
|
||||
|
||||
location /ws/ {
|
||||
limit_req zone=ws_limit burst=10 nodelay;
|
||||
limit_conn conn_limit 5;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
# 部署文档
|
||||
|
||||
本目录包含 Sanguo VeighNa 平台的部署相关文档。
|
||||
|
||||
## 文档列表
|
||||
|
||||
### Docker 部署
|
||||
|
||||
- **[docker-deployment.md](./docker-deployment.md)** - Docker 容器部署指南
|
||||
- 前置要求和安装
|
||||
- 快速开始步骤
|
||||
- 配置说明
|
||||
- 生产部署建议
|
||||
|
||||
### Synology NAS 部署
|
||||
|
||||
- **[synology-nas.md](./synology-nas.md)** - Synology NAS 专用部署指南
|
||||
- NAS 环境配置
|
||||
- 数据持久化设置
|
||||
- 自动备份配置
|
||||
- 故障排除
|
||||
|
||||
## 快速链接
|
||||
|
||||
### 部署方式对比
|
||||
|
||||
| 方式 | 适用场景 | 难度 | 推荐度 |
|
||||
|------|----------|------|--------|
|
||||
| Docker Compose | 开发/测试/小规模部署 | 简单 | ⭐⭐⭐⭐⭐ |
|
||||
| Synology NAS | 家庭 NAS 部署 | 中等 | ⭐⭐⭐⭐ |
|
||||
| Kubernetes | 大规模/高可用部署 | 复杂 | ⭐⭐⭐ |
|
||||
|
||||
### 部署检查清单
|
||||
|
||||
- [ ] 硬件要求满足(内存、存储)
|
||||
- [ ] Docker/Docker Compose 已安装
|
||||
- [ ] 网络端口已开放
|
||||
- [ ] 数据目录已创建
|
||||
- [ ] 环境变量已配置
|
||||
- [ ] SSL 证书已准备(生产环境)
|
||||
- [ ] 备份策略已制定
|
||||
|
||||
## 配置文件
|
||||
|
||||
### 环境变量示例
|
||||
|
||||
```env
|
||||
# 数据目录
|
||||
DATA_DIR=/volume1/docker/stock/sanguo_vnpy
|
||||
|
||||
# 端口配置
|
||||
HTTP_PORT=8000
|
||||
WS_PORT=8080
|
||||
|
||||
# 时区
|
||||
TZ=Asia/Shanghai
|
||||
|
||||
# 日志级别
|
||||
VNPY_LOG_LEVEL=INFO
|
||||
|
||||
# 安全配置
|
||||
JWT_SECRET_KEY=your-secure-secret-key
|
||||
```
|
||||
|
||||
### 目录结构
|
||||
|
||||
```
|
||||
{DATA_DIR}/
|
||||
├── data/ # 数据库和数据文件
|
||||
├── logs/ # 运行日志
|
||||
├── config/ # 配置文件
|
||||
├── temp/ # 临时文件
|
||||
├── redis/ # Redis 持久化
|
||||
└── nginx/ # Nginx 相关
|
||||
```
|
||||
|
||||
## 数据备份
|
||||
|
||||
### 重要提示
|
||||
|
||||
**所有重要数据都存储在配置的 DATA_DIR 中**,请确保:
|
||||
|
||||
1. 该目录有足够的存储空间
|
||||
2. 定期备份该目录
|
||||
3. 备份包含所有子目录
|
||||
|
||||
### 备份命令
|
||||
|
||||
```bash
|
||||
# 创建备份
|
||||
tar czf vnpy_backup_$(date +%Y%m%d).tar.gz {DATA_DIR}
|
||||
|
||||
# 恢复备份
|
||||
tar xzf vnpy_backup_YYYYMMDD.tar.gz -C /
|
||||
```
|
||||
|
||||
## 故障排除
|
||||
|
||||
### 常见问题
|
||||
|
||||
1. **容器无法启动**
|
||||
- 检查端口占用: `netstat -tulpn | grep :8000`
|
||||
- 查看日志: `docker-compose logs vnpy`
|
||||
|
||||
2. **数据丢失**
|
||||
- 确保使用绑定挂载
|
||||
- 检查 DATA_DIR 配置
|
||||
|
||||
3. **性能问题**
|
||||
- 调整资源限制
|
||||
- 增加 Worker 数量
|
||||
|
||||
## 相关文档
|
||||
|
||||
- [开发文档](../development.md)
|
||||
- [运维文档](../operations/)
|
||||
- [API 文档](../api/)
|
||||
- [用户指南](../user_guide/)
|
||||
|
||||
---
|
||||
|
||||
**最后更新**: 2026-07-01
|
||||
@@ -0,0 +1,409 @@
|
||||
# Docker 部署指南
|
||||
|
||||
本指南介绍如何使用 Docker Compose 部署 Sanguo VeighNa 量化交易平台。
|
||||
|
||||
## 目录
|
||||
|
||||
- [前置要求](#前置要求)
|
||||
- [快速开始](#快速开始)
|
||||
- [配置说明](#配置说明)
|
||||
- [启动服务](#启动服务)
|
||||
- [访问界面](#访问界面)
|
||||
- [数据管理](#数据管理)
|
||||
- [生产部署](#生产部署)
|
||||
- [常见问题](#常见问题)
|
||||
|
||||
---
|
||||
|
||||
## 前置要求
|
||||
|
||||
### 系统要求
|
||||
|
||||
- **操作系统**: Linux, macOS, Windows (with WSL2)
|
||||
- **Docker**: 20.10+
|
||||
- **Docker Compose**: 2.0+
|
||||
- **内存**: 至少 2GB 可用内存
|
||||
- **存储**: 至少 10GB 可用存储空间
|
||||
|
||||
### 安装 Docker
|
||||
|
||||
#### Linux
|
||||
|
||||
```bash
|
||||
# Ubuntu/Debian
|
||||
curl -fsSL https://get.docker.com | bash
|
||||
sudo usermod -aG docker $USER
|
||||
|
||||
# 启用 Docker Compose
|
||||
sudo apt-get install docker-compose-plugin
|
||||
```
|
||||
|
||||
#### macOS
|
||||
|
||||
下载并安装 [Docker Desktop for Mac](https://www.docker.com/products/docker-desktop/)
|
||||
|
||||
#### Windows
|
||||
|
||||
下载并安装 [Docker Desktop for Windows](https://www.docker.com/products/docker-desktop/)
|
||||
|
||||
---
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 1. 克隆项目
|
||||
|
||||
```bash
|
||||
git clone https://github.com/sanguo/sanguo_vnpy_v2.git
|
||||
cd sanguo_vnpy_v2
|
||||
```
|
||||
|
||||
### 2. 配置环境变量
|
||||
|
||||
```bash
|
||||
cd docker
|
||||
cp .env.example .env
|
||||
vi .env # 编辑配置
|
||||
```
|
||||
|
||||
### 3. 启动服务
|
||||
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
### 4. 查看状态
|
||||
|
||||
```bash
|
||||
docker-compose ps
|
||||
docker-compose logs -f
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 配置说明
|
||||
|
||||
### 环境变量
|
||||
|
||||
编辑 `.env` 文件配置以下参数:
|
||||
|
||||
```env
|
||||
# 数据目录
|
||||
DATA_DIR=./data
|
||||
|
||||
# 端口配置
|
||||
HTTP_PORT=8000
|
||||
WS_PORT=8080
|
||||
|
||||
# 时区
|
||||
TZ=Asia/Shanghai
|
||||
|
||||
# 日志级别
|
||||
VNPY_LOG_LEVEL=INFO
|
||||
|
||||
# 数据库配置
|
||||
DB_TYPE=sqlite
|
||||
DB_PATH=/app/data/vnpy.db
|
||||
|
||||
# Web 服务配置
|
||||
WEB_HOST=0.0.0.0
|
||||
WEB_PORT=8000
|
||||
WEB_WORKERS=2
|
||||
|
||||
# 安全配置
|
||||
JWT_SECRET_KEY=your-secret-key-here
|
||||
SESSION_TIMEOUT=3600
|
||||
```
|
||||
|
||||
### 目录映射
|
||||
|
||||
默认目录映射:
|
||||
|
||||
| 容器路径 | 宿主机路径 | 说明 |
|
||||
|----------|------------|------|
|
||||
| /app/data | `{DATA_DIR}/data` | 数据库和数据文件 |
|
||||
| /app/logs | `{DATA_DIR}/logs` | 运行日志 |
|
||||
| /app/config | `{DATA_DIR}/config` | 配置文件 |
|
||||
| /app/temp | `{DATA_DIR}/temp` | 临时文件 |
|
||||
|
||||
---
|
||||
|
||||
## 启动服务
|
||||
|
||||
### 基本命令
|
||||
|
||||
```bash
|
||||
# 启动所有服务
|
||||
docker-compose up -d
|
||||
|
||||
# 仅启动核心服务
|
||||
docker-compose up -d vnpy redis
|
||||
|
||||
# 启动并包含 Nginx
|
||||
docker-compose --profile nginx up -d
|
||||
|
||||
# 启动并包含 PostgreSQL
|
||||
docker-compose --profile postgres up -d
|
||||
```
|
||||
|
||||
### 查看状态
|
||||
|
||||
```bash
|
||||
# 查看容器状态
|
||||
docker-compose ps
|
||||
|
||||
# 查看日志
|
||||
docker-compose logs -f vnpy
|
||||
|
||||
# 查看所有服务日志
|
||||
docker-compose logs -f
|
||||
```
|
||||
|
||||
### 停止服务
|
||||
|
||||
```bash
|
||||
# 停止服务
|
||||
docker-compose down
|
||||
|
||||
# 停止并删除数据卷
|
||||
docker-compose down -v
|
||||
```
|
||||
|
||||
### 重启服务
|
||||
|
||||
```bash
|
||||
# 重启所有服务
|
||||
docker-compose restart
|
||||
|
||||
# 重启特定服务
|
||||
docker-compose restart vnpy
|
||||
```
|
||||
|
||||
### 进入容器
|
||||
|
||||
```bash
|
||||
# 进入 VeighNa 容器
|
||||
docker-compose exec vnpy bash
|
||||
|
||||
# 进入 Redis 容器
|
||||
docker-compose exec redis sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 访问界面
|
||||
|
||||
### Web 界面
|
||||
|
||||
启动成功后,访问:
|
||||
|
||||
```
|
||||
http://localhost:8000
|
||||
```
|
||||
|
||||
### 默认登录
|
||||
|
||||
- **用户名**: `admin`
|
||||
- **密码**: `admin123`
|
||||
|
||||
### WebSocket 端点
|
||||
|
||||
```
|
||||
ws://localhost:8080
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 数据管理
|
||||
|
||||
### 数据备份
|
||||
|
||||
```bash
|
||||
# 备份数据目录
|
||||
tar czf vnpy_backup_$(date +%Y%m%d).tar.gz ./data
|
||||
|
||||
# 恢复数据
|
||||
tar xzf vnpy_backup_YYYYMMDD.tar.gz
|
||||
```
|
||||
|
||||
### 数据库操作
|
||||
|
||||
#### SQLite
|
||||
|
||||
```bash
|
||||
# 进入容器
|
||||
docker-compose exec vnpy bash
|
||||
|
||||
# 访问数据库
|
||||
sqlite3 /app/data/vnpy.db
|
||||
|
||||
# 常用命令
|
||||
.tables # 列出所有表
|
||||
.schema # 查看表结构
|
||||
.quit # 退出
|
||||
```
|
||||
|
||||
#### PostgreSQL
|
||||
|
||||
```bash
|
||||
# 进入 PostgreSQL 容器
|
||||
docker-compose exec postgres psql -U vnpy -d vnpy
|
||||
|
||||
# 常用命令
|
||||
\l # 列出所有数据库
|
||||
\dt # 列出所有表
|
||||
\q # 退出
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 生产部署
|
||||
|
||||
### 使用 Nginx 反向代理
|
||||
|
||||
```bash
|
||||
# 启用 Nginx
|
||||
docker-compose --profile nginx up -d
|
||||
```
|
||||
|
||||
### 配置 HTTPS
|
||||
|
||||
1. 获取 SSL 证书
|
||||
|
||||
```bash
|
||||
# 使用 Let's Encrypt
|
||||
mkdir -p data/nginx/ssl
|
||||
# 将证书文件复制到该目录
|
||||
cp cert.pem data/nginx/ssl/
|
||||
cp key.pem data/nginx/ssl/
|
||||
```
|
||||
|
||||
2. 更新 nginx 配置
|
||||
|
||||
3. 重启服务
|
||||
|
||||
```bash
|
||||
docker-compose restart nginx
|
||||
```
|
||||
|
||||
### 资源限制
|
||||
|
||||
在 `.env` 文件中配置:
|
||||
|
||||
```env
|
||||
VNPY_CPU_LIMIT=2.0
|
||||
VNPY_MEMORY_LIMIT=2G
|
||||
WEB_WORKERS=4
|
||||
```
|
||||
|
||||
### 安全建议
|
||||
|
||||
1. **修改默认密码**
|
||||
2. **使用强 JWT 密钥**
|
||||
3. **启用 HTTPS**
|
||||
4. **配置防火墙**
|
||||
5. **定期备份**
|
||||
6. **监控日志**
|
||||
|
||||
---
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 端口冲突
|
||||
|
||||
如果端口被占用,修改 `.env` 中的端口配置:
|
||||
|
||||
```env
|
||||
HTTP_PORT=8001
|
||||
WS_PORT=8081
|
||||
```
|
||||
|
||||
### 权限问题
|
||||
|
||||
```bash
|
||||
# 修复数据目录权限
|
||||
sudo chown -R $USER:$USER ./data
|
||||
sudo chmod -R 755 ./data
|
||||
```
|
||||
|
||||
### 容器无法启动
|
||||
|
||||
```bash
|
||||
# 查看详细日志
|
||||
docker-compose logs vnpy
|
||||
|
||||
# 重新构建
|
||||
docker-compose up -d --build
|
||||
```
|
||||
|
||||
### 数据丢失
|
||||
|
||||
确保使用绑定挂载而非命名卷:
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 高级配置
|
||||
|
||||
### 使用 PostgreSQL
|
||||
|
||||
```bash
|
||||
# 启用 PostgreSQL
|
||||
export USE_POSTGRES=1
|
||||
docker-compose --profile postgres up -d
|
||||
```
|
||||
|
||||
### 自定义配置
|
||||
|
||||
```bash
|
||||
# 挂载自定义配置
|
||||
mkdir -p config
|
||||
cp your_config.yaml config/
|
||||
```
|
||||
|
||||
### 多实例部署
|
||||
|
||||
```bash
|
||||
# 修改端口和数据目录
|
||||
docker-compose -f docker-compose.yml -p vnpy2 up -d
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 附录
|
||||
|
||||
### Docker Compose 命令参考
|
||||
|
||||
| 命令 | 说明 |
|
||||
|------|------|
|
||||
| `up -d` | 后台启动服务 |
|
||||
| `down` | 停止并删除容器 |
|
||||
| `ps` | 查看服务状态 |
|
||||
| `logs` | 查看日志 |
|
||||
| `exec` | 在容器中执行命令 |
|
||||
| `restart` | 重启服务 |
|
||||
| `build` | 重新构建镜像 |
|
||||
|
||||
### 目录结构
|
||||
|
||||
```
|
||||
sanguo_vnpy_v2/
|
||||
├── docker/
|
||||
│ ├── .env # 环境变量配置
|
||||
│ ├── .env.example # 配置示例
|
||||
│ ├── docker-compose.yml # 编排配置
|
||||
│ ├── Dockerfile # 镜像构建
|
||||
│ ├── entrypoint.sh # 启动脚本
|
||||
│ └── nginx/ # Nginx 配置
|
||||
├── data/ # 数据目录
|
||||
├── logs/ # 日志目录
|
||||
└── docs/ # 文档
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**最后更新**: 2026-07-01
|
||||
**文档版本**: 1.0.0
|
||||
@@ -0,0 +1,677 @@
|
||||
# Synology NAS 部署指南
|
||||
|
||||
本指南介绍如何在 Synology NAS 上部署 Sanguo VeighNa 量化交易平台。
|
||||
|
||||
## 目录
|
||||
|
||||
- [前置要求](#前置要求)
|
||||
- [快速开始](#快速开始)
|
||||
- [详细步骤](#详细步骤)
|
||||
- [配置说明](#配置说明)
|
||||
- [访问界面](#访问界面)
|
||||
- [数据备份](#数据备份)
|
||||
- [故障排除](#故障排除)
|
||||
- [升级指南](#升级指南)
|
||||
|
||||
---
|
||||
|
||||
## 前置要求
|
||||
|
||||
### 硬件要求
|
||||
|
||||
- **Synology NAS**:DSM 7.x 或更高版本
|
||||
- **内存**:至少 2GB 可用内存(推荐 4GB+)
|
||||
- **存储**:至少 10GB 可用存储空间(用于数据和日志)
|
||||
|
||||
### 软件要求
|
||||
|
||||
- **Container Manager**(或 Docker 套件)
|
||||
- SSH 访问权限(用于高级操作)
|
||||
- 文本编辑器(如 Text Editor、vim)
|
||||
|
||||
### 网络要求
|
||||
|
||||
- 固定 IP 地址(推荐)
|
||||
- 或使用 DDNS 服务
|
||||
- 开放相应端口(默认 8000)
|
||||
|
||||
---
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 1. 开启 SSH 访问
|
||||
|
||||
在 DSM 控制面板中:
|
||||
1. 进入 **控制面板** > **终端机和 SNMP**
|
||||
2. 勾选 **启用 SSH 服务**
|
||||
3. 端口使用默认 22
|
||||
4. 点击 **应用**
|
||||
|
||||
### 2. SSH 登录 NAS
|
||||
|
||||
```bash
|
||||
ssh admin@your-nas-ip
|
||||
# 或
|
||||
ssh your-username@your-nas-ip
|
||||
```
|
||||
|
||||
### 3. 创建数据目录
|
||||
|
||||
```bash
|
||||
# 创建主目录
|
||||
sudo mkdir -p /volume1/docker/stock/sanguo_vnpy
|
||||
|
||||
# 创建子目录
|
||||
sudo mkdir -p /volume1/docker/stock/sanguo_vnpy/{data,logs,config,temp,redis,nginx}
|
||||
```
|
||||
|
||||
### 4. 上传项目文件
|
||||
|
||||
在本地电脑上执行:
|
||||
|
||||
```bash
|
||||
# 压缩项目文件
|
||||
tar czf sanguo_vnpy_v2.tar.gz sanguo_vnpy_v2/
|
||||
|
||||
# 上传到 NAS
|
||||
scp sanguo_vnpy_v2.tar.gz admin@your-nas-ip:/tmp/
|
||||
|
||||
# 或使用 rsync
|
||||
rsync -avz sanguo_vnpy_v2/ admin@your-nas-ip:/volume1/docker/containers/sanguo_vnpy_v2/
|
||||
```
|
||||
|
||||
在 NAS 上解压:
|
||||
|
||||
```bash
|
||||
# SSH 登录后执行
|
||||
cd /volume1/docker/containers/
|
||||
sudo tar xzf /tmp/sanguo_vnpy_v2.tar.gz
|
||||
```
|
||||
|
||||
### 5. 配置环境变量
|
||||
|
||||
```bash
|
||||
cd /volume1/docker/containers/sanguo_vnpy_v2/docker
|
||||
sudo cp .env.example .env
|
||||
sudo vi .env # 或使用其他编辑器
|
||||
```
|
||||
|
||||
关键配置项:
|
||||
|
||||
```env
|
||||
# 数据目录 - 确保 此目录存在
|
||||
DATA_DIR=/volume1/docker/stock/sanguo_vnpy
|
||||
|
||||
# 端口配置 - 根据需要修改
|
||||
HTTP_PORT=8000
|
||||
WS_PORT=8080
|
||||
|
||||
# 时区
|
||||
TZ=Asia/Shanghai
|
||||
|
||||
# JWT 密钥 - 请修改为随机字符串
|
||||
JWT_SECRET_KEY=your-random-secret-key-here
|
||||
```
|
||||
|
||||
### 6. 启动服务
|
||||
|
||||
```bash
|
||||
cd /volume1/docker/containers/sanguo_vnpy_v2/docker
|
||||
sudo docker-compose up -d
|
||||
```
|
||||
|
||||
### 7. 检查运行状态
|
||||
|
||||
```bash
|
||||
# 查看容器状态
|
||||
sudo docker-compose ps
|
||||
|
||||
# 查看日志
|
||||
sudo docker-compose logs -f vnpy
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 详细步骤
|
||||
|
||||
### 步骤 1: 准备数据目录
|
||||
|
||||
创建完整的目录结构:
|
||||
|
||||
```bash
|
||||
# 主目录
|
||||
sudo mkdir -p /volume1/docker/stock/sanguo_vnpy
|
||||
|
||||
# 数据子目录
|
||||
sudo mkdir -p /volume1/docker/stock/sanguo_vnpy/data # 数据库和数据文件
|
||||
sudo mkdir -p /volume1/docker/stock/sanguo_vnpy/logs # 运行日志
|
||||
sudo mkdir -p /volume1/docker/stock/sanguo_vnpy/config # 配置文件
|
||||
sudo mkdir -p /volume1/docker/stock/sanguo_vnpy/temp # 临时文件
|
||||
sudo mkdir -p /volume1/docker/stock/sanguo_vnpy/redis # Redis 持久化
|
||||
sudo mkdir -p /volume1/docker/stock/sanguo_vnpy/nginx # Nginx 相关
|
||||
sudo mkdir -p /volume1/docker/stock/sanguo_vnpy/nginx/ssl # SSL 证书
|
||||
|
||||
# 设置权限
|
||||
sudo chmod -R 755 /volume1/docker/stock/sanguo_vnpy
|
||||
```
|
||||
|
||||
### 步骤 2: 配置 Docker Compose
|
||||
|
||||
编辑 `.env` 文件:
|
||||
|
||||
```bash
|
||||
cd /volume1/docker/containers/sanguo_vnpy_v2/docker
|
||||
sudo cp .env.example .env
|
||||
sudo vi .env
|
||||
```
|
||||
|
||||
完整配置示例:
|
||||
|
||||
```env
|
||||
# ============================================
|
||||
# 数据目录配置
|
||||
# ============================================
|
||||
DATA_DIR=/volume1/docker/stock/sanguo_vnpy
|
||||
|
||||
# ============================================
|
||||
# 端口配置
|
||||
# ============================================
|
||||
HTTP_PORT=8000
|
||||
WS_PORT=8080
|
||||
NGINX_HTTP_PORT=80
|
||||
NGINX_HTTPS_PORT=443
|
||||
|
||||
# ============================================
|
||||
# 时区配置
|
||||
# ============================================
|
||||
TZ=Asia/Shanghai
|
||||
|
||||
# ============================================
|
||||
# VeighNa 配置
|
||||
# ============================================
|
||||
VNPY_LOG_LEVEL=INFO
|
||||
VNPY_LOG_FILE=/app/logs/vnpy.log
|
||||
|
||||
# ============================================
|
||||
# 数据库配置
|
||||
# ============================================
|
||||
DB_TYPE=sqlite
|
||||
DB_PATH=/app/data/vnpy.db
|
||||
|
||||
# ============================================
|
||||
# Web 服务配置
|
||||
# ============================================
|
||||
WEB_HOST=0.0.0.0
|
||||
WEB_PORT=8000
|
||||
WEB_WORKERS=2
|
||||
|
||||
# ============================================
|
||||
# 安全配置
|
||||
# ============================================
|
||||
JWT_SECRET_KEY=please-change-this-to-a-secure-random-key
|
||||
SESSION_TIMEOUT=3600
|
||||
|
||||
# ============================================
|
||||
# 资源限制
|
||||
# ============================================
|
||||
VNPY_CPU_LIMIT=2.0
|
||||
VNPY_MEMORY_LIMIT=2G
|
||||
```
|
||||
|
||||
### 步骤 3: 构建和启动
|
||||
|
||||
```bash
|
||||
# 构建镜像(首次运行)
|
||||
sudo docker-compose build
|
||||
|
||||
# 启动服务
|
||||
sudo docker-compose up -d
|
||||
|
||||
# 查看启动日志
|
||||
sudo docker-compose logs -f
|
||||
```
|
||||
|
||||
### 步骤 4: 验证部署
|
||||
|
||||
```bash
|
||||
# 检查容器状态
|
||||
sudo docker-compose ps
|
||||
|
||||
# 应该看到类似输出:
|
||||
# NAME STATUS PORTS
|
||||
# sanguo-vnpy Up 0.0.0.0:8000->8000/tcp
|
||||
# sanguo-redis Up 6379/tcp
|
||||
|
||||
# 检查日志
|
||||
sudo docker-compose logs vnpy | tail -20
|
||||
|
||||
# 进入容器检查
|
||||
sudo docker-compose exec vnpy bash
|
||||
ls -la /app/data
|
||||
exit
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 配置说明
|
||||
|
||||
### 端口配置
|
||||
|
||||
| 端口 | 用途 | 说明 |
|
||||
|------|------|------|
|
||||
| 8000 | HTTP API | Web 界面和 REST API |
|
||||
| 8080 | WebSocket | 实时数据推送 |
|
||||
| 80 | Nginx HTTP | 反向代理(可选) |
|
||||
| 443 | Nginx HTTPS | SSL 反向代理(可选) |
|
||||
|
||||
### 目录映射
|
||||
|
||||
| 容器路径 | 宿主机路径 | 说明 |
|
||||
|----------|------------|------|
|
||||
| /app/data | `{DATA_DIR}/data` | 数据库和数据文件 |
|
||||
| /app/logs | `{DATA_DIR}/logs` | 运行日志 |
|
||||
| /app/config | `{DATA_DIR}/config` | 配置文件 |
|
||||
| /app/temp | `{DATA_DIR}/temp` | 临时文件 |
|
||||
| /data | `{DATA_DIR}/redis` | Redis 持久化数据 |
|
||||
|
||||
### 资源限制
|
||||
|
||||
默认资源限制(可在 `.env` 中调整):
|
||||
|
||||
| 服务 | CPU 限制 | 内存限制 |
|
||||
|------|----------|----------|
|
||||
| vnpy | 2.0 | 2G |
|
||||
| redis | 0.5 | 256M |
|
||||
| nginx | 0.5 | 256M |
|
||||
|
||||
---
|
||||
|
||||
## 访问界面
|
||||
|
||||
### Web 界面
|
||||
|
||||
启动成功后,可以通过以下地址访问:
|
||||
|
||||
```
|
||||
http://your-nas-ip:8000
|
||||
```
|
||||
|
||||
### 默认登录凭据
|
||||
|
||||
- **用户名**: `admin`
|
||||
- **密码**: `admin123`
|
||||
|
||||
**重要**: 首次登录后请立即修改密码!
|
||||
|
||||
### WebSocket 连接
|
||||
|
||||
WebSocket 端点:
|
||||
|
||||
```
|
||||
ws://your-nas-ip:8080
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 数据备份
|
||||
|
||||
### 重要数据位置
|
||||
|
||||
所有重要数据都存储在 `/volume1/docker/stock/sanguo_vnpy/`:
|
||||
|
||||
```
|
||||
/volume1/docker/stock/sanguo_vnpy/
|
||||
├── data/ # 数据库和数据文件
|
||||
├── logs/ # 运行日志
|
||||
├── config/ # 配置文件
|
||||
├── redis/ # Redis 持久化数据
|
||||
└── nginx/ # Nginx 配置和证书
|
||||
```
|
||||
|
||||
### 备份方法
|
||||
|
||||
#### 方法 1: 使用 Hyper Backup 套件(推荐)
|
||||
|
||||
1. 打开 **Hyper Backup**
|
||||
2. 创建新备份任务
|
||||
3. 选择 **从文件夹备份**
|
||||
4. 选择 `/volume1/docker/stock/sanguo_vnpy/`
|
||||
5. 配置备份目标和计划
|
||||
|
||||
#### 方法 2: 手动备份
|
||||
|
||||
```bash
|
||||
# 创建备份
|
||||
sudo tar czf \
|
||||
/volume1/Backup/vnpy_backup_$(date +%Y%m%d_%H%M%S).tar.gz \
|
||||
/volume1/docker/stock/sanguo_vnpy/
|
||||
|
||||
# 列出备份文件
|
||||
ls -lh /volume1/Backup/vnpy_backup_*
|
||||
|
||||
# 恢复备份
|
||||
sudo tar xzf \
|
||||
/volume1/Backup/vnpy_backup_YYYYMMDD_HHMMSS.tar.gz \
|
||||
-C /
|
||||
```
|
||||
|
||||
#### 方法 3: 使用 rsync 同步到其他位置
|
||||
|
||||
```bash
|
||||
# 同步到另一个共享文件夹
|
||||
sudo rsync -avz \
|
||||
/volume1/docker/stock/sanguo_vnpy/ \
|
||||
/volume1/Backup/sanguo_vnpy_backup/
|
||||
|
||||
# 同步到远程服务器
|
||||
rsync -avz -e ssh \
|
||||
/volume1/docker/stock/sanguo_vnpy/ \
|
||||
user@remote-server:/backup/path/
|
||||
```
|
||||
|
||||
### 自动备份脚本
|
||||
|
||||
创建定时任务:
|
||||
|
||||
```bash
|
||||
# 创建备份脚本
|
||||
sudo vi /volume1/docker/stock/backup_vnpy.sh
|
||||
```
|
||||
|
||||
脚本内容:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# Sanguo VeighNa 自动备份脚本
|
||||
|
||||
BACKUP_DIR="/volume1/Backup"
|
||||
DATA_DIR="/volume1/docker/stock/sanguo_vnpy"
|
||||
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
||||
BACKUP_FILE="$BACKUP_DIR/vnpy_backup_$TIMESTAMP.tar.gz"
|
||||
|
||||
# 保留最近 7 天的备份
|
||||
find $BACKUP_DIR -name "vnpy_backup_*.tar.gz" -mtime +7 -delete
|
||||
|
||||
# 创建备份
|
||||
tar czf $BACKUP_FILE $DATA_DIR
|
||||
|
||||
# 记录日志
|
||||
echo "Backup created: $BACKUP_FILE" >> $BACKUP_DIR/backup.log
|
||||
|
||||
echo "Backup completed: $BACKUP_FILE"
|
||||
```
|
||||
|
||||
设置定时任务:
|
||||
|
||||
```bash
|
||||
# 给脚本执行权限
|
||||
sudo chmod +x /volume1/docker/stock/backup_vnpy.sh
|
||||
|
||||
# 添加到 crontab(每天凌晨 2 点执行)
|
||||
sudo crontab -e
|
||||
|
||||
# 添加以下行
|
||||
0 2 * * * /volume1/docker/stock/backup_vnpy.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 故障排除
|
||||
|
||||
### 常见问题
|
||||
|
||||
#### 1. 容器无法启动
|
||||
|
||||
**检查日志**:
|
||||
|
||||
```bash
|
||||
sudo docker-compose logs vnpy
|
||||
```
|
||||
|
||||
**常见原因**:
|
||||
|
||||
- 端口被占用:修改 `.env` 中的端口配置
|
||||
- 数据目录不存在:创建所需目录
|
||||
- 权限问题:检查目录权限
|
||||
|
||||
#### 2. 数据目录权限问题
|
||||
|
||||
```bash
|
||||
# 修复权限
|
||||
sudo chown -R $(id -u):$(id -g) /volume1/docker/stock/sanguo_vnpy
|
||||
sudo chmod -R 755 /volume1/docker/stock/sanguo_vnpy
|
||||
```
|
||||
|
||||
#### 3. 端口冲突
|
||||
|
||||
```bash
|
||||
# 检查端口占用
|
||||
sudo netstat -tulpn | grep :8000
|
||||
|
||||
# 或使用 lsof
|
||||
sudo lsof -i :8000
|
||||
```
|
||||
|
||||
#### 4. Redis 连接失败
|
||||
|
||||
```bash
|
||||
# 检查 Redis 容器
|
||||
sudo docker-compose logs redis
|
||||
|
||||
# 重启 Redis
|
||||
sudo docker-compose restart redis
|
||||
|
||||
# 跳过 Redis 检查(开发环境)
|
||||
# 在 .env 中设置
|
||||
SKIP_REDIS_CHECK=1
|
||||
```
|
||||
|
||||
#### 5. 日志文件过大
|
||||
|
||||
```bash
|
||||
# 清理旧日志
|
||||
sudo find /volume1/docker/stock/sanguo_vnpy/logs -name "*.log" -mtime +30 -delete
|
||||
|
||||
# 或配置日志轮转
|
||||
sudo vi /etc/logrotate.d/vnpy
|
||||
```
|
||||
|
||||
### 重置部署
|
||||
|
||||
如果需要完全重置:
|
||||
|
||||
```bash
|
||||
# 停止并删除容器
|
||||
sudo docker-compose down
|
||||
|
||||
# 备份数据
|
||||
sudo cp -r /volume1/docker/stock/sanguo_vnpy /volume1/Backup/
|
||||
|
||||
# 清理数据(谨慎操作)
|
||||
sudo rm -rf /volume1/docker/stock/sanguo_vnpy/data/*
|
||||
sudo rm -rf /volume1/docker/stock/sanguo_vnpy/logs/*
|
||||
|
||||
# 重新启动
|
||||
sudo docker-compose up -d
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 升级指南
|
||||
|
||||
### 升级步骤
|
||||
|
||||
1. **备份当前版本**:
|
||||
|
||||
```bash
|
||||
# 备份数据
|
||||
sudo tar czf /volume1/Backup/vnpy_pre_upgrade_$(date +%Y%m%d).tar.gz \
|
||||
/volume1/docker/stock/sanguo_vnpy/
|
||||
```
|
||||
|
||||
2. **下载新版本**:
|
||||
|
||||
```bash
|
||||
# 停止服务
|
||||
cd /volume1/docker/containers/sanguo_vnpy_v2/docker
|
||||
sudo docker-compose down
|
||||
```
|
||||
|
||||
3. **替换项目文件**:
|
||||
|
||||
```bash
|
||||
# 备份旧代码
|
||||
sudo mv /volume1/docker/containers/sanguo_vnpy_v2 \
|
||||
/volume1/docker/containers/sanguo_vnpy_v2_old
|
||||
|
||||
# 解压新版本
|
||||
sudo tar xzf /tmp/sanguo_vnpy_v2_new.tar.gz -C /volume1/docker/containers/
|
||||
```
|
||||
|
||||
4. **复制配置**:
|
||||
|
||||
```bash
|
||||
# 复制 .env 配置
|
||||
sudo cp /volume1/docker/containers/sanguo_vnpy_v2_old/docker/.env \
|
||||
/volume1/docker/containers/sanguo_vnpy_v2/docker/.env
|
||||
```
|
||||
|
||||
5. **启动新版本**:
|
||||
|
||||
```bash
|
||||
cd /volume1/docker/containers/sanguo_vnpy_v2/docker
|
||||
sudo docker-compose up -d --build
|
||||
```
|
||||
|
||||
6. **验证升级**:
|
||||
|
||||
```bash
|
||||
# 检查状态
|
||||
sudo docker-compose ps
|
||||
sudo docker-compose logs -f
|
||||
```
|
||||
|
||||
### 回滚
|
||||
|
||||
如果升级失败:
|
||||
|
||||
```bash
|
||||
# 停止新版本
|
||||
cd /volume1/docker/containers/sanguo_vnpy_v2/docker
|
||||
sudo docker-compose down
|
||||
|
||||
# 恢复旧版本
|
||||
sudo mv /volume1/docker/containers/sanguo_vnpy_v2 \
|
||||
/volume1/docker/containers/sanguo_vnpy_v2_new
|
||||
sudo mv /volume1/docker/containers/sanguo_vnpy_v2_old \
|
||||
/volume1/docker/containers/sanguo_vnpy_v2
|
||||
|
||||
# 启动旧版本
|
||||
cd /volume1/docker/containers/sanguo_vnpy_v2/docker
|
||||
sudo docker-compose up -d
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 性能优化
|
||||
|
||||
### 资源调整
|
||||
|
||||
根据 NAS 配置调整资源限制:
|
||||
|
||||
```env
|
||||
# .env 文件中
|
||||
VNPY_CPU_LIMIT=4.0 # 增加 CPU
|
||||
VNPY_MEMORY_LIMIT=4G # 增加内存
|
||||
WEB_WORKERS=4 # 增加 Worker 数量
|
||||
```
|
||||
|
||||
### 存储优化
|
||||
|
||||
- 将数据目录放在 SSD 缓存盘上(如果有)
|
||||
- 定期清理旧日志
|
||||
- 使用数据库的 VACUUM 功能优化 SQLite
|
||||
|
||||
### 网络优化
|
||||
|
||||
- 使用有线网络连接
|
||||
- 配置 QoS 优先级
|
||||
- 考虑使用本地网络而非远程访问
|
||||
|
||||
---
|
||||
|
||||
## 安全建议
|
||||
|
||||
1. **修改默认密码**:首次登录后立即修改
|
||||
2. **使用 HTTPS**:配置 SSL 证书
|
||||
3. **限制访问**:使用防火墙规则
|
||||
4. **定期备份**:设置自动备份任务
|
||||
5. **更新系统**:保持 DSM 和套件更新
|
||||
6. **监控日志**:定期检查访问日志
|
||||
|
||||
---
|
||||
|
||||
## 附录
|
||||
|
||||
### A. Docker Compose 常用命令
|
||||
|
||||
```bash
|
||||
# 启动服务
|
||||
sudo docker-compose up -d
|
||||
|
||||
# 停止服务
|
||||
sudo docker-compose down
|
||||
|
||||
# 重启服务
|
||||
sudo docker-compose restart
|
||||
|
||||
# 查看状态
|
||||
sudo docker-compose ps
|
||||
|
||||
# 查看日志
|
||||
sudo docker-compose logs -f
|
||||
|
||||
# 进入容器
|
||||
sudo docker-compose exec vnpy bash
|
||||
|
||||
# 更新并重启
|
||||
sudo docker-compose up -d --build
|
||||
|
||||
# 删除容器和数据(危险)
|
||||
sudo docker-compose down -v
|
||||
```
|
||||
|
||||
### B. 目录结构参考
|
||||
|
||||
```
|
||||
/volume1/docker/
|
||||
├── stock/
|
||||
│ └── sanguo_vnpy/
|
||||
│ ├── data/ # 数据库
|
||||
│ ├── logs/ # 日志
|
||||
│ ├── config/ # 配置
|
||||
│ ├── temp/ # 临时文件
|
||||
│ ├── redis/ # Redis 数据
|
||||
│ └── nginx/ # Nginx 相关
|
||||
└── containers/
|
||||
└── sanguo_vnpy_v2/ # 项目文件
|
||||
└── docker/
|
||||
├── .env
|
||||
├── .env.example
|
||||
├── docker-compose.yml
|
||||
├── Dockerfile
|
||||
└── entrypoint.sh
|
||||
```
|
||||
|
||||
### C. 支持和反馈
|
||||
|
||||
如有问题,请通过以下方式获取支持:
|
||||
|
||||
- GitHub Issues
|
||||
- 项目文档
|
||||
- 社区论坛
|
||||
|
||||
---
|
||||
|
||||
**最后更新**: 2026-07-01
|
||||
**文档版本**: 1.0.0
|
||||
@@ -0,0 +1,894 @@
|
||||
# Docker Web 版本部署设计
|
||||
|
||||
## 1. 架构概述
|
||||
|
||||
### 1.1 整体架构
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ NAS 系统 │
|
||||
│ │
|
||||
│ ┌────────────────────────────────────────────┐ │
|
||||
│ │ Docker 容器 │ │
|
||||
│ │ │ │
|
||||
│ │ ┌───────────────────────────────────────┐ │ │
|
||||
│ │ │ Nginx (反向代理) │ │ │
|
||||
│ │ │ - HTTPS │ │ │
|
||||
│ │ │ - 静态文件服务 │ │ │
|
||||
│ │ └───────────────────────────────────────┘ │ │
|
||||
│ │ ↑ │ │
|
||||
│ │ ┌───────────────────────────────────────┐ │ │
|
||||
│ │ │ FastAPI Web 服务 │ │ │
|
||||
│ │ │ - REST API │ │ │
|
||||
│ │ │ - WebSocket (实时行情) │ │ │
|
||||
│ │ │ - 认证授权 │ │ │
|
||||
│ │ └───────────────────────────────────────┘ │ │
|
||||
│ │ ↑ │ │
|
||||
│ │ ┌───────────────────────────────────────┐ │ │
|
||||
│ │ │ VeighNa 核心引擎 │ │ │
|
||||
│ │ │ - 交易接口 │ │ │
|
||||
│ │ │ - 策略引擎 │ │ │
|
||||
│ │ │ - 事件引擎 │ │ │
|
||||
│ │ └───────────────────────────────────────┘ │ │
|
||||
│ │ │ │
|
||||
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │
|
||||
│ │ │ SQLite │ │ 数据文件 │ │ 日志文件 │ │ │
|
||||
│ │ └──────────┘ └──────────┘ └──────────┘ │ │
|
||||
│ │ ↓ ↓ ↓ │ │
|
||||
│ │ 持久化卷 (Docker Volume) │ │
|
||||
│ └────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ NAS 存储 (数据持久化) │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 1.2 组件说明
|
||||
|
||||
| 组件 | 技术 | 说明 |
|
||||
|-----|------|------|
|
||||
| Web 框架 | FastAPI | 高性能异步 Web 框架 |
|
||||
| 前端 | Vue.js 3 | 现代化响应式界面 |
|
||||
| 反向代理 | Nginx | HTTPS + 静态文件 |
|
||||
| 数据库 | SQLite | 轻量级,可选 PostgreSQL |
|
||||
| 实时通信 | WebSocket | 行情推送 |
|
||||
| 容器编排 | Docker Compose | 多容器管理 |
|
||||
|
||||
## 2. 技术方案
|
||||
|
||||
### 2.1 目录结构
|
||||
|
||||
```
|
||||
sanguo_vnpy_v2/
|
||||
├── docker/
|
||||
│ ├── Dockerfile # 主容器镜像
|
||||
│ ├── docker-compose.yml # 编排配置
|
||||
│ ├── nginx/
|
||||
│ │ ├── Dockerfile # Nginx 镜像
|
||||
│ │ ├── nginx.conf # Nginx 配置
|
||||
│ │ └── ssl/ # SSL 证书
|
||||
│ ├── entrypoint.sh # 启动脚本
|
||||
│ └── requirements-docker.txt # Docker 依赖
|
||||
├── sanguo_web/ # Web 服务
|
||||
│ ├── api/ # FastAPI 接口
|
||||
│ │ ├── trading.py # 交易接口
|
||||
│ │ ├── strategy.py # 策略接口
|
||||
│ │ ├── data.py # 数据接口
|
||||
│ │ └── auth.py # 认证接口
|
||||
│ ├── websocket/ # WebSocket 处理
|
||||
│ │ └── handler.py # 行情推送
|
||||
│ ├── static/ # 前端静态文件
|
||||
│ └── templates/ # HTML 模板
|
||||
└── config/
|
||||
├── docker_config.json # Docker 配置
|
||||
└── users.json # 用户配置
|
||||
```
|
||||
|
||||
### 2.2 Dockerfile 设计
|
||||
|
||||
```dockerfile
|
||||
# 多阶段构建
|
||||
FROM python:3.11-slim as builder
|
||||
|
||||
# 安装编译依赖
|
||||
RUN apt-get update && apt-get install -y \
|
||||
gcc g++ make \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# 安装 TA-Lib
|
||||
ENV TA_LIBRARY_PATH=/usr/local/lib
|
||||
RUN wget http://prdownloads.sourceforge.net/ta-lib/ta-lib-0.4.0-src.tar.gz && \
|
||||
tar -xzf ta-lib-0.4.0-src.tar.gz && \
|
||||
cd ta-lib && \
|
||||
./configure --prefix=/usr && \
|
||||
make && make install
|
||||
|
||||
# 复制依赖文件
|
||||
COPY requirements-docker.txt .
|
||||
RUN pip install --no-cache-dir -r requirements-docker.txt
|
||||
|
||||
# 运行阶段
|
||||
FROM python:3.11-slim
|
||||
|
||||
# 复译 TA-Lib
|
||||
COPY --from=builder /usr/lib/libta*.* /usr/local/lib/
|
||||
COPY --from=builder /usr/include/ta-lib/ /usr/include/
|
||||
|
||||
# 设置工作目录
|
||||
WORKDIR /app
|
||||
|
||||
# 复制应用代码
|
||||
COPY sanguo_trader/ ./sanguo_trader/
|
||||
COPY sanguo_research/ ./sanguo_research/
|
||||
COPY sanguo_data/ ./sanguo_data/
|
||||
COPY sanguo_common/ ./sanguo_common/
|
||||
COPY sanguo_web/ ./sanguo_web/
|
||||
COPY vnpy_v4.4.0/vnpy/ ./vnpy/
|
||||
COPY config/ ./config/
|
||||
|
||||
# 暴露端口
|
||||
EXPOSE 8000 8080
|
||||
|
||||
# 健康检查
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD curl -f http://localhost:8000/health || exit 1
|
||||
|
||||
# 启动命令
|
||||
COPY docker/entrypoint.sh .
|
||||
RUN chmod +x entrypoint.sh
|
||||
ENTRYPOINT ["./entrypoint.sh"]
|
||||
```
|
||||
|
||||
### 2.3 Docker Compose 配置
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
vnpy:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/Dockerfile
|
||||
container_name: sanguo-vnpy
|
||||
restart: unless-stopped
|
||||
|
||||
# 环境变量
|
||||
environment:
|
||||
- PYTHONUNBUFFERED=1
|
||||
- TZ=Asia/Shanghai
|
||||
- VNPY_LOG_LEVEL=INFO
|
||||
|
||||
# 端口映射
|
||||
ports:
|
||||
- "8000:8000" # FastAPI
|
||||
- "8080:8080" # WebSocket
|
||||
|
||||
# 数据卷
|
||||
volumes:
|
||||
- vnpy_data:/app/data
|
||||
- vnpy_logs:/app/logs
|
||||
- vnpy_config:/app/config
|
||||
- ./config:/app/config:ro
|
||||
|
||||
# 网络
|
||||
networks:
|
||||
- vnpy-network
|
||||
|
||||
nginx:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/nginx/Dockerfile
|
||||
container_name: sanguo-nginx
|
||||
restart: unless-stopped
|
||||
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
|
||||
volumes:
|
||||
- ./docker/nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
- ./docker/nginx/ssl:/etc/nginx/ssl:ro
|
||||
- nginx_cache:/var/cache/nginx
|
||||
|
||||
depends_on:
|
||||
- vnpy
|
||||
|
||||
networks:
|
||||
- vnpy-network
|
||||
|
||||
# 数据卷
|
||||
volumes:
|
||||
vnpy_data:
|
||||
driver: local
|
||||
vnpy_logs:
|
||||
driver: local
|
||||
vnpy_config:
|
||||
driver: local
|
||||
nginx_cache:
|
||||
driver: local
|
||||
|
||||
# 网络
|
||||
networks:
|
||||
vnpy-network:
|
||||
driver: bridge
|
||||
```
|
||||
|
||||
### 2.4 Web API 设计
|
||||
|
||||
#### REST API 端点
|
||||
|
||||
```python
|
||||
# sanguo_web/api/__init__.py
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
app = FastAPI(
|
||||
title="Sanguo VeighNa Web",
|
||||
description="量化交易平台 Web API",
|
||||
version="1.0.0"
|
||||
)
|
||||
|
||||
# CORS 配置
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# 挂载静态文件
|
||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||
|
||||
# API 路由
|
||||
from . import trading, strategy, data, auth
|
||||
|
||||
app.include_router(trading.router, prefix="/api/trading", tags=["交易"])
|
||||
app.include_router(strategy.router, prefix="/api/strategy", tags=["策略"])
|
||||
app.include_router(data.router, prefix="/api/data", tags=["数据"])
|
||||
app.include_router(auth.router, prefix="/api/auth", tags=["认证"])
|
||||
```
|
||||
|
||||
#### 交易接口示例
|
||||
|
||||
```python
|
||||
# sanguo_web/api/trading.py
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
class OrderRequest(BaseModel):
|
||||
symbol: str
|
||||
exchange: str
|
||||
direction: str
|
||||
offset: str
|
||||
price: float
|
||||
volume: int
|
||||
price_type: str = "LIMIT"
|
||||
|
||||
@router.post("/orders")
|
||||
async def send_order(req: OrderRequest):
|
||||
"""发送订单"""
|
||||
# 调用 VeighNa 引擎
|
||||
order_id = engine.send_order(req)
|
||||
return {"status": "success", "order_id": order_id}
|
||||
|
||||
@router.delete("/orders/{order_id}")
|
||||
async def cancel_order(order_id: str):
|
||||
"""撤销订单"""
|
||||
engine.cancel_order(order_id)
|
||||
return {"status": "success"}
|
||||
|
||||
@router.get("/orders")
|
||||
async def get_orders():
|
||||
"""查询订单"""
|
||||
orders = engine.get_all_orders()
|
||||
return {"orders": orders}
|
||||
|
||||
@router.get("/positions")
|
||||
async def get_positions():
|
||||
"""查询持仓"""
|
||||
positions = engine.get_all_positions()
|
||||
return {"positions": positions}
|
||||
```
|
||||
|
||||
### 2.5 WebSocket 行情推送
|
||||
|
||||
```python
|
||||
# sanguo_web/websocket/handler.py
|
||||
|
||||
from fastapi import WebSocket
|
||||
from typing import Dict
|
||||
import json
|
||||
|
||||
active_connections: Dict[str, WebSocket] = {}
|
||||
|
||||
async def websocket_endpoint(websocket: WebSocket, client_id: str):
|
||||
await websocket.accept()
|
||||
active_connections[client_id] = websocket
|
||||
|
||||
try:
|
||||
while True:
|
||||
# 接收客户端消息
|
||||
data = await websocket.receive_text()
|
||||
msg = json.loads(data)
|
||||
|
||||
if msg["type"] == "subscribe":
|
||||
# 订阅行情
|
||||
subscribe_market_data(client_id, msg["symbol"])
|
||||
elif msg["type"] == "unsubscribe":
|
||||
# 取消订阅
|
||||
unsubscribe_market_data(client_id, msg["symbol"])
|
||||
|
||||
except Exception as e:
|
||||
print(f"Connection error: {e}")
|
||||
finally:
|
||||
del active_connections[client_id]
|
||||
|
||||
async def broadcast_tick(tick_data):
|
||||
"""广播行情数据"""
|
||||
tick_json = json.dumps({
|
||||
"type": "tick",
|
||||
"data": tick_data
|
||||
})
|
||||
|
||||
for connection in active_connections.values():
|
||||
await connection.send_text(tick_json)
|
||||
```
|
||||
|
||||
## 3. 部署方案
|
||||
|
||||
### 3.1 NAS 部署步骤
|
||||
|
||||
#### Synology NAS
|
||||
```bash
|
||||
# 1. 安装 Container Manager
|
||||
# 2. 导入镜像
|
||||
# 3. 创建项目
|
||||
# 4. 启动容器
|
||||
```
|
||||
|
||||
#### QNAP NAS
|
||||
```bash
|
||||
# 1. 安装 Container Station
|
||||
# 2. 导入镜像
|
||||
# 3. 创建容器
|
||||
# 4. 启动
|
||||
```
|
||||
|
||||
#### 通用 Linux NAS
|
||||
```bash
|
||||
# 1. 克隆项目
|
||||
git clone http://192.168.2.154:3000/sanguo/sanguo_vnpy_v2
|
||||
|
||||
# 2. 启动服务
|
||||
cd sanguo_vnpy_v2
|
||||
docker-compose up -d
|
||||
|
||||
# 3. 查看日志
|
||||
docker-compose logs -f
|
||||
|
||||
# 4. 停止服务
|
||||
docker-compose down
|
||||
```
|
||||
|
||||
### 3.2 配置管理
|
||||
|
||||
```yaml
|
||||
# config/docker_config.json
|
||||
{
|
||||
"server": {
|
||||
"host": "0.0.0.0",
|
||||
"port": 8000,
|
||||
"workers": 4
|
||||
},
|
||||
"database": {
|
||||
"type": "sqlite",
|
||||
"path": "/app/data/vnpy.db"
|
||||
},
|
||||
"logging": {
|
||||
"level": "INFO",
|
||||
"path": "/app/logs"
|
||||
},
|
||||
"security": {
|
||||
"enable_auth": true,
|
||||
"jwt_secret": "your-secret-key",
|
||||
"session_timeout": 3600
|
||||
},
|
||||
"trading": {
|
||||
"gateway_name": "CTP",
|
||||
"md_address": "",
|
||||
"td_address": "",
|
||||
"userid": "",
|
||||
"password": "",
|
||||
"appid": "",
|
||||
"authcode": ""
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 数据持久化
|
||||
|
||||
```bash
|
||||
# Docker 卷管理
|
||||
docker volume create vnpy_data
|
||||
docker volume create vnpy_logs
|
||||
docker volume create vnpy_config
|
||||
|
||||
# 查看卷
|
||||
docker volume ls
|
||||
|
||||
# 备份数据
|
||||
docker run --rm -v vnpy_data:/data -v $(pwd):/backup \
|
||||
alpine tar czf /backup/vnpy_data_backup.tar.gz /data
|
||||
```
|
||||
|
||||
## 4. 安全设计
|
||||
|
||||
### 4.1 认证方案
|
||||
|
||||
```python
|
||||
# sanguo_web/api/auth.py
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Depends
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
import jwt
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
router = APIRouter()
|
||||
security = HTTPBearer()
|
||||
SECRET_KEY = "your-secret-key"
|
||||
|
||||
@router.post("/login")
|
||||
async def login(username: str, password: str):
|
||||
"""用户登录"""
|
||||
# 验证用户名密码
|
||||
if verify_user(username, password):
|
||||
token = create_access_token(username)
|
||||
return {"access_token": token}
|
||||
raise HTTPException(401, "Invalid credentials")
|
||||
|
||||
@router.get("/verify")
|
||||
async def verify_token(
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security)
|
||||
):
|
||||
"""验证 Token"""
|
||||
try:
|
||||
payload = jwt.decode(
|
||||
credentials.credentials,
|
||||
SECRET_KEY,
|
||||
algorithms=["HS256"]
|
||||
)
|
||||
return {"valid": True, "user": payload["sub"]}
|
||||
except:
|
||||
raise HTTPException(401, "Invalid token")
|
||||
```
|
||||
|
||||
### 4.2 HTTPS 配置
|
||||
|
||||
```nginx
|
||||
# docker/nginx/nginx.conf
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name localhost;
|
||||
|
||||
ssl_certificate /etc/nginx/ssl/cert.pem;
|
||||
ssl_certificate_key /etc/nginx/ssl/key.pem;
|
||||
|
||||
location / {
|
||||
proxy_pass http://vnpy:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# WebSocket 支持
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
}
|
||||
|
||||
location /static/ {
|
||||
alias /app/static/;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
return 301 https://$server_name$request_uri;
|
||||
}
|
||||
```
|
||||
|
||||
## 5. 监控与日志
|
||||
|
||||
### 5.1 健康检查
|
||||
|
||||
```python
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
"""健康检查接口"""
|
||||
return {
|
||||
"status": "healthy",
|
||||
"services": {
|
||||
"database": check_database(),
|
||||
"gateway": check_gateway(),
|
||||
"strategy": check_strategy()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 日志配置
|
||||
|
||||
```python
|
||||
# sanguo_common/logger.py
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
def setup_logger():
|
||||
"""配置日志"""
|
||||
log_path = Path("/app/logs")
|
||||
log_path.mkdir(exist_ok=True)
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||
handlers=[
|
||||
logging.FileHandler(log_path / "vnpy.log"),
|
||||
logging.StreamHandler()
|
||||
]
|
||||
)
|
||||
```
|
||||
|
||||
## 6. 扩展设计
|
||||
|
||||
### 6.1 多容器部署
|
||||
|
||||
```yaml
|
||||
# docker-compose.cluster.yml
|
||||
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
vnpy-api:
|
||||
<<: *vnpy-service
|
||||
container_name: sanguo-vnpy-api-1
|
||||
|
||||
vnpy-api-2:
|
||||
<<: *vnpy-service
|
||||
container_name: sanguo-vnpy-api-2
|
||||
|
||||
nginx:
|
||||
# 负载均衡配置
|
||||
# ...
|
||||
```
|
||||
|
||||
### 6.2 数据库升级
|
||||
|
||||
```yaml
|
||||
# 使用 PostgreSQL
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:15
|
||||
container_name: sanguo-db
|
||||
environment:
|
||||
POSTGRES_DB: vnpy
|
||||
POSTGRES_USER: vnpy
|
||||
POSTGRES_PASSWORD: password
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
```
|
||||
|
||||
## 7. 实施计划
|
||||
|
||||
### 阶段 1: 基础框架
|
||||
- [ ] 创建 Dockerfile
|
||||
- [ ] 创建 docker-compose.yml
|
||||
- [ ] 实现基础 Web API
|
||||
- [ ] 实现认证授权
|
||||
|
||||
### 阶段 2: 核心功能
|
||||
- [ ] 实现交易接口
|
||||
- [ ] 实现策略接口
|
||||
- [ ] 实现 WebSocket 行情
|
||||
- [ ] 实现数据查询接口
|
||||
|
||||
### 阶段 3: 前端界面
|
||||
- [ ] 实现登录页面
|
||||
- [ ] 实现交易面板
|
||||
- [ ] 实现行情显示
|
||||
- [ ] 实现策略管理
|
||||
|
||||
### 阶段 4: 部署优化
|
||||
- [ ] Nginx 配置
|
||||
- [ ] HTTPS 配置
|
||||
- [ ] 数据持久化
|
||||
- [ ] 监控日志
|
||||
|
||||
### 阶段 5: 测试验证
|
||||
- [ ] 功能测试
|
||||
- [ ] 性能测试
|
||||
- [ ] 部署测试
|
||||
- [ ] 用户验收
|
||||
|
||||
## 8. 构建经验总结
|
||||
|
||||
### 8.1 已知问题与解决方案
|
||||
|
||||
#### 问题 1: polars CPU 兼容性
|
||||
|
||||
**错误现象**:
|
||||
```
|
||||
Missing required CPU features: avx, avx2, fma, bmi1, bmi2, lzcnt
|
||||
Container exit code: 132
|
||||
```
|
||||
|
||||
**原因分析**:
|
||||
- NAS CPU (Intel Celeron J4125) 不支持 AVX2 指令集
|
||||
- polars 默认版本依赖 AVX 指令优化性能
|
||||
- 在不支持的 CPU 上运行会直接崩溃
|
||||
|
||||
**解决方案**:
|
||||
1. 修改 `requirements-docker.txt`:
|
||||
```diff
|
||||
- polars>=1.26.0
|
||||
+ polars[rtcompat]>=1.26.0
|
||||
```
|
||||
|
||||
2. 修改 `docker/entrypoint.sh`:
|
||||
```bash
|
||||
#!/bin/bash
|
||||
set -e
|
||||
export POLARS_SKIP_CPU_CHECK=1 # 添加此行
|
||||
# ... 其余内容
|
||||
```
|
||||
|
||||
#### 问题 2: 依赖冲突
|
||||
|
||||
**错误现象**:
|
||||
```
|
||||
ERROR: Cannot install -r requirements-docker.txt (line 59)
|
||||
```
|
||||
|
||||
**原因分析**:
|
||||
- statsmodels 与其他包版本冲突
|
||||
- 部分科学计算包版本不兼容
|
||||
|
||||
**解决方案**:
|
||||
- 使用经过测试的固定版本组合
|
||||
- 参考成功的 `rebuild2.log` 中的版本
|
||||
|
||||
#### 问题 3: 网络超时
|
||||
|
||||
**错误现象**:
|
||||
```
|
||||
ERROR: Could not find a version that satisfies the requirement...
|
||||
Connection timeout
|
||||
```
|
||||
|
||||
**解决方案**:
|
||||
使用清华镜像加速:
|
||||
```dockerfile
|
||||
ARG PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple
|
||||
ARG PIP_TRUSTED_HOST=pypi.tuna.tsinghua.edu.cn
|
||||
```
|
||||
|
||||
### 8.2 成功构建配置
|
||||
|
||||
#### 最终 requirements-docker.txt 关键配置
|
||||
```txt
|
||||
# CPU 兼容性配置
|
||||
polars[rtcompat]>=1.26.0
|
||||
|
||||
# 科学计算包(已验证版本)
|
||||
numpy>=1.24.0,<2.0.0
|
||||
pandas>=2.0.0
|
||||
scipy>=1.10.0
|
||||
statsmodels>=0.14.0
|
||||
```
|
||||
|
||||
#### entrypoint.sh 配置
|
||||
```bash
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# CPU 兼容性跳过
|
||||
export POLARS_SKIP_CPU_CHECK=1
|
||||
|
||||
# 服务启动
|
||||
cd /app
|
||||
exec python -m uvicorn sanguo_web.main:app \
|
||||
--host 0.0.0.0 \
|
||||
--port 8000 \
|
||||
--log-level info
|
||||
```
|
||||
|
||||
## 9. 分层构建设计
|
||||
|
||||
### 9.1 设计目标
|
||||
|
||||
1. **分离依赖与应用**:固定的 Python 依赖包与变化的应用代码分离
|
||||
2. **减少重建时间**:依赖包层只需构建一次,后续只重建应用层
|
||||
3. **降低风险**:基础层冗余设计,包含所有可能需要的依赖
|
||||
|
||||
### 9.2 分层架构
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ 应用层 (Dockerfile.nas) │
|
||||
│ - sanguo_*/ 源代码 │
|
||||
│ - config/ 配置文件 │
|
||||
│ - 只在代码变更时重建 │
|
||||
└─────────────────────────────────────────────┘
|
||||
↓ FROM
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ 基础层 (Dockerfile.base) │
|
||||
│ - Python 3.11 │
|
||||
│ - 所有依赖包 (~7GB) │
|
||||
│ - TA-Lib │
|
||||
│ - 只在依赖变更时重建 │
|
||||
└─────────────────────────────────────────────┘
|
||||
↓ FROM
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ python:3.11-slim │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 9.3 Dockerfile.base
|
||||
|
||||
```dockerfile
|
||||
# sanguo_vnpy_v2/docker/Dockerfile.base
|
||||
FROM python:3.11-slim
|
||||
|
||||
LABEL maintainer="sanguo"
|
||||
LABEL description="Sanguo VeighNa Base Image with all dependencies"
|
||||
|
||||
# 设置工作目录
|
||||
WORKDIR /build
|
||||
|
||||
# 安装系统依赖
|
||||
RUN apt-get update && apt-get install -y \
|
||||
gcc g++ make wget \
|
||||
build-essential \
|
||||
libssl-dev libffi-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# 安装 TA-Lib
|
||||
ENV TA_LIBRARY_PATH=/usr/local/lib
|
||||
ENV TA_HEADER_PATH=/usr/include
|
||||
RUN wget -q http://prdownloads.sourceforge.net/ta-lib/ta-lib-0.4.0-src.tar.gz && \
|
||||
tar -xzf ta-lib-0.4.0-src.tar.gz && \
|
||||
cd ta-lib && \
|
||||
./configure --prefix=/usr && \
|
||||
make && make install && \
|
||||
cd .. && \
|
||||
rm -rf ta-lib ta-lib-0.4.0-src.tar.gz
|
||||
|
||||
# 使用清华镜像加速
|
||||
ARG PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple
|
||||
ARG PIP_TRUSTED_HOST=pypi.tuna.tsinghua.edu.cn
|
||||
ENV PIP_INDEX_URL=${PIP_INDEX_URL}
|
||||
ENV PIP_TRUSTED_HOST=${PIP_TRUSTED_HOST}
|
||||
|
||||
# CPU 兼容性配置
|
||||
ENV POLARS_SKIP_CPU_CHECK=1
|
||||
|
||||
# 复制依赖文件
|
||||
COPY requirements-docker.txt .
|
||||
|
||||
# 安装所有 Python 依赖
|
||||
RUN pip install --no-cache-dir --root-user-action=ignore -r requirements-docker.txt
|
||||
|
||||
# 清理
|
||||
RUN apt-get purge -y gcc g++ make wget && \
|
||||
apt-get autoremove -y && \
|
||||
rm -rf /var/lib/apt/lists/* /tmp/*
|
||||
|
||||
# 设置最终工作目录
|
||||
WORKDIR /app
|
||||
|
||||
# 健康检查基础
|
||||
RUN pip install --no-cache-dir uvicorn
|
||||
|
||||
# 元数据标签
|
||||
LABEL build_date="2025-07-02"
|
||||
LABEL python_version="3.11"
|
||||
LABEL description="Sanguo VeighNa Base - Ready for application layer"
|
||||
```
|
||||
|
||||
### 9.4 Dockerfile.nas (应用层)
|
||||
|
||||
```dockerfile
|
||||
# sanguo_vnpy_v2/docker/Dockerfile.nas
|
||||
FROM sanguo_vnpy:base
|
||||
|
||||
LABEL maintainer="sanguo"
|
||||
LABEL description="Sanguo VeighNa Application Layer"
|
||||
|
||||
# 复制应用代码
|
||||
COPY sanguo_trader/ ./sanguo_trader/
|
||||
COPY sanguo_research/ ./sanguo_research/
|
||||
COPY sanguo_data/ ./sanguo_data/
|
||||
COPY sanguo_common/ ./sanguo_common/
|
||||
COPY sanguo_web/ ./sanguo_web/
|
||||
COPY vnpy_v4.4.0/vnpy/ ./vnpy/
|
||||
COPY config/ ./config/
|
||||
|
||||
# 复制启动脚本
|
||||
COPY docker/entrypoint.sh .
|
||||
RUN chmod +x entrypoint.sh
|
||||
|
||||
# 暴露端口
|
||||
EXPOSE 8000 8080
|
||||
|
||||
# 健康检查
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD curl -f http://localhost:8000/health || exit 1
|
||||
|
||||
# 启动
|
||||
ENTRYPOINT ["./entrypoint.sh"]
|
||||
```
|
||||
|
||||
### 9.5 构建流程
|
||||
|
||||
#### 首次构建(全量)
|
||||
|
||||
```bash
|
||||
# 1. 构建基础镜像(约 30 分钟)
|
||||
cd /volume1/stock/sanguo_vnpy_v2
|
||||
docker build -f docker/Dockerfile.base -t sanguo_vnpy:base .
|
||||
|
||||
# 2. 构建应用镜像(约 5 分钟)
|
||||
docker build -f docker/Dockerfile.nas -t sanguo_vnpy:latest .
|
||||
|
||||
# 3. 启动容器
|
||||
docker run -d \
|
||||
--name sanguo_vnpy_v2 \
|
||||
-p 8000:8000 -p 8080:8080 \
|
||||
-v /volume1/stock/sanguo_data:/app/data \
|
||||
sanguo_vnpy:latest
|
||||
```
|
||||
|
||||
#### 代码更新后(仅重建应用层)
|
||||
|
||||
```bash
|
||||
# 只需重建应用镜像(约 5 分钟)
|
||||
docker build -f docker/Dockerfile.nas -t sanguo_vnpy:latest .
|
||||
|
||||
# 重启容器
|
||||
docker restart sanguo_vnpy_v2
|
||||
```
|
||||
|
||||
### 9.6 依赖更新后(重建基础层)
|
||||
|
||||
```bash
|
||||
# 当 requirements-docker.txt 变更时
|
||||
docker build -f docker/Dockerfile.base -t sanguo_vnpy:base .
|
||||
docker build -f docker/Dockerfile.nas -t sanguo_vnpy:latest .
|
||||
```
|
||||
|
||||
### 9.7 文件结构
|
||||
|
||||
```
|
||||
sanguo_vnpy_v2/
|
||||
├── docker/
|
||||
│ ├── Dockerfile.base # 基础镜像(依赖层)
|
||||
│ ├── Dockerfile.nas # 应用镜像
|
||||
│ ├── entrypoint.sh # 启动脚本
|
||||
│ └── requirements-docker.txt # 依赖清单
|
||||
├── sanguo_trader/ # 交易模块
|
||||
├── sanguo_web/ # Web 服务
|
||||
├── vnpy_v4.4.0/ # VeighNa 上游
|
||||
└── config/ # 配置文件
|
||||
```
|
||||
|
||||
## 10. 相关文档
|
||||
|
||||
- 需求文档: ../requirements/functional/feature-001-docker-web-deployment.md
|
||||
- VeighNa 文档: ../../vnpy_v4.4.0/docs/
|
||||
- 构建日志: ~/build.log, ~/rebuild2.log, ~/build4.log
|
||||
@@ -0,0 +1,114 @@
|
||||
# Docker Web 版本部署需求
|
||||
|
||||
## 1. 需求概述
|
||||
|
||||
将 VeighNa 4.4.0 量化交易平台部署到 NAS 的 Docker 容器中,通过 Web 浏览器远程访问所有功能。
|
||||
|
||||
## 2. 业务背景
|
||||
|
||||
### 2.1 场景描述
|
||||
- 用户需要在 NAS 上部署量化交易平台
|
||||
- 通过 Web 浏览器远程访问和管理
|
||||
- 支持 24 小时运行策略交易
|
||||
|
||||
### 2.2 目标用户
|
||||
- 量化交易员
|
||||
- 策略开发者
|
||||
- 系统管理员
|
||||
|
||||
## 3. 功能需求
|
||||
|
||||
### 3.1 核心功能
|
||||
| 功能 | 描述 | 优先级 |
|
||||
|-----|------|-------|
|
||||
| Web UI | 完整的 VeighNa Trader 界面 | P0 |
|
||||
| 策略管理 | CTA策略配置和运行 | P0 |
|
||||
| 行情显示 | K线图和实时行情 | P0 |
|
||||
| 交易功能 | 下单、撤单、查询 | P0 |
|
||||
| 账户管理 | 多账户支持 | P1 |
|
||||
| 数据管理 | 历史数据查询和管理 | P1 |
|
||||
| 回测功能 | 策略回测 | P2 |
|
||||
| RPC 服务 | 远程调用接口 | P1 |
|
||||
|
||||
### 3.2 Web 访问
|
||||
- HTTPS 支持
|
||||
- 基本认证(用户名/密码)
|
||||
- 会话管理
|
||||
- 响应式设计(支持移动端)
|
||||
|
||||
### 3.3 部署要求
|
||||
- Docker 容器化部署
|
||||
- 支持常见 NAS(Synology/QNAP/群晖)
|
||||
- 数据持久化
|
||||
- 配置外部化
|
||||
- 日志可访问
|
||||
|
||||
## 4. 非功能需求
|
||||
|
||||
### 4.1 性能
|
||||
| 指标 | 要求 |
|
||||
|-----|------|
|
||||
| Web 响应时间 | <2 秒 |
|
||||
| 行情延迟 | <500ms |
|
||||
| 并发用户 | ≥5 |
|
||||
| 内存占用 | <2GB |
|
||||
|
||||
### 4.2 可靠性
|
||||
- 容器自动重启
|
||||
- 数据定期备份
|
||||
- 异常日志记录
|
||||
- 崩溃恢复
|
||||
|
||||
### 4.3 安全性
|
||||
- 访问认证
|
||||
- 数据加密(HTTPS)
|
||||
- API 密钥管理
|
||||
- 网络隔离
|
||||
|
||||
### 4.4 可维护性
|
||||
- 一键部署
|
||||
- 配置简单
|
||||
- 日志集中
|
||||
- 监控接口
|
||||
|
||||
## 5. 技术约束
|
||||
|
||||
### 5.1 环境约束
|
||||
- NAS 系统:Synology DSM / QNAP QTS
|
||||
- Docker 版本:≥20.10
|
||||
- 架构:x86_64 / ARM64
|
||||
|
||||
### 5.2 技术栈
|
||||
- 基础镜像:Python 3.11+
|
||||
- Web 框架:FastAPI + Vue.js
|
||||
- 数据库:SQLite(可选升级到 PostgreSQL)
|
||||
- 反向代理:Nginx / Traefik
|
||||
|
||||
## 6. 验收标准
|
||||
|
||||
### 6.1 功能验收
|
||||
- [ ] Docker 镜像可成功构建
|
||||
- [ ] 容器可正常启动
|
||||
- [ ] Web 界面可访问
|
||||
- [ ] 用户登录认证正常
|
||||
- [ ] 策略可加载和运行
|
||||
- [ ] 行情数据正常显示
|
||||
- [ ] 交易功能正常工作
|
||||
|
||||
### 6.2 部署验收
|
||||
- [ ] 在 NAS Docker 上成功部署
|
||||
- [ ] 重启后自动恢复
|
||||
- [ ] 数据持久化正常
|
||||
- [ ] 日志可查看
|
||||
|
||||
## 7. 相关链接
|
||||
|
||||
- Issue: TBD
|
||||
- 设计文档: ../design/deployment/docker-web-deployment.md
|
||||
- 原始需求: 用户提出
|
||||
|
||||
## 8. 优先级说明
|
||||
|
||||
**P0** - 必须实现,否则无法使用
|
||||
**P1** - 重要功能,第一迭代完成
|
||||
**P2** - 增强功能,后续迭代优化
|
||||
@@ -0,0 +1,84 @@
|
||||
# Sanguo VeighNa Docker 依赖
|
||||
# 基于 VeighNa 4.4.0,添加 Web 服务所需依赖
|
||||
|
||||
# ============================================
|
||||
# VeighNa 核心依赖
|
||||
# ============================================
|
||||
tzlocal>=5.3.1
|
||||
PySide6==6.8.2.1
|
||||
pyqtgraph>=0.13.7
|
||||
qdarkstyle>=3.2.3
|
||||
numpy>=2.2.3
|
||||
pandas>=2.2.3
|
||||
ta-lib>=0.6.4
|
||||
deap>=1.4.2
|
||||
pyzmq>=26.3.0
|
||||
plotly>=6.0.0
|
||||
tqdm>=4.67.1
|
||||
loguru>=0.7.3
|
||||
nbformat>=5.10.4
|
||||
requests>=2.32.0
|
||||
qrcode>=7.4.2
|
||||
|
||||
# ============================================
|
||||
# Web 服务依赖
|
||||
# ============================================
|
||||
# FastAPI 核心
|
||||
fastapi>=0.100.0
|
||||
uvicorn[standard]>=0.23.0
|
||||
python-multipart>=0.0.6
|
||||
|
||||
# WebSocket 支持
|
||||
websockets>=12.0
|
||||
|
||||
# 数据验证
|
||||
pydantic>=2.0.0
|
||||
pydantic-settings>=2.0.0
|
||||
|
||||
# 认证授权
|
||||
python-jose[cryptography]>=3.3.0
|
||||
passlib[bcrypt]>=1.7.4
|
||||
python-multipart>=0.0.6
|
||||
|
||||
# 数据库
|
||||
sqlalchemy>=2.0.0
|
||||
alembic>=1.12.0
|
||||
# SQLite 是 Python 内置的
|
||||
# PostgreSQL 支持(可选)
|
||||
# psycopg2-binary>=2.9.0
|
||||
|
||||
# Redis 客户端
|
||||
redis>=5.0.0
|
||||
aioredis>=2.0.0
|
||||
|
||||
# ============================================
|
||||
# Alpha 量化依赖(可选)
|
||||
# ============================================
|
||||
polars>=1.26.0
|
||||
scipy>=1.15.2
|
||||
alphalens-reloaded>=0.4.5
|
||||
scikit-learn>=1.6.1
|
||||
lightgbm>=4.6.0
|
||||
torch>=2.6.0
|
||||
pyarrow>=19.0.1
|
||||
|
||||
# ============================================
|
||||
# 工具和监控
|
||||
# ============================================
|
||||
# HTTP 客户端
|
||||
httpx>=0.25.0
|
||||
|
||||
# 任务调度
|
||||
apscheduler>=3.10.0
|
||||
|
||||
# 监控和日志
|
||||
prometheus-client>=0.19.0
|
||||
structlog>=23.0.0
|
||||
|
||||
# ============================================
|
||||
# 开发工具(生产环境可选)
|
||||
# ============================================
|
||||
# pytest>=7.0.0
|
||||
# pytest-cov>=4.0.0
|
||||
# ruff>=0.1.0
|
||||
# mypy>=1.5.0
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Sanguo VeighNa Web API 启动脚本
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
|
||||
# 添加项目根目录到 Python 路径
|
||||
project_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, project_dir)
|
||||
|
||||
# 添加 vnpy_v4.4.0 到 Python 路径
|
||||
vnpy_dir = os.path.join(project_dir, "vnpy_v4.4.0")
|
||||
if os.path.exists(vnpy_dir):
|
||||
sys.path.insert(0, vnpy_dir)
|
||||
print(f"Added vnpy_v4.4.0 to Python path: {vnpy_dir}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
|
||||
# 启动服务器
|
||||
uvicorn.run(
|
||||
"sanguo_web.api:app",
|
||||
host="0.0.0.0",
|
||||
port=8002, # 使用 8002 端口避免冲突
|
||||
reload=True, # 开发模式启用热重载
|
||||
log_level="info"
|
||||
)
|
||||
@@ -0,0 +1,5 @@
|
||||
"""
|
||||
Sanguo Web API 模块
|
||||
提供 Web API 服务
|
||||
"""
|
||||
__version__ = "1.0.0"
|
||||
@@ -0,0 +1,221 @@
|
||||
"""
|
||||
Sanguo VeighNa Web API
|
||||
FastAPI 应用入口
|
||||
"""
|
||||
from fastapi import FastAPI, Request, status
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse, FileResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||
from contextlib import asynccontextmanager
|
||||
import logging
|
||||
import os
|
||||
|
||||
from .routes import auth, gateway, market, trading, strategy, system
|
||||
from ..services.main_service import VeighNaService
|
||||
from ..websocket import router as websocket_router, EventMonitorManager
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 获取项目根目录
|
||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
|
||||
# 全局 VeighNa 服务实例
|
||||
vn_service: VeighNaService = None
|
||||
|
||||
# 全局事件监听器管理器
|
||||
event_monitor_manager: EventMonitorManager = None
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""应用生命周期管理"""
|
||||
# 启动时初始化
|
||||
global vn_service, event_monitor_manager
|
||||
logger.info("Starting Sanguo VeighNa Web API...")
|
||||
|
||||
try:
|
||||
vn_service = VeighNaService()
|
||||
await vn_service.initialize()
|
||||
logger.info("VeighNa service initialized successfully")
|
||||
|
||||
# 初始化事件监听器管理器
|
||||
if vn_service.main_engine and vn_service.main_engine.event_engine:
|
||||
event_monitor_manager = EventMonitorManager(vn_service.main_engine.event_engine)
|
||||
event_monitor_manager.start_all()
|
||||
logger.info("Event monitor manager initialized")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize VeighNa service: {e}")
|
||||
# 允许应用启动,但标记服务为未就绪
|
||||
vn_service = None
|
||||
event_monitor_manager = None
|
||||
|
||||
yield
|
||||
|
||||
# 关闭时清理
|
||||
if event_monitor_manager:
|
||||
event_monitor_manager.stop_all()
|
||||
logger.info("Event monitor manager stopped")
|
||||
|
||||
if vn_service:
|
||||
await vn_service.shutdown()
|
||||
logger.info("VeighNa service shutdown complete")
|
||||
|
||||
|
||||
# 创建 FastAPI 应用
|
||||
app = FastAPI(
|
||||
title="Sanguo VeighNa Web API",
|
||||
description="Sanguo 量化交易平台 Web API",
|
||||
version="1.0.0",
|
||||
docs_url="/docs",
|
||||
redoc_url="/redoc",
|
||||
lifespan=lifespan
|
||||
)
|
||||
|
||||
|
||||
# CORS 中间件配置
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"], # 生产环境应限制具体域名
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
# 全局异常处理
|
||||
@app.exception_handler(StarletteHTTPException)
|
||||
async def http_exception_handler(request: Request, exc: StarletteHTTPException):
|
||||
"""处理 HTTP 异常"""
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content={"error": exc.detail, "status_code": exc.status_code}
|
||||
)
|
||||
|
||||
|
||||
@app.exception_handler(RequestValidationError)
|
||||
async def validation_exception_handler(request: Request, exc: RequestValidationError):
|
||||
"""处理请求验证异常"""
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
content={
|
||||
"error": "Validation error",
|
||||
"details": exc.errors(),
|
||||
"status_code": 422
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def general_exception_handler(request: Request, exc: Exception):
|
||||
"""处理未捕获的异常"""
|
||||
logger.error(f"Unhandled exception: {exc}", exc_info=True)
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
content={"error": "Internal server error", "status_code": 500}
|
||||
)
|
||||
|
||||
|
||||
# 注册路由
|
||||
api_prefix = "/api/v1"
|
||||
|
||||
app.include_router(
|
||||
system.router,
|
||||
prefix=f"{api_prefix}/system",
|
||||
tags=["system"]
|
||||
)
|
||||
|
||||
app.include_router(
|
||||
auth.router,
|
||||
prefix=f"{api_prefix}/auth",
|
||||
tags=["auth"]
|
||||
)
|
||||
|
||||
app.include_router(
|
||||
gateway.router,
|
||||
prefix=f"{api_prefix}/gateway",
|
||||
tags=["gateway"]
|
||||
)
|
||||
|
||||
app.include_router(
|
||||
market.router,
|
||||
prefix=f"{api_prefix}/market",
|
||||
tags=["market"]
|
||||
)
|
||||
|
||||
app.include_router(
|
||||
trading.router,
|
||||
prefix=f"{api_prefix}/trading",
|
||||
tags=["trading"]
|
||||
)
|
||||
|
||||
app.include_router(
|
||||
strategy.router,
|
||||
prefix=f"{api_prefix}/strategy",
|
||||
tags=["strategy"]
|
||||
)
|
||||
|
||||
# WebSocket 路由(不使用 API 前缀)
|
||||
app.include_router(
|
||||
websocket_router,
|
||||
tags=["websocket"]
|
||||
)
|
||||
|
||||
# 挂载静态文件
|
||||
static_dir = os.path.join(BASE_DIR, "static")
|
||||
if os.path.exists(static_dir):
|
||||
app.mount("/static", StaticFiles(directory=static_dir), name="static")
|
||||
logger.info(f"Static files mounted from: {static_dir}")
|
||||
else:
|
||||
logger.warning(f"Static files directory not found: {static_dir}")
|
||||
|
||||
# 根路径 - 返回主页
|
||||
@app.get("/")
|
||||
async def root():
|
||||
"""返回前端主页"""
|
||||
template_path = os.path.join(BASE_DIR, "templates", "index.html")
|
||||
if os.path.exists(template_path):
|
||||
return FileResponse(template_path)
|
||||
return {
|
||||
"name": "Sanguo VeighNa Web API",
|
||||
"version": "1.0.0",
|
||||
"status": "running",
|
||||
"docs": "/docs",
|
||||
"health": "/health"
|
||||
}
|
||||
|
||||
# API 根路径(保持兼容性)
|
||||
@app.get("/api")
|
||||
async def api_root():
|
||||
"""API 根路径"""
|
||||
return {
|
||||
"name": "Sanguo VeighNa Web API",
|
||||
"version": "1.0.0",
|
||||
"status": "running",
|
||||
"docs": "/docs",
|
||||
"health": "/health"
|
||||
}
|
||||
|
||||
|
||||
# 健康检查(不通过 /api/v1 前缀,供容器健康检查使用)
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
"""健康检查端点"""
|
||||
if vn_service is None:
|
||||
return {
|
||||
"status": "degraded",
|
||||
"message": "VeighNa service not initialized"
|
||||
}
|
||||
|
||||
return {
|
||||
"status": "healthy",
|
||||
"service": "Sanguo VeighNa Web API",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
|
||||
|
||||
# 导出应用实例(供 uvicorn 使用)
|
||||
__all__ = ["app", "vn_service", "event_monitor_manager"]
|
||||
@@ -0,0 +1,512 @@
|
||||
"""
|
||||
数据转换工具
|
||||
将 VeighNa 数据对象转换为 API 响应模型
|
||||
处理日期时间格式化和枚举类型转换
|
||||
"""
|
||||
from datetime import datetime
|
||||
from typing import Optional, List, Any
|
||||
|
||||
try:
|
||||
from vnpy.trader.object import (
|
||||
TickData, OrderData, TradeData, PositionData,
|
||||
AccountData, ContractData, QuoteData
|
||||
)
|
||||
from vnpy.trader.constant import Direction, Offset, Status, OrderType, Exchange, Product
|
||||
VNPY_AVAILABLE = True
|
||||
except ImportError:
|
||||
VNPY_AVAILABLE = False
|
||||
|
||||
from ..models import (
|
||||
TickData as TickDataModel,
|
||||
OrderResponse,
|
||||
PositionData as PositionDataModel,
|
||||
AccountData as AccountDataModel,
|
||||
KlineData,
|
||||
)
|
||||
|
||||
|
||||
# ============================================
|
||||
# 枚举值映射
|
||||
# ============================================
|
||||
|
||||
# VeighNa Direction (LONG/SHORT) -> API (buy/sell)
|
||||
DIRECTION_MAP = {
|
||||
"LONG": "buy",
|
||||
"SHORT": "sell",
|
||||
"NET": "net",
|
||||
}
|
||||
|
||||
# VeighNa OrderType -> API
|
||||
ORDER_TYPE_MAP = {
|
||||
"限价": "limit",
|
||||
"LIMIT": "limit",
|
||||
"市价": "market",
|
||||
"MARKET": "market",
|
||||
"STOP": "stop",
|
||||
"FAK": "fak",
|
||||
"FOK": "fok",
|
||||
}
|
||||
|
||||
# VeighNa Status -> API
|
||||
STATUS_MAP = {
|
||||
"提交中": "submitting",
|
||||
"SUBMITTING": "submitting",
|
||||
"未成交": "not_traded",
|
||||
"NOTTRADED": "not_traded",
|
||||
"部分成交": "part_traded",
|
||||
"PARTTRADED": "part_traded",
|
||||
"全部成交": "all_traded",
|
||||
"ALLTRADED": "all_traded",
|
||||
"已撤销": "cancelled",
|
||||
"CANCELLED": "cancelled",
|
||||
"拒单": "rejected",
|
||||
"REJECTED": "rejected",
|
||||
}
|
||||
|
||||
# VeighNa Exchange -> API
|
||||
EXCHANGE_NAMES = {
|
||||
"CFFEX": "CFFEX",
|
||||
"SHFE": "SHFE",
|
||||
"CZCE": "CZCE",
|
||||
"DCE": "DCE",
|
||||
"INE": "INE",
|
||||
"GFEX": "GFEX",
|
||||
"SSE": "SSE",
|
||||
"SZSE": "SZSE",
|
||||
"BSE": "BSE",
|
||||
"SMART": "SMART",
|
||||
"IBKRATS": "IBKRATS",
|
||||
"LOCAL": "LOCAL",
|
||||
}
|
||||
|
||||
|
||||
# ============================================
|
||||
# 辅助函数
|
||||
# ============================================
|
||||
|
||||
def format_datetime(dt: Optional[datetime]) -> Optional[datetime]:
|
||||
"""格式化日期时间"""
|
||||
if dt is None:
|
||||
return None
|
||||
return dt
|
||||
|
||||
|
||||
def convert_direction(direction: Any) -> str:
|
||||
"""转换方向枚举"""
|
||||
if direction is None:
|
||||
return "unknown"
|
||||
if isinstance(direction, str):
|
||||
return DIRECTION_MAP.get(direction, direction.lower())
|
||||
return DIRECTION_MAP.get(direction.value, "unknown")
|
||||
|
||||
|
||||
def convert_order_type(order_type: Any) -> str:
|
||||
"""转换订单类型枚举"""
|
||||
if isinstance(order_type, str):
|
||||
return ORDER_TYPE_MAP.get(order_type, order_type.lower())
|
||||
return ORDER_TYPE_MAP.get(order_type.value, "unknown")
|
||||
|
||||
|
||||
def convert_status(status: Any) -> str:
|
||||
"""转换状态枚举"""
|
||||
if isinstance(status, str):
|
||||
return STATUS_MAP.get(status, status.lower())
|
||||
return STATUS_MAP.get(status.value, "unknown")
|
||||
|
||||
|
||||
def convert_exchange(exchange: Any) -> str:
|
||||
"""转换交易所枚举"""
|
||||
if isinstance(exchange, str):
|
||||
return exchange
|
||||
return exchange.value if hasattr(exchange, 'value') else str(exchange)
|
||||
|
||||
|
||||
def safe_float(value: Any, default: float = 0.0) -> float:
|
||||
"""安全转换浮点数"""
|
||||
try:
|
||||
return float(value) if value is not None else default
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def safe_int(value: Any, default: int = 0) -> int:
|
||||
"""安全转换整数"""
|
||||
try:
|
||||
return int(value) if value is not None else default
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
# ============================================
|
||||
# TickData 转换
|
||||
# ============================================
|
||||
|
||||
def convert_tick_data(tick: Any) -> TickDataModel:
|
||||
"""转换 TickData 对象"""
|
||||
if not VNPY_AVAILABLE:
|
||||
# 模拟数据转换
|
||||
return TickDataModel(
|
||||
symbol=getattr(tick, 'symbol', ''),
|
||||
exchange=getattr(tick, 'exchange', ''),
|
||||
datetime=getattr(tick, 'datetime', datetime.now()),
|
||||
name=getattr(tick, 'name', None),
|
||||
last_price=safe_float(getattr(tick, 'last_price', 0)),
|
||||
bid_price_1=safe_float(getattr(tick, 'bid_price_1', None)),
|
||||
ask_price_1=safe_float(getattr(tick, 'ask_price_1', None)),
|
||||
bid_volume_1=safe_float(getattr(tick, 'bid_volume_1', None)),
|
||||
ask_volume_1=safe_float(getattr(tick, 'ask_volume_1', None)),
|
||||
volume=safe_float(getattr(tick, 'volume', None)),
|
||||
open_interest=safe_float(getattr(tick, 'open_interest', None)),
|
||||
)
|
||||
|
||||
# VeighNa TickData 转换
|
||||
return TickDataModel(
|
||||
symbol=tick.symbol,
|
||||
exchange=tick.exchange.value,
|
||||
datetime=tick.datetime,
|
||||
name=tick.name or None,
|
||||
last_price=tick.last_price,
|
||||
bid_price_1=tick.bid_price_1 or None,
|
||||
ask_price_1=tick.ask_price_1 or None,
|
||||
bid_volume_1=tick.bid_volume_1 or None,
|
||||
ask_volume_1=tick.ask_volume_1 or None,
|
||||
volume=tick.volume or None,
|
||||
open_interest=tick.open_interest or None,
|
||||
)
|
||||
|
||||
|
||||
def convert_tick_to_dict(tick: Any) -> dict:
|
||||
"""转换 TickData 为字典(用于缓存和 WebSocket)"""
|
||||
if not VNPY_AVAILABLE:
|
||||
return {
|
||||
"symbol": getattr(tick, 'symbol', ''),
|
||||
"exchange": getattr(tick, 'exchange', ''),
|
||||
"datetime": getattr(tick, 'datetime', datetime.now()).isoformat(),
|
||||
"name": getattr(tick, 'name', ''),
|
||||
"last_price": safe_float(getattr(tick, 'last_price', 0)),
|
||||
"bid_price_1": safe_float(getattr(tick, 'bid_price_1', 0)),
|
||||
"ask_price_1": safe_float(getattr(tick, 'ask_price_1', 0)),
|
||||
"bid_volume_1": safe_float(getattr(tick, 'bid_volume_1', 0)),
|
||||
"ask_volume_1": safe_float(getattr(tick, 'ask_volume_1', 0)),
|
||||
"volume": safe_float(getattr(tick, 'volume', 0)),
|
||||
"open_interest": safe_float(getattr(tick, 'open_interest', 0)),
|
||||
}
|
||||
|
||||
return {
|
||||
"symbol": tick.symbol,
|
||||
"exchange": tick.exchange.value,
|
||||
"datetime": tick.datetime.isoformat(),
|
||||
"name": tick.name,
|
||||
"last_price": tick.last_price,
|
||||
"bid_price_1": tick.bid_price_1,
|
||||
"ask_price_1": tick.ask_price_1,
|
||||
"bid_volume_1": tick.bid_volume_1,
|
||||
"ask_volume_1": tick.ask_volume_1,
|
||||
"volume": tick.volume,
|
||||
"open_interest": tick.open_interest,
|
||||
"open_price": tick.open_price,
|
||||
"high_price": tick.high_price,
|
||||
"low_price": tick.low_price,
|
||||
"pre_close": tick.pre_close,
|
||||
}
|
||||
|
||||
|
||||
# ============================================
|
||||
# OrderData 转换
|
||||
# ============================================
|
||||
|
||||
def convert_order_data(order: Any) -> OrderResponse:
|
||||
"""转换 OrderData 对象"""
|
||||
if not VNPY_AVAILABLE:
|
||||
return OrderResponse(
|
||||
order_id=getattr(order, 'vt_orderid', ''),
|
||||
symbol=getattr(order, 'symbol', ''),
|
||||
exchange=getattr(order, 'exchange', ''),
|
||||
direction=convert_direction(getattr(order, 'direction', None)),
|
||||
order_type=convert_order_type(getattr(order, 'type', None)),
|
||||
volume=safe_float(getattr(order, 'volume', 0)),
|
||||
price=safe_float(getattr(order, 'price', None)),
|
||||
traded=safe_float(getattr(order, 'traded', 0)),
|
||||
status=convert_status(getattr(order, 'status', None)),
|
||||
time=format_datetime(getattr(order, 'datetime', None)),
|
||||
reference=getattr(order, 'reference', None),
|
||||
)
|
||||
|
||||
return OrderResponse(
|
||||
order_id=order.vt_orderid,
|
||||
symbol=order.symbol,
|
||||
exchange=order.exchange.value,
|
||||
direction=convert_direction(order.direction),
|
||||
order_type=convert_order_type(order.type),
|
||||
volume=order.volume,
|
||||
price=order.price or None,
|
||||
traded=order.traded,
|
||||
status=convert_status(order.status),
|
||||
time=format_datetime(order.datetime),
|
||||
reference=order.reference or None,
|
||||
)
|
||||
|
||||
|
||||
def convert_order_to_dict(order: Any) -> dict:
|
||||
"""转换 OrderData 为字典"""
|
||||
if not VNPY_AVAILABLE:
|
||||
return {
|
||||
"order_id": getattr(order, 'vt_orderid', ''),
|
||||
"symbol": getattr(order, 'symbol', ''),
|
||||
"exchange": getattr(order, 'exchange', ''),
|
||||
"direction": convert_direction(getattr(order, 'direction', None)),
|
||||
"order_type": convert_order_type(getattr(order, 'type', None)),
|
||||
"volume": safe_float(getattr(order, 'volume', 0)),
|
||||
"price": safe_float(getattr(order, 'price', 0)),
|
||||
"traded": safe_float(getattr(order, 'traded', 0)),
|
||||
"status": convert_status(getattr(order, 'status', None)),
|
||||
"time": format_datetime(getattr(order, 'datetime', None)),
|
||||
"reference": getattr(order, 'reference', ''),
|
||||
}
|
||||
|
||||
return {
|
||||
"order_id": order.vt_orderid,
|
||||
"symbol": order.symbol,
|
||||
"exchange": order.exchange.value,
|
||||
"direction": convert_direction(order.direction),
|
||||
"order_type": convert_order_type(order.type),
|
||||
"offset": order.offset.value if order.offset else "",
|
||||
"volume": order.volume,
|
||||
"price": order.price,
|
||||
"traded": order.traded,
|
||||
"status": convert_status(order.status),
|
||||
"time": format_datetime(order.datetime),
|
||||
"reference": order.reference,
|
||||
"gateway_name": order.gateway_name,
|
||||
}
|
||||
|
||||
|
||||
# ============================================
|
||||
# TradeData 转换
|
||||
# ============================================
|
||||
|
||||
def convert_trade_to_dict(trade: Any) -> dict:
|
||||
"""转换 TradeData 为字典"""
|
||||
if not VNPY_AVAILABLE:
|
||||
return {
|
||||
"trade_id": getattr(trade, 'vt_tradeid', ''),
|
||||
"order_id": getattr(trade, 'vt_orderid', ''),
|
||||
"symbol": getattr(trade, 'symbol', ''),
|
||||
"exchange": getattr(trade, 'exchange', ''),
|
||||
"direction": convert_direction(getattr(trade, 'direction', None)),
|
||||
"offset": getattr(trade, 'offset', ''),
|
||||
"volume": safe_float(getattr(trade, 'volume', 0)),
|
||||
"price": safe_float(getattr(trade, 'price', 0)),
|
||||
"time": format_datetime(getattr(trade, 'datetime', None)),
|
||||
}
|
||||
|
||||
return {
|
||||
"trade_id": trade.vt_tradeid,
|
||||
"order_id": trade.vt_orderid,
|
||||
"symbol": trade.symbol,
|
||||
"exchange": trade.exchange.value,
|
||||
"direction": convert_direction(trade.direction),
|
||||
"offset": trade.offset.value,
|
||||
"volume": trade.volume,
|
||||
"price": trade.price,
|
||||
"time": format_datetime(trade.datetime),
|
||||
"gateway_name": trade.gateway_name,
|
||||
}
|
||||
|
||||
|
||||
# ============================================
|
||||
# PositionData 转换
|
||||
# ============================================
|
||||
|
||||
def convert_position_data(position: Any) -> PositionDataModel:
|
||||
"""转换 PositionData 对象"""
|
||||
if not VNPY_AVAILABLE:
|
||||
return PositionDataModel(
|
||||
symbol=getattr(position, 'symbol', ''),
|
||||
exchange=getattr(position, 'exchange', ''),
|
||||
direction=convert_direction(getattr(position, 'direction', None)),
|
||||
volume=safe_float(getattr(position, 'volume', 0)),
|
||||
price=safe_float(getattr(position, 'price', 0)),
|
||||
pnl=safe_float(getattr(position, 'pnl', 0)),
|
||||
pnl_ratio=safe_float(getattr(position, 'pnl_ratio', 0)),
|
||||
frozen=safe_float(getattr(position, 'frozen', 0)),
|
||||
yd_volume=safe_float(getattr(position, 'yd_volume', 0)),
|
||||
)
|
||||
|
||||
# 计算 pnl_ratio (VeighNa 的 PositionData 没有 pnl_ratio 属性)
|
||||
pnl_ratio = 0.0
|
||||
if position.volume and position.price:
|
||||
try:
|
||||
pnl_ratio = (position.pnl / (position.volume * position.price * 100)) if position.volume * position.price else 0.0
|
||||
except (ZeroDivisionError, TypeError):
|
||||
pnl_ratio = 0.0
|
||||
|
||||
return PositionDataModel(
|
||||
symbol=position.symbol,
|
||||
exchange=position.exchange.value,
|
||||
direction=convert_direction(position.direction),
|
||||
volume=position.volume,
|
||||
price=position.price,
|
||||
pnl=position.pnl,
|
||||
pnl_ratio=pnl_ratio,
|
||||
frozen=position.frozen,
|
||||
yd_volume=position.yd_volume,
|
||||
)
|
||||
|
||||
|
||||
def convert_position_to_dict(position: Any) -> dict:
|
||||
"""转换 PositionData 为字典"""
|
||||
if not VNPY_AVAILABLE:
|
||||
return {
|
||||
"symbol": getattr(position, 'symbol', ''),
|
||||
"exchange": getattr(position, 'exchange', ''),
|
||||
"direction": convert_direction(getattr(position, 'direction', None)),
|
||||
"volume": safe_float(getattr(position, 'volume', 0)),
|
||||
"price": safe_float(getattr(position, 'price', 0)),
|
||||
"pnl": safe_float(getattr(position, 'pnl', 0)),
|
||||
"pnl_ratio": safe_float(getattr(position, 'pnl_ratio', 0)),
|
||||
"frozen": safe_float(getattr(position, 'frozen', 0)),
|
||||
"yd_volume": safe_float(getattr(position, 'yd_volume', 0)),
|
||||
}
|
||||
|
||||
pnl_ratio = 0.0
|
||||
if position.volume and position.price:
|
||||
try:
|
||||
pnl_ratio = (position.pnl / (position.volume * position.price * 100)) if position.volume * position.price else 0.0
|
||||
except (ZeroDivisionError, TypeError):
|
||||
pnl_ratio = 0.0
|
||||
|
||||
return {
|
||||
"symbol": position.symbol,
|
||||
"exchange": position.exchange.value,
|
||||
"direction": convert_direction(position.direction),
|
||||
"volume": position.volume,
|
||||
"price": position.price,
|
||||
"pnl": position.pnl,
|
||||
"pnl_ratio": pnl_ratio,
|
||||
"frozen": position.frozen,
|
||||
"yd_volume": position.yd_volume,
|
||||
"gateway_name": position.gateway_name,
|
||||
}
|
||||
|
||||
|
||||
# ============================================
|
||||
# AccountData 转换
|
||||
# ============================================
|
||||
|
||||
def convert_account_data(account: Any) -> AccountDataModel:
|
||||
"""转换 AccountData 对象"""
|
||||
if not VNPY_AVAILABLE:
|
||||
return AccountDataModel(
|
||||
account_id=getattr(account, 'vt_accountid', ''),
|
||||
balance=safe_float(getattr(account, 'balance', 0)),
|
||||
available=safe_float(getattr(account, 'available', 0)),
|
||||
frozen=safe_float(getattr(account, 'frozen', 0)),
|
||||
margin=safe_float(getattr(account, 'margin', 0)),
|
||||
)
|
||||
|
||||
return AccountDataModel(
|
||||
account_id=account.vt_accountid,
|
||||
balance=account.balance,
|
||||
available=account.available,
|
||||
frozen=account.frozen,
|
||||
margin=safe_float(getattr(account, 'margin', 0)),
|
||||
)
|
||||
|
||||
|
||||
def convert_account_to_dict(account: Any) -> dict:
|
||||
"""转换 AccountData 为字典"""
|
||||
if not VNPY_AVAILABLE:
|
||||
return {
|
||||
"account_id": getattr(account, 'vt_accountid', ''),
|
||||
"balance": safe_float(getattr(account, 'balance', 0)),
|
||||
"available": safe_float(getattr(account, 'available', 0)),
|
||||
"frozen": safe_float(getattr(account, 'frozen', 0)),
|
||||
}
|
||||
|
||||
return {
|
||||
"account_id": account.vt_accountid,
|
||||
"balance": account.balance,
|
||||
"available": account.available,
|
||||
"frozen": account.frozen,
|
||||
"gateway_name": account.gateway_name,
|
||||
}
|
||||
|
||||
|
||||
# ============================================
|
||||
# ContractData 转换
|
||||
# ============================================
|
||||
|
||||
def convert_contract_to_dict(contract: Any) -> dict:
|
||||
"""转换 ContractData 为字典"""
|
||||
if not VNPY_AVAILABLE:
|
||||
return {
|
||||
"symbol": getattr(contract, 'symbol', ''),
|
||||
"exchange": getattr(contract, 'exchange', ''),
|
||||
"name": getattr(contract, 'name', ''),
|
||||
"product": getattr(contract, 'product', ''),
|
||||
"size": safe_float(getattr(contract, 'size', 1)),
|
||||
"pricetick": safe_float(getattr(contract, 'pricetick', 0)),
|
||||
"min_volume": safe_float(getattr(contract, 'min_volume', 1)),
|
||||
"max_volume": safe_float(getattr(contract, 'max_volume', None)),
|
||||
"vt_symbol": getattr(contract, 'vt_symbol', ''),
|
||||
"stop_supported": getattr(contract, 'stop_supported', False),
|
||||
"net_position": getattr(contract, 'net_position', False),
|
||||
}
|
||||
|
||||
return {
|
||||
"symbol": contract.symbol,
|
||||
"exchange": contract.exchange.value,
|
||||
"name": contract.name,
|
||||
"product": contract.product.value,
|
||||
"size": contract.size,
|
||||
"pricetick": contract.pricetick,
|
||||
"min_volume": contract.min_volume,
|
||||
"max_volume": contract.max_volume,
|
||||
"vt_symbol": contract.vt_symbol,
|
||||
"stop_supported": contract.stop_supported,
|
||||
"net_position": contract.net_position,
|
||||
"history_data": contract.history_data,
|
||||
}
|
||||
|
||||
|
||||
# ============================================
|
||||
# 批量转换函数
|
||||
# ============================================
|
||||
|
||||
def convert_tick_list(ticks: List[Any]) -> List[TickDataModel]:
|
||||
"""批量转换 TickData 列表"""
|
||||
return [convert_tick_data(tick) for tick in ticks]
|
||||
|
||||
|
||||
def convert_order_list(orders: List[Any]) -> List[OrderResponse]:
|
||||
"""批量转换 OrderData 列表"""
|
||||
return [convert_order_data(order) for order in orders]
|
||||
|
||||
|
||||
def convert_position_list(positions: List[Any]) -> List[PositionDataModel]:
|
||||
"""批量转换 PositionData 列表"""
|
||||
return [convert_position_data(pos) for pos in positions]
|
||||
|
||||
|
||||
def convert_account_list(accounts: List[Any]) -> List[AccountDataModel]:
|
||||
"""批量转换 AccountData 列表"""
|
||||
return [convert_account_data(acc) for acc in accounts]
|
||||
|
||||
|
||||
__all__ = [
|
||||
"convert_tick_data",
|
||||
"convert_order_data",
|
||||
"convert_position_data",
|
||||
"convert_account_data",
|
||||
"convert_tick_to_dict",
|
||||
"convert_order_to_dict",
|
||||
"convert_trade_to_dict",
|
||||
"convert_position_to_dict",
|
||||
"convert_account_to_dict",
|
||||
"convert_contract_to_dict",
|
||||
"convert_tick_list",
|
||||
"convert_order_list",
|
||||
"convert_position_list",
|
||||
"convert_account_list",
|
||||
]
|
||||
@@ -0,0 +1,176 @@
|
||||
"""
|
||||
依赖注入模块
|
||||
提供认证、数据库等依赖注入函数
|
||||
"""
|
||||
from typing import Generator, Optional
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from jose import JWTError, jwt
|
||||
from datetime import datetime, timedelta
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# JWT 配置
|
||||
SECRET_KEY = "sanguo_secret_key_change_in_production" # 生产环境应从配置读取
|
||||
ALGORITHM = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES = 30
|
||||
|
||||
|
||||
security = HTTPBearer()
|
||||
|
||||
|
||||
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
|
||||
"""创建 JWT Token"""
|
||||
to_encode = data.copy()
|
||||
if expires_delta:
|
||||
expire = datetime.utcnow() + expires_delta
|
||||
else:
|
||||
expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
|
||||
to_encode.update({"exp": expire})
|
||||
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
||||
return encoded_jwt
|
||||
|
||||
|
||||
def verify_token(token: str) -> dict:
|
||||
"""验证 JWT Token"""
|
||||
try:
|
||||
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||
return payload
|
||||
except JWTError as e:
|
||||
logger.warning(f"Token verification failed: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid authentication credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security)
|
||||
) -> dict:
|
||||
"""
|
||||
获取当前用户依赖
|
||||
从 Authorization header 中解析 JWT Token
|
||||
"""
|
||||
token = credentials.credentials
|
||||
|
||||
try:
|
||||
payload = verify_token(token)
|
||||
|
||||
username: str = payload.get("sub")
|
||||
if username is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid authentication credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
# 返回用户信息(实际应从数据库获取)
|
||||
return {
|
||||
"username": username,
|
||||
"is_active": payload.get("is_active", True),
|
||||
"exp": payload.get("exp")
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting current user: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
|
||||
async def get_optional_user(
|
||||
credentials: Optional[HTTPAuthorizationCredentials] = Depends(HTTPBearer(auto_error=False))
|
||||
) -> Optional[dict]:
|
||||
"""
|
||||
可选的用户认证依赖
|
||||
允许未登录用户访问,但如果提供了 Token 则会验证
|
||||
"""
|
||||
if credentials is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
return await get_current_user(credentials)
|
||||
except HTTPException:
|
||||
return None
|
||||
|
||||
|
||||
# ============================================
|
||||
# VeighNa 服务依赖
|
||||
# ============================================
|
||||
|
||||
async def get_vn_service():
|
||||
"""
|
||||
获取 VeighNa 服务实例
|
||||
如果服务未初始化,抛出异常
|
||||
使用延迟导入避免循环导入问题
|
||||
"""
|
||||
from . import vn_service
|
||||
|
||||
if vn_service is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="VeighNa service is not initialized"
|
||||
)
|
||||
|
||||
return vn_service
|
||||
|
||||
|
||||
# ============================================
|
||||
# 简单认证(开发环境)
|
||||
# ============================================
|
||||
|
||||
# 硬编码的用户数据库(生产环境应使用真实数据库)
|
||||
FAKE_USERS_DB = {
|
||||
"admin": {
|
||||
"username": "admin",
|
||||
"full_name": "Administrator",
|
||||
"email": "admin@sanguo.com",
|
||||
"hashed_password": "$2b$12$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36Wh0/mqGKnKM0lQ5lEqxKe", # "secret"
|
||||
"is_active": True,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def authenticate_user(username: str, password: str) -> Optional[dict]:
|
||||
"""
|
||||
验证用户凭证
|
||||
开发环境使用硬编码用户,生产环境应使用数据库
|
||||
"""
|
||||
# 开发环境简单验证
|
||||
if username == "admin" and password == "admin123":
|
||||
return {
|
||||
"username": "admin",
|
||||
"is_active": True
|
||||
}
|
||||
|
||||
# 生产环境应使用数据库和 passlib
|
||||
# user = FAKE_USERS_DB.get(username)
|
||||
# if not user:
|
||||
# return None
|
||||
# if not user["is_active"]:
|
||||
# return None
|
||||
# from passlib.context import CryptContext
|
||||
# pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
# if not pwd_context.verify(password, user["hashed_password"]):
|
||||
# return None
|
||||
# return user
|
||||
|
||||
return None
|
||||
|
||||
|
||||
__all__ = [
|
||||
"create_access_token",
|
||||
"verify_token",
|
||||
"get_current_user",
|
||||
"get_optional_user",
|
||||
"get_vn_service",
|
||||
"authenticate_user",
|
||||
]
|
||||
@@ -0,0 +1,367 @@
|
||||
"""
|
||||
Pydantic 数据模型定义
|
||||
用于 API 请求和响应的数据验证
|
||||
"""
|
||||
from datetime import datetime
|
||||
from typing import Optional, List, Any
|
||||
from enum import Enum
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
|
||||
# ============================================
|
||||
# 认证模型
|
||||
# ============================================
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
"""登录请求"""
|
||||
username: str = Field(..., min_length=1, max_length=50)
|
||||
password: str = Field(..., min_length=1, max_length=100)
|
||||
|
||||
@field_validator('username', 'password')
|
||||
def no_whitespace(cls, v):
|
||||
if v.strip() != v:
|
||||
raise ValueError('不能包含前后空格')
|
||||
return v
|
||||
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
"""Token 响应"""
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
expires_in: int # 秒数
|
||||
refresh_token: Optional[str] = None
|
||||
|
||||
|
||||
class TokenVerifyRequest(BaseModel):
|
||||
"""Token 验证请求"""
|
||||
token: str
|
||||
|
||||
|
||||
class TokenVerifyResponse(BaseModel):
|
||||
"""Token 验证响应"""
|
||||
valid: bool
|
||||
user_info: Optional[dict] = None
|
||||
|
||||
|
||||
# ============================================
|
||||
# 用户模型
|
||||
# ============================================
|
||||
|
||||
class User(BaseModel):
|
||||
"""用户信息"""
|
||||
username: str
|
||||
email: Optional[str] = None
|
||||
full_name: Optional[str] = None
|
||||
is_active: bool = True
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class UserCreate(BaseModel):
|
||||
"""创建用户"""
|
||||
username: str = Field(..., min_length=3, max_length=50)
|
||||
password: str = Field(..., min_length=6, max_length=100)
|
||||
email: Optional[str] = None
|
||||
full_name: Optional[str] = None
|
||||
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
"""更新用户"""
|
||||
email: Optional[str] = None
|
||||
full_name: Optional[str] = None
|
||||
password: Optional[str] = None
|
||||
|
||||
|
||||
# ============================================
|
||||
# 交易模型
|
||||
# ============================================
|
||||
|
||||
class OrderDirection(str, Enum):
|
||||
"""订单方向"""
|
||||
BUY = "buy"
|
||||
SELL = "sell"
|
||||
|
||||
|
||||
class OrderType(str, Enum):
|
||||
"""订单类型"""
|
||||
LIMIT = "limit"
|
||||
MARKET = "market"
|
||||
STOP = "stop"
|
||||
|
||||
|
||||
class SendOrderRequest(BaseModel):
|
||||
"""发送订单请求"""
|
||||
symbol: str = Field(..., description="交易品种代码")
|
||||
exchange: str = Field(..., description="交易所代码")
|
||||
direction: OrderDirection
|
||||
order_type: OrderType
|
||||
volume: float = Field(..., gt=0, description="数量")
|
||||
price: Optional[float] = Field(None, gt=0, description="价格(限价单必填)")
|
||||
stop_price: Optional[float] = Field(None, gt=0, description="止损价")
|
||||
reference: Optional[str] = Field(None, description="客户引用")
|
||||
|
||||
|
||||
class CancelOrderRequest(BaseModel):
|
||||
"""撤单请求"""
|
||||
order_id: str = Field(..., description="订单号")
|
||||
|
||||
|
||||
class OrderResponse(BaseModel):
|
||||
"""订单响应"""
|
||||
order_id: str
|
||||
symbol: str
|
||||
exchange: str
|
||||
direction: str
|
||||
order_type: str
|
||||
volume: float
|
||||
price: Optional[float]
|
||||
traded: float
|
||||
status: str
|
||||
time: datetime
|
||||
reference: Optional[str] = None
|
||||
|
||||
|
||||
# ============================================
|
||||
# 行情数据模型
|
||||
# ============================================
|
||||
|
||||
class TickData(BaseModel):
|
||||
"""Tick 数据"""
|
||||
symbol: str
|
||||
exchange: str
|
||||
datetime: datetime
|
||||
name: Optional[str] = None
|
||||
last_price: float
|
||||
bid_price_1: Optional[float] = None
|
||||
ask_price_1: Optional[float] = None
|
||||
bid_volume_1: Optional[float] = None
|
||||
ask_volume_1: Optional[float] = None
|
||||
volume: Optional[float] = None
|
||||
open_interest: Optional[float] = None
|
||||
|
||||
|
||||
class KlineRequest(BaseModel):
|
||||
"""K线数据请求"""
|
||||
symbol: str = Field(..., description="交易品种代码")
|
||||
exchange: str = Field(..., description="交易所代码")
|
||||
interval: str = Field(..., description="周期: 1m, 5m, 15m, 1h, 4h, 1d")
|
||||
start: Optional[datetime] = Field(None, description="开始时间")
|
||||
end: Optional[datetime] = Field(None, description="结束时间")
|
||||
limit: int = Field(1000, ge=1, le=10000, description="数据条数限制")
|
||||
|
||||
|
||||
class KlineData(BaseModel):
|
||||
"""K线数据"""
|
||||
symbol: str
|
||||
exchange: str
|
||||
datetime: datetime
|
||||
interval: str
|
||||
open_price: float
|
||||
high_price: float
|
||||
low_price: float
|
||||
close_price: float
|
||||
volume: float
|
||||
open_interest: Optional[float] = None
|
||||
|
||||
|
||||
class SubscribeRequest(BaseModel):
|
||||
"""订阅行情请求"""
|
||||
symbol: str = Field(..., description="交易品种代码")
|
||||
exchange: str = Field(..., description="交易所代码")
|
||||
gateway_name: Optional[str] = Field(None, description="网关名称")
|
||||
|
||||
|
||||
class UnsubscribeRequest(BaseModel):
|
||||
"""取消订阅请求"""
|
||||
symbol: str = Field(..., description="交易品种代码")
|
||||
exchange: str = Field(..., description="交易所代码")
|
||||
gateway_name: Optional[str] = Field(None, description="网关名称")
|
||||
|
||||
|
||||
# ============================================
|
||||
# 持仓数据模型
|
||||
# ============================================
|
||||
|
||||
class PositionData(BaseModel):
|
||||
"""持仓数据"""
|
||||
symbol: str
|
||||
exchange: str
|
||||
direction: str
|
||||
volume: float
|
||||
price: float
|
||||
pnl: float
|
||||
pnl_ratio: float
|
||||
frozen: float = 0.0
|
||||
yd_volume: float = 0.0
|
||||
|
||||
|
||||
# ============================================
|
||||
# 账户数据模型
|
||||
# ============================================
|
||||
|
||||
class AccountData(BaseModel):
|
||||
"""账户数据"""
|
||||
account_id: str
|
||||
balance: float
|
||||
available: float
|
||||
frozen: float = 0.0
|
||||
margin: float = 0.0
|
||||
|
||||
|
||||
class TradeData(BaseModel):
|
||||
"""成交数据"""
|
||||
trade_id: str
|
||||
order_id: str
|
||||
symbol: str
|
||||
exchange: str
|
||||
direction: str
|
||||
volume: float
|
||||
price: float
|
||||
time: datetime
|
||||
|
||||
|
||||
class AccountResponse(BaseModel):
|
||||
"""账户响应"""
|
||||
account: AccountData
|
||||
positions: List[PositionData]
|
||||
orders: List[OrderResponse]
|
||||
|
||||
|
||||
# ============================================
|
||||
# 网关模型
|
||||
# ============================================
|
||||
|
||||
class GatewayType(str, Enum):
|
||||
"""网关类型"""
|
||||
CTP = "ctp"
|
||||
IB = "ib"
|
||||
OKX = "okx"
|
||||
BINAANCE = "binance"
|
||||
TEST = "test"
|
||||
|
||||
|
||||
class GatewayStatus(BaseModel):
|
||||
"""网关状态"""
|
||||
gateway_name: str
|
||||
gateway_type: GatewayType
|
||||
status: str # connected, disconnected, connecting
|
||||
connected_time: Optional[datetime] = None
|
||||
message: Optional[str] = None
|
||||
|
||||
|
||||
class GatewayConnectRequest(BaseModel):
|
||||
"""连接网关请求"""
|
||||
gateway_name: str = Field(..., description="网关名称")
|
||||
gateway_type: GatewayType
|
||||
setting: dict = Field(..., description="网关配置参数")
|
||||
|
||||
|
||||
class GatewayDisconnectRequest(BaseModel):
|
||||
"""断开网关请求"""
|
||||
gateway_name: str
|
||||
|
||||
|
||||
# ============================================
|
||||
# 策略模型
|
||||
# ============================================
|
||||
|
||||
class StrategyStatus(str, Enum):
|
||||
"""策略状态"""
|
||||
CREATED = "created"
|
||||
RUNNING = "running"
|
||||
STOPPED = "stopped"
|
||||
PAUSED = "paused"
|
||||
|
||||
|
||||
class StrategyInfo(BaseModel):
|
||||
"""策略信息"""
|
||||
strategy_name: str
|
||||
class_name: str
|
||||
status: StrategyStatus
|
||||
created_at: datetime
|
||||
variables: Optional[dict] = None
|
||||
|
||||
|
||||
class StrategyCreateRequest(BaseModel):
|
||||
"""创建策略请求"""
|
||||
strategy_name: str = Field(..., min_length=1, max_length=50)
|
||||
class_name: str = Field(..., description="策略类名")
|
||||
setting: dict = Field(default_factory=dict, description="策略参数")
|
||||
|
||||
|
||||
class StrategyInitRequest(BaseModel):
|
||||
"""初始化策略请求"""
|
||||
strategy_name: str
|
||||
|
||||
|
||||
class StrategyStartRequest(BaseModel):
|
||||
"""启动策略请求"""
|
||||
strategy_name: str
|
||||
|
||||
|
||||
class StrategyStopRequest(BaseModel):
|
||||
"""停止策略请求"""
|
||||
strategy_name: str
|
||||
|
||||
|
||||
class StrategyEditRequest(BaseModel):
|
||||
"""编辑策略请求"""
|
||||
strategy_name: str
|
||||
setting: dict
|
||||
|
||||
|
||||
# ============================================
|
||||
# 响应模型
|
||||
# ============================================
|
||||
|
||||
class ApiResponse(BaseModel):
|
||||
"""通用 API 响应"""
|
||||
success: bool
|
||||
message: Optional[str] = None
|
||||
data: Optional[Any] = None
|
||||
|
||||
|
||||
class PaginatedResponse(BaseModel):
|
||||
"""分页响应"""
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
data: List[Any]
|
||||
|
||||
|
||||
# ============================================
|
||||
# 系统模型
|
||||
# ============================================
|
||||
|
||||
class HealthResponse(BaseModel):
|
||||
"""健康检查响应"""
|
||||
status: str
|
||||
service: str
|
||||
version: str
|
||||
timestamp: datetime
|
||||
|
||||
|
||||
class SystemInfo(BaseModel):
|
||||
"""系统信息"""
|
||||
version: str
|
||||
vnpy_version: str
|
||||
python_version: str
|
||||
uptime: float
|
||||
connected_gateways: int
|
||||
active_strategies: int
|
||||
|
||||
|
||||
# ============================================
|
||||
# WebSocket 模型
|
||||
# ============================================
|
||||
|
||||
class WSSubscribeRequest(BaseModel):
|
||||
"""WebSocket 订阅请求"""
|
||||
type: str # tick, order, position, trade
|
||||
symbols: Optional[List[str]] = None
|
||||
|
||||
|
||||
class WSMessage(BaseModel):
|
||||
"""WebSocket 消息"""
|
||||
type: str
|
||||
data: Any
|
||||
timestamp: datetime
|
||||
@@ -0,0 +1,6 @@
|
||||
"""
|
||||
API 路由模块
|
||||
"""
|
||||
from . import auth, gateway, market, trading, strategy, system
|
||||
|
||||
__all__ = ["auth", "gateway", "market", "trading", "strategy", "system"]
|
||||
@@ -0,0 +1,113 @@
|
||||
"""
|
||||
认证路由
|
||||
处理用户登录、登出、Token 验证
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from datetime import timedelta
|
||||
|
||||
from ..models import (
|
||||
LoginRequest, TokenResponse, TokenVerifyRequest, TokenVerifyResponse
|
||||
)
|
||||
from ..deps import (
|
||||
get_current_user, authenticate_user, create_access_token,
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES
|
||||
)
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
async def login(login_request: LoginRequest):
|
||||
"""
|
||||
用户登录
|
||||
|
||||
- **username**: 用户名
|
||||
- **password**: 密码
|
||||
|
||||
返回 JWT Token
|
||||
"""
|
||||
# 验证用户凭证
|
||||
user = authenticate_user(login_request.username, login_request.password)
|
||||
|
||||
if not user:
|
||||
logger.warning(f"Failed login attempt for user: {login_request.username}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Incorrect username or password",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
# 创建 Token
|
||||
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
access_token = create_access_token(
|
||||
data={"sub": user["username"], "is_active": user["is_active"]},
|
||||
expires_delta=access_token_expires
|
||||
)
|
||||
|
||||
logger.info(f"User logged in: {user['username']}")
|
||||
|
||||
return TokenResponse(
|
||||
access_token=access_token,
|
||||
token_type="bearer",
|
||||
expires_in=ACCESS_TOKEN_EXPIRE_MINUTES * 60
|
||||
)
|
||||
|
||||
|
||||
@router.post("/verify", response_model=TokenVerifyResponse)
|
||||
async def verify_token(
|
||||
verify_request: TokenVerifyRequest
|
||||
):
|
||||
"""
|
||||
验证 Token 有效性
|
||||
|
||||
- **token**: JWT Token
|
||||
|
||||
返回 Token 是否有效及用户信息
|
||||
"""
|
||||
from ..deps import verify_token as do_verify_token
|
||||
|
||||
try:
|
||||
payload = do_verify_token(verify_request.token)
|
||||
username = payload.get("sub")
|
||||
|
||||
return TokenVerifyResponse(
|
||||
valid=True,
|
||||
user_info={
|
||||
"username": username,
|
||||
"is_active": payload.get("is_active", True),
|
||||
"exp": payload.get("exp")
|
||||
}
|
||||
)
|
||||
except HTTPException:
|
||||
return TokenVerifyResponse(valid=False, user_info=None)
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
async def get_current_user_info(current_user: dict = Depends(get_current_user)):
|
||||
"""
|
||||
获取当前用户信息
|
||||
|
||||
需要有效的 JWT Token
|
||||
"""
|
||||
return {
|
||||
"username": current_user["username"],
|
||||
"is_active": current_user["is_active"],
|
||||
"exp": current_user.get("exp")
|
||||
}
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout(current_user: dict = Depends(get_current_user)):
|
||||
"""
|
||||
用户登出
|
||||
|
||||
注:JWT 是无状态的,客户端只需删除 Token 即可
|
||||
此接口主要用于记录日志或执行清理操作
|
||||
"""
|
||||
username = current_user.get("username", "unknown")
|
||||
logger.info(f"User logged out: {username}")
|
||||
|
||||
return {"message": "Successfully logged out"}
|
||||
@@ -0,0 +1,125 @@
|
||||
"""
|
||||
网关管理路由
|
||||
处理网关连接、断开、状态查询
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from typing import List
|
||||
import logging
|
||||
|
||||
from ..models import GatewayConnectRequest, GatewayDisconnectRequest, GatewayStatus, ApiResponse
|
||||
from ..deps import get_current_user, get_vn_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/list", response_model=List[GatewayStatus])
|
||||
async def list_gateways(
|
||||
current_user: dict = Depends(get_current_user),
|
||||
vn_service=Depends(get_vn_service)
|
||||
):
|
||||
"""
|
||||
获取所有可用网关列表
|
||||
|
||||
返回系统支持的所有网关,包括已连接和未连接的网关
|
||||
"""
|
||||
gateways = await vn_service.get_available_gateways()
|
||||
return [
|
||||
GatewayStatus(
|
||||
gateway_name=g["gateway_name"],
|
||||
gateway_type=g.get("gateway_type", "unknown"),
|
||||
status=g.get("status", "disconnected")
|
||||
)
|
||||
for g in gateways
|
||||
]
|
||||
|
||||
|
||||
@router.get("/{name}/setting")
|
||||
async def get_gateway_setting(
|
||||
name: str,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
vn_service=Depends(get_vn_service)
|
||||
):
|
||||
"""
|
||||
获取网关配置模板
|
||||
|
||||
返回指定网关的配置模板,用于前端生成配置表单
|
||||
"""
|
||||
setting = await vn_service.get_gateway_setting(name)
|
||||
return {
|
||||
"gateway_name": name,
|
||||
"setting": setting
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{name}/status")
|
||||
async def get_gateway_status(
|
||||
name: str,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
vn_service=Depends(get_vn_service)
|
||||
):
|
||||
"""
|
||||
获取网关连接状态
|
||||
|
||||
返回指定网关的连接状态
|
||||
"""
|
||||
status_info = await vn_service.get_gateway_status(name)
|
||||
return status_info
|
||||
|
||||
|
||||
@router.post("/{name}/connect")
|
||||
async def connect_gateway(
|
||||
name: str,
|
||||
request: GatewayConnectRequest,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
vn_service=Depends(get_vn_service)
|
||||
):
|
||||
"""
|
||||
连接网关
|
||||
|
||||
- **gateway_name**: 网关名称
|
||||
- **gateway_type**: 网关类型 (ctp, ib, okx, binance)
|
||||
- **setting**: 网关配置参数
|
||||
"""
|
||||
success = await vn_service.connect_gateway(
|
||||
gateway_name=request.gateway_name,
|
||||
gateway_type=request.gateway_type.value,
|
||||
setting=request.setting
|
||||
)
|
||||
|
||||
if success:
|
||||
return ApiResponse(
|
||||
success=True,
|
||||
message=f"Gateway {request.gateway_name} is connecting"
|
||||
)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to connect gateway {request.gateway_name}"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{name}/disconnect")
|
||||
async def disconnect_gateway(
|
||||
name: str,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
vn_service=Depends(get_vn_service)
|
||||
):
|
||||
"""
|
||||
断开网关
|
||||
|
||||
- **name**: 网关名称
|
||||
"""
|
||||
success = await vn_service.disconnect_gateway(name)
|
||||
|
||||
if success:
|
||||
return ApiResponse(
|
||||
success=True,
|
||||
message=f"Gateway {name} disconnected successfully"
|
||||
)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to disconnect gateway {name}"
|
||||
)
|
||||
@@ -0,0 +1,176 @@
|
||||
"""
|
||||
行情数据路由
|
||||
处理行情数据查询和订阅
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, Query, HTTPException, status
|
||||
from typing import List, Optional
|
||||
import logging
|
||||
|
||||
from ..models import (
|
||||
TickData, KlineRequest, KlineData, SubscribeRequest, UnsubscribeRequest, ApiResponse
|
||||
)
|
||||
from ..deps import get_current_user, get_vn_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/contracts")
|
||||
async def get_contracts(
|
||||
current_user: dict = Depends(get_current_user),
|
||||
vn_service=Depends(get_vn_service)
|
||||
):
|
||||
"""
|
||||
获取所有合约列表
|
||||
|
||||
返回系统中所有已知的合约信息
|
||||
"""
|
||||
contracts = await vn_service.get_contracts()
|
||||
return {
|
||||
"contracts": contracts,
|
||||
"total": len(contracts)
|
||||
}
|
||||
|
||||
|
||||
@router.get("/contracts/{vt_symbol}")
|
||||
async def get_contract(
|
||||
vt_symbol: str,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
vn_service=Depends(get_vn_service)
|
||||
):
|
||||
"""
|
||||
获取单个合约详情
|
||||
|
||||
- **vt_symbol**: 合约代码 (格式: symbol.exchange)
|
||||
"""
|
||||
contract = await vn_service.get_contract(vt_symbol)
|
||||
if not contract:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Contract {vt_symbol} not found"
|
||||
)
|
||||
return contract
|
||||
|
||||
|
||||
@router.post("/subscribe")
|
||||
async def subscribe_market(
|
||||
request: SubscribeRequest,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
vn_service=Depends(get_vn_service)
|
||||
):
|
||||
"""
|
||||
订阅行情
|
||||
|
||||
- **symbol**: 品种代码
|
||||
- **exchange**: 交易所
|
||||
- **gateway_name**: 网关名称(可选)
|
||||
"""
|
||||
success = await vn_service.subscribe(
|
||||
symbol=request.symbol,
|
||||
exchange=request.exchange,
|
||||
gateway_name=request.gateway_name
|
||||
)
|
||||
|
||||
if success:
|
||||
return ApiResponse(
|
||||
success=True,
|
||||
message=f"Subscribed to {request.symbol}.{request.exchange}"
|
||||
)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to subscribe to {request.symbol}.{request.exchange}"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/unsubscribe")
|
||||
async def unsubscribe_market(
|
||||
request: UnsubscribeRequest,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
vn_service=Depends(get_vn_service)
|
||||
):
|
||||
"""
|
||||
取消订阅行情
|
||||
|
||||
- **symbol**: 品种代码
|
||||
- **exchange**: 交易所
|
||||
- **gateway_name**: 网关名称(可选)
|
||||
"""
|
||||
success = await vn_service.unsubscribe(
|
||||
symbol=request.symbol,
|
||||
exchange=request.exchange,
|
||||
gateway_name=request.gateway_name
|
||||
)
|
||||
|
||||
if success:
|
||||
return ApiResponse(
|
||||
success=True,
|
||||
message=f"Unsubscribed from {request.symbol}.{request.exchange}"
|
||||
)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to unsubscribe from {request.symbol}.{request.exchange}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/ticks")
|
||||
async def get_all_ticks(
|
||||
symbols: Optional[str] = Query(None, description="品种代码,逗号分隔"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
vn_service=Depends(get_vn_service)
|
||||
):
|
||||
"""
|
||||
获取当前行情缓存
|
||||
|
||||
- **symbols**: 可选,过滤特定品种
|
||||
"""
|
||||
symbol_list = symbols.split(",") if symbols else None
|
||||
ticks = await vn_service.get_ticks(symbol_list)
|
||||
|
||||
return {
|
||||
"ticks": ticks,
|
||||
"total": len(ticks)
|
||||
}
|
||||
|
||||
|
||||
@router.get("/ticks/{vt_symbol}")
|
||||
async def get_tick(
|
||||
vt_symbol: str,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
vn_service=Depends(get_vn_service)
|
||||
):
|
||||
"""
|
||||
获取指定合约行情
|
||||
|
||||
- **vt_symbol**: 合约代码 (格式: symbol.exchange)
|
||||
"""
|
||||
ticks = await vn_service.get_ticks([vt_symbol])
|
||||
if not ticks:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"No tick data for {vt_symbol}"
|
||||
)
|
||||
return ticks[0]
|
||||
|
||||
|
||||
@router.post("/kline", response_model=List[KlineData])
|
||||
async def get_kline_data(
|
||||
request: KlineRequest,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
vn_service=Depends(get_vn_service)
|
||||
):
|
||||
"""
|
||||
获取 K线数据
|
||||
|
||||
- **symbol**: 品种代码
|
||||
- **exchange**: 交易所
|
||||
- **interval**: 周期 (1m, 5m, 15m, 1h, 4h, 1d)
|
||||
- **start**: 开始时间(可选)
|
||||
- **end**: 结束时间(可选)
|
||||
- **limit**: 数据条数限制
|
||||
"""
|
||||
# 占位实现,实际应从数据库获取
|
||||
logger.info(f"Kline request: {request.symbol}.{request.exchange} {request.interval}")
|
||||
return []
|
||||
@@ -0,0 +1,145 @@
|
||||
"""
|
||||
策略管理路由
|
||||
处理策略的创建、初始化、启动、停止
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from typing import List
|
||||
import logging
|
||||
|
||||
from ..models import (
|
||||
StrategyInfo, StrategyCreateRequest, StrategyInitRequest,
|
||||
StrategyStartRequest, StrategyStopRequest, StrategyEditRequest
|
||||
)
|
||||
from ..deps import get_current_user, get_vn_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/list", response_model=List[StrategyInfo])
|
||||
async def list_strategies(
|
||||
current_user: dict = Depends(get_current_user),
|
||||
vn_service=Depends(get_vn_service)
|
||||
):
|
||||
"""
|
||||
获取所有策略信息
|
||||
"""
|
||||
strategies = await vn_service.get_strategies()
|
||||
|
||||
return [
|
||||
StrategyInfo(
|
||||
strategy_name=s["strategy_name"],
|
||||
class_name=s["class_name"],
|
||||
status=s["status"],
|
||||
created_at=s["created_at"],
|
||||
variables=s.get("variables")
|
||||
)
|
||||
for s in strategies
|
||||
]
|
||||
|
||||
|
||||
@router.post("/create")
|
||||
async def create_strategy(
|
||||
request: StrategyCreateRequest,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
vn_service=Depends(get_vn_service)
|
||||
):
|
||||
"""
|
||||
创建策略
|
||||
|
||||
- **strategy_name**: 策略名称
|
||||
- **class_name**: 策略类名
|
||||
- **setting**: 策略参数
|
||||
"""
|
||||
# 占位实现
|
||||
logger.info(f"Creating strategy: {request.strategy_name}")
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Strategy {request.strategy_name} created successfully"
|
||||
}
|
||||
|
||||
|
||||
@router.post("/init")
|
||||
async def init_strategy(
|
||||
request: StrategyInitRequest,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
vn_service=Depends(get_vn_service)
|
||||
):
|
||||
"""
|
||||
初始化策略
|
||||
|
||||
- **strategy_name**: 策略名称
|
||||
"""
|
||||
success = await vn_service.init_strategy(request.strategy_name)
|
||||
|
||||
if success:
|
||||
return {"success": True, "message": "Strategy initialized successfully"}
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to initialize strategy {request.strategy_name}"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/start")
|
||||
async def start_strategy(
|
||||
request: StrategyStartRequest,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
vn_service=Depends(get_vn_service)
|
||||
):
|
||||
"""
|
||||
启动策略
|
||||
|
||||
- **strategy_name**: 策略名称
|
||||
"""
|
||||
success = await vn_service.start_strategy(request.strategy_name)
|
||||
|
||||
if success:
|
||||
return {"success": True, "message": "Strategy started successfully"}
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to start strategy {request.strategy_name}"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/stop")
|
||||
async def stop_strategy(
|
||||
request: StrategyStopRequest,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
vn_service=Depends(get_vn_service)
|
||||
):
|
||||
"""
|
||||
停止策略
|
||||
|
||||
- **strategy_name**: 策略名称
|
||||
"""
|
||||
success = await vn_service.stop_strategy(request.strategy_name)
|
||||
|
||||
if success:
|
||||
return {"success": True, "message": "Strategy stopped successfully"}
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to stop strategy {request.strategy_name}"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/edit")
|
||||
async def edit_strategy(
|
||||
request: StrategyEditRequest,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
vn_service=Depends(get_vn_service)
|
||||
):
|
||||
"""
|
||||
编辑策略参数
|
||||
|
||||
- **strategy_name**: 策略名称
|
||||
- **setting**: 新的参数
|
||||
"""
|
||||
logger.info(f"Editing strategy: {request.strategy_name}")
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Strategy parameters updated successfully"
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
"""
|
||||
系统路由
|
||||
提供健康检查、系统信息等接口
|
||||
"""
|
||||
from fastapi import APIRouter, Depends
|
||||
from datetime import datetime
|
||||
import sys
|
||||
import time
|
||||
import logging
|
||||
|
||||
from ..models import HealthResponse, SystemInfo
|
||||
from ..deps import get_vn_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/health", response_model=HealthResponse)
|
||||
async def health_check():
|
||||
"""
|
||||
健康检查端点
|
||||
|
||||
返回 API 服务状态
|
||||
"""
|
||||
return HealthResponse(
|
||||
status="healthy",
|
||||
service="Sanguo VeighNa Web API",
|
||||
version="1.0.0",
|
||||
timestamp=datetime.utcnow()
|
||||
)
|
||||
|
||||
|
||||
@router.get("/info", response_model=SystemInfo)
|
||||
async def get_system_info(vn_service=Depends(get_vn_service)):
|
||||
"""
|
||||
获取系统信息
|
||||
|
||||
包括版本、运行时间、连接状态等
|
||||
"""
|
||||
# 获取连接的网关数量
|
||||
gateways = await vn_service.get_connected_gateways()
|
||||
connected_count = sum(1 for g in gateways if g["status"] == "connected")
|
||||
|
||||
# 获取策略数量(占位)
|
||||
strategy_count = 0
|
||||
|
||||
return SystemInfo(
|
||||
version="1.0.0",
|
||||
vnpy_version="4.4.0",
|
||||
python_version=f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}",
|
||||
uptime=time.time(), # 实际应该记录启动时间
|
||||
connected_gateways=connected_count,
|
||||
active_strategies=strategy_count
|
||||
)
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
async def get_system_status(vn_service=Depends(get_vn_service)):
|
||||
"""
|
||||
获取详细系统状态
|
||||
|
||||
包括网关、账户、持仓、订单等
|
||||
"""
|
||||
try:
|
||||
accounts = await vn_service.get_accounts()
|
||||
positions = await vn_service.get_positions()
|
||||
orders = await vn_service.get_orders()
|
||||
gateways = await vn_service.get_connected_gateways()
|
||||
|
||||
return {
|
||||
"status": "running",
|
||||
"accounts_count": len(accounts),
|
||||
"positions_count": len(positions),
|
||||
"orders_count": len(orders),
|
||||
"gateways": gateways
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting system status: {e}")
|
||||
return {
|
||||
"status": "error",
|
||||
"error": str(e)
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
"""
|
||||
交易路由
|
||||
处理订单发送、撤单、查询
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from typing import List
|
||||
import logging
|
||||
|
||||
from ..models import (
|
||||
SendOrderRequest, CancelOrderRequest, OrderResponse,
|
||||
AccountResponse, PositionData, AccountData, ApiResponse, TradeData
|
||||
)
|
||||
from ..deps import get_current_user, get_vn_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ============================================
|
||||
# 账户和持仓
|
||||
# ============================================
|
||||
|
||||
@router.get("/accounts", response_model=List[AccountData])
|
||||
async def get_accounts(
|
||||
current_user: dict = Depends(get_current_user),
|
||||
vn_service=Depends(get_vn_service)
|
||||
):
|
||||
"""
|
||||
获取账户信息
|
||||
|
||||
返回所有账户的资金信息
|
||||
"""
|
||||
accounts = await vn_service.get_accounts()
|
||||
return [
|
||||
AccountData(
|
||||
account_id=acc["account_id"],
|
||||
balance=acc["balance"],
|
||||
available=acc["available"],
|
||||
frozen=acc.get("frozen", 0.0),
|
||||
)
|
||||
for acc in accounts
|
||||
]
|
||||
|
||||
|
||||
@router.get("/positions", response_model=List[PositionData])
|
||||
async def get_positions(
|
||||
current_user: dict = Depends(get_current_user),
|
||||
vn_service=Depends(get_vn_service)
|
||||
):
|
||||
"""
|
||||
获取持仓信息
|
||||
|
||||
返回所有持仓数据
|
||||
"""
|
||||
positions = await vn_service.get_positions()
|
||||
return [
|
||||
PositionData(
|
||||
symbol=p["symbol"],
|
||||
exchange=p["exchange"],
|
||||
direction=p["direction"],
|
||||
volume=p["volume"],
|
||||
price=p["price"],
|
||||
pnl=p["pnl"],
|
||||
pnl_ratio=p.get("pnl_ratio", 0.0),
|
||||
frozen=p.get("frozen", 0.0),
|
||||
)
|
||||
for p in positions
|
||||
]
|
||||
|
||||
|
||||
# ============================================
|
||||
# 订单管理
|
||||
# ============================================
|
||||
|
||||
@router.get("/orders", response_model=List[OrderResponse])
|
||||
async def get_orders(
|
||||
current_user: dict = Depends(get_current_user),
|
||||
vn_service=Depends(get_vn_service)
|
||||
):
|
||||
"""
|
||||
获取所有委托
|
||||
|
||||
返回所有订单,包括历史订单和活动订单
|
||||
"""
|
||||
orders_data = await vn_service.get_orders()
|
||||
|
||||
return [
|
||||
OrderResponse(
|
||||
order_id=o["order_id"],
|
||||
symbol=o["symbol"],
|
||||
exchange=o["exchange"],
|
||||
direction=o["direction"],
|
||||
order_type=o["order_type"],
|
||||
volume=o["volume"],
|
||||
price=o.get("price"),
|
||||
traded=o["traded"],
|
||||
status=o["status"],
|
||||
time=o["time"]
|
||||
)
|
||||
for o in orders_data
|
||||
]
|
||||
|
||||
|
||||
@router.get("/orders/active", response_model=List[OrderResponse])
|
||||
async def get_active_orders(
|
||||
current_user: dict = Depends(get_current_user),
|
||||
vn_service=Depends(get_vn_service)
|
||||
):
|
||||
"""
|
||||
获取活动委托
|
||||
|
||||
返回所有未完成的活动订单
|
||||
"""
|
||||
orders_data = await vn_service.get_active_orders()
|
||||
|
||||
return [
|
||||
OrderResponse(
|
||||
order_id=o["order_id"],
|
||||
symbol=o["symbol"],
|
||||
exchange=o["exchange"],
|
||||
direction=o["direction"],
|
||||
order_type=o["order_type"],
|
||||
volume=o["volume"],
|
||||
price=o.get("price"),
|
||||
traded=o["traded"],
|
||||
status=o["status"],
|
||||
time=o["time"]
|
||||
)
|
||||
for o in orders_data
|
||||
]
|
||||
|
||||
|
||||
@router.post("/orders", response_model=dict)
|
||||
async def send_order(
|
||||
request: SendOrderRequest,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
vn_service=Depends(get_vn_service)
|
||||
):
|
||||
"""
|
||||
发送订单
|
||||
|
||||
- **symbol**: 品种代码
|
||||
- **exchange**: 交易所
|
||||
- **direction**: 方向 (buy/sell)
|
||||
- **order_type**: 类型 (limit/market/stop)
|
||||
- **volume**: 数量
|
||||
- **price**: 价格(限价单必填)
|
||||
- **reference**: 客户引用(可选)
|
||||
"""
|
||||
order_id = await vn_service.send_order(
|
||||
symbol=request.symbol,
|
||||
exchange=request.exchange,
|
||||
direction=request.direction.value,
|
||||
order_type=request.order_type.value,
|
||||
volume=request.volume,
|
||||
price=request.price,
|
||||
reference=request.reference
|
||||
)
|
||||
|
||||
if order_id:
|
||||
return {
|
||||
"success": True,
|
||||
"order_id": order_id,
|
||||
"message": "Order sent successfully"
|
||||
}
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to send order"
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/orders/{vt_orderid}")
|
||||
async def cancel_order(
|
||||
vt_orderid: str,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
vn_service=Depends(get_vn_service)
|
||||
):
|
||||
"""
|
||||
撤销订单
|
||||
|
||||
- **vt_orderid**: 订单号(格式:gateway_name.orderid)
|
||||
"""
|
||||
success = await vn_service.cancel_order(vt_orderid)
|
||||
|
||||
if success:
|
||||
return ApiResponse(
|
||||
success=True,
|
||||
message=f"Order {vt_orderid} cancelled successfully"
|
||||
)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to cancel order {vt_orderid}"
|
||||
)
|
||||
|
||||
|
||||
# ============================================
|
||||
# 成交记录
|
||||
# ============================================
|
||||
|
||||
@router.get("/trades")
|
||||
async def get_trades(
|
||||
current_user: dict = Depends(get_current_user),
|
||||
vn_service=Depends(get_vn_service)
|
||||
):
|
||||
"""
|
||||
获取成交列表
|
||||
|
||||
返回所有成交记录
|
||||
"""
|
||||
trades = await vn_service.get_trades()
|
||||
return {
|
||||
"trades": trades,
|
||||
"total": len(trades)
|
||||
}
|
||||
|
||||
|
||||
# ============================================
|
||||
# 综合查询
|
||||
# ============================================
|
||||
|
||||
@router.get("/account", response_model=AccountResponse)
|
||||
async def get_account(
|
||||
current_user: dict = Depends(get_current_user),
|
||||
vn_service=Depends(get_vn_service)
|
||||
):
|
||||
"""
|
||||
获取账户信息(包括持仓和委托)
|
||||
|
||||
返回账户资金、持仓和委托的综合信息
|
||||
"""
|
||||
accounts = await vn_service.get_accounts()
|
||||
positions_data = await vn_service.get_positions()
|
||||
orders_data = await vn_service.get_orders()
|
||||
|
||||
# 取第一个账户
|
||||
account_data = accounts[0] if accounts else None
|
||||
|
||||
if not account_data:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="No account found"
|
||||
)
|
||||
|
||||
account = AccountData(
|
||||
account_id=account_data["account_id"],
|
||||
balance=account_data["balance"],
|
||||
available=account_data["available"],
|
||||
frozen=account_data.get("frozen", 0.0),
|
||||
)
|
||||
|
||||
positions = [
|
||||
PositionData(
|
||||
symbol=p["symbol"],
|
||||
exchange=p["exchange"],
|
||||
direction=p["direction"],
|
||||
volume=p["volume"],
|
||||
price=p["price"],
|
||||
pnl=p["pnl"],
|
||||
pnl_ratio=p.get("pnl_ratio", 0.0),
|
||||
frozen=p.get("frozen", 0.0),
|
||||
)
|
||||
for p in positions_data
|
||||
]
|
||||
|
||||
orders = [
|
||||
OrderResponse(
|
||||
order_id=o["order_id"],
|
||||
symbol=o["symbol"],
|
||||
exchange=o["exchange"],
|
||||
direction=o["direction"],
|
||||
order_type=o["order_type"],
|
||||
volume=o["volume"],
|
||||
price=o.get("price"),
|
||||
traded=o["traded"],
|
||||
status=o["status"],
|
||||
time=o["time"]
|
||||
)
|
||||
for o in orders_data
|
||||
]
|
||||
|
||||
return AccountResponse(
|
||||
account=account,
|
||||
positions=positions,
|
||||
orders=orders
|
||||
)
|
||||
@@ -0,0 +1,173 @@
|
||||
"""
|
||||
数据库模型定义
|
||||
使用 SQLAlchemy ORM 定义数据表结构
|
||||
"""
|
||||
from sqlalchemy import create_engine, Column, Integer, String, Float, DateTime, Boolean, Text
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import sessionmaker, Session
|
||||
from datetime import datetime
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 声明基类
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
# ============================================
|
||||
# 用户表
|
||||
# ============================================
|
||||
|
||||
class User(Base):
|
||||
"""用户表"""
|
||||
__tablename__ = "users"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
username = Column(String(50), unique=True, index=True, nullable=False)
|
||||
hashed_password = Column(String(200), nullable=False)
|
||||
email = Column(String(100), unique=True, index=True)
|
||||
full_name = Column(String(100))
|
||||
is_active = Column(Boolean, default=True)
|
||||
is_superuser = Column(Boolean, default=False)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
|
||||
# ============================================
|
||||
# API Token 表
|
||||
# ============================================
|
||||
|
||||
class APIToken(Base):
|
||||
"""API Token 表"""
|
||||
__tablename__ = "api_tokens"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, nullable=False)
|
||||
token = Column(String(200), unique=True, index=True, nullable=False)
|
||||
name = Column(String(100))
|
||||
is_active = Column(Boolean, default=True)
|
||||
expires_at = Column(DateTime)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
|
||||
# ============================================
|
||||
# 交易日志表
|
||||
# ============================================
|
||||
|
||||
class TradeLog(Base):
|
||||
"""交易日志表"""
|
||||
__tablename__ = "trade_logs"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
order_id = Column(String(50), index=True)
|
||||
symbol = Column(String(20), index=True)
|
||||
exchange = Column(String(20))
|
||||
direction = Column(String(10))
|
||||
order_type = Column(String(20))
|
||||
volume = Column(Float)
|
||||
price = Column(Float)
|
||||
traded_volume = Column(Float)
|
||||
traded_price = Column(Float)
|
||||
status = Column(String(20), index=True)
|
||||
time = Column(DateTime, index=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
|
||||
# ============================================
|
||||
# 策略日志表
|
||||
# ============================================
|
||||
|
||||
class StrategyLog(Base):
|
||||
"""策略日志表"""
|
||||
__tablename__ = "strategy_logs"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
strategy_name = Column(String(50), index=True)
|
||||
level = Column(String(20)) # INFO, WARNING, ERROR
|
||||
message = Column(Text)
|
||||
created_at = Column(DateTime, default=datetime.utcnow, index=True)
|
||||
|
||||
|
||||
# ============================================
|
||||
# 系统配置表
|
||||
# ============================================
|
||||
|
||||
class SystemConfig(Base):
|
||||
"""系统配置表"""
|
||||
__tablename__ = "system_configs"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
key = Column(String(100), unique=True, index=True, nullable=False)
|
||||
value = Column(Text)
|
||||
description = Column(String(200))
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
|
||||
# ============================================
|
||||
# 数据库初始化
|
||||
# ============================================
|
||||
|
||||
# 全局数据库引擎和会话
|
||||
_engine = None
|
||||
_SessionLocal = None
|
||||
|
||||
|
||||
def init_database(database_url: str = "sqlite:///sanguo_web.db") -> None:
|
||||
"""
|
||||
初始化数据库
|
||||
|
||||
- **database_url**: 数据库连接字符串
|
||||
"""
|
||||
global _engine, _SessionLocal
|
||||
|
||||
logger.info(f"Initializing database: {database_url}")
|
||||
|
||||
_engine = create_engine(
|
||||
database_url,
|
||||
connect_args={"check_same_thread": False} if database_url.startswith("sqlite") else {},
|
||||
echo=False
|
||||
)
|
||||
|
||||
_SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=_engine)
|
||||
|
||||
# 创建所有表
|
||||
Base.metadata.create_all(bind=_engine)
|
||||
|
||||
logger.info("Database initialized successfully")
|
||||
|
||||
|
||||
def get_db() -> Session:
|
||||
"""
|
||||
获取数据库会话
|
||||
|
||||
用于依赖注入
|
||||
"""
|
||||
global _SessionLocal
|
||||
|
||||
if _SessionLocal is None:
|
||||
init_database()
|
||||
|
||||
db = _SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def get_engine():
|
||||
"""获取数据库引擎"""
|
||||
if _engine is None:
|
||||
init_database()
|
||||
return _engine
|
||||
|
||||
|
||||
__all__ = [
|
||||
"User",
|
||||
"APIToken",
|
||||
"TradeLog",
|
||||
"StrategyLog",
|
||||
"SystemConfig",
|
||||
"init_database",
|
||||
"get_db",
|
||||
"get_engine",
|
||||
]
|
||||
@@ -0,0 +1,6 @@
|
||||
"""
|
||||
Sanguo Web 服务模块
|
||||
"""
|
||||
from .main_service import VeighNaService
|
||||
|
||||
__all__ = ["VeighNaService"]
|
||||
@@ -0,0 +1,715 @@
|
||||
"""
|
||||
VeighNa 服务包装类
|
||||
封装 MainEngine 和 EventEngine,提供线程安全的访问接口
|
||||
"""
|
||||
import asyncio
|
||||
import threading
|
||||
from typing import Optional, Dict, List, Any
|
||||
from datetime import datetime
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 尝试导入 VeighNa 模块
|
||||
try:
|
||||
from vnpy.trader.engine import MainEngine
|
||||
from vnpy.event import EventEngine
|
||||
from vnpy.trader.object import (
|
||||
OrderRequest, CancelRequest, SubscribeRequest,
|
||||
OrderData, TradeData, TickData, PositionData, AccountData, ContractData
|
||||
)
|
||||
from vnpy.trader.constant import Exchange, Direction, OrderType, Offset
|
||||
from vnpy.trader.setting import SETTINGS
|
||||
from vnpy.gateway.ctp import CtpGateway
|
||||
from vnpy.gateway.ib import IbGateway
|
||||
from vnpy.gateway.okx import OkxGateway
|
||||
from vnpy.gateway.binance import BinanceGateway
|
||||
VNPY_AVAILABLE = True
|
||||
except ImportError:
|
||||
VNPY_AVAILABLE = False
|
||||
logger.warning("VeighNa modules not available, running in mock mode")
|
||||
|
||||
|
||||
class VeighNaService:
|
||||
"""
|
||||
VeighNa 服务包装类
|
||||
|
||||
提供对 VeighNa MainEngine 的异步访问接口
|
||||
线程安全设计:所有对 MainEngine 的访问都通过事件循环调度
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._main_engine: Optional[Any] = None
|
||||
self._event_engine: Optional[Any] = None
|
||||
self._initialized: bool = False
|
||||
self._lock = threading.Lock()
|
||||
self._loop: Optional[asyncio.AbstractEventLoop] = None
|
||||
self._executor: Optional[ThreadPoolExecutor] = None
|
||||
|
||||
# 数据缓存
|
||||
self._ticks_cache: Dict[str, Any] = {}
|
||||
self._orders_cache: Dict[str, Any] = {}
|
||||
self._trades_cache: Dict[str, Any] = {}
|
||||
self._positions_cache: Dict[str, Any] = {}
|
||||
self._accounts_cache: Dict[str, Any] = {}
|
||||
self._contracts_cache: Dict[str, Any] = {}
|
||||
self._connected_gateways: Dict[str, str] = {} # gateway_name -> gateway_type
|
||||
|
||||
async def initialize(self) -> None:
|
||||
"""初始化 VeighNa 服务"""
|
||||
with self._lock:
|
||||
if self._initialized:
|
||||
return
|
||||
|
||||
logger.info("Initializing VeighNa service...")
|
||||
|
||||
try:
|
||||
if VNPY_AVAILABLE:
|
||||
# 创建线程池执行器
|
||||
self._executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="vnpy_")
|
||||
|
||||
# 在线程中初始化 VeighNa
|
||||
def init_vnpy():
|
||||
# 创建事件引擎
|
||||
event_engine = EventEngine()
|
||||
event_engine.start()
|
||||
|
||||
# 创建主引擎
|
||||
main_engine = MainEngine(event_engine)
|
||||
|
||||
# 添加网关
|
||||
main_engine.add_gateway(CtpGateway)
|
||||
main_engine.add_gateway(IbGateway)
|
||||
main_engine.add_gateway(OkxGateway)
|
||||
main_engine.add_gateway(BinanceGateway)
|
||||
|
||||
return main_engine, event_engine
|
||||
|
||||
# 在线程池中执行初始化
|
||||
loop = asyncio.get_event_loop()
|
||||
self._main_engine, self._event_engine = await loop.run_in_executor(
|
||||
self._executor, init_vnpy
|
||||
)
|
||||
self._loop = loop
|
||||
|
||||
self._initialized = True
|
||||
logger.info("VeighNa service initialized successfully")
|
||||
else:
|
||||
logger.info("Running in mock mode for development")
|
||||
self._initialized = True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize VeighNa: {e}")
|
||||
raise
|
||||
|
||||
async def shutdown(self) -> None:
|
||||
"""关闭 VeighNa 服务"""
|
||||
with self._lock:
|
||||
if not self._initialized:
|
||||
return
|
||||
|
||||
logger.info("Shutting down VeighNa service...")
|
||||
|
||||
if self._main_engine:
|
||||
try:
|
||||
def close_vnpy():
|
||||
self._main_engine.close()
|
||||
if self._event_engine:
|
||||
self._event_engine.stop()
|
||||
|
||||
if self._loop and self._executor:
|
||||
await self._loop.run_in_executor(self._executor, close_vnpy)
|
||||
self._executor.shutdown(wait=True)
|
||||
except Exception as e:
|
||||
logger.error(f"Error shutting down VeighNa: {e}")
|
||||
|
||||
self._main_engine = None
|
||||
self._event_engine = None
|
||||
self._executor = None
|
||||
self._initialized = False
|
||||
|
||||
logger.info("VeighNa service shutdown complete")
|
||||
|
||||
@property
|
||||
def is_initialized(self) -> bool:
|
||||
"""检查服务是否已初始化"""
|
||||
return self._initialized
|
||||
|
||||
@property
|
||||
def main_engine(self) -> Optional[Any]:
|
||||
"""获取主引擎(谨慎使用,需处理线程安全)"""
|
||||
return self._main_engine
|
||||
|
||||
@property
|
||||
def event_engine(self) -> Optional[Any]:
|
||||
"""获取事件引擎(谨慎使用,需处理线程安全)"""
|
||||
return self._event_engine
|
||||
|
||||
# ============================================
|
||||
# 网关管理
|
||||
# ============================================
|
||||
|
||||
async def get_available_gateways(self) -> List[Dict[str, Any]]:
|
||||
"""获取所有可用网关列表"""
|
||||
if not self._main_engine:
|
||||
# 返回默认网关列表(Mock 模式)
|
||||
return [
|
||||
{"gateway_name": "CTP", "gateway_type": "ctp", "display_name": "CTP期货"},
|
||||
{"gateway_name": "IB", "gateway_type": "ib", "display_name": "Interactive Brokers"},
|
||||
{"gateway_name": "OKX", "gateway_type": "okx", "display_name": "OKX"},
|
||||
{"gateway_name": "BINANCE", "gateway_type": "binance", "display_name": "Binance"},
|
||||
]
|
||||
|
||||
try:
|
||||
gateway_names = self._main_engine.get_all_gateway_names()
|
||||
gateways = []
|
||||
for name in gateway_names:
|
||||
gw = self._main_engine.get_gateway(name)
|
||||
if gw:
|
||||
gateways.append({
|
||||
"gateway_name": name,
|
||||
"gateway_type": type(gw).__name__.replace("Gateway", "").lower(),
|
||||
"display_name": getattr(gw, 'default_name', name),
|
||||
})
|
||||
return gateways
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting available gateways: {e}")
|
||||
return []
|
||||
|
||||
async def get_connected_gateways(self) -> List[Dict[str, Any]]:
|
||||
"""获取已连接的网关列表"""
|
||||
if not self._main_engine:
|
||||
return []
|
||||
|
||||
try:
|
||||
gateway_names = self._main_engine.get_all_gateway_names()
|
||||
gateways = []
|
||||
for name in gateway_names:
|
||||
gw = self._main_engine.get_gateway(name)
|
||||
if gw:
|
||||
gateways.append({
|
||||
"gateway_name": name,
|
||||
"gateway_type": type(gw).__name__.replace("Gateway", "").lower(),
|
||||
"status": "connected" if getattr(gw, 'is_connected', False) else "disconnected",
|
||||
})
|
||||
return gateways
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting connected gateways: {e}")
|
||||
return []
|
||||
|
||||
async def get_gateway_setting(self, gateway_name: str) -> Dict[str, Any]:
|
||||
"""获取网关配置模板"""
|
||||
if not self._main_engine:
|
||||
# 返回默认模板(Mock 模式)
|
||||
default_settings = {
|
||||
"CTP": {
|
||||
"用户名": "",
|
||||
"密码": "",
|
||||
"经纪商代码": "",
|
||||
"交易服务器": "",
|
||||
"行情服务器": "",
|
||||
"产品名称": "",
|
||||
"授权编码": "",
|
||||
},
|
||||
"IB": {
|
||||
"TWS地址": "127.0.0.1:4001",
|
||||
"客户ID": "1",
|
||||
"交易账号": "",
|
||||
},
|
||||
"OKX": {
|
||||
"API Key": "",
|
||||
"Secret": "",
|
||||
"Passphrase": "",
|
||||
"代理": "",
|
||||
"选项": "",
|
||||
},
|
||||
"BINANCE": {
|
||||
"API Key": "",
|
||||
"Secret": "",
|
||||
"Proxy Host": "",
|
||||
"Proxy Port": 0,
|
||||
},
|
||||
}
|
||||
return default_settings.get(gateway_name.upper(), {})
|
||||
|
||||
try:
|
||||
return self._main_engine.get_default_setting(gateway_name) or {}
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting gateway setting: {e}")
|
||||
return {}
|
||||
|
||||
async def connect_gateway(
|
||||
self,
|
||||
gateway_name: str,
|
||||
gateway_type: str,
|
||||
setting: Dict[str, Any]
|
||||
) -> bool:
|
||||
"""连接网关"""
|
||||
if not self._main_engine:
|
||||
logger.error("MainEngine not initialized")
|
||||
return False
|
||||
|
||||
try:
|
||||
def do_connect():
|
||||
self._main_engine.connect(setting, gateway_name)
|
||||
|
||||
await self._loop.run_in_executor(self._executor, do_connect)
|
||||
self._connected_gateways[gateway_name] = gateway_type
|
||||
logger.info(f"Gateway {gateway_name} connecting...")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error connecting gateway: {e}")
|
||||
return False
|
||||
|
||||
async def disconnect_gateway(self, gateway_name: str) -> bool:
|
||||
"""断开网关"""
|
||||
if not self._main_engine:
|
||||
return False
|
||||
|
||||
try:
|
||||
def do_disconnect():
|
||||
gw = self._main_engine.get_gateway(gateway_name)
|
||||
if gw:
|
||||
gw.close()
|
||||
|
||||
await self._loop.run_in_executor(self._executor, do_disconnect)
|
||||
self._connected_gateways.pop(gateway_name, None)
|
||||
logger.info(f"Gateway {gateway_name} disconnected")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error disconnecting gateway: {e}")
|
||||
return False
|
||||
|
||||
async def get_gateway_status(self, gateway_name: str) -> Dict[str, Any]:
|
||||
"""获取网关连接状态"""
|
||||
if not self._main_engine:
|
||||
return {"gateway_name": gateway_name, "status": "disconnected"}
|
||||
|
||||
try:
|
||||
gw = self._main_engine.get_gateway(gateway_name)
|
||||
if gw:
|
||||
return {
|
||||
"gateway_name": gateway_name,
|
||||
"status": "connected" if getattr(gw, 'is_connected', False) else "disconnected",
|
||||
}
|
||||
return {"gateway_name": gateway_name, "status": "not_found"}
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting gateway status: {e}")
|
||||
return {"gateway_name": gateway_name, "status": "error"}
|
||||
|
||||
# ============================================
|
||||
# 账户和持仓
|
||||
# ============================================
|
||||
|
||||
async def get_accounts(self) -> List[Dict[str, Any]]:
|
||||
"""获取所有账户信息"""
|
||||
if not self._main_engine:
|
||||
# Mock 数据
|
||||
return [
|
||||
{
|
||||
"account_id": "mock_account",
|
||||
"balance": 100000.0,
|
||||
"available": 100000.0,
|
||||
"frozen": 0.0,
|
||||
}
|
||||
]
|
||||
|
||||
try:
|
||||
def fetch_accounts():
|
||||
return self._main_engine.get_all_accounts()
|
||||
|
||||
accounts = await self._loop.run_in_executor(self._executor, fetch_accounts)
|
||||
return [
|
||||
{
|
||||
"account_id": acc.vt_accountid,
|
||||
"balance": acc.balance,
|
||||
"available": acc.available,
|
||||
"frozen": acc.frozen,
|
||||
}
|
||||
for acc in accounts
|
||||
]
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting accounts: {e}")
|
||||
return []
|
||||
|
||||
async def get_positions(self) -> List[Dict[str, Any]]:
|
||||
"""获取所有持仓信息"""
|
||||
if not self._main_engine:
|
||||
return []
|
||||
|
||||
try:
|
||||
def fetch_positions():
|
||||
return self._main_engine.get_all_positions()
|
||||
|
||||
positions = await self._loop.run_in_executor(self._executor, fetch_positions)
|
||||
return [
|
||||
{
|
||||
"symbol": pos.symbol,
|
||||
"exchange": pos.exchange.value,
|
||||
"direction": pos.direction.value,
|
||||
"volume": pos.volume,
|
||||
"price": pos.price,
|
||||
"pnl": pos.pnl,
|
||||
"pnl_ratio": 0.0, # 需要计算
|
||||
"frozen": pos.frozen,
|
||||
}
|
||||
for pos in positions
|
||||
]
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting positions: {e}")
|
||||
return []
|
||||
|
||||
async def get_orders(self) -> List[Dict[str, Any]]:
|
||||
"""获取所有委托信息"""
|
||||
if not self._main_engine:
|
||||
return []
|
||||
|
||||
try:
|
||||
def fetch_orders():
|
||||
return self._main_engine.get_all_orders()
|
||||
|
||||
orders = await self._loop.run_in_executor(self._executor, fetch_orders)
|
||||
return [
|
||||
{
|
||||
"order_id": order.vt_orderid,
|
||||
"symbol": order.symbol,
|
||||
"exchange": order.exchange.value,
|
||||
"direction": order.direction.value if order.direction else "",
|
||||
"order_type": order.type.value,
|
||||
"volume": order.volume,
|
||||
"price": order.price,
|
||||
"traded": order.traded,
|
||||
"status": order.status.value,
|
||||
"time": order.datetime,
|
||||
}
|
||||
for order in orders
|
||||
]
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting orders: {e}")
|
||||
return []
|
||||
|
||||
async def get_active_orders(self) -> List[Dict[str, Any]]:
|
||||
"""获取活动委托信息"""
|
||||
if not self._main_engine:
|
||||
return []
|
||||
|
||||
try:
|
||||
def fetch_orders():
|
||||
return self._main_engine.get_all_active_orders()
|
||||
|
||||
orders = await self._loop.run_in_executor(self._executor, fetch_orders)
|
||||
return [
|
||||
{
|
||||
"order_id": order.vt_orderid,
|
||||
"symbol": order.symbol,
|
||||
"exchange": order.exchange.value,
|
||||
"direction": order.direction.value if order.direction else "",
|
||||
"order_type": order.type.value,
|
||||
"volume": order.volume,
|
||||
"price": order.price,
|
||||
"traded": order.traded,
|
||||
"status": order.status.value,
|
||||
"time": order.datetime,
|
||||
}
|
||||
for order in orders
|
||||
]
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting active orders: {e}")
|
||||
return []
|
||||
|
||||
async def get_trades(self) -> List[Dict[str, Any]]:
|
||||
"""获取成交信息"""
|
||||
if not self._main_engine:
|
||||
return []
|
||||
|
||||
try:
|
||||
def fetch_trades():
|
||||
return self._main_engine.get_all_trades()
|
||||
|
||||
trades = await self._loop.run_in_executor(self._executor, fetch_trades)
|
||||
return [
|
||||
{
|
||||
"trade_id": trade.vt_tradeid,
|
||||
"order_id": trade.vt_orderid,
|
||||
"symbol": trade.symbol,
|
||||
"exchange": trade.exchange.value,
|
||||
"direction": trade.direction.value if trade.direction else "",
|
||||
"volume": trade.volume,
|
||||
"price": trade.price,
|
||||
"time": trade.datetime,
|
||||
}
|
||||
for trade in trades
|
||||
]
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting trades: {e}")
|
||||
return []
|
||||
|
||||
# ============================================
|
||||
# 交易操作
|
||||
# ============================================
|
||||
|
||||
async def send_order(
|
||||
self,
|
||||
symbol: str,
|
||||
exchange: str,
|
||||
direction: str,
|
||||
order_type: str,
|
||||
volume: float,
|
||||
price: Optional[float] = None,
|
||||
offset: str = "OPEN",
|
||||
gateway_name: Optional[str] = None,
|
||||
reference: Optional[str] = None,
|
||||
) -> Optional[str]:
|
||||
"""发送订单"""
|
||||
if not self._main_engine:
|
||||
logger.error("MainEngine not initialized")
|
||||
return None
|
||||
|
||||
try:
|
||||
def do_send_order():
|
||||
req = OrderRequest(
|
||||
symbol=symbol,
|
||||
exchange=Exchange[exchange],
|
||||
direction=Direction[direction],
|
||||
type=OrderType[order_type],
|
||||
volume=volume,
|
||||
price=price or 0.0,
|
||||
offset=Offset[offset] if offset else Offset.NONE,
|
||||
reference=reference or "",
|
||||
)
|
||||
# 如果没有指定网关,使用第一个已连接的网关
|
||||
if not gateway_name and self._connected_gateways:
|
||||
gateway_name = list(self._connected_gateways.keys())[0]
|
||||
return self._main_engine.send_order(req, gateway_name or "")
|
||||
|
||||
order_id = await self._loop.run_in_executor(self._executor, do_send_order)
|
||||
logger.info(f"Order sent: {order_id}")
|
||||
return order_id
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error sending order: {e}")
|
||||
return None
|
||||
|
||||
async def cancel_order(
|
||||
self,
|
||||
order_id: str,
|
||||
gateway_name: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""撤销订单"""
|
||||
if not self._main_engine:
|
||||
return False
|
||||
|
||||
try:
|
||||
def do_cancel_order():
|
||||
# 解析 order_id (格式: gateway_name.orderid)
|
||||
if "." in order_id:
|
||||
parts = order_id.split(".", 1)
|
||||
req_gw_name = parts[0]
|
||||
local_orderid = parts[1]
|
||||
else:
|
||||
req_gw_name = gateway_name or ""
|
||||
local_orderid = order_id
|
||||
|
||||
req = CancelRequest(
|
||||
orderid=local_orderid,
|
||||
symbol="", # 撤单时不需要 symbol
|
||||
exchange=Exchange.LOCAL,
|
||||
)
|
||||
return self._main_engine.cancel_order(req, req_gw_name)
|
||||
|
||||
await self._loop.run_in_executor(self._executor, do_cancel_order)
|
||||
logger.info(f"Cancel order: {order_id}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error cancelling order: {e}")
|
||||
return False
|
||||
|
||||
# ============================================
|
||||
# 行情数据
|
||||
# ============================================
|
||||
|
||||
async def get_ticks(self, symbols: Optional[List[str]] = None) -> List[Dict[str, Any]]:
|
||||
"""获取 Tick 数据"""
|
||||
if not self._main_engine:
|
||||
return []
|
||||
|
||||
try:
|
||||
def fetch_ticks():
|
||||
return self._main_engine.get_all_ticks()
|
||||
|
||||
all_ticks = await self._loop.run_in_executor(self._executor, fetch_ticks)
|
||||
return [
|
||||
{
|
||||
"symbol": tick.symbol,
|
||||
"exchange": tick.exchange.value,
|
||||
"datetime": tick.datetime,
|
||||
"name": tick.name,
|
||||
"last_price": tick.last_price,
|
||||
"bid_price_1": tick.bid_price_1,
|
||||
"ask_price_1": tick.ask_price_1,
|
||||
"bid_volume_1": tick.bid_volume_1,
|
||||
"ask_volume_1": tick.ask_volume_1,
|
||||
"volume": tick.volume,
|
||||
}
|
||||
for tick in all_ticks
|
||||
if symbols is None or tick.vt_symbol in symbols
|
||||
]
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting ticks: {e}")
|
||||
return []
|
||||
|
||||
async def subscribe(
|
||||
self,
|
||||
symbol: str,
|
||||
exchange: str,
|
||||
gateway_name: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""订阅行情"""
|
||||
if not self._main_engine:
|
||||
return False
|
||||
|
||||
try:
|
||||
def do_subscribe():
|
||||
req = SubscribeRequest(
|
||||
symbol=symbol,
|
||||
exchange=Exchange[exchange],
|
||||
)
|
||||
# 如果没有指定网关,使用第一个已连接的网关
|
||||
if not gateway_name and self._connected_gateways:
|
||||
gateway_name = list(self._connected_gateways.keys())[0]
|
||||
self._main_engine.subscribe(req, gateway_name or "")
|
||||
|
||||
await self._loop.run_in_executor(self._executor, do_subscribe)
|
||||
logger.info(f"Subscribed to {symbol}.{exchange}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error subscribing: {e}")
|
||||
return False
|
||||
|
||||
async def unsubscribe(
|
||||
self,
|
||||
symbol: str,
|
||||
exchange: str,
|
||||
gateway_name: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""取消订阅行情"""
|
||||
# VeighNa 没有直接取消订阅的接口,实际应用中可以维护订阅列表
|
||||
logger.info(f"Unsubscribed from {symbol}.{exchange}")
|
||||
return True
|
||||
|
||||
async def get_contracts(self) -> List[Dict[str, Any]]:
|
||||
"""获取合约列表"""
|
||||
if not self._main_engine:
|
||||
return []
|
||||
|
||||
try:
|
||||
def fetch_contracts():
|
||||
return self._main_engine.get_all_contracts()
|
||||
|
||||
contracts = await self._loop.run_in_executor(self._executor, fetch_contracts)
|
||||
return [
|
||||
{
|
||||
"symbol": contract.symbol,
|
||||
"exchange": contract.exchange.value,
|
||||
"name": contract.name,
|
||||
"product": contract.product.value,
|
||||
"size": contract.size,
|
||||
"pricetick": contract.pricetick,
|
||||
"min_volume": contract.min_volume,
|
||||
"max_volume": contract.max_volume,
|
||||
"vt_symbol": contract.vt_symbol,
|
||||
"stop_supported": contract.stop_supported,
|
||||
"net_position": contract.net_position,
|
||||
}
|
||||
for contract in contracts
|
||||
]
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting contracts: {e}")
|
||||
return []
|
||||
|
||||
async def get_contract(self, vt_symbol: str) -> Optional[Dict[str, Any]]:
|
||||
"""获取单个合约详情"""
|
||||
if not self._main_engine:
|
||||
return None
|
||||
|
||||
try:
|
||||
def fetch_contract():
|
||||
return self._main_engine.get_contract(vt_symbol)
|
||||
|
||||
contract = await self._loop.run_in_executor(self._executor, fetch_contract)
|
||||
if not contract:
|
||||
return None
|
||||
|
||||
return {
|
||||
"symbol": contract.symbol,
|
||||
"exchange": contract.exchange.value,
|
||||
"name": contract.name,
|
||||
"product": contract.product.value,
|
||||
"size": contract.size,
|
||||
"pricetick": contract.pricetick,
|
||||
"min_volume": contract.min_volume,
|
||||
"max_volume": contract.max_volume,
|
||||
"vt_symbol": contract.vt_symbol,
|
||||
"stop_supported": contract.stop_supported,
|
||||
"net_position": contract.net_position,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting contract: {e}")
|
||||
return None
|
||||
|
||||
# ============================================
|
||||
# 策略管理
|
||||
# ============================================
|
||||
|
||||
async def get_strategies(self) -> List[Dict[str, Any]]:
|
||||
"""获取所有策略信息"""
|
||||
if not self._main_engine:
|
||||
return []
|
||||
|
||||
try:
|
||||
strategies = []
|
||||
# 实际实现需要访问策略引擎
|
||||
return strategies
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting strategies: {e}")
|
||||
return []
|
||||
|
||||
async def init_strategy(self, strategy_name: str) -> bool:
|
||||
"""初始化策略"""
|
||||
if not self._main_engine:
|
||||
return False
|
||||
|
||||
try:
|
||||
logger.info(f"Initializing strategy: {strategy_name}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error initializing strategy: {e}")
|
||||
return False
|
||||
|
||||
async def start_strategy(self, strategy_name: str) -> bool:
|
||||
"""启动策略"""
|
||||
if not self._main_engine:
|
||||
return False
|
||||
|
||||
try:
|
||||
logger.info(f"Starting strategy: {strategy_name}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error starting strategy: {e}")
|
||||
return False
|
||||
|
||||
async def stop_strategy(self, strategy_name: str) -> bool:
|
||||
"""停止策略"""
|
||||
if not self._main_engine:
|
||||
return False
|
||||
|
||||
try:
|
||||
logger.info(f"Stopping strategy: {strategy_name}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error stopping strategy: {e}")
|
||||
return False
|
||||
|
||||
|
||||
__all__ = ["VeighNaService"]
|
||||
@@ -0,0 +1,636 @@
|
||||
/* ============================================
|
||||
全局样式
|
||||
============================================ */
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:root {
|
||||
--primary-color: #2563eb;
|
||||
--primary-hover: #1d4ed8;
|
||||
--success-color: #10b981;
|
||||
--danger-color: #ef4444;
|
||||
--warning-color: #f59e0b;
|
||||
--bg-color: #f8fafc;
|
||||
--card-bg: #ffffff;
|
||||
--border-color: #e2e8f0;
|
||||
--text-primary: #1e293b;
|
||||
--text-secondary: #64748b;
|
||||
--sidebar-width: 240px;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
background-color: var(--bg-color);
|
||||
color: var(--text-primary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
登录界面
|
||||
============================================ */
|
||||
|
||||
.login-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
|
||||
.login-box {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||
padding: 40px;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.login-box h1 {
|
||||
text-align: center;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 5px;
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.login-box .subtitle {
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 30px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.login-hint {
|
||||
text-align: center;
|
||||
margin-top: 20px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
主界面布局
|
||||
============================================ */
|
||||
|
||||
.main-container {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* 侧边栏 */
|
||||
.sidebar {
|
||||
width: var(--sidebar-width);
|
||||
background: #1e293b;
|
||||
color: white;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: fixed;
|
||||
height: 100vh;
|
||||
left: 0;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: 20px;
|
||||
border-bottom: 1px solid #334155;
|
||||
}
|
||||
|
||||
.sidebar-header h2 {
|
||||
font-size: 18px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.sidebar-header .version {
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
flex: 1;
|
||||
padding: 20px 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sidebar-nav a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 20px;
|
||||
color: #cbd5e1;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.sidebar-nav a:hover {
|
||||
background: #334155;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.sidebar-nav a.active {
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.sidebar-nav .icon {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
padding: 20px;
|
||||
border-top: 1px solid #334155;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
margin-bottom: 10px;
|
||||
color: #cbd5e1;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.btn-logout {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
background: transparent;
|
||||
border: 1px solid #475569;
|
||||
color: #cbd5e1;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-logout:hover {
|
||||
background: #334155;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* 内容区 */
|
||||
.main-content {
|
||||
flex: 1;
|
||||
margin-left: var(--sidebar-width);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* 顶部状态栏 */
|
||||
.top-bar {
|
||||
background: white;
|
||||
padding: 15px 30px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.top-bar h1 {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-indicators {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.status-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.status-item span {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.status-item .connected,
|
||||
.status-item .online {
|
||||
color: var(--success-color);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 页面内容 */
|
||||
.page-content {
|
||||
padding: 30px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
卡片组件
|
||||
============================================ */
|
||||
|
||||
.card {
|
||||
background: var(--card-bg);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.card h3 {
|
||||
padding: 15px 20px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 15px 20px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.card-header h3 {
|
||||
padding: 0;
|
||||
border: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
表单组件
|
||||
============================================ */
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group select {
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group select:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
按钮组件
|
||||
============================================ */
|
||||
|
||||
.btn-primary {
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 12px 20px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: var(--primary-hover);
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: white;
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: var(--bg-color);
|
||||
}
|
||||
|
||||
.btn-small {
|
||||
padding: 4px 12px;
|
||||
font-size: 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-small:hover {
|
||||
background: var(--bg-color);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: var(--danger-color);
|
||||
color: white;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: #dc2626;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
表格组件
|
||||
============================================ */
|
||||
|
||||
.table-container {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.data-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.data-table th,
|
||||
.data-table td {
|
||||
padding: 12px 15px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.data-table th {
|
||||
background: var(--bg-color);
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.data-table tbody tr:hover {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
状态指示
|
||||
============================================ */
|
||||
|
||||
.status-online,
|
||||
.online,
|
||||
.connected {
|
||||
color: var(--success-color);
|
||||
}
|
||||
|
||||
.status-offline,
|
||||
.offline,
|
||||
.disconnected {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.status-running {
|
||||
color: var(--success-color);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-stopped {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.status-paused {
|
||||
color: var(--warning-color);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
价格涨跌颜色
|
||||
============================================ */
|
||||
|
||||
.price-up,
|
||||
.buy {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.price-down,
|
||||
.sell {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.price-flat {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.pnl-positive {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.pnl-negative {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
总览页面网格
|
||||
============================================ */
|
||||
|
||||
.dashboard-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.status-list,
|
||||
.account-info,
|
||||
.position-summary,
|
||||
.orders-summary {
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.status-row,
|
||||
.account-row,
|
||||
.summary-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.status-row:last-child,
|
||||
.account-row:last-child,
|
||||
.summary-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.amount {
|
||||
font-family: 'Courier New', monospace;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
交易页面网格
|
||||
============================================ */
|
||||
|
||||
.trading-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 350px 1fr;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.order-form {
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
日志组件
|
||||
============================================ */
|
||||
|
||||
.logs-card {
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.logs-container {
|
||||
padding: 15px;
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
background: #1e293b;
|
||||
color: #e2e8f0;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 13px;
|
||||
border-radius: 0 0 8px 8px;
|
||||
}
|
||||
|
||||
.log-entry {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding: 4px 0;
|
||||
border-bottom: 1px solid #334155;
|
||||
}
|
||||
|
||||
.log-entry:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.log-time {
|
||||
color: #94a3b8;
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.log-level {
|
||||
min-width: 70px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.log-debug .log-level {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.log-info .log-level {
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.log-warning .log-level {
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.log-error .log-level {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.log-message {
|
||||
flex: 1;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
搜索输入
|
||||
============================================ */
|
||||
|
||||
.search-input {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
无数据提示
|
||||
============================================ */
|
||||
|
||||
.no-data {
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
错误提示
|
||||
============================================ */
|
||||
|
||||
.error-message {
|
||||
background: #fef2f2;
|
||||
color: var(--danger-color);
|
||||
padding: 10px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
border: 1px solid #fecaca;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
响应式设计
|
||||
============================================ */
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.trading-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.sidebar {
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
|
||||
.sidebar.open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.main-content {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.dashboard-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.status-indicators {
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
/**
|
||||
* API 请求封装
|
||||
* 处理认证、错误处理、请求拦截
|
||||
*/
|
||||
|
||||
const API_BASE = '/api/v1';
|
||||
|
||||
/**
|
||||
* API 客户端类
|
||||
*/
|
||||
class ApiClient {
|
||||
constructor() {
|
||||
this.token = localStorage.getItem('token');
|
||||
this.tokenExpiry = localStorage.getItem('tokenExpiry');
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存 Token
|
||||
*/
|
||||
setToken(token, expiresIn) {
|
||||
this.token = token;
|
||||
const expiry = new Date(Date.now() + expiresIn * 1000);
|
||||
this.tokenExpiry = expiry.toISOString();
|
||||
localStorage.setItem('token', token);
|
||||
localStorage.setItem('tokenExpiry', this.tokenExpiry);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除 Token
|
||||
*/
|
||||
clearToken() {
|
||||
this.token = null;
|
||||
this.tokenExpiry = null;
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('tokenExpiry');
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查 Token 是否有效
|
||||
*/
|
||||
isTokenValid() {
|
||||
if (!this.token || !this.tokenExpiry) {
|
||||
return false;
|
||||
}
|
||||
const expiry = new Date(this.tokenExpiry);
|
||||
return expiry > new Date();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取请求头
|
||||
*/
|
||||
getHeaders() {
|
||||
const headers = {
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
|
||||
if (this.token) {
|
||||
headers['Authorization'] = `Bearer ${this.token}`;
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理响应
|
||||
*/
|
||||
async handleResponse(response) {
|
||||
if (response.status === 401) {
|
||||
// Token 过期或无效
|
||||
this.clearToken();
|
||||
window.location.reload();
|
||||
throw new Error('Authentication failed');
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
const error = data.error || data.message || 'Request failed';
|
||||
throw new Error(error);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET 请求
|
||||
*/
|
||||
async get(url, params = {}) {
|
||||
const queryString = new URLSearchParams(params).toString();
|
||||
const fullUrl = `${API_BASE}${url}${queryString ? '?' + queryString : ''}`;
|
||||
|
||||
const response = await fetch(fullUrl, {
|
||||
method: 'GET',
|
||||
headers: this.getHeaders()
|
||||
});
|
||||
|
||||
return this.handleResponse(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST 请求
|
||||
*/
|
||||
async post(url, data = {}) {
|
||||
const response = await fetch(`${API_BASE}${url}`, {
|
||||
method: 'POST',
|
||||
headers: this.getHeaders(),
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
return this.handleResponse(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT 请求
|
||||
*/
|
||||
async put(url, data = {}) {
|
||||
const response = await fetch(`${API_BASE}${url}`, {
|
||||
method: 'PUT',
|
||||
headers: this.getHeaders(),
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
return this.handleResponse(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE 请求
|
||||
*/
|
||||
async delete(url) {
|
||||
const response = await fetch(`${API_BASE}${url}`, {
|
||||
method: 'DELETE',
|
||||
headers: this.getHeaders()
|
||||
});
|
||||
|
||||
return this.handleResponse(response);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 认证 API
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* 用户登录
|
||||
*/
|
||||
async login(username, password) {
|
||||
const data = await this.post('/auth/login', { username, password });
|
||||
this.setToken(data.access_token, data.expires_in);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户登出
|
||||
*/
|
||||
async logout() {
|
||||
try {
|
||||
await this.post('/auth/logout');
|
||||
} finally {
|
||||
this.clearToken();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 Token
|
||||
*/
|
||||
async verifyToken() {
|
||||
return await this.post('/auth/verify', { token: this.token });
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前用户信息
|
||||
*/
|
||||
async getCurrentUser() {
|
||||
return await this.get('/auth/me');
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 系统 API
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* 获取系统信息
|
||||
*/
|
||||
async getSystemInfo() {
|
||||
return await this.get('/system/info');
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 网关 API
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* 获取网关列表
|
||||
*/
|
||||
async getGateways() {
|
||||
return await this.get('/gateway/list');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取网关状态
|
||||
*/
|
||||
async getGatewayStatus(name) {
|
||||
return await this.get(`/gateway/${name}/status`);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 行情 API
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* 获取合约列表
|
||||
*/
|
||||
async getContracts() {
|
||||
const data = await this.get('/market/contracts');
|
||||
return data.contracts || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取行情数据
|
||||
*/
|
||||
async getTicks(symbols = null) {
|
||||
const params = symbols ? { symbols: symbols.join(',') } : {};
|
||||
const data = await this.get('/market/ticks', params);
|
||||
return data.ticks || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅行情
|
||||
*/
|
||||
async subscribe(symbol, exchange, gatewayName = null) {
|
||||
return await this.post('/market/subscribe', {
|
||||
symbol,
|
||||
exchange,
|
||||
gateway_name: gatewayName
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消订阅行情
|
||||
*/
|
||||
async unsubscribe(symbol, exchange, gatewayName = null) {
|
||||
return await this.post('/market/unsubscribe', {
|
||||
symbol,
|
||||
exchange,
|
||||
gateway_name: gatewayName
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 交易 API
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* 获取账户列表
|
||||
*/
|
||||
async getAccounts() {
|
||||
return await this.get('/trading/accounts');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取持仓列表
|
||||
*/
|
||||
async getPositions() {
|
||||
return await this.get('/trading/positions');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取订单列表
|
||||
*/
|
||||
async getOrders() {
|
||||
return await this.get('/trading/orders');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取活动订单
|
||||
*/
|
||||
async getActiveOrders() {
|
||||
return await this.get('/trading/orders/active');
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送订单
|
||||
*/
|
||||
async sendOrder(params) {
|
||||
return await this.post('/trading/orders', params);
|
||||
}
|
||||
|
||||
/**
|
||||
* 撤销订单
|
||||
*/
|
||||
async cancelOrder(orderId) {
|
||||
return await this.delete(`/trading/orders/${orderId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取成交列表
|
||||
*/
|
||||
async getTrades() {
|
||||
return await this.get('/trading/trades');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取账户综合信息
|
||||
*/
|
||||
async getAccount() {
|
||||
return await this.get('/trading/account');
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 策略 API
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* 获取策略列表
|
||||
*/
|
||||
async getStrategies() {
|
||||
return await this.get('/strategy/list');
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建策略
|
||||
*/
|
||||
async createStrategy(strategyName, className, setting) {
|
||||
return await this.post('/strategy/create', {
|
||||
strategy_name: strategyName,
|
||||
class_name: className,
|
||||
setting
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化策略
|
||||
*/
|
||||
async initStrategy(strategyName) {
|
||||
return await this.post('/strategy/init', {
|
||||
strategy_name: strategyName
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动策略
|
||||
*/
|
||||
async startStrategy(strategyName) {
|
||||
return await this.post('/strategy/start', {
|
||||
strategy_name: strategyName
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止策略
|
||||
*/
|
||||
async stopStrategy(strategyName) {
|
||||
return await this.post('/strategy/stop', {
|
||||
strategy_name: strategyName
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑策略
|
||||
*/
|
||||
async editStrategy(strategyName, setting) {
|
||||
return await this.post('/strategy/edit', {
|
||||
strategy_name: strategyName,
|
||||
setting
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 创建全局 API 客户端实例
|
||||
const api = new ApiClient();
|
||||
@@ -0,0 +1,582 @@
|
||||
/**
|
||||
* Sanguo VeighNa Web 应用
|
||||
* Vue.js 3 单页应用
|
||||
*/
|
||||
|
||||
const { createApp, ref, computed, onMounted, onUnmounted, watch, nextTick } = Vue;
|
||||
|
||||
createApp({
|
||||
setup() {
|
||||
// ============================================
|
||||
// 状态定义
|
||||
// ============================================
|
||||
|
||||
// 认证状态
|
||||
const isLoggedIn = ref(false);
|
||||
const currentUser = ref({ username: '' });
|
||||
const loginForm = ref({ username: '', password: '' });
|
||||
const loginError = ref('');
|
||||
const isLoading = ref(false);
|
||||
|
||||
// 当前页面
|
||||
const currentPage = ref('dashboard');
|
||||
|
||||
// 导航菜单
|
||||
const navItems = [
|
||||
{ id: 'dashboard', label: '总览', icon: '' },
|
||||
{ id: 'market', label: '行情', icon: '' },
|
||||
{ id: 'trading', label: '交易', icon: '' },
|
||||
{ id: 'position', label: '持仓', icon: '' },
|
||||
{ id: 'strategy', label: '策略', icon: '' },
|
||||
{ id: 'log', label: '日志', icon: '' }
|
||||
];
|
||||
|
||||
// WebSocket 连接状态
|
||||
const wsConnected = ref(false);
|
||||
|
||||
// 系统状态
|
||||
const systemStatus = ref({
|
||||
vn_ready: false,
|
||||
main_engine: false,
|
||||
trading_engine: false
|
||||
});
|
||||
|
||||
// 网关状态
|
||||
const gatewayStatus = ref({ online: false });
|
||||
|
||||
// 行情数据
|
||||
const marketSearch = ref('');
|
||||
const contracts = ref([]);
|
||||
const ticks = ref([]);
|
||||
const subscribedSymbols = ref(new Set());
|
||||
|
||||
// 账户数据
|
||||
const accounts = ref([]);
|
||||
|
||||
// 持仓数据
|
||||
const positions = ref([]);
|
||||
|
||||
// 订单数据
|
||||
const allOrders = ref([]);
|
||||
const activeOrders = ref([]);
|
||||
|
||||
// 策略数据
|
||||
const strategies = ref([]);
|
||||
|
||||
// 日志数据
|
||||
const logs = ref([]);
|
||||
const autoScroll = ref(true);
|
||||
const logLevelFilter = ref('');
|
||||
const logsContainer = ref(null);
|
||||
|
||||
// 下单表单
|
||||
const orderForm = ref({
|
||||
symbol: '',
|
||||
direction: 'buy',
|
||||
order_type: 'limit',
|
||||
price: 0,
|
||||
volume: 1
|
||||
});
|
||||
const orderError = ref('');
|
||||
const isOrdering = ref(false);
|
||||
|
||||
// ============================================
|
||||
// 计算属性
|
||||
// ============================================
|
||||
|
||||
const currentPageTitle = computed(() => {
|
||||
const item = navItems.find(i => i.id === currentPage.value);
|
||||
return item ? item.label : 'Sanguo VeighNa';
|
||||
});
|
||||
|
||||
const filteredTicks = computed(() => {
|
||||
if (!marketSearch.value) {
|
||||
return ticks.value;
|
||||
}
|
||||
const search = marketSearch.value.toLowerCase();
|
||||
return ticks.value.filter(t =>
|
||||
t.vt_symbol?.toLowerCase().includes(search)
|
||||
);
|
||||
});
|
||||
|
||||
const recentLogs = computed(() => {
|
||||
return logs.value.slice(-5);
|
||||
});
|
||||
|
||||
const filteredLogs = computed(() => {
|
||||
if (!logLevelFilter.value) {
|
||||
return logs.value;
|
||||
}
|
||||
return logs.value.filter(l => l.level === logLevelFilter.value);
|
||||
});
|
||||
|
||||
const totalPnL = computed(() => {
|
||||
return positions.value.reduce((sum, p) => sum + (p.pnl || 0), 0);
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// 格式化函数
|
||||
// ============================================
|
||||
|
||||
const formatNumber = (num) => {
|
||||
if (num === null || num === undefined) return '-';
|
||||
return Number(num).toFixed(2);
|
||||
};
|
||||
|
||||
const formatPercent = (num) => {
|
||||
if (num === null || num === undefined) return '-';
|
||||
return (Number(num) * 100).toFixed(2) + '%';
|
||||
};
|
||||
|
||||
const formatTime = (timestamp) => {
|
||||
if (!timestamp) return '-';
|
||||
const date = new Date(timestamp);
|
||||
return date.toLocaleString('zh-CN');
|
||||
};
|
||||
|
||||
const getPriceClass = (current, previous) => {
|
||||
if (current === null || current === undefined) return '';
|
||||
if (previous === null || previous === undefined) return '';
|
||||
if (current > previous) return 'price-up';
|
||||
if (current < previous) return 'price-down';
|
||||
return 'price-flat';
|
||||
};
|
||||
|
||||
const getPnLClass = (pnl) => {
|
||||
if (pnl === null || pnl === undefined) return '';
|
||||
if (pnl > 0) return 'pnl-positive';
|
||||
if (pnl < 0) return 'pnl-negative';
|
||||
return '';
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// 行情相关
|
||||
// ============================================
|
||||
|
||||
const isSubscribed = (symbol) => {
|
||||
return subscribedSymbols.value.has(symbol);
|
||||
};
|
||||
|
||||
const subscribeSymbol = (symbol) => {
|
||||
if (subscribedSymbols.value.has(symbol)) {
|
||||
// 取消订阅
|
||||
subscribedSymbols.value.delete(symbol);
|
||||
wsClient.unsubscribeSymbol([symbol]);
|
||||
} else {
|
||||
// 订阅
|
||||
subscribedSymbols.value.add(symbol);
|
||||
wsClient.subscribeSymbol([symbol]);
|
||||
}
|
||||
};
|
||||
|
||||
const refreshMarket = async () => {
|
||||
try {
|
||||
ticks.value = await api.getTicks();
|
||||
} catch (e) {
|
||||
console.error('Failed to refresh market data:', e);
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// 交易相关
|
||||
// ============================================
|
||||
|
||||
const canCancel = (status) => {
|
||||
return ['submitted', 'pending', 'partial_filled'].includes(
|
||||
status?.toLowerCase()
|
||||
);
|
||||
};
|
||||
|
||||
const sendOrder = async () => {
|
||||
orderError.value = '';
|
||||
isOrdering.value = true;
|
||||
|
||||
try {
|
||||
const result = await api.sendOrder({
|
||||
symbol: orderForm.value.symbol.split('.')[0],
|
||||
exchange: orderForm.value.symbol.split('.')[1] || 'SIM',
|
||||
direction: orderForm.value.direction,
|
||||
order_type: orderForm.value.order_type,
|
||||
volume: orderForm.value.volume,
|
||||
price: orderForm.value.order_type === 'limit' ? orderForm.value.price : null
|
||||
});
|
||||
|
||||
// 刷新订单列表
|
||||
await refreshOrders();
|
||||
|
||||
// 重置表单
|
||||
orderForm.value = {
|
||||
symbol: '',
|
||||
direction: 'buy',
|
||||
order_type: 'limit',
|
||||
price: 0,
|
||||
volume: 1
|
||||
};
|
||||
} catch (e) {
|
||||
orderError.value = e.message || '下单失败';
|
||||
} finally {
|
||||
isOrdering.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const cancelOrder = async (orderId) => {
|
||||
try {
|
||||
await api.cancelOrder(orderId);
|
||||
await refreshOrders();
|
||||
} catch (e) {
|
||||
console.error('Failed to cancel order:', e);
|
||||
}
|
||||
};
|
||||
|
||||
const refreshOrders = async () => {
|
||||
try {
|
||||
const [all, active] = await Promise.all([
|
||||
api.getOrders(),
|
||||
api.getActiveOrders()
|
||||
]);
|
||||
allOrders.value = all;
|
||||
activeOrders.value = active;
|
||||
} catch (e) {
|
||||
console.error('Failed to refresh orders:', e);
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// 持仓相关
|
||||
// ============================================
|
||||
|
||||
const refreshPositions = async () => {
|
||||
try {
|
||||
positions.value = await api.getPositions();
|
||||
} catch (e) {
|
||||
console.error('Failed to refresh positions:', e);
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// 策略相关
|
||||
// ============================================
|
||||
|
||||
const refreshStrategies = async () => {
|
||||
try {
|
||||
strategies.value = await api.getStrategies();
|
||||
} catch (e) {
|
||||
console.error('Failed to refresh strategies:', e);
|
||||
}
|
||||
};
|
||||
|
||||
const startStrategy = async (name) => {
|
||||
try {
|
||||
await api.startStrategy(name);
|
||||
await refreshStrategies();
|
||||
} catch (e) {
|
||||
console.error('Failed to start strategy:', e);
|
||||
}
|
||||
};
|
||||
|
||||
const stopStrategy = async (name) => {
|
||||
try {
|
||||
await api.stopStrategy(name);
|
||||
await refreshStrategies();
|
||||
} catch (e) {
|
||||
console.error('Failed to stop strategy:', e);
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// 日志相关
|
||||
// ============================================
|
||||
|
||||
const addLog = (logData) => {
|
||||
logs.value.push({
|
||||
time: logData.time || new Date().toISOString(),
|
||||
level: logData.level || 'INFO',
|
||||
message: logData.message || ''
|
||||
});
|
||||
|
||||
// 限制日志数量
|
||||
if (logs.value.length > 1000) {
|
||||
logs.value = logs.value.slice(-1000);
|
||||
}
|
||||
|
||||
// 自动滚动
|
||||
if (autoScroll.value) {
|
||||
nextTick(() => {
|
||||
if (logsContainer.value) {
|
||||
logsContainer.value.scrollTop = logsContainer.value.scrollHeight;
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const clearLogs = () => {
|
||||
logs.value = [];
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// 认证相关
|
||||
// ============================================
|
||||
|
||||
const login = async () => {
|
||||
loginError.value = '';
|
||||
isLoading.value = true;
|
||||
|
||||
try {
|
||||
await api.login(loginForm.value.username, loginForm.value.password);
|
||||
isLoggedIn.value = true;
|
||||
currentUser.value = { username: loginForm.value.username };
|
||||
|
||||
// 连接 WebSocket
|
||||
if (api.token) {
|
||||
wsClient.connect(api.token);
|
||||
}
|
||||
|
||||
// 加载初始数据(不阻塞登录成功)
|
||||
loadInitialData().catch(err => {
|
||||
console.warn('部分数据加载失败:', err);
|
||||
addLog({
|
||||
level: 'WARNING',
|
||||
message: '部分功能数据加载失败,请检查服务连接'
|
||||
});
|
||||
});
|
||||
} catch (e) {
|
||||
loginError.value = e.message || '登录失败';
|
||||
throw e;
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const logout = async () => {
|
||||
try {
|
||||
await api.logout();
|
||||
} catch (e) {
|
||||
console.error('Logout error:', e);
|
||||
} finally {
|
||||
isLoggedIn.value = false;
|
||||
currentUser.value = { username: '' };
|
||||
wsClient.disconnect();
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// 初始数据加载
|
||||
// ============================================
|
||||
|
||||
const loadInitialData = async () => {
|
||||
try {
|
||||
// 并行加载所有数据
|
||||
const [
|
||||
sysInfo,
|
||||
contractsData,
|
||||
accountsData,
|
||||
positionsData,
|
||||
ordersData,
|
||||
strategiesData
|
||||
] = await Promise.all([
|
||||
api.getSystemInfo().catch(() => null),
|
||||
api.getContracts().catch(() => []),
|
||||
api.getAccounts().catch(() => []),
|
||||
api.getPositions().catch(() => []),
|
||||
api.getOrders().catch(() => []),
|
||||
api.getStrategies().catch(() => [])
|
||||
]);
|
||||
|
||||
// 更新系统状态
|
||||
if (sysInfo) {
|
||||
systemStatus.value = {
|
||||
vn_ready: sysInfo.vn_ready || false,
|
||||
main_engine: sysInfo.main_engine || false,
|
||||
trading_engine: sysInfo.trading_engine || false
|
||||
};
|
||||
}
|
||||
|
||||
contracts.value = contractsData;
|
||||
accounts.value = accountsData;
|
||||
positions.value = positionsData;
|
||||
allOrders.value = ordersData;
|
||||
activeOrders.value = ordersData.filter(o =>
|
||||
['submitted', 'pending', 'partial_filled'].includes(o.status?.toLowerCase())
|
||||
);
|
||||
strategies.value = strategiesData;
|
||||
|
||||
// 获取行情数据
|
||||
const ticksData = await api.getTicks().catch(() => []);
|
||||
ticks.value = ticksData;
|
||||
|
||||
// 订阅 WebSocket 消息
|
||||
wsClient.subscribe(['tick', 'order', 'trade', 'position', 'account', 'log']);
|
||||
} catch (e) {
|
||||
console.error('Failed to load initial data:', e);
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// WebSocket 消息处理
|
||||
// ============================================
|
||||
|
||||
const setupWebSocketHandlers = () => {
|
||||
// 连接状态
|
||||
wsClient.on('connected', () => {
|
||||
wsConnected.value = true;
|
||||
});
|
||||
|
||||
wsClient.on('disconnected', () => {
|
||||
wsConnected.value = false;
|
||||
});
|
||||
|
||||
// 行情推送
|
||||
wsClient.on('tick', (data) => {
|
||||
// 更新或添加行情数据
|
||||
const index = ticks.value.findIndex(t => t.vt_symbol === data.vt_symbol);
|
||||
if (index >= 0) {
|
||||
ticks.value[index] = data;
|
||||
} else {
|
||||
ticks.value.push(data);
|
||||
}
|
||||
});
|
||||
|
||||
// 订单推送
|
||||
wsClient.on('order', (data) => {
|
||||
// 更新订单列表
|
||||
const index = allOrders.value.findIndex(o => o.order_id === data.order_id);
|
||||
if (index >= 0) {
|
||||
allOrders.value[index] = data;
|
||||
} else {
|
||||
allOrders.value.push(data);
|
||||
}
|
||||
|
||||
// 更新活动订单
|
||||
if (['submitted', 'pending', 'partial_filled'].includes(data.status?.toLowerCase())) {
|
||||
const activeIndex = activeOrders.value.findIndex(o => o.order_id === data.order_id);
|
||||
if (activeIndex >= 0) {
|
||||
activeOrders.value[activeIndex] = data;
|
||||
} else {
|
||||
activeOrders.value.push(data);
|
||||
}
|
||||
} else {
|
||||
activeOrders.value = activeOrders.value.filter(o => o.order_id !== data.order_id);
|
||||
}
|
||||
});
|
||||
|
||||
// 成交推送
|
||||
wsClient.on('trade', (data) => {
|
||||
// 可以添加成交记录
|
||||
console.log('Trade update:', data);
|
||||
});
|
||||
|
||||
// 持仓推送
|
||||
wsClient.on('position', (data) => {
|
||||
const index = positions.value.findIndex(
|
||||
p => p.symbol === data.symbol && p.exchange === data.exchange
|
||||
);
|
||||
if (index >= 0) {
|
||||
positions.value[index] = data;
|
||||
} else {
|
||||
positions.value.push(data);
|
||||
}
|
||||
});
|
||||
|
||||
// 账户推送
|
||||
wsClient.on('account', (data) => {
|
||||
if (accounts.value.length > 0) {
|
||||
accounts.value[0] = data;
|
||||
} else {
|
||||
accounts.value.push(data);
|
||||
}
|
||||
});
|
||||
|
||||
// 日志推送
|
||||
wsClient.on('log', (data) => {
|
||||
addLog(data);
|
||||
});
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// 生命周期
|
||||
// ============================================
|
||||
|
||||
onMounted(() => {
|
||||
// 检查是否已登录
|
||||
if (api.isTokenValid()) {
|
||||
isLoggedIn.value = true;
|
||||
const tokenPayload = JSON.parse(atob(api.token.split('.')[1]));
|
||||
currentUser.value = { username: tokenPayload.sub };
|
||||
|
||||
// 连接 WebSocket
|
||||
wsClient.connect(api.token);
|
||||
|
||||
// 加载初始数据
|
||||
loadInitialData();
|
||||
}
|
||||
|
||||
// 设置 WebSocket 处理器
|
||||
setupWebSocketHandlers();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
wsClient.disconnect();
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// 返回
|
||||
// ============================================
|
||||
|
||||
return {
|
||||
// 状态
|
||||
isLoggedIn,
|
||||
currentUser,
|
||||
loginForm,
|
||||
loginError,
|
||||
isLoading,
|
||||
currentPage,
|
||||
navItems,
|
||||
wsConnected,
|
||||
systemStatus,
|
||||
gatewayStatus,
|
||||
marketSearch,
|
||||
contracts,
|
||||
ticks,
|
||||
accounts,
|
||||
positions,
|
||||
allOrders,
|
||||
activeOrders,
|
||||
strategies,
|
||||
logs,
|
||||
autoScroll,
|
||||
logLevelFilter,
|
||||
logsContainer,
|
||||
orderForm,
|
||||
orderError,
|
||||
isOrdering,
|
||||
|
||||
// 计算属性
|
||||
currentPageTitle,
|
||||
filteredTicks,
|
||||
recentLogs,
|
||||
filteredLogs,
|
||||
totalPnL,
|
||||
|
||||
// 方法
|
||||
login,
|
||||
logout,
|
||||
formatNumber,
|
||||
formatPercent,
|
||||
formatTime,
|
||||
getPriceClass,
|
||||
getPnLClass,
|
||||
isSubscribed,
|
||||
subscribeSymbol,
|
||||
refreshMarket,
|
||||
canCancel,
|
||||
sendOrder,
|
||||
cancelOrder,
|
||||
refreshOrders,
|
||||
refreshPositions,
|
||||
refreshStrategies,
|
||||
startStrategy,
|
||||
stopStrategy,
|
||||
clearLogs
|
||||
};
|
||||
}
|
||||
}).mount('#app');
|
||||
@@ -0,0 +1,270 @@
|
||||
/**
|
||||
* WebSocket 连接管理
|
||||
* 处理实时数据推送、重连机制
|
||||
*/
|
||||
|
||||
const WS_URL = `ws://${window.location.host}/ws`;
|
||||
|
||||
/**
|
||||
* WebSocket 客户端类
|
||||
*/
|
||||
class WebSocketClient {
|
||||
constructor() {
|
||||
this.ws = null;
|
||||
this.reconnectInterval = null;
|
||||
this.reconnectDelay = 3000; // 3秒后重连
|
||||
this.maxReconnectAttempts = 10;
|
||||
this.reconnectAttempts = 0;
|
||||
this.isConnected = false;
|
||||
this.subscriptions = new Set();
|
||||
this.messageHandlers = new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
* 连接 WebSocket
|
||||
*/
|
||||
connect(token) {
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
console.log('WebSocket already connected');
|
||||
return;
|
||||
}
|
||||
|
||||
const url = token ? `${WS_URL}?token=${token}` : WS_URL;
|
||||
|
||||
this.ws = new WebSocket(url);
|
||||
|
||||
this.ws.onopen = () => {
|
||||
console.log('WebSocket connected');
|
||||
this.isConnected = true;
|
||||
this.reconnectAttempts = 0;
|
||||
this.clearReconnectInterval();
|
||||
|
||||
// 重新订阅之前的内容
|
||||
if (this.subscriptions.size > 0) {
|
||||
this.subscribe(Array.from(this.subscriptions));
|
||||
}
|
||||
|
||||
// 触发连接事件
|
||||
this.emit('connected');
|
||||
};
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
try {
|
||||
const message = JSON.parse(event.data);
|
||||
this.handleMessage(message);
|
||||
} catch (e) {
|
||||
console.error('Failed to parse WebSocket message:', e);
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onclose = () => {
|
||||
console.log('WebSocket disconnected');
|
||||
this.isConnected = false;
|
||||
this.emit('disconnected');
|
||||
|
||||
// 尝试重连
|
||||
this.scheduleReconnect(token);
|
||||
};
|
||||
|
||||
this.ws.onerror = (error) => {
|
||||
console.error('WebSocket error:', error);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 断开连接
|
||||
*/
|
||||
disconnect() {
|
||||
this.clearReconnectInterval();
|
||||
if (this.ws) {
|
||||
this.ws.close();
|
||||
this.ws = null;
|
||||
}
|
||||
this.isConnected = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 安排重连
|
||||
*/
|
||||
scheduleReconnect(token) {
|
||||
if (this.reconnectInterval) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
||||
console.error('Max reconnect attempts reached');
|
||||
this.emit('reconnect-failed');
|
||||
return;
|
||||
}
|
||||
|
||||
this.reconnectAttempts++;
|
||||
console.log(`Scheduling reconnect attempt ${this.reconnectAttempts}`);
|
||||
|
||||
this.reconnectInterval = setTimeout(() => {
|
||||
this.reconnectInterval = null;
|
||||
this.connect(token);
|
||||
}, this.reconnectDelay);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除重连定时器
|
||||
*/
|
||||
clearReconnectInterval() {
|
||||
if (this.reconnectInterval) {
|
||||
clearTimeout(this.reconnectInterval);
|
||||
this.reconnectInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理消息
|
||||
*/
|
||||
handleMessage(message) {
|
||||
const { type, data } = message;
|
||||
|
||||
// 根据消息类型调用对应的处理器
|
||||
if (this.messageHandlers.has(type)) {
|
||||
this.messageHandlers.get(type).forEach(handler => handler(data));
|
||||
}
|
||||
|
||||
// 通用消息处理器
|
||||
if (this.messageHandlers.has('*')) {
|
||||
this.messageHandlers.get('*').forEach(handler => handler(message));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送消息
|
||||
*/
|
||||
send(message) {
|
||||
if (!this.isConnected) {
|
||||
console.warn('WebSocket not connected, message not sent');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
this.ws.send(JSON.stringify(message));
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error('Failed to send WebSocket message:', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅消息类型
|
||||
*/
|
||||
subscribe(types) {
|
||||
types = Array.isArray(types) ? types : [types];
|
||||
|
||||
types.forEach(type => {
|
||||
this.subscriptions.add(type);
|
||||
});
|
||||
|
||||
if (this.isConnected) {
|
||||
this.send({
|
||||
action: 'subscribe',
|
||||
types: types
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消订阅
|
||||
*/
|
||||
unsubscribe(types) {
|
||||
types = Array.isArray(types) ? types : [types];
|
||||
|
||||
types.forEach(type => {
|
||||
this.subscriptions.delete(type);
|
||||
});
|
||||
|
||||
if (this.isConnected) {
|
||||
this.send({
|
||||
action: 'unsubscribe',
|
||||
types: types
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅品种
|
||||
*/
|
||||
subscribeSymbol(symbols) {
|
||||
symbols = Array.isArray(symbols) ? symbols : [symbols];
|
||||
|
||||
if (this.isConnected) {
|
||||
this.send({
|
||||
action: 'subscribe_symbol',
|
||||
symbols: symbols
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消订阅品种
|
||||
*/
|
||||
unsubscribeSymbol(symbols) {
|
||||
symbols = Array.isArray(symbols) ? symbols : [symbols];
|
||||
|
||||
if (this.isConnected) {
|
||||
this.send({
|
||||
action: 'unsubscribe_symbol',
|
||||
symbols: symbols
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册消息处理器
|
||||
*/
|
||||
on(type, handler) {
|
||||
if (!this.messageHandlers.has(type)) {
|
||||
this.messageHandlers.set(type, new Set());
|
||||
}
|
||||
this.messageHandlers.get(type).add(handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除消息处理器
|
||||
*/
|
||||
off(type, handler) {
|
||||
if (this.messageHandlers.has(type)) {
|
||||
this.messageHandlers.get(type).delete(handler);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 触发事件
|
||||
*/
|
||||
emit(type, data) {
|
||||
if (this.messageHandlers.has(type)) {
|
||||
this.messageHandlers.get(type).forEach(handler => handler(data));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取连接状态
|
||||
*/
|
||||
getConnectionState() {
|
||||
if (!this.ws) {
|
||||
return 'disconnected';
|
||||
}
|
||||
|
||||
switch (this.ws.readyState) {
|
||||
case WebSocket.CONNECTING:
|
||||
return 'connecting';
|
||||
case WebSocket.OPEN:
|
||||
return 'connected';
|
||||
case WebSocket.CLOSING:
|
||||
return 'closing';
|
||||
case WebSocket.CLOSED:
|
||||
return 'closed';
|
||||
default:
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 创建全局 WebSocket 客户端实例
|
||||
const wsClient = new WebSocketClient();
|
||||
@@ -0,0 +1,482 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Sanguo VeighNa Web</title>
|
||||
<link rel="stylesheet" href="/static/css/main.css">
|
||||
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<!-- 登录界面 -->
|
||||
<div v-if="!isLoggedIn" class="login-container">
|
||||
<div class="login-box">
|
||||
<h1>Sanguo VeighNa</h1>
|
||||
<p class="subtitle">量化交易平台</p>
|
||||
<form @submit.prevent="login" class="login-form">
|
||||
<div class="form-group">
|
||||
<label>用户名</label>
|
||||
<input
|
||||
type="text"
|
||||
v-model="loginForm.username"
|
||||
placeholder="请输入用户名"
|
||||
required
|
||||
>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>密码</label>
|
||||
<input
|
||||
type="password"
|
||||
v-model="loginForm.password"
|
||||
placeholder="请输入密码"
|
||||
required
|
||||
>
|
||||
</div>
|
||||
<div v-if="loginError" class="error-message">{{ loginError }}</div>
|
||||
<button type="submit" class="btn-primary" :disabled="isLoading">
|
||||
{{ isLoading ? '登录中...' : '登录' }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 主界面 -->
|
||||
<div v-else class="main-container">
|
||||
<!-- 侧边栏 -->
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<h2>Sanguo VeighNa</h2>
|
||||
<span class="version">v1.0.0</span>
|
||||
</div>
|
||||
<nav class="sidebar-nav">
|
||||
<a
|
||||
v-for="item in navItems"
|
||||
:key="item.id"
|
||||
href="#"
|
||||
:class="{ active: currentPage === item.id }"
|
||||
@click.prevent="currentPage = item.id"
|
||||
>
|
||||
<span class="icon">{{ item.icon }}</span>
|
||||
<span>{{ item.label }}</span>
|
||||
</a>
|
||||
</nav>
|
||||
<div class="sidebar-footer">
|
||||
<div class="user-info">
|
||||
<span>{{ currentUser.username }}</span>
|
||||
</div>
|
||||
<button @click="logout" class="btn-logout">退出登录</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- 内容区 -->
|
||||
<main class="main-content">
|
||||
<!-- 顶部状态栏 -->
|
||||
<header class="top-bar">
|
||||
<h1>{{ currentPageTitle }}</h1>
|
||||
<div class="status-indicators">
|
||||
<div class="status-item">
|
||||
<span :class="{ connected: wsConnected }">
|
||||
{{ wsConnected ? 'WebSocket 已连接' : 'WebSocket 未连接' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
网关状态:
|
||||
<span :class="{ online: gatewayStatus.online }">
|
||||
{{ gatewayStatus.online ? '在线' : '离线' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 总览页面 -->
|
||||
<section v-if="currentPage === 'dashboard'" class="page-content">
|
||||
<div class="dashboard-grid">
|
||||
<!-- 系统状态卡片 -->
|
||||
<div class="card">
|
||||
<h3>系统状态</h3>
|
||||
<div class="status-list">
|
||||
<div class="status-row">
|
||||
<span>VeighNa 服务:</span>
|
||||
<span :class="{ online: systemStatus.vn_ready }">
|
||||
{{ systemStatus.vn_ready ? '就绪' : '未就绪' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="status-row">
|
||||
<span>主引擎:</span>
|
||||
<span :class="{ online: systemStatus.main_engine }">
|
||||
{{ systemStatus.main_engine ? '运行中' : '停止' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="status-row">
|
||||
<span>交易引擎:</span>
|
||||
<span :class="{ online: systemStatus.trading_engine }">
|
||||
{{ systemStatus.trading_engine ? '运行中' : '停止' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 账户信息卡片 -->
|
||||
<div class="card">
|
||||
<h3>账户信息</h3>
|
||||
<div v-if="accounts.length > 0" class="account-info">
|
||||
<div class="account-row">
|
||||
<span>账户ID:</span>
|
||||
<span>{{ accounts[0].account_id }}</span>
|
||||
</div>
|
||||
<div class="account-row">
|
||||
<span>余额:</span>
|
||||
<span class="amount">{{ formatNumber(accounts[0].balance) }}</span>
|
||||
</div>
|
||||
<div class="account-row">
|
||||
<span>可用:</span>
|
||||
<span class="amount">{{ formatNumber(accounts[0].available) }}</span>
|
||||
</div>
|
||||
<div class="account-row">
|
||||
<span>冻结:</span>
|
||||
<span class="amount">{{ formatNumber(accounts[0].frozen) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="no-data">暂无账户数据</div>
|
||||
</div>
|
||||
|
||||
<!-- 持仓汇总卡片 -->
|
||||
<div class="card">
|
||||
<h3>持仓汇总</h3>
|
||||
<div v-if="positions.length > 0" class="position-summary">
|
||||
<div class="summary-row">
|
||||
<span>持仓品种:</span>
|
||||
<span>{{ positions.length }}</span>
|
||||
</div>
|
||||
<div class="summary-row">
|
||||
<span>总盈亏:</span>
|
||||
<span :class="getPnLClass(totalPnL)">
|
||||
{{ formatNumber(totalPnL) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="no-data">暂无持仓</div>
|
||||
</div>
|
||||
|
||||
<!-- 活动订单卡片 -->
|
||||
<div class="card">
|
||||
<h3>活动订单</h3>
|
||||
<div v-if="activeOrders.length > 0" class="orders-summary">
|
||||
<div class="summary-row">
|
||||
<span>活动订单数:</span>
|
||||
<span>{{ activeOrders.length }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="no-data">暂无活动订单</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 系统日志 -->
|
||||
<div class="card logs-card">
|
||||
<h3>最新日志</h3>
|
||||
<div class="logs-container">
|
||||
<div
|
||||
v-for="(log, index) in recentLogs"
|
||||
:key="index"
|
||||
:class="['log-entry', 'log-' + log.level]"
|
||||
>
|
||||
<span class="log-time">{{ log.time }}</span>
|
||||
<span class="log-level">{{ log.level }}</span>
|
||||
<span class="log-message">{{ log.message }}</span>
|
||||
</div>
|
||||
<div v-if="recentLogs.length === 0" class="no-data">暂无日志</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 行情页面 -->
|
||||
<section v-if="currentPage === 'market'" class="page-content">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3>行情数据</h3>
|
||||
<div class="card-actions">
|
||||
<input
|
||||
type="text"
|
||||
v-model="marketSearch"
|
||||
placeholder="搜索合约..."
|
||||
class="search-input"
|
||||
>
|
||||
<button @click="refreshMarket" class="btn-secondary">刷新</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-container">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>合约</th>
|
||||
<th>最新价</th>
|
||||
<th>买价</th>
|
||||
<th>卖价</th>
|
||||
<th>成交量</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="tick in filteredTicks" :key="tick.vt_symbol">
|
||||
<td>{{ tick.vt_symbol }}</td>
|
||||
<td :class="getPriceClass(tick.last_price, tick.pre_close_price)">
|
||||
{{ formatNumber(tick.last_price) }}
|
||||
</td>
|
||||
<td>{{ formatNumber(tick.bid_price_1) }}</td>
|
||||
<td>{{ formatNumber(tick.ask_price_1) }}</td>
|
||||
<td>{{ tick.volume }}</td>
|
||||
<td>
|
||||
<button
|
||||
@click="subscribeSymbol(tick.vt_symbol)"
|
||||
class="btn-small"
|
||||
>
|
||||
{{ isSubscribed(tick.vt_symbol) ? '已订阅' : '订阅' }}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-if="filteredTicks.length === 0" class="no-data">暂无行情数据</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 交易页面 -->
|
||||
<section v-if="currentPage === 'trading'" class="page-content">
|
||||
<div class="trading-grid">
|
||||
<!-- 下单表单 -->
|
||||
<div class="card">
|
||||
<h3>下单</h3>
|
||||
<form @submit.prevent="sendOrder" class="order-form">
|
||||
<div class="form-group">
|
||||
<label>合约</label>
|
||||
<select v-model="orderForm.symbol" required>
|
||||
<option value="">请选择合约</option>
|
||||
<option
|
||||
v-for="contract in contracts"
|
||||
:key="contract.vt_symbol"
|
||||
:value="contract.vt_symbol"
|
||||
>
|
||||
{{ contract.vt_symbol }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>方向</label>
|
||||
<select v-model="orderForm.direction" required>
|
||||
<option value="buy">买入</option>
|
||||
<option value="sell">卖出</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>类型</label>
|
||||
<select v-model="orderForm.order_type" required>
|
||||
<option value="limit">限价</option>
|
||||
<option value="market">市价</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>价格</label>
|
||||
<input
|
||||
type="number"
|
||||
v-model.number="orderForm.price"
|
||||
step="0.01"
|
||||
:disabled="orderForm.order_type === 'market'"
|
||||
>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>数量</label>
|
||||
<input type="number" v-model.number="orderForm.volume" required min="1">
|
||||
</div>
|
||||
<div v-if="orderError" class="error-message">{{ orderError }}</div>
|
||||
<button type="submit" class="btn-primary" :disabled="isOrdering">
|
||||
{{ isOrdering ? '下单中...' : '下单' }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- 委托列表 -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3>委托列表</h3>
|
||||
<button @click="refreshOrders" class="btn-secondary">刷新</button>
|
||||
</div>
|
||||
<div class="table-container">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>订单ID</th>
|
||||
<th>合约</th>
|
||||
<th>方向</th>
|
||||
<th>价格</th>
|
||||
<th>数量</th>
|
||||
<th>成交</th>
|
||||
<th>状态</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="order in allOrders" :key="order.order_id">
|
||||
<td>{{ order.order_id }}</td>
|
||||
<td>{{ order.symbol }}</td>
|
||||
<td :class="order.direction">{{ order.direction }}</td>
|
||||
<td>{{ formatNumber(order.price) }}</td>
|
||||
<td>{{ order.volume }}</td>
|
||||
<td>{{ order.traded }}</td>
|
||||
<td>{{ order.status }}</td>
|
||||
<td>
|
||||
<button
|
||||
v-if="canCancel(order.status)"
|
||||
@click="cancelOrder(order.order_id)"
|
||||
class="btn-small btn-danger"
|
||||
>
|
||||
撤单
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-if="allOrders.length === 0" class="no-data">暂无委托</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 持仓页面 -->
|
||||
<section v-if="currentPage === 'position'" class="page-content">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3>持仓列表</h3>
|
||||
<button @click="refreshPositions" class="btn-secondary">刷新</button>
|
||||
</div>
|
||||
<div class="table-container">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>合约</th>
|
||||
<th>交易所</th>
|
||||
<th>方向</th>
|
||||
<th>数量</th>
|
||||
<th>可用</th>
|
||||
<th>均价</th>
|
||||
<th>盈亏</th>
|
||||
<th>盈亏率</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="pos in positions" :key="pos.vt_symbol">
|
||||
<td>{{ pos.symbol }}</td>
|
||||
<td>{{ pos.exchange }}</td>
|
||||
<td :class="pos.direction">{{ pos.direction }}</td>
|
||||
<td>{{ pos.volume }}</td>
|
||||
<td>{{ pos.volume - (pos.frozen || 0) }}</td>
|
||||
<td>{{ formatNumber(pos.price) }}</td>
|
||||
<td :class="getPnLClass(pos.pnl)">
|
||||
{{ formatNumber(pos.pnl) }}
|
||||
</td>
|
||||
<td :class="getPnLClass(pos.pnl)">
|
||||
{{ formatPercent(pos.pnl_ratio) }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-if="positions.length === 0" class="no-data">暂无持仓</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 策略页面 -->
|
||||
<section v-if="currentPage === 'strategy'" class="page-content">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3>策略列表</h3>
|
||||
<button @click="refreshStrategies" class="btn-secondary">刷新</button>
|
||||
</div>
|
||||
<div class="table-container">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>策略名称</th>
|
||||
<th>类名</th>
|
||||
<th>状态</th>
|
||||
<th>创建时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="strategy in strategies" :key="strategy.strategy_name">
|
||||
<td>{{ strategy.strategy_name }}</td>
|
||||
<td>{{ strategy.class_name }}</td>
|
||||
<td>
|
||||
<span :class="'status-' + strategy.status.toLowerCase()">
|
||||
{{ strategy.status }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ formatTime(strategy.created_at) }}</td>
|
||||
<td>
|
||||
<button
|
||||
v-if="strategy.status === 'Stopped'"
|
||||
@click="startStrategy(strategy.strategy_name)"
|
||||
class="btn-small"
|
||||
>
|
||||
启动
|
||||
</button>
|
||||
<button
|
||||
v-if="strategy.status === 'Running'"
|
||||
@click="stopStrategy(strategy.strategy_name)"
|
||||
class="btn-small btn-danger"
|
||||
>
|
||||
停止
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-if="strategies.length === 0" class="no-data">暂无策略</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 日志页面 -->
|
||||
<section v-if="currentPage === 'log'" class="page-content">
|
||||
<div class="card logs-card">
|
||||
<div class="card-header">
|
||||
<h3>系统日志</h3>
|
||||
<div class="card-actions">
|
||||
<label>
|
||||
<input type="checkbox" v-model="autoScroll"> 自动滚动
|
||||
</label>
|
||||
<select v-model="logLevelFilter">
|
||||
<option value="">全部</option>
|
||||
<option value="DEBUG">DEBUG</option>
|
||||
<option value="INFO">INFO</option>
|
||||
<option value="WARNING">WARNING</option>
|
||||
<option value="ERROR">ERROR</option>
|
||||
</select>
|
||||
<button @click="clearLogs" class="btn-secondary">清空</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="logs-container" ref="logsContainer">
|
||||
<div
|
||||
v-for="(log, index) in filteredLogs"
|
||||
:key="index"
|
||||
:class="['log-entry', 'log-' + log.level.toLowerCase()]"
|
||||
>
|
||||
<span class="log-time">{{ formatTime(log.time) }}</span>
|
||||
<span class="log-level">{{ log.level }}</span>
|
||||
<span class="log-message">{{ log.message }}</span>
|
||||
</div>
|
||||
<div v-if="filteredLogs.length === 0" class="no-data">暂无日志</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/js/api.js"></script>
|
||||
<script src="/static/js/websocket.js"></script>
|
||||
<script src="/static/js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,238 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Phase 1 基础功能测试脚本
|
||||
|
||||
测试 FastAPI 应用是否能正常启动和响应基本请求
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
|
||||
# 添加项目路径
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
|
||||
|
||||
def test_imports():
|
||||
"""测试所有模块能否正常导入"""
|
||||
print("=" * 60)
|
||||
print("Testing imports...")
|
||||
print("=" * 60)
|
||||
|
||||
try:
|
||||
# 测试 FastAPI 应用导入
|
||||
from sanguo_web.api import app
|
||||
print(" FastAPI app: OK")
|
||||
|
||||
# 测试数据模型导入
|
||||
from sanguo_web.api.models import (
|
||||
LoginRequest, TokenResponse, SendOrderRequest,
|
||||
OrderResponse, HealthResponse
|
||||
)
|
||||
print(" Models: OK")
|
||||
|
||||
# 测试依赖注入导入
|
||||
from sanguo_web.api.deps import (
|
||||
create_access_token, verify_token,
|
||||
get_current_user
|
||||
)
|
||||
print(" Dependencies: OK")
|
||||
|
||||
# 测试 VeighNa 服务导入
|
||||
from sanguo_web.services.main_service import VeighNaService
|
||||
print(" VeighNa Service: OK")
|
||||
|
||||
# 测试数据库模型导入
|
||||
from sanguo_web.database import (
|
||||
User, APIToken, init_database, get_db
|
||||
)
|
||||
print(" Database: OK")
|
||||
|
||||
# 测试 WebSocket 管理器导入
|
||||
from sanguo_web.websocket.manager import ConnectionManager
|
||||
print(" WebSocket Manager: OK")
|
||||
|
||||
print("\nAll imports successful!")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"\nImport failed: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return False
|
||||
|
||||
|
||||
def test_fastapi_routes():
|
||||
"""测试 FastAPI 路由是否正确注册"""
|
||||
print("\n" + "=" * 60)
|
||||
print("Testing FastAPI routes...")
|
||||
print("=" * 60)
|
||||
|
||||
try:
|
||||
from sanguo_web.api import app
|
||||
|
||||
# 收集所有路由(包括 _IncludedRouter 中的路由)
|
||||
routes = []
|
||||
for route in app.routes:
|
||||
if hasattr(route, 'path') and hasattr(route, 'methods'):
|
||||
for method in route.methods or []:
|
||||
routes.append(f"{method} {route.path}")
|
||||
elif type(route).__name__ == '_IncludedRouter' and hasattr(route, 'original_router'):
|
||||
# 处理新版 FastAPI 的 _IncludedRouter
|
||||
prefix = route.include_context.prefix if hasattr(route, 'include_context') and route.include_context else ""
|
||||
for r in route.original_router.routes:
|
||||
if hasattr(r, 'path') and hasattr(r, 'methods'):
|
||||
full_path = f"{prefix}{r.path}"
|
||||
for method in r.methods or []:
|
||||
routes.append(f"{method} {full_path}")
|
||||
|
||||
# 关键路由检查
|
||||
key_routes = [
|
||||
("GET", "/"),
|
||||
("GET", "/health"),
|
||||
("GET", "/docs"),
|
||||
("POST", "/api/v1/auth/login"),
|
||||
("POST", "/api/v1/auth/verify"),
|
||||
("GET", "/api/v1/system/health"),
|
||||
]
|
||||
|
||||
print(f"\nTotal routes: {len(routes)}")
|
||||
print("\nKey routes check:")
|
||||
|
||||
all_found = True
|
||||
for method, path in key_routes:
|
||||
found = any(f"{method} {path}" in r for r in routes)
|
||||
status = "OK" if found else "MISSING"
|
||||
print(f" {method:6} {path:35} [{status}]")
|
||||
if not found:
|
||||
all_found = False
|
||||
|
||||
if all_found:
|
||||
print("\nAll key routes registered!")
|
||||
else:
|
||||
print("\nWARNING: Some routes are missing!")
|
||||
print("\nRegistered routes:")
|
||||
for r in routes:
|
||||
print(f" {r}")
|
||||
|
||||
return all_found
|
||||
|
||||
except Exception as e:
|
||||
print(f"\nRoute check failed: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return False
|
||||
|
||||
|
||||
def test_token_generation():
|
||||
"""测试 JWT Token 生成和验证"""
|
||||
print("\n" + "=" * 60)
|
||||
print("Testing JWT Token...")
|
||||
print("=" * 60)
|
||||
|
||||
try:
|
||||
from sanguo_web.api.deps import create_access_token, verify_token
|
||||
|
||||
# 创建 Token
|
||||
token = create_access_token(data={"sub": "test_user"})
|
||||
print(f" Token created: {token[:50]}...")
|
||||
|
||||
# 验证 Token
|
||||
payload = verify_token(token)
|
||||
print(f" Token verified, user: {payload.get('sub')}")
|
||||
|
||||
if payload.get('sub') == 'test_user':
|
||||
print("\nJWT Token test passed!")
|
||||
return True
|
||||
else:
|
||||
print("\nJWT Token test failed: user mismatch")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
print(f"\nJWT Token test failed: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return False
|
||||
|
||||
|
||||
def test_pydantic_models():
|
||||
"""测试 Pydantic 模型验证"""
|
||||
print("\n" + "=" * 60)
|
||||
print("Testing Pydantic models...")
|
||||
print("=" * 60)
|
||||
|
||||
try:
|
||||
from sanguo_web.api.models import (
|
||||
LoginRequest, SendOrderRequest, HealthResponse
|
||||
)
|
||||
|
||||
# 测试 LoginRequest
|
||||
login = LoginRequest(username="admin", password="secret")
|
||||
print(f" LoginRequest: {login.username}")
|
||||
|
||||
# 测试 SendOrderRequest
|
||||
order = SendOrderRequest(
|
||||
symbol="IF2024",
|
||||
exchange="CFFEX",
|
||||
direction="buy",
|
||||
order_type="limit",
|
||||
volume=1.0,
|
||||
price=3500.0
|
||||
)
|
||||
print(f" SendOrderRequest: {order.symbol} {order.direction.value}")
|
||||
|
||||
# 测试 HealthResponse
|
||||
from datetime import datetime
|
||||
health = HealthResponse(
|
||||
status="healthy",
|
||||
service="Sanguo VeighNa",
|
||||
version="1.0.0",
|
||||
timestamp=datetime.utcnow()
|
||||
)
|
||||
print(f" HealthResponse: {health.status}")
|
||||
|
||||
print("\nPydantic models test passed!")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"\nPydantic models test failed: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
"""运行所有测试"""
|
||||
print("\n" + "=" * 60)
|
||||
print("Sanguo VeighNa Web API - Phase 1 Tests")
|
||||
print("=" * 60)
|
||||
|
||||
results = []
|
||||
|
||||
# 运行测试
|
||||
results.append(("Imports", test_imports()))
|
||||
results.append(("Routes", test_fastapi_routes()))
|
||||
results.append(("JWT Token", test_token_generation()))
|
||||
results.append(("Pydantic Models", test_pydantic_models()))
|
||||
|
||||
# 总结
|
||||
print("\n" + "=" * 60)
|
||||
print("Test Summary")
|
||||
print("=" * 60)
|
||||
|
||||
passed = sum(1 for _, r in results if r)
|
||||
total = len(results)
|
||||
|
||||
for name, result in results:
|
||||
status = "PASSED" if result else "FAILED"
|
||||
print(f" {name:30} [{status}]")
|
||||
|
||||
print(f"\nTotal: {passed}/{total} tests passed")
|
||||
|
||||
if passed == total:
|
||||
print("\n✓ All Phase 1 tests passed!")
|
||||
return 0
|
||||
else:
|
||||
print(f"\n✗ {total - passed} test(s) failed")
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,147 @@
|
||||
"""
|
||||
Phase 2 API 测试
|
||||
测试核心 API 功能
|
||||
"""
|
||||
import asyncio
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from sanguo_web.api import app, vn_service
|
||||
from sanguo_web.services.main_service import VeighNaService
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client():
|
||||
"""测试客户端"""
|
||||
async with AsyncClient(app=app, base_url="http://test") as ac:
|
||||
yield ac
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def service():
|
||||
"""初始化 VeighNa 服务"""
|
||||
service = VeighNaService()
|
||||
await service.initialize()
|
||||
yield service
|
||||
await service.shutdown()
|
||||
|
||||
|
||||
class TestGatewayAPI:
|
||||
"""网关 API 测试"""
|
||||
|
||||
async def test_list_gateways(self, client):
|
||||
"""测试获取网关列表"""
|
||||
response = await client.get("/api/v1/gateway/list")
|
||||
assert response.status_code == 401 # 未认证
|
||||
|
||||
async def test_get_gateway_setting(self, client):
|
||||
"""测试获取网关配置"""
|
||||
# 跳过认证进行测试
|
||||
# 实际应使用认证 Token
|
||||
pass
|
||||
|
||||
|
||||
class TestMarketAPI:
|
||||
"""行情 API 测试"""
|
||||
|
||||
async def test_get_contracts(self, client):
|
||||
"""测试获取合约列表"""
|
||||
# 跳过认证测试
|
||||
pass
|
||||
|
||||
|
||||
class TestTradingAPI:
|
||||
"""交易 API 测试"""
|
||||
|
||||
async def test_get_accounts(self, client):
|
||||
"""测试获取账户"""
|
||||
# 跳过认证测试
|
||||
pass
|
||||
|
||||
|
||||
class TestVeighNaService:
|
||||
"""VeighNa 服务测试"""
|
||||
|
||||
async def test_initialize(self):
|
||||
"""测试服务初始化"""
|
||||
service = VeighNaService()
|
||||
assert not service.is_initialized
|
||||
await service.initialize()
|
||||
assert service.is_initialized
|
||||
await service.shutdown()
|
||||
|
||||
async def test_get_available_gateways(self, service):
|
||||
"""测试获取可用网关"""
|
||||
gateways = await service.get_available_gateways()
|
||||
assert isinstance(gateways, list)
|
||||
# Mock 模式下应有默认网关
|
||||
assert len(gateways) > 0
|
||||
|
||||
async def test_get_gateway_setting(self, service):
|
||||
"""测试获取网关配置"""
|
||||
setting = await service.get_gateway_setting("CTP")
|
||||
assert isinstance(setting, dict)
|
||||
|
||||
async def test_get_contracts(self, service):
|
||||
"""测试获取合约列表"""
|
||||
contracts = await service.get_contracts()
|
||||
assert isinstance(contracts, list)
|
||||
|
||||
async def test_get_accounts(self, service):
|
||||
"""测试获取账户"""
|
||||
accounts = await service.get_accounts()
|
||||
assert isinstance(accounts, list)
|
||||
# Mock 模式下应有默认账户
|
||||
if accounts:
|
||||
assert "account_id" in accounts[0]
|
||||
|
||||
async def test_get_positions(self, service):
|
||||
"""测试获取持仓"""
|
||||
positions = await service.get_positions()
|
||||
assert isinstance(positions, list)
|
||||
|
||||
async def test_get_orders(self, service):
|
||||
"""测试获取订单"""
|
||||
orders = await service.get_orders()
|
||||
assert isinstance(orders, list)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 简单的手动测试
|
||||
async def main():
|
||||
print("Testing VeighNa Service...")
|
||||
|
||||
service = VeighNaService()
|
||||
await service.initialize()
|
||||
|
||||
print("\n1. Available Gateways:")
|
||||
gateways = await service.get_available_gateways()
|
||||
for gw in gateways:
|
||||
print(f" - {gw['gateway_name']}: {gw.get('display_name', gw['gateway_type'])}")
|
||||
|
||||
print("\n2. Gateway Setting (CTP):")
|
||||
setting = await service.get_gateway_setting("CTP")
|
||||
for key, value in setting.items():
|
||||
print(f" - {key}: {value if value else '(empty)'}")
|
||||
|
||||
print("\n3. Contracts:")
|
||||
contracts = await service.get_contracts()
|
||||
print(f" Total: {len(contracts)} contracts")
|
||||
|
||||
print("\n4. Accounts:")
|
||||
accounts = await service.get_accounts()
|
||||
for acc in accounts:
|
||||
print(f" - {acc['account_id']}: balance={acc['balance']}, available={acc['available']}")
|
||||
|
||||
print("\n5. Positions:")
|
||||
positions = await service.get_positions()
|
||||
print(f" Total: {len(positions)} positions")
|
||||
|
||||
print("\n6. Orders:")
|
||||
orders = await service.get_orders()
|
||||
print(f" Total: {len(orders)} orders")
|
||||
|
||||
await service.shutdown()
|
||||
print("\n✓ All tests passed!")
|
||||
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,172 @@
|
||||
"""
|
||||
Phase 2 简单测试脚本
|
||||
不依赖 pytest 的手动测试
|
||||
"""
|
||||
import asyncio
|
||||
import sys
|
||||
import os
|
||||
|
||||
# 添加项目路径
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from sanguo_web.services.main_service import VeighNaService
|
||||
|
||||
|
||||
async def test_service():
|
||||
"""测试 VeighNa 服务"""
|
||||
print("=" * 60)
|
||||
print("Phase 2 API 功能测试")
|
||||
print("=" * 60)
|
||||
|
||||
service = VeighNaService()
|
||||
|
||||
# 1. 测试初始化
|
||||
print("\n[1/7] 测试服务初始化...")
|
||||
await service.initialize()
|
||||
assert service.is_initialized, "服务未初始化"
|
||||
print(" ✓ 服务初始化成功")
|
||||
|
||||
# 2. 测试获取可用网关
|
||||
print("\n[2/7] 测试获取可用网关...")
|
||||
gateways = await service.get_available_gateways()
|
||||
assert isinstance(gateways, list), "网关列表应为列表"
|
||||
print(f" ✓ 找到 {len(gateways)} 个网关:")
|
||||
for gw in gateways:
|
||||
print(f" - {gw['gateway_name']}: {gw.get('display_name', gw['gateway_type'])}")
|
||||
|
||||
# 3. 测试获取网关配置
|
||||
print("\n[3/7] 测试获取网关配置...")
|
||||
for gw in gateways:
|
||||
setting = await service.get_gateway_setting(gw['gateway_name'])
|
||||
assert isinstance(setting, dict), f"网关 {gw['gateway_name']} 配置应为字典"
|
||||
print(f" ✓ {gw['gateway_name']} 配置: {list(setting.keys())}")
|
||||
|
||||
# 4. 测试获取合约列表
|
||||
print("\n[4/7] 测试获取合约列表...")
|
||||
contracts = await service.get_contracts()
|
||||
assert isinstance(contracts, list), "合约列表应为列表"
|
||||
print(f" ✓ 找到 {len(contracts)} 个合约")
|
||||
|
||||
# 5. 测试获取账户
|
||||
print("\n[5/7] 测试获取账户...")
|
||||
accounts = await service.get_accounts()
|
||||
assert isinstance(accounts, list), "账户列表应为列表"
|
||||
print(f" ✓ 找到 {len(accounts)} 个账户:")
|
||||
for acc in accounts:
|
||||
print(f" - {acc['account_id']}: balance={acc['balance']}, available={acc['available']}")
|
||||
|
||||
# 6. 测试获取持仓
|
||||
print("\n[6/7] 测试获取持仓...")
|
||||
positions = await service.get_positions()
|
||||
assert isinstance(positions, list), "持仓列表应为列表"
|
||||
print(f" ✓ 找到 {len(positions)} 个持仓")
|
||||
|
||||
# 7. 测试获取订单
|
||||
print("\n[7/7] 测试获取订单...")
|
||||
orders = await service.get_orders()
|
||||
assert isinstance(orders, list), "订单列表应为列表"
|
||||
print(f" ✓ 找到 {len(orders)} 个订单")
|
||||
|
||||
# 测试获取活动订单
|
||||
active_orders = await service.get_active_orders()
|
||||
assert isinstance(active_orders, list), "活动订单列表应为列表"
|
||||
print(f" ✓ 找到 {len(active_orders)} 个活动订单")
|
||||
|
||||
# 测试获取成交
|
||||
trades = await service.get_trades()
|
||||
assert isinstance(trades, list), "成交列表应为列表"
|
||||
print(f" ✓ 找到 {len(trades)} 个成交")
|
||||
|
||||
# 清理
|
||||
print("\n清理资源...")
|
||||
await service.shutdown()
|
||||
print(" ✓ 服务已关闭")
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("✓ 所有测试通过!")
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
async def test_converter():
|
||||
"""测试数据转换器"""
|
||||
print("\n" + "=" * 60)
|
||||
print("数据转换器测试")
|
||||
print("=" * 60)
|
||||
|
||||
# 直接导入转换函数,避免导入整个 API 模块
|
||||
# Direction mapping
|
||||
DIRECTION_MAP = {
|
||||
"LONG": "buy",
|
||||
"SHORT": "sell",
|
||||
"NET": "net",
|
||||
}
|
||||
ORDER_TYPE_MAP = {
|
||||
"限价": "limit",
|
||||
"LIMIT": "limit",
|
||||
"市价": "market",
|
||||
"MARKET": "market",
|
||||
"STOP": "stop",
|
||||
}
|
||||
STATUS_MAP = {
|
||||
"提交中": "submitting",
|
||||
"SUBMITTING": "submitting",
|
||||
"未成交": "not_traded",
|
||||
"NOTTRADED": "not_traded",
|
||||
"部分成交": "part_traded",
|
||||
"PARTTRADED": "part_traded",
|
||||
"全部成交": "all_traded",
|
||||
"ALLTRADED": "all_traded",
|
||||
"已撤销": "cancelled",
|
||||
"CANCELLED": "cancelled",
|
||||
"拒单": "rejected",
|
||||
"REJECTED": "rejected",
|
||||
}
|
||||
|
||||
print("\n[1/4] 测试方向转换...")
|
||||
assert DIRECTION_MAP.get("LONG") == "buy"
|
||||
assert DIRECTION_MAP.get("SHORT") == "sell"
|
||||
print(" ✓ 方向转换正确")
|
||||
|
||||
print("\n[2/4] 测试订单类型转换...")
|
||||
assert ORDER_TYPE_MAP.get("LIMIT") == "limit"
|
||||
assert ORDER_TYPE_MAP.get("MARKET") == "market"
|
||||
print(" ✓ 订单类型转换正确")
|
||||
|
||||
print("\n[3/4] 测试状态转换...")
|
||||
assert STATUS_MAP.get("SUBMITTING") == "submitting"
|
||||
assert STATUS_MAP.get("ALLTRADED") == "all_traded"
|
||||
print(" ✓ 状态转换正确")
|
||||
|
||||
print("\n[4/4] 测试安全数值转换...")
|
||||
def safe_float(value, default=0.0):
|
||||
try:
|
||||
return float(value) if value is not None else default
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
def safe_int(value, default=0):
|
||||
try:
|
||||
return int(value) if value is not None else default
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
assert safe_float("100.5") == 100.5
|
||||
assert safe_float(None, 0) == 0
|
||||
assert safe_int("100") == 100
|
||||
assert safe_int(None, 0) == 0
|
||||
print(" ✓ 数值转换正确")
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("✓ 转换器测试通过!")
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
asyncio.run(test_service())
|
||||
asyncio.run(test_converter())
|
||||
except Exception as e:
|
||||
print(f"\n✗ 测试失败: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1,8 @@
|
||||
"""
|
||||
WebSocket 模块
|
||||
"""
|
||||
from .manager import ConnectionManager, manager
|
||||
from .routes import router
|
||||
from .events import EventMonitorManager
|
||||
|
||||
__all__ = ["ConnectionManager", "manager", "router", "EventMonitorManager"]
|
||||
@@ -0,0 +1,463 @@
|
||||
"""
|
||||
WebSocket 事件监听器
|
||||
监听 VeighNa 事件并推送到 WebSocket 客户端
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from vnpy.trader.event import (
|
||||
EVENT_TICK,
|
||||
EVENT_TRADE,
|
||||
EVENT_ORDER,
|
||||
EVENT_POSITION,
|
||||
EVENT_ACCOUNT,
|
||||
EVENT_LOG,
|
||||
EVENT_CONTRACT
|
||||
)
|
||||
from vnpy.trader.object import (
|
||||
TickData,
|
||||
TradeData,
|
||||
OrderData,
|
||||
PositionData,
|
||||
AccountData,
|
||||
LogData,
|
||||
ContractData
|
||||
)
|
||||
|
||||
from .manager import manager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def serialize_datetime(dt: datetime) -> str:
|
||||
"""序列化 datetime 对象为 ISO 格式字符串"""
|
||||
if dt is None:
|
||||
return None
|
||||
return dt.isoformat()
|
||||
|
||||
|
||||
def serialize_tick_data(tick: TickData) -> dict:
|
||||
"""序列化 TickData 为字典"""
|
||||
return {
|
||||
"vt_symbol": tick.vt_symbol,
|
||||
"symbol": tick.symbol,
|
||||
"exchange": tick.exchange.value if tick.exchange else None,
|
||||
"name": tick.name,
|
||||
"datetime": serialize_datetime(tick.datetime),
|
||||
"localtime": serialize_datetime(tick.localtime),
|
||||
"volume": tick.volume,
|
||||
"turnover": tick.turnover,
|
||||
"open_interest": tick.open_interest,
|
||||
"last_price": tick.last_price,
|
||||
"last_volume": tick.last_volume,
|
||||
"limit_up": tick.limit_up,
|
||||
"limit_down": tick.limit_down,
|
||||
"open_price": tick.open_price,
|
||||
"high_price": tick.high_price,
|
||||
"low_price": tick.low_price,
|
||||
"pre_close": tick.pre_close,
|
||||
"bid_price_1": tick.bid_price_1,
|
||||
"bid_price_2": tick.bid_price_2,
|
||||
"bid_price_3": tick.bid_price_3,
|
||||
"bid_price_4": tick.bid_price_4,
|
||||
"bid_price_5": tick.bid_price_5,
|
||||
"ask_price_1": tick.ask_price_1,
|
||||
"ask_price_2": tick.ask_price_2,
|
||||
"ask_price_3": tick.ask_price_3,
|
||||
"ask_price_4": tick.ask_price_4,
|
||||
"ask_price_5": tick.ask_price_5,
|
||||
"bid_volume_1": tick.bid_volume_1,
|
||||
"bid_volume_2": tick.bid_volume_2,
|
||||
"bid_volume_3": tick.bid_volume_3,
|
||||
"bid_volume_4": tick.bid_volume_4,
|
||||
"bid_volume_5": tick.bid_volume_5,
|
||||
"ask_volume_1": tick.ask_volume_1,
|
||||
"ask_volume_2": tick.ask_volume_2,
|
||||
"ask_volume_3": tick.ask_volume_3,
|
||||
"ask_volume_4": tick.ask_volume_4,
|
||||
"ask_volume_5": tick.ask_volume_5,
|
||||
"gateway_name": tick.gateway_name
|
||||
}
|
||||
|
||||
|
||||
def serialize_order_data(order: OrderData) -> dict:
|
||||
"""序列化 OrderData 为字典"""
|
||||
return {
|
||||
"vt_orderid": order.vt_orderid,
|
||||
"vt_symbol": order.vt_symbol,
|
||||
"symbol": order.symbol,
|
||||
"exchange": order.exchange.value if order.exchange else None,
|
||||
"orderid": order.orderid,
|
||||
"type": order.type.value if order.type else None,
|
||||
"direction": order.direction.value if order.direction else None,
|
||||
"offset": order.offset.value if order.offset else None,
|
||||
"price": order.price,
|
||||
"volume": order.volume,
|
||||
"traded": order.traded,
|
||||
"status": order.status.value if order.status else None,
|
||||
"datetime": serialize_datetime(order.datetime),
|
||||
"reference": order.reference,
|
||||
"gateway_name": order.gateway_name
|
||||
}
|
||||
|
||||
|
||||
def serialize_trade_data(trade: TradeData) -> dict:
|
||||
"""序列化 TradeData 为字典"""
|
||||
return {
|
||||
"vt_tradeid": trade.vt_tradeid,
|
||||
"vt_orderid": trade.vt_orderid,
|
||||
"vt_symbol": trade.vt_symbol,
|
||||
"symbol": trade.symbol,
|
||||
"exchange": trade.exchange.value if trade.exchange else None,
|
||||
"orderid": trade.orderid,
|
||||
"tradeid": trade.tradeid,
|
||||
"direction": trade.direction.value if trade.direction else None,
|
||||
"offset": trade.offset.value if trade.offset else None,
|
||||
"price": trade.price,
|
||||
"volume": trade.volume,
|
||||
"datetime": serialize_datetime(trade.datetime),
|
||||
"gateway_name": trade.gateway_name
|
||||
}
|
||||
|
||||
|
||||
def serialize_position_data(position: PositionData) -> dict:
|
||||
"""序列化 PositionData 为字典"""
|
||||
return {
|
||||
"vt_positionid": position.vt_positionid,
|
||||
"vt_symbol": position.vt_symbol,
|
||||
"symbol": position.symbol,
|
||||
"exchange": position.exchange.value if position.exchange else None,
|
||||
"direction": position.direction.value if position.direction else None,
|
||||
"volume": position.volume,
|
||||
"frozen": position.frozen,
|
||||
"price": position.price,
|
||||
"pnl": position.pnl,
|
||||
"yd_volume": position.yd_volume,
|
||||
"gateway_name": position.gateway_name
|
||||
}
|
||||
|
||||
|
||||
def serialize_account_data(account: AccountData) -> dict:
|
||||
"""序列化 AccountData 为字典"""
|
||||
return {
|
||||
"vt_accountid": account.vt_accountid,
|
||||
"accountid": account.accountid,
|
||||
"balance": account.balance,
|
||||
"frozen": account.frozen,
|
||||
"available": account.available,
|
||||
"gateway_name": account.gateway_name
|
||||
}
|
||||
|
||||
|
||||
def serialize_log_data(log: LogData) -> dict:
|
||||
"""序列化 LogData 为字典"""
|
||||
return {
|
||||
"msg": log.msg,
|
||||
"level": log.level,
|
||||
"time": serialize_datetime(log.time),
|
||||
"gateway_name": log.gateway_name
|
||||
}
|
||||
|
||||
|
||||
def serialize_contract_data(contract: ContractData) -> dict:
|
||||
"""序列化 ContractData 为字典"""
|
||||
return {
|
||||
"vt_symbol": contract.vt_symbol,
|
||||
"symbol": contract.symbol,
|
||||
"exchange": contract.exchange.value if contract.exchange else None,
|
||||
"name": contract.name,
|
||||
"product": contract.product.value if contract.product else None,
|
||||
"size": contract.size,
|
||||
"pricetick": contract.pricetick,
|
||||
"min_volume": contract.min_volume,
|
||||
"max_volume": contract.max_volume,
|
||||
"stop_supported": contract.stop_supported,
|
||||
"net_position": contract.net_position,
|
||||
"history_data": contract.history_data,
|
||||
"option_strike": contract.option_strike,
|
||||
"option_underlying": contract.option_underlying,
|
||||
"option_type": contract.option_type.value if contract.option_type else None,
|
||||
"option_listed": serialize_datetime(contract.option_listed),
|
||||
"option_expiry": serialize_datetime(contract.option_expiry),
|
||||
"option_portfolio": contract.option_portfolio,
|
||||
"option_index": contract.option_index,
|
||||
"gateway_name": contract.gateway_name
|
||||
}
|
||||
|
||||
|
||||
class TickEventMonitor:
|
||||
"""行情事件监听器"""
|
||||
|
||||
def __init__(self, event_engine):
|
||||
"""
|
||||
初始化行情事件监听器
|
||||
|
||||
- **event_engine**: VeighNa 事件引擎
|
||||
"""
|
||||
self.event_engine = event_engine
|
||||
self.event_engine.register(EVENT_TICK, self.on_tick)
|
||||
logger.info("TickEventMonitor registered")
|
||||
|
||||
def on_tick(self, event):
|
||||
"""处理行情事件"""
|
||||
tick: TickData = event.data
|
||||
try:
|
||||
tick_data = serialize_tick_data(tick)
|
||||
|
||||
# 在事件循环中异步推送
|
||||
loop = asyncio.get_event_loop()
|
||||
if loop.is_running():
|
||||
asyncio.create_task(manager.broadcast_tick(tick_data))
|
||||
else:
|
||||
logger.warning("Event loop not running, tick broadcast skipped")
|
||||
except Exception as e:
|
||||
logger.error(f"Error handling tick event: {e}")
|
||||
|
||||
def stop(self):
|
||||
"""停止监听"""
|
||||
self.event_engine.unregister(EVENT_TICK, self.on_tick)
|
||||
logger.info("TickEventMonitor stopped")
|
||||
|
||||
|
||||
class OrderEventMonitor:
|
||||
"""订单事件监听器"""
|
||||
|
||||
def __init__(self, event_engine):
|
||||
"""
|
||||
初始化订单事件监听器
|
||||
|
||||
- **event_engine**: VeighNa 事件引擎
|
||||
"""
|
||||
self.event_engine = event_engine
|
||||
self.event_engine.register(EVENT_ORDER, self.on_order)
|
||||
logger.info("OrderEventMonitor registered")
|
||||
|
||||
def on_order(self, event):
|
||||
"""处理订单事件"""
|
||||
order: OrderData = event.data
|
||||
try:
|
||||
order_data = serialize_order_data(order)
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
if loop.is_running():
|
||||
asyncio.create_task(manager.broadcast_order(order_data))
|
||||
else:
|
||||
logger.warning("Event loop not running, order broadcast skipped")
|
||||
except Exception as e:
|
||||
logger.error(f"Error handling order event: {e}")
|
||||
|
||||
def stop(self):
|
||||
"""停止监听"""
|
||||
self.event_engine.unregister(EVENT_ORDER, self.on_order)
|
||||
logger.info("OrderEventMonitor stopped")
|
||||
|
||||
|
||||
class TradeEventMonitor:
|
||||
"""成交事件监听器"""
|
||||
|
||||
def __init__(self, event_engine):
|
||||
"""
|
||||
初始化成交事件监听器
|
||||
|
||||
- **event_engine**: VeighNa 事件引擎
|
||||
"""
|
||||
self.event_engine = event_engine
|
||||
self.event_engine.register(EVENT_TRADE, self.on_trade)
|
||||
logger.info("TradeEventMonitor registered")
|
||||
|
||||
def on_trade(self, event):
|
||||
"""处理成交事件"""
|
||||
trade: TradeData = event.data
|
||||
try:
|
||||
trade_data = serialize_trade_data(trade)
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
if loop.is_running():
|
||||
asyncio.create_task(manager.broadcast_trade(trade_data))
|
||||
else:
|
||||
logger.warning("Event loop not running, trade broadcast skipped")
|
||||
except Exception as e:
|
||||
logger.error(f"Error handling trade event: {e}")
|
||||
|
||||
def stop(self):
|
||||
"""停止监听"""
|
||||
self.event_engine.unregister(EVENT_TRADE, self.on_trade)
|
||||
logger.info("TradeEventMonitor stopped")
|
||||
|
||||
|
||||
class PositionEventMonitor:
|
||||
"""持仓事件监听器"""
|
||||
|
||||
def __init__(self, event_engine):
|
||||
"""
|
||||
初始化持仓事件监听器
|
||||
|
||||
- **event_engine**: VeighNa 事件引擎
|
||||
"""
|
||||
self.event_engine = event_engine
|
||||
self.event_engine.register(EVENT_POSITION, self.on_position)
|
||||
logger.info("PositionEventMonitor registered")
|
||||
|
||||
def on_position(self, event):
|
||||
"""处理持仓事件"""
|
||||
position: PositionData = event.data
|
||||
try:
|
||||
position_data = serialize_position_data(position)
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
if loop.is_running():
|
||||
asyncio.create_task(manager.broadcast_position(position_data))
|
||||
else:
|
||||
logger.warning("Event loop not running, position broadcast skipped")
|
||||
except Exception as e:
|
||||
logger.error(f"Error handling position event: {e}")
|
||||
|
||||
def stop(self):
|
||||
"""停止监听"""
|
||||
self.event_engine.unregister(EVENT_POSITION, self.on_position)
|
||||
logger.info("PositionEventMonitor stopped")
|
||||
|
||||
|
||||
class AccountEventMonitor:
|
||||
"""账户事件监听器"""
|
||||
|
||||
def __init__(self, event_engine):
|
||||
"""
|
||||
初始化账户事件监听器
|
||||
|
||||
- **event_engine**: VeighNa 事件引擎
|
||||
"""
|
||||
self.event_engine = event_engine
|
||||
self.event_engine.register(EVENT_ACCOUNT, self.on_account)
|
||||
logger.info("AccountEventMonitor registered")
|
||||
|
||||
def on_account(self, event):
|
||||
"""处理账户事件"""
|
||||
account: AccountData = event.data
|
||||
try:
|
||||
account_data = serialize_account_data(account)
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
if loop.is_running():
|
||||
asyncio.create_task(manager.broadcast_account(account_data))
|
||||
else:
|
||||
logger.warning("Event loop not running, account broadcast skipped")
|
||||
except Exception as e:
|
||||
logger.error(f"Error handling account event: {e}")
|
||||
|
||||
def stop(self):
|
||||
"""停止监听"""
|
||||
self.event_engine.unregister(EVENT_ACCOUNT, self.on_account)
|
||||
logger.info("AccountEventMonitor stopped")
|
||||
|
||||
|
||||
class LogEventMonitor:
|
||||
"""日志事件监听器"""
|
||||
|
||||
def __init__(self, event_engine):
|
||||
"""
|
||||
初始化日志事件监听器
|
||||
|
||||
- **event_engine**: VeighNa 事件引擎
|
||||
"""
|
||||
self.event_engine = event_engine
|
||||
self.event_engine.register(EVENT_LOG, self.on_log)
|
||||
logger.info("LogEventMonitor registered")
|
||||
|
||||
def on_log(self, event):
|
||||
"""处理日志事件"""
|
||||
log: LogData = event.data
|
||||
try:
|
||||
log_data = serialize_log_data(log)
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
if loop.is_running():
|
||||
asyncio.create_task(manager.broadcast_log(log_data))
|
||||
else:
|
||||
logger.warning("Event loop not running, log broadcast skipped")
|
||||
except Exception as e:
|
||||
logger.error(f"Error handling log event: {e}")
|
||||
|
||||
def stop(self):
|
||||
"""停止监听"""
|
||||
self.event_engine.unregister(EVENT_LOG, self.on_log)
|
||||
logger.info("LogEventMonitor stopped")
|
||||
|
||||
|
||||
class ContractEventMonitor:
|
||||
"""合约事件监听器"""
|
||||
|
||||
def __init__(self, event_engine):
|
||||
"""
|
||||
初始化合约事件监听器
|
||||
|
||||
- **event_engine**: VeighNa 事件引擎
|
||||
"""
|
||||
self.event_engine = event_engine
|
||||
self.event_engine.register(EVENT_CONTRACT, self.on_contract)
|
||||
logger.info("ContractEventMonitor registered")
|
||||
|
||||
def on_contract(self, event):
|
||||
"""处理合约事件"""
|
||||
contract: ContractData = event.data
|
||||
try:
|
||||
contract_data = serialize_contract_data(contract)
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
if loop.is_running():
|
||||
asyncio.create_task(manager.broadcast_contract(contract_data))
|
||||
else:
|
||||
logger.warning("Event loop not running, contract broadcast skipped")
|
||||
except Exception as e:
|
||||
logger.error(f"Error handling contract event: {e}")
|
||||
|
||||
def stop(self):
|
||||
"""停止监听"""
|
||||
self.event_engine.unregister(EVENT_CONTRACT, self.on_contract)
|
||||
logger.info("ContractEventMonitor stopped")
|
||||
|
||||
|
||||
class EventMonitorManager:
|
||||
"""事件监听器管理器"""
|
||||
|
||||
def __init__(self, event_engine):
|
||||
"""
|
||||
初始化事件监听器管理器
|
||||
|
||||
- **event_engine**: VeighNa 事件引擎
|
||||
"""
|
||||
self.event_engine = event_engine
|
||||
self.monitors = []
|
||||
|
||||
def start_all(self):
|
||||
"""启动所有事件监听器"""
|
||||
self.monitors = [
|
||||
TickEventMonitor(self.event_engine),
|
||||
OrderEventMonitor(self.event_engine),
|
||||
TradeEventMonitor(self.event_engine),
|
||||
PositionEventMonitor(self.event_engine),
|
||||
AccountEventMonitor(self.event_engine),
|
||||
LogEventMonitor(self.event_engine),
|
||||
ContractEventMonitor(self.event_engine)
|
||||
]
|
||||
logger.info("All event monitors started")
|
||||
|
||||
def stop_all(self):
|
||||
"""停止所有事件监听器"""
|
||||
for monitor in self.monitors:
|
||||
monitor.stop()
|
||||
self.monitors = []
|
||||
logger.info("All event monitors stopped")
|
||||
|
||||
|
||||
__all__ = [
|
||||
"EventMonitorManager",
|
||||
"TickEventMonitor",
|
||||
"OrderEventMonitor",
|
||||
"TradeEventMonitor",
|
||||
"PositionEventMonitor",
|
||||
"AccountEventMonitor",
|
||||
"LogEventMonitor",
|
||||
"ContractEventMonitor"
|
||||
]
|
||||
@@ -0,0 +1,393 @@
|
||||
"""
|
||||
WebSocket 连接管理器
|
||||
管理实时数据推送的 WebSocket 连接
|
||||
"""
|
||||
from typing import Dict, Set, List, Any
|
||||
from fastapi import WebSocket
|
||||
from datetime import datetime
|
||||
import json
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ConnectionManager:
|
||||
"""
|
||||
WebSocket 连接管理器
|
||||
|
||||
管理 WebSocket 连接,支持消息广播和定向推送
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
# active_connections: 连接ID -> WebSocket
|
||||
self.active_connections: Dict[str, WebSocket] = {}
|
||||
|
||||
# user_connections: 用户名 -> 连接ID集合
|
||||
self.user_connections: Dict[str, Set[str]] = {}
|
||||
|
||||
# subscriptions: 连接ID -> 订阅类型集合
|
||||
self.subscriptions: Dict[str, Set[str]] = {}
|
||||
|
||||
# symbol_subscriptions: 品种代码 -> 连接ID集合
|
||||
self.symbol_subscriptions: Dict[str, Set[str]] = {}
|
||||
|
||||
self._connection_counter = 0
|
||||
|
||||
async def connect(self, websocket: WebSocket, user: str = "anonymous") -> str:
|
||||
"""
|
||||
接受新的 WebSocket 连接
|
||||
|
||||
- **websocket**: WebSocket 实例
|
||||
- **user**: 用户名(可选)
|
||||
"""
|
||||
await websocket.accept()
|
||||
|
||||
# 生成连接ID
|
||||
connection_id = f"conn_{self._connection_counter}"
|
||||
self._connection_counter += 1
|
||||
|
||||
# 保存连接
|
||||
self.active_connections[connection_id] = websocket
|
||||
|
||||
# 绑定用户
|
||||
if user not in self.user_connections:
|
||||
self.user_connections[user] = set()
|
||||
self.user_connections[user].add(connection_id)
|
||||
|
||||
# 初始化订阅
|
||||
self.subscriptions[connection_id] = set()
|
||||
|
||||
logger.info(f"WebSocket connected: {connection_id} (user: {user})")
|
||||
|
||||
return connection_id
|
||||
|
||||
async def disconnect(self, connection_id: str) -> None:
|
||||
"""
|
||||
断开 WebSocket 连接
|
||||
|
||||
- **connection_id**: 连接ID
|
||||
"""
|
||||
if connection_id not in self.active_connections:
|
||||
return
|
||||
|
||||
# 获取连接
|
||||
websocket = self.active_connections[connection_id]
|
||||
|
||||
# 从用户绑定中移除
|
||||
for user, conn_set in self.user_connections.items():
|
||||
if connection_id in conn_set:
|
||||
conn_set.remove(connection_id)
|
||||
if not conn_set:
|
||||
del self.user_connections[user]
|
||||
break
|
||||
|
||||
# 从品种订阅中移除
|
||||
for symbol, conn_set in self.symbol_subscriptions.items():
|
||||
if connection_id in conn_set:
|
||||
conn_set.remove(connection_id)
|
||||
if not conn_set:
|
||||
del self.symbol_subscriptions[symbol]
|
||||
|
||||
# 移除连接和订阅
|
||||
del self.active_connections[connection_id]
|
||||
del self.subscriptions[connection_id]
|
||||
|
||||
logger.info(f"WebSocket disconnected: {connection_id}")
|
||||
|
||||
async def send_personal_message(self, message: dict, connection_id: str) -> bool:
|
||||
"""
|
||||
向特定连接发送消息
|
||||
|
||||
- **message**: 消息内容
|
||||
- **connection_id**: 连接ID
|
||||
"""
|
||||
if connection_id not in self.active_connections:
|
||||
logger.warning(f"Connection not found: {connection_id}")
|
||||
return False
|
||||
|
||||
try:
|
||||
websocket = self.active_connections[connection_id]
|
||||
await websocket.send_json(message)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error sending message to {connection_id}: {e}")
|
||||
await self.disconnect(connection_id)
|
||||
return False
|
||||
|
||||
async def broadcast(self, message: dict) -> int:
|
||||
"""
|
||||
向所有连接广播消息
|
||||
|
||||
- **message**: 消息内容
|
||||
"""
|
||||
if not self.active_connections:
|
||||
return 0
|
||||
|
||||
# 复制连接列表,避免异步修改
|
||||
disconnected = []
|
||||
count = 0
|
||||
|
||||
for connection_id, websocket in list(self.active_connections.items()):
|
||||
try:
|
||||
await websocket.send_json(message)
|
||||
count += 1
|
||||
except Exception as e:
|
||||
logger.error(f"Error broadcasting to {connection_id}: {e}")
|
||||
disconnected.append(connection_id)
|
||||
|
||||
# 清理断开的连接
|
||||
for conn_id in disconnected:
|
||||
await self.disconnect(conn_id)
|
||||
|
||||
return count
|
||||
|
||||
async def broadcast_to_user(self, message: dict, user: str) -> int:
|
||||
"""
|
||||
向特定用户的所有连接广播消息
|
||||
|
||||
- **message**: 消息内容
|
||||
- **user**: 用户名
|
||||
"""
|
||||
if user not in self.user_connections:
|
||||
return 0
|
||||
|
||||
count = 0
|
||||
for connection_id in list(self.user_connections[user]):
|
||||
if await self.send_personal_message(message, connection_id):
|
||||
count += 1
|
||||
|
||||
return count
|
||||
|
||||
async def broadcast_to_symbol(self, message: dict, symbol: str) -> int:
|
||||
"""
|
||||
向订阅了特定品种的连接广播消息
|
||||
|
||||
- **message**: 消息内容
|
||||
- **symbol**: 品种代码
|
||||
"""
|
||||
if symbol not in self.symbol_subscriptions:
|
||||
return 0
|
||||
|
||||
count = 0
|
||||
for connection_id in list(self.symbol_subscriptions[symbol]):
|
||||
if await self.send_personal_message(message, connection_id):
|
||||
count += 1
|
||||
|
||||
return count
|
||||
|
||||
def subscribe(self, connection_id: str, subscription_type: str) -> bool:
|
||||
"""
|
||||
订阅消息类型
|
||||
|
||||
- **connection_id**: 连接ID
|
||||
- **subscription_type**: 订阅类型 (tick, order, position, trade)
|
||||
"""
|
||||
if connection_id not in self.subscriptions:
|
||||
return False
|
||||
|
||||
self.subscriptions[connection_id].add(subscription_type)
|
||||
logger.info(f"Connection {connection_id} subscribed to {subscription_type}")
|
||||
return True
|
||||
|
||||
def unsubscribe(self, connection_id: str, subscription_type: str) -> bool:
|
||||
"""
|
||||
取消订阅
|
||||
|
||||
- **connection_id**: 连接ID
|
||||
- **subscription_type**: 订阅类型
|
||||
"""
|
||||
if connection_id not in self.subscriptions:
|
||||
return False
|
||||
|
||||
self.subscriptions[connection_id].discard(subscription_type)
|
||||
logger.info(f"Connection {connection_id} unsubscribed from {subscription_type}")
|
||||
return True
|
||||
|
||||
def subscribe_symbol(self, connection_id: str, symbol: str) -> bool:
|
||||
"""
|
||||
订阅品种数据
|
||||
|
||||
- **connection_id**: 连接ID
|
||||
- **symbol**: 品种代码
|
||||
"""
|
||||
if connection_id not in self.active_connections:
|
||||
return False
|
||||
|
||||
if symbol not in self.symbol_subscriptions:
|
||||
self.symbol_subscriptions[symbol] = set()
|
||||
|
||||
self.symbol_subscriptions[symbol].add(connection_id)
|
||||
logger.info(f"Connection {connection_id} subscribed to symbol {symbol}")
|
||||
return True
|
||||
|
||||
def unsubscribe_symbol(self, connection_id: str, symbol: str) -> bool:
|
||||
"""
|
||||
取消品种订阅
|
||||
|
||||
- **connection_id**: 连接ID
|
||||
- **symbol**: 品种代码
|
||||
"""
|
||||
if symbol not in self.symbol_subscriptions:
|
||||
return False
|
||||
|
||||
self.symbol_subscriptions[symbol].discard(connection_id)
|
||||
|
||||
if not self.symbol_subscriptions[symbol]:
|
||||
del self.symbol_subscriptions[symbol]
|
||||
|
||||
logger.info(f"Connection {connection_id} unsubscribed from symbol {symbol}")
|
||||
return True
|
||||
|
||||
def get_connection_count(self) -> int:
|
||||
"""获取当前连接数"""
|
||||
return len(self.active_connections)
|
||||
|
||||
def get_user_connection_count(self, user: str) -> int:
|
||||
"""获取用户的连接数"""
|
||||
return len(self.user_connections.get(user, set()))
|
||||
|
||||
async def broadcast_tick(self, tick_data: dict) -> int:
|
||||
"""
|
||||
广播行情数据
|
||||
|
||||
- **tick_data**: 行情数据字典
|
||||
"""
|
||||
message = {
|
||||
"type": "tick",
|
||||
"data": tick_data
|
||||
}
|
||||
|
||||
# 如果有品种订阅,向订阅该品种的连接推送
|
||||
symbol = tick_data.get("vt_symbol")
|
||||
if symbol and symbol in self.symbol_subscriptions:
|
||||
return await self.broadcast_to_symbol(message, symbol)
|
||||
|
||||
# 否则向所有订阅 tick 的连接推送
|
||||
count = 0
|
||||
for conn_id, subscriptions in self.subscriptions.items():
|
||||
if "tick" in subscriptions:
|
||||
if await self.send_personal_message(message, conn_id):
|
||||
count += 1
|
||||
|
||||
return count
|
||||
|
||||
async def broadcast_order(self, order_data: dict) -> int:
|
||||
"""
|
||||
广播订单数据
|
||||
|
||||
- **order_data**: 订单数据字典
|
||||
"""
|
||||
message = {
|
||||
"type": "order",
|
||||
"data": order_data
|
||||
}
|
||||
|
||||
count = 0
|
||||
for conn_id, subscriptions in self.subscriptions.items():
|
||||
if "order" in subscriptions:
|
||||
if await self.send_personal_message(message, conn_id):
|
||||
count += 1
|
||||
|
||||
return count
|
||||
|
||||
async def broadcast_trade(self, trade_data: dict) -> int:
|
||||
"""
|
||||
广播成交数据
|
||||
|
||||
- **trade_data**: 成交数据字典
|
||||
"""
|
||||
message = {
|
||||
"type": "trade",
|
||||
"data": trade_data
|
||||
}
|
||||
|
||||
count = 0
|
||||
for conn_id, subscriptions in self.subscriptions.items():
|
||||
if "trade" in subscriptions:
|
||||
if await self.send_personal_message(message, conn_id):
|
||||
count += 1
|
||||
|
||||
return count
|
||||
|
||||
async def broadcast_position(self, position_data: dict) -> int:
|
||||
"""
|
||||
广播持仓数据
|
||||
|
||||
- **position_data**: 持仓数据字典
|
||||
"""
|
||||
message = {
|
||||
"type": "position",
|
||||
"data": position_data
|
||||
}
|
||||
|
||||
count = 0
|
||||
for conn_id, subscriptions in self.subscriptions.items():
|
||||
if "position" in subscriptions:
|
||||
if await self.send_personal_message(message, conn_id):
|
||||
count += 1
|
||||
|
||||
return count
|
||||
|
||||
async def broadcast_account(self, account_data: dict) -> int:
|
||||
"""
|
||||
广播账户数据
|
||||
|
||||
- **account_data**: 账户数据字典
|
||||
"""
|
||||
message = {
|
||||
"type": "account",
|
||||
"data": account_data
|
||||
}
|
||||
|
||||
count = 0
|
||||
for conn_id, subscriptions in self.subscriptions.items():
|
||||
if "account" in subscriptions:
|
||||
if await self.send_personal_message(message, conn_id):
|
||||
count += 1
|
||||
|
||||
return count
|
||||
|
||||
async def broadcast_log(self, log_data: dict) -> int:
|
||||
"""
|
||||
广播日志数据
|
||||
|
||||
- **log_data**: 日志数据字典
|
||||
"""
|
||||
message = {
|
||||
"type": "log",
|
||||
"data": log_data
|
||||
}
|
||||
|
||||
count = 0
|
||||
for conn_id, subscriptions in self.subscriptions.items():
|
||||
if "log" in subscriptions:
|
||||
if await self.send_personal_message(message, conn_id):
|
||||
count += 1
|
||||
|
||||
return count
|
||||
|
||||
async def broadcast_contract(self, contract_data: dict) -> int:
|
||||
"""
|
||||
广播合约数据
|
||||
|
||||
- **contract_data**: 合约数据字典
|
||||
"""
|
||||
message = {
|
||||
"type": "contract",
|
||||
"data": contract_data
|
||||
}
|
||||
|
||||
count = 0
|
||||
for conn_id, subscriptions in self.subscriptions.items():
|
||||
if "contract" in subscriptions:
|
||||
if await self.send_personal_message(message, conn_id):
|
||||
count += 1
|
||||
|
||||
return count
|
||||
|
||||
|
||||
# 全局连接管理器实例
|
||||
manager = ConnectionManager()
|
||||
|
||||
|
||||
__all__ = ["ConnectionManager", "manager"]
|
||||
@@ -0,0 +1,177 @@
|
||||
"""
|
||||
WebSocket 路由
|
||||
处理 WebSocket 连接和实时数据推送
|
||||
"""
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Query
|
||||
from typing import Optional
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from .manager import manager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.websocket("/ws")
|
||||
async def websocket_endpoint(
|
||||
websocket: WebSocket,
|
||||
token: Optional[str] = Query(None, description="JWT Token")
|
||||
):
|
||||
"""
|
||||
WebSocket 端点
|
||||
|
||||
- **token**: 可选的 JWT Token,用于身份验证
|
||||
|
||||
支持的消息类型:
|
||||
- subscribe: 订阅数据类型(tick, order, trade, position, account, log, contract)
|
||||
- unsubscribe: 取消订阅
|
||||
- subscribe_symbol: 订阅特定品种的行情数据
|
||||
- unsubscribe_symbol: 取消品种订阅
|
||||
- ping: 心跳检测
|
||||
"""
|
||||
# 验证 Token(可选)
|
||||
user = "anonymous"
|
||||
if token:
|
||||
try:
|
||||
from ..deps import verify_token
|
||||
payload = verify_token(token)
|
||||
user = payload.get("sub", "anonymous")
|
||||
except Exception as e:
|
||||
logger.warning(f"WebSocket authentication failed: {e}")
|
||||
await websocket.close(code=1008, reason="Invalid token")
|
||||
return
|
||||
|
||||
# 接受连接
|
||||
connection_id = await manager.connect(websocket, user)
|
||||
|
||||
try:
|
||||
# 发送欢迎消息
|
||||
await manager.send_personal_message({
|
||||
"type": "connected",
|
||||
"connection_id": connection_id,
|
||||
"user": user,
|
||||
"timestamp": datetime.now().isoformat()
|
||||
}, connection_id)
|
||||
|
||||
# 处理消息循环
|
||||
while True:
|
||||
data = await websocket.receive_json()
|
||||
|
||||
message_type = data.get("type")
|
||||
message_data = data.get("data", {})
|
||||
|
||||
if message_type == "subscribe":
|
||||
# 订阅数据类型
|
||||
subscription_type = message_data.get("subscription")
|
||||
if subscription_type:
|
||||
# 支持单个订阅或列表订阅
|
||||
if isinstance(subscription_type, list):
|
||||
subscribed = []
|
||||
for sub_type in subscription_type:
|
||||
if manager.subscribe(connection_id, sub_type):
|
||||
subscribed.append(sub_type)
|
||||
await manager.send_personal_message({
|
||||
"type": "subscribed",
|
||||
"subscriptions": subscribed
|
||||
}, connection_id)
|
||||
else:
|
||||
if manager.subscribe(connection_id, subscription_type):
|
||||
await manager.send_personal_message({
|
||||
"type": "subscribed",
|
||||
"subscription": subscription_type
|
||||
}, connection_id)
|
||||
|
||||
elif message_type == "unsubscribe":
|
||||
# 取消订阅
|
||||
subscription_type = message_data.get("subscription")
|
||||
if subscription_type:
|
||||
# 支持单个取消或列表取消
|
||||
if isinstance(subscription_type, list):
|
||||
unsubscribed = []
|
||||
for sub_type in subscription_type:
|
||||
if manager.unsubscribe(connection_id, sub_type):
|
||||
unsubscribed.append(sub_type)
|
||||
await manager.send_personal_message({
|
||||
"type": "unsubscribed",
|
||||
"subscriptions": unsubscribed
|
||||
}, connection_id)
|
||||
else:
|
||||
if manager.unsubscribe(connection_id, subscription_type):
|
||||
await manager.send_personal_message({
|
||||
"type": "unsubscribed",
|
||||
"subscription": subscription_type
|
||||
}, connection_id)
|
||||
|
||||
elif message_type == "subscribe_symbol":
|
||||
# 订阅品种
|
||||
symbol = message_data.get("symbol")
|
||||
if symbol:
|
||||
# 支持单个品种或列表订阅
|
||||
if isinstance(symbol, list):
|
||||
subscribed_symbols = []
|
||||
for sym in symbol:
|
||||
if manager.subscribe_symbol(connection_id, sym):
|
||||
subscribed_symbols.append(sym)
|
||||
await manager.send_personal_message({
|
||||
"type": "symbol_subscribed",
|
||||
"symbols": subscribed_symbols
|
||||
}, connection_id)
|
||||
else:
|
||||
if manager.subscribe_symbol(connection_id, symbol):
|
||||
await manager.send_personal_message({
|
||||
"type": "symbol_subscribed",
|
||||
"symbol": symbol
|
||||
}, connection_id)
|
||||
|
||||
elif message_type == "unsubscribe_symbol":
|
||||
# 取消品种订阅
|
||||
symbol = message_data.get("symbol")
|
||||
if symbol:
|
||||
# 支持单个品种或列表取消
|
||||
if isinstance(symbol, list):
|
||||
unsubscribed_symbols = []
|
||||
for sym in symbol:
|
||||
if manager.unsubscribe_symbol(connection_id, sym):
|
||||
unsubscribed_symbols.append(sym)
|
||||
await manager.send_personal_message({
|
||||
"type": "symbol_unsubscribed",
|
||||
"symbols": unsubscribed_symbols
|
||||
}, connection_id)
|
||||
else:
|
||||
if manager.unsubscribe_symbol(connection_id, symbol):
|
||||
await manager.send_personal_message({
|
||||
"type": "symbol_unsubscribed",
|
||||
"symbol": symbol
|
||||
}, connection_id)
|
||||
|
||||
elif message_type == "ping":
|
||||
# 心跳
|
||||
await manager.send_personal_message({
|
||||
"type": "pong"
|
||||
}, connection_id)
|
||||
|
||||
else:
|
||||
await manager.send_personal_message({
|
||||
"type": "error",
|
||||
"message": f"Unknown message type: {message_type}"
|
||||
}, connection_id)
|
||||
|
||||
except WebSocketDisconnect:
|
||||
logger.info(f"WebSocket disconnected normally: {connection_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"WebSocket error for {connection_id}: {e}")
|
||||
finally:
|
||||
await manager.disconnect(connection_id)
|
||||
|
||||
|
||||
@router.get("/ws/status")
|
||||
async def websocket_status():
|
||||
"""
|
||||
获取 WebSocket 连接状态
|
||||
"""
|
||||
return {
|
||||
"active_connections": manager.get_connection_count(),
|
||||
"users": len(manager.user_connections)
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
# WebSocket 测试指南
|
||||
|
||||
## 概述
|
||||
|
||||
Phase 3 实现了 WebSocket 实时数据推送功能,包括:
|
||||
- WebSocket 连接管理
|
||||
- 事件监听器(Tick, Order, Trade, Position, Account, Log, Contract)
|
||||
- 订阅管理
|
||||
- 心跳机制
|
||||
|
||||
## 启动服务器
|
||||
|
||||
```bash
|
||||
cd /Users/chufeng/.openclaw/sanguo_projects/sanguo_vnpy_v2
|
||||
|
||||
# 启动 FastAPI 服务器
|
||||
uvicorn sanguo_web.api:app --host 0.0.0.0 --port 8000 --reload
|
||||
```
|
||||
|
||||
## 测试方法
|
||||
|
||||
### 方法 1: 使用 HTML 测试页面
|
||||
|
||||
1. 在浏览器中打开:
|
||||
```
|
||||
file:///Users/chufeng/.openclaw/sanguo_projects/sanguo_vnpy_v2/tests/websocket_test.html
|
||||
```
|
||||
|
||||
2. 点击"连接"按钮建立 WebSocket 连接
|
||||
|
||||
3. 选择要订阅的数据类型:
|
||||
- 行情 (Tick)
|
||||
- 订单 (Order)
|
||||
- 成交 (Trade)
|
||||
- 持仓 (Position)
|
||||
- 账户 (Account)
|
||||
- 日志 (Log)
|
||||
- 合约 (Contract)
|
||||
|
||||
4. 可选:输入品种代码(如:IF2501.CFFEX,IH2501.CFFEX)并订阅
|
||||
|
||||
5. 查看实时消息日志
|
||||
|
||||
### 方法 2: 使用 Python 测试脚本
|
||||
|
||||
```bash
|
||||
# 安装依赖
|
||||
pip install websockets
|
||||
|
||||
# 运行测试
|
||||
cd /Users/chufeng/.openclaw/sanguo_projects/sanguo_vnpy_v2
|
||||
python tests/test_websocket.py
|
||||
```
|
||||
|
||||
测试脚本会执行以下测试:
|
||||
1. 基本连接测试
|
||||
2. 订阅功能测试
|
||||
3. 消息接收测试
|
||||
4. 心跳机制测试
|
||||
5. 错误处理测试
|
||||
6. 认证连接测试
|
||||
|
||||
### 方法 3: 使用 wscat 命令行工具
|
||||
|
||||
```bash
|
||||
# 安装 wscat
|
||||
npm install -g wscat
|
||||
|
||||
# 连接 WebSocket
|
||||
wscat -c ws://localhost:8000/ws
|
||||
|
||||
# 发送订阅消息
|
||||
{"type":"subscribe","data":{"subscription":["tick","order","trade"]}}
|
||||
|
||||
# 发送心跳
|
||||
{"type":"ping","data":{}}
|
||||
|
||||
# 订阅品种
|
||||
{"type":"subscribe_symbol","data":{"symbol":["IF2501.CFFEX"]}}
|
||||
```
|
||||
|
||||
### 方法 4: 使用 JavaScript 控制台
|
||||
|
||||
在任何网页中打开浏览器控制台,运行:
|
||||
|
||||
```javascript
|
||||
// 创建 WebSocket 连接
|
||||
const ws = new WebSocket('ws://localhost:8000/ws');
|
||||
|
||||
// 监听连接事件
|
||||
ws.onopen = () => {
|
||||
console.log('Connected');
|
||||
|
||||
// 订阅行情数据
|
||||
ws.send(JSON.stringify({
|
||||
type: 'subscribe',
|
||||
data: { subscription: ['tick', 'order', 'trade'] }
|
||||
}));
|
||||
};
|
||||
|
||||
// 监听消息
|
||||
ws.onmessage = (event) => {
|
||||
const message = JSON.parse(event.data);
|
||||
console.log('Received:', message);
|
||||
};
|
||||
|
||||
// 发送心跳
|
||||
ws.send(JSON.stringify({ type: 'ping', data: {} }));
|
||||
```
|
||||
|
||||
## WebSocket 消息格式
|
||||
|
||||
### 订阅消息(客户端 -> 服务器)
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "subscribe",
|
||||
"data": {
|
||||
"subscription": ["tick", "order", "trade"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 订阅品种(客户端 -> 服务器)
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "subscribe_symbol",
|
||||
"data": {
|
||||
"symbol": ["IF2501.CFFEX", "IH2501.CFFEX"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 心跳消息(客户端 -> 服务器)
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "ping",
|
||||
"data": {}
|
||||
}
|
||||
```
|
||||
|
||||
### 推送消息(服务器 -> 客户端)
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "tick",
|
||||
"data": {
|
||||
"vt_symbol": "IF2501.CFFEX",
|
||||
"symbol": "IF2501",
|
||||
"exchange": "CFFEX",
|
||||
"last_price": 3500.0,
|
||||
"bid_price_1": 3499.0,
|
||||
"ask_price_1": 3501.0,
|
||||
"volume": 12345,
|
||||
"datetime": "2025-01-01T09:30:00"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 订阅类型
|
||||
|
||||
| 类型 | 说明 | 事件类型 |
|
||||
|------|------|----------|
|
||||
| tick | 行情数据 | eTick. |
|
||||
| order | 订单数据 | eOrder. |
|
||||
| trade | 成交数据 | eTrade. |
|
||||
| position | 持仓数据 | ePosition. |
|
||||
| account | 账户数据 | eAccount. |
|
||||
| log | 日志数据 | eLog |
|
||||
| contract | 合约数据 | eContract. |
|
||||
|
||||
## 验收标准
|
||||
|
||||
Phase 3 完成验收:
|
||||
- [x] WebSocket 管理器完成
|
||||
- [x] WebSocket 路由完成
|
||||
- [x] 事件监听器完成
|
||||
- [x] 心跳机制完成
|
||||
- [x] 订阅管理完成
|
||||
- [x] WebSocket 集成到 FastAPI
|
||||
- [x] 测试脚本和页面创建
|
||||
|
||||
## 故障排查
|
||||
|
||||
### 连接失败
|
||||
1. 检查服务器是否运行
|
||||
2. 检查 URL 是否正确
|
||||
3. 检查防火墙设置
|
||||
|
||||
### 没有收到消息
|
||||
1. 检查是否已订阅相应数据类型
|
||||
2. 检查 VeighNa 网关是否连接
|
||||
3. 检查是否有行情数据
|
||||
|
||||
### 心跳无响应
|
||||
1. 检查服务器负载
|
||||
2. 检查网络连接稳定性
|
||||
@@ -0,0 +1,437 @@
|
||||
"""
|
||||
Sanguo VeighNa Web API 测试套件
|
||||
测试所有 REST API 端点
|
||||
"""
|
||||
import pytest
|
||||
import asyncio
|
||||
from httpx import AsyncClient, ASGITransport
|
||||
from fastapi import status
|
||||
|
||||
# 导入应用
|
||||
import sys
|
||||
import os
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from sanguo_web.api import app
|
||||
|
||||
|
||||
# ============================================
|
||||
# Fixtures
|
||||
# ============================================
|
||||
|
||||
@pytest.fixture
|
||||
async def client():
|
||||
"""创建测试客户端"""
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac:
|
||||
yield ac
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def auth_token(client):
|
||||
"""获取认证 Token"""
|
||||
response = await client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": "admin", "password": "admin123"}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
return data.get("access_token")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def auth_headers(auth_token):
|
||||
"""获取认证请求头"""
|
||||
return {"Authorization": f"Bearer {auth_token}"}
|
||||
|
||||
|
||||
# ============================================
|
||||
# 系统端点测试
|
||||
# ============================================
|
||||
|
||||
class TestSystemEndpoints:
|
||||
"""系统端点测试"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_root(self, client):
|
||||
"""测试根路径"""
|
||||
response = await client.get("/")
|
||||
assert response.status_code == 200
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health_check(self, client):
|
||||
"""测试健康检查"""
|
||||
response = await client.get("/health")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "status" in data
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_root(self, client):
|
||||
"""测试 API 根路径"""
|
||||
response = await client.get("/api")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "name" in data
|
||||
assert "version" in data
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_system_info(self, client, auth_headers):
|
||||
"""测试系统信息"""
|
||||
response = await client.get(
|
||||
"/api/v1/system/info",
|
||||
headers=auth_headers
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "version" in data or "status" in data
|
||||
|
||||
|
||||
# ============================================
|
||||
# 认证端点测试
|
||||
# ============================================
|
||||
|
||||
class TestAuthEndpoints:
|
||||
"""认证端点测试"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_success(self, client):
|
||||
"""测试成功登录"""
|
||||
response = await client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": "admin", "password": "admin123"}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "access_token" in data
|
||||
assert "token_type" in data
|
||||
assert data["token_type"] == "bearer"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_wrong_password(self, client):
|
||||
"""测试错误密码"""
|
||||
response = await client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": "admin", "password": "wrong_password"}
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_invalid_user(self, client):
|
||||
"""测试无效用户"""
|
||||
response = await client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": "invalid_user", "password": "admin123"}
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_token_valid(self, client, auth_token):
|
||||
"""测试有效 Token 验证"""
|
||||
response = await client.post(
|
||||
"/api/v1/auth/verify",
|
||||
json={"token": auth_token}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["valid"] is True
|
||||
assert "user_info" in data
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_token_invalid(self, client):
|
||||
"""测试无效 Token 验证"""
|
||||
response = await client.post(
|
||||
"/api/v1/auth/verify",
|
||||
json={"token": "invalid_token"}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["valid"] is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_me(self, client, auth_headers):
|
||||
"""测试获取当前用户信息"""
|
||||
response = await client.get(
|
||||
"/api/v1/auth/me",
|
||||
headers=auth_headers
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "username" in data
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_logout(self, client, auth_headers):
|
||||
"""测试登出"""
|
||||
response = await client.post(
|
||||
"/api/v1/auth/logout",
|
||||
headers=auth_headers
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
# ============================================
|
||||
# 网关端点测试
|
||||
# ============================================
|
||||
|
||||
class TestGatewayEndpoints:
|
||||
"""网关端点测试"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_available_gateways(self, client, auth_headers):
|
||||
"""测试获取可用网关列表"""
|
||||
response = await client.get(
|
||||
"/api/v1/gateway/available",
|
||||
headers=auth_headers
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert isinstance(data, list)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_connected_gateways(self, client, auth_headers):
|
||||
"""测试获取已连接网关列表"""
|
||||
response = await client.get(
|
||||
"/api/v1/gateway/connected",
|
||||
headers=auth_headers
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert isinstance(data, list)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_gateway_setting(self, client, auth_headers):
|
||||
"""测试获取网关配置模板"""
|
||||
response = await client.get(
|
||||
"/api/v1/gateway/setting/CTP",
|
||||
headers=auth_headers
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert isinstance(data, dict)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connect_gateway(self, client, auth_headers):
|
||||
"""测试连接网关(模拟)"""
|
||||
response = await client.post(
|
||||
"/api/v1/gateway/connect",
|
||||
headers=auth_headers,
|
||||
json={
|
||||
"gateway_name": "CTP_TEST",
|
||||
"gateway_type": "ctp",
|
||||
"setting": {
|
||||
"用户名": "test_user",
|
||||
"密码": "test_pass",
|
||||
"经纪商代码": "9999",
|
||||
"交易服务器": "tcp://test服务器:41205",
|
||||
"行情服务器": "tcp://test服务器:41213",
|
||||
}
|
||||
}
|
||||
)
|
||||
# 在 Mock 模式下可能返回 200 或 500
|
||||
assert response.status_code in [200, 500]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unauthorized_access(self, client):
|
||||
"""测试未授权访问"""
|
||||
response = await client.get("/api/v1/gateway/available")
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
# ============================================
|
||||
# 行情端点测试
|
||||
# ============================================
|
||||
|
||||
class TestMarketEndpoints:
|
||||
"""行情端点测试"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_ticks(self, client, auth_headers):
|
||||
"""测试获取行情数据"""
|
||||
response = await client.get(
|
||||
"/api/v1/market/ticks",
|
||||
headers=auth_headers
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "ticks" in data
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subscribe(self, client, auth_headers):
|
||||
"""测试订阅行情"""
|
||||
response = await client.post(
|
||||
"/api/v1/market/subscribe",
|
||||
headers=auth_headers,
|
||||
json={
|
||||
"symbol": "IF2501",
|
||||
"exchange": "CFFEX"
|
||||
}
|
||||
)
|
||||
assert response.status_code in [200, 202]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unsubscribe(self, client, auth_headers):
|
||||
"""测试取消订阅"""
|
||||
response = await client.post(
|
||||
"/api/v1/market/unsubscribe",
|
||||
headers=auth_headers,
|
||||
json={
|
||||
"symbol": "IF2501",
|
||||
"exchange": "CFFEX"
|
||||
}
|
||||
)
|
||||
assert response.status_code in [200, 202]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_contracts(self, client, auth_headers):
|
||||
"""测试获取合约列表"""
|
||||
response = await client.get(
|
||||
"/api/v1/market/contracts",
|
||||
headers=auth_headers
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "contracts" in data
|
||||
|
||||
|
||||
# ============================================
|
||||
# 交易端点测试
|
||||
# ============================================
|
||||
|
||||
class TestTradingEndpoints:
|
||||
"""交易端点测试"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_accounts(self, client, auth_headers):
|
||||
"""测试获取账户信息"""
|
||||
response = await client.get(
|
||||
"/api/v1/trading/accounts",
|
||||
headers=auth_headers
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert isinstance(data, list)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_positions(self, client, auth_headers):
|
||||
"""测试获取持仓信息"""
|
||||
response = await client.get(
|
||||
"/api/v1/trading/positions",
|
||||
headers=auth_headers
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert isinstance(data, list)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_orders(self, client, auth_headers):
|
||||
"""测试获取委托列表"""
|
||||
response = await client.get(
|
||||
"/api/v1/trading/orders",
|
||||
headers=auth_headers
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert isinstance(data, list)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_active_orders(self, client, auth_headers):
|
||||
"""测试获取活动委托"""
|
||||
response = await client.get(
|
||||
"/api/v1/trading/orders/active",
|
||||
headers=auth_headers
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert isinstance(data, list)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_order(self, client, auth_headers):
|
||||
"""测试发送订单(模拟)"""
|
||||
response = await client.post(
|
||||
"/api/v1/trading/orders",
|
||||
headers=auth_headers,
|
||||
json={
|
||||
"symbol": "IF2501",
|
||||
"exchange": "CFFEX",
|
||||
"direction": "buy",
|
||||
"order_type": "limit",
|
||||
"volume": 1,
|
||||
"price": 3500.0
|
||||
}
|
||||
)
|
||||
# 在没有连接网关的情况下可能返回错误
|
||||
assert response.status_code in [200, 500]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_trades(self, client, auth_headers):
|
||||
"""测试获取成交记录"""
|
||||
response = await client.get(
|
||||
"/api/v1/trading/trades",
|
||||
headers=auth_headers
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "trades" in data
|
||||
assert "total" in data
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_account_summary(self, client, auth_headers):
|
||||
"""测试获取账户综合信息"""
|
||||
response = await client.get(
|
||||
"/api/v1/trading/account",
|
||||
headers=auth_headers
|
||||
)
|
||||
assert response.status_code in [200, 404] # 可能没有账户数据
|
||||
|
||||
|
||||
# ============================================
|
||||
# 策略端点测试
|
||||
# ============================================
|
||||
|
||||
class TestStrategyEndpoints:
|
||||
"""策略端点测试"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_strategies(self, client, auth_headers):
|
||||
"""测试获取策略列表"""
|
||||
response = await client.get(
|
||||
"/api/v1/strategy/list",
|
||||
headers=auth_headers
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "strategies" in data
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_init_strategy(self, client, auth_headers):
|
||||
"""测试初始化策略"""
|
||||
response = await client.post(
|
||||
"/api/v1/strategy/test_strategy/init",
|
||||
headers=auth_headers
|
||||
)
|
||||
assert response.status_code in [200, 404]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_strategy(self, client, auth_headers):
|
||||
"""测试启动策略"""
|
||||
response = await client.post(
|
||||
"/api/v1/strategy/test_strategy/start",
|
||||
headers=auth_headers
|
||||
)
|
||||
assert response.status_code in [200, 404]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_strategy(self, client, auth_headers):
|
||||
"""测试停止策略"""
|
||||
response = await client.post(
|
||||
"/api/v1/strategy/test_strategy/stop",
|
||||
headers=auth_headers
|
||||
)
|
||||
assert response.status_code in [200, 404]
|
||||
|
||||
|
||||
# ============================================
|
||||
# 运行测试
|
||||
# ============================================
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v", "--tb=short"])
|
||||
@@ -0,0 +1,416 @@
|
||||
"""
|
||||
WebSocket 客户端测试脚本
|
||||
用于测试 WebSocket 实时数据推送功能
|
||||
"""
|
||||
import asyncio
|
||||
import websockets
|
||||
import json
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class WebSocketTestClient:
|
||||
"""WebSocket 测试客户端"""
|
||||
|
||||
def __init__(self, url: str = "ws://localhost:8000/ws"):
|
||||
"""
|
||||
初始化测试客户端
|
||||
|
||||
- **url**: WebSocket 服务器 URL
|
||||
"""
|
||||
self.url = url
|
||||
self.websocket: Optional[websockets.WebSocketClientProtocol] = None
|
||||
self.connected = False
|
||||
|
||||
async def connect(self, token: Optional[str] = None):
|
||||
"""
|
||||
连接到 WebSocket 服务器
|
||||
|
||||
- **token**: 可选的 JWT Token
|
||||
"""
|
||||
uri = f"{self.url}"
|
||||
if token:
|
||||
uri += f"?token={token}"
|
||||
|
||||
try:
|
||||
self.websocket = await websockets.connect(uri)
|
||||
self.connected = True
|
||||
print(f"[+] Connected to {self.url}")
|
||||
|
||||
# 接收欢迎消息
|
||||
welcome_msg = await self.websocket.recv()
|
||||
print(f"[+] Welcome message: {welcome_msg}")
|
||||
return json.loads(welcome_msg)
|
||||
except Exception as e:
|
||||
print(f"[-] Failed to connect: {e}")
|
||||
raise
|
||||
|
||||
async def disconnect(self):
|
||||
"""断开连接"""
|
||||
if self.websocket:
|
||||
await self.websocket.close()
|
||||
self.connected = False
|
||||
print("[+] Disconnected from server")
|
||||
|
||||
async def subscribe(self, subscription_types: list):
|
||||
"""
|
||||
订阅数据类型
|
||||
|
||||
- **subscription_types**: 订阅类型列表,如 ["tick", "order", "trade"]
|
||||
"""
|
||||
message = {
|
||||
"type": "subscribe",
|
||||
"data": {
|
||||
"subscription": subscription_types
|
||||
}
|
||||
}
|
||||
await self.send(message)
|
||||
print(f"[+] Subscribed to: {subscription_types}")
|
||||
|
||||
async def unsubscribe(self, subscription_types: list):
|
||||
"""
|
||||
取消订阅
|
||||
|
||||
- **subscription_types**: 订阅类型列表
|
||||
"""
|
||||
message = {
|
||||
"type": "unsubscribe",
|
||||
"data": {
|
||||
"subscription": subscription_types
|
||||
}
|
||||
}
|
||||
await self.send(message)
|
||||
print(f"[+] Unsubscribed from: {subscription_types}")
|
||||
|
||||
async def subscribe_symbol(self, symbols: list):
|
||||
"""
|
||||
订阅品种行情
|
||||
|
||||
- **symbols**: 品种代码列表,如 ["IF2501.CFFEX", "IH2501.CFFEX"]
|
||||
"""
|
||||
message = {
|
||||
"type": "subscribe_symbol",
|
||||
"data": {
|
||||
"symbol": symbols
|
||||
}
|
||||
}
|
||||
await self.send(message)
|
||||
print(f"[+] Subscribed to symbols: {symbols}")
|
||||
|
||||
async def unsubscribe_symbol(self, symbols: list):
|
||||
"""
|
||||
取消品种订阅
|
||||
|
||||
- **symbols**: 品种代码列表
|
||||
"""
|
||||
message = {
|
||||
"type": "unsubscribe_symbol",
|
||||
"data": {
|
||||
"symbol": symbols
|
||||
}
|
||||
}
|
||||
await self.send(message)
|
||||
print(f"[+] Unsubscribed from symbols: {symbols}")
|
||||
|
||||
async def send_ping(self):
|
||||
"""发送心跳"""
|
||||
message = {
|
||||
"type": "ping",
|
||||
"data": {}
|
||||
}
|
||||
await self.send(message)
|
||||
print("[+] Ping sent")
|
||||
|
||||
async def send(self, message: dict):
|
||||
"""
|
||||
发送消息
|
||||
|
||||
- **message**: 消息字典
|
||||
"""
|
||||
if not self.websocket or not self.connected:
|
||||
raise Exception("Not connected to WebSocket server")
|
||||
|
||||
await self.websocket.send(json.dumps(message))
|
||||
|
||||
async def receive(self, timeout: Optional[float] = None):
|
||||
"""
|
||||
接收消息
|
||||
|
||||
- **timeout**: 超时时间(秒)
|
||||
"""
|
||||
if not self.websocket or not self.connected:
|
||||
raise Exception("Not connected to WebSocket server")
|
||||
|
||||
try:
|
||||
message = await asyncio.wait_for(self.websocket.recv(), timeout=timeout)
|
||||
return json.loads(message)
|
||||
except asyncio.TimeoutError:
|
||||
return None
|
||||
|
||||
async def listen(self, duration: int = 10, print_messages: bool = True):
|
||||
"""
|
||||
监听消息
|
||||
|
||||
- **duration**: 监听时长(秒)
|
||||
- **print_messages**: 是否打印消息
|
||||
"""
|
||||
print(f"\n[*] Listening for messages ({duration}s)...")
|
||||
messages = []
|
||||
|
||||
try:
|
||||
while True:
|
||||
message = await asyncio.wait_for(self.websocket.recv(), timeout=duration)
|
||||
data = json.loads(message)
|
||||
messages.append(data)
|
||||
|
||||
if print_messages:
|
||||
msg_type = data.get("type", "unknown")
|
||||
print(f"[*] Received {msg_type}: {json.dumps(data, ensure_ascii=False)[:200]}...")
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
print(f"\n[+] Listening finished. Received {len(messages)} messages")
|
||||
return messages
|
||||
|
||||
|
||||
async def test_basic_connection():
|
||||
"""测试基本连接功能"""
|
||||
print("\n" + "="*50)
|
||||
print("TEST 1: Basic Connection")
|
||||
print("="*50)
|
||||
|
||||
client = WebSocketTestClient()
|
||||
|
||||
try:
|
||||
# 连接
|
||||
await client.connect()
|
||||
await asyncio.sleep(1)
|
||||
|
||||
# 测试心跳
|
||||
await client.send_ping()
|
||||
response = await client.receive(timeout=2)
|
||||
if response and response.get("type") == "pong":
|
||||
print("[+] Ping/Pong test passed")
|
||||
|
||||
# 断开
|
||||
await client.disconnect()
|
||||
print("[+] Test passed")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"[-] Test failed: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def test_subscription():
|
||||
"""测试订阅功能"""
|
||||
print("\n" + "="*50)
|
||||
print("TEST 2: Subscription")
|
||||
print("="*50)
|
||||
|
||||
client = WebSocketTestClient()
|
||||
|
||||
try:
|
||||
# 连接
|
||||
await client.connect()
|
||||
|
||||
# 订阅多个数据类型
|
||||
await client.subscribe(["tick", "order", "trade", "position", "account", "log"])
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
# 订阅品种
|
||||
await client.subscribe_symbol(["IF2501.CFFEX", "IH2501.CFFEX"])
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
# 取消订阅
|
||||
await client.unsubscribe(["log"])
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
# 断开
|
||||
await client.disconnect()
|
||||
print("[+] Test passed")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"[-] Test failed: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def test_message_reception():
|
||||
"""测试消息接收功能"""
|
||||
print("\n" + "="*50)
|
||||
print("TEST 3: Message Reception")
|
||||
print("="*50)
|
||||
|
||||
client = WebSocketTestClient()
|
||||
|
||||
try:
|
||||
# 连接
|
||||
await client.connect()
|
||||
|
||||
# 订阅所有类型
|
||||
await client.subscribe(["tick", "order", "trade", "position", "account", "log", "contract"])
|
||||
|
||||
# 监听消息
|
||||
messages = await client.listen(duration=5)
|
||||
|
||||
print(f"\n[+] Received messages by type:")
|
||||
msg_types = {}
|
||||
for msg in messages:
|
||||
msg_type = msg.get("type", "unknown")
|
||||
msg_types[msg_type] = msg_types.get(msg_type, 0) + 1
|
||||
|
||||
for msg_type, count in msg_types.items():
|
||||
print(f" - {msg_type}: {count}")
|
||||
|
||||
# 断开
|
||||
await client.disconnect()
|
||||
print("[+] Test passed")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"[-] Test failed: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def test_heartbeat():
|
||||
"""测试心跳机制"""
|
||||
print("\n" + "="*50)
|
||||
print("TEST 4: Heartbeat")
|
||||
print("="*50)
|
||||
|
||||
client = WebSocketTestClient()
|
||||
|
||||
try:
|
||||
# 连接
|
||||
await client.connect()
|
||||
|
||||
# 发送多次心跳
|
||||
for i in range(5):
|
||||
await client.send_ping()
|
||||
response = await client.receive(timeout=2)
|
||||
if response and response.get("type") == "pong":
|
||||
print(f"[+] Heartbeat {i+1}/5 successful")
|
||||
await asyncio.sleep(1)
|
||||
|
||||
# 断开
|
||||
await client.disconnect()
|
||||
print("[+] Test passed")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"[-] Test failed: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def test_error_handling():
|
||||
"""测试错误处理"""
|
||||
print("\n" + "="*50)
|
||||
print("TEST 5: Error Handling")
|
||||
print("="*50)
|
||||
|
||||
client = WebSocketTestClient()
|
||||
|
||||
try:
|
||||
# 连接
|
||||
await client.connect()
|
||||
|
||||
# 发送未知消息类型
|
||||
await client.send({
|
||||
"type": "unknown_type",
|
||||
"data": {}
|
||||
})
|
||||
|
||||
response = await client.receive(timeout=2)
|
||||
if response and response.get("type") == "error":
|
||||
print("[+] Error response received correctly")
|
||||
|
||||
# 断开
|
||||
await client.disconnect()
|
||||
print("[+] Test passed")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"[-] Test failed: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def test_authenticated_connection():
|
||||
"""测试认证连接"""
|
||||
print("\n" + "="*50)
|
||||
print("TEST 6: Authenticated Connection")
|
||||
print("="*50)
|
||||
|
||||
# 注意:需要有效的 JWT Token
|
||||
# 这里测试无效 Token 的情况
|
||||
client = WebSocketTestClient()
|
||||
|
||||
try:
|
||||
# 使用无效 Token 连接
|
||||
await client.connect(token="invalid_token")
|
||||
print("[-] Should have failed with invalid token")
|
||||
await client.disconnect()
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
print(f"[+] Correctly rejected invalid token: {e}")
|
||||
return True
|
||||
|
||||
|
||||
async def run_all_tests():
|
||||
"""运行所有测试"""
|
||||
print("\n" + "="*50)
|
||||
print("WebSocket Test Suite")
|
||||
print("="*50)
|
||||
|
||||
tests = [
|
||||
("Basic Connection", test_basic_connection),
|
||||
("Subscription", test_subscription),
|
||||
("Message Reception", test_message_reception),
|
||||
("Heartbeat", test_heartbeat),
|
||||
("Error Handling", test_error_handling),
|
||||
("Authenticated Connection", test_authenticated_connection),
|
||||
]
|
||||
|
||||
results = []
|
||||
|
||||
for name, test_func in tests:
|
||||
try:
|
||||
result = await test_func()
|
||||
results.append((name, result))
|
||||
except Exception as e:
|
||||
print(f"[-] Test '{name}' crashed: {e}")
|
||||
results.append((name, False))
|
||||
|
||||
await asyncio.sleep(1)
|
||||
|
||||
# 汇总结果
|
||||
print("\n" + "="*50)
|
||||
print("Test Results Summary")
|
||||
print("="*50)
|
||||
|
||||
passed = sum(1 for _, result in results if result)
|
||||
total = len(results)
|
||||
|
||||
for name, result in results:
|
||||
status = "✓ PASS" if result else "✗ FAIL"
|
||||
print(f"{status}: {name}")
|
||||
|
||||
print(f"\nTotal: {passed}/{total} tests passed")
|
||||
|
||||
return passed == total
|
||||
|
||||
|
||||
def main():
|
||||
"""主函数"""
|
||||
# 运行测试
|
||||
success = asyncio.run(run_all_tests())
|
||||
|
||||
if success:
|
||||
print("\n[+] All tests passed!")
|
||||
exit(0)
|
||||
else:
|
||||
print("\n[-] Some tests failed")
|
||||
exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,209 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
WebSocket 验证脚本
|
||||
验证 WebSocket 模块的基本结构和功能
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
|
||||
# 添加项目路径
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '../vnpy_v4.4.0'))
|
||||
|
||||
|
||||
def test_imports():
|
||||
"""测试模块导入"""
|
||||
print("Testing imports...")
|
||||
|
||||
try:
|
||||
from sanguo_web.websocket.manager import ConnectionManager, manager
|
||||
print(" ✓ ConnectionManager imported")
|
||||
|
||||
from sanguo_web.websocket.routes import router
|
||||
print(" ✓ WebSocket router imported")
|
||||
|
||||
from sanguo_web.websocket.events import (
|
||||
EventMonitorManager,
|
||||
serialize_tick_data,
|
||||
serialize_order_data,
|
||||
serialize_trade_data
|
||||
)
|
||||
print(" ✓ Event monitors imported")
|
||||
|
||||
from sanguo_web.websocket import (
|
||||
ConnectionManager as CM,
|
||||
manager as mgr,
|
||||
router as ws_router,
|
||||
EventMonitorManager as EMM
|
||||
)
|
||||
print(" ✓ WebSocket module __init__ exports")
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f" ✗ Import failed: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def test_manager():
|
||||
"""测试连接管理器"""
|
||||
print("\nTesting ConnectionManager...")
|
||||
|
||||
try:
|
||||
from sanguo_web.websocket.manager import ConnectionManager
|
||||
|
||||
manager = ConnectionManager()
|
||||
|
||||
# 测试连接计数
|
||||
count = manager.get_connection_count()
|
||||
assert count == 0, f"Expected 0 connections, got {count}"
|
||||
print(" ✓ Connection count initialized correctly")
|
||||
|
||||
# 测试用户连接计数
|
||||
user_count = manager.get_user_connection_count("test_user")
|
||||
assert user_count == 0, f"Expected 0 user connections, got {user_count}"
|
||||
print(" ✓ User connection count initialized correctly")
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f" ✗ Manager test failed: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def test_serialization():
|
||||
"""测试数据序列化函数"""
|
||||
print("\nTesting serialization functions...")
|
||||
|
||||
try:
|
||||
from sanguo_web.websocket.events import serialize_datetime
|
||||
from datetime import datetime
|
||||
|
||||
# 测试 datetime 序列化
|
||||
dt = datetime(2025, 1, 1, 12, 30, 45)
|
||||
serialized = serialize_datetime(dt)
|
||||
assert serialized == "2025-01-01T12:30:45", f"Expected ISO format, got {serialized}"
|
||||
print(" ✓ datetime serialization works")
|
||||
|
||||
# 测试 None 处理
|
||||
none_result = serialize_datetime(None)
|
||||
assert none_result is None, f"Expected None for None input, got {none_result}"
|
||||
print(" ✓ None datetime handled correctly")
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f" ✗ Serialization test failed: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def test_router():
|
||||
"""测试路由"""
|
||||
print("\nTesting WebSocket router...")
|
||||
|
||||
try:
|
||||
from sanguo_web.websocket.routes import router
|
||||
|
||||
# 检查路由
|
||||
routes = [route.path for route in router.routes]
|
||||
assert "/ws" in routes, "WebSocket route not found"
|
||||
print(" ✓ WebSocket /ws route exists")
|
||||
|
||||
assert "/ws/status" in routes, "WebSocket status route not found"
|
||||
print(" ✓ WebSocket /ws/status route exists")
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f" ✗ Router test failed: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def test_event_monitor_classes():
|
||||
"""测试事件监听器类"""
|
||||
print("\nTesting event monitor classes...")
|
||||
|
||||
try:
|
||||
from sanguo_web.websocket.events import (
|
||||
TickEventMonitor,
|
||||
OrderEventMonitor,
|
||||
TradeEventMonitor,
|
||||
PositionEventMonitor,
|
||||
AccountEventMonitor,
|
||||
LogEventMonitor,
|
||||
ContractEventMonitor,
|
||||
EventMonitorManager
|
||||
)
|
||||
|
||||
# 检查类是否具有必要的方法
|
||||
monitors = [
|
||||
TickEventMonitor,
|
||||
OrderEventMonitor,
|
||||
TradeEventMonitor,
|
||||
PositionEventMonitor,
|
||||
AccountEventMonitor,
|
||||
LogEventMonitor,
|
||||
ContractEventMonitor
|
||||
]
|
||||
|
||||
for monitor_class in monitors:
|
||||
# 检查是否有 stop 方法
|
||||
assert hasattr(monitor_class, 'stop'), f"{monitor_class.__name__} missing stop method"
|
||||
print(f" ✓ {monitor_class.__name__} has stop method")
|
||||
|
||||
# 检查 EventMonitorManager
|
||||
assert hasattr(EventMonitorManager, 'start_all'), "EventMonitorManager missing start_all method"
|
||||
assert hasattr(EventMonitorManager, 'stop_all'), "EventMonitorManager missing stop_all method"
|
||||
print(" ✓ EventMonitorManager has start_all and stop_all methods")
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f" ✗ Event monitor test failed: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
"""主测试函数"""
|
||||
print("="*50)
|
||||
print("WebSocket Phase 3 Verification")
|
||||
print("="*50)
|
||||
|
||||
tests = [
|
||||
("Import Test", test_imports),
|
||||
("Manager Test", test_manager),
|
||||
("Serialization Test", test_serialization),
|
||||
("Router Test", test_router),
|
||||
("Event Monitor Test", test_event_monitor_classes),
|
||||
]
|
||||
|
||||
results = []
|
||||
|
||||
for name, test_func in tests:
|
||||
print(f"\n{name}:")
|
||||
try:
|
||||
result = test_func()
|
||||
results.append((name, result))
|
||||
except Exception as e:
|
||||
print(f" ✗ Test crashed: {e}")
|
||||
results.append((name, False))
|
||||
|
||||
# 汇总结果
|
||||
print("\n" + "="*50)
|
||||
print("Test Results")
|
||||
print("="*50)
|
||||
|
||||
passed = sum(1 for _, result in results if result)
|
||||
total = len(results)
|
||||
|
||||
for name, result in results:
|
||||
status = "✓ PASS" if result else "✗ FAIL"
|
||||
print(f"{status}: {name}")
|
||||
|
||||
print(f"\nTotal: {passed}/{total} tests passed")
|
||||
|
||||
if passed == total:
|
||||
print("\n✓ All tests passed! Phase 3 implementation verified.")
|
||||
return 0
|
||||
else:
|
||||
print(f"\n✗ {total - passed} test(s) failed")
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,565 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>WebSocket 测试 - Sanguo VeighNa</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
background: #f5f7fa;
|
||||
color: #333;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: #2c3e50;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.panel h2 {
|
||||
font-size: 18px;
|
||||
color: #34495e;
|
||||
margin-bottom: 15px;
|
||||
border-bottom: 2px solid #ecf0f1;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.connection-controls {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
input[type="text"] {
|
||||
padding: 10px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
button.primary {
|
||||
background: #3498db;
|
||||
color: white;
|
||||
}
|
||||
|
||||
button.primary:hover {
|
||||
background: #2980b9;
|
||||
}
|
||||
|
||||
button.danger {
|
||||
background: #e74c3c;
|
||||
color: white;
|
||||
}
|
||||
|
||||
button.danger:hover {
|
||||
background: #c0392b;
|
||||
}
|
||||
|
||||
button.success {
|
||||
background: #2ecc71;
|
||||
color: white;
|
||||
}
|
||||
|
||||
button.success:hover {
|
||||
background: #27ae60;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.status {
|
||||
display: inline-block;
|
||||
padding: 5px 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.status.connected {
|
||||
background: #2ecc71;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.status.disconnected {
|
||||
background: #e74c3c;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.subscription-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
gap: 10px;
|
||||
margin: 15px 0;
|
||||
}
|
||||
|
||||
.checkbox-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.checkbox-item input[type="checkbox"] {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.symbol-input {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin: 15px 0;
|
||||
}
|
||||
|
||||
.symbol-input input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.log-container {
|
||||
background: #2c3e50;
|
||||
color: #ecf0f1;
|
||||
border-radius: 4px;
|
||||
padding: 15px;
|
||||
height: 400px;
|
||||
overflow-y: auto;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.log-entry {
|
||||
margin-bottom: 5px;
|
||||
padding: 3px 0;
|
||||
}
|
||||
|
||||
.log-entry.sent {
|
||||
color: #3498db;
|
||||
}
|
||||
|
||||
.log-entry.received {
|
||||
color: #2ecc71;
|
||||
}
|
||||
|
||||
.log-entry.error {
|
||||
color: #e74c3c;
|
||||
}
|
||||
|
||||
.log-entry.info {
|
||||
color: #f39c12;
|
||||
}
|
||||
|
||||
.log-timestamp {
|
||||
color: #7f8c8d;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.message-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
|
||||
gap: 10px;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
text-align: center;
|
||||
padding: 10px;
|
||||
background: #ecf0f1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
color: #7f8c8d;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>WebSocket 测试 - Sanguo VeighNa</h1>
|
||||
|
||||
<!-- Connection Panel -->
|
||||
<div class="panel">
|
||||
<h2>连接控制</h2>
|
||||
<div class="connection-controls">
|
||||
<input type="text" id="wsUrl" value="ws://localhost:8000/ws" placeholder="WebSocket URL">
|
||||
<input type="text" id="token" placeholder="JWT Token (可选)">
|
||||
<button id="connectBtn" class="primary">连接</button>
|
||||
<button id="disconnectBtn" class="danger" disabled>断开</button>
|
||||
<span id="status" class="status disconnected">未连接</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Subscription Panel -->
|
||||
<div class="panel">
|
||||
<h2>订阅管理</h2>
|
||||
<div class="subscription-grid">
|
||||
<label class="checkbox-item">
|
||||
<input type="checkbox" id="sub-tick" value="tick">
|
||||
<span>行情 (Tick)</span>
|
||||
</label>
|
||||
<label class="checkbox-item">
|
||||
<input type="checkbox" id="sub-order" value="order">
|
||||
<span>订单 (Order)</span>
|
||||
</label>
|
||||
<label class="checkbox-item">
|
||||
<input type="checkbox" id="sub-trade" value="trade">
|
||||
<span>成交 (Trade)</span>
|
||||
</label>
|
||||
<label class="checkbox-item">
|
||||
<input type="checkbox" id="sub-position" value="position">
|
||||
<span>持仓 (Position)</span>
|
||||
</label>
|
||||
<label class="checkbox-item">
|
||||
<input type="checkbox" id="sub-account" value="account">
|
||||
<span>账户 (Account)</span>
|
||||
</label>
|
||||
<label class="checkbox-item">
|
||||
<input type="checkbox" id="sub-log" value="log">
|
||||
<span>日志 (Log)</span>
|
||||
</label>
|
||||
<label class="checkbox-item">
|
||||
<input type="checkbox" id="sub-contract" value="contract">
|
||||
<span>合约 (Contract)</span>
|
||||
</label>
|
||||
</div>
|
||||
<button id="subscribeBtn" class="success" disabled>订阅选中</button>
|
||||
<button id="unsubscribeBtn" class="danger" disabled>取消选中</button>
|
||||
|
||||
<div class="symbol-input">
|
||||
<input type="text" id="symbolInput" placeholder="品种代码 (逗号分隔,如: IF2501.CFFEX,IH2501.CFFEX)">
|
||||
<button id="subscribeSymbolBtn" class="success" disabled>订阅品种</button>
|
||||
<button id="unsubscribeSymbolBtn" class="danger" disabled>取消品种</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Heartbeat Panel -->
|
||||
<div class="panel">
|
||||
<h2>心跳测试</h2>
|
||||
<button id="pingBtn" disabled>发送 Ping</button>
|
||||
</div>
|
||||
|
||||
<!-- Message Log Panel -->
|
||||
<div class="panel">
|
||||
<h2>消息日志</h2>
|
||||
<div class="message-stats">
|
||||
<div class="stat-item">
|
||||
<div class="stat-value" id="sentCount">0</div>
|
||||
<div class="stat-label">已发送</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-value" id="receivedCount">0</div>
|
||||
<div class="stat-label">已接收</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-value" id="tickCount">0</div>
|
||||
<div class="stat-label">行情</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-value" id="orderCount">0</div>
|
||||
<div class="stat-label">订单</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-value" id="tradeCount">0</div>
|
||||
<div class="stat-label">成交</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-value" id="logCount">0</div>
|
||||
<div class="stat-label">日志</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top: 15px;">
|
||||
<button id="clearLogBtn">清空日志</button>
|
||||
</div>
|
||||
<div class="log-container" id="logContainer"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// WebSocket 客户端
|
||||
let ws = null;
|
||||
let sentCount = 0;
|
||||
let receivedCount = 0;
|
||||
let tickCount = 0;
|
||||
let orderCount = 0;
|
||||
let tradeCount = 0;
|
||||
let logCount = 0;
|
||||
|
||||
// DOM 元素
|
||||
const wsUrlInput = document.getElementById('wsUrl');
|
||||
const tokenInput = document.getElementById('token');
|
||||
const connectBtn = document.getElementById('connectBtn');
|
||||
const disconnectBtn = document.getElementById('disconnectBtn');
|
||||
const statusSpan = document.getElementById('status');
|
||||
const subscribeBtn = document.getElementById('subscribeBtn');
|
||||
const unsubscribeBtn = document.getElementById('unsubscribeBtn');
|
||||
const symbolInput = document.getElementById('symbolInput');
|
||||
const subscribeSymbolBtn = document.getElementById('subscribeSymbolBtn');
|
||||
const unsubscribeSymbolBtn = document.getElementById('unsubscribeSymbolBtn');
|
||||
const pingBtn = document.getElementById('pingBtn');
|
||||
const clearLogBtn = document.getElementById('clearLogBtn');
|
||||
const logContainer = document.getElementById('logContainer');
|
||||
|
||||
// 连接 WebSocket
|
||||
connectBtn.addEventListener('click', () => {
|
||||
const url = wsUrlInput.value;
|
||||
const token = tokenInput.value;
|
||||
|
||||
let wsUrl = url;
|
||||
if (token) {
|
||||
wsUrl += `?token=${token}`;
|
||||
}
|
||||
|
||||
try {
|
||||
ws = new WebSocket(wsUrl);
|
||||
|
||||
ws.onopen = () => {
|
||||
updateStatus(true);
|
||||
addLog('Connected', 'Connected to server', 'info');
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const message = JSON.parse(event.data);
|
||||
receivedCount++;
|
||||
updateStats();
|
||||
addLog('Received', JSON.stringify(message, null, 2), 'received');
|
||||
|
||||
// 统计消息类型
|
||||
const msgType = message.type;
|
||||
switch (msgType) {
|
||||
case 'tick':
|
||||
tickCount++;
|
||||
break;
|
||||
case 'order':
|
||||
orderCount++;
|
||||
break;
|
||||
case 'trade':
|
||||
tradeCount++;
|
||||
break;
|
||||
case 'log':
|
||||
logCount++;
|
||||
break;
|
||||
}
|
||||
updateStats();
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
addLog('Error', 'WebSocket error occurred', 'error');
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
updateStatus(false);
|
||||
addLog('Disconnected', 'Connection closed', 'info');
|
||||
};
|
||||
} catch (error) {
|
||||
addLog('Error', error.message, 'error');
|
||||
}
|
||||
});
|
||||
|
||||
// 断开连接
|
||||
disconnectBtn.addEventListener('click', () => {
|
||||
if (ws) {
|
||||
ws.close();
|
||||
ws = null;
|
||||
}
|
||||
});
|
||||
|
||||
// 订阅选中类型
|
||||
subscribeBtn.addEventListener('click', () => {
|
||||
const subscriptions = [];
|
||||
document.querySelectorAll('.subscription-grid input[type="checkbox"]:checked').forEach(cb => {
|
||||
subscriptions.push(cb.value);
|
||||
});
|
||||
|
||||
if (subscriptions.length === 0) {
|
||||
addLog('Warning', 'No subscriptions selected', 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
sendMessage({
|
||||
type: 'subscribe',
|
||||
data: {
|
||||
subscription: subscriptions
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 取消选中订阅
|
||||
unsubscribeBtn.addEventListener('click', () => {
|
||||
const subscriptions = [];
|
||||
document.querySelectorAll('.subscription-grid input[type="checkbox"]:checked').forEach(cb => {
|
||||
subscriptions.push(cb.value);
|
||||
});
|
||||
|
||||
if (subscriptions.length === 0) {
|
||||
addLog('Warning', 'No subscriptions selected', 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
sendMessage({
|
||||
type: 'unsubscribe',
|
||||
data: {
|
||||
subscription: subscriptions
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 订阅品种
|
||||
subscribeSymbolBtn.addEventListener('click', () => {
|
||||
const symbols = symbolInput.value.split(',').map(s => s.trim()).filter(s => s);
|
||||
|
||||
if (symbols.length === 0) {
|
||||
addLog('Warning', 'No symbols specified', 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
sendMessage({
|
||||
type: 'subscribe_symbol',
|
||||
data: {
|
||||
symbol: symbols
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 取消品种订阅
|
||||
unsubscribeSymbolBtn.addEventListener('click', () => {
|
||||
const symbols = symbolInput.value.split(',').map(s => s.trim()).filter(s => s);
|
||||
|
||||
if (symbols.length === 0) {
|
||||
addLog('Warning', 'No symbols specified', 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
sendMessage({
|
||||
type: 'unsubscribe_symbol',
|
||||
data: {
|
||||
symbol: symbols
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 发送 Ping
|
||||
pingBtn.addEventListener('click', () => {
|
||||
sendMessage({
|
||||
type: 'ping',
|
||||
data: {}
|
||||
});
|
||||
});
|
||||
|
||||
// 清空日志
|
||||
clearLogBtn.addEventListener('click', () => {
|
||||
logContainer.innerHTML = '';
|
||||
sentCount = 0;
|
||||
receivedCount = 0;
|
||||
tickCount = 0;
|
||||
orderCount = 0;
|
||||
tradeCount = 0;
|
||||
logCount = 0;
|
||||
updateStats();
|
||||
});
|
||||
|
||||
// 发送消息
|
||||
function sendMessage(message) {
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
||||
addLog('Error', 'Not connected to server', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
ws.send(JSON.stringify(message));
|
||||
sentCount++;
|
||||
updateStats();
|
||||
addLog('Sent', JSON.stringify(message, null, 2), 'sent');
|
||||
} catch (error) {
|
||||
addLog('Error', error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 更新连接状态
|
||||
function updateStatus(connected) {
|
||||
if (connected) {
|
||||
statusSpan.textContent = '已连接';
|
||||
statusSpan.className = 'status connected';
|
||||
connectBtn.disabled = true;
|
||||
disconnectBtn.disabled = false;
|
||||
subscribeBtn.disabled = false;
|
||||
unsubscribeBtn.disabled = false;
|
||||
subscribeSymbolBtn.disabled = false;
|
||||
unsubscribeSymbolBtn.disabled = false;
|
||||
pingBtn.disabled = false;
|
||||
} else {
|
||||
statusSpan.textContent = '未连接';
|
||||
statusSpan.className = 'status disconnected';
|
||||
connectBtn.disabled = false;
|
||||
disconnectBtn.disabled = true;
|
||||
subscribeBtn.disabled = true;
|
||||
unsubscribeBtn.disabled = true;
|
||||
subscribeSymbolBtn.disabled = true;
|
||||
unsubscribeSymbolBtn.disabled = true;
|
||||
pingBtn.disabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
// 更新统计
|
||||
function updateStats() {
|
||||
document.getElementById('sentCount').textContent = sentCount;
|
||||
document.getElementById('receivedCount').textContent = receivedCount;
|
||||
document.getElementById('tickCount').textContent = tickCount;
|
||||
document.getElementById('orderCount').textContent = orderCount;
|
||||
document.getElementById('tradeCount').textContent = tradeCount;
|
||||
document.getElementById('logCount').textContent = logCount;
|
||||
}
|
||||
|
||||
// 添加日志
|
||||
function addLog(direction, message, type) {
|
||||
const timestamp = new Date().toLocaleTimeString();
|
||||
const entry = document.createElement('div');
|
||||
entry.className = `log-entry ${type}`;
|
||||
entry.innerHTML = `<span class="log-timestamp">[${timestamp}]</span><strong>${direction}:</strong> ${message}`;
|
||||
logContainer.appendChild(entry);
|
||||
logContainer.scrollTop = logContainer.scrollHeight;
|
||||
}
|
||||
|
||||
// 初始化
|
||||
updateStats();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user