Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ QMT_DRY_RUN_ONLY=true
QMT_EXECUTION_MODE=dry_run
# Required only by: python3 scripts/preflight_qmt_runtime.py --paper-admission
# QMT_PAPER_ADMISSION_INPUT_SHA256=<frozen SHA-256 of QMT_MARKET_HISTORY_PATH>
STRATEGY_PROFILE=cn_industry_etf_rotation
# No profiles are currently selectable; retained historical example only.
# STRATEGY_PROFILE=cn_industry_etf_rotation
ACCOUNT_PREFIX=QMT
ACCOUNT_REGION=CN
QMT_MARKET_HISTORY_PATH=data/fixtures/market_history.sample.csv
39 changes: 6 additions & 33 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ A-share quant platform layer for miniQMT / QMT, built on `QuantPlatformKit` and

## Status

Experimental. The current implementation evaluates strategies offline and previews orders. This project is not financial advice and does not guarantee any trading result.
Experimental. The pinned CN policy has no selectable runtime profiles. Retired profiles are rejected before offline evaluation or order preview; historical catalog metadata and adapters remain available. This project is not financial advice and does not guarantee any trading result.

## What this is

Expand All @@ -24,30 +24,10 @@ Requirements: Python 3.11 or later and Git. Run these commands from the reposito
python3 -m pip install -e '.[test]'
python3 -m pytest -q tests

unset RUNTIME_TARGET_JSON
export STRATEGY_PROFILE=cn_industry_etf_rotation
export QMT_DRY_RUN_ONLY=true
export QMT_EXECUTION_MODE=dry_run
export QMT_MARKET_HISTORY_PATH=data/fixtures/market_history.sample.csv

