diff --git a/docs/joinquant_comparison_plugin.md b/docs/joinquant_comparison_plugin.md index e8211dd..4162cfe 100644 --- a/docs/joinquant_comparison_plugin.md +++ b/docs/joinquant_comparison_plugin.md @@ -102,6 +102,31 @@ dated by construction/signal date, while the simulator executes at the next available open. The calendar option shifts exported target files to that next trading date, so JoinQuant reads the same target on the same execution session. +For JoinQuant 模拟盘, the browser automation has two operational phases: + +```bash +# Before T open: upload frozen target(s), save strategy, and start/restart 模拟盘. +uv run python cli.py joinquant write-browser-config \ + --out-path /tmp/chinese-equity-quant-realdata/joinquant_sim_config.json \ + --strategy-url "https://www.joinquant.com/" \ + --flow sim-trade + +uv run python cli.py joinquant run-browser-sim \ + --manifest-path /tmp/chinese-equity-quant-realdata/joinquant_smoke_manifest.json \ + --config-path /tmp/chinese-equity-quant-realdata/joinquant_sim_config.json \ + --storage-state ~/.config/chinese-equity-quant/joinquant_storage_state.json \ + --headed + +# After T close: download/export JoinQuant fills, positions, and pnl, then +# ingest/reconcile. The same run-browser-sim command can do this if the config +# includes download actions, otherwise use the ingest/reconcile commands. +``` + +The default simulated-trading template includes selectors for saving the +strategy and clicking simulated-trading controls such as `模拟盘`, `模拟交易`, +`启动`, and `重启`. These selectors are intentionally configurable because the +JoinQuant web UI can differ by account and page version. + ## Target-Shares Mode `target_shares` is the default and preferred correctness mode. The exported @@ -261,12 +286,13 @@ uv run python cli.py joinquant browser-login \ --storage-state ~/.config/chinese-equity-quant/joinquant_storage_state.json ``` -Create a selector/action config: +Create a selector/action config for a historical backtest: ```bash uv run python cli.py joinquant write-browser-config \ --out-path /tmp/chinese-equity-quant-realdata/joinquant_browser_config.json \ - --strategy-url "https://www.joinquant.com/" + --strategy-url "https://www.joinquant.com/" \ + --flow backtest ``` If selectors need tuning, capture the logged-in page: @@ -293,6 +319,16 @@ download result CSVs, and take screenshots. When the configured downloads produce fills, positions, and PnL CSVs, the runner automatically calls `joinquant ingest` and `joinquant reconcile`. +For forward testing / 模拟盘, create the config with `--flow sim-trade` and run: + +```bash +uv run python cli.py joinquant run-browser-sim \ + --manifest-path /tmp/chinese-equity-quant-realdata/joinquant_smoke_manifest.json \ + --config-path /tmp/chinese-equity-quant-realdata/joinquant_sim_config.json \ + --storage-state ~/.config/chinese-equity-quant/joinquant_storage_state.json \ + --headed +``` + Do not store raw JoinQuant passwords in this repository. The browser state file is created with `0600` permissions and should live under `~/.config`, outside the repo. diff --git a/plugins/joinquant/README.md b/plugins/joinquant/README.md index df92194..24c38cb 100644 --- a/plugins/joinquant/README.md +++ b/plugins/joinquant/README.md @@ -29,13 +29,24 @@ uv run python cli.py joinquant browser-login \ uv run python cli.py joinquant write-browser-config \ --out-path /tmp/chinese-equity-quant-realdata/joinquant_browser_config.json \ - --strategy-url "https://www.joinquant.com/..." + --strategy-url "https://www.joinquant.com/..." \ + --flow backtest uv run python cli.py joinquant run-browser-backtest \ --manifest-path /tmp/chinese-equity-quant-realdata/joinquant_smoke_manifest.json \ --config-path /tmp/chinese-equity-quant-realdata/joinquant_browser_config.json \ --storage-state ~/.config/chinese-equity-quant/joinquant_storage_state.json +uv run python cli.py joinquant write-browser-config \ + --out-path /tmp/chinese-equity-quant-realdata/joinquant_sim_config.json \ + --strategy-url "https://www.joinquant.com/<模拟盘 page>" \ + --flow sim-trade + +uv run python cli.py joinquant run-browser-sim \ + --manifest-path /tmp/chinese-equity-quant-realdata/joinquant_smoke_manifest.json \ + --config-path /tmp/chinese-equity-quant-realdata/joinquant_sim_config.json \ + --storage-state ~/.config/chinese-equity-quant/joinquant_storage_state.json + uv run python cli.py joinquant export-targets \ --positions-path portfolio/run1.pq \ --portfolio-name run1 \ @@ -84,3 +95,9 @@ export paths. Playwright. It reuses a saved browser login state, executes the configured UI actions, downloads JoinQuant CSVs when configured, and runs ingest/reconcile automatically once all three CSVs are present. + +`run-browser-sim` is the forward-test / 模拟盘 equivalent. Use a `--flow +sim-trade` config to upload the frozen next-session target file, save the +strategy, and start or restart the JoinQuant simulated-trading job. After close, +run it again with download actions or use `ingest` / `reconcile` directly on +exported CSVs. diff --git a/plugins/joinquant/browser.py b/plugins/joinquant/browser.py index aa0eb7d..034317b 100644 --- a/plugins/joinquant/browser.py +++ b/plugins/joinquant/browser.py @@ -1,4 +1,4 @@ -"""Browser automation for JoinQuant cloud backtest runs. +"""Browser automation for JoinQuant cloud backtest and simulated-trading runs. JoinQuant's public ``jqdatasdk`` is a data API; cloud strategy upload/run/export is exposed through the web application. This module keeps that automation @@ -29,15 +29,124 @@ def _require_playwright(): raise RuntimeError( "Playwright is required for JoinQuant browser automation. Install it " "in this uv environment, then install Chromium:\n" - " uv pip install playwright\n" + " uv sync --extra joinquant-browser\n" " uv run playwright install chromium" ) from exc return sync_playwright, PlaywrightTimeoutError -def default_browser_config(strategy_url: str = "") -> dict[str, Any]: +def _backtest_actions() -> list[dict[str, Any]]: + return [ + {"type": "goto", "url": "{strategy_url}"}, + { + "type": "paste_text_file", + "selector": "textarea, .ace_text-input, .cm-content, .CodeMirror textarea", + "path": "{wrapper_path}", + "description": "Paste generated wrapper strategy into the code editor.", + }, + { + "type": "set_input_files", + "selector": "input[type=file]", + "paths": "{target_csvs}", + "description": "Upload all aligned daily target CSV files.", + "optional": True, + }, + { + "type": "fill", + "selector": "input[name=start_date], input[placeholder*=开始]", + "text": "{backtest_start_date}", + "optional": True, + }, + { + "type": "fill", + "selector": "input[name=end_date], input[placeholder*=结束]", + "text": "{backtest_end_date}", + "optional": True, + }, + { + "type": "click", + "selector": "text=/运行回测|开始回测|回测|Run Backtest|Run/i", + "description": "Start the JoinQuant backtest.", + }, + { + "type": "wait_for_selector", + "selector": "text=/回测完成|运行完成|Backtest complete|Finished/i", + "timeout_ms": 600_000, + "optional": True, + }, + { + "type": "download", + "selector": "text=/导出成交|下载成交|fills|trades/i", + "save_as": "{expected_joinquant_csvs.fills}", + "optional": True, + }, + { + "type": "download", + "selector": "text=/导出持仓|下载持仓|positions/i", + "save_as": "{expected_joinquant_csvs.positions}", + "optional": True, + }, + { + "type": "download", + "selector": "text=/导出收益|下载收益|pnl|收益/i", + "save_as": "{expected_joinquant_csvs.pnl}", + "optional": True, + }, + {"type": "screenshot", "path": "{run_artifact_dir}/final.png"}, + ] + + +def _sim_trade_actions() -> list[dict[str, Any]]: + return [ + {"type": "goto", "url": "{strategy_url}"}, + { + "type": "paste_text_file", + "selector": "textarea, .ace_text-input, .cm-content, .CodeMirror textarea", + "path": "{wrapper_path}", + "description": "Paste generated wrapper strategy into the simulated-trading strategy editor.", + }, + { + "type": "set_input_files", + "selector": "input[type=file]", + "paths": "{target_csvs}", + "description": "Upload frozen target CSV files for 模拟盘.", + "optional": True, + }, + { + "type": "click", + "selector": "text=/保存|Save/i", + "description": "Save the strategy code and uploaded files.", + "optional": True, + }, + { + "type": "wait_for_selector", + "selector": "text=/保存成功|已保存|Saved/i", + "timeout_ms": 120_000, + "optional": True, + }, + { + "type": "click", + "selector": "text=/模拟盘|模拟交易|启动模拟|运行模拟|启动|重启|Run Sim|Start|Restart/i", + "description": "Start or restart the JoinQuant simulated-trading job.", + }, + { + "type": "wait_for_selector", + "selector": "text=/运行中|已启动|模拟交易运行|Started|Running/i", + "timeout_ms": 180_000, + "optional": True, + }, + {"type": "screenshot", "path": "{run_artifact_dir}/sim_trade_final.png"}, + ] + + +def default_browser_config(strategy_url: str = "", *, flow: str = "backtest") -> dict[str, Any]: """Return a selector/action template for JoinQuant browser automation.""" + if flow not in {"backtest", "sim-trade", "sim_trade"}: + raise ValueError("flow must be 'backtest' or 'sim-trade'") + normalized_flow = "sim-trade" if flow == "sim_trade" else flow + actions = _sim_trade_actions() if normalized_flow == "sim-trade" else _backtest_actions() return { + "flow": normalized_flow, "strategy_url": strategy_url, "login_url": DEFAULT_LOGIN_URL, "headless": False, @@ -47,73 +156,25 @@ def default_browser_config(strategy_url: str = "") -> dict[str, Any]: "Use `joinquant browser-snapshot` to capture HTML/screenshots for selector tuning.", "The action list is declarative and runs in order.", ], - "actions": [ - {"type": "goto", "url": "{strategy_url}"}, - { - "type": "paste_text_file", - "selector": "textarea, .ace_text-input, .cm-content, .CodeMirror textarea", - "path": "{wrapper_path}", - "description": "Paste generated wrapper strategy into the code editor.", - }, - { - "type": "set_input_files", - "selector": "input[type=file]", - "paths": "{target_csvs}", - "description": "Upload all aligned daily target CSV files.", - "optional": True, - }, - { - "type": "fill", - "selector": "input[name=start_date], input[placeholder*=开始]", - "text": "{backtest_start_date}", - "optional": True, - }, - { - "type": "fill", - "selector": "input[name=end_date], input[placeholder*=结束]", - "text": "{backtest_end_date}", - "optional": True, - }, - { - "type": "click", - "selector": "text=/运行回测|开始回测|回测|Run Backtest|Run/i", - "description": "Start the JoinQuant backtest.", - }, - { - "type": "wait_for_selector", - "selector": "text=/回测完成|运行完成|Backtest complete|Finished/i", - "timeout_ms": 600_000, - "optional": True, - }, - { - "type": "download", - "selector": "text=/导出成交|下载成交|fills|trades/i", - "save_as": "{expected_joinquant_csvs.fills}", - "optional": True, - }, - { - "type": "download", - "selector": "text=/导出持仓|下载持仓|positions/i", - "save_as": "{expected_joinquant_csvs.positions}", - "optional": True, - }, - { - "type": "download", - "selector": "text=/导出收益|下载收益|pnl|收益/i", - "save_as": "{expected_joinquant_csvs.pnl}", - "optional": True, - }, - {"type": "screenshot", "path": "{run_artifact_dir}/final.png"}, - ], + "actions": actions, } -def write_browser_config_template(path: str | Path, *, strategy_url: str = "") -> Path: +def write_browser_config_template( + path: str | Path, + *, + strategy_url: str = "", + flow: str = "backtest", +) -> Path: """Write a JSON config template for browser automation.""" out_path = Path(path) out_path.parent.mkdir(parents=True, exist_ok=True) out_path.write_text( - json.dumps(default_browser_config(strategy_url), indent=2, ensure_ascii=False) + "\n", + json.dumps( + default_browser_config(strategy_url, flow=flow), + indent=2, + ensure_ascii=False, + ) + "\n", encoding="utf-8", ) return out_path @@ -302,7 +363,7 @@ def _run_action(page: Any, action: dict[str, Any], context: dict[str, Any], time return record -def run_browser_backtest( +def run_browser_flow( *, manifest_path: str | Path, config_path: str | Path, @@ -310,12 +371,14 @@ def run_browser_backtest( out_dir: str | Path | None = None, headless: bool | None = None, auto_reconcile: bool = True, + flow_name: str | None = None, ) -> dict[str, Any]: - """Run configured browser automation for a JoinQuant backtest.""" + """Run configured browser automation for a JoinQuant web workflow.""" sync_playwright, _ = _require_playwright() manifest = load_json(manifest_path) config = load_json(config_path) - artifact_dir = Path(out_dir or Path(str(manifest["joinquant_export_dir"])).parent / "browser_run") + flow = flow_name or str(config.get("flow") or "browser") + artifact_dir = Path(out_dir or Path(str(manifest["joinquant_export_dir"])).parent / f"browser_{flow}") artifact_dir.mkdir(parents=True, exist_ok=True) context_vars = _manifest_context(manifest, artifact_dir, config) @@ -371,6 +434,7 @@ def run_browser_backtest( "created_at": datetime.now(timezone.utc).isoformat(), "manifest_path": str(manifest_path), "config_path": str(config_path), + "flow": flow, "storage_state": str(storage_state), "artifact_dir": str(artifact_dir), "actions": records, @@ -384,3 +448,45 @@ def run_browser_backtest( encoding="utf-8", ) return report + + +def run_browser_backtest( + *, + manifest_path: str | Path, + config_path: str | Path, + storage_state: str | Path, + out_dir: str | Path | None = None, + headless: bool | None = None, + auto_reconcile: bool = True, +) -> dict[str, Any]: + """Run configured browser automation for a JoinQuant backtest.""" + return run_browser_flow( + manifest_path=manifest_path, + config_path=config_path, + storage_state=storage_state, + out_dir=out_dir, + headless=headless, + auto_reconcile=auto_reconcile, + flow_name="backtest", + ) + + +def run_browser_sim_trade( + *, + manifest_path: str | Path, + config_path: str | Path, + storage_state: str | Path, + out_dir: str | Path | None = None, + headless: bool | None = None, + auto_reconcile: bool = True, +) -> dict[str, Any]: + """Run configured browser automation for JoinQuant 模拟盘.""" + return run_browser_flow( + manifest_path=manifest_path, + config_path=config_path, + storage_state=storage_state, + out_dir=out_dir, + headless=headless, + auto_reconcile=auto_reconcile, + flow_name="sim-trade", + ) diff --git a/plugins/joinquant/cli.py b/plugins/joinquant/cli.py index c3aa407..6554de1 100644 --- a/plugins/joinquant/cli.py +++ b/plugins/joinquant/cli.py @@ -7,6 +7,7 @@ import click from plugins.joinquant.browser import ( browser_snapshot, run_browser_backtest, + run_browser_sim_trade, save_login_state, write_browser_config_template, ) @@ -258,10 +259,17 @@ def browser_login_cmd(storage_state, login_url, headless, wait_seconds): @joinquant.command("write-browser-config") @click.option("--out-path", required=True, help="Path for JSON browser automation config") -@click.option("--strategy-url", default="", help="JoinQuant strategy edit/backtest URL") -def write_browser_config_cmd(out_path, strategy_url): +@click.option("--strategy-url", default="", help="JoinQuant strategy edit/backtest/模拟盘 URL") +@click.option( + "--flow", + type=click.Choice(["backtest", "sim-trade"]), + default="backtest", + show_default=True, + help="Browser automation template to write", +) +def write_browser_config_cmd(out_path, strategy_url, flow): """Write a browser automation selector/action config template.""" - path = write_browser_config_template(out_path, strategy_url=strategy_url) + path = write_browser_config_template(out_path, strategy_url=strategy_url, flow=flow) click.echo(f"Saved JoinQuant browser config template: {path}") @@ -323,3 +331,40 @@ def run_browser_backtest_cmd( click.echo(f" {key}: {path}") if report["reconcile_paths"]: click.echo(f"Saved reconciliation summary: {report['reconcile_paths']['summary_md']}") + + +@joinquant.command("run-browser-sim") +@click.option("--manifest-path", required=True, help="Manifest from target preparation") +@click.option("--config-path", required=True, help="JSON simulated-trading browser config") +@click.option( + "--storage-state", + default="~/.config/chinese-equity-quant/joinquant_storage_state.json", + show_default=True, +) +@click.option("--out-dir", default=None, help="Browser-run artifact directory") +@click.option("--headed", is_flag=True, help="Run with a visible browser") +@click.option("--no-auto-reconcile", is_flag=True, help="Skip ingest/reconcile after downloads") +def run_browser_sim_cmd( + manifest_path, + config_path, + storage_state, + out_dir, + headed, + no_auto_reconcile, +): + """Run JoinQuant 模拟盘 browser automation from manifest and config.""" + report = run_browser_sim_trade( + manifest_path=manifest_path, + config_path=config_path, + storage_state=storage_state, + out_dir=out_dir, + headless=not headed, + auto_reconcile=not no_auto_reconcile, + ) + click.echo(f"Saved browser sim-trade report: {report['report_path']}") + if report["downloaded"]: + click.echo("Downloaded JoinQuant CSVs:") + for key, path in report["downloaded"].items(): + click.echo(f" {key}: {path}") + if report["reconcile_paths"]: + click.echo(f"Saved reconciliation summary: {report['reconcile_paths']['summary_md']}") diff --git a/tests/test_joinquant_plugin.py b/tests/test_joinquant_plugin.py index 44ef75e..55b8da1 100644 --- a/tests/test_joinquant_plugin.py +++ b/tests/test_joinquant_plugin.py @@ -559,6 +559,27 @@ def test_browser_config_template_and_placeholder_resolution(tmp_path): assert resolve_template("save:{expected_joinquant_csvs.fills}", context) == "save:/tmp/jq_fills.csv" +def test_sim_trade_browser_config_template(tmp_path): + config_path = write_browser_config_template( + tmp_path / "sim_config.json", + strategy_url="https://www.joinquant.com/sim", + flow="sim-trade", + ) + config = json.loads(config_path.read_text()) + + assert config["flow"] == "sim-trade" + selectors = " ".join( + action.get("selector", "") for action in config["actions"] + ) + assert "模拟盘" in selectors + assert "模拟交易" in selectors + assert any( + action["type"] == "screenshot" + and action["path"] == "{run_artifact_dir}/sim_trade_final.png" + for action in config["actions"] + ) + + def test_joinquant_cli_browser_config_smoke(tmp_path): runner = CliRunner() config_path = tmp_path / "browser_config.json" @@ -574,3 +595,17 @@ def test_joinquant_cli_browser_config_smoke(tmp_path): assert result.exit_code == 0, result.output assert config_path.exists() assert default_browser_config()["actions"] + + sim_config_path = tmp_path / "sim_browser_config.json" + result = runner.invoke(cli, [ + "joinquant", + "write-browser-config", + "--out-path", + str(sim_config_path), + "--strategy-url", + "https://www.joinquant.com/sim", + "--flow", + "sim-trade", + ]) + assert result.exit_code == 0, result.output + assert json.loads(sim_config_path.read_text())["flow"] == "sim-trade"