ohmydata 0.0.2__tar.gz → 0.0.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 (23) hide show
  1. {ohmydata-0.0.2 → ohmydata-0.0.4}/CHANGELOG.md +13 -0
  2. {ohmydata-0.0.2 → ohmydata-0.0.4}/PKG-INFO +31 -1
  3. {ohmydata-0.0.2 → ohmydata-0.0.4}/README.md +30 -0
  4. {ohmydata-0.0.2 → ohmydata-0.0.4}/pyproject.toml +1 -1
  5. {ohmydata-0.0.2 → ohmydata-0.0.4}/src/ohmydata/__init__.py +1 -1
  6. {ohmydata-0.0.2 → ohmydata-0.0.4}/src/ohmydata/providers/tushare/__init__.py +10 -0
  7. ohmydata-0.0.4/src/ohmydata/providers/tushare/recipes/__init__.py +27 -0
  8. ohmydata-0.0.4/src/ohmydata/providers/tushare/recipes/weighted_dividend_yield.py +231 -0
  9. {ohmydata-0.0.2 → ohmydata-0.0.4}/uv.lock +1 -1
  10. ohmydata-0.0.2/src/ohmydata/providers/tushare/recipes/__init__.py +0 -15
  11. {ohmydata-0.0.2 → ohmydata-0.0.4}/.gitignore +0 -0
  12. {ohmydata-0.0.2 → ohmydata-0.0.4}/src/ohmydata/core/__init__.py +0 -0
  13. {ohmydata-0.0.2 → ohmydata-0.0.4}/src/ohmydata/core/errors.py +0 -0
  14. {ohmydata-0.0.2 → ohmydata-0.0.4}/src/ohmydata/core/policy.py +0 -0
  15. {ohmydata-0.0.2 → ohmydata-0.0.4}/src/ohmydata/core/provenance.py +0 -0
  16. {ohmydata-0.0.2 → ohmydata-0.0.4}/src/ohmydata/core/rate_limit.py +0 -0
  17. {ohmydata-0.0.2 → ohmydata-0.0.4}/src/ohmydata/core/snapshot.py +0 -0
  18. {ohmydata-0.0.2 → ohmydata-0.0.4}/src/ohmydata/core/specs.py +0 -0
  19. {ohmydata-0.0.2 → ohmydata-0.0.4}/src/ohmydata/providers/__init__.py +0 -0
  20. {ohmydata-0.0.2 → ohmydata-0.0.4}/src/ohmydata/providers/tushare/client.py +0 -0
  21. {ohmydata-0.0.2 → ohmydata-0.0.4}/src/ohmydata/providers/tushare/endpoints.py +0 -0
  22. {ohmydata-0.0.2 → ohmydata-0.0.4}/src/ohmydata/providers/tushare/errors.py +0 -0
  23. {ohmydata-0.0.2 → ohmydata-0.0.4}/src/ohmydata/providers/tushare/recipes/etf_adjusted_bars.py +0 -0
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.0.4 — 2026-08-01
4
+
5
+ - Added the explicit `NORMALIZE_SUPPORTED` weighted-dividend-yield coverage
6
+ policy. It normalizes only over finite supported weight, preserves the
7
+ original coverage metadata, reports a distinct formula identifier, and
8
+ leaves minimum-coverage acceptance to the caller.
9
+
10
+ ## 0.0.3 — 2026-07-31
11
+
12
+ - Added offline weighted portfolio and index dividend-yield recipes with
13
+ explicit finite-coverage policies, provider-native units, and deterministic
14
+ formula metadata.
15
+
3
16
  ## Unreleased — 0.0.2
4
17
 
5
18
  - Fixed adjusted ETF bars to tolerate Tushare factor-date supersets for the
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ohmydata
3
- Version: 0.0.2
3
+ Version: 0.0.4
4
4
  Summary: Offline-first market-data SDK scaffold
5
5
  Author: OMD contributors
6
6
  Requires-Python: <3.13,>=3.11
@@ -99,6 +99,36 @@ request = AdjustedEtfBarsRequest(
99
99
  )
