Skip to content

Commit 8fdd07b

Browse files
committed
Clarify recommendation horizon windows
1 parent b78581c commit 8fdd07b

8 files changed

Lines changed: 87 additions & 16 deletions

File tree

README.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ Notification channel rules are documented in
9797
The main JSON artifact is `ModelRecommendationReport`:
9898

9999
```text
100-
schema_version = 4
100+
schema_version = 5
101101
as_of
102102
generated_at
103103
mode = model_recommendations
@@ -120,8 +120,10 @@ recommendation_tier
120120
recommendation_tier_label
121121
primary_horizon
122122
primary_horizon_label
123+
primary_horizon_window
123124
horizon_note
124125
suitable_horizons[]
126+
suitable_horizon_windows{}
125127
strategy_style
126128
score
127129
evidence_score
@@ -134,6 +136,13 @@ evidence_refs[]
134136
review_checklist[]
135137
```
136138

139+
Default horizon windows:
140+
141+
- short: `1-10 trading days`
142+
- medium: `2-12 weeks`
143+
- long: `1-3 years`
144+
- not_applicable: source check, defer, or monitor only
145+
137146
## Regulatory Boundary
138147

139148
Even when no orders or allocations are generated, specific securities

README.zh-CN.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,8 @@ python scripts/build_advisory_report.py \
3333
- 推荐标的
3434
- 推荐等级:重点推荐、观察、先核验来源、暂缓、监控
3535
- 推荐层级:一级推荐、二级推荐、观察名单、来源核验
36-
- 适合周期:短线、中线、长线
36+
- 适合周期:短线、中线、长线,并明确时间窗口
37+
- 周期窗口:短线=1-10个交易日,中线=2-12周,长线=1-3年
3738
- 周期说明:短线风险、事件驱动验证周期、长期观察理由
3839
- 来源可信度:高、中、低、混合、无事件
3940
- 中长线、价值、事件、宏观等策略风格标签

docs/advisory_contract.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
Required top-level fields:
66

77
```text
8-
schema_version: "4"
8+
schema_version: "5"
99
as_of: ISO date
1010
generated_at: ISO datetime
1111
mode: "model_recommendations"
@@ -45,8 +45,10 @@ recommendation_tier
4545
recommendation_tier_label
4646
primary_horizon
4747
primary_horizon_label
48+
primary_horizon_window
4849
horizon_note
4950
suitable_horizons
51+
suitable_horizon_windows
5052
strategy_style
5153
score
5254
evidence_score
@@ -83,6 +85,13 @@ Allowed horizons:
8385
- `long`
8486
- `not_applicable`
8587

88+
Default horizon windows:
89+
90+
- `short`: `1-10个交易日`
91+
- `medium`: `2-12周`
92+
- `long`: `1-3年`
93+
- `not_applicable`: `不适用`
94+
8695
Allowed source confidence values:
8796

8897
- `high`

docs/notification_format.zh-CN.md

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,10 @@ recommendation_tier
3535
recommendation_tier_label
3636
primary_horizon
3737
primary_horizon_label
38+
primary_horizon_window
3839
horizon_note
3940
suitable_horizons[]
41+
suitable_horizon_windows{}
4042
strategy_style
4143
score
4244
evidence_score
@@ -67,12 +69,12 @@ Telegram 适合中等长度,最多展示前 3-5 个推荐:
6769
```text
6870
Quant Model Recommendations | Weekly | 2026-05-30
6971
70-
1. EVT1 | 一级推荐 | 重点推荐 | 中线 | 来源中 | score 0.85
72+
1. EVT1 | 一级推荐 | 重点推荐 | 中线(2-12周) | 来源中 | score 0.85
7173
理由:公开事件 + 披露证据 + AI shadow 仍在观察区间。
72-
周期:事件驱动以中线验证为主;短线只适合观察催化反应,波动和反转风险更高。
74+
周期:事件驱动主周期为2-12周;1-10个交易日只适合观察催化反应,波动和反转风险更高。
7375
风险:AI 数据缺口未完全解决。
7476
75-
2. EVT2 | 一级推荐 | 重点推荐 | 中线 | 来源高 | score 0.83
77+
2. EVT2 | 一级推荐 | 重点推荐 | 中线(2-12周) | 来源高 | score 0.83
7678
理由:政策资本事件触发,来源置信度高。
7779
风险:需要复核估值、财报日和最新价格行为。
7880
@@ -115,8 +117,14 @@ Subject: Quant Model Recommendations Weekly Review - 2026-05-30
115117
## 周期原则
116118

117119
- 主周期只给一个:短线、中线、长线或不适用。
120+
- 周期窗口必须固定显示:
121+
- 短线:`1-10个交易日`
122+
- 中线:`2-12周`
123+
- 长线:`1-3年`
118124
- 事件驱动默认以中线为主,短线只作为催化反应观察,不作为自动交易触发。
119-
- 长线推荐需要更多基本面、AI shadow 和事件持续性支持。
125+
- 长线推荐主窗口为 `1-3年`,需要更多基本面、AI shadow 和事件持续性支持;超过 3 年应通过年度复盘确认逻辑仍成立。
126+
- `primary_horizon_window` 必须给出主周期时间范围。
127+
- `suitable_horizon_windows` 必须给出所有可观察周期的时间范围。
120128
- `horizon_note` 必须解释为什么适合该周期,以及短线风险在哪里。
121129

