From cfc27a38def284d795395662005a78069e8e2544 Mon Sep 17 00:00:00 2001 From: Sam Fredrickson Date: Thu, 24 Sep 2026 02:00:56 -0700 Subject: [PATCH] Rewrite as a probabilistic model with walk-forward evaluation. Replace the 2024 model (model.py, ~2000 lines) with the btcmodel package, the baseline for future work: - Forecasts are quantiles of log price at each horizon, scored with CRPS in a walk-forward backtest (origins every 30 days from 2014, horizons 1 month to 4 years). Skill is relative to a zero-drift random walk, with circular block-bootstrap intervals and a count of independent windows. - Development data stops at 2024-11-26, the last day the 2024 model saw. Later outcomes are a holdout, scored only by `backtest --holdout`. - Models: random_walk, drift_rw, and cycle (the 2024 model's cycle-position drift, now kernel-smoothed and recency-weighted). On development data nothing beats the random walk with confidence; cycle loses at every horizon. - Prices: the Investing.com archive moves to data/ (cut at 2024-11-26; its last row was intraday) and is extended with Coinbase daily closes by `update`. Also: Nix flake dev shell (Python 3.13, pandas 3), ruff in place of black, pytest suite, and a rewritten README. NOTES.md is removed as inaccurate, and poetry is dropped. --- .gitignore | 8 +- NOTES.md | 361 ---- README.md | 110 +- btcmodel/__init__.py | 0 btcmodel/__main__.py | 100 ++ btcmodel/data.py | 117 ++ btcmodel/evaluate.py | 123 ++ btcmodel/forecast.py | 76 + btcmodel/halving.py | 34 + btcmodel/models/__init__.py | 15 + btcmodel/models/baselines.py | 47 + btcmodel/models/cycle.py | 73 + btcmodel/plots.py | 172 ++ data/coinbase.csv | 667 ++++++++ prices.csv => data/investing.csv | 0 moneyshot.png => docs/2024-forecast.png | Bin flake.lock | 27 + flake.nix | 36 + justfile | 31 +- model.py | 1999 ----------------------- poetry.lock | 734 --------- pyproject.toml | 34 +- tests/test_forecast.py | 44 + tests/test_models.py | 83 + 24 files changed, 1767 insertions(+), 3124 deletions(-) delete mode 100644 NOTES.md create mode 100644 btcmodel/__init__.py create mode 100644 btcmodel/__main__.py create mode 100644 btcmodel/data.py create mode 100644 btcmodel/evaluate.py create mode 100644 btcmodel/forecast.py create mode 100644 btcmodel/halving.py create mode 100644 btcmodel/models/__init__.py create mode 100644 btcmodel/models/baselines.py create mode 100644 btcmodel/models/cycle.py create mode 100644 btcmodel/plots.py create mode 100644 data/coinbase.csv rename prices.csv => data/investing.csv (100%) rename moneyshot.png => docs/2024-forecast.png (100%) create mode 100644 flake.lock create mode 100644 flake.nix delete mode 100644 model.py delete mode 100644 poetry.lock create mode 100644 tests/test_forecast.py create mode 100644 tests/test_models.py diff --git a/.gitignore b/.gitignore index 798dffa..21ab1d1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,8 @@ -bitcoin_*.png -bitcoin_*.txt /output +__pycache__/ +.pytest_cache/ +.ruff_cache/ +.venv/ +result scratch.py +.DS_Store diff --git a/NOTES.md b/NOTES.md deleted file mode 100644 index e9b90ff..0000000 --- a/NOTES.md +++ /dev/null @@ -1,361 +0,0 @@ -# Bitcoin Price Model Documentation - -## Model Overview -A probabilistic price projection model combining log returns analysis, cycle awareness, and Monte Carlo simulation. The model generates projected price ranges with confidence intervals, balancing short-term market dynamics with long-term cyclical patterns. - -## Core Design Principles - -### 1. Return Analysis -- Uses log returns for better handling of exponential growth -- Combines multiple timeframes for volatility estimation -- Implements adaptive window sizing based on market conditions -- Handles volatility clustering through regime-aware adjustments - -### 2. Cycle Integration -- Recognizes Bitcoin's ~4 year (1460 day) halving cycle -- Maps historical returns to cycle positions (0-1 scale) -- Adjusts expectations based on position in cycle -- Handles transitions between cycles with uncertainty scaling - -### 3. Market Era Recognition -Three distinct eras with specific characteristics: -- Early (2013-2017): Higher base volatility, conservative trends -- Transition (2017-2020): Futures market introduction period -- Mature (2020+): Institutional participation, reduced base volatility - -### 4. Uncertainty Estimation -- Generates both point estimates and confidence intervals -- Adapts uncertainty based on market conditions -- Uses asymmetric volatility response -- Implements dynamic confidence interval calibration - -## Architecture - -### Key Components -1. **Trend Analysis (`analyze_trends`)** - - Calculates cycle-position-specific returns - - Applies position-aware smoothing - - Handles cycle boundaries - -2. **Volatility Estimation (`calculate_volatility`)** - - Adaptive window sizing - - Multi-timeframe integration - - Era-specific scaling - - Regime detection and response - -3. **Price Projection (`project_prices`)** - - Monte Carlo simulation engine - - Dynamic uncertainty scaling - - Confidence interval calculation - - Trend integration - -4. **Projection Adjustment (`get_projection_adjustments`)** - - Time-varying uncertainty scaling - - Market condition response - - Cycle position awareness - - Minimum uncertainty bounds - -# Model Performance & Validation - -## Performance Characteristics - -### Normal Market Conditions -- MAPE: 30-40% typical -- 95% CI Coverage: ~95% -- 68% CI Coverage: ~73% -- Best performance in mature market periods (2020+) -- Most reliable for 3-6 month horizons - -### Stress Periods -- MAPE: 30-60% -- 95% CI Coverage: ~95% -- 68% CI Coverage: ~76% -- Wider but well-calibrated confidence intervals -- Maintains reliability through increased uncertainty - -### Key Strengths -1. Consistent confidence interval coverage -2. Rapid adaptation to volatility changes -3. Robust handling of cycle transitions -4. Well-calibrated uncertainty estimates - -### Known Limitations -1. Higher error during market structure changes -2. Increased uncertainty in early cycle periods -3. Limited incorporation of external factors -4. May underestimate extreme events - -## Validation Framework - -### Backtest Configuration -- Minimum training period: 8 years -- Validation period: 2 years -- Rolling window approach -- Separate evaluation of normal/stress periods - -### Key Test Periods -1. **Cycle Transitions** - - Pre/post halving periods - - Historical halvings (2016, 2020, 2024) - - Cycle peak/trough transitions - -2. **Market Structure Changes** - - Futures introduction (2017) - - Institution adoption (2020-2021) - - Major market events (e.g., COVID crash) - -3. **Recent History** - - 2021 bull market - - 2022 drawdown - - 2024 recovery - -### Validation Metrics -1. **Accuracy Measures** - - MAPE (Mean Absolute Percentage Error) - - RMSE (Root Mean Square Error) - - Maximum deviation - -2. **Calibration Measures** - - Confidence interval coverage - - Uncertainty estimation accuracy - - Regime transition handling - -3. **Stability Measures** - - Parameter sensitivity - - Training period dependence - - Regime change response - -# Technical Implementation - -## Core Functions - -### Volatility Calculation -```python -def calculate_volatility(df, short_window=30, medium_window=90, long_window=180): - """ - Adaptive volatility calculation combining multiple timeframes. - - Features: - - Dynamic window sizing based on market conditions - - Era-specific scaling factors - - Regime-aware adjustments - - Robust error handling and fallbacks - """ -``` - -Key parameters: -- `short_window`: Fast response (default 30 days) -- `medium_window`: Primary estimate (default 90 days) -- `long_window`: Stability baseline (default 180 days) - -Adaptive features: -- Windows shrink in high volatility periods -- Expand during low volatility -- Minimum size constraints for stability -- Weighted combination based on regime - -### Cycle Position -```python -def get_cycle_position(date, halving_dates): - """ - Calculate position in halving cycle (0 to 1). - 0 = halving event - 1 = just before next halving - """ -``` - -Position calculation: -- Linear interpolation between halvings -- Special handling for pre-first-halving -- Extension mechanism for future cycles -- Built-in boundary condition handling - -### Price Projection -```python -def project_prices(df, days_forward=365, simulations=1000, - confidence_levels=[0.95, 0.68]): - """ - Generate price projections with confidence intervals. - - Core simulation parameters: - - Number of paths: 1000 - - Confidence levels: 95% and 68% - - Dynamic uncertainty scaling - """ -``` - -## Data Requirements - -### Input Data -Minimum fields: -- Date -- Close price -- Trading volume (optional) -- High/Low (optional) - -Format requirements: -- Daily data preferred -- Sorted chronologically -- No missing dates -- Prices > 0 - -### Training Data -Minimum requirements: -- 2 years for basic operation -- 8 years recommended -- Must include at least one cycle transition -- Should span multiple market regimes - -## Error Handling - -### Data Validation -- Missing value detection and interpolation -- Outlier identification -- Zero/negative price handling -- Volume anomaly detection - -### Runtime Guards -- Minimum data length checks -- Window size validation -- Numerical stability checks -- Regime transition handling - -### Fallback Mechanisms -1. Simple volatility calculation -2. Default uncertainty estimates -3. Conservative parameter sets -4. Standard cycle assumption - -## Memory and Performance - -### Optimization Features -- Efficient numpy operations -- Vectorized calculations where possible -- Smart data windowing -- Caching of intermediate results - -### Resource Usage -Typical requirements for 10-year dataset: -- Memory: ~100MB -- CPU: ~2-5 seconds per projection -- Storage: Negligible - -### Parallelization -- Multiprocessing support for backtests -- Independent path simulation -- Multiple period analysis -- Backtest parallelization - -# Development History & Evolution - -## Major Versions - -### Version 1.0 (Initial Implementation) -- Basic log return analysis -- Fixed volatility windows -- Simple cycle position calculation -- Base Monte Carlo simulation - -### Version 2.0 (Market Structure) -- Added era-based adjustments -- Improved cycle handling -- Multiple timeframe volatility -- Enhanced Monte Carlo engine - -### Version 3.0 (Current) -- Adaptive volatility windows -- Dynamic uncertainty scaling -- Improved regime detection -- Enhanced confidence interval calibration - -## Key Improvements - -### Volatility Estimation -1. **Fixed → Adaptive Windows** - - Initial: Fixed 30/90/180 day windows - - Current: Dynamic sizing based on regime - - Result: Better regime transition handling - -2. **Uncertainty Calibration** - - Initial: Fixed scaling factors - - Current: Market-aware dynamic scaling - - Result: More reliable confidence intervals - -3. **Era Recognition** - - Initial: Single model for all periods - - Current: Era-specific adjustments - - Result: Better handling of market evolution - -### Simulation Engine -1. **Path Generation** - - Initial: Basic random walks - - Current: Regime-aware path simulation - - Result: More realistic price trajectories - -2. **Confidence Intervals** - - Initial: Fixed width - - Current: Dynamic, asymmetric intervals - - Result: Better calibrated uncertainty - -## Failed Experiments - -### 1. Complex Regime Detection -- Attempted multiple indicator fusion -- Added excessive complexity -- Reduced model stability -- Reverted to simpler approach - -### 2. Machine Learning Integration -- Tested neural network components -- Reduced interpretability -- Inconsistent improvements -- Kept traditional statistical approach - -### 3. External Factor Integration -- Tried incorporating macro indicators -- Added noise to projections -- Complicated parameter estimation -- Maintained focus on price dynamics - -## Recent Improvements (2024) - -### Adaptive Volatility Windows -- Implementation: Dynamic window sizing -- Purpose: Better regime handling -- Results: - - Improved 95% CI coverage to ~95% - - Better stress period handling - - More reliable uncertainty estimates - -### Performance Metrics -Normal Periods: -- MAPE: 39.9% -- RMSE: $12,007 -- 95% CI Coverage: 95.9% -- 68% CI Coverage: 72.5% - -Stress Periods: -- MAPE: 32.8% -- RMSE: $12,794 -- 95% CI Coverage: 95.2% -- 68% CI Coverage: 76.1% - -## Future Directions - -### Short Term -1. Fine-tune adaptive parameters -2. Improve transition period handling -3. Enhanced backtest framework -4. Additional regime indicators - -### Medium Term -1. Cycle strength indicators -2. Volume analysis integration -3. Improved documentation -4. Performance optimization - -### Long Term -1. Real-time adaptation framework -2. Advanced regime detection -3. Market microstructure integration -4. External API integration diff --git a/README.md b/README.md index e672fa0..f123895 100644 --- a/README.md +++ b/README.md @@ -6,18 +6,108 @@ **Don't take this seriously. It's all in good fun.** -I decided to have fun and ask Anthropic's Claude AI (3.6 Sonnet) to help build -a Bitcoin price model, using data from [Investing.com](https://www.investing.com/crypto/bitcoin/historical-data). +## The 2024 edition -The model is still a mess: lots of redundant code as we went through various -methods for projecting future prices; the plots' colors don't render correctly. +In November 2024 I asked Claude (3.6 Sonnet, via copy and paste in Claude Web) +to help build a Bitcoin price model. After a lot of branching it produced ~2000 +lines of cycle analysis, "market fundamentals", era adjustments and Monte Carlo, +and forecasts like this one (as of 2024-11-14): -I feel like I'm dangerous enough to know what to ask for out of a model, but -not knowledgeable enough evaluate whether what Claude produced actually makes -any sense. (Stats class in college was a long time ago...) +![moooooon](docs/2024-forecast.png) -## tl;dr show me the projection! +In September 2026 a newer Claude scored the forecasts it made on 2024-11-27 +against what actually happened: -As of writing (2024-11-14), here is what the model generates: +| | 2024 forecast (median) | Actual | +|---|---|---| +| Cycle top | 2025-10-21, $199K | 2025-10-06, $124.7K | +| 2026-06-30 | $133K (95% floor $65K) | $58.5K | +| 2026-09-23 | $124K | $84.4K | -![moooooon](./moneyshot.png) +It called the timing of the top within 15 days, 19 months ahead, but put the +level ~60% too high. "The price stays at $92.7K" was a better forecast (18% +mean error against 68%). Its "95%" intervals were really ~81% intervals past a +year out, thanks to a fudge factor that narrowed them at long horizons. + +That code lives in the jj/git history. This is the rewrite. + +## How it works now + +A forecast is a probability distribution of the price at each horizon, not a +line. Every model produces quantiles of log price, and every model is scored +the same way: + +- **Walk-forward.** Every 30 days from 2014 on, each model sees only the data up + to that day and forecasts 1 month to 4 years ahead. +- **CRPS.** Each forecast is scored against what happened with the continuous + ranked probability score, in log-price units (0.1 ≈ "typically 10% off"). It + rewards being sharp and being calibrated at once, and can't be gamed by + narrowing or widening intervals. +- **Baselines.** Skill is reported relative to a zero-drift random walk, with a + 90% block-bootstrap interval. Nearby forecasts overlap heavily, so the report + also shows `windows`: the number of genuinely independent outcomes. +- **Holdout.** Development only sees data up to 2024-11-26, the last day the + 2024 model saw. Everything after it is held out, and is scored only by + `just holdout`, once per round of model changes. (Caveat: we already know + roughly what happened in 2025-26, so it isn't perfectly blind.) + +### Models + +- `random_walk`: zero drift; "it stays about here, give or take". +- `drift_rw`: drift equal to the last four years' average; "it keeps doing what + it did last cycle". +- `cycle`: the 2024 model's one real idea. Expected return depends on the day of + the halving cycle, estimated from past cycles, with recent cycles weighted + more. + +### Findings so far (development data) + +- Nothing beats the random walk with any confidence at any horizon. At 2-4 + years there are only 3-5 independent outcomes in the whole history. +- `drift_rw` leads at 2-4 years (+13-18% skill, but the intervals span zero). +- `cycle` loses to both at every horizon, and so does every setting tried + (recency half-life 0.25-2 cycles, smoothing bandwidth 15-60 days). The cycle + *shape* costs accuracy. The level is the problem: each cycle has grown less + than the last (log return 4.0, 2.6, 2.0, i.e. roughly ×55, ×13, ×7), so any + average of past cycles overshoots. + +That points at diminishing returns as the structure worth modelling, e.g. a +power-law trend, which is next. + +## Usage + +With Nix: + +```sh +nix develop +just update # fetch new daily prices from Coinbase +just backtest # score models on development data -> output/backtest/ +just forecast # forecast from the latest price -> output/forecast/ +just test +just holdout # score on held-out outcomes; sparingly +``` + +Without Nix, any Python 3.13 with numpy, pandas 3, scipy and matplotlib works: +`python -m btcmodel --help`. + +## Layout + +``` +btcmodel/ + data.py price loading (Investing.com archive + Coinbase), dev cutoff + halving.py halving calendar, position in cycle + forecast.py Forecast (quantiles of log price), CRPS, PIT + evaluate.py walk-forward backtest and summary + models/ one file per model family; register new ones in __init__.py + plots.py fan chart, skill and calibration charts +``` + +A model is any object with a `name` and +`forecast(history, horizons) -> Forecast`. `history` is a date-indexed frame +holding everything up to the forecast origin and nothing after it. Other data +sources (hash rate, on-chain metrics, macro series) can join it as extra +columns. Anything published with a lag must be shifted to the date it was +actually available, or the backtest will quietly cheat. + +Price data: [Investing.com](https://www.investing.com/crypto/bitcoin/historical-data) +through 2024-11-26, then Coinbase Exchange daily closes (UTC). diff --git a/btcmodel/__init__.py b/btcmodel/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/btcmodel/__main__.py b/btcmodel/__main__.py new file mode 100644 index 0000000..54a4f67 --- /dev/null +++ b/btcmodel/__main__.py @@ -0,0 +1,100 @@ +""" +Command line entry point. + + python -m btcmodel update fetch new daily prices from Coinbase + python -m btcmodel backtest score models on development data + python -m btcmodel backtest --holdout score models on outcomes after DEV_CUTOFF + python -m btcmodel forecast forecast from the latest price +""" + +import argparse +from pathlib import Path + +import numpy as np +import pandas as pd + +from . import data, evaluate, plots +from .models import MODELS + +FORECAST_REPORT_HORIZONS = (182, 365, 730, 1095, 1460) +FORECAST_REPORT_LEVELS = (0.05, 0.25, 0.5, 0.75, 0.95) + + +def main() -> None: + parser = argparse.ArgumentParser(prog="btcmodel", description="Bitcoin price model") + parser.add_argument("-o", "--output", type=Path, default=Path("output")) + parser.add_argument("-m", "--models", nargs="+", choices=list(MODELS), default=list(MODELS)) + commands = parser.add_subparsers(dest="command", required=True) + commands.add_parser("update", help="fetch new daily prices") + backtest = commands.add_parser("backtest", help="walk-forward evaluation") + backtest.add_argument( + "--holdout", + action="store_true", + help=f"score outcomes after {data.DEV_CUTOFF:%Y-%m-%d} (don't use while developing)", + ) + commands.add_parser("forecast", help="forecast from the latest price") + args = parser.parse_args() + models = [MODELS[name] for name in args.models] + + if args.command == "update": + added = data.update_coinbase() + print(f"added {added} days; latest {data.load_prices().index[-1]:%Y-%m-%d}") + elif args.command == "backtest": + run_backtest(models, args.output, args.holdout) + elif args.command == "forecast": + run_forecast(models, args.output) + + +def run_backtest(models, output: Path, holdout: bool) -> None: + if holdout: + name, prices = "holdout", data.load_prices() + scores = evaluate.backtest(models, prices, score_after=data.DEV_CUTOFF) + else: + name, prices = "backtest", data.load_prices(until=data.DEV_CUTOFF) + scores = evaluate.backtest(models, prices) + out = output / name + out.mkdir(parents=True, exist_ok=True) + + summary = evaluate.summarize(scores) + report = ( + f"{name}: origins every {evaluate.ORIGIN_STEP_DAYS} days from " + f"{evaluate.FIRST_ORIGIN:%Y-%m-%d}, outcomes through {prices.index[-1]:%Y-%m-%d}\n\n" + + evaluate.format_summary(summary) + ) + print(report) + (out / "report.txt").write_text(report + "\n") + scores.to_csv(out / "scores.csv", index=False) + summary.to_csv(out / "summary.csv", index=False) + plots.skill_chart(summary, out / "skill.png", f"{name}: skill by horizon") + plots.calibration_chart(summary, out / "calibration.png", f"{name}: interval coverage") + print(f"\nwrote {out}/") + + +def run_forecast(models, output: Path) -> None: + prices = data.load_prices() + horizons = np.arange(1, max(FORECAST_REPORT_HORIZONS) + 1) + forecasts = {m.name: m.forecast(prices, horizons) for m in models} + out = output / "forecast" + out.mkdir(parents=True, exist_ok=True) + + rows = [] + for name, f in forecasts.items(): + for h in FORECAST_REPORT_HORIZONS: + row = {"model": name, "date": f.dates[h - 1].date(), "horizon": h} + for level in FORECAST_REPORT_LEVELS: + row[f"p{level * 100:02.0f}"] = np.exp(f.quantile(level)[h - 1]) + rows.append(row) + table = pd.DataFrame(rows) + table.to_csv(out / "forecast.csv", index=False) + + shown = table.copy() + for column in shown.columns[3:]: + shown[column] = shown[column].map(plots.price_formatter) + print(f"from {prices.index[-1]:%Y-%m-%d} at {plots.price_formatter(prices.close.iloc[-1])}\n") + print(shown.to_string(index=False)) + plots.fan_chart(prices, forecasts, out / "fan.png") + print(f"\nwrote {out}/") + + +if __name__ == "__main__": + main() diff --git a/btcmodel/data.py b/btcmodel/data.py new file mode 100644 index 0000000..826b9a0 --- /dev/null +++ b/btcmodel/data.py @@ -0,0 +1,117 @@ +"""Daily BTC-USD closing prices. + +Two sources, stitched at ARCHIVE_END: + +- data/investing.csv: the original Investing.com download (2010-07-18 onward). + Its last row (2024-11-27) was an intraday snapshot, so it is cut a day early. +- data/coinbase.csv: Coinbase Exchange daily candles (UTC days), appended by + `python -m btcmodel update`. + +Everything up to ARCHIVE_END is development data. Everything after it is the +holdout: outcomes nobody had seen while the 2024 model was being built, and which +model development here must not look at (see evaluate.py). +""" + +import datetime as dt +import json +import urllib.request +from pathlib import Path + +import numpy as np +import pandas as pd + +DATA_DIR = Path(__file__).resolve().parent.parent / "data" +ARCHIVE_CSV = DATA_DIR / "investing.csv" +COINBASE_CSV = DATA_DIR / "coinbase.csv" + +ARCHIVE_END = pd.Timestamp("2024-11-26") +DEV_CUTOFF = ARCHIVE_END + +# 2010 has four distinct prices and no change on 89% of days; it is noise. +DATA_START = pd.Timestamp("2011-01-01") + +COINBASE_URL = "https://api.exchange.coinbase.com/products/BTC-USD/candles" +COINBASE_MAX_CANDLES = 300 + + +def load_prices( + until: pd.Timestamp | str | None = None, start: pd.Timestamp | str = DATA_START +) -> pd.DataFrame: + """ + Load daily closes as a frame indexed by date, with a single `close` column. + + Models receive a prefix of this frame, so additional data sources can be + joined in as extra columns later without changing the model interface. + """ + archive = _read_archive() + parts = [archive[archive.index <= ARCHIVE_END]] + if COINBASE_CSV.exists(): + coinbase = pd.read_csv(COINBASE_CSV, index_col="date", parse_dates=["date"]) + parts.append(coinbase.loc[coinbase.index > ARCHIVE_END, ["close"]]) + df = pd.concat(parts).sort_index() + + df = df[df.index >= pd.Timestamp(start)] + if until is not None: + df = df[df.index <= pd.Timestamp(until)] + + expected = pd.date_range(df.index[0], df.index[-1], freq="D") + missing = expected.difference(df.index) + if len(missing): + raise ValueError(f"{len(missing)} missing days, first {missing[0].date()}") + if not (df["close"] > 0).all(): + raise ValueError("non-positive closing price") + return df + + +def log_returns(df: pd.DataFrame) -> pd.Series: + """Daily log returns of the close, without the leading NaN.""" + return np.log(df["close"]).diff().iloc[1:] + + +def _read_archive() -> pd.DataFrame: + raw = pd.read_csv(ARCHIVE_CSV, encoding="utf-8-sig", thousands=",") + dates = pd.to_datetime(raw["Date"], format="%m/%d/%Y") + return pd.DataFrame({"close": raw["Price"].astype(float).values}, index=dates.rename("date")) + + +def update_coinbase() -> int: + """Append completed daily candles since the last stored day. Returns rows added.""" + if COINBASE_CSV.exists(): + existing = pd.read_csv(COINBASE_CSV, index_col="date", parse_dates=["date"]) + first = existing.index.max() + pd.Timedelta(days=1) + else: + existing = None + first = ARCHIVE_END + pd.Timedelta(days=1) + # Today's candle is still forming; only take finished UTC days. + last = pd.Timestamp(dt.datetime.now(dt.UTC).date()) - pd.Timedelta(days=1) + + rows = [] + chunk_start = first + while chunk_start <= last: + chunk_end = min(chunk_start + pd.Timedelta(days=COINBASE_MAX_CANDLES - 1), last) + rows.extend(_fetch_candles(chunk_start, chunk_end)) + chunk_start = chunk_end + pd.Timedelta(days=1) + if not rows: + return 0 + + new = pd.DataFrame(rows, columns=["date", "close"]).set_index("date").sort_index() + new = new[(new.index >= first) & (new.index <= last)] + combined = new if existing is None else pd.concat([existing, new]) + combined = combined[~combined.index.duplicated(keep="last")].sort_index() + combined.to_csv(COINBASE_CSV, date_format="%Y-%m-%d") + return len(new) + + +def _fetch_candles(start: pd.Timestamp, end: pd.Timestamp) -> list[tuple[pd.Timestamp, float]]: + url = ( + f"{COINBASE_URL}?granularity=86400" + f"&start={start:%Y-%m-%d}T00:00:00Z&end={end:%Y-%m-%d}T00:00:00Z" + ) + # Coinbase rejects requests without a User-Agent. + request = urllib.request.Request(url, headers={"User-Agent": "btcmodel"}) + with urllib.request.urlopen(request, timeout=30) as response: + candles = json.load(response) + # Each candle is [time, low, high, open, close, volume]. + return [ + (pd.Timestamp(dt.datetime.fromtimestamp(c[0], dt.UTC).date()), float(c[4])) for c in candles + ] diff --git a/btcmodel/evaluate.py b/btcmodel/evaluate.py new file mode 100644 index 0000000..c2efc61 --- /dev/null +++ b/btcmodel/evaluate.py @@ -0,0 +1,123 @@ +""" +Walk-forward evaluation. + +From each origin (every ORIGIN_STEP_DAYS from FIRST_ORIGIN), each model sees the +data up to that day only and forecasts every horizon. Each forecast whose target +date has been observed is scored against what happened. + +Development runs load data only up to DEV_CUTOFF, so outcomes after it cannot +influence model design. The holdout run scores only targets after DEV_CUTOFF. +""" + +import numpy as np +import pandas as pd + +from .forecast import crps, pit +from .models import BASELINE + +HORIZONS = np.array([30, 91, 182, 365, 730, 1095, 1460]) +FIRST_ORIGIN = pd.Timestamp("2014-01-01") +ORIGIN_STEP_DAYS = 30 +COVERAGES = (0.5, 0.8, 0.95) + + +def backtest( + models, data: pd.DataFrame, horizons=HORIZONS, score_after: pd.Timestamp | None = None +) -> pd.DataFrame: + """One row per (model, origin, horizon) with an observed outcome.""" + log_close = np.log(data["close"]) + last = data.index[-1] + rows = [] + for origin in pd.date_range(FIRST_ORIGIN, last, freq=f"{ORIGIN_STEP_DAYS}D"): + targets = origin + pd.to_timedelta(horizons, unit="D") + scored = targets <= last + if score_after is not None: + scored &= targets > score_after + if not scored.any(): + continue + history = data.loc[:origin] + outcome = log_close.loc[targets[scored]].to_numpy() + for model in models: + f = model.forecast(history, horizons[scored]) + row = { + "model": model.name, + "origin": origin, + "horizon": f.horizons, + "outcome": outcome, + "median": f.quantile(0.5), + "crps": crps(f.log_quantiles, outcome), + "pit": pit(f.log_quantiles, outcome), + } + for c in COVERAGES: + lo, hi = f.interval(c) + row[f"in{c:.0%}"] = (lo <= outcome) & (outcome <= hi) + rows.append(pd.DataFrame(row)) + return pd.concat(rows, ignore_index=True) + + +def summarize(scores: pd.DataFrame, n_boot: int = 2000, seed: int = 0) -> pd.DataFrame: + """ + Per model and horizon: mean CRPS, skill relative to the baseline, and coverage. + + Skill is 1 - CRPS / baseline CRPS (positive = better than the baseline), + with a 90% moving-block bootstrap interval over origins. Forecasts from + nearby origins overlap heavily, so `windows` (the span covered divided by + the horizon) is the honest count of independent outcomes. Treat intervals + with fewer than ~5 windows as optimistic. + """ + rng = np.random.default_rng(seed) + rows = [] + for horizon, at_h in scores.groupby("horizon"): + base = at_h[at_h.model == BASELINE].set_index("origin")["crps"].sort_index() + span = (base.index[-1] - base.index[0]).days + horizon + block = max(1, min(int(np.ceil(horizon / ORIGIN_STEP_DAYS)), len(base) // 2)) + boot_index = _block_bootstrap_indices(len(base), block, n_boot, rng) + for model, g in at_h.groupby("model", sort=False): + m = g.set_index("origin")["crps"].reindex(base.index).to_numpy() + boot = 1 - m[boot_index].mean(axis=1) / base.to_numpy()[boot_index].mean(axis=1) + rows.append( + { + "model": model, + "horizon": horizon, + "forecasts": len(g), + "windows": span / horizon, + "crps": g["crps"].mean(), + "skill": 1 - m.mean() / base.mean(), + "skill_lo": np.quantile(boot, 0.05), + "skill_hi": np.quantile(boot, 0.95), + **{f"cov{c:.0%}": g[f"in{c:.0%}"].mean() for c in COVERAGES}, + "mean_pit": g["pit"].mean(), + } + ) + return pd.DataFrame(rows) + + +def _block_bootstrap_indices(n, block, n_boot, rng) -> np.ndarray: + """Circular block bootstrap, so the first and last origins aren't under-sampled.""" + n_blocks = int(np.ceil(n / block)) + starts = rng.integers(0, n, size=(n_boot, n_blocks)) + return ((starts[:, :, None] + np.arange(block)) % n).reshape(n_boot, -1)[:, :n] + + +def format_summary(summary: pd.DataFrame) -> str: + table = pd.DataFrame( + { + "model": summary["model"], + "horizon": summary["horizon"].map(horizon_label), + "windows": summary["windows"].map("{:.1f}".format), + "crps": summary["crps"].map("{:.3f}".format), + "skill vs rw [90%]": [ + f"{s:+.0%} [{lo:+.0%}, {hi:+.0%}]" + for s, lo, hi in zip(summary.skill, summary.skill_lo, summary.skill_hi, strict=True) + ], + **{f"in {c:.0%}": summary[f"cov{c:.0%}"].map("{:.0%}".format) for c in COVERAGES}, + "mean pit": summary["mean_pit"].map("{:.2f}".format), + } + ) + return table.to_string(index=False) + + +def horizon_label(days: int) -> str: + if days < 365: + return f"{round(days / 30.4)}mo" + return f"{round(days / 365)}y" diff --git a/btcmodel/forecast.py b/btcmodel/forecast.py new file mode 100644 index 0000000..200931a --- /dev/null +++ b/btcmodel/forecast.py @@ -0,0 +1,76 @@ +""" +The one output format every model produces, and how it is scored. + +A forecast is a set of quantiles of the natural-log price at each horizon. +Quantiles work for any model (closed-form, simulated, bootstrapped, mixtures) +and make scoring simple. Working in log price makes errors relative: a CRPS of +0.1 is roughly "typically 10% off", in 2013 or in 2026. +""" + +from dataclasses import dataclass + +import numpy as np +import pandas as pd +from scipy.stats import norm + +N_LEVELS = 100 +# Midpoints of 100 equal-probability bins: 0.005, 0.015, ..., 0.995. +LEVELS = (np.arange(N_LEVELS) + 0.5) / N_LEVELS + + +@dataclass(frozen=True) +class Forecast: + origin: pd.Timestamp + horizons: np.ndarray # days after origin, shape (H,) + log_quantiles: np.ndarray # log price at LEVELS, shape (H, N_LEVELS), rows nondecreasing + + @classmethod + def normal(cls, origin, horizons, mean, sd) -> "Forecast": + """Normal distribution in log price (i.e. lognormal price) at each horizon.""" + mean = np.broadcast_to(np.asarray(mean, dtype=float), np.shape(horizons)) + sd = np.broadcast_to(np.asarray(sd, dtype=float), np.shape(horizons)) + q = mean[:, None] + sd[:, None] * norm.ppf(LEVELS)[None, :] + return cls(pd.Timestamp(origin), np.asarray(horizons), q) + + @classmethod + def from_samples(cls, origin, horizons, samples) -> "Forecast": + """Empirical quantiles of simulated log prices, shape (n_samples, H).""" + q = np.quantile(np.asarray(samples), LEVELS, axis=0).T + return cls(pd.Timestamp(origin), np.asarray(horizons), q) + + @property + def dates(self) -> pd.DatetimeIndex: + return self.origin + pd.to_timedelta(self.horizons, unit="D") + + def quantile(self, level: float) -> np.ndarray: + """Log price at an arbitrary level, interpolated between grid levels.""" + return np.array([np.interp(level, LEVELS, row) for row in self.log_quantiles]) + + def interval(self, coverage: float) -> tuple[np.ndarray, np.ndarray]: + """Central interval in log price holding `coverage` probability.""" + tail = (1 - coverage) / 2 + return self.quantile(tail), self.quantile(1 - tail) + + +def crps(log_quantiles: np.ndarray, outcome: np.ndarray) -> np.ndarray: + """ + Continuous ranked probability score, from quantiles, in log-price units. + + CRPS is twice the pinball loss integrated over all quantile levels; the + quantile grid gives the integral directly. It rewards sharpness and + calibration together and has no free parameters to game. Lower is better. + """ + outcome = np.asarray(outcome, dtype=float) + u = outcome[..., None] - log_quantiles + pinball = u * (LEVELS - (u < 0)) + return 2 * pinball.mean(axis=-1) + + +def pit(log_quantiles: np.ndarray, outcome: np.ndarray) -> np.ndarray: + """Probability integral transform: forecast CDF evaluated at the outcome.""" + return np.array( + [ + np.interp(y, q, LEVELS, left=0.0, right=1.0) + for q, y in zip(np.atleast_2d(log_quantiles), np.atleast_1d(outcome), strict=True) + ] + ) diff --git a/btcmodel/halving.py b/btcmodel/halving.py new file mode 100644 index 0000000..f0f58b9 --- /dev/null +++ b/btcmodel/halving.py @@ -0,0 +1,34 @@ +"""Halving calendar and position within the halving cycle.""" + +import numpy as np +import pandas as pd + +GENESIS = pd.Timestamp("2009-01-03") + +# Block heights 210k, 420k, 630k, 840k (UTC dates). +HALVINGS = pd.DatetimeIndex(["2012-11-28", "2016-07-09", "2020-05-11", "2024-04-20"]) + +# Later halvings are projected at the length of the last cycle. Block times drift +# by weeks per cycle, which is noise at the resolution this is used. +_LAST_CYCLE = HALVINGS[-1] - HALVINGS[-2] +_PROJECTED = pd.DatetimeIndex([HALVINGS[-1] + k * _LAST_CYCLE for k in range(1, 6)]) + +# Genesis starts cycle 0. +CYCLE_STARTS = pd.DatetimeIndex([GENESIS]).append(HALVINGS).append(_PROJECTED) + + +def cycle_position(dates) -> tuple[np.ndarray, np.ndarray]: + """ + For each date, return (cycle index, days since that cycle began). + + Cycle 0 runs from genesis to the first halving; a halving day is day 0 of + the cycle it starts. + """ + dates = pd.DatetimeIndex(dates) + if (dates < GENESIS).any(): + raise ValueError("date before genesis") + if (dates >= CYCLE_STARTS[-1]).any(): + raise ValueError("date beyond projected halvings") + index = CYCLE_STARTS.searchsorted(dates, side="right") - 1 + days = (dates - CYCLE_STARTS[index]).days + return np.asarray(index), np.asarray(days) diff --git a/btcmodel/models/__init__.py b/btcmodel/models/__init__.py new file mode 100644 index 0000000..54657c2 --- /dev/null +++ b/btcmodel/models/__init__.py @@ -0,0 +1,15 @@ +""" +Candidate models. + +A model is any object with a `name` and a +`forecast(history: pd.DataFrame, horizons: np.ndarray) -> Forecast` method. +`history` holds every row up to and including the forecast origin and nothing +after it; the harness guarantees that, so models can use all of it freely. +""" + +from .baselines import DriftRandomWalk, RandomWalk +from .cycle import CycleModel + +# Order is fixed: it sets each model's colour in every chart. +MODELS = {m.name: m for m in (RandomWalk(), DriftRandomWalk(), CycleModel())} +BASELINE = RandomWalk.name diff --git a/btcmodel/models/baselines.py b/btcmodel/models/baselines.py new file mode 100644 index 0000000..3ec3db1 --- /dev/null +++ b/btcmodel/models/baselines.py @@ -0,0 +1,47 @@ +"""Reference forecasts every other model has to beat.""" + +from dataclasses import dataclass +from typing import ClassVar + +import numpy as np +import pandas as pd + +from ..data import log_returns +from ..forecast import Forecast + + +@dataclass(frozen=True) +class RandomWalk: + """ + Zero-drift random walk in log price: "it stays about here, give or take". + + Volatility is the trailing standard deviation of daily log returns. + """ + + name: ClassVar[str] = "random_walk" + vol_window: int = 365 + + def forecast(self, history: pd.DataFrame, horizons: np.ndarray) -> Forecast: + sigma = log_returns(history).iloc[-self.vol_window :].std() + mean = np.log(history["close"].iloc[-1]) + return Forecast.normal(history.index[-1], horizons, mean, sigma * np.sqrt(horizons)) + + +@dataclass(frozen=True) +class DriftRandomWalk: + """ + Random walk whose drift is the mean daily log return over the trailing + `drift_window` days (one halving cycle by default): "it keeps doing what it + did last cycle". + """ + + name: ClassVar[str] = "drift_rw" + drift_window: int = 1460 + vol_window: int = 365 + + def forecast(self, history: pd.DataFrame, horizons: np.ndarray) -> Forecast: + returns = log_returns(history) + mu = returns.iloc[-self.drift_window :].mean() + sigma = returns.iloc[-self.vol_window :].std() + mean = np.log(history["close"].iloc[-1]) + mu * horizons + return Forecast.normal(history.index[-1], horizons, mean, sigma * np.sqrt(horizons)) diff --git a/btcmodel/models/cycle.py b/btcmodel/models/cycle.py new file mode 100644 index 0000000..e225dc2 --- /dev/null +++ b/btcmodel/models/cycle.py @@ -0,0 +1,73 @@ +"""The 2024 model, distilled.""" + +from dataclasses import dataclass +from typing import ClassVar + +import numpy as np +import pandas as pd + +from ..data import log_returns +from ..forecast import Forecast +from ..halving import cycle_position + +# Longer than any cycle so far (the longest, cycle 0, is 1425 days). +MAX_CYCLE_DAYS = 1500 + + +@dataclass(frozen=True) +class CycleModel: + """ + Expected return depends on how many days it has been since the last halving. + + The drift for day d of the cycle is a weighted mean of the daily log returns + observed around day d of every past cycle. Of the 2024 model's ~2000 lines, + this idea did all the work. It differs from that model in two ways: + + - Neighbouring cycle days are pooled with a Gaussian kernel. The 2024 model + averaged each day separately and then took a rolling mean. + - Past cycles are down-weighted, halving each `recency_half_life` cycles, so + the 10-100x cycles of 2011-2017 don't set the level. The 2024 model + averaged all cycles equally, then scaled by ~0.7; it overshot the + 2025 peak by ~60%. (This is the idea on the old `tuning-b` branch.) + + Where the data is thin, the drift shrinks toward the overall weighted mean, + as if `prior_days` extra observations sat at that value. Noise is a + constant-volatility random walk, so the distribution is closed-form. + """ + + name: ClassVar[str] = "cycle" + bandwidth_days: float = 30.0 + recency_half_life: float = 1.0 + prior_days: float = 10.0 + vol_window: int = 365 + + def drift_by_cycle_day(self, history: pd.DataFrame) -> np.ndarray: + """Expected daily log return for each day of the cycle, shape (MAX_CYCLE_DAYS,).""" + returns = log_returns(history) + cycle, day = cycle_position(returns.index) + current_cycle = cycle_position(history.index[-1:])[0][0] + weight = 0.5 ** ((current_cycle - cycle) / self.recency_half_life) + + sum_wr = np.bincount(day, weights=weight * returns.values, minlength=MAX_CYCLE_DAYS) + sum_w = np.bincount(day, weights=weight, minlength=MAX_CYCLE_DAYS) + + # Peak-1 kernel, so smoothed weights count (recency-weighted) days of data. + half_width = int(np.ceil(4 * self.bandwidth_days)) + offsets = np.arange(-half_width, half_width + 1) + kernel = np.exp(-0.5 * (offsets / self.bandwidth_days) ** 2) + smooth_wr = np.convolve(sum_wr, kernel, mode="same") + smooth_w = np.convolve(sum_w, kernel, mode="same") + + overall = sum_wr.sum() / sum_w.sum() + return (smooth_wr + self.prior_days * overall) / (smooth_w + self.prior_days) + + def forecast(self, history: pd.DataFrame, horizons: np.ndarray) -> Forecast: + drift = self.drift_by_cycle_day(history) + origin = history.index[-1] + future = origin + pd.to_timedelta(np.arange(1, horizons.max() + 1), unit="D") + _, future_day = cycle_position(future) + cumulative = np.cumsum(drift[future_day]) + + sigma = log_returns(history).iloc[-self.vol_window :].std() + mean = np.log(history["close"].iloc[-1]) + cumulative[horizons - 1] + return Forecast.normal(origin, horizons, mean, sigma * np.sqrt(horizons)) diff --git a/btcmodel/plots.py b/btcmodel/plots.py new file mode 100644 index 0000000..95c49b9 --- /dev/null +++ b/btcmodel/plots.py @@ -0,0 +1,172 @@ +"""Charts. One colour per model, fixed by its position in MODELS.""" + +from pathlib import Path + +import matplotlib + +matplotlib.use("Agg") + +import matplotlib.pyplot as plt # noqa: E402 +import numpy as np # noqa: E402 +import pandas as pd # noqa: E402 +from matplotlib.ticker import FuncFormatter, LogLocator, PercentFormatter # noqa: E402 + +from .evaluate import COVERAGES, horizon_label # noqa: E402 +from .forecast import Forecast # noqa: E402 +from .halving import CYCLE_STARTS # noqa: E402 +from .models import BASELINE, MODELS # noqa: E402 + +SURFACE = "#fcfcfb" +INK = "#0b0b0b" +INK_SECONDARY = "#52514e" +MUTED = "#898781" +GRID = "#e1e0d9" +AXIS = "#c3c2b7" +SERIES = ["#2a78d6", "#eb6834", "#1baf7a", "#eda100", "#e87ba4", "#008300", "#4a3aa7", "#e34948"] + +plt.rcParams.update( + { + "figure.facecolor": SURFACE, + "axes.facecolor": SURFACE, + "savefig.facecolor": SURFACE, + "axes.edgecolor": AXIS, + "axes.linewidth": 0.8, + "axes.labelcolor": INK_SECONDARY, + "axes.titlecolor": INK, + "axes.titlesize": 11, + "axes.titleweight": "semibold", + "axes.titlelocation": "left", + "axes.spines.top": False, + "axes.spines.right": False, + "axes.grid": True, + "grid.color": GRID, + "grid.linewidth": 0.8, + "xtick.color": MUTED, + "ytick.color": MUTED, + "xtick.labelcolor": INK_SECONDARY, + "ytick.labelcolor": INK_SECONDARY, + "legend.frameon": False, + "legend.labelcolor": INK_SECONDARY, + "lines.linewidth": 2, + "lines.solid_capstyle": "round", + "lines.solid_joinstyle": "round", + "font.family": "sans-serif", + "font.size": 9, + } +) + + +def model_color(name: str) -> str: + return SERIES[list(MODELS).index(name) % len(SERIES)] + + +def price_formatter(x, _=None) -> str: + for scale, suffix in ((1e9, "B"), (1e6, "M"), (1e3, "K")): + if x >= scale: + return f"${x / scale:.3g}{suffix}" + return f"${x:.3g}" + + +def fan_chart(history: pd.DataFrame, forecasts: dict[str, Forecast], path: Path) -> None: + """History plus 50/80/95% intervals, one panel per model on shared axes.""" + fig, axes = plt.subplots( + len(forecasts), 1, figsize=(10, 3.2 * len(forecasts)), sharex=True, sharey=True + ) + axes = np.atleast_1d(axes) + shown = history[history.index >= history.index[-1] - pd.Timedelta(days=6 * 365)] + for ax, (name, f) in zip(axes, forecasts.items(), strict=True): + color = model_color(name) + for c in sorted(COVERAGES, reverse=True): + lo, hi = f.interval(c) + ax.fill_between(f.dates, np.exp(lo), np.exp(hi), color=color, alpha=0.1, lw=0) + median = np.exp(f.quantile(0.5)) + ax.plot(shown.index, shown["close"], color=INK, lw=1.2) + ax.plot(f.dates, median, color=color) + ax.annotate( + f"median {price_formatter(median[-1])}", + (f.dates[-1], median[-1]), + xytext=(6, 0), + textcoords="offset points", + va="center", + color=INK_SECONDARY, + ) + for start in CYCLE_STARTS: + if shown.index[0] <= start <= f.dates[-1]: + ax.axvline(start, color=AXIS, lw=0.8, zorder=0) + ax.set_yscale("log") + ax.yaxis.set_major_locator(LogLocator(base=10, subs=(1, 2, 5))) + ax.yaxis.set_major_formatter(FuncFormatter(price_formatter)) + ax.yaxis.set_minor_formatter(FuncFormatter(lambda *_: "")) + ax.set_title(f"{name}: forecast from {f.origin:%Y-%m-%d}") + axes[0].fill_between([], [], color=MUTED, alpha=0.3, label="50% interval") + axes[0].fill_between([], [], color=MUTED, alpha=0.2, label="80% interval") + axes[0].fill_between([], [], color=MUTED, alpha=0.1, label="95% interval") + axes[0].legend(loc="upper left") + axes[-1].set_xlabel("vertical lines: halvings (future ones projected)", color=MUTED) + fig.tight_layout() + fig.savefig(path, dpi=150) + plt.close(fig) + + +def skill_chart(summary: pd.DataFrame, path: Path, title: str) -> None: + """CRPS skill vs the random walk, by horizon, with bootstrap intervals.""" + horizons = sorted(summary["horizon"].unique()) + x = np.arange(len(horizons)) + fig, ax = plt.subplots(figsize=(8, 4.5)) + ax.axhline(0, color=model_color(BASELINE), lw=2, label=BASELINE) + for name, g in summary[summary.model != BASELINE].groupby("model", sort=False): + g = g.set_index("horizon").reindex(horizons) + color = model_color(name) + ax.fill_between(x, g.skill_lo, g.skill_hi, color=color, alpha=0.1, lw=0) + ax.plot(x, g.skill, color=color, marker="o", ms=6, mec=SURFACE, mew=2, label=name) + ax.annotate( + name, + (x[-1], g.skill.iloc[-1]), + xytext=(8, 0), + textcoords="offset points", + va="center", + color=INK_SECONDARY, + ) + ax.set_xticks(x, [horizon_label(h) for h in horizons]) + ax.set_xlabel("forecast horizon") + ax.set_ylabel("CRPS skill vs random walk (higher is better)") + ax.yaxis.set_major_formatter(PercentFormatter(1.0, decimals=0)) + ax.set_title(title) + ax.legend(loc="lower left") + fig.tight_layout() + fig.savefig(path, dpi=150) + plt.close(fig) + + +def calibration_chart(summary: pd.DataFrame, path: Path, title: str) -> None: + """How often each nominal interval contained the outcome, by horizon.""" + horizons = sorted(summary["horizon"].unique()) + x = np.arange(len(horizons)) + fig, axes = plt.subplots(1, len(COVERAGES), figsize=(12, 4), sharey=True) + for ax, c in zip(axes, COVERAGES, strict=True): + ax.axhline(c, color=INK_SECONDARY, lw=1) + ax.annotate( + "target", (x[-1], c), xytext=(0, 4), textcoords="offset points", ha="right", color=MUTED + ) + for name, g in summary.groupby("model", sort=False): + g = g.set_index("horizon").reindex(horizons) + ax.plot( + x, + g[f"cov{c:.0%}"], + color=model_color(name), + marker="o", + ms=6, + mec=SURFACE, + mew=2, + label=name, + ) + ax.set_xticks(x, [horizon_label(h) for h in horizons]) + ax.set_title(f"{c:.0%} interval") + ax.set_ylim(0, 1.02) + ax.yaxis.set_major_formatter(PercentFormatter(1.0, decimals=0)) + axes[0].set_ylabel("share of outcomes inside") + axes[-1].legend(loc="lower left") + fig.suptitle(title, x=0.01, ha="left", color=INK, fontsize=11, fontweight="semibold") + fig.tight_layout() + fig.savefig(path, dpi=150) + plt.close(fig) diff --git a/data/coinbase.csv b/data/coinbase.csv new file mode 100644 index 0000000..09bae26 --- /dev/null +++ b/data/coinbase.csv @@ -0,0 +1,667 @@ +date,close +2024-11-27,95951.19 +2024-11-28,95665.53 +2024-11-29,97490.56 +2024-11-30,96465.42 +2024-12-01,97263.18 +2024-12-02,95862.89 +2024-12-03,95924.52 +2024-12-04,98746.24 +2024-12-05,97044.23 +2024-12-06,99891.35 +2024-12-07,99929.32 +2024-12-08,101174.99 +2024-12-09,97324.81 +2024-12-10,96660.76 +2024-12-11,101202.11 +2024-12-12,100030.47 +2024-12-13,101428.75 +2024-12-14,101399.99 +2024-12-15,104447.76 +2024-12-16,106099.81 +2024-12-17,106136.99 +2024-12-18,100150.73 +2024-12-19,97372.21 +2024-12-20,97765.0 +2024-12-21,97230.08 +2024-12-22,95087.75 +2024-12-23,94764.56 +2024-12-24,98594.47 +2024-12-25,99346.28 +2024-12-26,95669.49 +2024-12-27,94171.89 +2024-12-28,95130.82 +2024-12-29,93563.35 +2024-12-30,92620.71 +2024-12-31,93354.22 +2025-01-01,94383.59 +2025-01-02,96903.19 +2025-01-03,98136.51 +2025-01-04,98209.85 +2025-01-05,98345.33 +2025-01-06,102279.41 +2025-01-07,96941.98 +2025-01-08,95036.63 +2025-01-09,92547.44 +2025-01-10,94701.18 +2025-01-11,94565.02 +2025-01-12,94509.62 +2025-01-13,94506.45 +2025-01-14,96534.96 +2025-01-15,100510.23 +2025-01-16,99981.78 +2025-01-17,104107.0 +2025-01-18,104435.0 +2025-01-19,101211.13 +2025-01-20,102145.43 +2025-01-21,106159.26 +2025-01-22,103667.11 +2025-01-23,103926.36 +2025-01-24,104850.27 +2025-01-25,104733.56 +2025-01-26,102563.0 +2025-01-27,102062.42 +2025-01-28,101290.0 +2025-01-29,103747.25 +2025-01-30,104742.64 +2025-01-31,102411.26 +2025-02-01,100623.85 +2025-02-02,97676.52 +2025-02-03,101460.2 +2025-02-04,97795.05 +2025-02-05,96638.33 +2025-02-06,96564.62 +2025-02-07,96537.08 +2025-02-08,96476.25 +2025-02-09,96475.82 +2025-02-10,97444.41 +2025-02-11,95774.08 +2025-02-12,97862.53 +2025-02-13,96625.29 +2025-02-14,97509.03 +2025-02-15,97596.94 +2025-02-16,96119.88 +2025-02-17,95781.8 +2025-02-18,95607.4 +2025-02-19,96632.03 +2025-02-20,98347.2 +2025-02-21,96157.03 +2025-02-22,96582.12 +2025-02-23,96265.98 +2025-02-24,91510.82 +2025-02-25,88583.74 +2025-02-26,84111.78 +2025-02-27,84625.19 +2025-02-28,84297.73 +2025-03-01,86018.76 +2025-03-02,94265.48 +2025-03-03,86161.1 +2025-03-04,87249.96 +2025-03-05,90603.73 +2025-03-06,89921.85 +2025-03-07,86756.98 +2025-03-08,86206.69 +2025-03-09,80699.17 +2025-03-10,78544.71 +2025-03-11,82914.51 +2025-03-12,83659.43 +2025-03-13,81073.43 +2025-03-14,83980.49 +2025-03-15,84351.46 +2025-03-16,82562.57 +2025-03-17,84011.4 +2025-03-18,82698.76 +2025-03-19,86877.96 +2025-03-20,84183.42 +2025-03-21,84061.96 +2025-03-22,83852.03 +2025-03-23,86092.94 +2025-03-24,87523.62 +2025-03-25,87427.88 +2025-03-26,86926.01 +2025-03-27,87217.48 +2025-03-28,84381.8 +2025-03-29,82616.83 +2025-03-30,82379.98 +2025-03-31,82534.32 +2025-04-01,85170.37 +2025-04-02,82490.08 +2025-04-03,83174.33 +2025-04-04,83860.16 +2025-04-05,83498.25 +2025-04-06,78370.75 +2025-04-07,79140.01 +2025-04-08,76251.64 +2025-04-09,82594.75 +2025-04-10,79552.34 +2025-04-11,83380.47 +2025-04-12,85271.01 +2025-04-13,83733.07 +2025-04-14,84590.36 +2025-04-15,83629.78 +2025-04-16,84028.72 +2025-04-17,84961.97 +2025-04-18,84466.47 +2025-04-19,85074.99 +2025-04-20,85177.34 +2025-04-21,87522.07 +2025-04-22,93489.1 +2025-04-23,93740.92 +2025-04-24,94021.96 +2025-04-25,94708.79 +2025-04-26,94666.64 +2025-04-27,93780.57 +2025-04-28,95042.57 +2025-04-29,94271.09 +2025-04-30,94182.54 +2025-05-01,96524.98 +2025-05-02,96929.81 +2025-05-03,95861.33 +2025-05-04,94272.55 +2025-05-05,94733.99 +2025-05-06,96839.17 +2025-05-07,97058.32 +2025-05-08,103253.49 +2025-05-09,102986.96 +2025-05-10,104821.19 +2025-05-11,104137.62 +2025-05-12,102800.21 +2025-05-13,104131.06 +2025-05-14,103545.38 +2025-05-15,103786.43 +2025-05-16,103500.03 +2025-05-17,103161.07 +2025-05-18,106473.46 +2025-05-19,105616.18 +2025-05-20,106883.24 +2025-05-21,109699.54 +2025-05-22,111722.53 +2025-05-23,107329.61 +2025-05-24,107794.01 +2025-05-25,109048.68 +2025-05-26,109464.32 +2025-05-27,108978.46 +2025-05-28,107819.29 +2025-05-29,105572.58 +2025-05-30,104033.26 +2025-05-31,104645.87 +2025-06-01,105697.94 +2025-06-02,105904.94 +2025-06-03,105447.82 +2025-06-04,104753.38 +2025-06-05,101570.2 +2025-06-06,104397.99 +2025-06-07,105619.02 +2025-06-08,105784.4 +2025-06-09,110301.15 +2025-06-10,110300.24 +2025-06-11,108669.37 +2025-06-12,105721.05 +2025-06-13,106118.7 +2025-06-14,105465.42 +2025-06-15,105599.25 +2025-06-16,106853.38 +2025-06-17,104590.44 +2025-06-18,104915.6 +2025-06-19,104671.9 +2025-06-20,103317.8 +2025-06-21,102160.03 +2025-06-22,100996.87 +2025-06-23,105419.39 +2025-06-24,106141.0 +2025-06-25,107400.22 +2025-06-26,107029.64 +2025-06-27,107113.38 +2025-06-28,107352.2 +2025-06-29,108386.44 +2025-06-30,107173.21 +2025-07-01,105711.78 +2025-07-02,108888.32 +2025-07-03,109628.83 +2025-07-04,108028.6 +2025-07-05,108246.65 +2025-07-06,109217.98 +2025-07-07,108269.84 +2025-07-08,108958.04 +2025-07-09,111282.85 +2025-07-10,116031.3 +2025-07-11,117582.16 +2025-07-12,117472.65 +2025-07-13,119130.81 +2025-07-14,119863.71 +2025-07-15,117781.64 +2025-07-16,118681.68 +2025-07-17,119273.69 +2025-07-18,118023.05 +2025-07-19,117914.17 +2025-07-20,117312.7 +2025-07-21,117428.79 +2025-07-22,120026.42 +2025-07-23,118810.44 +2025-07-24,118397.04 +2025-07-25,117633.27 +2025-07-26,117969.63 +2025-07-27,119465.52 +2025-07-28,118070.59 +2025-07-29,117933.39 +2025-07-30,117830.15 +2025-07-31,115761.13 +2025-08-01,113248.73 +2025-08-02,112542.74 +2025-08-03,114215.73 +2025-08-04,115051.85 +2025-08-05,114112.95 +2025-08-06,115028.83 +2025-08-07,117515.49 +2025-08-08,116683.79 +2025-08-09,116492.51 +2025-08-10,119309.37 +2025-08-11,118701.84 +2025-08-12,120113.18 +2025-08-13,123365.63 +2025-08-14,118389.79 +2025-08-15,117436.96 +2025-08-16,117455.68 +2025-08-17,117488.6 +2025-08-18,116286.76 +2025-08-19,112856.19 +2025-08-20,114276.0 +2025-08-21,112480.3 +2025-08-22,116908.68 +2025-08-23,115383.87 +2025-08-24,113478.0 +2025-08-25,110127.74 +2025-08-26,111788.01 +2025-08-27,111253.21 +2025-08-28,112574.85 +2025-08-29,108378.32 +2025-08-30,108827.93 +2025-08-31,108247.95 +2025-09-01,109240.55 +2025-09-02,111247.94 +2025-09-03,111756.41 +2025-09-04,110720.79 +2025-09-05,110670.02 +2025-09-06,110212.6 +2025-09-07,111129.61 +2025-09-08,112072.57 +2025-09-09,111549.32 +2025-09-10,113983.97 +2025-09-11,115540.0 +2025-09-12,116106.03 +2025-09-13,115968.35 +2025-09-14,115314.13 +2025-09-15,115381.08 +2025-09-16,116832.56 +2025-09-17,116484.4 +2025-09-18,117117.99 +2025-09-19,115690.55 +2025-09-20,115752.4 +2025-09-21,115282.27 +2025-09-22,112736.59 +2025-09-23,112017.21 +2025-09-24,113348.17 +2025-09-25,109035.72 +2025-09-26,109697.29 +2025-09-27,109681.16 +2025-09-28,112197.67 +2025-09-29,114365.07 +2025-09-30,114067.71 +2025-10-01,118659.97 +2025-10-02,120621.32 +2025-10-03,122318.4 +2025-10-04,122458.56 +2025-10-05,123520.79 +2025-10-06,124720.09 +2025-10-07,121393.95 +2025-10-08,123343.25 +2025-10-09,121714.51 +2025-10-10,112980.28 +2025-10-11,110768.89 +2025-10-12,115067.98 +2025-10-13,115274.03 +2025-10-14,113068.0 +2025-10-15,110804.12 +2025-10-16,108198.0 +2025-10-17,106463.3 +2025-10-18,107208.91 +2025-10-19,108676.78 +2025-10-20,110568.06 +2025-10-21,108362.27 +2025-10-22,107585.98 +2025-10-23,110116.03 +2025-10-24,111042.13 +2025-10-25,111666.21 +2025-10-26,114548.09 +2025-10-27,114087.06 +2025-10-28,112906.75 +2025-10-29,110032.13 +2025-10-30,108308.18 +2025-10-31,109555.27 +2025-11-01,110052.25 +2025-11-02,110536.01 +2025-11-03,106557.98 +2025-11-04,101468.15 +2025-11-05,103869.0 +2025-11-06,101290.5 +2025-11-07,103284.27 +2025-11-08,102285.57 +2025-11-09,104694.95 +2025-11-10,105979.38 +2025-11-11,103009.35 +2025-11-12,101639.01 +2025-11-13,99614.54 +2025-11-14,94503.96 +2025-11-15,95544.94 +2025-11-16,94183.97 +2025-11-17,92100.6 +2025-11-18,92914.46 +2025-11-19,91461.6 +2025-11-20,86536.88 +2025-11-21,85068.88 +2025-11-22,84684.0 +2025-11-23,86808.28 +2025-11-24,88266.2 +2025-11-25,87325.01 +2025-11-26,90468.84 +2025-11-27,91316.7 +2025-11-28,90902.7 +2025-11-29,90828.06 +2025-11-30,90369.51 +2025-12-01,86282.36 +2025-12-02,91308.05 +2025-12-03,93453.84 +2025-12-04,92093.54 +2025-12-05,89345.29 +2025-12-06,89267.97 +2025-12-07,90402.29 +2025-12-08,90653.86 +2025-12-09,92690.0 +2025-12-10,92035.99 +2025-12-11,92542.0 +2025-12-12,90278.0 +2025-12-13,90255.32 +2025-12-14,88171.61 +2025-12-15,86417.5 +2025-12-16,87854.01 +2025-12-17,86209.77 +2025-12-18,85460.02 +2025-12-19,88092.65 +2025-12-20,88324.0 +2025-12-21,88639.95 +2025-12-22,88556.77 +2025-12-23,87429.32 +2025-12-24,87609.03 +2025-12-25,87171.68 +2025-12-26,87296.01 +2025-12-27,87808.0 +2025-12-28,87877.29 +2025-12-29,87110.78 +2025-12-30,88397.5 +2025-12-31,87497.94 +2026-01-01,88738.34 +2026-01-02,89957.99 +2026-01-03,90598.02 +2026-01-04,91494.0 +2026-01-05,93870.06 +2026-01-06,93722.01 +2026-01-07,91279.73 +2026-01-08,91026.0 +2026-01-09,90515.45 +2026-01-10,90385.54 +2026-01-11,90872.01 +2026-01-12,91188.09 +2026-01-13,95384.23 +2026-01-14,96955.16 +2026-01-15,95587.65 +2026-01-16,95504.0 +2026-01-17,95109.99 +2026-01-18,93633.53 +2026-01-19,92559.66 +2026-01-20,88310.6 +2026-01-21,89363.71 +2026-01-22,89463.5 +2026-01-23,89474.93 +2026-01-24,89082.17 +2026-01-25,86561.94 +2026-01-26,88250.01 +2026-01-27,89116.96 +2026-01-28,89162.4 +2026-01-29,84513.2 +2026-01-30,84110.99 +2026-01-31,78648.0 +2026-02-01,76895.53 +2026-02-02,78666.85 +2026-02-03,75661.49 +2026-02-04,72998.0 +2026-02-05,62791.0 +2026-02-06,70527.7 +2026-02-07,69244.74 +2026-02-08,70279.33 +2026-02-09,70101.99 +2026-02-10,68803.65 +2026-02-11,67024.93 +2026-02-12,66208.0 +2026-02-13,68812.0 +2026-02-14,69786.96 +2026-02-15,68792.0 +2026-02-16,68858.13 +2026-02-17,67466.7 +2026-02-18,66419.79 +2026-02-19,66972.5 +2026-02-20,67984.42 +2026-02-21,67955.78 +2026-02-22,67617.98 +2026-02-23,64625.78 +2026-02-24,64068.33 +2026-02-25,67992.68 +2026-02-26,67490.51 +2026-02-27,65859.94 +2026-02-28,66967.85 +2026-03-01,65769.0 +2026-03-02,68832.01 +2026-03-03,68335.99 +2026-03-04,72683.26 +2026-03-05,70887.6 +2026-03-06,68108.24 +2026-03-07,67259.62 +2026-03-08,65970.56 +2026-03-09,68432.17 +2026-03-10,69960.83 +2026-03-11,70208.0 +2026-03-12,70531.56 +2026-03-13,70944.33 +2026-03-14,71232.02 +2026-03-15,72830.01 +2026-03-16,74886.47 +2026-03-17,73934.11 +2026-03-18,71245.02 +2026-03-19,69918.3 +2026-03-20,70497.01 +2026-03-21,68912.02 +2026-03-22,67844.94 +2026-03-23,70874.23 +2026-03-24,70533.49 +2026-03-25,71301.53 +2026-03-26,68769.02 +2026-03-27,66353.34 +2026-03-28,66320.15 +2026-03-29,65956.94 +2026-03-30,66737.18 +2026-03-31,68221.85 +2026-04-01,68112.35 +2026-04-02,66894.48 +2026-04-03,66959.99 +2026-04-04,67291.73 +2026-04-05,69005.0 +2026-04-06,68853.61 +2026-04-07,71910.2 +2026-04-08,71085.99 +2026-04-09,71798.01 +2026-04-10,72997.88 +2026-04-11,73086.0 +2026-04-12,70755.35 +2026-04-13,74446.0 +2026-04-14,74179.04 +2026-04-15,74836.31 +2026-04-16,75163.09 +2026-04-17,77098.01 +2026-04-18,75736.82 +2026-04-19,73823.14 +2026-04-20,75864.86 +2026-04-21,76348.58 +2026-04-22,78208.52 +2026-04-23,78281.32 +2026-04-24,77461.8 +2026-04-25,77650.72 +2026-04-26,78673.83 +2026-04-27,77364.42 +2026-04-28,76317.99 +2026-04-29,75752.88 +2026-04-30,76305.78 +2026-05-01,78234.05 +2026-05-02,78682.31 +2026-05-03,78558.89 +2026-05-04,79852.37 +2026-05-05,80907.72 +2026-05-06,81438.1 +2026-05-07,80005.38 +2026-05-08,80188.82 +2026-05-09,80655.96 +2026-05-10,82199.99 +2026-05-11,81727.06 +2026-05-12,80484.72 +2026-05-13,79288.26 +2026-05-14,81079.39 +2026-05-15,79058.51 +2026-05-16,78105.45 +2026-05-17,77407.59 +2026-05-18,76943.98 +2026-05-19,76767.48 +2026-05-20,77475.99 +2026-05-21,77547.62 +2026-05-22,75443.91 +2026-05-23,76650.0 +2026-05-24,76975.99 +2026-05-25,77249.07 +2026-05-26,75826.1 +2026-05-27,74315.98 +2026-05-28,73512.56 +2026-05-29,73366.77 +2026-05-30,73770.69 +2026-05-31,73575.17 +2026-06-01,71314.42 +2026-06-02,66658.35 +2026-06-03,64040.0 +2026-06-04,63806.02 +2026-06-05,61032.0 +2026-06-06,60850.48 +2026-06-07,63302.73 +2026-06-08,63062.98 +2026-06-09,61685.8 +2026-06-10,61449.46 +2026-06-11,63563.42 +2026-06-12,63538.43 +2026-06-13,64429.99 +2026-06-14,65706.62 +2026-06-15,66276.8 +2026-06-16,65616.64 +2026-06-17,64446.04 +2026-06-18,62879.01 +2026-06-19,63475.2 +2026-06-20,64222.99 +2026-06-21,63235.63 +2026-06-22,63950.61 +2026-06-23,62644.86 +2026-06-24,60983.13 +2026-06-25,59703.65 +2026-06-26,60002.53 +2026-06-27,59934.84 +2026-06-28,59474.01 +2026-06-29,60162.73 +2026-06-30,58523.93 +2026-07-01,59961.45 +2026-07-02,61484.02 +2026-07-03,62520.22 +2026-07-04,63086.45 +2026-07-05,63580.44 +2026-07-06,64001.84 +2026-07-07,63323.27 +2026-07-08,62237.7 +2026-07-09,63175.25 +2026-07-10,64128.49 +2026-07-11,63773.04 +2026-07-12,63740.32 +2026-07-13,62264.94 +2026-07-14,64988.64 +2026-07-15,64716.63 +2026-07-16,63770.6 +2026-07-17,63894.0 +2026-07-18,64796.36 +2026-07-19,64681.78 +2026-07-20,65213.05 +2026-07-21,66516.18 +2026-07-22,66086.61 +2026-07-23,65051.23 +2026-07-24,64083.32 +2026-07-25,64295.61 +2026-07-26,65341.07 +2026-07-27,63694.43 +2026-07-28,63847.12 +2026-07-29,63896.14 +2026-07-30,64721.9 +2026-07-31,62825.9 +2026-08-01,62764.2 +2026-08-02,63499.49 +2026-08-03,63466.51 +2026-08-04,64050.51 +2026-08-05,64603.03 +2026-08-06,64267.3 +2026-08-07,64891.61 +2026-08-08,64908.72 +2026-08-09,64848.69 +2026-08-10,63911.88 +2026-08-11,63531.75 +2026-08-12,63411.72 +2026-08-13,63425.35 +2026-08-14,62975.19 +2026-08-15,63018.75 +2026-08-16,62836.66 +2026-08-17,64484.18 +2026-08-18,64681.33 +2026-08-19,69300.01 +2026-08-20,73011.87 +2026-08-21,78325.54 +2026-08-22,77054.44 +2026-08-23,77729.36 +2026-08-24,78981.59 +2026-08-25,78526.8 +2026-08-26,79026.18 +2026-08-27,80275.34 +2026-08-28,77839.19 +2026-08-29,78233.93 +2026-08-30,77665.14 +2026-08-31,78562.74 +2026-09-01,77398.69 +2026-09-02,77307.36 +2026-09-03,81263.99 +2026-09-04,79675.12 +2026-09-05,79831.57 +2026-09-06,80339.13 +2026-09-07,79091.97 +2026-09-08,78447.11 +2026-09-09,78283.98 +2026-09-10,76536.55 +2026-09-11,77208.55 +2026-09-12,77262.85 +2026-09-13,76799.85 +2026-09-14,78175.01 +2026-09-15,75584.17 +2026-09-16,76144.99 +2026-09-17,76348.74 +2026-09-18,80875.04 +2026-09-19,81233.91 +2026-09-20,81159.64 +2026-09-21,86594.94 +2026-09-22,86198.05 +2026-09-23,84378.31 diff --git a/prices.csv b/data/investing.csv similarity index 100% rename from prices.csv rename to data/investing.csv diff --git a/moneyshot.png b/docs/2024-forecast.png similarity index 100% rename from moneyshot.png rename to docs/2024-forecast.png diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..5c4b99d --- /dev/null +++ b/flake.lock @@ -0,0 +1,27 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1790178388, + "narHash": "sha256-kK3t7gwoz4Nx8RF46cs1Xz/skKNcK7y3KYP+gGqX6W8=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "00455b0a3690d3f5dc61e9aef4277dc86235b73f", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..d631e0f --- /dev/null +++ b/flake.nix @@ -0,0 +1,36 @@ +{ + description = "Bitcoin price model"; + + inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; + + outputs = + { self, nixpkgs }: + let + systems = [ + "aarch64-darwin" + "x86_64-darwin" + "aarch64-linux" + "x86_64-linux" + ]; + forAllSystems = f: nixpkgs.lib.genAttrs systems (system: f nixpkgs.legacyPackages.${system}); + in + { + devShells = forAllSystems (pkgs: { + default = pkgs.mkShell { + packages = [ + (pkgs.python313.withPackages (ps: [ + ps.numpy + ps.pandas + ps.scipy + ps.matplotlib + ps.pytest + ])) + pkgs.ruff + pkgs.just + ]; + }; + }); + + formatter = forAllSystems (pkgs: pkgs.nixfmt); + }; +} diff --git a/justfile b/justfile index 1a0d231..a1b7a81 100644 --- a/justfile +++ b/justfile @@ -1,11 +1,34 @@ -run *name: - python3 ./model.py -n "{{ name }}" +# List recipes +default: + @just --list + +# Fetch new daily prices from Coinbase +update: + python -m btcmodel update + +# Score models on development data (outcomes up to the dev cutoff) +backtest *args: + python -m btcmodel {{ args }} backtest + +# Score models on held-out outcomes; run sparingly, never while tuning +holdout *args: + python -m btcmodel {{ args }} backtest --holdout + +# Forecast from the latest price +forecast *args: + python -m btcmodel {{ args }} forecast + +test: + pytest -q fmt: - black ./*.py + ruff format . + ruff check --fix --select I . + nix fmt flake.nix lint: - ruff check ./model.py + ruff check . + ruff format --check . clean: rm -rf output diff --git a/model.py b/model.py deleted file mode 100644 index a09aba8..0000000 --- a/model.py +++ /dev/null @@ -1,1999 +0,0 @@ -import argparse -import pandas as pd -import numpy as np -import matplotlib.pyplot as plt -import os -import seaborn as sns -import shutil -import warnings -from datetime import timedelta -from multiprocessing import Pool -from typing import BinaryIO - - -# Output - - -class Output: - """ - Output ensures result files get written to the right directory. - - Example: - output = Output("output", "test-1") - with output.create("summary.txt") as f: - # path to f is "output/test-1/summary.txt" - """ - - out_dir: str - - def __init__(self, base_dir: str, name: str): - """ - Initialize the output manager. This will both fully delete any existing - output directory, and then create an empty directory for the output. - - Args: - base_dir: The root directory for outputs - name: The subdir of the root, where the results files go - """ - self.out_dir = os.path.join(base_dir, name) - shutil.rmtree(self.out_dir, ignore_errors=True) - os.makedirs(self.out_dir) - - def create(self, filename) -> BinaryIO: - """ - Create a new file for writing in the output directory. - """ - full_path = self.named(filename) - f = open(full_path, "w") - return f - - def named(self, filename) -> str: - """ - Get the full path within the output directory for a named results file. - """ - full_path = os.path.join(self.out_dir, filename) - return full_path - - -# Utility functions - - -def get_halving_dates(): - """Return known and projected Bitcoin halving dates""" - return pd.to_datetime( - [ - "2008-01-03", # Bitcoin genesis block (treat as cycle start) - "2012-11-28", # First halving - "2016-07-09", # Second halving - "2020-05-11", # Third halving - "2024-04-19", # Fourth halving - "2028-04-20", # Fifth halving (projected) - ] - ) - - -def get_cycle_position(date, halving_dates): - """ - Calculate position in halving cycle (0 to 1) for a given date. - 0 represents a halving event, 1 represents just before the next halving. - """ - if len(halving_dates) == 0: - raise Exception("halving dates cannot be empty") - - # Convert date to datetime if it's not already - date = pd.to_datetime(date) - - # Find the most recent halving before this date - prev_halving = halving_dates[halving_dates <= date].max() - if pd.isna(prev_halving): - return 0.0 # For dates before first halving - - # Find next halving - future_halvings = halving_dates[halving_dates > date] - if len(future_halvings) == 0: - # For dates after last known halving, use same cycle length as last known cycle - last_cycle_length = (halving_dates[-1] - halving_dates[-2]).days - days_since_halving = (date - halving_dates[-1]).days - return min(days_since_halving / last_cycle_length, 1.0) - - next_halving = future_halvings.min() - - # Calculate position as fraction between halvings - days_since_halving = (date - prev_halving).days - cycle_length = (next_halving - prev_halving).days - return min(days_since_halving / cycle_length, 1.0) - - -def format_price(x, p): - """Format large numbers in K, M, B format with appropriate precision""" - if abs(x) >= 1e9: - return f"${x/1e9:.1f}B" - if abs(x) >= 1e6: - return f"${x/1e6:.1f}M" - if abs(x) >= 1e3: - return f"${x/1e3:.1f}K" - if abs(x) >= 1: - return f"${x:.0f}" - return f"${x:.2f}" # For values less than $1, show cents - - -def get_nice_price_points(min_price, max_price): - """ - Generate a reasonable set of price points for the y-axis that look clean - and cover the range without cluttering the chart. - """ - # Handle zero or negative prices - min_price = max(min_price, 0.0001) # Set minimum price to $0.0001 - - log_min = np.floor(np.log10(min_price)) - log_max = np.ceil(np.log10(max_price)) - price_points = [] - - # For very large ranges (spanning more than 4 orders of magnitude), - # only use powers of 10 and mid-points - if log_max - log_min > 4: - for exp in range(int(log_min), int(log_max + 1)): - base = 10**exp - # Add main power of 10 - if min_price <= base <= max_price: - price_points.append(base) - # Add mid-point if range is large enough - if min_price <= base * 5 <= max_price and exp > log_min: - price_points.append(base * 5) - else: - # For smaller ranges, use 1, 2, 5 sequence - for exp in range(int(log_min), int(log_max + 1)): - for mult in [1, 2, 5]: - point = mult * 10**exp - if min_price <= point <= max_price: - price_points.append(point) - - return np.array(price_points) - - -# Market metrics - - -class MarketFundamentals: - """ - Calculate and track fundamental market metrics for Bitcoin. - Designed to be extensible for additional metrics. - """ - - def __init__(self): - # Constants - self.GENESIS_DATE = pd.Timestamp("2009-01-03") - self.BLOCKS_PER_DAY = 144 - self.HALVING_INTERVAL = 210000 # blocks - - # Volatility adjustment parameters (from our tuning) - self.VOLUME_SCALE = 70 - self.DEPTH_SCALE = 7 - self.BASE_ADJUSTMENT = 0.68 - - def calculate_total_supply(self, date): - """Calculate total Bitcoin supply at a given date.""" - days_since_genesis = (date - self.GENESIS_DATE).days - if days_since_genesis < 0: - return 0 - - total_supply = 0 - remaining_blocks = days_since_genesis * self.BLOCKS_PER_DAY - current_reward = 50 - - while remaining_blocks > 0 and current_reward >= 0.01: - blocks_at_this_reward = min(remaining_blocks, self.HALVING_INTERVAL) - total_supply += blocks_at_this_reward * current_reward - remaining_blocks -= blocks_at_this_reward - current_reward /= 2 - - # Adjust for missed blocks and lost coins - total_supply *= 0.95 # Account for varying block times - total_supply *= 0.93 # Estimate for lost/inaccessible coins - - return total_supply - - def get_block_reward(self, date): - """Get Bitcoin block reward at a given date.""" - days_since_genesis = (date - self.GENESIS_DATE).days - if days_since_genesis < 0: - return 0 - - halvings = days_since_genesis // (4 * 365) # Approximate halving periods - return 50 / (2**halvings) - - def calculate_supply_metrics(self, date): - """Calculate supply-related metrics.""" - total_supply = self.calculate_total_supply(date) - block_reward = self.get_block_reward(date) - daily_new_supply = block_reward * self.BLOCKS_PER_DAY - - return { - "total_supply": total_supply, - "daily_new_supply": daily_new_supply, - "supply_growth_rate": daily_new_supply / total_supply, - "stock_to_flow": total_supply / (daily_new_supply * 365), # Annualized - } - - def calculate_market_metrics(self, df, date, window=30): - """Calculate market activity metrics.""" - recent_data = df[df["Date"] <= date].tail(window) - - if len(recent_data) < window: - return {"avg_volume": 0, "price_volatility": 0, "price_impact": 0} - - avg_volume = recent_data["Volume"].mean() - price_volatility = recent_data["Close"].pct_change().std() - price_impact = recent_data["Close"].std() / recent_data["Close"].mean() - - return { - "avg_volume": avg_volume, - "price_volatility": price_volatility, - "price_impact": price_impact, - } - - def get_market_maturity_metrics(self, df, date, window=30): - """ - Combine supply and market metrics to assess market maturity. - """ - supply_metrics = self.calculate_supply_metrics(date) - market_metrics = self.calculate_market_metrics(df, date, window) - - # Calculate combined metrics - volume_to_supply = market_metrics["avg_volume"] / supply_metrics["total_supply"] - market_depth = volume_to_supply / (market_metrics["price_impact"] + 0.001) - - return { - "volume_to_supply": volume_to_supply, - "supply_growth_rate": supply_metrics["supply_growth_rate"], - "market_depth": market_depth, - "stock_to_flow": supply_metrics["stock_to_flow"], - "price_impact": market_metrics["price_impact"], - } - - def calculate_volatility_adjustment(self, metrics): - """ - Calculate volatility adjustment based on market metrics. - """ - # Supply-based component - supply_based_vol = np.sqrt(metrics["supply_growth_rate"] * 365 * 100) - - # Market maturity component - maturity_factor = 1 - np.clip( - metrics["volume_to_supply"] * self.VOLUME_SCALE, 0, 0.6 - ) - - # Market depth component - depth_factor = np.clip( - 1 / np.sqrt(1 + metrics["market_depth"] * self.DEPTH_SCALE), 0.7, 1.3 - ) - - # Combine factors - adjustment = ( - self.BASE_ADJUSTMENT - * (1 + supply_based_vol) - * maturity_factor - * depth_factor - ) - - # Ensure reasonable bounds - return np.clip(adjustment, 0.65, 0.75) - - def calculate_confidence_adjustment(self, metrics, level): - """Calculate confidence interval adjustments with more sensitive market metrics.""" - # More granular depth impact - if metrics["market_depth"] > 0.1: - depth_factor = 0.5 - elif metrics["market_depth"] > 0.05: - depth_factor = 0.7 - else: - depth_factor = 1.0 - - # More granular volume impact - if metrics["volume_to_supply"] > 0.015: - volume_factor = 0.5 - elif metrics["volume_to_supply"] > 0.008: - volume_factor = 0.7 - else: - volume_factor = 1.0 - - depth_impact = np.clip(metrics["market_depth"] * 0.15 * depth_factor, 0, 0.15) - vol_impact = np.clip(metrics["volume_to_supply"] * 20 * volume_factor, 0, 0.15) - - total_adjustment = (depth_impact + vol_impact) * 0.4 - - if level >= 0.95: - total_adjustment *= 0.4 - - return level + (1 - level) * total_adjustment - - -def compare_adjustments(df, fundamentals): - """ - Compare fundamental-based adjustments with original era-based ones. - """ - # Sample dates for comparison - date_range = pd.date_range(start=df["Date"].min(), end=df["Date"].max(), freq="30D") - - results = [] - for date in date_range: - # Calculate era-based adjustment - if date < pd.Timestamp("2017-12-10"): - era_adj = 0.71 # early era - elif date < pd.Timestamp("2020-01-01"): - era_adj = 0.69 # transition era - else: - era_adj = 0.67 # mature era - - # Calculate fundamental-based adjustment - metrics = fundamentals.get_market_maturity_metrics(df, date) - fund_adj = fundamentals.calculate_volatility_adjustment(metrics) - - results.append( - { - "date": date, - "era_adjustment": era_adj, - "fundamental_adjustment": fund_adj, - "metrics": metrics, - } - ) - - return pd.DataFrame(results) - - -# Analysis functions - - -def analyze_trends(df): - """ - Analyze Bitcoin price trends using log returns with S2F awareness. - """ - df = df.copy() - fundamentals = MarketFundamentals() - - # Get halving dates and calculate cycle position - halving_dates = get_halving_dates() - df["Cycle_Position"] = df["Date"].apply( - lambda x: get_cycle_position(x, halving_dates) - ) - df["Cycle_Days"] = (df["Cycle_Position"] * 4 * 365).round().astype(int) - - # Calculate S2F metrics for each date - supply_metrics = [ - fundamentals.calculate_supply_metrics(date) for date in df["Date"] - ] - df["S2F_Ratio"] = [m["stock_to_flow"] for m in supply_metrics] - df["S2F_Change"] = df["S2F_Ratio"].pct_change() - - # Calculate log returns and basic cycle returns - df["Log_Price"] = np.log(df["Close"]) - df["Log_Return"] = df["Log_Price"].diff() - - # Calculate cycle-based returns - position_returns = df.groupby("Cycle_Days")["Log_Return"].mean() - - # Calculate S2F impact on returns - s2f_impact = df.groupby("Cycle_Days")["S2F_Change"].mean() - - # Smooth both components - window = 60 - smoothed_cycle_returns = position_returns.rolling( - window=window, - center=True, - min_periods=int(window / 2), - ).mean() - - smoothed_s2f_impact = s2f_impact.rolling( - window=window, - center=True, - min_periods=int(window / 2), - ).mean() - - # Fill NaN values - smoothed_cycle_returns = smoothed_cycle_returns.fillna(method="bfill").fillna( - method="ffill" - ) - smoothed_s2f_impact = smoothed_s2f_impact.fillna(method="bfill").fillna( - method="ffill" - ) - - # Combine cycle returns with S2F impact - s2f_weight = 0.3 # Adjustable parameter - combined_returns = ( - smoothed_cycle_returns * (1 - s2f_weight) + smoothed_s2f_impact * s2f_weight - ) - - # Apply dampening using current market metrics - latest_metrics = fundamentals.get_market_maturity_metrics(df, df["Date"].max()) - market_adjustment = fundamentals.calculate_volatility_adjustment(latest_metrics) - - def adaptive_dampen(x): - if x > 2 * combined_returns.std(): - return x * (0.6 * market_adjustment) - elif x < -2 * combined_returns.std(): - return x * (0.7 * market_adjustment) - return x * market_adjustment - - return combined_returns.map(adaptive_dampen) - - -def calculate_adaptive_volatility( - df, - short_window=30, - medium_window=90, - long_window=180, - vol_clip_min=0.5, - vol_clip_max=2.0, -): - """ - Calculate volatility with adaptive window sizes based on market conditions. - Returns a single volatility value for the most recent period. - - Incorporates long-term volatility as a stability baseline and additional - reference point for regime detection. - """ - df = df.copy() - df["Log_Return"] = np.log(df["Close"]).diff() - - # Remove any NaN values that could cause issues - df = df.dropna() - - if len(df) < long_window: - # Not enough data, fall back to simple volatility - return df["Log_Return"].std() - - # Get recent data for efficiency - lookback = max(long_window * 2, 360) # Use enough data for stable estimates - recent_df = df.iloc[-lookback:].copy() if len(df) > lookback else df.copy() - - try: - # Initial volatility estimate using base windows - short_vol = recent_df["Log_Return"].ewm(span=short_window, adjust=False).std() - medium_vol = recent_df["Log_Return"].ewm(span=medium_window, adjust=False).std() - long_vol = recent_df["Log_Return"].ewm(span=long_window, adjust=False).std() - - # Ensure we have valid volatility values - if short_vol.iloc[-1] == 0 or np.isnan(short_vol.iloc[-1]): - return df["Log_Return"].std() # Fallback to simple volatility - - # Calculate regime indicators for recent period - medium_vol_mean = medium_vol.rolling(min(90, len(recent_df))).mean() - long_vol_mean = long_vol.rolling(min(180, len(recent_df))).mean() - - if medium_vol_mean.iloc[-1] == 0: - vol_regime = pd.Series([1.0] * len(recent_df)) - else: - # Compare short-term to both medium and long-term volatility - medium_regime = short_vol / medium_vol_mean - long_regime = short_vol / long_vol_mean - - # Use the more conservative (higher) regime indicator - vol_regime = pd.concat([medium_regime, long_regime], axis=1).max(axis=1) - - vol_regime = vol_regime.clip(vol_clip_min, vol_clip_max) - - # Get most recent regime reading - latest_regime = vol_regime.iloc[-1] - - # Adjust window sizes based on current regime - adj_factor = 1 / latest_regime - adj_short = max(10, int(short_window * adj_factor)) # Minimum window of 10 - adj_medium = max(30, int(medium_window * adj_factor)) - adj_long = max(60, int(long_window * adj_factor)) - - # Calculate final volatilities using adjusted windows - final_short = recent_df["Log_Return"].iloc[-adj_short:].std() - final_medium = recent_df["Log_Return"].iloc[-adj_medium:].std() - final_long = recent_df["Log_Return"].iloc[-adj_long:].std() - - # If any volatility measure is NaN or 0, fall back to simple volatility - if np.isnan([final_short, final_medium, final_long]).any() or 0 in [ - final_short, - final_medium, - final_long, - ]: - return df["Log_Return"].std() - - # Calculate regime-based weights, now incorporating long-term volatility - high_vol_weight = (latest_regime - vol_clip_min) / (vol_clip_max - vol_clip_min) - base_weights = np.array([0.2, 0.5, 0.3]) # Short, medium, long weights - stress_weights = np.array( - [0.4, 0.4, 0.2] - ) # More weight on short-term during stress - - # Interpolate between base and stress weights - weights = ( - base_weights * (1 - high_vol_weight) + stress_weights * high_vol_weight - ) - - # Calculate final volatility using all three timeframes - final_vol = ( - final_short * weights[0] - + final_medium * weights[1] - + final_long * weights[2] - ) - - # Add uncertainty adjustment based on regime changes - regime_change = abs(vol_regime.diff()).fillna(0) - regime_change_mean = regime_change.rolling(5, min_periods=1).mean().iloc[-1] - if regime_change_mean == 0: - uncertainty_adjustment = 1.0 - else: - regime_change_zscore = regime_change.iloc[-1] / regime_change_mean - uncertainty_adjustment = 1 + np.clip(regime_change_zscore / 2, 0, 0.5) - - return max(final_vol * uncertainty_adjustment, df["Log_Return"].std() * 0.5) - - except Exception as e: - print(f"Error in adaptive volatility calculation: {e}") - # Fall back to simple volatility calculation - return df["Log_Return"].std() - - -def calculate_volatility(df, short_window=30, medium_window=90, long_window=180): - """Calculate volatility using fundamental metrics and adaptive windows.""" - df = df.copy() - df["Log_Return"] = np.log(df["Close"]).diff() - - if len(df) < 30: - return 0.02 # Reasonable default for very short periods - - try: - # Initialize fundamentals calculator - fundamentals = MarketFundamentals() - current_date = df["Date"].max() - - # Get recent data for efficiency - lookback = max(long_window * 2, 360) - recent_df = df.iloc[-lookback:].copy() if len(df) > lookback else df.copy() - - # Calculate base volatilities - short_vol = recent_df["Log_Return"].ewm(span=short_window, adjust=False).std() - medium_vol = recent_df["Log_Return"].ewm(span=medium_window, adjust=False).std() - long_vol = recent_df["Log_Return"].ewm(span=long_window, adjust=False).std() - - if short_vol.iloc[-1] == 0 or np.isnan(short_vol.iloc[-1]): - return df["Log_Return"].std() - - # Calculate volatility regime indicators - medium_vol_mean = medium_vol.rolling(min(90, len(recent_df))).mean() - long_vol_mean = long_vol.rolling(min(180, len(recent_df))).mean() - - # Compare short-term to both medium and long-term volatility - if medium_vol_mean.iloc[-1] == 0: - vol_regime = pd.Series([1.0] * len(recent_df)) - else: - medium_regime = short_vol / medium_vol_mean - long_regime = short_vol / long_vol_mean - vol_regime = pd.concat([medium_regime, long_regime], axis=1).max(axis=1) - - vol_regime = vol_regime.clip(0.5, 2.0) - latest_regime = vol_regime.iloc[-1] - - # Get market metrics - metrics = fundamentals.get_market_maturity_metrics(df, current_date) - - # Calculate adaptive weights - high_vol_weight = (latest_regime - 0.5) / 1.5 # 1.5 = 2.0 - 0.5 - base_weights = np.array([0.2, 0.5, 0.3]) - stress_weights = np.array([0.4, 0.4, 0.2]) - weights = ( - base_weights * (1 - high_vol_weight) + stress_weights * high_vol_weight - ) - - # Calculate final volatilities - final_short = recent_df["Log_Return"].iloc[-short_window:].std() - final_medium = recent_df["Log_Return"].iloc[-medium_window:].std() - final_long = recent_df["Log_Return"].iloc[-long_window:].std() - - if np.isnan([final_short, final_medium, final_long]).any() or 0 in [ - final_short, - final_medium, - final_long, - ]: - return df["Log_Return"].std() - - # Apply market-based adjustment - market_adjustment = fundamentals.calculate_volatility_adjustment(metrics) - - # Calculate final volatility - final_vol = ( - final_short * weights[0] - + final_medium * weights[1] - + final_long * weights[2] - ) * market_adjustment - - return max(final_vol, df["Log_Return"].std() * 0.5) - - except Exception as e: - print(f"Error in volatility calculation: {e}") - return df["Log_Return"].std() - - -def adjust_trend_expectations(expected_returns, cycle_position): - """ - Simple trend adjustment. - """ - if cycle_position > 0.75: - damping_factor = 0.70 - else: - damping_factor = 0.85 - - return expected_returns * damping_factor - - -def calculate_market_conditions(df, lookback_window=180): - """ - Calculate market condition metrics to inform uncertainty scaling. - """ - df = df.copy() # Avoid modifying original dataframe - metrics = {} - - # Use log returns for stability - df["Log_Return"] = np.log(df["Close"]).diff() - - # Handle initial NaN values - df["Log_Return"] = df["Log_Return"].fillna(method="bfill") - - # Recent vs historical volatility ratio - recent_vol = max(df["Log_Return"].tail(30).std(), 1e-6) # Prevent division by zero - historical_vol = max(df["Log_Return"].tail(lookback_window).std(), 1e-6) - metrics["vol_ratio"] = recent_vol / historical_vol - - # Trend strength using log prices - log_prices = np.log(df["Close"]) - ma50 = log_prices.rolling(50, min_periods=1).mean() - ma200 = log_prices.rolling(200, min_periods=1).mean() - metrics["trend_strength"] = (ma50.iloc[-1] - ma200.iloc[-1]) / historical_vol - - # Drawdown intensity - rolling_max = df["Close"].rolling(lookback_window, min_periods=1).max() - current_drawdown = df["Close"].iloc[-1] / rolling_max.iloc[-1] - 1 - metrics["drawdown"] = abs(min(current_drawdown, 0)) - - return metrics - - -def get_projection_adjustments(days_forward, current_cycle_position, df): - """Enhanced projection adjustments using market fundamentals.""" - adjustments = np.ones(days_forward) - fundamentals = MarketFundamentals() - - # Pre-calculate metrics - lookback = min(365, len(df)) - historical_metrics = [ - fundamentals.get_market_maturity_metrics(df, date) - for date in df["Date"].tail(lookback) - ] - historical_avg_volume_to_supply = np.mean( - [m["volume_to_supply"] for m in historical_metrics] - ) - - current_metrics = fundamentals.get_market_maturity_metrics(df, df["Date"].max()) - base_uncertainty = 0.016 * (1 + current_metrics["supply_growth_rate"] * 365) - vol_factor = 1 + 0.2 * abs(1 - current_metrics["volume_to_supply"] * 50) - market_depth_factor = 1 / np.sqrt(1 + current_metrics["market_depth"] * 5) - s2f_factor = 1 / np.log1p(current_metrics["stock_to_flow"]) - regime_scale = np.clip( - current_metrics["volume_to_supply"] / historical_avg_volume_to_supply, 0.88, 1.2 - ) - - for i in range(days_forward): - time_factor = min( - 1 - + (i / 365) - * base_uncertainty - * vol_factor - * market_depth_factor - * s2f_factor, - 1.20, - ) - - cycle_position = (current_cycle_position + i / 1460) % 1 - local_cycle_factor = 1.15 if cycle_position > 0.75 else 1.0 - - adjustments[i] = time_factor * local_cycle_factor * regime_scale - adjustments[i] = max(adjustments[i], 1.02 + (i / 365) * 0.01) - - return adjustments - - -def calculate_confidence_intervals(simulated_paths, confidence_levels=[0.95, 0.68]): - """ - Calculate confidence intervals with dynamic quantile selection based on market conditions. - """ - results = {} - - for level in confidence_levels: - # Calculate standard error of the median - median_std = np.std( - [np.median(simulated_paths[:, i]) for i in range(simulated_paths.shape[1])] - ) - - # Adjust quantiles based on estimation uncertainty - adjustment = min(0.1, median_std / np.median(simulated_paths)) # Cap adjustment - - # Widen intervals slightly when uncertainty is high - effective_level = level + (1 - level) * adjustment - - lower_percentile = (1 - effective_level) * 100 / 2 - upper_percentile = 100 - lower_percentile - - results[f"Lower_{int(level*100)}"] = np.percentile( - simulated_paths, lower_percentile, axis=1 - ) - results[f"Upper_{int(level*100)}"] = np.percentile( - simulated_paths, upper_percentile, axis=1 - ) - - return results - - -def project_prices( - df, days_forward=365, simulations=1000, confidence_levels=[0.95, 0.68] -): - """Generate price projections with fundamental-based adjustments.""" - df = df.copy() - fundamentals = MarketFundamentals() - - df["Log_Price"] = np.log(df["Close"]) - df["Log_Return"] = df["Log_Price"].diff() - - # Get halving dates and current cycle position - halving_dates = get_halving_dates() - current_date = df["Date"].max() - cycle_position = get_cycle_position(current_date, halving_dates) - current_cycle_days = int(cycle_position * 4 * 365) - - # Current price and date - last_price = df["Close"].iloc[-1] - last_date = df["Date"].iloc[-1] - - # Generate projection dates - future_dates = pd.date_range( - start=last_date + timedelta(days=1), periods=days_forward, freq="D" - ) - - # Calculate expected returns - future_cycle_days = [ - (current_cycle_days + i) % (4 * 365) for i in range(days_forward) - ] - cycle_trends = analyze_trends(df) - expected_returns = np.array( - [cycle_trends.get(day, cycle_trends.mean()) for day in future_cycle_days] - ) - - # Calculate base volatility - base_volatility = calculate_volatility(df) - - # Get projection adjustments - projection_adjustments = get_projection_adjustments( - days_forward, cycle_position, df - ) - - # Run Monte Carlo simulation - np.random.seed(42) # Restored for reproducibility - simulated_paths = np.zeros((days_forward, simulations)) - - for sim in range(simulations): - drift = expected_returns - vol = base_volatility - time_scaled_vol = vol * projection_adjustments - - returns = np.random.normal(loc=drift, scale=time_scaled_vol, size=days_forward) - - cumulative_returns = np.cumsum(returns) - price_path = last_price * np.exp(cumulative_returns) - simulated_paths[:, sim] = price_path - - # Calculate results - results = pd.DataFrame(index=future_dates) - results["Median"] = np.percentile(simulated_paths, 50, axis=1) - results["Expected_Trend"] = last_price * np.exp(np.cumsum(drift)) - - # Calculate confidence intervals - for level in confidence_levels: - metrics = fundamentals.get_market_maturity_metrics(df, current_date) - - # Calculate intervals by projection horizon - lower_bounds = [] - upper_bounds = [] - - for day in range(days_forward): - time_factor = ( - 0.85 if day > 365 else 0.9 if day > 180 else 0.95 if day > 90 else 1.0 - ) - - effective_level = ( - fundamentals.calculate_confidence_adjustment(metrics, level) - * time_factor - ) - - lower_percentile = (1 - effective_level) * 100 / 2 - upper_percentile = 100 - lower_percentile - - lower_bounds.append( - np.percentile(simulated_paths[day, :], lower_percentile) - ) - upper_bounds.append( - np.percentile(simulated_paths[day, :], upper_percentile) - ) - - results[f"Lower_{int(level*100)}"] = lower_bounds - results[f"Upper_{int(level*100)}"] = upper_bounds - - return results - - -def analyze_bitcoin_prices(csv_path): - """ - Analyze Bitcoin price data to calculate volatility and growth rates. - """ - # Read CSV with proper data types - df = pd.read_csv(csv_path, parse_dates=[0]) - - # Print first few rows of raw data to inspect - print("\nFirst few rows of raw data:") - print(df.head()) - - # Print data info to see types and non-null counts - print("\nDataset Info:") - print(df.info()) - - # Convert price columns to float and handle any potential formatting issues - numeric_columns = ["Price", "Open", "High", "Low", "Vol."] # Added Volume - for col in numeric_columns: - # Remove any commas and 'K'/'M' suffixes - df[col] = df[col].astype(str).str.replace(",", "") - # Convert K to thousands - df[col] = df[col].str.replace("K", "e3") - # Convert M to millions - df[col] = df[col].str.replace("M", "e6") - # Convert B to billions - df[col] = df[col].str.replace("B", "e9") - # Convert to numeric - df[col] = pd.to_numeric(df[col], errors="coerce") - - # Rename columns for clarity - df.columns = ["Date", "Close", "Open", "High", "Low", "Volume", "Change"] - - # Sort by date in ascending order - df = df.sort_values("Date") - - # Print summary statistics after conversion - print("\nPrice Summary After Conversion:") - print(df[["Close", "Open", "High", "Low", "Volume"]].describe()) - - # Calculate daily returns - df["Daily_Return"] = df["Close"].pct_change() - - # Print first few daily returns to verify calculation - print("\nFirst few daily returns:") - print(df[["Date", "Close", "Daily_Return"]].head()) - - # Check for any infinite or NaN values - print("\nInfinite or NaN value counts:") - print(df.isna().sum()) - - # Calculate metrics using 365 days for annualization - analysis = { - "period_start": df["Date"].min().strftime("%Y-%m-%d"), - "period_end": df["Date"].max().strftime("%Y-%m-%d"), - "total_days": len(df), - "daily_volatility": df["Daily_Return"].std(), - "annualized_volatility": df["Daily_Return"].std() * np.sqrt(365), - "total_return": (df["Close"].iloc[-1] / df["Close"].iloc[0] - 1) * 100, - "average_daily_return": df["Daily_Return"].mean() * 100, - "average_annual_return": ((1 + df["Daily_Return"].mean()) ** 365 - 1) * 100, - "min_price": df["Low"].min(), - "max_price": df["High"].max(), - "avg_price": df["Close"].mean(), - "start_price": df["Close"].iloc[0], - "end_price": df["Close"].iloc[-1], - } - - # Calculate rolling metrics - df["Rolling_Volatility_30d"] = df["Daily_Return"].rolling( - window=30 - ).std() * np.sqrt(365) - df["Rolling_Return_30d"] = df["Close"].pct_change(periods=30) * 100 - - return analysis, df - - -# Main plotting functions - - -def add_cdpr_plot(df, output: Output): - """ - Add a plot showing the Compounding Daily Periodic Rate (CDPR) over different time periods. - """ - plt.style.use("seaborn-v0_8") - fig, ax = plt.subplots(figsize=(15, 6)) - - # Calculate CDPR for different time periods - periods = [180, 360, 720] - cdpr = {} - - # Find the longest CDPR series length - max_period = max(periods) - - for period in periods: - daily_returns = df["Close"].pct_change().fillna(0) - cdpr[f"{period}d CDPR"] = ( - daily_returns.rolling(period).apply( - lambda x: (1 + x).prod() ** (1 / period) - 1, raw=True - ) - ) * 100 - - # Clip all CDPR series to the length of the longest one - cdpr[f"{period}d CDPR"] = cdpr[f"{period}d CDPR"][max_period:] - - # Find the non-NaN min and max CDPR values - cdpr_values = [values for values in cdpr.values()] - min_cdpr = np.nanmin([np.nanmin(values) for values in cdpr_values]) - max_cdpr = np.nanmax([np.nanmax(values) for values in cdpr_values]) - - # Ensure x-axis (dates) and y-axis (CDPR) have the same length - start_date = df["Date"].iloc[max_period:].min() - end_date = df["Date"].max() - plot_dates = pd.date_range(start=start_date, end=end_date, freq="D") - - # Plot CDPR lines - for label, values in cdpr.items(): - ax.plot(plot_dates, values, label=label) - - # Customize the plot - ax.set_title("Compounding Daily Periodic Rate (CDPR)") - ax.set_xlabel("Date") - ax.set_ylabel("CDPR (%)") - ax.grid(True, alpha=0.3) - - # Adjust y-axis tick marks and add shaded lines between ticks - yticks = list(np.arange(int(min_cdpr), int(max_cdpr) + 1, 0.5)) - ax.set_yticks(yticks) - ax.tick_params(axis="y", which="major", labelsize=8) - ax.set_yticklabels(["{:.1f}%".format(y) for y in yticks]) - - # Add shaded lines between tick marks - for i in range(1, len(yticks)): - ax.axhline( - y=yticks[i], color="lightgray", linestyle="--", linewidth=1, alpha=0.5 - ) - - ax.legend() - - # Save the plot - filename = output.named("bitcoin_cdpr_plot.png") - plt.tight_layout() - plt.savefig(filename, dpi=300, bbox_inches="tight") - plt.close() - - -def create_plots(df, output: Output, start=None, end=None, project_days=365): - """ - Create enhanced plots including market maturity visualization. - """ - # Add the new CDPR plot - add_cdpr_plot(df, output) - - # Filter data based on date range - mask = pd.Series(True, index=df.index) - if start: - mask &= df["Date"] >= pd.to_datetime(start) - if end: - mask &= df["Date"] <= pd.to_datetime(end) - - plot_df = df[mask].copy() - - if len(plot_df) == 0: - raise ValueError("No data found for the specified date range") - - # Generate projections - projections = project_prices(plot_df, days_forward=project_days) - - # Set up the style - plt.style.use("seaborn-v0_8") - - # Create figure with adjusted size and spacing - fig = plt.figure(figsize=(15, 15)) - - # Use GridSpec for better control over subplot spacing - gs = plt.GridSpec(5, 1, height_ratios=[3, 1.5, 1.5, 1.5, 2], hspace=0.4) - - # Date range for titles - hist_date_range = f" ({plot_df['Date'].min().strftime('%Y-%m-%d')} to {plot_df['Date'].max().strftime('%Y-%m-%d')})" - - # Calculate full date range including projections - full_date_range = pd.date_range(plot_df["Date"].min(), projections.index.max()) - - # 1. Price history and projections (log scale) - ax1 = fig.add_subplot(gs[0]) - - # Plot historical prices - ax1.semilogy(plot_df["Date"], plot_df["Close"], "b-", label="Historical Price") - - # Plot projections - ax1.semilogy( - projections.index, - projections["Expected_Trend"], - "--", - color="purple", - label="Expected Trend", - ) - ax1.semilogy( - projections.index, - projections["Median"], - ":", - color="green", - label="Simulated Median", - ) - ax1.fill_between( - projections.index, - projections["Lower_95"], - projections["Upper_95"], - alpha=0.2, - color="orange", - label="95% Confidence Interval", - ) - ax1.fill_between( - projections.index, - projections["Lower_68"], - projections["Upper_68"], - alpha=0.3, - color="green", - label="68% Confidence Interval", - ) - - # Customize y-axis - ax1.yaxis.set_major_formatter(plt.FuncFormatter(format_price)) - min_price = min(plot_df["Low"].min(), projections["Lower_95"].min()) - max_price = max(plot_df["High"].max(), projections["Upper_95"].max()) - price_points = get_nice_price_points(min_price, max_price) - ax1.set_yticks(price_points) - ax1.tick_params(axis="y", labelsize=8) - ax1.margins(y=0.02) - ax1.grid(True, which="major", linestyle="-", alpha=0.5) - ax1.grid(True, which="minor", linestyle=":", alpha=0.2) - ax1.set_title("Bitcoin Price History and Projections (Log Scale)" + hist_date_range) - ax1.legend(fontsize=8) - - # Set x-axis limits to full range - ax1.set_xlim(full_date_range[0], full_date_range[-1]) - ax1.tick_params(axis="x", rotation=45) - - # 3. Rolling volatility - ax3 = fig.add_subplot(gs[1]) - ax3.plot( - plot_df["Date"], - plot_df["Rolling_Volatility_30d"], - "r-", - label="30-Day Rolling Volatility", - ) - - # Add empty space to match price plot x-axis - ax3.set_xlim(full_date_range[0], full_date_range[-1]) - - # Add vertical line to mark start of projections - ax3.axvline(plot_df["Date"].max(), color="gray", linestyle="--", alpha=0.5) - ax3.text( - plot_df["Date"].max(), - ax3.get_ylim()[1], - "Projection Start", - rotation=90, - va="top", - ha="right", - alpha=0.7, - ) - - ax3.set_title("30-Day Rolling Volatility (Annualized)" + hist_date_range) - ax3.set_ylabel("Volatility") - ax3.grid(True) - ax3.yaxis.set_major_formatter(plt.FuncFormatter(lambda y, _: "{:.0%}".format(y))) - ax3.legend() - ax3.tick_params(axis="x", rotation=45) - - # 4. Returns distribution - ax4 = fig.add_subplot(gs[2]) - returns_mean = plot_df["Daily_Return"].mean() - returns_std = plot_df["Daily_Return"].std() - filtered_returns = plot_df["Daily_Return"][ - (plot_df["Daily_Return"] > returns_mean - 5 * returns_std) - & (plot_df["Daily_Return"] < returns_mean + 5 * returns_std) - ] - - sns.histplot(filtered_returns, bins=100, ax=ax4) - ax4.set_title( - "Distribution of Daily Returns (Excluding Extreme Outliers)" + hist_date_range - ) - ax4.set_xlabel("Daily Return") - ax4.set_ylabel("Count") - ax4.xaxis.set_major_formatter(plt.FuncFormatter(lambda x, _: "{:.0%}".format(x))) - - # Add mean line - ax4.axvline(filtered_returns.mean(), color="r", linestyle="dashed", linewidth=1) - ax4.text( - filtered_returns.mean(), - ax4.get_ylim()[1], - "Mean", - rotation=90, - va="top", - ha="right", - ) - - # 5. Projection ranges - ax5 = fig.add_subplot(gs[3:]) # Use last two grid spaces for larger plot - timepoints = np.array(range(30, project_days, 30)) - timepoints = timepoints[timepoints <= project_days] - - ranges = [] - labels = [] - positions = [] - - for t in timepoints: - idx = t - 1 - ranges.extend( - [ - projections["Lower_95"].iloc[idx], - projections["Lower_68"].iloc[idx], - projections["Median"].iloc[idx], - projections["Upper_68"].iloc[idx], - projections["Upper_95"].iloc[idx], - ] - ) - labels.extend(["95% Lower", "68% Lower", "Median", "68% Upper", "95% Upper"]) - positions.extend([t] * 5) - - ax5.scatter(positions, ranges, alpha=0.6) - - for t in timepoints: - idx = positions.index(t) - ax5.plot([t] * 5, ranges[idx : idx + 5], "k-", alpha=0.3) - - ax5.set_yscale("log") - min_price = min(ranges) - max_price = max(ranges) - price_points = get_nice_price_points(min_price, max_price) - ax5.set_yticks(price_points) - ax5.yaxis.set_major_formatter(plt.FuncFormatter(format_price)) - ax5.set_title("Projected Price Ranges at Future Timepoints") - ax5.set_xlabel("Days Forward") - ax5.set_ylabel("Price (USD)") - ax5.grid(True, alpha=0.3) - ax5.set_xticks(timepoints) - - # Save the plot - start_str = start if start else plot_df["Date"].min().strftime("%Y-%m-%d") - end_str = end if end else plot_df["Date"].max().strftime("%Y-%m-%d") - filename = output.named( - f"bitcoin_analysis_{start_str}_to_{end_str}_with_projections.png" - ) - - # Use tight_layout with adjusted parameters - plt.tight_layout(pad=2.0) - plt.savefig(filename, dpi=300, bbox_inches="tight") - plt.close() - - return projections - - -def visualize_cycle_patterns(df, output: Output, cycle_returns, cycle_volatility): - """ - Create enhanced visualization of Bitcoin's behavior across halving cycles. - """ - plt.style.use("seaborn-v0_8") - fig = plt.figure(figsize=(15, 15)) - - # Create a 3x1 subplot grid with different heights - gs = plt.GridSpec(3, 1, height_ratios=[2, 1, 2], hspace=0.3) - - # Plot 1: Returns across cycle with confidence bands - ax1 = plt.subplot(gs[0]) - - # Convert days to percentage through cycle - x_points = np.array(cycle_returns.index) / (4 * 365) * 100 - - # Calculate rolling mean and standard deviation for confidence bands - window = 30 # 30-day window - rolling_mean = pd.Series(cycle_returns.values).rolling(window=window).mean() - rolling_std = pd.Series(cycle_returns.values).rolling(window=window).std() - - # Plot confidence bands - ax1.fill_between( - x_points, - (rolling_mean - 2 * rolling_std) * 100, - (rolling_mean + 2 * rolling_std) * 100, - alpha=0.2, - color="blue", - label="95% Confidence", - ) - ax1.fill_between( - x_points, - (rolling_mean - rolling_std) * 100, - (rolling_mean + rolling_std) * 100, - alpha=0.3, - color="blue", - label="68% Confidence", - ) - - # Plot average returns - ax1.plot( - x_points, - cycle_returns.values * 100, - "b-", - label="Average Daily Return", - linewidth=2, - ) - ax1.axhline(y=0, color="gray", linestyle="--", alpha=0.5) - - # Add vertical lines for each year in cycle - for year in range(1, 4): - ax1.axvline(x=year * 25, color="gray", linestyle=":", alpha=0.3) - ax1.text( - year * 25, - ax1.get_ylim()[1], - f"Year {year}", - rotation=90, - va="top", - ha="right", - alpha=0.7, - ) - - # Highlight halving points - ax1.axvline(x=0, color="red", linestyle="--", alpha=0.5, label="Halving Event") - ax1.axvline(x=100, color="red", linestyle="--", alpha=0.5) - - ax1.set_title("Bitcoin Return Patterns Across Halving Cycle", pad=20) - ax1.set_xlabel("Position in Cycle (%)") - ax1.set_ylabel("Average Daily Return (%)") - ax1.grid(True, alpha=0.3) - ax1.legend(loc="upper right") - - # Plot 2: Volatility across cycle - ax2 = plt.subplot(gs[1]) - - # Calculate rolling volatility confidence bands - vol_mean = pd.Series(cycle_volatility.values).rolling(window=window).mean() - vol_std = pd.Series(cycle_volatility.values).rolling(window=window).std() - - # Plot volatility with confidence bands - annualized_factor = np.sqrt(365) * 100 - ax2.fill_between( - x_points, - (vol_mean - 2 * vol_std) * annualized_factor, - (vol_mean + 2 * vol_std) * annualized_factor, - alpha=0.2, - color="red", - label="95% Confidence", - ) - ax2.plot( - x_points, - cycle_volatility.values * annualized_factor, - "r-", - label="Annualized Volatility", - linewidth=2, - ) - - # Add year markers - for year in range(1, 4): - ax2.axvline(x=year * 25, color="gray", linestyle=":", alpha=0.3) - - ax2.axvline(x=0, color="red", linestyle="--", alpha=0.5) - ax2.axvline(x=100, color="red", linestyle="--", alpha=0.5) - - ax2.set_xlabel("Position in Cycle (%)") - ax2.set_ylabel("Volatility (%)") - ax2.grid(True, alpha=0.3) - ax2.legend(loc="upper right") - - # Plot 3: Average price trajectory within cycles - ax3 = plt.subplot(gs[2]) - - # Define a color scheme for cycles - cycle_colors = ["#1f77b4", "#ff7f0e", "#2ca02c", "#d62728", "#9467bd"] - - # Calculate average price path for each cycle - halving_dates = get_halving_dates() - cycles = [] - - for i in range(len(halving_dates) - 1): - cycle_start = halving_dates[i] - cycle_end = halving_dates[i + 1] - cycle_data = df[(df["Date"] >= cycle_start) & (df["Date"] < cycle_end)].copy() - - if len(cycle_data) > 0: - cycle_data["Cycle_Pct"] = ( - (cycle_data["Date"] - cycle_start).dt.total_seconds() - / (cycle_end - cycle_start).total_seconds() - * 100 - ) - cycle_data["Normalized_Price"] = ( - cycle_data["Close"] / cycle_data["Close"].iloc[0] - ) - cycles.append(cycle_data) - - # Plot each historical cycle with distinct colors - for i, cycle in enumerate(cycles): - ax3.semilogy( - cycle["Cycle_Pct"], - cycle["Normalized_Price"], - color=cycle_colors[i], - alpha=0.7, - label=f'Cycle {i+1} ({cycle["Date"].iloc[0].strftime("%Y")}-{cycle["Date"].iloc[-1].strftime("%Y")})', - ) - - # Calculate and plot average cycle - if cycles: - avg_cycle = pd.concat( - [c.set_index("Cycle_Pct")["Normalized_Price"] for c in cycles], axis=1 - ) - avg_cycle_mean = avg_cycle.mean(axis=1) - avg_cycle_std = avg_cycle.std(axis=1) - - ax3.semilogy( - avg_cycle_mean.index, - avg_cycle_mean.values, - "k-", - linewidth=2, - label="Average Cycle", - ) - ax3.fill_between( - avg_cycle_mean.index, - avg_cycle_mean * np.exp(-2 * avg_cycle_std), - avg_cycle_mean * np.exp(2 * avg_cycle_std), - alpha=0.2, - color="gray", - ) - - # Add year markers - for year in range(1, 4): - ax3.axvline(x=year * 25, color="gray", linestyle=":", alpha=0.3) - - ax3.axvline(x=0, color="red", linestyle="--", alpha=0.5) - ax3.axvline(x=100, color="red", linestyle="--", alpha=0.5) - - ax3.set_title("Price Performance Across Cycles (Normalized)", pad=20) - ax3.set_xlabel("Position in Cycle (%)") - ax3.set_ylabel("Price (Relative to Cycle Start)") - ax3.grid(True, alpha=0.3) - ax3.legend(loc="center left", bbox_to_anchor=(1.02, 0.5)) - - # Add current cycle position marker on all plots - current_position = get_cycle_position(df["Date"].max(), halving_dates) * 100 - for ax in [ax1, ax2, ax3]: - ax.axvline( - x=current_position, - color="green", - linestyle="-", - alpha=0.5, - label="Current Position", - ) - - # Main title for the figure - fig.suptitle("Bitcoin Halving Cycle Analysis", fontsize=16, y=0.95) - - # Adjust layout to prevent legend cutoff - plt.tight_layout() - - # Save the plot - filename = output.named("bitcoin_cycle_patterns.png") - plt.savefig(filename, dpi=300, bbox_inches="tight") - plt.close() - - -def create_backtest_plot( - df, - output: Output, - backtest_date="2020-05-11", - start_date="2012-11-28", - project_days=1650, -): - """ - Create a plot comparing actual price history against model projections from a historical date. - Returns both the projections and performance metrics. - - Args: - df: DataFrame with historical price data - backtest_date: Date to start the backtest from - start_date: Date to start considering historical data - project_days: Number of days to project forward from backtest date - - Returns: - tuple: (projections DataFrame, metrics dictionary) - """ - # Convert dates to datetime - backtest_date = pd.to_datetime(backtest_date) - start_date = pd.to_datetime(start_date) - - # Validate dates - if start_date >= backtest_date: - raise ValueError("start_date must be earlier than backtest_date") - - # Clean the data: remove rows with zero or invalid prices and filter by date - df = df[(df["Close"] > 0) & (df["Date"] >= start_date)].copy() - - # Split data into training (before backtest date) and validation (after backtest date) - training_df = df[df["Date"] <= backtest_date].copy() - validation_df = df[df["Date"] > backtest_date].copy() - - # Check if we have enough data - if len(training_df) < 30: # Require at least 30 days of training data - raise ValueError("Insufficient training data before backtest date") - - if len(validation_df) < project_days: - warnings.warn( - f"Validation period ({len(validation_df)} days) shorter than projection period ({project_days} days)" - ) - - # Generate historical projections using only training data - historical_projections = project_prices(training_df, days_forward=project_days) - - # Set up the plot - plt.style.use("seaborn-v0_8") - _, ax = plt.figure(figsize=(15, 10)), plt.gca() - - # Plot training data - heading_label = f'Historical Price (Training: {start_date.strftime("%Y-%m-%d")} to {backtest_date.strftime("%Y-%m-%d")})' - - ax.semilogy( - training_df["Date"], - training_df["Close"], - "b-", - label=heading_label, - alpha=0.7, - ) - - # Plot validation data - ax.semilogy( - validation_df["Date"], - validation_df["Close"], - "g-", - label=f'Actual Price (Validation: {backtest_date.strftime("%Y-%m-%d")} onwards)', - linewidth=2, - ) - - # Plot projections - ax.semilogy( - historical_projections.index, - historical_projections["Expected_Trend"], - "--", - color="purple", - label="Model Projection (Expected)", - ) - ax.semilogy( - historical_projections.index, - historical_projections["Median"], - ":", - color="orange", - label="Model Projection (Median)", - ) - - # Add confidence intervals - ax.fill_between( - historical_projections.index, - historical_projections["Lower_95"], - historical_projections["Upper_95"], - alpha=0.2, - color="orange", - label="95% Confidence Interval", - ) - ax.fill_between( - historical_projections.index, - historical_projections["Lower_68"], - historical_projections["Upper_68"], - alpha=0.3, - color="green", - label="68% Confidence Interval", - ) - - # Customize y-axis - ax.yaxis.set_major_formatter(plt.FuncFormatter(format_price)) - - # Set custom y-axis ticks - min_price = min( - df["Low"].min(), - historical_projections["Lower_95"].min(), - 0.0001, # Set minimum price floor - ) - max_price = max(df["High"].max(), historical_projections["Upper_95"].max()) - price_points = get_nice_price_points(min_price, max_price) - ax.set_yticks(price_points) - - # Add halving lines - halving_dates = get_halving_dates() - relevant_halvings = halving_dates[ - (halving_dates >= start_date) & (halving_dates <= validation_df["Date"].max()) - ] - for date in relevant_halvings: - ax.axvline(date, color="red", linestyle="--", alpha=0.3) - ax.text( - date, - ax.get_ylim()[1], - "Halving", - rotation=90, - va="top", - ha="right", - alpha=0.7, - ) - - # Calculate model performance metrics - metrics = {} - if len(validation_df) > 0: - # Create a common date range for comparison - actual_prices = validation_df.set_index("Date")["Close"] - common_dates = actual_prices.index.intersection(historical_projections.index) - - if len(common_dates) > 0: - actual_aligned = actual_prices[common_dates] - projections_aligned = historical_projections.loc[common_dates] - - # Calculate metrics using aligned data - metrics = { - "mape": np.mean( - np.abs( - (actual_aligned - projections_aligned["Expected_Trend"]) - / actual_aligned - ) - ) - * 100, - "rmse": np.sqrt( - np.mean( - (actual_aligned - projections_aligned["Expected_Trend"]) ** 2 - ) - ), - "max_error": np.max( - np.abs(actual_aligned - projections_aligned["Expected_Trend"]) - ), - "coverage_95": np.mean( - (actual_aligned >= projections_aligned["Lower_95"]) - & (actual_aligned <= projections_aligned["Upper_95"]) - ) - * 100, - "coverage_68": np.mean( - (actual_aligned >= projections_aligned["Lower_68"]) - & (actual_aligned <= projections_aligned["Upper_68"]) - ) - * 100, - } - - # Add metrics to plot - metrics_text = ( - f"Model Performance Metrics:\n" - f"MAPE: {metrics['mape']:.1f}%\n" - f"RMSE: ${metrics['rmse']:,.0f}\n" - f"Max Error: ${metrics['max_error']:,.0f}\n" - f"95% CI Coverage: {metrics['coverage_95']:.1f}%\n" - f"68% CI Coverage: {metrics['coverage_68']:.1f}%" - ) - ax.text( - 0.02, - 0.98, - metrics_text, - transform=ax.transAxes, - verticalalignment="top", - bbox=dict(facecolor="white", alpha=0.8), - ) - - # Customize plot - ax.set_title( - f'Bitcoin Price: Model Backtest\nTraining: {start_date.strftime("%Y-%m-%d")} to {backtest_date.strftime("%Y-%m-%d")}' - ) - ax.set_xlabel("Date") - ax.set_ylabel("Price (USD)") - ax.grid(True, which="major", linestyle="-", alpha=0.5) - ax.grid(True, which="minor", linestyle=":", alpha=0.2) - ax.legend(loc="center left", bbox_to_anchor=(1.02, 0.5)) - - # Adjust layout and save - plt.tight_layout() - filename = output.named( - f'bitcoin_backtest_{start_date.strftime("%Y%m%d")}_to_{backtest_date.strftime("%Y%m%d")}.png' - ) - plt.savefig(filename, dpi=300, bbox_inches="tight") - plt.close() - - return historical_projections, metrics - - -def run_projection(args): - df, start, output = args - _ = create_plots(df, output, start=start, project_days=365 * 4) - - -def run_projections(df, output: Output): - # Create main projection - projection_starts = [ - "2011-01-01", - "2012-01-01", - "2013-01-01", - "2014-01-01", - "2015-01-01", - "2016-07-09", - ] - args = [(df, start, output) for start in projection_starts] - with Pool() as pool: - pool.map(run_projection, args) - - -def run_single_backtest(args): - """ - Run a single backtest with the given parameters. - Must be defined at module level for multiprocessing. - - Args: - args: tuple of (params dict, DataFrame) - """ - params, df, output = args - try: - # Create a copy of params without the description - backtest_params = params.copy() - backtest_params.pop("description", None) - - projections, metrics = create_backtest_plot(df, output, **backtest_params) - - # Ensure metrics has all required keys with default values - if metrics is None: - metrics = {} - - default_metrics = { - "mape": 0.0, - "rmse": 0.0, - "max_error": 0.0, - "coverage_95": 0.0, - "coverage_68": 0.0, - } - - # Update metrics with defaults for any missing keys - metrics = {**default_metrics, **metrics} - - return { - "params": params, - "projections": projections, - "metrics": metrics, - "success": True, - } - except Exception as e: - print( - f"Error in backtest for period {params['description']}: {str(e)}" - ) # Debug print - return {"params": params, "error": str(e), "success": False} - - -def run_systematic_backtests( - df, output: Output, validation_years=2, min_training_years=8 -): - """ - Run a comprehensive suite of backtests with consistent validation periods. - Uses sliding windows for both start and end dates. - """ - # Convert years to days - validation_days = validation_years * 365 - min_training_days = min_training_years * 365 - - # Define start date for reliable data - mature_start = pd.Timestamp("2011-01-01") - last_possible_start = df["Date"].max() - pd.Timedelta( - days=min_training_days + validation_days - ) - end_date = df["Date"].max() - pd.Timedelta(days=validation_days) - - if mature_start >= last_possible_start: - raise ValueError( - f"Insufficient data for backtesting with current parameters:\n" - f"- Data range: {mature_start} to {df['Date'].max()}\n" - f"- Minimum training period: {min_training_years} years\n" - f"- Validation period: {validation_years} years" - ) - - old_backtests = [ - { - "start_date": "2016-07-09", - "backtest_date": "2024-04-19", - "project_days": validation_days, - "description": "Second until fourth halving", - }, - { - "start_date": "2013-01-01", # Includes pre-futures for cycle learning - "backtest_date": "2020-05-11", - "project_days": validation_days, - "description": "Post-Futures Window with two cycles of training", - }, - { - "start_date": "2014-01-01", - "backtest_date": "2021-12-31", - "project_days": validation_days, - "description": "Cross-Regime Test with two cycles of training", - }, - { - "start_date": "2015-01-01", - "backtest_date": "2022-01-01", - "project_days": validation_days, - "description": "Recent Window focusing on post-2022 behavior", - }, - ] - backtest_periods = [] - backtest_periods.extend(old_backtests) - - # Generate backtest periods with sliding windows - window_start = mature_start - step = pd.Timedelta(days=180) # 6 month steps - - while window_start <= last_possible_start: - backtest_date = window_start + pd.Timedelta(days=min_training_days) - - backtest_periods.append( - { - "start_date": window_start.strftime("%Y-%m-%d"), - "backtest_date": backtest_date.strftime("%Y-%m-%d"), - "project_days": validation_days, - "description": f"Training {window_start.strftime('%Y-%m-%d')} to {backtest_date.strftime('%Y-%m-%d')}", - } - ) - window_start += step - - # Add specific periods of interest - special_periods = [] - - # Halving-based periods - halving_dates = get_halving_dates() - relevant_halvings = [ - h - for h in halving_dates - if h < end_date and h > (mature_start + pd.Timedelta(days=min_training_days)) - ] - - for halving in relevant_halvings: - earliest_start = halving - pd.Timedelta(days=min_training_days) - if earliest_start >= mature_start: - special_periods.append( - { - "start_date": earliest_start.strftime("%Y-%m-%d"), - "backtest_date": halving.strftime("%Y-%m-%d"), - "project_days": validation_days, - "description": f"Pre-halving {halving.strftime('%Y')}", - } - ) - - # Market structure change periods - important_dates = [ - ("2017-12-01", "Post-futures introduction"), - ("2020-03-01", "Post-COVID crash"), - ("2021-11-01", "Post-2021 peak"), - ] - - for date, description in important_dates: - test_date = pd.Timestamp(date) - if test_date < end_date: - earliest_start = test_date - pd.Timedelta(days=min_training_days) - if earliest_start >= mature_start: - special_periods.append( - { - "start_date": earliest_start.strftime("%Y-%m-%d"), - "backtest_date": date, - "project_days": validation_days, - "description": description, - } - ) - - # Combine and remove any duplicates - all_periods = backtest_periods + special_periods - unique_periods = [] - seen_dates = set() - for period in all_periods: - key = f"{period['start_date']}_{period['backtest_date']}" - if key not in seen_dates: - unique_periods.append(period) - seen_dates.add(key) - - if not unique_periods: - raise ValueError("No valid backtest periods found with current parameters") - - # Sort periods by backtest date for clearer analysis - unique_periods.sort(key=lambda x: pd.Timestamp(x["backtest_date"])) - - print("\nRunning backtests with:") - print( - f"- Start dates range: {unique_periods[0]['start_date']} to {unique_periods[-1]['start_date']}" - ) - print( - f"- Backtest dates range: {unique_periods[0]['backtest_date']} to {unique_periods[-1]['backtest_date']}" - ) - print(f"- Minimum training period: {min_training_years} years") - print(f"- Validation period: {validation_years} years") - print(f"- Number of test periods: {len(unique_periods)}") - - print("\nTest periods:") - for period in unique_periods: - print(f"- {period['description']}") - - # Create args tuples with params and DataFrame - args = [(params, df, output) for params in unique_periods] - - # Use multiprocessing - with Pool() as pool: - results = pool.map(run_single_backtest, args) - - # Analyze results - successful_tests = [r for r in results if r["success"]] - failed_tests = [r for r in results if not r["success"]] - - # Define stress periods - stress_periods = { - # COVID crash and recovery - ("2020-03-01", "2020-09-01"): "COVID crash period", - # 2021 peak and subsequent crash - ("2021-11-01", "2022-06-01"): "2021 peak aftermath", - # Add more stress periods as needed - } - - def is_stress_period(test_date): - """Check if a test date falls in any stress period""" - test_date = pd.Timestamp(test_date) - for (start, end), _ in stress_periods.items(): - if pd.Timestamp(start) <= test_date <= pd.Timestamp(end): - return True - return False - - # Categorize results - normal_periods = [] - stress_periods_results = [] - - for result in successful_tests: - if is_stress_period(result["params"]["backtest_date"]): - stress_periods_results.append(result) - else: - normal_periods.append(result) - - # Calculate metrics for each category - def calculate_category_metrics(results): - if not results: - return None - return { - "count": len(results), - "mape": np.mean([r["metrics"]["mape"] for r in results]), - "rmse": np.mean([r["metrics"]["rmse"] for r in results]), - "max_error": np.mean([r["metrics"]["max_error"] for r in results]), - "coverage_95": np.mean([r["metrics"]["coverage_95"] for r in results]), - "coverage_68": np.mean([r["metrics"]["coverage_68"] for r in results]), - } - - normal_metrics = calculate_category_metrics(normal_periods) - stress_metrics = calculate_category_metrics(stress_periods_results) - - # Write detailed results - with output.create("bitcoin_backtest_results_summary.txt") as f: - f.write("Systematic Backtest Results\n") - f.write("==========================\n\n") - - f.write("Configuration:\n") - f.write(f"- Minimum training period: {min_training_years} years\n") - f.write(f"- Validation period: {validation_years} years\n") - f.write( - f"- Start dates range: {unique_periods[0]['start_date']} to {unique_periods[-1]['start_date']}\n" - ) - f.write( - f"- Backtest dates range: {unique_periods[0]['backtest_date']} to {unique_periods[-1]['backtest_date']}\n" - ) - f.write(f"- Number of test periods: {len(unique_periods)}\n\n") - - # Normal Periods - f.write("Normal Market Periods\n") - f.write("====================\n") - f.write(f"Number of periods: {len(normal_periods)}\n\n") - - for result in normal_periods: - f.write("\n" + "=" * 50 + "\n") - f.write(f"Period: {result['params']['description']}\n") - f.write( - f"Training: {result['params']['start_date']} to {result['params']['backtest_date']}\n" - ) - f.write( - f"Validation: {result['params']['backtest_date']} to {pd.Timestamp(result['params']['backtest_date']) + pd.Timedelta(days=validation_years*365):%Y-%m-%d}\n" - ) - f.write("\nMetrics:\n") - for metric, value in result["metrics"].items(): - if metric in ["mape", "coverage_95", "coverage_68"]: - f.write(f"- {metric}: {value:.1f}%\n") - else: - f.write(f"- {metric}: ${value:,.0f}\n") - f.write("\n") - - if normal_metrics: - f.write("\nNormal Periods Aggregate Metrics:\n") - f.write(f"MAPE: {normal_metrics['mape']:.1f}%\n") - f.write(f"RMSE: ${normal_metrics['rmse']:,.0f}\n") - f.write(f"Average Max Error: ${normal_metrics['max_error']:,.0f}\n") - f.write(f"95% CI Coverage: {normal_metrics['coverage_95']:.1f}%\n") - f.write(f"68% CI Coverage: {normal_metrics['coverage_68']:.1f}%\n") - - # Stress Periods - f.write("\n\nStress Periods\n") - f.write("=============\n") - f.write(f"Number of periods: {len(stress_periods_results)}\n\n") - - for result in stress_periods_results: - f.write("\n" + "=" * 50 + "\n") - f.write(f"Period: {result['params']['description']}\n") - f.write( - f"Training: {result['params']['start_date']} to {result['params']['backtest_date']}\n" - ) - f.write( - f"Validation: {result['params']['backtest_date']} to {pd.Timestamp(result['params']['backtest_date']) + pd.Timedelta(days=validation_years*365):%Y-%m-%d}\n" - ) - f.write("\nMetrics:\n") - for metric, value in result["metrics"].items(): - if metric in ["mape", "coverage_95", "coverage_68"]: - f.write(f"- {metric}: {value:.1f}%\n") - else: - f.write(f"- {metric}: ${value:,.0f}\n") - f.write("\n") - - if stress_metrics: - f.write("\nStress Periods Aggregate Metrics:\n") - f.write(f"MAPE: {stress_metrics['mape']:.1f}%\n") - f.write(f"RMSE: ${stress_metrics['rmse']:,.0f}\n") - f.write(f"Average Max Error: ${stress_metrics['max_error']:,.0f}\n") - f.write(f"95% CI Coverage: {stress_metrics['coverage_95']:.1f}%\n") - f.write(f"68% CI Coverage: {stress_metrics['coverage_68']:.1f}%\n") - - return ( - normal_metrics, - stress_metrics, - normal_periods, - stress_periods_results, - failed_tests, - ) - - -# CLI - - -def get_args() -> argparse.Namespace: - parser = argparse.ArgumentParser( - prog="model", - description="Bitcoin price model", - ) - parser.add_argument( - "-o", - "--output", - help="output base directory", - default="./output", - ) - parser.add_argument( - "-n", - "--name", - help="subdir of output base directory", - default="baseline", - ) - return parser.parse_args() - - -def main(): - args = get_args() - global output - output = Output(args.output, args.name) - - analysis, df = analyze_bitcoin_prices("prices.csv") - run_projections(df, output) - normal_metrics, stress_metrics, normal_results, stress_results, failed_tests = ( - run_systematic_backtests(df, output) - ) - - print("\nAggregate Metrics:") - print(f"Total backtests run: {normal_metrics['count']}") - print(f"Successful tests: {len(normal_results)}") - print(f"Failed tests: {len(failed_tests)}") - print("\nAverage Performance:") - print(f"MAPE: {normal_metrics['mape']:.1f}%") - print(f"RMSE: ${normal_metrics['rmse']:,.0f}") - print(f"95% CI Coverage: {normal_metrics['coverage_95']:.1f}%") - print(f"68% CI Coverage: {normal_metrics['coverage_68']:.1f}%") - - print("\nFailed Tests:") - for test in failed_tests: - print(f"Period: {test['params']['description']}") - print(f"Error: {test['error']}\n") - - -if __name__ == "__main__": - main() diff --git a/poetry.lock b/poetry.lock deleted file mode 100644 index e905167..0000000 --- a/poetry.lock +++ /dev/null @@ -1,734 +0,0 @@ -# This file is automatically @generated by Poetry 1.8.4 and should not be changed by hand. - -[[package]] -name = "contourpy" -version = "1.3.1" -description = "Python library for calculating contours of 2D quadrilateral grids" -optional = false -python-versions = ">=3.10" -files = [ - {file = "contourpy-1.3.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a045f341a77b77e1c5de31e74e966537bba9f3c4099b35bf4c2e3939dd54cdab"}, - {file = "contourpy-1.3.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:500360b77259914f7805af7462e41f9cb7ca92ad38e9f94d6c8641b089338124"}, - {file = "contourpy-1.3.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2f926efda994cdf3c8d3fdb40b9962f86edbc4457e739277b961eced3d0b4c1"}, - {file = "contourpy-1.3.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:adce39d67c0edf383647a3a007de0a45fd1b08dedaa5318404f1a73059c2512b"}, - {file = "contourpy-1.3.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:abbb49fb7dac584e5abc6636b7b2a7227111c4f771005853e7d25176daaf8453"}, - {file = "contourpy-1.3.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a0cffcbede75c059f535725c1680dfb17b6ba8753f0c74b14e6a9c68c29d7ea3"}, - {file = "contourpy-1.3.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ab29962927945d89d9b293eabd0d59aea28d887d4f3be6c22deaefbb938a7277"}, - {file = "contourpy-1.3.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:974d8145f8ca354498005b5b981165b74a195abfae9a8129df3e56771961d595"}, - {file = "contourpy-1.3.1-cp310-cp310-win32.whl", hash = "sha256:ac4578ac281983f63b400f7fe6c101bedc10651650eef012be1ccffcbacf3697"}, - {file = "contourpy-1.3.1-cp310-cp310-win_amd64.whl", hash = "sha256:174e758c66bbc1c8576992cec9599ce8b6672b741b5d336b5c74e35ac382b18e"}, - {file = "contourpy-1.3.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3e8b974d8db2c5610fb4e76307e265de0edb655ae8169e8b21f41807ccbeec4b"}, - {file = "contourpy-1.3.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:20914c8c973f41456337652a6eeca26d2148aa96dd7ac323b74516988bea89fc"}, - {file = "contourpy-1.3.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:19d40d37c1c3a4961b4619dd9d77b12124a453cc3d02bb31a07d58ef684d3d86"}, - {file = "contourpy-1.3.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:113231fe3825ebf6f15eaa8bc1f5b0ddc19d42b733345eae0934cb291beb88b6"}, - {file = "contourpy-1.3.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4dbbc03a40f916a8420e420d63e96a1258d3d1b58cbdfd8d1f07b49fcbd38e85"}, - {file = "contourpy-1.3.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a04ecd68acbd77fa2d39723ceca4c3197cb2969633836ced1bea14e219d077c"}, - {file = "contourpy-1.3.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c414fc1ed8ee1dbd5da626cf3710c6013d3d27456651d156711fa24f24bd1291"}, - {file = "contourpy-1.3.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:31c1b55c1f34f80557d3830d3dd93ba722ce7e33a0b472cba0ec3b6535684d8f"}, - {file = "contourpy-1.3.1-cp311-cp311-win32.whl", hash = "sha256:f611e628ef06670df83fce17805c344710ca5cde01edfdc72751311da8585375"}, - {file = "contourpy-1.3.1-cp311-cp311-win_amd64.whl", hash = "sha256:b2bdca22a27e35f16794cf585832e542123296b4687f9fd96822db6bae17bfc9"}, - {file = "contourpy-1.3.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0ffa84be8e0bd33410b17189f7164c3589c229ce5db85798076a3fa136d0e509"}, - {file = "contourpy-1.3.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:805617228ba7e2cbbfb6c503858e626ab528ac2a32a04a2fe88ffaf6b02c32bc"}, - {file = "contourpy-1.3.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ade08d343436a94e633db932e7e8407fe7de8083967962b46bdfc1b0ced39454"}, - {file = "contourpy-1.3.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:47734d7073fb4590b4a40122b35917cd77be5722d80683b249dac1de266aac80"}, - {file = "contourpy-1.3.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2ba94a401342fc0f8b948e57d977557fbf4d515f03c67682dd5c6191cb2d16ec"}, - {file = "contourpy-1.3.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:efa874e87e4a647fd2e4f514d5e91c7d493697127beb95e77d2f7561f6905bd9"}, - {file = "contourpy-1.3.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1bf98051f1045b15c87868dbaea84f92408337d4f81d0e449ee41920ea121d3b"}, - {file = "contourpy-1.3.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:61332c87493b00091423e747ea78200659dc09bdf7fd69edd5e98cef5d3e9a8d"}, - {file = "contourpy-1.3.1-cp312-cp312-win32.whl", hash = "sha256:e914a8cb05ce5c809dd0fe350cfbb4e881bde5e2a38dc04e3afe1b3e58bd158e"}, - {file = "contourpy-1.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:08d9d449a61cf53033612cb368f3a1b26cd7835d9b8cd326647efe43bca7568d"}, - {file = "contourpy-1.3.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a761d9ccfc5e2ecd1bf05534eda382aa14c3e4f9205ba5b1684ecfe400716ef2"}, - {file = "contourpy-1.3.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:523a8ee12edfa36f6d2a49407f705a6ef4c5098de4f498619787e272de93f2d5"}, - {file = "contourpy-1.3.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece6df05e2c41bd46776fbc712e0996f7c94e0d0543af1656956d150c4ca7c81"}, - {file = "contourpy-1.3.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:573abb30e0e05bf31ed067d2f82500ecfdaec15627a59d63ea2d95714790f5c2"}, - {file = "contourpy-1.3.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a9fa36448e6a3a1a9a2ba23c02012c43ed88905ec80163f2ffe2421c7192a5d7"}, - {file = "contourpy-1.3.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ea9924d28fc5586bf0b42d15f590b10c224117e74409dd7a0be3b62b74a501c"}, - {file = "contourpy-1.3.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5b75aa69cb4d6f137b36f7eb2ace9280cfb60c55dc5f61c731fdf6f037f958a3"}, - {file = "contourpy-1.3.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:041b640d4ec01922083645a94bb3b2e777e6b626788f4095cf21abbe266413c1"}, - {file = "contourpy-1.3.1-cp313-cp313-win32.whl", hash = "sha256:36987a15e8ace5f58d4d5da9dca82d498c2bbb28dff6e5d04fbfcc35a9cb3a82"}, - {file = "contourpy-1.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:a7895f46d47671fa7ceec40f31fae721da51ad34bdca0bee83e38870b1f47ffd"}, - {file = "contourpy-1.3.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:9ddeb796389dadcd884c7eb07bd14ef12408aaae358f0e2ae24114d797eede30"}, - {file = "contourpy-1.3.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:19c1555a6801c2f084c7ddc1c6e11f02eb6a6016ca1318dd5452ba3f613a1751"}, - {file = "contourpy-1.3.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:841ad858cff65c2c04bf93875e384ccb82b654574a6d7f30453a04f04af71342"}, - {file = "contourpy-1.3.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4318af1c925fb9a4fb190559ef3eec206845f63e80fb603d47f2d6d67683901c"}, - {file = "contourpy-1.3.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:14c102b0eab282427b662cb590f2e9340a9d91a1c297f48729431f2dcd16e14f"}, - {file = "contourpy-1.3.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:05e806338bfeaa006acbdeba0ad681a10be63b26e1b17317bfac3c5d98f36cda"}, - {file = "contourpy-1.3.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4d76d5993a34ef3df5181ba3c92fabb93f1eaa5729504fb03423fcd9f3177242"}, - {file = "contourpy-1.3.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:89785bb2a1980c1bd87f0cb1517a71cde374776a5f150936b82580ae6ead44a1"}, - {file = "contourpy-1.3.1-cp313-cp313t-win32.whl", hash = "sha256:8eb96e79b9f3dcadbad2a3891672f81cdcab7f95b27f28f1c67d75f045b6b4f1"}, - {file = "contourpy-1.3.1-cp313-cp313t-win_amd64.whl", hash = "sha256:287ccc248c9e0d0566934e7d606201abd74761b5703d804ff3df8935f523d546"}, - {file = "contourpy-1.3.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b457d6430833cee8e4b8e9b6f07aa1c161e5e0d52e118dc102c8f9bd7dd060d6"}, - {file = "contourpy-1.3.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cb76c1a154b83991a3cbbf0dfeb26ec2833ad56f95540b442c73950af2013750"}, - {file = "contourpy-1.3.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:44a29502ca9c7b5ba389e620d44f2fbe792b1fb5734e8b931ad307071ec58c53"}, - {file = "contourpy-1.3.1.tar.gz", hash = "sha256:dfd97abd83335045a913e3bcc4a09c0ceadbe66580cf573fe961f4a825efa699"}, -] - -[package.dependencies] -numpy = ">=1.23" - -[package.extras] -bokeh = ["bokeh", "selenium"] -docs = ["furo", "sphinx (>=7.2)", "sphinx-copybutton"] -mypy = ["contourpy[bokeh,docs]", "docutils-stubs", "mypy (==1.11.1)", "types-Pillow"] -test = ["Pillow", "contourpy[test-no-images]", "matplotlib"] -test-no-images = ["pytest", "pytest-cov", "pytest-rerunfailures", "pytest-xdist", "wurlitzer"] - -[[package]] -name = "cycler" -version = "0.12.1" -description = "Composable style cycles" -optional = false -python-versions = ">=3.8" -files = [ - {file = "cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30"}, - {file = "cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c"}, -] - -[package.extras] -docs = ["ipython", "matplotlib", "numpydoc", "sphinx"] -tests = ["pytest", "pytest-cov", "pytest-xdist"] - -[[package]] -name = "fonttools" -version = "4.55.0" -description = "Tools to manipulate font files" -optional = false -python-versions = ">=3.8" -files = [ - {file = "fonttools-4.55.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:51c029d4c0608a21a3d3d169dfc3fb776fde38f00b35ca11fdab63ba10a16f61"}, - {file = "fonttools-4.55.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bca35b4e411362feab28e576ea10f11268b1aeed883b9f22ed05675b1e06ac69"}, - {file = "fonttools-4.55.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9ce4ba6981e10f7e0ccff6348e9775ce25ffadbee70c9fd1a3737e3e9f5fa74f"}, - {file = "fonttools-4.55.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:31d00f9852a6051dac23294a4cf2df80ced85d1d173a61ba90a3d8f5abc63c60"}, - {file = "fonttools-4.55.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e198e494ca6e11f254bac37a680473a311a88cd40e58f9cc4dc4911dfb686ec6"}, - {file = "fonttools-4.55.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7208856f61770895e79732e1dcbe49d77bd5783adf73ae35f87fcc267df9db81"}, - {file = "fonttools-4.55.0-cp310-cp310-win32.whl", hash = "sha256:e7e6a352ff9e46e8ef8a3b1fe2c4478f8a553e1b5a479f2e899f9dc5f2055880"}, - {file = "fonttools-4.55.0-cp310-cp310-win_amd64.whl", hash = "sha256:636caaeefe586d7c84b5ee0734c1a5ab2dae619dc21c5cf336f304ddb8f6001b"}, - {file = "fonttools-4.55.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:fa34aa175c91477485c44ddfbb51827d470011e558dfd5c7309eb31bef19ec51"}, - {file = "fonttools-4.55.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:37dbb3fdc2ef7302d3199fb12468481cbebaee849e4b04bc55b77c24e3c49189"}, - {file = "fonttools-4.55.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b5263d8e7ef3c0ae87fbce7f3ec2f546dc898d44a337e95695af2cd5ea21a967"}, - {file = "fonttools-4.55.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f307f6b5bf9e86891213b293e538d292cd1677e06d9faaa4bf9c086ad5f132f6"}, - {file = "fonttools-4.55.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f0a4b52238e7b54f998d6a56b46a2c56b59c74d4f8a6747fb9d4042190f37cd3"}, - {file = "fonttools-4.55.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3e569711464f777a5d4ef522e781dc33f8095ab5efd7548958b36079a9f2f88c"}, - {file = "fonttools-4.55.0-cp311-cp311-win32.whl", hash = "sha256:2b3ab90ec0f7b76c983950ac601b58949f47aca14c3f21eed858b38d7ec42b05"}, - {file = "fonttools-4.55.0-cp311-cp311-win_amd64.whl", hash = "sha256:aa046f6a63bb2ad521004b2769095d4c9480c02c1efa7d7796b37826508980b6"}, - {file = "fonttools-4.55.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:838d2d8870f84fc785528a692e724f2379d5abd3fc9dad4d32f91cf99b41e4a7"}, - {file = "fonttools-4.55.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f46b863d74bab7bb0d395f3b68d3f52a03444964e67ce5c43ce43a75efce9246"}, - {file = "fonttools-4.55.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:33b52a9cfe4e658e21b1f669f7309b4067910321757fec53802ca8f6eae96a5a"}, - {file = "fonttools-4.55.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:732a9a63d6ea4a81b1b25a1f2e5e143761b40c2e1b79bb2b68e4893f45139a40"}, - {file = "fonttools-4.55.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7dd91ac3fcb4c491bb4763b820bcab6c41c784111c24172616f02f4bc227c17d"}, - {file = "fonttools-4.55.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1f0e115281a32ff532118aa851ef497a1b7cda617f4621c1cdf81ace3e36fb0c"}, - {file = "fonttools-4.55.0-cp312-cp312-win32.whl", hash = "sha256:6c99b5205844f48a05cb58d4a8110a44d3038c67ed1d79eb733c4953c628b0f6"}, - {file = "fonttools-4.55.0-cp312-cp312-win_amd64.whl", hash = "sha256:f8c8c76037d05652510ae45be1cd8fb5dd2fd9afec92a25374ac82255993d57c"}, - {file = "fonttools-4.55.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8118dc571921dc9e4b288d9cb423ceaf886d195a2e5329cc427df82bba872cd9"}, - {file = "fonttools-4.55.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:01124f2ca6c29fad4132d930da69158d3f49b2350e4a779e1efbe0e82bd63f6c"}, - {file = "fonttools-4.55.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:81ffd58d2691f11f7c8438796e9f21c374828805d33e83ff4b76e4635633674c"}, - {file = "fonttools-4.55.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5435e5f1eb893c35c2bc2b9cd3c9596b0fcb0a59e7a14121562986dd4c47b8dd"}, - {file = "fonttools-4.55.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d12081729280c39d001edd0f4f06d696014c26e6e9a0a55488fabc37c28945e4"}, - {file = "fonttools-4.55.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a7ad1f1b98ab6cb927ab924a38a8649f1ffd7525c75fe5b594f5dab17af70e18"}, - {file = "fonttools-4.55.0-cp313-cp313-win32.whl", hash = "sha256:abe62987c37630dca69a104266277216de1023cf570c1643bb3a19a9509e7a1b"}, - {file = "fonttools-4.55.0-cp313-cp313-win_amd64.whl", hash = "sha256:2863555ba90b573e4201feaf87a7e71ca3b97c05aa4d63548a4b69ea16c9e998"}, - {file = "fonttools-4.55.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:00f7cf55ad58a57ba421b6a40945b85ac7cc73094fb4949c41171d3619a3a47e"}, - {file = "fonttools-4.55.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:f27526042efd6f67bfb0cc2f1610fa20364396f8b1fc5edb9f45bb815fb090b2"}, - {file = "fonttools-4.55.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8e67974326af6a8879dc2a4ec63ab2910a1c1a9680ccd63e4a690950fceddbe"}, - {file = "fonttools-4.55.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:61dc0a13451143c5e987dec5254d9d428f3c2789a549a7cf4f815b63b310c1cc"}, - {file = "fonttools-4.55.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:b2e526b325a903868c62155a6a7e24df53f6ce4c5c3160214d8fe1be2c41b478"}, - {file = "fonttools-4.55.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:b7ef9068a1297714e6fefe5932c33b058aa1d45a2b8be32a4c6dee602ae22b5c"}, - {file = "fonttools-4.55.0-cp38-cp38-win32.whl", hash = "sha256:55718e8071be35dff098976bc249fc243b58efa263768c611be17fe55975d40a"}, - {file = "fonttools-4.55.0-cp38-cp38-win_amd64.whl", hash = "sha256:553bd4f8cc327f310c20158e345e8174c8eed49937fb047a8bda51daf2c353c8"}, - {file = "fonttools-4.55.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:3f901cef813f7c318b77d1c5c14cf7403bae5cb977cede023e22ba4316f0a8f6"}, - {file = "fonttools-4.55.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8c9679fc0dd7e8a5351d321d8d29a498255e69387590a86b596a45659a39eb0d"}, - {file = "fonttools-4.55.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dd2820a8b632f3307ebb0bf57948511c2208e34a4939cf978333bc0a3f11f838"}, - {file = "fonttools-4.55.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:23bbbb49bec613a32ed1b43df0f2b172313cee690c2509f1af8fdedcf0a17438"}, - {file = "fonttools-4.55.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:a656652e1f5d55b9728937a7e7d509b73d23109cddd4e89ee4f49bde03b736c6"}, - {file = "fonttools-4.55.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:f50a1f455902208486fbca47ce33054208a4e437b38da49d6721ce2fef732fcf"}, - {file = "fonttools-4.55.0-cp39-cp39-win32.whl", hash = "sha256:161d1ac54c73d82a3cded44202d0218ab007fde8cf194a23d3dd83f7177a2f03"}, - {file = "fonttools-4.55.0-cp39-cp39-win_amd64.whl", hash = "sha256:ca7fd6987c68414fece41c96836e945e1f320cda56fc96ffdc16e54a44ec57a2"}, - {file = "fonttools-4.55.0-py3-none-any.whl", hash = "sha256:12db5888cd4dd3fcc9f0ee60c6edd3c7e1fd44b7dd0f31381ea03df68f8a153f"}, - {file = "fonttools-4.55.0.tar.gz", hash = "sha256:7636acc6ab733572d5e7eec922b254ead611f1cdad17be3f0be7418e8bfaca71"}, -] - -[package.extras] -all = ["brotli (>=1.0.1)", "brotlicffi (>=0.8.0)", "fs (>=2.2.0,<3)", "lxml (>=4.0)", "lz4 (>=1.7.4.2)", "matplotlib", "munkres", "pycairo", "scipy", "skia-pathops (>=0.5.0)", "sympy", "uharfbuzz (>=0.23.0)", "unicodedata2 (>=15.1.0)", "xattr", "zopfli (>=0.1.4)"] -graphite = ["lz4 (>=1.7.4.2)"] -interpolatable = ["munkres", "pycairo", "scipy"] -lxml = ["lxml (>=4.0)"] -pathops = ["skia-pathops (>=0.5.0)"] -plot = ["matplotlib"] -repacker = ["uharfbuzz (>=0.23.0)"] -symfont = ["sympy"] -type1 = ["xattr"] -ufo = ["fs (>=2.2.0,<3)"] -unicode = ["unicodedata2 (>=15.1.0)"] -woff = ["brotli (>=1.0.1)", "brotlicffi (>=0.8.0)", "zopfli (>=0.1.4)"] - -[[package]] -name = "kiwisolver" -version = "1.4.7" -description = "A fast implementation of the Cassowary constraint solver" -optional = false -python-versions = ">=3.8" -files = [ - {file = "kiwisolver-1.4.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:8a9c83f75223d5e48b0bc9cb1bf2776cf01563e00ade8775ffe13b0b6e1af3a6"}, - {file = "kiwisolver-1.4.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:58370b1ffbd35407444d57057b57da5d6549d2d854fa30249771775c63b5fe17"}, - {file = "kiwisolver-1.4.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:aa0abdf853e09aff551db11fce173e2177d00786c688203f52c87ad7fcd91ef9"}, - {file = "kiwisolver-1.4.7-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:8d53103597a252fb3ab8b5845af04c7a26d5e7ea8122303dd7a021176a87e8b9"}, - {file = "kiwisolver-1.4.7-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:88f17c5ffa8e9462fb79f62746428dd57b46eb931698e42e990ad63103f35e6c"}, - {file = "kiwisolver-1.4.7-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:88a9ca9c710d598fd75ee5de59d5bda2684d9db36a9f50b6125eaea3969c2599"}, - {file = "kiwisolver-1.4.7-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f4d742cb7af1c28303a51b7a27aaee540e71bb8e24f68c736f6f2ffc82f2bf05"}, - {file = "kiwisolver-1.4.7-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e28c7fea2196bf4c2f8d46a0415c77a1c480cc0724722f23d7410ffe9842c407"}, - {file = "kiwisolver-1.4.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e968b84db54f9d42046cf154e02911e39c0435c9801681e3fc9ce8a3c4130278"}, - {file = "kiwisolver-1.4.7-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0c18ec74c0472de033e1bebb2911c3c310eef5649133dd0bedf2a169a1b269e5"}, - {file = "kiwisolver-1.4.7-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:8f0ea6da6d393d8b2e187e6a5e3fb81f5862010a40c3945e2c6d12ae45cfb2ad"}, - {file = "kiwisolver-1.4.7-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:f106407dda69ae456dd1227966bf445b157ccc80ba0dff3802bb63f30b74e895"}, - {file = "kiwisolver-1.4.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:84ec80df401cfee1457063732d90022f93951944b5b58975d34ab56bb150dfb3"}, - {file = "kiwisolver-1.4.7-cp310-cp310-win32.whl", hash = "sha256:71bb308552200fb2c195e35ef05de12f0c878c07fc91c270eb3d6e41698c3bcc"}, - {file = "kiwisolver-1.4.7-cp310-cp310-win_amd64.whl", hash = "sha256:44756f9fd339de0fb6ee4f8c1696cfd19b2422e0d70b4cefc1cc7f1f64045a8c"}, - {file = "kiwisolver-1.4.7-cp310-cp310-win_arm64.whl", hash = "sha256:78a42513018c41c2ffd262eb676442315cbfe3c44eed82385c2ed043bc63210a"}, - {file = "kiwisolver-1.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d2b0e12a42fb4e72d509fc994713d099cbb15ebf1103545e8a45f14da2dfca54"}, - {file = "kiwisolver-1.4.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2a8781ac3edc42ea4b90bc23e7d37b665d89423818e26eb6df90698aa2287c95"}, - {file = "kiwisolver-1.4.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:46707a10836894b559e04b0fd143e343945c97fd170d69a2d26d640b4e297935"}, - {file = "kiwisolver-1.4.7-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ef97b8df011141c9b0f6caf23b29379f87dd13183c978a30a3c546d2c47314cb"}, - {file = "kiwisolver-1.4.7-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3ab58c12a2cd0fc769089e6d38466c46d7f76aced0a1f54c77652446733d2d02"}, - {file = "kiwisolver-1.4.7-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:803b8e1459341c1bb56d1c5c010406d5edec8a0713a0945851290a7930679b51"}, - {file = "kiwisolver-1.4.7-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f9a9e8a507420fe35992ee9ecb302dab68550dedc0da9e2880dd88071c5fb052"}, - {file = "kiwisolver-1.4.7-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:18077b53dc3bb490e330669a99920c5e6a496889ae8c63b58fbc57c3d7f33a18"}, - {file = "kiwisolver-1.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6af936f79086a89b3680a280c47ea90b4df7047b5bdf3aa5c524bbedddb9e545"}, - {file = "kiwisolver-1.4.7-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:3abc5b19d24af4b77d1598a585b8a719beb8569a71568b66f4ebe1fb0449460b"}, - {file = "kiwisolver-1.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:933d4de052939d90afbe6e9d5273ae05fb836cc86c15b686edd4b3560cc0ee36"}, - {file = "kiwisolver-1.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:65e720d2ab2b53f1f72fb5da5fb477455905ce2c88aaa671ff0a447c2c80e8e3"}, - {file = "kiwisolver-1.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3bf1ed55088f214ba6427484c59553123fdd9b218a42bbc8c6496d6754b1e523"}, - {file = "kiwisolver-1.4.7-cp311-cp311-win32.whl", hash = "sha256:4c00336b9dd5ad96d0a558fd18a8b6f711b7449acce4c157e7343ba92dd0cf3d"}, - {file = "kiwisolver-1.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:929e294c1ac1e9f615c62a4e4313ca1823ba37326c164ec720a803287c4c499b"}, - {file = "kiwisolver-1.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:e33e8fbd440c917106b237ef1a2f1449dfbb9b6f6e1ce17c94cd6a1e0d438376"}, - {file = "kiwisolver-1.4.7-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:5360cc32706dab3931f738d3079652d20982511f7c0ac5711483e6eab08efff2"}, - {file = "kiwisolver-1.4.7-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:942216596dc64ddb25adb215c3c783215b23626f8d84e8eff8d6d45c3f29f75a"}, - {file = "kiwisolver-1.4.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:48b571ecd8bae15702e4f22d3ff6a0f13e54d3d00cd25216d5e7f658242065ee"}, - {file = "kiwisolver-1.4.7-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ad42ba922c67c5f219097b28fae965e10045ddf145d2928bfac2eb2e17673640"}, - {file = "kiwisolver-1.4.7-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:612a10bdae23404a72941a0fc8fa2660c6ea1217c4ce0dbcab8a8f6543ea9e7f"}, - {file = "kiwisolver-1.4.7-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9e838bba3a3bac0fe06d849d29772eb1afb9745a59710762e4ba3f4cb8424483"}, - {file = "kiwisolver-1.4.7-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:22f499f6157236c19f4bbbd472fa55b063db77a16cd74d49afe28992dff8c258"}, - {file = "kiwisolver-1.4.7-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:693902d433cf585133699972b6d7c42a8b9f8f826ebcaf0132ff55200afc599e"}, - {file = "kiwisolver-1.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4e77f2126c3e0b0d055f44513ed349038ac180371ed9b52fe96a32aa071a5107"}, - {file = "kiwisolver-1.4.7-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:657a05857bda581c3656bfc3b20e353c232e9193eb167766ad2dc58b56504948"}, - {file = "kiwisolver-1.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4bfa75a048c056a411f9705856abfc872558e33c055d80af6a380e3658766038"}, - {file = "kiwisolver-1.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:34ea1de54beef1c104422d210c47c7d2a4999bdecf42c7b5718fbe59a4cac383"}, - {file = "kiwisolver-1.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:90da3b5f694b85231cf93586dad5e90e2d71b9428f9aad96952c99055582f520"}, - {file = "kiwisolver-1.4.7-cp312-cp312-win32.whl", hash = "sha256:18e0cca3e008e17fe9b164b55735a325140a5a35faad8de92dd80265cd5eb80b"}, - {file = "kiwisolver-1.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:58cb20602b18f86f83a5c87d3ee1c766a79c0d452f8def86d925e6c60fbf7bfb"}, - {file = "kiwisolver-1.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:f5a8b53bdc0b3961f8b6125e198617c40aeed638b387913bf1ce78afb1b0be2a"}, - {file = "kiwisolver-1.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2e6039dcbe79a8e0f044f1c39db1986a1b8071051efba3ee4d74f5b365f5226e"}, - {file = "kiwisolver-1.4.7-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a1ecf0ac1c518487d9d23b1cd7139a6a65bc460cd101ab01f1be82ecf09794b6"}, - {file = "kiwisolver-1.4.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7ab9ccab2b5bd5702ab0803676a580fffa2aa178c2badc5557a84cc943fcf750"}, - {file = "kiwisolver-1.4.7-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f816dd2277f8d63d79f9c8473a79fe54047bc0467754962840782c575522224d"}, - {file = "kiwisolver-1.4.7-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cf8bcc23ceb5a1b624572a1623b9f79d2c3b337c8c455405ef231933a10da379"}, - {file = "kiwisolver-1.4.7-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dea0bf229319828467d7fca8c7c189780aa9ff679c94539eed7532ebe33ed37c"}, - {file = "kiwisolver-1.4.7-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c06a4c7cf15ec739ce0e5971b26c93638730090add60e183530d70848ebdd34"}, - {file = "kiwisolver-1.4.7-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:913983ad2deb14e66d83c28b632fd35ba2b825031f2fa4ca29675e665dfecbe1"}, - {file = "kiwisolver-1.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5337ec7809bcd0f424c6b705ecf97941c46279cf5ed92311782c7c9c2026f07f"}, - {file = "kiwisolver-1.4.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4c26ed10c4f6fa6ddb329a5120ba3b6db349ca192ae211e882970bfc9d91420b"}, - {file = "kiwisolver-1.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c619b101e6de2222c1fcb0531e1b17bbffbe54294bfba43ea0d411d428618c27"}, - {file = "kiwisolver-1.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:073a36c8273647592ea332e816e75ef8da5c303236ec0167196793eb1e34657a"}, - {file = "kiwisolver-1.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3ce6b2b0231bda412463e152fc18335ba32faf4e8c23a754ad50ffa70e4091ee"}, - {file = "kiwisolver-1.4.7-cp313-cp313-win32.whl", hash = "sha256:f4c9aee212bc89d4e13f58be11a56cc8036cabad119259d12ace14b34476fd07"}, - {file = "kiwisolver-1.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:8a3ec5aa8e38fc4c8af308917ce12c536f1c88452ce554027e55b22cbbfbff76"}, - {file = "kiwisolver-1.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:76c8094ac20ec259471ac53e774623eb62e6e1f56cd8690c67ce6ce4fcb05650"}, - {file = "kiwisolver-1.4.7-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:5d5abf8f8ec1f4e22882273c423e16cae834c36856cac348cfbfa68e01c40f3a"}, - {file = "kiwisolver-1.4.7-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:aeb3531b196ef6f11776c21674dba836aeea9d5bd1cf630f869e3d90b16cfade"}, - {file = "kiwisolver-1.4.7-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:b7d755065e4e866a8086c9bdada157133ff466476a2ad7861828e17b6026e22c"}, - {file = "kiwisolver-1.4.7-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:08471d4d86cbaec61f86b217dd938a83d85e03785f51121e791a6e6689a3be95"}, - {file = "kiwisolver-1.4.7-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7bbfcb7165ce3d54a3dfbe731e470f65739c4c1f85bb1018ee912bae139e263b"}, - {file = "kiwisolver-1.4.7-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5d34eb8494bea691a1a450141ebb5385e4b69d38bb8403b5146ad279f4b30fa3"}, - {file = "kiwisolver-1.4.7-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9242795d174daa40105c1d86aba618e8eab7bf96ba8c3ee614da8302a9f95503"}, - {file = "kiwisolver-1.4.7-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:a0f64a48bb81af7450e641e3fe0b0394d7381e342805479178b3d335d60ca7cf"}, - {file = "kiwisolver-1.4.7-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:8e045731a5416357638d1700927529e2b8ab304811671f665b225f8bf8d8f933"}, - {file = "kiwisolver-1.4.7-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:4322872d5772cae7369f8351da1edf255a604ea7087fe295411397d0cfd9655e"}, - {file = "kiwisolver-1.4.7-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:e1631290ee9271dffe3062d2634c3ecac02c83890ada077d225e081aca8aab89"}, - {file = "kiwisolver-1.4.7-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:edcfc407e4eb17e037bca59be0e85a2031a2ac87e4fed26d3e9df88b4165f92d"}, - {file = "kiwisolver-1.4.7-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:4d05d81ecb47d11e7f8932bd8b61b720bf0b41199358f3f5e36d38e28f0532c5"}, - {file = "kiwisolver-1.4.7-cp38-cp38-win32.whl", hash = "sha256:b38ac83d5f04b15e515fd86f312479d950d05ce2368d5413d46c088dda7de90a"}, - {file = "kiwisolver-1.4.7-cp38-cp38-win_amd64.whl", hash = "sha256:d83db7cde68459fc803052a55ace60bea2bae361fc3b7a6d5da07e11954e4b09"}, - {file = "kiwisolver-1.4.7-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:3f9362ecfca44c863569d3d3c033dbe8ba452ff8eed6f6b5806382741a1334bd"}, - {file = "kiwisolver-1.4.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:e8df2eb9b2bac43ef8b082e06f750350fbbaf2887534a5be97f6cf07b19d9583"}, - {file = "kiwisolver-1.4.7-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f32d6edbc638cde7652bd690c3e728b25332acbadd7cad670cc4a02558d9c417"}, - {file = "kiwisolver-1.4.7-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:e2e6c39bd7b9372b0be21456caab138e8e69cc0fc1190a9dfa92bd45a1e6e904"}, - {file = "kiwisolver-1.4.7-cp39-cp39-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:dda56c24d869b1193fcc763f1284b9126550eaf84b88bbc7256e15028f19188a"}, - {file = "kiwisolver-1.4.7-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:79849239c39b5e1fd906556c474d9b0439ea6792b637511f3fe3a41158d89ca8"}, - {file = "kiwisolver-1.4.7-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5e3bc157fed2a4c02ec468de4ecd12a6e22818d4f09cde2c31ee3226ffbefab2"}, - {file = "kiwisolver-1.4.7-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3da53da805b71e41053dc670f9a820d1157aae77b6b944e08024d17bcd51ef88"}, - {file = "kiwisolver-1.4.7-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:8705f17dfeb43139a692298cb6637ee2e59c0194538153e83e9ee0c75c2eddde"}, - {file = "kiwisolver-1.4.7-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:82a5c2f4b87c26bb1a0ef3d16b5c4753434633b83d365cc0ddf2770c93829e3c"}, - {file = "kiwisolver-1.4.7-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:ce8be0466f4c0d585cdb6c1e2ed07232221df101a4c6f28821d2aa754ca2d9e2"}, - {file = "kiwisolver-1.4.7-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:409afdfe1e2e90e6ee7fc896f3df9a7fec8e793e58bfa0d052c8a82f99c37abb"}, - {file = "kiwisolver-1.4.7-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:5b9c3f4ee0b9a439d2415012bd1b1cc2df59e4d6a9939f4d669241d30b414327"}, - {file = "kiwisolver-1.4.7-cp39-cp39-win32.whl", hash = "sha256:a79ae34384df2b615eefca647a2873842ac3b596418032bef9a7283675962644"}, - {file = "kiwisolver-1.4.7-cp39-cp39-win_amd64.whl", hash = "sha256:cf0438b42121a66a3a667de17e779330fc0f20b0d97d59d2f2121e182b0505e4"}, - {file = "kiwisolver-1.4.7-cp39-cp39-win_arm64.whl", hash = "sha256:764202cc7e70f767dab49e8df52c7455e8de0df5d858fa801a11aa0d882ccf3f"}, - {file = "kiwisolver-1.4.7-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:94252291e3fe68001b1dd747b4c0b3be12582839b95ad4d1b641924d68fd4643"}, - {file = "kiwisolver-1.4.7-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5b7dfa3b546da08a9f622bb6becdb14b3e24aaa30adba66749d38f3cc7ea9706"}, - {file = "kiwisolver-1.4.7-pp310-pypy310_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bd3de6481f4ed8b734da5df134cd5a6a64fe32124fe83dde1e5b5f29fe30b1e6"}, - {file = "kiwisolver-1.4.7-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a91b5f9f1205845d488c928e8570dcb62b893372f63b8b6e98b863ebd2368ff2"}, - {file = "kiwisolver-1.4.7-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:40fa14dbd66b8b8f470d5fc79c089a66185619d31645f9b0773b88b19f7223c4"}, - {file = "kiwisolver-1.4.7-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:eb542fe7933aa09d8d8f9d9097ef37532a7df6497819d16efe4359890a2f417a"}, - {file = "kiwisolver-1.4.7-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:bfa1acfa0c54932d5607e19a2c24646fb4c1ae2694437789129cf099789a3b00"}, - {file = "kiwisolver-1.4.7-pp38-pypy38_pp73-macosx_11_0_arm64.whl", hash = "sha256:eee3ea935c3d227d49b4eb85660ff631556841f6e567f0f7bda972df6c2c9935"}, - {file = "kiwisolver-1.4.7-pp38-pypy38_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:f3160309af4396e0ed04db259c3ccbfdc3621b5559b5453075e5de555e1f3a1b"}, - {file = "kiwisolver-1.4.7-pp38-pypy38_pp73-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:a17f6a29cf8935e587cc8a4dbfc8368c55edc645283db0ce9801016f83526c2d"}, - {file = "kiwisolver-1.4.7-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:10849fb2c1ecbfae45a693c070e0320a91b35dd4bcf58172c023b994283a124d"}, - {file = "kiwisolver-1.4.7-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:ac542bf38a8a4be2dc6b15248d36315ccc65f0743f7b1a76688ffb6b5129a5c2"}, - {file = "kiwisolver-1.4.7-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:8b01aac285f91ca889c800042c35ad3b239e704b150cfd3382adfc9dcc780e39"}, - {file = "kiwisolver-1.4.7-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:48be928f59a1f5c8207154f935334d374e79f2b5d212826307d072595ad76a2e"}, - {file = "kiwisolver-1.4.7-pp39-pypy39_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f37cfe618a117e50d8c240555331160d73d0411422b59b5ee217843d7b693608"}, - {file = "kiwisolver-1.4.7-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:599b5c873c63a1f6ed7eead644a8a380cfbdf5db91dcb6f85707aaab213b1674"}, - {file = "kiwisolver-1.4.7-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:801fa7802e5cfabe3ab0c81a34c323a319b097dfb5004be950482d882f3d7225"}, - {file = "kiwisolver-1.4.7-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:0c6c43471bc764fad4bc99c5c2d6d16a676b1abf844ca7c8702bdae92df01ee0"}, - {file = "kiwisolver-1.4.7.tar.gz", hash = "sha256:9893ff81bd7107f7b685d3017cc6583daadb4fc26e4a888350df530e41980a60"}, -] - -[[package]] -name = "matplotlib" -version = "3.9.2" -description = "Python plotting package" -optional = false -python-versions = ">=3.9" -files = [ - {file = "matplotlib-3.9.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:9d78bbc0cbc891ad55b4f39a48c22182e9bdaea7fc0e5dbd364f49f729ca1bbb"}, - {file = "matplotlib-3.9.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c375cc72229614632c87355366bdf2570c2dac01ac66b8ad048d2dabadf2d0d4"}, - {file = "matplotlib-3.9.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d94ff717eb2bd0b58fe66380bd8b14ac35f48a98e7c6765117fe67fb7684e64"}, - {file = "matplotlib-3.9.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ab68d50c06938ef28681073327795c5db99bb4666214d2d5f880ed11aeaded66"}, - {file = "matplotlib-3.9.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:65aacf95b62272d568044531e41de26285d54aec8cb859031f511f84bd8b495a"}, - {file = "matplotlib-3.9.2-cp310-cp310-win_amd64.whl", hash = "sha256:3fd595f34aa8a55b7fc8bf9ebea8aa665a84c82d275190a61118d33fbc82ccae"}, - {file = "matplotlib-3.9.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:d8dd059447824eec055e829258ab092b56bb0579fc3164fa09c64f3acd478772"}, - {file = "matplotlib-3.9.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c797dac8bb9c7a3fd3382b16fe8f215b4cf0f22adccea36f1545a6d7be310b41"}, - {file = "matplotlib-3.9.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d719465db13267bcef19ea8954a971db03b9f48b4647e3860e4bc8e6ed86610f"}, - {file = "matplotlib-3.9.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8912ef7c2362f7193b5819d17dae8629b34a95c58603d781329712ada83f9447"}, - {file = "matplotlib-3.9.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7741f26a58a240f43bee74965c4882b6c93df3e7eb3de160126d8c8f53a6ae6e"}, - {file = "matplotlib-3.9.2-cp311-cp311-win_amd64.whl", hash = "sha256:ae82a14dab96fbfad7965403c643cafe6515e386de723e498cf3eeb1e0b70cc7"}, - {file = "matplotlib-3.9.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:ac43031375a65c3196bee99f6001e7fa5bdfb00ddf43379d3c0609bdca042df9"}, - {file = "matplotlib-3.9.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:be0fc24a5e4531ae4d8e858a1a548c1fe33b176bb13eff7f9d0d38ce5112a27d"}, - {file = "matplotlib-3.9.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bf81de2926c2db243c9b2cbc3917619a0fc85796c6ba4e58f541df814bbf83c7"}, - {file = "matplotlib-3.9.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f6ee45bc4245533111ced13f1f2cace1e7f89d1c793390392a80c139d6cf0e6c"}, - {file = "matplotlib-3.9.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:306c8dfc73239f0e72ac50e5a9cf19cc4e8e331dd0c54f5e69ca8758550f1e1e"}, - {file = "matplotlib-3.9.2-cp312-cp312-win_amd64.whl", hash = "sha256:5413401594cfaff0052f9d8b1aafc6d305b4bd7c4331dccd18f561ff7e1d3bd3"}, - {file = "matplotlib-3.9.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:18128cc08f0d3cfff10b76baa2f296fc28c4607368a8402de61bb3f2eb33c7d9"}, - {file = "matplotlib-3.9.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4876d7d40219e8ae8bb70f9263bcbe5714415acfdf781086601211335e24f8aa"}, - {file = "matplotlib-3.9.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6d9f07a80deab4bb0b82858a9e9ad53d1382fd122be8cde11080f4e7dfedb38b"}, - {file = "matplotlib-3.9.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f7c0410f181a531ec4e93bbc27692f2c71a15c2da16766f5ba9761e7ae518413"}, - {file = "matplotlib-3.9.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:909645cce2dc28b735674ce0931a4ac94e12f5b13f6bb0b5a5e65e7cea2c192b"}, - {file = "matplotlib-3.9.2-cp313-cp313-win_amd64.whl", hash = "sha256:f32c7410c7f246838a77d6d1eff0c0f87f3cb0e7c4247aebea71a6d5a68cab49"}, - {file = "matplotlib-3.9.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:37e51dd1c2db16ede9cfd7b5cabdfc818b2c6397c83f8b10e0e797501c963a03"}, - {file = "matplotlib-3.9.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b82c5045cebcecd8496a4d694d43f9cc84aeeb49fe2133e036b207abe73f4d30"}, - {file = "matplotlib-3.9.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f053c40f94bc51bc03832a41b4f153d83f2062d88c72b5e79997072594e97e51"}, - {file = "matplotlib-3.9.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dbe196377a8248972f5cede786d4c5508ed5f5ca4a1e09b44bda889958b33f8c"}, - {file = "matplotlib-3.9.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:5816b1e1fe8c192cbc013f8f3e3368ac56fbecf02fb41b8f8559303f24c5015e"}, - {file = "matplotlib-3.9.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:cef2a73d06601437be399908cf13aee74e86932a5ccc6ccdf173408ebc5f6bb2"}, - {file = "matplotlib-3.9.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e0830e188029c14e891fadd99702fd90d317df294c3298aad682739c5533721a"}, - {file = "matplotlib-3.9.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:03ba9c1299c920964e8d3857ba27173b4dbb51ca4bab47ffc2c2ba0eb5e2cbc5"}, - {file = "matplotlib-3.9.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1cd93b91ab47a3616b4d3c42b52f8363b88ca021e340804c6ab2536344fad9ca"}, - {file = "matplotlib-3.9.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:6d1ce5ed2aefcdce11904fc5bbea7d9c21fff3d5f543841edf3dea84451a09ea"}, - {file = "matplotlib-3.9.2-cp39-cp39-win_amd64.whl", hash = "sha256:b2696efdc08648536efd4e1601b5fd491fd47f4db97a5fbfd175549a7365c1b2"}, - {file = "matplotlib-3.9.2-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:d52a3b618cb1cbb769ce2ee1dcdb333c3ab6e823944e9a2d36e37253815f9556"}, - {file = "matplotlib-3.9.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:039082812cacd6c6bec8e17a9c1e6baca230d4116d522e81e1f63a74d01d2e21"}, - {file = "matplotlib-3.9.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6758baae2ed64f2331d4fd19be38b7b4eae3ecec210049a26b6a4f3ae1c85dcc"}, - {file = "matplotlib-3.9.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:050598c2b29e0b9832cde72bcf97627bf00262adbc4a54e2b856426bb2ef0697"}, - {file = "matplotlib-3.9.2.tar.gz", hash = "sha256:96ab43906269ca64a6366934106fa01534454a69e471b7bf3d79083981aaab92"}, -] - -[package.dependencies] -contourpy = ">=1.0.1" -cycler = ">=0.10" -fonttools = ">=4.22.0" -kiwisolver = ">=1.3.1" -numpy = ">=1.23" -packaging = ">=20.0" -pillow = ">=8" -pyparsing = ">=2.3.1" -python-dateutil = ">=2.7" - -[package.extras] -dev = ["meson-python (>=0.13.1)", "numpy (>=1.25)", "pybind11 (>=2.6)", "setuptools (>=64)", "setuptools_scm (>=7)"] - -[[package]] -name = "numpy" -version = "2.1.3" -description = "Fundamental package for array computing in Python" -optional = false -python-versions = ">=3.10" -files = [ - {file = "numpy-2.1.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c894b4305373b9c5576d7a12b473702afdf48ce5369c074ba304cc5ad8730dff"}, - {file = "numpy-2.1.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:b47fbb433d3260adcd51eb54f92a2ffbc90a4595f8970ee00e064c644ac788f5"}, - {file = "numpy-2.1.3-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:825656d0743699c529c5943554d223c021ff0494ff1442152ce887ef4f7561a1"}, - {file = "numpy-2.1.3-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:6a4825252fcc430a182ac4dee5a505053d262c807f8a924603d411f6718b88fd"}, - {file = "numpy-2.1.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e711e02f49e176a01d0349d82cb5f05ba4db7d5e7e0defd026328e5cfb3226d3"}, - {file = "numpy-2.1.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78574ac2d1a4a02421f25da9559850d59457bac82f2b8d7a44fe83a64f770098"}, - {file = "numpy-2.1.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c7662f0e3673fe4e832fe07b65c50342ea27d989f92c80355658c7f888fcc83c"}, - {file = "numpy-2.1.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:fa2d1337dc61c8dc417fbccf20f6d1e139896a30721b7f1e832b2bb6ef4eb6c4"}, - {file = "numpy-2.1.3-cp310-cp310-win32.whl", hash = "sha256:72dcc4a35a8515d83e76b58fdf8113a5c969ccd505c8a946759b24e3182d1f23"}, - {file = "numpy-2.1.3-cp310-cp310-win_amd64.whl", hash = "sha256:ecc76a9ba2911d8d37ac01de72834d8849e55473457558e12995f4cd53e778e0"}, - {file = "numpy-2.1.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4d1167c53b93f1f5d8a139a742b3c6f4d429b54e74e6b57d0eff40045187b15d"}, - {file = "numpy-2.1.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c80e4a09b3d95b4e1cac08643f1152fa71a0a821a2d4277334c88d54b2219a41"}, - {file = "numpy-2.1.3-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:576a1c1d25e9e02ed7fa5477f30a127fe56debd53b8d2c89d5578f9857d03ca9"}, - {file = "numpy-2.1.3-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:973faafebaae4c0aaa1a1ca1ce02434554d67e628b8d805e61f874b84e136b09"}, - {file = "numpy-2.1.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:762479be47a4863e261a840e8e01608d124ee1361e48b96916f38b119cfda04a"}, - {file = "numpy-2.1.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc6f24b3d1ecc1eebfbf5d6051faa49af40b03be1aaa781ebdadcbc090b4539b"}, - {file = "numpy-2.1.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:17ee83a1f4fef3c94d16dc1802b998668b5419362c8a4f4e8a491de1b41cc3ee"}, - {file = "numpy-2.1.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:15cb89f39fa6d0bdfb600ea24b250e5f1a3df23f901f51c8debaa6a5d122b2f0"}, - {file = "numpy-2.1.3-cp311-cp311-win32.whl", hash = "sha256:d9beb777a78c331580705326d2367488d5bc473b49a9bc3036c154832520aca9"}, - {file = "numpy-2.1.3-cp311-cp311-win_amd64.whl", hash = "sha256:d89dd2b6da69c4fff5e39c28a382199ddedc3a5be5390115608345dec660b9e2"}, - {file = "numpy-2.1.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f55ba01150f52b1027829b50d70ef1dafd9821ea82905b63936668403c3b471e"}, - {file = "numpy-2.1.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:13138eadd4f4da03074851a698ffa7e405f41a0845a6b1ad135b81596e4e9958"}, - {file = "numpy-2.1.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:a6b46587b14b888e95e4a24d7b13ae91fa22386c199ee7b418f449032b2fa3b8"}, - {file = "numpy-2.1.3-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:0fa14563cc46422e99daef53d725d0c326e99e468a9320a240affffe87852564"}, - {file = "numpy-2.1.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8637dcd2caa676e475503d1f8fdb327bc495554e10838019651b76d17b98e512"}, - {file = "numpy-2.1.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2312b2aa89e1f43ecea6da6ea9a810d06aae08321609d8dc0d0eda6d946a541b"}, - {file = "numpy-2.1.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a38c19106902bb19351b83802531fea19dee18e5b37b36454f27f11ff956f7fc"}, - {file = "numpy-2.1.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:02135ade8b8a84011cbb67dc44e07c58f28575cf9ecf8ab304e51c05528c19f0"}, - {file = "numpy-2.1.3-cp312-cp312-win32.whl", hash = "sha256:e6988e90fcf617da2b5c78902fe8e668361b43b4fe26dbf2d7b0f8034d4cafb9"}, - {file = "numpy-2.1.3-cp312-cp312-win_amd64.whl", hash = "sha256:0d30c543f02e84e92c4b1f415b7c6b5326cbe45ee7882b6b77db7195fb971e3a"}, - {file = "numpy-2.1.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96fe52fcdb9345b7cd82ecd34547fca4321f7656d500eca497eb7ea5a926692f"}, - {file = "numpy-2.1.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f653490b33e9c3a4c1c01d41bc2aef08f9475af51146e4a7710c450cf9761598"}, - {file = "numpy-2.1.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:dc258a761a16daa791081d026f0ed4399b582712e6fc887a95af09df10c5ca57"}, - {file = "numpy-2.1.3-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:016d0f6f5e77b0f0d45d77387ffa4bb89816b57c835580c3ce8e099ef830befe"}, - {file = "numpy-2.1.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c181ba05ce8299c7aa3125c27b9c2167bca4a4445b7ce73d5febc411ca692e43"}, - {file = "numpy-2.1.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5641516794ca9e5f8a4d17bb45446998c6554704d888f86df9b200e66bdcce56"}, - {file = "numpy-2.1.3-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ea4dedd6e394a9c180b33c2c872b92f7ce0f8e7ad93e9585312b0c5a04777a4a"}, - {file = "numpy-2.1.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b0df3635b9c8ef48bd3be5f862cf71b0a4716fa0e702155c45067c6b711ddcef"}, - {file = "numpy-2.1.3-cp313-cp313-win32.whl", hash = "sha256:50ca6aba6e163363f132b5c101ba078b8cbd3fa92c7865fd7d4d62d9779ac29f"}, - {file = "numpy-2.1.3-cp313-cp313-win_amd64.whl", hash = "sha256:747641635d3d44bcb380d950679462fae44f54b131be347d5ec2bce47d3df9ed"}, - {file = "numpy-2.1.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:996bb9399059c5b82f76b53ff8bb686069c05acc94656bb259b1d63d04a9506f"}, - {file = "numpy-2.1.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:45966d859916ad02b779706bb43b954281db43e185015df6eb3323120188f9e4"}, - {file = "numpy-2.1.3-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:baed7e8d7481bfe0874b566850cb0b85243e982388b7b23348c6db2ee2b2ae8e"}, - {file = "numpy-2.1.3-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:a9f7f672a3388133335589cfca93ed468509cb7b93ba3105fce780d04a6576a0"}, - {file = "numpy-2.1.3-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7aac50327da5d208db2eec22eb11e491e3fe13d22653dce51b0f4109101b408"}, - {file = "numpy-2.1.3-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4394bc0dbd074b7f9b52024832d16e019decebf86caf909d94f6b3f77a8ee3b6"}, - {file = "numpy-2.1.3-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:50d18c4358a0a8a53f12a8ba9d772ab2d460321e6a93d6064fc22443d189853f"}, - {file = "numpy-2.1.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:14e253bd43fc6b37af4921b10f6add6925878a42a0c5fe83daee390bca80bc17"}, - {file = "numpy-2.1.3-cp313-cp313t-win32.whl", hash = "sha256:08788d27a5fd867a663f6fc753fd7c3ad7e92747efc73c53bca2f19f8bc06f48"}, - {file = "numpy-2.1.3-cp313-cp313t-win_amd64.whl", hash = "sha256:2564fbdf2b99b3f815f2107c1bbc93e2de8ee655a69c261363a1172a79a257d4"}, - {file = "numpy-2.1.3-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:4f2015dfe437dfebbfce7c85c7b53d81ba49e71ba7eadbf1df40c915af75979f"}, - {file = "numpy-2.1.3-pp310-pypy310_pp73-macosx_14_0_x86_64.whl", hash = "sha256:3522b0dfe983a575e6a9ab3a4a4dfe156c3e428468ff08ce582b9bb6bd1d71d4"}, - {file = "numpy-2.1.3-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c006b607a865b07cd981ccb218a04fc86b600411d83d6fc261357f1c0966755d"}, - {file = "numpy-2.1.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:e14e26956e6f1696070788252dcdff11b4aca4c3e8bd166e0df1bb8f315a67cb"}, - {file = "numpy-2.1.3.tar.gz", hash = "sha256:aa08e04e08aaf974d4458def539dece0d28146d866a39da5639596f4921fd761"}, -] - -[[package]] -name = "packaging" -version = "24.2" -description = "Core utilities for Python packages" -optional = false -python-versions = ">=3.8" -files = [ - {file = "packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759"}, - {file = "packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f"}, -] - -[[package]] -name = "pandas" -version = "2.2.3" -description = "Powerful data structures for data analysis, time series, and statistics" -optional = false -python-versions = ">=3.9" -files = [ - {file = "pandas-2.2.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:1948ddde24197a0f7add2bdc4ca83bf2b1ef84a1bc8ccffd95eda17fd836ecb5"}, - {file = "pandas-2.2.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:381175499d3802cde0eabbaf6324cce0c4f5d52ca6f8c377c29ad442f50f6348"}, - {file = "pandas-2.2.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d9c45366def9a3dd85a6454c0e7908f2b3b8e9c138f5dc38fed7ce720d8453ed"}, - {file = "pandas-2.2.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:86976a1c5b25ae3f8ccae3a5306e443569ee3c3faf444dfd0f41cda24667ad57"}, - {file = "pandas-2.2.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b8661b0238a69d7aafe156b7fa86c44b881387509653fdf857bebc5e4008ad42"}, - {file = "pandas-2.2.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:37e0aced3e8f539eccf2e099f65cdb9c8aa85109b0be6e93e2baff94264bdc6f"}, - {file = "pandas-2.2.3-cp310-cp310-win_amd64.whl", hash = "sha256:56534ce0746a58afaf7942ba4863e0ef81c9c50d3f0ae93e9497d6a41a057645"}, - {file = "pandas-2.2.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:66108071e1b935240e74525006034333f98bcdb87ea116de573a6a0dccb6c039"}, - {file = "pandas-2.2.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7c2875855b0ff77b2a64a0365e24455d9990730d6431b9e0ee18ad8acee13dbd"}, - {file = "pandas-2.2.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cd8d0c3be0515c12fed0bdbae072551c8b54b7192c7b1fda0ba56059a0179698"}, - {file = "pandas-2.2.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c124333816c3a9b03fbeef3a9f230ba9a737e9e5bb4060aa2107a86cc0a497fc"}, - {file = "pandas-2.2.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:63cc132e40a2e084cf01adf0775b15ac515ba905d7dcca47e9a251819c575ef3"}, - {file = "pandas-2.2.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:29401dbfa9ad77319367d36940cd8a0b3a11aba16063e39632d98b0e931ddf32"}, - {file = "pandas-2.2.3-cp311-cp311-win_amd64.whl", hash = "sha256:3fc6873a41186404dad67245896a6e440baacc92f5b716ccd1bc9ed2995ab2c5"}, - {file = "pandas-2.2.3-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:b1d432e8d08679a40e2a6d8b2f9770a5c21793a6f9f47fdd52c5ce1948a5a8a9"}, - {file = "pandas-2.2.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a5a1595fe639f5988ba6a8e5bc9649af3baf26df3998a0abe56c02609392e0a4"}, - {file = "pandas-2.2.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5de54125a92bb4d1c051c0659e6fcb75256bf799a732a87184e5ea503965bce3"}, - {file = "pandas-2.2.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fffb8ae78d8af97f849404f21411c95062db1496aeb3e56f146f0355c9989319"}, - {file = "pandas-2.2.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6dfcb5ee8d4d50c06a51c2fffa6cff6272098ad6540aed1a76d15fb9318194d8"}, - {file = "pandas-2.2.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:062309c1b9ea12a50e8ce661145c6aab431b1e99530d3cd60640e255778bd43a"}, - {file = "pandas-2.2.3-cp312-cp312-win_amd64.whl", hash = "sha256:59ef3764d0fe818125a5097d2ae867ca3fa64df032331b7e0917cf5d7bf66b13"}, - {file = "pandas-2.2.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f00d1345d84d8c86a63e476bb4955e46458b304b9575dcf71102b5c705320015"}, - {file = "pandas-2.2.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3508d914817e153ad359d7e069d752cdd736a247c322d932eb89e6bc84217f28"}, - {file = "pandas-2.2.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22a9d949bfc9a502d320aa04e5d02feab689d61da4e7764b62c30b991c42c5f0"}, - {file = "pandas-2.2.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3a255b2c19987fbbe62a9dfd6cff7ff2aa9ccab3fc75218fd4b7530f01efa24"}, - {file = "pandas-2.2.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:800250ecdadb6d9c78eae4990da62743b857b470883fa27f652db8bdde7f6659"}, - {file = "pandas-2.2.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6374c452ff3ec675a8f46fd9ab25c4ad0ba590b71cf0656f8b6daa5202bca3fb"}, - {file = "pandas-2.2.3-cp313-cp313-win_amd64.whl", hash = "sha256:61c5ad4043f791b61dd4752191d9f07f0ae412515d59ba8f005832a532f8736d"}, - {file = "pandas-2.2.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:3b71f27954685ee685317063bf13c7709a7ba74fc996b84fc6821c59b0f06468"}, - {file = "pandas-2.2.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:38cf8125c40dae9d5acc10fa66af8ea6fdf760b2714ee482ca691fc66e6fcb18"}, - {file = "pandas-2.2.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ba96630bc17c875161df3818780af30e43be9b166ce51c9a18c1feae342906c2"}, - {file = "pandas-2.2.3-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1db71525a1538b30142094edb9adc10be3f3e176748cd7acc2240c2f2e5aa3a4"}, - {file = "pandas-2.2.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:15c0e1e02e93116177d29ff83e8b1619c93ddc9c49083f237d4312337a61165d"}, - {file = "pandas-2.2.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:ad5b65698ab28ed8d7f18790a0dc58005c7629f227be9ecc1072aa74c0c1d43a"}, - {file = "pandas-2.2.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:bc6b93f9b966093cb0fd62ff1a7e4c09e6d546ad7c1de191767baffc57628f39"}, - {file = "pandas-2.2.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:5dbca4c1acd72e8eeef4753eeca07de9b1db4f398669d5994086f788a5d7cc30"}, - {file = "pandas-2.2.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8cd6d7cc958a3910f934ea8dbdf17b2364827bb4dafc38ce6eef6bb3d65ff09c"}, - {file = "pandas-2.2.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:99df71520d25fade9db7c1076ac94eb994f4d2673ef2aa2e86ee039b6746d20c"}, - {file = "pandas-2.2.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:31d0ced62d4ea3e231a9f228366919a5ea0b07440d9d4dac345376fd8e1477ea"}, - {file = "pandas-2.2.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:7eee9e7cea6adf3e3d24e304ac6b8300646e2a5d1cd3a3c2abed9101b0846761"}, - {file = "pandas-2.2.3-cp39-cp39-win_amd64.whl", hash = "sha256:4850ba03528b6dd51d6c5d273c46f183f39a9baf3f0143e566b89450965b105e"}, - {file = "pandas-2.2.3.tar.gz", hash = "sha256:4f18ba62b61d7e192368b84517265a99b4d7ee8912f8708660fb4a366cc82667"}, -] - -[package.dependencies] -numpy = {version = ">=1.26.0", markers = "python_version >= \"3.12\""} -python-dateutil = ">=2.8.2" -pytz = ">=2020.1" -tzdata = ">=2022.7" - -[package.extras] -all = ["PyQt5 (>=5.15.9)", "SQLAlchemy (>=2.0.0)", "adbc-driver-postgresql (>=0.8.0)", "adbc-driver-sqlite (>=0.8.0)", "beautifulsoup4 (>=4.11.2)", "bottleneck (>=1.3.6)", "dataframe-api-compat (>=0.1.7)", "fastparquet (>=2022.12.0)", "fsspec (>=2022.11.0)", "gcsfs (>=2022.11.0)", "html5lib (>=1.1)", "hypothesis (>=6.46.1)", "jinja2 (>=3.1.2)", "lxml (>=4.9.2)", "matplotlib (>=3.6.3)", "numba (>=0.56.4)", "numexpr (>=2.8.4)", "odfpy (>=1.4.1)", "openpyxl (>=3.1.0)", "pandas-gbq (>=0.19.0)", "psycopg2 (>=2.9.6)", "pyarrow (>=10.0.1)", "pymysql (>=1.0.2)", "pyreadstat (>=1.2.0)", "pytest (>=7.3.2)", "pytest-xdist (>=2.2.0)", "python-calamine (>=0.1.7)", "pyxlsb (>=1.0.10)", "qtpy (>=2.3.0)", "s3fs (>=2022.11.0)", "scipy (>=1.10.0)", "tables (>=3.8.0)", "tabulate (>=0.9.0)", "xarray (>=2022.12.0)", "xlrd (>=2.0.1)", "xlsxwriter (>=3.0.5)", "zstandard (>=0.19.0)"] -aws = ["s3fs (>=2022.11.0)"] -clipboard = ["PyQt5 (>=5.15.9)", "qtpy (>=2.3.0)"] -compression = ["zstandard (>=0.19.0)"] -computation = ["scipy (>=1.10.0)", "xarray (>=2022.12.0)"] -consortium-standard = ["dataframe-api-compat (>=0.1.7)"] -excel = ["odfpy (>=1.4.1)", "openpyxl (>=3.1.0)", "python-calamine (>=0.1.7)", "pyxlsb (>=1.0.10)", "xlrd (>=2.0.1)", "xlsxwriter (>=3.0.5)"] -feather = ["pyarrow (>=10.0.1)"] -fss = ["fsspec (>=2022.11.0)"] -gcp = ["gcsfs (>=2022.11.0)", "pandas-gbq (>=0.19.0)"] -hdf5 = ["tables (>=3.8.0)"] -html = ["beautifulsoup4 (>=4.11.2)", "html5lib (>=1.1)", "lxml (>=4.9.2)"] -mysql = ["SQLAlchemy (>=2.0.0)", "pymysql (>=1.0.2)"] -output-formatting = ["jinja2 (>=3.1.2)", "tabulate (>=0.9.0)"] -parquet = ["pyarrow (>=10.0.1)"] -performance = ["bottleneck (>=1.3.6)", "numba (>=0.56.4)", "numexpr (>=2.8.4)"] -plot = ["matplotlib (>=3.6.3)"] -postgresql = ["SQLAlchemy (>=2.0.0)", "adbc-driver-postgresql (>=0.8.0)", "psycopg2 (>=2.9.6)"] -pyarrow = ["pyarrow (>=10.0.1)"] -spss = ["pyreadstat (>=1.2.0)"] -sql-other = ["SQLAlchemy (>=2.0.0)", "adbc-driver-postgresql (>=0.8.0)", "adbc-driver-sqlite (>=0.8.0)"] -test = ["hypothesis (>=6.46.1)", "pytest (>=7.3.2)", "pytest-xdist (>=2.2.0)"] -xml = ["lxml (>=4.9.2)"] - -[[package]] -name = "pillow" -version = "11.0.0" -description = "Python Imaging Library (Fork)" -optional = false -python-versions = ">=3.9" -files = [ - {file = "pillow-11.0.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:6619654954dc4936fcff82db8eb6401d3159ec6be81e33c6000dfd76ae189947"}, - {file = "pillow-11.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:b3c5ac4bed7519088103d9450a1107f76308ecf91d6dabc8a33a2fcfb18d0fba"}, - {file = "pillow-11.0.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a65149d8ada1055029fcb665452b2814fe7d7082fcb0c5bed6db851cb69b2086"}, - {file = "pillow-11.0.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88a58d8ac0cc0e7f3a014509f0455248a76629ca9b604eca7dc5927cc593c5e9"}, - {file = "pillow-11.0.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:c26845094b1af3c91852745ae78e3ea47abf3dbcd1cf962f16b9a5fbe3ee8488"}, - {file = "pillow-11.0.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:1a61b54f87ab5786b8479f81c4b11f4d61702830354520837f8cc791ebba0f5f"}, - {file = "pillow-11.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:674629ff60030d144b7bca2b8330225a9b11c482ed408813924619c6f302fdbb"}, - {file = "pillow-11.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:598b4e238f13276e0008299bd2482003f48158e2b11826862b1eb2ad7c768b97"}, - {file = "pillow-11.0.0-cp310-cp310-win32.whl", hash = "sha256:9a0f748eaa434a41fccf8e1ee7a3eed68af1b690e75328fd7a60af123c193b50"}, - {file = "pillow-11.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:a5629742881bcbc1f42e840af185fd4d83a5edeb96475a575f4da50d6ede337c"}, - {file = "pillow-11.0.0-cp310-cp310-win_arm64.whl", hash = "sha256:ee217c198f2e41f184f3869f3e485557296d505b5195c513b2bfe0062dc537f1"}, - {file = "pillow-11.0.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:1c1d72714f429a521d8d2d018badc42414c3077eb187a59579f28e4270b4b0fc"}, - {file = "pillow-11.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:499c3a1b0d6fc8213519e193796eb1a86a1be4b1877d678b30f83fd979811d1a"}, - {file = "pillow-11.0.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c8b2351c85d855293a299038e1f89db92a2f35e8d2f783489c6f0b2b5f3fe8a3"}, - {file = "pillow-11.0.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f4dba50cfa56f910241eb7f883c20f1e7b1d8f7d91c750cd0b318bad443f4d5"}, - {file = "pillow-11.0.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:5ddbfd761ee00c12ee1be86c9c0683ecf5bb14c9772ddbd782085779a63dd55b"}, - {file = "pillow-11.0.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:45c566eb10b8967d71bf1ab8e4a525e5a93519e29ea071459ce517f6b903d7fa"}, - {file = "pillow-11.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b4fd7bd29610a83a8c9b564d457cf5bd92b4e11e79a4ee4716a63c959699b306"}, - {file = "pillow-11.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:cb929ca942d0ec4fac404cbf520ee6cac37bf35be479b970c4ffadf2b6a1cad9"}, - {file = "pillow-11.0.0-cp311-cp311-win32.whl", hash = "sha256:006bcdd307cc47ba43e924099a038cbf9591062e6c50e570819743f5607404f5"}, - {file = "pillow-11.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:52a2d8323a465f84faaba5236567d212c3668f2ab53e1c74c15583cf507a0291"}, - {file = "pillow-11.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:16095692a253047fe3ec028e951fa4221a1f3ed3d80c397e83541a3037ff67c9"}, - {file = "pillow-11.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d2c0a187a92a1cb5ef2c8ed5412dd8d4334272617f532d4ad4de31e0495bd923"}, - {file = "pillow-11.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:084a07ef0821cfe4858fe86652fffac8e187b6ae677e9906e192aafcc1b69903"}, - {file = "pillow-11.0.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8069c5179902dcdce0be9bfc8235347fdbac249d23bd90514b7a47a72d9fecf4"}, - {file = "pillow-11.0.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f02541ef64077f22bf4924f225c0fd1248c168f86e4b7abdedd87d6ebaceab0f"}, - {file = "pillow-11.0.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:fcb4621042ac4b7865c179bb972ed0da0218a076dc1820ffc48b1d74c1e37fe9"}, - {file = "pillow-11.0.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:00177a63030d612148e659b55ba99527803288cea7c75fb05766ab7981a8c1b7"}, - {file = "pillow-11.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8853a3bf12afddfdf15f57c4b02d7ded92c7a75a5d7331d19f4f9572a89c17e6"}, - {file = "pillow-11.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3107c66e43bda25359d5ef446f59c497de2b5ed4c7fdba0894f8d6cf3822dafc"}, - {file = "pillow-11.0.0-cp312-cp312-win32.whl", hash = "sha256:86510e3f5eca0ab87429dd77fafc04693195eec7fd6a137c389c3eeb4cfb77c6"}, - {file = "pillow-11.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:8ec4a89295cd6cd4d1058a5e6aec6bf51e0eaaf9714774e1bfac7cfc9051db47"}, - {file = "pillow-11.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:27a7860107500d813fcd203b4ea19b04babe79448268403172782754870dac25"}, - {file = "pillow-11.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:bcd1fb5bb7b07f64c15618c89efcc2cfa3e95f0e3bcdbaf4642509de1942a699"}, - {file = "pillow-11.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0e038b0745997c7dcaae350d35859c9715c71e92ffb7e0f4a8e8a16732150f38"}, - {file = "pillow-11.0.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ae08bd8ffc41aebf578c2af2f9d8749d91f448b3bfd41d7d9ff573d74f2a6b2"}, - {file = "pillow-11.0.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d69bfd8ec3219ae71bcde1f942b728903cad25fafe3100ba2258b973bd2bc1b2"}, - {file = "pillow-11.0.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:61b887f9ddba63ddf62fd02a3ba7add935d053b6dd7d58998c630e6dbade8527"}, - {file = "pillow-11.0.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:c6a660307ca9d4867caa8d9ca2c2658ab685de83792d1876274991adec7b93fa"}, - {file = "pillow-11.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:73e3a0200cdda995c7e43dd47436c1548f87a30bb27fb871f352a22ab8dcf45f"}, - {file = "pillow-11.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fba162b8872d30fea8c52b258a542c5dfd7b235fb5cb352240c8d63b414013eb"}, - {file = "pillow-11.0.0-cp313-cp313-win32.whl", hash = "sha256:f1b82c27e89fffc6da125d5eb0ca6e68017faf5efc078128cfaa42cf5cb38798"}, - {file = "pillow-11.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:8ba470552b48e5835f1d23ecb936bb7f71d206f9dfeee64245f30c3270b994de"}, - {file = "pillow-11.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:846e193e103b41e984ac921b335df59195356ce3f71dcfd155aa79c603873b84"}, - {file = "pillow-11.0.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4ad70c4214f67d7466bea6a08061eba35c01b1b89eaa098040a35272a8efb22b"}, - {file = "pillow-11.0.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:6ec0d5af64f2e3d64a165f490d96368bb5dea8b8f9ad04487f9ab60dc4bb6003"}, - {file = "pillow-11.0.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c809a70e43c7977c4a42aefd62f0131823ebf7dd73556fa5d5950f5b354087e2"}, - {file = "pillow-11.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:4b60c9520f7207aaf2e1d94de026682fc227806c6e1f55bba7606d1c94dd623a"}, - {file = "pillow-11.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:1e2688958a840c822279fda0086fec1fdab2f95bf2b717b66871c4ad9859d7e8"}, - {file = "pillow-11.0.0-cp313-cp313t-win32.whl", hash = "sha256:607bbe123c74e272e381a8d1957083a9463401f7bd01287f50521ecb05a313f8"}, - {file = "pillow-11.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5c39ed17edea3bc69c743a8dd3e9853b7509625c2462532e62baa0732163a904"}, - {file = "pillow-11.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:75acbbeb05b86bc53cbe7b7e6fe00fbcf82ad7c684b3ad82e3d711da9ba287d3"}, - {file = "pillow-11.0.0-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:2e46773dc9f35a1dd28bd6981332fd7f27bec001a918a72a79b4133cf5291dba"}, - {file = "pillow-11.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:2679d2258b7f1192b378e2893a8a0a0ca472234d4c2c0e6bdd3380e8dfa21b6a"}, - {file = "pillow-11.0.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eda2616eb2313cbb3eebbe51f19362eb434b18e3bb599466a1ffa76a033fb916"}, - {file = "pillow-11.0.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:20ec184af98a121fb2da42642dea8a29ec80fc3efbaefb86d8fdd2606619045d"}, - {file = "pillow-11.0.0-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:8594f42df584e5b4bb9281799698403f7af489fba84c34d53d1c4bfb71b7c4e7"}, - {file = "pillow-11.0.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:c12b5ae868897c7338519c03049a806af85b9b8c237b7d675b8c5e089e4a618e"}, - {file = "pillow-11.0.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:70fbbdacd1d271b77b7721fe3cdd2d537bbbd75d29e6300c672ec6bb38d9672f"}, - {file = "pillow-11.0.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:5178952973e588b3f1360868847334e9e3bf49d19e169bbbdfaf8398002419ae"}, - {file = "pillow-11.0.0-cp39-cp39-win32.whl", hash = "sha256:8c676b587da5673d3c75bd67dd2a8cdfeb282ca38a30f37950511766b26858c4"}, - {file = "pillow-11.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:94f3e1780abb45062287b4614a5bc0874519c86a777d4a7ad34978e86428b8dd"}, - {file = "pillow-11.0.0-cp39-cp39-win_arm64.whl", hash = "sha256:290f2cc809f9da7d6d622550bbf4c1e57518212da51b6a30fe8e0a270a5b78bd"}, - {file = "pillow-11.0.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:1187739620f2b365de756ce086fdb3604573337cc28a0d3ac4a01ab6b2d2a6d2"}, - {file = "pillow-11.0.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:fbbcb7b57dc9c794843e3d1258c0fbf0f48656d46ffe9e09b63bbd6e8cd5d0a2"}, - {file = "pillow-11.0.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d203af30149ae339ad1b4f710d9844ed8796e97fda23ffbc4cc472968a47d0b"}, - {file = "pillow-11.0.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21a0d3b115009ebb8ac3d2ebec5c2982cc693da935f4ab7bb5c8ebe2f47d36f2"}, - {file = "pillow-11.0.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:73853108f56df97baf2bb8b522f3578221e56f646ba345a372c78326710d3830"}, - {file = "pillow-11.0.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:e58876c91f97b0952eb766123bfef372792ab3f4e3e1f1a2267834c2ab131734"}, - {file = "pillow-11.0.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:224aaa38177597bb179f3ec87eeefcce8e4f85e608025e9cfac60de237ba6316"}, - {file = "pillow-11.0.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:5bd2d3bdb846d757055910f0a59792d33b555800813c3b39ada1829c372ccb06"}, - {file = "pillow-11.0.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:375b8dd15a1f5d2feafff536d47e22f69625c1aa92f12b339ec0b2ca40263273"}, - {file = "pillow-11.0.0-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:daffdf51ee5db69a82dd127eabecce20729e21f7a3680cf7cbb23f0829189790"}, - {file = "pillow-11.0.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:7326a1787e3c7b0429659e0a944725e1b03eeaa10edd945a86dead1913383944"}, - {file = "pillow-11.0.0.tar.gz", hash = "sha256:72bacbaf24ac003fea9bff9837d1eedb6088758d41e100c1552930151f677739"}, -] - -[package.extras] -docs = ["furo", "olefile", "sphinx (>=8.1)", "sphinx-copybutton", "sphinx-inline-tabs", "sphinxext-opengraph"] -fpx = ["olefile"] -mic = ["olefile"] -tests = ["check-manifest", "coverage", "defusedxml", "markdown2", "olefile", "packaging", "pyroma", "pytest", "pytest-cov", "pytest-timeout"] -typing = ["typing-extensions"] -xmp = ["defusedxml"] - -[[package]] -name = "pyparsing" -version = "3.2.0" -description = "pyparsing module - Classes and methods to define and execute parsing grammars" -optional = false -python-versions = ">=3.9" -files = [ - {file = "pyparsing-3.2.0-py3-none-any.whl", hash = "sha256:93d9577b88da0bbea8cc8334ee8b918ed014968fd2ec383e868fb8afb1ccef84"}, - {file = "pyparsing-3.2.0.tar.gz", hash = "sha256:cbf74e27246d595d9a74b186b810f6fbb86726dbf3b9532efb343f6d7294fe9c"}, -] - -[package.extras] -diagrams = ["jinja2", "railroad-diagrams"] - -[[package]] -name = "python-dateutil" -version = "2.9.0.post0" -description = "Extensions to the standard Python datetime module" -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" -files = [ - {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, - {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, -] - -[package.dependencies] -six = ">=1.5" - -[[package]] -name = "pytz" -version = "2024.2" -description = "World timezone definitions, modern and historical" -optional = false -python-versions = "*" -files = [ - {file = "pytz-2024.2-py2.py3-none-any.whl", hash = "sha256:31c7c1817eb7fae7ca4b8c7ee50c72f93aa2dd863de768e1ef4245d426aa0725"}, - {file = "pytz-2024.2.tar.gz", hash = "sha256:2aa355083c50a0f93fa581709deac0c9ad65cca8a9e9beac660adcbd493c798a"}, -] - -[[package]] -name = "scipy" -version = "1.14.1" -description = "Fundamental algorithms for scientific computing in Python" -optional = false -python-versions = ">=3.10" -files = [ - {file = "scipy-1.14.1-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:b28d2ca4add7ac16ae8bb6632a3c86e4b9e4d52d3e34267f6e1b0c1f8d87e389"}, - {file = "scipy-1.14.1-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:d0d2821003174de06b69e58cef2316a6622b60ee613121199cb2852a873f8cf3"}, - {file = "scipy-1.14.1-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:8bddf15838ba768bb5f5083c1ea012d64c9a444e16192762bd858f1e126196d0"}, - {file = "scipy-1.14.1-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:97c5dddd5932bd2a1a31c927ba5e1463a53b87ca96b5c9bdf5dfd6096e27efc3"}, - {file = "scipy-1.14.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2ff0a7e01e422c15739ecd64432743cf7aae2b03f3084288f399affcefe5222d"}, - {file = "scipy-1.14.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e32dced201274bf96899e6491d9ba3e9a5f6b336708656466ad0522d8528f69"}, - {file = "scipy-1.14.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8426251ad1e4ad903a4514712d2fa8fdd5382c978010d1c6f5f37ef286a713ad"}, - {file = "scipy-1.14.1-cp310-cp310-win_amd64.whl", hash = "sha256:a49f6ed96f83966f576b33a44257d869756df6cf1ef4934f59dd58b25e0327e5"}, - {file = "scipy-1.14.1-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:2da0469a4ef0ecd3693761acbdc20f2fdeafb69e6819cc081308cc978153c675"}, - {file = "scipy-1.14.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:c0ee987efa6737242745f347835da2cc5bb9f1b42996a4d97d5c7ff7928cb6f2"}, - {file = "scipy-1.14.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3a1b111fac6baec1c1d92f27e76511c9e7218f1695d61b59e05e0fe04dc59617"}, - {file = "scipy-1.14.1-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8475230e55549ab3f207bff11ebfc91c805dc3463ef62eda3ccf593254524ce8"}, - {file = "scipy-1.14.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:278266012eb69f4a720827bdd2dc54b2271c97d84255b2faaa8f161a158c3b37"}, - {file = "scipy-1.14.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fef8c87f8abfb884dac04e97824b61299880c43f4ce675dd2cbeadd3c9b466d2"}, - {file = "scipy-1.14.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b05d43735bb2f07d689f56f7b474788a13ed8adc484a85aa65c0fd931cf9ccd2"}, - {file = "scipy-1.14.1-cp311-cp311-win_amd64.whl", hash = "sha256:716e389b694c4bb564b4fc0c51bc84d381735e0d39d3f26ec1af2556ec6aad94"}, - {file = "scipy-1.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:631f07b3734d34aced009aaf6fedfd0eb3498a97e581c3b1e5f14a04164a456d"}, - {file = "scipy-1.14.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:af29a935803cc707ab2ed7791c44288a682f9c8107bc00f0eccc4f92c08d6e07"}, - {file = "scipy-1.14.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:2843f2d527d9eebec9a43e6b406fb7266f3af25a751aa91d62ff416f54170bc5"}, - {file = "scipy-1.14.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:eb58ca0abd96911932f688528977858681a59d61a7ce908ffd355957f7025cfc"}, - {file = "scipy-1.14.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:30ac8812c1d2aab7131a79ba62933a2a76f582d5dbbc695192453dae67ad6310"}, - {file = "scipy-1.14.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f9ea80f2e65bdaa0b7627fb00cbeb2daf163caa015e59b7516395fe3bd1e066"}, - {file = "scipy-1.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:edaf02b82cd7639db00dbff629995ef185c8df4c3ffa71a5562a595765a06ce1"}, - {file = "scipy-1.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:2ff38e22128e6c03ff73b6bb0f85f897d2362f8c052e3b8ad00532198fbdae3f"}, - {file = "scipy-1.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1729560c906963fc8389f6aac023739ff3983e727b1a4d87696b7bf108316a79"}, - {file = "scipy-1.14.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:4079b90df244709e675cdc8b93bfd8a395d59af40b72e339c2287c91860deb8e"}, - {file = "scipy-1.14.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:e0cf28db0f24a38b2a0ca33a85a54852586e43cf6fd876365c86e0657cfe7d73"}, - {file = "scipy-1.14.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:0c2f95de3b04e26f5f3ad5bb05e74ba7f68b837133a4492414b3afd79dfe540e"}, - {file = "scipy-1.14.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b99722ea48b7ea25e8e015e8341ae74624f72e5f21fc2abd45f3a93266de4c5d"}, - {file = "scipy-1.14.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5149e3fd2d686e42144a093b206aef01932a0059c2a33ddfa67f5f035bdfe13e"}, - {file = "scipy-1.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e4f5a7c49323533f9103d4dacf4e4f07078f360743dec7f7596949149efeec06"}, - {file = "scipy-1.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:baff393942b550823bfce952bb62270ee17504d02a1801d7fd0719534dfb9c84"}, - {file = "scipy-1.14.1.tar.gz", hash = "sha256:5a275584e726026a5699459aa72f828a610821006228e841b94275c4a7c08417"}, -] - -[package.dependencies] -numpy = ">=1.23.5,<2.3" - -[package.extras] -dev = ["cython-lint (>=0.12.2)", "doit (>=0.36.0)", "mypy (==1.10.0)", "pycodestyle", "pydevtool", "rich-click", "ruff (>=0.0.292)", "types-psutil", "typing_extensions"] -doc = ["jupyterlite-pyodide-kernel", "jupyterlite-sphinx (>=0.13.1)", "jupytext", "matplotlib (>=3.5)", "myst-nb", "numpydoc", "pooch", "pydata-sphinx-theme (>=0.15.2)", "sphinx (>=5.0.0,<=7.3.7)", "sphinx-design (>=0.4.0)"] -test = ["Cython", "array-api-strict (>=2.0)", "asv", "gmpy2", "hypothesis (>=6.30)", "meson", "mpmath", "ninja", "pooch", "pytest", "pytest-cov", "pytest-timeout", "pytest-xdist", "scikit-umfpack", "threadpoolctl"] - -[[package]] -name = "seaborn" -version = "0.13.2" -description = "Statistical data visualization" -optional = false -python-versions = ">=3.8" -files = [ - {file = "seaborn-0.13.2-py3-none-any.whl", hash = "sha256:636f8336facf092165e27924f223d3c62ca560b1f2bb5dff7ab7fad265361987"}, - {file = "seaborn-0.13.2.tar.gz", hash = "sha256:93e60a40988f4d65e9f4885df477e2fdaff6b73a9ded434c1ab356dd57eefff7"}, -] - -[package.dependencies] -matplotlib = ">=3.4,<3.6.1 || >3.6.1" -numpy = ">=1.20,<1.24.0 || >1.24.0" -pandas = ">=1.2" - -[package.extras] -dev = ["flake8", "flit", "mypy", "pandas-stubs", "pre-commit", "pytest", "pytest-cov", "pytest-xdist"] -docs = ["ipykernel", "nbconvert", "numpydoc", "pydata_sphinx_theme (==0.10.0rc2)", "pyyaml", "sphinx (<6.0.0)", "sphinx-copybutton", "sphinx-design", "sphinx-issues"] -stats = ["scipy (>=1.7)", "statsmodels (>=0.12)"] - -[[package]] -name = "six" -version = "1.16.0" -description = "Python 2 and 3 compatibility utilities" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" -files = [ - {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, - {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, -] - -[[package]] -name = "tzdata" -version = "2024.2" -description = "Provider of IANA time zone data" -optional = false -python-versions = ">=2" -files = [ - {file = "tzdata-2024.2-py2.py3-none-any.whl", hash = "sha256:a48093786cdcde33cad18c2555e8532f34422074448fbc874186f0abd79565cd"}, - {file = "tzdata-2024.2.tar.gz", hash = "sha256:7d85cc416e9382e69095b7bdf4afd9e3880418a2413feec7069d533d6b4e31cc"}, -] - -[metadata] -lock-version = "2.0" -python-versions = "^3.13" -content-hash = "376b98659bcfe622090d838b23f142eaa76c6441ace6ca751db716db53d9af63" diff --git a/pyproject.toml b/pyproject.toml index d6af8cf..92616d9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,18 +1,24 @@ -[tool.poetry] -name = "bitcoin-model" -version = "0.1.0" -description = "" -authors = ["Anthropic Claude ", "Sam Fredrickson "] +[project] +name = "btcmodel" +version = "0.2.0" +description = "Probabilistic Bitcoin price model" +authors = [ + { name = "Anthropic Claude", email = "sales@anthropic.com" }, + { name = "Sam Fredrickson", email = "samfredrickson@gmail.com" }, +] readme = "README.md" +requires-python = ">=3.13" +dependencies = ["numpy>=2", "pandas>=3", "scipy>=1.14", "matplotlib>=3.9"] -[tool.poetry.dependencies] -python = "^3.13" -pandas = "^2.2.3" -matplotlib = "^3.9.2" -seaborn = "^0.13.2" -scipy = "^1.14.1" +[dependency-groups] +dev = ["pytest", "ruff"] +[tool.ruff] +line-length = 100 -[build-system] -requires = ["poetry-core"] -build-backend = "poetry.core.masonry.api" +[tool.ruff.lint] +select = ["E", "F", "I", "B", "UP"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["."] diff --git a/tests/test_forecast.py b/tests/test_forecast.py new file mode 100644 index 0000000..a452733 --- /dev/null +++ b/tests/test_forecast.py @@ -0,0 +1,44 @@ +import numpy as np +import pandas as pd +from scipy.stats import norm + +from btcmodel.forecast import Forecast, crps, pit + + +def normal_crps(mu, sigma, y): + """Closed-form CRPS of N(mu, sigma^2) at y.""" + z = (y - mu) / sigma + return sigma * (z * (2 * norm.cdf(z) - 1) + 2 * norm.pdf(z) - 1 / np.sqrt(np.pi)) + + +def test_crps_matches_closed_form_for_normal(): + f = Forecast.normal("2020-01-01", np.array([1, 2, 3]), mean=[0.0, 1.0, 2.0], sd=[1.0, 0.5, 2.0]) + y = np.array([0.3, -0.2, 5.0]) + expected = normal_crps(np.array([0.0, 1.0, 2.0]), np.array([1.0, 0.5, 2.0]), y) + np.testing.assert_allclose(crps(f.log_quantiles, y), expected, rtol=0.02) + + +def test_crps_prefers_the_right_forecast(): + y = np.array([0.0]) + good = Forecast.normal("2020-01-01", np.array([1]), 0.0, 0.1) + biased = Forecast.normal("2020-01-01", np.array([1]), 0.5, 0.1) + vague = Forecast.normal("2020-01-01", np.array([1]), 0.0, 2.0) + assert crps(good.log_quantiles, y) < crps(biased.log_quantiles, y) + assert crps(good.log_quantiles, y) < crps(vague.log_quantiles, y) + + +def test_pit_and_intervals(): + f = Forecast.normal("2020-01-01", np.array([1, 1, 1]), 0.0, 1.0) + np.testing.assert_allclose( + pit(f.log_quantiles, [0.0, 1.0, -10.0]), [0.5, 0.841, 0.0], atol=0.01 + ) + lo, hi = f.interval(0.95) + np.testing.assert_allclose(hi, 1.96, atol=0.01) + np.testing.assert_allclose(lo, -1.96, atol=0.01) + + +def test_from_samples_recovers_quantiles(): + rng = np.random.default_rng(0) + f = Forecast.from_samples("2020-01-01", np.array([10]), rng.normal(0, 1, size=(200_000, 1))) + np.testing.assert_allclose(f.quantile(0.5), 0.0, atol=0.02) + assert f.dates[0] == pd.Timestamp("2020-01-11") diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..5f8b378 --- /dev/null +++ b/tests/test_models.py @@ -0,0 +1,83 @@ +import numpy as np +import pandas as pd +import pytest + +from btcmodel import data +from btcmodel.evaluate import backtest +from btcmodel.halving import HALVINGS, cycle_position +from btcmodel.models import MODELS, CycleModel + + +def synthetic_prices(daily_return, start="2011-01-01", end="2024-11-26", noise=0.0, seed=0): + """Prices whose daily log return is `daily_return(cycle_day)` plus optional noise.""" + dates = pd.date_range(start, end, freq="D", name="date") + _, day = cycle_position(dates) + r = daily_return(day) + noise * np.random.default_rng(seed).standard_normal(len(dates)) + return pd.DataFrame({"close": 100 * np.exp(np.cumsum(r))}, index=dates) + + +def test_cycle_position(): + index, day = cycle_position(pd.DatetimeIndex(["2009-01-03", "2012-11-27", *HALVINGS])) + assert list(index) == [0, 0, 1, 2, 3, 4] + assert list(day) == [0, 1424, 0, 0, 0, 0] + # A projected halving about four years after the last one starts cycle 5. + index, _ = cycle_position(pd.DatetimeIndex(["2028-06-01"])) + assert index[0] == 5 + + +def test_cycle_model_recovers_a_cycle_shaped_drift(): + # Up for the first half of each cycle, down in the second half. + def shape(day): + return np.where(day < 700, 0.002, -0.001) + + prices = synthetic_prices(shape) + drift = CycleModel(prior_days=0).drift_by_cycle_day(prices) + assert drift[300] == pytest.approx(0.002, abs=2e-4) + assert drift[1100] == pytest.approx(-0.001, abs=2e-4) + + +def test_cycle_model_weights_recent_cycles_more(): + # The same day of the cycle returns less in each later cycle. + prices = synthetic_prices(lambda day: np.zeros_like(day, dtype=float)) + cycle, _ = cycle_position(prices.index) + r = 0.004 / 2.0**cycle + prices["close"] = 100 * np.exp(np.cumsum(r)) + drift = CycleModel(recency_half_life=0.25).drift_by_cycle_day(prices) + equal = CycleModel(recency_half_life=1e9).drift_by_cycle_day(prices) + latest_complete = 0.004 / 2.0**3 + assert abs(drift[900] - latest_complete) < abs(equal[900] - latest_complete) + + +@pytest.mark.parametrize("name", list(MODELS)) +def test_models_produce_valid_forecasts(name): + prices = synthetic_prices(lambda day: 0.001 + 0 * day, noise=0.03) + horizons = np.array([1, 30, 365, 1460]) + f = MODELS[name].forecast(prices, horizons) + assert f.log_quantiles.shape == (4, 100) + assert np.all(np.diff(f.log_quantiles, axis=1) >= 0) + lo, hi = f.interval(0.8) + assert np.all(np.diff(hi - lo) > 0), "uncertainty should grow with horizon" + + +def test_backtest_never_shows_models_the_future(): + prices = synthetic_prices(lambda day: 0.001 + 0 * day, noise=0.03) + + class Spy: + name = "random_walk" + + def forecast(self, history, horizons): + origin = history.index[-1] + assert history.index.max() == origin + assert (origin + pd.Timedelta(days=int(horizons.min()))) > history.index.max() + return MODELS["random_walk"].forecast(history, horizons) + + scores = backtest([Spy()], prices) + assert len(scores) > 0 + targets = scores.origin + pd.to_timedelta(scores.horizon, unit="D") + assert targets.max() <= prices.index[-1] + + +def test_development_data_stops_at_cutoff(): + prices = data.load_prices(until=data.DEV_CUTOFF) + assert prices.index[0] == data.DATA_START + assert prices.index[-1] == data.DEV_CUTOFF