| 1 | from datetime import datetime, timezone |
| 2 | from decimal import Decimal |
| 3 | from pathlib import Path |
| 4 | import threading |
| 5 | import time |
| 6 | from types import SimpleNamespace |
| 7 | from uuid import UUID |
| 8 | |
| 9 | import asyncio |
| 10 | import pytest |
| 11 | |
| 12 | import app.structured_research as structured_research |
| 13 | from app.fact_precedence import FactSourceTier, FinancialFact, FinancialFactKey |
| 14 | from app.models import CompanyResearchProfile, DocumentStatus, DocumentType, ProvenancedValue, ReliabilityLevel, ResearchDocument, SourceClassification, SourceMode, SourceType |
| 15 | from app.persistence import SqliteResearchPersistence |
| 16 | from app.repository import ResearchRepository, _InstrumentRefreshGate |
| 17 | from app.settings import Settings |
| 18 | from app.source_discovery import DiscoveryResult |
| 19 | from app.source_registry import RegisteredResearchSource |
| 20 | from app.structured_research import _STATEMENT_HEADING, _classify_financial_row, _financial_columns, _financial_number, _header_dates, _nse_statement_candidates, _parse_nse_income_statement, _parse_statement_candidate, _row_semantic_tokens, _statement_quality, latest_quarterly_result, parsed_nse_balance_sheet_periods, parsed_nse_cash_flow_periods, parsed_nse_income_statement_periods |
| 21 | |
| 22 | |
| 23 | def _document(text: str) -> ResearchDocument: |
| 24 | return ResearchDocument( |
| 25 | canonical_url="https://nsearchives.nseindia.com/corporate/result.pdf", original_url="https://nsearchives.nseindia.com/corporate/result.pdf", |
| 26 | source_type=SourceType.EXCHANGE_ANNOUNCEMENT, source_classification=SourceClassification.EXCHANGE, |
| 27 | source_name="NSE corporate announcements", publisher="NSE", content_type="application/pdf", document_type=DocumentType.PDF_REFERENCE, |
| 28 | normalized_text=text, content_hash="fixture", status=DocumentStatus.PROCESSED, reliability_level=ReliabilityLevel.LEVEL_A, |
| 29 | source_mode=SourceMode.REAL, instrument_id=UUID("99999999-9999-9999-9999-999999999999"), |
| 30 | company_id=UUID("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"), published_at=datetime(2026, 8, 1, tzinfo=timezone.utc), |
| 31 | ) |
| 32 | |
| 33 | |
| 34 | JUNE = """Amounts in Rs. Crore Extract of Statement of Unaudited Financial Results for the quaiter ended 30\" Jiine 2026 Particulars Quarter Ended Year Ended 30th Ju ne 31st March 30th June 31st March Z026 2026 2025 2026 Revenue From Operations 8,261.11 7,335.75 6,915.38 27,284.15 Net Profit for the period after Tax 1,927.21 1,684.31 1,745.69 6,427.00 Earning Per Share (of Rs 10 each) Basic (Rs.) 1.47 1.29 1.34 5.36 Diluted (Rs.) 1.47 1.29 1.34 5.36 Borrowings 142851.50 163186.05""" |
| 35 | |
| 36 | |
| 37 | # Sanitized structural excerpts from the persisted June-2026 layout families. |
| 38 | # Values are fixture-local; assertions below verify only aligned source cells. |
| 39 | FEDERAL_BANK_JUNE_2026 = """STANDALONE UNAUDITED FINANCIAL RESULTS FOR THE QUARTER ENDED JUNE 30, 2026 (Rs. in Lakhs) Particulars Quarter ended Year ended 30.06.2026 31.03.2026 30.06.2025 31.03.2026 1. Interest earned 210000.00 190000.00 180000.00 760000.00 2. Other income 40000.00 35000.00 30000.00 120000.00 3. TOTAL INCOME 250000.00 225000.00 210000.00 880000.00 12. Net Profit from Ordinary Activities after tax 30000.00 28000.00 26000.00 105000.00 Basic EPS 1.63 1.52 1.42 5.71 Diluted EPS 1.63 1.52 1.42 5.71""" |
| 40 | |
| 41 | SHRIRAM_JUNE_2026 = """STATEMENT OF UNAUDITED STANDALONE FINANCIAL RESULTS FOR THE QUARTER ENDED JUNE 30, 2026 (Rs. in Crore) Particulars Quarter ended Year ended 30.06.2026 31.03.2026 30.06.2025 31.03.2026 Revenue from operations 5000.00 4700.00 4300.00 18500.00 Total revenue from operations 5050.00 4750.00 4350.00 18700.00 Other income 100.00 90.00 80.00 350.00 Total income 5150.00 4840.00 4430.00 19050.00 Profit for the period/year 900.00 850.00 790.00 3300.00 Basic EPS 12.50 11.80 10.90 45.80 Diluted EPS 12.50 11.80 10.90 45.80""" |
| 42 | |
| 43 | UJJIVAN_JUNE_2026 = """Statement of Unaudited Financial Results for the quarter ended June 30, 2026 (Rs. in Lakh) Particulars Quarter ended Year ended June 30 2026 March 31 2026 June 30 2025 March 31 2026 Interest earned 190000 175000 160000 690000 Other income 38093 35000 32000 113897 Total income 228093 210000 192000 803897 Net profit from ordinary activities after tax 31654 30000 28000 69263 Net profit for the period/year 31654 30000 28000 69263 Basic EPS 1.63 1.54 1.42 3.57 Diluted EPS 1.63 1.54 1.42 3.57""" |
| 44 | |
| 45 | PRE_PARTICULARS_GROUP_LAYOUT = """Statement of Unaudited Financial Results (Rs. in Lakh) Quarter ended Year ended SI Particulars June 30, March 31, June 30, March 31, No. 2026 2026 2025 2026 Total income (1)+(2) 2,28,093 2,18,506 1,86,783 8,03,897 Profit after tax (10)-(11) 31,654 28,197 10,322 69,263 Basic Earnings Per Share 1.63 1.45 0.53 3.57""" |
| 46 | |
| 47 | PRE_PARTICULARS_AUDITED_LAYOUT = """Statement of Audited Financial Results (Rs. in Lakh) Quarter ended Year ended SI Particulars March 31, December 31, March 31, 2025 March 31, March 31, 2025 No. 2026 2025 2026 Total income (1)+(2) 2,18,506 2,04,740 1,84,305 8,03,897 7,20,059 Net profit after tax (10)-(11) 28,197 18,572 8,339 69,263 72,610 Basic Earnings Per Share 1.45 0.96 0.43 3.57 3.75""" |
| 48 | |
| 49 | INVALID_CALENDAR_HEADER = """Quarter ended Half year ended Year ended SI September 30, June 30, 2023 September 30, September September March 31, No. 2023 2022 30, 2023 30, 2022 2023""" |
| 50 | |
| 51 | ZODIAC_JUNE_2026 = """Statement Of Unaudited Standalone Financial Results For the Quarter ended On 30th June, 2026 (Rs. in Lakhs) Particulars Quarter Ended Year Ended 30th June 2026 31st March 2026 30th June 2025 31st March 2026 Revenue From Operations 125.50 118.00 104.25 450.00 Other Income 2.50 2.00 1.75 8.00 Total Income 128.00 120.00 106.00 458.00 Net Profit/(Loss) After Tax 9.75 8.50 7.25 31.00 Basic Earnings Per Share 0.98 0.85 0.73 3.10 Diluted Earnings Per Share 0.98 0.85 0.73 3.10""" |
| 52 | |
| 53 | |
| 54 | # Structural excerpts from the persisted Deepak Nitrite filing. The (5)/(6) |
| 55 | # cells are table row references, not profit values. |
| 56 | DEEPAK_DECEMBER_2025 = """CONSOLIDATED Statement of Unaudited Financial Results for the quarter and nine months ended 31 December 2025 (Rs. in Crore) Particulars Quarter Ended Nine Months Ended 31 December 2025 30 September 2025 31 December 2024 31 December 2025 31 December 2024 Revenue From Operations 1974.97 1901.89 1903.40 5766.74 6102.24 Net Profit for the period/year (5 • 6) 99.82 118.75 98.13 330.83 494.85 STANDALONE Statement of Unaudited Financial Results for the quarter and nine months ended 31 December 2025 (Rs. in Crore) Particulars Quarter Ended Nine Months Ended 31 December 2025 30 September 2025 31 December 2024 31 December 2025 31 December 2024 Revenue From Operations 693.59 615.92 551 .59 1921.79 1872.00 Net Profit for the period/year (5 - 6) 8.14 111.65 17.25 150.09 222.57""" |
| 57 | |
| 58 | |
| 59 | # This Electronics Mart-shaped header retains the report-level quarter/nine- |
| 60 | # month semantics while OCR loses the table's nine-month group label. |
| 61 | ELECTRONICS_MART_DECEMBER_2025 = """CONSOLIDATED Statement of Unaudited Financial Results for the third quarter and nine months ended 31 December 2025 (Rs. in Lakhs) Particulars Quarter Ended Year Ended 31 December 2025 30 September 2025 31 December 2024 31 December 2025 31 December 2024 Revenue From Operations 1000.00 900.00 800.00 2800.00 2300.00 Net Profit for the period/year 335.04 296.45 161.42 900.00 674.04""" |
| 62 | |
| 63 | |
| 64 | def _multi_period_statement( |
| 65 | current: str, |
| 66 | prior: str, |
| 67 | comparable: str, |
| 68 | annual: str, |
| 69 | *, |
| 70 | revenue: str = "100.00", |
| 71 | pat: str = "10.00", |
| 72 | ) -> str: |
| 73 | return ( |
| 74 | "Statement of Unaudited Financial Results Particulars Quarter Ended Year Ended " |
| 75 | f"{current} {prior} {comparable} {annual} " |
| 76 | "Revenue From Operations " |
| 77 | f"{revenue} 90.00 80.00 350.00 " |
| 78 | f"Profit after Tax {pat} 9.00 8.00 30.00" |
| 79 | ) |
| 80 | |
| 81 | |
| 82 | def _rolling_financial_documents() -> list[ResearchDocument]: |
| 83 | """Two durable filings exposing the latest four explicitly reported quarters.""" |
| 84 | return [ |
| 85 | _document(_multi_period_statement( |
| 86 | "30 June 2026", "31 March 2026", "31 December 2025", "31 March 2026", |
| 87 | revenue="160.00", pat="16.00", |
| 88 | )).model_copy(update={"published_at": datetime(2026, 8, 1, tzinfo=timezone.utc)}), |
| 89 | _document(_multi_period_statement( |
| 90 | "30 September 2025", "30 June 2025", "30 September 2024", "31 March 2025", |
| 91 | revenue="150.00", pat="15.00", |
| 92 | )).model_copy(update={"published_at": datetime(2025, 11, 1, tzinfo=timezone.utc)}), |
| 93 | ] |
| 94 | |
| 95 | |
| 96 | def _balance_sheet(*, current: str = "31 March 2026", prior: str = "31 March 2025", basis: str = "") -> str: |
| 97 | return ( |
| 98 | f"{basis} Balance Sheet Particulars As at {current} {prior} " |
| 99 | "Total Assets 500.00 450.00 Total Equity 120.00 110.00 " |
| 100 | "Total Liabilities 380.00 340.00 Total Current Assets 200.00 180.00 " |
| 101 | "Total Current Liabilities 150.00 140.00 Cash and Cash Equivalents 25.00 20.00 " |
| 102 | "Total Borrowings 300.00 275.00" |
| 103 | ) |
| 104 | |
| 105 | |
| 106 | def _cash_flow(*, current: str = "31 March 2026", prior: str = "31 March 2025", basis: str = "") -> str: |
| 107 | return ( |
| 108 | f"{basis} Statement of Cash Flows Particulars Year Ended {current} {prior} " |
| 109 | "Net cash from operating activities 100.00 90.00 " |
| 110 | "Net cash used in investing activities 40.00 35.00 " |
| 111 | "Net cash from financing activities 20.00 15.00 " |
| 112 | "Net increase in cash and cash equivalents 40.00 40.00 " |
| 113 | "Operating profit 200.00 180.00 Opening cash and cash equivalents 10.00 8.00" |
| 114 | ) |
| 115 | |
| 116 | |
| 117 | def test_noisy_nse_june_table_uses_current_column_and_preserves_inr_crore() -> None: |
| 118 | result = latest_quarterly_result([_document(JUNE)]) |
| 119 | assert result is not None |
| 120 | assert result.period == "2026-06-30" |
| 121 | assert result.revenue.value == Decimal("8261.11") |
| 122 | assert result.pat.value == Decimal("1927.21") |
| 123 | assert result.eps.value == Decimal("1.47") |
| 124 | assert result.eps.value != Decimal("10") |
| 125 | assert result.eps.unit == "INR per share" |
| 126 | assert result.revenue.unit == "INR crore" |
| 127 | assert result.debt_or_borrowings is None |
| 128 | |
| 129 | |
| 130 | def test_deepak_shaped_pat_rows_skip_note_references_and_keep_reporting_bases_separate() -> None: |
| 131 | periods = parsed_nse_income_statement_periods([_document(DEEPAK_DECEMBER_2025)]) |
| 132 | values = { |
| 133 | (period.period_end, period.period_type, period.reporting_basis): { |
| 134 | metric: value.value for metric, value in period.metrics |
| 135 | } |
| 136 | for period in periods |
| 137 | } |
| 138 | |
| 139 | assert values[("2025-12-31", "QUARTERLY", "CONSOLIDATED")] == { |
| 140 | "revenue": Decimal("1974.97"), "pat": Decimal("99.82"), |
| 141 | } |
| 142 | assert values[("2024-12-31", "QUARTERLY", "CONSOLIDATED")] == { |
| 143 | "revenue": Decimal("1903.40"), "pat": Decimal("98.13"), |
| 144 | } |
| 145 | assert values[("2025-12-31", "QUARTERLY", "STANDALONE")] == { |
| 146 | "revenue": Decimal("693.59"), "pat": Decimal("8.14"), |
| 147 | } |
| 148 | assert values[("2024-12-31", "QUARTERLY", "STANDALONE")] == { |
| 149 | "revenue": Decimal("551.59"), "pat": Decimal("17.25"), |
| 150 | } |
| 151 | |
| 152 | |
| 153 | def test_electronics_mart_shaped_quarter_nine_month_report_does_not_persist_cumulative_columns_as_annual() -> None: |
| 154 | periods = parsed_nse_income_statement_periods([_document(ELECTRONICS_MART_DECEMBER_2025)]) |
| 155 | values = { |
| 156 | (period.period_end, period.period_type): {metric: value.value for metric, value in period.metrics} |
| 157 | for period in periods |
| 158 | } |
| 159 | |
| 160 | assert values[("2025-12-31", "QUARTERLY")]["pat"] == Decimal("335.04") |
| 161 | assert values[("2024-12-31", "QUARTERLY")]["pat"] == Decimal("161.42") |
| 162 | assert not any(period.period_type == "ANNUAL" for period in periods) |
| 163 | |
| 164 | |
| 165 | def _income_metrics(text: str) -> dict[str, Decimal]: |
| 166 | periods = parsed_nse_income_statement_periods([_document(text)]) |
| 167 | period = next(value for value in periods if value.period_end == "2026-06-30" and value.period_type == "QUARTERLY") |
| 168 | return {metric: value.value for metric, value in period.metrics} |
| 169 | |
| 170 | |
| 171 | def test_federal_bank_style_june_2026_financial_layout_extracts_aligned_bank_result_rows() -> None: |
| 172 | period = next(value for value in parsed_nse_income_statement_periods([_document(FEDERAL_BANK_JUNE_2026)]) |
| 173 | if value.period_end == "2026-06-30" and value.period_type == "QUARTERLY") |
| 174 | |
| 175 | assert period.reporting_basis == "STANDALONE" |
| 176 | assert {metric: value.value for metric, value in period.metrics} == { |
| 177 | "revenue": Decimal("250000.00"), "pat": Decimal("30000.00"), "eps": Decimal("1.63"), |
| 178 | } |
| 179 | |
| 180 | |
| 181 | def test_shriram_style_june_2026_financial_layout_keeps_quarter_and_year_columns_aligned() -> None: |
| 182 | assert _income_metrics(SHRIRAM_JUNE_2026) == { |
| 183 | "revenue": Decimal("5050.00"), "pat": Decimal("900.00"), "eps": Decimal("12.50"), |
| 184 | } |
| 185 | |
| 186 | |
| 187 | def test_ujjivan_style_june_2026_financial_layout_supports_month_first_dates_and_bank_rows() -> None: |
| 188 | assert _income_metrics(UJJIVAN_JUNE_2026) == { |
| 189 | "revenue": Decimal("228093"), "pat": Decimal("31654"), "eps": Decimal("1.63"), |
| 190 | } |
| 191 | |
| 192 | |
| 193 | def test_pre_particulars_period_groups_associate_with_post_particulars_dates() -> None: |
| 194 | periods = parsed_nse_income_statement_periods([_document(PRE_PARTICULARS_GROUP_LAYOUT)]) |
| 195 | quarterly = { |
| 196 | period.period_end: {metric: value.value for metric, value in period.metrics} |
| 197 | for period in periods if period.period_type == "QUARTERLY" |
| 198 | } |
| 199 | annual = { |
| 200 | period.period_end: {metric: value.value for metric, value in period.metrics} |
| 201 | for period in periods if period.period_type == "ANNUAL" |
| 202 | } |
| 203 | |
| 204 | assert quarterly["2026-06-30"] == { |
| 205 | "revenue": Decimal("228093"), "pat": Decimal("31654"), "eps": Decimal("1.63"), |
| 206 | } |
| 207 | assert quarterly["2026-03-31"] == { |
| 208 | "revenue": Decimal("218506"), "pat": Decimal("28197"), "eps": Decimal("1.45"), |
| 209 | } |
| 210 | assert quarterly["2025-06-30"] == { |
| 211 | "revenue": Decimal("186783"), "pat": Decimal("10322"), "eps": Decimal("0.53"), |
| 212 | } |
| 213 | assert annual["2026-03-31"] == { |
| 214 | "revenue": Decimal("803897"), "pat": Decimal("69263"), "eps": Decimal("3.57"), |
| 215 | } |
| 216 | |
| 217 | |
| 218 | def test_pre_particulars_groups_do_not_leak_from_a_previous_statement_region() -> None: |
| 219 | text = ( |
| 220 | "Auditor discussion: Quarter ended Year ended. " |
| 221 | "Statement of Unaudited Financial Results Particulars " |
| 222 | "June 30 2026 March 31 2026 June 30 2025 March 31 2026 " |
| 223 | "Total income 100 90 80 350 Profit after tax 10 9 8 30" |
| 224 | ) |
| 225 | |
| 226 | assert _nse_statement_candidates(text, datetime(2026, 8, 1, tzinfo=timezone.utc)) == [] |
| 227 | |
| 228 | |
| 229 | def test_auditor_prose_before_pre_particulars_financial_statement_keeps_alignment() -> None: |
| 230 | text = ( |
| 231 | "Auditor report on Financial Results. The Financial Results were reviewed. " |
| 232 | + PRE_PARTICULARS_GROUP_LAYOUT |
| 233 | ) |
| 234 | |
| 235 | assert _income_metrics(text) == { |
| 236 | "revenue": Decimal("228093"), "pat": Decimal("31654"), "eps": Decimal("1.63"), |
| 237 | } |
| 238 | |
| 239 | |
| 240 | def test_pre_particulars_groups_align_mixed_explicit_and_deferred_year_cells() -> None: |
| 241 | periods = parsed_nse_income_statement_periods([_document(PRE_PARTICULARS_AUDITED_LAYOUT)]) |
| 242 | values = { |
| 243 | (period.period_end, period.period_type): {metric: value.value for metric, value in period.metrics} |
| 244 | for period in periods |
| 245 | } |
| 246 | |
| 247 | assert values[("2026-03-31", "QUARTERLY")] == { |
| 248 | "revenue": Decimal("218506"), "pat": Decimal("28197"), "eps": Decimal("1.45"), |
| 249 | } |
| 250 | assert values[("2025-12-31", "QUARTERLY")] == { |
| 251 | "revenue": Decimal("204740"), "pat": Decimal("18572"), "eps": Decimal("0.96"), |
| 252 | } |
| 253 | assert values[("2025-03-31", "QUARTERLY")] == { |
| 254 | "revenue": Decimal("184305"), "pat": Decimal("8339"), "eps": Decimal("0.43"), |
| 255 | } |
| 256 | assert values[("2026-03-31", "ANNUAL")] == { |
| 257 | "revenue": Decimal("803897"), "pat": Decimal("69263"), "eps": Decimal("3.57"), |
| 258 | } |
| 259 | assert values[("2025-03-31", "ANNUAL")] == { |
| 260 | "revenue": Decimal("720059"), "pat": Decimal("72610"), "eps": Decimal("3.75"), |
| 261 | } |
| 262 | |
| 263 | |
| 264 | def test_invalid_calendar_header_is_rejected_without_date_correction() -> None: |
| 265 | groups, dates = structured_research._tokenize_financial_header( |
| 266 | INVALID_CALENDAR_HEADER, |
| 267 | datetime(2023, 11, 1, tzinfo=timezone.utc), |
| 268 | ) |
| 269 | |
| 270 | assert groups == [] |
| 271 | assert dates == [] |
| 272 | malformed = ( |
| 273 | "Statement of Unaudited Financial Results Particulars " + INVALID_CALENDAR_HEADER |
| 274 | + " Total income 100 90 80 270 250 350 Profit after tax 10 9 8 27 25 35" |
| 275 | ) |
| 276 | assert parsed_nse_income_statement_periods([_document(malformed)]) == [] |
| 277 | |
| 278 | |
| 279 | def test_invalid_calendar_candidate_does_not_block_later_valid_statement() -> None: |
| 280 | malformed = ( |
| 281 | "Statement of Unaudited Financial Results Particulars " + INVALID_CALENDAR_HEADER |
| 282 | + " Total income 100 90 80 270 250 350 Profit after tax 10 9 8 27 25 35 " |
| 283 | ) |
| 284 | periods = parsed_nse_income_statement_periods([_document(malformed + JUNE)]) |
| 285 | values = { |
| 286 | metric: value.value |
| 287 | for period in periods |
| 288 | if period.period_end == "2026-06-30" and period.period_type == "QUARTERLY" |
| 289 | for metric, value in period.metrics |
| 290 | } |
| 291 | |
| 292 | assert values == { |
| 293 | "revenue": Decimal("8261.11"), "pat": Decimal("1927.21"), "eps": Decimal("1.47"), |
| 294 | } |
| 295 | |
| 296 | |
| 297 | def test_zodiac_style_june_2026_financial_layout_supports_basic_earnings_per_share_row() -> None: |
| 298 | period = next(value for value in parsed_nse_income_statement_periods([_document(ZODIAC_JUNE_2026)]) |
| 299 | if value.period_end == "2026-06-30" and value.period_type == "QUARTERLY") |
| 300 | |
| 301 | assert period.reporting_basis == "STANDALONE" |
| 302 | assert {metric: value.value for metric, value in period.metrics} == { |
| 303 | "revenue": Decimal("125.50"), "pat": Decimal("9.75"), "eps": Decimal("0.98"), |
| 304 | } |
| 305 | |
| 306 | |
| 307 | def test_latest_quarterly_result_discovers_nse_statement_candidates_once_per_document(monkeypatch) -> None: |
| 308 | original = structured_research._nse_statement_candidates |
| 309 | calls = 0 |
| 310 | |
| 311 | def recording_candidates(*args, **kwargs): |
| 312 | nonlocal calls |
| 313 | calls += 1 |
| 314 | return original(*args, **kwargs) |
| 315 | |
| 316 | monkeypatch.setattr(structured_research, "_nse_statement_candidates", recording_candidates) |
| 317 | |
| 318 | result = latest_quarterly_result([_document(JUNE), _document(JUNE)]) |
| 319 | |
| 320 | assert result is not None |
| 321 | assert result.period == "2026-06-30" |
| 322 | assert result.revenue.value == Decimal("8261.11") |
| 323 | assert result.pat.value == Decimal("1927.21") |
| 324 | assert result.eps.value == Decimal("1.47") |
| 325 | assert calls == 2 |
| 326 | |
| 327 | |
| 328 | def test_statement_boundary_index_preserves_heading_particulars_and_statement_end_offsets() -> None: |
| 329 | text = ( |
| 330 | "Statement of Unaudited Financial Results Particulars Quarter Ended Year Ended " |
| 331 | "30 June 2026 31 March 2026 30 June 2025 31 March 2026 " |
| 332 | "Revenue From Operations 100.00 90.00 80.00 350.00 " |
| 333 | "Profit after Tax 10.00 9.00 8.00 30.00 Basic EPS 1.00 0.90 0.80 3.50 " |
| 334 | "Statement of Assets and Liabilities Particulars As at 31 March 2026 31 March 2025" |
| 335 | ) |
| 336 | start = _STATEMENT_HEADING.search(text) |
| 337 | assert start is not None |
| 338 | |
| 339 | direct = _parse_statement_candidate(text, datetime(2026, 8, 1, tzinfo=timezone.utc), start.start(), True) |
| 340 | indexed = _parse_statement_candidate( |
| 341 | text, |
| 342 | datetime(2026, 8, 1, tzinfo=timezone.utc), |
| 343 | start.start(), |
| 344 | True, |
| 345 | boundary_index=structured_research._StatementBoundaryIndex.from_text(text), |
| 346 | ) |
| 347 | |
| 348 | assert indexed == direct |
| 349 | assert indexed is not None |
| 350 | assert indexed.current_column.period_end == "2026-06-30" |
| 351 | assert "Statement of Assets" not in indexed.region |
| 352 | |
| 353 | |
| 354 | def test_statement_candidate_parse_reuses_identical_header_tokenization_within_document(monkeypatch) -> None: |
| 355 | text = ( |
| 356 | "Particulars Quarter Ended Year Ended 30 June 2026 31 March 2026 30 June 2025 31 March 2026 " |
| 357 | "Revenue From Operations 100.00 90.00 80.00 350.00 Profit after Tax 10.00 9.00 8.00 30.00 " |
| 358 | "Particulars Notes to the financial results" |
| 359 | ) |
| 360 | original = structured_research._tokenize_financial_header |
| 361 | calls = 0 |
| 362 | |
| 363 | def recording_tokenizer(*args, **kwargs): |
| 364 | nonlocal calls |
| 365 | calls += 1 |
| 366 | return original(*args, **kwargs) |
| 367 | |
| 368 | monkeypatch.setattr(structured_research, "_tokenize_financial_header", recording_tokenizer) |
| 369 | |
| 370 | candidates = structured_research._nse_statement_candidates( |
| 371 | text, |
| 372 | datetime(2026, 8, 1, tzinfo=timezone.utc), |
| 373 | ) |
| 374 | |
| 375 | assert candidates |
| 376 | assert candidates[0].current_column.period_end == "2026-06-30" |
| 377 | assert calls == 1 |
| 378 | |
| 379 | |
| 380 | def test_statement_candidate_parse_tokenizes_distinct_headers_independently(monkeypatch) -> None: |
| 381 | text = ( |
| 382 | "Statement of Unaudited Financial Results Particulars Quarter Ended Year Ended " |
| 383 | "30 June 2026 31 March 2026 30 June 2025 31 March 2026 Revenue From Operations 100.00 90.00 80.00 350.00 " |
| 384 | "Profit after Tax 10.00 9.00 8.00 30.00 " |
| 385 | "Statement of Unaudited Financial Results Particulars Quarter Ended Year Ended " |
| 386 | "30 September 2026 30 June 2026 30 September 2025 31 March 2026 Revenue From Operations 110.00 100.00 90.00 360.00 " |
| 387 | "Profit after Tax 11.00 10.00 9.00 31.00" |
| 388 | ) |
| 389 | original = structured_research._tokenize_financial_header |
| 390 | calls = 0 |
| 391 | |
| 392 | def recording_tokenizer(*args, **kwargs): |
| 393 | nonlocal calls |
| 394 | calls += 1 |
| 395 | return original(*args, **kwargs) |
| 396 | |
| 397 | monkeypatch.setattr(structured_research, "_tokenize_financial_header", recording_tokenizer) |
| 398 | boundary_index = structured_research._StatementBoundaryIndex.from_text(text) |
| 399 | context = structured_research._StatementCandidateParseContext(boundary_index) |
| 400 | starts = [start for start, _ in boundary_index.statement_headings] |
| 401 | |
| 402 | first = _parse_statement_candidate( |
| 403 | text, datetime(2026, 8, 1, tzinfo=timezone.utc), starts[0], True, parse_context=context, |
| 404 | ) |
| 405 | second = _parse_statement_candidate( |
| 406 | text, datetime(2026, 8, 1, tzinfo=timezone.utc), starts[1], True, parse_context=context, |
| 407 | ) |
| 408 | |
| 409 | assert first is not None |
| 410 | assert second is not None |
| 411 | assert first.current_column.period_end == "2026-06-30" |
| 412 | assert second.current_column.period_end == "2026-09-30" |
| 413 | assert calls == 2 |
| 414 | |
| 415 | |
| 416 | def test_statement_candidate_tokenization_cache_does_not_leak_between_parser_invocations(monkeypatch) -> None: |
| 417 | original = structured_research._tokenize_financial_header |
| 418 | calls = 0 |
| 419 | |
| 420 | def recording_tokenizer(*args, **kwargs): |
| 421 | nonlocal calls |
| 422 | calls += 1 |
| 423 | return original(*args, **kwargs) |
| 424 | |
| 425 | monkeypatch.setattr(structured_research, "_tokenize_financial_header", recording_tokenizer) |
| 426 | |
| 427 | assert latest_quarterly_result([_document(JUNE)]) is not None |
| 428 | assert latest_quarterly_result([_document(JUNE)]) is not None |
| 429 | |
| 430 | assert calls == 2 |
| 431 | |
| 432 | |
| 433 | def test_march_quarter_uses_first_column_not_annual_column() -> None: |
| 434 | result = latest_quarterly_result([_document("Amounts in Rs. Crore Extract of Statement Of Audited Financial Results for the quarter and Year ended 31st March 2026 Particulars Quarter Ended Year Ended 31st March 31st December 31st March 31st March 31st March 2026 2025 2025 2026 2025 33 Revenue From Operations 7,335.75 6,661.13 6,722.83 27,284.15 27,152.14 3 Net Profit after Tax 1,684.31 1,500.00 1,300.00 6,427.00 6,100.00 Earning Per Share (of Rs 10 each) Basic 1.29 1.15 1.00 5.36 5.00")]) |
| 435 | assert result is not None |
| 436 | assert result.period == "2026-03-31" |
| 437 | assert result.revenue.value == Decimal("7335.75") |
| 438 | assert result.pat.value == Decimal("1684.31") |
| 439 | assert result.eps.value == Decimal("1.29") |
| 440 | assert result.revenue.value != Decimal("33") |
| 441 | assert result.pat.value != Decimal("3") |
| 442 | |
| 443 | |
| 444 | def test_december_table_ignores_nine_month_and_year_columns_and_missing_metrics_stay_missing() -> None: |
| 445 | result = latest_quarterly_result([_document("Amounts in Rs. Crore Statement of Unaudited Financial Results Particulars Quarter Ended Nine Month Ended Year Ended 31 December 2025 30 September 2025 31 December 2024 31 December 2025 31 December 2024 31 March 2025 Total Revenue From operations 6,661.13 6,400.00 5,900.00 19,500.00 18,000.00 25,000.00 Total Income 6,919.23 6,700.00 6,200.00 20,200.00 19,000.00 26,000.00 Finance costs 4,812.02 4,600.00 4,100.00 14,100.00 12,300.00 18,900.00 Profit for the period 1,802.19 1,500.00 1,300.00 5,100.00 4,000.00 6,000.00")]) |
| 446 | assert result is not None |
| 447 | assert result.period == "2025-12-31" |
| 448 | assert result.revenue.value == Decimal("6661.13") |
| 449 | assert result.pat.value == Decimal("1802.19") |
| 450 | assert result.revenue.value != Decimal("6919.23") |
| 451 | assert result.eps is None |
| 452 | assert result.ebitda is None |
| 453 | |
| 454 | |
| 455 | def test_multi_period_parser_emits_explicit_quarterly_and_annual_columns() -> None: |
| 456 | periods = parsed_nse_income_statement_periods([_document(JUNE)]) |
| 457 | values = { |
| 458 | (period.period_end, period.period_type): {metric: value.value for metric, value in period.metrics} |
| 459 | for period in periods |
| 460 | } |
| 461 | assert values[("2026-06-30", "QUARTERLY")]["revenue"] == Decimal("8261.11") |
| 462 | assert values[("2026-06-30", "QUARTERLY")]["pat"] == Decimal("1927.21") |
| 463 | assert values[("2026-06-30", "QUARTERLY")]["eps"] == Decimal("1.47") |
| 464 | assert values[("2026-03-31", "QUARTERLY")]["revenue"] == Decimal("7335.75") |
| 465 | assert values[("2026-03-31", "ANNUAL")]["revenue"] == Decimal("27284.15") |
| 466 | # A March quarter remains a quarter because the table header says so. |
| 467 | assert ("2026-03-31", "QUARTERLY") in values |
| 468 | |
| 469 | |
| 470 | def test_multi_period_parser_keeps_consolidated_and_standalone_periods_distinct() -> None: |
| 471 | consolidated = _document(JUNE.replace("Extract of Statement", "Consolidated Statement")) |
| 472 | standalone = _document(JUNE.replace("Extract of Statement", "Standalone Statement").replace("8,261.11", "7,000.00")) |
| 473 | periods = parsed_nse_income_statement_periods([consolidated, standalone]) |
| 474 | values = { |
| 475 | period.reporting_basis: dict(period.metrics)["revenue"].value |
| 476 | for period in periods |
| 477 | if period.period_end == "2026-06-30" and period.period_type == "QUARTERLY" |
| 478 | } |
| 479 | assert values == {"CONSOLIDATED": Decimal("8261.11"), "STANDALONE": Decimal("7000.00")} |
| 480 | |
| 481 | |
| 482 | def test_multi_period_parser_emits_annual_only_when_explicitly_present() -> None: |
| 483 | text = ( |
| 484 | "Statement of Audited Financial Results Particulars Year Ended " |
| 485 | "31 March 2026 31 March 2025 Revenue From Operations 350.00 300.00 " |
| 486 | "Profit after Tax 30.00 25.00" |
| 487 | ) |
| 488 | periods = parsed_nse_income_statement_periods([_document(text)]) |
| 489 | assert {(period.period_end, period.period_type) for period in periods} == { |
| 490 | ("2026-03-31", "ANNUAL"), ("2025-03-31", "ANNUAL"), |
| 491 | } |
| 492 | |
| 493 | |
| 494 | def test_balance_sheet_parser_maps_explicit_as_at_columns_without_derivation() -> None: |
| 495 | periods = parsed_nse_balance_sheet_periods([_document(_balance_sheet())]) |
| 496 | values = {period.period_end: dict(period.metrics) for period in periods} |
| 497 | assert set(values) == {"2026-03-31", "2025-03-31"} |
| 498 | assert all(period.period_type == "AS_AT" for period in periods) |
| 499 | assert values["2026-03-31"]["total_assets"].value == Decimal("500") |
| 500 | assert values["2026-03-31"]["total_equity"].value == Decimal("120") |
| 501 | assert values["2026-03-31"]["total_liabilities"].value == Decimal("380") |
| 502 | assert values["2026-03-31"]["current_assets"].value == Decimal("200") |
| 503 | assert values["2026-03-31"]["current_liabilities"].value == Decimal("150") |
| 504 | assert values["2026-03-31"]["cash_and_cash_equivalents"].value == Decimal("25") |
| 505 | assert values["2026-03-31"]["total_debt"].value == Decimal("300") |
| 506 | assert "total_debt" not in dict(parsed_nse_balance_sheet_periods([_document( |
| 507 | _balance_sheet().replace("Total Borrowings 300.00 275.00", "Long Term Borrowings 200.00 180.00") |
| 508 | )])[0].metrics) |
| 509 | |
| 510 | |
| 511 | def test_balance_sheet_row_boundaries_skip_ocr_separator_without_rejecting_genuine_one() -> None: |
| 512 | text = ( |
| 513 | "Balance Sheet Particulars As at 31 March 2026 31 March 2025 " |
| 514 | "Total Non-Financial Assets 11.691.51 11.844.60 11 " |
| 515 | "Total Assets I 480.742.87 I 481.218.19 " |
| 516 | "Total Non-Financial Liabilities 312.75 172.19 11 " |
| 517 | "Total Liabilities I 426.318.91 I 430.445.98 " |
| 518 | "Cash and Cash Equivalents 1 2" |
| 519 | ) |
| 520 | values = dict(parsed_nse_balance_sheet_periods([_document(text)])[0].metrics) |
| 521 | assert values["total_assets"].value == Decimal("480742.87") |
| 522 | assert values["total_liabilities"].value == Decimal("426318.91") |
| 523 | assert values["cash_and_cash_equivalents"].value == Decimal("1") |
| 524 | |
| 525 | |
| 526 | def test_balance_sheet_parser_keeps_reporting_bases_distinct() -> None: |
| 527 | periods = parsed_nse_balance_sheet_periods([ |
| 528 | _document(_balance_sheet(basis="Consolidated")), |
| 529 | _document(_balance_sheet(basis="Standalone").replace("500.00", "400.00", 1)), |
| 530 | ]) |
| 531 | values = { |
| 532 | period.reporting_basis: dict(period.metrics)["total_assets"].value |
| 533 | for period in periods if period.period_end == "2026-03-31" |
| 534 | } |
| 535 | assert values == {"CONSOLIDATED": Decimal("500"), "STANDALONE": Decimal("400")} |
| 536 | |
| 537 | |
| 538 | def test_cash_flow_parser_emits_only_explicit_annual_flow_rows() -> None: |
| 539 | periods = parsed_nse_cash_flow_periods([_document(_cash_flow())]) |
| 540 | values = {period.period_end: dict(period.metrics) for period in periods} |
| 541 | assert set(values) == {"2026-03-31", "2025-03-31"} |
| 542 | assert all(period.period_type == "ANNUAL" for period in periods) |
| 543 | assert values["2026-03-31"]["cash_flow_from_operating_activities"].value == Decimal("100") |
| 544 | assert values["2026-03-31"]["cash_flow_from_investing_activities"].value == Decimal("40") |
| 545 | assert values["2026-03-31"]["cash_flow_from_financing_activities"].value == Decimal("20") |
| 546 | assert values["2026-03-31"]["net_change_in_cash"].value == Decimal("40") |
| 547 | assert parsed_nse_cash_flow_periods([_document(_cash_flow().replace("Year Ended", "As at"))]) == [] |
| 548 | |
| 549 | |
| 550 | def test_cash_flow_parser_keeps_reporting_bases_distinct() -> None: |
| 551 | periods = parsed_nse_cash_flow_periods([ |
| 552 | _document(_cash_flow(basis="Consolidated")), |
| 553 | _document(_cash_flow(basis="Standalone").replace("100.00", "70.00", 1)), |
| 554 | ]) |
| 555 | values = {period.reporting_basis: dict(period.metrics)["cash_flow_from_operating_activities"].value |
| 556 | for period in periods if period.period_end == "2026-03-31"} |
| 557 | assert values == {"CONSOLIDATED": Decimal("100"), "STANDALONE": Decimal("70")} |
| 558 | |
| 559 | |
| 560 | def test_cash_flow_two_quarters_and_explicit_year_ended_emits_only_annual_column() -> None: |
| 561 | text = ( |
| 562 | "Statement for the Cash Fl OWS Particulars Quarter Ended Quarter Ended Year Ended " |
| 563 | "30 June 2025 30 June 2024 31 March 2025 " |
| 564 | "Net Cash Flow/(Used) in Operating Activities (A) 1.467.53 4,641.47 8.229.57 " |
| 565 | "Net Cash Flow/(used) in Investing Activities (B) '0.241 (0.11) '0.lot " |
| 566 | "Net Cash Generated By/(Used ln) Financing Activities (C) '6.975.lot (4.640.381 12.571.951 " |
| 567 | "Net Increase in Cash and Cash Equivalents (A+B+C) (5,507.81) 0.98 5,657.52" |
| 568 | ) |
| 569 | periods = parsed_nse_cash_flow_periods([_document(text)]) |
| 570 | assert len(periods) == 1 |
| 571 | assert periods[0].period_end == "2025-03-31" |
| 572 | assert periods[0].period_type == "ANNUAL" |
| 573 | values = dict(periods[0].metrics) |
| 574 | assert values["net_change_in_cash"].value == Decimal("5657.52") |
| 575 | assert values["cash_flow_from_operating_activities"].value == Decimal("8229.57") |
| 576 | assert "cash_flow_from_investing_activities" not in values |
| 577 | assert "cash_flow_from_financing_activities" not in values |
| 578 | |
| 579 | |
| 580 | def test_generic_reordered_statement_layout_maps_quarter_columns_not_annual_columns() -> None: |
| 581 | text = "Amounts in Rs. Crore Statement of Audited Financial Results Particulars Year Ended Quarter Ended 31 March 2026 31 March 2025 31 March 2026 31 December 2025 31 March 2025 Revenue From Operations 27,284.15 27,152.14 7,335.75 6,661.13 6,722.83 Profit after Tax 6,427.00 6,100.00 1,684.31 1,500.00 1,300.00" |
| 582 | result = latest_quarterly_result([_document(text)]) |
| 583 | assert result is not None |
| 584 | assert result.period == "2026-03-31" |
| 585 | assert result.revenue.value == Decimal("7335.75") |
| 586 | assert result.pat.value == Decimal("1684.31") |
| 587 | |
| 588 | |
| 589 | def test_standalone_and_consolidated_statements_are_not_combined() -> None: |
| 590 | text = "Amounts in Rs. Crore Standalone Statement of Unaudited Financial Results Particulars Quarter Ended Year Ended 30 June 2026 31 March 2026 30 June 2025 31 March 2026 Revenue From Operations 100.00 90.00 80.00 350.00 Profit after Tax 10.00 9.00 8.00 30.00 Consolidated Statement of Unaudited Financial Results Particulars Quarter Ended Year Ended 30 June 2026 31 March 2026 30 June 2025 31 March 2026 Revenue From Operations 200.00 190.00 180.00 700.00 Profit after Tax 20.00 19.00 18.00 60.00" |
| 591 | result = latest_quarterly_result([_document(text)]) |
| 592 | assert result is not None |
| 593 | assert result.reporting_basis == "STANDALONE" |
| 594 | assert result.revenue.value == Decimal("100") |
| 595 | |
| 596 | |
| 597 | def test_ambiguous_or_unaligned_quarter_table_is_rejected_instead_of_fabricating_values() -> None: |
| 598 | result = latest_quarterly_result([_document("Quarter Ended Particulars Revenue From Operations 8,261.11 Profit after Tax 1,927.21 Earning Per Share (of Rs 10 each) Basic 1.47")]) |
| 599 | assert result is None |
| 600 | |
| 601 | |
| 602 | def test_absurd_ocr_header_year_and_no_valid_metric_cells_produce_parse_no_result() -> None: |
| 603 | document = _document("Statement of Unaudited Financial Results Particulars Quarter Ended Year Ended 30 September 2005 30 June 2005 30 September 2004 31 March 2005 Revenue From Operations Note 3") |
| 604 | assert latest_quarterly_result([document]) is None |
| 605 | repository = ResearchRepository() |
| 606 | repository._persistence = _FactRecorder() |
| 607 | assert repository._persist_official_financial_facts(document) == (False, 0) |
| 608 | |
| 609 | |
| 610 | def test_strict_ocr_numeric_cells_accept_whole_token_repairs_and_reject_partial_digits() -> None: |
| 611 | assert _financial_number("7,335.7S") == Decimal("7335.75") |
| 612 | assert _financial_number("Z7,284.1S") == Decimal("27284.15") |
| 613 | assert _financial_number("6.661.13") == Decimal("6661.13") |
| 614 | assert _financial_number("|.33S.rs") is None |
| 615 | assert _financial_number("\\.9Z|.21") is None |
| 616 | |
| 617 | |
| 618 | def test_structural_row_classifier_distinguishes_pat_from_pbt_tax_and_comprehensive_income() -> None: |
| 619 | assert _classify_financial_row("Profit for the period from continuing operations") == "PAT" |
| 620 | assert _classify_financial_row("Profit before tax") == "PBT" |
| 621 | assert _classify_financial_row("Proflt for the perlod fr0m continuinq operati0ns") == "PAT" |
| 622 | assert _classify_financial_row("Tax expense") is None |
| 623 | assert _classify_financial_row("Total comprehensive income for the period") is None |
| 624 | assert "PROFIT" in _row_semantic_tokens("Proflt for the perlod") |
| 625 | |
| 626 | |
| 627 | def test_later_clean_statement_beats_earlier_corrupted_compact_statement() -> None: |
| 628 | text = """Extract of Statement of Audited Financial Results Particulars Quarter Ended Year Ended 31St March 3lst December 31st M8rcli 31st March 31st March 2026 20ZS Z02S ZOZ6 Z025 Revenue F.om oDeraoons |.33S.rs 6,661.13 6.722.83 Z7,284.1S Z7,1SZ.14 Net ProfJt 1,684.31 1,cO2.19 I.681.87 7,009.17 6,502.00 Statement of Audited Financial Results for the quarter and year ended 31st March 2026 Particulars Quarter Ended Year Ended 31st March 31st December 31st March 31st March 31st March 2026 2025 2025 2026 2025 Total Revenue From operations 7,335.75 6,661.13 6,722.83 27,284.15 27,152.14 Profit for the period From continuing operations 1,684.31 1,802.19 1,681.87 7,009.17 6,502.00 Earnings per equity share Basic 1.29 1.38 1.29 5.36 4.98""" |
| 629 | result = latest_quarterly_result([_document(text)]) |
| 630 | assert result is not None |
| 631 | assert result.period == "2026-03-31" |
| 632 | assert result.revenue.value == Decimal("7335.75") |
| 633 | assert result.pat.value == Decimal("1684.31") |
| 634 | assert result.eps.value == Decimal("1.29") |
| 635 | |
| 636 | |
| 637 | def test_later_clean_june_statement_beats_compact_extract_without_repairing_bad_pat() -> None: |
| 638 | text = """Extract of Statement of Unaudited Financial Results for the quarter ended 30 June 2026 Particulars Quarter Ended Year Ended 30th June 31st March 30th June 31st March Z026 2026 2025 2026 Revenue From Operations 8,261.11 7,335.7S 6,915.38 27,284.1S Net Pro{it for the period after Tax \\.9Z|.21 1,684.31 1,745.69 i.009.\1 Earning Per Share (of Rs 10 each) Basic (Rs.) 1.47 1.29 1.34 5.36 Statement of Unaud.ited Financial Results for the quarter ended 30th June ZOZ6 (Amounts in Rs. Crore, unless stated otherwise) Particulars Quarter Ended Year Ended 30tli June 31st March 30th June 31st March Z026 20Z6 2025 2026 (I) Total Revenue From operations 8,261.11 7,335.75 6,915.38 27,Z84.15 (X) Profit for the period From continuing operations (VIII-IX) 1,927.21 1,684.31 1,745.69 7,009.17 Earnings per equity share (Face Value of Rs.10/- per share) -Basic (Rs.) 1.47 1.29 1.34 5.36""" |
| 639 | result = latest_quarterly_result([_document(text)]) |
| 640 | assert result is not None |
| 641 | assert result.period == "2026-06-30" |
| 642 | assert result.revenue.value == Decimal("8261.11") |
| 643 | assert result.pat.value == Decimal("1927.21") |
| 644 | assert result.eps.value == Decimal("1.47") |
| 645 | assert result.revenue.value != Decimal("27284.15") |
| 646 | assert result.eps.value != Decimal("10") |
| 647 | |
| 648 | |
| 649 | def test_production_shaped_june_text_selects_later_full_statement_over_compact_extract() -> None: |
| 650 | text = """PDF_PAGE 1 Extract of Statement of Unaudited Financial Results for the quaiter ended 30\" Jiine 2026 (Amounts in Rs. Crore, unless stated otherwise) Particulars Quarter Ended Year Ended 30th Ju ne 31st March 30th June 31st March Z026 2026 2025 2026 Unaudited Audited Unaud-ited Audited (I) Revenue From Operations 8,261.11 7,335.7S 6,915.38 27,284.1S (VIII) Profit Before Exceptional Items and Tax 1,927.21 1,684.31 1,745.69 7,009.17 Net Profit for the period after Tax \\.9Z|.21 1,684.31 1,745.69 i.009.\1 Basic EPS 1.47 1.29 1.34 5.36 PDF_PAGE 3 Statement of Unaud.ited Financial Results for the quarter ended 30th June ZOZ6 (Amounts in Rs. Crore, unless stated otherwise) Particulars Quarter Ended Year Ended 30tli June 31st March 30th June 31st March Z026 20Z6 2025 2026 Unaudited Audited Unaudited Audited (I) Total Revenue From operations 8,261.11 7,335.75 6,915.38 27,284.15 (VIII) Profit Before Exceptional Items and Tax 1,927.21 1,684.31 1,745.69 7,009.17 (X) Profit for the period From contjp▒▒i?gop,engtions (VIII-lx) 1,927.21 1,684.31 1,745.69 7,009.17 Other Comprehensive Income (B) (I) Items that will be reclassified to profit or loss -Effective portion of gains and loss on cash flow hedging instruments Earnings per equity share (Face Value of Rs.10/- per share) Basic EPS 1.47 1.29 1.34 5.36""" |
| 651 | document = _document(text) |
| 652 | compact_start = text.index("Extract of Statement") |
| 653 | full_start = text.index("Statement of Unaud.ited") |
| 654 | compact = _parse_statement_candidate(text, document.published_at, compact_start, True) |
| 655 | full = _parse_statement_candidate(text, document.published_at, full_start, True) |
| 656 | assert compact is not None |
| 657 | assert full is not None |
| 658 | assert "Basic EPS" in full.region |
| 659 | assert _statement_quality(compact) == (2, 0, 4, 1) |
| 660 | assert _statement_quality(full) > _statement_quality(compact) |
| 661 | statement = _parse_nse_income_statement(text, document.published_at) |
| 662 | assert statement is not None |
| 663 | assert statement.region.lstrip().startswith("Statement of Unaud.ited") |
| 664 | result = latest_quarterly_result([document]) |
| 665 | assert result is not None |
| 666 | assert result.period == "2026-06-30" |
| 667 | assert result.revenue.value == Decimal("8261.11") |
| 668 | assert result.pat.value == Decimal("1927.21") |
| 669 | assert result.eps.value == Decimal("1.47") |
| 670 | assert _financial_number("\\.9Z|.21") is None |
| 671 | |
| 672 | |
| 673 | def test_production_shaped_march_text_accepts_clean_audlted_heading_after_corrupt_extract() -> None: |
| 674 | text = """PDF_PAGE 1 Extract af Statement Of Andlted FJnanclal Results for the quarter and Year ended 31st Ma.ch 2026 Partlculars Quarte. Ended Year Ended 31St March 3lst De(ember 31st M8rcli 31st March 31st March 2026 20ZS Z02S ZOZ6 Z025 Revenue F.om oDeraoons |.33S.rs 6,661.13 6.722.83 Z7,284.1S Z7,1SZ.14 PDF_PAGE 3 Statement of Audlted Fl nclal Results for the quarter and year ended 315t March 2026 Particulars Quarter Ended Year Ended Year Ended 31st March 3lst December 3Lst March 31st March 31st March 2026 2025 2025 2026 2025 Total Revenue From operations 7,335.75 6,661.13 6,722.83 27,284.15 27,152.14 Profit for the period 1,684.31 1,802.19 1,681.87 7,009.17 6,502.00 Earnings per equity share Basic 1.29 1.38 1.29 5.36 4.98""" |
| 675 | document = _document(text) |
| 676 | full_start = text.index("Statement of Audlted") |
| 677 | assert _STATEMENT_HEADING.search(text, full_start).start() == full_start |
| 678 | header = "Quarter Ended Year Ended Year Ended 31st March 3lst December 3Lst March 31st March 31st March 2026 2025 2025 2026 2025" |
| 679 | dates = _header_dates(header, document.published_at, ["quarter", "year", "year"]) |
| 680 | assert dates == ["2026-03-31", "2025-12-31", "2025-03-31", "2026-03-31", "2025-03-31"] |
| 681 | assert [(column.period_end, column.period_type) for column in _financial_columns(["quarter", "year"], dates)] == [ |
| 682 | ("2026-03-31", "QUARTERLY"), ("2025-12-31", "QUARTERLY"), ("2025-03-31", "QUARTERLY"), |
| 683 | ("2026-03-31", "ANNUAL"), ("2025-03-31", "ANNUAL"), |
| 684 | ] |
| 685 | full = _parse_statement_candidate(text, document.published_at, full_start, True) |
| 686 | assert full is not None |
| 687 | assert [(column.index, column.period_end, column.period_type) for column in full.columns] == [ |
| 688 | (0, "2026-03-31", "QUARTERLY"), (1, "2025-12-31", "QUARTERLY"), |
| 689 | (2, "2025-03-31", "QUARTERLY"), (3, "2026-03-31", "ANNUAL"), |
| 690 | (4, "2025-03-31", "ANNUAL"), |
| 691 | ] |
| 692 | assert full.current_column.index == 0 |
| 693 | statement = _parse_nse_income_statement(text, document.published_at) |
| 694 | assert statement is not None |
| 695 | assert statement.region.lstrip().startswith("Statement of Audlted") |
| 696 | result = latest_quarterly_result([document]) |
| 697 | assert result is not None |
| 698 | assert result.period == "2026-03-31" |
| 699 | assert result.revenue.value == Decimal("7335.75") |
| 700 | assert result.pat.value == Decimal("1684.31") |
| 701 | assert result.eps.value == Decimal("1.29") |
| 702 | assert _financial_number("|.33S.rs") is None |
| 703 | |
| 704 | |
| 705 | def test_production_shaped_december_text_accepts_ocr_heading_and_quarter_nine_month_columns() -> None: |
| 706 | text = """PDF_PAGE 2 Statement Of umaudtoed Flnanclal Results for the quarter and nine month ended 31 De[embei 2025 P8mculars Quar`er Ended Nlne Momh Ended Year Ended 31 December 30 September 31 December 31 December 31 December 31 March 2025 2025 2024 2025 2024 2025 Revenue From operations 6.661.13 6,371.89 6,763.43 19,948.40 20,428.40 27,152.14 Profit for the period From continuing operations 1,802.19 1,776.98 1,630.66 5,324.86 4,820.13 6,502.00 PDF_PAGE 4 Statemeot of Unaudlted Financial Results for the quarter and nine month ended 31 Deceli`I]er 2025 parriculars Quarter Ended Nine Month Ended year Ended 31 December 30 Se|)tember 31 December 31 D▒cembe] 31 December Slat March 2025 2025 20Z4 20Z5 2024 2025 Total Revenue From operations 6,661.1304 6,371.89 6,763.43 19,948.40024 20,428.40032 27,152.14072 Profit for the period From continuing operations 1,802.19 1,776.98 1,630.66 5,324.86 4,820.13 6,502.00 PDF_PAGE 8 Statement of unaudited financial results auditor report Press release PAT Rs 1,802.19 crore""" |
| 707 | document = _document(text) |
| 708 | full_start = text.index("Statemeot") |
| 709 | assert _STATEMENT_HEADING.search(text, full_start).start() == full_start |
| 710 | header = "Quarter Ended Nine Month Ended year Ended 31 December 30 Se|)tember 31 December 31 D▒cembe] 31 December Slat March 2025 2025 20Z4 20Z5 2024 2025" |
| 711 | dates = _header_dates(header, document.published_at, ["quarter", "nine month", "year"]) |
| 712 | assert dates == ["2025-12-31", "2025-09-30", "2024-12-31", "2025-12-31", "2024-12-31", "2025-03-31"] |
| 713 | assert [column.period_type for column in _financial_columns(["quarter", "nine month", "year"], dates)] == ["QUARTERLY", "QUARTERLY", "QUARTERLY", "NINE_MONTH", "NINE_MONTH", "ANNUAL"] |
| 714 | full = _parse_statement_candidate(text, document.published_at, full_start, True) |
| 715 | assert full is not None |
| 716 | assert full.current_column.period_end == "2025-12-31" |
| 717 | assert full.current_column.period_type == "QUARTERLY" |
| 718 | statement = _parse_nse_income_statement(text, document.published_at) |
| 719 | assert statement is not None |
| 720 | assert statement.region.lstrip().startswith("Statement Of umaudtoed") |
| 721 | assert "Press release" not in statement.region |
| 722 | result = latest_quarterly_result([document]) |
| 723 | assert result is not None |
| 724 | assert result.period == "2025-12-31" |
| 725 | assert result.revenue.value == Decimal("6661.13") |
| 726 | assert result.pat.value == Decimal("1802.19") |
| 727 | assert result.revenue.value != Decimal("19500") |
| 728 | |
| 729 | |
| 730 | def test_exact_irfc_normalized_text_fixtures_extract_official_quarterly_facts() -> None: |
| 731 | fixtures = { |
| 732 | "irfc_2026_06_30_normalized.txt": ("2026-06-30", "8261.11", "1927.21", "1.47"), |
| 733 | "irfc_2026_03_31_normalized.txt": ("2026-03-31", "7335.75", "1684.31", "1.29"), |
| 734 | "irfc_2025_12_31_normalized.txt": ("2025-12-31", "6661.13", "1802.19", None), |
| 735 | } |
| 736 | root = Path(__file__).parent / "fixtures" / "nse" / "irfc" |
| 737 | for filename, (period, revenue, pat, eps) in fixtures.items(): |
| 738 | result = latest_quarterly_result([_document((root / filename).read_text(encoding="utf-8"))]) |
| 739 | assert result is not None, filename |
| 740 | assert result.period == period |
| 741 | assert result.revenue.value == Decimal(revenue) |
| 742 | assert result.pat.value == Decimal(pat) |
| 743 | if eps is not None: |
| 744 | assert result.eps.value == Decimal(eps) |
| 745 | |
| 746 | |
| 747 | def test_direct_aligned_eps_outranks_fallback_regardless_of_candidate_order(monkeypatch) -> None: |
| 748 | text = (Path(__file__).parent / "fixtures" / "nse" / "irfc" / "irfc_2026_06_30_normalized.txt").read_text(encoding="utf-8") |
| 749 | document = _document(text) |
| 750 | original = structured_research._nse_statement_candidates |
| 751 | candidates = original(text, document.published_at, require_quarterly=False) |
| 752 | assert len(candidates) >= 2 |
| 753 | monkeypatch.setattr( |
| 754 | structured_research, |
| 755 | "_nse_statement_candidates", |
| 756 | lambda *_args, **_kwargs: list(reversed(candidates)), |
| 757 | ) |
| 758 | |
| 759 | periods = parsed_nse_income_statement_periods([document]) |
| 760 | values = { |
| 761 | (period.period_end, period.period_type): dict(period.metrics) |
| 762 | for period in periods |
| 763 | } |
| 764 | assert values[("2026-03-31", "ANNUAL")]["eps"].value == Decimal("5.36") |
| 765 | assert values[("2026-06-30", "QUARTERLY")]["eps"].value == Decimal("1.47") |
| 766 | assert values[("2026-03-31", "QUARTERLY")]["eps"].value == Decimal("1.29") |
| 767 | |
| 768 | |
| 769 | def test_eps_fallback_remains_available_without_a_direct_aligned_basic_row() -> None: |
| 770 | text = _multi_period_statement("30 June 2026", "31 March 2026", "30 June 2025", "31 March 2026") |
| 771 | text += " 1.47 1.29 1.34 5.36 Earnings per equity share (Face Value of Rs.10/- per share) -Basic (Rs.)" |
| 772 | |
| 773 | periods = parsed_nse_income_statement_periods([_document(text)]) |
| 774 | values = { |
| 775 | (period.period_end, period.period_type): dict(period.metrics) |
| 776 | for period in periods |
| 777 | } |
| 778 | assert values[("2026-03-31", "ANNUAL")]["eps"].value == Decimal("5.36") |
| 779 | |
| 780 | |
| 781 | def test_direct_aligned_diluted_eps_outranks_malformed_fallback_when_basic_is_unaligned() -> None: |
| 782 | text = (Path(__file__).parent / "fixtures" / "nse" / "irfc" / "irfc_2026_03_31_normalized.txt").read_text(encoding="utf-8") |
| 783 | |
| 784 | periods = parsed_nse_income_statement_periods([_document(text)]) |
| 785 | values = { |
| 786 | (period.period_end, period.period_type): dict(period.metrics) |
| 787 | for period in periods |
| 788 | } |
| 789 | assert values[("2026-03-31", "ANNUAL")]["eps"].value == Decimal("5.36") |
| 790 | assert values[("2025-03-31", "ANNUAL")]["eps"].value == Decimal("4.98") |
| 791 | assert values[("2025-12-31", "QUARTERLY")]["eps"].value == Decimal("1.38") |
| 792 | |
| 793 | |
| 794 | class _FactRecorder: |
| 795 | def __init__(self): self.facts = [] |
| 796 | def load_financial_facts(self, instrument_ids=None): |
| 797 | return [fact for fact in self.facts if instrument_ids is None or fact.key.instrument_id in instrument_ids] |
| 798 | def upsert_financial_fact(self, fact, **_kwargs): self.facts.append(fact); return True |
| 799 | |
| 800 | |
| 801 | def test_repository_persists_official_nse_fact_with_canonical_period_end() -> None: |
| 802 | repository = ResearchRepository() |
| 803 | recorder = _FactRecorder() |
| 804 | repository._persistence = recorder |
| 805 | repository._persist_official_financial_facts(_document(JUNE)) |
| 806 | revenue = next(fact for fact in recorder.facts if fact.key.metric == "revenue") |
| 807 | eps = next(fact for fact in recorder.facts if fact.key.metric == "eps") |
| 808 | assert revenue.key.period_end == "2026-06-30" |
| 809 | assert revenue.source_tier == FactSourceTier.OFFICIAL_NSE |
| 810 | assert revenue.source_provider == "NSE" |
| 811 | assert revenue.value.source_url == "https://nsearchives.nseindia.com/corporate/result.pdf" |
| 812 | assert eps.key.period_end == "2026-06-30" |
| 813 | assert eps.value.value == Decimal("1.47") |
| 814 | assert eps.value.unit == "INR per share" |
| 815 | assert all(fact.key.metric != "debt_or_borrowings" for fact in recorder.facts) |
| 816 | |
| 817 | |
| 818 | def _document_fact(document: ResearchDocument, metric: str, period: str, period_type: str, basis: str | None, value: str) -> FinancialFact: |
| 819 | return FinancialFact( |
| 820 | FinancialFactKey(document.instrument_id, metric, period, period_type, basis), |
| 821 | ProvenancedValue( |
| 822 | value=Decimal(value), unit="INR crore", source_url=document.canonical_url, |
| 823 | source_name=document.source_name, source_type=str(document.source_type), retrieved_at=document.retrieved_at, |
| 824 | ), |
| 825 | FactSourceTier.OFFICIAL_NSE, "NSE", str(document.document_id), SourceMode.REAL, |
| 826 | ) |
| 827 | |
| 828 | |
| 829 | def test_document_scoped_reconciliation_replaces_deepak_values_without_touching_other_document_facts() -> None: |
| 830 | repository = ResearchRepository(persistence=SqliteResearchPersistence()) |
| 831 | document = _document(DEEPAK_DECEMBER_2025).model_copy(update={ |
| 832 | "document_id": UUID("2ebb7d9a-64ce-4431-93ca-2585d16d6252"), |
| 833 | }) |
| 834 | # The persisted source text proves the standalone comparative revenue is |
| 835 | # 551.59; seed the observed stale 551.00 alongside the PAT row references. |
| 836 | for basis in ("CONSOLIDATED", "STANDALONE"): |
| 837 | repository._persistence.upsert_financial_fact(_document_fact(document, "pat", "2025-12-31", "QUARTERLY", basis, "5")) |
| 838 | repository._persistence.upsert_financial_fact(_document_fact(document, "pat", "2024-12-31", "QUARTERLY", basis, "6")) |
| 839 | repository._persistence.upsert_financial_fact(_document_fact(document, "revenue", "2024-12-31", "QUARTERLY", "STANDALONE", "551.00")) |
| 840 | unrelated = FinancialFact( |
| 841 | FinancialFactKey(document.instrument_id, "revenue", "2023-12-31", "QUARTERLY", "STANDALONE"), |
| 842 | ProvenancedValue(value=Decimal("77"), unit="INR crore", source_url="https://other.example", source_name="Other", retrieved_at=document.retrieved_at), |
| 843 | FactSourceTier.OFFICIAL_NSE, "NSE", "other-official-document", SourceMode.REAL, |
| 844 | ) |
| 845 | repository._persistence.upsert_financial_fact(unrelated) |
| 846 | |
| 847 | parsed, _written = repository._reconcile_persisted_official_financial_document(document) |
| 848 | assert parsed |
| 849 | facts = {fact.key: fact for fact in repository.financial_facts_for(document.instrument_id)} |
| 850 | assert facts[FinancialFactKey(document.instrument_id, "pat", "2025-12-31", "QUARTERLY", "CONSOLIDATED")].value.value == Decimal("99.82") |
| 851 | assert facts[FinancialFactKey(document.instrument_id, "pat", "2024-12-31", "QUARTERLY", "CONSOLIDATED")].value.value == Decimal("98.13") |
| 852 | assert facts[FinancialFactKey(document.instrument_id, "pat", "2025-12-31", "QUARTERLY", "STANDALONE")].value.value == Decimal("8.14") |
| 853 | assert facts[FinancialFactKey(document.instrument_id, "pat", "2024-12-31", "QUARTERLY", "STANDALONE")].value.value == Decimal("17.25") |
| 854 | assert facts[FinancialFactKey(document.instrument_id, "revenue", "2024-12-31", "QUARTERLY", "STANDALONE")].value.value == Decimal("551.59") |
| 855 | assert facts[unrelated.key].source_identity == "other-official-document" |
| 856 | assert all(fact.source_identity == str(document.document_id) for key, fact in facts.items() if key != unrelated.key) |
| 857 | before = list(repository.financial_facts_for(document.instrument_id)) |
| 858 | assert repository._reconcile_persisted_official_financial_document(document)[0] |
| 859 | assert repository.financial_facts_for(document.instrument_id) == before |
| 860 | |
| 861 | |
| 862 | def test_document_scoped_reconciliation_deletes_electronics_mart_obsolete_annual_keys_only_for_that_source() -> None: |
| 863 | repository = ResearchRepository(persistence=SqliteResearchPersistence()) |
| 864 | document = _document(ELECTRONICS_MART_DECEMBER_2025).model_copy(update={ |
| 865 | "document_id": UUID("df9d629e-a0ee-4290-ada9-bbb9da6d06df"), |
| 866 | }) |
| 867 | for period, value in (("2025-12-31", "335.04"), ("2024-12-31", "674.04")): |
| 868 | repository._persistence.upsert_financial_fact(_document_fact(document, "pat", period, "ANNUAL", "CONSOLIDATED", value)) |
| 869 | other = FinancialFact( |
| 870 | FinancialFactKey(document.instrument_id, "pat", "2023-12-31", "ANNUAL", "CONSOLIDATED"), |
| 871 | ProvenancedValue(value=Decimal("99"), unit="INR crore", source_url="https://other.example", source_name="Other", retrieved_at=document.retrieved_at), |
| 872 | FactSourceTier.OFFICIAL_NSE, "NSE", "other-official-document", SourceMode.REAL, |
| 873 | ) |
| 874 | repository._persistence.upsert_financial_fact(other) |
| 875 | |
| 876 | assert repository._reconcile_persisted_official_financial_document(document)[0] |
| 877 | facts = {fact.key: fact for fact in repository.financial_facts_for(document.instrument_id)} |
| 878 | assert FinancialFactKey(document.instrument_id, "pat", "2025-12-31", "ANNUAL", "CONSOLIDATED") not in facts |
| 879 | assert FinancialFactKey(document.instrument_id, "pat", "2024-12-31", "ANNUAL", "CONSOLIDATED") not in facts |
| 880 | assert facts[FinancialFactKey(document.instrument_id, "pat", "2025-12-31", "QUARTERLY", "CONSOLIDATED")].value.value == Decimal("335.04") |
| 881 | assert facts[FinancialFactKey(document.instrument_id, "pat", "2024-12-31", "QUARTERLY", "CONSOLIDATED")].value.value == Decimal("161.42") |
| 882 | assert facts[other.key].source_identity == "other-official-document" |
| 883 | |
| 884 | |
| 885 | def test_document_scoped_reconciliation_does_not_delete_when_parser_emits_no_valid_facts() -> None: |
| 886 | repository = ResearchRepository(persistence=SqliteResearchPersistence()) |
| 887 | document = _document("NSE announcement without a supported financial table") |
| 888 | stale = _document_fact(document, "pat", "2025-12-31", "QUARTERLY", None, "5") |
| 889 | repository._persistence.upsert_financial_fact(stale) |
| 890 | |
| 891 | assert repository._reconcile_persisted_official_financial_document(document) == (False, 0) |
| 892 | assert repository.financial_facts_for(document.instrument_id) == [stale] |
| 893 | |
| 894 | |
| 895 | def test_document_scoped_reconciliation_preserves_another_document_owning_the_same_semantic_key() -> None: |
| 896 | repository = ResearchRepository(persistence=SqliteResearchPersistence()) |
| 897 | document = _document(JUNE) |
| 898 | existing = FinancialFact( |
| 899 | FinancialFactKey(document.instrument_id, "revenue", "2026-06-30", "QUARTERLY", None), |
| 900 | ProvenancedValue(value=Decimal("8261.11"), unit="INR crore", source_url="https://other.example", source_name="Other", retrieved_at=document.retrieved_at), |
| 901 | FactSourceTier.OFFICIAL_NSE, "NSE", "other-official-document", SourceMode.REAL, |
| 902 | ) |
| 903 | repository._persistence.upsert_financial_fact(existing) |
| 904 | |
| 905 | assert repository._reconcile_persisted_official_financial_document(document)[0] |
| 906 | fact = next(item for item in repository.financial_facts_for(document.instrument_id) if item.key == existing.key) |
| 907 | assert fact.source_identity == "other-official-document" |
| 908 | assert fact.value.value == Decimal("8261.11") |
| 909 | |
| 910 | |
| 911 | def test_document_scoped_reconciliation_rolls_back_on_persistence_failure(monkeypatch) -> None: |
| 912 | repository = ResearchRepository(persistence=SqliteResearchPersistence()) |
| 913 | document = _document(DEEPAK_DECEMBER_2025) |
| 914 | stale = _document_fact(document, "pat", "2025-12-31", "ANNUAL", "CONSOLIDATED", "5") |
| 915 | repository._persistence.upsert_financial_fact(stale) |
| 916 | original = repository._persistence._write_financial_fact |
| 917 | calls = 0 |
| 918 | |
| 919 | def fail_second_write(fact): |
| 920 | nonlocal calls |
| 921 | calls += 1 |
| 922 | if calls == 2: |
| 923 | raise RuntimeError("simulated persistence failure") |
| 924 | original(fact) |
| 925 | |
| 926 | monkeypatch.setattr(repository._persistence, "_write_financial_fact", fail_second_write) |
| 927 | with pytest.raises(RuntimeError, match="simulated persistence failure"): |
| 928 | repository._reconcile_persisted_official_financial_document(document) |
| 929 | assert repository.financial_facts_for(document.instrument_id) == [stale] |
| 930 | |
| 931 | |
| 932 | def test_repository_retains_financial_history_while_newest_windows_remain_available_with_document_provenance() -> None: |
| 933 | repository = ResearchRepository() |
| 934 | repository._persistence = SqliteResearchPersistence() |
| 935 | periods_by_year = [ |
| 936 | (2026, "30 June 2026", "31 March 2026", "30 June 2025"), |
| 937 | (2025, "30 June 2025", "31 March 2025", "30 June 2024"), |
| 938 | (2024, "30 June 2024", "31 March 2024", "30 June 2023"), |
| 939 | (2023, "30 June 2023", "31 March 2023", "30 June 2022"), |
| 940 | (2022, "30 June 2022", "31 March 2022", "30 June 2021"), |
| 941 | ] |
| 942 | documents = [ |
| 943 | _document(_multi_period_statement(current, prior, comparable, f"31 March {year}", revenue=f"{year - 1920}.00")).model_copy( |
| 944 | update={"published_at": datetime(year, 8, 1, tzinfo=timezone.utc)} |
| 945 | ) |
| 946 | for year, current, prior, comparable in periods_by_year |
| 947 | ] |
| 948 | for document in documents: |
| 949 | repository._persist_official_financial_facts(document) |
| 950 | |
| 951 | facts = [fact for fact in repository.financial_facts_for(documents[0].instrument_id) if fact.key.metric == "revenue"] |
| 952 | quarterly = {fact.key.period_end for fact in facts if fact.key.period_type == "QUARTERLY"} |
| 953 | annual = {fact.key.period_end for fact in facts if fact.key.period_type == "ANNUAL"} |
| 954 | assert {"2026-06-30", "2026-03-31", "2025-06-30", "2022-03-31"} <= quarterly |
| 955 | assert annual == {"2026-03-31", "2025-03-31", "2024-03-31", "2023-03-31", "2022-03-31"} |
| 956 | assert len(quarterly) > 4 |
| 957 | assert all(fact.source_tier == FactSourceTier.OFFICIAL_NSE and fact.source_provider == "NSE" for fact in facts) |
| 958 | assert all(fact.source_identity in {str(document.document_id) for document in documents} for fact in facts) |
| 959 | latest_revenue = next(fact for fact in facts if fact.key.period_end == "2026-06-30" and fact.key.period_type == "QUARTERLY") |
| 960 | assert latest_revenue.source_identity == str(documents[0].document_id) |
| 961 | assert latest_revenue.value.source_url == documents[0].canonical_url |
| 962 | total_before = len(repository.financial_facts_for(documents[0].instrument_id)) |
| 963 | before = len(facts) |
| 964 | repository._persist_official_financial_facts(documents[0]) |
| 965 | assert len(repository.financial_facts_for(documents[0].instrument_id)) == total_before |
| 966 | assert len([fact for fact in repository.financial_facts_for(documents[0].instrument_id) if fact.key.metric == "revenue"]) == before |
| 967 | |
| 968 | |
| 969 | def test_duplicate_official_period_does_not_overwrite_existing_document_provenance() -> None: |
| 970 | repository = ResearchRepository() |
| 971 | repository._persistence = SqliteResearchPersistence() |
| 972 | first = _document(_multi_period_statement("30 June 2026", "31 March 2026", "30 June 2025", "31 March 2026", revenue="100.00")) |
| 973 | second = _document(_multi_period_statement("30 June 2026", "31 March 2026", "30 June 2025", "31 March 2026", revenue="999.00")) |
| 974 | repository._persist_official_financial_facts(first) |
| 975 | repository._persist_official_financial_facts(second) |
| 976 | facts = [ |
| 977 | fact for fact in repository.financial_facts_for(first.instrument_id) |
| 978 | if fact.key.metric == "revenue" and fact.key.period_end == "2026-06-30" and fact.key.period_type == "QUARTERLY" |
| 979 | ] |
| 980 | assert len(facts) == 1 |
| 981 | assert facts[0].value.value == Decimal("100.00") |
| 982 | assert facts[0].source_identity == str(first.document_id) |
| 983 | |
| 984 | |
| 985 | def test_balance_sheet_persistence_retains_history_idempotently_and_keeps_document_provenance() -> None: |
| 986 | repository = ResearchRepository() |
| 987 | repository._persistence = SqliteResearchPersistence() |
| 988 | documents = [ |
| 989 | _document(_balance_sheet(current=f"31 March {year}", prior=f"31 March {year - 1}")).model_copy( |
| 990 | update={"published_at": datetime(year, 8, 1, tzinfo=timezone.utc)} |
| 991 | ) |
| 992 | for year in (2026, 2025, 2024, 2023, 2022) |
| 993 | ] |
| 994 | for document in documents: |
| 995 | repository._persist_official_financial_facts(document) |
| 996 | facts = [fact for fact in repository.financial_facts_for(documents[0].instrument_id) if fact.key.metric == "total_assets"] |
| 997 | assert {fact.key.period_end for fact in facts} == {"2026-03-31", "2025-03-31", "2024-03-31", "2023-03-31", "2022-03-31", "2021-03-31"} |
| 998 | assert all(fact.key.period_type == "AS_AT" and fact.source_identity in {str(item.document_id) for item in documents} for fact in facts) |
| 999 | before = len(repository.financial_facts_for(documents[0].instrument_id)) |
| 1000 | repository._persist_official_financial_facts(documents[0]) |
| 1001 | assert len(repository.financial_facts_for(documents[0].instrument_id)) == before |
| 1002 | |
| 1003 | |
| 1004 | def test_duplicate_balance_sheet_period_does_not_overwrite_other_official_document() -> None: |
| 1005 | repository = ResearchRepository() |
| 1006 | repository._persistence = SqliteResearchPersistence() |
| 1007 | first = _document(_balance_sheet()) |
| 1008 | second = _document(_balance_sheet().replace("Total Assets 500.00", "Total Assets 999.00")) |
| 1009 | repository._persist_official_financial_facts(first) |
| 1010 | repository._persist_official_financial_facts(second) |
| 1011 | fact = next( |
| 1012 | item for item in repository.financial_facts_for(first.instrument_id) |
| 1013 | if item.key.metric == "total_assets" and item.key.period_end == "2026-03-31" |
| 1014 | ) |
| 1015 | assert fact.value.value == Decimal("500") |
| 1016 | assert fact.source_identity == str(first.document_id) |
| 1017 | |
| 1018 | |
| 1019 | def test_cash_flow_persistence_retains_history_and_preserves_official_provenance() -> None: |
| 1020 | repository = ResearchRepository() |
| 1021 | repository._persistence = SqliteResearchPersistence() |
| 1022 | documents = [ |
| 1023 | _document(_cash_flow(current=f"31 March {year}", prior=f"31 March {year - 1}")).model_copy( |
| 1024 | update={"published_at": datetime(year, 8, 1, tzinfo=timezone.utc)} |
| 1025 | ) |
| 1026 | for year in (2026, 2025, 2024, 2023, 2022) |
| 1027 | ] |
| 1028 | for document in documents: |
| 1029 | repository._persist_official_financial_facts(document) |
| 1030 | facts = [fact for fact in repository.financial_facts_for(documents[0].instrument_id) |
| 1031 | if fact.key.metric == "cash_flow_from_operating_activities"] |
| 1032 | assert {fact.key.period_end for fact in facts} == {"2026-03-31", "2025-03-31", "2024-03-31", "2023-03-31", "2022-03-31", "2021-03-31"} |
| 1033 | assert all(fact.key.period_type == "ANNUAL" and fact.source_identity in {str(item.document_id) for item in documents} for fact in facts) |
| 1034 | first = _document(_cash_flow()) |
| 1035 | second = _document(_cash_flow().replace("100.00", "999.00", 1)) |
| 1036 | repository._persist_official_financial_facts(first) |
| 1037 | repository._persist_official_financial_facts(second) |
| 1038 | fact = next(item for item in repository.financial_facts_for(first.instrument_id) |
| 1039 | if item.key.metric == "cash_flow_from_operating_activities" and item.key.period_end == "2026-03-31") |
| 1040 | assert fact.value.value == Decimal("100") |
| 1041 | |
| 1042 | |
| 1043 | def _profile() -> CompanyResearchProfile: |
| 1044 | return CompanyResearchProfile(instrument_id=UUID("99999999-9999-9999-9999-999999999999"), company_id=UUID("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"), |
| 1045 | company_name="Example Components Limited", isin="INE000A01010", ticker="EXAMPLE", exchange="NSE", mic="XNSE", |
| 1046 | country="IN", currency="INR", provider_instrument_ids={"NSE": "EXAMPLE"}) |
| 1047 | |
| 1048 | |
| 1049 | def _trusted_source(profile: CompanyResearchProfile) -> RegisteredResearchSource: |
| 1050 | return RegisteredResearchSource(source_id="nse-result", instrument_id=profile.instrument_id, |
| 1051 | url="https://nsearchives.nseindia.com/corporate/result.pdf", source_type=SourceType.EXCHANGE_ANNOUNCEMENT, |
| 1052 | source_classification=SourceClassification.EXCHANGE, source_name="NSE corporate announcements", publisher="NSE", |
| 1053 | reliability_level=ReliabilityLevel.LEVEL_A, domain="nsearchives.nseindia.com", company_id=profile.company_id, |
| 1054 | discovery_method="NSE_OFFICIAL_API", official_nse_profile_symbol="EXAMPLE", categories=("FINANCIAL_RESULTS",)) |
| 1055 | |
| 1056 | |
| 1057 | def test_reused_trusted_nse_document_reconciles_same_document_facts_without_fetch_or_events() -> None: |
| 1058 | profile = _profile() |
| 1059 | source = _trusted_source(profile) |
| 1060 | repository = ResearchRepository() |
| 1061 | repository._persistence = SqliteResearchPersistence() |
| 1062 | document = _document(JUNE).model_copy(update={"canonical_url": source.url, "original_url": source.url}) |
| 1063 | repository.documents[document.document_id] = document |
| 1064 | bad = FinancialFact(FinancialFactKey(profile.instrument_id, "eps", "2026-06-30", "QUARTERLY", None), |
| 1065 | ProvenancedValue(value=Decimal("10"), unit="INR crore", source_url=source.url, source_name="NSE", source_type="EXCHANGE", retrieved_at=document.retrieved_at), |
| 1066 | FactSourceTier.OFFICIAL_NSE, "NSE", str(document.document_id), SourceMode.REAL) |
| 1067 | repository._persistence.upsert_financial_fact(bad) |
| 1068 | events_before = len(repository.events) |
| 1069 | documents_before = len(repository.documents) |
| 1070 | |
| 1071 | asyncio.run(repository._fetch_official_filings(profile, [DiscoveryResult("FINANCIAL_RESULTS", source)], set())) |
| 1072 | facts = repository.financial_facts_for(profile.instrument_id) |
| 1073 | # Filtered SQL may use a different index; assert the reconciled period, |
| 1074 | # independently of database row order and comparative-period facts. |
| 1075 | current = {fact.key.metric: fact for fact in facts |
| 1076 | if fact.key.period_end == "2026-06-30" and fact.key.period_type == "QUARTERLY"} |
| 1077 | eps = current["eps"] |
| 1078 | assert eps.value.value == Decimal("1.47") |
| 1079 | assert eps.value.unit == "INR per share" |
| 1080 | assert current["revenue"].value.value == Decimal("8261.11") |
| 1081 | assert current["pat"].value.value == Decimal("1927.21") |
| 1082 | assert len(repository.events) == events_before |
| 1083 | assert len(repository.documents) == documents_before |
| 1084 | |
| 1085 | asyncio.run(repository._fetch_official_filings(profile, [DiscoveryResult("FINANCIAL_RESULTS", source)], set())) |
| 1086 | assert len(repository.financial_facts_for(profile.instrument_id)) == len(facts) |
| 1087 | yahoo = FinancialFact(eps.key, ProvenancedValue(value=Decimal("9"), source_url="https://yahoo.test", source_name="Yahoo", source_type="YAHOO", retrieved_at=document.retrieved_at), FactSourceTier.YAHOO, "YAHOO_FINANCE", "yahoo", SourceMode.REAL) |
| 1088 | assert not repository._persistence.upsert_financial_fact(yahoo) |
| 1089 | assert next(fact for fact in repository.financial_facts_for(profile.instrument_id) if fact.key == eps.key).value.value == Decimal("1.47") |
| 1090 | |
| 1091 | |
| 1092 | def test_already_persisted_official_document_recovers_zero_facts_without_redownload() -> None: |
| 1093 | profile = _profile() |
| 1094 | source = _trusted_source(profile) |
| 1095 | repository = ResearchRepository(persistence=SqliteResearchPersistence()) |
| 1096 | document = _document(JUNE).model_copy(update={"canonical_url": source.url, "original_url": source.url}) |
| 1097 | repository.documents[document.document_id] = document |
| 1098 | documents_before = len(repository.documents) |
| 1099 | parser_calls = 0 |
| 1100 | network_calls = 0 |
| 1101 | original_persist = repository._persist_official_financial_facts |
| 1102 | |
| 1103 | def record_parser(reused_document): |
| 1104 | nonlocal parser_calls |
| 1105 | parser_calls += 1 |
| 1106 | return original_persist(reused_document) |
| 1107 | |
| 1108 | async def no_network(*_args, **_kwargs): |
| 1109 | nonlocal network_calls |
| 1110 | network_calls += 1 |
| 1111 | raise AssertionError("an already persisted official document must not be downloaded again") |
| 1112 | |
| 1113 | repository._persist_official_financial_facts = record_parser # type: ignore[method-assign] |
| 1114 | repository._single_flight_official_filing = no_network # type: ignore[method-assign] |
| 1115 | |
| 1116 | asyncio.run(repository._fetch_official_filings(profile, [DiscoveryResult("FINANCIAL_RESULTS", source)], set())) |
| 1117 | |
| 1118 | facts_after_recovery = repository.financial_facts_for(profile.instrument_id) |
| 1119 | assert parser_calls == 1 |
| 1120 | assert network_calls == 0 |
| 1121 | assert len(repository.documents) == documents_before |
| 1122 | assert {fact.key.metric for fact in facts_after_recovery} >= {"revenue", "pat", "eps"} |
| 1123 | |
| 1124 | asyncio.run(repository._fetch_official_filings(profile, [DiscoveryResult("FINANCIAL_RESULTS", source)], set())) |
| 1125 | assert network_calls == 0 |
| 1126 | assert len(repository.documents) == documents_before |
| 1127 | assert len(repository.financial_facts_for(profile.instrument_id)) == len(facts_after_recovery) |
| 1128 | |
| 1129 | |
| 1130 | def test_reused_official_document_repairs_missing_facts_without_degrading_existing_official_revenue() -> None: |
| 1131 | profile = _profile() |
| 1132 | source = _trusted_source(profile) |
| 1133 | repository = ResearchRepository(persistence=SqliteResearchPersistence()) |
| 1134 | document = _document(JUNE).model_copy(update={"canonical_url": source.url, "original_url": source.url}) |
| 1135 | repository.documents[document.document_id] = document |
| 1136 | revenue = next( |
| 1137 | value for period in parsed_nse_income_statement_periods([document]) |
| 1138 | if period.period_end == "2026-06-30" and period.period_type == "QUARTERLY" |
| 1139 | for metric, value in period.metrics if metric == "revenue" |
| 1140 | ) |
| 1141 | existing_revenue = FinancialFact( |
| 1142 | FinancialFactKey(profile.instrument_id, "revenue", "2026-06-30", "QUARTERLY", None), |
| 1143 | revenue, |
| 1144 | FactSourceTier.OFFICIAL_NSE, |
| 1145 | "NSE", |
| 1146 | "older-official-document", |
| 1147 | SourceMode.REAL, |
| 1148 | ) |
| 1149 | repository._persistence.upsert_financial_fact(existing_revenue) |
| 1150 | network_calls = 0 |
| 1151 | |
| 1152 | async def no_network(*_args, **_kwargs): |
| 1153 | nonlocal network_calls |
| 1154 | network_calls += 1 |
| 1155 | raise AssertionError("persisted evidence must repair missing facts without a provider request") |
| 1156 | |
| 1157 | repository._single_flight_official_filing = no_network # type: ignore[method-assign] |
| 1158 | asyncio.run(repository._fetch_official_filings(profile, [DiscoveryResult("FINANCIAL_RESULTS", source)], set())) |
| 1159 | |
| 1160 | facts = {fact.key.metric: fact for fact in repository.financial_facts_for(profile.instrument_id) |
| 1161 | if fact.key.period_end == "2026-06-30" and fact.key.period_type == "QUARTERLY"} |
| 1162 | assert network_calls == 0 |
| 1163 | assert facts["revenue"].source_identity == "older-official-document" |
| 1164 | assert facts["revenue"].value.value == Decimal("8261.11") |
| 1165 | assert facts["pat"].value.value == Decimal("1927.21") |
| 1166 | assert facts["eps"].value.value == Decimal("1.47") |
| 1167 | |
| 1168 | |
| 1169 | def test_persisted_official_document_with_no_parseable_financial_result_creates_no_facts() -> None: |
| 1170 | profile = _profile() |
| 1171 | repository = ResearchRepository(persistence=SqliteResearchPersistence()) |
| 1172 | document = _document("NSE exchange announcement: board meeting outcome and management commentary only.") |
| 1173 | repository.documents[document.document_id] = document |
| 1174 | parser_calls = 0 |
| 1175 | original_persist = repository._persist_official_financial_facts |
| 1176 | |
| 1177 | def record_parser(reused_document): |
| 1178 | nonlocal parser_calls |
| 1179 | parser_calls += 1 |
| 1180 | return original_persist(reused_document) |
| 1181 | |
| 1182 | repository._persist_official_financial_facts = record_parser # type: ignore[method-assign] |
| 1183 | |
| 1184 | assert asyncio.run(repository._reconcile_incomplete_persisted_official_financial_facts(profile)) is False |
| 1185 | assert parser_calls == 0 |
| 1186 | assert repository.financial_facts_for(profile.instrument_id) == [] |
| 1187 | |
| 1188 | |
| 1189 | def test_reused_official_reconciliation_does_not_block_the_event_loop() -> None: |
| 1190 | profile = _profile() |
| 1191 | source = _trusted_source(profile) |
| 1192 | repository = ResearchRepository() |
| 1193 | document = _document(JUNE).model_copy(update={"canonical_url": source.url, "original_url": source.url}) |
| 1194 | repository.documents[document.document_id] = document |
| 1195 | started = threading.Event() |
| 1196 | |
| 1197 | def slow_reconcile(*_args) -> None: |
| 1198 | started.set() |
| 1199 | time.sleep(0.08) |
| 1200 | |
| 1201 | repository._reconcile_reused_official_financial_facts = slow_reconcile |
| 1202 | |
| 1203 | async def run() -> int: |
| 1204 | task = asyncio.create_task(repository._fetch_official_filings(profile, [DiscoveryResult("FINANCIAL_RESULTS", source)], set())) |
| 1205 | while not started.is_set(): |
| 1206 | await asyncio.sleep(0) |
| 1207 | ticks = 0 |
| 1208 | while not task.done(): |
| 1209 | await asyncio.sleep(0.005) |
| 1210 | ticks += 1 |
| 1211 | await task |
| 1212 | return ticks |
| 1213 | |
| 1214 | assert asyncio.run(run()) > 0 |
| 1215 | |
| 1216 | |
| 1217 | def test_async_yahoo_fact_persistence_preserves_fact_precedence_payload() -> None: |
| 1218 | repository = ResearchRepository() |
| 1219 | recorder = _FactRecorder() |
| 1220 | repository._persistence = recorder |
| 1221 | snapshot = SimpleNamespace( |
| 1222 | resolution=SimpleNamespace(provider_ticker="EXAMPLE.NS"), |
| 1223 | statement_facts=[{ |
| 1224 | "metric": "revenue", |
| 1225 | "value": Decimal("100"), |
| 1226 | "periodEnd": "2026-06-30", |
| 1227 | "periodType": "QUARTERLY", |
| 1228 | "unit": "INR crore", |
| 1229 | "sourceUrl": "https://finance.yahoo.test/EXAMPLE.NS", |
| 1230 | "sourceName": "Yahoo Finance", |
| 1231 | "sourceType": "YAHOO_FINANCE", |
| 1232 | "retrievedAt": datetime(2026, 8, 1, tzinfo=timezone.utc), |
| 1233 | "confidence": Decimal("0.8"), |
| 1234 | }], |
| 1235 | ) |
| 1236 | |
| 1237 | asyncio.run(repository.persist_yahoo_statement_facts_async(_profile().instrument_id, snapshot)) |
| 1238 | |
| 1239 | assert len(recorder.facts) == 1 |
| 1240 | fact = recorder.facts[0] |
| 1241 | assert fact.source_tier == FactSourceTier.YAHOO |
| 1242 | assert fact.source_identity == "EXAMPLE.NS:revenue:2026-06-30:QUARTERLY" |
| 1243 | assert fact.key.period_end == "2026-06-30" |
| 1244 | |
| 1245 | |
| 1246 | def test_reused_untrusted_or_textless_document_cannot_create_facts() -> None: |
| 1247 | profile = _profile() |
| 1248 | repository = ResearchRepository() |
| 1249 | repository._persistence = SqliteResearchPersistence() |
| 1250 | source = _trusted_source(profile) |
| 1251 | untrusted = source.__class__(**{**source.__dict__, "official_nse_profile_symbol": None}) |
| 1252 | repository._reconcile_reused_official_financial_facts(profile, untrusted, _document(JUNE).model_copy(update={"canonical_url": source.url})) |
| 1253 | repository._reconcile_reused_official_financial_facts(profile, source, _document(JUNE).model_copy(update={"normalized_text": ""})) |
| 1254 | assert repository.financial_facts_for(profile.instrument_id) == [] |
| 1255 | |
| 1256 | |
| 1257 | def test_persisted_trusted_financial_document_repairs_zero_normalized_facts_idempotently() -> None: |
| 1258 | profile = _profile() |
| 1259 | repository = ResearchRepository() |
| 1260 | repository._persistence = SqliteResearchPersistence() |
| 1261 | document = _document(JUNE) |
| 1262 | repository.documents[document.document_id] = document |
| 1263 | first = asyncio.run(repository._incomplete_persisted_official_financial_documents(profile)) |
| 1264 | assert first == [document] |
| 1265 | asyncio.run(repository._run_blocking_persistence(repository._persist_official_financial_facts, document)) |
| 1266 | assert {fact.key.metric for fact in repository.financial_facts_for(profile.instrument_id)} >= {"revenue", "pat"} |
| 1267 | assert asyncio.run(repository._incomplete_persisted_official_financial_documents(profile)) == [] |
| 1268 | |
| 1269 | |
| 1270 | def test_no_due_refresh_repairs_persisted_official_facts_without_discovery() -> None: |
| 1271 | profile = _profile() |
| 1272 | repository = ResearchRepository( |
| 1273 | settings=Settings(research_live_enabled=True, research_demo_enabled=False), |
| 1274 | persistence=SqliteResearchPersistence(), |
| 1275 | ) |
| 1276 | repository.profiles = [profile] |
| 1277 | document = _document(JUNE) |
| 1278 | repository.documents[document.document_id] = document |
| 1279 | fresh_gate = _InstrumentRefreshGate(set(), False, False, "FRESH_AND_COMPLETE") |
| 1280 | repository._instrument_refresh_gate = lambda *args, **kwargs: fresh_gate # type: ignore[method-assign] |
| 1281 | calls = {"official": 0, "search": 0, "registered": 0} |
| 1282 | |
| 1283 | async def official_discover(*args, **kwargs): |
| 1284 | calls["official"] += 1 |
| 1285 | return [] |
| 1286 | |
| 1287 | async def search_discover(*args, **kwargs): |
| 1288 | calls["search"] += 1 |
| 1289 | return [] |
| 1290 | |
| 1291 | async def fetch_registered(*args, **kwargs): |
| 1292 | calls["registered"] += 1 |
| 1293 | raise AssertionError("fresh repair must not fetch a source") |
| 1294 | |
| 1295 | repository._official_filing_discovery.discover = official_discover # type: ignore[method-assign] |
| 1296 | repository._discovery.discover = search_discover # type: ignore[method-assign] |
| 1297 | repository._fetch_registered_source = fetch_registered # type: ignore[method-assign] |
| 1298 | |
| 1299 | asyncio.run(repository.refresh(profile.instrument_id, allow_demo=False)) |
| 1300 | |
| 1301 | assert {fact.key.metric for fact in repository.financial_facts_for(profile.instrument_id)} >= {"revenue", "pat"} |
| 1302 | assert calls == {"official": 0, "search": 0, "registered": 0} |
| 1303 | writes = 0 |
| 1304 | original_persist = repository._persist_official_financial_facts |
| 1305 | |
| 1306 | def count_persist(reused_document): |
| 1307 | nonlocal writes |
| 1308 | writes += 1 |
| 1309 | return original_persist(reused_document) |
| 1310 | |
| 1311 | repository._persist_official_financial_facts = count_persist # type: ignore[method-assign] |
| 1312 | asyncio.run(repository.refresh(profile.instrument_id, allow_demo=False)) |
| 1313 | assert writes == 0 |
| 1314 | |
| 1315 | |
| 1316 | def test_long_absence_keeps_existing_financial_provider_path_eligible_after_local_reconcile() -> None: |
| 1317 | profile = _profile() |
| 1318 | repository = ResearchRepository(settings=Settings(research_live_enabled=True, research_demo_enabled=False), persistence=SqliteResearchPersistence()) |
| 1319 | repository.profiles = [profile] |
| 1320 | old = _document(_multi_period_statement("30 June 2025", "31 March 2025", "31 December 2024", "31 March 2025")) |
| 1321 | repository.documents[old.document_id] = old |
| 1322 | repository._persist_official_financial_facts(old) |
| 1323 | repository._instrument_refresh_gate = lambda *args, **kwargs: _InstrumentRefreshGate({"FINANCIAL_RESULTS"}, False, False, "STALE") # type: ignore[method-assign] |
| 1324 | calls = {"discover": 0, "provider": 0} |
| 1325 | source = _trusted_source(profile) |
| 1326 | async def discover(*_args, **_kwargs): |
| 1327 | calls["discover"] += 1 |
| 1328 | return [DiscoveryResult("FINANCIAL_RESULTS", source)] |
| 1329 | async def provider(*_args, **_kwargs): |
| 1330 | calls["provider"] += 1 |
| 1331 | return True |
| 1332 | repository._official_filing_discovery.discover = discover # type: ignore[method-assign] |
| 1333 | repository._fetch_official_filings = provider # type: ignore[method-assign] |
| 1334 | repository._discovery.discover = lambda *_args, **_kwargs: [] # type: ignore[method-assign] |
| 1335 | |
| 1336 | asyncio.run(repository._refresh_live(profile.instrument_id, set())) |
| 1337 | assert calls == {"discover": 1, "provider": 1} |
| 1338 | |
| 1339 | |
| 1340 | def test_persisted_trusted_financial_document_with_revenue_and_pat_is_complete_without_eps() -> None: |
| 1341 | profile = _profile() |
| 1342 | repository = ResearchRepository() |
| 1343 | repository._persistence = SqliteResearchPersistence() |
| 1344 | document = _document(JUNE) |
| 1345 | repository.documents[document.document_id] = document |
| 1346 | repository._persist_official_financial_facts(document) |
| 1347 | for fact in list(repository.financial_facts_for(profile.instrument_id)): |
| 1348 | if fact.key.metric == "eps": |
| 1349 | repository._persistence._connection.execute("DELETE FROM global_financial_facts WHERE metric = 'eps'") |
| 1350 | repository._persistence._connection.commit() |
| 1351 | assert asyncio.run(repository._incomplete_persisted_official_financial_documents(profile)) == [] |
| 1352 | |
| 1353 | |
| 1354 | def test_latest_trusted_period_repairs_even_when_older_period_is_complete() -> None: |
| 1355 | profile = _profile() |
| 1356 | repository = ResearchRepository() |
| 1357 | repository._persistence = SqliteResearchPersistence() |
| 1358 | root = Path(__file__).parent / "fixtures" / "nse" / "irfc" |
| 1359 | older = _document((root / "irfc_2026_03_31_normalized.txt").read_text(encoding="utf-8")) |
| 1360 | latest = _document(JUNE) |
| 1361 | repository.documents[older.document_id] = older |
| 1362 | repository.documents[latest.document_id] = latest |
| 1363 | repository._persist_official_financial_facts(older) |
| 1364 | |
| 1365 | assert asyncio.run(repository._incomplete_persisted_official_financial_documents(profile)) == [latest] |
| 1366 | repository._persist_official_financial_facts(latest) |
| 1367 | assert asyncio.run(repository._incomplete_persisted_official_financial_documents(profile)) == [] |
| 1368 | |
| 1369 | |
| 1370 | def test_non_nse_profile_does_not_activate_fresh_financial_repair() -> None: |
| 1371 | profile = _profile().model_copy(update={"exchange": "XAMS", "country": "NL", "provider_instrument_ids": {}}) |
| 1372 | repository = ResearchRepository( |
| 1373 | settings=Settings(research_live_enabled=True, research_demo_enabled=False), |
| 1374 | persistence=SqliteResearchPersistence(), |
| 1375 | ) |
| 1376 | repository.profiles = [profile] |
| 1377 | document = _document(JUNE) |
| 1378 | repository.documents[document.document_id] = document |
| 1379 | repository._instrument_refresh_gate = lambda *args, **kwargs: _InstrumentRefreshGate(set(), False, False, "FRESH_AND_COMPLETE") # type: ignore[method-assign] |
| 1380 | writes = 0 |
| 1381 | original_persist = repository._persist_official_financial_facts |
| 1382 | |
| 1383 | def count_persist(reused_document): |
| 1384 | nonlocal writes |
| 1385 | writes += 1 |
| 1386 | return original_persist(reused_document) |
| 1387 | |
| 1388 | repository._persist_official_financial_facts = count_persist # type: ignore[method-assign] |
| 1389 | asyncio.run(repository.refresh(profile.instrument_id, allow_demo=False)) |
| 1390 | assert writes == 0 |
| 1391 | assert repository.financial_facts_for(profile.instrument_id) == [] |
| 1392 | |
| 1393 | |
| 1394 | def test_rolling_window_recovers_newer_quarters_after_one_year_absence_and_retains_history() -> None: |
| 1395 | profile = _profile() |
| 1396 | repository = ResearchRepository(persistence=SqliteResearchPersistence()) |
| 1397 | old_documents = [ |
| 1398 | _document(_multi_period_statement("30 June 2025", "31 March 2025", "31 December 2024", "31 March 2025")), |
| 1399 | _document(_multi_period_statement("30 September 2024", "30 June 2024", "31 March 2024", "31 March 2024")), |
| 1400 | ] |
| 1401 | for document in old_documents: |
| 1402 | repository.documents[document.document_id] = document |
| 1403 | repository._persist_official_financial_facts(document) |
| 1404 | current_documents = _rolling_financial_documents() |
| 1405 | for document in current_documents: |
| 1406 | repository.documents[document.document_id] = document |
| 1407 | |
| 1408 | assert asyncio.run(repository._incomplete_persisted_official_financial_documents(profile)) == current_documents |
| 1409 | assert asyncio.run(repository._reconcile_incomplete_persisted_official_financial_facts(profile)) is True |
| 1410 | quarterly = { |
| 1411 | fact.key.period_end for fact in repository.financial_facts_for(profile.instrument_id) |
| 1412 | if fact.key.metric == "revenue" and fact.key.period_type == "QUARTERLY" |
| 1413 | } |
| 1414 | assert {"2026-06-30", "2026-03-31", "2025-12-31", "2025-09-30"} <= quarterly |
| 1415 | assert {"2025-06-30", "2024-09-30"} <= quarterly |
| 1416 | before = len(repository.financial_facts_for(profile.instrument_id)) |
| 1417 | assert asyncio.run(repository._reconcile_incomplete_persisted_official_financial_facts(profile)) is False |
| 1418 | assert len(repository.financial_facts_for(profile.instrument_id)) == before |
| 1419 | |
| 1420 | |
| 1421 | def test_rolling_window_repairs_historical_gap_and_fresh_path_never_calls_provider() -> None: |
| 1422 | profile = _profile() |
| 1423 | repository = ResearchRepository(settings=Settings(research_live_enabled=True, research_demo_enabled=False), persistence=SqliteResearchPersistence()) |
| 1424 | repository.profiles = [profile] |
| 1425 | documents = _rolling_financial_documents() |
| 1426 | for document in documents: |
| 1427 | repository.documents[document.document_id] = document |
| 1428 | repository._persist_official_financial_facts(document) |
| 1429 | repository._persistence._connection.execute("DELETE FROM global_financial_facts WHERE period_end = '2026-03-31'") |
| 1430 | repository._persistence._connection.commit() |
| 1431 | repository._instrument_refresh_gate = lambda *args, **kwargs: _InstrumentRefreshGate(set(), False, False, "FRESH_AND_COMPLETE") # type: ignore[method-assign] |
| 1432 | async def forbidden(*_args, **_kwargs): |
| 1433 | raise AssertionError("persisted evidence repair must not call a provider") |
| 1434 | repository._fetch_registered_source = forbidden # type: ignore[method-assign] |
| 1435 | repository._official_filing_discovery.discover = forbidden # type: ignore[method-assign] |
| 1436 | repository._discovery.discover = forbidden # type: ignore[method-assign] |
| 1437 | |
| 1438 | asyncio.run(repository._refresh_live(profile.instrument_id, set())) |
| 1439 | recovered = {(fact.key.period_end, fact.key.metric) for fact in repository.financial_facts_for(profile.instrument_id)} |
| 1440 | assert {("2026-03-31", "revenue"), ("2026-03-31", "pat")} <= recovered |
| 1441 | |
| 1442 | |
| 1443 | def test_rolling_window_is_basis_isolated_and_malformed_document_does_not_block_valid_recovery() -> None: |
| 1444 | profile = _profile() |
| 1445 | repository = ResearchRepository(persistence=SqliteResearchPersistence()) |
| 1446 | malformed = _document(INVALID_CALENDAR_HEADER) |
| 1447 | standalone = _document("STANDALONE " + _multi_period_statement("30 June 2026", "31 March 2026", "31 December 2025", "31 March 2026")) |
| 1448 | consolidated = _document("CONSOLIDATED " + _multi_period_statement("30 September 2025", "30 June 2025", "31 March 2025", "31 March 2025")) |
| 1449 | for document in (malformed, standalone, consolidated): |
| 1450 | repository.documents[document.document_id] = document |
| 1451 | |
| 1452 | selected = asyncio.run(repository._incomplete_persisted_official_financial_documents(profile)) |
| 1453 | assert selected == [standalone, consolidated] |
| 1454 | asyncio.run(repository._reconcile_incomplete_persisted_official_financial_facts(profile)) |
| 1455 | facts = repository.financial_facts_for(profile.instrument_id) |
| 1456 | assert {fact.key.reporting_basis for fact in facts if fact.key.period_type == "QUARTERLY"} == {"STANDALONE", "CONSOLIDATED"} |