diff --git a/frontend/src/api/portfolio.ts b/frontend/src/api/portfolio.ts index e968d9a..9191e30 100644 --- a/frontend/src/api/portfolio.ts +++ b/frontend/src/api/portfolio.ts @@ -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 { diff --git a/frontend/src/views/backtest/PortfolioBacktest.vue b/frontend/src/views/backtest/PortfolioBacktest.vue index 2857829..b5a4d3b 100644 --- a/frontend/src/views/backtest/PortfolioBacktest.vue +++ b/frontend/src/views/backtest/PortfolioBacktest.vue @@ -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 { 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 { + + + 0.0003=万3(双边,最低5元) + + + + 0.001=千1(仅卖出) + + + + 元/笔 + + + + 0.001=万10,0=不加 +
开始回测 diff --git a/sanguo_api/routes_portfolio.py b/sanguo_api/routes_portfolio.py index 0d23fc5..bf51964 100644 --- a/sanguo_api/routes_portfolio.py +++ b/sanguo_api/routes_portfolio.py @@ -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} diff --git a/sanguo_orchestrator/portfolio_worker.py b/sanguo_orchestrator/portfolio_worker.py index d8b9d26..d758139 100644 --- a/sanguo_orchestrator/portfolio_worker.py +++ b/sanguo_orchestrator/portfolio_worker.py @@ -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", diff --git a/sanguo_orchestrator/runner.py b/sanguo_orchestrator/runner.py index 149f4ba..e1eb7af 100644 --- a/sanguo_orchestrator/runner.py +++ b/sanguo_orchestrator/runner.py @@ -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, ) diff --git a/sanguo_portfolio/runner_backtest.py b/sanguo_portfolio/runner_backtest.py index e7731c7..65715f2 100644 --- a/sanguo_portfolio/runner_backtest.py +++ b/sanguo_portfolio/runner_backtest.py @@ -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):