python3 scripts/preflight_qmt_runtime.py
python3 main.py
```

In another terminal, query the local service:

```bash
curl http://127.0.0.1:8080/probe
curl http://127.0.0.1:8080/profiles
curl http://127.0.0.1:8080/dry-run
```

The fixture-backed smoke scripts run a local strategy cycle without starting the HTTP service:

```bash
python3 scripts/smoke_cn_industry_etf_rotation_dry_run_e2e.py
python3 scripts/smoke_cn_industry_etf_rotation_aggressive_dry_run_e2e.py
```
Query `GET /profiles` to inspect the disabled historical profiles. No profile can currently pass preflight or run through `/dry-run`. The retained industry ETF smoke scripts reject their retired profiles under this policy.

## Configuration

Expand All @@ -63,20 +43,13 @@ Use [`.env.example`](.env.example) as the dry-run configuration reference. Expor
| `RUNTIME_TARGET_JSON` | No | none | JSON runtime target from `QuantRuntimeSettings`; takes precedence over `STRATEGY_PROFILE`. |
| `PORT` | No | `8080` | HTTP port; the Flask entrypoint binds to `0.0.0.0`. |

## Supported dry-run profiles

| Profile | Input mode | Role |
|---|---|---|
| `cn_industry_etf_rotation` | `market_history` | Primary profile. |
| `cn_industry_etf_rotation_aggressive` | `market_history` | Optional profile with a 25% volatility target. |

Set `STRATEGY_PROFILE=cn_industry_etf_rotation_aggressive` to select the optional profile when `RUNTIME_TARGET_JSON` is unset. The QMT target layout is documented in [QuantRuntimeSettings](https://github.com/QuantStrategyLab/QuantRuntimeSettings/blob/main/examples/targets/qmt/README.md).
## Retired profiles

Profile availability follows the `CnEquityStrategies` catalog and QMT rollout allowlist. Research-only profiles such as `cn_dividend_quality_snapshot` and `cn_index_etf_tactical_rotation` are rejected by the QMT runtime policy.
`cn_industry_etf_rotation` and `cn_industry_etf_rotation_aggressive` are no longer selectable, including through aliases, environment configuration or `RUNTIME_TARGET_JSON`. The QMT rollout allowlist is empty. Historical definitions and adapters remain available for reading and isolated contract tests; they do not grant runtime selection.

## Offline paper admission

`--paper-admission` checks a local configuration and fixed input file. It requires `QMT_EXECUTION_MODE=paper`, `QMT_DRY_RUN_ONLY=true`, and a pre-recorded SHA-256 digest matching `QMT_MARKET_HISTORY_PATH`.
`--paper-admission` currently rejects retired profiles at configuration validation. The retained checks below describe the offline contract for an eligible profile. The command checks a local configuration and fixed input file. It requires `QMT_EXECUTION_MODE=paper`, `QMT_DRY_RUN_ONLY=true`, and a pre-recorded SHA-256 digest matching `QMT_MARKET_HISTORY_PATH`.

After configuring the profile and input path, replace `<expected-sha256>` with that recorded digest:

Expand All @@ -91,7 +64,7 @@ The command loads the strategy contract and hashes the local input. It does not

## Disabled-target lifecycle validation

The weekday `Runtime Target Lifecycle` workflow runs deterministic preflight and a fixture-backed dry-run for a fixed disabled target. It publishes `disabled` / `dry_run` status. A completed cycle whose target weights are empty because of the risk gate is a successful no-order check; a preflight or dry-run failure is reported as `attention`. These reports do not change target settings, establish a broker connection, or submit orders.
With the current empty allowlist, the retained industry ETF target fails preflight and is reported as `attention`. The weekday `Runtime Target Lifecycle` workflow runs deterministic preflight and a fixture-backed dry-run for a fixed disabled target. It publishes `disabled` / `dry_run` status. A completed cycle whose target weights are empty because of the risk gate is a successful no-order check; a preflight or dry-run failure is reported as `attention`. These reports do not change target settings, establish a broker connection, or submit orders.

## HTTP endpoints

Expand Down
39 changes: 6 additions & 33 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

## 状态

实验性项目。当前实现离线评估策略并预览订单。本项目不构成投资建议,也不保证任何交易结果。
实验性项目。固定依赖中的 A 股政策已无可选运行策略;退役策略会在离线评估或订单预览之前被拒绝,历史策略元数据和适配器仍保留。本项目不构成投资建议,也不保证任何交易结果。

## 简介

Expand All @@ -24,30 +24,10 @@ QmtPlatform 提供 QMT 平台集成层。它从 `CnEquityStrategies` 加载策
python3 -m pip install -e '.[test]'
python3 -m pytest -q tests

unset RUNTIME_TARGET_JSON
export STRATEGY_PROFILE=cn_industry_etf_rotation
export QMT_DRY_RUN_ONLY=true
export QMT_EXECUTION_MODE=dry_run
export QMT_MARKET_HISTORY_PATH=data/fixtures/market_history.sample.csv

python3 scripts/preflight_qmt_runtime.py
python3 main.py
```

在另一个终端查询本地服务:

```bash
curl http://127.0.0.1:8080/probe
curl http://127.0.0.1:8080/profiles
curl http://127.0.0.1:8080/dry-run
```

以下 smoke 脚本使用固定样本运行本地策略周期,无需启动 HTTP 服务:

```bash
python3 scripts/smoke_cn_industry_etf_rotation_dry_run_e2e.py
python3 scripts/smoke_cn_industry_etf_rotation_aggressive_dry_run_e2e.py
```
通过 `GET /profiles` 查看已停用的历史策略。目前没有策略能通过预检或运行 `/dry-run`;保留的行业 ETF smoke 脚本也会因策略退役而拒绝运行。

## 配置

Expand All @@ -63,20 +43,13 @@ python3 scripts/smoke_cn_industry_etf_rotation_aggressive_dry_run_e2e.py
| `RUNTIME_TARGET_JSON` | 否 | 无 | 来自 `QuantRuntimeSettings` 的 JSON 运行目标;优先于 `STRATEGY_PROFILE`。 |
| `PORT` | 否 | `8080` | HTTP 端口;Flask 入口绑定 `0.0.0.0`。 |

## 支持的 dry-run 策略

| 策略配置 | 输入模式 | 用途 |
|---|---|---|
| `cn_industry_etf_rotation` | `market_history` | 主要策略。 |
| `cn_industry_etf_rotation_aggressive` | `market_history` | 可选策略,目标波动率为 25%。 |

