Skip to content

Commit b8fbd75

Browse files
Pigbibicodex
andcommitted
fix: separate source score from final decision tiers
Co-Authored-By: Codex <noreply@openai.com>
1 parent 0fdf582 commit b8fbd75

4 files changed

Lines changed: 97 additions & 16 deletions

File tree

docs/advisory_contract.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,7 @@ list while preserving audit details in JSON:
154154
```text
155155
recommendations[]
156156
watchlist[]
157+
overflow_recommendations[]
157158
horizon_buckets.short | medium | long
158159
horizon_rankings.short | medium | long
159160
horizon_action_buckets.short | medium | long
@@ -177,6 +178,17 @@ why_selected[]
177178
risk_summary
178179
```
179180

181+
Section semantics are strict: `recommendations[]` and
182+
`overflow_recommendations[]` contain only items with `action = "recommend"`;
183+
`watchlist[]` contains only items with `action = "watch"`.
184+
`overflow_recommendations[]` preserves valid recommendations outside the public
185+
top-N list without relabeling them as watchlist items.
186+
187+
For compatibility, `summary.recommendation_count` remains the base-layer
188+
`recommendations[]` count. The final layer separately reports
189+
`base_recommendation_count`, `final_recommendation_count`,
190+
`final_watchlist_count`, and `final_overflow_recommendation_count`.
191+
180192
Scoring and gate intent by horizon:
181193

182194
- short: recent market confirmation is required; event/news evidence and momentum

src/quant_advisor_research/advisory_report.py

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -886,18 +886,12 @@ def normalize_source_score(rec: dict[str, Any] | None) -> float:
886886
"unknown": 0.1,
887887
"no_event": 0.0,
888888
}
889-
tier_scores = {
890-
"tier_1": 1.0,
891-
"tier_2": 0.85,
892-
"watchlist": 0.6,
893-
"source_check": 0.25,
894-
"defer": 0.0,
895-
"monitor": 0.0,
896-
}
889+
if str(rec.get("source_confidence", "")) == "no_event":
890+
return 0.0
897891
evidence_component = clamp(as_float(rec.get("evidence_score")) / 18, 0, 1)
898892
confidence_component = confidence_scores.get(str(rec.get("source_confidence", "")), 0.0)
899893
blended = evidence_component * 0.65 + confidence_component * 0.35
900-
return round(max(blended, tier_scores.get(str(rec.get("recommendation_tier", "")), 0.0)), 3)
894+
return round(blended, 3)
901895

902896

