commodutil 4.5.0__tar.gz → 4.7.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 (82) hide show
  1. {commodutil-4.5.0 → commodutil-4.7.0}/PKG-INFO +1 -1
  2. {commodutil-4.5.0 → commodutil-4.7.0}/commodutil/convfactors.py +218 -127
  3. {commodutil-4.5.0 → commodutil-4.7.0}/commodutil/standards/price_unit.py +3 -3
  4. {commodutil-4.5.0 → commodutil-4.7.0}/commodutil/standards/unit_registry.py +18 -4
  5. {commodutil-4.5.0 → commodutil-4.7.0}/commodutil.egg-info/PKG-INFO +1 -1
  6. {commodutil-4.5.0 → commodutil-4.7.0}/commodutil.egg-info/SOURCES.txt +2 -0
  7. {commodutil-4.5.0 → commodutil-4.7.0}/commodutil.egg-info/scm_file_list.json +2 -0
  8. commodutil-4.7.0/commodutil.egg-info/scm_version.json +8 -0
  9. {commodutil-4.5.0 → commodutil-4.7.0}/tests/golden/golden_factors.json +48 -48
  10. commodutil-4.7.0/tests/test_align_fx_result.py +130 -0
  11. {commodutil-4.5.0 → commodutil-4.7.0}/tests/test_conv.py +21 -0
  12. commodutil-4.7.0/tests/test_rate_pint.py +81 -0
  13. {commodutil-4.5.0 → commodutil-4.7.0}/tests/test_standards_units.py +5 -0
  14. {commodutil-4.5.0 → commodutil-4.7.0}/tests/test_unit_registry.py +41 -0
  15. commodutil-4.5.0/commodutil.egg-info/scm_version.json +0 -8
  16. {commodutil-4.5.0 → commodutil-4.7.0}/.coveragerc +0 -0
  17. {commodutil-4.5.0 → commodutil-4.7.0}/.github/workflows/1_tests.yml +0 -0
  18. {commodutil-4.5.0 → commodutil-4.7.0}/.github/workflows/2_coverage.yml +0 -0
  19. {commodutil-4.5.0 → commodutil-4.7.0}/.github/workflows/3_linting.yml +0 -0
  20. {commodutil-4.5.0 → commodutil-4.7.0}/.github/workflows/4_release.yml +0 -0
  21. {commodutil-4.5.0 → commodutil-4.7.0}/.gitignore +0 -0
  22. {commodutil-4.5.0 → commodutil-4.7.0}/.pypirc +0 -0
  23. {commodutil-4.5.0 → commodutil-4.7.0}/azure-build-pipelines.yml +0 -0
  24. {commodutil-4.5.0 → commodutil-4.7.0}/commodutil/__init__.py +0 -0
  25. {commodutil-4.5.0 → commodutil-4.7.0}/commodutil/arb.py +0 -0
  26. {commodutil-4.5.0 → commodutil-4.7.0}/commodutil/dates.py +0 -0
  27. {commodutil-4.5.0 → commodutil-4.7.0}/commodutil/forward/__init__.py +0 -0
  28. {commodutil-4.5.0 → commodutil-4.7.0}/commodutil/forward/calendar.py +0 -0
  29. {commodutil-4.5.0 → commodutil-4.7.0}/commodutil/forward/continuous.py +0 -0
  30. {commodutil-4.5.0 → commodutil-4.7.0}/commodutil/forward/fly.py +0 -0
  31. {commodutil-4.5.0 → commodutil-4.7.0}/commodutil/forward/quarterly.py +0 -0
  32. {commodutil-4.5.0 → commodutil-4.7.0}/commodutil/forward/spreads.py +0 -0
  33. {commodutil-4.5.0 → commodutil-4.7.0}/commodutil/forward/structure.py +0 -0
  34. {commodutil-4.5.0 → commodutil-4.7.0}/commodutil/forward/util.py +0 -0
  35. {commodutil-4.5.0 → commodutil-4.7.0}/commodutil/forwards.py +0 -0
  36. {commodutil-4.5.0 → commodutil-4.7.0}/commodutil/pandasutil.py +0 -0
  37. {commodutil-4.5.0 → commodutil-4.7.0}/commodutil/standards/__init__.py +0 -0
  38. {commodutil-4.5.0 → commodutil-4.7.0}/commodutil/standards/analysis_types.py +0 -0
  39. {commodutil-4.5.0 → commodutil-4.7.0}/commodutil/standards/commodities.py +0 -0
  40. {commodutil-4.5.0 → commodutil-4.7.0}/commodutil/standards/commodity_groups.py +0 -0
  41. {commodutil-4.5.0 → commodutil-4.7.0}/commodutil/standards/currency.py +0 -0
  42. {commodutil-4.5.0 → commodutil-4.7.0}/commodutil/standards/price_units.py +0 -0
  43. {commodutil-4.5.0 → commodutil-4.7.0}/commodutil/standards/regions.py +0 -0
  44. {commodutil-4.5.0 → commodutil-4.7.0}/commodutil/standards/units.py +0 -0
  45. {commodutil-4.5.0 → commodutil-4.7.0}/commodutil/stats.py +0 -0
  46. {commodutil-4.5.0 → commodutil-4.7.0}/commodutil/transforms.py +0 -0
  47. {commodutil-4.5.0 → commodutil-4.7.0}/commodutil.egg-info/dependency_links.txt +0 -0
  48. {commodutil-4.5.0 → commodutil-4.7.0}/commodutil.egg-info/requires.txt +0 -0
  49. {commodutil-4.5.0 → commodutil-4.7.0}/commodutil.egg-info/top_level.txt +0 -0
  50. {commodutil-4.5.0 → commodutil-4.7.0}/pyproject.toml +0 -0
  51. {commodutil-4.5.0 → commodutil-4.7.0}/requirements-test.txt +0 -0
  52. {commodutil-4.5.0 → commodutil-4.7.0}/requirements.txt +0 -0
  53. {commodutil-4.5.0 → commodutil-4.7.0}/requirements_dev.txt +0 -0
  54. {commodutil-4.5.0 → commodutil-4.7.0}/setup.cfg +0 -0
  55. {commodutil-4.5.0 → commodutil-4.7.0}/tests/__init__.py +0 -0
  56. {commodutil-4.5.0 → commodutil-4.7.0}/tests/conftest.py +0 -0
  57. {commodutil-4.5.0 → commodutil-4.7.0}/tests/forward/__init__.py +0 -0
  58. {commodutil-4.5.0 → commodutil-4.7.0}/tests/forward/conftest.py +0 -0
  59. {commodutil-4.5.0 → commodutil-4.7.0}/tests/forward/test_calendar.py +0 -0
  60. {commodutil-4.5.0 → commodutil-4.7.0}/tests/forward/test_continuous.py +0 -0
  61. {commodutil-4.5.0 → commodutil-4.7.0}/tests/forward/test_fly.py +0 -0
  62. {commodutil-4.5.0 → commodutil-4.7.0}/tests/forward/test_quarterly.py +0 -0
  63. {commodutil-4.5.0 → commodutil-4.7.0}/tests/forward/test_spreads.py +0 -0
  64. {commodutil-4.5.0 → commodutil-4.7.0}/tests/forward/test_structure.py +0 -0
  65. {commodutil-4.5.0 → commodutil-4.7.0}/tests/forward/test_util.py +0 -0
  66. {commodutil-4.5.0 → commodutil-4.7.0}/tests/test_arb.py +0 -0
  67. {commodutil-4.5.0 → commodutil-4.7.0}/tests/test_cl.csv +0 -0
  68. {commodutil-4.5.0 → commodutil-4.7.0}/tests/test_dates.py +0 -0
  69. {commodutil-4.5.0 → commodutil-4.7.0}/tests/test_forwards.py +0 -0
  70. {commodutil-4.5.0 → commodutil-4.7.0}/tests/test_golden_factors.py +0 -0
  71. {commodutil-4.5.0 → commodutil-4.7.0}/tests/test_pandasutils.py +0 -0
  72. {commodutil-4.5.0 → commodutil-4.7.0}/tests/test_price_conv.py +0 -0
  73. {commodutil-4.5.0 → commodutil-4.7.0}/tests/test_price_unit.py +0 -0
  74. {commodutil-4.5.0 → commodutil-4.7.0}/tests/test_standards_analysis_types.py +0 -0
  75. {commodutil-4.5.0 → commodutil-4.7.0}/tests/test_standards_commodities.py +0 -0
  76. {commodutil-4.5.0 → commodutil-4.7.0}/tests/test_standards_commodity_groups.py +0 -0
  77. {commodutil-4.5.0 → commodutil-4.7.0}/tests/test_standards_currency.py +0 -0
  78. {commodutil-4.5.0 → commodutil-4.7.0}/tests/test_standards_price_units.py +0 -0
  79. {commodutil-4.5.0 → commodutil-4.7.0}/tests/test_standards_regions.py +0 -0
  80. {commodutil-4.5.0 → commodutil-4.7.0}/tests/test_stats.py +0 -0
  81. {commodutil-4.5.0 → commodutil-4.7.0}/tests/test_transforms.py +0 -0
  82. {commodutil-4.5.0 → commodutil-4.7.0}/tests/test_weekly.csv +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: commodutil