122130
## 禁用表达

src/quant_advisor_research/advisory_report.py

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,13 @@
4343
"negative": -3,
4444
}
4545

46+
HORIZON_WINDOWS = {
47+
"short": "1-10个交易日",
48+
"medium": "2-12周",
49+
"long": "1-3年",
50+
"not_applicable": "不适用",
51+
}
52+
4653

4754
@dataclass(frozen=True)
4855
class Event:
@@ -151,12 +158,12 @@ def horizon_fit(style: str, rating: str) -> tuple[str, list[str], str, str]:
151158
if rating in {"verify_source", "defer", "monitor"}:
152159
return "not_applicable", ["not_applicable"], "不适用", "该项不是当前推荐,仅用于来源核验、风险暂缓或持续监控。"
153160
if style == "event_driven":
154-
return "medium", ["short", "medium"], "中线", "事件驱动以中线验证为主;短线只适合观察催化反应,波动和反转风险更高。"
161+
return "medium", ["short", "medium"], "中线", "事件驱动主周期为2-12周;1-10个交易日只适合观察催化反应,波动和反转风险更高。"
155162
if style in {"long_horizon_growth", "value_quality"}:
156-
return "long", ["medium", "long"], "长线", "更适合用中长期基本面和趋势验证,不适合按短线噪声频繁切换。"
163+
return "long", ["medium", "long"], "长线", "主周期为1-3年,更适合用基本面、产业趋势和事件持续性验证;超过3年需要年度复盘确认逻辑仍成立。"
157164
if style == "macro_context":
158-
return "medium", ["medium"], "中线", "宏观和政策背景更适合中线观察,需要结合市场环境复核。"
159-
return "medium", ["medium"], "中线", "默认按中线研究处理,等待更多证据后再调整周期判断。"
165+
return "medium", ["medium"], "中线", "主周期为2-12周,宏观和政策背景需要结合市场环境复核。"
166+
return "medium", ["medium"], "中线", "默认按2-12周中线研究处理,等待更多证据后再调整周期判断。"
160167

161168