100
100
  ```
101
101
 
102
+ ### Offline weighted dividend yield recipes
103
+
104
+ `build_portfolio_dividend_yield` and `build_index_dividend_yield` calculate a
105
+ provider-semantic weighted yield from already-downloaded Pandas frames. Portfolio
106
+ `mkv` is yuan; index `weight` and `daily_basic.dv_ttm` are provider percentages.
107
+ The returned `dividend_yield` is a decimal ratio (`sum((w_i / W) * dv_ttm_i) / 100`),
108
+ where `W` is the provider-native total weight.
109
+ Choose `DividendYieldCoveragePolicy.REQUIRE_COMPLETE` to reject missing finite
110
+ yield coverage, `PRESERVE_INCOMPLETE` to return `None`, or the explicitly named
111
+ `NORMALIZE_SUPPORTED` policy to divide only by finite supported weight while
112
+ still reporting the original `finite_weight_coverage`. Callers own any minimum
113
+ coverage threshold and must not present a normalized partial estimate as full
114
+ coverage. Zero supported coverage remains unknown. Inputs are not modified, and
115
+ dates are identity checks only: the recipe does not infer point-in-time
116
+ availability or report selection.
117
+
118
+ ```python
119
+ from ohmydata.providers.tushare import (
120
+ DividendYieldCoveragePolicy,
121
+ build_index_dividend_yield,
122
+ )
123
+
124
+ result = build_index_dividend_yield(
125
+ index_weights_df,
126
+ daily_basic_df,
127
+ DividendYieldCoveragePolicy.REQUIRE_COMPLETE,
128
+ )
129
+ print(result.dividend_yield)
130
+ ```
131
+
102
132
  ## Local checks
103
133
 
104
134
  ```bash
@@ -88,6 +88,36 @@ request = AdjustedEtfBarsRequest(
88
88
  )
89
89
  ```
90
90
 
91
+ ### Offline weighted dividend yield recipes
92
+
93
+ `build_portfolio_dividend_yield` and `build_index_dividend_yield` calculate a
94
+ provider-semantic weighted yield from already-downloaded Pandas frames. Portfolio
95
+ `mkv` is yuan; index `weight` and `daily_basic.dv_ttm` are provider percentages.
96
+ The returned `dividend_yield` is a decimal ratio (`sum((w_i / W) * dv_ttm_i) / 100`),
97
+ where `W` is the provider-native total weight.
98
+ Choose `DividendYieldCoveragePolicy.REQUIRE_COMPLETE` to reject missing finite
99
+ yield coverage, `PRESERVE_INCOMPLETE` to return `None`, or the explicitly named
100
+ `NORMALIZE_SUPPORTED` policy to divide only by finite supported weight while
101
+ still reporting the original `finite_weight_coverage`. Callers own any minimum
102
+ coverage threshold and must not present a normalized partial estimate as full
103
+ coverage. Zero supported coverage remains unknown. Inputs are not modified, and
104
+ dates are identity checks only: the recipe does not infer point-in-time
105
+ availability or report selection.
106
+
107
+ ```python
108
+ from ohmydata.providers.tushare import (
109
+ DividendYieldCoveragePolicy,
110
+ build_index_dividend_yield,
111
+ )
112
+
113
+ result = build_index_dividend_yield(
114
+ index_weights_df,
115
+ daily_basic_df,
116
+ DividendYieldCoveragePolicy.REQUIRE_COMPLETE,
117
+ )
118
+ print(result.dividend_yield)
119
+ ```
120
+
91
121
  ## Local checks
92
122
 
93
123
  ```bash
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "ohmydata"
7
- version = "0.0.2"
7
+ version = "0.0.4"
8
8
  description = "Offline-first market-data SDK scaffold"
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.0.2"
3
+ __version__ = "0.0.4"
4
4
 
5
5
  __all__ = ["__version__"]
@@ -17,7 +17,12 @@ from .recipes import (
17
17
  AdjustedEtfBarsRequest,
18
18
  AdjustedEtfBarsResult,
19
19
  AdjustmentCoveragePolicy,
20
+ DividendYieldCoveragePolicy,
21
+ DividendYieldWeightSource,
22
+ WeightedDividendYieldResult,
20
23
  build_adjusted_etf_bars,
24
+ build_index_dividend_yield,
25
+ build_portfolio_dividend_yield,
21
26
  fetch_adjusted_etf_bars,
22
27
  )
23
28
 
