fix: 修复登录500错误和移除明文密码提示

- 修复 deps.py 中 get_vn_service 的引用错误 (vn_service.vn_service -> vn_service)
- 移除登录页面上的明文密码提示
- 改进前端错误处理,避免数据加载失败导致登录显示错误
This commit is contained in:
2026-07-02 12:23:55 +08:00
parent 3fbe6c9be5
commit 918bbed0fc
63 changed files with 16699 additions and 10 deletions
+128
View File
@@ -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 AgentsExecute/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/`
+174
View File
@@ -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)
}
+550
View File
@@ -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
}
+391
View File
@@ -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)
}
+410
View File
@@ -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
View File
@@ -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
View File
@@ -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
+187 -6
View File
@@ -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/` 目录
+86
View File
@@ -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
+71
View File
@@ -0,0 +1,71 @@
# Sanguo VeighNa Docker 镜像 - NAS 适配版
# 使用 Python 3.10Synology 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"]
+59
View File
@@ -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"
+43
View File
@@ -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"]
+35
View File
@@ -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"]
+160
View File
@@ -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 "$@"
+282
View File
@@ -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 "$@"
+262
View File
@@ -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}
#
# ============================================
+340
View File
@@ -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 "$@"
+196
View File
@@ -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;
}
}
+122
View File
@@ -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
+409
View File
@@ -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
+677
View File
@@ -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 容器化部署
- 支持常见 NASSynology/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** - 增强功能,后续迭代优化
+84
View File
@@ -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
View File
@@ -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"
)
+5
View File
@@ -0,0 +1,5 @@
"""
Sanguo Web API 模块
提供 Web API 服务
"""
__version__ = "1.0.0"
+221
View File
@@ -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"]
+512
View File
@@ -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",
]
+176
View File
@@ -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",
]
+367
View File
@@ -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
+6
View File
@@ -0,0 +1,6 @@
"""
API 路由模块
"""
from . import auth, gateway, market, trading, strategy, system
__all__ = ["auth", "gateway", "market", "trading", "strategy", "system"]
+113
View File
@@ -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"}
+125
View File
@@ -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}"
)
+176
View File
@@ -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 []
+145
View File
@@ -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"
}
+83
View File
@@ -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)
}
+288
View File
@@ -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
)
+173
View File
@@ -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",
]
+6
View File
@@ -0,0 +1,6 @@
"""
Sanguo Web 服务模块
"""
from .main_service import VeighNaService
__all__ = ["VeighNaService"]
+715
View File
@@ -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"]
+636
View File
@@ -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;
}
}
+373
View File
@@ -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();
+582
View File
@@ -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');
+270
View File
@@ -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();
+482
View File
@@ -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>
+238
View File
@@ -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())
+147
View File
@@ -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())
+172
View File
@@ -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)
+8
View File
@@ -0,0 +1,8 @@
"""
WebSocket 模块
"""
from .manager import ConnectionManager, manager
from .routes import router
from .events import EventMonitorManager
__all__ = ["ConnectionManager", "manager", "router", "EventMonitorManager"]
+463
View File
@@ -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"
]
+393
View File
@@ -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"]
+177
View File
@@ -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)
}
+199
View File
@@ -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. 检查网络连接稳定性
+437
View File
@@ -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"])
+416
View File
@@ -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()
+209
View File
@@ -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())
+565
View File
@@ -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>