Add JoinQuant simulated trading automation

This commit is contained in:
Yuxuan Yan
2026-07-04 18:12:01 +08:00
parent 39a93259e1
commit d05931f41a
5 changed files with 311 additions and 72 deletions
+38 -2
View File
@@ -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 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. 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/<your 模拟盘 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 \
--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 Mode
`target_shares` is the default and preferred correctness mode. The exported `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 --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 ```bash
uv run python cli.py joinquant write-browser-config \ uv run python cli.py joinquant write-browser-config \
--out-path /tmp/chinese-equity-quant-realdata/joinquant_browser_config.json \ --out-path /tmp/chinese-equity-quant-realdata/joinquant_browser_config.json \
--strategy-url "https://www.joinquant.com/<your strategy page>" --strategy-url "https://www.joinquant.com/<your strategy page>" \
--flow backtest
``` ```
If selectors need tuning, capture the logged-in page: 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 produce fills, positions, and PnL CSVs, the runner automatically calls
`joinquant ingest` and `joinquant reconcile`. `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 Do not store raw JoinQuant passwords in this repository. The browser state file
is created with `0600` permissions and should live under `~/.config`, outside is created with `0600` permissions and should live under `~/.config`, outside
the repo. the repo.
+18 -1
View File
@@ -29,13 +29,24 @@ uv run python cli.py joinquant browser-login \
uv run python cli.py joinquant write-browser-config \ uv run python cli.py joinquant write-browser-config \
--out-path /tmp/chinese-equity-quant-realdata/joinquant_browser_config.json \ --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 \ uv run python cli.py joinquant run-browser-backtest \
--manifest-path /tmp/chinese-equity-quant-realdata/joinquant_smoke_manifest.json \ --manifest-path /tmp/chinese-equity-quant-realdata/joinquant_smoke_manifest.json \
--config-path /tmp/chinese-equity-quant-realdata/joinquant_browser_config.json \ --config-path /tmp/chinese-equity-quant-realdata/joinquant_browser_config.json \
--storage-state ~/.config/chinese-equity-quant/joinquant_storage_state.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 \ uv run python cli.py joinquant export-targets \
--positions-path portfolio/run1.pq \ --positions-path portfolio/run1.pq \
--portfolio-name run1 \ --portfolio-name run1 \
@@ -84,3 +95,9 @@ export paths.
Playwright. It reuses a saved browser login state, executes the configured UI Playwright. It reuses a saved browser login state, executes the configured UI
actions, downloads JoinQuant CSVs when configured, and runs ingest/reconcile actions, downloads JoinQuant CSVs when configured, and runs ingest/reconcile
automatically once all three CSVs are present. 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.
+172 -66
View File
@@ -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 JoinQuant's public ``jqdatasdk`` is a data API; cloud strategy upload/run/export
is exposed through the web application. This module keeps that automation is exposed through the web application. This module keeps that automation
@@ -29,15 +29,124 @@ def _require_playwright():
raise RuntimeError( raise RuntimeError(
"Playwright is required for JoinQuant browser automation. Install it " "Playwright is required for JoinQuant browser automation. Install it "
"in this uv environment, then install Chromium:\n" "in this uv environment, then install Chromium:\n"
" uv pip install playwright\n" " uv sync --extra joinquant-browser\n"
" uv run playwright install chromium" " uv run playwright install chromium"
) from exc ) from exc
return sync_playwright, PlaywrightTimeoutError 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.""" """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 { return {
"flow": normalized_flow,
"strategy_url": strategy_url, "strategy_url": strategy_url,
"login_url": DEFAULT_LOGIN_URL, "login_url": DEFAULT_LOGIN_URL,
"headless": False, "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.", "Use `joinquant browser-snapshot` to capture HTML/screenshots for selector tuning.",
"The action list is declarative and runs in order.", "The action list is declarative and runs in order.",
], ],
"actions": [ "actions": 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"},
],
} }
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.""" """Write a JSON config template for browser automation."""
out_path = Path(path) out_path = Path(path)
out_path.parent.mkdir(parents=True, exist_ok=True) out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_text( 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", encoding="utf-8",
) )
return out_path return out_path
@@ -302,7 +363,7 @@ def _run_action(page: Any, action: dict[str, Any], context: dict[str, Any], time
return record return record
def run_browser_backtest( def run_browser_flow(
*, *,
manifest_path: str | Path, manifest_path: str | Path,
config_path: str | Path, config_path: str | Path,
@@ -310,12 +371,14 @@ def run_browser_backtest(
out_dir: str | Path | None = None, out_dir: str | Path | None = None,
headless: bool | None = None, headless: bool | None = None,
auto_reconcile: bool = True, auto_reconcile: bool = True,
flow_name: str | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Run configured browser automation for a JoinQuant backtest.""" """Run configured browser automation for a JoinQuant web workflow."""
sync_playwright, _ = _require_playwright() sync_playwright, _ = _require_playwright()
manifest = load_json(manifest_path) manifest = load_json(manifest_path)
config = load_json(config_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) artifact_dir.mkdir(parents=True, exist_ok=True)
context_vars = _manifest_context(manifest, artifact_dir, config) context_vars = _manifest_context(manifest, artifact_dir, config)
@@ -371,6 +434,7 @@ def run_browser_backtest(
"created_at": datetime.now(timezone.utc).isoformat(), "created_at": datetime.now(timezone.utc).isoformat(),
"manifest_path": str(manifest_path), "manifest_path": str(manifest_path),
"config_path": str(config_path), "config_path": str(config_path),
"flow": flow,
"storage_state": str(storage_state), "storage_state": str(storage_state),
"artifact_dir": str(artifact_dir), "artifact_dir": str(artifact_dir),
"actions": records, "actions": records,
@@ -384,3 +448,45 @@ def run_browser_backtest(
encoding="utf-8", encoding="utf-8",
) )
return report 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",
)
+48 -3
View File
@@ -7,6 +7,7 @@ import click
from plugins.joinquant.browser import ( from plugins.joinquant.browser import (
browser_snapshot, browser_snapshot,
run_browser_backtest, run_browser_backtest,
run_browser_sim_trade,
save_login_state, save_login_state,
write_browser_config_template, write_browser_config_template,
) )
@@ -258,10 +259,17 @@ def browser_login_cmd(storage_state, login_url, headless, wait_seconds):
@joinquant.command("write-browser-config") @joinquant.command("write-browser-config")
@click.option("--out-path", required=True, help="Path for JSON browser automation 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") @click.option("--strategy-url", default="", help="JoinQuant strategy edit/backtest/模拟盘 URL")
def write_browser_config_cmd(out_path, strategy_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.""" """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}") click.echo(f"Saved JoinQuant browser config template: {path}")
@@ -323,3 +331,40 @@ def run_browser_backtest_cmd(
click.echo(f" {key}: {path}") click.echo(f" {key}: {path}")
if report["reconcile_paths"]: if report["reconcile_paths"]:
click.echo(f"Saved reconciliation summary: {report['reconcile_paths']['summary_md']}") 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']}")
+35
View File
@@ -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" 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): def test_joinquant_cli_browser_config_smoke(tmp_path):
runner = CliRunner() runner = CliRunner()
config_path = tmp_path / "browser_config.json" 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 result.exit_code == 0, result.output
assert config_path.exists() assert config_path.exists()
assert default_browser_config()["actions"] 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"