| 1 | """Provider-neutral financial fact identity and precedence rules. |
| 2 | |
| 3 | This is deliberately independent of document parsing. Every future financial |
| 4 | ingester must normalize into ``FinancialFact`` and call ``merge_fact`` at its |
| 5 | durable upsert boundary instead of making provider-specific replacement choices. |
| 6 | """ |
| 7 | from __future__ import annotations |
| 8 | |
| 9 | from dataclasses import dataclass |
| 10 | from enum import IntEnum |
| 11 | from uuid import UUID |
| 12 | |
| 13 | from app.models import ProvenancedValue, SourceMode |
| 14 | |
| 15 | |
| 16 | class FactSourceTier(IntEnum): |
| 17 | SEARCH = 1 |
| 18 | YAHOO = 2 |
| 19 | # 3 is durable historical storage for official NSE facts. Never reuse it. |
| 20 | OFFICIAL_NSE = 3 |
| 21 | STRUCTURED_FUNDAMENTALS = 4 |
| 22 | OFFICIAL_REGULATORY = 5 |
| 23 | |
| 24 | |
| 25 | # Provider-neutral list of tiers eligible for financial read projections. |
| 26 | SUPPORTED_FINANCIAL_SOURCE_TIERS = frozenset({ |
| 27 | FactSourceTier.YAHOO, |
| 28 | FactSourceTier.STRUCTURED_FUNDAMENTALS, |
| 29 | FactSourceTier.OFFICIAL_NSE, |
| 30 | FactSourceTier.OFFICIAL_REGULATORY, |
| 31 | }) |
| 32 | |
| 33 | |
| 34 | def fact_source_authority(tier: FactSourceTier) -> int: |
| 35 | """Return authority without changing durable source-tier identities.""" |
| 36 | return { |
| 37 | FactSourceTier.SEARCH: 1, |
| 38 | FactSourceTier.YAHOO: 2, |
| 39 | FactSourceTier.STRUCTURED_FUNDAMENTALS: 3, |
| 40 | FactSourceTier.OFFICIAL_NSE: 4, |
| 41 | FactSourceTier.OFFICIAL_REGULATORY: 5, |
| 42 | }[tier] |
| 43 | |
| 44 | |
| 45 | @dataclass(frozen=True) |
| 46 | class FinancialFactKey: |
| 47 | instrument_id: UUID |
| 48 | metric: str |
| 49 | period_end: str | None |
| 50 | period_type: str |
| 51 | reporting_basis: str | None = None |
| 52 | |
| 53 | |
| 54 | @dataclass(frozen=True) |
| 55 | class FinancialFact: |
| 56 | key: FinancialFactKey |
| 57 | value: ProvenancedValue |
| 58 | source_tier: FactSourceTier |
| 59 | source_provider: str |
| 60 | source_identity: str |
| 61 | source_mode: SourceMode = SourceMode.REAL |
| 62 | |
| 63 | |
| 64 | def merge_fact(existing: FinancialFact | None, incoming: FinancialFact | None, *, |
| 65 | allow_same_tier_correction: bool = False) -> FinancialFact | None: |
| 66 | """Return the accepted fact without letting a fallback erase evidence. |
| 67 | |
| 68 | A correction is intentionally opt-in and only valid for a same-tier official |
| 69 | fact with the same canonical fact identity. Retrieval time never controls |
| 70 | precedence. |
| 71 | """ |
| 72 | if incoming is None or _missing(incoming.value.value): |
| 73 | return existing |
| 74 | if existing is None or _missing(existing.value.value): |
| 75 | return incoming |
| 76 | if existing.key != incoming.key: |
| 77 | raise ValueError("Financial facts with different canonical identities cannot be merged") |
| 78 | if fact_source_authority(incoming.source_tier) > fact_source_authority(existing.source_tier): |
| 79 | return incoming |
| 80 | if fact_source_authority(incoming.source_tier) < fact_source_authority(existing.source_tier): |
| 81 | return existing |
| 82 | if allow_same_tier_correction and incoming.source_tier == FactSourceTier.OFFICIAL_NSE: |
| 83 | return incoming |
| 84 | return existing |
| 85 | |
| 86 | |
| 87 | def _missing(value: object) -> bool: |
| 88 | return value is None or (isinstance(value, str) and not value.strip()) |