903897
def theme_symbol_context(theme_momentum: dict[str, Any] | None) -> dict[str, dict[str, Any]]:
@@ -1328,10 +1322,13 @@ def build_final_decisions(
13281322
recommendation_candidates = [item for item in picks if item["action"] == "recommend"]
13291323
recommendations_out = recommendation_candidates[:max_recommendations]
13301324
recommendation_symbols = {item["symbol"] for item in recommendations_out}
1325+
overflow_recommendations = [
1326+
item for item in recommendation_candidates if item["symbol"] not in recommendation_symbols
1327+
]
13311328
watchlist_out = [
13321329
item
13331330
for item in picks
1334-
if item["action"] == "watch" or (item["action"] == "recommend" and item["symbol"] not in recommendation_symbols)
1331+
if item["action"] == "watch"
13351332
][:max_watchlist]
13361333
horizon_buckets = {
13371334
horizon: [item["symbol"] for item in recommendations_out if item.get("primary_horizon") == horizon]
@@ -1373,6 +1370,7 @@ def build_final_decisions(
13731370
"method": "Final recommendation blend for model scoring.",
13741371
"recommendations": recommendations_out,
13751372
"watchlist": watchlist_out,
1373+
"overflow_recommendations": overflow_recommendations,
13761374
"horizon_buckets": horizon_buckets,
13771375
"horizon_rankings": horizon_rankings,
13781376
"horizon_action_buckets": horizon_action_buckets,
@@ -1488,7 +1486,12 @@ def build_advisory_report(
14881486
"market_confirmation": str(market_confirmation_path) if market_confirmation_path else "",
14891487
},
14901488
"summary": {
1489+
# Keep recommendation_count as the historical base-layer count.
14911490
"recommendation_count": len(recommendations),
1491+
"base_recommendation_count": len(recommendations),
1492+
"final_recommendation_count": len(final_decisions["recommendations"]),
1493+
"final_watchlist_count": len(final_decisions["watchlist"]),
1494+
"final_overflow_recommendation_count": len(final_decisions["overflow_recommendations"]),
14921495
"candidate_universe_count": len(all_recommendations),
14931496
"source_event_count": len(events),
14941497
"ai_regime": ai_signal.get("regime", "not_available") if ai_signal else "not_available",

src/quant_advisor_research/contracts.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -198,7 +198,12 @@ def validate_advisory_report(payload: Mapping[str, Any]) -> None:
198198

199199
if "final_decisions" in payload:
200200
final_decisions = _require_mapping(payload["final_decisions"], "final_decisions")
201-
for section in ("recommendations", "watchlist"):
201+
section_actions = {
202+
"recommendations": "recommend",
203+
"watchlist": "watch",
204+
"overflow_recommendations": "recommend",
205+
}
206+
for section, expected_action in section_actions.items():
202207
for index, pick in enumerate(_require_sequence(final_decisions.get(section, []), f"final_decisions.{section}")):
203208
item = _require_mapping(pick, f"final_decisions.{section}[{index}]")
204209
account_keys = sorted(DISALLOWED_ACCOUNT_ACTION_KEYS & set(item))
@@ -207,6 +212,10 @@ def validate_advisory_report(payload: Mapping[str, Any]) -> None:
207212
f"final_decisions.{section}[{index}] contains account-action fields: {', '.join(account_keys)}"
208213
)
209214
_require_string(item.get("symbol"), f"final_decisions.{section}[{index}].symbol")
215+
if item.get("action") != expected_action:
216+
raise AdvisoryValidationError(
217+
f"final_decisions.{section}[{index}].action must be {expected_action}"
218+
)
210219
if "horizon_scores" in item:
211220
horizon_scores = _require_mapping(
212221
item["horizon_scores"], f"final_decisions.{section}[{index}].horizon_scores"

tests/test_advisory_report.py

Lines changed: 62 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,14 @@
55

66
import pytest
77

8-
from quant_advisor_research.advisory_report import build_advisory_report, primary_horizon_from_actions, render_markdown
8+
from quant_advisor_research.advisory_report import (
9+
build_advisory_report,
10+
build_final_decisions,
11+
load_theme_momentum,
12+
normalize_source_score,
13+
primary_horizon_from_actions,
14+
render_markdown,
15+
)
916
from quant_advisor_research.artifacts import write_report_manifest
1017
from quant_advisor_research.contracts import AdvisoryValidationError, validate_advisory_report
1118

@@ -307,10 +314,10 @@ def test_theme_bias_can_lift_static_watchlist_item_without_direct_symbol_bias(tm
307314
assert rec["rating"] == "watch"
308315
assert rec["evidence_score"] > 4
309316
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"]
317+
assert report["summary"]["long_context_available"] is False
318+
assert report["summary"]["long_context_missing_reason"] == "current_candidates_do_not_meet_long_context_gate"
319+
assert "MU" not in report["summary"]["long_context_symbols"]
320+
assert report["final_decisions"]["horizon_action_buckets"]["long"]["watch"] == []
314321

315322

316323
def test_theme_momentum_snapshot_is_display_context_not_rating_input(tmp_path: Path) -> None:
@@ -443,3 +450,53 @@ def test_contract_rejects_final_decision_account_action_fields() -> None:
443450

444451
with pytest.raises(AdvisoryValidationError):
445452
validate_advisory_report(report)
453+
454+
455+
def test_source_score_does_not_use_recommendation_tier_prior() -> None:
456+
assert normalize_source_score(
457+
{
458+
"evidence_score": 5,
459+
"source_confidence": "no_event",
460+
"recommendation_tier": "watchlist",
461+
}
462+
) == 0.0
463+
464+
465+
def test_final_decision_sections_keep_action_semantics_and_overflow() -> None:
466+
report = build_advisory_report(
467+
as_of="2026-05-30",
468+
cadence="weekly",
469+
political_events_path=ROOT / "examples/political_events.example.csv",
470+
political_watchlist_path=ROOT / "examples/political_watchlist.example.csv",
471+
ai_signal_path=ROOT / "examples/research_signal_context.example.json",
472+
theme_momentum_path=ROOT / "examples/theme_momentum_snapshot.example.json",
473+
)
474+
decisions = build_final_decisions(
475+
report["recommendations"],
476+
load_theme_momentum(ROOT / "examples/theme_momentum_snapshot.example.json"),
477+
max_recommendations=1,
478+
max_watchlist=1,
479+
)
480+
481+
assert all(item["action"] == "recommend" for item in decisions["recommendations"])
482+
assert all(item["action"] == "watch" for item in decisions["watchlist"])
483+
assert all(item["action"] == "recommend" for item in decisions["overflow_recommendations"])
484+
assert decisions["overflow_recommendations"]
485+
assert report["summary"]["recommendation_count"] == report["summary"]["base_recommendation_count"]
486+
assert report["summary"]["final_recommendation_count"] == len(report["final_decisions"]["recommendations"])
487+
assert report["summary"]["final_watchlist_count"] == len(report["final_decisions"]["watchlist"])
488+
489+
490+
def test_contract_rejects_final_decision_section_action_mismatch() -> None:
491+
report = build_advisory_report(
492+
as_of="2026-05-30",
493+
cadence="weekly",
494+
political_events_path=ROOT / "examples/political_events.example.csv",
495+
political_watchlist_path=ROOT / "examples/political_watchlist.example.csv",
496+
ai_signal_path=ROOT / "examples/research_signal_context.example.json",
497+
theme_momentum_path=ROOT / "examples/theme_momentum_snapshot.example.json",
498+
)
499+
report["final_decisions"]["watchlist"][0]["action"] = "recommend"
500+
501+
with pytest.raises(AdvisoryValidationError, match="watchlist.*action must be watch"):
502+
validate_advisory_report(report)

0 commit comments

Comments
 (0)