577 lines
19 KiB
Python
577 lines
19 KiB
Python
"""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_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"]
|