diff --git a/plugins/joinquant/browser.py b/plugins/joinquant/browser.py index 4ad98a3..8eca770 100644 --- a/plugins/joinquant/browser.py +++ b/plugins/joinquant/browser.py @@ -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 diff --git a/plugins/joinquant/cli.py b/plugins/joinquant/cli.py index 6eb96fb..baa3623 100644 --- a/plugins/joinquant/cli.py +++ b/plugins/joinquant/cli.py @@ -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}") diff --git a/plugins/joinquant/wrapper_strategy.py b/plugins/joinquant/wrapper_strategy.py index 3df5aa4..fcc53ed 100644 --- a/plugins/joinquant/wrapper_strategy.py +++ b/plugins/joinquant/wrapper_strategy.py @@ -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", ) diff --git a/tests/test_joinquant_plugin.py b/tests/test_joinquant_plugin.py index 8309be8..8f4a480 100644 --- a/tests/test_joinquant_plugin.py +++ b/tests/test_joinquant_plugin.py @@ -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):