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.
- {commodutil-3.6.0 → commodutil-3.6.2}/PKG-INFO +1 -1
- {commodutil-3.6.0 → commodutil-3.6.2}/commodutil/stats.py +105 -5
- {commodutil-3.6.0 → commodutil-3.6.2}/commodutil.egg-info/PKG-INFO +1 -1
- {commodutil-3.6.0 → commodutil-3.6.2}/.coveragerc +0 -0
- {commodutil-3.6.0 → commodutil-3.6.2}/.github/workflows/1_tests.yml +0 -0
- {commodutil-3.6.0 → commodutil-3.6.2}/.github/workflows/2_coverage.yml +0 -0
- {commodutil-3.6.0 → commodutil-3.6.2}/.github/workflows/3_linting.yml +0 -0
- {commodutil-3.6.0 → commodutil-3.6.2}/.github/workflows/4_release.yml +0 -0
- {commodutil-3.6.0 → commodutil-3.6.2}/.gitignore +0 -0
- {commodutil-3.6.0 → commodutil-3.6.2}/.pypirc +0 -0
- {commodutil-3.6.0 → commodutil-3.6.2}/azure-build-pipelines.yml +0 -0
- {commodutil-3.6.0 → commodutil-3.6.2}/commodutil/__init__.py +0 -0
- {commodutil-3.6.0 → commodutil-3.6.2}/commodutil/arb.py +0 -0
- {commodutil-3.6.0 → commodutil-3.6.2}/commodutil/convfactors.py +0 -0
- {commodutil-3.6.0 → commodutil-3.6.2}/commodutil/dates.py +0 -0
- {commodutil-3.6.0 → commodutil-3.6.2}/commodutil/forward/__init__.py +0 -0
- {commodutil-3.6.0 → commodutil-3.6.2}/commodutil/forward/calendar.py +0 -0
- {commodutil-3.6.0 → commodutil-3.6.2}/commodutil/forward/continuous.py +0 -0
- {commodutil-3.6.0 → commodutil-3.6.2}/commodutil/forward/fly.py +0 -0
- {commodutil-3.6.0 → commodutil-3.6.2}/commodutil/forward/quarterly.py +0 -0
- {commodutil-3.6.0 → commodutil-3.6.2}/commodutil/forward/spreads.py +0 -0
- {commodutil-3.6.0 → commodutil-3.6.2}/commodutil/forward/structure.py +0 -0
- {commodutil-3.6.0 → commodutil-3.6.2}/commodutil/forward/util.py +0 -0
- {commodutil-3.6.0 → commodutil-3.6.2}/commodutil/forwards.py +0 -0
- {commodutil-3.6.0 → commodutil-3.6.2}/commodutil/pandasutil.py +0 -0
- {commodutil-3.6.0 → commodutil-3.6.2}/commodutil/transforms.py +0 -0
- {commodutil-3.6.0 → commodutil-3.6.2}/commodutil.egg-info/SOURCES.txt +0 -0
- {commodutil-3.6.0 → commodutil-3.6.2}/commodutil.egg-info/dependency_links.txt +0 -0
- {commodutil-3.6.0 → commodutil-3.6.2}/commodutil.egg-info/requires.txt +0 -0
- {commodutil-3.6.0 → commodutil-3.6.2}/commodutil.egg-info/top_level.txt +0 -0
- {commodutil-3.6.0 → commodutil-3.6.2}/pyproject.toml +0 -0
- {commodutil-3.6.0 → commodutil-3.6.2}/requirements-test.txt +0 -0
- {commodutil-3.6.0 → commodutil-3.6.2}/requirements.txt +0 -0
- {commodutil-3.6.0 → commodutil-3.6.2}/requirements_dev.txt +0 -0
- {commodutil-3.6.0 → commodutil-3.6.2}/scripts/rbw_structure_scan.py +0 -0
- {commodutil-3.6.0 → commodutil-3.6.2}/setup.cfg +0 -0
- {commodutil-3.6.0 → commodutil-3.6.2}/tests/__init__.py +0 -0
- {commodutil-3.6.0 → commodutil-3.6.2}/tests/conftest.py +0 -0
- {commodutil-3.6.0 → commodutil-3.6.2}/tests/forward/__init__.py +0 -0
- {commodutil-3.6.0 → commodutil-3.6.2}/tests/forward/conftest.py +0 -0
- {commodutil-3.6.0 → commodutil-3.6.2}/tests/forward/test_calendar.py +0 -0
- {commodutil-3.6.0 → commodutil-3.6.2}/tests/forward/test_continuous.py +0 -0
- {commodutil-3.6.0 → commodutil-3.6.2}/tests/forward/test_fly.py +0 -0
- {commodutil-3.6.0 → commodutil-3.6.2}/tests/forward/test_quarterly.py +0 -0
- {commodutil-3.6.0 → commodutil-3.6.2}/tests/forward/test_spreads.py +0 -0
- {commodutil-3.6.0 → commodutil-3.6.2}/tests/forward/test_structure.py +0 -0
- {commodutil-3.6.0 → commodutil-3.6.2}/tests/forward/test_util.py +0 -0
- {commodutil-3.6.0 → commodutil-3.6.2}/tests/test_arb.py +0 -0
- {commodutil-3.6.0 → commodutil-3.6.2}/tests/test_cl.csv +0 -0
- {commodutil-3.6.0 → commodutil-3.6.2}/tests/test_conv.py +0 -0
- {commodutil-3.6.0 → commodutil-3.6.2}/tests/test_dates.py +0 -0
- {commodutil-3.6.0 → commodutil-3.6.2}/tests/test_forwards.py +0 -0
- {commodutil-3.6.0 → commodutil-3.6.2}/tests/test_pandasutils.py +0 -0
- {commodutil-3.6.0 → commodutil-3.6.2}/tests/test_price_conv.py +0 -0
- {commodutil-3.6.0 → commodutil-3.6.2}/tests/test_stats.py +0 -0
- {commodutil-3.6.0 → commodutil-3.6.2}/tests/test_transforms.py +0 -0
- {commodutil-3.6.0 → commodutil-3.6.2}/tests/test_weekly.csv +0 -0
|
@@ -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
|
-
|
|
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,
|
|
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
|