feat(shadow-desk): 通路测试策略channel_test+模拟盘列表改造: 策略=每日等权轮换ETF/蓝筹(卖旧买新走全买卖通路)+T+1当日卖探针(两端应一致拒单,验证拒单通路),注册backtest/live/前端,默认universe 5只高流动ETF,4单测(坑:BrokerFacade是dataclass,子类方法被父类__init__实例属性遮蔽须实例注入); 模拟盘列表:加类型列(个股/组合)+创建时间列,标的去JSON中括号,列宽重排,模式筛选加影子 [vps]
CI/CD / test (push) Successful in 12s
CI/CD / nas-deploy (push) Successful in 28s
CI/CD / nas-verify (push) Successful in 9s

This commit is contained in:
2026-08-14 14:32:52 +08:00
parent 6220fc5943
commit 36db2ed5a6
10 changed files with 256 additions and 21 deletions
+1
View File
@@ -37,6 +37,7 @@ export interface PaperAccount {
initial_capital?: number
start_date?: string
end_date?: string
created_at?: string
last_run_date?: string | null
next_run_at?: string | null
checkpoint_date?: string | null
+5
View File
@@ -40,6 +40,11 @@
border-color: rgba(0, 229, 255, 0.3);
margin-left: 4px;
}
.chip-cta {
color: #6ee7a8;
background: rgba(110, 231, 168, 0.1);
border-color: rgba(110, 231, 168, 0.3);
}
.chip-portfolio {
color: var(--amber);
background: rgba(255, 176, 0, 0.08);
@@ -83,6 +83,7 @@ const strategyOptions = [
{ label: '牛熊动量', value: 'momentum_timing' },
{ label: '价值精选', value: 'value_selection' },
{ label: '小市值轮动', value: 'small_cap' },
{ label: '通路测试(影子vs实盘双轨)', value: 'channel_test' },
]
const BENCHMARK_OPTIONS = [
{ label: '沪深300', value: '000300.XSHG' },
+35 -19
View File
@@ -57,11 +57,22 @@ function num(v: number | null | undefined): string {
}
function symbolsShort(s: string | undefined): string {
if (!s) return '—'
const arr = typeof s === 'string' ? s.split(',') : []
return arr.length > 3 ? arr.slice(0, 3).join(',') + ` +${arr.length - 3}` : s
let arr: string[] = []
try {
arr = JSON.parse(s)
} catch {
arr = String(s).split(',').map((x) => x.trim())
}
if (!arr.length) return '—'
return arr.length > 3 ? arr.slice(0, 3).join(',') + ` +${arr.length - 3}` : arr.join(',')
}
const typeLabel: Record<string, string> = { cta: '个股', portfolio: '组合' }
function createdShort(v: string | undefined): string {
if (!v) return '—'
return v.replace('T', ' ').slice(0, 16)
}
function open(a: PaperAccount): void {
router.push(a.mode === 'live' ? `/paper/live/${a.id}` : `/paper/result/${a.id}`)
router.push(a.mode === 'replay' ? `/paper/result/${a.id}` : `/paper/live/${a.id}`)
}
function goNew(): void {
router.push('/paper/new')
@@ -170,6 +181,7 @@ async function saveEdit(): Promise<void> {
<el-select v-model="modeFilter" placeholder="全部模式" clearable style="width: 130px">
<el-option label="回放" value="replay" />
<el-option label="实走" value="live" />
<el-option label="影子" value="shadow" />
</el-select>
<el-select v-model="statusFilter" placeholder="全部状态" clearable style="width: 130px">
<el-option label="运行中" value="running" />
@@ -181,51 +193,55 @@ async function saveEdit(): Promise<void> {
<el-card class="table-card" shadow="never">
<el-table :data="filtered" size="small" empty-text="暂无模拟盘">
<el-table-column label="名称 / ID" min-width="160">
<el-table-column label="名称 / ID" min-width="150">
<template #default="{ row }">
<div class="cell-name">{{ row.name }}</div>
<div class="cell-id mono">#{{ row.id }}</div>
</template>
</el-table-column>
<el-table-column label="模式" width="80">
<el-table-column label="类型" width="76">
<template #default="{ row }">
<span class="chip" :class="row.strategy_type === 'portfolio' ? 'chip-portfolio' : 'chip-cta'">
{{ typeLabel[row.strategy_type] || row.strategy_type || '个股' }}
</span>
</template>
</el-table-column>
<el-table-column label="模式" width="92">
<template #default="{ row }">
<span class="chip chip-paper" :class="{ 'chip-shadow': row.mode === 'shadow' }">{{ modeLabel[row.mode] || row.mode }}</span>
<span v-if="row.strategy_type === 'portfolio' && row.mode === 'live'" class="chip chip-shadow" title="日终回放=每晚 20:30 全量重放结算">日终</span>
</template>
</el-table-column>
<el-table-column label="频率" width="70">
<template #default="{ row }"><span class="mono">{{ row.interval }}</span></template>
<el-table-column label="频率" width="64">
<template #default="{ row }"><span class="mono muted">{{ row.interval }}</span></template>
</el-table-column>
<el-table-column prop="instance" label="实例" min-width="160">
<template #default="{ row }"><span class="mono">{{ row.instance || '—' }}</span></template>
</el-table-column>
<el-table-column label="标的" min-width="120">
<el-table-column label="标的" min-width="130">
<template #default="{ row }"><span class="mono">{{ symbolsShort(row.symbols) }}</span></template>
</el-table-column>
<el-table-column label="收益率" width="100" align="right">
<el-table-column label="收益率" width="86" align="right">
<template #default="{ row }">
<span class="mono" :class="(row.total_return ?? 0) >= 0 ? 'up' : 'down'">{{ pct(row.total_return) }}</span>
</template>
</el-table-column>
<el-table-column label="最新净值" width="120" align="right">
<el-table-column label="最新净值" width="104" align="right">
<template #default="{ row }"><span class="mono">{{ num(row.latest_equity) }}</span></template>
</el-table-column>
<el-table-column label="起止" min-width="180">
<template #default="{ row }"><span class="mono muted">{{ row.start_date }} ~ {{ row.end_date }}</span></template>
<el-table-column label="创建时间" width="142">
<template #default="{ row }"><span class="mono muted">{{ createdShort(row.created_at) }}</span></template>
</el-table-column>
<el-table-column label="状态" width="84">
<el-table-column label="状态" width="80">
<template #default="{ row }"><span class="chip" :class="`st-${row.status}`">{{ statusLabel[row.status] || row.status }}</span></template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right">
<el-table-column label="操作" width="176" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="open(row)">查看</el-button>
<el-button
v-if="row.mode === 'live' && row.status === 'running'"
v-if="row.mode !== 'replay' && row.status === 'running'"
link type="warning" :loading="actionLoading === row.id"
@click="onStop(row)"
>停止</el-button>
<el-button
v-if="row.mode === 'live' && row.status === 'stopped'"
v-if="row.mode !== 'replay' && row.status === 'stopped'"
link type="success" :loading="actionLoading === row.id"
@click="onResume(row)"
>恢复</el-button>
+1
View File
@@ -32,6 +32,7 @@ const PORTFOLIO_LABELS: Record<string, string> = {
momentum_timing: '牛熊动量',
value_selection: '价值精选',
small_cap: '小市值轮动',
channel_test: '通路测试(影子vs实盘双轨)',
}
onMounted(async () => {
+3
View File
@@ -23,6 +23,7 @@ def _build_live_strategy(provider):
"""env 配置 → StrategyTemplate 实例(对齐 runner_backtest._build_strategy)。"""
from sanguo_portfolio.strategies import (
AllWeatherConfig, AllWeatherStrategy,
ChannelTestConfig, ChannelTestStrategy,
MomentumTimingConfig, MomentumTimingStrategy,
SmallCapConfig, SmallCapStrategy,
ValueSelectionConfig, ValueSelectionStrategy,
@@ -39,6 +40,8 @@ def _build_live_strategy(provider):
provider=provider, config=ValueSelectionConfig(max_pool=max_pool)),
"small_cap": lambda: SmallCapStrategy(
provider=provider, config=SmallCapConfig(max_pool=max_pool)),
"channel_test": lambda: ChannelTestStrategy(
provider=provider, config=ChannelTestConfig()),
}
if name not in factories:
raise ValueError(
+5 -2
View File
@@ -56,8 +56,8 @@ def parse_args() -> argparse.Namespace:
p.add_argument("--slippage", type=float, default=0.0, help="滑点比率(万10=0.001,0=不加)")
p.add_argument(
"--strategy", default="all_weather",
choices=["all_weather", "momentum_timing", "value_selection", "small_cap"],
help="策略: all_weather(全天候轮动) / momentum_timing(牛熊分界+取强舍弱+均线动量) / value_selection(价值精选6条月度调仓) / small_cap(小市值20只轮动,无对冲)",
choices=["all_weather", "momentum_timing", "value_selection", "small_cap", "channel_test"],
help="策略: all_weather / momentum_timing / value_selection / small_cap / channel_test(通路测试,影子vs实盘双轨验证)",
)
p.add_argument(
"--provider", default="local", choices=["local", "baostock", "miniqmt", "unified"],
@@ -176,6 +176,9 @@ def _build_strategy(args: argparse.Namespace, provider: Any) -> Any:
provider=provider,
config=SmallCapConfig(max_pool=args.max_pool),
)
if name == "channel_test":
from .strategies import ChannelTestConfig, ChannelTestStrategy
return ChannelTestStrategy(provider=provider, config=ChannelTestConfig())
raise ValueError(
f"未知 strategy: {name}(支持: all_weather / momentum_timing / value_selection / small_cap)"
)
+3
View File
@@ -1,5 +1,6 @@
"""sanguo_portfolio 策略层。"""
from .all_weather import AllWeatherConfig, AllWeatherStrategy, BrokerFacade
from .channel_test import ChannelTestConfig, ChannelTestStrategy
from .momentum_timing import MomentumTimingConfig, MomentumTimingStrategy
from .small_cap import SmallCapConfig, SmallCapStrategy
from .value_selection import ValueSelectionConfig, ValueSelectionStrategy
@@ -8,6 +9,8 @@ __all__ = [
"AllWeatherStrategy",
"AllWeatherConfig",
"BrokerFacade",
"ChannelTestStrategy",
"ChannelTestConfig",
"MomentumTimingStrategy",
"MomentumTimingConfig",
"SmallCapStrategy",
+116
View File
@@ -0,0 +1,116 @@
"""通路测试策略(影子柜台 vs 实盘 双轨验证专用,docs/design/paper-shadow-desk-design.md §8)。
不是为赚钱是为**把买卖全通路在真实 miniQMT 数据上跑通** ShadowBroker
QmtBroker 在同一段时间同一批订单上各自成交事后对账验证通路正确性
设计目标每个调仓日都尽量触发
- order_target_value 等权买入若干只 走买入 + 整手(100) + 资金扣减 + 均价
- 次日先清掉上轮非目标持仓 走卖出 + 印花税 + T+1 可卖
- 轮换目标集每个周期偏移一格 既有卖()又有买()长期跑必两端都覆盖
- T+1 拒单探针买入后立即试卖同一只(当日) 两端都应被 T+1 验证拒单通路一致
- 上涨/下跌/停牌/涨跌停靠自然行情出现差异进双轨对账报告P1.3 涨跌停拦截随影子柜台完善
universe 默认几只高流动性 ETF/蓝筹miniQMT 必有数据好成交可经 env/max_pool
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from typing import Any, List, Optional
from .all_weather import BrokerFacade, _available_cash, _get_positions
logger = logging.getLogger(__name__)
# 默认 universe:流动性好的 ETF + 蓝筹,miniQMT 必有数据,实盘也容易成交
_DEFAULT_UNIVERSE: List[str] = [
"510300.XSHG", # 沪深300ETF
"510050.XSHG", # 上证50ETF
"159915.XSHE", # 创业板ETF
"510500.XSHG", # 中证500ETF
"588000.XSHG", # 科创50ETF
]
@dataclass
class ChannelTestConfig:
universe: List[str] = field(default_factory=lambda: list(_DEFAULT_UNIVERSE))
hold_n: int = 2 # 每轮等权持有几只
period: int = 1 # 每 N 个交易日轮换一次(1=每日)
probe_t1: bool = True # 是否做 T+1 当日卖探针(验证拒单通路)
benchmark: str = "000300.XSHG"
class ChannelTestStrategy:
"""通路测试策略:周期性等权轮换 + T+1 拒单探针。"""
def __init__(
self,
provider: Any,
broker: Optional[BrokerFacade] = None,
config: Optional[ChannelTestConfig] = None,
) -> None:
self.provider = provider
self.broker = broker or BrokerFacade()
self.config = config or ChannelTestConfig()
self._day = 0
def initialize(self, context: Any) -> None:
b = self.broker
b.set_benchmark(self.config.benchmark)
b.set_option("use_real_price", True)
b.set_option("avoid_future_data", True)
b.run_daily(self.rotate, "9:30")
# ---------------- 轮换主流程 ----------------
def _target_set(self) -> List[str]:
"""按 day 偏移在 universe 里取 hold_n 只(循环),保证每轮目标变。"""
u = self.config.universe or _DEFAULT_UNIVERSE
n = max(1, self.config.hold_n)
if len(u) <= n:
return list(u)
offset = (self._day // max(1, self.config.period)) % len(u)
# 取从 offset 起的 n 只(环绕)
return [u[(offset + i) % len(u)] for i in range(n)]
def rotate(self, context: Any) -> None:
self._day += 1
if (self._day - 1) % max(1, self.config.period) != 0:
return
positions = _get_positions(context)
target = self._target_set()
target_set = set(target)
logger.info("[channel_test] day=%d target=%s holding=%s",
self._day, target, list(positions.keys()))
# 1) 卖:清掉不在目标里的持仓(走卖出通路)
for code in list(positions.keys()):
if code not in target_set:
logger.info("[channel_test] 卖出 %s", code)
self.broker.order_target_value(code, 0)
# 2) 买/调:目标等权(走买入通路 + 整手)
cash = _available_cash(context)
total = cash + sum(_safe_value(p) for p in positions.values())
per = total / max(1, len(target))
for code in target:
logger.info("[channel_test] 调仓 %s → target_value=%.2f", code, per)
self.broker.order_target_value(code, per)
# 3) T+1 拒单探针:当日买入立即试卖 → 两端都应 T+1 拒(验证拒单通路)
if self.config.probe_t1 and target:
probe_code = target[0]
try:
self.broker.order_target_value(probe_code, 0)
logger.info("[channel_test] T+1 探针 %s 当日卖已提交(预期被拒)", probe_code)
except Exception as exc: # noqa: BLE001 - 探针失败不阻断主流程
logger.debug("[channel_test] T+1 探针异常(正常): %s", exc)
def _safe_value(pos: Any) -> float:
"""从 position 对象取市值,兼容多种属性名。"""
for attr in ("value", "market_value", "total_value"):
v = getattr(pos, attr, None)
if isinstance(v, (int, float)):
return float(v)
return 0.0
+86
View File
@@ -0,0 +1,86 @@
"""通路测试策略(ChannelTestStrategy)单元测试。
验证轮换目标集 + 卖旧买新调度 + T+1 探针, mock broker 记录下单调用
"""
from __future__ import annotations
from sanguo_portfolio.strategies import ChannelTestConfig, ChannelTestStrategy
from sanguo_portfolio.strategies.all_weather import BrokerFacade
class _MockBroker(BrokerFacade):
def __init__(self) -> None:
# 注意:BrokerFacade 是 dataclass,父类 __init__ 会用字段默认值覆盖同名
# 实例属性,子类方法重写会被遮蔽 → 必须在 super().__init__() 之后
# 用实例属性注入记录函数。
self.calls: list[tuple[str, str, float]] = [] # (method, code, value)
super().__init__()
self.order_target_value = self._record_otv
def _record_otv(self, code: str, value: float):
self.calls.append(("otv", code, value))
return None
class _Pos:
def __init__(self, value: float) -> None:
self.value = value
class _Ctx:
def __init__(self, positions: dict, cash: float) -> None:
self.portfolio = type("P", (), {"positions": positions,
"available_cash": cash,
"total_value": cash + sum(p.value for p in positions.values())})()
def test_target_set_rotates_with_day():
s = ChannelTestStrategy(provider=None, config=ChannelTestConfig(
universe=["A", "B", "C", "D"], hold_n=2, period=1))
s._day = 0
assert set(s._target_set()) <= {"A", "B", "C", "D"}
s._day = 1
t1 = s._target_set()
s._day = 2
t2 = s._target_set()
assert len(t1) == 2 and len(t2) == 2
assert t1 != t2 # 不同周期目标偏移
def test_rotate_sells_non_target_and_buys_target():
broker = _MockBroker()
s = ChannelTestStrategy(provider=None, broker=broker, config=ChannelTestConfig(
universe=["A", "B", "C", "D"], hold_n=2, period=1, probe_t1=False))
# rotate#1 → day=1 → offset=1 → target=[B,C];当前持仓 C,D
# → 卖出 D(C 在目标内保留),对 B,C 调仓(买入)
ctx = _Ctx({"C": _Pos(1000), "D": _Pos(1000)}, cash=8000)
s.rotate(ctx)
zero_calls = {c for m, c, v in broker.calls if m == "otv" and v == 0}
assert zero_calls == {"D"} # 只卖非目标的 D
buys = {c for m, c, v in broker.calls if m == "otv" and v > 0}
assert buys == {"B", "C"} # 买新 B + 调仓 C
def test_rotate_t1_probe_fires():
broker = _MockBroker()
s = ChannelTestStrategy(provider=None, broker=broker, config=ChannelTestConfig(
universe=["A", "B"], hold_n=1, period=1, probe_t1=True))
ctx = _Ctx({}, cash=10000)
s.rotate(ctx)
# rotate#1 → target=[B];探针对 target[0]=B 再次 order_target_value(0)(当日卖,预期被 T+1 拒)
otv_calls = [c for m, c, v in broker.calls if m == "otv"]
assert otv_calls.count("B") >= 2 # 一次买入调仓 + 一次 T+1 探针
def test_period_skips_off_cycle_days():
broker = _MockBroker()
s = ChannelTestStrategy(provider=None, broker=broker, config=ChannelTestConfig(
universe=["A", "B"], hold_n=1, period=3, probe_t1=False))
ctx = _Ctx({}, cash=10000)
s.rotate(ctx) # day1 → 触发
n1 = len(broker.calls)
s.rotate(ctx) # day2 → 跳过
s.rotate(ctx) # day3 → 跳过
assert len(broker.calls) == n1
s.rotate(ctx) # day4 → 触发
assert len(broker.calls) > n1