ohmydata 0.2.3__tar.gz → 0.2.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. {ohmydata-0.2.3 → ohmydata-0.2.4}/CHANGELOG.md +15 -0
  2. {ohmydata-0.2.3 → ohmydata-0.2.4}/PKG-INFO +1 -1
  3. {ohmydata-0.2.3 → ohmydata-0.2.4}/pyproject.toml +1 -1
  4. {ohmydata-0.2.3 → ohmydata-0.2.4}/src/ohmydata/__init__.py +1 -1
  5. {ohmydata-0.2.3 → ohmydata-0.2.4}/src/ohmydata/providers/sec/__init__.py +2 -0
  6. {ohmydata-0.2.3 → ohmydata-0.2.4}/src/ohmydata/providers/sec/_statement_parser.py +169 -57
  7. {ohmydata-0.2.3 → ohmydata-0.2.4}/src/ohmydata/providers/sec/edgartools_adapter.py +20 -1
  8. {ohmydata-0.2.3 → ohmydata-0.2.4}/uv.lock +1 -1
  9. {ohmydata-0.2.3 → ohmydata-0.2.4}/.gitignore +0 -0
  10. {ohmydata-0.2.3 → ohmydata-0.2.4}/README.md +0 -0
  11. {ohmydata-0.2.3 → ohmydata-0.2.4}/src/ohmydata/adapters/__init__.py +0 -0
  12. {ohmydata-0.2.3 → ohmydata-0.2.4}/src/ohmydata/adapters/polars.py +0 -0
  13. {ohmydata-0.2.3 → ohmydata-0.2.4}/src/ohmydata/cli.py +0 -0
  14. {ohmydata-0.2.3 → ohmydata-0.2.4}/src/ohmydata/core/__init__.py +0 -0
  15. {ohmydata-0.2.3 → ohmydata-0.2.4}/src/ohmydata/core/_vintage_lock.py +0 -0
  16. {ohmydata-0.2.3 → ohmydata-0.2.4}/src/ohmydata/core/availability.py +0 -0
  17. {ohmydata-0.2.3 → ohmydata-0.2.4}/src/ohmydata/core/errors.py +0 -0
  18. {ohmydata-0.2.3 → ohmydata-0.2.4}/src/ohmydata/core/facts.py +0 -0
  19. {ohmydata-0.2.3 → ohmydata-0.2.4}/src/ohmydata/core/policy.py +0 -0
  20. {ohmydata-0.2.3 → ohmydata-0.2.4}/src/ohmydata/core/provenance.py +0 -0
  21. {ohmydata-0.2.3 → ohmydata-0.2.4}/src/ohmydata/core/rate_limit.py +0 -0
  22. {ohmydata-0.2.3 → ohmydata-0.2.4}/src/ohmydata/core/snapshot.py +0 -0
  23. {ohmydata-0.2.3 → ohmydata-0.2.4}/src/ohmydata/core/specs.py +0 -0
  24. {ohmydata-0.2.3 → ohmydata-0.2.4}/src/ohmydata/core/vintage.py +0 -0
  25. {ohmydata-0.2.3 → ohmydata-0.2.4}/src/ohmydata/providers/__init__.py +0 -0
  26. {ohmydata-0.2.3 → ohmydata-0.2.4}/src/ohmydata/providers/sec/artifacts.py +0 -0
  27. {ohmydata-0.2.3 → ohmydata-0.2.4}/src/ohmydata/providers/sec/batch.py +0 -0
  28. {ohmydata-0.2.3 → ohmydata-0.2.4}/src/ohmydata/providers/sec/cli.py +0 -0
  29. {ohmydata-0.2.3 → ohmydata-0.2.4}/src/ohmydata/providers/sec/core_dataset.py +0 -0
  30. {ohmydata-0.2.3 → ohmydata-0.2.4}/src/ohmydata/providers/sec/edgar.py +0 -0
  31. {ohmydata-0.2.3 → ohmydata-0.2.4}/src/ohmydata/providers/sec/endpoints.py +0 -0
  32. {ohmydata-0.2.3 → ohmydata-0.2.4}/src/ohmydata/providers/sec/errors.py +0 -0
  33. {ohmydata-0.2.3 → ohmydata-0.2.4}/src/ohmydata/providers/sec/financials.py +0 -0
  34. {ohmydata-0.2.3 → ohmydata-0.2.4}/src/ohmydata/providers/sec/financials_dataset.py +0 -0
  35. {ohmydata-0.2.3 → ohmydata-0.2.4}/src/ohmydata/providers/sec/http.py +0 -0
  36. {ohmydata-0.2.3 → ohmydata-0.2.4}/src/ohmydata/providers/sec/nport.py +0 -0
  37. {ohmydata-0.2.3 → ohmydata-0.2.4}/src/ohmydata/providers/sec/qualification.py +0 -0
  38. {ohmydata-0.2.3 → ohmydata-0.2.4}/src/ohmydata/providers/sec/qualification_dataset.py +0 -0
  39. {ohmydata-0.2.3 → ohmydata-0.2.4}/src/ohmydata/providers/tushare/__init__.py +0 -0
  40. {ohmydata-0.2.3 → ohmydata-0.2.4}/src/ohmydata/providers/tushare/client.py +0 -0
  41. {ohmydata-0.2.3 → ohmydata-0.2.4}/src/ohmydata/providers/tushare/endpoints.py +0 -0
  42. {ohmydata-0.2.3 → ohmydata-0.2.4}/src/ohmydata/providers/tushare/errors.py +0 -0
  43. {ohmydata-0.2.3 → ohmydata-0.2.4}/src/ohmydata/providers/tushare/observations/__init__.py +0 -0
  44. {ohmydata-0.2.3 → ohmydata-0.2.4}/src/ohmydata/providers/tushare/observations/capture.py +0 -0
  45. {ohmydata-0.2.3 → ohmydata-0.2.4}/src/ohmydata/providers/tushare/observations/serialization.py +0 -0
  46. {ohmydata-0.2.3 → ohmydata-0.2.4}/src/ohmydata/providers/tushare/recipes/__init__.py +0 -0
  47. {ohmydata-0.2.3 → ohmydata-0.2.4}/src/ohmydata/providers/tushare/recipes/etf_adjusted_bars.py +0 -0
  48. {ohmydata-0.2.3 → ohmydata-0.2.4}/src/ohmydata/providers/tushare/recipes/etf_index_mapping.py +0 -0
  49. {ohmydata-0.2.3 → ohmydata-0.2.4}/src/ohmydata/providers/tushare/recipes/etf_pcf_history.py +0 -0
  50. {ohmydata-0.2.3 → ohmydata-0.2.4}/src/ohmydata/providers/tushare/recipes/index_weight_vintage.py +0 -0
  51. {ohmydata-0.2.3 → ohmydata-0.2.4}/src/ohmydata/providers/tushare/recipes/lookthrough_bundle.py +0 -0
  52. {ohmydata-0.2.3 → ohmydata-0.2.4}/src/ohmydata/providers/tushare/recipes/shared.py +0 -0
  53. {ohmydata-0.2.3 → ohmydata-0.2.4}/src/ohmydata/providers/tushare/recipes/weighted_dividend_yield.py +0 -0
  54. {ohmydata-0.2.3 → ohmydata-0.2.4}/src/ohmydata/providers/tushare/vintage_artifacts.py +0 -0
  55. {ohmydata-0.2.3 → ohmydata-0.2.4}/src/ohmydata/providers/tushare/vintage_canonical.py +0 -0
  56. {ohmydata-0.2.3 → ohmydata-0.2.4}/src/ohmydata/providers/tushare/vintage_gates.py +0 -0
  57. {ohmydata-0.2.3 → ohmydata-0.2.4}/src/ohmydata/providers/tushare/vintage_plane.py +0 -0
  58. {ohmydata-0.2.3 → ohmydata-0.2.4}/src/ohmydata/providers/tushare/vintage_producer.py +0 -0
  59. {ohmydata-0.2.3 → ohmydata-0.2.4}/src/ohmydata/providers/yfinance/__init__.py +0 -0
  60. {ohmydata-0.2.3 → ohmydata-0.2.4}/src/ohmydata/providers/yfinance/_fundamentals_parsers.py +0 -0
  61. {ohmydata-0.2.3 → ohmydata-0.2.4}/src/ohmydata/providers/yfinance/_fundamentals_periods.py +0 -0
  62. {ohmydata-0.2.3 → ohmydata-0.2.4}/src/ohmydata/providers/yfinance/client.py +0 -0
  63. {ohmydata-0.2.3 → ohmydata-0.2.4}/src/ohmydata/providers/yfinance/endpoints.py +0 -0
  64. {ohmydata-0.2.3 → ohmydata-0.2.4}/src/ohmydata/providers/yfinance/errors.py +0 -0
  65. {ohmydata-0.2.3 → ohmydata-0.2.4}/src/ohmydata/providers/yfinance/fundamentals.py +0 -0
  66. {ohmydata-0.2.3 → ohmydata-0.2.4}/src/ohmydata/providers/yfinance/quality.py +0 -0
  67. {ohmydata-0.2.3 → ohmydata-0.2.4}/src/ohmydata/tools/__init__.py +0 -0
  68. {ohmydata-0.2.3 → ohmydata-0.2.4}/src/ohmydata/tools/cli.py +0 -0
  69. {ohmydata-0.2.3 → ohmydata-0.2.4}/src/ohmydata/tools/drift_audit.py +0 -0
  70. {ohmydata-0.2.3 → ohmydata-0.2.4}/src/ohmydata/tools/universes.py +0 -0
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.4 — 2026-09-09
4
+
5
+ - Fix the SEC native-fact adapter for edgartools 5.56.0: `XBRL.facts` is a
6
+ `FactsView`, while original `Fact` objects belong to `XBRL.parser.facts`.
7
+ Regression fixtures now use actual XBRL/FactsView/Statement objects.
8
+ - A selected filing with zero rows and parsing failures now raises
9
+ `SecFinancialsParseError`; its `vintage` retains coverage/accession and its
10
+ cause retains the first failure. Missing/empty filings without parser failures
11
+ and explicitly flagged partial coverage retain their existing behavior.
12
+ See [the financials contract](docs/financial-period-integrity.md).
13
+ - Native SEC duplicate facts now use exact Decimal precision intervals and retain
14
+ the highest precision original fact per context, while rejecting true value or
15
+ unit conflicts, mixed known/unknown precision, and arithmetic inputs beyond
16
+ the bounded 10,000-digit guard.
17
+
3
18
  ## 0.2.3 — 2026-09-09
