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.
Files changed (81) hide show
  1. {commodutil-4.4.0 → commodutil-4.6.0}/PKG-INFO +1 -1
  2. {commodutil-4.4.0 → commodutil-4.6.0}/commodutil/convfactors.py +358 -177
  3. {commodutil-4.4.0 → commodutil-4.6.0}/commodutil/standards/__init__.py +5 -0
  4. commodutil-4.6.0/commodutil/standards/price_unit.py +163 -0
  5. commodutil-4.6.0/commodutil/standards/unit_registry.py +319 -0
  6. {commodutil-4.4.0 → commodutil-4.6.0}/commodutil/standards/units.py +20 -57
  7. {commodutil-4.4.0 → commodutil-4.6.0}/commodutil.egg-info/PKG-INFO +1 -1
  8. {commodutil-4.4.0 → commodutil-4.6.0}/commodutil.egg-info/SOURCES.txt +6 -0
  9. {commodutil-4.4.0 → commodutil-4.6.0}/commodutil.egg-info/scm_file_list.json +6 -0
  10. {commodutil-4.4.0 → commodutil-4.6.0}/commodutil.egg-info/scm_version.json +2 -2
  11. commodutil-4.6.0/tests/test_align_fx_result.py +130 -0
  12. commodutil-4.6.0/tests/test_price_unit.py +232 -0
  13. commodutil-4.6.0/tests/test_rate_pint.py +81 -0
  14. {commodutil-4.4.0 → commodutil-4.6.0}/tests/test_standards_units.py +23 -3
  15. commodutil-4.6.0/tests/test_unit_registry.py +396 -0
  16. {commodutil-4.4.0 → commodutil-4.6.0}/.coveragerc +0 -0
  17. {commodutil-4.4.0 → commodutil-4.6.0}/.github/workflows/1_tests.yml +0 -0
  18. {commodutil-4.4.0 → commodutil-4.6.0}/.github/workflows/2_coverage.yml +0 -0
  19. {commodutil-4.4.0 → commodutil-4.6.0}/.github/workflows/3_linting.yml +0 -0
  20. {commodutil-4.4.0 → commodutil-4.6.0}/.github/workflows/4_release.yml +0 -0
  21. {commodutil-4.4.0 → commodutil-4.6.0}/.gitignore +0 -0
  22. {commodutil-4.4.0 → commodutil-4.6.0}/.pypirc +0 -0
  23. {commodutil-4.4.0 → commodutil-4.6.0}/azure-build-pipelines.yml +0 -0
  24. {commodutil-4.4.0 → commodutil-4.6.0}/commodutil/__init__.py +0 -0
  25. {commodutil-4.4.0 → commodutil-4.6.0}/commodutil/arb.py +0 -0
  26. {commodutil-4.4.0 → commodutil-4.6.0}/commodutil/dates.py +0 -0
  27. {commodutil-4.4.0 → commodutil-4.6.0}/commodutil/forward/__init__.py +0 -0
  28. {commodutil-4.4.0 → commodutil-4.6.0}/commodutil/forward/calendar.py +0 -0
  29. {commodutil-4.4.0 → commodutil-4.6.0}/commodutil/forward/continuous.py +0 -0
  30. {commodutil-4.4.0 → commodutil-4.6.0}/commodutil/forward/fly.py +0 -0
  31. {commodutil-4.4.0 → commodutil-4.6.0}/commodutil/forward/quarterly.py +0 -0
  32. {commodutil-4.4.0 → commodutil-4.6.0}/commodutil/forward/spreads.py +0 -0
  33. {commodutil-4.4.0 → commodutil-4.6.0}/commodutil/forward/structure.py +0 -0
  34. {commodutil-4.4.0 → commodutil-4.6.0}/commodutil/forward/util.py +0 -0
  35. {commodutil-4.4.0 → commodutil-4.6.0}/commodutil/forwards.py +0 -0
  36. {commodutil-4.4.0 → commodutil-4.6.0}/commodutil/pandasutil.py +0 -0
  37. {commodutil-4.4.0 → commodutil-4.6.0}/commodutil/standards/analysis_types.py +0 -0
  38. {commodutil-4.4.0 → commodutil-4.6.0}/commodutil/standards/commodities.py +0 -0
  39. {commodutil-4.4.0 → commodutil-4.6.0}/commodutil/standards/commodity_groups.py +0 -0
  40. {commodutil-4.4.0 → commodutil-4.6.0}/commodutil/standards/currency.py +0 -0
  41. {commodutil-4.4.0 → commodutil-4.6.0}/commodutil/standards/price_units.py +0 -0
  42. {commodutil-4.4.0 → commodutil-4.6.0}/commodutil/standards/regions.py +0 -0
  43. {commodutil-4.4.0 → commodutil-4.6.0}/commodutil/stats.py +0 -0
  44. {commodutil-4.4.0 → commodutil-4.6.0}/commodutil/transforms.py +0 -0
  45. {commodutil-4.4.0 → commodutil-4.6.0}/commodutil.egg-info/dependency_links.txt +0 -0
  46. {commodutil-4.4.0 → commodutil-4.6.0}/commodutil.egg-info/requires.txt +0 -0
  47. {commodutil-4.4.0 → commodutil-4.6.0}/commodutil.egg-info/top_level.txt +0 -0
  48. {commodutil-4.4.0 → commodutil-4.6.0}/pyproject.toml +0 -0
  49. {commodutil-4.4.0 → commodutil-4.6.0}/requirements-test.txt +0 -0
  50. {commodutil-4.4.0 → commodutil-4.6.0}/requirements.txt +0 -0
  51. {commodutil-4.4.0 → commodutil-4.6.0}/requirements_dev.txt +0 -0
  52. {commodutil-4.4.0 → commodutil-4.6.0}/setup.cfg +0 -0
  53. {commodutil-4.4.0 → commodutil-4.6.0}/tests/__init__.py +0 -0
  54. {commodutil-4.4.0 → commodutil-4.6.0}/tests/conftest.py +0 -0
  55. {commodutil-4.4.0 → commodutil-4.6.0}/tests/forward/__init__.py +0 -0
  56. {commodutil-4.4.0 → commodutil-4.6.0}/tests/forward/conftest.py +0 -0
  57. {commodutil-4.4.0 → commodutil-4.6.0}/tests/forward/test_calendar.py +0 -0
  58. {commodutil-4.4.0 → commodutil-4.6.0}/tests/forward/test_continuous.py +0 -0
  59. {commodutil-4.4.0 → commodutil-4.6.0}/tests/forward/test_fly.py +0 -0
  60. {commodutil-4.4.0 → commodutil-4.6.0}/tests/forward/test_quarterly.py +0 -0
  61. {commodutil-4.4.0 → commodutil-4.6.0}/tests/forward/test_spreads.py +0 -0
  62. {commodutil-4.4.0 → commodutil-4.6.0}/tests/forward/test_structure.py +0 -0
  63. {commodutil-4.4.0 → commodutil-4.6.0}/tests/forward/test_util.py +0 -0
  64. {commodutil-4.4.0 → commodutil-4.6.0}/tests/golden/golden_factors.json +0 -0
  65. {commodutil-4.4.0 → commodutil-4.6.0}/tests/test_arb.py +0 -0
  66. {commodutil-4.4.0 → commodutil-4.6.0}/tests/test_cl.csv +0 -0
  67. {commodutil-4.4.0 → commodutil-4.6.0}/tests/test_conv.py +0 -0
  68. {commodutil-4.4.0 → commodutil-4.6.0}/tests/test_dates.py +0 -0
  69. {commodutil-4.4.0 → commodutil-4.6.0}/tests/test_forwards.py +0 -0
  70. {commodutil-4.4.0 → commodutil-4.6.0}/tests/test_golden_factors.py +0 -0
  71. {commodutil-4.4.0 → commodutil-4.6.0}/tests/test_pandasutils.py +0 -0
  72. {commodutil-4.4.0 → commodutil-4.6.0}/tests/test_price_conv.py +0 -0
  73. {commodutil-4.4.0 → commodutil-4.6.0}/tests/test_standards_analysis_types.py +0 -0
  74. {commodutil-4.4.0 → commodutil-4.6.0}/tests/test_standards_commodities.py +0 -0
  75. {commodutil-4.4.0 → commodutil-4.6.0}/tests/test_standards_commodity_groups.py +0 -0
  76. {commodutil-4.4.0 → commodutil-4.6.0}/tests/test_standards_currency.py +0 -0
  77. {commodutil-4.4.0 → commodutil-4.6.0}/tests/test_standards_price_units.py +0 -0
  78. {commodutil-4.4.0 → commodutil-4.6.0}/tests/test_standards_regions.py +0 -0
  79. {commodutil-4.4.0 → commodutil-4.6.0}/tests/test_stats.py +0 -0
  80. {commodutil-4.4.0 → commodutil-4.6.0}/tests/test_transforms.py +0 -0
  81. {commodutil-4.4.0 → commodutil-4.6.0}/tests/test_weekly.csv +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: commodutil
