Skip to content

Commit 4ea998b

Browse files
committed
Track horizon-specific recommendation context
1 parent 2b29919 commit 4ea998b

9 files changed

Lines changed: 125 additions & 15 deletions

README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,17 @@ Live site:
2929

3030
<https://quantstrategylab.github.io/QuantAdvisorResearch/>
3131

32+
33+
## Horizon Source Split
34+
35+
Advisor is the final composition layer. Source ownership by horizon is:
36+
37+
- Short term (`1-10 trading days`): `source_events.csv` / `political_events.csv` from `PoliticalEventTrackingResearch` for event and policy/news catalysts.
38+
- Medium term (`2-12 weeks`): `theme_momentum_snapshot.json` from `AiLongHorizonSignalPipelines`, now explicitly marked as `medium_horizon_theme_context`.
39+
- Long term (`1-3 years`): `latest_signal.json` / `signal_history/*.json` from `AiLongHorizonSignalPipelines` as AI shadow context.
40+
41+
Final recommendations are still deterministic Advisor outputs. The AI repository does not directly produce short-term recommendations or replace the final decision engine.
42+
3243
## Boundary
3344

3445
This repository owns:

README.zh-CN.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,17 @@ QuantStrategyLab 的“智慧顾投”研究协调仓库。它生成非个性化
1717
- 其他量化策略/快照仓库:保持独立,不作为当前推荐链路的直接输入。
1818
- 各券商平台仓库:继续只负责执行链路;本仓库不调用它们。
1919

20+
21+
## 短中长线来源分工
22+
23+
Advisor 是最终合成层,三个周期的输入分工如下:
24+
25+
- 短线(1-10 个交易日):`PoliticalEventTrackingResearch``source_events.csv` / `political_events.csv`,用于事件和新闻政策催化。
26+
- 中线(2-12 周):`AiLongHorizonSignalPipelines``theme_momentum_snapshot.json`,现在明确标记为 `medium_horizon_theme_context`
27+
- 长线(1-3 年):`AiLongHorizonSignalPipelines``latest_signal.json` / `signal_history/*.json`,作为 AI shadow 背景。
28+
29+
最终推荐仍由本仓库确定性合成。AI 仓库不直接输出短线推荐,也不替代本仓库的最终决策。
30+
2031
## 当前 MVP
2132

2233
当前实现一个确定性报告生成器:

docs/system_design.zh-CN.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,15 @@ symbol_theme_exposure
104104

105105
但使用边界保持不变:主题 bias 只能作为研究背景和轻量评分输入,不能绕过事件证据、来源质量、风险提示和非个性化/不执行的合约约束。这样可以避免因为近期热点临时修改 universe 或权重,从而降低类似量化回测过拟合的问题。
106106

107+
108+
## 短中长线来源分工
109+
110+
- 短线(1-10 个交易日):事件事实层负责,主要来自 `PoliticalEventTrackingResearch` 的官方来源、新闻政策和事件新鲜度。
111+
- 中线(2-12 周):主题上下文层负责,`AiLongHorizonSignalPipelines``theme_momentum_snapshot.json` 标记为 `medium_horizon_theme_context`
112+
- 长线(1-3 年):AI shadow 背景层负责,来自 `latest_signal.json``signal_history/*.json`
113+
114+
`QuantAdvisorResearch` 只在最后做确定性合成,报告中的 `supporting_context` 会记录每个最终推荐用到了短线、中线、长线哪些输入。
115+
107116
## Theme momentum 展示边界
108117

109118
`QuantAdvisorResearch` 可以消费 `theme_momentum_snapshot.json`,但只用于报告展示:

examples/ai_long_horizon_signal.example.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"as_of": "2026-05-28",
44
"generated_at": "2026-05-28T14:38:47.474891Z",
55
"mode": "shadow",
6-
"horizon": "1-3 months",
6+
"horizon": "1-3 years",
77
"universe": [
88
"IDX1",
99
"IDX2",

examples/theme_momentum_snapshot.example.json

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@
33
"as_of": "2026-05-30",
44
"generated_at": "2026-05-30T00:00:00Z",
55
"mode": "theme_momentum_snapshot",
6+
"artifact_type": "medium_horizon_theme_context",
7+
"horizon": "medium",
8+
"horizon_window": "2-12 weeks",
9+
"horizon_window_label": "2-12周",
610
"taxonomy_version": "2026-05-31-core-themes-v1",
711
"methodology": {
812
"windows": {
@@ -56,13 +60,20 @@
5660
}
5761
],
5862
"data_quality": {
63+
"coverage": {
64+
"configured_symbol_count": 4,
65+
"priced_symbol_count": 4,
66+
"price_coverage_ratio": 1.0,
67+
"insufficient_history_symbol_count": 0
68+
},
5969
"missing_price_symbols": [],
70+
"insufficient_history_symbols": [],
6071
"unranked_themes": []
6172
},
6273
"policy": {
6374
"execution_allowed": false,
6475
"portfolio_allocation_allowed": false,
6576
"theme_rank_is_research_context_only": true,
66-
"downstream_use": "Theme momentum snapshot for research ranking and replay only; do not route to broker execution."
77+
"downstream_use": "Medium-horizon theme context for research ranking and replay only; do not route to broker execution."
6778
}
6879
}

