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.
Files changed (66) hide show
  1. {commodutil-3.8.1 → commodutil-3.9.0}/PKG-INFO +1 -1
  2. commodutil-3.9.0/commodutil/__init__.py +84 -0
  3. {commodutil-3.8.1 → commodutil-3.9.0}/commodutil/convfactors.py +196 -112
  4. commodutil-3.9.0/commodutil/standards/__init__.py +1 -0
  5. commodutil-3.9.0/commodutil/standards/currency.py +222 -0
  6. commodutil-3.9.0/commodutil/standards/regions.py +98 -0
  7. commodutil-3.9.0/commodutil/standards/units.py +30 -0
  8. {commodutil-3.8.1 → commodutil-3.9.0}/commodutil.egg-info/PKG-INFO +1 -1
  9. {commodutil-3.8.1 → commodutil-3.9.0}/commodutil.egg-info/SOURCES.txt +7 -0
  10. commodutil-3.9.0/tests/test_price_conv.py +345 -0
  11. commodutil-3.9.0/tests/test_standards_currency.py +132 -0
  12. commodutil-3.9.0/tests/test_standards_regions.py +152 -0
  13. commodutil-3.9.0/tests/test_standards_units.py +31 -0
  14. commodutil-3.8.1/tests/forward/__init__.py +0 -0
  15. commodutil-3.8.1/tests/test_price_conv.py +0 -106
  16. {commodutil-3.8.1 → commodutil-3.9.0}/.coveragerc +0 -0
  17. {commodutil-3.8.1 → commodutil-3.9.0}/.github/workflows/1_tests.yml +0 -0
  18. {commodutil-3.8.1 → commodutil-3.9.0}/.github/workflows/2_coverage.yml +0 -0
  19. {commodutil-3.8.1 → commodutil-3.9.0}/.github/workflows/3_linting.yml +0 -0
  20. {commodutil-3.8.1 → commodutil-3.9.0}/.github/workflows/4_release.yml +0 -0
  21. {commodutil-3.8.1 → commodutil-3.9.0}/.gitignore +0 -0
  22. {commodutil-3.8.1 → commodutil-3.9.0}/.pypirc +0 -0
  23. {commodutil-3.8.1 → commodutil-3.9.0}/azure-build-pipelines.yml +0 -0
  24. {commodutil-3.8.1 → commodutil-3.9.0}/commodutil/arb.py +0 -0
  25. {commodutil-3.8.1 → commodutil-3.9.0}/commodutil/dates.py +0 -0
  26. {commodutil-3.8.1/commodutil → commodutil-3.9.0/commodutil/forward}/__init__.py +0 -0
  27. {commodutil-3.8.1 → commodutil-3.9.0}/commodutil/forward/calendar.py +0 -0
  28. {commodutil-3.8.1 → commodutil-3.9.0}/commodutil/forward/continuous.py +0 -0
  29. {commodutil-3.8.1 → commodutil-3.9.0}/commodutil/forward/fly.py +0 -0
  30. {commodutil-3.8.1 → commodutil-3.9.0}/commodutil/forward/quarterly.py +0 -0
  31. {commodutil-3.8.1 → commodutil-3.9.0}/commodutil/forward/spreads.py +0 -0
  32. {commodutil-3.8.1 → commodutil-3.9.0}/commodutil/forward/structure.py +0 -0
  33. {commodutil-3.8.1 → commodutil-3.9.0}/commodutil/forward/util.py +0 -0
  34. {commodutil-3.8.1 → commodutil-3.9.0}/commodutil/forwards.py +0 -0
  35. {commodutil-3.8.1 → commodutil-3.9.0}/commodutil/pandasutil.py +0 -0
  36. {commodutil-3.8.1 → commodutil-3.9.0}/commodutil/stats.py +0 -0
  37. {commodutil-3.8.1 → commodutil-3.9.0}/commodutil/transforms.py +0 -0
  38. {commodutil-3.8.1 → commodutil-3.9.0}/commodutil.egg-info/dependency_links.txt +0 -0
  39. {commodutil-3.8.1 → commodutil-3.9.0}/commodutil.egg-info/requires.txt +0 -0
  40. {commodutil-3.8.1 → commodutil-3.9.0}/commodutil.egg-info/top_level.txt +0 -0
  41. {commodutil-3.8.1 → commodutil-3.9.0}/pyproject.toml +0 -0
  42. {commodutil-3.8.1 → commodutil-3.9.0}/requirements-test.txt +0 -0
  43. {commodutil-3.8.1 → commodutil-3.9.0}/requirements.txt +0 -0
  44. {commodutil-3.8.1 → commodutil-3.9.0}/requirements_dev.txt +0 -0
  45. {commodutil-3.8.1 → commodutil-3.9.0}/scripts/rbw_structure_scan.py +0 -0
  46. {commodutil-3.8.1 → commodutil-3.9.0}/setup.cfg +0 -0
  47. {commodutil-3.8.1/commodutil/forward → commodutil-3.9.0/tests}/__init__.py +0 -0
  48. {commodutil-3.8.1 → commodutil-3.9.0}/tests/conftest.py +0 -0
  49. {commodutil-3.8.1/tests → commodutil-3.9.0/tests/forward}/__init__.py +0 -0
  50. {commodutil-3.8.1 → commodutil-3.9.0}/tests/forward/conftest.py +0 -0
  51. {commodutil-3.8.1 → commodutil-3.9.0}/tests/forward/test_calendar.py +0 -0
  52. {commodutil-3.8.1 → commodutil-3.9.0}/tests/forward/test_continuous.py +0 -0
  53. {commodutil-3.8.1 → commodutil-3.9.0}/tests/forward/test_fly.py +0 -0
  54. {commodutil-3.8.1 → commodutil-3.9.0}/tests/forward/test_quarterly.py +0 -0
  55. {commodutil-3.8.1 → commodutil-3.9.0}/tests/forward/test_spreads.py +0 -0
  56. {commodutil-3.8.1 → commodutil-3.9.0}/tests/forward/test_structure.py +0 -0
  57. {commodutil-3.8.1 → commodutil-3.9.0}/tests/forward/test_util.py +0 -0
  58. {commodutil-3.8.1 → commodutil-3.9.0}/tests/test_arb.py +0 -0
  59. {commodutil-3.8.1 → commodutil-3.9.0}/tests/test_cl.csv +0 -0
  60. {commodutil-3.8.1 → commodutil-3.9.0}/tests/test_conv.py +0 -0
  61. {commodutil-3.8.1 → commodutil-3.9.0}/tests/test_dates.py +0 -0
  62. {commodutil-3.8.1 → commodutil-3.9.0}/tests/test_forwards.py +0 -0
  63. {commodutil-3.8.1 → commodutil-3.9.0}/tests/test_pandasutils.py +0 -0
  64. {commodutil-3.8.1 → commodutil-3.9.0}/tests/test_stats.py +0 -0
  65. {commodutil-3.8.1 → commodutil-3.9.0}/tests/test_transforms.py +0 -0
  66. {commodutil-3.8.1 → commodutil-3.9.0}/tests/test_weekly.csv +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: commodutil
