main
py 1,456 lines 82.9 KB
Raw
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"}