Skip to content

Commit b09bafc

Browse files
committed
Expose long-horizon context diagnostics
1 parent e45551e commit b09bafc

9 files changed

Lines changed: 206 additions & 17 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ Advisor is the final composition layer. Source ownership by horizon is:
4545
- Medium term (`2-12 weeks`): `theme_momentum_snapshot.json` from `ResearchSignalContextPipelines`, now explicitly marked as `medium_horizon_theme_context`, focused on theme momentum and symbol momentum.
4646
- Long term (`1-3 years`): `latest_signal.json` / `signal_history/*.json` from `ResearchSignalContextPipelines` as AI shadow context.
4747

48-
Final recommendations are still deterministic Intelligent Advisory outputs. The signal context repository does not directly produce short-term recommendations or replace the final decision engine. Advisor now records separate short/medium/long horizon scores and gates for each final pick; public pages keep the simpler final recommendation layout.
48+
Final recommendations are still deterministic Intelligent Advisory outputs. The signal context repository does not directly produce short-term recommendations or replace the final decision engine. Advisor records separate short/medium/long horizon scores and gates for each final pick, plus JSON diagnostics that explain whether long-horizon context was actually available. Public pages keep the simpler final recommendation layout and can show a compact long-context symbol strip when no pick's primary bucket is long.
4949

5050
## Boundary
5151

README.zh-CN.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ QuantStrategyLab 的“智慧投顾研究系统”协调仓库。它把事件证
3333
- 中线(2-12 周):`ResearchSignalContextPipelines``theme_momentum_snapshot.json`,现在明确标记为 `medium_horizon_theme_context`,重点看主题动量和个股动量。
3434
- 长线(1-3 年):`ResearchSignalContextPipelines``latest_signal.json` / `signal_history/*.json`,作为 AI shadow 背景。
3535

36-
最终研究结论仍由本仓库确定性合成。信号上下文仓库不直接输出短线推荐,也不替代本仓库的最终决策。本仓库会为最终推荐记录短/中/长线独立评分和独立门槛,但公开页面仍保持简洁,只展示最终列表、背景、理由和风险。
36+
最终研究结论仍由本仓库确定性合成。信号上下文仓库不直接输出短线推荐,也不替代本仓库的最终决策。本仓库会为最终推荐记录短/中/长线独立评分和独立门槛,并在 JSON 里记录长线背景是否可用。公开页面仍保持简洁,只展示最终列表、背景、理由和风险;如果没有标的以长线作为主周期,但已经通过长线观察门槛,页面会显示一个简洁的“长线观察”标的条
3737

3838
## 当前 MVP
3939

