release: v1.17.10 — 一键寻优「查看」跳转携带完整行情上下文

- SymbolPicker code/category/startDate/endDate 改 defineModel 双向同步
- OptimizeView 两个「查看」按钮跳转 URL 附带 symbol/startDate/endDate/category
- BacktestView onMounted 回填标的/周期/日期到 SymbolPicker 表单
- 向后兼容:老 URL(只有 strategy/params) 仍正常,缺失字段保持默认
- 前端 vue-tsc / vite build 通过,后端 870 单测全绿(无回归)
This commit is contained in:
Justin Gu
2026-07-04 04:12:46 +08:00
parent 57cad1f3db
commit 07205b6166
5 changed files with 100 additions and 24 deletions
+17
View File
@@ -2,6 +2,23 @@
本文件记录 easy-tdx 的版本变更。格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/)。 本文件记录 easy-tdx 的版本变更。格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/)。
## [1.17.10] — 2026-07-04
**Web UI 一键寻优「查看」按钮跳转携带完整行情上下文** —— `/optimize` 页策略排名表的两个「查看」按钮此前跳转只带 `strategy` + `params`,丢失了股票代码、周期、起止日期,导致跳到回测页后用户得手动重选标的与日期才能复现寻优行情。本次让跳转 URL 额外携带 `symbol/startDate/endDate/category`,回测页 `onMounted` 自动回填到 `SymbolPicker` 表单(股票代码/周期/起止日期全部就位),用户只需点「开始回测」即可完整复现。**向后兼容**:老书签(只有 `strategy/params`)仍正常工作,缺失字段保持默认值。前端 `vue-tsc --noEmit` / `vite build` 通过,后端 870 单测全绿(无回归)。
### 新增
- **SymbolPicker 表单状态双向同步**`web-ui/src/components/SymbolPicker.vue`)—— `code/category/startDate/endDate` 从私有 `ref` 升级为 `defineModel`(带默认值),父组件可读(拼 URL)可写(回填表单)。`defineExpose({ loadBars, loading })` 保留不动,向后兼容已有调用。
### 变更
- **「查看」跳转 URL 携带完整上下文**(`web-ui/src/views/OptimizeView.vue`)—— 抽 `buildBacktestQuery(strategyName, params)` 统一构造 query`onViewParams`(单策略网格排名表)/`onViewAll`(全局策略排名表)两个按钮跳转时附带 `symbol/startDate/endDate/category`
- **回测页回填标的与日期**`web-ui/src/views/BacktestView.vue`)—— `onMounted` 新增读取 `route.query.symbol/startDate/endDate/category`,各字段独立 `if` 守卫回填到 `SymbolPicker` v-model 镜像 ref。老 URL 缺失字段保持默认值。
### 已知约束(非 bug
- **URL query `category` 无白名单校验** —— 与既有 `strategy/params` 读取风格一致,非法值由后端 `/bars` 兜底拒绝;前端 `<select>` 显示为空但不崩溃。属项目既有输入校验风格,留作后续可选加固(应整体覆盖,避免不对称修补)。
## [1.17.9] — 2026-07-04 ## [1.17.9] — 2026-07-04
**修复 Web UI 回测「交易」统计面板离谱数值** —— 单标的回测页绩效指标右侧「交易」面板出现 `平均盈利 65409694.45%``最大盈利 133926612.60%``平均持仓天数 1173.792``盈亏比 0.000`(却胜率 100%)等明显异常值。根因是后端 `avg_win/avg_loss/max_win/max_loss` 返回**绝对盈亏额(元)**,前端 `MetricTable.vue` 却按**百分比小数 ×100** 显示;`_compute_avg_holding_days``YYYYMMDD` 整数相减代替真实日期相减(跨月放大,如 `20240201-20240131=70`);`profit_factor` 在无亏损交易时被强制记为 `0.0`。真实数据复现用户场景(300580RSI reversal n=14/超卖30/超买70/开盘价,2020-01-06~2026-07-03)验证修复:平均盈利 `65409694.45% → 26.85%`、最大盈利 `133926612.60% → 49.26%`、平均持仓 `1173.792 → 91.0 天`、盈亏比 `0.000 → 999.000`。**870 单测全绿**+3 回归守卫),ruff format/check / mypy strict / 前端 vue-tsc 全通过。 **修复 Web UI 回测「交易」统计面板离谱数值** —— 单标的回测页绩效指标右侧「交易」面板出现 `平均盈利 65409694.45%``最大盈利 133926612.60%``平均持仓天数 1173.792``盈亏比 0.000`(却胜率 100%)等明显异常值。根因是后端 `avg_win/avg_loss/max_win/max_loss` 返回**绝对盈亏额(元)**,前端 `MetricTable.vue` 却按**百分比小数 ×100** 显示;`_compute_avg_holding_days``YYYYMMDD` 整数相减代替真实日期相减(跨月放大,如 `20240201-20240131=70`);`profit_factor` 在无亏损交易时被强制记为 `0.0`。真实数据复现用户场景(300580RSI reversal n=14/超卖30/超买70/开盘价,2020-01-06~2026-07-03)验证修复:平均盈利 `65409694.45% → 26.85%`、最大盈利 `133926612.60% → 49.26%`、平均持仓 `1173.792 → 91.0 天`、盈亏比 `0.000 → 999.000`。**870 单测全绿**+3 回归守卫),ruff format/check / mypy strict / 前端 vue-tsc 全通过。
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "easy-tdx" name = "easy-tdx"
version = "1.17.9" version = "1.17.10"
description = "通达信 TCP 协议行情数据客户端,支持在线行情、离线数据读取与写入同步" description = "通达信 TCP 协议行情数据客户端,支持在线行情、离线数据读取与写入同步"
readme = "README.md" readme = "README.md"
requires-python = ">=3.10" requires-python = ">=3.10"
+16 -11
View File
@@ -13,17 +13,22 @@ import type { Category } from '../types'
const store = useBacktestStore() const store = useBacktestStore()
const code = ref('000001') // 代码 / 周期 / 日期通过 defineModel 与父组件双向同步:
const category = ref<Category>('DAY') // 既允许父组件读取(如寻优页「查看」按钮拼 URL 带上这些值),
// 也允许父组件写入(如回测页从 URL query 回填表单)。
// 日期默认:结束=今天,开始=3年前 // 未绑定时取默认值,向后兼容。
function isoDaysFromNow(days: number): string { //
const d = new Date() // 注意:defineModel 的 default 不能引用本 <script setup> 内声明的局部函数
d.setDate(d.getDate() + days) // (编译期会被 hoist 到 setup() 外,此时函数还未定义),
return d.toISOString().slice(0, 10) // 因此日期默认值用内联字面量表达式计算。
} const code = defineModel<string>('code', { default: '000001' })
const endDate = ref(isoDaysFromNow(0)) const category = defineModel<Category>('category', { default: 'DAY' })
const startDate = ref(isoDaysFromNow(-365 * 3)) const startDate = defineModel<string>('startDate', {
default: new Date(Date.now() - 365 * 3 * 86400_000).toISOString().slice(0, 10),
})
const endDate = defineModel<string>('endDate', {
default: new Date().toISOString().slice(0, 10),
})
const error = ref('') const error = ref('')
// loading 由父组件控制(回测/寻优时驱动),组件自身只暴露 loadBars // loading 由父组件控制(回测/寻优时驱动),组件自身只暴露 loadBars
+31 -2
View File
@@ -12,7 +12,7 @@ import MetricTable from '../components/MetricTable.vue'
import StrategyPicker from '../components/StrategyPicker.vue' import StrategyPicker from '../components/StrategyPicker.vue'
import SymbolPicker from '../components/SymbolPicker.vue' import SymbolPicker from '../components/SymbolPicker.vue'
import TradeTable from '../components/TradeTable.vue' import TradeTable from '../components/TradeTable.vue'
import type { ExecutionMode } from '../types' import type { Category, ExecutionMode } from '../types'
import { useBacktestStore } from '../stores/backtest' import { useBacktestStore } from '../stores/backtest'
const store = useBacktestStore() const store = useBacktestStore()
@@ -21,6 +21,18 @@ const route = useRoute()
// SymbolPicker 实例引用,用于触发取行情 // SymbolPicker 实例引用,用于触发取行情
const symbolPicker = ref<InstanceType<typeof SymbolPicker> | null>(null) const symbolPicker = ref<InstanceType<typeof SymbolPicker> | null>(null)
// 镜像 SymbolPicker 的代码/周期/日期,与 SymbolPicker 通过 v-model 双向同步。
// 初始值与 SymbolPicker 默认一致;onMounted 时若 URL query 带了寻优页传来的值则覆盖。
const code = ref('000001')
const category = ref<Category>('DAY')
function isoDaysFromNow(days: number): string {
const d = new Date()
d.setDate(d.getDate() + days)
return d.toISOString().slice(0, 10)
}
const startDate = ref(isoDaysFromNow(-365 * 3))
const endDate = ref(isoDaysFromNow(0))
// 表单状态(v-model 给子组件) // 表单状态(v-model 给子组件)
const strategy = ref('ma_cross') const strategy = ref('ma_cross')
const params = ref<Record<string, number | string | boolean>>({}) const params = ref<Record<string, number | string | boolean>>({})
@@ -56,6 +68,17 @@ onMounted(async () => {
// query 参数解析失败,忽略 // query 参数解析失败,忽略
} }
} }
// 从 URL query 回填标的代码 / 周期 / 日期范围(寻优页「查看」跳转带来)。
// 各字段独立 if 守卫:老书签(只有 strategy/params)仍保持默认值,向后兼容。
const qSymbol = route.query.symbol as string | undefined
const qStartDate = route.query.startDate as string | undefined
const qEndDate = route.query.endDate as string | undefined
const qCategory = route.query.category as Category | undefined
if (qSymbol) code.value = qSymbol
if (qStartDate) startDate.value = qStartDate
if (qEndDate) endDate.value = qEndDate
if (qCategory) category.value = qCategory
}) })
// 取行情 + 回测 串联(点击「开始回测」触发) // 取行情 + 回测 串联(点击「开始回测」触发)
@@ -82,7 +105,13 @@ async function onRun() {
<aside class="config-panel"> <aside class="config-panel">
<section class="panel-section"> <section class="panel-section">
<h3>行情数据</h3> <h3>行情数据</h3>
<SymbolPicker ref="symbolPicker" /> <SymbolPicker
ref="symbolPicker"
v-model:code="code"
v-model:category="category"
v-model:start-date="startDate"
v-model:end-date="endDate"
/>
</section> </section>
<section class="panel-section"> <section class="panel-section">
+35 -10
View File
@@ -9,7 +9,7 @@ import OptimizeHeatmap from '../components/OptimizeHeatmap.vue'
import OptimizeResultTable from '../components/OptimizeResultTable.vue' import OptimizeResultTable from '../components/OptimizeResultTable.vue'
import ParamGridPicker from '../components/ParamGridPicker.vue' import ParamGridPicker from '../components/ParamGridPicker.vue'
import SymbolPicker from '../components/SymbolPicker.vue' import SymbolPicker from '../components/SymbolPicker.vue'
import type { ExecutionMode } from '../types' import type { Category, ExecutionMode } from '../types'
import { useBacktestStore } from '../stores/backtest' import { useBacktestStore } from '../stores/backtest'
const store = useBacktestStore() const store = useBacktestStore()
@@ -18,6 +18,18 @@ const router = useRouter()
// SymbolPicker 实例引用,用于触发取行情 // SymbolPicker 实例引用,用于触发取行情
const symbolPicker = ref<InstanceType<typeof SymbolPicker> | null>(null) const symbolPicker = ref<InstanceType<typeof SymbolPicker> | null>(null)
// 镜像 SymbolPicker 的代码/周期/日期,用于「查看」跳转时拼进 URL query。
// 与 SymbolPicker 通过 v-model 双向同步,初始值与 SymbolPicker 默认一致。
const code = ref('000001')
const category = ref<Category>('DAY')
function isoDaysFromNow(days: number): string {
const d = new Date()
d.setDate(d.getDate() + days)
return d.toISOString().slice(0, 10)
}
const startDate = ref(isoDaysFromNow(-365 * 3))
const endDate = ref(isoDaysFromNow(0))
const strategy = ref('ma_cross') const strategy = ref('ma_cross')
const paramGrid = ref<Record<string, Array<number | string>>>({}) const paramGrid = ref<Record<string, Array<number | string>>>({})
const cash = ref(1000000) const cash = ref(1000000)
@@ -83,21 +95,28 @@ async function onRunAll() {
}) })
} }
/** 「查看」跳转时,把当前标的 + 周期 + 日期范围一并塞进 query,
* 让回测页能完整复现寻优时的行情(而非只带策略参数)。 */
function buildBacktestQuery(strategyName: string, params: Record<string, number | string>) {
return {
strategy: strategyName,
params: JSON.stringify(params),
symbol: code.value,
startDate: startDate.value,
endDate: endDate.value,
category: category.value,
}
}
// 点击排名表「查看」→ 跳转单标的页用该参数回测 // 点击排名表「查看」→ 跳转单标的页用该参数回测
function onViewParams(params: Record<string, number | string>) { function onViewParams(params: Record<string, number | string>) {
// 通过 query 传递参数,单标的页接收后自动填充 // 通过 query 传递参数,单标的页接收后自动填充
router.push({ router.push({ path: '/', query: buildBacktestQuery(strategy.value, params) })
path: '/',
query: { strategy: strategy.value, params: JSON.stringify(params) },
})
} }
// 一键寻优结果点击「查看」→ 跳转单标的页用该策略 + 参数回测 // 一键寻优结果点击「查看」→ 跳转单标的页用该策略 + 参数回测
function onViewAll(strategyName: string, params: Record<string, number | string>) { function onViewAll(strategyName: string, params: Record<string, number | string>) {
router.push({ router.push({ path: '/', query: buildBacktestQuery(strategyName, params) })
path: '/',
query: { strategy: strategyName, params: JSON.stringify(params) },
})
} }
function pct(v: number | null | undefined): string { function pct(v: number | null | undefined): string {
@@ -113,7 +132,13 @@ function num(v: number | null | undefined, d = 2): string {
<aside class="config-panel"> <aside class="config-panel">
<section class="panel-section"> <section class="panel-section">
<h3>行情数据</h3> <h3>行情数据</h3>
<SymbolPicker ref="symbolPicker" /> <SymbolPicker
ref="symbolPicker"
v-model:code="code"
v-model:category="category"
v-model:start-date="startDate"
v-model:end-date="endDate"
/>
</section> </section>
<section class="panel-section"> <section class="panel-section">