3
- Version: 4.5.0
3
+ Version: 4.7.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
@@ -131,8 +131,8 @@ COMMODITIES = {
131
131
  "naphtha", 0.706720311 * ureg.kg / ureg.L, 34.826266 * ureg.GJ / ureg.m**3
132
132
  ), # HHV; EIA petrochemical naphtha <401F 5.248 MMBtu/bbl -> 49.28 GJ/t gross (was NCV 44.90)
133
133
  "ethanol": Commodity(
134
- "ethanol", 0.755079324 * ureg.kg / ureg.L, 23.485167 * ureg.GJ / ureg.m**3
135
- ), # HHV; EIA fuel ethanol (undenatured) 3.539 MMBtu/bbl -> 31.10 GJ/t gross (was net ~27.81); NOTE density 0.7551 looks copied from gasoline (ethanol ~0.789) - flagged, left unchanged
134
+ "ethanol", 0.7937 * ureg.kg / ureg.L, 23.485167 * ureg.GJ / ureg.m**3
135
+ ), # HHV; EIA fuel ethanol (undenatured) 3.539 MMBtu/bbl. Density: ASTM D4806 undenatured fuel ethanol SG 60/60F lower bound 0.7937 (Energy Transfer spec note; IEA AMF rounds ethanol density to 0.79 kg/L). Energy_content is per-volume, so bbl->MMBtu stays fixed while mt factors move.
136
136
  # Middle distillates