@@ -26,6 +31,8 @@ __all__ = [
26
31
  "AdjustedEtfBarsResult",
27
32
  "AdjustmentCoveragePolicy",
28
33
  "DailyBasicRequest",
34
+ "DividendYieldCoveragePolicy",
35
+ "DividendYieldWeightSource",
29
36
  "EmptyPolicy",
30
37
  "FundAdjustmentRequest",
31
38
  "FundBasicRequest",
@@ -38,7 +45,10 @@ __all__ = [
38
45
  "TradeCalendarRequest",
39
46
  "TushareClient",
40
47
  "TushareFetchResult",
48
+ "WeightedDividendYieldResult",
41
49
  "build_adjusted_etf_bars",
50
+ "build_index_dividend_yield",
51
+ "build_portfolio_dividend_yield",
42
52
  "classify_tushare_exception",
43
53
  "fetch_adjusted_etf_bars",
44
54
  ]
@@ -0,0 +1,27 @@
1
+ from .etf_adjusted_bars import (
2
+ AdjustedEtfBarsRequest,
3
+ AdjustedEtfBarsResult,
4
+ AdjustmentCoveragePolicy,
5
+ build_adjusted_etf_bars,
6
+ fetch_adjusted_etf_bars,
7
+ )
8
+ from .weighted_dividend_yield import (
9
+ DividendYieldCoveragePolicy,
10
+ DividendYieldWeightSource,
11
+ WeightedDividendYieldResult,
12
+ build_index_dividend_yield,
13
+ build_portfolio_dividend_yield,
14
+ )
15
+
16
+ __all__ = [
17
+ "AdjustedEtfBarsRequest",
18
+ "AdjustedEtfBarsResult",
19
+ "AdjustmentCoveragePolicy",
20
+ "DividendYieldCoveragePolicy",
21
+ "DividendYieldWeightSource",
22
+ "WeightedDividendYieldResult",
23
+ "build_adjusted_etf_bars",
24
+ "build_index_dividend_yield",
25
+ "build_portfolio_dividend_yield",
26
+ "fetch_adjusted_etf_bars",
27
+ ]
@@ -0,0 +1,231 @@
1
+ """Offline weighted dividend-yield recipes for Tushare-native frames."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import numbers
7
+ from dataclasses import dataclass
8
+ from enum import Enum
9
+ from typing import Any
10
+
11
+ from ....core import CoverageError, SchemaMismatchError
12
+
13
+ PORTFOLIO_FORMULA_IDENTIFIER = "fund_portfolio_mkv_weighted_daily_basic_dv_ttm_v1"
14
+ INDEX_FORMULA_IDENTIFIER = "index_weight_weighted_daily_basic_dv_ttm_v1"
15
+ PORTFOLIO_SUPPORTED_FORMULA_IDENTIFIER = (
16
+ "fund_portfolio_mkv_supported_weight_normalized_daily_basic_dv_ttm_v1"
17
+ )
18
+ INDEX_SUPPORTED_FORMULA_IDENTIFIER = (
19
+ "index_weight_supported_weight_normalized_daily_basic_dv_ttm_v1"
20
+ )
21
+
22
+
23
+ class DividendYieldCoveragePolicy(str, Enum):
24
+ REQUIRE_COMPLETE = "REQUIRE_COMPLETE"
25
+ PRESERVE_INCOMPLETE = "PRESERVE_INCOMPLETE"
26
+ NORMALIZE_SUPPORTED = "NORMALIZE_SUPPORTED"
27
+
28
+
29
+ class DividendYieldWeightSource(str, Enum):
30
+ FUND_PORTFOLIO = "FUND_PORTFOLIO"
31
+ INDEX_WEIGHT = "INDEX_WEIGHT"
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class WeightedDividendYieldResult:
36
+ dividend_yield: float | None
37
+ finite_weight_coverage: float
38
+ provider_total_weight: float
39
+ provider_supported_weight: float
40
+ constituent_count: int
41
+ supported_constituent_count: int
42
+ weight_source: DividendYieldWeightSource
43
+ coverage_policy: DividendYieldCoveragePolicy
44
+ formula_identifier: str
45
+
46
+
47
+ def _pd() -> Any:
48
+ try:
49
+ import pandas as pd
50
+ except ImportError as exc:
51
+ raise ImportError("install ohmydata[tushare]") from exc
52
+ return pd
53
+
54
+
55
+ def _finite_real(value: Any) -> bool:
56
+ return (
57
+ isinstance(value, numbers.Real)
58
+ and not isinstance(value, bool) # type: ignore[reportUnnecessaryIsInstance]
59
+ and math.isfinite(float(value))
60
+ )
61
+
62
+
63
+ def _key_valid(value: Any) -> bool:
64
+ return isinstance(value, str) and bool(value)
65
+
66
+
67
+ def _validate_frame(frame: Any, name: str, required: tuple[str, ...]) -> Any:
68
+ pd = _pd()
69
+ if not isinstance(frame, pd.DataFrame):
70
+ raise TypeError(f"{name} must be a Pandas DataFrame")
71
+ missing = [field for field in required if field not in frame.columns]
72
+ if missing:
73
+ raise SchemaMismatchError("required recipe fields are missing")
74
+ return frame.copy(deep=True)
75
+
76
+
77
+ def _validate_daily(frame: Any) -> Any:
78
+ daily = _validate_frame(frame, "daily_basic_frame", ("ts_code", "trade_date", "dv_ttm"))
79
+ if daily.empty:
80
+ return daily
81
+ if daily["ts_code"].isna().any() or any(not _key_valid(x) for x in daily["ts_code"].tolist()):
82
+ raise SchemaMismatchError("invalid daily-basic symbol")
83
+ if daily["ts_code"].duplicated().any():
84
+ raise SchemaMismatchError("duplicate daily-basic symbol")
85
+ if daily["trade_date"].isna().any() or daily["trade_date"].nunique(dropna=False) != 1:
86
+ raise SchemaMismatchError("daily-basic frame must contain exactly one trade date")
87
+ return daily
88
+
89
+
90
+ def _validate_policy(policy: Any) -> DividendYieldCoveragePolicy:
91
+ if not isinstance(policy, DividendYieldCoveragePolicy): # type: ignore[reportUnnecessaryIsInstance]
92
+ raise TypeError("coverage_policy must DividendYieldCoveragePolicy")
93
+ return policy
94
+
95
+
96
+ def _calculate(
97
+ weights: Any,
98
+ daily_frame: Any,
99
+ policy: DividendYieldCoveragePolicy,
100
+ source: DividendYieldWeightSource,
101
+ key: str,
102
+ formula_identifier: str,
103
+ supported_formula_identifier: str,
104
+ weight_field: str,
105
+ minimum: float,
106
+ ) -> WeightedDividendYieldResult:
107
+ _validate_policy(policy)
108
+ daily = _validate_daily(daily_frame)
109
+ required = (
110
+ ("ts_code", "end_date", "symbol", "mkv")
111
+ if source is DividendYieldWeightSource.FUND_PORTFOLIO
112
+ else ("index_code", "con_code", "trade_date", "weight")
113
+ )
114
+ frame = _validate_frame(weights, "weight_frame", required)
115
+ if frame.empty:
116
+ raise CoverageError("weight frame is empty")
117
+ if source is DividendYieldWeightSource.FUND_PORTFOLIO:
118
+ if frame["ts_code"].isna().any() or frame["ts_code"].nunique(dropna=False) != 1:
119
+ raise SchemaMismatchError("portfolio frame must contain exactly one fund")
120
+ if frame["end_date"].isna().any() or frame["end_date"].nunique(dropna=False) != 1:
121
+ raise SchemaMismatchError("portfolio frame must contain exactly one report date")
122
+ if frame["symbol"].isna().any() or any(not _key_valid(x) for x in frame["symbol"].tolist()):
123
+ raise SchemaMismatchError("invalid portfolio symbol")
124
+ if frame["symbol"].duplicated().any():
125
+ raise SchemaMismatchError("duplicate portfolio symbol")
126
+ else:
127
+ if frame["index_code"].isna().any() or frame["index_code"].nunique(dropna=False) != 1:
128
+ raise SchemaMismatchError("index frame must contain exactly one index")
129
+ if frame["trade_date"].isna().any() or frame["trade_date"].nunique(dropna=False) != 1:
130
+ raise SchemaMismatchError("index frame must contain exactly one trade date")
131
+ if frame["con_code"].isna().any() or any(
132
+ not _key_valid(x) for x in frame["con_code"].tolist()
133
+ ):
134
+ raise SchemaMismatchError("invalid index constituent")
135
+ if frame["con_code"].duplicated().any():
136
+ raise SchemaMismatchError("duplicate index constituent")
137
+ values = frame[weight_field].tolist()
138
+ invalid_weight = any(not _finite_real(value) or float(value) < minimum for value in values)
139
+ if source is DividendYieldWeightSource.FUND_PORTFOLIO:
140
+ invalid_weight = invalid_weight or any(
141
+ _finite_real(value) and float(value) <= 0 for value in values
142
+ )
143
+ if invalid_weight:
144
+ raise SchemaMismatchError("invalid provider weight")
145
+ total = float(sum(float(value) for value in values))
146
+ if total <= 0 or not math.isfinite(total):
147
+ raise CoverageError("provider total weight must be positive")
148
+ # A zero-weight index constituent is not coverage-relevant.
149
+ positive = frame.loc[frame[weight_field].astype(float) > 0, [key, weight_field]].copy()
150
+ lookup = {row.ts_code: row.dv_ttm for row in daily.itertuples(index=False)}
151
+ supported_weight = 0.0
152
+ supported_count = 0
153
+ weighted_sum = 0.0
154
+ for row in positive.itertuples(index=False):
155
+ weight = float(getattr(row, weight_field))
156
+ value = lookup.get(getattr(row, key))
157
+ if _finite_real(value):
158
+ supported_weight += weight
159
+ supported_count += 1
160
+ weighted_sum += (weight / total) * float(value) # type: ignore[arg-type]
161
+ coverage = supported_weight / total
162
+ complete = math.isclose(coverage, 1.0, rel_tol=0.0, abs_tol=1e-12)
163
+ if not complete and policy is DividendYieldCoveragePolicy.REQUIRE_COMPLETE:
164
+ raise CoverageError("finite dividend-yield weight coverage is incomplete")
165
+ if complete:
166
+ result_yield = weighted_sum / 100.0
167
+ elif policy is DividendYieldCoveragePolicy.NORMALIZE_SUPPORTED and supported_weight > 0:
168
+ result_yield = (weighted_sum / coverage) / 100.0
169
+ else:
170
+ result_yield = None
171
+ effective_formula_identifier = (
172
+ supported_formula_identifier
173
+ if policy is DividendYieldCoveragePolicy.NORMALIZE_SUPPORTED
174
+ else formula_identifier
175
+ )
176
+ return WeightedDividendYieldResult(
177
+ result_yield,
178
+ coverage,
179
+ total,
180
+ supported_weight,
181
+ len(frame),
182
+ supported_count,
183
+ source,
184
+ policy,
185
+ effective_formula_identifier,
186
+ )
187
+
188
+
189
+ def build_portfolio_dividend_yield(
190
+ weight_frame: Any,
191
+ daily_basic_frame: Any,
192
+ coverage_policy: DividendYieldCoveragePolicy,
193
+ ) -> WeightedDividendYieldResult:
194
+ return _calculate(
195
+ weight_frame,
196
+ daily_basic_frame,
197
+ coverage_policy,
198
+ DividendYieldWeightSource.FUND_PORTFOLIO,
199
+ "symbol",
200
+ PORTFOLIO_FORMULA_IDENTIFIER,
201
+ PORTFOLIO_SUPPORTED_FORMULA_IDENTIFIER,
202
+ "mkv",
203
+ 0.0,
204
+ )
205
+
206
+
207
+ def build_index_dividend_yield(
208
+ weight_frame: Any,
209
+ daily_basic_frame: Any,
210
+ coverage_policy: DividendYieldCoveragePolicy,
211
+ ) -> WeightedDividendYieldResult:
212
+ return _calculate(
213
+ weight_frame,
214
+ daily_basic_frame,
215
+ coverage_policy,
216
+ DividendYieldWeightSource.INDEX_WEIGHT,
217
+ "con_code",
218
+ INDEX_FORMULA_IDENTIFIER,
219
+ INDEX_SUPPORTED_FORMULA_IDENTIFIER,
220
+ "weight",
221
+ 0.0,
222
+ )
223
+
224
+
225
+ __all__ = [
226
+ "DividendYieldCoveragePolicy",
227
+ "DividendYieldWeightSource",
228
+ "WeightedDividendYieldResult",
229
+ "build_index_dividend_yield",
230
+ "build_portfolio_dividend_yield",
231
+ ]
@@ -223,7 +223,7 @@ wheels = [
223
223
 
224
224
  [[package]]
225
225
  name = "ohmydata"
226
- version = "0.0.2"
226
+ version = "0.0.4"
227
227
  source = { editable = "." }
228
228
 
229
229
  [package.optional-dependencies]
@@ -1,15 +0,0 @@
1
- from .etf_adjusted_bars import (
2
- AdjustedEtfBarsRequest,
3
- AdjustedEtfBarsResult,
4
- AdjustmentCoveragePolicy,
5
- build_adjusted_etf_bars,
6
- fetch_adjusted_etf_bars,
7
- )
8
-
9
- __all__ = [
10
- "AdjustedEtfBarsRequest",
11
- "AdjustedEtfBarsResult",
12
- "AdjustmentCoveragePolicy",
13
- "build_adjusted_etf_bars",
14
- "fetch_adjusted_etf_bars",
15
- ]
File without changes