commodutil 4.4.0__tar.gz → 4.5.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 (79) hide show
  1. {commodutil-4.4.0 → commodutil-4.5.0}/PKG-INFO +1 -1
  2. {commodutil-4.4.0 → commodutil-4.5.0}/commodutil/convfactors.py +142 -52
  3. {commodutil-4.4.0 → commodutil-4.5.0}/commodutil/standards/__init__.py +5 -0
  4. commodutil-4.5.0/commodutil/standards/price_unit.py +163 -0
  5. commodutil-4.5.0/commodutil/standards/unit_registry.py +319 -0
  6. {commodutil-4.4.0 → commodutil-4.5.0}/commodutil/standards/units.py +20 -57
  7. {commodutil-4.4.0 → commodutil-4.5.0}/commodutil.egg-info/PKG-INFO +1 -1
  8. {commodutil-4.4.0 → commodutil-4.5.0}/commodutil.egg-info/SOURCES.txt +4 -0
  9. {commodutil-4.4.0 → commodutil-4.5.0}/commodutil.egg-info/scm_file_list.json +4 -0
  10. {commodutil-4.4.0 → commodutil-4.5.0}/commodutil.egg-info/scm_version.json +2 -2
  11. commodutil-4.5.0/tests/test_price_unit.py +232 -0
  12. {commodutil-4.4.0 → commodutil-4.5.0}/tests/test_standards_units.py +23 -3
  13. commodutil-4.5.0/tests/test_unit_registry.py +396 -0
  14. {commodutil-4.4.0 → commodutil-4.5.0}/.coveragerc +0 -0
  15. {commodutil-4.4.0 → commodutil-4.5.0}/.github/workflows/1_tests.yml +0 -0
  16. {commodutil-4.4.0 → commodutil-4.5.0}/.github/workflows/2_coverage.yml +0 -0
  17. {commodutil-4.4.0 → commodutil-4.5.0}/.github/workflows/3_linting.yml +0 -0
  18. {commodutil-4.4.0 → commodutil-4.5.0}/.github/workflows/4_release.yml +0 -0
  19. {commodutil-4.4.0 → commodutil-4.5.0}/.gitignore +0 -0
  20. {commodutil-4.4.0 → commodutil-4.5.0}/.pypirc +0 -0
  21. {commodutil-4.4.0 → commodutil-4.5.0}/azure-build-pipelines.yml +0 -0
  22. {commodutil-4.4.0 → commodutil-4.5.0}/commodutil/__init__.py +0 -0
  23. {commodutil-4.4.0 → commodutil-4.5.0}/commodutil/arb.py +0 -0
  24. {commodutil-4.4.0 → commodutil-4.5.0}/commodutil/dates.py +0 -0
  25. {commodutil-4.4.0 → commodutil-4.5.0}/commodutil/forward/__init__.py +0 -0
  26. {commodutil-4.4.0 → commodutil-4.5.0}/commodutil/forward/calendar.py +0 -0
  27. {commodutil-4.4.0 → commodutil-4.5.0}/commodutil/forward/continuous.py +0 -0
  28. {commodutil-4.4.0 → commodutil-4.5.0}/commodutil/forward/fly.py +0 -0
  29. {commodutil-4.4.0 → commodutil-4.5.0}/commodutil/forward/quarterly.py +0 -0
  30. {commodutil-4.4.0 → commodutil-4.5.0}/commodutil/forward/spreads.py +0 -0
  31. {commodutil-4.4.0 → commodutil-4.5.0}/commodutil/forward/structure.py +0 -0
  32. {commodutil-4.4.0 → commodutil-4.5.0}/commodutil/forward/util.py +0 -0
  33. {commodutil-4.4.0 → commodutil-4.5.0}/commodutil/forwards.py +0 -0
  34. {commodutil-4.4.0 → commodutil-4.5.0}/commodutil/pandasutil.py +0 -0
  35. {commodutil-4.4.0 → commodutil-4.5.0}/commodutil/standards/analysis_types.py +0 -0
  36. {commodutil-4.4.0 → commodutil-4.5.0}/commodutil/standards/commodities.py +0 -0
  37. {commodutil-4.4.0 → commodutil-4.5.0}/commodutil/standards/commodity_groups.py +0 -0
  38. {commodutil-4.4.0 → commodutil-4.5.0}/commodutil/standards/currency.py +0 -0
  39. {commodutil-4.4.0 → commodutil-4.5.0}/commodutil/standards/price_units.py +0 -0
  40. {commodutil-4.4.0 → commodutil-4.5.0}/commodutil/standards/regions.py +0 -0
  41. {commodutil-4.4.0 → commodutil-4.5.0}/commodutil/stats.py +0 -0
  42. {commodutil-4.4.0 → commodutil-4.5.0}/commodutil/transforms.py +0 -0
  43. {commodutil-4.4.0 → commodutil-4.5.0}/commodutil.egg-info/dependency_links.txt +0 -0
  44. {commodutil-4.4.0 → commodutil-4.5.0}/commodutil.egg-info/requires.txt +0 -0
  45. {commodutil-4.4.0 → commodutil-4.5.0}/commodutil.egg-info/top_level.txt +0 -0
  46. {commodutil-4.4.0 → commodutil-4.5.0}/pyproject.toml +0 -0
  47. {commodutil-4.4.0 → commodutil-4.5.0}/requirements-test.txt +0 -0
  48. {commodutil-4.4.0 → commodutil-4.5.0}/requirements.txt +0 -0
  49. {commodutil-4.4.0 → commodutil-4.5.0}/requirements_dev.txt +0 -0
  50. {commodutil-4.4.0 → commodutil-4.5.0}/setup.cfg +0 -0
  51. {commodutil-4.4.0 → commodutil-4.5.0}/tests/__init__.py +0 -0
  52. {commodutil-4.4.0 → commodutil-4.5.0}/tests/conftest.py +0 -0
  53. {commodutil-4.4.0 → commodutil-4.5.0}/tests/forward/__init__.py +0 -0
  54. {commodutil-4.4.0 → commodutil-4.5.0}/tests/forward/conftest.py +0 -0
  55. {commodutil-4.4.0 → commodutil-4.5.0}/tests/forward/test_calendar.py +0 -0
  56. {commodutil-4.4.0 → commodutil-4.5.0}/tests/forward/test_continuous.py +0 -0
  57. {commodutil-4.4.0 → commodutil-4.5.0}/tests/forward/test_fly.py +0 -0
  58. {commodutil-4.4.0 → commodutil-4.5.0}/tests/forward/test_quarterly.py +0 -0
  59. {commodutil-4.4.0 → commodutil-4.5.0}/tests/forward/test_spreads.py +0 -0
  60. {commodutil-4.4.0 → commodutil-4.5.0}/tests/forward/test_structure.py +0 -0
  61. {commodutil-4.4.0 → commodutil-4.5.0}/tests/forward/test_util.py +0 -0
  62. {commodutil-4.4.0 → commodutil-4.5.0}/tests/golden/golden_factors.json +0 -0
  63. {commodutil-4.4.0 → commodutil-4.5.0}/tests/test_arb.py +0 -0
  64. {commodutil-4.4.0 → commodutil-4.5.0}/tests/test_cl.csv +0 -0
  65. {commodutil-4.4.0 → commodutil-4.5.0}/tests/test_conv.py +0 -0
  66. {commodutil-4.4.0 → commodutil-4.5.0}/tests/test_dates.py +0 -0
  67. {commodutil-4.4.0 → commodutil-4.5.0}/tests/test_forwards.py +0 -0
  68. {commodutil-4.4.0 → commodutil-4.5.0}/tests/test_golden_factors.py +0 -0
  69. {commodutil-4.4.0 → commodutil-4.5.0}/tests/test_pandasutils.py +0 -0
  70. {commodutil-4.4.0 → commodutil-4.5.0}/tests/test_price_conv.py +0 -0
  71. {commodutil-4.4.0 → commodutil-4.5.0}/tests/test_standards_analysis_types.py +0 -0
  72. {commodutil-4.4.0 → commodutil-4.5.0}/tests/test_standards_commodities.py +0 -0
  73. {commodutil-4.4.0 → commodutil-4.5.0}/tests/test_standards_commodity_groups.py +0 -0
  74. {commodutil-4.4.0 → commodutil-4.5.0}/tests/test_standards_currency.py +0 -0
  75. {commodutil-4.4.0 → commodutil-4.5.0}/tests/test_standards_price_units.py +0 -0
  76. {commodutil-4.4.0 → commodutil-4.5.0}/tests/test_standards_regions.py +0 -0
  77. {commodutil-4.4.0 → commodutil-4.5.0}/tests/test_stats.py +0 -0
  78. {commodutil-4.4.0 → commodutil-4.5.0}/tests/test_transforms.py +0 -0
  79. {commodutil-4.4.0 → commodutil-4.5.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.5.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,6 +287,16 @@ class CommodityConverter:
314
287
  # With pandas Series and daily rates
315
288
  convert(series, 'kt/month', 'bbl/day', commodity='gasoline')
316
289
  """
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
+
317
300
  # Normalize and parse units to handle daily/monthly rates
318
301
  from_unit = _to_pint_token(from_unit)
319
302
  to_unit = _to_pint_token(to_unit)
@@ -605,13 +588,22 @@ converter = CommodityConverter()
605
588
 
606
589
 
607
590
  # Convenience functions for direct use
608
- def convert(value, from_unit: str, to_unit: str, commodity: Optional[str] = None):
609
- """Convert values between units"""
591
+ def convert(
592
+ value,
593
+ from_unit: Union[str, PriceUnit],
594
+ to_unit: Union[str, PriceUnit],
595
+ commodity: Optional[str] = None,
596
+ ):
597
+ """Convert values between units. Units may be strings or ``PriceUnit``."""
610
598
  return converter.convert(value, from_unit, to_unit, commodity)
611
599
 
612
600
 
613
- def convfactor(from_unit: str, to_unit: str, commodity: Optional[str] = None) -> float:
614
- """Get conversion factor between units"""
601
+ def convfactor(
602
+ from_unit: Union[str, PriceUnit],
603
+ to_unit: Union[str, PriceUnit],
604
+ commodity: Optional[str] = None,
605
+ ) -> float:
606
+ """Get conversion factor between units. Units may be strings or ``PriceUnit``."""
615
607
  return converter.convert(1.0, from_unit, to_unit, commodity)
616
608
 
617
609
 
@@ -626,8 +618,8 @@ def convfactor(from_unit: str, to_unit: str, commodity: Optional[str] = None) ->
626
618
 
627
619
  def convert_price(
628
620
  value: Union[float, pd.Series],
629
- from_unit: str,
630
- to_unit: str,
621
+ from_unit: Union[str, PriceUnit],
622
+ to_unit: Union[str, PriceUnit],
631
623
  commodity: Optional[str] = None,
632
624
  fx: Union[float, pd.Series, None] = None,
633
625
  ffill_policy: str = "strict",
@@ -682,8 +674,20 @@ def convert_price(
682
674
  fx_series = pd.Series([1.07, 1.08, 1.06], index=p.index)
683
675
  convert_price(p, 'EUR/MWh', 'USD/MMBtu', fx=fx_series)
684
676
  """
685
- from_ccy, from_bare_unit = _currency.split_currency_unit(from_unit)
686
- to_ccy, to_bare_unit = _currency.split_currency_unit(to_unit)
677
+ # Parse each side once at the boundary into a PriceUnit and read its legs,
678
+ # instead of re-splitting raw strings. PriceUnit.parse is built on
679
+ # split_currency_unit, so (currency, quantity_leg) is byte-identical to the
680
+ # previous split_currency_unit(...) result — behaviour and error strings are
681
+ # unchanged. from_unit/to_unit are re-bound to the canonical string form for
682
+ # use in the error messages / examples below.
683
+ from_pu = (
684
+ from_unit if isinstance(from_unit, PriceUnit) else PriceUnit.parse(from_unit)
685
+ )
686
+ to_pu = to_unit if isinstance(to_unit, PriceUnit) else PriceUnit.parse(to_unit)
687
+ from_unit = str(from_pu)
688
+ to_unit = str(to_pu)
689
+ from_ccy, from_bare_unit = (from_pu.currency or ""), from_pu.quantity_leg()
690
+ to_ccy, to_bare_unit = (to_pu.currency or ""), to_pu.quantity_leg()
687
691
 
688
692
  # Resolve the underlying "major" currency on each side for same-base detection
689
693
  # (e.g. USc and USD share major USD — pure scale, no FX needed).
@@ -801,6 +805,92 @@ def convert_price(
801
805
  return unit_converted * fx / fractional_divisor
802
806
 
803
807
 
808
+ def convert_currency_leg(
809
+ value: Union[float, pd.Series],
810
+ from_unit: Union[str, PriceUnit],
811
+ to_unit: Union[str, PriceUnit],
812
+ fx: Union[float, pd.Series, None] = None,
813
+ ) -> Union[float, pd.Series]:
814
+ """Convert ONLY the currency leg of a price whose quantity denominator is
815
+ unchanged.
816
+
817
+ This is the separable-currency-leg helper from the conversion architecture
818
+ plan (Phase 3.1). Unlike :func:`convert_price`, it performs NO physical-unit
819
+ conversion and never calls :func:`convfactor`, so it works when the quantity
820
+ denominator is non-physical or unknown to pint — e.g. ``'USc/RIN' ->
821
+ 'USD/RIN'`` (a pure ``/100`` scale). ``convert_price`` cannot do that today
822
+ because it tries to compute ``convfactor('RIN', 'RIN')`` and raises on the
823
+ unknown unit.
824
+
825
+ It reimplements, as a first-class API, the broad-except ``/100.0`` currency
826
+ fallback shim in ``oilrisk``'s ``artis.py`` (lines ~75-98): scale a
827
+ fractional-currency quote (USc, GBp, ...) to its major unit without needing a
828
+ commodity or a physical factor.
829
+
830
+ Rules:
831
+ * The two quantity denominators must be **string-equal** after parsing
832
+ (``from`` and ``to`` refer to the same thing priced per identical unit);
833
+ otherwise ``ValueError``. No physical-unit validation is performed.
834
+ * Same-base fractional (``USc``->``USD``, ``GBp``->``GBP``): pure divisor
835
+ scale, no ``fx`` needed.
836
+ * No source currency, or source already the USD major: no-op (returns
837
+ ``value`` unchanged).
838
+ * Cross major-currency (e.g. ``EUR``->``USD``): ``fx`` is required (quoted
839
+ target-per-source) or ``ValueError`` is raised. ``fx`` is applied
840
+ element-wise; index-aligned FX Series are the caller's responsibility
841
+ (use :func:`convert_price` for the strict/ffill staleness machinery).
842
+ * Target currency is restricted to USD (matching ``convert_price``),
843
+ except pure same-base fractional scaling like ``GBp``->``GBP``.
844
+
845
+ Examples:
846
+ convert_currency_leg(250.0, 'USc/RIN', 'USD/RIN') # -> 2.5 (/100)
847
+ convert_currency_leg(50.0, 'GBp/therm', 'GBP/therm') # -> 0.5
848
+ convert_currency_leg(10.0, 'EUR/RIN', 'USD/RIN', fx=1.07) # -> 10.7
849
+ """
850
+ from_pu = (
851
+ from_unit if isinstance(from_unit, PriceUnit) else PriceUnit.parse(from_unit)
852
+ )
853
+ to_pu = to_unit if isinstance(to_unit, PriceUnit) else PriceUnit.parse(to_unit)
854
+
855
+ # Quantity denominators must match exactly — string equality only, so a
856
+ # non-physical denominator like 'RIN' is fine (no pint lookup).
857
+ if from_pu.quantity_leg() != to_pu.quantity_leg():
858
+ raise ValueError(
859
+ f"convert_currency_leg requires identical quantity denominators; "
860
+ f"got '{from_pu.quantity_leg()}' vs '{to_pu.quantity_leg()}'"
861
+ )
862
+
863
+ from_ccy = from_pu.currency or ""
864
+ to_ccy = to_pu.currency or ""
865
+ from_major = from_pu.major_currency or ""
866
+ to_major = to_pu.major_currency or ""
867
+
868
+ same_base_fractional = bool(from_ccy and to_ccy and from_major == to_major)
869
+
870
+ # USD-only currency target, except pure same-base fractional scaling.
871
+ if to_ccy and to_major != "USD" and not same_base_fractional:
872
+ raise ValueError(
873
+ f"convert_currency_leg currently only supports USD/* as target; "
874
+ f"got '{to_pu}'"
875
+ )
876
+
877
+ # Same-base fractional (USc->USD, GBp->GBP, ...): pure divisor scale.
878
+ if same_base_fractional:
879
+ return value * (to_pu.fractional_divisor / from_pu.fractional_divisor)
880
+
881
+ # No source currency, or already USD major: currency leg is a no-op.
882
+ if not from_ccy or from_major == "USD":
883
+ return value
884
+
885
+ # Cross major-currency: an FX leg is required.
886
+ if fx is None:
887
+ raise ValueError(
888
+ f"FX rate required to convert {from_pu} -> {to_pu} "
889
+ f"(source currency '{from_ccy}' is non-USD)"
890
+ )
891
+ return value * fx / from_pu.fractional_divisor
892
+
893
+
804
894
  def list_commodities():
805
895
  """List all available commodities"""
806
896
  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",
@@ -0,0 +1,163 @@
1
+ """commodutil.standards.price_unit: the ``PriceUnit`` value type.
2
+
3
+ A ``PriceUnit`` is the structured form of a price-unit string such as
4
+ ``'USD/bbl'``, ``'GBp/therm'``, ``'kt/month'`` or a bare ``'bbl'``. It splits a
5
+ quote into three independently-usable legs:
6
+
7
+ * ``currency`` — canonical currency token per ``standards.currency``
8
+ (``'USD'``, ``'GBp'``, ``'$'``, ...), or ``None`` for a
9
+ bare / rate-only unit.
10
+ * ``quantity_unit`` — the bare physical unit token (``'bbl'``, ``'MWh'`` ...).
11
+ * ``period`` — the rate period for forms like ``'bbl/day'`` (``'day'`` /
12
+ ``'month'`` / ``'year'``), or ``None``.
13
+
14
+ Design goals (conversion-architecture-plan.md, Phase 3.1):
15
+
16
+ * ``PriceUnit.parse(str(pu)) == pu`` is guaranteed for any ``PriceUnit`` — the
17
+ string form is canonical and losslessly round-trips.
18
+ * ``parse`` is built directly on ``standards.currency.split_currency_unit`` plus
19
+ the same rate-suffix logic ``convfactors`` uses, so the legs it produces are
20
+ byte-for-byte what those callers computed before — letting ``convert`` /
21
+ ``convert_price`` adopt it at the boundary with zero behaviour change.
22
+ * The **currency leg is usable on its own** (``major_currency``,
23
+ ``fractional_divisor``), which is what lets a currency-only conversion (USc ->
24
+ USD) succeed even when the quantity denominator is non-physical/unknown (the
25
+ RIN class of bug — see ``convfactors.convert_currency_leg``).
26
+
27
+ Pure stdlib: depends only on ``standards.currency`` (itself pure stdlib). No
28
+ pint, no pandas.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ from dataclasses import dataclass
34
+ from typing import Optional, Union
35
+
36
+ from commodutil.standards.currency import (
37
+ FRACTIONAL_CURRENCY_DIVISORS,
38
+ fractional_to_major,
39
+ split_currency_unit,
40
+ )
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class PriceUnit:
45
+ """Immutable, structured representation of a price-unit string.
46
+
47
+ See module docstring. Construct directly with canonical legs or, more
48
+ commonly, via :meth:`parse`. Directly-constructed instances are normalised
49
+ (whitespace stripped; period lower-cased and de-pluralised; empty strings
50
+ coerced to ``None``) so equality and the round-trip guarantee hold
51
+ regardless of how the instance was built.
52
+ """
53
+
54
+ currency: Optional[str] = None
55
+ quantity_unit: str = ""
56
+ period: Optional[str] = None
57
+
58
+ def __post_init__(self) -> None:
59
+ # frozen dataclass -> mutate via object.__setattr__ during init only.
60
+ object.__setattr__(self, "quantity_unit", str(self.quantity_unit).strip())
61
+ if self.currency is not None:
62
+ currency = str(self.currency).strip()
63
+ object.__setattr__(self, "currency", currency or None)
64
+ if self.period is not None:
65
+ # Mirror convfactors._parse_rate_unit: day(s)/month(s)/year(s).
66
+ period = str(self.period).strip().lower().rstrip("s")
67
+ object.__setattr__(self, "period", period or None)
68
+
69
+ # ---- construction -----------------------------------------------------
70
+
71
+ @classmethod
72
+ def parse(cls, text: Union["PriceUnit", str]) -> "PriceUnit":
73
+ """Parse a price-unit string into a ``PriceUnit``.
74
+
75
+ Handles every form the codebase round-trips today:
76
+ * bare unit: ``'bbl'``, ``'MMBtu'``, ``'m^3'``
77
+ * rate unit: ``'bbl/day'``, ``'kt/month'`` (currency=None)
78
+ * currency-qualified: ``'USD/bbl'``, ``'GBp/therm'``, ``'$/bbl'``
79
+ * currency + rate: ``'USD/bbl/day'`` (accepted for completeness;
80
+ not currently emitted anywhere in the codebase)
81
+
82
+ A currency prefix is recognised only when it is a valid token per
83
+ ``standards.currency`` (via ``split_currency_unit``), so a bare rate
84
+ unit like ``'bbl/day'`` is NOT mis-parsed as currency ``'bbl'`` — it
85
+ stays quantity ``'bbl'`` / period ``'day'``.
86
+
87
+ Passing an existing ``PriceUnit`` returns it unchanged.
88
+ """
89
+ if isinstance(text, cls):
90
+ return text
91
+ if text is None:
92
+ raise ValueError("PriceUnit.parse requires a non-None unit string")
93
+ raw = str(text).strip()
94
+ if not raw:
95
+ raise ValueError("PriceUnit.parse requires a non-empty unit string")
96
+
97
+ # split_currency_unit returns ('', raw) when the prefix is not a valid
98
+ # currency token, leaving bare rate units intact.
99
+ currency, bare = split_currency_unit(raw)
100
+ quantity_unit, period = cls._split_period(bare)
101
+ return cls(
102
+ currency=currency or None, quantity_unit=quantity_unit, period=period
103
+ )
104
+
105
+ @staticmethod
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."""
109
+ if "/" in bare:
110
+ base, _, period = bare.partition("/")
111
+ period = period.strip().lower().rstrip("s")
112
+ return base.strip(), (period or None)
113
+ return bare.strip(), None
114
+
115
+ # ---- string form ------------------------------------------------------
116
+
117
+ def __str__(self) -> str:
118
+ denom = self.quantity_unit
119
+ if self.period:
120
+ denom = f"{denom}/{self.period}"
121
+ if self.currency:
122
+ return f"{self.currency}/{denom}"
123
+ return denom
124
+
125
+ # ---- leg accessors ----------------------------------------------------
126
+
127
+ def currency_leg(self) -> Optional[str]:
128
+ """The currency token, or ``None`` for a bare/rate-only unit."""
129
+ return self.currency
130
+
131
+ def quantity_leg(self) -> str:
132
+ """The full quantity denominator string (unit plus any rate period).
133
+
134
+ Byte-identical to the bare-unit element returned by
135
+ ``standards.currency.split_currency_unit`` — e.g. ``'bbl'`` for
136
+ ``'USD/bbl'``, ``'bbl/day'`` for ``'bbl/day'``.
137
+ """
138
+ denom = self.quantity_unit
139
+ if self.period:
140
+ denom = f"{denom}/{self.period}"
141
+ return denom
142
+
143
+ @property
144
+ def is_currency_qualified(self) -> bool:
145
+ """True when a currency leg is present."""
146
+ return self.currency is not None
147
+
148
+ @property
149
+ def major_currency(self) -> Optional[str]:
150
+ """The parent major currency (USc -> USD, GBp -> GBP, $ -> USD), or
151
+ ``None`` when there is no currency leg."""
152
+ if not self.currency:
153
+ return None
154
+ return fractional_to_major(self.currency)
155
+
156
+ @property
157
+ def fractional_divisor(self) -> float:
158
+ """Divisor lifting a fractional-currency quote to its major unit
159
+ (GBp/USc/... -> 100.0); 1.0 for major or absent currencies."""
160
+ return FRACTIONAL_CURRENCY_DIVISORS.get(self.currency, 1.0)
161
+
162
+
163
+ __all__ = ["PriceUnit"]