4
19
 
5
20
  - Repair SEC financial-period extraction and filing eligibility in the OMD adapter
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: ohmydata
3
- Version: 0.2.3
3
+ Version: 0.2.4
4
4
  Summary: Financial and alternative market data ingestion SDK.
5
5
  Author: OMD contributors
6
6
  Requires-Python: <3.13,>=3.11
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "ohmydata"
7
- version = "0.2.3"
7
+ version = "0.2.4"
8
8
  description = "Financial and alternative market data ingestion SDK."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11,<3.13"
@@ -1,5 +1,5 @@
1
1
  """Provisional offline-only Oh My Data package scaffold."""
2
2
 
3
- __version__ = "0.2.3"
3
+ __version__ = "0.2.4"
4
4
 
5
5
  __all__ = ["__version__"]
@@ -36,6 +36,7 @@ from .edgar import (
36
36
  )
37
37
  from .edgartools_adapter import (
38
38
  SecFinancialsClient,
39
+ SecFinancialsParseError,
39
40
  SecStatementParseError,
40
41
  ensure_edgar_available,
41
42
  parse_statement_rows,
@@ -90,6 +91,7 @@ __all__ = [
90
91
  "SecEmptyPolicy",
91
92
  "SecEquityEtfUniverse",
92
93
  "SecFinancialsClient",
94
+ "SecFinancialsParseError",
93
95
  "SecFinancialsRequest",
94
96
  "SecFundHoldingVintage",
95
97
  "SecFundSelector",
@@ -5,9 +5,9 @@ from __future__ import annotations
5
5
  import json
6
6
  import re
7
7
  from collections.abc import Mapping
8
- from dataclasses import replace
9
8
  from datetime import date
10
9
  from decimal import Decimal, InvalidOperation
10
+ from fractions import Fraction
11
11
  from typing import Any
12
12
 
13
13
  from .financials import SecStatementRow, StatementType
@@ -16,6 +16,7 @@ _DISPLAY_PERIOD = re.compile(r"^(\d{4}-\d{2}-\d{2})(?:\s+\((FY|Q[1-4]|YTD)\))?$"
16
16
  _STRUCTURED_PERIOD = re.compile(
17
17
  r"^(?:(instant)_(\d{4}-\d{2}-\d{2})|(duration)_(\d{4}-\d{2}-\d{2})_(\d{4}-\d{2}-\d{2}))$"
18
18
  )
19
+ _MAX_FACT_ARITHMETIC_DIGITS = 10_000
19
20
 
20
21
 
21
22
  class SecStatementParseError(ValueError):
@@ -92,11 +93,122 @@ def _dim_key(dimensions: Mapping[str, Any]) -> str:
92
93
  return json.dumps(dict(dimensions), sort_keys=True, separators=(",", ":"))
93
94
 
94
95
 
96
+ def _fact_precision(fact: Any) -> int | str | None:
97
+ """Return finite precision, None for missing, and ``INF`` for infinity."""
98
+ decimals = fact.decimals
99
+ if decimals is None or type(decimals).__name__ in {"NAType", "NaTType"}:
100
+ return None
101
+ if isinstance(decimals, str) and decimals.strip() == "INF":
102
+ return "INF"
103
+ if isinstance(decimals, bool):
104
+ raise SecStatementParseError("invalid fact precision")
105
+ if isinstance(decimals, int):
106
+ return decimals
107
+ if isinstance(decimals, str) and re.fullmatch(r"[+-]?\d+", decimals.strip()):
108
+ return int(decimals.strip())
109
+ raise SecStatementParseError("invalid fact precision")
110
+
111
+
112
+ def _fact_interval(value: Decimal, precision: int | str | None) -> tuple[Fraction, Fraction]:
113
+ value_tuple = value.as_tuple()
114
+ if not isinstance(value_tuple.exponent, int):
115
+ raise SecStatementParseError("non-finite statement fact")
116
+ if (
117
+ abs(value_tuple.exponent) > _MAX_FACT_ARITHMETIC_DIGITS
118
+ or len(value_tuple.digits) > _MAX_FACT_ARITHMETIC_DIGITS
119
+ ):
120
+ raise SecStatementParseError("statement fact exceeds arithmetic bounds")
121
+ if isinstance(precision, int) and abs(precision) > _MAX_FACT_ARITHMETIC_DIGITS:
122
+ raise SecStatementParseError("statement fact exceeds arithmetic bounds")
123
+ exact = Fraction(value)
124
+ if precision is None or precision == "INF":
125
+ return exact, exact
126
+ if not isinstance(precision, int):
127
+ raise SecStatementParseError("invalid fact precision")
128
+ half_unit = Fraction(10 ** (-precision), 2) if precision < 0 else Fraction(1, 2 * 10**precision)
129
+ return exact - half_unit, exact + half_unit
130
+
131
+
132
+ def _select_native_facts(facts: list[Any]) -> list[Any]:
133
+ """Validate one duplicate group and select its best fact per context."""
134
+ parsed: list[tuple[Any, Decimal, int | str | None]] = []
135
+ unit_refs = set()
136
+ intervals = []
137
+ missing_precision = False
138
+ values_by_precision: dict[int | str, set[Decimal]] = {}
139
+ best_by_context: dict[str, tuple[tuple[int, int], tuple[str, str], Any]] = {}
140
+
141
+ def rank(precision: int | str | None) -> tuple[int, int]:
142
+ if precision is None:
143
+ return (0, 0)
144
+ if precision == "INF":
145
+ return (2, 0)
146
+ if not isinstance(precision, int):
147
+ raise SecStatementParseError("invalid fact precision")
148
+ return (1, precision)
149
+
150
+ for fact in facts:
151
+ value = _number(fact.value)
152
+ precision = _fact_precision(fact)
153
+ missing_precision |= precision is None
154
+ unit_refs.add(fact.unit_ref)
155
+ if value is None:
156
+ continue
157
+ parsed.append((fact, value, precision))
158
+ intervals.append(_fact_interval(value, precision))
159
+ if precision is not None:
160
+ values_by_precision.setdefault(precision, set()).add(value)
161
+ context = fact.context_ref
162
+ tie_break = (str(fact.value), str(fact.decimals))
163
+ candidate = (rank(precision), tie_break, fact)
164
+ current = best_by_context.get(context)
165
+ if (
166
+ current is None
167
+ or candidate[0] > current[0]
168
+ or (candidate[0] == current[0] and candidate[1] < current[1])
169
+ ):
170
+ best_by_context[context] = candidate
171
+ if len(unit_refs) != 1:
172
+ raise SecStatementParseError("conflicting native facts for period and dimensions")
173
+ if not parsed:
174
+ return []
175
+ if len(parsed) != len(facts):
176
+ raise SecStatementParseError("non-numeric statement fact")
177
+ # Every interval must share one common point; pairwise or adjacent checks
178
+ # can incorrectly accept a chain of individually overlapping intervals.
179
+ lower = max(interval[0] for interval in intervals)
180
+ upper = min(interval[1] for interval in intervals)
181
+ if missing_precision and any(precision is not None for _, _, precision in parsed):
182
+ raise SecStatementParseError("conflicting native facts for period and dimensions")
183
+ if missing_precision and len({value for _, value, _ in parsed}) != 1:
184
+ raise SecStatementParseError("conflicting native facts for period and dimensions")
185
+ for values in values_by_precision.values():
186
+ if len(values) != 1:
187
+ raise SecStatementParseError("conflicting native facts for period and dimensions")
188
+ if lower > upper:
189
+ raise SecStatementParseError("conflicting native facts for period and dimensions")
190
+ selected: list[Any] = []
191
+ for context_ref in sorted(best_by_context):
192
+ selected.append(best_by_context[context_ref][2])
193
+ return selected
194
+
195
+
95
196
  def _native_index(xbrl: Any, concepts: set[str]) -> dict[tuple[str, str, str], list[Any]]:
96
197
  """One pass over facts, avoiding a full source scan for each row or period."""
198
+ # Edgar 5.56 XBRL.facts is FactsView, an enriched query interface whose
199
+ # get_facts() may rewrite concept identifiers. The raw Fact objects are
200
+ # owned by XBRLParser.facts (also exposed upstream as XBRL._facts).
201
+ # Keep this version-specific boundary explicit: never use display values
202
+ # or the enriched view as a fallback for unavailable native facts.
203
+ try:
204
+ native_facts = xbrl.parser.facts
205
+ except AttributeError as exc:
206
+ raise SecStatementParseError("native parser fact mapping unavailable") from exc
207
+ if not isinstance(native_facts, Mapping):
208
+ raise SecStatementParseError("native parser facts must be a mapping")
97
209
  result: dict[tuple[str, str, str], list[Any]] = {}
98
210
  seen: set[int] = set()
99
- for fact in xbrl.facts.values():
211
+ for fact in native_facts.values():
100
212
  if id(fact) in seen:
101
213
  continue
102
214
  seen.add(id(fact))
@@ -156,70 +268,70 @@ def _structured_rows(
156
268
  unit_ref = (item.get("units") or {}).get(key)
157
269
  native_decimals = (item.get("decimals") or {}).get(key)
158
270
  context_ref = None
159
- context_refs: list[str | None] = [None]
160
271
  if index is not None:
161
272
  facts = index.get((concept.replace(":", "_"), key, _dim_key(dims)), [])
162
273
  if not facts:
163
274
  raise SecStatementParseError("statement value has no native fact context")
164
- identities = {(f.value, f.unit_ref, str(f.decimals)) for f in facts}
165
- if len(identities) != 1:
166
- raise SecStatementParseError(
167
- "conflicting native facts for period and dimensions"
168
- )
169
- fact = min(facts, key=lambda f: f.context_ref)
170
- raw, unit_ref, native_decimals = fact.value, fact.unit_ref, fact.decimals
171
- context_ref = fact.context_ref
172
- context_refs = sorted({f.context_ref for f in facts})
173
- value = _number(raw)
174
- if value is None:
175
- continue
176
- unit = _text(unit_ref) or _text(item.get("unit"))
177
- if xbrl is not None and unit_ref:
178
- definition = xbrl.units.get(unit_ref)
179
- if definition is None:
180
- unit = None
181
- elif isinstance(definition, dict):
182
- unit = _text(definition.get("measure")) or json.dumps(
183
- definition, sort_keys=True
184
- )
185
- else:
186
- raise SecStatementParseError("invalid native unit definition")
187
- decimals = None
188
- if native_decimals is not None and str(native_decimals) != "INF":
189
- try:
190
- decimals = int(native_decimals)
191
- except (TypeError, ValueError) as exc:
192
- raise SecStatementParseError("invalid fact precision") from exc
193
- row = SecStatementRow(
194
- statement_type=kind,
195
- standard_concept=_text(item.get("standard_concept")) or concept,
196
- concept=concept,
197
- label=_text(item.get("label")) or "",
198
- value=value,
199
- value_native=str(raw),
200
- unit=unit,
201
- unit_ref=_text(unit_ref),
202
- decimals=decimals,
203
- decimals_native=_text(native_decimals),
204
- period_start=start,
205
- period_end=end,
206
- period_type=period_type,
207
- period_key=str(key),
208
- context_ref=context_ref,
209
- dimension=_dim_key(dims) if dims else None,
210
- is_point_in_time=period_type == "instant",
211
- period_source="xbrl-context" if index is not None else "structured-period-key",
212
- )
213
- for reference in context_refs:
214
- contextual_row = replace(row, context_ref=reference)
215
- identity = concept, str(key), _dim_key(dims), reference
275
+ selected_facts = _select_native_facts(facts)
276
+ else:
277
+ selected_facts = [None]
278
+ for selected_fact in selected_facts:
279
+ selected_raw = raw
280
+ selected_unit_ref = unit_ref
281
+ selected_decimals = native_decimals
282
+ if selected_fact is not None:
283
+ selected_raw = selected_fact.value
284
+ selected_unit_ref = selected_fact.unit_ref
285
+ selected_decimals = selected_fact.decimals
286
+ context_ref = selected_fact.context_ref
287
+ value = _number(selected_raw)
288
+ if value is None:
289
+ continue
290
+ unit = _text(selected_unit_ref) or _text(item.get("unit"))
291
+ if xbrl is not None and selected_unit_ref:
292
+ definition = xbrl.units.get(selected_unit_ref)
293
+ if definition is None:
294
+ unit = None
295
+ elif isinstance(definition, dict):
296
+ unit = _text(definition.get("measure")) or json.dumps(
297
+ definition, sort_keys=True
298
+ )
299
+ else:
300
+ raise SecStatementParseError("invalid native unit definition")
301
+ decimals = None
302
+ if selected_decimals is not None and str(selected_decimals).strip() != "INF":
303
+ try:
304
+ decimals = int(str(selected_decimals).strip())
305
+ except (TypeError, ValueError) as exc:
306
+ raise SecStatementParseError("invalid fact precision") from exc
307
+ row = SecStatementRow(
308
+ statement_type=kind,
309
+ standard_concept=_text(item.get("standard_concept")) or concept,
310
+ concept=concept,
311
+ label=_text(item.get("label")) or "",
312
+ value=value,
313
+ value_native=str(selected_raw),
314
+ unit=unit,
315
+ unit_ref=_text(selected_unit_ref),
316
+ decimals=decimals,
317
+ decimals_native=_text(selected_decimals),
318
+ period_start=start,
319
+ period_end=end,
320
+ period_type=period_type,
321
+ period_key=str(key),
322
+ context_ref=context_ref,
323
+ dimension=_dim_key(dims) if dims else None,
324
+ is_point_in_time=period_type == "instant",
325
+ period_source="xbrl-context" if index is not None else "structured-period-key",
326
+ )
327
+ identity = concept, str(key), _dim_key(dims), context_ref
216
328
  if identity in seen:
217
329
  previous = seen[identity]
218
330
  if (previous.value, previous.unit) != (row.value, row.unit):
219
331
  raise SecStatementParseError("conflicting duplicate statement facts")
220
332
  continue
221
- seen[identity] = contextual_row
222
- rows.append(contextual_row)
333
+ seen[identity] = row
334
+ rows.append(row)
223
335
  return rows
224
336
 
225
337
 
@@ -17,6 +17,18 @@ logger = logging.getLogger(__name__)
17
17
  _EASTERN_TZ = ZoneInfo("America/New_York")
18
18
 
19
19
 
20
+ class SecFinancialsParseError(SecStatementParseError):
21
+ """A selected filing produced no financial rows because parsing failed.
22
+
23
+ ``vintage`` retains accession and coverage flags; ``__cause__`` retains
24
+ the first parser failure. Neither is replaced by a different filing.
25
+ """
26
+
27
+ def __init__(self, vintage: SecCompanyFinancialVintage) -> None:
28
+ super().__init__("selected SEC filing produced no financial rows because parsing failed")
29
+ self.vintage = vintage
30
+
31
+
20
32
  def ensure_edgar_available() -> None:
21
33
  """Require the optional Edgar financials dependency."""
22
34
  try:
@@ -179,14 +191,16 @@ class SecFinancialsClient:
179
191
 
180
192
  # Parse the report object (TenK, TenQ, etc.)
181
193
  quality_flags: list[str] = []
194
+ parse_failures: list[Exception] = []
182
195
  if accepted_at is None:
183
196
  quality_flags.append("ACCEPTED_AT_MISSING")
184
197
  try:
185
198
  report: Any = filing.obj()
186
199
  except (AttributeError, KeyError, ValueError, TypeError, OSError) as err:
187
- logger.debug("Could not parse filing obj: %s", err)
200
+ logger.debug("Could not parse filing obj: %s", type(err).__name__)
188
201
  report = None
189
202
  quality_flags.append("FILING_PARSE_FAILED")
203
+ parse_failures.append(err)
190
204
 
191
205
  try:
192
206
  fin: Any = getattr(report, "financials", None) if report is not None else None
@@ -201,6 +215,7 @@ class SecFinancialsClient:
201
215
  logger.debug("Could not access filing financials: %s", type(err).__name__)
202
216
  fin = None
203
217
  quality_flags.append("FINANCIALS_PARSE_FAILED")
218
+ parse_failures.append(err)
204
219
  if fin is None:
205
220
  quality_flags.append("NO_FINANCIALS_OBJECT")
206
221
 
@@ -229,6 +244,7 @@ class SecFinancialsClient:
229
244
  "Could not extract %s: %s", statement_type, type(err).__name__
230
245
  )
231
246
  quality_flags.append(f"{flag}_PARSE_FAILED")
247
+ parse_failures.append(err)
232
248
 
233
249
  if not rows:
234
250
  quality_flags.append("NO_FINANCIAL_STATEMENTS")
@@ -260,6 +276,8 @@ class SecFinancialsClient:
260
276
  quality_flags=tuple(quality_flags),
261
277
  rows=tuple(rows),
262
278
  )
279
+ if not rows and parse_failures:
280
+ raise SecFinancialsParseError(vintage) from parse_failures[0]
263
281
  vintages.append(vintage)
264
282
 
265
283
  return vintages
@@ -267,6 +285,7 @@ class SecFinancialsClient:
267
285
 
268
286
  __all__ = [
269
287
  "SecFinancialsClient",
288
+ "SecFinancialsParseError",
270
289
  "SecStatementParseError",
271
290
  "ensure_edgar_available",
272
291
  "parse_statement_rows",
@@ -491,7 +491,7 @@ wheels = [
491
491
 
492
492
  [[package]]
493
493
  name = "ohmydata"
494
- version = "0.2.3"
494
+ version = "0.2.4"
495
495
  source = { editable = "." }
496
496
 
497
497
  [package.optional-dependencies]
File without changes
File without changes
File without changes