3
- Version: 3.8.1
3
+ Version: 3.9.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
@@ -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
- ureg.define("barrel = 158.987294928 liter = bbl")
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", 0.0 * ureg.kg / ureg.L, 0.036 * ureg.GJ / ureg.m**3
114
- ), # LNG figures
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
- density_kg_m3 = comm.density.to("kg/m^3")
292
- if density_kg_m3.magnitude == 0:
309
+ if comm.density is None:
293
310
  raise ValueError(
294
- f"Density not defined for {commodity}; cannot convert energy to mass"
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
- density_kg_m3 = comm.density.to("kg/m^3")
304
- if density_kg_m3.magnitude == 0:
322
+ if comm.density is None:
305
323
  raise ValueError(
306
- f"Density not defined for {commodity}; cannot convert mass to energy"
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
- density_kg_L = comm.density.to("kg/L")
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"Density not defined for {commodity}; cannot convert between mass and volume"
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
- "barrel",
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 using commodity-aware quantity factors.
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
- Examples:
560
- # Gasoline: $/mt -> $/bbl (divide by ~8.33)
561
- convert_price(100, 'mt', 'bbl', 'gasoline') # ~12.0
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
- # US gallon to barrel: $/gal -> $/bbl (multiply by 42)
564
- convert_price(2.5, 'gal', 'bbl') # ~105.0
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
- # ---- FX scaling helpers for $/{currency}/{energy-or-volume-or-mass} prices ----
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
- def _split_currency_unit(token: str) -> tuple[str, str]:
597
- """Split a 'CCY/unit' token into ('CCY', 'unit'). Returns ('', token) if no slash."""
598
- if "/" not in token:
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
- def convert_price_with_fx(
605
- value: Union[float, pd.Series],
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
- Use this when you have prices quoted in a foreign currency per a non-USD-
615
- standard unit (e.g. EUR/MWh, GBp/therm) and you want $/MMBtu.
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
- The currency token is the prefix before the '/' (e.g. 'EUR' in 'EUR/MWh',
618
- 'GBp' in 'GBp/therm'). Pass `fx` as the rate (or pandas Series of rates)
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
- `from_unit` / `to_unit` are EITHER bare units ('mt', 'bbl', 'gal', 'MMBtu',
625
- 'MWh', 'therm') OR currency-qualified ('USD/bbl', 'EUR/MWh', 'GBp/therm').
626
- If a currency is supplied on `to_unit`, it is currently assumed to be USD
627
- (passing anything else raises ValueError — extend in future if non-USD
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 = _split_currency_unit(from_unit)
653
- to_ccy, to_bare_unit = _split_currency_unit(to_unit)
654
-
655
- # Validate target currency explicit USD only for now
656
- if to_ccy and to_ccy.upper() not in {"USD", "$"}:
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"convert_price_with_fx currently only supports USD/* as target; got '{to_unit}'"
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
- unit_converted = convert_price(value, from_bare_unit, to_bare_unit, commodity)
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 from_ccy.upper() in {"USD", "$"}:
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
- # Align FX to value index
679
- fx_aligned = fx.reindex(unit_converted.index).ffill()
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."""