Harden JoinQuant browser smoke automation

This commit is contained in:
Yuxuan Yan
2026-07-04 19:27:48 +08:00
parent 5fada75d23
commit efa9d24c73
4 changed files with 206 additions and 19 deletions
+145 -18
View File
@@ -39,33 +39,104 @@ 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",
"type": "click",
"selector": "text=不再提示",
"timeout_ms": 5000,
"force": True,
"optional": True,
"description": "Dismiss JoinQuant first-run editor guide.",
},
{
"type": "click",
"selector": "text=确定",
"timeout_ms": 5000,
"force": True,
"optional": True,
"description": "Close JoinQuant first-run editor guide.",
},
{
"type": "click",
"selector": "text=跳过",
"timeout_ms": 5000,
"force": True,
"optional": True,
"description": "Skip JoinQuant first-run editor guide.",
},
{
"type": "evaluate",
"script": (
"() => {"
"document.querySelectorAll("
"'.introjs-overlay,.introjs-helperLayer,.introjs-tooltipReferenceLayer,"
".introjs-tooltip,.introjs-disableInteraction'"
").forEach((node) => node.remove());"
"document.body.classList.remove('introjs-noscroll');"
"}"
),
"optional": True,
"description": "Remove any remaining JoinQuant intro overlay.",
},
{
"type": "set_ace_editor_file",
"selector": ".ace_editor",
"path": "{wrapper_path}",
"description": "Paste generated wrapper strategy into the code editor.",
"description": "Set generated wrapper strategy in the Ace code editor.",
},
{
"type": "set_input_files",
"selector": "input[type=file]",
"paths": "{target_csvs}",
"description": "Upload all aligned daily target CSV files.",
"timeout_ms": 5000,
"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}",
"type": "evaluate",
"script": (
"(arg) => {"
"const setValue = (selector, value) => {"
"const el = document.querySelector(selector);"
"if (!el) return false;"
"el.value = value;"
"el.setAttribute('value', value);"
"el.dispatchEvent(new Event('input', {bubbles: true}));"
"el.dispatchEvent(new Event('change', {bubbles: true}));"
"return true;"
"};"
"return {"
"start: setValue('#startTime', arg.start),"
"end: setValue('#endTime', arg.end),"
"capital: setValue('#daily_backtest_capital_base_box', arg.capital)"
"};"
"}"
),
"arg": {
"start": "{backtest_start_date}",
"end": "{backtest_end_date}",
"capital": "{booksize}",
},
"description": "Set JoinQuant backtest dates and starting capital.",
"optional": True,
},
{
"type": "click",
"selector": "text=/运行回测|开始回测|回测|Run Backtest|Run/i",
"selector": "#algo-save-button",
"timeout_ms": 5000,
"description": "Save the JoinQuant strategy code.",
"optional": True,
},
{
"type": "click",
"selector": ".bootstrap-dialog .btn-primary, .modal.in button:has-text(\"确定\")",
"timeout_ms": 5000,
"force": True,
"description": "Confirm any JoinQuant save dialog.",
"optional": True,
},
{"type": "wait_for_timeout", "timeout_ms": 2000},
{
"type": "click",
"selector": "#daily-new-backtest-button",
"description": "Start the JoinQuant backtest.",
},
{
@@ -78,18 +149,21 @@ def _backtest_actions() -> list[dict[str, Any]]:
"type": "download",
"selector": "text=/导出成交|下载成交|fills|trades/i",
"save_as": "{expected_joinquant_csvs.fills}",
"timeout_ms": 15000,
"optional": True,
},
{
"type": "download",
"selector": "text=/导出持仓|下载持仓|positions/i",
"save_as": "{expected_joinquant_csvs.positions}",
"timeout_ms": 15000,
"optional": True,
},
{
"type": "download",
"selector": "text=/导出收益|下载收益|pnl|收益/i",
"save_as": "{expected_joinquant_csvs.pnl}",
"timeout_ms": 15000,
"optional": True,
},
{"type": "screenshot", "path": "{run_artifact_dir}/final.png"},
@@ -237,8 +311,8 @@ def _manifest_context(manifest: dict[str, Any], artifact_dir: Path, config: dict
return context
_TOKEN_RE = re.compile(r"^\{([^{}]+)\}$")
_PARTIAL_TOKEN_RE = re.compile(r"\{([^{}]+)\}")
_TOKEN_RE = re.compile(r"^\{([A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*)\}$")
_PARTIAL_TOKEN_RE = re.compile(r"\{([A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*)\}")
def resolve_template(value: Any, context: dict[str, Any]) -> Any:
@@ -408,7 +482,10 @@ def _run_action(page: Any, action: dict[str, Any], context: dict[str, Any], time
if kind == "goto":
page.goto(str(resolved["url"]), wait_until=resolved.get("wait_until", "domcontentloaded"), timeout=timeout_ms)
elif kind == "click":
_locator(page, str(resolved["selector"])).click(timeout=timeout_ms)
_locator(page, str(resolved["selector"])).click(
timeout=timeout_ms,
force=bool(resolved.get("force", False)),
)
elif kind == "fill":
_locator(page, str(resolved["selector"])).fill(str(resolved.get("text", "")), timeout=timeout_ms)
elif kind == "press":
@@ -426,6 +503,29 @@ def _run_action(page: Any, action: dict[str, Any], context: dict[str, Any], time
page.keyboard.press("Control+A")
page.keyboard.insert_text(text)
record["n_chars"] = len(text)
elif kind == "set_ace_editor_file":
text = Path(str(resolved["path"])).read_text(encoding="utf-8")
selector = str(resolved.get("selector", ".ace_editor"))
page.wait_for_function(
"(selector) => Boolean(window.ace && document.querySelector(selector))",
arg=selector,
timeout=timeout_ms,
)
page.evaluate(
"""(arg) => {
const node = document.querySelector(arg.selector);
if (!node || !window.ace) {
throw new Error(`Ace editor not found for ${arg.selector}`);
}
const editor = window.ace.edit(node);
editor.setValue(arg.text, -1);
editor.clearSelection();
editor.focus();
return editor.getValue().length;
}""",
{"selector": selector, "text": text},
)
record["n_chars"] = len(text)
elif kind == "evaluate":
page.evaluate(str(resolved["script"]), resolved.get("arg"))
elif kind == "wait_for_selector":
@@ -479,6 +579,8 @@ def run_browser_flow(
raise ValueError("Browser config contains no actions")
records: list[dict[str, Any]] = []
failure: Exception | None = None
failure_artifacts: dict[str, str] = {}
with sync_playwright() as pw:
browser = pw.chromium.launch(
headless=bool(config.get("headless", False) if headless is None else headless)
@@ -489,8 +591,28 @@ def run_browser_flow(
)
page = context.new_page()
page.set_default_timeout(timeout_ms)
for action in actions:
records.append(_run_action(page, action, context_vars, timeout_ms))
try:
for action in actions:
records.append(_run_action(page, action, context_vars, timeout_ms))
except Exception as exc: # pragma: no cover - requires live browser UI.
failure = exc
screenshot_path = artifact_dir / "failure.png"
html_path = artifact_dir / "failure.html"
try:
page.screenshot(path=str(screenshot_path), full_page=True)
html_path.write_text(page.content(), encoding="utf-8")
failure_artifacts = {
"screenshot": str(screenshot_path),
"html": str(html_path),
}
except Exception as artifact_exc:
failure_artifacts = {"artifact_error": str(artifact_exc)}
records.append({
"type": "browser_flow",
"status": "failed",
"error": str(exc),
**failure_artifacts,
})
browser.close()
expected = manifest.get("expected_joinquant_csvs", {})
@@ -526,6 +648,7 @@ def run_browser_flow(
"manifest_path": str(manifest_path),
"config_path": str(config_path),
"flow": flow,
"status": "failed" if failure else "ok",
"storage_state": str(storage_state),
"artifact_dir": str(artifact_dir),
"actions": records,
@@ -538,6 +661,10 @@ def run_browser_flow(
json.dumps(report, indent=2, ensure_ascii=False) + "\n",
encoding="utf-8",
)
if failure is not None:
raise RuntimeError(
f"Browser flow failed; report saved to {report_path}: {failure}"
) from failure
return report
+7 -1
View File
@@ -155,13 +155,19 @@ def reconcile_cmd(
)
@click.option("--out-path", required=True, help="Path for generated standalone strategy")
@click.option("--allow-short", is_flag=True, help="Do not clip negative targets in the generated wrapper")
def write_wrapper_cmd(portfolio_name, mode, out_path, allow_short):
@click.option(
"--embed-targets-dir",
default=None,
help="Embed CSV target files from this directory into the strategy source",
)
def write_wrapper_cmd(portfolio_name, mode, out_path, allow_short, embed_targets_dir):
"""Generate a standalone JoinQuant wrapper strategy."""
path = write_wrapper_strategy(
portfolio_name=portfolio_name,
mode=mode,
out_path=out_path,
allow_short=allow_short,
embedded_targets_dir=embed_targets_dir,
)
click.echo(f"Saved JoinQuant wrapper strategy: {path}")
+30
View File
@@ -8,6 +8,7 @@ write a configured standalone file for a real run.
from __future__ import annotations
import json
from pathlib import Path
from string import Template
from typing import Literal
@@ -33,6 +34,7 @@ PORTFOLIO_NAME = "${portfolio_name}"
TARGET_MODE = "${mode}"
ALLOW_SHORT = ${allow_short}
TARGET_FILE_PREFIX = "" # Optional uploaded-file prefix, for example "run1/"
_EMBEDDED_TARGETS = ${embedded_targets}
def initialize(context):
@@ -55,6 +57,7 @@ def _today_file_name(context):
def _read_target_file(file_name):
${embedded_target_read}
data = read_file(TARGET_FILE_PREFIX + file_name)
if isinstance(data, bytes):
data = data.decode("utf-8")
@@ -201,6 +204,8 @@ exec(_WRAPPER_TEMPLATE.substitute(
portfolio_name="run1",
mode="target_shares",
allow_short="False",
embedded_targets="{}",
embedded_target_read="",
))
@@ -209,23 +214,47 @@ def render_wrapper_strategy(
portfolio_name: str,
mode: WrapperMode = "target_shares",
allow_short: bool = False,
embedded_targets: dict[str, str] | None = None,
) -> str:
"""Render the standalone JoinQuant wrapper strategy source."""
if mode not in {"target_shares", "target_value"}:
raise ValueError("mode must be 'target_shares' or 'target_value'")
embedded_targets = embedded_targets or {}
embedded_target_read = ""
if embedded_targets:
embedded_target_read = (
" if file_name in _EMBEDDED_TARGETS:\n"
" return _EMBEDDED_TARGETS[file_name]\n"
)
return _WRAPPER_TEMPLATE.substitute(
portfolio_name=portfolio_name,
mode=mode,
allow_short="True" if allow_short else "False",
embedded_targets=json.dumps(embedded_targets, ensure_ascii=False, indent=4),
embedded_target_read=embedded_target_read,
)
def _load_embedded_targets(targets_dir: str | Path | None) -> dict[str, str]:
if targets_dir is None:
return {}
root = Path(targets_dir)
targets = {
path.name: path.read_text(encoding="utf-8")
for path in sorted(root.glob("*.csv"))
}
if not targets:
raise ValueError(f"No CSV target files found under {root}")
return targets
def write_wrapper_strategy(
*,
portfolio_name: str,
mode: WrapperMode = "target_shares",
out_path: str | Path,
allow_short: bool = False,
embedded_targets_dir: str | Path | None = None,
) -> Path:
"""Write a configured standalone JoinQuant wrapper strategy."""
path = Path(out_path)
@@ -235,6 +264,7 @@ def write_wrapper_strategy(
portfolio_name=portfolio_name,
mode=mode,
allow_short=allow_short,
embedded_targets=_load_embedded_targets(embedded_targets_dir),
),
encoding="utf-8",
)