commodutil 4.7.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.
- commodutil-5.4.0/CHANGELOG.md +45 -0
- {commodutil-4.7.0 → commodutil-5.4.0}/PKG-INFO +1 -1
- {commodutil-4.7.0 → commodutil-5.4.0}/commodutil/__init__.py +2 -1
- {commodutil-4.7.0 → commodutil-5.4.0}/commodutil/convfactors.py +31 -449
- commodutil-5.4.0/commodutil/priceconv.py +410 -0
- {commodutil-4.7.0 → commodutil-5.4.0}/commodutil/standards/__init__.py +6 -5
- {commodutil-4.7.0 → commodutil-5.4.0}/commodutil/standards/commodities.py +115 -0
- {commodutil-4.7.0 → commodutil-5.4.0}/commodutil/standards/price_unit.py +110 -4
- {commodutil-4.7.0 → commodutil-5.4.0}/commodutil/standards/unit_registry.py +31 -1
- {commodutil-4.7.0 → commodutil-5.4.0}/commodutil/standards/units.py +46 -25
- {commodutil-4.7.0 → commodutil-5.4.0}/commodutil.egg-info/PKG-INFO +1 -1
- {commodutil-4.7.0 → commodutil-5.4.0}/commodutil.egg-info/SOURCES.txt +4 -2
- {commodutil-4.7.0 → commodutil-5.4.0}/commodutil.egg-info/scm_file_list.json +4 -2
- commodutil-5.4.0/commodutil.egg-info/scm_version.json +8 -0
- commodutil-5.4.0/tests/test_aliases.py +93 -0
- {commodutil-4.7.0 → commodutil-5.4.0}/tests/test_price_unit.py +129 -3
- commodutil-5.4.0/tests/test_priceconv_reexports.py +16 -0
- {commodutil-4.7.0 → commodutil-5.4.0}/tests/test_standards_commodities.py +146 -0
- {commodutil-4.7.0 → commodutil-5.4.0}/tests/test_standards_units.py +96 -26
- {commodutil-4.7.0 → commodutil-5.4.0}/tests/test_unit_registry.py +78 -2
- commodutil-4.7.0/commodutil/standards/price_units.py +0 -99
- commodutil-4.7.0/commodutil.egg-info/scm_version.json +0 -8
- commodutil-4.7.0/tests/test_standards_price_units.py +0 -122
- {commodutil-4.7.0 → commodutil-5.4.0}/.coveragerc +0 -0
- {commodutil-4.7.0 → commodutil-5.4.0}/.github/workflows/1_tests.yml +0 -0
- {commodutil-4.7.0 → commodutil-5.4.0}/.github/workflows/2_coverage.yml +0 -0
- {commodutil-4.7.0 → commodutil-5.4.0}/.github/workflows/3_linting.yml +0 -0
- {commodutil-4.7.0 → commodutil-5.4.0}/.github/workflows/4_release.yml +0 -0
- {commodutil-4.7.0 → commodutil-5.4.0}/.gitignore +0 -0
- {commodutil-4.7.0 → commodutil-5.4.0}/.pypirc +0 -0
- {commodutil-4.7.0 → commodutil-5.4.0}/azure-build-pipelines.yml +0 -0
- {commodutil-4.7.0 → commodutil-5.4.0}/commodutil/arb.py +0 -0
- {commodutil-4.7.0 → commodutil-5.4.0}/commodutil/dates.py +0 -0
- {commodutil-4.7.0 → commodutil-5.4.0}/commodutil/forward/__init__.py +0 -0
- {commodutil-4.7.0 → commodutil-5.4.0}/commodutil/forward/calendar.py +0 -0
- {commodutil-4.7.0 → commodutil-5.4.0}/commodutil/forward/continuous.py +0 -0
- {commodutil-4.7.0 → commodutil-5.4.0}/commodutil/forward/fly.py +0 -0
- {commodutil-4.7.0 → commodutil-5.4.0}/commodutil/forward/quarterly.py +0 -0
- {commodutil-4.7.0 → commodutil-5.4.0}/commodutil/forward/spreads.py +0 -0
- {commodutil-4.7.0 → commodutil-5.4.0}/commodutil/forward/structure.py +0 -0
- {commodutil-4.7.0 → commodutil-5.4.0}/commodutil/forward/util.py +0 -0
- {commodutil-4.7.0 → commodutil-5.4.0}/commodutil/forwards.py +0 -0
- {commodutil-4.7.0 → commodutil-5.4.0}/commodutil/pandasutil.py +0 -0
- {commodutil-4.7.0 → commodutil-5.4.0}/commodutil/standards/analysis_types.py +0 -0
- {commodutil-4.7.0 → commodutil-5.4.0}/commodutil/standards/commodity_groups.py +0 -0
- {commodutil-4.7.0 → commodutil-5.4.0}/commodutil/standards/currency.py +0 -0
- {commodutil-4.7.0 → commodutil-5.4.0}/commodutil/standards/regions.py +0 -0
- {commodutil-4.7.0 → commodutil-5.4.0}/commodutil/stats.py +0 -0
- {commodutil-4.7.0 → commodutil-5.4.0}/commodutil/transforms.py +0 -0
- {commodutil-4.7.0 → commodutil-5.4.0}/commodutil.egg-info/dependency_links.txt +0 -0
- {commodutil-4.7.0 → commodutil-5.4.0}/commodutil.egg-info/requires.txt +0 -0
- {commodutil-4.7.0 → commodutil-5.4.0}/commodutil.egg-info/top_level.txt +0 -0
- {commodutil-4.7.0 → commodutil-5.4.0}/pyproject.toml +0 -0
- {commodutil-4.7.0 → commodutil-5.4.0}/requirements-test.txt +0 -0
- {commodutil-4.7.0 → commodutil-5.4.0}/requirements.txt +0 -0
- {commodutil-4.7.0 → commodutil-5.4.0}/requirements_dev.txt +0 -0
- {commodutil-4.7.0 → commodutil-5.4.0}/setup.cfg +0 -0
- {commodutil-4.7.0 → commodutil-5.4.0}/tests/__init__.py +0 -0
- {commodutil-4.7.0 → commodutil-5.4.0}/tests/conftest.py +0 -0
- {commodutil-4.7.0 → commodutil-5.4.0}/tests/forward/__init__.py +0 -0
- {commodutil-4.7.0 → commodutil-5.4.0}/tests/forward/conftest.py +0 -0
- {commodutil-4.7.0 → commodutil-5.4.0}/tests/forward/test_calendar.py +0 -0
- {commodutil-4.7.0 → commodutil-5.4.0}/tests/forward/test_continuous.py +0 -0
- {commodutil-4.7.0 → commodutil-5.4.0}/tests/forward/test_fly.py +0 -0
- {commodutil-4.7.0 → commodutil-5.4.0}/tests/forward/test_quarterly.py +0 -0
- {commodutil-4.7.0 → commodutil-5.4.0}/tests/forward/test_spreads.py +0 -0
- {commodutil-4.7.0 → commodutil-5.4.0}/tests/forward/test_structure.py +0 -0
- {commodutil-4.7.0 → commodutil-5.4.0}/tests/forward/test_util.py +0 -0
- {commodutil-4.7.0 → commodutil-5.4.0}/tests/golden/golden_factors.json +0 -0
- {commodutil-4.7.0 → commodutil-5.4.0}/tests/test_align_fx_result.py +0 -0
- {commodutil-4.7.0 → commodutil-5.4.0}/tests/test_arb.py +0 -0
- {commodutil-4.7.0 → commodutil-5.4.0}/tests/test_cl.csv +0 -0
- {commodutil-4.7.0 → commodutil-5.4.0}/tests/test_conv.py +0 -0
- {commodutil-4.7.0 → commodutil-5.4.0}/tests/test_dates.py +0 -0
- {commodutil-4.7.0 → commodutil-5.4.0}/tests/test_forwards.py +0 -0
- {commodutil-4.7.0 → commodutil-5.4.0}/tests/test_golden_factors.py +0 -0
- {commodutil-4.7.0 → commodutil-5.4.0}/tests/test_pandasutils.py +0 -0
- {commodutil-4.7.0 → commodutil-5.4.0}/tests/test_price_conv.py +0 -0
- {commodutil-4.7.0 → commodutil-5.4.0}/tests/test_rate_pint.py +0 -0
- {commodutil-4.7.0 → commodutil-5.4.0}/tests/test_standards_analysis_types.py +0 -0
- {commodutil-4.7.0 → commodutil-5.4.0}/tests/test_standards_commodity_groups.py +0 -0
- {commodutil-4.7.0 → commodutil-5.4.0}/tests/test_standards_currency.py +0 -0
- {commodutil-4.7.0 → commodutil-5.4.0}/tests/test_standards_regions.py +0 -0
- {commodutil-4.7.0 → commodutil-5.4.0}/tests/test_stats.py +0 -0
- {commodutil-4.7.0 → commodutil-5.4.0}/tests/test_transforms.py +0 -0
- {commodutil-4.7.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.
|
|
@@ -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
|
|
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
|
|
@@ -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
|
-
|
|
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
|
-
|
|
580
|
-
|
|
581
|
-
|
|
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
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
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
|
-
|
|
711
|
-
|
|
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
|
-
|
|
717
|
-
|
|
564
|
+
def __getattr__(name: str):
|
|
565
|
+
if name in _PRICECONV_EXPORTS:
|
|
566
|
+
from importlib import import_module
|
|
718
567
|
|
|
719
|
-
|
|
720
|
-
|
|
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
|
-
|
|
953
|
-
|
|
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
|