commodutil 4.5.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.5.0 → commodutil-4.6.0}/PKG-INFO +1 -1
  2. {commodutil-4.5.0 → commodutil-4.6.0}/commodutil/convfactors.py +216 -125
  3. {commodutil-4.5.0 → commodutil-4.6.0}/commodutil/standards/price_unit.py +3 -3
  4. {commodutil-4.5.0 → commodutil-4.6.0}/commodutil.egg-info/PKG-INFO +1 -1
  5. {commodutil-4.5.0 → commodutil-4.6.0}/commodutil.egg-info/SOURCES.txt +2 -0
  6. {commodutil-4.5.0 → commodutil-4.6.0}/commodutil.egg-info/scm_file_list.json +2 -0
  7. {commodutil-4.5.0 → commodutil-4.6.0}/commodutil.egg-info/scm_version.json +2 -2
  8. commodutil-4.6.0/tests/test_align_fx_result.py +130 -0
  9. commodutil-4.6.0/tests/test_rate_pint.py +81 -0
  10. {commodutil-4.5.0 → commodutil-4.6.0}/.coveragerc +0 -0
  11. {commodutil-4.5.0 → commodutil-4.6.0}/.github/workflows/1_tests.yml +0 -0
  12. {commodutil-4.5.0 → commodutil-4.6.0}/.github/workflows/2_coverage.yml +0 -0
  13. {commodutil-4.5.0 → commodutil-4.6.0}/.github/workflows/3_linting.yml +0 -0
  14. {commodutil-4.5.0 → commodutil-4.6.0}/.github/workflows/4_release.yml +0 -0
  15. {commodutil-4.5.0 → commodutil-4.6.0}/.gitignore +0 -0
  16. {commodutil-4.5.0 → commodutil-4.6.0}/.pypirc +0 -0
  17. {commodutil-4.5.0 → commodutil-4.6.0}/azure-build-pipelines.yml +0 -0
  18. {commodutil-4.5.0 → commodutil-4.6.0}/commodutil/__init__.py +0 -0
  19. {commodutil-4.5.0 → commodutil-4.6.0}/commodutil/arb.py +0 -0
  20. {commodutil-4.5.0 → commodutil-4.6.0}/commodutil/dates.py +0 -0
  21. {commodutil-4.5.0 → commodutil-4.6.0}/commodutil/forward/__init__.py +0 -0
  22. {commodutil-4.5.0 → commodutil-4.6.0}/commodutil/forward/calendar.py +0 -0
  23. {commodutil-4.5.0 → commodutil-4.6.0}/commodutil/forward/continuous.py +0 -0
  24. {commodutil-4.5.0 → commodutil-4.6.0}/commodutil/forward/fly.py +0 -0
  25. {commodutil-4.5.0 → commodutil-4.6.0}/commodutil/forward/quarterly.py +0 -0
  26. {commodutil-4.5.0 → commodutil-4.6.0}/commodutil/forward/spreads.py +0 -0
  27. {commodutil-4.5.0 → commodutil-4.6.0}/commodutil/forward/structure.py +0 -0
  28. {commodutil-4.5.0 → commodutil-4.6.0}/commodutil/forward/util.py +0 -0
  29. {commodutil-4.5.0 → commodutil-4.6.0}/commodutil/forwards.py +0 -0
  30. {commodutil-4.5.0 → commodutil-4.6.0}/commodutil/pandasutil.py +0 -0
  31. {commodutil-4.5.0 → commodutil-4.6.0}/commodutil/standards/__init__.py +0 -0
  32. {commodutil-4.5.0 → commodutil-4.6.0}/commodutil/standards/analysis_types.py +0 -0
  33. {commodutil-4.5.0 → commodutil-4.6.0}/commodutil/standards/commodities.py +0 -0
  34. {commodutil-4.5.0 → commodutil-4.6.0}/commodutil/standards/commodity_groups.py +0 -0
  35. {commodutil-4.5.0 → commodutil-4.6.0}/commodutil/standards/currency.py +0 -0
  36. {commodutil-4.5.0 → commodutil-4.6.0}/commodutil/standards/price_units.py +0 -0
  37. {commodutil-4.5.0 → commodutil-4.6.0}/commodutil/standards/regions.py +0 -0
  38. {commodutil-4.5.0 → commodutil-4.6.0}/commodutil/standards/unit_registry.py +0 -0
  39. {commodutil-4.5.0 → commodutil-4.6.0}/commodutil/standards/units.py +0 -0
  40. {commodutil-4.5.0 → commodutil-4.6.0}/commodutil/stats.py +0 -0
  41. {commodutil-4.5.0 → commodutil-4.6.0}/commodutil/transforms.py +0 -0
  42. {commodutil-4.5.0 → commodutil-4.6.0}/commodutil.egg-info/dependency_links.txt +0 -0
  43. {commodutil-4.5.0 → commodutil-4.6.0}/commodutil.egg-info/requires.txt +0 -0
  44. {commodutil-4.5.0 → commodutil-4.6.0}/commodutil.egg-info/top_level.txt +0 -0
  45. {commodutil-4.5.0 → commodutil-4.6.0}/pyproject.toml +0 -0
  46. {commodutil-4.5.0 → commodutil-4.6.0}/requirements-test.txt +0 -0
  47. {commodutil-4.5.0 → commodutil-4.6.0}/requirements.txt +0 -0
  48. {commodutil-4.5.0 → commodutil-4.6.0}/requirements_dev.txt +0 -0
  49. {commodutil-4.5.0 → commodutil-4.6.0}/setup.cfg +0 -0
  50. {commodutil-4.5.0 → commodutil-4.6.0}/tests/__init__.py +0 -0
  51. {commodutil-4.5.0 → commodutil-4.6.0}/tests/conftest.py +0 -0
  52. {commodutil-4.5.0 → commodutil-4.6.0}/tests/forward/__init__.py +0 -0
  53. {commodutil-4.5.0 → commodutil-4.6.0}/tests/forward/conftest.py +0 -0
  54. {commodutil-4.5.0 → commodutil-4.6.0}/tests/forward/test_calendar.py +0 -0
  55. {commodutil-4.5.0 → commodutil-4.6.0}/tests/forward/test_continuous.py +0 -0
  56. {commodutil-4.5.0 → commodutil-4.6.0}/tests/forward/test_fly.py +0 -0
  57. {commodutil-4.5.0 → commodutil-4.6.0}/tests/forward/test_quarterly.py +0 -0
  58. {commodutil-4.5.0 → commodutil-4.6.0}/tests/forward/test_spreads.py +0 -0
  59. {commodutil-4.5.0 → commodutil-4.6.0}/tests/forward/test_structure.py +0 -0
  60. {commodutil-4.5.0 → commodutil-4.6.0}/tests/forward/test_util.py +0 -0
  61. {commodutil-4.5.0 → commodutil-4.6.0}/tests/golden/golden_factors.json +0 -0
  62. {commodutil-4.5.0 → commodutil-4.6.0}/tests/test_arb.py +0 -0
  63. {commodutil-4.5.0 → commodutil-4.6.0}/tests/test_cl.csv +0 -0
  64. {commodutil-4.5.0 → commodutil-4.6.0}/tests/test_conv.py +0 -0
  65. {commodutil-4.5.0 → commodutil-4.6.0}/tests/test_dates.py +0 -0
  66. {commodutil-4.5.0 → commodutil-4.6.0}/tests/test_forwards.py +0 -0
  67. {commodutil-4.5.0 → commodutil-4.6.0}/tests/test_golden_factors.py +0 -0
  68. {commodutil-4.5.0 → commodutil-4.6.0}/tests/test_pandasutils.py +0 -0
  69. {commodutil-4.5.0 → commodutil-4.6.0}/tests/test_price_conv.py +0 -0
  70. {commodutil-4.5.0 → commodutil-4.6.0}/tests/test_price_unit.py +0 -0
  71. {commodutil-4.5.0 → commodutil-4.6.0}/tests/test_standards_analysis_types.py +0 -0
  72. {commodutil-4.5.0 → commodutil-4.6.0}/tests/test_standards_commodities.py +0 -0
  73. {commodutil-4.5.0 → commodutil-4.6.0}/tests/test_standards_commodity_groups.py +0 -0
  74. {commodutil-4.5.0 → commodutil-4.6.0}/tests/test_standards_currency.py +0 -0
  75. {commodutil-4.5.0 → commodutil-4.6.0}/tests/test_standards_price_units.py +0 -0
  76. {commodutil-4.5.0 → commodutil-4.6.0}/tests/test_standards_regions.py +0 -0
  77. {commodutil-4.5.0 → commodutil-4.6.0}/tests/test_standards_units.py +0 -0
  78. {commodutil-4.5.0 → commodutil-4.6.0}/tests/test_stats.py +0 -0
  79. {commodutil-4.5.0 → commodutil-4.6.0}/tests/test_transforms.py +0 -0
  80. {commodutil-4.5.0 → commodutil-4.6.0}/tests/test_unit_registry.py +0 -0
  81. {commodutil-4.5.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.5.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
@@ -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")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: commodutil
3
- Version: 4.5.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
@@ -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",
@@ -1,7 +1,7 @@
1
1
  {
2
- "tag": "4.5.0",
2
+ "tag": "4.6.0",
3
3
  "distance": 0,
4
- "node": "g5738049a36890f22f52027e9b93bf094f462689e",
4
+ "node": "g53c22a462207c9d8759c79cb652fec7690b4b98e",
5
5
  "dirty": false,
6
6
  "branch": "HEAD",
7
7
  "node_date": "2026-07-02"
@@ -0,0 +1,130 @@
1
+ """Phase 3.4 tests: align_fx (extracted) and convert_price_result (additive).
2
+
3
+ convert_price itself is unchanged (its behaviour is pinned by the golden fixture
4
+ and the existing tests/test_price_conv.py). These cover the new public surface:
5
+ the extracted align_fx helper and the result-bearing convert_price_result.
6
+ """
7
+
8
+ import logging
9
+
10
+ import pandas as pd
11
+ import pytest
12
+
13
+ from commodutil import convfactors
14
+ from commodutil.convfactors import ConversionResult
15
+
16
+
17
+ # ---- align_fx direct (mirrors the convert_price fx alignment tests) ----
18
+
19
+
20
+ def test_align_fx_strict_raises_on_pre_start():
21
+ fx = pd.Series([1.10, 1.11, 1.12], index=pd.date_range("2026-01-05", periods=3))
22
+ idx = pd.date_range("2026-01-01", periods=3) # entirely before fx start
23
+ with pytest.raises(ValueError, match="FX missing or stale"):
24
+ convfactors.align_fx(fx, idx, policy="strict")
25
+
26
+
27
+ def test_align_fx_strict_within_staleness_ok():
28
+ fx = pd.Series([1.07, 1.08, 1.09], index=pd.date_range("2026-01-01", periods=3))
29
+ idx = pd.date_range("2026-01-01", periods=5) # 2 days past fx end, within 7d
30
+ out = convfactors.align_fx(fx, idx, policy="strict")
31
+ assert out.isna().sum() == 0
32
+ assert out.iloc[-1] == pytest.approx(1.09) # forward-filled within staleness
33
+
34
+
35
+ def test_align_fx_ffill_warns_and_fills(caplog):
36
+ fx = pd.Series([1.10, 1.11, 1.12], index=pd.date_range("2026-01-05", periods=3))
37
+ idx = pd.date_range("2026-01-01", periods=3) # pre-start
38
+ with caplog.at_level(logging.WARNING, logger="commodutil.convfactors"):
39
+ out = convfactors.align_fx(fx, idx, policy="ffill")
40
+ assert out.isna().sum() == 0
41
+ assert any("ffill_policy='ffill'" in rec.message for rec in caplog.records)
42
+
43
+
44
+ def test_align_fx_ffill_all_nan_raises():
45
+ fx = pd.Series([float("nan")] * 3, index=pd.date_range("2026-01-01", periods=3))
46
+ idx = pd.date_range("2026-01-01", periods=3)
47
+ with pytest.raises(ValueError, match="entirely NaN"):
48
+ convfactors.align_fx(fx, idx, policy="ffill")
49
+
50
+
51
+ def test_align_fx_unknown_policy_raises():
52
+ fx = pd.Series([1.07], index=pd.date_range("2026-01-01", periods=1))
53
+ with pytest.raises(ValueError, match="Unknown ffill_policy"):
54
+ convfactors.align_fx(fx, fx.index, policy="bogus")
55
+
56
+
57
+ def test_align_fx_matches_convert_price_series_path():
58
+ # convert_price delegates to align_fx; a manual compose reproduces it.
59
+ idx = pd.date_range("2026-01-01", periods=3)
60
+ p = pd.Series([35.0, 36.5, 34.2], index=idx)
61
+ fx = pd.Series([1.07, 1.08, 1.06], index=idx)
62
+ via_cp = convfactors.convert_price(p, "EUR/MWh", "USD/MMBtu", fx=fx)
63
+ factor = convfactors.convfactor("MWh", "MMBtu")
64
+ fx_aligned = convfactors.align_fx(fx, p.index)
65
+ manual = (p / factor) * fx_aligned # EUR major, divisor 1.0
66
+ pd.testing.assert_series_equal(via_cp, manual)
67
+
68
+
69
+ # ---- convert_price_result ----
70
+
71
+ _SAMPLE = [
72
+ (100.0, "mt", "bbl", "gasoline", None),
73
+ (2.5, "gal", "bbl", None, None),
74
+ (250.0, "USc/gal", "USD/gal", None, None),
75
+ (100.0, "USD/mt", "USD/bbl", "diesel", None),
76
+ (35.0, "EUR/MWh", "USD/MMBtu", None, 1.07),
77
+ (80.0, "GBp/therm", "USD/MMBtu", None, 1.25),
78
+ ]
79
+
80
+
81
+ def test_convert_price_result_value_matches_convert_price():
82
+ for value, frm, to, commodity, fx in _SAMPLE:
83
+ res = convfactors.convert_price_result(value, frm, to, commodity, fx=fx)
84
+ expected = convfactors.convert_price(value, frm, to, commodity, fx=fx)
85
+ assert isinstance(res, ConversionResult)
86
+ assert res.value == pytest.approx(expected, rel=1e-12)
87
+ assert res.applied is True
88
+ assert res.note.startswith("convert_price:")
89
+
90
+
91
+ def test_convert_price_result_is_frozen():
92
+ res = convfactors.convert_price_result(2.5, "gal", "bbl")
93
+ with pytest.raises(Exception):
94
+ res.value = 0.0 # frozen dataclass
95
+
96
+
97
+ def test_convert_price_result_identity_unchanged():
98
+ res = convfactors.convert_price_result(100.0, "USD/bbl", "USD/bbl")
99
+ assert res.value == 100.0
100
+ assert res.applied is False
101
+ assert res.note == "unchanged"
102
+
103
+
104
+ def test_convert_price_result_currency_leg_fallback_rin():
105
+ # convert_price can't do convfactor('RIN','RIN'); the fallback applies the
106
+ # pure currency scale and records the currency_leg route.
107
+ res = convfactors.convert_price_result(250.0, "USc/RIN", "USD/RIN")
108
+ assert res.value == pytest.approx(2.5, rel=1e-12)
109
+ assert res.applied is True
110
+ assert res.note.startswith("currency_leg:")
111
+
112
+
113
+ def test_convert_price_result_keep_raw_on_unconvertible():
114
+ # Different, pint-unknown denominators: no route works -> keep-raw.
115
+ res = convfactors.convert_price_result(100.0, "GBp/XYZ", "USD/ABC", on_error="keep")
116
+ assert res.value == 100.0
117
+ assert res.applied is False
118
+ assert res.note.startswith("kept-raw:")
119
+
120
+
121
+ def test_convert_price_result_raise_is_default():
122
+ with pytest.raises(Exception):
123
+ convfactors.convert_price_result(100.0, "GBp/XYZ", "USD/ABC")
124
+
125
+
126
+ def test_convert_price_result_invalid_on_error():
127
+ with pytest.raises(ValueError, match="on_error must be"):
128
+ convfactors.convert_price_result(
129
+ 1.0, "USD/mt", "USD/bbl", "diesel", on_error="x"
130
+ )
@@ -0,0 +1,81 @@
1
+ """Phase 3.3 tests: rate conversions lean on pint (separable design).
2
+
3
+ The hand-rolled scalar rate machinery (_parse_rate_unit / _rate_factor_scalar)
4
+ is deleted. A rate conversion is now the base-unit factor (the SAME
5
+ _convert_scalar path as a non-rate conversion) times a period ratio derived from
6
+ pint's calendar lengths (_period_rate_factor), so the calendar constants are no
7
+ longer duplicated. The Series path keeps calendar-aware days_in_month. Both are
8
+ fed by the single PriceUnit parser. Numerics are pinned by the golden fixture;
9
+ these tests assert the structural change and preserved edge behaviours.
10
+ """
11
+
12
+ import inspect
13
+ import re
14
+
15
+ import pandas as pd
16
+ import pytest
17
+
18
+ from commodutil import convfactors
19
+
20
+
21
+ def test_hand_rolled_rate_machinery_deleted():
22
+ converter = convfactors.converter
23
+ assert not hasattr(converter, "_parse_rate_unit")
24
+ assert not hasattr(converter, "_rate_factor_scalar")
25
+
26
+
27
+ def test_no_hardcoded_calendar_constants_in_convfactors():
28
+ # The dedup guarantee: pint is the single source of month/year lengths, so
29
+ # the old literals must not reappear anywhere in the module.
30
+ src = inspect.getsource(convfactors)
31
+ assert "30.4375" not in src
32
+ assert not re.search(r"\b365\.25\b", src)
33
+
34
+
35
+ def test_rate_consistent_with_base_factor_times_pint_ratio():
36
+ # Rate result == base-unit factor x pint period ratio, for the golden
37
+ # commodities. Proves the separable design and that the ratio is pint's.
38
+ month_len = (1 * convfactors.ureg("month")).to("day").magnitude
39
+ for commodity in ("crude", "diesel", "gasoline", "fuel_oil"):
40
+ base = convfactors.convfactor("kt", "bbl", commodity)
41
+ rate = convfactors.convert(1.0, "kt/month", "bbl/day", commodity)
42
+ assert rate == pytest.approx(base / month_len, rel=1e-12), commodity
43
+
44
+
45
+ def test_scalar_rate_uses_pint_calendar_constants():
46
+ # pint's month=30.4375d, year=365.25d — the exact constants the deleted
47
+ # code hand-rolled.
48
+ assert convfactors.convert(1.0, "bbl/day", "bbl/month") == pytest.approx(
49
+ 30.4375, rel=1e-12
50
+ )
51
+ assert convfactors.convert(1.0, "bbl/day", "bbl/year") == pytest.approx(
52
+ 365.25, rel=1e-12
53
+ )
54
+
55
+
56
+ def test_none_commodity_rate_raises_bare_unit_message():
57
+ # Error messages are preserved character-for-character (bare units, not the
58
+ # rate form) — these are the two pinned golden rate errors.
59
+ with pytest.raises(ValueError, match=r"^Commodity required for kt to bbl$"):
60
+ convfactors.convert(1.0, "kt/month", "bbl/day")
61
+ with pytest.raises(ValueError, match=r"^Commodity required for bbl to mt$"):
62
+ convfactors.convert(1.0, "bbl/day", "mt/year")
63
+
64
+
65
+ def test_series_month_aware_uses_calendar_days():
66
+ # The Series path keeps calendar-aware days_in_month (2020 is a leap year,
67
+ # so February has 29 days) — pint's fixed-length month cannot express this.
68
+ dates = pd.date_range("2020-01-01", periods=3, freq="MS") # Jan, Feb, Mar 2020
69
+ s = pd.Series([100.0, 100.0, 100.0], index=dates) # kt/month
70
+ out = convfactors.convert(s, "kt/month", "bbl/day", "diesel")
71
+ kt_to_bbl = convfactors.convfactor("kt", "bbl", "diesel")
72
+ assert out.iloc[0] == pytest.approx(100 * kt_to_bbl / 31, abs=1) # Jan 31d
73
+ assert out.iloc[1] == pytest.approx(100 * kt_to_bbl / 29, abs=1) # Feb 29d (leap)
74
+ assert out.iloc[2] == pytest.approx(100 * kt_to_bbl / 31, abs=1) # Mar 31d
75
+
76
+
77
+ def test_series_non_month_period_matches_pint_ratio():
78
+ # A calendar-unaware index falls back to the pint period ratio (year->day).
79
+ s = pd.Series([365250.0, 365250.0]) # RangeIndex, no days_in_month
80
+ out = convfactors.convert(s, "bbl/year", "bbl/day")
81
+ assert out.iloc[0] == pytest.approx(1000.0, abs=1e-6)
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes