commodutil 4.4.0__tar.gz → 4.6.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-4.4.0 → commodutil-4.6.0}/PKG-INFO +1 -1
- {commodutil-4.4.0 → commodutil-4.6.0}/commodutil/convfactors.py +358 -177
- {commodutil-4.4.0 → commodutil-4.6.0}/commodutil/standards/__init__.py +5 -0
- commodutil-4.6.0/commodutil/standards/price_unit.py +163 -0
- commodutil-4.6.0/commodutil/standards/unit_registry.py +319 -0
- {commodutil-4.4.0 → commodutil-4.6.0}/commodutil/standards/units.py +20 -57
- {commodutil-4.4.0 → commodutil-4.6.0}/commodutil.egg-info/PKG-INFO +1 -1
- {commodutil-4.4.0 → commodutil-4.6.0}/commodutil.egg-info/SOURCES.txt +6 -0
- {commodutil-4.4.0 → commodutil-4.6.0}/commodutil.egg-info/scm_file_list.json +6 -0
- {commodutil-4.4.0 → commodutil-4.6.0}/commodutil.egg-info/scm_version.json +2 -2
- commodutil-4.6.0/tests/test_align_fx_result.py +130 -0
- commodutil-4.6.0/tests/test_price_unit.py +232 -0
- commodutil-4.6.0/tests/test_rate_pint.py +81 -0
- {commodutil-4.4.0 → commodutil-4.6.0}/tests/test_standards_units.py +23 -3
- commodutil-4.6.0/tests/test_unit_registry.py +396 -0
- {commodutil-4.4.0 → commodutil-4.6.0}/.coveragerc +0 -0
- {commodutil-4.4.0 → commodutil-4.6.0}/.github/workflows/1_tests.yml +0 -0
- {commodutil-4.4.0 → commodutil-4.6.0}/.github/workflows/2_coverage.yml +0 -0
- {commodutil-4.4.0 → commodutil-4.6.0}/.github/workflows/3_linting.yml +0 -0
- {commodutil-4.4.0 → commodutil-4.6.0}/.github/workflows/4_release.yml +0 -0
- {commodutil-4.4.0 → commodutil-4.6.0}/.gitignore +0 -0
- {commodutil-4.4.0 → commodutil-4.6.0}/.pypirc +0 -0
- {commodutil-4.4.0 → commodutil-4.6.0}/azure-build-pipelines.yml +0 -0
- {commodutil-4.4.0 → commodutil-4.6.0}/commodutil/__init__.py +0 -0
- {commodutil-4.4.0 → commodutil-4.6.0}/commodutil/arb.py +0 -0
- {commodutil-4.4.0 → commodutil-4.6.0}/commodutil/dates.py +0 -0
- {commodutil-4.4.0 → commodutil-4.6.0}/commodutil/forward/__init__.py +0 -0
- {commodutil-4.4.0 → commodutil-4.6.0}/commodutil/forward/calendar.py +0 -0
- {commodutil-4.4.0 → commodutil-4.6.0}/commodutil/forward/continuous.py +0 -0
- {commodutil-4.4.0 → commodutil-4.6.0}/commodutil/forward/fly.py +0 -0
- {commodutil-4.4.0 → commodutil-4.6.0}/commodutil/forward/quarterly.py +0 -0
- {commodutil-4.4.0 → commodutil-4.6.0}/commodutil/forward/spreads.py +0 -0
- {commodutil-4.4.0 → commodutil-4.6.0}/commodutil/forward/structure.py +0 -0
- {commodutil-4.4.0 → commodutil-4.6.0}/commodutil/forward/util.py +0 -0
- {commodutil-4.4.0 → commodutil-4.6.0}/commodutil/forwards.py +0 -0
- {commodutil-4.4.0 → commodutil-4.6.0}/commodutil/pandasutil.py +0 -0
- {commodutil-4.4.0 → commodutil-4.6.0}/commodutil/standards/analysis_types.py +0 -0
- {commodutil-4.4.0 → commodutil-4.6.0}/commodutil/standards/commodities.py +0 -0
- {commodutil-4.4.0 → commodutil-4.6.0}/commodutil/standards/commodity_groups.py +0 -0
- {commodutil-4.4.0 → commodutil-4.6.0}/commodutil/standards/currency.py +0 -0
- {commodutil-4.4.0 → commodutil-4.6.0}/commodutil/standards/price_units.py +0 -0
- {commodutil-4.4.0 → commodutil-4.6.0}/commodutil/standards/regions.py +0 -0
- {commodutil-4.4.0 → commodutil-4.6.0}/commodutil/stats.py +0 -0
- {commodutil-4.4.0 → commodutil-4.6.0}/commodutil/transforms.py +0 -0
- {commodutil-4.4.0 → commodutil-4.6.0}/commodutil.egg-info/dependency_links.txt +0 -0
- {commodutil-4.4.0 → commodutil-4.6.0}/commodutil.egg-info/requires.txt +0 -0
- {commodutil-4.4.0 → commodutil-4.6.0}/commodutil.egg-info/top_level.txt +0 -0
- {commodutil-4.4.0 → commodutil-4.6.0}/pyproject.toml +0 -0
- {commodutil-4.4.0 → commodutil-4.6.0}/requirements-test.txt +0 -0
- {commodutil-4.4.0 → commodutil-4.6.0}/requirements.txt +0 -0
- {commodutil-4.4.0 → commodutil-4.6.0}/requirements_dev.txt +0 -0
- {commodutil-4.4.0 → commodutil-4.6.0}/setup.cfg +0 -0
- {commodutil-4.4.0 → commodutil-4.6.0}/tests/__init__.py +0 -0
- {commodutil-4.4.0 → commodutil-4.6.0}/tests/conftest.py +0 -0
- {commodutil-4.4.0 → commodutil-4.6.0}/tests/forward/__init__.py +0 -0
- {commodutil-4.4.0 → commodutil-4.6.0}/tests/forward/conftest.py +0 -0
- {commodutil-4.4.0 → commodutil-4.6.0}/tests/forward/test_calendar.py +0 -0
- {commodutil-4.4.0 → commodutil-4.6.0}/tests/forward/test_continuous.py +0 -0
- {commodutil-4.4.0 → commodutil-4.6.0}/tests/forward/test_fly.py +0 -0
- {commodutil-4.4.0 → commodutil-4.6.0}/tests/forward/test_quarterly.py +0 -0
- {commodutil-4.4.0 → commodutil-4.6.0}/tests/forward/test_spreads.py +0 -0
- {commodutil-4.4.0 → commodutil-4.6.0}/tests/forward/test_structure.py +0 -0
- {commodutil-4.4.0 → commodutil-4.6.0}/tests/forward/test_util.py +0 -0
- {commodutil-4.4.0 → commodutil-4.6.0}/tests/golden/golden_factors.json +0 -0
- {commodutil-4.4.0 → commodutil-4.6.0}/tests/test_arb.py +0 -0
- {commodutil-4.4.0 → commodutil-4.6.0}/tests/test_cl.csv +0 -0
- {commodutil-4.4.0 → commodutil-4.6.0}/tests/test_conv.py +0 -0
- {commodutil-4.4.0 → commodutil-4.6.0}/tests/test_dates.py +0 -0
- {commodutil-4.4.0 → commodutil-4.6.0}/tests/test_forwards.py +0 -0
- {commodutil-4.4.0 → commodutil-4.6.0}/tests/test_golden_factors.py +0 -0
- {commodutil-4.4.0 → commodutil-4.6.0}/tests/test_pandasutils.py +0 -0
- {commodutil-4.4.0 → commodutil-4.6.0}/tests/test_price_conv.py +0 -0
- {commodutil-4.4.0 → commodutil-4.6.0}/tests/test_standards_analysis_types.py +0 -0
- {commodutil-4.4.0 → commodutil-4.6.0}/tests/test_standards_commodities.py +0 -0
- {commodutil-4.4.0 → commodutil-4.6.0}/tests/test_standards_commodity_groups.py +0 -0
- {commodutil-4.4.0 → commodutil-4.6.0}/tests/test_standards_currency.py +0 -0
- {commodutil-4.4.0 → commodutil-4.6.0}/tests/test_standards_price_units.py +0 -0
- {commodutil-4.4.0 → commodutil-4.6.0}/tests/test_standards_regions.py +0 -0
- {commodutil-4.4.0 → commodutil-4.6.0}/tests/test_stats.py +0 -0
- {commodutil-4.4.0 → commodutil-4.6.0}/tests/test_transforms.py +0 -0
- {commodutil-4.4.0 → commodutil-4.6.0}/tests/test_weekly.csv +0 -0
|
@@ -12,6 +12,8 @@ import pandas as pd
|
|
|
12
12
|
from functools import lru_cache
|
|
13
13
|
|
|
14
14
|
from commodutil.standards import currency as _currency
|
|
15
|
+
from commodutil.standards.price_unit import PriceUnit
|
|
16
|
+
from commodutil.standards.unit_registry import PINT_DEFINITIONS as _PINT_DEFINITIONS
|
|
15
17
|
from commodutil.standards.units import to_pint_token as _to_pint_token
|
|
16
18
|
|
|
17
19
|
logger = logging.getLogger(__name__)
|
|
@@ -31,48 +33,19 @@ ureg = pint.UnitRegistry()
|
|
|
31
33
|
|
|
32
34
|
# Define oil & gas specific units.
|
|
33
35
|
#
|
|
34
|
-
#
|
|
35
|
-
#
|
|
36
|
-
#
|
|
37
|
-
#
|
|
38
|
-
#
|
|
39
|
-
#
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
ureg.define(
|
|
47
|
-
ureg.define("billion_cubic_meter = 1e9 meter**3 = bcm = BCM")
|
|
48
|
-
ureg.define("billion_cubic_foot = 1e9 foot**3 = bcf = BCF")
|
|
49
|
-
ureg.define("tonne_of_oil_equivalent = 41.868e9 joule = toe = TOE")
|
|
50
|
-
ureg.define("million_tonne_of_oil_equivalent = 1e6 tonne_of_oil_equivalent = Mtoe")
|
|
51
|
-
ureg.define("barrel_of_oil_equivalent = 6.119e9 joule = boe = BOE")
|
|
52
|
-
ureg.define("million_barrel_of_oil_equivalent = 1e6 barrel_of_oil_equivalent = Mboe")
|
|
53
|
-
ureg.define("megatonne = 1e6 metric_ton = Mt")
|
|
54
|
-
# Pint defines `Btu` (= 1055.056 J) and `therm` (= 1e5 Btu) but NOT `MMBtu`.
|
|
55
|
-
# The natgas / NGL pages quote in $/MMBtu, so register it explicitly (and the
|
|
56
|
-
# normalize_unit helper already maps 'MMBTU' -> 'MMBtu').
|
|
57
|
-
ureg.define("million_british_thermal_unit = 1e6 Btu = MMBtu")
|
|
58
|
-
|
|
59
|
-
# Case-insensitive aliases for the most-quoted energy / power units.
|
|
60
|
-
# Pint is case-sensitive by default, so `ureg('mmbtu')` or `ureg('mw')` raise
|
|
61
|
-
# UndefinedUnitError even though `MMBtu` / `MW` work. Callers (Power Query, ad-hoc
|
|
62
|
-
# scripts, copy-pasted Platts symbols) very commonly use the lowercase / all-caps
|
|
63
|
-
# / underscored forms, so register them explicitly here.
|
|
64
|
-
#
|
|
65
|
-
# Energy: MMBtu + therm + Btu spellings (MMBtu is defined above; therm + Btu are
|
|
66
|
-
# in pint's defaults). `million_btu` is the long-form alias.
|
|
67
|
-
ureg.define("@alias million_british_thermal_unit = mmbtu = MMBTU = million_btu")
|
|
68
|
-
ureg.define("@alias therm = Therm = THERM")
|
|
69
|
-
ureg.define("@alias british_thermal_unit = btu")
|
|
70
|
-
# Power / energy: MW and MWh are SI-prefix forms of `watt` / `watt_hour`, so they
|
|
71
|
-
# cannot be aliased via `@alias`. Register lowercase / all-caps spellings as
|
|
72
|
-
# standalone units with the same magnitude.
|
|
73
|
-
ureg.define("mw = 1e6 watt")
|
|
74
|
-
ureg.define("mwh = 1e6 watt * hour")
|
|
75
|
-
ureg.define("MWH = 1e6 watt * hour")
|
|
36
|
+
# The exact pint definitions (and their required ordering) live as the
|
|
37
|
+
# `pint_specs` of each row in commodutil.standards.unit_registry; they are
|
|
38
|
+
# applied here in registry order. This includes the load-bearing re-routes and
|
|
39
|
+
# case spellings that keep this registry correct:
|
|
40
|
+
# * `oil_barrel` (158.987294928 L = 42 US gal) with `bbl` routed to it —
|
|
41
|
+
# pint's default `barrel` is the US dry barrel (~119.24 L), left untouched.
|
|
42
|
+
# * `metric_ton = mt` — pint's `mt` would otherwise be milli-tonne.
|
|
43
|
+
# * MMBtu, plus case aliases (mmbtu/MMBTU, therm/THERM, btu) and lowercase /
|
|
44
|
+
# all-caps power spellings (mw, mwh, MWH) — the registry stays CASE-SENSITIVE
|
|
45
|
+
# (kt/kilotesla etc. collide non-deterministically under case-folding).
|
|
46
|
+
# To add or change a unit definition, edit its UnitRow in the registry.
|
|
47
|
+
for _definition in _PINT_DEFINITIONS:
|
|
48
|
+
ureg.define(_definition)
|
|
76
49
|
|
|
77
50
|
|
|
78
51
|
@dataclass
|
|
@@ -294,8 +267,8 @@ class CommodityConverter:
|
|
|
294
267
|
def convert(
|
|
295
268
|
self,
|
|
296
269
|
value: Union[float, pd.Series],
|
|
297
|
-
from_unit: str,
|
|
298
|
-
to_unit: str,
|
|
270
|
+
from_unit: Union[str, PriceUnit],
|
|
271
|
+
to_unit: Union[str, PriceUnit],
|
|
299
272
|
commodity: Optional[str] = None,
|
|
300
273
|
) -> Union[float, pd.Series]:
|
|
301
274
|
"""
|
|
@@ -314,34 +287,42 @@ class CommodityConverter:
|
|
|
314
287
|
# With pandas Series and daily rates
|
|
315
288
|
convert(series, 'kt/month', 'bbl/day', commodity='gasoline')
|
|
316
289
|
"""
|
|
317
|
-
#
|
|
290
|
+
# Accept PriceUnit | str at the boundary: a PriceUnit collapses to its
|
|
291
|
+
# canonical string form, which the existing rate/pint logic then handles
|
|
292
|
+
# unchanged (convert/convfactor operate on bare & rate units, never on
|
|
293
|
+
# currency-qualified ones). str(PriceUnit) is byte-identical to the
|
|
294
|
+
# equivalent canonical string, so numeric behaviour is unchanged.
|
|
295
|
+
if isinstance(from_unit, PriceUnit):
|
|
296
|
+
from_unit = str(from_unit)
|
|
297
|
+
if isinstance(to_unit, PriceUnit):
|
|
298
|
+
to_unit = str(to_unit)
|
|
299
|
+
|
|
300
|
+
# Normalize, then split rate units with the single PriceUnit parser
|
|
301
|
+
# (quantity leg + rate period). No private rate parser anymore.
|
|
318
302
|
from_unit = _to_pint_token(from_unit)
|
|
319
303
|
to_unit = _to_pint_token(to_unit)
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
304
|
+
from_pu = PriceUnit.parse(from_unit)
|
|
305
|
+
to_pu = PriceUnit.parse(to_unit)
|
|
306
|
+
from_base = _to_pint_token(from_pu.quantity_unit)
|
|
307
|
+
to_base = _to_pint_token(to_pu.quantity_unit)
|
|
308
|
+
from_period = from_pu.period
|
|
309
|
+
to_period = to_pu.period
|
|
326
310
|
|
|
327
|
-
# Create quantity
|
|
328
311
|
if isinstance(value, pd.Series):
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
to_base,
|
|
333
|
-
commodity,
|
|
334
|
-
from_rate["period"],
|
|
335
|
-
to_rate["period"],
|
|
312
|
+
# Series keeps the calendar-aware days_in_month path (pint's
|
|
313
|
+
# fixed-length month can't express it).
|
|
314
|
+
return self._convert_series(
|
|
315
|
+
value, from_base, to_base, commodity, from_period, to_period
|
|
336
316
|
)
|
|
337
|
-
else:
|
|
338
|
-
result = self._convert_scalar(value, from_base, to_base, commodity)
|
|
339
|
-
if from_rate["period"] or to_rate["period"]:
|
|
340
|
-
factor = self._rate_factor_scalar(
|
|
341
|
-
from_rate["period"], to_rate["period"]
|
|
342
|
-
)
|
|
343
|
-
result = result * factor
|
|
344
317
|
|
|
318
|
+
# Scalar: the base-unit factor goes through the SAME _convert_scalar path
|
|
319
|
+
# as a non-rate conversion (so a rate result is consistent-by-construction
|
|
320
|
+
# with the base one), multiplied by the period ratio. That ratio comes
|
|
321
|
+
# from pint's own calendar month/year lengths, replacing the deleted
|
|
322
|
+
# hand-rolled duplicates of those constants.
|
|
323
|
+
result = self._convert_scalar(value, from_base, to_base, commodity)
|
|
324
|
+
if from_period or to_period:
|
|
325
|
+
result = result * self._period_rate_factor(from_period, to_period)
|
|
345
326
|
return result
|
|
346
327
|
|
|
347
328
|
@lru_cache(maxsize=128)
|
|
@@ -466,7 +447,7 @@ class CommodityConverter:
|
|
|
466
447
|
"""Convert a pandas Series with optional rate handling.
|
|
467
448
|
|
|
468
449
|
- Month conversions use the index's actual days_in_month when available.
|
|
469
|
-
- Other time conversions use
|
|
450
|
+
- Other time conversions use pint's calendar period lengths.
|
|
470
451
|
"""
|
|
471
452
|
result = series.copy()
|
|
472
453
|
|
|
@@ -483,9 +464,9 @@ class CommodityConverter:
|
|
|
483
464
|
else:
|
|
484
465
|
result = result / series.index.days_in_month
|
|
485
466
|
else:
|
|
486
|
-
#
|
|
487
|
-
|
|
488
|
-
result = result *
|
|
467
|
+
# Other period conversions (e.g. year<->day): pint's ratio
|
|
468
|
+
# of period lengths.
|
|
469
|
+
result = result * self._period_rate_factor(from_period, to_period)
|
|
489
470
|
|
|
490
471
|
# Apply unit conversion
|
|
491
472
|
factor_units = self._convert_scalar(1.0, from_unit, to_unit, commodity)
|
|
@@ -493,53 +474,24 @@ class CommodityConverter:
|
|
|
493
474
|
|
|
494
475
|
return result
|
|
495
476
|
|
|
496
|
-
def
|
|
497
|
-
"""Parse units like 'bbl/day' or 'kt/month'."""
|
|
498
|
-
if "/" in unit:
|
|
499
|
-
base, period = unit.split("/", 1)
|
|
500
|
-
base = _to_pint_token(base)
|
|
501
|
-
period = period.strip().lower().rstrip("s") # day(s), month(s), year(s)
|
|
502
|
-
return {"base": base, "period": period}
|
|
503
|
-
return {"base": _to_pint_token(unit), "period": None}
|
|
504
|
-
|
|
505
|
-
def _rate_factor_scalar(
|
|
477
|
+
def _period_rate_factor(
|
|
506
478
|
self, from_period: Optional[str], to_period: Optional[str]
|
|
507
479
|
) -> float:
|
|
508
|
-
"""
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
480
|
+
"""Ratio converting a per-``from_period`` rate to per-``to_period``,
|
|
481
|
+
derived from pint's calendar period lengths (day/month/year) — the
|
|
482
|
+
single source that replaces the deleted hand-rolled constants. A
|
|
483
|
+
``None`` period on either side is a no-op (1.0), matching historical
|
|
484
|
+
behaviour.
|
|
485
|
+
|
|
486
|
+
Feeds both the scalar path (period factor) and the Series non-month
|
|
487
|
+
path; the calendar-aware month<->day Series case is handled separately
|
|
488
|
+
with days_in_month.
|
|
513
489
|
"""
|
|
514
|
-
if from_period == to_period:
|
|
515
|
-
return 1.0
|
|
516
|
-
if from_period is None and to_period is None:
|
|
517
|
-
return 1.0
|
|
518
|
-
if from_period is None or to_period is None:
|
|
519
|
-
# Ambiguous to add/remove a time dimension for scalar; no-op to preserve behavior
|
|
490
|
+
if from_period == to_period or from_period is None or to_period is None:
|
|
520
491
|
return 1.0
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
# Helper to express rates as per-day factors
|
|
526
|
-
def per_day_factor(period: str) -> float:
|
|
527
|
-
if period == "day":
|
|
528
|
-
return 1.0
|
|
529
|
-
if period == "year":
|
|
530
|
-
return 1.0 / avg_days_per_year
|
|
531
|
-
if period == "month":
|
|
532
|
-
return 1.0 / avg_days_per_month
|
|
533
|
-
# Fallback to Pint if it's a known time unit
|
|
534
|
-
try:
|
|
535
|
-
return (1 * (self.ureg(period) ** -1)).to(self.ureg.day**-1).magnitude
|
|
536
|
-
except Exception:
|
|
537
|
-
raise ValueError(f"Unsupported rate period: {period}")
|
|
538
|
-
|
|
539
|
-
from_per_day = per_day_factor(from_period)
|
|
540
|
-
to_per_day = per_day_factor(to_period)
|
|
541
|
-
# Convert from per-from_period to per-to_period
|
|
542
|
-
return from_per_day / to_per_day
|
|
492
|
+
from_days = (1 * self.ureg(from_period)).to("day").magnitude
|
|
493
|
+
to_days = (1 * self.ureg(to_period)).to("day").magnitude
|
|
494
|
+
return to_days / from_days
|
|
543
495
|
|
|
544
496
|
def _is_energy(self, unit: str) -> bool:
|
|
545
497
|
return self.ureg(unit).check("[energy]")
|
|
@@ -605,13 +557,22 @@ converter = CommodityConverter()
|
|
|
605
557
|
|
|
606
558
|
|
|
607
559
|
# Convenience functions for direct use
|
|
608
|
-
def convert(
|
|
609
|
-
|
|
560
|
+
def convert(
|
|
561
|
+
value,
|
|
562
|
+
from_unit: Union[str, PriceUnit],
|
|
563
|
+
to_unit: Union[str, PriceUnit],
|
|
564
|
+
commodity: Optional[str] = None,
|
|
565
|
+
):
|
|
566
|
+
"""Convert values between units. Units may be strings or ``PriceUnit``."""
|
|
610
567
|
return converter.convert(value, from_unit, to_unit, commodity)
|
|
611
568
|
|
|
612
569
|
|
|
613
|
-
def convfactor(
|
|
614
|
-
|
|
570
|
+
def convfactor(
|
|
571
|
+
from_unit: Union[str, PriceUnit],
|
|
572
|
+
to_unit: Union[str, PriceUnit],
|
|
573
|
+
commodity: Optional[str] = None,
|
|
574
|
+
) -> float:
|
|
575
|
+
"""Get conversion factor between units. Units may be strings or ``PriceUnit``."""
|
|
615
576
|
return converter.convert(1.0, from_unit, to_unit, commodity)
|
|
616
577
|
|
|
617
578
|
|
|
@@ -624,10 +585,88 @@ def convfactor(from_unit: str, to_unit: str, commodity: Optional[str] = None) ->
|
|
|
624
585
|
# wanting currency vocabulary should import from commodutil.standards.currency.
|
|
625
586
|
|
|
626
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
|
+
|
|
627
666
|
def convert_price(
|
|
628
667
|
value: Union[float, pd.Series],
|
|
629
|
-
from_unit: str,
|
|
630
|
-
to_unit: str,
|
|
668
|
+
from_unit: Union[str, PriceUnit],
|
|
669
|
+
to_unit: Union[str, PriceUnit],
|
|
631
670
|
commodity: Optional[str] = None,
|
|
632
671
|
fx: Union[float, pd.Series, None] = None,
|
|
633
672
|
ffill_policy: str = "strict",
|
|
@@ -682,8 +721,20 @@ def convert_price(
|
|
|
682
721
|
fx_series = pd.Series([1.07, 1.08, 1.06], index=p.index)
|
|
683
722
|
convert_price(p, 'EUR/MWh', 'USD/MMBtu', fx=fx_series)
|
|
684
723
|
"""
|
|
685
|
-
|
|
686
|
-
|
|
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()
|
|
687
738
|
|
|
688
739
|
# Resolve the underlying "major" currency on each side for same-base detection
|
|
689
740
|
# (e.g. USc and USD share major USD — pure scale, no FX needed).
|
|
@@ -740,67 +791,197 @@ def convert_price(
|
|
|
740
791
|
fractional_divisor = _currency.FRACTIONAL_CURRENCY_DIVISORS.get(from_ccy, 1.0)
|
|
741
792
|
|
|
742
793
|
if isinstance(unit_converted, pd.Series) and isinstance(fx, pd.Series):
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
# Bounded ffill: only forward-fill within `max_staleness`. Anything
|
|
747
|
-
# uncovered (e.g. value-dates before fx.index.min() or stale past
|
|
748
|
-
# the limit) stays NaN and triggers a loud raise — no silent
|
|
749
|
-
# back-fill, no silent stale extrapolation.
|
|
750
|
-
union_idx = fx.index.union(target_idx)
|
|
751
|
-
fx_union = fx.reindex(union_idx).sort_index().ffill()
|
|
752
|
-
# Track how stale each ffilled value is and zero-out anything older
|
|
753
|
-
# than max_staleness.
|
|
754
|
-
valid_mask = ~fx.reindex(union_idx).isna()
|
|
755
|
-
last_valid_pos = (
|
|
756
|
-
pd.Series(union_idx, index=union_idx).where(valid_mask).ffill()
|
|
757
|
-
)
|
|
758
|
-
staleness = pd.Series(union_idx, index=union_idx) - last_valid_pos
|
|
759
|
-
fx_union = fx_union.where(staleness <= max_staleness)
|
|
760
|
-
fx_aligned = fx_union.reindex(target_idx)
|
|
761
|
-
if fx_aligned.isna().any():
|
|
762
|
-
missing = target_idx[fx_aligned.isna()]
|
|
763
|
-
first_missing = missing[0]
|
|
764
|
-
first_missing_str = (
|
|
765
|
-
first_missing.date()
|
|
766
|
-
if hasattr(first_missing, "date")
|
|
767
|
-
else first_missing
|
|
768
|
-
)
|
|
769
|
-
raise ValueError(
|
|
770
|
-
f"FX missing or stale (>{max_staleness}) for "
|
|
771
|
-
f"{len(missing)} target date(s) (first: {first_missing_str}). "
|
|
772
|
-
f"Pass ffill_policy='ffill' to fill with the last non-null "
|
|
773
|
-
f"FX (BACKTEST FUTURE LEAKAGE RISK)."
|
|
774
|
-
)
|
|
775
|
-
elif ffill_policy == "ffill":
|
|
776
|
-
logger.warning(
|
|
777
|
-
"convert_price: ffill_policy='ffill' — pre-FX-start dates will "
|
|
778
|
-
"be back-filled with the latest FX. Future-leakage risk in "
|
|
779
|
-
"backtests; prefer 'strict' for historical research."
|
|
780
|
-
)
|
|
781
|
-
if not target_idx.isin(fx.index).all():
|
|
782
|
-
union_idx = fx.index.union(target_idx)
|
|
783
|
-
fx_aligned = fx.reindex(union_idx).ffill().reindex(target_idx)
|
|
784
|
-
else:
|
|
785
|
-
fx_aligned = fx.reindex(target_idx).ffill()
|
|
786
|
-
if fx_aligned.isna().any():
|
|
787
|
-
fx_nonnull = fx.dropna()
|
|
788
|
-
if fx_nonnull.size == 0:
|
|
789
|
-
raise ValueError(
|
|
790
|
-
"FX series is entirely NaN; refusing the silent "
|
|
791
|
-
"multiply-by-1.0 fallback."
|
|
792
|
-
)
|
|
793
|
-
fx_aligned = fx_aligned.fillna(fx_nonnull.iloc[-1])
|
|
794
|
-
else:
|
|
795
|
-
raise ValueError(
|
|
796
|
-
f"Unknown ffill_policy: {ffill_policy!r} (expected 'strict' or 'ffill')"
|
|
797
|
-
)
|
|
798
|
-
|
|
794
|
+
fx_aligned = align_fx(
|
|
795
|
+
fx, unit_converted.index, policy=ffill_policy, max_staleness=max_staleness
|
|
796
|
+
)
|
|
799
797
|
return unit_converted * fx_aligned / fractional_divisor
|
|
800
798
|
|
|
801
799
|
return unit_converted * fx / fractional_divisor
|
|
802
800
|
|
|
803
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":
|
|
877
|
+
return value
|
|
878
|
+
|
|
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
|
+
|
|
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
|
+
|
|
804
985
|
def list_commodities():
|
|
805
986
|
"""List all available commodities"""
|
|
806
987
|
return converter.available_commodities
|
|
@@ -33,6 +33,9 @@ from commodutil.standards.currency import (
|
|
|
33
33
|
split_currency_unit,
|
|
34
34
|
to_symbol,
|
|
35
35
|
)
|
|
36
|
+
from commodutil.standards.price_unit import (
|
|
37
|
+
PriceUnit,
|
|
38
|
+
)
|
|
36
39
|
from commodutil.standards.price_units import (
|
|
37
40
|
resolve_price_unit,
|
|
38
41
|
resolve_price_unit_from_attrs,
|
|
@@ -81,6 +84,8 @@ __all__ = [
|
|
|
81
84
|
"required_fx_pair",
|
|
82
85
|
"split_currency_unit",
|
|
83
86
|
"to_symbol",
|
|
87
|
+
# price_unit
|
|
88
|
+
"PriceUnit",
|
|
84
89
|
# price_units
|
|
85
90
|
"resolve_price_unit",
|
|
86
91
|
"resolve_price_unit_from_attrs",
|