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.
- {commodutil-4.4.0 → commodutil-4.5.0}/PKG-INFO +1 -1
- {commodutil-4.4.0 → commodutil-4.5.0}/commodutil/convfactors.py +142 -52
- {commodutil-4.4.0 → commodutil-4.5.0}/commodutil/standards/__init__.py +5 -0
- commodutil-4.5.0/commodutil/standards/price_unit.py +163 -0
- commodutil-4.5.0/commodutil/standards/unit_registry.py +319 -0
- {commodutil-4.4.0 → commodutil-4.5.0}/commodutil/standards/units.py +20 -57
- {commodutil-4.4.0 → commodutil-4.5.0}/commodutil.egg-info/PKG-INFO +1 -1
- {commodutil-4.4.0 → commodutil-4.5.0}/commodutil.egg-info/SOURCES.txt +4 -0
- {commodutil-4.4.0 → commodutil-4.5.0}/commodutil.egg-info/scm_file_list.json +4 -0
- {commodutil-4.4.0 → commodutil-4.5.0}/commodutil.egg-info/scm_version.json +2 -2
- commodutil-4.5.0/tests/test_price_unit.py +232 -0
- {commodutil-4.4.0 → commodutil-4.5.0}/tests/test_standards_units.py +23 -3
- commodutil-4.5.0/tests/test_unit_registry.py +396 -0
- {commodutil-4.4.0 → commodutil-4.5.0}/.coveragerc +0 -0
- {commodutil-4.4.0 → commodutil-4.5.0}/.github/workflows/1_tests.yml +0 -0
- {commodutil-4.4.0 → commodutil-4.5.0}/.github/workflows/2_coverage.yml +0 -0
- {commodutil-4.4.0 → commodutil-4.5.0}/.github/workflows/3_linting.yml +0 -0
- {commodutil-4.4.0 → commodutil-4.5.0}/.github/workflows/4_release.yml +0 -0
- {commodutil-4.4.0 → commodutil-4.5.0}/.gitignore +0 -0
- {commodutil-4.4.0 → commodutil-4.5.0}/.pypirc +0 -0
- {commodutil-4.4.0 → commodutil-4.5.0}/azure-build-pipelines.yml +0 -0
- {commodutil-4.4.0 → commodutil-4.5.0}/commodutil/__init__.py +0 -0
- {commodutil-4.4.0 → commodutil-4.5.0}/commodutil/arb.py +0 -0
- {commodutil-4.4.0 → commodutil-4.5.0}/commodutil/dates.py +0 -0
- {commodutil-4.4.0 → commodutil-4.5.0}/commodutil/forward/__init__.py +0 -0
- {commodutil-4.4.0 → commodutil-4.5.0}/commodutil/forward/calendar.py +0 -0
- {commodutil-4.4.0 → commodutil-4.5.0}/commodutil/forward/continuous.py +0 -0
- {commodutil-4.4.0 → commodutil-4.5.0}/commodutil/forward/fly.py +0 -0
- {commodutil-4.4.0 → commodutil-4.5.0}/commodutil/forward/quarterly.py +0 -0
- {commodutil-4.4.0 → commodutil-4.5.0}/commodutil/forward/spreads.py +0 -0
- {commodutil-4.4.0 → commodutil-4.5.0}/commodutil/forward/structure.py +0 -0
- {commodutil-4.4.0 → commodutil-4.5.0}/commodutil/forward/util.py +0 -0
- {commodutil-4.4.0 → commodutil-4.5.0}/commodutil/forwards.py +0 -0
- {commodutil-4.4.0 → commodutil-4.5.0}/commodutil/pandasutil.py +0 -0
- {commodutil-4.4.0 → commodutil-4.5.0}/commodutil/standards/analysis_types.py +0 -0
- {commodutil-4.4.0 → commodutil-4.5.0}/commodutil/standards/commodities.py +0 -0
- {commodutil-4.4.0 → commodutil-4.5.0}/commodutil/standards/commodity_groups.py +0 -0
- {commodutil-4.4.0 → commodutil-4.5.0}/commodutil/standards/currency.py +0 -0
- {commodutil-4.4.0 → commodutil-4.5.0}/commodutil/standards/price_units.py +0 -0
- {commodutil-4.4.0 → commodutil-4.5.0}/commodutil/standards/regions.py +0 -0
- {commodutil-4.4.0 → commodutil-4.5.0}/commodutil/stats.py +0 -0
- {commodutil-4.4.0 → commodutil-4.5.0}/commodutil/transforms.py +0 -0
- {commodutil-4.4.0 → commodutil-4.5.0}/commodutil.egg-info/dependency_links.txt +0 -0
- {commodutil-4.4.0 → commodutil-4.5.0}/commodutil.egg-info/requires.txt +0 -0
- {commodutil-4.4.0 → commodutil-4.5.0}/commodutil.egg-info/top_level.txt +0 -0
- {commodutil-4.4.0 → commodutil-4.5.0}/pyproject.toml +0 -0
- {commodutil-4.4.0 → commodutil-4.5.0}/requirements-test.txt +0 -0
- {commodutil-4.4.0 → commodutil-4.5.0}/requirements.txt +0 -0
- {commodutil-4.4.0 → commodutil-4.5.0}/requirements_dev.txt +0 -0
- {commodutil-4.4.0 → commodutil-4.5.0}/setup.cfg +0 -0
- {commodutil-4.4.0 → commodutil-4.5.0}/tests/__init__.py +0 -0
- {commodutil-4.4.0 → commodutil-4.5.0}/tests/conftest.py +0 -0
- {commodutil-4.4.0 → commodutil-4.5.0}/tests/forward/__init__.py +0 -0
- {commodutil-4.4.0 → commodutil-4.5.0}/tests/forward/conftest.py +0 -0
- {commodutil-4.4.0 → commodutil-4.5.0}/tests/forward/test_calendar.py +0 -0
- {commodutil-4.4.0 → commodutil-4.5.0}/tests/forward/test_continuous.py +0 -0
- {commodutil-4.4.0 → commodutil-4.5.0}/tests/forward/test_fly.py +0 -0
- {commodutil-4.4.0 → commodutil-4.5.0}/tests/forward/test_quarterly.py +0 -0
- {commodutil-4.4.0 → commodutil-4.5.0}/tests/forward/test_spreads.py +0 -0
- {commodutil-4.4.0 → commodutil-4.5.0}/tests/forward/test_structure.py +0 -0
- {commodutil-4.4.0 → commodutil-4.5.0}/tests/forward/test_util.py +0 -0
- {commodutil-4.4.0 → commodutil-4.5.0}/tests/golden/golden_factors.json +0 -0
- {commodutil-4.4.0 → commodutil-4.5.0}/tests/test_arb.py +0 -0
- {commodutil-4.4.0 → commodutil-4.5.0}/tests/test_cl.csv +0 -0
- {commodutil-4.4.0 → commodutil-4.5.0}/tests/test_conv.py +0 -0
- {commodutil-4.4.0 → commodutil-4.5.0}/tests/test_dates.py +0 -0
- {commodutil-4.4.0 → commodutil-4.5.0}/tests/test_forwards.py +0 -0
- {commodutil-4.4.0 → commodutil-4.5.0}/tests/test_golden_factors.py +0 -0
- {commodutil-4.4.0 → commodutil-4.5.0}/tests/test_pandasutils.py +0 -0
- {commodutil-4.4.0 → commodutil-4.5.0}/tests/test_price_conv.py +0 -0
- {commodutil-4.4.0 → commodutil-4.5.0}/tests/test_standards_analysis_types.py +0 -0
- {commodutil-4.4.0 → commodutil-4.5.0}/tests/test_standards_commodities.py +0 -0
- {commodutil-4.4.0 → commodutil-4.5.0}/tests/test_standards_commodity_groups.py +0 -0
- {commodutil-4.4.0 → commodutil-4.5.0}/tests/test_standards_currency.py +0 -0
- {commodutil-4.4.0 → commodutil-4.5.0}/tests/test_standards_price_units.py +0 -0
- {commodutil-4.4.0 → commodutil-4.5.0}/tests/test_standards_regions.py +0 -0
- {commodutil-4.4.0 → commodutil-4.5.0}/tests/test_stats.py +0 -0
- {commodutil-4.4.0 → commodutil-4.5.0}/tests/test_transforms.py +0 -0
- {commodutil-4.4.0 → commodutil-4.5.0}/tests/test_weekly.csv +0 -0
|
@@ -12,6 +12,8 @@ import pandas as pd
|
|
|
12
12
|
from functools import lru_cache
|
|
13
13
|
|
|
14
14
|
from commodutil.standards import currency as _currency
|
|
15
|
+
from commodutil.standards.price_unit import PriceUnit
|
|
16
|
+
from commodutil.standards.unit_registry import PINT_DEFINITIONS as _PINT_DEFINITIONS
|
|
15
17
|
from commodutil.standards.units import to_pint_token as _to_pint_token
|
|
16
18
|
|
|
17
19
|
logger = logging.getLogger(__name__)
|
|
@@ -31,48 +33,19 @@ ureg = pint.UnitRegistry()
|
|
|
31
33
|
|
|
32
34
|
# Define oil & gas specific units.
|
|
33
35
|
#
|
|
34
|
-
#
|
|
35
|
-
#
|
|
36
|
-
#
|
|
37
|
-
#
|
|
38
|
-
#
|
|
39
|
-
#
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
ureg.define(
|
|
47
|
-
ureg.define("billion_cubic_meter = 1e9 meter**3 = bcm = BCM")
|
|
48
|
-
ureg.define("billion_cubic_foot = 1e9 foot**3 = bcf = BCF")
|
|
49
|
-
ureg.define("tonne_of_oil_equivalent = 41.868e9 joule = toe = TOE")
|
|
50
|
-
ureg.define("million_tonne_of_oil_equivalent = 1e6 tonne_of_oil_equivalent = Mtoe")
|
|
51
|
-
ureg.define("barrel_of_oil_equivalent = 6.119e9 joule = boe = BOE")
|
|
52
|
-
ureg.define("million_barrel_of_oil_equivalent = 1e6 barrel_of_oil_equivalent = Mboe")
|
|
53
|
-
ureg.define("megatonne = 1e6 metric_ton = Mt")
|
|
54
|
-
# Pint defines `Btu` (= 1055.056 J) and `therm` (= 1e5 Btu) but NOT `MMBtu`.
|
|
55
|
-
# The natgas / NGL pages quote in $/MMBtu, so register it explicitly (and the
|
|
56
|
-
# normalize_unit helper already maps 'MMBTU' -> 'MMBtu').
|
|
57
|
-
ureg.define("million_british_thermal_unit = 1e6 Btu = MMBtu")
|
|
58
|
-
|
|
59
|
-
# Case-insensitive aliases for the most-quoted energy / power units.
|
|
60
|
-
# Pint is case-sensitive by default, so `ureg('mmbtu')` or `ureg('mw')` raise
|
|
61
|
-
# UndefinedUnitError even though `MMBtu` / `MW` work. Callers (Power Query, ad-hoc
|
|
62
|
-
# scripts, copy-pasted Platts symbols) very commonly use the lowercase / all-caps
|
|
63
|
-
# / underscored forms, so register them explicitly here.
|
|
64
|
-
#
|
|
65
|
-
# Energy: MMBtu + therm + Btu spellings (MMBtu is defined above; therm + Btu are
|
|
66
|
-
# in pint's defaults). `million_btu` is the long-form alias.
|
|
67
|
-
ureg.define("@alias million_british_thermal_unit = mmbtu = MMBTU = million_btu")
|
|
68
|
-
ureg.define("@alias therm = Therm = THERM")
|
|
69
|
-
ureg.define("@alias british_thermal_unit = btu")
|
|
70
|
-
# Power / energy: MW and MWh are SI-prefix forms of `watt` / `watt_hour`, so they
|
|
71
|
-
# cannot be aliased via `@alias`. Register lowercase / all-caps spellings as
|
|
72
|
-
# standalone units with the same magnitude.
|
|
73
|
-
ureg.define("mw = 1e6 watt")
|
|
74
|
-
ureg.define("mwh = 1e6 watt * hour")
|
|
75
|
-
ureg.define("MWH = 1e6 watt * hour")
|
|
36
|
+
# The exact pint definitions (and their required ordering) live as the
|
|
37
|
+
# `pint_specs` of each row in commodutil.standards.unit_registry; they are
|
|
38
|
+
# applied here in registry order. This includes the load-bearing re-routes and
|
|
39
|
+
# case spellings that keep this registry correct:
|
|
40
|
+
# * `oil_barrel` (158.987294928 L = 42 US gal) with `bbl` routed to it —
|
|
41
|
+
# pint's default `barrel` is the US dry barrel (~119.24 L), left untouched.
|
|
42
|
+
# * `metric_ton = mt` — pint's `mt` would otherwise be milli-tonne.
|
|
43
|
+
# * MMBtu, plus case aliases (mmbtu/MMBTU, therm/THERM, btu) and lowercase /
|
|
44
|
+
# all-caps power spellings (mw, mwh, MWH) — the registry stays CASE-SENSITIVE
|
|
45
|
+
# (kt/kilotesla etc. collide non-deterministically under case-folding).
|
|
46
|
+
# To add or change a unit definition, edit its UnitRow in the registry.
|
|
47
|
+
for _definition in _PINT_DEFINITIONS:
|
|
48
|
+
ureg.define(_definition)
|
|
76
49
|
|
|
77
50
|
|
|
78
51
|
@dataclass
|
|
@@ -294,8 +267,8 @@ class CommodityConverter:
|
|
|
294
267
|
def convert(
|
|
295
268
|
self,
|
|
296
269
|
value: Union[float, pd.Series],
|
|
297
|
-
from_unit: str,
|
|
298
|
-
to_unit: str,
|
|
270
|
+
from_unit: Union[str, PriceUnit],
|
|
271
|
+
to_unit: Union[str, PriceUnit],
|
|
299
272
|
commodity: Optional[str] = None,
|
|
300
273
|
) -> Union[float, pd.Series]:
|
|
301
274
|
"""
|
|
@@ -314,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(
|
|
609
|
-
|
|
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(
|
|
614
|
-
|
|
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
|
-
|
|
686
|
-
|
|
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"]
|