commodutil 3.6.0__tar.gz → 3.6.2__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 (57) hide show
  1. {commodutil-3.6.0 → commodutil-3.6.2}/PKG-INFO +1 -1
  2. {commodutil-3.6.0 → commodutil-3.6.2}/commodutil/stats.py +105 -5
  3. {commodutil-3.6.0 → commodutil-3.6.2}/commodutil.egg-info/PKG-INFO +1 -1
  4. {commodutil-3.6.0 → commodutil-3.6.2}/.coveragerc +0 -0
  5. {commodutil-3.6.0 → commodutil-3.6.2}/.github/workflows/1_tests.yml +0 -0
  6. {commodutil-3.6.0 → commodutil-3.6.2}/.github/workflows/2_coverage.yml +0 -0
  7. {commodutil-3.6.0 → commodutil-3.6.2}/.github/workflows/3_linting.yml +0 -0
  8. {commodutil-3.6.0 → commodutil-3.6.2}/.github/workflows/4_release.yml +0 -0
  9. {commodutil-3.6.0 → commodutil-3.6.2}/.gitignore +0 -0
  10. {commodutil-3.6.0 → commodutil-3.6.2}/.pypirc +0 -0
  11. {commodutil-3.6.0 → commodutil-3.6.2}/azure-build-pipelines.yml +0 -0
  12. {commodutil-3.6.0 → commodutil-3.6.2}/commodutil/__init__.py +0 -0
  13. {commodutil-3.6.0 → commodutil-3.6.2}/commodutil/arb.py +0 -0
  14. {commodutil-3.6.0 → commodutil-3.6.2}/commodutil/convfactors.py +0 -0
  15. {commodutil-3.6.0 → commodutil-3.6.2}/commodutil/dates.py +0 -0
  16. {commodutil-3.6.0 → commodutil-3.6.2}/commodutil/forward/__init__.py +0 -0
  17. {commodutil-3.6.0 → commodutil-3.6.2}/commodutil/forward/calendar.py +0 -0
  18. {commodutil-3.6.0 → commodutil-3.6.2}/commodutil/forward/continuous.py +0 -0
  19. {commodutil-3.6.0 → commodutil-3.6.2}/commodutil/forward/fly.py +0 -0
  20. {commodutil-3.6.0 → commodutil-3.6.2}/commodutil/forward/quarterly.py +0 -0
  21. {commodutil-3.6.0 → commodutil-3.6.2}/commodutil/forward/spreads.py +0 -0
  22. {commodutil-3.6.0 → commodutil-3.6.2}/commodutil/forward/structure.py +0 -0
  23. {commodutil-3.6.0 → commodutil-3.6.2}/commodutil/forward/util.py +0 -0
  24. {commodutil-3.6.0 → commodutil-3.6.2}/commodutil/forwards.py +0 -0
  25. {commodutil-3.6.0 → commodutil-3.6.2}/commodutil/pandasutil.py +0 -0
  26. {commodutil-3.6.0 → commodutil-3.6.2}/commodutil/transforms.py +0 -0
  27. {commodutil-3.6.0 → commodutil-3.6.2}/commodutil.egg-info/SOURCES.txt +0 -0
  28. {commodutil-3.6.0 → commodutil-3.6.2}/commodutil.egg-info/dependency_links.txt +0 -0
  29. {commodutil-3.6.0 → commodutil-3.6.2}/commodutil.egg-info/requires.txt +0 -0
  30. {commodutil-3.6.0 → commodutil-3.6.2}/commodutil.egg-info/top_level.txt +0 -0
  31. {commodutil-3.6.0 → commodutil-3.6.2}/pyproject.toml +0 -0
  32. {commodutil-3.6.0 → commodutil-3.6.2}/requirements-test.txt +0 -0
  33. {commodutil-3.6.0 → commodutil-3.6.2}/requirements.txt +0 -0
  34. {commodutil-3.6.0 → commodutil-3.6.2}/requirements_dev.txt +0 -0
  35. {commodutil-3.6.0 → commodutil-3.6.2}/scripts/rbw_structure_scan.py +0 -0
  36. {commodutil-3.6.0 → commodutil-3.6.2}/setup.cfg +0 -0
  37. {commodutil-3.6.0 → commodutil-3.6.2}/tests/__init__.py +0 -0
  38. {commodutil-3.6.0 → commodutil-3.6.2}/tests/conftest.py +0 -0
  39. {commodutil-3.6.0 → commodutil-3.6.2}/tests/forward/__init__.py +0 -0
  40. {commodutil-3.6.0 → commodutil-3.6.2}/tests/forward/conftest.py +0 -0
  41. {commodutil-3.6.0 → commodutil-3.6.2}/tests/forward/test_calendar.py +0 -0
  42. {commodutil-3.6.0 → commodutil-3.6.2}/tests/forward/test_continuous.py +0 -0
  43. {commodutil-3.6.0 → commodutil-3.6.2}/tests/forward/test_fly.py +0 -0
  44. {commodutil-3.6.0 → commodutil-3.6.2}/tests/forward/test_quarterly.py +0 -0
  45. {commodutil-3.6.0 → commodutil-3.6.2}/tests/forward/test_spreads.py +0 -0
  46. {commodutil-3.6.0 → commodutil-3.6.2}/tests/forward/test_structure.py +0 -0
  47. {commodutil-3.6.0 → commodutil-3.6.2}/tests/forward/test_util.py +0 -0
  48. {commodutil-3.6.0 → commodutil-3.6.2}/tests/test_arb.py +0 -0
  49. {commodutil-3.6.0 → commodutil-3.6.2}/tests/test_cl.csv +0 -0
  50. {commodutil-3.6.0 → commodutil-3.6.2}/tests/test_conv.py +0 -0
  51. {commodutil-3.6.0 → commodutil-3.6.2}/tests/test_dates.py +0 -0
  52. {commodutil-3.6.0 → commodutil-3.6.2}/tests/test_forwards.py +0 -0
  53. {commodutil-3.6.0 → commodutil-3.6.2}/tests/test_pandasutils.py +0 -0
  54. {commodutil-3.6.0 → commodutil-3.6.2}/tests/test_price_conv.py +0 -0
  55. {commodutil-3.6.0 → commodutil-3.6.2}/tests/test_stats.py +0 -0
  56. {commodutil-3.6.0 → commodutil-3.6.2}/tests/test_transforms.py +0 -0
  57. {commodutil-3.6.0 → commodutil-3.6.2}/tests/test_weekly.csv +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: commodutil
