Harden JoinQuant browser smoke automation
This commit is contained in:
+142
-15
@@ -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": "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}",
|
||||
},
|
||||
{
|
||||
"type": "fill",
|
||||
"selector": "input[name=end_date], input[placeholder*=结束]",
|
||||
"text": "{backtest_end_date}",
|
||||
"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)
|
||||
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
|
||||
|
||||
|
||||
|
||||
@@ -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}")
|
||||
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -514,6 +514,28 @@ def test_wrapper_strategy_generation_smoke(tmp_path):
|
||||
assert "order_target_value" in text
|
||||
|
||||
|
||||
def test_wrapper_strategy_can_embed_target_csvs(tmp_path):
|
||||
targets_dir = tmp_path / "targets"
|
||||
targets_dir.mkdir()
|
||||
(targets_dir / "20260701.csv").write_text(
|
||||
"date,portfolio_name,symbol_id,jq_symbol,target_shares,target_value,export_mode\n"
|
||||
"2026-07-01,run1,sh600000,600000.XSHG,1000,10000,target_shares\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
path = write_wrapper_strategy(
|
||||
portfolio_name="run1",
|
||||
mode="target_shares",
|
||||
out_path=tmp_path / "wrapper_embedded.py",
|
||||
embedded_targets_dir=targets_dir,
|
||||
)
|
||||
|
||||
text = path.read_text()
|
||||
assert '"20260701.csv"' in text
|
||||
assert "600000.XSHG" in text
|
||||
assert "if file_name in _EMBEDDED_TARGETS" in text
|
||||
|
||||
|
||||
def test_build_fixed_share_positions_excludes_final_executionless_date():
|
||||
data = pd.DataFrame({
|
||||
"symbol_id": ["sh600000", "sh600000", "sh600000"],
|
||||
@@ -558,6 +580,8 @@ def test_browser_config_template_and_placeholder_resolution(tmp_path):
|
||||
"/tmp/20240111.csv",
|
||||
]
|
||||
assert resolve_template("save:{expected_joinquant_csvs.fills}", context) == "save:/tmp/jq_fills.csv"
|
||||
script = "(arg) => { return new Event('change', {bubbles: true}); }"
|
||||
assert resolve_template(script, context) == script
|
||||
|
||||
|
||||
def test_load_env_file_handles_quotes_without_shell_sourcing(tmp_path):
|
||||
|
||||
Reference in New Issue
Block a user