Add JoinQuant simulated trading automation
This commit is contained in:
@@ -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/<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` 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/<your strategy page>"
|
||||
--strategy-url "https://www.joinquant.com/<your strategy page>" \
|
||||
--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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
+126
-20
@@ -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,25 +29,14 @@ 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]:
|
||||
"""Return a selector/action template for JoinQuant browser automation."""
|
||||
return {
|
||||
"strategy_url": strategy_url,
|
||||
"login_url": DEFAULT_LOGIN_URL,
|
||||
"headless": False,
|
||||
"timeout_ms": 120_000,
|
||||
"notes": [
|
||||
"Fill strategy_url and selectors after inspecting your JoinQuant strategy page.",
|
||||
"Use `joinquant browser-snapshot` to capture HTML/screenshots for selector tuning.",
|
||||
"The action list is declarative and runs in order.",
|
||||
],
|
||||
"actions": [
|
||||
def _backtest_actions() -> list[dict[str, Any]]:
|
||||
return [
|
||||
{"type": "goto", "url": "{strategy_url}"},
|
||||
{
|
||||
"type": "paste_text_file",
|
||||
@@ -104,16 +93,88 @@ def default_browser_config(strategy_url: str = "") -> dict[str, Any]:
|
||||
"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,
|
||||
"timeout_ms": 120_000,
|
||||
"notes": [
|
||||
"Fill strategy_url and selectors after inspecting your JoinQuant strategy page.",
|
||||
"Use `joinquant browser-snapshot` to capture HTML/screenshots for selector tuning.",
|
||||
"The action list is declarative and runs in order.",
|
||||
],
|
||||
"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",
|
||||
)
|
||||
|
||||
@@ -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']}")
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user