3
- Version: 4.4.0
3
+ Version: 4.6.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
@@ -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
- # Pint already ships a `barrel` unit but its default (US dry barrel,
35
- # ~119.24 L) is NOT the oil/petroleum barrel. Rather than silently
36
- # clobbering pint's default (which would mean downstream callers using
37
- # `ureg.barrel` for non-oil contexts get the wrong answer), we register
38
- # a distinct `oil_barrel` (158.987294928 L = 42 US gallons) and route
39
- # the `bbl` alias to it. `ureg.barrel` retains pint's default meaning.
40
- ureg.define("oil_barrel = 158.987294928 liter = bbl")
41
- ureg.define("gallon = 3.785411784 liter = gal")
42
- ureg.define("metric_ton = 1000 kilogram = mt")
43
- ureg.define("kiloton = 1000 metric_ton = kt")
44
- ureg.define("cubic_kilometer = 1e9 meter**3 = km3") # 1 km^3 = 1 billion m^3
45
- ureg.define("gigajoule = 1e9 joule = gj = GJ")
46
- ureg.define("petajoule = 1e15 joule = pj = PJ")
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
- # Normalize and parse units to handle daily/monthly rates
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
- from_rate = self._parse_rate_unit(from_unit)
321
- to_rate = self._parse_rate_unit(to_unit)
322
-
323
- # Get base units
324
- from_base = from_rate["base"]
325
- to_base = to_rate["base"]
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
- result = self._convert_series(
330
- value,
331
- from_base,
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 standard averages (365.25 days/year, 30.4375 days/month).
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
- # Fallback to scalar factor for other period conversions
487
- factor = self._rate_factor_scalar(from_period, to_period)
488
- result = result * factor
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 _parse_rate_unit(self, unit: str) -> dict:
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
- """Scalar factor to convert between rate periods for scalars.
509
-
510
- Uses average calendar lengths when months/years are involved.
511
- - Average days per year: 365.25
512
- - Average days per month: 365.25 / 12 = 30.4375
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
- avg_days_per_year = 365.25
523
- avg_days_per_month = avg_days_per_year / 12.0
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(value, from_unit: str, to_unit: str, commodity: Optional[str] = None):
609
- """Convert values between units"""
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(from_unit: str, to_unit: str, commodity: Optional[str] = None) -> float:
614
- """Get conversion factor between units"""
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
- from_ccy, from_bare_unit = _currency.split_currency_unit(from_unit)
686
- to_ccy, to_bare_unit = _currency.split_currency_unit(to_unit)
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
- target_idx = unit_converted.index
744
-
745
- if ffill_policy == "strict":
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",