commodutil 4.6.0__tar.gz → 5.4.0__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 (86) hide show
  1. commodutil-5.4.0/CHANGELOG.md +45 -0
  2. {commodutil-4.6.0 → commodutil-5.4.0}/PKG-INFO +1 -1
  3. {commodutil-4.6.0 → commodutil-5.4.0}/commodutil/__init__.py +2 -1
  4. {commodutil-4.6.0 → commodutil-5.4.0}/commodutil/convfactors.py +33 -451
  5. commodutil-5.4.0/commodutil/priceconv.py +410 -0
  6. {commodutil-4.6.0 → commodutil-5.4.0}/commodutil/standards/__init__.py +6 -5
  7. {commodutil-4.6.0 → commodutil-5.4.0}/commodutil/standards/commodities.py +115 -0
  8. {commodutil-4.6.0 → commodutil-5.4.0}/commodutil/standards/price_unit.py +110 -4
  9. {commodutil-4.6.0 → commodutil-5.4.0}/commodutil/standards/unit_registry.py +49 -5
  10. {commodutil-4.6.0 → commodutil-5.4.0}/commodutil/standards/units.py +46 -25
  11. {commodutil-4.6.0 → commodutil-5.4.0}/commodutil.egg-info/PKG-INFO +1 -1
  12. {commodutil-4.6.0 → commodutil-5.4.0}/commodutil.egg-info/SOURCES.txt +4 -2
  13. {commodutil-4.6.0 → commodutil-5.4.0}/commodutil.egg-info/scm_file_list.json +4 -2
  14. commodutil-5.4.0/commodutil.egg-info/scm_version.json +8 -0
  15. {commodutil-4.6.0 → commodutil-5.4.0}/tests/golden/golden_factors.json +48 -48
  16. commodutil-5.4.0/tests/test_aliases.py +93 -0
  17. {commodutil-4.6.0 → commodutil-5.4.0}/tests/test_conv.py +21 -0
  18. {commodutil-4.6.0 → commodutil-5.4.0}/tests/test_price_unit.py +129 -3
  19. commodutil-5.4.0/tests/test_priceconv_reexports.py +16 -0
  20. {commodutil-4.6.0 → commodutil-5.4.0}/tests/test_standards_commodities.py +146 -0
  21. {commodutil-4.6.0 → commodutil-5.4.0}/tests/test_standards_units.py +101 -26
  22. {commodutil-4.6.0 → commodutil-5.4.0}/tests/test_unit_registry.py +119 -2
  23. commodutil-4.6.0/commodutil/standards/price_units.py +0 -99
  24. commodutil-4.6.0/commodutil.egg-info/scm_version.json +0 -8
  25. commodutil-4.6.0/tests/test_standards_price_units.py +0 -122
  26. {commodutil-4.6.0 → commodutil-5.4.0}/.coveragerc +0 -0
  27. {commodutil-4.6.0 → commodutil-5.4.0}/.github/workflows/1_tests.yml +0 -0
  28. {commodutil-4.6.0 → commodutil-5.4.0}/.github/workflows/2_coverage.yml +0 -0
  29. {commodutil-4.6.0 → commodutil-5.4.0}/.github/workflows/3_linting.yml +0 -0
  30. {commodutil-4.6.0 → commodutil-5.4.0}/.github/workflows/4_release.yml +0 -0
  31. {commodutil-4.6.0 → commodutil-5.4.0}/.gitignore +0 -0
  32. {commodutil-4.6.0 → commodutil-5.4.0}/.pypirc +0 -0
  33. {commodutil-4.6.0 → commodutil-5.4.0}/azure-build-pipelines.yml +0 -0
  34. {commodutil-4.6.0 → commodutil-5.4.0}/commodutil/arb.py +0 -0
  35. {commodutil-4.6.0 → commodutil-5.4.0}/commodutil/dates.py +0 -0
  36. {commodutil-4.6.0 → commodutil-5.4.0}/commodutil/forward/__init__.py +0 -0
  37. {commodutil-4.6.0 → commodutil-5.4.0}/commodutil/forward/calendar.py +0 -0
  38. {commodutil-4.6.0 → commodutil-5.4.0}/commodutil/forward/continuous.py +0 -0
  39. {commodutil-4.6.0 → commodutil-5.4.0}/commodutil/forward/fly.py +0 -0
  40. {commodutil-4.6.0 → commodutil-5.4.0}/commodutil/forward/quarterly.py +0 -0
  41. {commodutil-4.6.0 → commodutil-5.4.0}/commodutil/forward/spreads.py +0 -0
  42. {commodutil-4.6.0 → commodutil-5.4.0}/commodutil/forward/structure.py +0 -0
  43. {commodutil-4.6.0 → commodutil-5.4.0}/commodutil/forward/util.py +0 -0
  44. {commodutil-4.6.0 → commodutil-5.4.0}/commodutil/forwards.py +0 -0
  45. {commodutil-4.6.0 → commodutil-5.4.0}/commodutil/pandasutil.py +0 -0
  46. {commodutil-4.6.0 → commodutil-5.4.0}/commodutil/standards/analysis_types.py +0 -0
  47. {commodutil-4.6.0 → commodutil-5.4.0}/commodutil/standards/commodity_groups.py +0 -0
  48. {commodutil-4.6.0 → commodutil-5.4.0}/commodutil/standards/currency.py +0 -0
  49. {commodutil-4.6.0 → commodutil-5.4.0}/commodutil/standards/regions.py +0 -0
  50. {commodutil-4.6.0 → commodutil-5.4.0}/commodutil/stats.py +0 -0
  51. {commodutil-4.6.0 → commodutil-5.4.0}/commodutil/transforms.py +0 -0
  52. {commodutil-4.6.0 → commodutil-5.4.0}/commodutil.egg-info/dependency_links.txt +0 -0
  53. {commodutil-4.6.0 → commodutil-5.4.0}/commodutil.egg-info/requires.txt +0 -0
  54. {commodutil-4.6.0 → commodutil-5.4.0}/commodutil.egg-info/top_level.txt +0 -0
  55. {commodutil-4.6.0 → commodutil-5.4.0}/pyproject.toml +0 -0
  56. {commodutil-4.6.0 → commodutil-5.4.0}/requirements-test.txt +0 -0
  57. {commodutil-4.6.0 → commodutil-5.4.0}/requirements.txt +0 -0
  58. {commodutil-4.6.0 → commodutil-5.4.0}/requirements_dev.txt +0 -0
  59. {commodutil-4.6.0 → commodutil-5.4.0}/setup.cfg +0 -0
  60. {commodutil-4.6.0 → commodutil-5.4.0}/tests/__init__.py +0 -0
  61. {commodutil-4.6.0 → commodutil-5.4.0}/tests/conftest.py +0 -0
  62. {commodutil-4.6.0 → commodutil-5.4.0}/tests/forward/__init__.py +0 -0
  63. {commodutil-4.6.0 → commodutil-5.4.0}/tests/forward/conftest.py +0 -0
  64. {commodutil-4.6.0 → commodutil-5.4.0}/tests/forward/test_calendar.py +0 -0
  65. {commodutil-4.6.0 → commodutil-5.4.0}/tests/forward/test_continuous.py +0 -0
  66. {commodutil-4.6.0 → commodutil-5.4.0}/tests/forward/test_fly.py +0 -0
  67. {commodutil-4.6.0 → commodutil-5.4.0}/tests/forward/test_quarterly.py +0 -0
  68. {commodutil-4.6.0 → commodutil-5.4.0}/tests/forward/test_spreads.py +0 -0
  69. {commodutil-4.6.0 → commodutil-5.4.0}/tests/forward/test_structure.py +0 -0
  70. {commodutil-4.6.0 → commodutil-5.4.0}/tests/forward/test_util.py +0 -0
  71. {commodutil-4.6.0 → commodutil-5.4.0}/tests/test_align_fx_result.py +0 -0
  72. {commodutil-4.6.0 → commodutil-5.4.0}/tests/test_arb.py +0 -0
  73. {commodutil-4.6.0 → commodutil-5.4.0}/tests/test_cl.csv +0 -0
  74. {commodutil-4.6.0 → commodutil-5.4.0}/tests/test_dates.py +0 -0
  75. {commodutil-4.6.0 → commodutil-5.4.0}/tests/test_forwards.py +0 -0
  76. {commodutil-4.6.0 → commodutil-5.4.0}/tests/test_golden_factors.py +0 -0
  77. {commodutil-4.6.0 → commodutil-5.4.0}/tests/test_pandasutils.py +0 -0
  78. {commodutil-4.6.0 → commodutil-5.4.0}/tests/test_price_conv.py +0 -0
  79. {commodutil-4.6.0 → commodutil-5.4.0}/tests/test_rate_pint.py +0 -0
  80. {commodutil-4.6.0 → commodutil-5.4.0}/tests/test_standards_analysis_types.py +0 -0
  81. {commodutil-4.6.0 → commodutil-5.4.0}/tests/test_standards_commodity_groups.py +0 -0
  82. {commodutil-4.6.0 → commodutil-5.4.0}/tests/test_standards_currency.py +0 -0
  83. {commodutil-4.6.0 → commodutil-5.4.0}/tests/test_standards_regions.py +0 -0
  84. {commodutil-4.6.0 → commodutil-5.4.0}/tests/test_stats.py +0 -0
  85. {commodutil-4.6.0 → commodutil-5.4.0}/tests/test_transforms.py +0 -0
  86. {commodutil-4.6.0 → commodutil-5.4.0}/tests/test_weekly.csv +0 -0
