commodutil 3.8.1__tar.gz → 3.9.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-3.8.1 → commodutil-3.9.0}/PKG-INFO +1 -1
- commodutil-3.9.0/commodutil/__init__.py +84 -0
- {commodutil-3.8.1 → commodutil-3.9.0}/commodutil/convfactors.py +196 -112
- commodutil-3.9.0/commodutil/standards/__init__.py +1 -0
- commodutil-3.9.0/commodutil/standards/currency.py +222 -0
- commodutil-3.9.0/commodutil/standards/regions.py +98 -0
- commodutil-3.9.0/commodutil/standards/units.py +30 -0
- {commodutil-3.8.1 → commodutil-3.9.0}/commodutil.egg-info/PKG-INFO +1 -1
- {commodutil-3.8.1 → commodutil-3.9.0}/commodutil.egg-info/SOURCES.txt +7 -0
- commodutil-3.9.0/tests/test_price_conv.py +345 -0
- commodutil-3.9.0/tests/test_standards_currency.py +132 -0
- commodutil-3.9.0/tests/test_standards_regions.py +152 -0
- commodutil-3.9.0/tests/test_standards_units.py +31 -0
- commodutil-3.8.1/tests/forward/__init__.py +0 -0
- commodutil-3.8.1/tests/test_price_conv.py +0 -106
- {commodutil-3.8.1 → commodutil-3.9.0}/.coveragerc +0 -0
- {commodutil-3.8.1 → commodutil-3.9.0}/.github/workflows/1_tests.yml +0 -0
- {commodutil-3.8.1 → commodutil-3.9.0}/.github/workflows/2_coverage.yml +0 -0
- {commodutil-3.8.1 → commodutil-3.9.0}/.github/workflows/3_linting.yml +0 -0
- {commodutil-3.8.1 → commodutil-3.9.0}/.github/workflows/4_release.yml +0 -0
- {commodutil-3.8.1 → commodutil-3.9.0}/.gitignore +0 -0
- {commodutil-3.8.1 → commodutil-3.9.0}/.pypirc +0 -0
- {commodutil-3.8.1 → commodutil-3.9.0}/azure-build-pipelines.yml +0 -0
- {commodutil-3.8.1 → commodutil-3.9.0}/commodutil/arb.py +0 -0
- {commodutil-3.8.1 → commodutil-3.9.0}/commodutil/dates.py +0 -0
- {commodutil-3.8.1/commodutil → commodutil-3.9.0/commodutil/forward}/__init__.py +0 -0
- {commodutil-3.8.1 → commodutil-3.9.0}/commodutil/forward/calendar.py +0 -0
- {commodutil-3.8.1 → commodutil-3.9.0}/commodutil/forward/continuous.py +0 -0
- {commodutil-3.8.1 → commodutil-3.9.0}/commodutil/forward/fly.py +0 -0
- {commodutil-3.8.1 → commodutil-3.9.0}/commodutil/forward/quarterly.py +0 -0
- {commodutil-3.8.1 → commodutil-3.9.0}/commodutil/forward/spreads.py +0 -0
- {commodutil-3.8.1 → commodutil-3.9.0}/commodutil/forward/structure.py +0 -0
- {commodutil-3.8.1 → commodutil-3.9.0}/commodutil/forward/util.py +0 -0
- {commodutil-3.8.1 → commodutil-3.9.0}/commodutil/forwards.py +0 -0
- {commodutil-3.8.1 → commodutil-3.9.0}/commodutil/pandasutil.py +0 -0
- {commodutil-3.8.1 → commodutil-3.9.0}/commodutil/stats.py +0 -0
- {commodutil-3.8.1 → commodutil-3.9.0}/commodutil/transforms.py +0 -0
- {commodutil-3.8.1 → commodutil-3.9.0}/commodutil.egg-info/dependency_links.txt +0 -0
- {commodutil-3.8.1 → commodutil-3.9.0}/commodutil.egg-info/requires.txt +0 -0
- {commodutil-3.8.1 → commodutil-3.9.0}/commodutil.egg-info/top_level.txt +0 -0
- {commodutil-3.8.1 → commodutil-3.9.0}/pyproject.toml +0 -0
- {commodutil-3.8.1 → commodutil-3.9.0}/requirements-test.txt +0 -0
- {commodutil-3.8.1 → commodutil-3.9.0}/requirements.txt +0 -0
- {commodutil-3.8.1 → commodutil-3.9.0}/requirements_dev.txt +0 -0
- {commodutil-3.8.1 → commodutil-3.9.0}/scripts/rbw_structure_scan.py +0 -0
- {commodutil-3.8.1 → commodutil-3.9.0}/setup.cfg +0 -0
- {commodutil-3.8.1/commodutil/forward → commodutil-3.9.0/tests}/__init__.py +0 -0
- {commodutil-3.8.1 → commodutil-3.9.0}/tests/conftest.py +0 -0
- {commodutil-3.8.1/tests → commodutil-3.9.0/tests/forward}/__init__.py +0 -0
- {commodutil-3.8.1 → commodutil-3.9.0}/tests/forward/conftest.py +0 -0
- {commodutil-3.8.1 → commodutil-3.9.0}/tests/forward/test_calendar.py +0 -0
- {commodutil-3.8.1 → commodutil-3.9.0}/tests/forward/test_continuous.py +0 -0
- {commodutil-3.8.1 → commodutil-3.9.0}/tests/forward/test_fly.py +0 -0
- {commodutil-3.8.1 → commodutil-3.9.0}/tests/forward/test_quarterly.py +0 -0
- {commodutil-3.8.1 → commodutil-3.9.0}/tests/forward/test_spreads.py +0 -0
- {commodutil-3.8.1 → commodutil-3.9.0}/tests/forward/test_structure.py +0 -0
- {commodutil-3.8.1 → commodutil-3.9.0}/tests/forward/test_util.py +0 -0
- {commodutil-3.8.1 → commodutil-3.9.0}/tests/test_arb.py +0 -0
- {commodutil-3.8.1 → commodutil-3.9.0}/tests/test_cl.csv +0 -0
- {commodutil-3.8.1 → commodutil-3.9.0}/tests/test_conv.py +0 -0
- {commodutil-3.8.1 → commodutil-3.9.0}/tests/test_dates.py +0 -0
- {commodutil-3.8.1 → commodutil-3.9.0}/tests/test_forwards.py +0 -0
- {commodutil-3.8.1 → commodutil-3.9.0}/tests/test_pandasutils.py +0 -0
- {commodutil-3.8.1 → commodutil-3.9.0}/tests/test_stats.py +0 -0
- {commodutil-3.8.1 → commodutil-3.9.0}/tests/test_transforms.py +0 -0
- {commodutil-3.8.1 → commodutil-3.9.0}/tests/test_weekly.csv +0 -0
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""commodutil: Commodity market standards backbone.
|
|
2
|
+
|
|
3
|
+
Public API for unit conversions, forward curve transforms, date utilities,
|
|
4
|
+
and pandas helpers. Re-exports the most-used symbols from sub-modules so
|
|
5
|
+
callers can write `from commodutil import convert_price` instead of
|
|
6
|
+
`from commodutil.convfactors import convert_price`.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from commodutil.convfactors import (
|
|
10
|
+
ALIASES,
|
|
11
|
+
COMMODITIES,
|
|
12
|
+
Commodity,
|
|
13
|
+
FRACTIONAL_TO_MAJOR,
|
|
14
|
+
VALID_CURRENCY_TOKENS,
|
|
15
|
+
convert,
|
|
16
|
+
convert_price,
|
|
17
|
+
convfactor,
|
|
18
|
+
fractional_to_major,
|
|
19
|
+
is_fractional_currency,
|
|
20
|
+
list_commodities,
|
|
21
|
+
list_units,
|
|
22
|
+
split_currency_unit,
|
|
23
|
+
)
|
|
24
|
+
from commodutil.dates import (
|
|
25
|
+
curmon,
|
|
26
|
+
curmonyear,
|
|
27
|
+
curmonyear_str,
|
|
28
|
+
curyear,
|
|
29
|
+
find_year,
|
|
30
|
+
last_day_of_prev_month,
|
|
31
|
+
nextyear,
|
|
32
|
+
prevmon,
|
|
33
|
+
prevmon_str,
|
|
34
|
+
prevyear,
|
|
35
|
+
start_day_of_prev_month,
|
|
36
|
+
)
|
|
37
|
+
from commodutil.forwards import (
|
|
38
|
+
all_spread_combinations,
|
|
39
|
+
time_spreads,
|
|
40
|
+
)
|
|
41
|
+
from commodutil.pandasutil import (
|
|
42
|
+
fillna_downbet,
|
|
43
|
+
mergets,
|
|
44
|
+
)
|
|
45
|
+
from commodutil.transforms import (
|
|
46
|
+
seasonalize,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
__all__ = [
|
|
50
|
+
# convfactors
|
|
51
|
+
"ALIASES",
|
|
52
|
+
"COMMODITIES",
|
|
53
|
+
"Commodity",
|
|
54
|
+
"FRACTIONAL_TO_MAJOR",
|
|
55
|
+
"VALID_CURRENCY_TOKENS",
|
|
56
|
+
"convert",
|
|
57
|
+
"convert_price",
|
|
58
|
+
"convfactor",
|
|
59
|
+
"fractional_to_major",
|
|
60
|
+
"is_fractional_currency",
|
|
61
|
+
"list_commodities",
|
|
62
|
+
"list_units",
|
|
63
|
+
"split_currency_unit",
|
|
64
|
+
# dates
|
|
65
|
+
"curmon",
|
|
66
|
+
"curmonyear",
|
|
67
|
+
"curmonyear_str",
|
|
68
|
+
"curyear",
|
|
69
|
+
"find_year",
|
|
70
|
+
"last_day_of_prev_month",
|
|
71
|
+
"nextyear",
|
|
72
|
+
"prevmon",
|
|
73
|
+
"prevmon_str",
|
|
74
|
+
"prevyear",
|
|
75
|
+
"start_day_of_prev_month",
|
|
76
|
+
# forwards
|
|
77
|
+
"all_spread_combinations",
|
|
78
|
+
"time_spreads",
|
|
79
|
+
# pandasutil
|
|
80
|
+
"fillna_downbet",
|
|
81
|
+
"mergets",
|
|
82
|
+
# transforms
|
|
83
|
+
"seasonalize",
|
|
84
|
+
]
|
|
@@ -3,6 +3,7 @@ Modern implementation of commodity unit conversions using Pint.
|
|
|
3
3
|
Clean-slate design with no backward-compatibility constraints.
|
|
4
4
|
"""
|
|
5
5
|
|
|
6
|
+
import logging
|
|
6
7
|
import pint
|
|
7
8
|
from pint.errors import DimensionalityError
|
|
8
9
|
from typing import Union, Optional
|
|
@@ -10,11 +11,20 @@ from dataclasses import dataclass
|
|
|
10
11
|
import pandas as pd
|
|
11
12
|
from functools import lru_cache
|
|
12
13
|
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
13
16
|
# Initialize pint with custom definitions
|
|
14
17
|
ureg = pint.UnitRegistry()
|
|
15
18
|
|
|
16
|
-
# Define oil & gas specific units
|
|
17
|
-
|
|
19
|
+
# Define oil & gas specific units.
|
|
20
|
+
#
|
|
21
|
+
# Pint already ships a `barrel` unit but its default (US dry barrel,
|
|
22
|
+
# ~119.24 L) is NOT the oil/petroleum barrel. Rather than silently
|
|
23
|
+
# clobbering pint's default (which would mean downstream callers using
|
|
24
|
+
# `ureg.barrel` for non-oil contexts get the wrong answer), we register
|
|
25
|
+
# a distinct `oil_barrel` (158.987294928 L = 42 US gallons) and route
|
|
26
|
+
# the `bbl` alias to it. `ureg.barrel` retains pint's default meaning.
|
|
27
|
+
ureg.define("oil_barrel = 158.987294928 liter = bbl")
|
|
18
28
|
ureg.define("gallon = 3.785411784 liter = gal")
|
|
19
29
|
ureg.define("metric_ton = 1000 kilogram = mt")
|
|
20
30
|
ureg.define("kiloton = 1000 metric_ton = kt")
|
|
@@ -54,15 +64,22 @@ ureg.define("MWH = 1e6 watt * hour")
|
|
|
54
64
|
|
|
55
65
|
@dataclass
|
|
56
66
|
class Commodity:
|
|
57
|
-
"""Represents a commodity with its physical properties
|
|
67
|
+
"""Represents a commodity with its physical properties.
|
|
68
|
+
|
|
69
|
+
`density` is `Optional[pint.Quantity]`: `None` means "no liquid density
|
|
70
|
+
defined" (e.g. pipeline natural gas — it's a gas, not a liquid, so
|
|
71
|
+
mass<->volume conversion is undefined and must raise).
|
|
72
|
+
Previously this used `0.0 kg/L` as a sentinel; magnitude==0 checks
|
|
73
|
+
were scattered through the codebase. `None` makes the intent explicit.
|
|
74
|
+
"""
|
|
58
75
|
|
|
59
76
|
name: str
|
|
60
|
-
density: pint.Quantity # kg/L or API gravity
|
|
77
|
+
density: Optional[pint.Quantity] = None # kg/L or API gravity; None = not a liquid
|
|
61
78
|
energy_content: Optional[pint.Quantity] = None # GJ/m^3 or similar
|
|
62
79
|
|
|
63
80
|
def __post_init__(self):
|
|
64
81
|
# Ensure quantities have correct dimensions
|
|
65
|
-
if not isinstance(self.density, pint.Quantity):
|
|
82
|
+
if self.density is not None and not isinstance(self.density, pint.Quantity):
|
|
66
83
|
self.density = self.density * ureg.kg / ureg.liter
|
|
67
84
|
if self.energy_content and not isinstance(self.energy_content, pint.Quantity):
|
|
68
85
|
self.energy_content = self.energy_content * ureg.GJ / ureg.m**3
|
|
@@ -109,9 +126,10 @@ COMMODITIES = {
|
|
|
109
126
|
"natgas", 0.542225066 * ureg.kg / ureg.L, 26.137 * ureg.GJ / ureg.m**3
|
|
110
127
|
),
|
|
111
128
|
# Natural gas (gaseous, pipeline): BP approx 36 PJ per bcm => 0.036 GJ/m**3
|
|
129
|
+
# density=None: not a liquid, so mass<->volume conversion is undefined.
|
|
112
130
|
"natural_gas": Commodity(
|
|
113
|
-
"natural_gas",
|
|
114
|
-
),
|
|
131
|
+
"natural_gas", density=None, energy_content=0.036 * ureg.GJ / ureg.m**3
|
|
132
|
+
),
|
|
115
133
|
# Light gases (NGLs as discrete species — supersede the generic 'lpg' alias
|
|
116
134
|
# for unit conversions where pure-component density/HHV matter, e.g.
|
|
117
135
|
# $/gal <-> $/MMBtu for the MB OPIS futures (AC0, B0, AD0, A8I)).
|
|
@@ -288,11 +306,12 @@ class CommodityConverter:
|
|
|
288
306
|
if is_to_volume:
|
|
289
307
|
return volume_m3.to(to_unit).magnitude
|
|
290
308
|
elif is_to_mass:
|
|
291
|
-
|
|
292
|
-
if density_kg_m3.magnitude == 0:
|
|
309
|
+
if comm.density is None:
|
|
293
310
|
raise ValueError(
|
|
294
|
-
f"
|
|
311
|
+
f"Mass<->volume conversion not supported for {commodity!r} "
|
|
312
|
+
f"(no density defined); cannot convert energy to mass"
|
|
295
313
|
)
|
|
314
|
+
density_kg_m3 = comm.density.to("kg/m^3")
|
|
296
315
|
mass_kg = (volume_m3 * density_kg_m3).to("kg")
|
|
297
316
|
return mass_kg.to(to_unit).magnitude
|
|
298
317
|
else:
|
|
@@ -300,11 +319,12 @@ class CommodityConverter:
|
|
|
300
319
|
else:
|
|
301
320
|
# Volume/Mass -> Energy
|
|
302
321
|
if is_from_mass:
|
|
303
|
-
|
|
304
|
-
if density_kg_m3.magnitude == 0:
|
|
322
|
+
if comm.density is None:
|
|
305
323
|
raise ValueError(
|
|
306
|
-
f"
|
|
324
|
+
f"Mass<->volume conversion not supported for {commodity!r} "
|
|
325
|
+
f"(no density defined); cannot convert mass to energy"
|
|
307
326
|
)
|
|
327
|
+
density_kg_m3 = comm.density.to("kg/m^3")
|
|
308
328
|
mass_kg = qty.to("kg")
|
|
309
329
|
volume_m3 = (mass_kg / density_kg_m3).to("m^3")
|
|
310
330
|
elif is_from_volume:
|
|
@@ -319,12 +339,13 @@ class CommodityConverter:
|
|
|
319
339
|
if not commodity:
|
|
320
340
|
raise ValueError(f"Commodity required for {from_unit} to {to_unit}")
|
|
321
341
|
comm = self.get_commodity(commodity)
|
|
322
|
-
|
|
323
|
-
density_kg_m3 = comm.density.to("kg/m^3")
|
|
324
|
-
if density_kg_L.magnitude == 0 or density_kg_m3.magnitude == 0:
|
|
342
|
+
if comm.density is None:
|
|
325
343
|
raise ValueError(
|
|
326
|
-
f"
|
|
344
|
+
f"Mass<->volume conversion not supported for {commodity!r} "
|
|
345
|
+
f"(no density defined)"
|
|
327
346
|
)
|
|
347
|
+
density_kg_L = comm.density.to("kg/L")
|
|
348
|
+
density_kg_m3 = comm.density.to("kg/m^3")
|
|
328
349
|
if is_from_mass and is_to_volume:
|
|
329
350
|
mass_kg = qty.to("kg")
|
|
330
351
|
volume_L = (mass_kg / density_kg_L).to("L")
|
|
@@ -490,7 +511,7 @@ class CommodityConverter:
|
|
|
490
511
|
return [
|
|
491
512
|
# Volume
|
|
492
513
|
"bbl",
|
|
493
|
-
"
|
|
514
|
+
"oil_barrel",
|
|
494
515
|
"L",
|
|
495
516
|
"liter",
|
|
496
517
|
"m³",
|
|
@@ -544,125 +565,134 @@ def convfactor(from_unit: str, to_unit: str, commodity: Optional[str] = None) ->
|
|
|
544
565
|
return converter.convert(1.0, from_unit, to_unit, commodity)
|
|
545
566
|
|
|
546
567
|
|
|
568
|
+
# ---- Currency-aware price conversion helpers ----
|
|
569
|
+
#
|
|
570
|
+
# Vocabulary moved to commodutil.standards.currency (2026-05) so it's
|
|
571
|
+
# importable without dragging in pint / pandas. convfactors still owns the
|
|
572
|
+
# integrated unit + currency `convert_price` math (which depends on the
|
|
573
|
+
# pint registry above). Names are re-exported for backwards compatibility —
|
|
574
|
+
# `from commodutil.convfactors import VALID_CURRENCY_TOKENS` still works.
|
|
575
|
+
|
|
576
|
+
from commodutil.standards import currency as _currency
|
|
577
|
+
|
|
578
|
+
_FRACTIONAL_CURRENCY_DIVISORS = _currency.FRACTIONAL_CURRENCY_DIVISORS
|
|
579
|
+
_FRACTIONAL_TO_MAJOR = _currency.FRACTIONAL_TO_MAJOR
|
|
580
|
+
_VALID_CURRENCY_TOKENS = _currency.VALID_CURRENCY_TOKENS
|
|
581
|
+
|
|
582
|
+
# Public re-exports — preserve every existing public symbol so that
|
|
583
|
+
# `from commodutil.convfactors import VALID_CURRENCY_TOKENS, fractional_to_major, ...`
|
|
584
|
+
# continues to resolve for downstream packages (pyoilprice etc.).
|
|
585
|
+
VALID_CURRENCY_TOKENS = _VALID_CURRENCY_TOKENS
|
|
586
|
+
FRACTIONAL_TO_MAJOR = _FRACTIONAL_TO_MAJOR
|
|
587
|
+
fractional_to_major = _currency.fractional_to_major
|
|
588
|
+
is_fractional_currency = _currency.is_fractional_currency
|
|
589
|
+
split_currency_unit = _currency.split_currency_unit
|
|
590
|
+
_split_currency_unit = split_currency_unit
|
|
591
|
+
|
|
592
|
+
|
|
547
593
|
def convert_price(
|
|
548
594
|
value: Union[float, pd.Series],
|
|
549
595
|
from_unit: str,
|
|
550
596
|
to_unit: str,
|
|
551
597
|
commodity: Optional[str] = None,
|
|
598
|
+
fx: Union[float, pd.Series, None] = None,
|
|
599
|
+
ffill_policy: str = "strict",
|
|
600
|
+
max_staleness: pd.Timedelta = pd.Timedelta(days=7),
|
|
552
601
|
) -> Union[float, pd.Series]:
|
|
553
602
|
"""
|
|
554
|
-
Convert price values ($/unit) between units
|
|
603
|
+
Convert price values ($/unit) between units, optionally bearing an FX rate.
|
|
555
604
|
|
|
556
605
|
Price conversion is the inverse of quantity conversion:
|
|
557
606
|
price_to = price_from / convfactor(from_unit, to_unit, commodity)
|
|
558
607
|
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
608
|
+
If `from_unit` and `to_unit` differ in currency (e.g. EUR/MWh -> USD/MMBtu),
|
|
609
|
+
`fx` must be supplied (scalar or pandas.Series indexed by date), quoted as
|
|
610
|
+
target/source — i.e. USD-per-foreign-currency. Fractional-currency
|
|
611
|
+
prefixes ('GBp', 'USc', ...) are auto-detected and divided by 100.
|
|
562
612
|
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
"""
|
|
566
|
-
factor = convfactor(from_unit, to_unit, commodity)
|
|
567
|
-
if factor is None or factor == 0:
|
|
568
|
-
return value
|
|
569
|
-
if isinstance(value, pd.Series):
|
|
570
|
-
return value / factor
|
|
571
|
-
return value / factor
|
|
613
|
+
If `fx` is a Series and `value` is a Series, alignment policy is controlled
|
|
614
|
+
by `ffill_policy` and `max_staleness`:
|
|
572
615
|
|
|
616
|
+
- `ffill_policy='strict'` (default): FX is forward-filled onto value.index
|
|
617
|
+
with a bounded ffill of `max_staleness`. If any target dates remain
|
|
618
|
+
uncovered, a `ValueError` is raised — refusing to silently back-fill
|
|
619
|
+
pre-FX-start dates (which would be future leakage in backtests) or
|
|
620
|
+
to extend stale FX values indefinitely.
|
|
621
|
+
- `ffill_policy='ffill'`: legacy permissive behaviour — union the two
|
|
622
|
+
indices, ffill across the union, then reindex back; any remaining
|
|
623
|
+
NaNs are filled with the most-recent non-null FX. Emits a logging
|
|
624
|
+
warning because this is unsafe for backtests.
|
|
573
625
|
|
|
574
|
-
|
|
575
|
-
#
|
|
576
|
-
# Design note (2026-05): callers building cross-currency / cross-unit energy
|
|
577
|
-
# prices (e.g. TTF EUR/MWh -> $/MMBtu, NBP GBp/therm -> $/MMBtu) previously had
|
|
578
|
-
# to roll their own helper because `convert_price` is FX-agnostic.
|
|
579
|
-
# `convert_price_with_fx` keeps the FX leg explicit (an FX *series* or scalar,
|
|
580
|
-
# fetched and aligned by the caller — convfactors does not pull from any FX
|
|
581
|
-
# feed itself) and delegates the unit-leg to `convert_price`. This keeps the
|
|
582
|
-
# single-conversion-call ergonomics promised by the saved feedback memory
|
|
583
|
-
# without coupling commodutil to any pricing feed.
|
|
584
|
-
|
|
585
|
-
_FRACTIONAL_CURRENCY_DIVISORS = {
|
|
586
|
-
# Quoted-in-fractional-units of a major currency. Multiplying by the FX rate
|
|
587
|
-
# for the major currency (e.g. GBP/USD) AND dividing by 100 lifts the
|
|
588
|
-
# fractional-currency quote to its USD equivalent.
|
|
589
|
-
"GBp": 100.0, # pence in sterling, e.g. NBP at "70 GBp/therm"
|
|
590
|
-
"USc": 100.0, # US cents
|
|
591
|
-
"EUc": 100.0, # euro cents
|
|
592
|
-
"JPy": 100.0, # rare, but for symmetry
|
|
593
|
-
}
|
|
626
|
+
There is no all-NaN `1.0` fallback. If FX is unusable, the function raises.
|
|
594
627
|
|
|
628
|
+
`from_unit` / `to_unit` are EITHER bare units ('mt', 'bbl', 'gal', 'MMBtu',
|
|
629
|
+
'MWh', 'therm') OR currency-qualified ('USD/bbl', 'EUR/MWh', 'GBp/therm').
|
|
630
|
+
Currency-qualified targets are currently restricted to USD (anything else
|
|
631
|
+
raises ValueError — extend in future if non-USD targets are needed).
|
|
595
632
|
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
return "", token
|
|
600
|
-
ccy, unit = token.split("/", 1)
|
|
601
|
-
return ccy.strip(), unit.strip()
|
|
602
|
-
|
|
633
|
+
Examples:
|
|
634
|
+
# Gasoline: $/mt -> $/bbl (divide by ~8.33)
|
|
635
|
+
convert_price(100, 'mt', 'bbl', commodity='gasoline') # ~12.0
|
|
603
636
|
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
from_unit: str,
|
|
607
|
-
to_unit: str,
|
|
608
|
-
fx: Union[float, pd.Series, None] = None,
|
|
609
|
-
commodity: Optional[str] = None,
|
|
610
|
-
) -> Union[float, pd.Series]:
|
|
611
|
-
"""
|
|
612
|
-
Convert a price across both a currency leg AND a unit leg in one call.
|
|
637
|
+
# US gallon to barrel: $/gal -> $/bbl (multiply by 42)
|
|
638
|
+
convert_price(2.5, 'gal', 'bbl') # ~105.0
|
|
613
639
|
|
|
614
|
-
|
|
615
|
-
|
|
640
|
+
# TTF EUR/MWh -> $/MMBtu (EURUSD = 1.07)
|
|
641
|
+
convert_price(35.0, 'EUR/MWh', 'USD/MMBtu', fx=1.07) # ~10.98
|
|
616
642
|
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
quoted as **target/source** — i.e. USD-per-foreign-currency. So for
|
|
620
|
-
EUR/MWh -> $/MMBtu, pass `fx = EURUSD` (the EURUSD spot, ~1.07).
|
|
621
|
-
For GBp/therm -> $/MMBtu, pass `fx = GBPUSD` — the fractional 'GBp' suffix
|
|
622
|
-
is detected automatically and divided by 100.
|
|
643
|
+
# NBP GBp/therm -> $/MMBtu (GBPUSD = 1.25); GBp auto-detected & /100
|
|
644
|
+
convert_price(80.0, 'GBp/therm', 'USD/MMBtu', fx=1.25) # ~10.00
|
|
623
645
|
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
targets are needed).
|
|
629
|
-
|
|
630
|
-
Examples
|
|
631
|
-
--------
|
|
632
|
-
>>> # TTF EUR/MWh -> $/MMBtu (EURUSD = 1.07)
|
|
633
|
-
>>> convert_price_with_fx(35.0, 'EUR/MWh', 'USD/MMBtu', fx=1.07) # ~10.98
|
|
634
|
-
>>>
|
|
635
|
-
>>> # NBP GBp/therm -> $/MMBtu (GBPUSD = 1.25)
|
|
636
|
-
>>> # GBp prefix auto-detected & /100; therm->MMBtu *10; net /10
|
|
637
|
-
>>> convert_price_with_fx(80.0, 'GBp/therm', 'USD/MMBtu', fx=1.25) # ~10.00
|
|
638
|
-
>>>
|
|
639
|
-
>>> # Time-varying FX with a pandas Series
|
|
640
|
-
>>> p = pd.Series([35.0, 36.5, 34.2], index=pd.date_range('2026', periods=3))
|
|
641
|
-
>>> fx_series = pd.Series([1.07, 1.08, 1.06], index=p.index)
|
|
642
|
-
>>> convert_price_with_fx(p, 'EUR/MWh', 'USD/MMBtu', fx=fx_series)
|
|
643
|
-
|
|
644
|
-
Notes
|
|
645
|
-
-----
|
|
646
|
-
- If FX is None and the currency token is USD-equivalent, this falls back
|
|
647
|
-
to `convert_price` (pure unit-leg).
|
|
648
|
-
- The FX series is aligned to `value`'s index when both are pandas Series.
|
|
649
|
-
- Returning a numeric type (not None) is always preferred — if `fx` is
|
|
650
|
-
missing for some dates in a Series, those rows become NaN (pandas default).
|
|
646
|
+
# Time-varying FX with a pandas Series
|
|
647
|
+
p = pd.Series([35.0, 36.5, 34.2], index=pd.date_range('2026', periods=3))
|
|
648
|
+
fx_series = pd.Series([1.07, 1.08, 1.06], index=p.index)
|
|
649
|
+
convert_price(p, 'EUR/MWh', 'USD/MMBtu', fx=fx_series)
|
|
651
650
|
"""
|
|
652
|
-
from_ccy, from_bare_unit =
|
|
653
|
-
to_ccy, to_bare_unit =
|
|
654
|
-
|
|
655
|
-
#
|
|
656
|
-
|
|
651
|
+
from_ccy, from_bare_unit = split_currency_unit(from_unit)
|
|
652
|
+
to_ccy, to_bare_unit = split_currency_unit(to_unit)
|
|
653
|
+
|
|
654
|
+
# Resolve the underlying "major" currency on each side for same-base detection
|
|
655
|
+
# (e.g. USc and USD share major USD — pure scale, no FX needed).
|
|
656
|
+
from_major = _FRACTIONAL_TO_MAJOR.get(
|
|
657
|
+
from_ccy, from_ccy.upper() if from_ccy else ""
|
|
658
|
+
)
|
|
659
|
+
to_major = _FRACTIONAL_TO_MAJOR.get(to_ccy, to_ccy.upper() if to_ccy else "")
|
|
660
|
+
# Treat '$' as 'USD' for the purpose of major-currency comparison.
|
|
661
|
+
if from_major == "$":
|
|
662
|
+
from_major = "USD"
|
|
663
|
+
if to_major == "$":
|
|
664
|
+
to_major = "USD"
|
|
665
|
+
|
|
666
|
+
same_base_fractional = bool(from_ccy and to_ccy and from_major == to_major)
|
|
667
|
+
|
|
668
|
+
# Validate target currency — explicit USD only for now (only enforced when
|
|
669
|
+
# the target is currency-qualified at all AND we're not in a same-base
|
|
670
|
+
# fractional case like GBp/therm -> GBP/therm, which is a pure scale).
|
|
671
|
+
if to_ccy and to_major != "USD" and not same_base_fractional:
|
|
657
672
|
raise ValueError(
|
|
658
|
-
f"
|
|
673
|
+
f"convert_price currently only supports USD/* as target; got '{to_unit}'"
|
|
659
674
|
)
|
|
660
675
|
|
|
661
676
|
# Unit-leg conversion (no FX yet — uses commodity factors)
|
|
662
|
-
|
|
677
|
+
factor = convfactor(from_bare_unit, to_bare_unit, commodity)
|
|
678
|
+
if factor is None or factor == 0:
|
|
679
|
+
unit_converted = value
|
|
680
|
+
else:
|
|
681
|
+
unit_converted = value / factor
|
|
682
|
+
|
|
683
|
+
# Same-base fractional case: USc -> USD, GBp -> GBP, EUc -> EUR, JPy -> JPY.
|
|
684
|
+
# This is a pure /100 scale (or *100 in the reverse direction) — no FX
|
|
685
|
+
# needed even though the literal currency tokens differ. Handle BEFORE the
|
|
686
|
+
# `fx is None` raise below.
|
|
687
|
+
if same_base_fractional:
|
|
688
|
+
from_div = _FRACTIONAL_CURRENCY_DIVISORS.get(from_ccy, 1.0)
|
|
689
|
+
to_div = _FRACTIONAL_CURRENCY_DIVISORS.get(to_ccy, 1.0)
|
|
690
|
+
# value is in source-currency units; divide by from_div to get majors,
|
|
691
|
+
# multiply by to_div to get target-currency units.
|
|
692
|
+
return unit_converted * (to_div / from_div)
|
|
663
693
|
|
|
664
694
|
# If no source currency or it's already USD, no FX leg needed
|
|
665
|
-
if not from_ccy or
|
|
695
|
+
if not from_ccy or from_major == "USD":
|
|
666
696
|
return unit_converted
|
|
667
697
|
|
|
668
698
|
# Apply FX leg
|
|
@@ -675,8 +705,62 @@ def convert_price_with_fx(
|
|
|
675
705
|
fractional_divisor = _FRACTIONAL_CURRENCY_DIVISORS.get(from_ccy, 1.0)
|
|
676
706
|
|
|
677
707
|
if isinstance(unit_converted, pd.Series) and isinstance(fx, pd.Series):
|
|
678
|
-
|
|
679
|
-
|
|
708
|
+
target_idx = unit_converted.index
|
|
709
|
+
|
|
710
|
+
if ffill_policy == "strict":
|
|
711
|
+
# Bounded ffill: only forward-fill within `max_staleness`. Anything
|
|
712
|
+
# uncovered (e.g. value-dates before fx.index.min() or stale past
|
|
713
|
+
# the limit) stays NaN and triggers a loud raise — no silent
|
|
714
|
+
# back-fill, no silent stale extrapolation.
|
|
715
|
+
union_idx = fx.index.union(target_idx)
|
|
716
|
+
fx_union = fx.reindex(union_idx).sort_index().ffill()
|
|
717
|
+
# Track how stale each ffilled value is and zero-out anything older
|
|
718
|
+
# than max_staleness.
|
|
719
|
+
valid_mask = ~fx.reindex(union_idx).isna()
|
|
720
|
+
last_valid_pos = (
|
|
721
|
+
pd.Series(union_idx, index=union_idx).where(valid_mask).ffill()
|
|
722
|
+
)
|
|
723
|
+
staleness = pd.Series(union_idx, index=union_idx) - last_valid_pos
|
|
724
|
+
fx_union = fx_union.where(staleness <= max_staleness)
|
|
725
|
+
fx_aligned = fx_union.reindex(target_idx)
|
|
726
|
+
if fx_aligned.isna().any():
|
|
727
|
+
missing = target_idx[fx_aligned.isna()]
|
|
728
|
+
first_missing = missing[0]
|
|
729
|
+
first_missing_str = (
|
|
730
|
+
first_missing.date()
|
|
731
|
+
if hasattr(first_missing, "date")
|
|
732
|
+
else first_missing
|
|
733
|
+
)
|
|
734
|
+
raise ValueError(
|
|
735
|
+
f"FX missing or stale (>{max_staleness}) for "
|
|
736
|
+
f"{len(missing)} target date(s) (first: {first_missing_str}). "
|
|
737
|
+
f"Pass ffill_policy='ffill' to fill with the last non-null "
|
|
738
|
+
f"FX (BACKTEST FUTURE LEAKAGE RISK)."
|
|
739
|
+
)
|
|
740
|
+
elif ffill_policy == "ffill":
|
|
741
|
+
logger.warning(
|
|
742
|
+
"convert_price: ffill_policy='ffill' — pre-FX-start dates will "
|
|
743
|
+
"be back-filled with the latest FX. Future-leakage risk in "
|
|
744
|
+
"backtests; prefer 'strict' for historical research."
|
|
745
|
+
)
|
|
746
|
+
if not target_idx.isin(fx.index).all():
|
|
747
|
+
union_idx = fx.index.union(target_idx)
|
|
748
|
+
fx_aligned = fx.reindex(union_idx).ffill().reindex(target_idx)
|
|
749
|
+
else:
|
|
750
|
+
fx_aligned = fx.reindex(target_idx).ffill()
|
|
751
|
+
if fx_aligned.isna().any():
|
|
752
|
+
fx_nonnull = fx.dropna()
|
|
753
|
+
if fx_nonnull.size == 0:
|
|
754
|
+
raise ValueError(
|
|
755
|
+
"FX series is entirely NaN; refusing the silent "
|
|
756
|
+
"multiply-by-1.0 fallback."
|
|
757
|
+
)
|
|
758
|
+
fx_aligned = fx_aligned.fillna(fx_nonnull.iloc[-1])
|
|
759
|
+
else:
|
|
760
|
+
raise ValueError(
|
|
761
|
+
f"Unknown ffill_policy: {ffill_policy!r} (expected 'strict' or 'ffill')"
|
|
762
|
+
)
|
|
763
|
+
|
|
680
764
|
return unit_converted * fx_aligned / fractional_divisor
|
|
681
765
|
|
|
682
766
|
return unit_converted * fx / fractional_divisor
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""commodutil.standards: canonical vocabularies for commodity trading."""
|