mirror of
https://ghfast.top/https://github.com/aeroxw/easy_tdx_max.git
synced 2026-09-12 14:34:18 +08:00
fix: 寻优跳过参数范围/对比支持组合/日线x轴年份/寻优跳转填充参数
4 个独立修复: 1. 寻优跳过参数范围检查:Param.validate 加 skip_bounds 参数, RegisteredStrategy.build 透传,ParamGridOptimizer 传 skip_bounds=True。 修复寻优时 fast=250 被 max_value=60 拦截的问题(探索超范围值是寻优目的)。 2. 对比页支持组合回测:CompareView 新增 extractComparable() 统一提取净值+指标, 支持单标的(performance/equity_curve)和组合(total_performance/combined_equity)。 修复勾选组合回测任务报「非单标的回测」错误。 3. K线x轴日线显示完整年份:isIntraday 判断从 length>10 改为检查时分秒非零 (日线归一化后带 T00:00:00 后缀,长度也 >10 导致误判为分钟线,slice 砍年份)。 4. 寻优跳转填充参数:BacktestView 加 useRoute(),onMounted 读 query.strategy + query.params,await nextTick 后覆盖(避免 StrategyPicker watch 重置)。 修复寻优点「查看」跳转后参数未填充、仍用默认值的问题。 测试:823 passed, mypy 211 files OK, vue-tsc OK
This commit is contained in:
@@ -143,7 +143,7 @@ class ParamGridOptimizer:
|
||||
strategy_name: str,
|
||||
param_grid: dict[str, list[Any]],
|
||||
df: pd.DataFrame,
|
||||
cash: float = 100_000.0,
|
||||
cash: float = 1_000_000.0,
|
||||
commission: float = 0.0003,
|
||||
min_commission: float = 5.0,
|
||||
stamp_tax: float = 0.001,
|
||||
@@ -181,7 +181,8 @@ class ParamGridOptimizer:
|
||||
for combo in itertools.product(*value_lists):
|
||||
params = dict(zip(param_names, combo, strict=True))
|
||||
try:
|
||||
strategy = entry.build(params)
|
||||
# 寻优时跳过参数范围检查——探索超范围值是寻优的目的
|
||||
strategy = entry.build(params, skip_bounds=True)
|
||||
engine = BacktestEngine(
|
||||
strategy=strategy,
|
||||
cash=self._cash,
|
||||
|
||||
@@ -114,7 +114,7 @@ class Param:
|
||||
schema["description"] = self.description
|
||||
return schema
|
||||
|
||||
def validate(self, value: Any) -> Any:
|
||||
def validate(self, value: Any, *, skip_bounds: bool = False) -> Any:
|
||||
"""校验并强制转换取值,不合法抛 ValueError。
|
||||
|
||||
NaN/Inf 的 float 输入会被拒绝(NaN 绕过所有比较,Inf 仅在有界时被拦,
|
||||
@@ -150,14 +150,25 @@ class Param:
|
||||
f"参数 '{self.name}' 期望 {self.type.__name__},得到 {value!r}"
|
||||
) from exc
|
||||
|
||||
if self.choices is not None and self.type is str and converted not in self.choices:
|
||||
raise ValueError(
|
||||
f"参数 '{self.name}' 取值 {converted!r} 不在可选范围 {list(self.choices)} 内"
|
||||
)
|
||||
if self.type in (int, float) and self.min_value is not None and converted < self.min_value:
|
||||
raise ValueError(f"参数 '{self.name}'={converted} 小于下限 {self.min_value}")
|
||||
if self.type in (int, float) and self.max_value is not None and converted > self.max_value:
|
||||
raise ValueError(f"参数 '{self.name}'={converted} 大于上限 {self.max_value}")
|
||||
# skip_bounds=True 时跳过范围/取值集合检查(供寻优器探索超范围值),
|
||||
# 仍保留类型转换 + NaN/Inf 拦截。
|
||||
if not skip_bounds:
|
||||
if self.choices is not None and self.type is str and converted not in self.choices:
|
||||
raise ValueError(
|
||||
f"参数 '{self.name}' 取值 {converted!r} 不在可选范围 {list(self.choices)} 内"
|
||||
)
|
||||
if (
|
||||
self.type in (int, float)
|
||||
and self.min_value is not None
|
||||
and converted < self.min_value
|
||||
):
|
||||
raise ValueError(f"参数 '{self.name}'={converted} 小于下限 {self.min_value}")
|
||||
if (
|
||||
self.type in (int, float)
|
||||
and self.max_value is not None
|
||||
and converted > self.max_value
|
||||
):
|
||||
raise ValueError(f"参数 '{self.name}'={converted} 大于上限 {self.max_value}")
|
||||
return converted
|
||||
|
||||
|
||||
@@ -180,10 +191,11 @@ class ParametrizedStrategy(Strategy):
|
||||
# 实例属性:已校验的参数值字典(init/next 中通过 self.p[name] 访问)。
|
||||
p: dict[str, Any]
|
||||
|
||||
def __init__(self, **kwargs: Any) -> None:
|
||||
def __init__(self, *, skip_bounds: bool = False, **kwargs: Any) -> None:
|
||||
"""从 kwargs 构造策略参数。
|
||||
|
||||
多余的未知参数抛 ValueError;缺失参数取默认值。
|
||||
skip_bounds=True 时跳过参数范围检查(供寻优器探索超范围值)。
|
||||
"""
|
||||
super().__init__()
|
||||
declared = {param.name: param for param in self.params}
|
||||
@@ -194,7 +206,7 @@ class ParametrizedStrategy(Strategy):
|
||||
resolved: dict[str, Any] = {}
|
||||
for name, param in declared.items():
|
||||
raw = kwargs.get(name, param.default)
|
||||
resolved[name] = param.validate(raw)
|
||||
resolved[name] = param.validate(raw, skip_bounds=skip_bounds)
|
||||
self.p = resolved
|
||||
|
||||
|
||||
@@ -220,9 +232,17 @@ class RegisteredStrategy:
|
||||
"params": [p.to_schema() for p in self.params],
|
||||
}
|
||||
|
||||
def build(self, params: dict[str, Any] | None = None) -> ParametrizedStrategy:
|
||||
"""用给定参数构造策略实例,缺失参数取默认值。"""
|
||||
return self.strategy_cls(**(params or {}))
|
||||
def build(
|
||||
self,
|
||||
params: dict[str, Any] | None = None,
|
||||
*,
|
||||
skip_bounds: bool = False,
|
||||
) -> ParametrizedStrategy:
|
||||
"""用给定参数构造策略实例,缺失参数取默认值。
|
||||
|
||||
skip_bounds=True 时跳过参数范围检查(供寻优器探索超范围值)。
|
||||
"""
|
||||
return self.strategy_cls(**(params or {}), skip_bounds=skip_bounds)
|
||||
|
||||
|
||||
class StrategyRegistry:
|
||||
|
||||
@@ -30,8 +30,13 @@ function buildOption(): echarts.EChartsCoreOption {
|
||||
const keyIndex = new Map<string, number>()
|
||||
keys.forEach((k, i) => keyIndex.set(k, i))
|
||||
|
||||
// x 轴显示:日线只显示日期,分钟线显示日期+时间
|
||||
const isIntraday = keys.some((k) => k.length > 10)
|
||||
// x 轴显示:日线只显示日期 YYYY-MM-DD,分钟线显示 MM-DD HH:mm。
|
||||
// 判断分钟线:datetime 有非零时分秒(日线归一化后是 T00:00:00)。
|
||||
// 不能用 length > 10 判断——日线带 T00:00:00 后缀长度也是 19。
|
||||
const isIntraday = keys.some((k) => {
|
||||
const time = k.slice(11, 19) // HH:MM:SS 部分
|
||||
return time && time !== '00:00:00'
|
||||
})
|
||||
const dates = keys.map((k) => (isIntraday ? k.replace('T', ' ').slice(5, 16) : k.slice(0, 10)))
|
||||
|
||||
const ohlc = props.bars.map((b) => [b.open, b.close, b.low, b.high])
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
// 回测主页面:左配置面板 / 右报告面板。
|
||||
// 编排:取行情 → 选策略+参数 → 回测 → 展示 K线+净值+指标+成交。
|
||||
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { nextTick, onMounted, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import EquityChart from '../components/EquityChart.vue'
|
||||
import KlineChart from '../components/KlineChart.vue'
|
||||
@@ -14,6 +15,7 @@ import type { ExecutionMode } from '../types'
|
||||
import { useBacktestStore } from '../stores/backtest'
|
||||
|
||||
const store = useBacktestStore()
|
||||
const route = useRoute()
|
||||
|
||||
// 表单状态(v-model 给子组件)
|
||||
const strategy = ref('ma_cross')
|
||||
@@ -25,10 +27,27 @@ const execution = ref<ExecutionMode>('next_open')
|
||||
|
||||
const EXECUTIONS: ExecutionMode[] = ['next_open', 'next_close', 'this_close', 'worst', 'best']
|
||||
|
||||
onMounted(() => {
|
||||
store.loadStrategies().catch((e) => {
|
||||
onMounted(async () => {
|
||||
await store.loadStrategies().catch((e) => {
|
||||
store.error = `加载策略列表失败:${e instanceof Error ? e.message : e}`
|
||||
})
|
||||
|
||||
// 从 URL query 读取寻优页传来的 strategy + params(跳转自动填充)
|
||||
const qStrategy = route.query.strategy as string | undefined
|
||||
const qParams = route.query.params as string | undefined
|
||||
if (qStrategy) {
|
||||
strategy.value = qStrategy
|
||||
// 等待 StrategyPicker 的 watch(selectedSchema) 触发完默认值重置后,
|
||||
// 再用 query 的 params 覆盖,避免被 watch 重置掉
|
||||
await nextTick()
|
||||
}
|
||||
if (qParams) {
|
||||
try {
|
||||
params.value = JSON.parse(qParams) as Record<string, number | string | boolean>
|
||||
} catch {
|
||||
// query 参数解析失败,忽略
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
async function onRun() {
|
||||
|
||||
@@ -1,19 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
// 结果对比页面:选 2-4 个已完成的回测 task,叠加净值曲线 + 横向指标对比。
|
||||
// 支持单标的回测(equity_curve)和组合回测(combined_equity)的混合对比。
|
||||
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
|
||||
import CompareChart from '../components/CompareChart.vue'
|
||||
import CompareTable from '../components/CompareTable.vue'
|
||||
import { fetchTask, fetchTaskList, formatError } from '../api'
|
||||
import type { BacktestResult, TaskSummary } from '../types'
|
||||
import type { BacktestResult, EquityPoint, TaskSummary } from '../types'
|
||||
|
||||
/** 统一的可对比项:无论单标的还是组合,都归一化为 equity + performance。 */
|
||||
interface CompareItem {
|
||||
label: string
|
||||
equity: EquityPoint[]
|
||||
performance: Record<string, number>
|
||||
}
|
||||
|
||||
const taskList = ref<TaskSummary[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const selectedIds = ref<Set<string>>(new Set())
|
||||
// 已加载的详情(task_id → BacktestResult)
|
||||
const details = ref<Map<string, { label: string; result: BacktestResult }>>(new Map())
|
||||
const details = ref<Map<string, CompareItem>>(new Map())
|
||||
|
||||
onMounted(loadTasks)
|
||||
|
||||
@@ -22,7 +29,6 @@ async function loadTasks() {
|
||||
error.value = ''
|
||||
try {
|
||||
const resp = await fetchTaskList(20)
|
||||
// 只显示已完成的单标的回测(status=done 且 result 是 BacktestResult)
|
||||
taskList.value = resp.tasks.filter((t) => t.status === 'done')
|
||||
} catch (e) {
|
||||
error.value = formatError(e)
|
||||
@@ -31,37 +37,62 @@ async function loadTasks() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 从 task result 提取可对比的 equity + performance。
|
||||
* 支持单标的(equity_curve/performance)和组合(combined_equity/total_performance)。 */
|
||||
function extractComparable(
|
||||
result: Record<string, unknown> | null,
|
||||
): { equity: EquityPoint[]; performance: Record<string, number> } | null {
|
||||
if (!result) return null
|
||||
// 单标的回测
|
||||
if (result.performance && result.equity_curve) {
|
||||
return {
|
||||
equity: result.equity_curve as EquityPoint[],
|
||||
performance: result.performance as Record<string, number>,
|
||||
}
|
||||
}
|
||||
// 组合回测
|
||||
if (result.total_performance && result.combined_equity) {
|
||||
return {
|
||||
equity: result.combined_equity as EquityPoint[],
|
||||
performance: result.total_performance as Record<string, number>,
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async function toggle(taskId: string) {
|
||||
if (selectedIds.value.has(taskId)) {
|
||||
selectedIds.value.delete(taskId)
|
||||
details.value.delete(taskId)
|
||||
} else {
|
||||
if (selectedIds.value.size >= 4) return // 最多 4 个
|
||||
if (selectedIds.value.size >= 4) return
|
||||
selectedIds.value.add(taskId)
|
||||
// 拉详情
|
||||
try {
|
||||
const state = await fetchTask(taskId)
|
||||
const result = state.result as BacktestResult
|
||||
if (!result?.performance || !result?.equity_curve) {
|
||||
throw new Error('该任务结果不可对比(非单标的回测)')
|
||||
const comparable = extractComparable(state.result as Record<string, unknown> | null)
|
||||
if (!comparable) {
|
||||
throw new Error('该任务结果不含净值曲线(寻优任务等不可对比)')
|
||||
}
|
||||
details.value.set(taskId, { label: state.description, result })
|
||||
details.value.set(taskId, { label: state.description, ...comparable })
|
||||
} catch (e) {
|
||||
selectedIds.value.delete(taskId)
|
||||
error.value = e instanceof Error ? e.message : String(e)
|
||||
}
|
||||
}
|
||||
// 触发响应式
|
||||
selectedIds.value = new Set(selectedIds.value)
|
||||
details.value = new Map(details.value)
|
||||
}
|
||||
|
||||
const compareItems = ref<Array<{ label: string; result: BacktestResult }>>([])
|
||||
function refreshItems() {
|
||||
compareItems.value = Array.from(details.value.values())
|
||||
// CompareChart/CompareTable 期望 { label, result: BacktestResult },
|
||||
// 这里把归一化的 equity/performance 包装回去
|
||||
compareItems.value = Array.from(details.value.values()).map((item) => ({
|
||||
label: item.label,
|
||||
result: { equity_curve: item.equity, performance: item.performance } as unknown as BacktestResult,
|
||||
}))
|
||||
}
|
||||
|
||||
// 监听 details 变化刷新对比项
|
||||
watch(details, refreshItems, { deep: true })
|
||||
</script>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user