main
py 1,522 lines 64.3 KB
Raw
1 """Provider-neutral research readiness and targeted refresh planning contracts.
2
3 This module deliberately contains no provider clients and no portfolio access. A
4 readiness request starts with one canonical global instrument ID, reads a durable
5 snapshot, classifies every registered requirement, and returns a plan containing
6 only required facts that need work. Provider adapters remain behind the plan.
7 """
8 from __future__ import annotations
9
10 from dataclasses import dataclass, field, replace
11 from datetime import datetime, timedelta, timezone
12 from decimal import Decimal
13 from enum import StrEnum
14 from types import MappingProxyType
15 from typing import Any, Mapping, Protocol, Sequence
16 from uuid import UUID
17
18 from app.research_applicability import RequirementApplicability
19
20
21 class RuleEngineArea(StrEnum):
22 VALUATION = "VALUATION"
23 FUNDAMENTAL_BUSINESS_QUALITY = "FUNDAMENTAL_BUSINESS_QUALITY"
24 GROWTH = "GROWTH"
25 BALANCE_SHEET = "BALANCE_SHEET"
26 QUARTERLY_EARNINGS_TREND = "QUARTERLY_EARNINGS_TREND"
27 ORDER_BOOK_CAPACITY_CATALYSTS = "ORDER_BOOK_CAPACITY_CATALYSTS"
28 PRICE_TECHNICAL = "PRICE_TECHNICAL"
29 NEWS_GEOPOLITICAL_EVENTS = "NEWS_GEOPOLITICAL_EVENTS"
30 SHAREHOLDING = "SHAREHOLDING"
31 MANAGEMENT_GOVERNANCE = "MANAGEMENT_GOVERNANCE"
32 SECTOR_MACRO = "SECTOR_MACRO"
33
34
35 RULE_ENGINE_AREA_WEIGHTS = MappingProxyType(
36 {
37 RuleEngineArea.VALUATION: Decimal("0.18"),
38 RuleEngineArea.FUNDAMENTAL_BUSINESS_QUALITY: Decimal("0.16"),
39 RuleEngineArea.GROWTH: Decimal("0.14"),
40 RuleEngineArea.BALANCE_SHEET: Decimal("0.09"),
41 RuleEngineArea.QUARTERLY_EARNINGS_TREND: Decimal("0.09"),
42 RuleEngineArea.ORDER_BOOK_CAPACITY_CATALYSTS: Decimal("0.08"),
43 RuleEngineArea.PRICE_TECHNICAL: Decimal("0.07"),
44 RuleEngineArea.NEWS_GEOPOLITICAL_EVENTS: Decimal("0.07"),
45 RuleEngineArea.SHAREHOLDING: Decimal("0.04"),
46 RuleEngineArea.MANAGEMENT_GOVERNANCE: Decimal("0.05"),
47 RuleEngineArea.SECTOR_MACRO: Decimal("0.03"),
48 }
49 )
50
51
52 class ResearchRequirementStatus(StrEnum):
53 READY_FRESH = "READY_FRESH"
54 READY_STALE = "READY_STALE"
55 PARTIAL = "PARTIAL"
56 MISSING = "MISSING"
57 CONFLICTING = "CONFLICTING"
58 UNSUPPORTED = "UNSUPPORTED"
59 NOT_APPLICABLE = "NOT_APPLICABLE"
60 REFRESHING = "REFRESHING"
61 FAILED = "FAILED"
62
63
64 class ResearchSupportedAction(StrEnum):
65 FIND_DATA = "FIND_DATA"
66 UPLOAD_EVIDENCE = "UPLOAD_EVIDENCE"
67 RUN_PARTIAL_ANALYSIS = "RUN_PARTIAL_ANALYSIS"
68
69
70 class ResearchRequirementImportance(StrEnum):
71 """How strongly a concrete input contributes to data completeness."""
72
73 MANDATORY = "MANDATORY"
74 IMPORTANT = "IMPORTANT"
75 SUPPORTING = "SUPPORTING"
76
77
78 class FreshnessMode(StrEnum):
79 MARKET_SESSION_AWARE = "MARKET_SESSION_AWARE"
80 DAILY_INCREMENTAL = "DAILY_INCREMENTAL"
81 RELEASE_AWARE_QUARTERLY = "RELEASE_AWARE_QUARTERLY"
82 REPORTING_CALENDAR_AWARE_ANNUAL = "REPORTING_CALENDAR_AWARE_ANNUAL"
83 QUARTERLY = "QUARTERLY"
84 EVENT_DRIVEN_BOUNDED = "EVENT_DRIVEN_BOUNDED"
85 ROLLING_EVENT_WINDOW = "ROLLING_EVENT_WINDOW"
86 UNRESOLVED_HISTORY = "UNRESOLVED_HISTORY"
87 FACT_SPECIFIC_DAILY = "FACT_SPECIFIC_DAILY"
88
89
90 class FreshnessDateBasis(StrEnum):
91 AS_OF_OR_RETRIEVED = "AS_OF_OR_RETRIEVED"
92 EVENT_OR_PUBLICATION = "EVENT_OR_PUBLICATION"
93
94
95 class ResearchSourceTier(StrEnum):
96 OFFICIAL = "OFFICIAL"
97 REGULATORY = "REGULATORY"
98 TRUSTED_MARKET_DATA = "TRUSTED_MARKET_DATA"
99 LICENSED_STRUCTURED = "LICENSED_STRUCTURED"
100 APPROVED_SECONDARY = "APPROVED_SECONDARY"
101 APPROVED_EXTERNAL_TOOL = "APPROVED_EXTERNAL_TOOL"
102 USER_UPLOAD = "USER_UPLOAD"
103 UNVERIFIED = "UNVERIFIED"
104
105
106 _SOURCE_TIER_AUTHORITY = {
107 ResearchSourceTier.OFFICIAL: 0,
108 ResearchSourceTier.REGULATORY: 1,
109 ResearchSourceTier.TRUSTED_MARKET_DATA: 2,
110 ResearchSourceTier.LICENSED_STRUCTURED: 3,
111 ResearchSourceTier.APPROVED_SECONDARY: 4,
112 ResearchSourceTier.APPROVED_EXTERNAL_TOOL: 5,
113 ResearchSourceTier.USER_UPLOAD: 6,
114 ResearchSourceTier.UNVERIFIED: 7,
115 }
116
117
118 @dataclass(frozen=True)
119 class ResearchRequirementInput:
120 input_id: str
121 importance: ResearchRequirementImportance
122 description: str = ""
123
124 def __post_init__(self) -> None:
125 object.__setattr__(self, "input_id", _normalized_key(self.input_id, "input_id"))
126 if not isinstance(self.importance, ResearchRequirementImportance):
127 object.__setattr__(
128 self, "importance", ResearchRequirementImportance(self.importance)
129 )
130
131
132 @dataclass(frozen=True)
133 class ResearchRequirement:
134 requirement_id: str
135 rule_engine_area: RuleEngineArea
136 mandatory: bool
137 freshness_policy_id: str
138 minimum_evidence_count: int = 1
139 supported_actions: tuple[ResearchSupportedAction, ...] = (
140 ResearchSupportedAction.FIND_DATA,
141 ResearchSupportedAction.UPLOAD_EVIDENCE,
142 ResearchSupportedAction.RUN_PARTIAL_ANALYSIS,
143 )
144 importance: ResearchRequirementImportance | None = None
145 inputs: tuple[ResearchRequirementInput, ...] = ()
146
147 def __post_init__(self) -> None:
148 requirement_id = _normalized_key(self.requirement_id, "requirement_id")
149 policy_id = _normalized_key(self.freshness_policy_id, "freshness_policy_id")
150 if self.minimum_evidence_count < 1:
151 raise ValueError("minimum_evidence_count must be positive")
152 object.__setattr__(self, "requirement_id", requirement_id)
153 object.__setattr__(self, "freshness_policy_id", policy_id)
154 object.__setattr__(self, "supported_actions", tuple(self.supported_actions))
155 importance = self.importance or (
156 ResearchRequirementImportance.MANDATORY
157 if self.mandatory
158 else ResearchRequirementImportance.IMPORTANT
159 )
160 if not isinstance(importance, ResearchRequirementImportance):
161 importance = ResearchRequirementImportance(importance)
162 inputs = tuple(self.inputs)
163 input_ids = [item.input_id for item in inputs]
164 if len(input_ids) != len(set(input_ids)):
165 raise ValueError(f"Concrete input IDs must be unique for {requirement_id}")
166 object.__setattr__(self, "importance", importance)
167 object.__setattr__(self, "inputs", inputs)
168
169
170 class ResearchRequirementRegistry:
171 """Immutable registry of data requirements used by deterministic rules."""
172
173 def __init__(
174 self,
175 requirements: Sequence[ResearchRequirement],
176 area_weights: Mapping[RuleEngineArea, Decimal] = RULE_ENGINE_AREA_WEIGHTS,
177 ) -> None:
178 by_id = {requirement.requirement_id: requirement for requirement in requirements}
179 if len(by_id) != len(requirements):
180 raise ValueError("Research requirement IDs must be unique")
181 if set(area_weights) != set(RuleEngineArea):
182 raise ValueError("Every Rule Engine area must have exactly one configured weight")
183 if sum(area_weights.values(), Decimal("0")) != Decimal("1.00"):
184 raise ValueError("Rule Engine area weights must total 1.00")
185 missing_areas = set(RuleEngineArea) - {item.rule_engine_area for item in requirements}
186 if missing_areas:
187 raise ValueError(f"Requirements are missing Rule Engine areas: {sorted(missing_areas)}")
188 self._requirements = tuple(requirements)
189 self._by_id = MappingProxyType(by_id)
190 self._area_weights = MappingProxyType(dict(area_weights))
191
192 @property
193 def requirements(self) -> tuple[ResearchRequirement, ...]:
194 return self._requirements
195
196 @property
197 def area_weights(self) -> Mapping[RuleEngineArea, Decimal]:
198 return self._area_weights
199
200 def get(self, requirement_id: str) -> ResearchRequirement:
201 return self._by_id[_normalized_key(requirement_id, "requirement_id")]
202
203 def for_area(self, area: RuleEngineArea) -> tuple[ResearchRequirement, ...]:
204 return tuple(item for item in self._requirements if item.rule_engine_area == area)
205
206 @classmethod
207 def default(cls) -> "ResearchRequirementRegistry":
208 mandatory = ResearchRequirementImportance.MANDATORY
209 important = ResearchRequirementImportance.IMPORTANT
210 supporting = ResearchRequirementImportance.SUPPORTING
211 input_ = ResearchRequirementInput
212 return cls(
213 (
214 ResearchRequirement(
215 "VALUATION_INPUTS",
216 RuleEngineArea.VALUATION,
217 True,
218 "VALUATION_INPUTS",
219 inputs=(
220 input_("LATEST_USABLE_PRICE", mandatory),
221 input_("EARNINGS_BASIS", mandatory),
222 input_("PE", important),
223 input_("PB", important),
224 input_("EV_EBITDA", supporting),
225 input_("FCF_YIELD", supporting),
226 input_("HISTORICAL_OR_PEER_VALUATION", supporting),
227 ),
228 ),
229 ResearchRequirement(
230 "BUSINESS_QUALITY_FACTS",
231 RuleEngineArea.FUNDAMENTAL_BUSINESS_QUALITY,
232 True,
233 "ANNUAL_FINANCIALS",
234 inputs=(
235 input_("PROFITABILITY_HISTORY", mandatory),
236 input_("ROE", important),
237 input_("ROCE", important),
238 input_("MARGINS", important),
239 input_("CASH_CONVERSION_OR_FCF_QUALITY", important),
240 ),
241 ),
242 ResearchRequirement(
243 "GROWTH_FACTS",
244 RuleEngineArea.GROWTH,
245 True,
246 "QUARTERLY_FINANCIALS",
247 inputs=(
248 input_("REVENUE_HISTORY", mandatory),
249 input_("EARNINGS_HISTORY", mandatory),
250 input_("QUARTERLY_YOY_QOQ_TRENDS", important),
251 input_("ANNUAL_CAGR_INPUTS", important),
252 ),
253 ),
254 ResearchRequirement(
255 "BALANCE_SHEET_FACTS",
256 RuleEngineArea.BALANCE_SHEET,
257 True,
258 "QUARTERLY_FINANCIALS",
259 inputs=(
260 input_("DEBT", mandatory),
261 input_("EQUITY", mandatory),
262 input_("CASH", important),
263 input_("INTEREST_COVERAGE_INPUTS", supporting),
264 input_("LIQUIDITY_CURRENT_RATIO_INPUTS", supporting),
265 ),
266 ),
267 ResearchRequirement(
268 "QUARTERLY_FINANCIALS",
269 RuleEngineArea.QUARTERLY_EARNINGS_TREND,
270 True,
271 "QUARTERLY_FINANCIALS",
272 inputs=(
273 input_("LATEST_QUARTERLY_RESULT", mandatory),
274 input_("COMPARABLE_QUARTERS", mandatory),
275 input_("QUARTERLY_REVENUE", mandatory),
276 input_("QUARTERLY_PAT", mandatory),
277 input_("QUARTERLY_EBITDA_OR_OPERATING_PROFIT", important),
278 input_("QUARTERLY_EPS", important),
279 input_("QUARTERLY_MARGINS", important),
280 ),
281 ),
282 ResearchRequirement(
283 "ORDER_BOOK_CAPEX_GUIDANCE",
284 RuleEngineArea.ORDER_BOOK_CAPACITY_CATALYSTS,
285 False,
286 "ORDER_BOOK_CAPEX_GUIDANCE",
287 importance=important,
288 inputs=(
289 input_("MATERIAL_CATALYST_EVIDENCE", mandatory),
290 input_("ORDER_BOOK_OR_MAJOR_CONTRACT", important),
291 input_("CAPACITY_OR_CAPEX_OR_COMMISSIONING", important),
292 input_("MANAGEMENT_GUIDANCE", supporting),
293 ),
294 ),
295 ResearchRequirement(
296 "LATEST_PRICE",
297 RuleEngineArea.PRICE_TECHNICAL,
298 True,
299 "LATEST_PRICE",
300 inputs=(input_("LATEST_USABLE_PRICE", mandatory),),
301 ),
302 ResearchRequirement(
303 "HISTORICAL_PRICE_SERIES",
304 RuleEngineArea.PRICE_TECHNICAL,
305 True,
306 "HISTORICAL_PRICE_SERIES",
307 inputs=(
308 input_("DURABLE_PRICE_OBSERVATIONS", mandatory),
309 input_("FIFTY_OBSERVATION_TECHNICAL_BASIS", important),
310 input_("ONE_HUNDRED_FIFTY_OBSERVATION_TECHNICAL_BASIS", supporting),
311 ),
312 ),
313 ResearchRequirement(
314 "CURRENT_NEWS",
315 RuleEngineArea.NEWS_GEOPOLITICAL_EVENTS,
316 False,
317 "CURRENT_NEWS",
318 inputs=(input_("RELEVANT_CURRENT_EVENT_EVIDENCE", mandatory),),
319 ),
320 ResearchRequirement(
321 "SHAREHOLDING",
322 RuleEngineArea.SHAREHOLDING,
323 False,
324 "SHAREHOLDING",
325 importance=important,
326 inputs=(
327 input_("LATEST_VALID_SHAREHOLDING_PERIOD", mandatory),
328 input_("PROMOTER_INSTITUTIONAL_PUBLIC_CATEGORIES", important),
329 input_("PROMOTER_PLEDGE", supporting),
330 ),
331 ),
332 ResearchRequirement(
333 "GOVERNANCE_HISTORY",
334 RuleEngineArea.MANAGEMENT_GOVERNANCE,
335 True,
336 "GOVERNANCE_HISTORY",
337 inputs=(input_("GOVERNANCE_EVIDENCE", mandatory),),
338 ),
339 ResearchRequirement(
340 "SECTOR_MACRO",
341 RuleEngineArea.SECTOR_MACRO,
342 True,
343 "SECTOR_MACRO",
344 inputs=(
345 input_("CANONICAL_SECTOR", mandatory),
346 input_("SECTOR_PERFORMANCE", important),
347 input_("RELEVANT_MACRO_EVENT_EXPOSURE", supporting),
348 ),
349 ),
350 )
351 )
352
353
354 @dataclass(frozen=True)
355 class ResearchEvidence:
356 evidence_id: str
357 requirement_id: str
358 source: str
359 source_tier: ResearchSourceTier
360 retrieved_at: datetime
361 as_of: datetime | None = None
362 published_at: datetime | None = None
363 event_date: datetime | None = None
364 valid_until: datetime | None = None
365 fact_key: str | None = None
366 value_fingerprint: str | None = None
367 complete: bool = True
368 confidence: float | None = None
369 unresolved: bool = False
370 source_url: str | None = None
371 covered_input_ids: tuple[str, ...] = ()
372
373 def __post_init__(self) -> None:
374 evidence_id = str(self.evidence_id).strip()
375 if not evidence_id:
376 raise ValueError("evidence_id is required")
377 source = str(self.source).strip()
378 if not source:
379 raise ValueError("source is required")
380 if self.confidence is not None and not 0 <= self.confidence <= 1:
381 raise ValueError("confidence must be between 0 and 1")
382 for name in ("retrieved_at", "as_of", "published_at", "event_date", "valid_until"):
383 value = getattr(self, name)
384 if value is not None:
385 _require_aware(value, name)
386 object.__setattr__(self, "evidence_id", evidence_id)
387 object.__setattr__(self, "requirement_id", _normalized_key(self.requirement_id, "requirement_id"))
388 object.__setattr__(self, "source", source.upper())
389 object.__setattr__(
390 self,
391 "covered_input_ids",
392 tuple(_normalized_key(value, "covered_input_id") for value in self.covered_input_ids),
393 )
394 if not isinstance(self.source_tier, ResearchSourceTier):
395 object.__setattr__(self, "source_tier", ResearchSourceTier(self.source_tier))
396
397
398 @dataclass(frozen=True)
399 class FreshnessPolicy:
400 policy_id: str
401 mode: FreshnessMode
402 maximum_age: timedelta | None
403 date_basis: FreshnessDateBasis = FreshnessDateBasis.AS_OF_OR_RETRIEVED
404 scoring_window: timedelta | None = None
405 retain_while_unresolved: bool = False
406 description: str = ""
407
408 def __post_init__(self) -> None:
409 object.__setattr__(self, "policy_id", _normalized_key(self.policy_id, "policy_id"))
410 if self.maximum_age is not None and self.maximum_age <= timedelta(0):
411 raise ValueError("maximum_age must be positive")
412 if self.scoring_window is not None and self.scoring_window <= timedelta(0):
413 raise ValueError("scoring_window must be positive")
414
415 def evidence_time(self, evidence: ResearchEvidence) -> datetime | None:
416 if self.date_basis == FreshnessDateBasis.EVENT_OR_PUBLICATION:
417 return evidence.event_date or evidence.published_at
418 return evidence.as_of or evidence.published_at or evidence.retrieved_at
419
420 def age(self, evidence: ResearchEvidence, now: datetime) -> timedelta | None:
421 _require_aware(now, "now")
422 anchor = self.evidence_time(evidence)
423 return None if anchor is None else now.astimezone(timezone.utc) - anchor.astimezone(timezone.utc)
424
425 def is_fresh(self, evidence: ResearchEvidence, now: datetime) -> bool:
426 _require_aware(now, "now")
427 if self.retain_while_unresolved and evidence.unresolved:
428 return True
429 if evidence.valid_until is not None:
430 return now.astimezone(timezone.utc) <= evidence.valid_until.astimezone(timezone.utc)
431 age = self.age(evidence, now)
432 if age is None or age < timedelta(0):
433 return False
434 return self.maximum_age is None or age <= self.maximum_age
435
436
437 class FreshnessPolicyRegistry:
438 """Fact-specific policies; there is intentionally no broad research TTL."""
439
440 def __init__(self, policies: Sequence[FreshnessPolicy]) -> None:
441 by_id = {policy.policy_id: policy for policy in policies}
442 if len(by_id) != len(policies):
443 raise ValueError("Freshness policy IDs must be unique")
444 self._by_id = MappingProxyType(by_id)
445
446 def get(self, policy_id: str) -> FreshnessPolicy:
447 return self._by_id[_normalized_key(policy_id, "policy_id")]
448
449 @classmethod
450 def default(cls) -> "FreshnessPolicyRegistry":
451 day = timedelta(days=1)
452 return cls(
453 (
454 FreshnessPolicy(
455 "LATEST_PRICE",
456 FreshnessMode.MARKET_SESSION_AWARE,
457 timedelta(minutes=15),
458 description="Use exchange/session valid-until when supplied; otherwise expire by minutes.",
459 ),
460 FreshnessPolicy(
461 "HISTORICAL_PRICE_SERIES",
462 FreshnessMode.DAILY_INCREMENTAL,
463 timedelta(hours=36),
464 description="Advance the durable daily series incrementally after the next expected session.",
465 ),
466 FreshnessPolicy(
467 "QUARTERLY_FINANCIALS",
468 FreshnessMode.RELEASE_AWARE_QUARTERLY,
469 timedelta(days=120),
470 description="Use the issuer release calendar through valid-until when known.",
471 ),
472 FreshnessPolicy(
473 "ANNUAL_FINANCIALS",
474 FreshnessMode.REPORTING_CALENDAR_AWARE_ANNUAL,
475 timedelta(days=400),
476 description="Use the issuer reporting calendar through valid-until when known.",
477 ),
478 FreshnessPolicy("SHAREHOLDING", FreshnessMode.QUARTERLY, timedelta(days=120)),
479 FreshnessPolicy(
480 "ORDER_BOOK_CAPEX_GUIDANCE",
481 FreshnessMode.EVENT_DRIVEN_BOUNDED,
482 timedelta(days=30),
483 ),
484 FreshnessPolicy("VALUATION_INPUTS", FreshnessMode.FACT_SPECIFIC_DAILY, day),
485 FreshnessPolicy(
486 "CURRENT_NEWS",
487 FreshnessMode.ROLLING_EVENT_WINDOW,
488 timedelta(days=1),
489 date_basis=FreshnessDateBasis.EVENT_OR_PUBLICATION,
490 scoring_window=timedelta(days=30),
491 ),
492 FreshnessPolicy(
493 "GOVERNANCE_HISTORY",
494 FreshnessMode.UNRESOLVED_HISTORY,
495 timedelta(days=365),
496 date_basis=FreshnessDateBasis.EVENT_OR_PUBLICATION,
497 retain_while_unresolved=True,
498 ),
499 FreshnessPolicy("SECTOR_MACRO", FreshnessMode.FACT_SPECIFIC_DAILY, day),
500 )
501 )
502
503
504 @dataclass(frozen=True)
505 class ExternalResearchToolAuthorization:
506 """Serializable grant issued only after the fallback policy permits a target."""
507
508 global_instrument_id: UUID
509 requirement_id: str
510 permitted_provider_ids: tuple[str, ...]
511 issued_at: datetime
512 issued_by: str = "ProviderFallbackPolicy"
513 authorized: bool = True
514
515 def __post_init__(self) -> None:
516 _require_global_instrument_id(self.global_instrument_id)
517 object.__setattr__(self, "requirement_id", _normalized_key(self.requirement_id, "requirement_id"))
518 providers = tuple(
519 _normalized_key(value, "permitted_provider_id") for value in self.permitted_provider_ids
520 )
521 if not providers:
522 raise ValueError("at least one permitted external provider is required")
523 object.__setattr__(self, "permitted_provider_ids", providers)
524 _require_aware(self.issued_at, "issued_at")
525
526 def as_gateway_payload(self) -> dict[str, object]:
527 return {
528 "authorized": self.authorized,
529 "issuedBy": self.issued_by,
530 "globalInstrumentId": str(self.global_instrument_id),
531 "requirementId": self.requirement_id,
532 "permittedProviderIds": list(self.permitted_provider_ids),
533 "issuedAt": self.issued_at.isoformat(),
534 }
535
536
537 @dataclass(frozen=True)
538 class ProviderFallbackPolicy:
539 trigger_statuses: frozenset[ResearchRequirementStatus] = frozenset(
540 {
541 ResearchRequirementStatus.MISSING,
542 ResearchRequirementStatus.PARTIAL,
543 ResearchRequirementStatus.CONFLICTING,
544 ResearchRequirementStatus.FAILED,
545 }
546 )
547 low_confidence_below: float = 0.70
548 allow_external_tool_gateway: bool = True
549
550 def permits(self, status: ResearchRequirementStatus, confidence: float | None = None) -> bool:
551 return (
552 status in self.trigger_statuses
553 or (confidence is not None and confidence < self.low_confidence_below)
554 ) and self.allow_external_tool_gateway
555
556 def authorize_external_tool(
557 self,
558 *,
559 global_instrument_id: UUID,
560 requirement_id: str,
561 status: ResearchRequirementStatus,
562 confidence: float | None,
563 permitted_provider_ids: Sequence[str],
564 now: datetime | None = None,
565 ) -> ExternalResearchToolAuthorization | None:
566 """Issue a narrow MCP gateway grant only for a policy-approved fallback."""
567 if not self.permits(status, confidence):
568 return None
569 return ExternalResearchToolAuthorization(
570 global_instrument_id=global_instrument_id,
571 requirement_id=requirement_id,
572 permitted_provider_ids=tuple(permitted_provider_ids),
573 issued_at=now or datetime.now(timezone.utc),
574 )
575
576
577 @dataclass(frozen=True)
578 class ProviderAuthority:
579 source: str
580 source_tier: ResearchSourceTier
581
582 def __post_init__(self) -> None:
583 object.__setattr__(self, "source", _normalized_key(self.source, "source"))
584
585
586 @dataclass(frozen=True)
587 class ProviderAuthorityPolicy:
588 requirement_id: str
589 jurisdiction: str
590 authorities: tuple[ProviderAuthority, ...]
591 fallback_policy: ProviderFallbackPolicy = field(default_factory=ProviderFallbackPolicy)
592 selection_semantics: str = ""
593
594 def __post_init__(self) -> None:
595 object.__setattr__(self, "requirement_id", _normalized_key(self.requirement_id, "requirement_id"))
596 object.__setattr__(self, "jurisdiction", _normalized_key(self.jurisdiction, "jurisdiction"))
597 object.__setattr__(self, "authorities", tuple(self.authorities))
598 sources = [authority.source for authority in self.authorities]
599 if len(sources) != len(set(sources)):
600 raise ValueError("Provider authority sources must be unique within a policy")
601
602 def rank(self, evidence: ResearchEvidence) -> tuple[int, int]:
603 explicit = next(
604 (index for index, authority in enumerate(self.authorities) if authority.source == evidence.source),
605 None,
606 )
607 if explicit is not None:
608 return explicit, _SOURCE_TIER_AUTHORITY[evidence.source_tier]
609 return len(self.authorities) + 1, _SOURCE_TIER_AUTHORITY[evidence.source_tier]
610
611
612 class ProviderAuthorityRegistry:
613 """Selects authority by fact and jurisdiction, never by one global provider rank."""
614
615 def __init__(self, policies: Sequence[ProviderAuthorityPolicy]) -> None:
616 by_key = {(item.requirement_id, item.jurisdiction): item for item in policies}
617 if len(by_key) != len(policies):
618 raise ValueError("Provider authority policies must be unique by requirement and jurisdiction")
619 self._by_key = MappingProxyType(by_key)
620
621 def policy_for(self, requirement_id: str, jurisdiction: str) -> ProviderAuthorityPolicy:
622 requirement = _normalized_key(requirement_id, "requirement_id")
623 region = _normalized_key(jurisdiction, "jurisdiction")
624 policy = self._by_key.get((requirement, region)) or self._by_key.get((requirement, "GLOBAL"))
625 if policy is None:
626 raise KeyError(
627 f"No fact-specific provider authority policy for {requirement} in {region}"
628 )
629 return policy
630
631 @classmethod
632 def default(cls) -> "ProviderAuthorityRegistry":
633 policies: list[ProviderAuthorityPolicy] = []
634 financial_requirements = (
635 "VALUATION_INPUTS",
636 "BUSINESS_QUALITY_FACTS",
637 "GROWTH_FACTS",
638 "BALANCE_SHEET_FACTS",
639 "QUARTERLY_FINANCIALS",
640 )
641 financial_sources = {
642 "GLOBAL": (
643 ("REGULATORY_FILING", ResearchSourceTier.REGULATORY),
644 ("COMPANY_FILING", ResearchSourceTier.OFFICIAL),
645 ("LICENSED_STRUCTURED", ResearchSourceTier.LICENSED_STRUCTURED),
646 ("APPROVED_SECONDARY", ResearchSourceTier.APPROVED_SECONDARY),
647 ("APPROVED_EXTERNAL_TOOL", ResearchSourceTier.APPROVED_EXTERNAL_TOOL),
648 ("USER_UPLOAD", ResearchSourceTier.USER_UPLOAD),
649 ),
650 "INDIA": (
651 ("NSE", ResearchSourceTier.OFFICIAL),
652 ("COMPANY_FILING", ResearchSourceTier.OFFICIAL),
653 ("LICENSED_STRUCTURED", ResearchSourceTier.LICENSED_STRUCTURED),
654 ("APPROVED_SECONDARY", ResearchSourceTier.APPROVED_SECONDARY),
655 ("APPROVED_EXTERNAL_TOOL", ResearchSourceTier.APPROVED_EXTERNAL_TOOL),
656 ("USER_UPLOAD", ResearchSourceTier.USER_UPLOAD),
657 ),
658 "USA": (
659 ("SEC_EDGAR", ResearchSourceTier.REGULATORY),
660 ("COMPANY_FILING", ResearchSourceTier.OFFICIAL),
661 ("LICENSED_STRUCTURED", ResearchSourceTier.LICENSED_STRUCTURED),
662 ("APPROVED_SECONDARY", ResearchSourceTier.APPROVED_SECONDARY),
663 ("APPROVED_EXTERNAL_TOOL", ResearchSourceTier.APPROVED_EXTERNAL_TOOL),
664 ("USER_UPLOAD", ResearchSourceTier.USER_UPLOAD),
665 ),
666 "EUROPE": (
667 ("REGULATORY_FILING", ResearchSourceTier.REGULATORY),
668 ("COMPANY_FILING", ResearchSourceTier.OFFICIAL),
669 ("EODHD", ResearchSourceTier.LICENSED_STRUCTURED),
670 ("APPROVED_SECONDARY", ResearchSourceTier.APPROVED_SECONDARY),
671 ("APPROVED_EXTERNAL_TOOL", ResearchSourceTier.APPROVED_EXTERNAL_TOOL),
672 ("USER_UPLOAD", ResearchSourceTier.USER_UPLOAD),
673 ),
674 }
675 for requirement_id in financial_requirements:
676 for region, sources in financial_sources.items():
677 policies.append(
678 ProviderAuthorityPolicy(
679 requirement_id,
680 region,
681 tuple(ProviderAuthority(source, tier) for source, tier in sources),
682 )
683 )
684
685 market_sources = tuple(
686 ProviderAuthority(source, tier)
687 for source, tier in (
688 ("CONFIGURED_MARKET_DATA", ResearchSourceTier.TRUSTED_MARKET_DATA),
689 ("EXCHANGE_MARKET_DATA", ResearchSourceTier.OFFICIAL),
690 ("LICENSED_MARKET_DATA", ResearchSourceTier.LICENSED_STRUCTURED),
691 ("YAHOO_FINANCE", ResearchSourceTier.APPROVED_SECONDARY),
692 )
693 )
694 for requirement_id in ("LATEST_PRICE", "HISTORICAL_PRICE_SERIES"):
695 policies.append(
696 ProviderAuthorityPolicy(
697 requirement_id,
698 "GLOBAL",
699 market_sources,
700 selection_semantics="Choose by exchange, market session, and required freshness.",
701 )
702 )
703
704 policies.extend(
705 (
706 ProviderAuthorityPolicy(
707 "SHAREHOLDING",
708 "INDIA",
709 (
710 ProviderAuthority("NSE", ResearchSourceTier.OFFICIAL),
711 ProviderAuthority("COMPANY_FILING", ResearchSourceTier.OFFICIAL),
712 ProviderAuthority("LICENSED_STRUCTURED", ResearchSourceTier.LICENSED_STRUCTURED),
713 ProviderAuthority("APPROVED_EXTERNAL_TOOL", ResearchSourceTier.APPROVED_EXTERNAL_TOOL),
714 ProviderAuthority("USER_UPLOAD", ResearchSourceTier.USER_UPLOAD),
715 ),
716 ),
717 ProviderAuthorityPolicy(
718 "SHAREHOLDING",
719 "GLOBAL",
720 (
721 ProviderAuthority("REGULATORY_FILING", ResearchSourceTier.REGULATORY),
722 ProviderAuthority("COMPANY_FILING", ResearchSourceTier.OFFICIAL),
723 ProviderAuthority(
724 "LICENSED_STRUCTURED", ResearchSourceTier.LICENSED_STRUCTURED
725 ),
726 ProviderAuthority(
727 "APPROVED_SECONDARY", ResearchSourceTier.APPROVED_SECONDARY
728 ),
729 ProviderAuthority(
730 "APPROVED_EXTERNAL_TOOL",
731 ResearchSourceTier.APPROVED_EXTERNAL_TOOL,
732 ),
733 ProviderAuthority("USER_UPLOAD", ResearchSourceTier.USER_UPLOAD),
734 ),
735 ),
736 ProviderAuthorityPolicy(
737 "ORDER_BOOK_CAPEX_GUIDANCE",
738 "GLOBAL",
739 (
740 ProviderAuthority("COMPANY_FILING", ResearchSourceTier.OFFICIAL),
741 ProviderAuthority("REGULATORY_FILING", ResearchSourceTier.REGULATORY),
742 ProviderAuthority(
743 "APPROVED_SECONDARY", ResearchSourceTier.APPROVED_SECONDARY
744 ),
745 ProviderAuthority(
746 "APPROVED_EXTERNAL_TOOL",
747 ResearchSourceTier.APPROVED_EXTERNAL_TOOL,
748 ),
749 ProviderAuthority("USER_UPLOAD", ResearchSourceTier.USER_UPLOAD),
750 ),
751 ),
752 ProviderAuthorityPolicy(
753 "CURRENT_NEWS",
754 "GLOBAL",
755 (
756 ProviderAuthority("SEARCH_COVERAGE", ResearchSourceTier.APPROVED_SECONDARY),
757 ProviderAuthority("REGULATORY_FILING", ResearchSourceTier.REGULATORY),
758 ProviderAuthority("OFFICIAL_COMPANY", ResearchSourceTier.OFFICIAL),
759 ProviderAuthority("REPUTABLE_NEWS", ResearchSourceTier.APPROVED_SECONDARY),
760 ProviderAuthority("APPROVED_EXTERNAL_TOOL", ResearchSourceTier.APPROVED_EXTERNAL_TOOL),
761 ProviderAuthority("USER_UPLOAD", ResearchSourceTier.USER_UPLOAD),
762 ),
763 ),
764 ProviderAuthorityPolicy(
765 "GOVERNANCE_HISTORY",
766 "GLOBAL",
767 (
768 ProviderAuthority(
769 "REGULATOR_OR_COURT_RECORD", ResearchSourceTier.REGULATORY
770 ),
771 ProviderAuthority("COMPANY_FILING", ResearchSourceTier.OFFICIAL),
772 ProviderAuthority(
773 "REPUTABLE_NEWS", ResearchSourceTier.APPROVED_SECONDARY
774 ),
775 ProviderAuthority(
776 "APPROVED_EXTERNAL_TOOL",
777 ResearchSourceTier.APPROVED_EXTERNAL_TOOL,
778 ),
779 ProviderAuthority("USER_UPLOAD", ResearchSourceTier.USER_UPLOAD),
780 ),
781 ),
782 ProviderAuthorityPolicy(
783 "SECTOR_MACRO",
784 "GLOBAL",
785 (
786 ProviderAuthority(
787 "OFFICIAL_STATISTICS_OR_CENTRAL_BANK",
788 ResearchSourceTier.OFFICIAL,
789 ),
790 ProviderAuthority(
791 "EXCHANGE_OR_INDEX_PROVIDER", ResearchSourceTier.TRUSTED_MARKET_DATA
792 ),
793 ProviderAuthority(
794 "LICENSED_STRUCTURED", ResearchSourceTier.LICENSED_STRUCTURED
795 ),
796 ProviderAuthority(
797 "REPUTABLE_NEWS", ResearchSourceTier.APPROVED_SECONDARY
798 ),
799 ProviderAuthority(
800 "APPROVED_EXTERNAL_TOOL",
801 ResearchSourceTier.APPROVED_EXTERNAL_TOOL,
802 ),
803 ProviderAuthority("USER_UPLOAD", ResearchSourceTier.USER_UPLOAD),
804 ),
805 ),
806 ProviderAuthorityPolicy(
807 "CANONICAL_IDENTITY",
808 "GLOBAL",
809 (
810 ProviderAuthority("ISIN_OR_PERMANENT_ID", ResearchSourceTier.OFFICIAL),
811 ProviderAuthority("EXACT_EXCHANGE_AND_SYMBOL", ResearchSourceTier.REGULATORY),
812 ProviderAuthority("NAME_CONFIRMATION", ResearchSourceTier.UNVERIFIED),
813 ),
814 ProviderFallbackPolicy(allow_external_tool_gateway=False),
815 ),
816 )
817 )
818 return cls(policies)
819
820
821 @dataclass(frozen=True)
822 class DurableResearchSnapshot:
823 """One DB-first read result; it never contains newly fetched provider data."""
824
825 global_instrument_id: UUID
826 evidence_by_requirement: Mapping[str, Sequence[ResearchEvidence]] = field(default_factory=dict)
827 supported_requirement_ids: frozenset[str] | None = None
828 refreshing_requirement_ids: frozenset[str] = frozenset()
829 failure_reasons: Mapping[str, str] = field(default_factory=dict)
830 applicability_by_requirement: Mapping[str, RequirementApplicability] = field(default_factory=dict)
831 acquisition_observations: Mapping[str, Mapping[str, Any]] = field(default_factory=dict)
832
833 def __post_init__(self) -> None:
834 _require_global_instrument_id(self.global_instrument_id)
835 normalized: dict[str, tuple[ResearchEvidence, ...]] = {}
836 for requirement_id, values in self.evidence_by_requirement.items():
837 key = _normalized_key(requirement_id, "requirement_id")
838 evidence = tuple(values)
839 if any(item.requirement_id != key for item in evidence):
840 raise ValueError(f"Evidence does not match requirement {key}")
841 normalized[key] = evidence
842 supported = self.supported_requirement_ids
843 if supported is not None:
844 supported = frozenset(_normalized_key(value, "requirement_id") for value in supported)
845 refreshing = frozenset(
846 _normalized_key(value, "requirement_id") for value in self.refreshing_requirement_ids
847 )
848 failures = {
849 _normalized_key(key, "requirement_id"): str(value)
850 for key, value in self.failure_reasons.items()
851 }
852 object.__setattr__(self, "evidence_by_requirement", MappingProxyType(normalized))
853 object.__setattr__(self, "supported_requirement_ids", supported)
854 object.__setattr__(self, "refreshing_requirement_ids", refreshing)
855 object.__setattr__(self, "failure_reasons", MappingProxyType(failures))
856
857 def evidence_for(self, requirement_id: str) -> tuple[ResearchEvidence, ...]:
858 return tuple(self.evidence_by_requirement.get(_normalized_key(requirement_id, "requirement_id"), ()))
859
860
861 class ResearchReadinessDataSource(Protocol):
862 """Durable DB reader implemented by a later persistence adapter."""
863
864 def load_by_global_instrument_id(
865 self,
866 global_instrument_id: UUID,
867 requirements: Sequence[ResearchRequirement],
868 ) -> DurableResearchSnapshot:
869 ...
870
871
872 @dataclass(frozen=True)
873 class ConflictResolution:
874 selected: ResearchEvidence | None
875 conflict_reason: str | None = None
876
877
878 class ResearchConflictResolver:
879 """Detect disagreement only for the same canonical fact at equal authority."""
880
881 def resolve(
882 self,
883 requirement: ResearchRequirement,
884 evidence: Sequence[ResearchEvidence],
885 authority_policy: ProviderAuthorityPolicy,
886 ) -> ConflictResolution:
887 if not evidence:
888 return ConflictResolution(None)
889 ordered = sorted(
890 evidence,
891 key=lambda item: (
892 authority_policy.rank(item),
893 -(item.as_of or item.published_at or item.retrieved_at).timestamp(),
894 -item.retrieved_at.timestamp(),
895 item.evidence_id,
896 ),
897 )
898 selected = ordered[0]
899 by_fact: dict[str, list[ResearchEvidence]] = {}
900 for item in evidence:
901 if item.fact_key and item.value_fingerprint is not None:
902 by_fact.setdefault(item.fact_key, []).append(item)
903 for fact_key, candidates in sorted(by_fact.items()):
904 best_rank = min(authority_policy.rank(item) for item in candidates)
905 peers = [item for item in candidates if authority_policy.rank(item) == best_rank]
906 values = {str(item.value_fingerprint).strip().casefold() for item in peers}
907 if len(values) > 1:
908 ids = ",".join(sorted(item.evidence_id for item in peers))
909 return ConflictResolution(selected, f"EQUAL_AUTHORITY_CONFLICT:{fact_key}:{ids}")
910 return ConflictResolution(selected)
911
912
913 class ResearchCoverageService:
914 """Separates durable history from evidence eligible for current rules."""
915
916 def score_input_evidence(
917 self,
918 requirement: ResearchRequirement,
919 evidence: Sequence[ResearchEvidence],
920 freshness_policy: FreshnessPolicy,
921 now: datetime,
922 ) -> tuple[ResearchEvidence, ...]:
923 _require_aware(now, "now")
924 if freshness_policy.scoring_window is None:
925 return tuple(evidence)
926 cutoff = now.astimezone(timezone.utc) - freshness_policy.scoring_window
927 eligible: list[ResearchEvidence] = []
928 for item in evidence:
929 event_at = item.event_date or item.published_at
930 if event_at is None:
931 continue
932 normalized = event_at.astimezone(timezone.utc)
933 if cutoff <= normalized <= now.astimezone(timezone.utc):
934 eligible.append(item)
935 return tuple(eligible)
936
937 def governance_history(self, snapshot: DurableResearchSnapshot) -> tuple[ResearchEvidence, ...]:
938 """Return durable governance evidence without applying the current-news window."""
939 return snapshot.evidence_for("GOVERNANCE_HISTORY")
940
941 @staticmethod
942 def covered_input_ids(
943 requirement: ResearchRequirement,
944 evidence: Sequence[ResearchEvidence],
945 ) -> frozenset[str]:
946 if not requirement.inputs:
947 return frozenset()
948 all_inputs = frozenset(item.input_id for item in requirement.inputs)
949 covered: set[str] = set()
950 for item in evidence:
951 if not item.complete:
952 continue
953 # Empty coverage is the backwards-compatible aggregate-evidence
954 # contract used by existing adapters and fixtures.
955 covered.update(item.covered_input_ids or all_inputs)
956 return frozenset(value for value in covered if value in all_inputs)
957
958
959 class ResearchDataConfidence(StrEnum):
960 HIGH = "HIGH"
961 MEDIUM = "MEDIUM"
962 LOW = "LOW"
963
964
965 @dataclass(frozen=True)
966 class ResearchRequirementReadiness:
967 requirement_id: str
968 rule_engine_area: RuleEngineArea
969 mandatory: bool
970 status: ResearchRequirementStatus
971 source: str | None
972 source_tier: ResearchSourceTier | None
973 as_of: datetime | None
974 retrieved_at: datetime | None
975 age: timedelta | None
976 freshness_policy: FreshnessPolicy
977 evidence_ids: tuple[str, ...]
978 missing_reason: str | None
979 conflict_reason: str | None
980 supported_actions: tuple[ResearchSupportedAction, ...]
981 importance: ResearchRequirementImportance
982 source_url: str | None
983 covered_input_ids: tuple[str, ...]
984 missing_input_ids: tuple[str, ...]
985 coverage_pct: int
986 critical_coverage_pct: int
987 applicability: str = "APPLICABLE"
988 applicability_reason: str | None = None
989 classification: str | None = None
990 classification_source: str | None = None
991 not_applicable_input_reasons: Mapping[str, str] = field(default_factory=dict)
992 acquisition_observation: Mapping[str, Any] | None = None
993
994
995 @dataclass(frozen=True)
996 class ResearchReadinessResult:
997 global_instrument_id: UUID
998 requirements: tuple[ResearchRequirementReadiness, ...]
999 generated_at: datetime
1000 overall_status: ResearchRequirementStatus = ResearchRequirementStatus.PARTIAL
1001 overall_completeness_pct: int = 0
1002 critical_completeness_pct: int = 0
1003 confidence: ResearchDataConfidence = ResearchDataConfidence.LOW
1004 confidence_pct: int = 0
1005
1006 def __post_init__(self) -> None:
1007 _require_global_instrument_id(self.global_instrument_id)
1008 _require_aware(self.generated_at, "generated_at")
1009
1010 def for_requirement(self, requirement_id: str) -> ResearchRequirementReadiness:
1011 key = _normalized_key(requirement_id, "requirement_id")
1012 return next(item for item in self.requirements if item.requirement_id == key)
1013
1014 @property
1015 def mandatory_ready(self) -> bool:
1016 return all(
1017 not item.mandatory or item.status in {ResearchRequirementStatus.READY_FRESH, ResearchRequirementStatus.NOT_APPLICABLE}
1018 for item in self.requirements
1019 )
1020
1021
1022 class ResearchReadinessService:
1023 """Read durable evidence once and classify it without provider side effects."""
1024
1025 def __init__(
1026 self,
1027 data_source: ResearchReadinessDataSource,
1028 requirement_registry: ResearchRequirementRegistry | None = None,
1029 freshness_registry: FreshnessPolicyRegistry | None = None,
1030 authority_registry: ProviderAuthorityRegistry | None = None,
1031 conflict_resolver: ResearchConflictResolver | None = None,
1032 coverage_service: ResearchCoverageService | None = None,
1033 ) -> None:
1034 self.data_source = data_source
1035 self.requirement_registry = requirement_registry or ResearchRequirementRegistry.default()
1036 self.freshness_registry = freshness_registry or FreshnessPolicyRegistry.default()
1037 self.authority_registry = authority_registry or ProviderAuthorityRegistry.default()
1038 self.conflict_resolver = conflict_resolver or ResearchConflictResolver()
1039 self.coverage_service = coverage_service or ResearchCoverageService()
1040
1041 def assess(
1042 self,
1043 global_instrument_id: UUID,
1044 *,
1045 jurisdiction: str = "GLOBAL",
1046 now: datetime | None = None,
1047 ) -> ResearchReadinessResult:
1048 instrument_id = _require_global_instrument_id(global_instrument_id)
1049 evaluated_at = now or datetime.now(timezone.utc)
1050 _require_aware(evaluated_at, "now")
1051 requirements = self.requirement_registry.requirements
1052 snapshot = self.data_source.load_by_global_instrument_id(instrument_id, requirements)
1053 if snapshot.global_instrument_id != instrument_id:
1054 raise ValueError("Durable snapshot globalInstrumentId does not match the request")
1055 classified = tuple(
1056 self._classify(requirement, snapshot, jurisdiction, evaluated_at)
1057 for requirement in requirements
1058 )
1059 overall_completeness = self._overall_completeness(classified)
1060 critical_completeness = self._critical_completeness(classified)
1061 overall_status = self._overall_status(classified)
1062 confidence_pct = self._confidence_pct(
1063 classified, overall_completeness, critical_completeness
1064 )
1065 confidence = (
1066 ResearchDataConfidence.HIGH
1067 if confidence_pct >= 80
1068 else ResearchDataConfidence.MEDIUM
1069 if confidence_pct >= 55
1070 else ResearchDataConfidence.LOW
1071 )
1072 return ResearchReadinessResult(
1073 instrument_id,
1074 classified,
1075 evaluated_at,
1076 overall_status=overall_status,
1077 overall_completeness_pct=overall_completeness,
1078 critical_completeness_pct=critical_completeness,
1079 confidence=confidence,
1080 confidence_pct=confidence_pct,
1081 )
1082
1083 def _classify(
1084 self,
1085 requirement: ResearchRequirement,
1086 snapshot: DurableResearchSnapshot,
1087 jurisdiction: str,
1088 now: datetime,
1089 ) -> ResearchRequirementReadiness:
1090 policy = self.freshness_registry.get(requirement.freshness_policy_id)
1091 applicability = snapshot.applicability_by_requirement.get(requirement.requirement_id, RequirementApplicability())
1092 def with_applicability(value):
1093 return replace(value, applicability=applicability.state,
1094 applicability_reason=applicability.reason, classification=applicability.classification,
1095 classification_source=applicability.source,
1096 not_applicable_input_reasons=applicability.excluded_inputs,
1097 acquisition_observation=snapshot.acquisition_observations.get(requirement.requirement_id))
1098 news_state = snapshot.acquisition_observations.get(requirement.requirement_id, {}).get('news_readiness')
1099 if requirement.requirement_id == 'CURRENT_NEWS' and news_state in {'PARTIAL_SEARCH','FAILED_SEARCH','STALE_SEARCH'}:
1100 state = {'PARTIAL_SEARCH':ResearchRequirementStatus.PARTIAL,'FAILED_SEARCH':ResearchRequirementStatus.FAILED,
1101 'STALE_SEARCH':ResearchRequirementStatus.READY_STALE}[news_state]
1102 return with_applicability(self._result(requirement,policy,state,missing_reason=news_state))
1103 if applicability.state == "NOT_APPLICABLE":
1104 return with_applicability(self._result(requirement, policy, ResearchRequirementStatus.NOT_APPLICABLE))
1105 if applicability.excluded_inputs:
1106 requirement = replace(requirement, inputs=tuple(
1107 item for item in requirement.inputs if item.input_id not in applicability.excluded_inputs))
1108 supported = (
1109 snapshot.supported_requirement_ids is None
1110 or requirement.requirement_id in snapshot.supported_requirement_ids
1111 )
1112 if not supported:
1113 return self._result(
1114 requirement,
1115 policy,
1116 ResearchRequirementStatus.UNSUPPORTED,
1117 missing_reason="REQUIREMENT_UNSUPPORTED_FOR_INSTRUMENT",
1118 )
1119
1120 durable_evidence = snapshot.evidence_for(requirement.requirement_id)
1121 eligible = self.coverage_service.score_input_evidence(requirement, durable_evidence, policy, now)
1122 authority = self.authority_registry.policy_for(requirement.requirement_id, jurisdiction)
1123 resolution = self.conflict_resolver.resolve(requirement, eligible, authority)
1124 selected = resolution.selected
1125 covered_inputs = self.coverage_service.covered_input_ids(requirement, eligible)
1126 missing_inputs = tuple(
1127 item.input_id for item in requirement.inputs if item.input_id not in covered_inputs
1128 )
1129 mandatory_inputs = tuple(
1130 item.input_id
1131 for item in requirement.inputs
1132 if item.importance == ResearchRequirementImportance.MANDATORY
1133 )
1134 missing_mandatory_inputs = tuple(
1135 item_id for item_id in mandatory_inputs if item_id not in covered_inputs
1136 )
1137
1138 if requirement.requirement_id in snapshot.refreshing_requirement_ids:
1139 status = ResearchRequirementStatus.REFRESHING
1140 missing_reason = None
1141 elif resolution.conflict_reason:
1142 status = ResearchRequirementStatus.CONFLICTING
1143 missing_reason = None
1144 elif not eligible:
1145 failure = snapshot.failure_reasons.get(requirement.requirement_id)
1146 status = ResearchRequirementStatus.FAILED if failure else ResearchRequirementStatus.MISSING
1147 observed_empty = any(row.get("outcome") == "SUCCESS_EMPTY" for row in
1148 snapshot.acquisition_observations.get(requirement.requirement_id, {}).get("history", []))
1149 missing_reason = failure or ("NO_QUALIFYING_CURRENT_EVENTS" if observed_empty else None) or (
1150 "NO_EVIDENCE_IN_CURRENT_NEWS_WINDOW"
1151 if policy.scoring_window is not None and durable_evidence
1152 else "NO_DURABLE_EVIDENCE"
1153 )
1154 elif sum(1 for item in eligible if item.complete) < requirement.minimum_evidence_count:
1155 status = ResearchRequirementStatus.PARTIAL
1156 missing_reason = "INSUFFICIENT_COMPLETE_EVIDENCE"
1157 elif missing_mandatory_inputs:
1158 status = ResearchRequirementStatus.PARTIAL
1159 missing_reason = "MISSING_REQUIRED_INPUTS:" + ",".join(missing_mandatory_inputs)
1160 elif self._required_inputs_are_fresh(
1161 requirement, eligible, authority, policy, now
1162 ):
1163 status = ResearchRequirementStatus.READY_FRESH
1164 missing_reason = None
1165 else:
1166 status = ResearchRequirementStatus.READY_STALE
1167 missing_reason = "FRESHNESS_POLICY_EXPIRED"
1168
1169 # Report the actual mandatory freshness blocker, not a newer supporting
1170 # input (e.g. June finance cost beside March debt/equity for TMCV).
1171 if status == ResearchRequirementStatus.READY_STALE:
1172 blockers = []
1173 for input_id in mandatory_inputs:
1174 candidates = [e for e in eligible if e.complete and input_id in
1175 (e.covered_input_ids or tuple(i.input_id for i in requirement.inputs))]
1176 if candidates:
1177 chosen = min(candidates,key=lambda e:(authority.rank(e),
1178 -(e.as_of or e.published_at or e.retrieved_at).timestamp(),-e.retrieved_at.timestamp(),e.evidence_id))
1179 if not policy.is_fresh(chosen,now): blockers.append((input_id,chosen))
1180 if blockers:
1181 selected = min((e for _,e in blockers),key=lambda e:(policy.evidence_time(e),e.evidence_id))
1182 missing_reason += ':' + ','.join(sorted(i for i,_ in blockers))
1183
1184 return with_applicability(self._result(
1185 requirement,
1186 policy,
1187 status,
1188 selected=selected,
1189 evidence=eligible,
1190 missing_reason=missing_reason,
1191 conflict_reason=resolution.conflict_reason,
1192 now=now,
1193 covered_input_ids=covered_inputs,
1194 missing_input_ids=missing_inputs,
1195 ))
1196
1197 @staticmethod
1198 def _result(
1199 requirement: ResearchRequirement,
1200 policy: FreshnessPolicy,
1201 status: ResearchRequirementStatus,
1202 *,
1203 selected: ResearchEvidence | None = None,
1204 evidence: Sequence[ResearchEvidence] = (),
1205 missing_reason: str | None = None,
1206 conflict_reason: str | None = None,
1207 now: datetime | None = None,
1208 covered_input_ids: frozenset[str] = frozenset(),
1209 missing_input_ids: Sequence[str] = (),
1210 ) -> ResearchRequirementReadiness:
1211 actions = requirement.supported_actions
1212 if status in {ResearchRequirementStatus.READY_FRESH, ResearchRequirementStatus.REFRESHING, ResearchRequirementStatus.NOT_APPLICABLE}:
1213 actions = ()
1214 elif status == ResearchRequirementStatus.UNSUPPORTED:
1215 actions = tuple(action for action in actions if action != ResearchSupportedAction.FIND_DATA)
1216 age = policy.age(selected, now) if selected is not None and now is not None else None
1217 coverage_pct, critical_coverage_pct = _requirement_coverage(
1218 requirement, covered_input_ids, bool(evidence)
1219 )
1220 return ResearchRequirementReadiness(
1221 requirement_id=requirement.requirement_id,
1222 rule_engine_area=requirement.rule_engine_area,
1223 mandatory=requirement.mandatory,
1224 status=status,
1225 source=selected.source if selected else None,
1226 source_tier=selected.source_tier if selected else None,
1227 as_of=selected.as_of if selected else None,
1228 retrieved_at=selected.retrieved_at if selected else None,
1229 age=age,
1230 freshness_policy=policy,
1231 evidence_ids=tuple(item.evidence_id for item in evidence),
1232 missing_reason=missing_reason,
1233 conflict_reason=conflict_reason,
1234 supported_actions=actions,
1235 importance=requirement.importance or ResearchRequirementImportance.IMPORTANT,
1236 source_url=selected.source_url if selected else None,
1237 covered_input_ids=tuple(sorted(covered_input_ids)),
1238 missing_input_ids=tuple(missing_input_ids),
1239 coverage_pct=coverage_pct,
1240 critical_coverage_pct=critical_coverage_pct,
1241 )
1242
1243 @staticmethod
1244 def _required_inputs_are_fresh(
1245 requirement: ResearchRequirement,
1246 evidence: Sequence[ResearchEvidence],
1247 authority: ProviderAuthorityPolicy,
1248 policy: FreshnessPolicy,
1249 now: datetime,
1250 ) -> bool:
1251 mandatory = tuple(
1252 item.input_id
1253 for item in requirement.inputs
1254 if item.importance == ResearchRequirementImportance.MANDATORY
1255 )
1256 if not mandatory:
1257 selected = ResearchConflictResolver().resolve(requirement, evidence, authority).selected
1258 return selected is not None and policy.is_fresh(selected, now)
1259 all_input_ids = frozenset(item.input_id for item in requirement.inputs)
1260 for input_id in mandatory:
1261 candidates = [
1262 item
1263 for item in evidence
1264 if item.complete
1265 and input_id in (frozenset(item.covered_input_ids) or all_input_ids)
1266 ]
1267 if not candidates:
1268 return False
1269 selected = sorted(
1270 candidates,
1271 key=lambda item: (
1272 authority.rank(item),
1273 -(item.as_of or item.published_at or item.retrieved_at).timestamp(),
1274 -item.retrieved_at.timestamp(),
1275 item.evidence_id,
1276 ),
1277 )[0]
1278 if not policy.is_fresh(selected, now):
1279 return False
1280 return True
1281
1282 def _overall_completeness(
1283 self, requirements: Sequence[ResearchRequirementReadiness]
1284 ) -> int:
1285 weighted = Decimal("0")
1286 included_weight = Decimal("0")
1287 for area, area_weight in self.requirement_registry.area_weights.items():
1288 values = [item for item in requirements if item.rule_engine_area == area]
1289 supported = [
1290 item for item in values if item.status != ResearchRequirementStatus.NOT_APPLICABLE
1291 ]
1292 if not supported:
1293 continue
1294 area_pct = Decimal(sum(item.coverage_pct for item in supported)) / Decimal(
1295 len(supported)
1296 )
1297 weighted += area_weight * area_pct
1298 included_weight += area_weight
1299 return 0 if included_weight == 0 else int((weighted / included_weight).quantize(Decimal("1")))
1300
1301 def _critical_completeness(
1302 self, requirements: Sequence[ResearchRequirementReadiness]
1303 ) -> int:
1304 weighted = Decimal("0")
1305 included_weight = Decimal("0")
1306 for area, area_weight in self.requirement_registry.area_weights.items():
1307 values = [
1308 item
1309 for item in requirements
1310 if item.rule_engine_area == area and item.mandatory and item.status != ResearchRequirementStatus.NOT_APPLICABLE
1311 ]
1312 if not values:
1313 continue
1314 area_pct = Decimal(sum(item.critical_coverage_pct for item in values)) / Decimal(
1315 len(values)
1316 )
1317 weighted += area_weight * area_pct
1318 included_weight += area_weight
1319 return 0 if included_weight == 0 else int((weighted / included_weight).quantize(Decimal("1")))
1320
1321 @staticmethod
1322 def _overall_status(
1323 requirements: Sequence[ResearchRequirementReadiness],
1324 ) -> ResearchRequirementStatus:
1325 supported = [
1326 item for item in requirements if item.status != ResearchRequirementStatus.NOT_APPLICABLE
1327 ]
1328 if not supported:
1329 return ResearchRequirementStatus.NOT_APPLICABLE
1330 mandatory = [item for item in supported if item.mandatory]
1331 if any(item.status == ResearchRequirementStatus.CONFLICTING for item in mandatory):
1332 return ResearchRequirementStatus.CONFLICTING
1333 if any(item.status == ResearchRequirementStatus.REFRESHING for item in supported):
1334 return ResearchRequirementStatus.REFRESHING
1335 if any(item.status == ResearchRequirementStatus.FAILED for item in mandatory):
1336 return ResearchRequirementStatus.FAILED
1337 if all(item.status == ResearchRequirementStatus.READY_FRESH for item in supported):
1338 return ResearchRequirementStatus.READY_FRESH
1339 return ResearchRequirementStatus.PARTIAL
1340
1341 @staticmethod
1342 def _confidence_pct(
1343 requirements: Sequence[ResearchRequirementReadiness],
1344 overall_completeness: int,
1345 critical_completeness: int,
1346 ) -> int:
1347 selected = [item for item in requirements if item.source_tier is not None]
1348 tier_scores = {
1349 ResearchSourceTier.OFFICIAL: 100,
1350 ResearchSourceTier.REGULATORY: 100,
1351 ResearchSourceTier.TRUSTED_MARKET_DATA: 90,
1352 ResearchSourceTier.LICENSED_STRUCTURED: 85,
1353 ResearchSourceTier.APPROVED_SECONDARY: 70,
1354 ResearchSourceTier.APPROVED_EXTERNAL_TOOL: 60,
1355 ResearchSourceTier.USER_UPLOAD: 50,
1356 ResearchSourceTier.UNVERIFIED: 20,
1357 }
1358 authority = (
1359 sum(tier_scores[item.source_tier] for item in selected) / len(selected)
1360 if selected
1361 else 0
1362 )
1363 fresh = (
1364 100
1365 * sum(item.status == ResearchRequirementStatus.READY_FRESH for item in selected)
1366 / len(selected)
1367 if selected
1368 else 0
1369 )
1370 score = (
1371 critical_completeness * 0.45
1372 + overall_completeness * 0.25
1373 + authority * 0.20
1374 + fresh * 0.10
1375 )
1376 score -= 20 * sum(
1377 item.status == ResearchRequirementStatus.CONFLICTING for item in requirements
1378 )
1379 score -= 10 * sum(
1380 item.mandatory and item.status == ResearchRequirementStatus.FAILED
1381 for item in requirements
1382 )
1383 return max(0, min(100, round(score)))
1384
1385
1386 @dataclass(frozen=True)
1387 class ResearchRefreshTarget:
1388 requirement_id: str
1389 rule_engine_area: RuleEngineArea
1390 reason: ResearchRequirementStatus
1391 authority_policy: ProviderAuthorityPolicy
1392 existing_evidence_ids: tuple[str, ...]
1393
1394
1395 @dataclass(frozen=True)
1396 class ResearchRefreshPlan:
1397 global_instrument_id: UUID
1398 targets: tuple[ResearchRefreshTarget, ...]
1399 created_at: datetime
1400
1401 def __post_init__(self) -> None:
1402 _require_global_instrument_id(self.global_instrument_id)
1403 _require_aware(self.created_at, "created_at")
1404
1405
1406 class ResearchRefreshPlanner:
1407 """Plan mandatory targeted work; planning itself never invokes a provider."""
1408
1409 _TARGET_STATUSES = frozenset(
1410 {
1411 ResearchRequirementStatus.READY_STALE,
1412 ResearchRequirementStatus.PARTIAL,
1413 ResearchRequirementStatus.MISSING,
1414 ResearchRequirementStatus.CONFLICTING,
1415 ResearchRequirementStatus.FAILED,
1416 }
1417 )
1418
1419 def __init__(self, authority_registry: ProviderAuthorityRegistry | None = None) -> None:
1420 self.authority_registry = authority_registry or ProviderAuthorityRegistry.default()
1421
1422 def plan(
1423 self,
1424 readiness: ResearchReadinessResult,
1425 *,
1426 jurisdiction: str = "GLOBAL",
1427 requirement_ids: Sequence[str] | None = None,
1428 include_non_mandatory: bool = False,
1429 ) -> ResearchRefreshPlan:
1430 selected = (
1431 None
1432 if requirement_ids is None
1433 else frozenset(_normalized_key(value, "requirement_id") for value in requirement_ids)
1434 )
1435 targets = tuple(
1436 ResearchRefreshTarget(
1437 requirement_id=item.requirement_id,
1438 rule_engine_area=item.rule_engine_area,
1439 reason=item.status,
1440 authority_policy=self.authority_registry.policy_for(item.requirement_id, jurisdiction),
1441 existing_evidence_ids=item.evidence_ids,
1442 )
1443 for item in readiness.requirements
1444 if item.status in self._TARGET_STATUSES
1445 and (selected is None or item.requirement_id in selected)
1446 and (item.mandatory or include_non_mandatory or selected is not None)
1447 )
1448 return ResearchRefreshPlan(readiness.global_instrument_id, targets, readiness.generated_at)
1449
1450
1451 class ExternalResearchToolGateway(Protocol):
1452 """Provider-neutral MCP seam; fallback authorization is decided upstream."""
1453
1454 async def acquire_requirement(
1455 self,
1456 profile: Any,
1457 *,
1458 region: str,
1459 requirement_id: str,
1460 authorization: ExternalResearchToolAuthorization,
1461 request_id: str,
1462 ) -> Any:
1463 """Invoke one policy-authorized configured capability for a verified mapping."""
1464 ...
1465
1466 async def find_evidence(
1467 self,
1468 global_instrument_id: UUID,
1469 requirement: ResearchRequirement,
1470 authority_policy: ProviderAuthorityPolicy,
1471 *,
1472 authorization: ExternalResearchToolAuthorization,
1473 ) -> Sequence[ResearchEvidence]:
1474 ...
1475
1476
1477 def _normalized_key(value: str, field_name: str) -> str:
1478 normalized = str(value).strip().upper()
1479 if not normalized:
1480 raise ValueError(f"{field_name} is required")
1481 return normalized
1482
1483
1484 def _requirement_coverage(
1485 requirement: ResearchRequirement,
1486 covered_input_ids: frozenset[str],
1487 has_evidence: bool,
1488 ) -> tuple[int, int]:
1489 if not requirement.inputs:
1490 value = 100 if has_evidence else 0
1491 return value, value
1492 weights = {
1493 ResearchRequirementImportance.MANDATORY: 3,
1494 ResearchRequirementImportance.IMPORTANT: 2,
1495 ResearchRequirementImportance.SUPPORTING: 1,
1496 }
1497 denominator = sum(weights[item.importance] for item in requirement.inputs)
1498 numerator = sum(
1499 weights[item.importance]
1500 for item in requirement.inputs
1501 if item.input_id in covered_input_ids
1502 )
1503 mandatory = [
1504 item for item in requirement.inputs
1505 if item.importance == ResearchRequirementImportance.MANDATORY
1506 ]
1507 mandatory_covered = sum(item.input_id in covered_input_ids for item in mandatory)
1508 overall = round(100 * numerator / denominator) if denominator else 0
1509 critical = round(100 * mandatory_covered / len(mandatory)) if mandatory else overall
1510 return overall, critical
1511
1512
1513 def _require_aware(value: datetime, field_name: str) -> datetime:
1514 if value.tzinfo is None or value.utcoffset() is None:
1515 raise ValueError(f"{field_name} must be timezone-aware")
1516 return value
1517
1518
1519 def _require_global_instrument_id(value: UUID) -> UUID:
1520 if not isinstance(value, UUID) or value.int == 0:
1521 raise ValueError("canonical globalInstrumentId is required")
1522 return value