feat(portfolio): P1-a 组合策略K线周期字段(前后端): 模拟盘新建组合卡显周期下拉(原v-if藏起,实走/影子用miniQMT档位); live组合分支不写死d(空=d默认,cta空=15m); 组合回测加interval(d=day,非d拒400+runner拒ValueError,分钟档等数据层); worker params存interval供回放预填; 前端组合回测表单加下拉(仅日线可选,其余disabled标注影子可用) [vps]
This commit is contained in:
@@ -6,6 +6,7 @@ import type { EChartsCoreOption } from 'echarts'
|
||||
import { useChart } from '@/composables/useChart'
|
||||
import { darkTitle, darkTooltip, darkGrid, darkAxis } from '@/utils/echartsDark'
|
||||
import { getTaskParams } from '@/api/backtest'
|
||||
import { INTERVAL_OPTIONS } from '@/constants/intervals'
|
||||
import {
|
||||
postPortfolioBacktest,
|
||||
getPortfolioResult,
|
||||
@@ -60,6 +61,7 @@ const form = reactive({
|
||||
stamp_duty_rate: 0.001,
|
||||
min_commission: 5,
|
||||
slippage: 0.001,
|
||||
interval: 'd',
|
||||
})
|
||||
watch(
|
||||
() => [form.start, form.end],
|
||||
@@ -360,6 +362,7 @@ async function onSubmit(): Promise<void> {
|
||||
stamp_duty_rate: Number(form.stamp_duty_rate),
|
||||
min_commission: Number(form.min_commission),
|
||||
slippage: Number(form.slippage),
|
||||
interval: form.interval,
|
||||
})
|
||||
ElMessage.success('回测已提交,后台运行中')
|
||||
router.push('/backtest/history') // 跳任务中心(历史任务页)看进度/结果
|
||||
@@ -398,6 +401,7 @@ async function prefillFromTask(tid: string): Promise<void> {
|
||||
if (p.stamp_duty_rate != null) form.stamp_duty_rate = Number(p.stamp_duty_rate)
|
||||
if (p.min_commission != null) form.min_commission = Number(p.min_commission)
|
||||
if (p.slippage != null) form.slippage = Number(p.slippage)
|
||||
if (p.interval) form.interval = String(p.interval)
|
||||
} catch {
|
||||
/* 预填失败,走默认 */
|
||||
}
|
||||
@@ -444,6 +448,15 @@ function fmtNum(v: number | null | undefined, digits = 2): string {
|
||||
<el-input-number v-model="form.max_pool" :min="0" :step="10" :controls="false" style="width: 220px" />
|
||||
<span class="muted form-hint">0=全市场不限, N=前N只(MVP验证用, 默认30)</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="K 线周期">
|
||||
<el-select v-model="form.interval" style="width: 220px">
|
||||
<el-option v-for="o in INTERVAL_OPTIONS" :key="o.value" :value="o.value" :label="o.label" :disabled="o.value !== 'd'">
|
||||
<span>{{ o.label }}</span>
|
||||
<span v-if="o.value !== 'd'" class="muted" style="float: right; font-size: 12px">影子柜台实时可用·组合回放暂仅日线</span>
|
||||
</el-option>
|
||||
</el-select>
|
||||
<span class="muted form-hint">组合回放暂仅日线(本地数据);分钟档影子柜台/实盘可用</span>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
|
||||
@@ -287,6 +287,15 @@ async function onSubmit(): Promise<void> {
|
||||
<el-option v-for="b in BENCH_OPTIONS" :key="b.value" :label="b.label" :value="b.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="K 线周期">
|
||||
<el-select v-model="form.interval" style="width: 220px">
|
||||
<el-option v-for="o in INTERVAL_OPTIONS" :key="o.value" :value="o.value" :label="o.label">
|
||||
<span>{{ o.label }}</span>
|
||||
<span v-if="!o.replayOk" class="muted" style="float: right; font-size: 12px">影子柜台实时可用·回放暂无本地数据</span>
|
||||
</el-option>
|
||||
</el-select>
|
||||
<span class="muted form-hint">miniQMT 成品K线,默认日线</span>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
|
||||
@@ -206,15 +206,15 @@ function onSymbols(v: string): void {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!isPortfolio" class="seg-label" style="margin-top:16px">K 线周期</div>
|
||||
<el-select v-if="!isPortfolio" v-model="form.interval" style="width: 220px">
|
||||
<el-option v-for="i in INTERVAL_OPTIONS" :key="i.value"
|
||||
<div class="seg-label" style="margin-top:16px">K 线周期</div>
|
||||
<el-select v-model="form.interval" style="width: 220px">
|
||||
<el-option v-for="i in INTERVAL_OPTIONS" :key="i.value"
|
||||
:value="i.value" :label="i.label">
|
||||
<span>{{ i.label }}</span>
|
||||
<span v-if="!i.replayOk" class="muted" style="float:right;font-size:12px">影子柜台实时可用·回放暂无本地数据</span>
|
||||
</el-option>
|
||||
</el-select>
|
||||
<div v-if="!isPortfolio" class="muted" style="font-size:12px;margin-top:4px">
|
||||
<div class="muted" style="font-size:12px;margin-top:4px">
|
||||
{{ INTERVAL_OPTIONS.find(o => o.value === form.interval)?.matchHint || (form.interval === 'd' ? '' : '分钟级周期需标的分钟数据') }}
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
@@ -43,7 +43,7 @@ class LiveCreateRequest(BaseModel):
|
||||
strategy_class: str = "AShareDoubleMaStrategy"
|
||||
strategy_name: str
|
||||
setting: dict = {}
|
||||
interval: str = "15m"
|
||||
interval: str = "" # 空=按类型给默认(cta→15m, portfolio→d);前端下拉显式传
|
||||
initial_capital: float = 1_000_000
|
||||
connect_wait_sec: int = 10
|
||||
init_wait_sec: int = 60
|
||||
@@ -83,9 +83,11 @@ def create_live(req: LiveCreateRequest):
|
||||
payload.setdefault("max_pool", 30)
|
||||
payload.setdefault("benchmark", "000300.XSHG")
|
||||
payload["vt_symbol"] = payload["pool"]
|
||||
payload["interval"] = "d"
|
||||
# 周期由前端下拉传(miniQMT 成品K线档位);空=默认日线
|
||||
payload["interval"] = payload.get("interval") or "d"
|
||||
else:
|
||||
# CTA 实盘:标的允许只写 6 位码,后端自动补交易所后缀
|
||||
payload["interval"] = payload.get("interval") or "15m"
|
||||
payload["vt_symbol"] = _normalize_vt_symbol(payload.get("vt_symbol", ""))
|
||||
# mini_path 兜底:req → env SANGUO_QMT_PATH → 内置默认(空值会导致 connect=-1)
|
||||
if not payload.get("mini_path"):
|
||||
|
||||
@@ -43,11 +43,14 @@ class PortfolioBacktestRequest(BaseModel):
|
||||
stamp_duty_rate: float = Field(default=0.001, description="印花税率卖出(千1=0.001)")
|
||||
min_commission: float = Field(default=5.0, description="单笔最低佣金(元)")
|
||||
slippage: float = Field(default=0.0, description="滑点比率(万10=0.001,0=不加)")
|
||||
interval: str = Field(default="d", description="K线周期:组合回放暂仅日线(d)")
|
||||
|
||||
|
||||
@router.post("/portfolio/backtest", dependencies=[Depends(verify_token)])
|
||||
async def run_portfolio_backtest(req: PortfolioBacktestRequest):
|
||||
"""异步提交组合回测任务,返回 task_id。前端轮询 GET /task/{id} 再取结果。"""
|
||||
if req.interval != "d":
|
||||
raise HTTPException(400, "组合回放暂仅支持日线(影子柜台将支持全周期分钟档)")
|
||||
tid = await get_orchestrator().submit_portfolio(
|
||||
start=req.start_date,
|
||||
end=req.end_date,
|
||||
@@ -60,6 +63,7 @@ async def run_portfolio_backtest(req: PortfolioBacktestRequest):
|
||||
stamp_duty_rate=req.stamp_duty_rate,
|
||||
min_commission=req.min_commission,
|
||||
slippage=req.slippage,
|
||||
interval=req.interval,
|
||||
)
|
||||
return {"task_id": tid}
|
||||
|
||||
|
||||
@@ -119,6 +119,7 @@ def run_portfolio_task(spec: dict) -> Any:
|
||||
"stamp_duty_rate": spec.get("stamp_duty_rate", 0.001),
|
||||
"min_commission": spec.get("min_commission", 5.0),
|
||||
"slippage": spec.get("slippage", 0.0),
|
||||
"interval": spec.get("interval", "d"),
|
||||
},
|
||||
start=period.get("start", ""),
|
||||
end=period.get("end", ""),
|
||||
|
||||
@@ -141,7 +141,8 @@ class Orchestrator:
|
||||
commission_rate: float = 0.0003,
|
||||
stamp_duty_rate: float = 0.001,
|
||||
min_commission: float = 5.0,
|
||||
slippage: float = 0.0) -> str:
|
||||
slippage: float = 0.0,
|
||||
interval: str = "d") -> str:
|
||||
"""Submit a portfolio backtest task asynchronously.
|
||||
|
||||
Runs runner_backtest as a subprocess (3600s hard cap) inside the
|
||||
@@ -165,6 +166,7 @@ class Orchestrator:
|
||||
stamp_duty_rate=stamp_duty_rate,
|
||||
min_commission=min_commission,
|
||||
slippage=slippage,
|
||||
interval=interval,
|
||||
db_path=self.db_path,
|
||||
file_dir=file_dir,
|
||||
)
|
||||
|
||||
@@ -375,6 +375,10 @@ def _write_result_md(result: Dict[str, Any], path: str, args: argparse.Namespace
|
||||
logger.warning("写结果文件失败: %s", exc)
|
||||
|
||||
|
||||
def _raise_interval(interval: Any) -> str:
|
||||
raise ValueError(f"组合回测暂仅支持日线(interval=d),收到: {interval}")
|
||||
|
||||
|
||||
def run_backtest_json(params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""JSON 入口(供 SSH 触发,前端 MVP 用)。
|
||||
|
||||
@@ -402,7 +406,9 @@ def run_backtest_json(params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
end=params.get("end_date", "2024-02-29"),
|
||||
cash=float(params.get("initial_cash", 1_000_000.0)),
|
||||
benchmark=params.get("benchmark", "000300.XSHG"),
|
||||
frequency="day",
|
||||
# 周期:目前组合回放只有日线有本地数据;分钟级等数据层补齐后放开
|
||||
frequency="day" if params.get("interval", "d") in ("", "d", "day") else (
|
||||
_raise_interval(params.get("interval"))),
|
||||
strategy=strategy_name,
|
||||
provider=params.get("provider", "local"),
|
||||
provider_config=params.get("provider_config", "{}"),
|
||||
|
||||
@@ -27,6 +27,7 @@ def _create_portfolio(db, **kw):
|
||||
strategy_type="portfolio", strategy_class=kw.get("strategy_class", "all_weather"),
|
||||
pool=kw.get("pool", "hs300_subset"), max_pool=kw.get("max_pool", 30),
|
||||
benchmark=kw.get("benchmark", "000300.XSHG"),
|
||||
interval=kw.get("interval", ""),
|
||||
initial_capital=kw.get("initial_capital", 500000),
|
||||
)
|
||||
return rl.create_live(req)["account_id"]
|
||||
@@ -72,10 +73,17 @@ def test_create_portfolio_live(live_db):
|
||||
acc = live_persistence.get_account(live_db, aid)
|
||||
assert acc["strategy_type"] == "portfolio"
|
||||
assert acc["vt_symbol"] == "hs300_subset" # 组合行 vt_symbol=池名
|
||||
assert acc["interval"] == "d"
|
||||
assert acc["interval"] == "d" # 未传周期 → 组合默认日线
|
||||
assert acc["status"] == "stopped"
|
||||
|
||||
|
||||
def test_create_portfolio_interval_passthrough(live_db):
|
||||
"""组合实盘周期由前端下拉传(miniQMT 档位),后端不写死。"""
|
||||
aid = _create_portfolio(live_db, interval="15m")
|
||||
acc = live_persistence.get_account(live_db, aid)
|
||||
assert acc["interval"] == "15m"
|
||||
|
||||
|
||||
def test_create_portfolio_rejects_empty_strategy(live_db):
|
||||
with pytest.raises(Exception):
|
||||
_create_portfolio(live_db, strategy_class="")
|
||||
|
||||
Reference in New Issue
Block a user