commodutil 4.5.0__tar.gz → 4.6.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.6.0}/PKG-INFO +1 -1
- {commodutil-4.5.0 → commodutil-4.6.0}/commodutil/convfactors.py +216 -125
- {commodutil-4.5.0 → commodutil-4.6.0}/commodutil/standards/price_unit.py +3 -3
- {commodutil-4.5.0 → commodutil-4.6.0}/commodutil.egg-info/PKG-INFO +1 -1
- {commodutil-4.5.0 → commodutil-4.6.0}/commodutil.egg-info/SOURCES.txt +2 -0
- {commodutil-4.5.0 → commodutil-4.6.0}/commodutil.egg-info/scm_file_list.json +2 -0
- {commodutil-4.5.0 → commodutil-4.6.0}/commodutil.egg-info/scm_version.json +2 -2
- commodutil-4.6.0/tests/test_align_fx_result.py +130 -0
- commodutil-4.6.0/tests/test_rate_pint.py +81 -0
- {commodutil-4.5.0 → commodutil-4.6.0}/.coveragerc +0 -0
- {commodutil-4.5.0 → commodutil-4.6.0}/.github/workflows/1_tests.yml +0 -0
- {commodutil-4.5.0 → commodutil-4.6.0}/.github/workflows/2_coverage.yml +0 -0
- {commodutil-4.5.0 → commodutil-4.6.0}/.github/workflows/3_linting.yml +0 -0
- {commodutil-4.5.0 → commodutil-4.6.0}/.github/workflows/4_release.yml +0 -0
- {commodutil-4.5.0 → commodutil-4.6.0}/.gitignore +0 -0
- {commodutil-4.5.0 → commodutil-4.6.0}/.pypirc +0 -0
- {commodutil-4.5.0 → commodutil-4.6.0}/azure-build-pipelines.yml +0 -0
- {commodutil-4.5.0 → commodutil-4.6.0}/commodutil/__init__.py +0 -0
- {commodutil-4.5.0 → commodutil-4.6.0}/commodutil/arb.py +0 -0
- {commodutil-4.5.0 → commodutil-4.6.0}/commodutil/dates.py +0 -0
- {commodutil-4.5.0 → commodutil-4.6.0}/commodutil/forward/__init__.py +0 -0
- {commodutil-4.5.0 → commodutil-4.6.0}/commodutil/forward/calendar.py +0 -0
- {commodutil-4.5.0 → commodutil-4.6.0}/commodutil/forward/continuous.py +0 -0
- {commodutil-4.5.0 → commodutil-4.6.0}/commodutil/forward/fly.py +0 -0
- {commodutil-4.5.0 → commodutil-4.6.0}/commodutil/forward/quarterly.py +0 -0
- {commodutil-4.5.0 → commodutil-4.6.0}/commodutil/forward/spreads.py +0 -0
- {commodutil-4.5.0 → commodutil-4.6.0}/commodutil/forward/structure.py +0 -0
- {commodutil-4.5.0 → commodutil-4.6.0}/commodutil/forward/util.py +0 -0
- {commodutil-4.5.0 → commodutil-4.6.0}/commodutil/forwards.py +0 -0
- {commodutil-4.5.0 → commodutil-4.6.0}/commodutil/pandasutil.py +0 -0
- {commodutil-4.5.0 → commodutil-4.6.0}/commodutil/standards/__init__.py +0 -0
- {commodutil-4.5.0 → commodutil-4.6.0}/commodutil/standards/analysis_types.py +0 -0
- {commodutil-4.5.0 → commodutil-4.6.0}/commodutil/standards/commodities.py +0 -0
- {commodutil-4.5.0 → commodutil-4.6.0}/commodutil/standards/commodity_groups.py +0 -0
- {commodutil-4.5.0 → commodutil-4.6.0}/commodutil/standards/currency.py +0 -0
- {commodutil-4.5.0 → commodutil-4.6.0}/commodutil/standards/price_units.py +0 -0
- {commodutil-4.5.0 → commodutil-4.6.0}/commodutil/standards/regions.py +0 -0
- {commodutil-4.5.0 → commodutil-4.6.0}/commodutil/standards/unit_registry.py +0 -0
- {commodutil-4.5.0 → commodutil-4.6.0}/commodutil/standards/units.py +0 -0
- {commodutil-4.5.0 → commodutil-4.6.0}/commodutil/stats.py +0 -0
- {commodutil-4.5.0 → commodutil-4.6.0}/commodutil/transforms.py +0 -0
- {commodutil-4.5.0 → commodutil-4.6.0}/commodutil.egg-info/dependency_links.txt +0 -0
- {commodutil-4.5.0 → commodutil-4.6.0}/commodutil.egg-info/requires.txt +0 -0
- {commodutil-4.5.0 → commodutil-4.6.0}/commodutil.egg-info/top_level.txt +0 -0
- {commodutil-4.5.0 → commodutil-4.6.0}/pyproject.toml +0 -0
- {commodutil-4.5.0 → commodutil-4.6.0}/requirements-test.txt +0 -0
- {commodutil-4.5.0 → commodutil-4.6.0}/requirements.txt +0 -0
- {commodutil-4.5.0 → commodutil-4.6.0}/requirements_dev.txt +0 -0
- {commodutil-4.5.0 → commodutil-4.6.0}/setup.cfg +0 -0
- {commodutil-4.5.0 → commodutil-4.6.0}/tests/__init__.py +0 -0
- {commodutil-4.5.0 → commodutil-4.6.0}/tests/conftest.py +0 -0
- {commodutil-4.5.0 → commodutil-4.6.0}/tests/forward/__init__.py +0 -0
- {commodutil-4.5.0 → commodutil-4.6.0}/tests/forward/conftest.py +0 -0
- {commodutil-4.5.0 → commodutil-4.6.0}/tests/forward/test_calendar.py +0 -0
- {commodutil-4.5.0 → commodutil-4.6.0}/tests/forward/test_continuous.py +0 -0
- {commodutil-4.5.0 → commodutil-4.6.0}/tests/forward/test_fly.py +0 -0
- {commodutil-4.5.0 → commodutil-4.6.0}/tests/forward/test_quarterly.py +0 -0
- {commodutil-4.5.0 → commodutil-4.6.0}/tests/forward/test_spreads.py +0 -0
- {commodutil-4.5.0 → commodutil-4.6.0}/tests/forward/test_structure.py +0 -0
- {commodutil-4.5.0 → commodutil-4.6.0}/tests/forward/test_util.py +0 -0
- {commodutil-4.5.0 → commodutil-4.6.0}/tests/golden/golden_factors.json +0 -0
- {commodutil-4.5.0 → commodutil-4.6.0}/tests/test_arb.py +0 -0
- {commodutil-4.5.0 → commodutil-4.6.0}/tests/test_cl.csv +0 -0
- {commodutil-4.5.0 → commodutil-4.6.0}/tests/test_conv.py +0 -0
- {commodutil-4.5.0 → commodutil-4.6.0}/tests/test_dates.py +0 -0
- {commodutil-4.5.0 → commodutil-4.6.0}/tests/test_forwards.py +0 -0
- {commodutil-4.5.0 → commodutil-4.6.0}/tests/test_golden_factors.py +0 -0
- {commodutil-4.5.0 → commodutil-4.6.0}/tests/test_pandasutils.py +0 -0
- {commodutil-4.5.0 → commodutil-4.6.0}/tests/test_price_conv.py +0 -0
- {commodutil-4.5.0 → commodutil-4.6.0}/tests/test_price_unit.py +0 -0
- {commodutil-4.5.0 → commodutil-4.6.0}/tests/test_standards_analysis_types.py +0 -0
- {commodutil-4.5.0 → commodutil-4.6.0}/tests/test_standards_commodities.py +0 -0
- {commodutil-4.5.0 → commodutil-4.6.0}/tests/test_standards_commodity_groups.py +0 -0
- {commodutil-4.5.0 → commodutil-4.6.0}/tests/test_standards_currency.py +0 -0
- {commodutil-4.5.0 → commodutil-4.6.0}/tests/test_standards_price_units.py +0 -0
- {commodutil-4.5.0 → commodutil-4.6.0}/tests/test_standards_regions.py +0 -0
- {commodutil-4.5.0 → commodutil-4.6.0}/tests/test_standards_units.py +0 -0
- {commodutil-4.5.0 → commodutil-4.6.0}/tests/test_stats.py +0 -0
- {commodutil-4.5.0 → commodutil-4.6.0}/tests/test_transforms.py +0 -0
- {commodutil-4.5.0 → commodutil-4.6.0}/tests/test_unit_registry.py +0 -0
- {commodutil-4.5.0 → commodutil-4.6.0}/tests/test_weekly.csv +0 -0
|
@@ -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")
|
|
@@ -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",
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Phase 3.4 tests: align_fx (extracted) and convert_price_result (additive).
|
|
2
|
+
|
|
3
|
+
convert_price itself is unchanged (its behaviour is pinned by the golden fixture
|
|
4
|
+
and the existing tests/test_price_conv.py). These cover the new public surface:
|
|
5
|
+
the extracted align_fx helper and the result-bearing convert_price_result.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
|
|
10
|
+
import pandas as pd
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
from commodutil import convfactors
|
|
14
|
+
from commodutil.convfactors import ConversionResult
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# ---- align_fx direct (mirrors the convert_price fx alignment tests) ----
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_align_fx_strict_raises_on_pre_start():
|
|
21
|
+
fx = pd.Series([1.10, 1.11, 1.12], index=pd.date_range("2026-01-05", periods=3))
|
|
22
|
+
idx = pd.date_range("2026-01-01", periods=3) # entirely before fx start
|
|
23
|
+
with pytest.raises(ValueError, match="FX missing or stale"):
|
|
24
|
+
convfactors.align_fx(fx, idx, policy="strict")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_align_fx_strict_within_staleness_ok():
|
|
28
|
+
fx = pd.Series([1.07, 1.08, 1.09], index=pd.date_range("2026-01-01", periods=3))
|
|
29
|
+
idx = pd.date_range("2026-01-01", periods=5) # 2 days past fx end, within 7d
|
|
30
|
+
out = convfactors.align_fx(fx, idx, policy="strict")
|
|
31
|
+
assert out.isna().sum() == 0
|
|
32
|
+
assert out.iloc[-1] == pytest.approx(1.09) # forward-filled within staleness
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_align_fx_ffill_warns_and_fills(caplog):
|
|
36
|
+
fx = pd.Series([1.10, 1.11, 1.12], index=pd.date_range("2026-01-05", periods=3))
|
|
37
|
+
idx = pd.date_range("2026-01-01", periods=3) # pre-start
|
|
38
|
+
with caplog.at_level(logging.WARNING, logger="commodutil.convfactors"):
|
|
39
|
+
out = convfactors.align_fx(fx, idx, policy="ffill")
|
|
40
|
+
assert out.isna().sum() == 0
|
|
41
|
+
assert any("ffill_policy='ffill'" in rec.message for rec in caplog.records)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_align_fx_ffill_all_nan_raises():
|
|
45
|
+
fx = pd.Series([float("nan")] * 3, index=pd.date_range("2026-01-01", periods=3))
|
|
46
|
+
idx = pd.date_range("2026-01-01", periods=3)
|
|
47
|
+
with pytest.raises(ValueError, match="entirely NaN"):
|
|
48
|
+
convfactors.align_fx(fx, idx, policy="ffill")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_align_fx_unknown_policy_raises():
|
|
52
|
+
fx = pd.Series([1.07], index=pd.date_range("2026-01-01", periods=1))
|
|
53
|
+
with pytest.raises(ValueError, match="Unknown ffill_policy"):
|
|
54
|
+
convfactors.align_fx(fx, fx.index, policy="bogus")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_align_fx_matches_convert_price_series_path():
|
|
58
|
+
# convert_price delegates to align_fx; a manual compose reproduces it.
|
|
59
|
+
idx = pd.date_range("2026-01-01", periods=3)
|
|
60
|
+
p = pd.Series([35.0, 36.5, 34.2], index=idx)
|
|
61
|
+
fx = pd.Series([1.07, 1.08, 1.06], index=idx)
|
|
62
|
+
via_cp = convfactors.convert_price(p, "EUR/MWh", "USD/MMBtu", fx=fx)
|
|
63
|
+
factor = convfactors.convfactor("MWh", "MMBtu")
|
|
64
|
+
fx_aligned = convfactors.align_fx(fx, p.index)
|
|
65
|
+
manual = (p / factor) * fx_aligned # EUR major, divisor 1.0
|
|
66
|
+
pd.testing.assert_series_equal(via_cp, manual)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# ---- convert_price_result ----
|
|
70
|
+
|
|
71
|
+
_SAMPLE = [
|
|
72
|
+
(100.0, "mt", "bbl", "gasoline", None),
|
|
73
|
+
(2.5, "gal", "bbl", None, None),
|
|
74
|
+
(250.0, "USc/gal", "USD/gal", None, None),
|
|
75
|
+
(100.0, "USD/mt", "USD/bbl", "diesel", None),
|
|
76
|
+
(35.0, "EUR/MWh", "USD/MMBtu", None, 1.07),
|
|
77
|
+
(80.0, "GBp/therm", "USD/MMBtu", None, 1.25),
|
|
78
|
+
]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_convert_price_result_value_matches_convert_price():
|
|
82
|
+
for value, frm, to, commodity, fx in _SAMPLE:
|
|
83
|
+
res = convfactors.convert_price_result(value, frm, to, commodity, fx=fx)
|
|
84
|
+
expected = convfactors.convert_price(value, frm, to, commodity, fx=fx)
|
|
85
|
+
assert isinstance(res, ConversionResult)
|
|
86
|
+
assert res.value == pytest.approx(expected, rel=1e-12)
|
|
87
|
+
assert res.applied is True
|
|
88
|
+
assert res.note.startswith("convert_price:")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_convert_price_result_is_frozen():
|
|
92
|
+
res = convfactors.convert_price_result(2.5, "gal", "bbl")
|
|
93
|
+
with pytest.raises(Exception):
|
|
94
|
+
res.value = 0.0 # frozen dataclass
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_convert_price_result_identity_unchanged():
|
|
98
|
+
res = convfactors.convert_price_result(100.0, "USD/bbl", "USD/bbl")
|
|
99
|
+
assert res.value == 100.0
|
|
100
|
+
assert res.applied is False
|
|
101
|
+
assert res.note == "unchanged"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def test_convert_price_result_currency_leg_fallback_rin():
|
|
105
|
+
# convert_price can't do convfactor('RIN','RIN'); the fallback applies the
|
|
106
|
+
# pure currency scale and records the currency_leg route.
|
|
107
|
+
res = convfactors.convert_price_result(250.0, "USc/RIN", "USD/RIN")
|
|
108
|
+
assert res.value == pytest.approx(2.5, rel=1e-12)
|
|
109
|
+
assert res.applied is True
|
|
110
|
+
assert res.note.startswith("currency_leg:")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def test_convert_price_result_keep_raw_on_unconvertible():
|
|
114
|
+
# Different, pint-unknown denominators: no route works -> keep-raw.
|
|
115
|
+
res = convfactors.convert_price_result(100.0, "GBp/XYZ", "USD/ABC", on_error="keep")
|
|
116
|
+
assert res.value == 100.0
|
|
117
|
+
assert res.applied is False
|
|
118
|
+
assert res.note.startswith("kept-raw:")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def test_convert_price_result_raise_is_default():
|
|
122
|
+
with pytest.raises(Exception):
|
|
123
|
+
convfactors.convert_price_result(100.0, "GBp/XYZ", "USD/ABC")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def test_convert_price_result_invalid_on_error():
|
|
127
|
+
with pytest.raises(ValueError, match="on_error must be"):
|
|
128
|
+
convfactors.convert_price_result(
|
|
129
|
+
1.0, "USD/mt", "USD/bbl", "diesel", on_error="x"
|
|
130
|
+
)
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Phase 3.3 tests: rate conversions lean on pint (separable design).
|
|
2
|
+
|
|
3
|
+
The hand-rolled scalar rate machinery (_parse_rate_unit / _rate_factor_scalar)
|
|
4
|
+
is deleted. A rate conversion is now the base-unit factor (the SAME
|
|
5
|
+
_convert_scalar path as a non-rate conversion) times a period ratio derived from
|
|
6
|
+
pint's calendar lengths (_period_rate_factor), so the calendar constants are no
|
|
7
|
+
longer duplicated. The Series path keeps calendar-aware days_in_month. Both are
|
|
8
|
+
fed by the single PriceUnit parser. Numerics are pinned by the golden fixture;
|
|
9
|
+
these tests assert the structural change and preserved edge behaviours.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import inspect
|
|
13
|
+
import re
|
|
14
|
+
|
|
15
|
+
import pandas as pd
|
|
16
|
+
import pytest
|
|
17
|
+
|
|
18
|
+
from commodutil import convfactors
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_hand_rolled_rate_machinery_deleted():
|
|
22
|
+
converter = convfactors.converter
|
|
23
|
+
assert not hasattr(converter, "_parse_rate_unit")
|
|
24
|
+
assert not hasattr(converter, "_rate_factor_scalar")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_no_hardcoded_calendar_constants_in_convfactors():
|
|
28
|
+
# The dedup guarantee: pint is the single source of month/year lengths, so
|
|
29
|
+
# the old literals must not reappear anywhere in the module.
|
|
30
|
+
src = inspect.getsource(convfactors)
|
|
31
|
+
assert "30.4375" not in src
|
|
32
|
+
assert not re.search(r"\b365\.25\b", src)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_rate_consistent_with_base_factor_times_pint_ratio():
|
|
36
|
+
# Rate result == base-unit factor x pint period ratio, for the golden
|
|
37
|
+
# commodities. Proves the separable design and that the ratio is pint's.
|
|
38
|
+
month_len = (1 * convfactors.ureg("month")).to("day").magnitude
|
|
39
|
+
for commodity in ("crude", "diesel", "gasoline", "fuel_oil"):
|
|
40
|
+
base = convfactors.convfactor("kt", "bbl", commodity)
|
|
41
|
+
rate = convfactors.convert(1.0, "kt/month", "bbl/day", commodity)
|
|
42
|
+
assert rate == pytest.approx(base / month_len, rel=1e-12), commodity
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_scalar_rate_uses_pint_calendar_constants():
|
|
46
|
+
# pint's month=30.4375d, year=365.25d — the exact constants the deleted
|
|
47
|
+
# code hand-rolled.
|
|
48
|
+
assert convfactors.convert(1.0, "bbl/day", "bbl/month") == pytest.approx(
|
|
49
|
+
30.4375, rel=1e-12
|
|
50
|
+
)
|
|
51
|
+
assert convfactors.convert(1.0, "bbl/day", "bbl/year") == pytest.approx(
|
|
52
|
+
365.25, rel=1e-12
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_none_commodity_rate_raises_bare_unit_message():
|
|
57
|
+
# Error messages are preserved character-for-character (bare units, not the
|
|
58
|
+
# rate form) — these are the two pinned golden rate errors.
|
|
59
|
+
with pytest.raises(ValueError, match=r"^Commodity required for kt to bbl$"):
|
|
60
|
+
convfactors.convert(1.0, "kt/month", "bbl/day")
|
|
61
|
+
with pytest.raises(ValueError, match=r"^Commodity required for bbl to mt$"):
|
|
62
|
+
convfactors.convert(1.0, "bbl/day", "mt/year")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_series_month_aware_uses_calendar_days():
|
|
66
|
+
# The Series path keeps calendar-aware days_in_month (2020 is a leap year,
|
|
67
|
+
# so February has 29 days) — pint's fixed-length month cannot express this.
|
|
68
|
+
dates = pd.date_range("2020-01-01", periods=3, freq="MS") # Jan, Feb, Mar 2020
|
|
69
|
+
s = pd.Series([100.0, 100.0, 100.0], index=dates) # kt/month
|
|
70
|
+
out = convfactors.convert(s, "kt/month", "bbl/day", "diesel")
|
|
71
|
+
kt_to_bbl = convfactors.convfactor("kt", "bbl", "diesel")
|
|
72
|
+
assert out.iloc[0] == pytest.approx(100 * kt_to_bbl / 31, abs=1) # Jan 31d
|
|
73
|
+
assert out.iloc[1] == pytest.approx(100 * kt_to_bbl / 29, abs=1) # Feb 29d (leap)
|
|
74
|
+
assert out.iloc[2] == pytest.approx(100 * kt_to_bbl / 31, abs=1) # Mar 31d
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_series_non_month_period_matches_pint_ratio():
|
|
78
|
+
# A calendar-unaware index falls back to the pint period ratio (year->day).
|
|
79
|
+
s = pd.Series([365250.0, 365250.0]) # RangeIndex, no days_in_month
|
|
80
|
+
out = convfactors.convert(s, "bbl/year", "bbl/day")
|
|
81
|
+
assert out.iloc[0] == pytest.approx(1000.0, abs=1e-6)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|