commodutil 4.5.0__tar.gz → 4.7.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.5.0 → commodutil-4.7.0}/PKG-INFO +1 -1
- {commodutil-4.5.0 → commodutil-4.7.0}/commodutil/convfactors.py +218 -127
- {commodutil-4.5.0 → commodutil-4.7.0}/commodutil/standards/price_unit.py +3 -3
- {commodutil-4.5.0 → commodutil-4.7.0}/commodutil/standards/unit_registry.py +18 -4
- {commodutil-4.5.0 → commodutil-4.7.0}/commodutil.egg-info/PKG-INFO +1 -1
- {commodutil-4.5.0 → commodutil-4.7.0}/commodutil.egg-info/SOURCES.txt +2 -0
- {commodutil-4.5.0 → commodutil-4.7.0}/commodutil.egg-info/scm_file_list.json +2 -0
- commodutil-4.7.0/commodutil.egg-info/scm_version.json +8 -0
- {commodutil-4.5.0 → commodutil-4.7.0}/tests/golden/golden_factors.json +48 -48
- commodutil-4.7.0/tests/test_align_fx_result.py +130 -0
- {commodutil-4.5.0 → commodutil-4.7.0}/tests/test_conv.py +21 -0
- commodutil-4.7.0/tests/test_rate_pint.py +81 -0
- {commodutil-4.5.0 → commodutil-4.7.0}/tests/test_standards_units.py +5 -0
- {commodutil-4.5.0 → commodutil-4.7.0}/tests/test_unit_registry.py +41 -0
- commodutil-4.5.0/commodutil.egg-info/scm_version.json +0 -8
- {commodutil-4.5.0 → commodutil-4.7.0}/.coveragerc +0 -0
- {commodutil-4.5.0 → commodutil-4.7.0}/.github/workflows/1_tests.yml +0 -0
- {commodutil-4.5.0 → commodutil-4.7.0}/.github/workflows/2_coverage.yml +0 -0
- {commodutil-4.5.0 → commodutil-4.7.0}/.github/workflows/3_linting.yml +0 -0
- {commodutil-4.5.0 → commodutil-4.7.0}/.github/workflows/4_release.yml +0 -0
- {commodutil-4.5.0 → commodutil-4.7.0}/.gitignore +0 -0
- {commodutil-4.5.0 → commodutil-4.7.0}/.pypirc +0 -0
- {commodutil-4.5.0 → commodutil-4.7.0}/azure-build-pipelines.yml +0 -0
- {commodutil-4.5.0 → commodutil-4.7.0}/commodutil/__init__.py +0 -0
- {commodutil-4.5.0 → commodutil-4.7.0}/commodutil/arb.py +0 -0
- {commodutil-4.5.0 → commodutil-4.7.0}/commodutil/dates.py +0 -0
- {commodutil-4.5.0 → commodutil-4.7.0}/commodutil/forward/__init__.py +0 -0
- {commodutil-4.5.0 → commodutil-4.7.0}/commodutil/forward/calendar.py +0 -0
- {commodutil-4.5.0 → commodutil-4.7.0}/commodutil/forward/continuous.py +0 -0
- {commodutil-4.5.0 → commodutil-4.7.0}/commodutil/forward/fly.py +0 -0
- {commodutil-4.5.0 → commodutil-4.7.0}/commodutil/forward/quarterly.py +0 -0
- {commodutil-4.5.0 → commodutil-4.7.0}/commodutil/forward/spreads.py +0 -0
- {commodutil-4.5.0 → commodutil-4.7.0}/commodutil/forward/structure.py +0 -0
- {commodutil-4.5.0 → commodutil-4.7.0}/commodutil/forward/util.py +0 -0
- {commodutil-4.5.0 → commodutil-4.7.0}/commodutil/forwards.py +0 -0
- {commodutil-4.5.0 → commodutil-4.7.0}/commodutil/pandasutil.py +0 -0
- {commodutil-4.5.0 → commodutil-4.7.0}/commodutil/standards/__init__.py +0 -0
- {commodutil-4.5.0 → commodutil-4.7.0}/commodutil/standards/analysis_types.py +0 -0
- {commodutil-4.5.0 → commodutil-4.7.0}/commodutil/standards/commodities.py +0 -0
- {commodutil-4.5.0 → commodutil-4.7.0}/commodutil/standards/commodity_groups.py +0 -0
- {commodutil-4.5.0 → commodutil-4.7.0}/commodutil/standards/currency.py +0 -0
- {commodutil-4.5.0 → commodutil-4.7.0}/commodutil/standards/price_units.py +0 -0
- {commodutil-4.5.0 → commodutil-4.7.0}/commodutil/standards/regions.py +0 -0
- {commodutil-4.5.0 → commodutil-4.7.0}/commodutil/standards/units.py +0 -0
- {commodutil-4.5.0 → commodutil-4.7.0}/commodutil/stats.py +0 -0
- {commodutil-4.5.0 → commodutil-4.7.0}/commodutil/transforms.py +0 -0
- {commodutil-4.5.0 → commodutil-4.7.0}/commodutil.egg-info/dependency_links.txt +0 -0
- {commodutil-4.5.0 → commodutil-4.7.0}/commodutil.egg-info/requires.txt +0 -0
- {commodutil-4.5.0 → commodutil-4.7.0}/commodutil.egg-info/top_level.txt +0 -0
- {commodutil-4.5.0 → commodutil-4.7.0}/pyproject.toml +0 -0
- {commodutil-4.5.0 → commodutil-4.7.0}/requirements-test.txt +0 -0
- {commodutil-4.5.0 → commodutil-4.7.0}/requirements.txt +0 -0
- {commodutil-4.5.0 → commodutil-4.7.0}/requirements_dev.txt +0 -0
- {commodutil-4.5.0 → commodutil-4.7.0}/setup.cfg +0 -0
- {commodutil-4.5.0 → commodutil-4.7.0}/tests/__init__.py +0 -0
- {commodutil-4.5.0 → commodutil-4.7.0}/tests/conftest.py +0 -0
- {commodutil-4.5.0 → commodutil-4.7.0}/tests/forward/__init__.py +0 -0
- {commodutil-4.5.0 → commodutil-4.7.0}/tests/forward/conftest.py +0 -0
- {commodutil-4.5.0 → commodutil-4.7.0}/tests/forward/test_calendar.py +0 -0
- {commodutil-4.5.0 → commodutil-4.7.0}/tests/forward/test_continuous.py +0 -0
- {commodutil-4.5.0 → commodutil-4.7.0}/tests/forward/test_fly.py +0 -0
- {commodutil-4.5.0 → commodutil-4.7.0}/tests/forward/test_quarterly.py +0 -0
- {commodutil-4.5.0 → commodutil-4.7.0}/tests/forward/test_spreads.py +0 -0
- {commodutil-4.5.0 → commodutil-4.7.0}/tests/forward/test_structure.py +0 -0
- {commodutil-4.5.0 → commodutil-4.7.0}/tests/forward/test_util.py +0 -0
- {commodutil-4.5.0 → commodutil-4.7.0}/tests/test_arb.py +0 -0
- {commodutil-4.5.0 → commodutil-4.7.0}/tests/test_cl.csv +0 -0
- {commodutil-4.5.0 → commodutil-4.7.0}/tests/test_dates.py +0 -0
- {commodutil-4.5.0 → commodutil-4.7.0}/tests/test_forwards.py +0 -0
- {commodutil-4.5.0 → commodutil-4.7.0}/tests/test_golden_factors.py +0 -0
- {commodutil-4.5.0 → commodutil-4.7.0}/tests/test_pandasutils.py +0 -0
- {commodutil-4.5.0 → commodutil-4.7.0}/tests/test_price_conv.py +0 -0
- {commodutil-4.5.0 → commodutil-4.7.0}/tests/test_price_unit.py +0 -0
- {commodutil-4.5.0 → commodutil-4.7.0}/tests/test_standards_analysis_types.py +0 -0
- {commodutil-4.5.0 → commodutil-4.7.0}/tests/test_standards_commodities.py +0 -0
- {commodutil-4.5.0 → commodutil-4.7.0}/tests/test_standards_commodity_groups.py +0 -0
- {commodutil-4.5.0 → commodutil-4.7.0}/tests/test_standards_currency.py +0 -0
- {commodutil-4.5.0 → commodutil-4.7.0}/tests/test_standards_price_units.py +0 -0
- {commodutil-4.5.0 → commodutil-4.7.0}/tests/test_standards_regions.py +0 -0
- {commodutil-4.5.0 → commodutil-4.7.0}/tests/test_stats.py +0 -0
- {commodutil-4.5.0 → commodutil-4.7.0}/tests/test_transforms.py +0 -0
- {commodutil-4.5.0 → commodutil-4.7.0}/tests/test_weekly.csv +0 -0
|
@@ -131,8 +131,8 @@ COMMODITIES = {
|
|
|
131
131
|
"naphtha", 0.706720311 * ureg.kg / ureg.L, 34.826266 * ureg.GJ / ureg.m**3
|
|
132
132
|
), # HHV; EIA petrochemical naphtha <401F 5.248 MMBtu/bbl -> 49.28 GJ/t gross (was NCV 44.90)
|
|
133
133
|
"ethanol": Commodity(
|
|
134
|
-
"ethanol", 0.
|
|
135
|
-
), # HHV; EIA fuel ethanol (undenatured) 3.539 MMBtu/bbl
|
|
134
|
+
"ethanol", 0.7937 * ureg.kg / ureg.L, 23.485167 * ureg.GJ / ureg.m**3
|
|
135
|
+
), # HHV; EIA fuel ethanol (undenatured) 3.539 MMBtu/bbl. Density: ASTM D4806 undenatured fuel ethanol SG 60/60F lower bound 0.7937 (Energy Transfer spec note; IEA AMF rounds ethanol density to 0.79 kg/L). Energy_content is per-volume, so bbl->MMBtu stays fixed while mt factors move.
|
|
136
136
|
# Middle distillates
|
|
137
137
|
"diesel": Commodity(
|
|
138
138
|
"diesel", 0.844269902 * ureg.kg / ureg.L, 38.290312 * ureg.GJ / ureg.m**3
|
|
@@ -297,34 +297,32 @@ class CommodityConverter:
|
|
|
297
297
|
if isinstance(to_unit, PriceUnit):
|
|
298
298
|
to_unit = str(to_unit)
|
|
299
299
|
|
|
300
|
-
# Normalize
|
|
300
|
+
# Normalize, then split rate units with the single PriceUnit parser
|
|
301
|
+
# (quantity leg + rate period). No private rate parser anymore.
|
|
301
302
|
from_unit = _to_pint_token(from_unit)
|
|
302
303
|
to_unit = _to_pint_token(to_unit)
|
|
303
|
-
|
|
304
|
-
|
|
304
|
+
from_pu = PriceUnit.parse(from_unit)
|
|
305
|
+
to_pu = PriceUnit.parse(to_unit)
|
|
306
|
+
from_base = _to_pint_token(from_pu.quantity_unit)
|
|
307
|
+
to_base = _to_pint_token(to_pu.quantity_unit)
|
|
308
|
+
from_period = from_pu.period
|
|
309
|
+
to_period = to_pu.period
|
|
305
310
|
|
|
306
|
-
# Get base units
|
|
307
|
-
from_base = from_rate["base"]
|
|
308
|
-
to_base = to_rate["base"]
|
|
309
|
-
|
|
310
|
-
# Create quantity
|
|
311
311
|
if isinstance(value, pd.Series):
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
to_base,
|
|
316
|
-
commodity,
|
|
317
|
-
from_rate["period"],
|
|
318
|
-
to_rate["period"],
|
|
312
|
+
# Series keeps the calendar-aware days_in_month path (pint's
|
|
313
|
+
# fixed-length month can't express it).
|
|
314
|
+
return self._convert_series(
|
|
315
|
+
value, from_base, to_base, commodity, from_period, to_period
|
|
319
316
|
)
|
|
320
|
-
else:
|
|
321
|
-
result = self._convert_scalar(value, from_base, to_base, commodity)
|
|
322
|
-
if from_rate["period"] or to_rate["period"]:
|
|
323
|
-
factor = self._rate_factor_scalar(
|
|
324
|
-
from_rate["period"], to_rate["period"]
|
|
325
|
-
)
|
|
326
|
-
result = result * factor
|
|
327
317
|
|
|
318
|
+
# Scalar: the base-unit factor goes through the SAME _convert_scalar path
|
|
319
|
+
# as a non-rate conversion (so a rate result is consistent-by-construction
|
|
320
|
+
# with the base one), multiplied by the period ratio. That ratio comes
|
|
321
|
+
# from pint's own calendar month/year lengths, replacing the deleted
|
|
322
|
+
# hand-rolled duplicates of those constants.
|
|
323
|
+
result = self._convert_scalar(value, from_base, to_base, commodity)
|
|
324
|
+
if from_period or to_period:
|
|
325
|
+
result = result * self._period_rate_factor(from_period, to_period)
|
|
328
326
|
return result
|
|
329
327
|
|
|
330
328
|
@lru_cache(maxsize=128)
|
|
@@ -449,7 +447,7 @@ class CommodityConverter:
|
|
|
449
447
|
"""Convert a pandas Series with optional rate handling.
|
|
450
448
|
|
|
451
449
|
- Month conversions use the index's actual days_in_month when available.
|
|
452
|
-
- Other time conversions use
|
|
450
|
+
- Other time conversions use pint's calendar period lengths.
|
|
453
451
|
"""
|
|
454
452
|
result = series.copy()
|
|
455
453
|
|
|
@@ -466,9 +464,9 @@ class CommodityConverter:
|
|
|
466
464
|
else:
|
|
467
465
|
result = result / series.index.days_in_month
|
|
468
466
|
else:
|
|
469
|
-
#
|
|
470
|
-
|
|
471
|
-
result = result *
|
|
467
|
+
# Other period conversions (e.g. year<->day): pint's ratio
|
|
468
|
+
# of period lengths.
|
|
469
|
+
result = result * self._period_rate_factor(from_period, to_period)
|
|
472
470
|
|
|
473
471
|
# Apply unit conversion
|
|
474
472
|
factor_units = self._convert_scalar(1.0, from_unit, to_unit, commodity)
|
|
@@ -476,53 +474,24 @@ class CommodityConverter:
|
|
|
476
474
|
|
|
477
475
|
return result
|
|
478
476
|
|
|
479
|
-
def
|
|
480
|
-
"""Parse units like 'bbl/day' or 'kt/month'."""
|
|
481
|
-
if "/" in unit:
|
|
482
|
-
base, period = unit.split("/", 1)
|
|
483
|
-
base = _to_pint_token(base)
|
|
484
|
-
period = period.strip().lower().rstrip("s") # day(s), month(s), year(s)
|
|
485
|
-
return {"base": base, "period": period}
|
|
486
|
-
return {"base": _to_pint_token(unit), "period": None}
|
|
487
|
-
|
|
488
|
-
def _rate_factor_scalar(
|
|
477
|
+
def _period_rate_factor(
|
|
489
478
|
self, from_period: Optional[str], to_period: Optional[str]
|
|
490
479
|
) -> float:
|
|
491
|
-
"""
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
480
|
+
"""Ratio converting a per-``from_period`` rate to per-``to_period``,
|
|
481
|
+
derived from pint's calendar period lengths (day/month/year) — the
|
|
482
|
+
single source that replaces the deleted hand-rolled constants. A
|
|
483
|
+
``None`` period on either side is a no-op (1.0), matching historical
|
|
484
|
+
behaviour.
|
|
485
|
+
|
|
486
|
+
Feeds both the scalar path (period factor) and the Series non-month
|
|
487
|
+
path; the calendar-aware month<->day Series case is handled separately
|
|
488
|
+
with days_in_month.
|
|
496
489
|
"""
|
|
497
|
-
if from_period == to_period:
|
|
498
|
-
return 1.0
|
|
499
|
-
if from_period is None and to_period is None:
|
|
500
|
-
return 1.0
|
|
501
|
-
if from_period is None or to_period is None:
|
|
502
|
-
# Ambiguous to add/remove a time dimension for scalar; no-op to preserve behavior
|
|
490
|
+
if from_period == to_period or from_period is None or to_period is None:
|
|
503
491
|
return 1.0
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
# Helper to express rates as per-day factors
|
|
509
|
-
def per_day_factor(period: str) -> float:
|
|
510
|
-
if period == "day":
|
|
511
|
-
return 1.0
|
|
512
|
-
if period == "year":
|
|
513
|
-
return 1.0 / avg_days_per_year
|
|
514
|
-
if period == "month":
|
|
515
|
-
return 1.0 / avg_days_per_month
|
|
516
|
-
# Fallback to Pint if it's a known time unit
|
|
517
|
-
try:
|
|
518
|
-
return (1 * (self.ureg(period) ** -1)).to(self.ureg.day**-1).magnitude
|
|
519
|
-
except Exception:
|
|
520
|
-
raise ValueError(f"Unsupported rate period: {period}")
|
|
521
|
-
|
|
522
|
-
from_per_day = per_day_factor(from_period)
|
|
523
|
-
to_per_day = per_day_factor(to_period)
|
|
524
|
-
# Convert from per-from_period to per-to_period
|
|
525
|
-
return from_per_day / to_per_day
|
|
492
|
+
from_days = (1 * self.ureg(from_period)).to("day").magnitude
|
|
493
|
+
to_days = (1 * self.ureg(to_period)).to("day").magnitude
|
|
494
|
+
return to_days / from_days
|
|
526
495
|
|
|
527
496
|
def _is_energy(self, unit: str) -> bool:
|
|
528
497
|
return self.ureg(unit).check("[energy]")
|
|
@@ -616,6 +585,84 @@ def convfactor(
|
|
|
616
585
|
# wanting currency vocabulary should import from commodutil.standards.currency.
|
|
617
586
|
|
|
618
587
|
|
|
588
|
+
def align_fx(
|
|
589
|
+
fx: pd.Series,
|
|
590
|
+
index: pd.Index,
|
|
591
|
+
policy: str = "strict",
|
|
592
|
+
max_staleness: pd.Timedelta = pd.Timedelta(days=7),
|
|
593
|
+
) -> pd.Series:
|
|
594
|
+
"""Align an FX Series onto ``index`` for a price conversion.
|
|
595
|
+
|
|
596
|
+
Extracted verbatim from ``convert_price`` (Phase 3.4) so new code can compose
|
|
597
|
+
FX alignment explicitly; ``convert_price`` delegates here, so behaviour and
|
|
598
|
+
error/warning text are unchanged.
|
|
599
|
+
|
|
600
|
+
- ``policy='strict'`` (default): forward-fill FX onto ``index`` bounded by
|
|
601
|
+
``max_staleness``; any target date left uncovered (pre-FX-start, or stale
|
|
602
|
+
past the limit) raises ``ValueError`` rather than silently back-filling or
|
|
603
|
+
extrapolating.
|
|
604
|
+
- ``policy='ffill'``: permissive — union the indices, ffill across the union,
|
|
605
|
+
reindex back, and fill any residual NaN with the most-recent non-null FX.
|
|
606
|
+
Emits a logging warning (future-leakage risk in backtests). Raises only if
|
|
607
|
+
the FX series is entirely NaN.
|
|
608
|
+
"""
|
|
609
|
+
target_idx = index
|
|
610
|
+
|
|
611
|
+
if policy == "strict":
|
|
612
|
+
# Bounded ffill: only forward-fill within `max_staleness`. Anything
|
|
613
|
+
# uncovered (e.g. value-dates before fx.index.min() or stale past
|
|
614
|
+
# the limit) stays NaN and triggers a loud raise — no silent
|
|
615
|
+
# back-fill, no silent stale extrapolation.
|
|
616
|
+
union_idx = fx.index.union(target_idx)
|
|
617
|
+
fx_union = fx.reindex(union_idx).sort_index().ffill()
|
|
618
|
+
# Track how stale each ffilled value is and zero-out anything older
|
|
619
|
+
# than max_staleness.
|
|
620
|
+
valid_mask = ~fx.reindex(union_idx).isna()
|
|
621
|
+
last_valid_pos = pd.Series(union_idx, index=union_idx).where(valid_mask).ffill()
|
|
622
|
+
staleness = pd.Series(union_idx, index=union_idx) - last_valid_pos
|
|
623
|
+
fx_union = fx_union.where(staleness <= max_staleness)
|
|
624
|
+
fx_aligned = fx_union.reindex(target_idx)
|
|
625
|
+
if fx_aligned.isna().any():
|
|
626
|
+
missing = target_idx[fx_aligned.isna()]
|
|
627
|
+
first_missing = missing[0]
|
|
628
|
+
first_missing_str = (
|
|
629
|
+
first_missing.date()
|
|
630
|
+
if hasattr(first_missing, "date")
|
|
631
|
+
else first_missing
|
|
632
|
+
)
|
|
633
|
+
raise ValueError(
|
|
634
|
+
f"FX missing or stale (>{max_staleness}) for "
|
|
635
|
+
f"{len(missing)} target date(s) (first: {first_missing_str}). "
|
|
636
|
+
f"Pass ffill_policy='ffill' to fill with the last non-null "
|
|
637
|
+
f"FX (BACKTEST FUTURE LEAKAGE RISK)."
|
|
638
|
+
)
|
|
639
|
+
elif policy == "ffill":
|
|
640
|
+
logger.warning(
|
|
641
|
+
"convert_price: ffill_policy='ffill' — pre-FX-start dates will "
|
|
642
|
+
"be back-filled with the latest FX. Future-leakage risk in "
|
|
643
|
+
"backtests; prefer 'strict' for historical research."
|
|
644
|
+
)
|
|
645
|
+
if not target_idx.isin(fx.index).all():
|
|
646
|
+
union_idx = fx.index.union(target_idx)
|
|
647
|
+
fx_aligned = fx.reindex(union_idx).ffill().reindex(target_idx)
|
|
648
|
+
else:
|
|
649
|
+
fx_aligned = fx.reindex(target_idx).ffill()
|
|
650
|
+
if fx_aligned.isna().any():
|
|
651
|
+
fx_nonnull = fx.dropna()
|
|
652
|
+
if fx_nonnull.size == 0:
|
|
653
|
+
raise ValueError(
|
|
654
|
+
"FX series is entirely NaN; refusing the silent "
|
|
655
|
+
"multiply-by-1.0 fallback."
|
|
656
|
+
)
|
|
657
|
+
fx_aligned = fx_aligned.fillna(fx_nonnull.iloc[-1])
|
|
658
|
+
else:
|
|
659
|
+
raise ValueError(
|
|
660
|
+
f"Unknown ffill_policy: {policy!r} (expected 'strict' or 'ffill')"
|
|
661
|
+
)
|
|
662
|
+
|
|
663
|
+
return fx_aligned
|
|
664
|
+
|
|
665
|
+
|
|
619
666
|
def convert_price(
|
|
620
667
|
value: Union[float, pd.Series],
|
|
621
668
|
from_unit: Union[str, PriceUnit],
|
|
@@ -744,62 +791,9 @@ def convert_price(
|
|
|
744
791
|
fractional_divisor = _currency.FRACTIONAL_CURRENCY_DIVISORS.get(from_ccy, 1.0)
|
|
745
792
|
|
|
746
793
|
if isinstance(unit_converted, pd.Series) and isinstance(fx, pd.Series):
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
# Bounded ffill: only forward-fill within `max_staleness`. Anything
|
|
751
|
-
# uncovered (e.g. value-dates before fx.index.min() or stale past
|
|
752
|
-
# the limit) stays NaN and triggers a loud raise — no silent
|
|
753
|
-
# back-fill, no silent stale extrapolation.
|
|
754
|
-
union_idx = fx.index.union(target_idx)
|
|
755
|
-
fx_union = fx.reindex(union_idx).sort_index().ffill()
|
|
756
|
-
# Track how stale each ffilled value is and zero-out anything older
|
|
757
|
-
# than max_staleness.
|
|
758
|
-
valid_mask = ~fx.reindex(union_idx).isna()
|
|
759
|
-
last_valid_pos = (
|
|
760
|
-
pd.Series(union_idx, index=union_idx).where(valid_mask).ffill()
|
|
761
|
-
)
|
|
762
|
-
staleness = pd.Series(union_idx, index=union_idx) - last_valid_pos
|
|
763
|
-
fx_union = fx_union.where(staleness <= max_staleness)
|
|
764
|
-
fx_aligned = fx_union.reindex(target_idx)
|
|
765
|
-
if fx_aligned.isna().any():
|
|
766
|
-
missing = target_idx[fx_aligned.isna()]
|
|
767
|
-
first_missing = missing[0]
|
|
768
|
-
first_missing_str = (
|
|
769
|
-
first_missing.date()
|
|
770
|
-
if hasattr(first_missing, "date")
|
|
771
|
-
else first_missing
|
|
772
|
-
)
|
|
773
|
-
raise ValueError(
|
|
774
|
-
f"FX missing or stale (>{max_staleness}) for "
|
|
775
|
-
f"{len(missing)} target date(s) (first: {first_missing_str}). "
|
|
776
|
-
f"Pass ffill_policy='ffill' to fill with the last non-null "
|
|
777
|
-
f"FX (BACKTEST FUTURE LEAKAGE RISK)."
|
|
778
|
-
)
|
|
779
|
-
elif ffill_policy == "ffill":
|
|
780
|
-
logger.warning(
|
|
781
|
-
"convert_price: ffill_policy='ffill' — pre-FX-start dates will "
|
|
782
|
-
"be back-filled with the latest FX. Future-leakage risk in "
|
|
783
|
-
"backtests; prefer 'strict' for historical research."
|
|
784
|
-
)
|
|
785
|
-
if not target_idx.isin(fx.index).all():
|
|
786
|
-
union_idx = fx.index.union(target_idx)
|
|
787
|
-
fx_aligned = fx.reindex(union_idx).ffill().reindex(target_idx)
|
|
788
|
-
else:
|
|
789
|
-
fx_aligned = fx.reindex(target_idx).ffill()
|
|
790
|
-
if fx_aligned.isna().any():
|
|
791
|
-
fx_nonnull = fx.dropna()
|
|
792
|
-
if fx_nonnull.size == 0:
|
|
793
|
-
raise ValueError(
|
|
794
|
-
"FX series is entirely NaN; refusing the silent "
|
|
795
|
-
"multiply-by-1.0 fallback."
|
|
796
|
-
)
|
|
797
|
-
fx_aligned = fx_aligned.fillna(fx_nonnull.iloc[-1])
|
|
798
|
-
else:
|
|
799
|
-
raise ValueError(
|
|
800
|
-
f"Unknown ffill_policy: {ffill_policy!r} (expected 'strict' or 'ffill')"
|
|
801
|
-
)
|
|
802
|
-
|
|
794
|
+
fx_aligned = align_fx(
|
|
795
|
+
fx, unit_converted.index, policy=ffill_policy, max_staleness=max_staleness
|
|
796
|
+
)
|
|
803
797
|
return unit_converted * fx_aligned / fractional_divisor
|
|
804
798
|
|
|
805
799
|
return unit_converted * fx / fractional_divisor
|
|
@@ -891,6 +885,103 @@ def convert_currency_leg(
|
|
|
891
885
|
return value * fx / from_pu.fractional_divisor
|
|
892
886
|
|
|
893
887
|
|
|
888
|
+
@dataclass(frozen=True)
|
|
889
|
+
class ConversionResult:
|
|
890
|
+
"""Result of :func:`convert_price_result`.
|
|
891
|
+
|
|
892
|
+
- ``value``: the converted price (float or pandas Series).
|
|
893
|
+
- ``applied``: True when a non-identity conversion route ran (from/to differ);
|
|
894
|
+
False for an identity ('unchanged') or a kept-raw error.
|
|
895
|
+
- ``note``: which route produced ``value`` — e.g.
|
|
896
|
+
``'convert_price:USD/mt->USD/bbl[diesel]'``, ``'currency_leg:USc/RIN->USD/RIN'``,
|
|
897
|
+
``'unchanged'``, or ``'kept-raw:<error>'``.
|
|
898
|
+
"""
|
|
899
|
+
|
|
900
|
+
value: Union[float, pd.Series]
|
|
901
|
+
applied: bool
|
|
902
|
+
note: str
|
|
903
|
+
|
|
904
|
+
|
|
905
|
+
def convert_price_result(
|
|
906
|
+
value: Union[float, pd.Series],
|
|
907
|
+
from_unit: Union[str, PriceUnit],
|
|
908
|
+
to_unit: Union[str, PriceUnit],
|
|
909
|
+
commodity: Optional[str] = None,
|
|
910
|
+
fx: Union[float, pd.Series, None] = None,
|
|
911
|
+
ffill_policy: str = "strict",
|
|
912
|
+
max_staleness: pd.Timedelta = pd.Timedelta(days=7),
|
|
913
|
+
on_error: str = "raise",
|
|
914
|
+
) -> ConversionResult:
|
|
915
|
+
"""Result-bearing wrapper over :func:`convert_price`: returns a
|
|
916
|
+
:class:`ConversionResult` (value, applied, note) instead of a bare value.
|
|
917
|
+
|
|
918
|
+
Both oilrisk price loaders (``artis.py`` and
|
|
919
|
+
``load_marketplace_price_snapshots.py``) independently invented exactly this
|
|
920
|
+
``(value, changed?, source_note)`` shape around convert_price; this is the
|
|
921
|
+
shared primitive they unify onto in Phase 4.2.
|
|
922
|
+
|
|
923
|
+
Same parameters as :func:`convert_price`, plus:
|
|
924
|
+
* ``on_error='raise'`` (default): a conversion failure propagates, so plain
|
|
925
|
+
use stays strict.
|
|
926
|
+
* ``on_error='keep'``: on failure (and when no currency-leg fallback
|
|
927
|
+
applies) return the ORIGINAL ``value`` with ``applied=False`` and a
|
|
928
|
+
``'kept-raw:...'`` note — the keep-raw semantic oilrisk wants for rows it
|
|
929
|
+
can't convert but must not drop.
|
|
930
|
+
|
|
931
|
+
Fallback: if the full conversion fails but both units are currency-qualified
|
|
932
|
+
with equal quantity denominators, the currency leg alone is applied via
|
|
933
|
+
:func:`convert_currency_leg` (e.g. ``'USc/RIN'->'USD/RIN'``), with a
|
|
934
|
+
``'currency_leg:...'`` note — mirroring the fallback oilrisk's artis.py does
|
|
935
|
+
at its call site so Phase 4.2 can delete that logic there.
|
|
936
|
+
"""
|
|
937
|
+
if on_error not in ("raise", "keep"):
|
|
938
|
+
raise ValueError(f"on_error must be 'raise' or 'keep', got {on_error!r}")
|
|
939
|
+
|
|
940
|
+
from_pu = (
|
|
941
|
+
from_unit if isinstance(from_unit, PriceUnit) else PriceUnit.parse(from_unit)
|
|
942
|
+
)
|
|
943
|
+
to_pu = to_unit if isinstance(to_unit, PriceUnit) else PriceUnit.parse(to_unit)
|
|
944
|
+
label = f"{from_pu}->{to_pu}"
|
|
945
|
+
if commodity:
|
|
946
|
+
label += f"[{commodity}]"
|
|
947
|
+
|
|
948
|
+
# Identity: same currency + unit + period -> nothing to do.
|
|
949
|
+
if from_pu == to_pu:
|
|
950
|
+
return ConversionResult(value=value, applied=False, note="unchanged")
|
|
951
|
+
|
|
952
|
+
try:
|
|
953
|
+
out = convert_price(
|
|
954
|
+
value,
|
|
955
|
+
from_pu,
|
|
956
|
+
to_pu,
|
|
957
|
+
commodity,
|
|
958
|
+
fx=fx,
|
|
959
|
+
ffill_policy=ffill_policy,
|
|
960
|
+
max_staleness=max_staleness,
|
|
961
|
+
)
|
|
962
|
+
return ConversionResult(value=out, applied=True, note=f"convert_price:{label}")
|
|
963
|
+
except Exception as exc:
|
|
964
|
+
# Currency-leg fallback: the full conversion failed, but if both sides
|
|
965
|
+
# are currency-qualified with equal denominators the currency scale is
|
|
966
|
+
# still well-defined (e.g. convert_price can't do convfactor('RIN',
|
|
967
|
+
# 'RIN'), but 'USc/RIN'->'USD/RIN' is a clean /100).
|
|
968
|
+
if (
|
|
969
|
+
from_pu.is_currency_qualified
|
|
970
|
+
and to_pu.is_currency_qualified
|
|
971
|
+
and from_pu.quantity_leg() == to_pu.quantity_leg()
|
|
972
|
+
):
|
|
973
|
+
try:
|
|
974
|
+
out = convert_currency_leg(value, from_pu, to_pu, fx=fx)
|
|
975
|
+
return ConversionResult(
|
|
976
|
+
value=out, applied=True, note=f"currency_leg:{label}"
|
|
977
|
+
)
|
|
978
|
+
except Exception:
|
|
979
|
+
pass # fall through to on_error handling
|
|
980
|
+
if on_error == "keep":
|
|
981
|
+
return ConversionResult(value=value, applied=False, note=f"kept-raw:{exc}")
|
|
982
|
+
raise
|
|
983
|
+
|
|
984
|
+
|
|
894
985
|
def list_commodities():
|
|
895
986
|
"""List all available commodities"""
|
|
896
987
|
return converter.available_commodities
|
|
@@ -62,7 +62,7 @@ class PriceUnit:
|
|
|
62
62
|
currency = str(self.currency).strip()
|
|
63
63
|
object.__setattr__(self, "currency", currency or None)
|
|
64
64
|
if self.period is not None:
|
|
65
|
-
#
|
|
65
|
+
# Rate-period normalisation: day(s)/month(s)/year(s) -> singular.
|
|
66
66
|
period = str(self.period).strip().lower().rstrip("s")
|
|
67
67
|
object.__setattr__(self, "period", period or None)
|
|
68
68
|
|
|
@@ -104,8 +104,8 @@ class PriceUnit:
|
|
|
104
104
|
|
|
105
105
|
@staticmethod
|
|
106
106
|
def _split_period(bare: str) -> tuple[str, Optional[str]]:
|
|
107
|
-
"""Split a bare unit into (quantity_unit, period)
|
|
108
|
-
|
|
107
|
+
"""Split a bare unit into (quantity_unit, period) with day(s)/month(s)/
|
|
108
|
+
year(s) rate-period normalisation."""
|
|
109
109
|
if "/" in bare:
|
|
110
110
|
base, _, period = bare.partition("/")
|
|
111
111
|
period = period.strip().lower().rstrip("s")
|
|
@@ -203,16 +203,25 @@ UNIT_ROWS: Tuple[UnitRow, ...] = (
|
|
|
203
203
|
# into UNIT_MAP. A prefix like 'cubic met' does NOT work under
|
|
204
204
|
# word-boundary matching (trailing letters kill the lookahead), so each
|
|
205
205
|
# form is listed explicitly.
|
|
206
|
+
#
|
|
207
|
+
# Phase 2.1 residual consolidation: the bare notations 'm3' / 'm^3' /
|
|
208
|
+
# 'm**3' are PROMOTED from public_only into UNIT_MAP so curvemetadata's
|
|
209
|
+
# word-boundary parse_unit resolves them directly and its local
|
|
210
|
+
# _EXTRA_UNIT_ALIASES fallback can be deleted. Word-boundary safe: 'm3'
|
|
211
|
+
# cannot match inside 'm30' (trailing [0-9] kills the lookahead); the
|
|
212
|
+
# '**'/'^' chars are regex-escaped by parse_unit. Purely additive to
|
|
213
|
+
# UNIT_MAP; PUBLIC_UNIT_MAP is unchanged (a UNIT_MAP entry is inherited
|
|
214
|
+
# by PUBLIC_UNIT_MAP either way).
|
|
206
215
|
unit_map_spellings=(
|
|
207
216
|
"cubic meter",
|
|
208
217
|
"cubic meters",
|
|
209
218
|
"cubic metre",
|
|
210
219
|
"cubic metres",
|
|
211
|
-
),
|
|
212
|
-
public_only_spellings=(
|
|
213
220
|
"m3",
|
|
214
221
|
"m^3",
|
|
215
222
|
"m**3",
|
|
223
|
+
),
|
|
224
|
+
public_only_spellings=(
|
|
216
225
|
"cubic_meter",
|
|
217
226
|
"cubic_meters",
|
|
218
227
|
"cubic_metre",
|
|
@@ -222,8 +231,13 @@ UNIT_ROWS: Tuple[UnitRow, ...] = (
|
|
|
222
231
|
UnitRow(
|
|
223
232
|
"kg",
|
|
224
233
|
"mass",
|
|
225
|
-
#
|
|
226
|
-
|
|
234
|
+
# Phase 2.1 residual consolidation: kg / kilogram(s) / kilogramme(s)
|
|
235
|
+
# PROMOTED from public_only into UNIT_MAP so parse_unit resolves them and
|
|
236
|
+
# curvemetadata's _EXTRA_UNIT_ALIASES fallback can be deleted. The
|
|
237
|
+
# numeric-prefixed scaled form ("25,000 kg") stays a curvemetadata-local
|
|
238
|
+
# regex (its no-space variant "25000kg" is unreachable by word-boundary
|
|
239
|
+
# matching). Additive to UNIT_MAP; PUBLIC_UNIT_MAP unchanged.
|
|
240
|
+
unit_map_spellings=(
|
|
227
241
|
"kg",
|
|
228
242
|
"kilogram",
|
|
229
243
|
"kilograms",
|
|
@@ -45,6 +45,7 @@ commodutil/standards/unit_registry.py
|
|
|
45
45
|
commodutil/standards/units.py
|
|
46
46
|
tests/__init__.py
|
|
47
47
|
tests/conftest.py
|
|
48
|
+
tests/test_align_fx_result.py
|
|
48
49
|
tests/test_arb.py
|
|
49
50
|
tests/test_cl.csv
|
|
50
51
|
tests/test_conv.py
|
|
@@ -54,6 +55,7 @@ tests/test_golden_factors.py
|
|
|
54
55
|
tests/test_pandasutils.py
|
|
55
56
|
tests/test_price_conv.py
|
|
56
57
|
tests/test_price_unit.py
|
|
58
|
+
tests/test_rate_pint.py
|
|
57
59
|
tests/test_standards_analysis_types.py
|
|
58
60
|
tests/test_standards_commodities.py
|
|
59
61
|
tests/test_standards_commodity_groups.py
|
|
@@ -8,10 +8,12 @@
|
|
|
8
8
|
"requirements_dev.txt",
|
|
9
9
|
".gitignore",
|
|
10
10
|
".pypirc",
|
|
11
|
+
"tests/test_rate_pint.py",
|
|
11
12
|
"tests/__init__.py",
|
|
12
13
|
"tests/test_dates.py",
|
|
13
14
|
"tests/test_price_unit.py",
|
|
14
15
|
"tests/test_standards_analysis_types.py",
|
|
16
|
+
"tests/test_align_fx_result.py",
|
|
15
17
|
"tests/test_standards_price_units.py",
|
|
16
18
|
"tests/test_standards_commodities.py",
|
|
17
19
|
"tests/test_standards_commodity_groups.py",
|