137
137
  "diesel": Commodity(
138
138
  "diesel", 0.844269902 * ureg.kg / ureg.L, 38.290312 * ureg.GJ / ureg.m**3
@@ -297,34 +297,32 @@ class CommodityConverter:
297
297
  if isinstance(to_unit, PriceUnit):
298
298
  to_unit = str(to_unit)
299
299
 
300
- # Normalize and parse units to handle daily/monthly rates
300
+ # Normalize, then split rate units with the single PriceUnit parser
301
+ # (quantity leg + rate period). No private rate parser anymore.
301
302
  from_unit = _to_pint_token(from_unit)
302
303
  to_unit = _to_pint_token(to_unit)
303
- from_rate = self._parse_rate_unit(from_unit)
304
- to_rate = self._parse_rate_unit(to_unit)
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
305
310
 
306
- # Get base units
307
- from_base = from_rate["base"]
308
- to_base = to_rate["base"]
309
-
310
- # Create quantity
311
311
  if isinstance(value, pd.Series):
312
- result = self._convert_series(
313
- value,
314
- from_base,
315
- to_base,
316
- commodity,
317
- from_rate["period"],
318
- 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
319
316
  )
320
- else:
321
- result = self._convert_scalar(value, from_base, to_base, commodity)
322
- if from_rate["period"] or to_rate["period"]:
323
- factor = self._rate_factor_scalar(
324
- from_rate["period"], to_rate["period"]
325
- )
326
- result = result * factor
327
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)
328
326
  return result
329
327
 
330
328
  @lru_cache(maxsize=128)