@@ -0,0 +1,45 @@
1
+ # Changelog
2
+
3
+ ## 5.4.0 - 2026-07-12
4
+
5
+ - Added `normalize_price_unit_strict` and `is_canonical_price_unit` to `commodutil.standards.units` (and the `commodutil` / `commodutil.standards` facades): the STRICT counterpart to the lenient `canonical_price_unit_token`. Where the lenient token preserves unrecognised currency/unit fragments so it can label any quote string, the strict variant returns a canonical `CCY/unit` token only when both legs resolve (recognised currency over a `canonical_quantity_unit` denominator) and `None` otherwise — for validating a curated price-unit qualifier that must round-trip through conversion (e.g. `usd_ton`/`USD/ton` refused, `USD/MT` -> `USD/mt`).
6
+
7
+ ## 5.0.1 - 2026-07-03
8
+
9
+ - Breaking: removed `default_unit_for_commodity` and `_DEFAULT_UNIT` from `commodutil.standards.units`; these fabricated per-commodity quote units, and the gold instrument registry answers this per-instrument.
10
+ - Breaking: removed the `commodutil.standards.price_units` module. Price-unit resolution (`resolve_price_unit`, `resolve_price_unit_from_attrs`) moved into `commodutil.standards.price_unit` alongside the `PriceUnit` value type. Migrate imports to `commodutil.standards.price_unit`.
11
+ - First release of the 5.x line; the 5.0.0 version number is unusable on the feed.
12
+
13
+ ## 4.10.0 - 2026-07-03
14
+
15
+ - Added exchange unit spellings for `MTONS`, `CBM`, `THM`, and `KL` to the unit registry, with `KL`/kilolitre spellings resolving to the canonical `m^3` unit.
16
+ - Moved commodity alias ownership to `commodutil.standards.commodities.COMMODITY_ALIASES`; `CommodityConverter` now consumes a defensive copy from that source.
17
+ - Breaking: removed the `commodutil.convfactors.ALIASES` re-export. Migrate callers to `from commodutil.standards.commodities import COMMODITY_ALIASES`.
18
+
19
+ ## 4.8.0 - 2026-07-03
20
+
21
+ - Split price, currency, and FX conversion helpers into `commodutil.priceconv`, while keeping lazy compatibility exports from `commodutil.convfactors` for the price-conversion API.
22
+ - Consolidated petrochemical taxonomy keywords into `commodutil.standards.commodities`.
23
+ - Added `infer_ngl_species` for discrete NGL species inference from free text.
24
+
25
+ ## 4.7.0 - 2026-07-03
26
+
27
+ - Fixed ethanol density to `0.7937 kg/L`, preserving the EIA gross per-volume energy content while correcting mass-based conversion factors.
28
+ - Promoted `kg` and bare cubic-metre spellings (`m3`, `m^3`, `m**3`) into the `UNIT_MAP` tier so vendor-spec parsers can resolve them directly.
29
+
30
+ ## 4.6.0 - 2026-07-02
31
+
32
+ - Replaced hand-rolled scalar rate-period conversion with `PriceUnit` parsing and Pint-derived period ratios, while preserving calendar-aware month/day handling for series.
33
+ - Added `align_fx`, `ConversionResult`, and `convert_price_result` for explicit price-conversion results, currency-leg fallback, and keep-raw error handling.
34
+
35
+ ## 4.5.0 - 2026-07-02
36
+
37
+ - Added the `PriceUnit` value type for structured price-unit parsing across currency, quantity-unit, and rate-period legs.
38
+ - Added `commodutil.standards.unit_registry` as the single source of truth for unit vocabulary, public unit maps, and Pint definitions.
39
+ - Added ICE unit spellings for `MMBtu`, `GJ`, `MWh`, `m^3`, `MW`, `RIN`, `FEU`, and `day`, covering the registry gap identified in ICE contract metadata.
40
+
41
+ ## 4.4.0 - 2026-07-02
42
+
43
+ - Standardized covered liquid and biofuel energy-content factors on the EIA gross/HHV basis, documenting explicit exceptions and basis assumptions.
44
+ - Added API2/coal support with mass-basis energy content for density-less solid-fuel conversions and Coal taxonomy inference.
45
+ - Added the golden-factor regression fixture and validation tests for quantity, rate, and price conversions.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: commodutil
3
- Version: 4.6.0
3
+ Version: 5.4.0
4
4
  Summary: common commodity/oil analytics utils
