"""Tests for the JoinQuant comparison plugin (network-free).""" from __future__ import annotations import hashlib import json from pathlib import Path import pandas as pd import pytest from click.testing import CliRunner from cli import cli from pipeline.common.schema import FILL_COLUMNS, PNL_COLUMNS, POSITION_COLUMNS from plugins.joinquant.browser import ( default_browser_config, resolve_template, write_browser_config_template, ) from plugins.joinquant.export_targets import export_targets from plugins.joinquant.ingest import ( ingest_joinquant_outputs, normalize_fills_csv, ) from plugins.joinquant.reconcile import reconcile_joinquant from plugins.joinquant.schema import ( JOINQUANT_FILL_COLUMNS, JOINQUANT_PNL_COLUMNS, JOINQUANT_POSITION_COLUMNS, JOINQUANT_TARGET_COLUMNS, RECONCILE_COLUMNS, ) from plugins.joinquant.smoke import build_fixed_share_positions from plugins.joinquant.symbols import from_joinquant_symbol, to_joinquant_symbol from plugins.joinquant.wrapper_strategy import write_wrapper_strategy def _positions( *, symbol: str = "sh600000", date: str = "2026-07-01", shares: int = 1000, price: float = 10.0, portfolio_name: str = "run1", ) -> pd.DataFrame: target_value = float(shares * price) weight = target_value / 1_000_000.0 return pd.DataFrame([{ "symbol_id": symbol, "date": pd.Timestamp(date), "portfolio_name": portfolio_name, "target_weight": weight, "target_value": target_value, "target_shares": float(shares) + 0.25, "position_shares": shares, "position_value": target_value, "price": price, }], columns=POSITION_COLUMNS) def _our_fills( *, symbol: str = "sh600000", date: str = "2026-07-01", shares: int = 1000, price: float = 10.0, cost: float = 5.0, portfolio_name: str = "run1", ) -> pd.DataFrame: fills = pd.DataFrame([{ "symbol_id": symbol, "date": pd.Timestamp(date), "portfolio_name": portfolio_name, "prev_shares": 0, "target_shares": shares, "traded_shares": shares, "realized_shares": shares, "blocked": 0, "trade_cost": cost, "trade_price": price, }]) return fills def _our_pnl( *, date: str = "2026-07-01", pnl: float = 100.0, cost: float = 5.0, portfolio_name: str = "run1", ) -> pd.DataFrame: return pd.DataFrame([{ "date": pd.Timestamp(date), "portfolio_name": portfolio_name, "gross_exposure": 10_000.0, "net_exposure": 10_000.0, "pnl": pnl, "cost": cost, "turnover": 1.0, "n_positions": 1, }], columns=PNL_COLUMNS) def _jq_fills( *, symbol: str = "sh600000", date: str = "2026-07-01", shares: int = 1000, price: float = 10.0, cost: float = 5.0, portfolio_name: str = "run1", raw_status: str = "filled", ) -> pd.DataFrame: return pd.DataFrame([{ "date": date, "portfolio_name": portfolio_name, "symbol_id": symbol, "jq_symbol": to_joinquant_symbol(symbol), "order_id": "ord-1", "side": "buy" if shares >= 0 else "sell", "requested_shares": shares, "filled_shares": shares, "fill_price": price, "trade_value": abs(shares * price), "trade_cost": cost, "blocked": 0, "raw_status": raw_status, }], columns=JOINQUANT_FILL_COLUMNS) def _jq_positions( *, symbol: str = "sh600000", date: str = "2026-07-01", shares: int = 1000, price: float = 10.0, portfolio_name: str = "run1", ) -> pd.DataFrame: return pd.DataFrame([{ "date": date, "portfolio_name": portfolio_name, "symbol_id": symbol, "jq_symbol": to_joinquant_symbol(symbol), "position_shares": shares, "position_value": shares * price, "cash": 990_000.0, "total_value": 1_000_000.0, }], columns=JOINQUANT_POSITION_COLUMNS) def _jq_pnl( *, date: str = "2026-07-01", pnl: float = 100.0, cost: float = 5.0, portfolio_name: str = "run1", ) -> pd.DataFrame: return pd.DataFrame([{ "date": date, "portfolio_name": portfolio_name, "gross_exposure": 10_000.0, "net_exposure": 10_000.0, "cash": 990_000.0, "total_value": 1_000_000.0, "pnl": pnl, "cost": cost, "turnover": 1.0, }], columns=JOINQUANT_PNL_COLUMNS) def _write_parquets(tmp_path: Path, frames: dict[str, pd.DataFrame]) -> dict[str, Path]: paths = {} for name, frame in frames.items(): path = tmp_path / f"{name}.pq" frame.to_parquet(path, index=False) paths[name] = path return paths def _export_targets_for(tmp_path: Path, positions: pd.DataFrame) -> tuple[Path, Path]: positions_path = tmp_path / "positions.pq" positions.to_parquet(positions_path, index=False) targets_root = tmp_path / "targets" export_targets( positions_path, portfolio_name="run1", out_dir=targets_root, mode="target_shares", ) return positions_path, targets_root / "run1" @pytest.mark.parametrize( ("internal", "joinquant"), [ ("sh600000", "600000.XSHG"), ("sh688001", "688001.XSHG"), ("sz000001", "000001.XSHE"), ("sz001001", "001001.XSHE"), ("sz002594", "002594.XSHE"), ("sz300001", "300001.XSHE"), ], ) def test_symbol_mapping_both_directions(internal, joinquant): assert to_joinquant_symbol(internal) == joinquant assert from_joinquant_symbol(joinquant) == internal @pytest.mark.parametrize("bad", ["600000", "bj830000", "sh000001", "sz600000", "abc"]) def test_symbol_mapping_rejects_invalid_symbols(bad): with pytest.raises(ValueError): to_joinquant_symbol(bad) @pytest.mark.parametrize("bad", ["600000", "600000.XSHE", "000001.XSHG", "abc.XSHG"]) def test_reverse_symbol_mapping_rejects_invalid_symbols(bad): with pytest.raises(ValueError): from_joinquant_symbol(bad) def test_export_targets_schema_snapshot_hash_and_no_overwrite(tmp_path): positions_path = tmp_path / "positions.pq" _positions().to_parquet(positions_path, index=False) snapshots = export_targets( positions_path, portfolio_name="run1", out_dir=tmp_path / "targets", mode="target_shares", ) csv_path = tmp_path / "targets" / "run1" / "20260701.csv" parquet_path = tmp_path / "targets" / "run1" / "20260701.parquet" snapshot_path = tmp_path / "snapshots" / "run1" / "20260701.json" assert csv_path.exists() assert parquet_path.exists() assert snapshot_path.exists() target = pd.read_csv(csv_path) assert list(target.columns) == JOINQUANT_TARGET_COLUMNS assert int(target.loc[0, "target_shares"]) == 1000 assert float(target.loc[0, "target_value"]) == 10_000.0 assert target.loc[0, "export_mode"] == "target_shares" snapshot = json.loads(snapshot_path.read_text()) actual_hash = hashlib.sha256(csv_path.read_bytes()).hexdigest() assert snapshots[0]["file_sha256"] == actual_hash assert snapshot["file_sha256"] == actual_hash assert snapshot["n_symbols"] == 1 with pytest.raises(FileExistsError): export_targets( positions_path, portfolio_name="run1", out_dir=tmp_path / "targets", mode="target_shares", ) def test_export_targets_target_value_mode_from_position_columns(tmp_path): positions_path = tmp_path / "positions.pq" _positions(shares=250, price=20.0).to_parquet(positions_path, index=False) export_targets( positions_path, portfolio_name="run1", out_dir=tmp_path / "targets_value", mode="target_value", ) target = pd.read_parquet(tmp_path / "targets_value" / "run1" / "20260701.parquet") assert list(target.columns) == JOINQUANT_TARGET_COLUMNS assert target.loc[0, "export_mode"] == "target_value" assert target.loc[0, "target_value"] == 5_000.0 assert target.loc[0, "target_shares"] == 250 def test_export_targets_can_shift_to_next_execution_session(tmp_path): positions_path = tmp_path / "positions.pq" _positions(date="2024-01-09").to_parquet(positions_path, index=False) calendar_path = tmp_path / "daily.pq" pd.DataFrame({ "date": pd.to_datetime(["2024-01-09", "2024-01-10", "2024-01-11"]), "symbol_id": ["sh600000", "sh600000", "sh600000"], }).to_parquet(calendar_path, index=False) snapshots = export_targets( positions_path, portfolio_name="run1", out_dir=tmp_path / "targets_shifted", mode="target_shares", start_date="2024-01-10", end_date="2024-01-10", execution_calendar_path=calendar_path, ) assert len(snapshots) == 1 assert snapshots[0]["date"] == "2024-01-10" assert (tmp_path / "targets_shifted" / "run1" / "20240110.csv").exists() target = pd.read_csv(tmp_path / "targets_shifted" / "run1" / "20240110.csv") assert target.loc[0, "date"] == "2024-01-10" def test_ingest_permissive_csv_column_mapping_and_output_schemas(tmp_path): fills_csv = tmp_path / "jq_fills.csv" positions_csv = tmp_path / "jq_positions.csv" pnl_csv = tmp_path / "jq_pnl.csv" pd.DataFrame([{ "Trade Date": "2026-07-01 09:31:00", "Security": "600000.XSHG", "Direction": "buy", "Order Amount": 1000, "Filled Amount": 1000, "Price": 10.0, "Status": "filled", }]).to_csv(fills_csv, index=False) pd.DataFrame([{ "Date": "2026-07-01", "Security": "600000.XSHG", "Shares": 1000, "Market Value": 10_000.0, "Cash": 990_000.0, "Portfolio Value": 1_000_000.0, }]).to_csv(positions_csv, index=False) pd.DataFrame([{ "Date": "2026-07-01", "Portfolio Value": 1_000_000.0, "Daily PnL": 100.0, "Turnover": 1.0, }]).to_csv(pnl_csv, index=False) fills = normalize_fills_csv(fills_csv, "run1") assert list(fills.columns) == JOINQUANT_FILL_COLUMNS assert fills.loc[0, "symbol_id"] == "sh600000" assert fills.loc[0, "jq_symbol"] == "600000.XSHG" assert fills.loc[0, "trade_cost"] == 0.0 assert fills.loc[0, "blocked"] == 0 paths = ingest_joinquant_outputs( portfolio_name="run1", fills_csv=fills_csv, positions_csv=positions_csv, pnl_csv=pnl_csv, out_dir=tmp_path / "ingested", ) assert list(pd.read_parquet(paths["fills"]).columns) == JOINQUANT_FILL_COLUMNS assert list(pd.read_parquet(paths["positions"]).columns) == JOINQUANT_POSITION_COLUMNS assert list(pd.read_parquet(paths["pnl"]).columns) == JOINQUANT_PNL_COLUMNS def _run_reconcile_case( tmp_path: Path, *, positions: pd.DataFrame | None = None, our_fills: pd.DataFrame | None = None, jq_fills: pd.DataFrame | None = None, jq_positions: pd.DataFrame | None = None, our_pnl: pd.DataFrame | None = None, jq_pnl: pd.DataFrame | None = None, ) -> pd.DataFrame: positions = _positions() if positions is None else positions _, targets_dir = _export_targets_for(tmp_path, positions) paths = _write_parquets(tmp_path, { "our_fills": _our_fills() if our_fills is None else our_fills, "our_positions": positions, "our_pnl": _our_pnl() if our_pnl is None else our_pnl, "jq_fills": _jq_fills() if jq_fills is None else jq_fills, "jq_positions": _jq_positions() if jq_positions is None else jq_positions, "jq_pnl": _jq_pnl() if jq_pnl is None else jq_pnl, }) out_paths = reconcile_joinquant( portfolio_name="run1", targets_dir=targets_dir, our_fills_path=paths["our_fills"], our_positions_path=paths["our_positions"], our_pnl_path=paths["our_pnl"], jq_fills_path=paths["jq_fills"], jq_positions_path=paths["jq_positions"], jq_pnl_path=paths["jq_pnl"], out_dir=tmp_path / "reconcile", ) report = pd.read_parquet(out_paths["daily_reconcile"]) assert list(report.columns) == RECONCILE_COLUMNS assert out_paths["summary_md"].exists() assert out_paths["summary_csv"].exists() return report def test_reconcile_exact_match(tmp_path): report = _run_reconcile_case(tmp_path) assert report.loc[0, "diff_reason"] == "MATCH" assert report.loc[0, "filled_share_diff"] == 0 assert report.loc[0, "position_share_diff"] == 0 def test_reconcile_price_mismatch(tmp_path): report = _run_reconcile_case(tmp_path, jq_fills=_jq_fills(price=10.5)) assert report.loc[0, "diff_reason"] == "PRICE_MISMATCH" def test_reconcile_cost_mismatch(tmp_path): report = _run_reconcile_case( tmp_path, jq_fills=_jq_fills(cost=8.0), jq_pnl=_jq_pnl(cost=8.0), ) assert report.loc[0, "diff_reason"] == "COST_MODEL" def test_reconcile_missing_symbol_in_joinquant(tmp_path): empty_jq_fills = pd.DataFrame(columns=JOINQUANT_FILL_COLUMNS) empty_jq_positions = pd.DataFrame(columns=JOINQUANT_POSITION_COLUMNS) report = _run_reconcile_case( tmp_path, jq_fills=empty_jq_fills, jq_positions=empty_jq_positions, ) assert report.loc[0, "diff_reason"] == "MISSING_IN_JOINQUANT" def test_reconcile_short_target_with_long_only_joinquant_output(tmp_path): positions = _positions(shares=-100, price=10.0) our_fills = _our_fills(shares=-100, price=10.0) jq_fills = _jq_fills(shares=0, price=10.0, cost=0.0, raw_status="short clipped") jq_positions = _jq_positions(shares=0, price=10.0) report = _run_reconcile_case( tmp_path, positions=positions, our_fills=our_fills, jq_fills=jq_fills, jq_positions=jq_positions, ) assert report.loc[0, "diff_reason"] == "SHORT_NOT_SUPPORTED" def test_joinquant_cli_smoke_export_ingest_reconcile_and_wrapper(tmp_path): runner = CliRunner() positions_path = tmp_path / "positions.pq" _positions().to_parquet(positions_path, index=False) result = runner.invoke(cli, [ "joinquant", "export-targets", "--positions-path", str(positions_path), "--portfolio-name", "run1", "--mode", "target_shares", "--out-dir", str(tmp_path / "targets"), ]) assert result.exit_code == 0, result.output assert "Exported JoinQuant targets" in result.output fills_csv = tmp_path / "jq_fills.csv" positions_csv = tmp_path / "jq_positions.csv" pnl_csv = tmp_path / "jq_pnl.csv" _jq_fills().to_csv(fills_csv, index=False) _jq_positions().to_csv(positions_csv, index=False) _jq_pnl().to_csv(pnl_csv, index=False) result = runner.invoke(cli, [ "joinquant", "ingest", "--portfolio-name", "run1", "--fills-csv", str(fills_csv), "--positions-csv", str(positions_csv), "--pnl-csv", str(pnl_csv), "--out-dir", str(tmp_path / "ingested"), ]) assert result.exit_code == 0, result.output assert "Saved JoinQuant fills" in result.output paths = _write_parquets(tmp_path, { "our_fills": _our_fills(), "our_pnl": _our_pnl(), }) result = runner.invoke(cli, [ "joinquant", "reconcile", "--portfolio-name", "run1", "--targets-dir", str(tmp_path / "targets" / "run1"), "--our-fills-path", str(paths["our_fills"]), "--our-positions-path", str(positions_path), "--our-pnl-path", str(paths["our_pnl"]), "--jq-fills-path", str(tmp_path / "ingested" / "run1" / "fills.pq"), "--jq-positions-path", str(tmp_path / "ingested" / "run1" / "positions.pq"), "--jq-pnl-path", str(tmp_path / "ingested" / "run1" / "pnl.pq"), "--out-dir", str(tmp_path / "reconcile"), ]) assert result.exit_code == 0, result.output assert "Saved reconciliation parquet" in result.output wrapper_path = tmp_path / "wrapper_strategy_run1.py" result = runner.invoke(cli, [ "joinquant", "write-wrapper", "--portfolio-name", "run1", "--mode", "target_shares", "--out-path", str(wrapper_path), ]) assert result.exit_code == 0, result.output assert "Saved JoinQuant wrapper strategy" in result.output text = wrapper_path.read_text() assert 'PORTFOLIO_NAME = "run1"' in text assert 'TARGET_MODE = "target_shares"' in text assert "ALLOW_SHORT = False" in text def test_wrapper_strategy_generation_smoke(tmp_path): path = write_wrapper_strategy( portfolio_name="run2", mode="target_value", out_path=tmp_path / "wrapper.py", ) text = path.read_text() assert 'PORTFOLIO_NAME = "run2"' in text assert 'TARGET_MODE = "target_value"' in text assert "order_target_value" in text def test_build_fixed_share_positions_excludes_final_executionless_date(): data = pd.DataFrame({ "symbol_id": ["sh600000", "sh600000", "sh600000"], "date": pd.to_datetime(["2024-01-09", "2024-01-10", "2024-01-11"]), "close": [10.0, 10.5, 11.0], }) positions = build_fixed_share_positions( data, trade_symbol="sh600000", portfolio_name="run1", shares=1000, booksize=1_000_000.0, ) assert list(positions.columns) == POSITION_COLUMNS assert positions["date"].dt.strftime("%Y-%m-%d").tolist() == [ "2024-01-09", "2024-01-10", ] assert positions["position_shares"].tolist() == [1000, 1000] assert positions["target_value"].tolist() == [10_000.0, 10_500.0] def test_browser_config_template_and_placeholder_resolution(tmp_path): config_path = write_browser_config_template( tmp_path / "browser_config.json", strategy_url="https://www.joinquant.com/example", ) config = json.loads(config_path.read_text()) assert config["strategy_url"] == "https://www.joinquant.com/example" assert config["actions"][0]["type"] == "goto" context = { "wrapper_path": "/tmp/wrapper.py", "target_csvs": ["/tmp/20240110.csv", "/tmp/20240111.csv"], "expected_joinquant_csvs": {"fills": "/tmp/jq_fills.csv"}, } assert resolve_template("{wrapper_path}", context) == "/tmp/wrapper.py" assert resolve_template("{target_csvs}", context) == [ "/tmp/20240110.csv", "/tmp/20240111.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): runner = CliRunner() config_path = tmp_path / "browser_config.json" result = runner.invoke(cli, [ "joinquant", "write-browser-config", "--out-path", str(config_path), "--strategy-url", "https://www.joinquant.com/example", ]) 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"