未设置 `RUNTIME_TARGET_JSON` 时,可通过 `STRATEGY_PROFILE=cn_industry_etf_rotation_aggressive` 选择可选策略。QMT 运行目标结构见 [QuantRuntimeSettings](https://github.com/QuantStrategyLab/QuantRuntimeSettings/blob/main/examples/targets/qmt/README.md)。
## 已退役策略

可用策略由 `CnEquityStrategies` 的策略目录和 QMT 启用清单决定。QMT 运行配置不接受 `cn_dividend_quality_snapshot`、`cn_index_etf_tactical_rotation` 等仅供研究的策略配置。
`cn_industry_etf_rotation` 和 `cn_industry_etf_rotation_aggressive` 均不可再选择,包括别名、环境变量及 `RUNTIME_TARGET_JSON` 入口。QMT 运行允许清单为空。历史定义和适配器供读取及隔离的契约测试使用,不赋予运行选择资格。

## 离线 paper-admission 检查

`--paper-admission` 检查本地配置和固定输入文件。它要求 `QMT_EXECUTION_MODE=paper`、`QMT_DRY_RUN_ONLY=true`,且预先记录的 SHA-256 摘要与 `QMT_MARKET_HISTORY_PATH` 一致。
`--paper-admission` 目前会在配置检查时拒绝退役策略。下面保留的是具备选择资格时的离线检查契约:检查本地配置和固定输入文件。它要求 `QMT_EXECUTION_MODE=paper`、`QMT_DRY_RUN_ONLY=true`,且预先记录的 SHA-256 摘要与 `QMT_MARKET_HISTORY_PATH` 一致。

配置策略和输入路径后,将 `<expected-sha256>` 替换为预先记录的摘要:

Expand All @@ -91,7 +64,7 @@ python3 scripts/preflight_qmt_runtime.py --paper-admission

## 已停用目标的生命周期验证

工作日运行的 `Runtime Target Lifecycle` 工作流为固定的已停用目标执行确定性预检和基于样本的 dry-run,并发布 `disabled` / `dry_run` 状态。若周期已完成,但风控检查使目标权重为空,视为成功完成无订单检查;预检或 dry-run 失败则报告为 `attention`。这些报告不会修改目标设置、建立券商连接或提交订单。
当前允许清单为空,保留的行业 ETF 目标会在预检失败并报告为 `attention`。工作日运行的 `Runtime Target Lifecycle` 工作流为固定的已停用目标执行确定性预检和基于样本的 dry-run,并发布 `disabled` / `dry_run` 状态。若周期已完成,但风控检查使目标权重为空,视为成功完成无订单检查;预检或 dry-run 失败则报告为 `attention`。这些报告不会修改目标设置、建立券商连接或提交订单。

## HTTP 接口

Expand Down
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ authors = [{ name = "QuantStrategyLab" }]
dependencies = [
"flask>=3.0",
"pandas>=2.0",
"quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@7f140f07ac89f0b4b88347a903906825dde11c39",
"cn-equity-strategies @ git+https://github.com/QuantStrategyLab/CnEquityStrategies.git@3ed1bfb5c2d481f96b941c3d5299d477ccded734",
"quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@f6f2079f6b53d3cbb3b72bdcc5c2476ed8f170f1",
"cn-equity-strategies @ git+https://github.com/QuantStrategyLab/CnEquityStrategies.git@29b6cb74464ddb01874a9b3390dd56d19d5860af",
]

[project.optional-dependencies]
Expand Down
19 changes: 19 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,22 @@ def market_history_csv(tmp_path) -> str:
path = tmp_path / "market_history.csv"
frame.to_csv(path, index=False)
return str(path)


@pytest.fixture
def historical_strategy_policy(monkeypatch):
"""Exercise retained offline contracts under an explicit historical policy.

Opt in only for tests of risk/preflight behavior below profile selection.
Retirement entrypoint tests use the real empty policy.
"""
from dataclasses import replace
import strategy_registry

monkeypatch.setattr(
strategy_registry,
"PLATFORM_POLICY",
replace(strategy_registry.PLATFORM_POLICY, enabled_profiles=frozenset({
"cn_industry_etf_rotation", "cn_industry_etf_rotation_aggressive",
})),
)
24 changes: 9 additions & 15 deletions tests/test_dry_run_cycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,28 +10,21 @@
from strategy_registry import QMT_PLATFORM, PLATFORM_POLICY, get_enabled_profiles_for_platform


def test_qmt_enabled_profiles_include_primary_runtime_strategies():
profiles = get_enabled_profiles_for_platform(QMT_PLATFORM, policy=PLATFORM_POLICY)
assert profiles == frozenset(
{
CN_INDUSTRY_ETF_ROTATION_PROFILE,
CN_INDUSTRY_ETF_ROTATION_AGGRESSIVE_PROFILE,
}
)
def test_qmt_enabled_profiles_are_empty_after_retirement():
assert get_enabled_profiles_for_platform(QMT_PLATFORM, policy=PLATFORM_POLICY) == frozenset()


def test_load_platform_runtime_settings_from_env(monkeypatch: pytest.MonkeyPatch, market_history_csv: str):
monkeypatch.setenv("STRATEGY_PROFILE", CN_INDUSTRY_ETF_ROTATION_PROFILE)
@pytest.mark.parametrize("profile", [CN_INDUSTRY_ETF_ROTATION_PROFILE, CN_INDUSTRY_ETF_ROTATION_AGGRESSIVE_PROFILE])
def test_load_platform_runtime_settings_rejects_retired_profiles(monkeypatch, market_history_csv, profile):
monkeypatch.setenv("STRATEGY_PROFILE", profile)
monkeypatch.setenv("QMT_DRY_RUN_ONLY", "true")
monkeypatch.setenv("QMT_MARKET_HISTORY_PATH", market_history_csv)
monkeypatch.delenv("RUNTIME_TARGET_JSON", raising=False)

settings = load_platform_runtime_settings()
assert settings.strategy_profile == CN_INDUSTRY_ETF_ROTATION_PROFILE
assert settings.dry_run_only is True
assert settings.market_history_path == market_history_csv
with pytest.raises(ValueError, match="Unsupported STRATEGY_PROFILE"):
load_platform_runtime_settings()


@pytest.mark.usefixtures("historical_strategy_policy")
def test_dry_run_cycle_fails_closed_on_concentrated_target(
monkeypatch: pytest.MonkeyPatch,
market_history_csv: str,
Expand Down Expand Up @@ -65,6 +58,7 @@ def test_dry_run_cycle_fails_closed_on_concentrated_target(
assert report["dry_run_only"] is True


@pytest.mark.usefixtures("historical_strategy_policy")
def test_aggressive_industry_profile_fails_closed_on_concentration(
monkeypatch: pytest.MonkeyPatch,
market_history_csv: str,
Expand Down
20 changes: 18 additions & 2 deletions tests/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ def client(monkeypatch: pytest.MonkeyPatch, market_history_csv: str):
return app.test_client()


def test_probe_endpoint(client):
def test_probe_endpoint(client, historical_strategy_policy):
response = client.get("/probe")
assert response.status_code == 200
payload = response.get_json()
Expand All @@ -23,7 +23,7 @@ def test_probe_endpoint(client):
assert payload["strategy_profile"] == "cn_industry_etf_rotation"


def test_dry_run_endpoint(client):
def test_dry_run_endpoint(client, historical_strategy_policy):
response = client.get("/dry-run")
assert response.status_code == 200
payload = response.get_json()
Expand All @@ -32,3 +32,19 @@ def test_dry_run_endpoint(client):
assert payload["order_previews"] == []
assert payload["risk_flags"] == ["rejected:concentration"]
assert payload["diagnostics"]["risk_gate"] == "REJECT"


def test_retired_profile_cannot_run_through_http(client, monkeypatch):
import main

def unexpected_cycle(**kwargs):
pytest.fail("Retired profile reached the strategy cycle")

monkeypatch.setattr(main, "run_dry_run_cycle", unexpected_cycle)
assert client.get("/dry-run").status_code == 500


def test_profiles_endpoint_has_no_enabled_profiles(client):
response = client.get("/profiles")
assert response.status_code == 200
assert not any(row["enabled"] for row in response.get_json()["profiles"])
8 changes: 8 additions & 0 deletions tests/test_runtime_preflight.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ def _valid_e3_receipt() -> dict[str, object]:
}


@pytest.mark.usefixtures("historical_strategy_policy")
def test_preflight_accepts_primary_dry_run_config(monkeypatch, market_history_csv: str):
monkeypatch.setenv("STRATEGY_PROFILE", "cn_industry_etf_rotation")
monkeypatch.setenv("QMT_DRY_RUN_ONLY", "true")
Expand All @@ -57,6 +58,7 @@ def test_preflight_accepts_primary_dry_run_config(monkeypatch, market_history_cs
assert report.issues == ()


@pytest.mark.usefixtures("historical_strategy_policy")
def test_preflight_requires_market_history_for_direct_profile(monkeypatch):
monkeypatch.setenv("STRATEGY_PROFILE", "cn_industry_etf_rotation")
monkeypatch.setenv("QMT_DRY_RUN_ONLY", "true")
Expand All @@ -69,6 +71,7 @@ def test_preflight_requires_market_history_for_direct_profile(monkeypatch):
assert _issue_codes(report) == {"missing_market_history"}


@pytest.mark.usefixtures("historical_strategy_policy")
def test_preflight_blocks_non_dry_run_config(monkeypatch, market_history_csv: str):
monkeypatch.setenv("STRATEGY_PROFILE", "cn_industry_etf_rotation")
monkeypatch.setenv("QMT_DRY_RUN_ONLY", "false")
Expand All @@ -81,6 +84,7 @@ def test_preflight_blocks_non_dry_run_config(monkeypatch, market_history_csv: st
assert _issue_codes(report) == {"non_dry_run_blocked"}


@pytest.mark.usefixtures("historical_strategy_policy")
def test_paper_admission_requires_paper_mode_and_a_frozen_input_digest(monkeypatch, market_history_csv: str):
monkeypatch.setenv("STRATEGY_PROFILE", "cn_industry_etf_rotation")
monkeypatch.setenv("QMT_DRY_RUN_ONLY", "true")
Expand All @@ -101,6 +105,7 @@ def test_paper_admission_requires_paper_mode_and_a_frozen_input_digest(monkeypat
assert report.to_payload() == run_preflight(paper_admission=True).to_payload()


@pytest.mark.usefixtures("historical_strategy_policy")
def test_paper_admission_rejects_live_mode_and_non_dry_run(monkeypatch, market_history_csv: str):
monkeypatch.setenv("STRATEGY_PROFILE", "cn_industry_etf_rotation")
monkeypatch.setenv("QMT_DRY_RUN_ONLY", "false")
Expand All @@ -120,6 +125,7 @@ def test_paper_admission_rejects_live_mode_and_non_dry_run(monkeypatch, market_h
}


@pytest.mark.usefixtures("historical_strategy_policy")
def test_paper_admission_rejects_an_unfrozen_input(monkeypatch, market_history_csv: str):
monkeypatch.setenv("STRATEGY_PROFILE", "cn_industry_etf_rotation")
monkeypatch.setenv("QMT_DRY_RUN_ONLY", "true")
Expand All @@ -141,6 +147,7 @@ def test_preflight_cli_has_no_non_dry_run_bypass():
assert exc_info.value.code == 2


@pytest.mark.usefixtures("historical_strategy_policy")
def test_preflight_cli_runs_offline_paper_admission(monkeypatch, market_history_csv: str):
monkeypatch.setenv("STRATEGY_PROFILE", "cn_industry_etf_rotation")
monkeypatch.setenv("QMT_DRY_RUN_ONLY", "true")
Expand Down Expand Up @@ -224,6 +231,7 @@ def test_e3_receipt_validator_fails_closed_for_missing_required_fields():
assert _issue_codes(report) == {"missing_receipt_fields", "verify_only_required"}


@pytest.mark.usefixtures("historical_strategy_policy")
def test_preflight_cli_validates_e3_receipt_offline(monkeypatch, market_history_csv: str, tmp_path, capsys):
monkeypatch.setenv("STRATEGY_PROFILE", "cn_industry_etf_rotation")
monkeypatch.setenv("QMT_DRY_RUN_ONLY", "true")
Expand Down
Loading
Loading