5
5
  Author-email: aeorxc <author@example.com>
6
6
  Project-URL: Homepage, https://dev.azure.com/RWEST-MFI-TE/Oil/_git/commodutil
@@ -22,7 +22,6 @@ from __future__ import annotations
22
22
  # resolve via __getattr__ below.
23
23
  _LAZY_EXPORTS = {
24
24
  # convfactors (heaviest -- pint registry + Commodity dataclass init)
25
- "ALIASES": "commodutil.convfactors",
26
25
  "COMMODITIES": "commodutil.convfactors",
27
26
  "Commodity": "commodutil.convfactors",
28
27
  "convert": "commodutil.convfactors",
@@ -41,6 +40,8 @@ _LAZY_EXPORTS = {
41
40
  "canonical_price_unit_token": "commodutil.standards.units",
42
41
  "canonical_quantity_unit": "commodutil.standards.units",
43
42
  "canonical_unit_token": "commodutil.standards.units",
43
+ "is_canonical_price_unit": "commodutil.standards.units",
44
+ "normalize_price_unit_strict": "commodutil.standards.units",
44
45
  "quantity_unit_from_price_unit": "commodutil.standards.units",
45
46
  # dates
46
47
  "curmon": "commodutil.dates",
@@ -1,6 +1,8 @@
1
1
  """
2
2
  Modern implementation of commodity unit conversions using Pint.
3
3
  Clean-slate design with no backward-compatibility constraints.
4
+
5
+ Module map: quantity/pint conversion lives here; price/currency/FX lives in ``commodutil.priceconv``; vocabulary lives in ``commodutil.standards.unit_registry``; ``PriceUnit`` grammar and resolve precedence live in ``commodutil.standards.price_unit``.
4
6
  """
5
7
 
6
8
  import logging
@@ -11,7 +13,7 @@ from dataclasses import dataclass
11
13
  import pandas as pd
12
14
  from functools import lru_cache
13
15
 
14
- from commodutil.standards import currency as _currency
16
+ from commodutil.standards.commodities import COMMODITY_ALIASES as _COMMODITY_ALIASES
15
17
  from commodutil.standards.price_unit import PriceUnit
16
18
  from commodutil.standards.unit_registry import PINT_DEFINITIONS as _PINT_DEFINITIONS
17
19
  from commodutil.standards.units import to_pint_token as _to_pint_token
@@ -131,8 +133,8 @@ COMMODITIES = {
131
133
  "naphtha", 0.706720311 * ureg.kg / ureg.L, 34.826266 * ureg.GJ / ureg.m**3
132
134
  ), # HHV; EIA petrochemical naphtha <401F 5.248 MMBtu/bbl -> 49.28 GJ/t gross (was NCV 44.90)
133
135
  "ethanol": Commodity(
134
- "ethanol", 0.755079324 * ureg.kg / ureg.L, 23.485167 * ureg.GJ / ureg.m**3
135
- ), # HHV; EIA fuel ethanol (undenatured) 3.539 MMBtu/bbl -> 31.10 GJ/t gross (was net ~27.81); NOTE density 0.7551 looks copied from gasoline (ethanol ~0.789) - flagged, left unchanged
136
+ "ethanol", 0.7937 * ureg.kg / ureg.L, 23.485167 * ureg.GJ / ureg.m**3
137
+ ), # HHV; EIA fuel ethanol (undenatured) 3.539 MMBtu/bbl. Density: ASTM D4806 undenatured fuel ethanol SG 60/60F lower bound 0.7937 (Energy Transfer spec note; IEA AMF rounds ethanol density to 0.79 kg/L). Energy_content is per-volume, so bbl->MMBtu stays fixed while mt factors move.
136
138
  # Middle distillates
137
139
  "diesel": Commodity(
138
140
  "diesel", 0.844269902 * ureg.kg / ureg.L, 38.290312 * ureg.GJ / ureg.m**3
@@ -204,48 +206,6 @@ COMMODITIES = {
204
206
  # GCV = 29.29 GJ/t) which understated coal $/MMBtu by ~11%.
205
207
  }
206
208
 
207
- # Aliases for compatibility
208
- ALIASES = {
209
- # Common synonyms and marketing terms
210
- "ulsd": "diesel",
211
- "gasoil": "diesel",
212
- "gas_oil": "diesel",
213
- "gas oil": "diesel",
214
- "go": "diesel",
215
- "kerosene": "jet",
216
- # Motor gasoline
217
- "gas": "gasoline",
218
- "mogas": "gasoline",
219
- # Fuel oil
220
- "fueloil": "fuel_oil",
221
- "fuel oil": "fuel_oil",
222
- "fo": "fuel_oil",
223
- # Crude
224
- "crude oil": "crude",
225
- "crudeoil": "crude",
226
- # LPG and nat gas
227
- # Note: 'propane'/'butane'/'isobutane'/'natural_gasoline' are now first-class
228
- # entries in COMMODITIES (added 2026-05 for $/gal<->$/MMBtu work on the
229
- # MB OPIS NGL futures). The legacy 'propane -> lpg' alias is gone; existing
230
- # callers using commodity='lpg' for a generic LPG blend still work via the
231
- # 'lpg' entry. NGL-species aliases below cover common spellings.
232
- "n_butane": "butane",
233
- "n-butane": "butane",
234
- "normal_butane": "butane",
235
- "normal butane": "butane",
236
- "iso_butane": "isobutane",
237
- "iso-butane": "isobutane",
238
- "i_butane": "isobutane",
239
- "i-butane": "isobutane",
240
- "natgaso": "natural_gasoline",
241
- "nat_gasoline": "natural_gasoline",
242
- "pentanes_plus": "natural_gasoline",
243
- "lng": "natgas", # LNG (liquefied natural gas)
244
- "ng": "natural_gas", # pipeline natural gas (gaseous)
245
- "naturalgas": "natural_gas",
246
- "nat_gas": "natural_gas",
247
- }
248
-
249
209
 
250
210
  class CommodityConverter:
251
211
  """Clean, modern interface for commodity unit conversions"""
@@ -253,7 +213,12 @@ class CommodityConverter:
253
213
  def __init__(self):
254
214
  self.ureg = ureg
255
215
  self.commodities = COMMODITIES
256
- self.aliases = ALIASES
216
+ # Alias (spelling/synonym) -> canonical COMMODITIES key. Sole owner is
217
+ # commodutil.standards.commodities.COMMODITY_ALIASES (grouped by target;
218
+ # edit there). Defensive copy so converter-local mutation can't touch
219
+ # the owner. The old convfactors.ALIASES re-export was removed with the
220
+ # B1 consolidation — import COMMODITY_ALIASES from standards instead.
221
+ self.aliases = dict(_COMMODITY_ALIASES)
257
222
 
258
223
  @lru_cache(maxsize=128)
259
224
  def get_commodity(self, name: str) -> Commodity:
@@ -576,421 +541,38 @@ def convfactor(
576
541
  return converter.convert(1.0, from_unit, to_unit, commodity)
577
542
 
578
543
 
579
- # ---- Currency-aware price conversion helpers ----
580
- #
581
- # Currency vocabulary lives in commodutil.standards.currency (importable
582
- # without dragging in pint / pandas). convfactors owns only the integrated
583
- # unit + currency `convert_price` math (which depends on the pint registry
584
- # above) and reads currency vocabulary directly from _currency. Callers
585
- # wanting currency vocabulary should import from commodutil.standards.currency.
586
-
587
-
588
- def align_fx(
589
- fx: pd.Series,
590
- index: pd.Index,
591
- policy: str = "strict",
592
- max_staleness: pd.Timedelta = pd.Timedelta(days=7),
593
- ) -> pd.Series:
594
- """Align an FX Series onto ``index`` for a price conversion.
595
-
596
- Extracted verbatim from ``convert_price`` (Phase 3.4) so new code can compose
597
- FX alignment explicitly; ``convert_price`` delegates here, so behaviour and
598
- error/warning text are unchanged.
599
-
600
- - ``policy='strict'`` (default): forward-fill FX onto ``index`` bounded by
601
- ``max_staleness``; any target date left uncovered (pre-FX-start, or stale
602
- past the limit) raises ``ValueError`` rather than silently back-filling or
603
- extrapolating.
604
- - ``policy='ffill'``: permissive — union the indices, ffill across the union,
605
- reindex back, and fill any residual NaN with the most-recent non-null FX.
606
- Emits a logging warning (future-leakage risk in backtests). Raises only if
607
- the FX series is entirely NaN.
608
- """
609
- target_idx = index
610
-
611
- if policy == "strict":
612
- # Bounded ffill: only forward-fill within `max_staleness`. Anything
613
- # uncovered (e.g. value-dates before fx.index.min() or stale past
614
- # the limit) stays NaN and triggers a loud raise — no silent
615
- # back-fill, no silent stale extrapolation.
616
- union_idx = fx.index.union(target_idx)
617
- fx_union = fx.reindex(union_idx).sort_index().ffill()
618
- # Track how stale each ffilled value is and zero-out anything older
619
- # than max_staleness.
620
- valid_mask = ~fx.reindex(union_idx).isna()
621
- last_valid_pos = pd.Series(union_idx, index=union_idx).where(valid_mask).ffill()
622
- staleness = pd.Series(union_idx, index=union_idx) - last_valid_pos
623
- fx_union = fx_union.where(staleness <= max_staleness)
624
- fx_aligned = fx_union.reindex(target_idx)
625
- if fx_aligned.isna().any():
626
- missing = target_idx[fx_aligned.isna()]
627
- first_missing = missing[0]
628
- first_missing_str = (
629
- first_missing.date()
630
- if hasattr(first_missing, "date")
631
- else first_missing
632
- )
633
- raise ValueError(
634
- f"FX missing or stale (>{max_staleness}) for "
635
- f"{len(missing)} target date(s) (first: {first_missing_str}). "
636
- f"Pass ffill_policy='ffill' to fill with the last non-null "
637
- f"FX (BACKTEST FUTURE LEAKAGE RISK)."
638
- )
639
- elif policy == "ffill":
640
- logger.warning(
641
- "convert_price: ffill_policy='ffill' — pre-FX-start dates will "
642
- "be back-filled with the latest FX. Future-leakage risk in "
643
- "backtests; prefer 'strict' for historical research."
644
- )
645
- if not target_idx.isin(fx.index).all():
646
- union_idx = fx.index.union(target_idx)
647
- fx_aligned = fx.reindex(union_idx).ffill().reindex(target_idx)
648
- else:
649
- fx_aligned = fx.reindex(target_idx).ffill()
650
- if fx_aligned.isna().any():
651
- fx_nonnull = fx.dropna()
652
- if fx_nonnull.size == 0:
653
- raise ValueError(
654
- "FX series is entirely NaN; refusing the silent "
655
- "multiply-by-1.0 fallback."
656
- )
657
- fx_aligned = fx_aligned.fillna(fx_nonnull.iloc[-1])
658
- else:
659
- raise ValueError(
660
- f"Unknown ffill_policy: {policy!r} (expected 'strict' or 'ffill')"
661
- )
662
-
663
- return fx_aligned
664
-
665
-
666
- def convert_price(
667
- value: Union[float, pd.Series],
668
- from_unit: Union[str, PriceUnit],
669
- to_unit: Union[str, PriceUnit],
670
- commodity: Optional[str] = None,
671
- fx: Union[float, pd.Series, None] = None,
672
- ffill_policy: str = "strict",
673
- max_staleness: pd.Timedelta = pd.Timedelta(days=7),
674
- ) -> Union[float, pd.Series]:
675
- """
676
- Convert price values ($/unit) between units, optionally bearing an FX rate.
677
-
678
- Price conversion is the inverse of quantity conversion:
679
- price_to = price_from / convfactor(from_unit, to_unit, commodity)
680
-
681
- If `from_unit` and `to_unit` differ in currency (e.g. EUR/MWh -> USD/MMBtu),
682
- `fx` must be supplied (scalar or pandas.Series indexed by date), quoted as
683
- target/source — i.e. USD-per-foreign-currency. Fractional-currency
684
- prefixes ('GBp', 'USc', ...) are auto-detected and divided by 100.
685
-
686
- If `fx` is a Series and `value` is a Series, alignment policy is controlled
687
- by `ffill_policy` and `max_staleness`:
688
-
689
- - `ffill_policy='strict'` (default): FX is forward-filled onto value.index
690
- with a bounded ffill of `max_staleness`. If any target dates remain
691
- uncovered, a `ValueError` is raised — refusing to silently back-fill
692
- pre-FX-start dates (which would be future leakage in backtests) or
693
- to extend stale FX values indefinitely.
694
- - `ffill_policy='ffill'`: legacy permissive behaviour — union the two
695
- indices, ffill across the union, then reindex back; any remaining
696
- NaNs are filled with the most-recent non-null FX. Emits a logging
697
- warning because this is unsafe for backtests.
544
+ def list_commodities():
545
+ """List all available commodities"""
546
+ return converter.available_commodities
698
547
 
699
- There is no all-NaN `1.0` fallback. If FX is unusable, the function raises.
700
548
 
701
- `from_unit` / `to_unit` are EITHER bare units ('mt', 'bbl', 'gal', 'MMBtu',
702
- 'MWh', 'therm') OR currency-qualified ('USD/bbl', 'EUR/MWh', 'GBp/therm').
703
- Currency-qualified targets are currently restricted to USD (anything else
704
- raises ValueError extend in future if non-USD targets are needed).
549
+ def list_units():
550
+ """List common units"""
551
+ # Return normalized forms to avoid encoding issues
552
+ return [_to_pint_token(u) for u in converter.available_units]
705
553
 
706
- Examples:
707
- # Gasoline: $/mt -> $/bbl (divide by ~8.33)
708
- convert_price(100, 'mt', 'bbl', commodity='gasoline') # ~12.0
709
554
 
710
- # US gallon to barrel: $/gal -> $/bbl (multiply by 42)
711
- convert_price(2.5, 'gal', 'bbl') # ~105.0
555
+ _PRICECONV_EXPORTS = {
556
+ "align_fx",
557
+ "convert_price",
558
+ "convert_currency_leg",
559
+ "ConversionResult",
560
+ "convert_price_result",
561
+ }
712
562
 
713
- # TTF EUR/MWh -> $/MMBtu (EURUSD = 1.07)
714
- convert_price(35.0, 'EUR/MWh', 'USD/MMBtu', fx=1.07) # ~10.98
715
563
 
716
- # NBP GBp/therm -> $/MMBtu (GBPUSD = 1.25); GBp auto-detected & /100
717
- convert_price(80.0, 'GBp/therm', 'USD/MMBtu', fx=1.25) # ~10.00
564
+ def __getattr__(name: str):
565
+ if name in _PRICECONV_EXPORTS:
566
+ from importlib import import_module
718
567
 
719
- # Time-varying FX with a pandas Series
720
- p = pd.Series([35.0, 36.5, 34.2], index=pd.date_range('2026', periods=3))
721
- fx_series = pd.Series([1.07, 1.08, 1.06], index=p.index)
722
- convert_price(p, 'EUR/MWh', 'USD/MMBtu', fx=fx_series)
723
- """
724
- # Parse each side once at the boundary into a PriceUnit and read its legs,
725
- # instead of re-splitting raw strings. PriceUnit.parse is built on
726
- # split_currency_unit, so (currency, quantity_leg) is byte-identical to the
727
- # previous split_currency_unit(...) result — behaviour and error strings are
728
- # unchanged. from_unit/to_unit are re-bound to the canonical string form for
729
- # use in the error messages / examples below.
730
- from_pu = (
731
- from_unit if isinstance(from_unit, PriceUnit) else PriceUnit.parse(from_unit)
732
- )
733
- to_pu = to_unit if isinstance(to_unit, PriceUnit) else PriceUnit.parse(to_unit)
734
- from_unit = str(from_pu)
735
- to_unit = str(to_pu)
736
- from_ccy, from_bare_unit = (from_pu.currency or ""), from_pu.quantity_leg()
737
- to_ccy, to_bare_unit = (to_pu.currency or ""), to_pu.quantity_leg()
738
-
739
- # Resolve the underlying "major" currency on each side for same-base detection
740
- # (e.g. USc and USD share major USD — pure scale, no FX needed).
741
- from_major = _currency.FRACTIONAL_TO_MAJOR.get(
742
- from_ccy, from_ccy.upper() if from_ccy else ""
743
- )
744
- to_major = _currency.FRACTIONAL_TO_MAJOR.get(
745
- to_ccy, to_ccy.upper() if to_ccy else ""
746
- )
747
- # Treat '$' as 'USD' for the purpose of major-currency comparison.
748
- if from_major == "$":
749
- from_major = "USD"
750
- if to_major == "$":
751
- to_major = "USD"
752
-
753
- same_base_fractional = bool(from_ccy and to_ccy and from_major == to_major)
754
-
755
- # Validate target currency — explicit USD only for now (only enforced when
756
- # the target is currency-qualified at all AND we're not in a same-base
757
- # fractional case like GBp/therm -> GBP/therm, which is a pure scale).
758
- if to_ccy and to_major != "USD" and not same_base_fractional:
759
- raise ValueError(
760
- f"convert_price currently only supports USD/* as target; got '{to_unit}'"
761
- )
762
-
763
- # Unit-leg conversion (no FX yet — uses commodity factors).
764
- # convfactor returns a nonzero float or raises, so no None/zero guard is
765
- # needed here.
766
- factor = convfactor(from_bare_unit, to_bare_unit, commodity)
767
- unit_converted = value / factor
768
-
769
- # Same-base fractional case: USc -> USD, GBp -> GBP, EUc -> EUR, JPy -> JPY.
770
- # This is a pure /100 scale (or *100 in the reverse direction) — no FX
771
- # needed even though the literal currency tokens differ. Handle BEFORE the
772
- # `fx is None` raise below.
773
- if same_base_fractional:
774
- from_div = _currency.FRACTIONAL_CURRENCY_DIVISORS.get(from_ccy, 1.0)
775
- to_div = _currency.FRACTIONAL_CURRENCY_DIVISORS.get(to_ccy, 1.0)
776
- # value is in source-currency units; divide by from_div to get majors,
777
- # multiply by to_div to get target-currency units.
778
- return unit_converted * (to_div / from_div)
779
-
780
- # If no source currency or it's already USD, no FX leg needed
781
- if not from_ccy or from_major == "USD":
782
- return unit_converted
783
-
784
- # Apply FX leg
785
- if fx is None:
786
- raise ValueError(
787
- f"FX rate required to convert {from_unit} -> {to_unit} "
788
- f"(source currency '{from_ccy}' is non-USD)"
789
- )
790
-
791
- fractional_divisor = _currency.FRACTIONAL_CURRENCY_DIVISORS.get(from_ccy, 1.0)
792
-
793
- if isinstance(unit_converted, pd.Series) and isinstance(fx, pd.Series):
794
- fx_aligned = align_fx(
795
- fx, unit_converted.index, policy=ffill_policy, max_staleness=max_staleness
796
- )
797
- return unit_converted * fx_aligned / fractional_divisor
798
-
799
- return unit_converted * fx / fractional_divisor
800
-
801
-
802
- def convert_currency_leg(
803
- value: Union[float, pd.Series],
804
- from_unit: Union[str, PriceUnit],
805
- to_unit: Union[str, PriceUnit],
806
- fx: Union[float, pd.Series, None] = None,
807
- ) -> Union[float, pd.Series]:
808
- """Convert ONLY the currency leg of a price whose quantity denominator is
809
- unchanged.
810
-
811
- This is the separable-currency-leg helper from the conversion architecture
812
- plan (Phase 3.1). Unlike :func:`convert_price`, it performs NO physical-unit
813
- conversion and never calls :func:`convfactor`, so it works when the quantity
814
- denominator is non-physical or unknown to pint — e.g. ``'USc/RIN' ->
815
- 'USD/RIN'`` (a pure ``/100`` scale). ``convert_price`` cannot do that today
816
- because it tries to compute ``convfactor('RIN', 'RIN')`` and raises on the
817
- unknown unit.
818
-
819
- It reimplements, as a first-class API, the broad-except ``/100.0`` currency
820
- fallback shim in ``oilrisk``'s ``artis.py`` (lines ~75-98): scale a
821
- fractional-currency quote (USc, GBp, ...) to its major unit without needing a
822
- commodity or a physical factor.
823
-
824
- Rules:
825
- * The two quantity denominators must be **string-equal** after parsing
826
- (``from`` and ``to`` refer to the same thing priced per identical unit);
827
- otherwise ``ValueError``. No physical-unit validation is performed.
828
- * Same-base fractional (``USc``->``USD``, ``GBp``->``GBP``): pure divisor
829
- scale, no ``fx`` needed.
830
- * No source currency, or source already the USD major: no-op (returns
831
- ``value`` unchanged).
832
- * Cross major-currency (e.g. ``EUR``->``USD``): ``fx`` is required (quoted
833
- target-per-source) or ``ValueError`` is raised. ``fx`` is applied
834
- element-wise; index-aligned FX Series are the caller's responsibility
835
- (use :func:`convert_price` for the strict/ffill staleness machinery).
836
- * Target currency is restricted to USD (matching ``convert_price``),
837
- except pure same-base fractional scaling like ``GBp``->``GBP``.
838
-
839
- Examples:
840
- convert_currency_leg(250.0, 'USc/RIN', 'USD/RIN') # -> 2.5 (/100)
841
- convert_currency_leg(50.0, 'GBp/therm', 'GBP/therm') # -> 0.5
842
- convert_currency_leg(10.0, 'EUR/RIN', 'USD/RIN', fx=1.07) # -> 10.7
843
- """
844
- from_pu = (
845
- from_unit if isinstance(from_unit, PriceUnit) else PriceUnit.parse(from_unit)
846
- )
847
- to_pu = to_unit if isinstance(to_unit, PriceUnit) else PriceUnit.parse(to_unit)
848
-
849
- # Quantity denominators must match exactly — string equality only, so a
850
- # non-physical denominator like 'RIN' is fine (no pint lookup).
851
- if from_pu.quantity_leg() != to_pu.quantity_leg():
852
- raise ValueError(
853
- f"convert_currency_leg requires identical quantity denominators; "
854
- f"got '{from_pu.quantity_leg()}' vs '{to_pu.quantity_leg()}'"
855
- )
856
-
857
- from_ccy = from_pu.currency or ""
858
- to_ccy = to_pu.currency or ""
859
- from_major = from_pu.major_currency or ""
860
- to_major = to_pu.major_currency or ""
861
-
862
- same_base_fractional = bool(from_ccy and to_ccy and from_major == to_major)
863
-
864
- # USD-only currency target, except pure same-base fractional scaling.
865
- if to_ccy and to_major != "USD" and not same_base_fractional:
866
- raise ValueError(
867
- f"convert_currency_leg currently only supports USD/* as target; "
868
- f"got '{to_pu}'"
869
- )
870
-
871
- # Same-base fractional (USc->USD, GBp->GBP, ...): pure divisor scale.
872
- if same_base_fractional:
873
- return value * (to_pu.fractional_divisor / from_pu.fractional_divisor)
874
-
875
- # No source currency, or already USD major: currency leg is a no-op.
876
- if not from_ccy or from_major == "USD":
568
+ value = getattr(import_module("commodutil.priceconv"), name)
569
+ globals()[name] = value
877
570
  return value
571
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
878
572
 
879
- # Cross major-currency: an FX leg is required.
880
- if fx is None:
881
- raise ValueError(
882
- f"FX rate required to convert {from_pu} -> {to_pu} "
883
- f"(source currency '{from_ccy}' is non-USD)"
884
- )
885
- return value * fx / from_pu.fractional_divisor
886
-
887
-
888
- @dataclass(frozen=True)
889
- class ConversionResult:
890
- """Result of :func:`convert_price_result`.
891
-
892
- - ``value``: the converted price (float or pandas Series).
893
- - ``applied``: True when a non-identity conversion route ran (from/to differ);
894
- False for an identity ('unchanged') or a kept-raw error.
895
- - ``note``: which route produced ``value`` — e.g.
896
- ``'convert_price:USD/mt->USD/bbl[diesel]'``, ``'currency_leg:USc/RIN->USD/RIN'``,
897
- ``'unchanged'``, or ``'kept-raw:<error>'``.
898
- """
899
-
900
- value: Union[float, pd.Series]
901
- applied: bool
902
- note: str
903
-
904
-
905
- def convert_price_result(
906
- value: Union[float, pd.Series],
907
- from_unit: Union[str, PriceUnit],
908
- to_unit: Union[str, PriceUnit],
909
- commodity: Optional[str] = None,
910
- fx: Union[float, pd.Series, None] = None,
911
- ffill_policy: str = "strict",
912
- max_staleness: pd.Timedelta = pd.Timedelta(days=7),
913
- on_error: str = "raise",
914
- ) -> ConversionResult:
915
- """Result-bearing wrapper over :func:`convert_price`: returns a
916
- :class:`ConversionResult` (value, applied, note) instead of a bare value.
917
-
918
- Both oilrisk price loaders (``artis.py`` and
919
- ``load_marketplace_price_snapshots.py``) independently invented exactly this
920
- ``(value, changed?, source_note)`` shape around convert_price; this is the
921
- shared primitive they unify onto in Phase 4.2.
922
-
923
- Same parameters as :func:`convert_price`, plus:
924
- * ``on_error='raise'`` (default): a conversion failure propagates, so plain
925
- use stays strict.
926
- * ``on_error='keep'``: on failure (and when no currency-leg fallback
927
- applies) return the ORIGINAL ``value`` with ``applied=False`` and a
928
- ``'kept-raw:...'`` note — the keep-raw semantic oilrisk wants for rows it
929
- can't convert but must not drop.
930
-
931
- Fallback: if the full conversion fails but both units are currency-qualified
932
- with equal quantity denominators, the currency leg alone is applied via
933
- :func:`convert_currency_leg` (e.g. ``'USc/RIN'->'USD/RIN'``), with a
934
- ``'currency_leg:...'`` note — mirroring the fallback oilrisk's artis.py does
935
- at its call site so Phase 4.2 can delete that logic there.
936
- """
937
- if on_error not in ("raise", "keep"):
938
- raise ValueError(f"on_error must be 'raise' or 'keep', got {on_error!r}")
939
-
940
- from_pu = (
941
- from_unit if isinstance(from_unit, PriceUnit) else PriceUnit.parse(from_unit)
942
- )
943
- to_pu = to_unit if isinstance(to_unit, PriceUnit) else PriceUnit.parse(to_unit)
944
- label = f"{from_pu}->{to_pu}"
945
- if commodity:
946
- label += f"[{commodity}]"
947
-
948
- # Identity: same currency + unit + period -> nothing to do.
949
- if from_pu == to_pu:
950
- return ConversionResult(value=value, applied=False, note="unchanged")
951
573
 
952
- try:
953
- out = convert_price(
954
- value,
955
- from_pu,
956
- to_pu,
957
- commodity,
958
- fx=fx,
959
- ffill_policy=ffill_policy,
960
- max_staleness=max_staleness,
961
- )
962
- return ConversionResult(value=out, applied=True, note=f"convert_price:{label}")
963
- except Exception as exc:
964
- # Currency-leg fallback: the full conversion failed, but if both sides
965
- # are currency-qualified with equal denominators the currency scale is
966
- # still well-defined (e.g. convert_price can't do convfactor('RIN',
967
- # 'RIN'), but 'USc/RIN'->'USD/RIN' is a clean /100).
968
- if (
969
- from_pu.is_currency_qualified
970
- and to_pu.is_currency_qualified
971
- and from_pu.quantity_leg() == to_pu.quantity_leg()
972
- ):
973
- try:
974
- out = convert_currency_leg(value, from_pu, to_pu, fx=fx)
975
- return ConversionResult(
976
- value=out, applied=True, note=f"currency_leg:{label}"
977
- )
978
- except Exception:
979
- pass # fall through to on_error handling
980
- if on_error == "keep":
981
- return ConversionResult(value=value, applied=False, note=f"kept-raw:{exc}")
982
- raise
983
-
984
-
985
- def list_commodities():
986
- """List all available commodities"""
987
- return converter.available_commodities
988
-
989
-
990
- def list_units():
991
- """List common units"""
992
- # Return normalized forms to avoid encoding issues
993
- return [_to_pint_token(u) for u in converter.available_units]
574
+ def __dir__():
575
+ return sorted(set(globals()) | _PRICECONV_EXPORTS)
994
576
 
995
577
 
996
578
  # Example usage