Files
quant-os/platforms/qlib_runner.py
T

867 lines
29 KiB
Python

"""Qlib 0.9.7 native momentum backtest and Alpha158/LightGBM workflow.
This module never downloads data. A real, user-authorized ``provider_uri`` is
required. Qlib, pandas and LightGBM are imported only inside execution paths.
"""
from __future__ import annotations
import argparse
from collections import deque
import datetime as dt
import hashlib
import importlib
import importlib.util
import json
import math
import os
from pathlib import Path
import tempfile
from typing import Any, Dict, Iterable, Mapping, Optional, Sequence
from quant60.portable_core import momentum_score
class QlibUnavailableError(RuntimeError):
"""Raised when Qlib or its configured provider cannot be used."""
def _table_audit(table: Any) -> Optional[Dict[str, Any]]:
"""Return compact deterministic evidence for a pandas Series/DataFrame."""
to_json = getattr(table, "to_json", None)
if not callable(to_json):
return None
encoded = to_json(
orient="split",
date_format="iso",
date_unit="ns",
double_precision=15,
).encode("utf-8")
columns = [
str(value)
for value in getattr(table, "columns", [getattr(table, "name", "value")])
]
audit: Dict[str, Any] = {
"row_count": int(len(table)),
"columns": columns,
"content_sha256": hashlib.sha256(encoded).hexdigest(),
}
index = getattr(table, "index", None)
if index is not None and len(index):
audit["period_start"] = str(index[0])
audit["period_end"] = str(index[-1])
def finite_values(name: str) -> list[float]:
if name not in columns:
return []
try:
raw = table[name].dropna().tolist()
values = [float(value) for value in raw]
except (KeyError, TypeError, ValueError, AttributeError):
return []
return [value for value in values if math.isfinite(value)]
returns = finite_values("return")
benchmark = finite_values("bench")
costs = finite_values("cost")
turnover = finite_values("turnover")
if returns:
wealth = 1.0
peak = 1.0
max_drawdown = 0.0
for value in returns:
wealth *= 1.0 + value
peak = max(peak, wealth)
max_drawdown = min(max_drawdown, wealth / peak - 1.0)
audit["derived_performance"] = {
"cumulative_return": wealth - 1.0,
"max_drawdown": max_drawdown,
"benchmark_cumulative_return": (
math.prod(1.0 + value for value in benchmark) - 1.0
if benchmark
else None
),
"total_cost": sum(costs) if costs else None,
"total_turnover": sum(turnover) if turnover else None,
}
return audit
def _mapping_table_audits(values: Any) -> Dict[str, Any]:
if not isinstance(values, Mapping):
return {}
output: Dict[str, Any] = {}
for frequency, item in sorted(values.items(), key=lambda pair: str(pair[0])):
table = item[0] if isinstance(item, (tuple, list)) and item else item
audit = _table_audit(table)
if audit is not None:
output[str(frequency)] = audit
return output
def _version_tuple(value: str) -> tuple[int, ...]:
parts = []
for chunk in str(value).split("."):
digits = "".join(character for character in chunk if character.isdigit())
if not digits:
break
parts.append(int(digits))
return tuple(parts)
def _load_qlib() -> Dict[str, Any]:
if importlib.util.find_spec("qlib") is None:
raise QlibUnavailableError(
"pyqlib 0.9.7 is not installed in this research environment"
)
try:
qlib = importlib.import_module("qlib")
data_module = importlib.import_module("qlib.data")
executor_module = importlib.import_module("qlib.backtest.executor")
backtest_module = importlib.import_module("qlib.backtest")
strategy_module = importlib.import_module(
"qlib.contrib.strategy.signal_strategy"
)
except Exception as exc:
raise QlibUnavailableError(f"Qlib import failed: {exc}") from exc
version = str(getattr(qlib, "__version__", "unknown"))
if version != "unknown" and _version_tuple(version) != (0, 9, 7):
raise QlibUnavailableError(
f"Qlib exactly 0.9.7 is required by this pinned adapter; found {version}"
)
return {
"qlib": qlib,
"D": data_module.D,
"SimulatorExecutor": executor_module.SimulatorExecutor,
"backtest": backtest_module.backtest,
"TopkDropoutStrategy": strategy_module.TopkDropoutStrategy,
"version": version,
}
def _validate_provider(provider_uri: str) -> str:
value = str(provider_uri or "").strip()
if not value:
raise ValueError("provider_uri is required; no public data is downloaded")
if "://" not in value:
path = Path(value).expanduser().resolve()
if not path.is_dir():
raise FileNotFoundError(
f"Qlib provider_uri does not exist: {path}"
)
return str(path)
return value
def _infer_columns(frame: Any) -> tuple[str, str, str]:
columns = [str(column) for column in frame.columns]
instrument = next(
(
name
for name in ("instrument", "level_0", "level_1")
if name in columns
),
None,
)
datetime_column = next(
(
name
for name in ("datetime", "date", "level_1", "level_0")
if name in columns and name != instrument
),
None,
)
close = next(
(name for name in ("$close", "close") if name in columns),
None,
)
if close is None:
non_index = [
name
for name in columns
if name not in {instrument, datetime_column}
]
close = non_index[0] if len(non_index) == 1 else None
if instrument is None or datetime_column is None or close is None:
raise ValueError(
f"unexpected D.features columns/index after reset: {columns}"
)
# Unnamed MultiIndexes may reset as level_0/level_1. Infer their order
# from the first non-null value instead of assuming a Qlib build detail.
if instrument.startswith("level_") and datetime_column.startswith("level_"):
sample = frame[[instrument, datetime_column]].dropna().head(1)
if not sample.empty:
first = sample.iloc[0][instrument]
if not isinstance(first, str) or not (
first.upper().startswith(("SH", "SZ", "BJ"))
or first[:1].isdigit()
):
instrument, datetime_column = datetime_column, instrument
return instrument, datetime_column, close
def generate_portable_momentum_signal(
D: Any,
instruments: Any,
*,
start_time: str,
end_time: str,
feature_start_time: Optional[str] = None,
lookback: int = 20,
skip: int = 0,
rebalance: str = "weekly",
) -> Any:
"""Use ``D.features`` and portable momentum to build a Qlib score Series.
The result is indexed ``(datetime, instrument)``. Qlib's
``BaseSignalStrategy`` reads the prior-step score (``shift=1``), so a score
computed on completed bar T is executed on the next engine step.
"""
pandas = importlib.import_module("pandas")
if feature_start_time is None:
start = dt.datetime.fromisoformat(str(start_time)[:10])
padding_days = max(14, 3 * (int(lookback) + int(skip) + 2))
feature_start_time = (start - dt.timedelta(days=padding_days)).date().isoformat()
if rebalance not in {"daily", "weekly"}:
raise ValueError("rebalance must be daily or weekly")
instrument_query = instruments
if isinstance(instruments, str):
instrument_query = D.instruments(market=instruments)
raw = D.features(
instrument_query,
["$close"],
start_time=feature_start_time,
end_time=end_time,
freq="day",
)
if raw is None or len(raw) == 0:
raise QlibUnavailableError("D.features returned no close data")
records = raw.reset_index()
instrument_col, datetime_col, close_col = _infer_columns(records)
records[datetime_col] = pandas.to_datetime(records[datetime_col])
records = records.sort_values([instrument_col, datetime_col])
rows = []
start_bound = pandas.Timestamp(start_time)
end_bound = pandas.Timestamp(end_time)
for instrument, group in records.groupby(instrument_col, sort=True):
prices = deque(maxlen=int(lookback) + int(skip) + 1)
for _, row in group.iterrows():
value = row[close_col]
if value is None or (
isinstance(value, float) and not math.isfinite(value)
):
prices.append(float("nan"))
continue
prices.append(float(value))
score = momentum_score(prices, lookback=lookback, skip=skip)
when = pandas.Timestamp(row[datetime_col])
if score is None or when < start_bound or when > end_bound:
continue
rows.append(
{
"datetime": when,
"instrument": str(instrument),
"score": float(score),
}
)
if not rows:
raise QlibUnavailableError(
"no momentum signal was produced; provider history may be shorter "
"than lookback + skip + 1"
)
signal = pandas.DataFrame(rows)
if rebalance == "weekly":
# Select the true first provider session of each week, rather than
# the first date on which a warmed-up signal happens to exist.
calendar = records[[datetime_col]].drop_duplicates().copy()
calendar = calendar[
(calendar[datetime_col] >= start_bound)
& (calendar[datetime_col] <= end_bound)
]
calendar["_week"] = calendar[datetime_col].dt.to_period("W")
first_dates = set(
calendar.groupby("_week")[datetime_col].min().tolist()
)
signal = signal[signal["datetime"].isin(first_dates)]
if signal.empty:
raise QlibUnavailableError(
"no signal exists on a first weekly provider session"
)
return signal.set_index(["datetime", "instrument"]).sort_index()["score"]
def run_native_momentum_backtest(
*,
provider_uri: str,
market: str,
benchmark: str,
start_time: str,
end_time: str,
feature_start_time: Optional[str] = None,
lookback: int = 20,
skip: int = 0,
rebalance: str = "weekly",
topk: int = 50,
n_drop: int = 5,
account: float = 10_000_000.0,
deal_price: str = "open",
open_cost: float = 0.0003,
close_cost: float = 0.0008,
min_cost: float = 5.0,
limit_threshold: float = 0.095,
) -> Dict[str, Any]:
"""Run the official SimulatorExecutor + TopkDropoutStrategy stack."""
provider = _validate_provider(provider_uri)
api = _load_qlib()
constant = importlib.import_module("qlib.constant")
api["qlib"].init(provider_uri=provider, region=constant.REG_CN)
signal = generate_portable_momentum_signal(
api["D"],
market,
start_time=start_time,
end_time=end_time,
feature_start_time=feature_start_time,
lookback=lookback,
skip=skip,
rebalance=rebalance,
)
strategy = api["TopkDropoutStrategy"](
signal=signal,
topk=int(topk),
n_drop=int(n_drop),
hold_thresh=1,
only_tradable=True,
forbid_all_trade_at_limit=False,
)
executor = api["SimulatorExecutor"](
time_per_step="day",
generate_portfolio_metrics=True,
)
portfolio_metrics, indicators = api["backtest"](
start_time=start_time,
end_time=end_time,
strategy=strategy,
executor=executor,
account=float(account),
benchmark=benchmark,
exchange_kwargs={
"freq": "day",
"limit_threshold": float(limit_threshold),
"deal_price": deal_price,
"open_cost": float(open_cost),
"close_cost": float(close_cost),
"min_cost": float(min_cost),
},
)
return {
"qlib_version": api["version"],
"signal": signal,
"portfolio_metrics": portfolio_metrics,
"indicators": indicators,
"clock_contract": (
"weekly_first_provider_session_close_to_next_engine_step"
if rebalance == "weekly"
else "T_close_signal_to_T_plus_1_engine_step"
),
"strategy_fidelity": (
"portable_signal_only_research_bridge_not_quant60_"
"target_or_order_parity"
),
"a_share_rule_fidelity": (
"uniform_limit_threshold_approximation_not_point_in_time_"
"board_or_ST_rules"
),
}
def build_alpha158_lightgbm_task(
*,
market: str,
train: Sequence[str],
valid: Sequence[str],
test: Sequence[str],
topk: int = 50,
n_drop: int = 5,
benchmark: str = "SH000300",
account: float = 10_000_000.0,
) -> Dict[str, Any]:
"""Return a Qlib 0.9.7 task plus official portfolio analysis config."""
if not (len(train) == len(valid) == len(test) == 2):
raise ValueError("train/valid/test must each contain start and end")
handler = {
"start_time": train[0],
"end_time": test[1],
"fit_start_time": train[0],
"fit_end_time": train[1],
"instruments": market,
}
task = {
"model": {
"class": "LGBModel",
"module_path": "qlib.contrib.model.gbdt",
"kwargs": {
"loss": "mse",
"learning_rate": 0.05,
"num_leaves": 64,
"max_depth": 8,
"subsample": 0.9,
"colsample_bytree": 0.9,
"lambda_l1": 1.0,
"lambda_l2": 1.0,
"num_threads": 4,
},
},
"dataset": {
"class": "DatasetH",
"module_path": "qlib.data.dataset",
"kwargs": {
"handler": {
"class": "Alpha158",
"module_path": "qlib.contrib.data.handler",
"kwargs": handler,
},
"segments": {
"train": tuple(train),
"valid": tuple(valid),
"test": tuple(test),
},
},
},
}
portfolio_analysis = {
"executor": {
"class": "SimulatorExecutor",
"module_path": "qlib.backtest.executor",
"kwargs": {
"time_per_step": "day",
"generate_portfolio_metrics": True,
},
},
"strategy": {
"class": "TopkDropoutStrategy",
"module_path": "qlib.contrib.strategy.signal_strategy",
"kwargs": {
# The runner replaces this with the actual (model, dataset).
"signal": None,
"topk": int(topk),
"n_drop": int(n_drop),
},
},
"backtest": {
"start_time": test[0],
"end_time": test[1],
"account": float(account),
"benchmark": benchmark,
"exchange_kwargs": {
"freq": "day",
"limit_threshold": 0.095,
"deal_price": "open",
"open_cost": 0.0003,
"close_cost": 0.0008,
"min_cost": 5.0,
},
},
}
return {"task": task, "portfolio_analysis": portfolio_analysis}
def run_alpha158_lightgbm_workflow(
*,
provider_uri: str,
market: str,
benchmark: str,
train: Sequence[str],
valid: Sequence[str],
test: Sequence[str],
experiment_name: str = "quant60_alpha158_lightgbm",
topk: int = 50,
n_drop: int = 5,
) -> Dict[str, Any]:
"""Fit, record signals and run PortAnaRecord with official Qlib APIs."""
provider = _validate_provider(provider_uri)
# Qlib 0.9.7 defaults to a local MLflow file store. MLflow 3.14 requires
# an explicit acknowledgement before it will open that backend. Keep the
# acknowledgement local to this process; a caller-provided tracking
# configuration still owns the actual destination.
os.environ.setdefault("MLFLOW_ALLOW_FILE_STORE", "true")
api = _load_qlib()
constant = importlib.import_module("qlib.constant")
utils = importlib.import_module("qlib.utils")
workflow = importlib.import_module("qlib.workflow")
records = importlib.import_module("qlib.workflow.record_temp")
api["qlib"].init(provider_uri=provider, region=constant.REG_CN)
config = build_alpha158_lightgbm_task(
market=market,
benchmark=benchmark,
train=train,
valid=valid,
test=test,
topk=topk,
n_drop=n_drop,
)
model = utils.init_instance_by_config(config["task"]["model"])
dataset = utils.init_instance_by_config(config["task"]["dataset"])
config["portfolio_analysis"]["strategy"]["kwargs"]["signal"] = (model, dataset)
with workflow.R.start(experiment_name=experiment_name):
model.fit(dataset)
workflow.R.save_objects(**{"trained_model.pkl": model})
recorder = workflow.R.get_recorder()
records.SignalRecord(model, dataset, recorder).generate()
records.SigAnaRecord(recorder).generate()
portfolio_record = records.PortAnaRecord(
recorder,
config["portfolio_analysis"],
"day",
)
portfolio_record.generate()
try:
report = recorder.load_object(
"portfolio_analysis/report_normal_1day.pkl"
)
except Exception as exc:
raise QlibUnavailableError(
"PortAnaRecord completed without a readable 1day report"
) from exc
report_audit = _table_audit(report)
if report_audit is None:
raise QlibUnavailableError(
"Qlib portfolio report is not a pandas-compatible table"
)
recorded_metrics = {
str(key): float(value)
for key, value in sorted(recorder.list_metrics().items())
if math.isfinite(float(value))
}
artifact_paths = sorted(
str(value)
for value in recorder.list_artifacts("portfolio_analysis")
)
recorder_id = getattr(recorder, "id", None)
return {
"qlib_version": api["version"],
"experiment_name": experiment_name,
"recorder_id": recorder_id,
"mlflow_local_file_store_explicitly_allowed": (
os.environ.get("MLFLOW_ALLOW_FILE_STORE") == "true"
),
"task": config["task"],
"portfolio_analysis": config["portfolio_analysis"],
"recorder_evidence": {
"portfolio_report": report_audit,
"recorded_metrics": recorded_metrics,
"portfolio_artifact_paths": artifact_paths,
},
}
def _canonical_cli_date(value: Optional[str], *, name: str) -> str:
raw = str(value or "").strip()
if not raw:
raise ValueError(f"{name} is required")
try:
parsed = dt.date.fromisoformat(raw)
except ValueError as exc:
raise ValueError(f"{name} must be an ISO date (YYYY-MM-DD)") from exc
if parsed.isoformat() != raw:
raise ValueError(f"{name} must be an ISO date (YYYY-MM-DD)")
return raw
def _validate_nonempty(value: str, *, name: str) -> str:
normalized = str(value or "").strip()
if not normalized:
raise ValueError(f"{name} must not be empty")
return normalized
def _validate_common_cli_args(args: argparse.Namespace) -> None:
args.provider_uri = _validate_nonempty(
args.provider_uri,
name="provider-uri",
)
args.market = _validate_nonempty(args.market, name="market")
args.benchmark = _validate_nonempty(args.benchmark, name="benchmark")
if args.topk <= 0:
raise ValueError("topk must be greater than zero")
if args.n_drop < 0:
raise ValueError("n-drop must be zero or greater")
if args.n_drop > args.topk:
raise ValueError("n-drop must not exceed topk")
if args.output_json is not None:
args.output_json = _validate_nonempty(
args.output_json,
name="output-json",
)
def _validated_momentum_dates(args: argparse.Namespace) -> tuple[str, str, Optional[str]]:
start = _canonical_cli_date(args.start, name="start")
end = _canonical_cli_date(args.end, name="end")
if start > end:
raise ValueError("start must be on or before end")
feature_start = None
if args.feature_start is not None:
feature_start = _canonical_cli_date(
args.feature_start,
name="feature-start",
)
if feature_start > start:
raise ValueError("feature-start must be on or before start")
if args.lookback <= 0:
raise ValueError("lookback must be greater than zero")
if args.skip < 0:
raise ValueError("skip must be zero or greater")
return start, end, feature_start
def _validated_alpha158_segments(
args: argparse.Namespace,
) -> tuple[tuple[str, str], tuple[str, str], tuple[str, str]]:
train = (
_canonical_cli_date(args.train_start, name="train-start"),
_canonical_cli_date(args.train_end, name="train-end"),
)
valid = (
_canonical_cli_date(args.valid_start, name="valid-start"),
_canonical_cli_date(args.valid_end, name="valid-end"),
)
test = (
_canonical_cli_date(args.test_start, name="test-start"),
_canonical_cli_date(args.test_end, name="test-end"),
)
for name, segment in (("train", train), ("valid", valid), ("test", test)):
if segment[0] > segment[1]:
raise ValueError(f"{name}-start must be on or before {name}-end")
if train[1] >= valid[0]:
raise ValueError("train and valid segments must be ordered and non-overlapping")
if valid[1] >= test[0]:
raise ValueError("valid and test segments must be ordered and non-overlapping")
args.experiment_name = _validate_nonempty(
args.experiment_name,
name="experiment-name",
)
return train, valid, test
def _portfolio_frequencies(portfolio_metrics: Any) -> list[str]:
if isinstance(portfolio_metrics, Mapping):
values = portfolio_metrics.keys()
elif isinstance(portfolio_metrics, (list, tuple, set, frozenset)):
values = portfolio_metrics
else:
values = ()
return sorted(str(value) for value in values)
def _momentum_cli_summary(result: Mapping[str, Any]) -> Dict[str, Any]:
signal = result.get("signal")
signal_table = (
signal.to_frame(name="score")
if callable(getattr(signal, "to_frame", None))
else None
)
return {
"workflow": "momentum",
"qlib_version": str(result.get("qlib_version", "unknown")),
"signal_rows": len(signal) if signal is not None else 0,
"portfolio_frequencies": _portfolio_frequencies(
result.get("portfolio_metrics")
),
"signal_evidence": _table_audit(signal_table),
"portfolio_report_evidence": _mapping_table_audits(
result.get("portfolio_metrics")
),
"indicator_evidence": _mapping_table_audits(
result.get("indicators")
),
"clock_contract": result.get("clock_contract"),
"strategy_fidelity": result.get("strategy_fidelity"),
"a_share_rule_fidelity": result.get("a_share_rule_fidelity"),
"investment_value_claim": False,
}
def _alpha158_cli_summary(
result: Mapping[str, Any],
*,
market: str,
benchmark: str,
train: Sequence[str],
valid: Sequence[str],
test: Sequence[str],
) -> Dict[str, Any]:
recorder_id = result.get("recorder_id")
return {
"workflow": "alpha158",
"qlib_version": str(result.get("qlib_version", "unknown")),
"experiment_name": str(result.get("experiment_name", "")),
"recorder_id": None if recorder_id is None else str(recorder_id),
"mlflow_local_file_store_explicitly_allowed": bool(
result.get("mlflow_local_file_store_explicitly_allowed", False)
),
"market": market,
"benchmark": benchmark,
"segments": {
"train": list(train),
"valid": list(valid),
"test": list(test),
},
"recorder_evidence": result.get("recorder_evidence"),
"investment_value_claim": False,
}
def _write_cli_json(path: str, payload: Mapping[str, Any]) -> None:
destination = Path(path).expanduser().resolve()
if destination.exists() and destination.is_dir():
raise ValueError(f"output-json points to a directory: {destination}")
destination.parent.mkdir(parents=True, exist_ok=True)
encoded = (
json.dumps(
dict(payload),
ensure_ascii=False,
indent=2,
sort_keys=True,
allow_nan=False,
)
+ "\n"
).encode("utf-8")
descriptor, temporary = tempfile.mkstemp(
prefix=f".{destination.name}.",
suffix=".tmp",
dir=str(destination.parent),
)
try:
with os.fdopen(descriptor, "wb") as handle:
handle.write(encoded)
handle.flush()
os.fsync(handle.fileno())
os.replace(temporary, destination)
except BaseException:
try:
os.unlink(temporary)
except FileNotFoundError:
pass
raise
def _main(argv: Optional[list[str]] = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--workflow",
choices=("momentum", "alpha158"),
default="momentum",
help="default momentum preserves the original CLI behavior",
)
parser.add_argument("--provider-uri", required=True)
parser.add_argument("--market", default="csi300")
parser.add_argument("--benchmark", default="SH000300")
parser.add_argument("--start")
parser.add_argument("--end")
parser.add_argument("--feature-start")
parser.add_argument("--lookback", type=int, default=20)
parser.add_argument("--skip", type=int, default=0)
parser.add_argument("--topk", type=int, default=50)
parser.add_argument("--n-drop", type=int, default=5)
parser.add_argument("--rebalance", choices=("daily", "weekly"), default="weekly")
parser.add_argument("--train-start")
parser.add_argument("--train-end")
parser.add_argument("--valid-start")
parser.add_argument("--valid-end")
parser.add_argument("--test-start")
parser.add_argument("--test-end")
parser.add_argument(
"--experiment-name",
default="quant60_alpha158_lightgbm",
)
parser.add_argument(
"--output-json",
"--result-json",
dest="output_json",
help="optionally save the same JSON summary printed to stdout",
)
args = parser.parse_args(argv)
try:
_validate_common_cli_args(args)
if args.workflow == "momentum":
if any(
value is not None
for value in (
args.train_start,
args.train_end,
args.valid_start,
args.valid_end,
args.test_start,
args.test_end,
)
):
raise ValueError(
"train/valid/test segment arguments are only valid for "
"workflow=alpha158"
)
start, end, feature_start = _validated_momentum_dates(args)
result = run_native_momentum_backtest(
provider_uri=args.provider_uri,
market=args.market,
benchmark=args.benchmark,
start_time=start,
end_time=end,
feature_start_time=feature_start,
lookback=args.lookback,
skip=args.skip,
topk=args.topk,
n_drop=args.n_drop,
rebalance=args.rebalance,
)
summary = _momentum_cli_summary(result)
else:
if any(
value is not None
for value in (args.start, args.end, args.feature_start)
):
raise ValueError(
"start/end/feature-start are only valid for "
"workflow=momentum"
)
train, valid, test = _validated_alpha158_segments(args)
result = run_alpha158_lightgbm_workflow(
provider_uri=args.provider_uri,
market=args.market,
benchmark=args.benchmark,
train=train,
valid=valid,
test=test,
experiment_name=args.experiment_name,
topk=args.topk,
n_drop=args.n_drop,
)
summary = _alpha158_cli_summary(
result,
market=args.market,
benchmark=args.benchmark,
train=train,
valid=valid,
test=test,
)
if args.output_json:
_write_cli_json(args.output_json, summary)
except (OSError, ValueError) as exc:
parser.error(str(exc))
print(
json.dumps(
summary,
ensure_ascii=False,
sort_keys=True,
)
)
return 0
if __name__ == "__main__":
raise SystemExit(_main())