162169
def rating_label(rating: str) -> str:
@@ -273,6 +280,8 @@ def build_recommendation(
273280
style = strategy_style(item, events, ai_bias)
274281
score = round(clamp((evidence_score - risk_score + 8) / 24, 0.05, 0.85), 2)
275282
primary_horizon, suitable_horizons, primary_horizon_label, horizon_note = horizon_fit(style, rating)
283+
primary_horizon_window = HORIZON_WINDOWS[primary_horizon]
284+
suitable_horizon_windows = {horizon: HORIZON_WINDOWS[horizon] for horizon in suitable_horizons}
276285
confidence, confidence_label = source_confidence(events)
277286
tier, tier_label = recommendation_tier(rating, score, confidence)
278287

@@ -305,8 +314,10 @@ def build_recommendation(
305314
"recommendation_tier_label": tier_label,
306315
"primary_horizon": primary_horizon,
307316
"primary_horizon_label": primary_horizon_label,
317+
"primary_horizon_window": primary_horizon_window,
308318
"horizon_note": horizon_note,
309319
"suitable_horizons": suitable_horizons,
320+
"suitable_horizon_windows": suitable_horizon_windows,
310321
"strategy_style": style,
311322
"score": score,
312323
"evidence_score": int(evidence_score),
@@ -371,7 +382,7 @@ def build_advisory_report(
371382
recommendations = recommendations[:max_candidates]
372383

373384
report = {
374-
"schema_version": "4",
385+
"schema_version": "5",
375386
"as_of": as_of_date.isoformat(),
376387
"generated_at": utc_now_iso(),
377388
"mode": "model_recommendations",
@@ -430,7 +441,11 @@ def render_markdown(report: dict[str, Any]) -> str:
430441
f"### {rec['symbol']} - {rec['rating_label']}",
431442
"",
432443
f"- 推荐层级: `{rec['recommendation_tier_label']}`",
433-
f"- 适合周期: `{rec['primary_horizon_label']}`",
444+
f"- 适合周期: `{rec['primary_horizon_label']}({rec['primary_horizon_window']})`",
445+
"- 可观察周期: "
446+
+ ", ".join(
447+
f"`{horizon}={window}`" for horizon, window in rec["suitable_horizon_windows"].items()
448+
),
434449
f"- 周期说明: {rec['horizon_note']}",
435450
f"- Strategy style: `{rec['strategy_style']}`",
436451
f"- Model score: `{rec['score']}`",

src/quant_advisor_research/contracts.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -97,8 +97,8 @@ def validate_advisory_report(payload: Mapping[str, Any]) -> None:
9797
if missing:
9898
raise AdvisoryValidationError(f"missing required keys: {', '.join(missing)}")
9999

100-
if payload["schema_version"] != "4":
101-
raise AdvisoryValidationError("schema_version must be '4'")
100+
if payload["schema_version"] != "5":
101+
raise AdvisoryValidationError("schema_version must be '5'")
102102
_require_iso_date(payload["as_of"], "as_of")
103103
_require_iso_datetime(payload["generated_at"], "generated_at")
104104
if payload["mode"] != "model_recommendations":
@@ -127,8 +127,10 @@ def validate_advisory_report(payload: Mapping[str, Any]) -> None:
127127
"recommendation_tier_label",
128128
"primary_horizon",
129129
"primary_horizon_label",
130+
"primary_horizon_window",
130131
"horizon_note",
131132
"suitable_horizons",
133+
"suitable_horizon_windows",
132134
"strategy_style",
133135
"score",
134136
"evidence_score",
@@ -152,10 +154,18 @@ def validate_advisory_report(payload: Mapping[str, Any]) -> None:
152154
if rec["primary_horizon"] not in ALLOWED_HORIZONS:
153155
raise AdvisoryValidationError(f"recommendations[{index}].primary_horizon is not allowed")
154156
_require_string(rec["primary_horizon_label"], f"recommendations[{index}].primary_horizon_label")
157+
_require_string(rec["primary_horizon_window"], f"recommendations[{index}].primary_horizon_window")
155158
_require_string(rec["horizon_note"], f"recommendations[{index}].horizon_note")
156159
horizons = _require_sequence(rec["suitable_horizons"], f"recommendations[{index}].suitable_horizons")
157160
if any(horizon not in ALLOWED_HORIZONS for horizon in horizons):
158161
raise AdvisoryValidationError(f"recommendations[{index}].suitable_horizons contains an unsupported horizon")
162+
horizon_windows = _require_mapping(
163+
rec["suitable_horizon_windows"], f"recommendations[{index}].suitable_horizon_windows"
164+
)
165+
for horizon in horizons:
166+
_require_string(
167+
horizon_windows.get(horizon), f"recommendations[{index}].suitable_horizon_windows.{horizon}"
168+
)
159169
if rec["strategy_style"] not in ALLOWED_STRATEGY_STYLES:
160170
raise AdvisoryValidationError(f"recommendations[{index}].strategy_style is not allowed")
161171
_require_number_0_1(rec["score"], f"recommendations[{index}].score")

src/quant_advisor_research/publisher.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ def render_report_html(report: dict[str, Any]) -> str:
5353
</header>
5454
<dl>
5555
<div><dt>Horizon</dt><dd>{html.escape(rec['primary_horizon_label'])}</dd></div>
56+
<div><dt>Window</dt><dd>{html.escape(rec['primary_horizon_window'])}</dd></div>
5657
<div><dt>Tier</dt><dd>{html.escape(rec['recommendation_tier_label'])}</dd></div>
5758
<div><dt>Source</dt><dd>{html.escape(rec['source_confidence_label'])}</dd></div>
5859
<div><dt>Style</dt><dd>{html.escape(rec['strategy_style'])}</dd></div>

tests/test_advisory_report.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,9 @@ def test_high_evidence_events_generate_recommendations_with_horizon() -> None:
7575
assert by_symbol["EVT1"]["recommendation_tier"] == "tier_1"
7676
assert by_symbol["EVT1"]["recommendation_tier_label"] == "一级推荐"
7777
assert by_symbol["EVT1"]["primary_horizon"] in {"short", "medium", "long"}
78-
assert "短线" in by_symbol["EVT1"]["horizon_note"]
78+
assert by_symbol["EVT1"]["primary_horizon_window"] == "2-12周"
79+
assert by_symbol["EVT1"]["suitable_horizon_windows"]["short"] == "1-10个交易日"
80+
assert "1-10个交易日" in by_symbol["EVT1"]["horizon_note"]
7981
assert by_symbol["EVT1"]["source_confidence"] == "medium"
8082
assert by_symbol["EVT1"]["reasons"]
8183

@@ -118,6 +120,22 @@ def test_mixed_confidence_recommendation_is_not_tier_one(tmp_path: Path) -> None
118120
assert rec["recommendation_tier"] == "tier_2"
119121

120122

123+
def test_long_horizon_window_is_measured_in_years() -> None:
124+
report = build_advisory_report(
125+
as_of="2026-05-30",
126+
cadence="weekly",
127+
political_events_path=ROOT / "examples/political_events.example.csv",
128+
political_watchlist_path=ROOT / "examples/political_watchlist.example.csv",
129+
ai_signal_path=ROOT / "examples/ai_long_horizon_signal.example.json",
130+
)
131+
132+
by_symbol = {item["symbol"]: item for item in report["recommendations"]}
133+
134+
assert by_symbol["IDX1"]["primary_horizon"] == "long"
135+
assert by_symbol["IDX1"]["primary_horizon_window"] == "1-3年"
136+
assert "超过3年" in by_symbol["IDX1"]["horizon_note"]
137+
138+
121139
def test_contract_rejects_execution_enabled_report() -> None:
122140
report = build_advisory_report(
123141
as_of="2026-05-30",

0 commit comments

Comments
 (0)