@@ -449,7 +447,7 @@ class CommodityConverter:
449
447
  """Convert a pandas Series with optional rate handling.
450
448
 
451
449
  - Month conversions use the index's actual days_in_month when available.
452
- - Other time conversions use standard averages (365.25 days/year, 30.4375 days/month).
450
+ - Other time conversions use pint's calendar period lengths.
453
451
  """
454
452
  result = series.copy()
455
453
 
@@ -466,9 +464,9 @@ class CommodityConverter:
466
464
  else:
467
465
  result = result / series.index.days_in_month
468
466
  else:
469
- # Fallback to scalar factor for other period conversions
470
- factor = self._rate_factor_scalar(from_period, to_period)
471
- 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)
472
470
 
473
471
  # Apply unit conversion
474
472
  factor_units = self._convert_scalar(1.0, from_unit, to_unit, commodity)
@@ -476,53 +474,24 @@ class CommodityConverter:
476
474
 
477
475
  return result
478
476
 
479
- def _parse_rate_unit(self, unit: str) -> dict:
480
- """Parse units like 'bbl/day' or 'kt/month'."""
481
- if "/" in unit:
482
- base, period = unit.split("/", 1)
483
- base = _to_pint_token(base)
484
- period = period.strip().lower().rstrip("s") # day(s), month(s), year(s)
485
- return {"base": base, "period": period}
486
- return {"base": _to_pint_token(unit), "period": None}
487
-
488
- def _rate_factor_scalar(
477
+ def _period_rate_factor(
489
478
  self, from_period: Optional[str], to_period: Optional[str]
490
479
  ) -> float:
491
- """Scalar factor to convert between rate periods for scalars.
492
-
493
- Uses average calendar lengths when months/years are involved.
494
- - Average days per year: 365.25
495
- - 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.
496
489
  """
497
- if from_period == to_period:
498
- return 1.0
499
- if from_period is None and to_period is None:
500
- return 1.0
501
- if from_period is None or to_period is None:
502
- # 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:
503
491
  return 1.0
504
-
505
- avg_days_per_year = 365.25
506
- avg_days_per_month = avg_days_per_year / 12.0
507
-
508
- # Helper to express rates as per-day factors
509
- def per_day_factor(period: str) -> float:
510
- if period == "day":
511
- return 1.0
512
- if period == "year":
513
- return 1.0 / avg_days_per_year
514
- if period == "month":
515
- return 1.0 / avg_days_per_month
516
- # Fallback to Pint if it's a known time unit
517
- try:
518
- return (1 * (self.ureg(period) ** -1)).to(self.ureg.day**-1).magnitude
519
- except Exception:
520
- raise ValueError(f"Unsupported rate period: {period}")
521
-
522
- from_per_day = per_day_factor(from_period)
523
- to_per_day = per_day_factor(to_period)
524
- # Convert from per-from_period to per-to_period
525
- 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
526
495
 
527
496
  def _is_energy(self, unit: str) -> bool:
528
497
  return self.ureg(unit).check("[energy]")
@@ -616,6 +585,84 @@ def convfactor(
616
585
  # wanting currency vocabulary should import from commodutil.standards.currency.
617
586
 
618
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
+
619
666
  def convert_price(
620
667
  value: Union[float, pd.Series],
621
668
  from_unit: Union[str, PriceUnit],
@@ -744,62 +791,9 @@ def convert_price(
744
791
  fractional_divisor = _currency.FRACTIONAL_CURRENCY_DIVISORS.get(from_ccy, 1.0)
745
792
 
746
793
  if isinstance(unit_converted, pd.Series) and isinstance(fx, pd.Series):
747
- target_idx = unit_converted.index
748
-
749
- if ffill_policy == "strict":
750
- # Bounded ffill: only forward-fill within `max_staleness`. Anything
751
- # uncovered (e.g. value-dates before fx.index.min() or stale past
752
- # the limit) stays NaN and triggers a loud raise — no silent
753
- # back-fill, no silent stale extrapolation.
754
- union_idx = fx.index.union(target_idx)
755
- fx_union = fx.reindex(union_idx).sort_index().ffill()
756
- # Track how stale each ffilled value is and zero-out anything older
757
- # than max_staleness.
758
- valid_mask = ~fx.reindex(union_idx).isna()
759
- last_valid_pos = (
760
- pd.Series(union_idx, index=union_idx).where(valid_mask).ffill()
761
- )
762
- staleness = pd.Series(union_idx, index=union_idx) - last_valid_pos
763
- fx_union = fx_union.where(staleness <= max_staleness)
764
- fx_aligned = fx_union.reindex(target_idx)
765
- if fx_aligned.isna().any():
766
- missing = target_idx[fx_aligned.isna()]
767
- first_missing = missing[0]
768
- first_missing_str = (
769
- first_missing.date()
770
- if hasattr(first_missing, "date")
771
- else first_missing
772
- )
773
- raise ValueError(
774
- f"FX missing or stale (>{max_staleness}) for "
775
- f"{len(missing)} target date(s) (first: {first_missing_str}). "
776
- f"Pass ffill_policy='ffill' to fill with the last non-null "
777
- f"FX (BACKTEST FUTURE LEAKAGE RISK)."
778
- )
779
- elif ffill_policy == "ffill":
780
- logger.warning(
781
- "convert_price: ffill_policy='ffill' — pre-FX-start dates will "
782
- "be back-filled with the latest FX. Future-leakage risk in "
783
- "backtests; prefer 'strict' for historical research."
784
- )
785
- if not target_idx.isin(fx.index).all():
786
- union_idx = fx.index.union(target_idx)
787
- fx_aligned = fx.reindex(union_idx).ffill().reindex(target_idx)
788
- else:
789
- fx_aligned = fx.reindex(target_idx).ffill()
790
- if fx_aligned.isna().any():
791
- fx_nonnull = fx.dropna()
792
- if fx_nonnull.size == 0:
793
- raise ValueError(
794
- "FX series is entirely NaN; refusing the silent "
795
- "multiply-by-1.0 fallback."
796
- )
797
- fx_aligned = fx_aligned.fillna(fx_nonnull.iloc[-1])
798
- else:
799
- raise ValueError(
800
- f"Unknown ffill_policy: {ffill_policy!r} (expected 'strict' or 'ffill')"
801
- )
802
-
794
+ fx_aligned = align_fx(
795
+ fx, unit_converted.index, policy=ffill_policy, max_staleness=max_staleness
796
+ )
803
797
  return unit_converted * fx_aligned / fractional_divisor
804
798
 
805
799
  return unit_converted * fx / fractional_divisor
@@ -891,6 +885,103 @@ def convert_currency_leg(
891
885
  return value * fx / from_pu.fractional_divisor
892
886
 
893
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
+
894
985
  def list_commodities():
895
986
  """List all available commodities"""
896
987
  return converter.available_commodities
@@ -62,7 +62,7 @@ class PriceUnit:
62
62
  currency = str(self.currency).strip()
63
63
  object.__setattr__(self, "currency", currency or None)
64
64
  if self.period is not None:
65
- # Mirror convfactors._parse_rate_unit: day(s)/month(s)/year(s).
65
+ # Rate-period normalisation: day(s)/month(s)/year(s) -> singular.
66
66
  period = str(self.period).strip().lower().rstrip("s")
67
67
  object.__setattr__(self, "period", period or None)
68
68
 
@@ -104,8 +104,8 @@ class PriceUnit:
104
104
 
105
105
  @staticmethod
106
106
  def _split_period(bare: str) -> tuple[str, Optional[str]]:
107
- """Split a bare unit into (quantity_unit, period), mirroring
108
- convfactors._parse_rate_unit's period handling."""
107
+ """Split a bare unit into (quantity_unit, period) with day(s)/month(s)/
108
+ year(s) rate-period normalisation."""
109
109
  if "/" in bare:
110
110
  base, _, period = bare.partition("/")
111
111
  period = period.strip().lower().rstrip("s")
@@ -203,16 +203,25 @@ UNIT_ROWS: Tuple[UnitRow, ...] = (
203
203
  # into UNIT_MAP. A prefix like 'cubic met' does NOT work under
204
204
  # word-boundary matching (trailing letters kill the lookahead), so each
205
205
  # form is listed explicitly.
206
+ #
207
+ # Phase 2.1 residual consolidation: the bare notations 'm3' / 'm^3' /
208
+ # 'm**3' are PROMOTED from public_only into UNIT_MAP so curvemetadata's
209
+ # word-boundary parse_unit resolves them directly and its local
210
+ # _EXTRA_UNIT_ALIASES fallback can be deleted. Word-boundary safe: 'm3'
211
+ # cannot match inside 'm30' (trailing [0-9] kills the lookahead); the
212
+ # '**'/'^' chars are regex-escaped by parse_unit. Purely additive to
213
+ # UNIT_MAP; PUBLIC_UNIT_MAP is unchanged (a UNIT_MAP entry is inherited
214
+ # by PUBLIC_UNIT_MAP either way).
206
215
  unit_map_spellings=(
207
216
  "cubic meter",
208
217
  "cubic meters",
209
218
  "cubic metre",
210
219
  "cubic metres",
211
- ),
212
- public_only_spellings=(
213
220
  "m3",
214
221
  "m^3",
215
222
  "m**3",
223
+ ),
224
+ public_only_spellings=(
216
225
  "cubic_meter",
217
226
  "cubic_meters",
218
227
  "cubic_metre",
@@ -222,8 +231,13 @@ UNIT_ROWS: Tuple[UnitRow, ...] = (
222
231
  UnitRow(
223
232
  "kg",
224
233
  "mass",
225
- # curvemetadata: kg / kilogram(s) / kilogramme(s)
226
- public_only_spellings=(
234
+ # Phase 2.1 residual consolidation: kg / kilogram(s) / kilogramme(s)
235
+ # PROMOTED from public_only into UNIT_MAP so parse_unit resolves them and
236
+ # curvemetadata's _EXTRA_UNIT_ALIASES fallback can be deleted. The
237
+ # numeric-prefixed scaled form ("25,000 kg") stays a curvemetadata-local
238
+ # regex (its no-space variant "25000kg" is unreachable by word-boundary
239
+ # matching). Additive to UNIT_MAP; PUBLIC_UNIT_MAP unchanged.
240
+ unit_map_spellings=(
227
241
  "kg",
228
242
  "kilogram",
229
243
  "kilograms",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: commodutil
3
- Version: 4.5.0
3
+ Version: 4.7.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
@@ -45,6 +45,7 @@ commodutil/standards/unit_registry.py
45
45
  commodutil/standards/units.py
46
46
  tests/__init__.py
47
47
  tests/conftest.py
48
+ tests/test_align_fx_result.py
48
49
  tests/test_arb.py
49
50
  tests/test_cl.csv
50
51
  tests/test_conv.py
@@ -54,6 +55,7 @@ tests/test_golden_factors.py
54
55
  tests/test_pandasutils.py
55
56
  tests/test_price_conv.py
56
57
  tests/test_price_unit.py
58
+ tests/test_rate_pint.py
57
59
  tests/test_standards_analysis_types.py
58
60
  tests/test_standards_commodities.py
59
61
  tests/test_standards_commodity_groups.py
@@ -8,10 +8,12 @@
8
8
  "requirements_dev.txt",
9
9
  ".gitignore",
10
10
  ".pypirc",
11
+ "tests/test_rate_pint.py",
11
12
  "tests/__init__.py",
12
13
  "tests/test_dates.py",
13
14
  "tests/test_price_unit.py",
14
15
  "tests/test_standards_analysis_types.py",
16
+ "tests/test_align_fx_result.py",
15
17
  "tests/test_standards_price_units.py",
16
18
  "tests/test_standards_commodities.py",
17
19
  "tests/test_standards_commodity_groups.py",
@@ -0,0 +1,8 @@
1
+ {
2
+ "tag": "4.7.0",
3
+ "distance": 0,
4
+ "node": "ge71901f8d2006cfe2abbc815edbbf7d645edfb3a",
5
+ "dirty": false,
6
+ "branch": "HEAD",
7
+ "node_date": "2026-07-03"
8
+ }