src/quant_advisor_research/advisory_report.py

Lines changed: 64 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@
5151
"long": "1-3年",
5252
"not_applicable": "不适用",
5353
}
54+
THEME_MOMENTUM_ARTIFACT_TYPE = "medium_horizon_theme_context"
5455

5556
CADENCE_LABELS_ZH = {
5657
"daily": "日度",
@@ -284,12 +285,17 @@ def summarize_theme_momentum(payload: dict[str, Any] | None, *, max_themes: int
284285
return {
285286
"available": True,
286287
"as_of": payload.get("as_of", ""),
288+
"artifact_type": payload.get("artifact_type", THEME_MOMENTUM_ARTIFACT_TYPE),
289+
"horizon": payload.get("horizon", "medium"),
290+
"horizon_window": payload.get("horizon_window", "2-12 weeks"),
291+
"horizon_window_label": payload.get("horizon_window_label", HORIZON_WINDOWS["medium"]),
287292
"taxonomy_version": payload.get("taxonomy_version", ""),
288293
"top_themes": top_themes,
289294
"data_quality": payload.get("data_quality", {}),
290295
"policy": {
291296
"execution_allowed": False,
292297
"theme_rank_is_research_context_only": True,
298+
"direct_short_term_recommendation_allowed": False,
293299
},
294300
}
295301

@@ -728,6 +734,10 @@ def build_recommendation(
728734
]
729735
)
730736

737+
long_horizon_ai_score = 0.0
738+
if ai_bias in {"positive", "watch", "neutral"}:
739+
long_horizon_ai_score = round(clamp(ai_confidence, 0, 1), 3)
740+
731741
return {
732742
"symbol": symbol,
733743
"name": item.name if item else symbol,
@@ -752,6 +762,15 @@ def build_recommendation(
752762
"evidence_summary": " ".join(reasons),
753763
"evidence_refs": dedupe(evidence_refs),
754764
"review_checklist": dedupe(review_checklist),
765+
"ai_context": {
766+
"source": "latest_signal" if ai_signal else "",
767+
"horizon": "long" if ai_signal else "",
768+
"horizon_window": HORIZON_WINDOWS["long"] if ai_signal else "",
769+
"bias": ai_bias or "",
770+
"confidence": round(ai_confidence, 3),
771+
"theme_ids": ai_bias_source_themes,
772+
},
773+
"long_horizon_ai_score": long_horizon_ai_score,
755774
}
756775

757776

@@ -829,6 +848,7 @@ def theme_symbol_context(theme_momentum: dict[str, Any] | None) -> dict[str, dic
829848
"primary_theme_label": theme_label(theme_id, theme_name),
830849
"best_theme_rank": rank,
831850
"ai_signal_score": ai_signal_score,
851+
"medium_context_score": ai_signal_score,
832852
"symbol_momentum_score": symbol_momentum_score,
833853
"momentum_score": momentum_score,
834854
"return_3m": return_3m,
@@ -843,6 +863,7 @@ def theme_symbol_context(theme_momentum: dict[str, Any] | None) -> dict[str, dic
843863
current["best_theme_rank"] = rank
844864
if ai_signal_score > as_float(current.get("ai_signal_score")):
845865
current["ai_signal_score"] = ai_signal_score
866+
current["medium_context_score"] = ai_signal_score
846867
if momentum_score > as_float(current.get("momentum_score")):
847868
current["symbol_momentum_score"] = symbol_momentum_score
848869
current["momentum_score"] = momentum_score
@@ -881,6 +902,24 @@ def final_action_label(action: str) -> str:
881902
}.get(action, "跳过")
882903

883904

905+
def supporting_context_for(
906+
rec: dict[str, Any] | None,
907+
*,
908+
source_score: float,
909+
momentum_score: float,
910+
medium_context_score: float,
911+
long_context_score: float,
912+
) -> dict[str, list[str]]:
913+
context = {"short": [], "medium": [], "long": []}
914+
if source_score >= 0.35 and rec:
915+
context["short"].append("source_events")
916+
if momentum_score >= 0.35 or medium_context_score >= 0.35:
917+
context["medium"].append("theme_momentum_snapshot")
918+
if long_context_score >= 0.35 and rec:
919+
context["long"].append("latest_signal")
920+
return context
921+
922+
884923
def build_final_decisions(
885924
recommendations: list[dict[str, Any]],
886925
theme_momentum: dict[str, Any] | None,
@@ -898,10 +937,13 @@ def build_final_decisions(
898937
theme = theme_context.get(symbol, {})
899938
source_score = normalize_source_score(rec)
900939
momentum_score = as_float(theme.get("momentum_score"))
901-
ai_signal_score = as_float(theme.get("ai_signal_score"))
902-
support_count = sum(score >= 0.35 for score in (source_score, momentum_score, ai_signal_score))
903-
combined_score = round(source_score * 0.15 + momentum_score * 0.40 + ai_signal_score * 0.45, 3)
904-
action = final_action_for(rec, combined_score, support_count, momentum_score, ai_signal_score)
940+
medium_context_score = as_float(theme.get("medium_context_score", theme.get("ai_signal_score")))
941+
long_context_score = as_float(rec.get("long_horizon_ai_score")) if rec else 0.0
942+
support_count = sum(
943+
score >= 0.35 for score in (source_score, momentum_score, medium_context_score, long_context_score)
944+
)
945+
combined_score = round(source_score * 0.15 + momentum_score * 0.40 + medium_context_score * 0.45, 3)
946+
action = final_action_for(rec, combined_score, support_count, momentum_score, medium_context_score)
905947
if action == "skip":
906948
continue
907949
name = rec.get("name", symbol) if rec else symbol
@@ -915,9 +957,12 @@ def build_final_decisions(
915957
reasons.append(
916958
f"动量:个股动量分数={display_number(theme.get('symbol_momentum_score'))},近3个月={display_percent(theme.get('return_3m'))}。"
917959
)
918-
if ai_signal_score >= 0.35:
960+
if medium_context_score >= 0.35:
919961
labels = ", ".join(theme.get("theme_labels", [])[:3]) or str(theme.get("primary_theme_label", ""))
920-
reasons.append(f"AI信号仓库:{labels}。")
962+
reasons.append(f"中线主题上下文:{labels}。")
963+
if long_context_score >= 0.35 and rec:
964+
bias = rec.get("ai_context", {}).get("bias", "")
965+
reasons.append(f"长线AI背景:{bias or '已读取'}。")
921966
if not reasons:
922967
reasons.append("当前进入观察名单,但多源证据仍需继续补强。")
923968
if rec and rec.get("primary_horizon") != "not_applicable":
@@ -944,7 +989,16 @@ def build_final_decisions(
944989
"combined_score": combined_score,
945990
"source_score": round(source_score, 3),
946991
"momentum_score": round(momentum_score, 3),
947-
"ai_signal_score": round(ai_signal_score, 3),
992+
"ai_signal_score": round(medium_context_score, 3),
993+
"medium_context_score": round(medium_context_score, 3),
994+
"long_context_score": round(long_context_score, 3),
995+
"supporting_context": supporting_context_for(
996+
rec,
997+
source_score=source_score,
998+
momentum_score=momentum_score,
999+
medium_context_score=medium_context_score,
1000+
long_context_score=long_context_score,
1001+
),
9481002
"business_summary": profile["business"],
9491003
"prospect_summary": profile["prospect"],
9501004
"why_selected": dedupe(reasons),
@@ -1051,6 +1105,9 @@ def build_advisory_report(
10511105
"source_mode": source_mode,
10521106
"data_quality_warnings": data_quality_warnings,
10531107
"theme_momentum_available": theme_momentum_summary["available"],
1108+
"theme_momentum_artifact_type": theme_momentum_summary.get("artifact_type", ""),
1109+
"theme_momentum_horizon": theme_momentum_summary.get("horizon", ""),
1110+
"theme_momentum_horizon_window": theme_momentum_summary.get("horizon_window_label", ""),
10541111
"top_theme_ids": [theme["theme_id"] for theme in theme_momentum_summary["top_themes"]],
10551112
"theme_first_candidate_count": len(theme_first_candidates),
10561113
"top_theme_candidate_symbols": [item["symbol"] for item in theme_first_candidates[:8]],

src/quant_advisor_research/publisher.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ def render_final_decisions_html(report: dict[str, Any]) -> str:
100100
<div><dt>综合分</dt><dd>{html.escape(display_number(pick.get('combined_score')))}</dd></div>
101101
<div><dt>政策/新闻</dt><dd>{html.escape(display_number(pick.get('source_score')))}</dd></div>
102102
<div><dt>动量</dt><dd>{html.escape(display_number(pick.get('momentum_score')))}</dd></div>
103-
<div><dt>AI信号仓库</dt><dd>{html.escape(display_number(pick.get('ai_signal_score')))}</dd></div>
103+
<div><dt>中线主题</dt><dd>{html.escape(display_number(pick.get('medium_context_score', pick.get('ai_signal_score'))))}</dd></div>
104104
</dl>
105105
<p><strong>股票背景:</strong>{html.escape(str(pick.get('business_summary', '')))}</p>
106106
<p><strong>推荐理由:</strong>{html.escape(str(pick.get('prospect_summary', '')))}</p>

tests/test_advisory_report.py

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,8 @@ def test_render_markdown_keeps_public_report_direct() -> None:
178178
assert "受众:" not in markdown
179179
assert "AI 状态" not in markdown
180180
assert "本期最终结论" not in markdown
181+
assert "中线主题上下文" in markdown
182+
assert "AI信号仓库" not in markdown
181183
assert "AiLongHorizonSignalPipelines" not in markdown
182184

183185

@@ -340,6 +342,9 @@ def test_theme_momentum_snapshot_is_display_context_not_rating_input(tmp_path: P
340342
theme_momentum_path=theme_momentum_path,
341343
)
342344

345+
assert report_with_theme["summary"]["theme_momentum_artifact_type"] == "medium_horizon_theme_context"
346+
assert report_with_theme["summary"]["theme_momentum_horizon"] == "medium"
347+
assert report_with_theme["summary"]["theme_momentum_horizon_window"] == "2-12周"
343348
assert report_with_theme["summary"]["top_theme_ids"][:1] == ["hbm_memory"]
344349
assert report_with_theme["summary"]["top_theme_candidate_symbols"][:3] == ["MU", "NVDA", "DELL"]
345350
assert report_with_theme["theme_momentum"]["top_themes"][0]["theme_id"] == "hbm_memory"
@@ -349,10 +354,13 @@ def test_theme_momentum_snapshot_is_display_context_not_rating_input(tmp_path: P
349354
assert report_with_theme["theme_first_candidates"][0]["industry_background"]
350355
assert report_with_theme["theme_first_candidates"][0]["recommendation_summary"]
351356
assert report_with_theme["theme_first_candidates"][2]["symbol"] == "DELL"
352-
assert report_with_theme["final_decisions"]["recommendations"][0]["symbol"] == "MU"
353-
assert report_with_theme["final_decisions"]["recommendations"][0]["business_summary"]
354-
assert "存储周期" in report_with_theme["final_decisions"]["recommendations"][0]["risk_summary"]
355-
assert "ai_signal_score" in report_with_theme["final_decisions"]["recommendations"][0]
357+
first_pick = report_with_theme["final_decisions"]["recommendations"][0]
358+
assert first_pick["symbol"] == "MU"
359+
assert first_pick["business_summary"]
360+
assert "存储周期" in first_pick["risk_summary"]
361+
assert "ai_signal_score" in first_pick
362+
assert first_pick["medium_context_score"] == first_pick["ai_signal_score"]
363+
assert first_pick["supporting_context"]["medium"] == ["theme_momentum_snapshot"]
356364
assert "AiLongHorizonSignalPipelines" not in report_with_theme["final_decisions"]["method"]
357365
assert report_with_theme["final_decisions"]["watchlist"][0]["symbol"] == "DELL"
358366
assert report_with_theme["recommendations"][0]["rating"] == report_without_theme["recommendations"][0]["rating"]
@@ -362,6 +370,8 @@ def test_theme_momentum_snapshot_is_display_context_not_rating_input(tmp_path: P
362370
assert "## 本期最终结论" not in markdown
363371
assert "股票背景" in markdown
364372
assert "推荐理由" in markdown
373+
assert "中线主题上下文" in markdown
374+
assert "AI信号仓库" not in markdown
365375
assert "AiLongHorizonSignalPipelines" not in markdown
366376
assert "## 主题候选(解释材料,不是最终推荐)" not in markdown
367377
assert "为什么入选" not in markdown

tests/test_publisher.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,8 @@ def test_render_report_html_includes_theme_momentum_context() -> None:
123123
assert "<span class=\"pill\">模式:" not in html
124124
assert "受众:" not in html
125125
assert "AI 状态:" not in html
126-
assert "AI信号仓库" in html
126+
assert "中线主题" in html
127+
assert "AI信号仓库" not in html
127128
assert "AiLongHorizonSignalPipelines" not in html
128129
assert "股票背景" in html
129130
assert "推荐理由" in html

0 commit comments

Comments
 (0)