3
- Version: 3.6.0
3
+ Version: 3.6.2
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
@@ -211,9 +211,6 @@ def reindex_year_point_stats(
211
211
 
212
212
  dft = transforms.reindex_year(df)
213
213
 
214
- if trim_expiry:
215
- dft = trim_expiry_noise(dft)
216
-
217
214
  if dft is None or dft.empty:
218
215
  return PointStats(
219
216
  asof=pd.NaT,
@@ -227,9 +224,14 @@ def reindex_year_point_stats(
227
224
  percentile=None,
228
225
  )
229
226
 
230
- asof_ts = pd.Timestamp(dft.index.max()) if asof is None else pd.Timestamp(asof)
231
-
227
+ # Select prompt BEFORE trimming so rollover logic sees complete data
232
228
  prompt_col = select_reindex_prompt_column(dft, within_days=within_days)
229
+
230
+ if trim_expiry:
231
+ exclude = [prompt_col] if prompt_col is not None else None
232
+ dft = trim_expiry_noise(dft, exclude_columns=exclude)
233
+
234
+ asof_ts = pd.Timestamp(dft.index.max()) if asof is None else pd.Timestamp(asof)
233
235
  year_map = dates.find_year(dft)
234
236
  prompt_year = year_map.get(prompt_col) if prompt_col is not None else None
235
237
  prompt_year_int = prompt_year if isinstance(prompt_year, int) else None
@@ -387,6 +389,66 @@ def _base_label_from_column(col) -> str | None:
387
389
  return s or None
388
390
 
389
391
 
392
+ # ---------------------------------------------------------------------------
393
+ # Expired-structure detection
394
+ # ---------------------------------------------------------------------------
395
+
396
+ _QUARTER_LAST_MONTH = {"q1": 3, "q2": 6, "q3": 9, "q4": 12}
397
+
398
+
399
+ def _front_delivery_month(base_label):
400
+ """Extract front delivery month number from label like 'JanFeb', 'Q1Q2'.
401
+
402
+ Uses month_abbr_inv from commodutil.forward.util for month parsing
403
+ (same pattern as spread_combination_fly in forwards.py).
404
+ """
405
+ from commodutil.forward.util import month_abbr_inv
406
+
407
+ s = base_label.strip().lower()
408
+ if not s or len(s) < 2:
409
+ return None
410
+ # Monthly: starts with 3-letter month abbreviation
411
+ if len(s) >= 3 and s[:3] in month_abbr_inv:
412
+ return month_abbr_inv[s[:3]]
413
+ # Quarterly: starts with Q + digit
414
+ m = re.match(r"q(\d)", s)
415
+ if m:
416
+ return {"1": 1, "2": 4, "3": 7, "4": 10}.get(m.group(1))
417
+ return None # CAL, H1H2, SummerWinter — don't filter
418
+
419
+
420
+ def _is_structure_expired(base_label, year, ref_date=None):
421
+ """Check if a structure's front delivery leg has expired.
422
+
423
+ Monthly structures: expired when front month has passed.
424
+ Quarterly structures: expired when entire front quarter has passed.
425
+ CAL/H1/H2/Summer/Winter: never filtered (partial expiry is normal).
426
+ """
427
+ if ref_date is None:
428
+ ref_date = datetime.now()
429
+ if isinstance(ref_date, pd.Timestamp):
430
+ ref_date = ref_date.to_pydatetime()
431
+ front_month = _front_delivery_month(base_label)
432
+ if front_month is None:
433
+ return False
434
+ s = base_label.strip().lower()
435
+ # Quarterly: expired after the last month of the front quarter
436
+ q_match = re.match(r"q(\d)", s)
437
+ if q_match:
438
+ last_m = _QUARTER_LAST_MONTH.get(f"q{q_match.group(1)}", front_month)
439
+ boundary = (
440
+ datetime(year + 1, 1, 1) if last_m == 12 else datetime(year, last_m + 1, 1)
441
+ )
442
+ else:
443
+ # Monthly: expired after the front month ends
444
+ boundary = (
445
+ datetime(year + 1, 1, 1)
446
+ if front_month == 12
447
+ else datetime(year, front_month + 1, 1)
448
+ )
449
+ return ref_date >= boundary
450
+
451
+
390
452
  def reindex_year_point_stats_table(
391
453
  df: pd.DataFrame,
392
454
  *,
@@ -395,6 +457,7 @@ def reindex_year_point_stats_table(
395
457
  within_days: int = 10,
396
458
  min_columns: int = 3,
397
459
  trim_expiry: bool = False,
460
+ skip_expired: bool = True,
398
461
  ) -> pd.DataFrame:
399
462
  """
400
463
  Compute prompt-vs-history point stats for many structures in one dataframe.
@@ -408,6 +471,8 @@ def reindex_year_point_stats_table(
408
471
  - returns a sortable table (z-score/percentile) for scanning cheap/rich structures.
409
472
 
410
473
  If ``trim_expiry=True``, each per-group call applies expiry noise trimming.
474
+ If ``skip_expired=True`` (default), structures whose front delivery month has
475
+ already expired are excluded from results.
411
476
 
412
477
  Notes:
413
478
  - Columns must include a 4-digit year somewhere for `dates.find_year` to work reliably.
@@ -435,10 +500,16 @@ def reindex_year_point_stats_table(
435
500
  continue
436
501
  groups.setdefault(key, []).append(col)
437
502
 
503
+ ref_date = datetime.now() if asof is None else pd.Timestamp(asof).to_pydatetime()
504
+
438
505
  rows: list[dict] = []
439
506
  for key, cols in groups.items():
440
507
  if len(cols) < min_columns:
441
508
  continue
509
+ if skip_expired and _is_structure_expired(
510
+ key, dates.curyear, ref_date=ref_date
511
+ ):
512
+ continue
442
513
  stats_res = reindex_year_point_stats(
443
514
  df[cols],
444
515
  asof=asof,
@@ -489,6 +560,7 @@ def prompt_strip_point_stats(
489
560
  asof: datetime | str | pd.Timestamp | None = None,
490
561
  lookback_bdays: int = 756,
491
562
  require_all_columns: bool = True,
563
+ seasonal_window_days: int | None = None,
492
564
  ) -> pd.DataFrame:
493
565
  """
494
566
  Compute point stats per prompt-tenor column for a "prompt strip" dataframe.
@@ -502,9 +574,17 @@ def prompt_strip_point_stats(
502
574
  - for each column, computes (value, mean, std, zscore, percentile) vs a trailing window
503
575
  of `lookback_bdays` observations, excluding the current as-of value.
504
576
 
577
+ Parameters:
578
+ seasonal_window_days: If set, filter reference values to ±N calendar days around the
579
+ same day-of-year as the as-of date. This converts the z-score from unconditional
580
+ ("is $9.30 cheap vs ALL M1 history?") to seasonal ("is $9.30 cheap for mid-February?").
581
+ CRITICAL for seasonal products like gasoline cracks, gasnaph, or any structure with
582
+ strong intra-year patterns. Typical value: 21 (±3 weeks = ~6 week window).
583
+
505
584
  Returns:
506
585
  A DataFrame indexed by column name with stats columns.
507
586
  The selected as-of timestamp is stored in `result.attrs["asof"]`.
587
+ If seasonal_window_days is set, `result.attrs["seasonal_window_days"]` is also stored.
508
588
  """
509
589
  if df is None or df.empty:
510
590
  res = pd.DataFrame(
@@ -556,6 +636,17 @@ def prompt_strip_point_stats(
556
636
  else:
557
637
  ref = hist.iloc[:-1]
558
638
 
639
+ # Seasonal filtering: keep only reference values within ±N calendar days
640
+ # of the same day-of-year as the as-of date. This prevents unconditional
641
+ # z-scores from confusing seasonal patterns with cheap/rich signals.
642
+ if seasonal_window_days is not None and not ref.empty:
643
+ asof_doy = asof_ts.dayofyear
644
+ ref_doy = ref.index.dayofyear
645
+ # Circular distance (handles year-wrap, e.g., Jan vs Dec)
646
+ dist = (ref_doy - asof_doy).to_series(index=ref.index).abs()
647
+ dist = dist.where(dist <= 182, 365 - dist)
648
+ ref = ref[dist.values <= seasonal_window_days]
649
+
559
650
  mean, std, z, p = point_stats(float(current), ref.tolist())
560
651
  rows.append(
561
652
  {
@@ -571,6 +662,8 @@ def prompt_strip_point_stats(
571
662
 
572
663
  res = pd.DataFrame(rows).set_index("tenor")
573
664
  res.attrs["asof"] = asof_ts
665
+ if seasonal_window_days is not None:
666
+ res.attrs["seasonal_window_days"] = seasonal_window_days
574
667
  return res
575
668
 
576
669
 
@@ -650,6 +743,7 @@ def detect_expiry_noise_cutoff(
650
743
  def trim_expiry_noise(
651
744
  df: pd.DataFrame,
652
745
  *,
746
+ exclude_columns: Iterable | None = None,
653
747
  threshold_std: float = 2.0,
654
748
  min_stable_frac: float = 0.6,
655
749
  calm_streak: int = 3,
@@ -661,10 +755,16 @@ def trim_expiry_noise(
661
755
  For each column, detects the expiry-noise cutoff via
662
756
  ``detect_expiry_noise_cutoff`` and sets values after that date to NaN.
663
757
 
758
+ Columns listed in ``exclude_columns`` are left untouched (useful for
759
+ preserving the prompt/current year column).
760
+
664
761
  Returns a copy of the DataFrame with noisy tails removed.
665
762
  """
666
763
  out = df.copy()
764
+ skip = set(exclude_columns) if exclude_columns else set()
667
765
  for col in out.columns:
766
+ if col in skip:
767
+ continue
668
768
  s = pd.to_numeric(out[col], errors="coerce")
669
769
  cutoff = detect_expiry_noise_cutoff(
670
770
  s,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: commodutil
3
- Version: 3.6.0
3
+ Version: 3.6.2
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
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