docs/advisory_contract.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,7 @@ recommendations[]
156156
watchlist[]
157157
horizon_buckets.short | medium | long
158158
horizon_rankings.short | medium | long
159+
horizon_action_buckets.short | medium | long
159160
```
160161

161162
Each final pick may carry:
@@ -187,6 +188,25 @@ Scoring and gate intent by horizon:
187188

188189
These fields are audit metadata. Public HTML/RSS/Telegram renderers still show
189190
only final recommendations, stock background, recommendation reasons, and risks.
191+
If the primary long bucket is empty but final picks still pass the long-horizon
192+
`watch` or `recommend` gate, the public HTML may show a compact long-context
193+
symbol strip instead of exposing internal scores.
194+
195+
## Long-context Diagnostics
196+
197+
`summary` includes long-context health fields so an empty long bucket can be
198+
debugged without reading renderer code:
199+
200+
```text
201+
long_context_available
202+
long_context_symbol_count
203+
long_context_symbols
204+
long_context_missing_reason
205+
```
206+
207+
If `long_context_available=false`, `long_context_missing_reason` should point to
208+
the first likely ingestion problem, such as missing AI shadow input, a non-long
209+
`latest_signal` horizon, or missing symbol/theme coverage.
190210

191211
## Artifact Manifest
192212

docs/system_design.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,8 @@ execution targets for this advisory pipeline.
6262
`QuantAdvisorResearch` records per-recommendation `supporting_context`,
6363
`horizon_scores`, and `horizon_actions` so each final recommendation can be
6464
traced back to short-, medium-, and long-horizon inputs and gates.
65+
The report summary also records long-context availability diagnostics. This
66+
keeps a genuinely weak long signal separate from an upstream ingestion gap.
6567

6668
## Design Patterns
6769

@@ -96,6 +98,8 @@ The public HTML/RSS/Telegram outputs should stay direct:
9698
and risks;
9799
- render public recommendations as long-, medium-, and short-horizon columns,
98100
sorted by each horizon score inside the column;
101+
- show a compact long-context symbol strip when the primary long bucket is empty
102+
but final picks still pass long-horizon watch/recommend gates;
99103
- hide internal tags such as `source_mode`, mode labels, audience labels, and
100104
repository names;
101105
- keep `theme_first_candidates[]`, `horizon_scores`, and `selection_trace` in

docs/system_design.zh-CN.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,13 +114,15 @@ symbol_theme_exposure
114114
- 长线(1-3 年):AI shadow 背景层负责,来自 `latest_signal.json``signal_history/*.json`
115115

116116
`QuantAdvisorResearch` 只在最后做确定性合成,报告中的 `supporting_context``horizon_scores``horizon_actions` 会记录每个最终推荐用到了短线、中线、长线哪些输入,以及每个周期是否达到推荐或观察门槛。
117+
报告 summary 还会记录长线背景是否可用,避免把“上游 artifact 缺字段”误读成“长线没有机会”。
117118

118119
## Theme momentum 展示边界
119120

120121
`QuantAdvisorResearch` 可以消费 `theme_momentum_snapshot.json`,用途分两层:
121122

122123
- 公开页面只展示最终推荐,不展示主题候选池;
123124
- 公开页面按长线 / 中线 / 短线三列展示,每列内按对应周期分数排序;
125+
- 如果长线主周期为空,但最终推荐已通过长线观察或推荐门槛,公开页面显示简洁的“长线观察”标的条;
124126
- JSON/Markdown 保留 `theme_first_candidates[]` 供审计;
125127
- `final_decisions` 可以把主题动量作为中线评分的重要输入;
126128
- 基础 `recommendations[]` 评级仍由事件、watchlist 和 AI 背景生成;

src/quant_advisor_research/advisory_report.py

Lines changed: 75 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1328,33 +1328,88 @@ def build_final_decisions(
13281328
horizon: [item["symbol"] for item in recommendations_out if item.get("primary_horizon") == horizon]
13291329
for horizon in ("short", "medium", "long")
13301330
}
1331+
ranked_by_horizon = {
1332+
horizon: sorted(
1333+
picks,
1334+
key=lambda candidate: (
1335+
-as_float(candidate.get("horizon_scores", {}).get(horizon, {}).get("score")),
1336+
str(candidate.get("symbol", "")),
1337+
),
1338+
)
1339+
for horizon in ("short", "medium", "long")
1340+
}
13311341
horizon_rankings = {
13321342
horizon: [
13331343
{
13341344
"symbol": item["symbol"],
13351345
"score": item["horizon_scores"][horizon]["score"],
13361346
"action": item["horizon_actions"][horizon],
13371347
}
1338-
for item in sorted(
1339-
picks,
1340-
key=lambda candidate: (
1341-
-as_float(candidate.get("horizon_scores", {}).get(horizon, {}).get("score")),
1342-
str(candidate.get("symbol", "")),
1343-
),
1344-
)[:max_recommendations]
1348+
for item in ranked_by_horizon[horizon][:max_recommendations]
13451349
]
13461350
for horizon in ("short", "medium", "long")
13471351
}
1352+
horizon_action_buckets = {
1353+
horizon: {
1354+
action: [
1355+
item["symbol"]
1356+
for item in ranked_by_horizon[horizon]
1357+
if item["horizon_actions"].get(horizon) == action
1358+
][:max_recommendations]
1359+
for action in ("recommend", "watch")
1360+
}
1361+
for horizon in ("short", "medium", "long")
1362+
}
13481363
return {
13491364
"method": "Final recommendation blend for model scoring.",
13501365
"recommendations": recommendations_out,
13511366
"watchlist": watchlist_out,
13521367
"horizon_buckets": horizon_buckets,
13531368
"horizon_rankings": horizon_rankings,
1369+
"horizon_action_buckets": horizon_action_buckets,
13541370
"position_policy": "No target shares, position size, portfolio weight, or account-specific allocation is provided.",
13551371
}
13561372

13571373

1374+
def long_context_symbols_from_decisions(final_decisions: dict[str, Any]) -> list[str]:
1375+
symbols: list[str] = []
1376+
action_buckets = final_decisions.get("horizon_action_buckets", {})
1377+
long_buckets = action_buckets.get("long", {}) if isinstance(action_buckets, dict) else {}
1378+
if isinstance(long_buckets, dict):
1379+
for action in ("recommend", "watch"):
1380+
for symbol in long_buckets.get(action, []):
1381+
symbol_text = str(symbol).upper()
1382+
if symbol_text and symbol_text not in symbols:
1383+
symbols.append(symbol_text)
1384+
if symbols:
1385+
return symbols
1386+
1387+
for section in ("recommendations", "watchlist"):
1388+
for item in final_decisions.get(section, []):
1389+
if not isinstance(item, dict):
1390+
continue
1391+
symbol_text = str(item.get("symbol", "")).upper()
1392+
action = str(item.get("horizon_actions", {}).get("long", ""))
1393+
has_long_context = action in {"recommend", "watch"} or as_float(item.get("long_context_score")) >= 0.35
1394+
if symbol_text and has_long_context:
1395+
if symbol_text not in symbols:
1396+
symbols.append(symbol_text)
1397+
return symbols
1398+
1399+
1400+
def infer_long_context_missing_reason(ai_signal: dict[str, Any] | None) -> str:
1401+
if not ai_signal:
1402+
return "ai_signal_not_available"
1403+
horizon_text = str(ai_signal.get("horizon", "")).strip()
1404+
if horizon_text not in {HORIZON_WINDOWS["long"], "1-3 years"}:
1405+
return "latest_signal_horizon_not_long"
1406+
if not ai_signal.get("symbol_bias") and not ai_signal.get("theme_bias"):
1407+
return "latest_signal_lacks_symbol_or_theme_bias"
1408+
if not ai_signal.get("symbol_bias") and not ai_signal.get("symbol_theme_exposure"):
1409+
return "latest_signal_lacks_symbol_theme_exposure"
1410+
return "current_candidates_do_not_meet_long_context_gate"
1411+
1412+
13581413
def build_advisory_report(
13591414
*,
13601415
as_of: str,
@@ -1393,7 +1448,7 @@ def build_advisory_report(
13931448
for key in ("symbol_bias", "research_bias", "candidate_bias"):
13941449
symbols |= {symbol.upper() for symbol in normalize_ai_mapping(ai_signal.get(key) or {})}
13951450

1396-
recommendations = [
1451+
all_recommendations = [
13971452
build_recommendation(
13981453
symbol=symbol,
13991454
item=watchlist.get(symbol),
@@ -1403,10 +1458,11 @@ def build_advisory_report(
14031458
)
14041459
for symbol in sorted(symbols)
14051460
]
1406-
recommendations.sort(key=lambda rec: (-rec["evidence_score"], rec["risk_score"], rec["symbol"]))
1407-
recommendations = recommendations[:max_candidates]
1408-
theme_first_candidates = build_theme_first_candidates(theme_momentum, recommendations)
1409-
final_decisions = build_final_decisions(recommendations, theme_momentum, market_confirmations)
1461+
all_recommendations.sort(key=lambda rec: (-rec["evidence_score"], rec["risk_score"], rec["symbol"]))
1462+
recommendations = all_recommendations[:max_candidates]
1463+
theme_first_candidates = build_theme_first_candidates(theme_momentum, all_recommendations)
1464+
final_decisions = build_final_decisions(all_recommendations, theme_momentum, market_confirmations)
1465+
long_context_symbols = long_context_symbols_from_decisions(final_decisions)
14101466

14111467
report = {
14121468
"schema_version": "5",
@@ -1424,6 +1480,7 @@ def build_advisory_report(
14241480
},
14251481
"summary": {
14261482
"recommendation_count": len(recommendations),
1483+
"candidate_universe_count": len(all_recommendations),
14271484
"source_event_count": len(events),
14281485
"ai_regime": ai_signal.get("regime", "not_available") if ai_signal else "not_available",
14291486
"ai_confidence": ai_signal.get("confidence", 0.0) if ai_signal else 0.0,
@@ -1436,6 +1493,12 @@ def build_advisory_report(
14361493
"top_theme_ids": [theme["theme_id"] for theme in theme_momentum_summary["top_themes"]],
14371494
"theme_first_candidate_count": len(theme_first_candidates),
14381495
"market_confirmation_count": len(market_confirmations),
1496+
"long_context_available": bool(long_context_symbols),
1497+
"long_context_symbol_count": len(long_context_symbols),
1498+
"long_context_symbols": long_context_symbols[:12],
1499+
"long_context_missing_reason": ""
1500+
if long_context_symbols
1501+
else infer_long_context_missing_reason(ai_signal),
14391502
"top_theme_candidate_symbols": [item["symbol"] for item in theme_first_candidates[:8]],
14401503
"top_recommended_symbols": [item["symbol"] for item in final_decisions["recommendations"][:5]],
14411504
"review_note": "Intelligent advisory research output. No order, target quantity, account suitability, or portfolio allocation is encoded.",

src/quant_advisor_research/publisher.py

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,30 @@ def ranked_horizon_picks(final_picks: list[dict[str, Any]], horizon: str) -> lis
131131
return sorted(picks, key=lambda pick: (-horizon_pick_score(pick, horizon), str(pick.get("symbol", ""))))
132132

133133

134+
def ranked_secondary_horizon_picks(final_picks: list[dict[str, Any]], horizon: str) -> list[dict[str, Any]]:
135+
picks = [
136+
pick
137+
for pick in final_picks
138+
if pick.get("primary_horizon") != horizon
139+
and pick.get("horizon_actions", {}).get(horizon) in {"recommend", "watch"}
140+
]
141+
return sorted(picks, key=lambda pick: (-horizon_pick_score(pick, horizon), str(pick.get("symbol", ""))))
142+
143+
144+
def render_horizon_context_strip(picks: list[dict[str, Any]]) -> str:
145+
if not picks:
146+
return ""
147+
symbols = "".join(
148+
f'<span class="context-symbol">{html.escape(str(pick.get("symbol", "")))}</span>' for pick in picks[:5]
149+
)
150+
return f"""
151+
<div class="context-strip">
152+
<p>长线观察</p>
153+
<div class="context-symbols">{symbols}</div>
154+
</div>
155+
"""
156+
157+
134158
def render_final_card(pick: dict[str, Any], *, rank: int) -> str:
135159
reasons = "".join(f"<li>{html.escape(str(reason))}</li>" for reason in pick.get("why_selected", []))
136160
return f"""
@@ -166,7 +190,11 @@ def render_final_decisions_html(report: dict[str, Any]) -> str:
166190
render_final_card(pick, rank=index)
167191
for index, pick in enumerate(ranked_horizon_picks(final_picks, horizon), start=1)
168192
]
169-
body = "".join(cards) if cards else '<p class="empty-column">暂无</p>'
193+
body = "".join(cards)
194+
if not body and horizon == "long":
195+
body = render_horizon_context_strip(ranked_secondary_horizon_picks(final_picks, horizon))
196+
if not body:
197+
body = '<p class="empty-column">暂无</p>'
170198
columns.append(
171199
f"""
172200
<section class="horizon-column horizon-{html.escape(horizon)}">
@@ -416,6 +444,10 @@ def render_report_html(report: dict[str, Any]) -> str:
416444
.horizon-column-header p {{ margin: 5px 0 0; color: var(--muted); font-size: .88rem; }}
417445
.horizon-cards {{ display: grid; gap: 13px; }}
418446
.empty-column {{ margin: 24px 0; color: var(--muted); text-align: center; }}
447+
.context-strip {{ border: 1px dashed rgba(15,139,98,.32); border-radius: 20px; padding: 16px; background: rgba(240,253,244,.74); }}
448+
.context-strip p {{ margin: 0 0 10px; color: var(--green); font-size: .92rem; font-weight: 850; }}
449+
.context-symbols {{ display: flex; flex-wrap: wrap; gap: 8px; }}
450+
.context-symbol {{ display: inline-flex; align-items: center; min-height: 30px; padding: 6px 10px; border-radius: 999px; background: #fff; border: 1px solid rgba(15,139,98,.22); color: var(--ink); font-size: .92rem; font-weight: 850; }}
419451
.final-card {{
420452
background: rgba(255,255,255,.9);
421453
border: 1px solid rgba(217,226,239,.95);
@@ -486,11 +518,32 @@ def render_report_html(report: dict[str, Any]) -> str:
486518
"""
487519

488520

489-
def format_horizon_summary(report: dict[str, Any]) -> list[tuple[str, str, str, list[str]]]:
521+
def horizon_summary_symbols(report: dict[str, Any], horizon: str) -> list[str]:
490522
decisions = report.get("final_decisions", {})
491523
buckets = decisions.get("horizon_buckets", {}) if isinstance(decisions, dict) else {}
524+
primary_symbols = [str(symbol) for symbol in buckets.get(horizon, [])]
525+
if primary_symbols:
526+
return primary_symbols
527+
528+
final_picks = [pick for pick in decisions.get("recommendations", []) if isinstance(pick, dict)]
529+
secondary_picks = [
530+
pick
531+
for pick in final_picks
532+
if pick.get("horizon_actions", {}).get(horizon) in {"recommend", "watch"}
533+
]
534+
return [
535+
str(pick.get("symbol", ""))
536+
for pick in sorted(
537+
secondary_picks,
538+
key=lambda item: (-horizon_pick_score(item, horizon), str(item.get("symbol", ""))),
539+
)
540+
if str(pick.get("symbol", ""))
541+
]
542+
543+
544+
def format_horizon_summary(report: dict[str, Any]) -> list[tuple[str, str, str, list[str]]]:
492545
return [
493-
(horizon, label, window, [str(symbol) for symbol in buckets.get(horizon, [])])
546+
(horizon, label, window, horizon_summary_symbols(report, horizon))
494547
for horizon, label, window in HORIZON_COLUMNS
495548
]
496549

tests/test_advisory_report.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,10 @@ def test_theme_bias_can_lift_static_watchlist_item_without_direct_symbol_bias(tm
307307
assert rec["rating"] == "watch"
308308
assert rec["evidence_score"] > 4
309309
assert any("主题=hbm_memory" in reason for reason in rec["reasons"])
310+
assert report["summary"]["long_context_available"] is True
311+
assert report["summary"]["long_context_missing_reason"] == ""
312+
assert "MU" in report["summary"]["long_context_symbols"]
313+
assert report["final_decisions"]["horizon_action_buckets"]["long"]["watch"][:1] == ["MU"]
310314

311315

312316
def test_theme_momentum_snapshot_is_display_context_not_rating_input(tmp_path: Path) -> None:

tests/test_publisher.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,11 @@ def pick(symbol: str, horizon: str, score: float) -> dict:
150150
"medium": {"score": score if horizon == "medium" else 0.0},
151151
"short": {"score": score if horizon == "short" else 0.0},
152152
},
153+
"horizon_actions": {
154+
"long": "recommend" if horizon == "long" else "skip",
155+
"medium": "recommend" if horizon == "medium" else "skip",
156+
"short": "recommend" if horizon == "short" else "skip",
157+
},
153158
"business_summary": f"{symbol} background",
154159
"prospect_summary": f"{symbol} reason",
155160
"why_selected": ["ranked"],
@@ -176,6 +181,44 @@ def pick(symbol: str, horizon: str, score: float) -> dict:
176181
assert "最终推荐" not in html
177182

178183

184+
def test_render_report_html_shows_long_context_when_primary_bucket_is_medium() -> None:
185+
report = build_sample_report()
186+
report["final_decisions"]["recommendations"] = [
187+
{
188+
"symbol": "MU",
189+
"name": "Micron Technology",
190+
"primary_horizon": "medium",
191+
"primary_horizon_label": "中线",
192+
"primary_horizon_window": "2-12周",
193+
"combined_score": 0.9,
194+
"source_score": 0.1,
195+
"momentum_score": 0.9,
196+
"medium_context_score": 0.9,
197+
"long_context_score": 0.62,
198+
"horizon_scores": {
199+
"long": {"score": 0.66},
200+
"medium": {"score": 0.9},
201+
"short": {"score": 0.1},
202+
},
203+
"horizon_actions": {
204+
"long": "watch",
205+
"medium": "recommend",
206+
"short": "skip",
207+
},
208+
"business_summary": "MU background",
209+
"prospect_summary": "MU reason",
210+
"why_selected": ["ranked"],
211+
"risk_summary": "risk",
212+
}
213+
]
214+
215+
html = render_report_html(report)
216+
217+
assert "长线观察" in html
218+
assert '<span class="context-symbol">MU</span>' in html
219+
assert "最终推荐" not in html
220+
221+
179222
def test_render_report_html_includes_theme_momentum_context() -> None:
180223
report = build_advisory_report(
181224
as_of="2026-05-30",

0 commit comments

Comments
 (0)