feat(portfolio): 组合回测接入A股费用(佣金/印花税/最低佣金/滑点)对齐个股回测; BulletTrade set_order_cost/set_slippage [vps]
CI/CD / test (push) Successful in 9s
CI/CD / nas-deploy (push) Successful in 25s
CI/CD / nas-verify (push) Successful in 10s

This commit is contained in:
2026-08-13 10:32:49 +08:00
parent b48a3b5c75
commit 66b393df16
6 changed files with 82 additions and 3 deletions
+5
View File
@@ -8,6 +8,11 @@ export interface PortfolioBacktestReq {
end_date: string
initial_cash: number
benchmark?: string
// A 股费用(对齐个股回测)
commission_rate?: number
stamp_duty_rate?: number
min_commission?: number
slippage?: number
}
export interface EquityPoint {
@@ -57,6 +57,11 @@ const form = reactive({
end: dr.end,
cash: 1_000_000,
benchmark: '000300.XSHG',
// A 股费用(对齐个股回测)
commission_rate: 0.0003,
stamp_duty_rate: 0.001,
min_commission: 5,
slippage: 0.001,
})
watch(
() => [form.start, form.end],
@@ -199,6 +204,10 @@ async function onSubmit(): Promise<void> {
end_date: form.end,
initial_cash: form.cash,
benchmark: form.benchmark,
commission_rate: Number(form.commission_rate),
stamp_duty_rate: Number(form.stamp_duty_rate),
min_commission: Number(form.min_commission),
slippage: Number(form.slippage),
})
try {
localStorage.setItem('last_portfolio_task', tid)
@@ -328,6 +337,22 @@ function fmtNum(v: number | null | undefined, digits = 2): string {
<el-option v-for="b in BENCHMARK_OPTIONS" :key="b.value" :label="b.label" :value="b.value" />
</el-select>
</el-form-item>
<el-form-item label="佣金率">
<el-input v-model="form.commission_rate" style="width: 160px" />
<span class="muted form-hint">0.0003=万3(双边,最低5元)</span>
</el-form-item>
<el-form-item label="印花税率">
<el-input v-model="form.stamp_duty_rate" style="width: 160px" />
<span class="muted form-hint">0.001=千1(仅卖出)</span>
</el-form-item>
<el-form-item label="最低佣金">
<el-input v-model="form.min_commission" style="width: 160px" />
<span class="muted form-hint">/</span>
</el-form-item>
<el-form-item label="滑点(比率)">
<el-input v-model="form.slippage" style="width: 160px" />
<span class="muted form-hint">0.001=万10,0=不加</span>
</el-form-item>
</el-form>
<div class="submit-bar">
<el-button type="primary" size="large" @click="onSubmit">开始回测</el-button>
+9
View File
@@ -38,6 +38,11 @@ class PortfolioBacktestRequest(BaseModel):
description="策略: all_weather/momentum_timing/value_selection/small_cap",
)
max_pool: int = Field(default=30, description="选股池上限: 0=全市场不限, N=前N只(MVP验证用)")
# A 股费用(对齐个股回测)
commission_rate: float = Field(default=0.0003, description="佣金率双边(万3=0.0003)")
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=不加)")
@router.post("/portfolio/backtest", dependencies=[Depends(verify_token)])
@@ -51,6 +56,10 @@ async def run_portfolio_backtest(req: PortfolioBacktestRequest):
strategy=req.strategy,
max_pool=req.max_pool,
provider_config=None,
commission_rate=req.commission_rate,
stamp_duty_rate=req.stamp_duty_rate,
min_commission=req.min_commission,
slippage=req.slippage,
)
return {"task_id": tid}
+14 -2
View File
@@ -60,7 +60,11 @@ def run_portfolio_task(spec: dict) -> Any:
db_path = spec.get("db_path", "")
file_dir = spec.get("file_dir")
argv, cwd = _build_argv(start, end, cash, benchmark, max_pool, provider_config, strategy)
argv, cwd = _build_argv(
start, end, cash, benchmark, max_pool, provider_config, strategy,
spec.get("commission_rate", 0.0003), spec.get("stamp_duty_rate", 0.001),
spec.get("min_commission", 5.0), spec.get("slippage", 0.0),
)
logger.info("[portfolio_worker] task=%s running: %s", task_id, " ".join(argv[3:]))
try:
@@ -130,6 +134,8 @@ def _build_argv(
start: str, end: str, cash: float, benchmark: str,
max_pool: int, provider_config: Optional[dict],
strategy: str = "all_weather",
commission_rate: float = 0.0003, stamp_duty_rate: float = 0.001,
min_commission: float = 5.0, slippage: float = 0.0,
) -> tuple[list[str], Optional[str]]:
"""Three-machine adaptive argv construction.
@@ -146,6 +152,10 @@ def _build_argv(
"--cash", str(cash), "--benchmark", benchmark,
"--strategy", strategy,
"--max-pool", str(max_pool),
"--commission-rate", str(commission_rate),
"--stamp-duty-rate", str(stamp_duty_rate),
"--min-commission", str(min_commission),
"--slippage", str(slippage),
]
# NAS container: unified provider reads NAS authoritative data layer
# (dbbardata + parquet). VPS keeps default provider (cwd=_VPS_WORKDIR).
@@ -168,7 +178,9 @@ def _build_argv(
f"--start {start} --end {end} "
f"--cash {cash} --benchmark {benchmark} "
f"--strategy {strategy} "
f"--max-pool {max_pool}"
f"--max-pool {max_pool} "
f"--commission-rate {commission_rate} --stamp-duty-rate {stamp_duty_rate} "
f"--min-commission {min_commission} --slippage {slippage}"
)
ssh_argv = [
"ssh", "-o", "ConnectTimeout=15", "-o", "StrictHostKeyChecking=no",
+9 -1
View File
@@ -124,7 +124,11 @@ class Orchestrator:
async def submit_portfolio(self, start: str, end: str, cash: float,
benchmark: str, strategy: str = "all_weather",
max_pool: int = 30,
provider_config=None) -> str:
provider_config=None,
commission_rate: float = 0.0003,
stamp_duty_rate: float = 0.001,
min_commission: float = 5.0,
slippage: float = 0.0) -> str:
"""Submit a portfolio backtest task asynchronously.
Runs runner_backtest as a subprocess (3600s hard cap) inside the
@@ -143,6 +147,10 @@ class Orchestrator:
strategy=strategy,
max_pool=max_pool,
provider_config=provider_config,
commission_rate=commission_rate,
stamp_duty_rate=stamp_duty_rate,
min_commission=min_commission,
slippage=slippage,
db_path=self.db_path,
file_dir=file_dir,
)
+20
View File
@@ -49,6 +49,11 @@ def parse_args() -> argparse.Namespace:
p.add_argument("--benchmark", default="000300.XSHG", help="基准代码")
p.add_argument("--max-pool", type=int, default=0, help="限制选股池前N只(0=不限,MVP验证用)")
p.add_argument("--frequency", default="day", help="回测频率 day/minute")
# A 股费用(对齐个股回测;BulletTrade 默认仅印花税千1+最低5元,这里显式可配)
p.add_argument("--commission-rate", type=float, default=0.0003, help="佣金率双边(万3=0.0003)")
p.add_argument("--stamp-duty-rate", type=float, default=0.001, help="印花税率卖出(千1=0.001)")
p.add_argument("--min-commission", type=float, default=5.0, help="单笔最低佣金(元)")
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"],
@@ -237,6 +242,21 @@ def run_backtest(args: argparse.Namespace) -> Dict[str, Any]:
# 才能真正设到 bullet_trade settings → fq_mode=pre 与 get_current_data 一致, 买入才成交
holder["broker"] = build_broker_facade_inner(strategy, context)
strategy.broker = holder["broker"]
# A 股费用 + 滑点(聚宽风格全局函数,对齐个股回测;BulletTrade 默认费用不全)
from bullet_trade.core.api import set_order_cost, set_slippage # type: ignore
from bullet_trade.core.settings import OrderCost, FixedSlippage # type: ignore
set_order_cost(
OrderCost(
open_tax=0.0,
close_tax=args.stamp_duty_rate,
open_commission=args.commission_rate,
close_commission=args.commission_rate,
min_commission=args.min_commission,
),
type="stock",
)
if args.slippage:
set_slippage(FixedSlippage(value=args.slippage))
strategy.initialize(context)
def build_broker_facade_inner(strategy: Any, context: Any):