commit 73a0210002cb6f9dcb870304d3b3c3f53fb7cd21 Author: Claudiu Farcas Date: Sun Jun 21 13:00:30 2026 +0300 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..369f4e5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,23 @@ +# Virtual environment +.venv/ +venv/ + +# Python caches +__pycache__/ +*.py[cod] +.pytest_cache/ + +# Generated outputs (regenerated by main.py / exporter.py) +results/ + +# Local planning notes +docs/superpowers/plans/ + +# Transient browser-verification artifacts (Playwright MCP) +.playwright-mcp/ + +# macOS +.DS_Store + +# Local report inputs (personal brokerage exports) +EUR_*.xlsx diff --git a/INSTALL_FOR_AGENTS.md b/INSTALL_FOR_AGENTS.md new file mode 100644 index 0000000..700a673 --- /dev/null +++ b/INSTALL_FOR_AGENTS.md @@ -0,0 +1,155 @@ +# Install Skills For Agents + +This file is written for LLM agents and coding assistants. Follow it when a user asks you to install, use, or copy the XTB portfolio skills from this repository. + +The repository ships two standalone, harness-neutral skill folders: + +- `skills/xtb-portfolio-review` +- `skills/xtb-wealthfolio-export` + +Each skill folder is self-contained: it includes `SKILL.md`, references, runnable scripts, Python source files, requirements, and offline Chart.js assets where needed. Users may copy a single skill folder without cloning the full repository. + +## Choose The Installation Path + +Use one of these paths based on the user's agent harness. + +| Harness | Recommended action | +| ------- | ------------------ | +| Codex | Copy the desired skill folder into `~/.codex/skills/`. | +| Claude or Claude Code | Copy the desired skill folder into the user's configured skills/instructions directory, or keep it in the project and read `SKILL.md` before use. | +| Cursor, Aider, OpenHands, generic LLM | Keep/copy the skill folder anywhere accessible and explicitly read `SKILL.md` before running scripts. | +| Unknown harness | Do not assume a special install location. Use the skill folder directly. | + +## Install From This Repository + +Run these commands from the repository root. + +For Codex: + +```bash +mkdir -p "$HOME/.codex/skills" +cp -R skills/xtb-portfolio-review "$HOME/.codex/skills/" +cp -R skills/xtb-wealthfolio-export "$HOME/.codex/skills/" +``` + +For a generic agent workspace, copy the skill folders to a user-chosen directory: + +```bash +mkdir -p ./agent-skills +cp -R skills/xtb-portfolio-review ./agent-skills/ +cp -R skills/xtb-wealthfolio-export ./agent-skills/ +``` + +If only one workflow is needed, copy only that folder. + +## Install From A Copied Skill Folder + +If the user already has only one copied skill folder, no repository files are required. Work from the directory where the user's XTB workbook and future `results/` folder should live. + +For portfolio review: + +```bash +/path/to/xtb-portfolio-review/scripts/setup-env.sh +/path/to/xtb-portfolio-review/scripts/validate-review.sh +/path/to/xtb-portfolio-review/scripts/run-review.sh /path/to/report.xlsx +``` + +For Wealthfolio export: + +```bash +/path/to/xtb-wealthfolio-export/scripts/setup-env.sh +/path/to/xtb-wealthfolio-export/scripts/validate-export.sh +/path/to/xtb-wealthfolio-export/scripts/export-wealthfolio.sh /path/to/report.xlsx +``` + +The setup scripts create or reuse `.venv` in the current working directory. If network access or package installation requires approval, ask before running `setup-env.sh`. + +## Use Without Installing + +If you cannot copy files, use the skill in place: + +1. Read the relevant `SKILL.md` completely. +2. Read referenced files only when the skill tells you to. +3. Run the bundled validation script. +4. Run the bundled workflow script. +5. Report generated output paths and data-quality caveats to the user. + +Example prompts a user can give an agent: + +```text +Read skills/xtb-portfolio-review/SKILL.md and use that skill to generate a portfolio report for my XTB export. +``` + +```text +Read skills/xtb-wealthfolio-export/SKILL.md and use that skill to create a Wealthfolio CSV from my XTB export. +``` + +## Skill Contents + +Expected portable structure: + +```text +skills/ + xtb-portfolio-review/ + SKILL.md + references/ + scripts/ + setup-env.sh + validate-review.sh + run-review.sh + main.py + html_charts.py + requirements.txt + assets/ + + xtb-wealthfolio-export/ + SKILL.md + references/ + scripts/ + setup-env.sh + validate-export.sh + export-wealthfolio.sh + exporter.py + main.py + html_charts.py + requirements.txt + assets/ +``` + +Do not require the root-level `main.py`, `exporter.py`, or `html_charts.py` for copied skill usage. Those root files are repository compatibility shims only. + +## Verification Commands + +From the repository root: + +```bash +skills/xtb-portfolio-review/scripts/validate-review.sh +skills/xtb-wealthfolio-export/scripts/validate-export.sh +``` + +If the full repository test suite is available: + +```bash +.venv/bin/python -m pytest -q +``` + +Successful validation means the Python dependencies are importable and the bundled skill tools can be loaded. A successful portfolio or export run is still the final check for a specific workbook. + +## Operational Rules For Agents + +- Prefer the bundled scripts inside the skill folder over re-implementing behavior. +- Keep generated files in the user's current working directory, usually under `results/`. +- Do not upload or expose XTB workbooks; they can contain personal financial data. +- Do not present portfolio output as investment advice. Report computed values, assumptions, and caveats. +- If dependencies are missing, propose running `scripts/setup-env.sh`. +- If package installation needs network access or elevated permissions, ask the user first. +- If a workbook path is ambiguous, ask the user which `.xlsx` file to use. +- If validation fails, report the failing command and the actionable error. + +## Copy-Paste Installation Request + +A user can paste this to another agent: + +```text +Install the XTB agent skills from this repository. Read INSTALL_FOR_AGENTS.md, copy the needed folder from skills/ into your skill or instruction directory if your harness supports that, run the skill's setup and validation scripts, then use the relevant SKILL.md workflow for my XTB workbook. +``` diff --git a/README.md b/README.md new file mode 100644 index 0000000..6ce80e9 --- /dev/null +++ b/README.md @@ -0,0 +1,258 @@ +# XTB Portfolio Review & Wealthfolio Exporter + +A set of Python tools that turn an **XTB brokerage report** (`.xlsx` export) into: + +1. A complete, human-readable **portfolio review** (console and a self-contained HTML report with interactive, offline charts and analysis tables). +2. A **Wealthfolio-compatible CSV** so the same XTB history can be imported into the [Wealthfolio](https://wealthfolio.app/) portfolio tracker. + +The parser is generic for XTB exports in this format. Tests generate a small +synthetic workbook at runtime, while personal brokerage exports should stay +local and untracked. + +--- + +## Background: the XTB export format + +An XTB report is an `.xlsx` file with a fixed layout: + +- **Rows 1–4**: metadata (account number, report period). +- **Row 5** (`header=4`): the actual column headers. +- **Sheets**: + - `Closed Positions` — realized trades, with a `Profit/Loss` column. May contain a + `Profit/loss` summary row and/or be empty (all positions still open). + - `Cash Operations` — every cash flow: stock purchases/sales, deposits, withdrawals, + dividends, dividend tax, free-funds interest, currency conversions. Each trade row + carries a comment like `OPEN BUY 6 @ 301.50` or `CLOSE SELL 2 @ 100.00`, and the + sheet ends with a `Total` row (the broker-reported ending cash balance). + +Two quirks the code handles explicitly: + +- **Header is on row 5**, not row 1. +- **Split-fill quantity notation**: `OPEN BUY 1/100 @ 14.3130` means *1 share out of a + 100-share parent order* — the numerator is the executed quantity. The tools use the + numerator (falling back to `cash / price`) rather than mis-reading `1/100` as `0.01`. +- **Stock-sale close notation**: some XTB stock-sale rows are written as + `CLOSE BUY ...` while the row type is `Stock sell` and the amount is positive + sale proceeds. The tools treat these as sales for holdings, cash flows, and + Wealthfolio export. + +--- + +## Files + +### Source code + +| File | Purpose | +| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `skills/xtb-portfolio-review/scripts/main.py` | **Portfolio review generator.** Parses the XTB report, reconstructs trades from Cash Operations comments, runs FIFO lot-matching for realized P/L, computes cash flows, holdings (cost basis), performance metrics, contribution/risk/income analysis, and reconciliation against the broker's `Total` row. Outputs a console report and a self-contained HTML report with interactive Chart.js charts and offline table tools (bundled inline, no internet required). | +| `skills/xtb-wealthfolio-export/scripts/exporter.py` | **XTB → Wealthfolio CSV exporter.** Maps each Cash Operation to a Wealthfolio row (`date,symbol,quantity,activityType,unitPrice,currency,fee`). | +| `main.py`, `exporter.py`, `html_charts.py` | Thin compatibility entry points that preserve the original repo commands/imports while delegating to the bundled skill implementations. | + +### Tests + +| File | Purpose | +| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `test_portfolio.py` | Unit + integration tests for `main.py` (parsing, FIFO realized P/L, cash-flow categorization, income, open positions, performance, analysis helpers, reconciliation against the generated synthetic workbook, HTML structure and interactions). | +| `test_exporter.py` | Tests for `exporter.py` (activity-type classification, the split-fill quantity parser, full row mapping, schema validation on the generated synthetic workbook, empty-input handling). | + +### Local inputs + +Personal `.xlsx` exports are not committed. Place your XTB report in the repo +folder when running the tools locally, or pass its path explicitly. + +### Generated outputs (regenerated by running the tools) + +All generated files are written to the **`results/`** folder (created +automatically) and **named after the input report**: for input +`EUR_demo_report.xlsx` every output uses that stem plus a descriptor, e.g. +`EUR_demo_report_review.html`. + +| File | Produced by | Content | +| ------------------------------------------------- | ------------- | --------------------------------------------------------------------------- | +| `results/_review.html` | `main.py` | Self-contained HTML report with interactive Chart.js charts, analysis sections, sortable/filterable tables, sticky navigation, and print/PDF styles; works offline. | +| `results/_holdings.csv` | `main.py` | Open holdings: ticker, shares, avg cost, cost basis, return %, allocation %.| +| `results/_cash_flows.csv` | `main.py` | Aggregated cash flows (deposits, interest, dividends, invested, …). | +| `results/_realized_pl.csv` | `main.py` | Realized P/L per ticker. | +| `results/_open_positions.csv` | `main.py` | Live market value / unrealized P/L (when an `Open Positions` sheet exists). | +| `results/_performance.csv` | `main.py` | Performance metrics (portfolio value, returns, yield). | +| `results/_income.csv` | `main.py` | Income (dividends + interest) by month. | +| `results/_evolution.csv` | `main.py` | Daily cost / market value / realized P/L series (drives the evolution chart).| +| `results/_wealthfolio.csv` | `exporter.py` | Wealthfolio-importable transaction history. | + +--- + +## Setup + +Requires Python 3.10+. + +```bash +python3 -m venv .venv +.venv/bin/python -m pip install --upgrade pip +.venv/bin/python -m pip install -r requirements.txt +``` + +> The HTML report bundles Chart.js v4.5.1 (vendored inside each relevant skill at `scripts/assets/chartjs.umd.min.js`, version pinned in `scripts/assets/chartjs.VERSION`) so its charts render interactively with no internet connection. + +## Agent skills + +This repository includes harness-neutral agent skills under `skills/` for users +who want an LLM or coding agent to operate the tools consistently. Each skill is +a self-contained folder with a `SKILL.md`, `references/`, and bundled +`scripts/`, so users can copy a skill folder to another machine and still run +the relevant XTB workflow without cloning the full repository. + +Agents should start with [`INSTALL_FOR_AGENTS.md`](INSTALL_FOR_AGENTS.md) for +copy/install/use instructions. + +| Skill | Purpose | +| ----- | ------- | +| `xtb-portfolio-review` | Generate and verify XTB portfolio review reports, including reconciliation, holdings, performance, income, risk, and data-quality caveats. | +| `xtb-wealthfolio-export` | Export and validate Wealthfolio-compatible CSV files from XTB reports, including activity mappings and import-readiness checks. | + +Use the skill folder directly, or copy it into the skill/instruction directory +for your harness. With a generic LLM, ask it to read the relevant `SKILL.md`. +For Codex, you can also copy either folder into `~/.codex/skills/`, then invoke +it in a new session: + +```text +Use $xtb-portfolio-review to generate and verify an XTB portfolio report. +Use $xtb-wealthfolio-export to create and validate a Wealthfolio CSV from an XTB report. +``` + +Each copied skill folder includes `scripts/requirements.txt` plus shell wrappers +for environment setup, validation, and execution. From the directory where you +want `.venv` and `results/` to live, install dependencies with: + +```bash +skills/xtb-portfolio-review/scripts/setup-env.sh +skills/xtb-wealthfolio-export/scripts/setup-env.sh +``` + +## Usage + +### Generate the portfolio review + +```bash +.venv/bin/python main.py # auto-detects the only .xlsx in the folder +.venv/bin/python main.py EUR_demo_report.xlsx # explicit report +.venv/bin/python main.py --csv # also write the CSV outputs +``` + +By default only the self-contained **HTML report** (with inline interactive +charts and table tools) is written to `results/`. Pass `--csv` to additionally +export the per-section CSVs (holdings, cash flows, performance, …). + +If no path is given and exactly one `.xlsx` is present in the current +directory, it is used automatically; if there are none or several, pass the +path explicitly. Any same-format XTB export works — the currency is +auto-detected from the filename prefix (e.g. `EUR_…`, `USD_…`). + +### HTML report features + +The generated review HTML is a single offline file. It includes: + +- **Executive Summary** — largest holding, top unrealized winner/loser, cash + allocation, pricing warnings, and reconciliation status. +- **Concentration & Risk** — top-1/top-3/top-5 position weights, cash weight, + positions above 20%, and cost-priced position count. +- **Income Quality** — gross income, dividend tax, net income, tax drag, net + income yield, and dividend/interest mix. +- **Methodology & Data Quality** — live-vs-cost pricing coverage, cost fallback + tickers, reconciliation status, and the main calculation assumptions. +- **Return Contribution** — per-ticker market value, unrealized P/L, realized + P/L, total contribution, and contribution % of total gain. +- **Interactive charts** — portfolio evolution, holdings allocation, cash flows, + and income over time when data exists. +- **Offline table tools** — data tables are sortable and filterable in the + browser without any external JavaScript. +- **Navigation and print support** — a sticky section nav for browsing and + print/PDF styles for cleaner exported reports. + +### Export to Wealthfolio CSV + +```bash +.venv/bin/python exporter.py # uses the default report -> results/_wealthfolio.csv +.venv/bin/python exporter.py EUR_other.xlsx -o my.csv # explicit input/output +``` + +### Run the tests + +```bash +.venv/bin/python -m pytest -q +``` + +--- + +## How the review is computed + +- **Trades** are reconstructed from the `OPEN/CLOSE BUY/SELL … @ price` comments in + Cash Operations (the `Closed Positions` sheet is often empty for still-open accounts). + Trades are keyed by the **real `Ticker`** column (e.g. `SPYL.DE`), so descriptive + variants of the same instrument merge into a single holding. Trades are processed in + **chronological order** — XTB sheets sometimes list a position's close leg before its + open leg, so date-ordering is required for correct FIFO lot matching. +- **Holdings** are the net open lots per ticker at cost basis, with allocation %. +- **Live market value** is fetched via [`yfinance`](https://github.com/ranaroussi/yfinance) + for the last trading day on/before the report's `Date to`. The close is taken in the + symbol's native currency and converted to the account currency when needed. Any ticker + that can't be priced (delisted / not on Yahoo) falls back to **cost basis** and is + flagged `price_source = "cost"` in the holdings CSV and report. +- **Realized P/L** prefers the broker's `Closed Positions` `Profit/Loss` column; when that + is absent, it falls back to **FIFO lot matching** from CLOSE trades. +- **Cash flows** are categorized (deposits, withdrawals, interest, dividends, dividend tax, + FX fees, invested, proceeds) and reconciled against the broker's `Total` (ending cash). +- **Performance** combines **live market value** (or cost basis fallback) with cash to give + portfolio value, total gain, total return %, money-weighted return (XIRR), and + income yield. XIRR uses external deposits/withdrawals plus terminal portfolio + value; dividends and interest are not treated as external cash flows unless + they leave the account as withdrawals. +- **Return contribution** combines each open holding's unrealized P/L with any + realized P/L by ticker, then expresses the result as a share of total gain. +- **Concentration & risk** is derived from market-value weights, cash weight, and + pricing source coverage; it flags large top holdings and cost-priced positions. +- **Income quality** separates gross income, dividend tax, net income, tax drag, + net income yield, and the dividend-vs-interest mix. +- **Evolution chart** replays the trades chronologically and, for each trading day, + computes the open cost basis, the open market value (from historical closes via + yfinance, falling back to cost for unpriced tickers), and cumulative realized P/L. + The gap between the **Cost** and **Value (realized + unrealized)** lines is the total + investment gain/loss. Daily series is persisted to `results/_evolution.csv` + when `--csv` is used. + +### Wealthfolio activity mapping + +| XTB operation | Wealthfolio `activityType` | +| ------------------------------------------------- | -------------------------- | +| `Stock purchase` / `OPEN BUY` | `BUY` | +| `Stock sale` / `CLOSE SELL` / `OPEN SELL` (short) | `SELL` | +| `Deposit` / `Withdrawal` | `DEPOSIT` / `WITHDRAWAL` | +| `Dividend` | `DIVIDEND` | +| `Free funds interest` | `INTEREST` | +| `Dividend tax` | `TAX` | +| `Currency conversion` | `FEE` | + +Per the Wealthfolio [CSV spec](https://wealthfolio.app/docs/guide/csv-import/), cash +activities (`DEPOSIT`/`WITHDRAWAL`/`DIVIDEND`/`INTEREST`/`TAX`/`FEE`) carry their total +value in the `amount` column with `quantity = 1` and `unitPrice = 1`; the `fee` column is +only used for inline `BUY`/`SELL` commissions. Pure-cash rows use the `$CASH-` symbol +(e.g. `$CASH-EUR`), while `DIVIDEND` keeps the security's real ticker. `BUY`/`SELL` leave +`amount` blank — Wealthfolio auto-calculates it as `quantity × unitPrice`. + +--- + +## Notes & limitations + +- **Live prices** are daily closes from yfinance, taken for the last trading day on or + before the report's `Date to`. A symbol that can't be resolved (e.g. some proprietary + XTB instrument codes) is valued at cost and flagged with `price_source = "cost"`. +- **Cost fallback positions** carry zero unrealized P/L in the report, contribution + table, and evolution chart. The methodology section lists every cost fallback ticker. +- **Money-weighted return (XIRR)** requires at least one external cash outflow and + one inflow. When the dated cash-flow series cannot be solved, the report shows `n/a`. +- **Reconciliation** compares computed ending cash against the XTB `Total` row; it reports + `[OK]` when they match within €0.01. +- **HTML interactions** (charts, sorting, filtering, sticky navigation) are all inline + and offline; no CDN or network access is required to open the generated report. +- Thousand-separators are intentionally **not** parsed in numeric fields (ambiguous with + decimal dot); XTB's plain decimal format is handled correctly. +- All generated artifacts go to `results/` (git-ignored via `.gitignore`). diff --git a/conftest.py b/conftest.py new file mode 100644 index 0000000..ee1ecaa --- /dev/null +++ b/conftest.py @@ -0,0 +1,87 @@ +"""Shared pytest fixtures.""" +from __future__ import annotations + +from pathlib import Path + +import pandas as pd +import pytest + +import main + + +def write_synthetic_xtb_report(path: Path) -> Path: + """Write a minimal non-sensitive XTB-style workbook for integration tests.""" + closed_positions = pd.DataFrame(columns=["Instrument", "Ticker", "Profit/Loss"]) + open_positions = pd.DataFrame( + [ + { + "Instrument": "Demo Equity", + "Ticker": "DEMO.DE", + "Market Value": 300.0, + "Current Value": 300.0, + "Unrealized P/L": 0.0, + "Open Price": 100.0, + "Market Price": 100.0, + } + ] + ) + cash_ops = pd.DataFrame( + [ + ["Deposit", "", "", "2026-01-01 09:00:00", 1000.0, "deposit funds", "Cash"], + [ + "Stock purchase", + "Demo Equity", + "DEMO.DE", + "2026-01-02 09:00:00", + -500.0, + "OPEN BUY 5 @ 100.00", + "My Trades", + ], + ["Dividend", "Demo Equity", "DEMO.DE", "2026-01-03 09:00:00", 10.0, "Dividend", "Cash"], + [ + "Dividend tax", + "Demo Equity", + "DEMO.DE", + "2026-01-03 09:01:00", + -1.5, + "Dividend tax", + "Cash", + ], + [ + "Stock sale", + "Demo Equity", + "DEMO.DE", + "2026-01-04 09:00:00", + 240.0, + "CLOSE SELL 2 @ 120.00", + "My Trades", + ], + ["Total", "", "", "", 748.5, "", ""], + ], + columns=["Type", "Instrument", "Ticker", "Time", "Amount", "Comment", "Product"], + ) + + with pd.ExcelWriter(path, engine="openpyxl") as writer: + closed_positions.to_excel(writer, sheet_name=main.POSITIONS_SHEET, index=False, startrow=4) + open_positions.to_excel(writer, sheet_name=main.OPEN_POSITIONS_SHEET, index=False, startrow=4) + cash_ops.to_excel(writer, sheet_name=main.CASH_SHEET, index=False, startrow=4) + + cash_sheet = writer.book[main.CASH_SHEET] + cash_sheet.cell(row=1, column=1, value="Account") + cash_sheet.cell(row=1, column=2, value="DEMO-ACCOUNT") + cash_sheet.cell(row=2, column=1, value="Date from") + cash_sheet.cell(row=2, column=2, value="2026-01-01") + cash_sheet.cell(row=3, column=1, value="Date to") + cash_sheet.cell(row=3, column=2, value="2026-01-04") + + return path + + +@pytest.fixture(autouse=True) +def _synthetic_report(tmp_path): + previous = main.REPORT_FILE + main.REPORT_FILE = write_synthetic_xtb_report(tmp_path / "EUR_demo_report.xlsx") + try: + yield + finally: + main.REPORT_FILE = previous diff --git a/exporter.py b/exporter.py new file mode 100644 index 0000000..cd9229c --- /dev/null +++ b/exporter.py @@ -0,0 +1,40 @@ +"""Compatibility entry point for the XTB to Wealthfolio export skill. + +The canonical implementation lives in +`skills/xtb-wealthfolio-export/scripts/exporter.py` so the skill folder can be +copied and used standalone by an LLM agent. This shim preserves the historical +repo API: `import exporter` and `python exporter.py`. +""" +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path + + +_IMPL_PATH = ( + Path(__file__).resolve().parent + / "skills" + / "xtb-wealthfolio-export" + / "scripts" + / "exporter.py" +) + + +def _load_impl(): + script_dir = _IMPL_PATH.parent + if str(script_dir) not in sys.path: + sys.path.insert(0, str(script_dir)) + spec = importlib.util.spec_from_file_location(__name__, _IMPL_PATH) + if spec is None or spec.loader is None: + raise ImportError(f"Could not load XTB Wealthfolio implementation at {_IMPL_PATH}") + module = importlib.util.module_from_spec(spec) + sys.modules[__name__] = module + spec.loader.exec_module(module) + return module + + +_impl = _load_impl() + +if __name__ == "__main__": + _impl.main_cli() diff --git a/html_charts.py b/html_charts.py new file mode 100644 index 0000000..3b12187 --- /dev/null +++ b/html_charts.py @@ -0,0 +1,32 @@ +"""Compatibility import for the portfolio review chart helpers. + +The canonical implementation lives in +`skills/xtb-portfolio-review/scripts/html_charts.py`. +""" +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path + + +_IMPL_PATH = ( + Path(__file__).resolve().parent + / "skills" + / "xtb-portfolio-review" + / "scripts" + / "html_charts.py" +) + + +def _load_impl(): + spec = importlib.util.spec_from_file_location(__name__, _IMPL_PATH) + if spec is None or spec.loader is None: + raise ImportError(f"Could not load HTML chart implementation at {_IMPL_PATH}") + module = importlib.util.module_from_spec(spec) + sys.modules[__name__] = module + spec.loader.exec_module(module) + return module + + +_load_impl() diff --git a/main.py b/main.py new file mode 100644 index 0000000..e53e300 --- /dev/null +++ b/main.py @@ -0,0 +1,36 @@ +"""Compatibility entry point for the XTB portfolio review skill. + +The canonical implementation lives in +`skills/xtb-portfolio-review/scripts/main.py` so the skill folder can be copied +and used standalone by an LLM agent. This shim preserves the historical repo +API: `import main` and `python main.py`. +""" +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path + +_IMPL_PATH = ( + Path(__file__).resolve().parent + / "skills" + / "xtb-portfolio-review" + / "scripts" + / "main.py" +) + + +def _load_impl(): + script_dir = _IMPL_PATH.parent + if str(script_dir) not in sys.path: + sys.path.insert(0, str(script_dir)) + spec = importlib.util.spec_from_file_location(__name__, _IMPL_PATH) + if spec is None or spec.loader is None: + raise ImportError(f"Could not load XTB portfolio implementation at {_IMPL_PATH}") + module = importlib.util.module_from_spec(spec) + sys.modules[__name__] = module + spec.loader.exec_module(module) + return module + + +_impl = _load_impl() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..89ee17c --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +pandas +openpyxl +yfinance +pytest diff --git a/skills/xtb-portfolio-review/SKILL.md b/skills/xtb-portfolio-review/SKILL.md new file mode 100644 index 0000000..a424885 --- /dev/null +++ b/skills/xtb-portfolio-review/SKILL.md @@ -0,0 +1,42 @@ +--- +name: xtb-portfolio-review +description: Use when analyzing XTB brokerage .xlsx exports with the local portfolio review tool, generating or checking HTML/CSV reports, validating cash reconciliation, reviewing holdings, risk, income, performance, or explaining report outputs from main.py. +--- + +# XTB Portfolio Review + +Use this skill to run and assess XTB portfolio reviews from a copied skill folder. The skill bundles the required Python tools in `scripts/`, so it can run without the original repository as long as Python dependencies are installed. + +## Workflow + +1. Identify the target workbook. If the user does not name one and exactly one non-lock `.xlsx` exists in the current working directory, use it. +2. Ensure dependencies are available: + `/scripts/setup-env.sh` +3. Validate the bundled tools: + `/scripts/validate-review.sh` +4. Generate the review from the directory where outputs should be written: + `/scripts/run-review.sh ` +5. Inspect `results/` outputs named from the workbook stem, especially `_review.html`, `_holdings.csv`, `_cash_flows.csv`, `_performance.csv`, `_income.csv`, and `_evolution.csv`. +6. Check whether computed ending cash reconciles to the broker `Total` row within EUR/USD/etc. `0.01`. +7. Report findings with caveats: cost-priced tickers, missing live prices, cash mismatch, XIRR availability, concentration, income tax drag, and any generated file paths. + +## Bundled Tools + +- `scripts/main.py`: standalone XTB portfolio review generator. +- `scripts/html_charts.py`: offline Chart.js report rendering helper. +- `scripts/assets/chartjs.umd.min.js`: vendored Chart.js bundle for self-contained HTML. +- `scripts/run-review.sh`: shell wrapper that runs the bundled review tool with `--csv`. +- `scripts/validate-review.sh`: dependency and asset smoke check. +- `scripts/setup-env.sh`: creates `.venv` in the current working directory and installs dependencies. +- `scripts/requirements.txt`: Python dependencies. + +## References + +- Read `references/xtb-format.md` when parsing behavior, report assumptions, or XTB edge cases matter. +- Read `references/validation-checklist.md` before claiming a generated portfolio review is correct or ready to use. + +## Guardrails + +- Do not treat the generated report as investment advice; describe what the tool computed and the data-quality limits. +- Prefer the bundled validation script and generated CSVs over eyeballing the HTML alone. +- Preserve offline/self-contained HTML behavior; do not introduce CDN dependencies when modifying the report. diff --git a/skills/xtb-portfolio-review/references/validation-checklist.md b/skills/xtb-portfolio-review/references/validation-checklist.md new file mode 100644 index 0000000..d0726d5 --- /dev/null +++ b/skills/xtb-portfolio-review/references/validation-checklist.md @@ -0,0 +1,36 @@ +# Portfolio Review Validation Checklist + +Load this before saying an XTB portfolio review is ready. + +## Commands + +- Install dependencies: + `/scripts/setup-env.sh` +- Validate bundled tools: + `/scripts/validate-review.sh` +- Generate report and CSVs: + `/scripts/run-review.sh ` +- If working inside the original project repository, full tests are also useful: + `.venv/bin/python -m pytest -q` + +## Required Checks + +- The command exits successfully and writes `results/_review.html`. +- CSV side outputs exist when `--csv` was used. +- Cash reconciliation is `[OK]` or the mismatch is explicitly reported. +- Holdings with live-price failures are visible as cost fallbacks. +- The HTML remains self-contained/offline: no CDN script or stylesheet dependency. +- The report includes methodology/data-quality notes for pricing and reconciliation. + +## Useful Output Files + +- `_holdings.csv`: shares, cost basis, market value, allocation, unrealized P/L, price source. +- `_cash_flows.csv`: deposits, withdrawals, invested, proceeds, dividends, tax, fees, ending cash. +- `_realized_pl.csv`: realized profit/loss by ticker. +- `_performance.csv`: portfolio value, total gain, return metrics, income yield. +- `_income.csv`: dividend and interest income over time. +- `_evolution.csv`: daily cost/value/realized series for charts. + +## Reporting Style + +Summarize computed facts and data-quality status. Avoid recommendations to buy, sell, rebalance, or time markets unless the user explicitly asks for financial planning context, and still frame it as educational analysis rather than advice. diff --git a/skills/xtb-portfolio-review/references/xtb-format.md b/skills/xtb-portfolio-review/references/xtb-format.md new file mode 100644 index 0000000..399222c --- /dev/null +++ b/skills/xtb-portfolio-review/references/xtb-format.md @@ -0,0 +1,33 @@ +# XTB Report Format Notes + +Load this when XTB parsing details matter. + +## Workbook Layout + +- XTB exports are `.xlsx` files. +- Metadata is in rows 1-4. +- Column headers begin on row 5, so pandas should use `header=4`. +- Main sheets: + - `Cash Operations`: trades, deposits, withdrawals, dividends, taxes, interest, conversions, and broker `Total` row. + - `Closed Positions`: realized trade summary; can be empty for still-open accounts. + - `Open Positions`: optional live/open-position sheet. + +## Trade Reconstruction + +- The review reconstructs trades primarily from `Cash Operations` comments such as `OPEN BUY 6 @ 301.50` and `CLOSE SELL 2 @ 100.00`. +- Use the real `Ticker` column as the instrument key, not only descriptive instrument text. +- Process trades chronologically before FIFO matching. +- Split-fill notation like `OPEN BUY 1/100 @ 14.3130` means executed quantity is `1`; use the numerator, not `0.01`. +- Some XTB stock sales appear as `CLOSE BUY` while the row type is `Stock sell` and amount is positive. Treat these economically as sales. + +## Valuation + +- Live prices come from `yfinance` daily closes at or before the report end date. +- Use trusted same-instrument symbol aliases only. Do not substitute a different share class as a proxy. +- If no trusted price exists, hold the ticker at cost and surface `price_source = cost` plus the reason. + +## Cash And Performance + +- Reconciliation compares computed ending cash with the broker `Total` row. +- Dividends and interest are internal cash flows unless withdrawn; do not count them as external cash flows for XIRR. +- XIRR may be `n/a` if the cash-flow signs or solver conditions are insufficient. diff --git a/skills/xtb-portfolio-review/scripts/assets/chartjs.VERSION b/skills/xtb-portfolio-review/scripts/assets/chartjs.VERSION new file mode 100644 index 0000000..4404a17 --- /dev/null +++ b/skills/xtb-portfolio-review/scripts/assets/chartjs.VERSION @@ -0,0 +1 @@ +4.5.1 diff --git a/skills/xtb-portfolio-review/scripts/assets/chartjs.umd.min.js b/skills/xtb-portfolio-review/scripts/assets/chartjs.umd.min.js new file mode 100644 index 0000000..008464f --- /dev/null +++ b/skills/xtb-portfolio-review/scripts/assets/chartjs.umd.min.js @@ -0,0 +1,14 @@ +/*! + * Chart.js v4.5.1 + * https://www.chartjs.org + * (c) 2025 Chart.js Contributors + * Released under the MIT License + */ +!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t="undefined"!=typeof globalThis?globalThis:t||self).Chart=e()}(this,(function(){"use strict";var t=Object.freeze({__proto__:null,get Colors(){return Jo},get Decimation(){return ta},get Filler(){return ba},get Legend(){return Ma},get SubTitle(){return Pa},get Title(){return ka},get Tooltip(){return Na}});function e(){}const i=(()=>{let t=0;return()=>t++})();function s(t){return null==t}function n(t){if(Array.isArray&&Array.isArray(t))return!0;const e=Object.prototype.toString.call(t);return"[object"===e.slice(0,7)&&"Array]"===e.slice(-6)}function o(t){return null!==t&&"[object Object]"===Object.prototype.toString.call(t)}function a(t){return("number"==typeof t||t instanceof Number)&&isFinite(+t)}function r(t,e){return a(t)?t:e}function l(t,e){return void 0===t?e:t}const h=(t,e)=>"string"==typeof t&&t.endsWith("%")?parseFloat(t)/100:+t/e,c=(t,e)=>"string"==typeof t&&t.endsWith("%")?parseFloat(t)/100*e:+t;function d(t,e,i){if(t&&"function"==typeof t.call)return t.apply(i,e)}function u(t,e,i,s){let a,r,l;if(n(t))if(r=t.length,s)for(a=r-1;a>=0;a--)e.call(i,t[a],a);else for(a=0;at,x:t=>t.x,y:t=>t.y};function v(t){const e=t.split("."),i=[];let s="";for(const t of e)s+=t,s.endsWith("\\")?s=s.slice(0,-1)+".":(i.push(s),s="");return i}function M(t,e){const i=y[e]||(y[e]=function(t){const e=v(t);return t=>{for(const i of e){if(""===i)break;t=t&&t[i]}return t}}(e));return i(t)}function w(t){return t.charAt(0).toUpperCase()+t.slice(1)}const k=t=>void 0!==t,S=t=>"function"==typeof t,P=(t,e)=>{if(t.size!==e.size)return!1;for(const i of t)if(!e.has(i))return!1;return!0};function D(t){return"mouseup"===t.type||"click"===t.type||"contextmenu"===t.type}const C=Math.PI,O=2*C,A=O+C,T=Number.POSITIVE_INFINITY,L=C/180,E=C/2,R=C/4,I=2*C/3,z=Math.log10,F=Math.sign;function V(t,e,i){return Math.abs(t-e)t-e)).pop(),e}function N(t){return!function(t){return"symbol"==typeof t||"object"==typeof t&&null!==t&&!(Symbol.toPrimitive in t||"toString"in t||"valueOf"in t)}(t)&&!isNaN(parseFloat(t))&&isFinite(t)}function H(t,e){const i=Math.round(t);return i-e<=t&&i+e>=t}function j(t,e,i){let s,n,o;for(s=0,n=t.length;sl&&h=Math.min(e,i)-s&&t<=Math.max(e,i)+s}function et(t,e,i){i=i||(i=>t[i]1;)s=o+n>>1,i(s)?o=s:n=s;return{lo:o,hi:n}}const it=(t,e,i,s)=>et(t,i,s?s=>{const n=t[s][e];return nt[s][e]et(t,i,(s=>t[s][e]>=i));function nt(t,e,i){let s=0,n=t.length;for(;ss&&t[n-1]>i;)n--;return s>0||n{const i="_onData"+w(e),s=t[e];Object.defineProperty(t,e,{configurable:!0,enumerable:!1,value(...e){const n=s.apply(this,e);return t._chartjs.listeners.forEach((t=>{"function"==typeof t[i]&&t[i](...e)})),n}})})))}function rt(t,e){const i=t._chartjs;if(!i)return;const s=i.listeners,n=s.indexOf(e);-1!==n&&s.splice(n,1),s.length>0||(ot.forEach((e=>{delete t[e]})),delete t._chartjs)}function lt(t){const e=new Set(t);return e.size===t.length?t:Array.from(e)}const ht="undefined"==typeof window?function(t){return t()}:window.requestAnimationFrame;function ct(t,e){let i=[],s=!1;return function(...n){i=n,s||(s=!0,ht.call(window,(()=>{s=!1,t.apply(e,i)})))}}function dt(t,e){let i;return function(...s){return e?(clearTimeout(i),i=setTimeout(t,e,s)):t.apply(this,s),e}}const ut=t=>"start"===t?"left":"end"===t?"right":"center",ft=(t,e,i)=>"start"===t?e:"end"===t?i:(e+i)/2,gt=(t,e,i,s)=>t===(s?"left":"right")?i:"center"===t?(e+i)/2:e;function pt(t,e,i){const n=e.length;let o=0,a=n;if(t._sorted){const{iScale:r,vScale:l,_parsed:h}=t,c=t.dataset&&t.dataset.options?t.dataset.options.spanGaps:null,d=r.axis,{min:u,max:f,minDefined:g,maxDefined:p}=r.getUserBounds();if(g){if(o=Math.min(it(h,d,u).lo,i?n:it(e,d,r.getPixelForValue(u)).lo),c){const t=h.slice(0,o+1).reverse().findIndex((t=>!s(t[l.axis])));o-=Math.max(0,t)}o=Z(o,0,n-1)}if(p){let t=Math.max(it(h,r.axis,f,!0).hi+1,i?0:it(e,d,r.getPixelForValue(f),!0).hi+1);if(c){const e=h.slice(t-1).findIndex((t=>!s(t[l.axis])));t+=Math.max(0,e)}a=Z(t,o,n)-o}else a=n-o}return{start:o,count:a}}function mt(t){const{xScale:e,yScale:i,_scaleRanges:s}=t,n={xmin:e.min,xmax:e.max,ymin:i.min,ymax:i.max};if(!s)return t._scaleRanges=n,!0;const o=s.xmin!==e.min||s.xmax!==e.max||s.ymin!==i.min||s.ymax!==i.max;return Object.assign(s,n),o}class xt{constructor(){this._request=null,this._charts=new Map,this._running=!1,this._lastDate=void 0}_notify(t,e,i,s){const n=e.listeners[s],o=e.duration;n.forEach((s=>s({chart:t,initial:e.initial,numSteps:o,currentStep:Math.min(i-e.start,o)})))}_refresh(){this._request||(this._running=!0,this._request=ht.call(window,(()=>{this._update(),this._request=null,this._running&&this._refresh()})))}_update(t=Date.now()){let e=0;this._charts.forEach(((i,s)=>{if(!i.running||!i.items.length)return;const n=i.items;let o,a=n.length-1,r=!1;for(;a>=0;--a)o=n[a],o._active?(o._total>i.duration&&(i.duration=o._total),o.tick(t),r=!0):(n[a]=n[n.length-1],n.pop());r&&(s.draw(),this._notify(s,i,t,"progress")),n.length||(i.running=!1,this._notify(s,i,t,"complete"),i.initial=!1),e+=n.length})),this._lastDate=t,0===e&&(this._running=!1)}_getAnims(t){const e=this._charts;let i=e.get(t);return i||(i={running:!1,initial:!0,items:[],listeners:{complete:[],progress:[]}},e.set(t,i)),i}listen(t,e,i){this._getAnims(t).listeners[e].push(i)}add(t,e){e&&e.length&&this._getAnims(t).items.push(...e)}has(t){return this._getAnims(t).items.length>0}start(t){const e=this._charts.get(t);e&&(e.running=!0,e.start=Date.now(),e.duration=e.items.reduce(((t,e)=>Math.max(t,e._duration)),0),this._refresh())}running(t){if(!this._running)return!1;const e=this._charts.get(t);return!!(e&&e.running&&e.items.length)}stop(t){const e=this._charts.get(t);if(!e||!e.items.length)return;const i=e.items;let s=i.length-1;for(;s>=0;--s)i[s].cancel();e.items=[],this._notify(t,e,Date.now(),"complete")}remove(t){return this._charts.delete(t)}}var bt=new xt; +/*! + * @kurkle/color v0.3.2 + * https://github.com/kurkle/color#readme + * (c) 2023 Jukka Kurkela + * Released under the MIT License + */function _t(t){return t+.5|0}const yt=(t,e,i)=>Math.max(Math.min(t,i),e);function vt(t){return yt(_t(2.55*t),0,255)}function Mt(t){return yt(_t(255*t),0,255)}function wt(t){return yt(_t(t/2.55)/100,0,1)}function kt(t){return yt(_t(100*t),0,100)}const St={0:0,1:1,2:2,3:3,4:4,5:5,6:6,7:7,8:8,9:9,A:10,B:11,C:12,D:13,E:14,F:15,a:10,b:11,c:12,d:13,e:14,f:15},Pt=[..."0123456789ABCDEF"],Dt=t=>Pt[15&t],Ct=t=>Pt[(240&t)>>4]+Pt[15&t],Ot=t=>(240&t)>>4==(15&t);function At(t){var e=(t=>Ot(t.r)&&Ot(t.g)&&Ot(t.b)&&Ot(t.a))(t)?Dt:Ct;return t?"#"+e(t.r)+e(t.g)+e(t.b)+((t,e)=>t<255?e(t):"")(t.a,e):void 0}const Tt=/^(hsla?|hwb|hsv)\(\s*([-+.e\d]+)(?:deg)?[\s,]+([-+.e\d]+)%[\s,]+([-+.e\d]+)%(?:[\s,]+([-+.e\d]+)(%)?)?\s*\)$/;function Lt(t,e,i){const s=e*Math.min(i,1-i),n=(e,n=(e+t/30)%12)=>i-s*Math.max(Math.min(n-3,9-n,1),-1);return[n(0),n(8),n(4)]}function Et(t,e,i){const s=(s,n=(s+t/60)%6)=>i-i*e*Math.max(Math.min(n,4-n,1),0);return[s(5),s(3),s(1)]}function Rt(t,e,i){const s=Lt(t,1,.5);let n;for(e+i>1&&(n=1/(e+i),e*=n,i*=n),n=0;n<3;n++)s[n]*=1-e-i,s[n]+=e;return s}function It(t){const e=t.r/255,i=t.g/255,s=t.b/255,n=Math.max(e,i,s),o=Math.min(e,i,s),a=(n+o)/2;let r,l,h;return n!==o&&(h=n-o,l=a>.5?h/(2-n-o):h/(n+o),r=function(t,e,i,s,n){return t===n?(e-i)/s+(e>16&255,o>>8&255,255&o]}return t}(),Ht.transparent=[0,0,0,0]);const e=Ht[t.toLowerCase()];return e&&{r:e[0],g:e[1],b:e[2],a:4===e.length?e[3]:255}}const $t=/^rgba?\(\s*([-+.\d]+)(%)?[\s,]+([-+.e\d]+)(%)?[\s,]+([-+.e\d]+)(%)?(?:[\s,/]+([-+.e\d]+)(%)?)?\s*\)$/;const Yt=t=>t<=.0031308?12.92*t:1.055*Math.pow(t,1/2.4)-.055,Ut=t=>t<=.04045?t/12.92:Math.pow((t+.055)/1.055,2.4);function Xt(t,e,i){if(t){let s=It(t);s[e]=Math.max(0,Math.min(s[e]+s[e]*i,0===e?360:1)),s=Ft(s),t.r=s[0],t.g=s[1],t.b=s[2]}}function qt(t,e){return t?Object.assign(e||{},t):t}function Kt(t){var e={r:0,g:0,b:0,a:255};return Array.isArray(t)?t.length>=3&&(e={r:t[0],g:t[1],b:t[2],a:255},t.length>3&&(e.a=Mt(t[3]))):(e=qt(t,{r:0,g:0,b:0,a:1})).a=Mt(e.a),e}function Gt(t){return"r"===t.charAt(0)?function(t){const e=$t.exec(t);let i,s,n,o=255;if(e){if(e[7]!==i){const t=+e[7];o=e[8]?vt(t):yt(255*t,0,255)}return i=+e[1],s=+e[3],n=+e[5],i=255&(e[2]?vt(i):yt(i,0,255)),s=255&(e[4]?vt(s):yt(s,0,255)),n=255&(e[6]?vt(n):yt(n,0,255)),{r:i,g:s,b:n,a:o}}}(t):Bt(t)}class Jt{constructor(t){if(t instanceof Jt)return t;const e=typeof t;let i;var s,n,o;"object"===e?i=Kt(t):"string"===e&&(o=(s=t).length,"#"===s[0]&&(4===o||5===o?n={r:255&17*St[s[1]],g:255&17*St[s[2]],b:255&17*St[s[3]],a:5===o?17*St[s[4]]:255}:7!==o&&9!==o||(n={r:St[s[1]]<<4|St[s[2]],g:St[s[3]]<<4|St[s[4]],b:St[s[5]]<<4|St[s[6]],a:9===o?St[s[7]]<<4|St[s[8]]:255})),i=n||jt(t)||Gt(t)),this._rgb=i,this._valid=!!i}get valid(){return this._valid}get rgb(){var t=qt(this._rgb);return t&&(t.a=wt(t.a)),t}set rgb(t){this._rgb=Kt(t)}rgbString(){return this._valid?(t=this._rgb)&&(t.a<255?`rgba(${t.r}, ${t.g}, ${t.b}, ${wt(t.a)})`:`rgb(${t.r}, ${t.g}, ${t.b})`):void 0;var t}hexString(){return this._valid?At(this._rgb):void 0}hslString(){return this._valid?function(t){if(!t)return;const e=It(t),i=e[0],s=kt(e[1]),n=kt(e[2]);return t.a<255?`hsla(${i}, ${s}%, ${n}%, ${wt(t.a)})`:`hsl(${i}, ${s}%, ${n}%)`}(this._rgb):void 0}mix(t,e){if(t){const i=this.rgb,s=t.rgb;let n;const o=e===n?.5:e,a=2*o-1,r=i.a-s.a,l=((a*r==-1?a:(a+r)/(1+a*r))+1)/2;n=1-l,i.r=255&l*i.r+n*s.r+.5,i.g=255&l*i.g+n*s.g+.5,i.b=255&l*i.b+n*s.b+.5,i.a=o*i.a+(1-o)*s.a,this.rgb=i}return this}interpolate(t,e){return t&&(this._rgb=function(t,e,i){const s=Ut(wt(t.r)),n=Ut(wt(t.g)),o=Ut(wt(t.b));return{r:Mt(Yt(s+i*(Ut(wt(e.r))-s))),g:Mt(Yt(n+i*(Ut(wt(e.g))-n))),b:Mt(Yt(o+i*(Ut(wt(e.b))-o))),a:t.a+i*(e.a-t.a)}}(this._rgb,t._rgb,e)),this}clone(){return new Jt(this.rgb)}alpha(t){return this._rgb.a=Mt(t),this}clearer(t){return this._rgb.a*=1-t,this}greyscale(){const t=this._rgb,e=_t(.3*t.r+.59*t.g+.11*t.b);return t.r=t.g=t.b=e,this}opaquer(t){return this._rgb.a*=1+t,this}negate(){const t=this._rgb;return t.r=255-t.r,t.g=255-t.g,t.b=255-t.b,this}lighten(t){return Xt(this._rgb,2,t),this}darken(t){return Xt(this._rgb,2,-t),this}saturate(t){return Xt(this._rgb,1,t),this}desaturate(t){return Xt(this._rgb,1,-t),this}rotate(t){return function(t,e){var i=It(t);i[0]=Vt(i[0]+e),i=Ft(i),t.r=i[0],t.g=i[1],t.b=i[2]}(this._rgb,t),this}}function Zt(t){if(t&&"object"==typeof t){const e=t.toString();return"[object CanvasPattern]"===e||"[object CanvasGradient]"===e}return!1}function Qt(t){return Zt(t)?t:new Jt(t)}function te(t){return Zt(t)?t:new Jt(t).saturate(.5).darken(.1).hexString()}const ee=["x","y","borderWidth","radius","tension"],ie=["color","borderColor","backgroundColor"];const se=new Map;function ne(t,e,i){return function(t,e){e=e||{};const i=t+JSON.stringify(e);let s=se.get(i);return s||(s=new Intl.NumberFormat(t,e),se.set(i,s)),s}(e,i).format(t)}const oe={values:t=>n(t)?t:""+t,numeric(t,e,i){if(0===t)return"0";const s=this.chart.options.locale;let n,o=t;if(i.length>1){const e=Math.max(Math.abs(i[0].value),Math.abs(i[i.length-1].value));(e<1e-4||e>1e15)&&(n="scientific"),o=function(t,e){let i=e.length>3?e[2].value-e[1].value:e[1].value-e[0].value;Math.abs(i)>=1&&t!==Math.floor(t)&&(i=t-Math.floor(t));return i}(t,i)}const a=z(Math.abs(o)),r=isNaN(a)?1:Math.max(Math.min(-1*Math.floor(a),20),0),l={notation:n,minimumFractionDigits:r,maximumFractionDigits:r};return Object.assign(l,this.options.ticks.format),ne(t,s,l)},logarithmic(t,e,i){if(0===t)return"0";const s=i[e].significand||t/Math.pow(10,Math.floor(z(t)));return[1,2,3,5,10,15].includes(s)||e>.8*i.length?oe.numeric.call(this,t,e,i):""}};var ae={formatters:oe};const re=Object.create(null),le=Object.create(null);function he(t,e){if(!e)return t;const i=e.split(".");for(let e=0,s=i.length;et.chart.platform.getDevicePixelRatio(),this.elements={},this.events=["mousemove","mouseout","click","touchstart","touchmove"],this.font={family:"'Helvetica Neue', 'Helvetica', 'Arial', sans-serif",size:12,style:"normal",lineHeight:1.2,weight:null},this.hover={},this.hoverBackgroundColor=(t,e)=>te(e.backgroundColor),this.hoverBorderColor=(t,e)=>te(e.borderColor),this.hoverColor=(t,e)=>te(e.color),this.indexAxis="x",this.interaction={mode:"nearest",intersect:!0,includeInvisible:!1},this.maintainAspectRatio=!0,this.onHover=null,this.onClick=null,this.parsing=!0,this.plugins={},this.responsive=!0,this.scale=void 0,this.scales={},this.showLine=!0,this.drawActiveElementsOnTop=!0,this.describe(t),this.apply(e)}set(t,e){return ce(this,t,e)}get(t){return he(this,t)}describe(t,e){return ce(le,t,e)}override(t,e){return ce(re,t,e)}route(t,e,i,s){const n=he(this,t),a=he(this,i),r="_"+e;Object.defineProperties(n,{[r]:{value:n[e],writable:!0},[e]:{enumerable:!0,get(){const t=this[r],e=a[s];return o(t)?Object.assign({},e,t):l(t,e)},set(t){this[r]=t}}})}apply(t){t.forEach((t=>t(this)))}}var ue=new de({_scriptable:t=>!t.startsWith("on"),_indexable:t=>"events"!==t,hover:{_fallback:"interaction"},interaction:{_scriptable:!1,_indexable:!1}},[function(t){t.set("animation",{delay:void 0,duration:1e3,easing:"easeOutQuart",fn:void 0,from:void 0,loop:void 0,to:void 0,type:void 0}),t.describe("animation",{_fallback:!1,_indexable:!1,_scriptable:t=>"onProgress"!==t&&"onComplete"!==t&&"fn"!==t}),t.set("animations",{colors:{type:"color",properties:ie},numbers:{type:"number",properties:ee}}),t.describe("animations",{_fallback:"animation"}),t.set("transitions",{active:{animation:{duration:400}},resize:{animation:{duration:0}},show:{animations:{colors:{from:"transparent"},visible:{type:"boolean",duration:0}}},hide:{animations:{colors:{to:"transparent"},visible:{type:"boolean",easing:"linear",fn:t=>0|t}}}})},function(t){t.set("layout",{autoPadding:!0,padding:{top:0,right:0,bottom:0,left:0}})},function(t){t.set("scale",{display:!0,offset:!1,reverse:!1,beginAtZero:!1,bounds:"ticks",clip:!0,grace:0,grid:{display:!0,lineWidth:1,drawOnChartArea:!0,drawTicks:!0,tickLength:8,tickWidth:(t,e)=>e.lineWidth,tickColor:(t,e)=>e.color,offset:!1},border:{display:!0,dash:[],dashOffset:0,width:1},title:{display:!1,text:"",padding:{top:4,bottom:4}},ticks:{minRotation:0,maxRotation:50,mirror:!1,textStrokeWidth:0,textStrokeColor:"",padding:3,display:!0,autoSkip:!0,autoSkipPadding:3,labelOffset:0,callback:ae.formatters.values,minor:{},major:{},align:"center",crossAlign:"near",showLabelBackdrop:!1,backdropColor:"rgba(255, 255, 255, 0.75)",backdropPadding:2}}),t.route("scale.ticks","color","","color"),t.route("scale.grid","color","","borderColor"),t.route("scale.border","color","","borderColor"),t.route("scale.title","color","","color"),t.describe("scale",{_fallback:!1,_scriptable:t=>!t.startsWith("before")&&!t.startsWith("after")&&"callback"!==t&&"parser"!==t,_indexable:t=>"borderDash"!==t&&"tickBorderDash"!==t&&"dash"!==t}),t.describe("scales",{_fallback:"scale"}),t.describe("scale.ticks",{_scriptable:t=>"backdropPadding"!==t&&"callback"!==t,_indexable:t=>"backdropPadding"!==t})}]);function fe(){return"undefined"!=typeof window&&"undefined"!=typeof document}function ge(t){let e=t.parentNode;return e&&"[object ShadowRoot]"===e.toString()&&(e=e.host),e}function pe(t,e,i){let s;return"string"==typeof t?(s=parseInt(t,10),-1!==t.indexOf("%")&&(s=s/100*e.parentNode[i])):s=t,s}const me=t=>t.ownerDocument.defaultView.getComputedStyle(t,null);function xe(t,e){return me(t).getPropertyValue(e)}const be=["top","right","bottom","left"];function _e(t,e,i){const s={};i=i?"-"+i:"";for(let n=0;n<4;n++){const o=be[n];s[o]=parseFloat(t[e+"-"+o+i])||0}return s.width=s.left+s.right,s.height=s.top+s.bottom,s}const ye=(t,e,i)=>(t>0||e>0)&&(!i||!i.shadowRoot);function ve(t,e){if("native"in t)return t;const{canvas:i,currentDevicePixelRatio:s}=e,n=me(i),o="border-box"===n.boxSizing,a=_e(n,"padding"),r=_e(n,"border","width"),{x:l,y:h,box:c}=function(t,e){const i=t.touches,s=i&&i.length?i[0]:t,{offsetX:n,offsetY:o}=s;let a,r,l=!1;if(ye(n,o,t.target))a=n,r=o;else{const t=e.getBoundingClientRect();a=s.clientX-t.left,r=s.clientY-t.top,l=!0}return{x:a,y:r,box:l}}(t,i),d=a.left+(c&&r.left),u=a.top+(c&&r.top);let{width:f,height:g}=e;return o&&(f-=a.width+r.width,g-=a.height+r.height),{x:Math.round((l-d)/f*i.width/s),y:Math.round((h-u)/g*i.height/s)}}const Me=t=>Math.round(10*t)/10;function we(t,e,i,s){const n=me(t),o=_e(n,"margin"),a=pe(n.maxWidth,t,"clientWidth")||T,r=pe(n.maxHeight,t,"clientHeight")||T,l=function(t,e,i){let s,n;if(void 0===e||void 0===i){const o=t&&ge(t);if(o){const t=o.getBoundingClientRect(),a=me(o),r=_e(a,"border","width"),l=_e(a,"padding");e=t.width-l.width-r.width,i=t.height-l.height-r.height,s=pe(a.maxWidth,o,"clientWidth"),n=pe(a.maxHeight,o,"clientHeight")}else e=t.clientWidth,i=t.clientHeight}return{width:e,height:i,maxWidth:s||T,maxHeight:n||T}}(t,e,i);let{width:h,height:c}=l;if("content-box"===n.boxSizing){const t=_e(n,"border","width"),e=_e(n,"padding");h-=e.width+t.width,c-=e.height+t.height}h=Math.max(0,h-o.width),c=Math.max(0,s?h/s:c-o.height),h=Me(Math.min(h,a,l.maxWidth)),c=Me(Math.min(c,r,l.maxHeight)),h&&!c&&(c=Me(h/2));return(void 0!==e||void 0!==i)&&s&&l.height&&c>l.height&&(c=l.height,h=Me(Math.floor(c*s))),{width:h,height:c}}function ke(t,e,i){const s=e||1,n=Me(t.height*s),o=Me(t.width*s);t.height=Me(t.height),t.width=Me(t.width);const a=t.canvas;return a.style&&(i||!a.style.height&&!a.style.width)&&(a.style.height=`${t.height}px`,a.style.width=`${t.width}px`),(t.currentDevicePixelRatio!==s||a.height!==n||a.width!==o)&&(t.currentDevicePixelRatio=s,a.height=n,a.width=o,t.ctx.setTransform(s,0,0,s,0,0),!0)}const Se=function(){let t=!1;try{const e={get passive(){return t=!0,!1}};fe()&&(window.addEventListener("test",null,e),window.removeEventListener("test",null,e))}catch(t){}return t}();function Pe(t,e){const i=xe(t,e),s=i&&i.match(/^(\d+)(\.\d+)?px$/);return s?+s[1]:void 0}function De(t){return!t||s(t.size)||s(t.family)?null:(t.style?t.style+" ":"")+(t.weight?t.weight+" ":"")+t.size+"px "+t.family}function Ce(t,e,i,s,n){let o=e[n];return o||(o=e[n]=t.measureText(n).width,i.push(n)),o>s&&(s=o),s}function Oe(t,e,i,s){let o=(s=s||{}).data=s.data||{},a=s.garbageCollect=s.garbageCollect||[];s.font!==e&&(o=s.data={},a=s.garbageCollect=[],s.font=e),t.save(),t.font=e;let r=0;const l=i.length;let h,c,d,u,f;for(h=0;hi.length){for(h=0;h0&&t.stroke()}}function Re(t,e,i){return i=i||.5,!e||t&&t.x>e.left-i&&t.xe.top-i&&t.y0&&""!==r.strokeColor;let c,d;for(t.save(),t.font=a.string,function(t,e){e.translation&&t.translate(e.translation[0],e.translation[1]),s(e.rotation)||t.rotate(e.rotation),e.color&&(t.fillStyle=e.color),e.textAlign&&(t.textAlign=e.textAlign),e.textBaseline&&(t.textBaseline=e.textBaseline)}(t,r),c=0;ct[0])){const o=i||t;void 0===s&&(s=ti("_fallback",t));const a={[Symbol.toStringTag]:"Object",_cacheable:!0,_scopes:t,_rootScopes:o,_fallback:s,_getTarget:n,override:i=>je([i,...t],e,o,s)};return new Proxy(a,{deleteProperty:(e,i)=>(delete e[i],delete e._keys,delete t[0][i],!0),get:(i,s)=>qe(i,s,(()=>function(t,e,i,s){let n;for(const o of e)if(n=ti(Ue(o,t),i),void 0!==n)return Xe(t,n)?Ze(i,s,t,n):n}(s,e,t,i))),getOwnPropertyDescriptor:(t,e)=>Reflect.getOwnPropertyDescriptor(t._scopes[0],e),getPrototypeOf:()=>Reflect.getPrototypeOf(t[0]),has:(t,e)=>ei(t).includes(e),ownKeys:t=>ei(t),set(t,e,i){const s=t._storage||(t._storage=n());return t[e]=s[e]=i,delete t._keys,!0}})}function $e(t,e,i,s){const a={_cacheable:!1,_proxy:t,_context:e,_subProxy:i,_stack:new Set,_descriptors:Ye(t,s),setContext:e=>$e(t,e,i,s),override:n=>$e(t.override(n),e,i,s)};return new Proxy(a,{deleteProperty:(e,i)=>(delete e[i],delete t[i],!0),get:(t,e,i)=>qe(t,e,(()=>function(t,e,i){const{_proxy:s,_context:a,_subProxy:r,_descriptors:l}=t;let h=s[e];S(h)&&l.isScriptable(e)&&(h=function(t,e,i,s){const{_proxy:n,_context:o,_subProxy:a,_stack:r}=i;if(r.has(t))throw new Error("Recursion detected: "+Array.from(r).join("->")+"->"+t);r.add(t);let l=e(o,a||s);r.delete(t),Xe(t,l)&&(l=Ze(n._scopes,n,t,l));return l}(e,h,t,i));n(h)&&h.length&&(h=function(t,e,i,s){const{_proxy:n,_context:a,_subProxy:r,_descriptors:l}=i;if(void 0!==a.index&&s(t))return e[a.index%e.length];if(o(e[0])){const i=e,s=n._scopes.filter((t=>t!==i));e=[];for(const o of i){const i=Ze(s,n,t,o);e.push($e(i,a,r&&r[t],l))}}return e}(e,h,t,l.isIndexable));Xe(e,h)&&(h=$e(h,a,r&&r[e],l));return h}(t,e,i))),getOwnPropertyDescriptor:(e,i)=>e._descriptors.allKeys?Reflect.has(t,i)?{enumerable:!0,configurable:!0}:void 0:Reflect.getOwnPropertyDescriptor(t,i),getPrototypeOf:()=>Reflect.getPrototypeOf(t),has:(e,i)=>Reflect.has(t,i),ownKeys:()=>Reflect.ownKeys(t),set:(e,i,s)=>(t[i]=s,delete e[i],!0)})}function Ye(t,e={scriptable:!0,indexable:!0}){const{_scriptable:i=e.scriptable,_indexable:s=e.indexable,_allKeys:n=e.allKeys}=t;return{allKeys:n,scriptable:i,indexable:s,isScriptable:S(i)?i:()=>i,isIndexable:S(s)?s:()=>s}}const Ue=(t,e)=>t?t+w(e):e,Xe=(t,e)=>o(e)&&"adapters"!==t&&(null===Object.getPrototypeOf(e)||e.constructor===Object);function qe(t,e,i){if(Object.prototype.hasOwnProperty.call(t,e)||"constructor"===e)return t[e];const s=i();return t[e]=s,s}function Ke(t,e,i){return S(t)?t(e,i):t}const Ge=(t,e)=>!0===t?e:"string"==typeof t?M(e,t):void 0;function Je(t,e,i,s,n){for(const o of e){const e=Ge(i,o);if(e){t.add(e);const o=Ke(e._fallback,i,n);if(void 0!==o&&o!==i&&o!==s)return o}else if(!1===e&&void 0!==s&&i!==s)return null}return!1}function Ze(t,e,i,s){const a=e._rootScopes,r=Ke(e._fallback,i,s),l=[...t,...a],h=new Set;h.add(s);let c=Qe(h,l,i,r||i,s);return null!==c&&((void 0===r||r===i||(c=Qe(h,l,r,c,s),null!==c))&&je(Array.from(h),[""],a,r,(()=>function(t,e,i){const s=t._getTarget();e in s||(s[e]={});const a=s[e];if(n(a)&&o(i))return i;return a||{}}(e,i,s))))}function Qe(t,e,i,s,n){for(;i;)i=Je(t,e,i,s,n);return i}function ti(t,e){for(const i of e){if(!i)continue;const e=i[t];if(void 0!==e)return e}}function ei(t){let e=t._keys;return e||(e=t._keys=function(t){const e=new Set;for(const i of t)for(const t of Object.keys(i).filter((t=>!t.startsWith("_"))))e.add(t);return Array.from(e)}(t._scopes)),e}function ii(t,e,i,s){const{iScale:n}=t,{key:o="r"}=this._parsing,a=new Array(s);let r,l,h,c;for(r=0,l=s;re"x"===t?"y":"x";function ai(t,e,i,s){const n=t.skip?e:t,o=e,a=i.skip?e:i,r=q(o,n),l=q(a,o);let h=r/(r+l),c=l/(r+l);h=isNaN(h)?0:h,c=isNaN(c)?0:c;const d=s*h,u=s*c;return{previous:{x:o.x-d*(a.x-n.x),y:o.y-d*(a.y-n.y)},next:{x:o.x+u*(a.x-n.x),y:o.y+u*(a.y-n.y)}}}function ri(t,e="x"){const i=oi(e),s=t.length,n=Array(s).fill(0),o=Array(s);let a,r,l,h=ni(t,0);for(a=0;a!t.skip))),"monotone"===e.cubicInterpolationMode)ri(t,n);else{let i=s?t[t.length-1]:t[0];for(o=0,a=t.length;o0===t||1===t,di=(t,e,i)=>-Math.pow(2,10*(t-=1))*Math.sin((t-e)*O/i),ui=(t,e,i)=>Math.pow(2,-10*t)*Math.sin((t-e)*O/i)+1,fi={linear:t=>t,easeInQuad:t=>t*t,easeOutQuad:t=>-t*(t-2),easeInOutQuad:t=>(t/=.5)<1?.5*t*t:-.5*(--t*(t-2)-1),easeInCubic:t=>t*t*t,easeOutCubic:t=>(t-=1)*t*t+1,easeInOutCubic:t=>(t/=.5)<1?.5*t*t*t:.5*((t-=2)*t*t+2),easeInQuart:t=>t*t*t*t,easeOutQuart:t=>-((t-=1)*t*t*t-1),easeInOutQuart:t=>(t/=.5)<1?.5*t*t*t*t:-.5*((t-=2)*t*t*t-2),easeInQuint:t=>t*t*t*t*t,easeOutQuint:t=>(t-=1)*t*t*t*t+1,easeInOutQuint:t=>(t/=.5)<1?.5*t*t*t*t*t:.5*((t-=2)*t*t*t*t+2),easeInSine:t=>1-Math.cos(t*E),easeOutSine:t=>Math.sin(t*E),easeInOutSine:t=>-.5*(Math.cos(C*t)-1),easeInExpo:t=>0===t?0:Math.pow(2,10*(t-1)),easeOutExpo:t=>1===t?1:1-Math.pow(2,-10*t),easeInOutExpo:t=>ci(t)?t:t<.5?.5*Math.pow(2,10*(2*t-1)):.5*(2-Math.pow(2,-10*(2*t-1))),easeInCirc:t=>t>=1?t:-(Math.sqrt(1-t*t)-1),easeOutCirc:t=>Math.sqrt(1-(t-=1)*t),easeInOutCirc:t=>(t/=.5)<1?-.5*(Math.sqrt(1-t*t)-1):.5*(Math.sqrt(1-(t-=2)*t)+1),easeInElastic:t=>ci(t)?t:di(t,.075,.3),easeOutElastic:t=>ci(t)?t:ui(t,.075,.3),easeInOutElastic(t){const e=.1125;return ci(t)?t:t<.5?.5*di(2*t,e,.45):.5+.5*ui(2*t-1,e,.45)},easeInBack(t){const e=1.70158;return t*t*((e+1)*t-e)},easeOutBack(t){const e=1.70158;return(t-=1)*t*((e+1)*t+e)+1},easeInOutBack(t){let e=1.70158;return(t/=.5)<1?t*t*((1+(e*=1.525))*t-e)*.5:.5*((t-=2)*t*((1+(e*=1.525))*t+e)+2)},easeInBounce:t=>1-fi.easeOutBounce(1-t),easeOutBounce(t){const e=7.5625,i=2.75;return t<1/i?e*t*t:t<2/i?e*(t-=1.5/i)*t+.75:t<2.5/i?e*(t-=2.25/i)*t+.9375:e*(t-=2.625/i)*t+.984375},easeInOutBounce:t=>t<.5?.5*fi.easeInBounce(2*t):.5*fi.easeOutBounce(2*t-1)+.5};function gi(t,e,i,s){return{x:t.x+i*(e.x-t.x),y:t.y+i*(e.y-t.y)}}function pi(t,e,i,s){return{x:t.x+i*(e.x-t.x),y:"middle"===s?i<.5?t.y:e.y:"after"===s?i<1?t.y:e.y:i>0?e.y:t.y}}function mi(t,e,i,s){const n={x:t.cp2x,y:t.cp2y},o={x:e.cp1x,y:e.cp1y},a=gi(t,n,i),r=gi(n,o,i),l=gi(o,e,i),h=gi(a,r,i),c=gi(r,l,i);return gi(h,c,i)}const xi=/^(normal|(\d+(?:\.\d+)?)(px|em|%)?)$/,bi=/^(normal|italic|initial|inherit|unset|(oblique( -?[0-9]?[0-9]deg)?))$/;function _i(t,e){const i=(""+t).match(xi);if(!i||"normal"===i[1])return 1.2*e;switch(t=+i[2],i[3]){case"px":return t;case"%":t/=100}return e*t}const yi=t=>+t||0;function vi(t,e){const i={},s=o(e),n=s?Object.keys(e):e,a=o(t)?s?i=>l(t[i],t[e[i]]):e=>t[e]:()=>t;for(const t of n)i[t]=yi(a(t));return i}function Mi(t){return vi(t,{top:"y",right:"x",bottom:"y",left:"x"})}function wi(t){return vi(t,["topLeft","topRight","bottomLeft","bottomRight"])}function ki(t){const e=Mi(t);return e.width=e.left+e.right,e.height=e.top+e.bottom,e}function Si(t,e){t=t||{},e=e||ue.font;let i=l(t.size,e.size);"string"==typeof i&&(i=parseInt(i,10));let s=l(t.style,e.style);s&&!(""+s).match(bi)&&(console.warn('Invalid font style specified: "'+s+'"'),s=void 0);const n={family:l(t.family,e.family),lineHeight:_i(l(t.lineHeight,e.lineHeight),i),size:i,style:s,weight:l(t.weight,e.weight),string:""};return n.string=De(n),n}function Pi(t,e,i,s){let o,a,r,l=!0;for(o=0,a=t.length;oi&&0===t?0:t+e;return{min:a(s,-Math.abs(o)),max:a(n,o)}}function Ci(t,e){return Object.assign(Object.create(t),e)}function Oi(t,e,i){return t?function(t,e){return{x:i=>t+t+e-i,setWidth(t){e=t},textAlign:t=>"center"===t?t:"right"===t?"left":"right",xPlus:(t,e)=>t-e,leftForLtr:(t,e)=>t-e}}(e,i):{x:t=>t,setWidth(t){},textAlign:t=>t,xPlus:(t,e)=>t+e,leftForLtr:(t,e)=>t}}function Ai(t,e){let i,s;"ltr"!==e&&"rtl"!==e||(i=t.canvas.style,s=[i.getPropertyValue("direction"),i.getPropertyPriority("direction")],i.setProperty("direction",e,"important"),t.prevTextDirection=s)}function Ti(t,e){void 0!==e&&(delete t.prevTextDirection,t.canvas.style.setProperty("direction",e[0],e[1]))}function Li(t){return"angle"===t?{between:J,compare:K,normalize:G}:{between:tt,compare:(t,e)=>t-e,normalize:t=>t}}function Ei({start:t,end:e,count:i,loop:s,style:n}){return{start:t%i,end:e%i,loop:s&&(e-t+1)%i==0,style:n}}function Ri(t,e,i){if(!i)return[t];const{property:s,start:n,end:o}=i,a=e.length,{compare:r,between:l,normalize:h}=Li(s),{start:c,end:d,loop:u,style:f}=function(t,e,i){const{property:s,start:n,end:o}=i,{between:a,normalize:r}=Li(s),l=e.length;let h,c,{start:d,end:u,loop:f}=t;if(f){for(d+=l,u+=l,h=0,c=l;hb||l(n,x,p)&&0!==r(n,x),v=()=>!b||0===r(o,p)||l(o,x,p);for(let t=c,i=c;t<=d;++t)m=e[t%a],m.skip||(p=h(m[s]),p!==x&&(b=l(p,n,o),null===_&&y()&&(_=0===r(p,n)?t:i),null!==_&&v()&&(g.push(Ei({start:_,end:t,loop:u,count:a,style:f})),_=null),i=t,x=p));return null!==_&&g.push(Ei({start:_,end:d,loop:u,count:a,style:f})),g}function Ii(t,e){const i=[],s=t.segments;for(let n=0;nn&&t[o%e].skip;)o--;return o%=e,{start:n,end:o}}(i,n,o,s);if(!0===s)return Fi(t,[{start:a,end:r,loop:o}],i,e);return Fi(t,function(t,e,i,s){const n=t.length,o=[];let a,r=e,l=t[e];for(a=e+1;a<=i;++a){const i=t[a%n];i.skip||i.stop?l.skip||(s=!1,o.push({start:e%n,end:(a-1)%n,loop:s}),e=r=i.stop?a:null):(r=a,l.skip&&(e=a)),l=i}return null!==r&&o.push({start:e%n,end:r%n,loop:s}),o}(i,a,r!s(t[e.axis])));n.lo-=Math.max(0,a);const r=i.slice(n.hi).findIndex((t=>!s(t[e.axis])));n.hi+=Math.max(0,r)}return n}if(o._sharedOptions){const t=a[0],s="function"==typeof t.getRange&&t.getRange(e);if(s){const t=r(a,e,i-s),n=r(a,e,i+s);return{lo:t.lo,hi:n.hi}}}}return{lo:0,hi:a.length-1}}function $i(t,e,i,s,n){const o=t.getSortedVisibleDatasetMetas(),a=i[e];for(let t=0,i=o.length;t{t[a]&&t[a](e[i],n)&&(o.push({element:t,datasetIndex:s,index:l}),r=r||t.inRange(e.x,e.y,n))})),s&&!r?[]:o}var Ki={evaluateInteractionItems:$i,modes:{index(t,e,i,s){const n=ve(e,t),o=i.axis||"x",a=i.includeInvisible||!1,r=i.intersect?Yi(t,n,o,s,a):Xi(t,n,o,!1,s,a),l=[];return r.length?(t.getSortedVisibleDatasetMetas().forEach((t=>{const e=r[0].index,i=t.data[e];i&&!i.skip&&l.push({element:i,datasetIndex:t.index,index:e})})),l):[]},dataset(t,e,i,s){const n=ve(e,t),o=i.axis||"xy",a=i.includeInvisible||!1;let r=i.intersect?Yi(t,n,o,s,a):Xi(t,n,o,!1,s,a);if(r.length>0){const e=r[0].datasetIndex,i=t.getDatasetMeta(e).data;r=[];for(let t=0;tYi(t,ve(e,t),i.axis||"xy",s,i.includeInvisible||!1),nearest(t,e,i,s){const n=ve(e,t),o=i.axis||"xy",a=i.includeInvisible||!1;return Xi(t,n,o,i.intersect,s,a)},x:(t,e,i,s)=>qi(t,ve(e,t),"x",i.intersect,s),y:(t,e,i,s)=>qi(t,ve(e,t),"y",i.intersect,s)}};const Gi=["left","top","right","bottom"];function Ji(t,e){return t.filter((t=>t.pos===e))}function Zi(t,e){return t.filter((t=>-1===Gi.indexOf(t.pos)&&t.box.axis===e))}function Qi(t,e){return t.sort(((t,i)=>{const s=e?i:t,n=e?t:i;return s.weight===n.weight?s.index-n.index:s.weight-n.weight}))}function ts(t,e){const i=function(t){const e={};for(const i of t){const{stack:t,pos:s,stackWeight:n}=i;if(!t||!Gi.includes(s))continue;const o=e[t]||(e[t]={count:0,placed:0,weight:0,size:0});o.count++,o.weight+=n}return e}(t),{vBoxMaxWidth:s,hBoxMaxHeight:n}=e;let o,a,r;for(o=0,a=t.length;o{s[t]=Math.max(e[t],i[t])})),s}return s(t?["left","right"]:["top","bottom"])}function os(t,e,i,s){const n=[];let o,a,r,l,h,c;for(o=0,a=t.length,h=0;ot.box.fullSize)),!0),s=Qi(Ji(e,"left"),!0),n=Qi(Ji(e,"right")),o=Qi(Ji(e,"top"),!0),a=Qi(Ji(e,"bottom")),r=Zi(e,"x"),l=Zi(e,"y");return{fullSize:i,leftAndTop:s.concat(o),rightAndBottom:n.concat(l).concat(a).concat(r),chartArea:Ji(e,"chartArea"),vertical:s.concat(n).concat(l),horizontal:o.concat(a).concat(r)}}(t.boxes),l=r.vertical,h=r.horizontal;u(t.boxes,(t=>{"function"==typeof t.beforeLayout&&t.beforeLayout()}));const c=l.reduce(((t,e)=>e.box.options&&!1===e.box.options.display?t:t+1),0)||1,d=Object.freeze({outerWidth:e,outerHeight:i,padding:n,availableWidth:o,availableHeight:a,vBoxMaxWidth:o/2/c,hBoxMaxHeight:a/2}),f=Object.assign({},n);is(f,ki(s));const g=Object.assign({maxPadding:f,w:o,h:a,x:n.left,y:n.top},n),p=ts(l.concat(h),d);os(r.fullSize,g,d,p),os(l,g,d,p),os(h,g,d,p)&&os(l,g,d,p),function(t){const e=t.maxPadding;function i(i){const s=Math.max(e[i]-t[i],0);return t[i]+=s,s}t.y+=i("top"),t.x+=i("left"),i("right"),i("bottom")}(g),rs(r.leftAndTop,g,d,p),g.x+=g.w,g.y+=g.h,rs(r.rightAndBottom,g,d,p),t.chartArea={left:g.left,top:g.top,right:g.left+g.w,bottom:g.top+g.h,height:g.h,width:g.w},u(r.chartArea,(e=>{const i=e.box;Object.assign(i,t.chartArea),i.update(g.w,g.h,{left:0,top:0,right:0,bottom:0})}))}};class hs{acquireContext(t,e){}releaseContext(t){return!1}addEventListener(t,e,i){}removeEventListener(t,e,i){}getDevicePixelRatio(){return 1}getMaximumSize(t,e,i,s){return e=Math.max(0,e||t.width),i=i||t.height,{width:e,height:Math.max(0,s?Math.floor(e/s):i)}}isAttached(t){return!0}updateConfig(t){}}class cs extends hs{acquireContext(t){return t&&t.getContext&&t.getContext("2d")||null}updateConfig(t){t.options.animation=!1}}const ds="$chartjs",us={touchstart:"mousedown",touchmove:"mousemove",touchend:"mouseup",pointerenter:"mouseenter",pointerdown:"mousedown",pointermove:"mousemove",pointerup:"mouseup",pointerleave:"mouseout",pointerout:"mouseout"},fs=t=>null===t||""===t;const gs=!!Se&&{passive:!0};function ps(t,e,i){t&&t.canvas&&t.canvas.removeEventListener(e,i,gs)}function ms(t,e){for(const i of t)if(i===e||i.contains(e))return!0}function xs(t,e,i){const s=t.canvas,n=new MutationObserver((t=>{let e=!1;for(const i of t)e=e||ms(i.addedNodes,s),e=e&&!ms(i.removedNodes,s);e&&i()}));return n.observe(document,{childList:!0,subtree:!0}),n}function bs(t,e,i){const s=t.canvas,n=new MutationObserver((t=>{let e=!1;for(const i of t)e=e||ms(i.removedNodes,s),e=e&&!ms(i.addedNodes,s);e&&i()}));return n.observe(document,{childList:!0,subtree:!0}),n}const _s=new Map;let ys=0;function vs(){const t=window.devicePixelRatio;t!==ys&&(ys=t,_s.forEach(((e,i)=>{i.currentDevicePixelRatio!==t&&e()})))}function Ms(t,e,i){const s=t.canvas,n=s&&ge(s);if(!n)return;const o=ct(((t,e)=>{const s=n.clientWidth;i(t,e),s{const e=t[0],i=e.contentRect.width,s=e.contentRect.height;0===i&&0===s||o(i,s)}));return a.observe(n),function(t,e){_s.size||window.addEventListener("resize",vs),_s.set(t,e)}(t,o),a}function ws(t,e,i){i&&i.disconnect(),"resize"===e&&function(t){_s.delete(t),_s.size||window.removeEventListener("resize",vs)}(t)}function ks(t,e,i){const s=t.canvas,n=ct((e=>{null!==t.ctx&&i(function(t,e){const i=us[t.type]||t.type,{x:s,y:n}=ve(t,e);return{type:i,chart:e,native:t,x:void 0!==s?s:null,y:void 0!==n?n:null}}(e,t))}),t);return function(t,e,i){t&&t.addEventListener(e,i,gs)}(s,e,n),n}class Ss extends hs{acquireContext(t,e){const i=t&&t.getContext&&t.getContext("2d");return i&&i.canvas===t?(function(t,e){const i=t.style,s=t.getAttribute("height"),n=t.getAttribute("width");if(t[ds]={initial:{height:s,width:n,style:{display:i.display,height:i.height,width:i.width}}},i.display=i.display||"block",i.boxSizing=i.boxSizing||"border-box",fs(n)){const e=Pe(t,"width");void 0!==e&&(t.width=e)}if(fs(s))if(""===t.style.height)t.height=t.width/(e||2);else{const e=Pe(t,"height");void 0!==e&&(t.height=e)}}(t,e),i):null}releaseContext(t){const e=t.canvas;if(!e[ds])return!1;const i=e[ds].initial;["height","width"].forEach((t=>{const n=i[t];s(n)?e.removeAttribute(t):e.setAttribute(t,n)}));const n=i.style||{};return Object.keys(n).forEach((t=>{e.style[t]=n[t]})),e.width=e.width,delete e[ds],!0}addEventListener(t,e,i){this.removeEventListener(t,e);const s=t.$proxies||(t.$proxies={}),n={attach:xs,detach:bs,resize:Ms}[e]||ks;s[e]=n(t,e,i)}removeEventListener(t,e){const i=t.$proxies||(t.$proxies={}),s=i[e];if(!s)return;({attach:ws,detach:ws,resize:ws}[e]||ps)(t,e,s),i[e]=void 0}getDevicePixelRatio(){return window.devicePixelRatio}getMaximumSize(t,e,i,s){return we(t,e,i,s)}isAttached(t){const e=t&&ge(t);return!(!e||!e.isConnected)}}function Ps(t){return!fe()||"undefined"!=typeof OffscreenCanvas&&t instanceof OffscreenCanvas?cs:Ss}var Ds=Object.freeze({__proto__:null,BasePlatform:hs,BasicPlatform:cs,DomPlatform:Ss,_detectPlatform:Ps});const Cs="transparent",Os={boolean:(t,e,i)=>i>.5?e:t,color(t,e,i){const s=Qt(t||Cs),n=s.valid&&Qt(e||Cs);return n&&n.valid?n.mix(s,i).hexString():e},number:(t,e,i)=>t+(e-t)*i};class As{constructor(t,e,i,s){const n=e[i];s=Pi([t.to,s,n,t.from]);const o=Pi([t.from,n,s]);this._active=!0,this._fn=t.fn||Os[t.type||typeof o],this._easing=fi[t.easing]||fi.linear,this._start=Math.floor(Date.now()+(t.delay||0)),this._duration=this._total=Math.floor(t.duration),this._loop=!!t.loop,this._target=e,this._prop=i,this._from=o,this._to=s,this._promises=void 0}active(){return this._active}update(t,e,i){if(this._active){this._notify(!1);const s=this._target[this._prop],n=i-this._start,o=this._duration-n;this._start=i,this._duration=Math.floor(Math.max(o,t.duration)),this._total+=n,this._loop=!!t.loop,this._to=Pi([t.to,e,s,t.from]),this._from=Pi([t.from,s,e])}}cancel(){this._active&&(this.tick(Date.now()),this._active=!1,this._notify(!1))}tick(t){const e=t-this._start,i=this._duration,s=this._prop,n=this._from,o=this._loop,a=this._to;let r;if(this._active=n!==a&&(o||e1?2-r:r,r=this._easing(Math.min(1,Math.max(0,r))),this._target[s]=this._fn(n,a,r))}wait(){const t=this._promises||(this._promises=[]);return new Promise(((e,i)=>{t.push({res:e,rej:i})}))}_notify(t){const e=t?"res":"rej",i=this._promises||[];for(let t=0;t{const a=t[s];if(!o(a))return;const r={};for(const t of e)r[t]=a[t];(n(a.properties)&&a.properties||[s]).forEach((t=>{t!==s&&i.has(t)||i.set(t,r)}))}))}_animateOptions(t,e){const i=e.options,s=function(t,e){if(!e)return;let i=t.options;if(!i)return void(t.options=e);i.$shared&&(t.options=i=Object.assign({},i,{$shared:!1,$animations:{}}));return i}(t,i);if(!s)return[];const n=this._createAnimations(s,i);return i.$shared&&function(t,e){const i=[],s=Object.keys(e);for(let e=0;e{t.options=i}),(()=>{})),n}_createAnimations(t,e){const i=this._properties,s=[],n=t.$animations||(t.$animations={}),o=Object.keys(e),a=Date.now();let r;for(r=o.length-1;r>=0;--r){const l=o[r];if("$"===l.charAt(0))continue;if("options"===l){s.push(...this._animateOptions(t,e));continue}const h=e[l];let c=n[l];const d=i.get(l);if(c){if(d&&c.active()){c.update(d,h,a);continue}c.cancel()}d&&d.duration?(n[l]=c=new As(d,t,l,h),s.push(c)):t[l]=h}return s}update(t,e){if(0===this._properties.size)return void Object.assign(t,e);const i=this._createAnimations(t,e);return i.length?(bt.add(this._chart,i),!0):void 0}}function Ls(t,e){const i=t&&t.options||{},s=i.reverse,n=void 0===i.min?e:0,o=void 0===i.max?e:0;return{start:s?o:n,end:s?n:o}}function Es(t,e){const i=[],s=t._getSortedDatasetMetas(e);let n,o;for(n=0,o=s.length;n0||!i&&e<0)return n.index}return null}function Vs(t,e){const{chart:i,_cachedMeta:s}=t,n=i._stacks||(i._stacks={}),{iScale:o,vScale:a,index:r}=s,l=o.axis,h=a.axis,c=function(t,e,i){return`${t.id}.${e.id}.${i.stack||i.type}`}(o,a,s),d=e.length;let u;for(let t=0;ti[t].axis===e)).shift()}function Ws(t,e){const i=t.controller.index,s=t.vScale&&t.vScale.axis;if(s){e=e||t._parsed;for(const t of e){const e=t._stacks;if(!e||void 0===e[s]||void 0===e[s][i])return;delete e[s][i],void 0!==e[s]._visualValues&&void 0!==e[s]._visualValues[i]&&delete e[s]._visualValues[i]}}}const Ns=t=>"reset"===t||"none"===t,Hs=(t,e)=>e?t:Object.assign({},t);class js{static defaults={};static datasetElementType=null;static dataElementType=null;constructor(t,e){this.chart=t,this._ctx=t.ctx,this.index=e,this._cachedDataOpts={},this._cachedMeta=this.getMeta(),this._type=this._cachedMeta.type,this.options=void 0,this._parsing=!1,this._data=void 0,this._objectData=void 0,this._sharedOptions=void 0,this._drawStart=void 0,this._drawCount=void 0,this.enableOptionSharing=!1,this.supportsDecimation=!1,this.$context=void 0,this._syncList=[],this.datasetElementType=new.target.datasetElementType,this.dataElementType=new.target.dataElementType,this.initialize()}initialize(){const t=this._cachedMeta;this.configure(),this.linkScales(),t._stacked=Is(t.vScale,t),this.addElements(),this.options.fill&&!this.chart.isPluginEnabled("filler")&&console.warn("Tried to use the 'fill' option without the 'Filler' plugin enabled. Please import and register the 'Filler' plugin and make sure it is not disabled in the options")}updateIndex(t){this.index!==t&&Ws(this._cachedMeta),this.index=t}linkScales(){const t=this.chart,e=this._cachedMeta,i=this.getDataset(),s=(t,e,i,s)=>"x"===t?e:"r"===t?s:i,n=e.xAxisID=l(i.xAxisID,Bs(t,"x")),o=e.yAxisID=l(i.yAxisID,Bs(t,"y")),a=e.rAxisID=l(i.rAxisID,Bs(t,"r")),r=e.indexAxis,h=e.iAxisID=s(r,n,o,a),c=e.vAxisID=s(r,o,n,a);e.xScale=this.getScaleForId(n),e.yScale=this.getScaleForId(o),e.rScale=this.getScaleForId(a),e.iScale=this.getScaleForId(h),e.vScale=this.getScaleForId(c)}getDataset(){return this.chart.data.datasets[this.index]}getMeta(){return this.chart.getDatasetMeta(this.index)}getScaleForId(t){return this.chart.scales[t]}_getOtherScale(t){const e=this._cachedMeta;return t===e.iScale?e.vScale:e.iScale}reset(){this._update("reset")}_destroy(){const t=this._cachedMeta;this._data&&rt(this._data,this),t._stacked&&Ws(t)}_dataCheck(){const t=this.getDataset(),e=t.data||(t.data=[]),i=this._data;if(o(e)){const t=this._cachedMeta;this._data=function(t,e){const{iScale:i,vScale:s}=e,n="x"===i.axis?"x":"y",o="x"===s.axis?"x":"y",a=Object.keys(t),r=new Array(a.length);let l,h,c;for(l=0,h=a.length;l0&&i._parsed[t-1];if(!1===this._parsing)i._parsed=s,i._sorted=!0,d=s;else{d=n(s[t])?this.parseArrayData(i,s,t,e):o(s[t])?this.parseObjectData(i,s,t,e):this.parsePrimitiveData(i,s,t,e);const a=()=>null===c[l]||f&&c[l]t&&!e.hidden&&e._stacked&&{keys:Es(i,!0),values:null})(e,i,this.chart),h={min:Number.POSITIVE_INFINITY,max:Number.NEGATIVE_INFINITY},{min:c,max:d}=function(t){const{min:e,max:i,minDefined:s,maxDefined:n}=t.getUserBounds();return{min:s?e:Number.NEGATIVE_INFINITY,max:n?i:Number.POSITIVE_INFINITY}}(r);let u,f;function g(){f=s[u];const e=f[r.axis];return!a(f[t.axis])||c>e||d=0;--u)if(!g()){this.updateRangeFromParsed(h,t,f,l);break}return h}getAllParsedValues(t){const e=this._cachedMeta._parsed,i=[];let s,n,o;for(s=0,n=e.length;s=0&&tthis.getContext(i,s,e)),c);return f.$shared&&(f.$shared=r,n[o]=Object.freeze(Hs(f,r))),f}_resolveAnimations(t,e,i){const s=this.chart,n=this._cachedDataOpts,o=`animation-${e}`,a=n[o];if(a)return a;let r;if(!1!==s.options.animation){const s=this.chart.config,n=s.datasetAnimationScopeKeys(this._type,e),o=s.getOptionScopes(this.getDataset(),n);r=s.createResolver(o,this.getContext(t,i,e))}const l=new Ts(s,r&&r.animations);return r&&r._cacheable&&(n[o]=Object.freeze(l)),l}getSharedOptions(t){if(t.$shared)return this._sharedOptions||(this._sharedOptions=Object.assign({},t))}includeOptions(t,e){return!e||Ns(t)||this.chart._animationsDisabled}_getSharedOptions(t,e){const i=this.resolveDataElementOptions(t,e),s=this._sharedOptions,n=this.getSharedOptions(i),o=this.includeOptions(e,n)||n!==s;return this.updateSharedOptions(n,e,i),{sharedOptions:n,includeOptions:o}}updateElement(t,e,i,s){Ns(s)?Object.assign(t,i):this._resolveAnimations(e,s).update(t,i)}updateSharedOptions(t,e,i){t&&!Ns(e)&&this._resolveAnimations(void 0,e).update(t,i)}_setStyle(t,e,i,s){t.active=s;const n=this.getStyle(e,s);this._resolveAnimations(e,i,s).update(t,{options:!s&&this.getSharedOptions(n)||n})}removeHoverStyle(t,e,i){this._setStyle(t,i,"active",!1)}setHoverStyle(t,e,i){this._setStyle(t,i,"active",!0)}_removeDatasetHoverStyle(){const t=this._cachedMeta.dataset;t&&this._setStyle(t,void 0,"active",!1)}_setDatasetHoverStyle(){const t=this._cachedMeta.dataset;t&&this._setStyle(t,void 0,"active",!0)}_resyncElements(t){const e=this._data,i=this._cachedMeta.data;for(const[t,e,i]of this._syncList)this[t](e,i);this._syncList=[];const s=i.length,n=e.length,o=Math.min(n,s);o&&this.parse(0,o),n>s?this._insertElements(s,n-s,t):n{for(t.length+=e,a=t.length-1;a>=o;a--)t[a]=t[a-e]};for(r(n),a=t;a{s[t]=i[t]&&i[t].active()?i[t]._to:this[t]})),s}}function Ys(t,e){const i=t.options.ticks,n=function(t){const e=t.options.offset,i=t._tickSize(),s=t._length/i+(e?0:1),n=t._maxLength/i;return Math.floor(Math.min(s,n))}(t),o=Math.min(i.maxTicksLimit||n,n),a=i.major.enabled?function(t){const e=[];let i,s;for(i=0,s=t.length;io)return function(t,e,i,s){let n,o=0,a=i[0];for(s=Math.ceil(s),n=0;nn)return e}return Math.max(n,1)}(a,e,o);if(r>0){let t,i;const n=r>1?Math.round((h-l)/(r-1)):null;for(Us(e,c,d,s(n)?0:l-n,l),t=0,i=r-1;t"top"===e||"left"===e?t[e]+i:t[e]-i,qs=(t,e)=>Math.min(e||t,t);function Ks(t,e){const i=[],s=t.length/e,n=t.length;let o=0;for(;oa+r)))return h}function Js(t){return t.drawTicks?t.tickLength:0}function Zs(t,e){if(!t.display)return 0;const i=Si(t.font,e),s=ki(t.padding);return(n(t.text)?t.text.length:1)*i.lineHeight+s.height}function Qs(t,e,i){let s=ut(t);return(i&&"right"!==e||!i&&"right"===e)&&(s=(t=>"left"===t?"right":"right"===t?"left":t)(s)),s}class tn extends $s{constructor(t){super(),this.id=t.id,this.type=t.type,this.options=void 0,this.ctx=t.ctx,this.chart=t.chart,this.top=void 0,this.bottom=void 0,this.left=void 0,this.right=void 0,this.width=void 0,this.height=void 0,this._margins={left:0,right:0,top:0,bottom:0},this.maxWidth=void 0,this.maxHeight=void 0,this.paddingTop=void 0,this.paddingBottom=void 0,this.paddingLeft=void 0,this.paddingRight=void 0,this.axis=void 0,this.labelRotation=void 0,this.min=void 0,this.max=void 0,this._range=void 0,this.ticks=[],this._gridLineItems=null,this._labelItems=null,this._labelSizes=null,this._length=0,this._maxLength=0,this._longestTextCache={},this._startPixel=void 0,this._endPixel=void 0,this._reversePixels=!1,this._userMax=void 0,this._userMin=void 0,this._suggestedMax=void 0,this._suggestedMin=void 0,this._ticksLength=0,this._borderValue=0,this._cache={},this._dataLimitsCached=!1,this.$context=void 0}init(t){this.options=t.setContext(this.getContext()),this.axis=t.axis,this._userMin=this.parse(t.min),this._userMax=this.parse(t.max),this._suggestedMin=this.parse(t.suggestedMin),this._suggestedMax=this.parse(t.suggestedMax)}parse(t,e){return t}getUserBounds(){let{_userMin:t,_userMax:e,_suggestedMin:i,_suggestedMax:s}=this;return t=r(t,Number.POSITIVE_INFINITY),e=r(e,Number.NEGATIVE_INFINITY),i=r(i,Number.POSITIVE_INFINITY),s=r(s,Number.NEGATIVE_INFINITY),{min:r(t,i),max:r(e,s),minDefined:a(t),maxDefined:a(e)}}getMinMax(t){let e,{min:i,max:s,minDefined:n,maxDefined:o}=this.getUserBounds();if(n&&o)return{min:i,max:s};const a=this.getMatchingVisibleMetas();for(let r=0,l=a.length;rs?s:i,s=n&&i>s?i:s,{min:r(i,r(s,i)),max:r(s,r(i,s))}}getPadding(){return{left:this.paddingLeft||0,top:this.paddingTop||0,right:this.paddingRight||0,bottom:this.paddingBottom||0}}getTicks(){return this.ticks}getLabels(){const t=this.chart.data;return this.options.labels||(this.isHorizontal()?t.xLabels:t.yLabels)||t.labels||[]}getLabelItems(t=this.chart.chartArea){return this._labelItems||(this._labelItems=this._computeLabelItems(t))}beforeLayout(){this._cache={},this._dataLimitsCached=!1}beforeUpdate(){d(this.options.beforeUpdate,[this])}update(t,e,i){const{beginAtZero:s,grace:n,ticks:o}=this.options,a=o.sampleSize;this.beforeUpdate(),this.maxWidth=t,this.maxHeight=e,this._margins=i=Object.assign({left:0,right:0,top:0,bottom:0},i),this.ticks=null,this._labelSizes=null,this._gridLineItems=null,this._labelItems=null,this.beforeSetDimensions(),this.setDimensions(),this.afterSetDimensions(),this._maxLength=this.isHorizontal()?this.width+i.left+i.right:this.height+i.top+i.bottom,this._dataLimitsCached||(this.beforeDataLimits(),this.determineDataLimits(),this.afterDataLimits(),this._range=Di(this,n,s),this._dataLimitsCached=!0),this.beforeBuildTicks(),this.ticks=this.buildTicks()||[],this.afterBuildTicks();const r=a=n||i<=1||!this.isHorizontal())return void(this.labelRotation=s);const h=this._getLabelSizes(),c=h.widest.width,d=h.highest.height,u=Z(this.chart.width-c,0,this.maxWidth);o=t.offset?this.maxWidth/i:u/(i-1),c+6>o&&(o=u/(i-(t.offset?.5:1)),a=this.maxHeight-Js(t.grid)-e.padding-Zs(t.title,this.chart.options.font),r=Math.sqrt(c*c+d*d),l=Y(Math.min(Math.asin(Z((h.highest.height+6)/o,-1,1)),Math.asin(Z(a/r,-1,1))-Math.asin(Z(d/r,-1,1)))),l=Math.max(s,Math.min(n,l))),this.labelRotation=l}afterCalculateLabelRotation(){d(this.options.afterCalculateLabelRotation,[this])}afterAutoSkip(){}beforeFit(){d(this.options.beforeFit,[this])}fit(){const t={width:0,height:0},{chart:e,options:{ticks:i,title:s,grid:n}}=this,o=this._isVisible(),a=this.isHorizontal();if(o){const o=Zs(s,e.options.font);if(a?(t.width=this.maxWidth,t.height=Js(n)+o):(t.height=this.maxHeight,t.width=Js(n)+o),i.display&&this.ticks.length){const{first:e,last:s,widest:n,highest:o}=this._getLabelSizes(),r=2*i.padding,l=$(this.labelRotation),h=Math.cos(l),c=Math.sin(l);if(a){const e=i.mirror?0:c*n.width+h*o.height;t.height=Math.min(this.maxHeight,t.height+e+r)}else{const e=i.mirror?0:h*n.width+c*o.height;t.width=Math.min(this.maxWidth,t.width+e+r)}this._calculatePadding(e,s,c,h)}}this._handleMargins(),a?(this.width=this._length=e.width-this._margins.left-this._margins.right,this.height=t.height):(this.width=t.width,this.height=this._length=e.height-this._margins.top-this._margins.bottom)}_calculatePadding(t,e,i,s){const{ticks:{align:n,padding:o},position:a}=this.options,r=0!==this.labelRotation,l="top"!==a&&"x"===this.axis;if(this.isHorizontal()){const a=this.getPixelForTick(0)-this.left,h=this.right-this.getPixelForTick(this.ticks.length-1);let c=0,d=0;r?l?(c=s*t.width,d=i*e.height):(c=i*t.height,d=s*e.width):"start"===n?d=e.width:"end"===n?c=t.width:"inner"!==n&&(c=t.width/2,d=e.width/2),this.paddingLeft=Math.max((c-a+o)*this.width/(this.width-a),0),this.paddingRight=Math.max((d-h+o)*this.width/(this.width-h),0)}else{let i=e.height/2,s=t.height/2;"start"===n?(i=0,s=t.height):"end"===n&&(i=e.height,s=0),this.paddingTop=i+o,this.paddingBottom=s+o}}_handleMargins(){this._margins&&(this._margins.left=Math.max(this.paddingLeft,this._margins.left),this._margins.top=Math.max(this.paddingTop,this._margins.top),this._margins.right=Math.max(this.paddingRight,this._margins.right),this._margins.bottom=Math.max(this.paddingBottom,this._margins.bottom))}afterFit(){d(this.options.afterFit,[this])}isHorizontal(){const{axis:t,position:e}=this.options;return"top"===e||"bottom"===e||"x"===t}isFullSize(){return this.options.fullSize}_convertTicksToLabels(t){let e,i;for(this.beforeTickToLabelConversion(),this.generateTickLabels(t),e=0,i=t.length;e{const i=t.gc,s=i.length/2;let n;if(s>e){for(n=0;n({width:r[t]||0,height:l[t]||0});return{first:P(0),last:P(e-1),widest:P(k),highest:P(S),widths:r,heights:l}}getLabelForValue(t){return t}getPixelForValue(t,e){return NaN}getValueForPixel(t){}getPixelForTick(t){const e=this.ticks;return t<0||t>e.length-1?null:this.getPixelForValue(e[t].value)}getPixelForDecimal(t){this._reversePixels&&(t=1-t);const e=this._startPixel+t*this._length;return Q(this._alignToPixels?Ae(this.chart,e,0):e)}getDecimalForPixel(t){const e=(t-this._startPixel)/this._length;return this._reversePixels?1-e:e}getBasePixel(){return this.getPixelForValue(this.getBaseValue())}getBaseValue(){const{min:t,max:e}=this;return t<0&&e<0?e:t>0&&e>0?t:0}getContext(t){const e=this.ticks||[];if(t>=0&&ta*s?a/i:r/s:r*s0}_computeGridLineItems(t){const e=this.axis,i=this.chart,s=this.options,{grid:n,position:a,border:r}=s,h=n.offset,c=this.isHorizontal(),d=this.ticks.length+(h?1:0),u=Js(n),f=[],g=r.setContext(this.getContext()),p=g.display?g.width:0,m=p/2,x=function(t){return Ae(i,t,p)};let b,_,y,v,M,w,k,S,P,D,C,O;if("top"===a)b=x(this.bottom),w=this.bottom-u,S=b-m,D=x(t.top)+m,O=t.bottom;else if("bottom"===a)b=x(this.top),D=t.top,O=x(t.bottom)-m,w=b+m,S=this.top+u;else if("left"===a)b=x(this.right),M=this.right-u,k=b-m,P=x(t.left)+m,C=t.right;else if("right"===a)b=x(this.left),P=t.left,C=x(t.right)-m,M=b+m,k=this.left+u;else if("x"===e){if("center"===a)b=x((t.top+t.bottom)/2+.5);else if(o(a)){const t=Object.keys(a)[0],e=a[t];b=x(this.chart.scales[t].getPixelForValue(e))}D=t.top,O=t.bottom,w=b+m,S=w+u}else if("y"===e){if("center"===a)b=x((t.left+t.right)/2);else if(o(a)){const t=Object.keys(a)[0],e=a[t];b=x(this.chart.scales[t].getPixelForValue(e))}M=b-m,k=M-u,P=t.left,C=t.right}const A=l(s.ticks.maxTicksLimit,d),T=Math.max(1,Math.ceil(d/A));for(_=0;_0&&(o-=s/2)}d={left:o,top:n,width:s+e.width,height:i+e.height,color:t.backdropColor}}x.push({label:v,font:P,textOffset:O,options:{rotation:m,color:i,strokeColor:o,strokeWidth:h,textAlign:f,textBaseline:A,translation:[M,w],backdrop:d}})}return x}_getXAxisLabelAlignment(){const{position:t,ticks:e}=this.options;if(-$(this.labelRotation))return"top"===t?"left":"right";let i="center";return"start"===e.align?i="left":"end"===e.align?i="right":"inner"===e.align&&(i="inner"),i}_getYAxisLabelAlignment(t){const{position:e,ticks:{crossAlign:i,mirror:s,padding:n}}=this.options,o=t+n,a=this._getLabelSizes().widest.width;let r,l;return"left"===e?s?(l=this.right+n,"near"===i?r="left":"center"===i?(r="center",l+=a/2):(r="right",l+=a)):(l=this.right-o,"near"===i?r="right":"center"===i?(r="center",l-=a/2):(r="left",l=this.left)):"right"===e?s?(l=this.left+n,"near"===i?r="right":"center"===i?(r="center",l-=a/2):(r="left",l-=a)):(l=this.left+o,"near"===i?r="left":"center"===i?(r="center",l+=a/2):(r="right",l=this.right)):r="right",{textAlign:r,x:l}}_computeLabelArea(){if(this.options.ticks.mirror)return;const t=this.chart,e=this.options.position;return"left"===e||"right"===e?{top:0,left:this.left,bottom:t.height,right:this.right}:"top"===e||"bottom"===e?{top:this.top,left:0,bottom:this.bottom,right:t.width}:void 0}drawBackground(){const{ctx:t,options:{backgroundColor:e},left:i,top:s,width:n,height:o}=this;e&&(t.save(),t.fillStyle=e,t.fillRect(i,s,n,o),t.restore())}getLineWidthForValue(t){const e=this.options.grid;if(!this._isVisible()||!e.display)return 0;const i=this.ticks.findIndex((e=>e.value===t));if(i>=0){return e.setContext(this.getContext(i)).lineWidth}return 0}drawGrid(t){const e=this.options.grid,i=this.ctx,s=this._gridLineItems||(this._gridLineItems=this._computeGridLineItems(t));let n,o;const a=(t,e,s)=>{s.width&&s.color&&(i.save(),i.lineWidth=s.width,i.strokeStyle=s.color,i.setLineDash(s.borderDash||[]),i.lineDashOffset=s.borderDashOffset,i.beginPath(),i.moveTo(t.x,t.y),i.lineTo(e.x,e.y),i.stroke(),i.restore())};if(e.display)for(n=0,o=s.length;n{this.drawBackground(),this.drawGrid(t),this.drawTitle()}},{z:s,draw:()=>{this.drawBorder()}},{z:e,draw:t=>{this.drawLabels(t)}}]:[{z:e,draw:t=>{this.draw(t)}}]}getMatchingVisibleMetas(t){const e=this.chart.getSortedVisibleDatasetMetas(),i=this.axis+"AxisID",s=[];let n,o;for(n=0,o=e.length;n{const s=i.split("."),n=s.pop(),o=[t].concat(s).join("."),a=e[i].split("."),r=a.pop(),l=a.join(".");ue.route(o,n,l,r)}))}(e,t.defaultRoutes);t.descriptors&&ue.describe(e,t.descriptors)}(t,o,i),this.override&&ue.override(t.id,t.overrides)),o}get(t){return this.items[t]}unregister(t){const e=this.items,i=t.id,s=this.scope;i in e&&delete e[i],s&&i in ue[s]&&(delete ue[s][i],this.override&&delete re[i])}}class sn{constructor(){this.controllers=new en(js,"datasets",!0),this.elements=new en($s,"elements"),this.plugins=new en(Object,"plugins"),this.scales=new en(tn,"scales"),this._typedRegistries=[this.controllers,this.scales,this.elements]}add(...t){this._each("register",t)}remove(...t){this._each("unregister",t)}addControllers(...t){this._each("register",t,this.controllers)}addElements(...t){this._each("register",t,this.elements)}addPlugins(...t){this._each("register",t,this.plugins)}addScales(...t){this._each("register",t,this.scales)}getController(t){return this._get(t,this.controllers,"controller")}getElement(t){return this._get(t,this.elements,"element")}getPlugin(t){return this._get(t,this.plugins,"plugin")}getScale(t){return this._get(t,this.scales,"scale")}removeControllers(...t){this._each("unregister",t,this.controllers)}removeElements(...t){this._each("unregister",t,this.elements)}removePlugins(...t){this._each("unregister",t,this.plugins)}removeScales(...t){this._each("unregister",t,this.scales)}_each(t,e,i){[...e].forEach((e=>{const s=i||this._getRegistryForType(e);i||s.isForType(e)||s===this.plugins&&e.id?this._exec(t,s,e):u(e,(e=>{const s=i||this._getRegistryForType(e);this._exec(t,s,e)}))}))}_exec(t,e,i){const s=w(t);d(i["before"+s],[],i),e[t](i),d(i["after"+s],[],i)}_getRegistryForType(t){for(let e=0;et.filter((t=>!e.some((e=>t.plugin.id===e.plugin.id))));this._notify(s(e,i),t,"stop"),this._notify(s(i,e),t,"start")}}function an(t,e){return e||!1!==t?!0===t?{}:t:null}function rn(t,{plugin:e,local:i},s,n){const o=t.pluginScopeKeys(e),a=t.getOptionScopes(s,o);return i&&e.defaults&&a.push(e.defaults),t.createResolver(a,n,[""],{scriptable:!1,indexable:!1,allKeys:!0})}function ln(t,e){const i=ue.datasets[t]||{};return((e.datasets||{})[t]||{}).indexAxis||e.indexAxis||i.indexAxis||"x"}function hn(t){if("x"===t||"y"===t||"r"===t)return t}function cn(t,...e){if(hn(t))return t;for(const s of e){const e=s.axis||("top"===(i=s.position)||"bottom"===i?"x":"left"===i||"right"===i?"y":void 0)||t.length>1&&hn(t[0].toLowerCase());if(e)return e}var i;throw new Error(`Cannot determine type of '${t}' axis. Please provide 'axis' or 'position' option.`)}function dn(t,e,i){if(i[e+"AxisID"]===t)return{axis:e}}function un(t,e){const i=re[t.type]||{scales:{}},s=e.scales||{},n=ln(t.type,e),a=Object.create(null);return Object.keys(s).forEach((e=>{const r=s[e];if(!o(r))return console.error(`Invalid scale configuration for scale: ${e}`);if(r._proxy)return console.warn(`Ignoring resolver passed as options for scale: ${e}`);const l=cn(e,r,function(t,e){if(e.data&&e.data.datasets){const i=e.data.datasets.filter((e=>e.xAxisID===t||e.yAxisID===t));if(i.length)return dn(t,"x",i[0])||dn(t,"y",i[0])}return{}}(e,t),ue.scales[r.type]),h=function(t,e){return t===e?"_index_":"_value_"}(l,n),c=i.scales||{};a[e]=b(Object.create(null),[{axis:l},r,c[l],c[h]])})),t.data.datasets.forEach((i=>{const n=i.type||t.type,o=i.indexAxis||ln(n,e),r=(re[n]||{}).scales||{};Object.keys(r).forEach((t=>{const e=function(t,e){let i=t;return"_index_"===t?i=e:"_value_"===t&&(i="x"===e?"y":"x"),i}(t,o),n=i[e+"AxisID"]||e;a[n]=a[n]||Object.create(null),b(a[n],[{axis:e},s[n],r[t]])}))})),Object.keys(a).forEach((t=>{const e=a[t];b(e,[ue.scales[e.type],ue.scale])})),a}function fn(t){const e=t.options||(t.options={});e.plugins=l(e.plugins,{}),e.scales=un(t,e)}function gn(t){return(t=t||{}).datasets=t.datasets||[],t.labels=t.labels||[],t}const pn=new Map,mn=new Set;function xn(t,e){let i=pn.get(t);return i||(i=e(),pn.set(t,i),mn.add(i)),i}const bn=(t,e,i)=>{const s=M(e,i);void 0!==s&&t.add(s)};class _n{constructor(t){this._config=function(t){return(t=t||{}).data=gn(t.data),fn(t),t}(t),this._scopeCache=new Map,this._resolverCache=new Map}get platform(){return this._config.platform}get type(){return this._config.type}set type(t){this._config.type=t}get data(){return this._config.data}set data(t){this._config.data=gn(t)}get options(){return this._config.options}set options(t){this._config.options=t}get plugins(){return this._config.plugins}update(){const t=this._config;this.clearCache(),fn(t)}clearCache(){this._scopeCache.clear(),this._resolverCache.clear()}datasetScopeKeys(t){return xn(t,(()=>[[`datasets.${t}`,""]]))}datasetAnimationScopeKeys(t,e){return xn(`${t}.transition.${e}`,(()=>[[`datasets.${t}.transitions.${e}`,`transitions.${e}`],[`datasets.${t}`,""]]))}datasetElementScopeKeys(t,e){return xn(`${t}-${e}`,(()=>[[`datasets.${t}.elements.${e}`,`datasets.${t}`,`elements.${e}`,""]]))}pluginScopeKeys(t){const e=t.id;return xn(`${this.type}-plugin-${e}`,(()=>[[`plugins.${e}`,...t.additionalOptionScopes||[]]]))}_cachedScopes(t,e){const i=this._scopeCache;let s=i.get(t);return s&&!e||(s=new Map,i.set(t,s)),s}getOptionScopes(t,e,i){const{options:s,type:n}=this,o=this._cachedScopes(t,i),a=o.get(e);if(a)return a;const r=new Set;e.forEach((e=>{t&&(r.add(t),e.forEach((e=>bn(r,t,e)))),e.forEach((t=>bn(r,s,t))),e.forEach((t=>bn(r,re[n]||{},t))),e.forEach((t=>bn(r,ue,t))),e.forEach((t=>bn(r,le,t)))}));const l=Array.from(r);return 0===l.length&&l.push(Object.create(null)),mn.has(e)&&o.set(e,l),l}chartOptionScopes(){const{options:t,type:e}=this;return[t,re[e]||{},ue.datasets[e]||{},{type:e},ue,le]}resolveNamedOptions(t,e,i,s=[""]){const o={$shared:!0},{resolver:a,subPrefixes:r}=yn(this._resolverCache,t,s);let l=a;if(function(t,e){const{isScriptable:i,isIndexable:s}=Ye(t);for(const o of e){const e=i(o),a=s(o),r=(a||e)&&t[o];if(e&&(S(r)||vn(r))||a&&n(r))return!0}return!1}(a,e)){o.$shared=!1;l=$e(a,i=S(i)?i():i,this.createResolver(t,i,r))}for(const t of e)o[t]=l[t];return o}createResolver(t,e,i=[""],s){const{resolver:n}=yn(this._resolverCache,t,i);return o(e)?$e(n,e,void 0,s):n}}function yn(t,e,i){let s=t.get(e);s||(s=new Map,t.set(e,s));const n=i.join();let o=s.get(n);if(!o){o={resolver:je(e,i),subPrefixes:i.filter((t=>!t.toLowerCase().includes("hover")))},s.set(n,o)}return o}const vn=t=>o(t)&&Object.getOwnPropertyNames(t).some((e=>S(t[e])));const Mn=["top","bottom","left","right","chartArea"];function wn(t,e){return"top"===t||"bottom"===t||-1===Mn.indexOf(t)&&"x"===e}function kn(t,e){return function(i,s){return i[t]===s[t]?i[e]-s[e]:i[t]-s[t]}}function Sn(t){const e=t.chart,i=e.options.animation;e.notifyPlugins("afterRender"),d(i&&i.onComplete,[t],e)}function Pn(t){const e=t.chart,i=e.options.animation;d(i&&i.onProgress,[t],e)}function Dn(t){return fe()&&"string"==typeof t?t=document.getElementById(t):t&&t.length&&(t=t[0]),t&&t.canvas&&(t=t.canvas),t}const Cn={},On=t=>{const e=Dn(t);return Object.values(Cn).filter((t=>t.canvas===e)).pop()};function An(t,e,i){const s=Object.keys(t);for(const n of s){const s=+n;if(s>=e){const o=t[n];delete t[n],(i>0||s>e)&&(t[s+i]=o)}}}class Tn{static defaults=ue;static instances=Cn;static overrides=re;static registry=nn;static version="4.5.1";static getChart=On;static register(...t){nn.add(...t),Ln()}static unregister(...t){nn.remove(...t),Ln()}constructor(t,e){const s=this.config=new _n(e),n=Dn(t),o=On(n);if(o)throw new Error("Canvas is already in use. Chart with ID '"+o.id+"' must be destroyed before the canvas with ID '"+o.canvas.id+"' can be reused.");const a=s.createResolver(s.chartOptionScopes(),this.getContext());this.platform=new(s.platform||Ps(n)),this.platform.updateConfig(s);const r=this.platform.acquireContext(n,a.aspectRatio),l=r&&r.canvas,h=l&&l.height,c=l&&l.width;this.id=i(),this.ctx=r,this.canvas=l,this.width=c,this.height=h,this._options=a,this._aspectRatio=this.aspectRatio,this._layers=[],this._metasets=[],this._stacks=void 0,this.boxes=[],this.currentDevicePixelRatio=void 0,this.chartArea=void 0,this._active=[],this._lastEvent=void 0,this._listeners={},this._responsiveListeners=void 0,this._sortedMetasets=[],this.scales={},this._plugins=new on,this.$proxies={},this._hiddenIndices={},this.attached=!1,this._animationsDisabled=void 0,this.$context=void 0,this._doResize=dt((t=>this.update(t)),a.resizeDelay||0),this._dataChanges=[],Cn[this.id]=this,r&&l?(bt.listen(this,"complete",Sn),bt.listen(this,"progress",Pn),this._initialize(),this.attached&&this.update()):console.error("Failed to create chart: can't acquire context from the given item")}get aspectRatio(){const{options:{aspectRatio:t,maintainAspectRatio:e},width:i,height:n,_aspectRatio:o}=this;return s(t)?e&&o?o:n?i/n:null:t}get data(){return this.config.data}set data(t){this.config.data=t}get options(){return this._options}set options(t){this.config.options=t}get registry(){return nn}_initialize(){return this.notifyPlugins("beforeInit"),this.options.responsive?this.resize():ke(this,this.options.devicePixelRatio),this.bindEvents(),this.notifyPlugins("afterInit"),this}clear(){return Te(this.canvas,this.ctx),this}stop(){return bt.stop(this),this}resize(t,e){bt.running(this)?this._resizeBeforeDraw={width:t,height:e}:this._resize(t,e)}_resize(t,e){const i=this.options,s=this.canvas,n=i.maintainAspectRatio&&this.aspectRatio,o=this.platform.getMaximumSize(s,t,e,n),a=i.devicePixelRatio||this.platform.getDevicePixelRatio(),r=this.width?"resize":"attach";this.width=o.width,this.height=o.height,this._aspectRatio=this.aspectRatio,ke(this,a,!0)&&(this.notifyPlugins("resize",{size:o}),d(i.onResize,[this,o],this),this.attached&&this._doResize(r)&&this.render())}ensureScalesHaveIDs(){u(this.options.scales||{},((t,e)=>{t.id=e}))}buildOrUpdateScales(){const t=this.options,e=t.scales,i=this.scales,s=Object.keys(i).reduce(((t,e)=>(t[e]=!1,t)),{});let n=[];e&&(n=n.concat(Object.keys(e).map((t=>{const i=e[t],s=cn(t,i),n="r"===s,o="x"===s;return{options:i,dposition:n?"chartArea":o?"bottom":"left",dtype:n?"radialLinear":o?"category":"linear"}})))),u(n,(e=>{const n=e.options,o=n.id,a=cn(o,n),r=l(n.type,e.dtype);void 0!==n.position&&wn(n.position,a)===wn(e.dposition)||(n.position=e.dposition),s[o]=!0;let h=null;if(o in i&&i[o].type===r)h=i[o];else{h=new(nn.getScale(r))({id:o,type:r,ctx:this.ctx,chart:this}),i[h.id]=h}h.init(n,t)})),u(s,((t,e)=>{t||delete i[e]})),u(i,(t=>{ls.configure(this,t,t.options),ls.addBox(this,t)}))}_updateMetasets(){const t=this._metasets,e=this.data.datasets.length,i=t.length;if(t.sort(((t,e)=>t.index-e.index)),i>e){for(let t=e;te.length&&delete this._stacks,t.forEach(((t,i)=>{0===e.filter((e=>e===t._dataset)).length&&this._destroyDatasetMeta(i)}))}buildOrUpdateControllers(){const t=[],e=this.data.datasets;let i,s;for(this._removeUnreferencedMetasets(),i=0,s=e.length;i{this.getDatasetMeta(e).controller.reset()}),this)}reset(){this._resetElements(),this.notifyPlugins("reset")}update(t){const e=this.config;e.update();const i=this._options=e.createResolver(e.chartOptionScopes(),this.getContext()),s=this._animationsDisabled=!i.animation;if(this._updateScales(),this._checkEventBindings(),this._updateHiddenIndices(),this._plugins.invalidate(),!1===this.notifyPlugins("beforeUpdate",{mode:t,cancelable:!0}))return;const n=this.buildOrUpdateControllers();this.notifyPlugins("beforeElementsUpdate");let o=0;for(let t=0,e=this.data.datasets.length;t{t.reset()})),this._updateDatasets(t),this.notifyPlugins("afterUpdate",{mode:t}),this._layers.sort(kn("z","_idx"));const{_active:a,_lastEvent:r}=this;r?this._eventHandler(r,!0):a.length&&this._updateHoverStyles(a,a,!0),this.render()}_updateScales(){u(this.scales,(t=>{ls.removeBox(this,t)})),this.ensureScalesHaveIDs(),this.buildOrUpdateScales()}_checkEventBindings(){const t=this.options,e=new Set(Object.keys(this._listeners)),i=new Set(t.events);P(e,i)&&!!this._responsiveListeners===t.responsive||(this.unbindEvents(),this.bindEvents())}_updateHiddenIndices(){const{_hiddenIndices:t}=this,e=this._getUniformDataChanges()||[];for(const{method:i,start:s,count:n}of e){An(t,s,"_removeElements"===i?-n:n)}}_getUniformDataChanges(){const t=this._dataChanges;if(!t||!t.length)return;this._dataChanges=[];const e=this.data.datasets.length,i=e=>new Set(t.filter((t=>t[0]===e)).map(((t,e)=>e+","+t.splice(1).join(",")))),s=i(0);for(let t=1;tt.split(","))).map((t=>({method:t[1],start:+t[2],count:+t[3]})))}_updateLayout(t){if(!1===this.notifyPlugins("beforeLayout",{cancelable:!0}))return;ls.update(this,this.width,this.height,t);const e=this.chartArea,i=e.width<=0||e.height<=0;this._layers=[],u(this.boxes,(t=>{i&&"chartArea"===t.position||(t.configure&&t.configure(),this._layers.push(...t._layers()))}),this),this._layers.forEach(((t,e)=>{t._idx=e})),this.notifyPlugins("afterLayout")}_updateDatasets(t){if(!1!==this.notifyPlugins("beforeDatasetsUpdate",{mode:t,cancelable:!0})){for(let t=0,e=this.data.datasets.length;t=0;--e)this._drawDataset(t[e]);this.notifyPlugins("afterDatasetsDraw")}_drawDataset(t){const e=this.ctx,i={meta:t,index:t.index,cancelable:!0},s=Ni(this,t);!1!==this.notifyPlugins("beforeDatasetDraw",i)&&(s&&Ie(e,s),t.controller.draw(),s&&ze(e),i.cancelable=!1,this.notifyPlugins("afterDatasetDraw",i))}isPointInArea(t){return Re(t,this.chartArea,this._minPadding)}getElementsAtEventForMode(t,e,i,s){const n=Ki.modes[e];return"function"==typeof n?n(this,t,i,s):[]}getDatasetMeta(t){const e=this.data.datasets[t],i=this._metasets;let s=i.filter((t=>t&&t._dataset===e)).pop();return s||(s={type:null,data:[],dataset:null,controller:null,hidden:null,xAxisID:null,yAxisID:null,order:e&&e.order||0,index:t,_dataset:e,_parsed:[],_sorted:!1},i.push(s)),s}getContext(){return this.$context||(this.$context=Ci(null,{chart:this,type:"chart"}))}getVisibleDatasetCount(){return this.getSortedVisibleDatasetMetas().length}isDatasetVisible(t){const e=this.data.datasets[t];if(!e)return!1;const i=this.getDatasetMeta(t);return"boolean"==typeof i.hidden?!i.hidden:!e.hidden}setDatasetVisibility(t,e){this.getDatasetMeta(t).hidden=!e}toggleDataVisibility(t){this._hiddenIndices[t]=!this._hiddenIndices[t]}getDataVisibility(t){return!this._hiddenIndices[t]}_updateVisibility(t,e,i){const s=i?"show":"hide",n=this.getDatasetMeta(t),o=n.controller._resolveAnimations(void 0,s);k(e)?(n.data[e].hidden=!i,this.update()):(this.setDatasetVisibility(t,i),o.update(n,{visible:i}),this.update((e=>e.datasetIndex===t?s:void 0)))}hide(t,e){this._updateVisibility(t,e,!1)}show(t,e){this._updateVisibility(t,e,!0)}_destroyDatasetMeta(t){const e=this._metasets[t];e&&e.controller&&e.controller._destroy(),delete this._metasets[t]}_stop(){let t,e;for(this.stop(),bt.remove(this),t=0,e=this.data.datasets.length;t{e.addEventListener(this,i,s),t[i]=s},s=(t,e,i)=>{t.offsetX=e,t.offsetY=i,this._eventHandler(t)};u(this.options.events,(t=>i(t,s)))}bindResponsiveEvents(){this._responsiveListeners||(this._responsiveListeners={});const t=this._responsiveListeners,e=this.platform,i=(i,s)=>{e.addEventListener(this,i,s),t[i]=s},s=(i,s)=>{t[i]&&(e.removeEventListener(this,i,s),delete t[i])},n=(t,e)=>{this.canvas&&this.resize(t,e)};let o;const a=()=>{s("attach",a),this.attached=!0,this.resize(),i("resize",n),i("detach",o)};o=()=>{this.attached=!1,s("resize",n),this._stop(),this._resize(0,0),i("attach",a)},e.isAttached(this.canvas)?a():o()}unbindEvents(){u(this._listeners,((t,e)=>{this.platform.removeEventListener(this,e,t)})),this._listeners={},u(this._responsiveListeners,((t,e)=>{this.platform.removeEventListener(this,e,t)})),this._responsiveListeners=void 0}updateHoverStyle(t,e,i){const s=i?"set":"remove";let n,o,a,r;for("dataset"===e&&(n=this.getDatasetMeta(t[0].datasetIndex),n.controller["_"+s+"DatasetHoverStyle"]()),a=0,r=t.length;a{const i=this.getDatasetMeta(t);if(!i)throw new Error("No dataset found at index "+t);return{datasetIndex:t,element:i.data[e],index:e}}));!f(i,e)&&(this._active=i,this._lastEvent=null,this._updateHoverStyles(i,e))}notifyPlugins(t,e,i){return this._plugins.notify(this,t,e,i)}isPluginEnabled(t){return 1===this._plugins._cache.filter((e=>e.plugin.id===t)).length}_updateHoverStyles(t,e,i){const s=this.options.hover,n=(t,e)=>t.filter((t=>!e.some((e=>t.datasetIndex===e.datasetIndex&&t.index===e.index)))),o=n(e,t),a=i?t:n(t,e);o.length&&this.updateHoverStyle(o,s.mode,!1),a.length&&s.mode&&this.updateHoverStyle(a,s.mode,!0)}_eventHandler(t,e){const i={event:t,replay:e,cancelable:!0,inChartArea:this.isPointInArea(t)},s=e=>(e.options.events||this.options.events).includes(t.native.type);if(!1===this.notifyPlugins("beforeEvent",i,s))return;const n=this._handleEvent(t,e,i.inChartArea);return i.cancelable=!1,this.notifyPlugins("afterEvent",i,s),(n||i.changed)&&this.render(),this}_handleEvent(t,e,i){const{_active:s=[],options:n}=this,o=e,a=this._getActiveElements(t,s,i,o),r=D(t),l=function(t,e,i,s){return i&&"mouseout"!==t.type?s?e:t:null}(t,this._lastEvent,i,r);i&&(this._lastEvent=null,d(n.onHover,[t,a,this],this),r&&d(n.onClick,[t,a,this],this));const h=!f(a,s);return(h||e)&&(this._active=a,this._updateHoverStyles(a,s,e)),this._lastEvent=l,h}_getActiveElements(t,e,i,s){if("mouseout"===t.type)return[];if(!i)return e;const n=this.options.hover;return this.getElementsAtEventForMode(t,n.mode,n,s)}}function Ln(){return u(Tn.instances,(t=>t._plugins.invalidate()))}function En(){throw new Error("This method is not implemented: Check that a complete date adapter is provided.")}class Rn{static override(t){Object.assign(Rn.prototype,t)}options;constructor(t){this.options=t||{}}init(){}formats(){return En()}parse(){return En()}format(){return En()}add(){return En()}diff(){return En()}startOf(){return En()}endOf(){return En()}}var In={_date:Rn};function zn(t){const e=t.iScale,i=function(t,e){if(!t._cache.$bar){const i=t.getMatchingVisibleMetas(e);let s=[];for(let e=0,n=i.length;et-e)))}return t._cache.$bar}(e,t.type);let s,n,o,a,r=e._length;const l=()=>{32767!==o&&-32768!==o&&(k(a)&&(r=Math.min(r,Math.abs(o-a)||r)),a=o)};for(s=0,n=i.length;sMath.abs(r)&&(l=r,h=a),e[i.axis]=h,e._custom={barStart:l,barEnd:h,start:n,end:o,min:a,max:r}}(t,e,i,s):e[i.axis]=i.parse(t,s),e}function Vn(t,e,i,s){const n=t.iScale,o=t.vScale,a=n.getLabels(),r=n===o,l=[];let h,c,d,u;for(h=i,c=i+s;ht.x,i="left",s="right"):(e=t.base"spacing"!==t,_indexable:t=>"spacing"!==t&&!t.startsWith("borderDash")&&!t.startsWith("hoverBorderDash")};static overrides={aspectRatio:1,plugins:{legend:{labels:{generateLabels(t){const e=t.data,{labels:{pointStyle:i,textAlign:s,color:n,useBorderRadius:o,borderRadius:a}}=t.legend.options;return e.labels.length&&e.datasets.length?e.labels.map(((e,r)=>{const l=t.getDatasetMeta(0).controller.getStyle(r);return{text:e,fillStyle:l.backgroundColor,fontColor:n,hidden:!t.getDataVisibility(r),lineDash:l.borderDash,lineDashOffset:l.borderDashOffset,lineJoin:l.borderJoinStyle,lineWidth:l.borderWidth,strokeStyle:l.borderColor,textAlign:s,pointStyle:i,borderRadius:o&&(a||l.borderRadius),index:r}})):[]}},onClick(t,e,i){i.chart.toggleDataVisibility(e.index),i.chart.update()}}}};constructor(t,e){super(t,e),this.enableOptionSharing=!0,this.innerRadius=void 0,this.outerRadius=void 0,this.offsetX=void 0,this.offsetY=void 0}linkScales(){}parse(t,e){const i=this.getDataset().data,s=this._cachedMeta;if(!1===this._parsing)s._parsed=i;else{let n,a,r=t=>+i[t];if(o(i[t])){const{key:t="value"}=this._parsing;r=e=>+M(i[e],t)}for(n=t,a=t+e;nJ(t,r,l,!0)?1:Math.max(e,e*i,s,s*i),g=(t,e,s)=>J(t,r,l,!0)?-1:Math.min(e,e*i,s,s*i),p=f(0,h,d),m=f(E,c,u),x=g(C,h,d),b=g(C+E,c,u);s=(p-x)/2,n=(m-b)/2,o=-(p+x)/2,a=-(m+b)/2}return{ratioX:s,ratioY:n,offsetX:o,offsetY:a}}(u,d,r),x=(i.width-o)/f,b=(i.height-o)/g,_=Math.max(Math.min(x,b)/2,0),y=c(this.options.radius,_),v=(y-Math.max(y*r,0))/this._getVisibleDatasetWeightTotal();this.offsetX=p*y,this.offsetY=m*y,s.total=this.calculateTotal(),this.outerRadius=y-v*this._getRingWeightOffset(this.index),this.innerRadius=Math.max(this.outerRadius-v*l,0),this.updateElements(n,0,n.length,t)}_circumference(t,e){const i=this.options,s=this._cachedMeta,n=this._getCircumference();return e&&i.animation.animateRotate||!this.chart.getDataVisibility(t)||null===s._parsed[t]||s.data[t].hidden?0:this.calculateCircumference(s._parsed[t]*n/O)}updateElements(t,e,i,s){const n="reset"===s,o=this.chart,a=o.chartArea,r=o.options.animation,l=(a.left+a.right)/2,h=(a.top+a.bottom)/2,c=n&&r.animateScale,d=c?0:this.innerRadius,u=c?0:this.outerRadius,{sharedOptions:f,includeOptions:g}=this._getSharedOptions(e,s);let p,m=this._getRotation();for(p=0;p0&&!isNaN(t)?O*(Math.abs(t)/e):0}getLabelAndValue(t){const e=this._cachedMeta,i=this.chart,s=i.data.labels||[],n=ne(e._parsed[t],i.options.locale);return{label:s[t]||"",value:n}}getMaxBorderWidth(t){let e=0;const i=this.chart;let s,n,o,a,r;if(!t)for(s=0,n=i.data.datasets.length;s{const o=t.getDatasetMeta(0).controller.getStyle(n);return{text:e,fillStyle:o.backgroundColor,strokeStyle:o.borderColor,fontColor:s,lineWidth:o.borderWidth,pointStyle:i,hidden:!t.getDataVisibility(n),index:n}}))}return[]}},onClick(t,e,i){i.chart.toggleDataVisibility(e.index),i.chart.update()}}},scales:{r:{type:"radialLinear",angleLines:{display:!1},beginAtZero:!0,grid:{circular:!0},pointLabels:{display:!1},startAngle:0}}};constructor(t,e){super(t,e),this.innerRadius=void 0,this.outerRadius=void 0}getLabelAndValue(t){const e=this._cachedMeta,i=this.chart,s=i.data.labels||[],n=ne(e._parsed[t].r,i.options.locale);return{label:s[t]||"",value:n}}parseObjectData(t,e,i,s){return ii.bind(this)(t,e,i,s)}update(t){const e=this._cachedMeta.data;this._updateRadius(),this.updateElements(e,0,e.length,t)}getMinMax(){const t=this._cachedMeta,e={min:Number.POSITIVE_INFINITY,max:Number.NEGATIVE_INFINITY};return t.data.forEach(((t,i)=>{const s=this.getParsed(i).r;!isNaN(s)&&this.chart.getDataVisibility(i)&&(se.max&&(e.max=s))})),e}_updateRadius(){const t=this.chart,e=t.chartArea,i=t.options,s=Math.min(e.right-e.left,e.bottom-e.top),n=Math.max(s/2,0),o=(n-Math.max(i.cutoutPercentage?n/100*i.cutoutPercentage:1,0))/t.getVisibleDatasetCount();this.outerRadius=n-o*this.index,this.innerRadius=this.outerRadius-o}updateElements(t,e,i,s){const n="reset"===s,o=this.chart,a=o.options.animation,r=this._cachedMeta.rScale,l=r.xCenter,h=r.yCenter,c=r.getIndexAngle(0)-.5*C;let d,u=c;const f=360/this.countVisibleElements();for(d=0;d{!isNaN(this.getParsed(i).r)&&this.chart.getDataVisibility(i)&&e++})),e}_computeAngle(t,e,i){return this.chart.getDataVisibility(t)?$(this.resolveDataElementOptions(t,e).angle||i):0}}var Un=Object.freeze({__proto__:null,BarController:class extends js{static id="bar";static defaults={datasetElementType:!1,dataElementType:"bar",categoryPercentage:.8,barPercentage:.9,grouped:!0,animations:{numbers:{type:"number",properties:["x","y","base","width","height"]}}};static overrides={scales:{_index_:{type:"category",offset:!0,grid:{offset:!0}},_value_:{type:"linear",beginAtZero:!0}}};parsePrimitiveData(t,e,i,s){return Vn(t,e,i,s)}parseArrayData(t,e,i,s){return Vn(t,e,i,s)}parseObjectData(t,e,i,s){const{iScale:n,vScale:o}=t,{xAxisKey:a="x",yAxisKey:r="y"}=this._parsing,l="x"===n.axis?a:r,h="x"===o.axis?a:r,c=[];let d,u,f,g;for(d=i,u=i+s;dt.controller.options.grouped)),o=i.options.stacked,a=[],r=this._cachedMeta.controller.getParsed(e),l=r&&r[i.axis],h=t=>{const e=t._parsed.find((t=>t[i.axis]===l)),n=e&&e[t.vScale.axis];if(s(n)||isNaN(n))return!0};for(const i of n)if((void 0===e||!h(i))&&((!1===o||-1===a.indexOf(i.stack)||void 0===o&&void 0===i.stack)&&a.push(i.stack),i.index===t))break;return a.length||a.push(void 0),a}_getStackCount(t){return this._getStacks(void 0,t).length}_getAxisCount(){return this._getAxis().length}getFirstScaleIdForIndexAxis(){const t=this.chart.scales,e=this.chart.options.indexAxis;return Object.keys(t).filter((i=>t[i].axis===e)).shift()}_getAxis(){const t={},e=this.getFirstScaleIdForIndexAxis();for(const i of this.chart.data.datasets)t[l("x"===this.chart.options.indexAxis?i.xAxisID:i.yAxisID,e)]=!0;return Object.keys(t)}_getStackIndex(t,e,i){const s=this._getStacks(t,i),n=void 0!==e?s.indexOf(e):-1;return-1===n?s.length-1:n}_getRuler(){const t=this.options,e=this._cachedMeta,i=e.iScale,s=[];let n,o;for(n=0,o=e.data.length;n=i?1:-1)}(u,e,r)*a,f===r&&(x-=u/2);const t=e.getPixelForDecimal(0),s=e.getPixelForDecimal(1),o=Math.min(t,s),h=Math.max(t,s);x=Math.max(Math.min(x,h),o),d=x+u,i&&!c&&(l._stacks[e.axis]._visualValues[n]=e.getValueForPixel(d)-e.getValueForPixel(x))}if(x===e.getPixelForValue(r)){const t=F(u)*e.getLineWidthForValue(r)/2;x+=t,u-=t}return{size:u,base:x,head:d,center:d+u/2}}_calculateBarIndexPixels(t,e){const i=e.scale,n=this.options,o=n.skipNull,a=l(n.maxBarThickness,1/0);let r,h;const c=this._getAxisCount();if(e.grouped){const i=o?this._getStackCount(t):e.stackCount,d="flex"===n.barThickness?function(t,e,i,s){const n=e.pixels,o=n[t];let a=t>0?n[t-1]:null,r=t=0;--i)e=Math.max(e,t[i].size(this.resolveDataElementOptions(i))/2);return e>0&&e}getLabelAndValue(t){const e=this._cachedMeta,i=this.chart.data.labels||[],{xScale:s,yScale:n}=e,o=this.getParsed(t),a=s.getLabelForValue(o.x),r=n.getLabelForValue(o.y),l=o._custom;return{label:i[t]||"",value:"("+a+", "+r+(l?", "+l:"")+")"}}update(t){const e=this._cachedMeta.data;this.updateElements(e,0,e.length,t)}updateElements(t,e,i,s){const n="reset"===s,{iScale:o,vScale:a}=this._cachedMeta,{sharedOptions:r,includeOptions:l}=this._getSharedOptions(e,s),h=o.axis,c=a.axis;for(let d=e;d0&&this.getParsed(e-1);for(let i=0;i<_;++i){const g=t[i],_=x?g:{};if(i=b){_.skip=!0;continue}const v=this.getParsed(i),M=s(v[f]),w=_[u]=a.getPixelForValue(v[u],i),k=_[f]=o||M?r.getBasePixel():r.getPixelForValue(l?this.applyStack(r,v,l):v[f],i);_.skip=isNaN(w)||isNaN(k)||M,_.stop=i>0&&Math.abs(v[u]-y[u])>m,p&&(_.parsed=v,_.raw=h.data[i]),d&&(_.options=c||this.resolveDataElementOptions(i,g.active?"active":n)),x||this.updateElement(g,i,_,n),y=v}}getMaxOverflow(){const t=this._cachedMeta,e=t.dataset,i=e.options&&e.options.borderWidth||0,s=t.data||[];if(!s.length)return i;const n=s[0].size(this.resolveDataElementOptions(0)),o=s[s.length-1].size(this.resolveDataElementOptions(s.length-1));return Math.max(i,n,o)/2}draw(){const t=this._cachedMeta;t.dataset.updateControlPoints(this.chart.chartArea,t.iScale.axis),super.draw()}},PieController:class extends $n{static id="pie";static defaults={cutout:0,rotation:0,circumference:360,radius:"100%"}},PolarAreaController:Yn,RadarController:class extends js{static id="radar";static defaults={datasetElementType:"line",dataElementType:"point",indexAxis:"r",showLine:!0,elements:{line:{fill:"start"}}};static overrides={aspectRatio:1,scales:{r:{type:"radialLinear"}}};getLabelAndValue(t){const e=this._cachedMeta.vScale,i=this.getParsed(t);return{label:e.getLabels()[t],value:""+e.getLabelForValue(i[e.axis])}}parseObjectData(t,e,i,s){return ii.bind(this)(t,e,i,s)}update(t){const e=this._cachedMeta,i=e.dataset,s=e.data||[],n=e.iScale.getLabels();if(i.points=s,"resize"!==t){const e=this.resolveDatasetElementOptions(t);this.options.showLine||(e.borderWidth=0);const o={_loop:!0,_fullLoop:n.length===s.length,options:e};this.updateElement(i,void 0,o,t)}this.updateElements(s,0,s.length,t)}updateElements(t,e,i,s){const n=this._cachedMeta.rScale,o="reset"===s;for(let a=e;a0&&this.getParsed(e-1);for(let c=e;c0&&Math.abs(i[f]-_[f])>x,m&&(p.parsed=i,p.raw=h.data[c]),u&&(p.options=d||this.resolveDataElementOptions(c,e.active?"active":n)),b||this.updateElement(e,c,p,n),_=i}this.updateSharedOptions(d,n,c)}getMaxOverflow(){const t=this._cachedMeta,e=t.data||[];if(!this.options.showLine){let t=0;for(let i=e.length-1;i>=0;--i)t=Math.max(t,e[i].size(this.resolveDataElementOptions(i))/2);return t>0&&t}const i=t.dataset,s=i.options&&i.options.borderWidth||0;if(!e.length)return s;const n=e[0].size(this.resolveDataElementOptions(0)),o=e[e.length-1].size(this.resolveDataElementOptions(e.length-1));return Math.max(s,n,o)/2}}});function Xn(t,e,i,s){const n=vi(t.options.borderRadius,["outerStart","outerEnd","innerStart","innerEnd"]);const o=(i-e)/2,a=Math.min(o,s*e/2),r=t=>{const e=(i-Math.min(o,t))*s/2;return Z(t,0,Math.min(o,e))};return{outerStart:r(n.outerStart),outerEnd:r(n.outerEnd),innerStart:Z(n.innerStart,0,a),innerEnd:Z(n.innerEnd,0,a)}}function qn(t,e,i,s){return{x:i+t*Math.cos(e),y:s+t*Math.sin(e)}}function Kn(t,e,i,s,n,o){const{x:a,y:r,startAngle:l,pixelMargin:h,innerRadius:c}=e,d=Math.max(e.outerRadius+s+i-h,0),u=c>0?c+s+i+h:0;let f=0;const g=n-l;if(s){const t=((c>0?c-s:0)+(d>0?d-s:0))/2;f=(g-(0!==t?g*t/(t+s):g))/2}const p=(g-Math.max(.001,g*d-i/C)/d)/2,m=l+p+f,x=n-p-f,{outerStart:b,outerEnd:_,innerStart:y,innerEnd:v}=Xn(e,u,d,x-m),M=d-b,w=d-_,k=m+b/M,S=x-_/w,P=u+y,D=u+v,O=m+y/P,A=x-v/D;if(t.beginPath(),o){const e=(k+S)/2;if(t.arc(a,r,d,k,e),t.arc(a,r,d,e,S),_>0){const e=qn(w,S,a,r);t.arc(e.x,e.y,_,S,x+E)}const i=qn(D,x,a,r);if(t.lineTo(i.x,i.y),v>0){const e=qn(D,A,a,r);t.arc(e.x,e.y,v,x+E,A+Math.PI)}const s=(x-v/u+(m+y/u))/2;if(t.arc(a,r,u,x-v/u,s,!0),t.arc(a,r,u,s,m+y/u,!0),y>0){const e=qn(P,O,a,r);t.arc(e.x,e.y,y,O+Math.PI,m-E)}const n=qn(M,m,a,r);if(t.lineTo(n.x,n.y),b>0){const e=qn(M,k,a,r);t.arc(e.x,e.y,b,m-E,k)}}else{t.moveTo(a,r);const e=Math.cos(k)*d+a,i=Math.sin(k)*d+r;t.lineTo(e,i);const s=Math.cos(S)*d+a,n=Math.sin(S)*d+r;t.lineTo(s,n)}t.closePath()}function Gn(t,e,i,s,n){const{fullCircles:o,startAngle:a,circumference:r,options:l}=e,{borderWidth:h,borderJoinStyle:c,borderDash:d,borderDashOffset:u,borderRadius:f}=l,g="inner"===l.borderAlign;if(!h)return;t.setLineDash(d||[]),t.lineDashOffset=u,g?(t.lineWidth=2*h,t.lineJoin=c||"round"):(t.lineWidth=h,t.lineJoin=c||"bevel");let p=e.endAngle;if(o){Kn(t,e,i,s,p,n);for(let e=0;en?(h=n/l,t.arc(o,a,l,i+h,s-h,!0)):t.arc(o,a,n,i+E,s-E),t.closePath(),t.clip()}(t,e,p),l.selfJoin&&p-a>=C&&0===f&&"miter"!==c&&function(t,e,i){const{startAngle:s,x:n,y:o,outerRadius:a,innerRadius:r,options:l}=e,{borderWidth:h,borderJoinStyle:c}=l,d=Math.min(h/a,G(s-i));if(t.beginPath(),t.arc(n,o,a-h/2,s+d/2,i-d/2),r>0){const e=Math.min(h/r,G(s-i));t.arc(n,o,r+h/2,i-e/2,s+e/2,!0)}else{const e=Math.min(h/2,a*G(s-i));if("round"===c)t.arc(n,o,e,i-C/2,s+C/2,!0);else if("bevel"===c){const a=2*e*e,r=-a*Math.cos(i+C/2)+n,l=-a*Math.sin(i+C/2)+o,h=a*Math.cos(s+C/2)+n,c=a*Math.sin(s+C/2)+o;t.lineTo(r,l),t.lineTo(h,c)}}t.closePath(),t.moveTo(0,0),t.rect(0,0,t.canvas.width,t.canvas.height),t.clip("evenodd")}(t,e,p),o||(Kn(t,e,i,s,p,n),t.stroke())}function Jn(t,e,i=e){t.lineCap=l(i.borderCapStyle,e.borderCapStyle),t.setLineDash(l(i.borderDash,e.borderDash)),t.lineDashOffset=l(i.borderDashOffset,e.borderDashOffset),t.lineJoin=l(i.borderJoinStyle,e.borderJoinStyle),t.lineWidth=l(i.borderWidth,e.borderWidth),t.strokeStyle=l(i.borderColor,e.borderColor)}function Zn(t,e,i){t.lineTo(i.x,i.y)}function Qn(t,e,i={}){const s=t.length,{start:n=0,end:o=s-1}=i,{start:a,end:r}=e,l=Math.max(n,a),h=Math.min(o,r),c=nr&&o>r;return{count:s,start:l,loop:e.loop,ilen:h(a+(h?r-t:t))%o,_=()=>{f!==g&&(t.lineTo(m,g),t.lineTo(m,f),t.lineTo(m,p))};for(l&&(d=n[b(0)],t.moveTo(d.x,d.y)),c=0;c<=r;++c){if(d=n[b(c)],d.skip)continue;const e=d.x,i=d.y,s=0|e;s===u?(ig&&(g=i),m=(x*m+e)/++x):(_(),t.lineTo(e,i),u=s,x=0,f=g=i),p=i}_()}function io(t){const e=t.options,i=e.borderDash&&e.borderDash.length;return!(t._decimated||t._loop||e.tension||"monotone"===e.cubicInterpolationMode||e.stepped||i)?eo:to}const so="function"==typeof Path2D;function no(t,e,i,s){so&&!e.options.segment?function(t,e,i,s){let n=e._path;n||(n=e._path=new Path2D,e.path(n,i,s)&&n.closePath()),Jn(t,e.options),t.stroke(n)}(t,e,i,s):function(t,e,i,s){const{segments:n,options:o}=e,a=io(e);for(const r of n)Jn(t,o,r.style),t.beginPath(),a(t,e,r,{start:i,end:i+s-1})&&t.closePath(),t.stroke()}(t,e,i,s)}class oo extends $s{static id="line";static defaults={borderCapStyle:"butt",borderDash:[],borderDashOffset:0,borderJoinStyle:"miter",borderWidth:3,capBezierPoints:!0,cubicInterpolationMode:"default",fill:!1,spanGaps:!1,stepped:!1,tension:0};static defaultRoutes={backgroundColor:"backgroundColor",borderColor:"borderColor"};static descriptors={_scriptable:!0,_indexable:t=>"borderDash"!==t&&"fill"!==t};constructor(t){super(),this.animated=!0,this.options=void 0,this._chart=void 0,this._loop=void 0,this._fullLoop=void 0,this._path=void 0,this._points=void 0,this._segments=void 0,this._decimated=!1,this._pointsUpdated=!1,this._datasetIndex=void 0,t&&Object.assign(this,t)}updateControlPoints(t,e){const i=this.options;if((i.tension||"monotone"===i.cubicInterpolationMode)&&!i.stepped&&!this._pointsUpdated){const s=i.spanGaps?this._loop:this._fullLoop;hi(this._points,i,t,s,e),this._pointsUpdated=!0}}set points(t){this._points=t,delete this._segments,delete this._path,this._pointsUpdated=!1}get points(){return this._points}get segments(){return this._segments||(this._segments=zi(this,this.options.segment))}first(){const t=this.segments,e=this.points;return t.length&&e[t[0].start]}last(){const t=this.segments,e=this.points,i=t.length;return i&&e[t[i-1].end]}interpolate(t,e){const i=this.options,s=t[e],n=this.points,o=Ii(this,{property:e,start:s,end:s});if(!o.length)return;const a=[],r=function(t){return t.stepped?pi:t.tension||"monotone"===t.cubicInterpolationMode?mi:gi}(i);let l,h;for(l=0,h=o.length;l"borderDash"!==t};circumference;endAngle;fullCircles;innerRadius;outerRadius;pixelMargin;startAngle;constructor(t){super(),this.options=void 0,this.circumference=void 0,this.startAngle=void 0,this.endAngle=void 0,this.innerRadius=void 0,this.outerRadius=void 0,this.pixelMargin=0,this.fullCircles=0,t&&Object.assign(this,t)}inRange(t,e,i){const s=this.getProps(["x","y"],i),{angle:n,distance:o}=X(s,{x:t,y:e}),{startAngle:a,endAngle:r,innerRadius:h,outerRadius:c,circumference:d}=this.getProps(["startAngle","endAngle","innerRadius","outerRadius","circumference"],i),u=(this.options.spacing+this.options.borderWidth)/2,f=l(d,r-a),g=J(n,a,r)&&a!==r,p=f>=O||g,m=tt(o,h+u,c+u);return p&&m}getCenterPoint(t){const{x:e,y:i,startAngle:s,endAngle:n,innerRadius:o,outerRadius:a}=this.getProps(["x","y","startAngle","endAngle","innerRadius","outerRadius"],t),{offset:r,spacing:l}=this.options,h=(s+n)/2,c=(o+a+l+r)/2;return{x:e+Math.cos(h)*c,y:i+Math.sin(h)*c}}tooltipPosition(t){return this.getCenterPoint(t)}draw(t){const{options:e,circumference:i}=this,s=(e.offset||0)/4,n=(e.spacing||0)/2,o=e.circular;if(this.pixelMargin="inner"===e.borderAlign?.33:0,this.fullCircles=i>O?Math.floor(i/O):0,0===i||this.innerRadius<0||this.outerRadius<0)return;t.save();const a=(this.startAngle+this.endAngle)/2;t.translate(Math.cos(a)*s,Math.sin(a)*s);const r=s*(1-Math.sin(Math.min(C,i||0)));t.fillStyle=e.backgroundColor,t.strokeStyle=e.borderColor,function(t,e,i,s,n){const{fullCircles:o,startAngle:a,circumference:r}=e;let l=e.endAngle;if(o){Kn(t,e,i,s,l,n);for(let e=0;e("string"==typeof e?(i=t.push(e)-1,s.unshift({index:i,label:e})):isNaN(e)&&(i=null),i))(t,e,i,s);return n!==t.lastIndexOf(e)?i:n}function mo(t){const e=this.getLabels();return t>=0&&ts=e?s:t,a=t=>n=i?n:t;if(t){const t=F(s),e=F(n);t<0&&e<0?a(0):t>0&&e>0&&o(0)}if(s===n){let e=0===n?1:Math.abs(.05*n);a(n+e),t||o(s-e)}this.min=s,this.max=n}getTickLimit(){const t=this.options.ticks;let e,{maxTicksLimit:i,stepSize:s}=t;return s?(e=Math.ceil(this.max/s)-Math.floor(this.min/s)+1,e>1e3&&(console.warn(`scales.${this.id}.ticks.stepSize: ${s} would result generating up to ${e} ticks. Limiting to 1000.`),e=1e3)):(e=this.computeTickLimit(),i=i||11),i&&(e=Math.min(i,e)),e}computeTickLimit(){return Number.POSITIVE_INFINITY}buildTicks(){const t=this.options,e=t.ticks;let i=this.getTickLimit();i=Math.max(2,i);const n=function(t,e){const i=[],{bounds:n,step:o,min:a,max:r,precision:l,count:h,maxTicks:c,maxDigits:d,includeBounds:u}=t,f=o||1,g=c-1,{min:p,max:m}=e,x=!s(a),b=!s(r),_=!s(h),y=(m-p)/(d+1);let v,M,w,k,S=B((m-p)/g/f)*f;if(S<1e-14&&!x&&!b)return[{value:p},{value:m}];k=Math.ceil(m/S)-Math.floor(p/S),k>g&&(S=B(k*S/g/f)*f),s(l)||(v=Math.pow(10,l),S=Math.ceil(S*v)/v),"ticks"===n?(M=Math.floor(p/S)*S,w=Math.ceil(m/S)*S):(M=p,w=m),x&&b&&o&&H((r-a)/o,S/1e3)?(k=Math.round(Math.min((r-a)/S,c)),S=(r-a)/k,M=a,w=r):_?(M=x?a:M,w=b?r:w,k=h-1,S=(w-M)/k):(k=(w-M)/S,k=V(k,Math.round(k),S/1e3)?Math.round(k):Math.ceil(k));const P=Math.max(U(S),U(M));v=Math.pow(10,s(l)?P:l),M=Math.round(M*v)/v,w=Math.round(w*v)/v;let D=0;for(x&&(u&&M!==a?(i.push({value:a}),Mr)break;i.push({value:t})}return b&&u&&w!==r?i.length&&V(i[i.length-1].value,r,xo(r,y,t))?i[i.length-1].value=r:i.push({value:r}):b&&w!==r||i.push({value:w}),i}({maxTicks:i,bounds:t.bounds,min:t.min,max:t.max,precision:e.precision,step:e.stepSize,count:e.count,maxDigits:this._maxDigits(),horizontal:this.isHorizontal(),minRotation:e.minRotation||0,includeBounds:!1!==e.includeBounds},this._range||this);return"ticks"===t.bounds&&j(n,this,"value"),t.reverse?(n.reverse(),this.start=this.max,this.end=this.min):(this.start=this.min,this.end=this.max),n}configure(){const t=this.ticks;let e=this.min,i=this.max;if(super.configure(),this.options.offset&&t.length){const s=(i-e)/Math.max(t.length-1,1)/2;e-=s,i+=s}this._startValue=e,this._endValue=i,this._valueRange=i-e}getLabelForValue(t){return ne(t,this.chart.options.locale,this.options.ticks.format)}}class _o extends bo{static id="linear";static defaults={ticks:{callback:ae.formatters.numeric}};determineDataLimits(){const{min:t,max:e}=this.getMinMax(!0);this.min=a(t)?t:0,this.max=a(e)?e:1,this.handleTickRangeOptions()}computeTickLimit(){const t=this.isHorizontal(),e=t?this.width:this.height,i=$(this.options.ticks.minRotation),s=(t?Math.sin(i):Math.cos(i))||.001,n=this._resolveTickFontOptions(0);return Math.ceil(e/Math.min(40,n.lineHeight/s))}getPixelForValue(t){return null===t?NaN:this.getPixelForDecimal((t-this._startValue)/this._valueRange)}getValueForPixel(t){return this._startValue+this.getDecimalForPixel(t)*this._valueRange}}const yo=t=>Math.floor(z(t)),vo=(t,e)=>Math.pow(10,yo(t)+e);function Mo(t){return 1===t/Math.pow(10,yo(t))}function wo(t,e,i){const s=Math.pow(10,i),n=Math.floor(t/s);return Math.ceil(e/s)-n}function ko(t,{min:e,max:i}){e=r(t.min,e);const s=[],n=yo(e);let o=function(t,e){let i=yo(e-t);for(;wo(t,e,i)>10;)i++;for(;wo(t,e,i)<10;)i--;return Math.min(i,yo(t))}(e,i),a=o<0?Math.pow(10,Math.abs(o)):1;const l=Math.pow(10,o),h=n>o?Math.pow(10,n):0,c=Math.round((e-h)*a)/a,d=Math.floor((e-h)/l/10)*l*10;let u=Math.floor((c-d)/Math.pow(10,o)),f=r(t.min,Math.round((h+d+u*Math.pow(10,o))*a)/a);for(;f=10?u=u<15?15:20:u++,u>=20&&(o++,u=2,a=o>=0?1:a),f=Math.round((h+d+u*Math.pow(10,o))*a)/a;const g=r(t.max,f);return s.push({value:g,major:Mo(g),significand:u}),s}class So extends tn{static id="logarithmic";static defaults={ticks:{callback:ae.formatters.logarithmic,major:{enabled:!0}}};constructor(t){super(t),this.start=void 0,this.end=void 0,this._startValue=void 0,this._valueRange=0}parse(t,e){const i=bo.prototype.parse.apply(this,[t,e]);if(0!==i)return a(i)&&i>0?i:null;this._zero=!0}determineDataLimits(){const{min:t,max:e}=this.getMinMax(!0);this.min=a(t)?Math.max(0,t):null,this.max=a(e)?Math.max(0,e):null,this.options.beginAtZero&&(this._zero=!0),this._zero&&this.min!==this._suggestedMin&&!a(this._userMin)&&(this.min=t===vo(this.min,0)?vo(this.min,-1):vo(this.min,0)),this.handleTickRangeOptions()}handleTickRangeOptions(){const{minDefined:t,maxDefined:e}=this.getUserBounds();let i=this.min,s=this.max;const n=e=>i=t?i:e,o=t=>s=e?s:t;i===s&&(i<=0?(n(1),o(10)):(n(vo(i,-1)),o(vo(s,1)))),i<=0&&n(vo(s,-1)),s<=0&&o(vo(i,1)),this.min=i,this.max=s}buildTicks(){const t=this.options,e=ko({min:this._userMin,max:this._userMax},this);return"ticks"===t.bounds&&j(e,this,"value"),t.reverse?(e.reverse(),this.start=this.max,this.end=this.min):(this.start=this.min,this.end=this.max),e}getLabelForValue(t){return void 0===t?"0":ne(t,this.chart.options.locale,this.options.ticks.format)}configure(){const t=this.min;super.configure(),this._startValue=z(t),this._valueRange=z(this.max)-z(t)}getPixelForValue(t){return void 0!==t&&0!==t||(t=this.min),null===t||isNaN(t)?NaN:this.getPixelForDecimal(t===this.min?0:(z(t)-this._startValue)/this._valueRange)}getValueForPixel(t){const e=this.getDecimalForPixel(t);return Math.pow(10,this._startValue+e*this._valueRange)}}function Po(t){const e=t.ticks;if(e.display&&t.display){const t=ki(e.backdropPadding);return l(e.font&&e.font.size,ue.font.size)+t.height}return 0}function Do(t,e,i,s,n){return t===s||t===n?{start:e-i/2,end:e+i/2}:tn?{start:e-i,end:e}:{start:e,end:e+i}}function Co(t){const e={l:t.left+t._padding.left,r:t.right-t._padding.right,t:t.top+t._padding.top,b:t.bottom-t._padding.bottom},i=Object.assign({},e),s=[],o=[],a=t._pointLabels.length,r=t.options.pointLabels,l=r.centerPointLabels?C/a:0;for(let u=0;ue.r&&(r=(s.end-e.r)/o,t.r=Math.max(t.r,e.r+r)),n.starte.b&&(l=(n.end-e.b)/a,t.b=Math.max(t.b,e.b+l))}function Ao(t,e,i){const s=t.drawingArea,{extra:n,additionalAngle:o,padding:a,size:r}=i,l=t.getPointPosition(e,s+n+a,o),h=Math.round(Y(G(l.angle+E))),c=function(t,e,i){90===i||270===i?t-=e/2:(i>270||i<90)&&(t-=e);return t}(l.y,r.h,h),d=function(t){if(0===t||180===t)return"center";if(t<180)return"left";return"right"}(h),u=function(t,e,i){"right"===i?t-=e:"center"===i&&(t-=e/2);return t}(l.x,r.w,d);return{visible:!0,x:l.x,y:c,textAlign:d,left:u,top:c,right:u+r.w,bottom:c+r.h}}function To(t,e){if(!e)return!0;const{left:i,top:s,right:n,bottom:o}=t;return!(Re({x:i,y:s},e)||Re({x:i,y:o},e)||Re({x:n,y:s},e)||Re({x:n,y:o},e))}function Lo(t,e,i){const{left:n,top:o,right:a,bottom:r}=i,{backdropColor:l}=e;if(!s(l)){const i=wi(e.borderRadius),s=ki(e.backdropPadding);t.fillStyle=l;const h=n-s.left,c=o-s.top,d=a-n+s.width,u=r-o+s.height;Object.values(i).some((t=>0!==t))?(t.beginPath(),He(t,{x:h,y:c,w:d,h:u,radius:i}),t.fill()):t.fillRect(h,c,d,u)}}function Eo(t,e,i,s){const{ctx:n}=t;if(i)n.arc(t.xCenter,t.yCenter,e,0,O);else{let i=t.getPointPosition(0,e);n.moveTo(i.x,i.y);for(let o=1;ot,padding:5,centerPointLabels:!1}};static defaultRoutes={"angleLines.color":"borderColor","pointLabels.color":"color","ticks.color":"color"};static descriptors={angleLines:{_fallback:"grid"}};constructor(t){super(t),this.xCenter=void 0,this.yCenter=void 0,this.drawingArea=void 0,this._pointLabels=[],this._pointLabelItems=[]}setDimensions(){const t=this._padding=ki(Po(this.options)/2),e=this.width=this.maxWidth-t.width,i=this.height=this.maxHeight-t.height;this.xCenter=Math.floor(this.left+e/2+t.left),this.yCenter=Math.floor(this.top+i/2+t.top),this.drawingArea=Math.floor(Math.min(e,i)/2)}determineDataLimits(){const{min:t,max:e}=this.getMinMax(!1);this.min=a(t)&&!isNaN(t)?t:0,this.max=a(e)&&!isNaN(e)?e:0,this.handleTickRangeOptions()}computeTickLimit(){return Math.ceil(this.drawingArea/Po(this.options))}generateTickLabels(t){bo.prototype.generateTickLabels.call(this,t),this._pointLabels=this.getLabels().map(((t,e)=>{const i=d(this.options.pointLabels.callback,[t,e],this);return i||0===i?i:""})).filter(((t,e)=>this.chart.getDataVisibility(e)))}fit(){const t=this.options;t.display&&t.pointLabels.display?Co(this):this.setCenterPoint(0,0,0,0)}setCenterPoint(t,e,i,s){this.xCenter+=Math.floor((t-e)/2),this.yCenter+=Math.floor((i-s)/2),this.drawingArea-=Math.min(this.drawingArea/2,Math.max(t,e,i,s))}getIndexAngle(t){return G(t*(O/(this._pointLabels.length||1))+$(this.options.startAngle||0))}getDistanceFromCenterForValue(t){if(s(t))return NaN;const e=this.drawingArea/(this.max-this.min);return this.options.reverse?(this.max-t)*e:(t-this.min)*e}getValueForDistanceFromCenter(t){if(s(t))return NaN;const e=t/(this.drawingArea/(this.max-this.min));return this.options.reverse?this.max-e:this.min+e}getPointLabelContext(t){const e=this._pointLabels||[];if(t>=0&&t=0;n--){const e=t._pointLabelItems[n];if(!e.visible)continue;const o=s.setContext(t.getPointLabelContext(n));Lo(i,o,e);const a=Si(o.font),{x:r,y:l,textAlign:h}=e;Ne(i,t._pointLabels[n],r,l+a.lineHeight/2,a,{color:o.color,textAlign:h,textBaseline:"middle"})}}(this,o),s.display&&this.ticks.forEach(((t,e)=>{if(0!==e||0===e&&this.min<0){r=this.getDistanceFromCenterForValue(t.value);const i=this.getContext(e),a=s.setContext(i),l=n.setContext(i);!function(t,e,i,s,n){const o=t.ctx,a=e.circular,{color:r,lineWidth:l}=e;!a&&!s||!r||!l||i<0||(o.save(),o.strokeStyle=r,o.lineWidth=l,o.setLineDash(n.dash||[]),o.lineDashOffset=n.dashOffset,o.beginPath(),Eo(t,i,a,s),o.closePath(),o.stroke(),o.restore())}(this,a,r,o,l)}})),i.display){for(t.save(),a=o-1;a>=0;a--){const s=i.setContext(this.getPointLabelContext(a)),{color:n,lineWidth:o}=s;o&&n&&(t.lineWidth=o,t.strokeStyle=n,t.setLineDash(s.borderDash),t.lineDashOffset=s.borderDashOffset,r=this.getDistanceFromCenterForValue(e.reverse?this.min:this.max),l=this.getPointPosition(a,r),t.beginPath(),t.moveTo(this.xCenter,this.yCenter),t.lineTo(l.x,l.y),t.stroke())}t.restore()}}drawBorder(){}drawLabels(){const t=this.ctx,e=this.options,i=e.ticks;if(!i.display)return;const s=this.getIndexAngle(0);let n,o;t.save(),t.translate(this.xCenter,this.yCenter),t.rotate(s),t.textAlign="center",t.textBaseline="middle",this.ticks.forEach(((s,a)=>{if(0===a&&this.min>=0&&!e.reverse)return;const r=i.setContext(this.getContext(a)),l=Si(r.font);if(n=this.getDistanceFromCenterForValue(this.ticks[a].value),r.showLabelBackdrop){t.font=l.string,o=t.measureText(s.label).width,t.fillStyle=r.backdropColor;const e=ki(r.backdropPadding);t.fillRect(-o/2-e.left,-n-l.size/2-e.top,o+e.width,l.size+e.height)}Ne(t,s.label,0,-n,l,{color:r.color,strokeColor:r.textStrokeColor,strokeWidth:r.textStrokeWidth})})),t.restore()}drawTitle(){}}const Io={millisecond:{common:!0,size:1,steps:1e3},second:{common:!0,size:1e3,steps:60},minute:{common:!0,size:6e4,steps:60},hour:{common:!0,size:36e5,steps:24},day:{common:!0,size:864e5,steps:30},week:{common:!1,size:6048e5,steps:4},month:{common:!0,size:2628e6,steps:12},quarter:{common:!1,size:7884e6,steps:4},year:{common:!0,size:3154e7}},zo=Object.keys(Io);function Fo(t,e){return t-e}function Vo(t,e){if(s(e))return null;const i=t._adapter,{parser:n,round:o,isoWeekday:r}=t._parseOpts;let l=e;return"function"==typeof n&&(l=n(l)),a(l)||(l="string"==typeof n?i.parse(l,n):i.parse(l)),null===l?null:(o&&(l="week"!==o||!N(r)&&!0!==r?i.startOf(l,o):i.startOf(l,"isoWeek",r)),+l)}function Bo(t,e,i,s){const n=zo.length;for(let o=zo.indexOf(t);o=e?i[s]:i[n]]=!0}}else t[e]=!0}function No(t,e,i){const s=[],n={},o=e.length;let a,r;for(a=0;a=0&&(e[l].major=!0);return e}(t,s,n,i):s}class Ho extends tn{static id="time";static defaults={bounds:"data",adapters:{},time:{parser:!1,unit:!1,round:!1,isoWeekday:!1,minUnit:"millisecond",displayFormats:{}},ticks:{source:"auto",callback:!1,major:{enabled:!1}}};constructor(t){super(t),this._cache={data:[],labels:[],all:[]},this._unit="day",this._majorUnit=void 0,this._offsets={},this._normalized=!1,this._parseOpts=void 0}init(t,e={}){const i=t.time||(t.time={}),s=this._adapter=new In._date(t.adapters.date);s.init(e),b(i.displayFormats,s.formats()),this._parseOpts={parser:i.parser,round:i.round,isoWeekday:i.isoWeekday},super.init(t),this._normalized=e.normalized}parse(t,e){return void 0===t?null:Vo(this,t)}beforeLayout(){super.beforeLayout(),this._cache={data:[],labels:[],all:[]}}determineDataLimits(){const t=this.options,e=this._adapter,i=t.time.unit||"day";let{min:s,max:n,minDefined:o,maxDefined:r}=this.getUserBounds();function l(t){o||isNaN(t.min)||(s=Math.min(s,t.min)),r||isNaN(t.max)||(n=Math.max(n,t.max))}o&&r||(l(this._getLabelBounds()),"ticks"===t.bounds&&"labels"===t.ticks.source||l(this.getMinMax(!1))),s=a(s)&&!isNaN(s)?s:+e.startOf(Date.now(),i),n=a(n)&&!isNaN(n)?n:+e.endOf(Date.now(),i)+1,this.min=Math.min(s,n-1),this.max=Math.max(s+1,n)}_getLabelBounds(){const t=this.getLabelTimestamps();let e=Number.POSITIVE_INFINITY,i=Number.NEGATIVE_INFINITY;return t.length&&(e=t[0],i=t[t.length-1]),{min:e,max:i}}buildTicks(){const t=this.options,e=t.time,i=t.ticks,s="labels"===i.source?this.getLabelTimestamps():this._generate();"ticks"===t.bounds&&s.length&&(this.min=this._userMin||s[0],this.max=this._userMax||s[s.length-1]);const n=this.min,o=nt(s,n,this.max);return this._unit=e.unit||(i.autoSkip?Bo(e.minUnit,this.min,this.max,this._getLabelCapacity(n)):function(t,e,i,s,n){for(let o=zo.length-1;o>=zo.indexOf(i);o--){const i=zo[o];if(Io[i].common&&t._adapter.diff(n,s,i)>=e-1)return i}return zo[i?zo.indexOf(i):0]}(this,o.length,e.minUnit,this.min,this.max)),this._majorUnit=i.major.enabled&&"year"!==this._unit?function(t){for(let e=zo.indexOf(t)+1,i=zo.length;e+t.value)))}initOffsets(t=[]){let e,i,s=0,n=0;this.options.offset&&t.length&&(e=this.getDecimalForValue(t[0]),s=1===t.length?1-e:(this.getDecimalForValue(t[1])-e)/2,i=this.getDecimalForValue(t[t.length-1]),n=1===t.length?i:(i-this.getDecimalForValue(t[t.length-2]))/2);const o=t.length<3?.5:.25;s=Z(s,0,o),n=Z(n,0,o),this._offsets={start:s,end:n,factor:1/(s+1+n)}}_generate(){const t=this._adapter,e=this.min,i=this.max,s=this.options,n=s.time,o=n.unit||Bo(n.minUnit,e,i,this._getLabelCapacity(e)),a=l(s.ticks.stepSize,1),r="week"===o&&n.isoWeekday,h=N(r)||!0===r,c={};let d,u,f=e;if(h&&(f=+t.startOf(f,"isoWeek",r)),f=+t.startOf(f,h?"day":o),t.diff(i,e,o)>1e5*a)throw new Error(e+" and "+i+" are too far apart with stepSize of "+a+" "+o);const g="data"===s.ticks.source&&this.getDataTimestamps();for(d=f,u=0;d+t))}getLabelForValue(t){const e=this._adapter,i=this.options.time;return i.tooltipFormat?e.format(t,i.tooltipFormat):e.format(t,i.displayFormats.datetime)}format(t,e){const i=this.options.time.displayFormats,s=this._unit,n=e||i[s];return this._adapter.format(t,n)}_tickFormatFunction(t,e,i,s){const n=this.options,o=n.ticks.callback;if(o)return d(o,[t,e,i],this);const a=n.time.displayFormats,r=this._unit,l=this._majorUnit,h=r&&a[r],c=l&&a[l],u=i[e],f=l&&c&&u&&u.major;return this._adapter.format(t,s||(f?c:h))}generateTickLabels(t){let e,i,s;for(e=0,i=t.length;e0?a:1}getDataTimestamps(){let t,e,i=this._cache.data||[];if(i.length)return i;const s=this.getMatchingVisibleMetas();if(this._normalized&&s.length)return this._cache.data=s[0].controller.getAllParsedValues(this);for(t=0,e=s.length;t=t[r].pos&&e<=t[l].pos&&({lo:r,hi:l}=it(t,"pos",e)),({pos:s,time:o}=t[r]),({pos:n,time:a}=t[l])):(e>=t[r].time&&e<=t[l].time&&({lo:r,hi:l}=it(t,"time",e)),({time:s,pos:o}=t[r]),({time:n,pos:a}=t[l]));const h=n-s;return h?o+(a-o)*(e-s)/h:o}var $o=Object.freeze({__proto__:null,CategoryScale:class extends tn{static id="category";static defaults={ticks:{callback:mo}};constructor(t){super(t),this._startValue=void 0,this._valueRange=0,this._addedLabels=[]}init(t){const e=this._addedLabels;if(e.length){const t=this.getLabels();for(const{index:i,label:s}of e)t[i]===s&&t.splice(i,1);this._addedLabels=[]}super.init(t)}parse(t,e){if(s(t))return null;const i=this.getLabels();return((t,e)=>null===t?null:Z(Math.round(t),0,e))(e=isFinite(e)&&i[e]===t?e:po(i,t,l(e,t),this._addedLabels),i.length-1)}determineDataLimits(){const{minDefined:t,maxDefined:e}=this.getUserBounds();let{min:i,max:s}=this.getMinMax(!0);"ticks"===this.options.bounds&&(t||(i=0),e||(s=this.getLabels().length-1)),this.min=i,this.max=s}buildTicks(){const t=this.min,e=this.max,i=this.options.offset,s=[];let n=this.getLabels();n=0===t&&e===n.length-1?n:n.slice(t,e+1),this._valueRange=Math.max(n.length-(i?0:1),1),this._startValue=this.min-(i?.5:0);for(let i=t;i<=e;i++)s.push({value:i});return s}getLabelForValue(t){return mo.call(this,t)}configure(){super.configure(),this.isHorizontal()||(this._reversePixels=!this._reversePixels)}getPixelForValue(t){return"number"!=typeof t&&(t=this.parse(t)),null===t?NaN:this.getPixelForDecimal((t-this._startValue)/this._valueRange)}getPixelForTick(t){const e=this.ticks;return t<0||t>e.length-1?null:this.getPixelForValue(e[t].value)}getValueForPixel(t){return Math.round(this._startValue+this.getDecimalForPixel(t)*this._valueRange)}getBasePixel(){return this.bottom}},LinearScale:_o,LogarithmicScale:So,RadialLinearScale:Ro,TimeScale:Ho,TimeSeriesScale:class extends Ho{static id="timeseries";static defaults=Ho.defaults;constructor(t){super(t),this._table=[],this._minPos=void 0,this._tableRange=void 0}initOffsets(){const t=this._getTimestampsForTable(),e=this._table=this.buildLookupTable(t);this._minPos=jo(e,this.min),this._tableRange=jo(e,this.max)-this._minPos,super.initOffsets(t)}buildLookupTable(t){const{min:e,max:i}=this,s=[],n=[];let o,a,r,l,h;for(o=0,a=t.length;o=e&&l<=i&&s.push(l);if(s.length<2)return[{time:e,pos:0},{time:i,pos:1}];for(o=0,a=s.length;ot-e))}_getTimestampsForTable(){let t=this._cache.all||[];if(t.length)return t;const e=this.getDataTimestamps(),i=this.getLabelTimestamps();return t=e.length&&i.length?this.normalize(e.concat(i)):e.length?e:i,t=this._cache.all=t,t}getDecimalForValue(t){return(jo(this._table,t)-this._minPos)/this._tableRange}getValueForPixel(t){const e=this._offsets,i=this.getDecimalForPixel(t)/e.factor-e.end;return jo(this._table,i*this._tableRange+this._minPos,!0)}}});const Yo=["rgb(54, 162, 235)","rgb(255, 99, 132)","rgb(255, 159, 64)","rgb(255, 205, 86)","rgb(75, 192, 192)","rgb(153, 102, 255)","rgb(201, 203, 207)"],Uo=Yo.map((t=>t.replace("rgb(","rgba(").replace(")",", 0.5)")));function Xo(t){return Yo[t%Yo.length]}function qo(t){return Uo[t%Uo.length]}function Ko(t){let e=0;return(i,s)=>{const n=t.getDatasetMeta(s).controller;n instanceof $n?e=function(t,e){return t.backgroundColor=t.data.map((()=>Xo(e++))),e}(i,e):n instanceof Yn?e=function(t,e){return t.backgroundColor=t.data.map((()=>qo(e++))),e}(i,e):n&&(e=function(t,e){return t.borderColor=Xo(e),t.backgroundColor=qo(e),++e}(i,e))}}function Go(t){let e;for(e in t)if(t[e].borderColor||t[e].backgroundColor)return!0;return!1}var Jo={id:"colors",defaults:{enabled:!0,forceOverride:!1},beforeLayout(t,e,i){if(!i.enabled)return;const{data:{datasets:s},options:n}=t.config,{elements:o}=n,a=Go(s)||(r=n)&&(r.borderColor||r.backgroundColor)||o&&Go(o)||"rgba(0,0,0,0.1)"!==ue.borderColor||"rgba(0,0,0,0.1)"!==ue.backgroundColor;var r;if(!i.forceOverride&&a)return;const l=Ko(t);s.forEach(l)}};function Zo(t){if(t._decimated){const e=t._data;delete t._decimated,delete t._data,Object.defineProperty(t,"data",{configurable:!0,enumerable:!0,writable:!0,value:e})}}function Qo(t){t.data.datasets.forEach((t=>{Zo(t)}))}var ta={id:"decimation",defaults:{algorithm:"min-max",enabled:!1},beforeElementsUpdate:(t,e,i)=>{if(!i.enabled)return void Qo(t);const n=t.width;t.data.datasets.forEach(((e,o)=>{const{_data:a,indexAxis:r}=e,l=t.getDatasetMeta(o),h=a||e.data;if("y"===Pi([r,t.options.indexAxis]))return;if(!l.controller.supportsDecimation)return;const c=t.scales[l.xAxisID];if("linear"!==c.type&&"time"!==c.type)return;if(t.options.parsing)return;let{start:d,count:u}=function(t,e){const i=e.length;let s,n=0;const{iScale:o}=t,{min:a,max:r,minDefined:l,maxDefined:h}=o.getUserBounds();return l&&(n=Z(it(e,o.axis,a).lo,0,i-1)),s=h?Z(it(e,o.axis,r).hi+1,n,i)-n:i-n,{start:n,count:s}}(l,h);if(u<=(i.threshold||4*n))return void Zo(e);let f;switch(s(a)&&(e._data=h,delete e.data,Object.defineProperty(e,"data",{configurable:!0,enumerable:!0,get:function(){return this._decimated},set:function(t){this._data=t}})),i.algorithm){case"lttb":f=function(t,e,i,s,n){const o=n.samples||s;if(o>=i)return t.slice(e,e+i);const a=[],r=(i-2)/(o-2);let l=0;const h=e+i-1;let c,d,u,f,g,p=e;for(a[l++]=t[p],c=0;cu&&(u=f,d=t[s],g=s);a[l++]=d,p=g}return a[l++]=t[h],a}(h,d,u,n,i);break;case"min-max":f=function(t,e,i,n){let o,a,r,l,h,c,d,u,f,g,p=0,m=0;const x=[],b=e+i-1,_=t[e].x,y=t[b].x-_;for(o=e;og&&(g=l,d=o),p=(m*p+a.x)/++m;else{const i=o-1;if(!s(c)&&!s(d)){const e=Math.min(c,d),s=Math.max(c,d);e!==u&&e!==i&&x.push({...t[e],x:p}),s!==u&&s!==i&&x.push({...t[s],x:p})}o>0&&i!==u&&x.push(t[i]),x.push(a),h=e,m=0,f=g=l,c=d=u=o}}return x}(h,d,u,n);break;default:throw new Error(`Unsupported decimation algorithm '${i.algorithm}'`)}e._decimated=f}))},destroy(t){Qo(t)}};function ea(t,e,i,s){if(s)return;let n=e[t],o=i[t];return"angle"===t&&(n=G(n),o=G(o)),{property:t,start:n,end:o}}function ia(t,e,i){for(;e>t;e--){const t=i[e];if(!isNaN(t.x)&&!isNaN(t.y))break}return e}function sa(t,e,i,s){return t&&e?s(t[i],e[i]):t?t[i]:e?e[i]:0}function na(t,e){let i=[],s=!1;return n(t)?(s=!0,i=t):i=function(t,e){const{x:i=null,y:s=null}=t||{},n=e.points,o=[];return e.segments.forEach((({start:t,end:e})=>{e=ia(t,e,n);const a=n[t],r=n[e];null!==s?(o.push({x:a.x,y:s}),o.push({x:r.x,y:s})):null!==i&&(o.push({x:i,y:a.y}),o.push({x:i,y:r.y}))})),o}(t,e),i.length?new oo({points:i,options:{tension:0},_loop:s,_fullLoop:s}):null}function oa(t){return t&&!1!==t.fill}function aa(t,e,i){let s=t[e].fill;const n=[e];let o;if(!i)return s;for(;!1!==s&&-1===n.indexOf(s);){if(!a(s))return s;if(o=t[s],!o)return!1;if(o.visible)return s;n.push(s),s=o.fill}return!1}function ra(t,e,i){const s=function(t){const e=t.options,i=e.fill;let s=l(i&&i.target,i);void 0===s&&(s=!!e.backgroundColor);if(!1===s||null===s)return!1;if(!0===s)return"origin";return s}(t);if(o(s))return!isNaN(s.value)&&s;let n=parseFloat(s);return a(n)&&Math.floor(n)===n?function(t,e,i,s){"-"!==t&&"+"!==t||(i=e+i);if(i===e||i<0||i>=s)return!1;return i}(s[0],e,n,i):["origin","start","end","stack","shape"].indexOf(s)>=0&&s}function la(t,e,i){const s=[];for(let n=0;n=0;--e){const i=n[e].$filler;i&&(i.line.updateControlPoints(o,i.axis),s&&i.fill&&ua(t.ctx,i,o))}},beforeDatasetsDraw(t,e,i){if("beforeDatasetsDraw"!==i.drawTime)return;const s=t.getSortedVisibleDatasetMetas();for(let e=s.length-1;e>=0;--e){const i=s[e].$filler;oa(i)&&ua(t.ctx,i,t.chartArea)}},beforeDatasetDraw(t,e,i){const s=e.meta.$filler;oa(s)&&"beforeDatasetDraw"===i.drawTime&&ua(t.ctx,s,t.chartArea)},defaults:{propagate:!0,drawTime:"beforeDatasetDraw"}};const _a=(t,e)=>{let{boxHeight:i=e,boxWidth:s=e}=t;return t.usePointStyle&&(i=Math.min(i,e),s=t.pointStyleWidth||Math.min(s,e)),{boxWidth:s,boxHeight:i,itemHeight:Math.max(e,i)}};class ya extends $s{constructor(t){super(),this._added=!1,this.legendHitBoxes=[],this._hoveredItem=null,this.doughnutMode=!1,this.chart=t.chart,this.options=t.options,this.ctx=t.ctx,this.legendItems=void 0,this.columnSizes=void 0,this.lineWidths=void 0,this.maxHeight=void 0,this.maxWidth=void 0,this.top=void 0,this.bottom=void 0,this.left=void 0,this.right=void 0,this.height=void 0,this.width=void 0,this._margins=void 0,this.position=void 0,this.weight=void 0,this.fullSize=void 0}update(t,e,i){this.maxWidth=t,this.maxHeight=e,this._margins=i,this.setDimensions(),this.buildLabels(),this.fit()}setDimensions(){this.isHorizontal()?(this.width=this.maxWidth,this.left=this._margins.left,this.right=this.width):(this.height=this.maxHeight,this.top=this._margins.top,this.bottom=this.height)}buildLabels(){const t=this.options.labels||{};let e=d(t.generateLabels,[this.chart],this)||[];t.filter&&(e=e.filter((e=>t.filter(e,this.chart.data)))),t.sort&&(e=e.sort(((e,i)=>t.sort(e,i,this.chart.data)))),this.options.reverse&&e.reverse(),this.legendItems=e}fit(){const{options:t,ctx:e}=this;if(!t.display)return void(this.width=this.height=0);const i=t.labels,s=Si(i.font),n=s.size,o=this._computeTitleHeight(),{boxWidth:a,itemHeight:r}=_a(i,n);let l,h;e.font=s.string,this.isHorizontal()?(l=this.maxWidth,h=this._fitRows(o,n,a,r)+10):(h=this.maxHeight,l=this._fitCols(o,s,a,r)+10),this.width=Math.min(l,t.maxWidth||this.maxWidth),this.height=Math.min(h,t.maxHeight||this.maxHeight)}_fitRows(t,e,i,s){const{ctx:n,maxWidth:o,options:{labels:{padding:a}}}=this,r=this.legendHitBoxes=[],l=this.lineWidths=[0],h=s+a;let c=t;n.textAlign="left",n.textBaseline="middle";let d=-1,u=-h;return this.legendItems.forEach(((t,f)=>{const g=i+e/2+n.measureText(t.text).width;(0===f||l[l.length-1]+g+2*a>o)&&(c+=h,l[l.length-(f>0?0:1)]=0,u+=h,d++),r[f]={left:0,top:u,row:d,width:g,height:s},l[l.length-1]+=g+a})),c}_fitCols(t,e,i,s){const{ctx:n,maxHeight:o,options:{labels:{padding:a}}}=this,r=this.legendHitBoxes=[],l=this.columnSizes=[],h=o-t;let c=a,d=0,u=0,f=0,g=0;return this.legendItems.forEach(((t,o)=>{const{itemWidth:p,itemHeight:m}=function(t,e,i,s,n){const o=function(t,e,i,s){let n=t.text;n&&"string"!=typeof n&&(n=n.reduce(((t,e)=>t.length>e.length?t:e)));return e+i.size/2+s.measureText(n).width}(s,t,e,i),a=function(t,e,i){let s=t;"string"!=typeof e.text&&(s=va(e,i));return s}(n,s,e.lineHeight);return{itemWidth:o,itemHeight:a}}(i,e,n,t,s);o>0&&u+m+2*a>h&&(c+=d+a,l.push({width:d,height:u}),f+=d+a,g++,d=u=0),r[o]={left:f,top:u,col:g,width:p,height:m},d=Math.max(d,p),u+=m+a})),c+=d,l.push({width:d,height:u}),c}adjustHitBoxes(){if(!this.options.display)return;const t=this._computeTitleHeight(),{legendHitBoxes:e,options:{align:i,labels:{padding:s},rtl:n}}=this,o=Oi(n,this.left,this.width);if(this.isHorizontal()){let n=0,a=ft(i,this.left+s,this.right-this.lineWidths[n]);for(const r of e)n!==r.row&&(n=r.row,a=ft(i,this.left+s,this.right-this.lineWidths[n])),r.top+=this.top+t+s,r.left=o.leftForLtr(o.x(a),r.width),a+=r.width+s}else{let n=0,a=ft(i,this.top+t+s,this.bottom-this.columnSizes[n].height);for(const r of e)r.col!==n&&(n=r.col,a=ft(i,this.top+t+s,this.bottom-this.columnSizes[n].height)),r.top=a,r.left+=this.left+s,r.left=o.leftForLtr(o.x(r.left),r.width),a+=r.height+s}}isHorizontal(){return"top"===this.options.position||"bottom"===this.options.position}draw(){if(this.options.display){const t=this.ctx;Ie(t,this),this._draw(),ze(t)}}_draw(){const{options:t,columnSizes:e,lineWidths:i,ctx:s}=this,{align:n,labels:o}=t,a=ue.color,r=Oi(t.rtl,this.left,this.width),h=Si(o.font),{padding:c}=o,d=h.size,u=d/2;let f;this.drawTitle(),s.textAlign=r.textAlign("left"),s.textBaseline="middle",s.lineWidth=.5,s.font=h.string;const{boxWidth:g,boxHeight:p,itemHeight:m}=_a(o,d),x=this.isHorizontal(),b=this._computeTitleHeight();f=x?{x:ft(n,this.left+c,this.right-i[0]),y:this.top+c+b,line:0}:{x:this.left+c,y:ft(n,this.top+b+c,this.bottom-e[0].height),line:0},Ai(this.ctx,t.textDirection);const _=m+c;this.legendItems.forEach(((y,v)=>{s.strokeStyle=y.fontColor,s.fillStyle=y.fontColor;const M=s.measureText(y.text).width,w=r.textAlign(y.textAlign||(y.textAlign=o.textAlign)),k=g+u+M;let S=f.x,P=f.y;r.setWidth(this.width),x?v>0&&S+k+c>this.right&&(P=f.y+=_,f.line++,S=f.x=ft(n,this.left+c,this.right-i[f.line])):v>0&&P+_>this.bottom&&(S=f.x=S+e[f.line].width+c,f.line++,P=f.y=ft(n,this.top+b+c,this.bottom-e[f.line].height));if(function(t,e,i){if(isNaN(g)||g<=0||isNaN(p)||p<0)return;s.save();const n=l(i.lineWidth,1);if(s.fillStyle=l(i.fillStyle,a),s.lineCap=l(i.lineCap,"butt"),s.lineDashOffset=l(i.lineDashOffset,0),s.lineJoin=l(i.lineJoin,"miter"),s.lineWidth=n,s.strokeStyle=l(i.strokeStyle,a),s.setLineDash(l(i.lineDash,[])),o.usePointStyle){const a={radius:p*Math.SQRT2/2,pointStyle:i.pointStyle,rotation:i.rotation,borderWidth:n},l=r.xPlus(t,g/2);Ee(s,a,l,e+u,o.pointStyleWidth&&g)}else{const o=e+Math.max((d-p)/2,0),a=r.leftForLtr(t,g),l=wi(i.borderRadius);s.beginPath(),Object.values(l).some((t=>0!==t))?He(s,{x:a,y:o,w:g,h:p,radius:l}):s.rect(a,o,g,p),s.fill(),0!==n&&s.stroke()}s.restore()}(r.x(S),P,y),S=gt(w,S+g+u,x?S+k:this.right,t.rtl),function(t,e,i){Ne(s,i.text,t,e+m/2,h,{strikethrough:i.hidden,textAlign:r.textAlign(i.textAlign)})}(r.x(S),P,y),x)f.x+=k+c;else if("string"!=typeof y.text){const t=h.lineHeight;f.y+=va(y,t)+c}else f.y+=_})),Ti(this.ctx,t.textDirection)}drawTitle(){const t=this.options,e=t.title,i=Si(e.font),s=ki(e.padding);if(!e.display)return;const n=Oi(t.rtl,this.left,this.width),o=this.ctx,a=e.position,r=i.size/2,l=s.top+r;let h,c=this.left,d=this.width;if(this.isHorizontal())d=Math.max(...this.lineWidths),h=this.top+l,c=ft(t.align,c,this.right-d);else{const e=this.columnSizes.reduce(((t,e)=>Math.max(t,e.height)),0);h=l+ft(t.align,this.top,this.bottom-e-t.labels.padding-this._computeTitleHeight())}const u=ft(a,c,c+d);o.textAlign=n.textAlign(ut(a)),o.textBaseline="middle",o.strokeStyle=e.color,o.fillStyle=e.color,o.font=i.string,Ne(o,e.text,u,h,i)}_computeTitleHeight(){const t=this.options.title,e=Si(t.font),i=ki(t.padding);return t.display?e.lineHeight+i.height:0}_getLegendItemAt(t,e){let i,s,n;if(tt(t,this.left,this.right)&&tt(e,this.top,this.bottom))for(n=this.legendHitBoxes,i=0;it.chart.options.color,boxWidth:40,padding:10,generateLabels(t){const e=t.data.datasets,{labels:{usePointStyle:i,pointStyle:s,textAlign:n,color:o,useBorderRadius:a,borderRadius:r}}=t.legend.options;return t._getSortedDatasetMetas().map((t=>{const l=t.controller.getStyle(i?0:void 0),h=ki(l.borderWidth);return{text:e[t.index].label,fillStyle:l.backgroundColor,fontColor:o,hidden:!t.visible,lineCap:l.borderCapStyle,lineDash:l.borderDash,lineDashOffset:l.borderDashOffset,lineJoin:l.borderJoinStyle,lineWidth:(h.width+h.height)/4,strokeStyle:l.borderColor,pointStyle:s||l.pointStyle,rotation:l.rotation,textAlign:n||l.textAlign,borderRadius:a&&(r||l.borderRadius),datasetIndex:t.index}}),this)}},title:{color:t=>t.chart.options.color,display:!1,position:"center",text:""}},descriptors:{_scriptable:t=>!t.startsWith("on"),labels:{_scriptable:t=>!["generateLabels","filter","sort"].includes(t)}}};class wa extends $s{constructor(t){super(),this.chart=t.chart,this.options=t.options,this.ctx=t.ctx,this._padding=void 0,this.top=void 0,this.bottom=void 0,this.left=void 0,this.right=void 0,this.width=void 0,this.height=void 0,this.position=void 0,this.weight=void 0,this.fullSize=void 0}update(t,e){const i=this.options;if(this.left=0,this.top=0,!i.display)return void(this.width=this.height=this.right=this.bottom=0);this.width=this.right=t,this.height=this.bottom=e;const s=n(i.text)?i.text.length:1;this._padding=ki(i.padding);const o=s*Si(i.font).lineHeight+this._padding.height;this.isHorizontal()?this.height=o:this.width=o}isHorizontal(){const t=this.options.position;return"top"===t||"bottom"===t}_drawArgs(t){const{top:e,left:i,bottom:s,right:n,options:o}=this,a=o.align;let r,l,h,c=0;return this.isHorizontal()?(l=ft(a,i,n),h=e+t,r=n-i):("left"===o.position?(l=i+t,h=ft(a,s,e),c=-.5*C):(l=n-t,h=ft(a,e,s),c=.5*C),r=s-e),{titleX:l,titleY:h,maxWidth:r,rotation:c}}draw(){const t=this.ctx,e=this.options;if(!e.display)return;const i=Si(e.font),s=i.lineHeight/2+this._padding.top,{titleX:n,titleY:o,maxWidth:a,rotation:r}=this._drawArgs(s);Ne(t,e.text,0,0,i,{color:e.color,maxWidth:a,rotation:r,textAlign:ut(e.align),textBaseline:"middle",translation:[n,o]})}}var ka={id:"title",_element:wa,start(t,e,i){!function(t,e){const i=new wa({ctx:t.ctx,options:e,chart:t});ls.configure(t,i,e),ls.addBox(t,i),t.titleBlock=i}(t,i)},stop(t){const e=t.titleBlock;ls.removeBox(t,e),delete t.titleBlock},beforeUpdate(t,e,i){const s=t.titleBlock;ls.configure(t,s,i),s.options=i},defaults:{align:"center",display:!1,font:{weight:"bold"},fullSize:!0,padding:10,position:"top",text:"",weight:2e3},defaultRoutes:{color:"color"},descriptors:{_scriptable:!0,_indexable:!1}};const Sa=new WeakMap;var Pa={id:"subtitle",start(t,e,i){const s=new wa({ctx:t.ctx,options:i,chart:t});ls.configure(t,s,i),ls.addBox(t,s),Sa.set(t,s)},stop(t){ls.removeBox(t,Sa.get(t)),Sa.delete(t)},beforeUpdate(t,e,i){const s=Sa.get(t);ls.configure(t,s,i),s.options=i},defaults:{align:"center",display:!1,font:{weight:"normal"},fullSize:!0,padding:0,position:"top",text:"",weight:1500},defaultRoutes:{color:"color"},descriptors:{_scriptable:!0,_indexable:!1}};const Da={average(t){if(!t.length)return!1;let e,i,s=new Set,n=0,o=0;for(e=0,i=t.length;et+e))/s.size,y:n/o}},nearest(t,e){if(!t.length)return!1;let i,s,n,o=e.x,a=e.y,r=Number.POSITIVE_INFINITY;for(i=0,s=t.length;i-1?t.split("\n"):t}function Aa(t,e){const{element:i,datasetIndex:s,index:n}=e,o=t.getDatasetMeta(s).controller,{label:a,value:r}=o.getLabelAndValue(n);return{chart:t,label:a,parsed:o.getParsed(n),raw:t.data.datasets[s].data[n],formattedValue:r,dataset:o.getDataset(),dataIndex:n,datasetIndex:s,element:i}}function Ta(t,e){const i=t.chart.ctx,{body:s,footer:n,title:o}=t,{boxWidth:a,boxHeight:r}=e,l=Si(e.bodyFont),h=Si(e.titleFont),c=Si(e.footerFont),d=o.length,f=n.length,g=s.length,p=ki(e.padding);let m=p.height,x=0,b=s.reduce(((t,e)=>t+e.before.length+e.lines.length+e.after.length),0);if(b+=t.beforeBody.length+t.afterBody.length,d&&(m+=d*h.lineHeight+(d-1)*e.titleSpacing+e.titleMarginBottom),b){m+=g*(e.displayColors?Math.max(r,l.lineHeight):l.lineHeight)+(b-g)*l.lineHeight+(b-1)*e.bodySpacing}f&&(m+=e.footerMarginTop+f*c.lineHeight+(f-1)*e.footerSpacing);let _=0;const y=function(t){x=Math.max(x,i.measureText(t).width+_)};return i.save(),i.font=h.string,u(t.title,y),i.font=l.string,u(t.beforeBody.concat(t.afterBody),y),_=e.displayColors?a+2+e.boxPadding:0,u(s,(t=>{u(t.before,y),u(t.lines,y),u(t.after,y)})),_=0,i.font=c.string,u(t.footer,y),i.restore(),x+=p.width,{width:x,height:m}}function La(t,e,i,s){const{x:n,width:o}=i,{width:a,chartArea:{left:r,right:l}}=t;let h="center";return"center"===s?h=n<=(r+l)/2?"left":"right":n<=o/2?h="left":n>=a-o/2&&(h="right"),function(t,e,i,s){const{x:n,width:o}=s,a=i.caretSize+i.caretPadding;return"left"===t&&n+o+a>e.width||"right"===t&&n-o-a<0||void 0}(h,t,e,i)&&(h="center"),h}function Ea(t,e,i){const s=i.yAlign||e.yAlign||function(t,e){const{y:i,height:s}=e;return it.height-s/2?"bottom":"center"}(t,i);return{xAlign:i.xAlign||e.xAlign||La(t,e,i,s),yAlign:s}}function Ra(t,e,i,s){const{caretSize:n,caretPadding:o,cornerRadius:a}=t,{xAlign:r,yAlign:l}=i,h=n+o,{topLeft:c,topRight:d,bottomLeft:u,bottomRight:f}=wi(a);let g=function(t,e){let{x:i,width:s}=t;return"right"===e?i-=s:"center"===e&&(i-=s/2),i}(e,r);const p=function(t,e,i){let{y:s,height:n}=t;return"top"===e?s+=i:s-="bottom"===e?n+i:n/2,s}(e,l,h);return"center"===l?"left"===r?g+=h:"right"===r&&(g-=h):"left"===r?g-=Math.max(c,u)+n:"right"===r&&(g+=Math.max(d,f)+n),{x:Z(g,0,s.width-e.width),y:Z(p,0,s.height-e.height)}}function Ia(t,e,i){const s=ki(i.padding);return"center"===e?t.x+t.width/2:"right"===e?t.x+t.width-s.right:t.x+s.left}function za(t){return Ca([],Oa(t))}function Fa(t,e){const i=e&&e.dataset&&e.dataset.tooltip&&e.dataset.tooltip.callbacks;return i?t.override(i):t}const Va={beforeTitle:e,title(t){if(t.length>0){const e=t[0],i=e.chart.data.labels,s=i?i.length:0;if(this&&this.options&&"dataset"===this.options.mode)return e.dataset.label||"";if(e.label)return e.label;if(s>0&&e.dataIndex{const e={before:[],lines:[],after:[]},n=Fa(i,t);Ca(e.before,Oa(Ba(n,"beforeLabel",this,t))),Ca(e.lines,Ba(n,"label",this,t)),Ca(e.after,Oa(Ba(n,"afterLabel",this,t))),s.push(e)})),s}getAfterBody(t,e){return za(Ba(e.callbacks,"afterBody",this,t))}getFooter(t,e){const{callbacks:i}=e,s=Ba(i,"beforeFooter",this,t),n=Ba(i,"footer",this,t),o=Ba(i,"afterFooter",this,t);let a=[];return a=Ca(a,Oa(s)),a=Ca(a,Oa(n)),a=Ca(a,Oa(o)),a}_createItems(t){const e=this._active,i=this.chart.data,s=[],n=[],o=[];let a,r,l=[];for(a=0,r=e.length;at.filter(e,s,n,i)))),t.itemSort&&(l=l.sort(((e,s)=>t.itemSort(e,s,i)))),u(l,(e=>{const i=Fa(t.callbacks,e);s.push(Ba(i,"labelColor",this,e)),n.push(Ba(i,"labelPointStyle",this,e)),o.push(Ba(i,"labelTextColor",this,e))})),this.labelColors=s,this.labelPointStyles=n,this.labelTextColors=o,this.dataPoints=l,l}update(t,e){const i=this.options.setContext(this.getContext()),s=this._active;let n,o=[];if(s.length){const t=Da[i.position].call(this,s,this._eventPosition);o=this._createItems(i),this.title=this.getTitle(o,i),this.beforeBody=this.getBeforeBody(o,i),this.body=this.getBody(o,i),this.afterBody=this.getAfterBody(o,i),this.footer=this.getFooter(o,i);const e=this._size=Ta(this,i),a=Object.assign({},t,e),r=Ea(this.chart,i,a),l=Ra(i,a,r,this.chart);this.xAlign=r.xAlign,this.yAlign=r.yAlign,n={opacity:1,x:l.x,y:l.y,width:e.width,height:e.height,caretX:t.x,caretY:t.y}}else 0!==this.opacity&&(n={opacity:0});this._tooltipItems=o,this.$context=void 0,n&&this._resolveAnimations().update(this,n),t&&i.external&&i.external.call(this,{chart:this.chart,tooltip:this,replay:e})}drawCaret(t,e,i,s){const n=this.getCaretPosition(t,i,s);e.lineTo(n.x1,n.y1),e.lineTo(n.x2,n.y2),e.lineTo(n.x3,n.y3)}getCaretPosition(t,e,i){const{xAlign:s,yAlign:n}=this,{caretSize:o,cornerRadius:a}=i,{topLeft:r,topRight:l,bottomLeft:h,bottomRight:c}=wi(a),{x:d,y:u}=t,{width:f,height:g}=e;let p,m,x,b,_,y;return"center"===n?(_=u+g/2,"left"===s?(p=d,m=p-o,b=_+o,y=_-o):(p=d+f,m=p+o,b=_-o,y=_+o),x=p):(m="left"===s?d+Math.max(r,h)+o:"right"===s?d+f-Math.max(l,c)-o:this.caretX,"top"===n?(b=u,_=b-o,p=m-o,x=m+o):(b=u+g,_=b+o,p=m+o,x=m-o),y=b),{x1:p,x2:m,x3:x,y1:b,y2:_,y3:y}}drawTitle(t,e,i){const s=this.title,n=s.length;let o,a,r;if(n){const l=Oi(i.rtl,this.x,this.width);for(t.x=Ia(this,i.titleAlign,i),e.textAlign=l.textAlign(i.titleAlign),e.textBaseline="middle",o=Si(i.titleFont),a=i.titleSpacing,e.fillStyle=i.titleColor,e.font=o.string,r=0;r0!==t))?(t.beginPath(),t.fillStyle=n.multiKeyBackground,He(t,{x:e,y:g,w:h,h:l,radius:r}),t.fill(),t.stroke(),t.fillStyle=a.backgroundColor,t.beginPath(),He(t,{x:i,y:g+1,w:h-2,h:l-2,radius:r}),t.fill()):(t.fillStyle=n.multiKeyBackground,t.fillRect(e,g,h,l),t.strokeRect(e,g,h,l),t.fillStyle=a.backgroundColor,t.fillRect(i,g+1,h-2,l-2))}t.fillStyle=this.labelTextColors[i]}drawBody(t,e,i){const{body:s}=this,{bodySpacing:n,bodyAlign:o,displayColors:a,boxHeight:r,boxWidth:l,boxPadding:h}=i,c=Si(i.bodyFont);let d=c.lineHeight,f=0;const g=Oi(i.rtl,this.x,this.width),p=function(i){e.fillText(i,g.x(t.x+f),t.y+d/2),t.y+=d+n},m=g.textAlign(o);let x,b,_,y,v,M,w;for(e.textAlign=o,e.textBaseline="middle",e.font=c.string,t.x=Ia(this,m,i),e.fillStyle=i.bodyColor,u(this.beforeBody,p),f=a&&"right"!==m?"center"===o?l/2+h:l+2+h:0,y=0,M=s.length;y0&&e.stroke()}_updateAnimationTarget(t){const e=this.chart,i=this.$animations,s=i&&i.x,n=i&&i.y;if(s||n){const i=Da[t.position].call(this,this._active,this._eventPosition);if(!i)return;const o=this._size=Ta(this,t),a=Object.assign({},i,this._size),r=Ea(e,t,a),l=Ra(t,a,r,e);s._to===l.x&&n._to===l.y||(this.xAlign=r.xAlign,this.yAlign=r.yAlign,this.width=o.width,this.height=o.height,this.caretX=i.x,this.caretY=i.y,this._resolveAnimations().update(this,l))}}_willRender(){return!!this.opacity}draw(t){const e=this.options.setContext(this.getContext());let i=this.opacity;if(!i)return;this._updateAnimationTarget(e);const s={width:this.width,height:this.height},n={x:this.x,y:this.y};i=Math.abs(i)<.001?0:i;const o=ki(e.padding),a=this.title.length||this.beforeBody.length||this.body.length||this.afterBody.length||this.footer.length;e.enabled&&a&&(t.save(),t.globalAlpha=i,this.drawBackground(n,t,s,e),Ai(t,e.textDirection),n.y+=o.top,this.drawTitle(n,t,e),this.drawBody(n,t,e),this.drawFooter(n,t,e),Ti(t,e.textDirection),t.restore())}getActiveElements(){return this._active||[]}setActiveElements(t,e){const i=this._active,s=t.map((({datasetIndex:t,index:e})=>{const i=this.chart.getDatasetMeta(t);if(!i)throw new Error("Cannot find a dataset at index "+t);return{datasetIndex:t,element:i.data[e],index:e}})),n=!f(i,s),o=this._positionChanged(s,e);(n||o)&&(this._active=s,this._eventPosition=e,this._ignoreReplayEvents=!0,this.update(!0))}handleEvent(t,e,i=!0){if(e&&this._ignoreReplayEvents)return!1;this._ignoreReplayEvents=!1;const s=this.options,n=this._active||[],o=this._getActiveElements(t,n,e,i),a=this._positionChanged(o,t),r=e||!f(o,n)||a;return r&&(this._active=o,(s.enabled||s.external)&&(this._eventPosition={x:t.x,y:t.y},this.update(!0,e))),r}_getActiveElements(t,e,i,s){const n=this.options;if("mouseout"===t.type)return[];if(!s)return e.filter((t=>this.chart.data.datasets[t.datasetIndex]&&void 0!==this.chart.getDatasetMeta(t.datasetIndex).controller.getParsed(t.index)));const o=this.chart.getElementsAtEventForMode(t,n.mode,n,i);return n.reverse&&o.reverse(),o}_positionChanged(t,e){const{caretX:i,caretY:s,options:n}=this,o=Da[n.position].call(this,t,e);return!1!==o&&(i!==o.x||s!==o.y)}}var Na={id:"tooltip",_element:Wa,positioners:Da,afterInit(t,e,i){i&&(t.tooltip=new Wa({chart:t,options:i}))},beforeUpdate(t,e,i){t.tooltip&&t.tooltip.initialize(i)},reset(t,e,i){t.tooltip&&t.tooltip.initialize(i)},afterDraw(t){const e=t.tooltip;if(e&&e._willRender()){const i={tooltip:e};if(!1===t.notifyPlugins("beforeTooltipDraw",{...i,cancelable:!0}))return;e.draw(t.ctx),t.notifyPlugins("afterTooltipDraw",i)}},afterEvent(t,e){if(t.tooltip){const i=e.replay;t.tooltip.handleEvent(e.event,i,e.inChartArea)&&(e.changed=!0)}},defaults:{enabled:!0,external:null,position:"average",backgroundColor:"rgba(0,0,0,0.8)",titleColor:"#fff",titleFont:{weight:"bold"},titleSpacing:2,titleMarginBottom:6,titleAlign:"left",bodyColor:"#fff",bodySpacing:2,bodyFont:{},bodyAlign:"left",footerColor:"#fff",footerSpacing:2,footerMarginTop:6,footerFont:{weight:"bold"},footerAlign:"left",padding:6,caretPadding:2,caretSize:5,cornerRadius:6,boxHeight:(t,e)=>e.bodyFont.size,boxWidth:(t,e)=>e.bodyFont.size,multiKeyBackground:"#fff",displayColors:!0,boxPadding:0,borderColor:"rgba(0,0,0,0)",borderWidth:0,animation:{duration:400,easing:"easeOutQuart"},animations:{numbers:{type:"number",properties:["x","y","width","height","caretX","caretY"]},opacity:{easing:"linear",duration:200}},callbacks:Va},defaultRoutes:{bodyFont:"font",footerFont:"font",titleFont:"font"},descriptors:{_scriptable:t=>"filter"!==t&&"itemSort"!==t&&"external"!==t,_indexable:!1,callbacks:{_scriptable:!1,_indexable:!1},animation:{_fallback:!1},animations:{_fallback:"animation"}},additionalOptionScopes:["interaction"]};return Tn.register(Un,$o,go,t),Tn.helpers={...Hi},Tn._adapters=In,Tn.Animation=As,Tn.Animations=Ts,Tn.animator=bt,Tn.controllers=nn.controllers.items,Tn.DatasetController=js,Tn.Element=$s,Tn.elements=go,Tn.Interaction=Ki,Tn.layouts=ls,Tn.platforms=Ds,Tn.Scale=tn,Tn.Ticks=ae,Object.assign(Tn,Un,$o,go,t,Ds),Tn.Chart=Tn,"undefined"!=typeof window&&(window.Chart=Tn),Tn})); +//# sourceMappingURL=chart.umd.min.js.map diff --git a/skills/xtb-portfolio-review/scripts/html_charts.py b/skills/xtb-portfolio-review/scripts/html_charts.py new file mode 100644 index 0000000..da953be --- /dev/null +++ b/skills/xtb-portfolio-review/scripts/html_charts.py @@ -0,0 +1,365 @@ +"""Interactive Chart.js charts for the self-contained HTML report. + +This module is the only place that knows about Chart.js. It reads the vendored +UMD bundle from assets/ and builds Chart.js config dicts (pure functions) plus +an HTML fragment that inlines the bundle, the data (JSON), and a render script. +""" +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import pandas as pd + +ASSETS_DIR = Path(__file__).resolve().parent / "assets" +CHARTJS_PATH = ASSETS_DIR / "chartjs.umd.min.js" +CHARTJS_VERSION_PATH = ASSETS_DIR / "chartjs.VERSION" + + +def load_chartjs_inline() -> str: + """Return the minified Chart.js UMD source, vendored under assets/.""" + if not CHARTJS_PATH.exists(): + raise FileNotFoundError( + f"Chart.js bundle not found at {CHARTJS_PATH}. " + "Re-vendor it (see assets/chartjs.VERSION)." + ) + return CHARTJS_PATH.read_text(encoding="utf-8") + + +def _iso(value: Any) -> str: + if hasattr(value, "isoformat"): + return value.isoformat()[:10] + return str(value) + + +def _round_series(values) -> list[float]: + return [round(float(v), 2) for v in values] + + +def evolution_chart_config(evolution_df: pd.DataFrame, currency: str) -> dict | None: + """Build a Chart.js line-chart config for cost vs value over time. + + Returns None when there is no evolution data (caller omits the card). + """ + if evolution_df is None or evolution_df.empty: + return None + labels = [_iso(d) for d in evolution_df.index] + return { + "type": "line", + "data": { + "labels": labels, + "datasets": [ + { + "label": "Cost (invested)", + "data": _round_series(evolution_df["cost"]), + "borderColor": "#6b7280", + "backgroundColor": "#6b7280", + "borderWidth": 2, + "fill": False, + "pointRadius": 0, + "tension": 0.1, + }, + { + "label": "Value (realized + unrealized)", + "data": _round_series(evolution_df["total_value"]), + "borderColor": "#2c5282", + "backgroundColor": "#2c5282", + "borderWidth": 2, + "fill": False, + "pointRadius": 0, + "tension": 0.1, + }, + { + "label": "Cumulative realized P/L", + "data": _round_series(evolution_df["realized_pl"]), + "borderColor": "#f39c12", + "backgroundColor": "#f39c12", + "borderWidth": 1.5, + "borderDash": [6, 4], + "fill": False, + "pointRadius": 0, + "tension": 0.1, + }, + ], + }, + "options": { + "responsive": True, + "maintainAspectRatio": False, + "interaction": {"mode": "index", "intersect": False}, + "plugins": { + "legend": {"position": "bottom", + "labels": {"boxWidth": 12, "font": {"size": 12}}}, + }, + "scales": { + "x": {"ticks": {"maxRotation": 45, "autoSkip": True}}, + "y": {"beginAtZero": False}, + }, + }, + } + + +DOUGHNUT_COLORS = [ + "#2c5282", "#1f9d55", "#f39c12", "#3498db", "#9b59b6", + "#e67e22", "#16a085", "#34495e", "#e3342f", "#7f8c8d", +] + + +def review_charts_config( + holdings: pd.DataFrame, + flows: dict[str, float], + income_by_period: pd.Series, + currency: str, +) -> dict: + """Build Chart.js configs for the three review charts. + + Returns {'holdings': cfg|None, 'cashflows': cfg|None, 'income': cfg|None}. + Each is None when its source data is empty. + """ + holdings_cfg = _holdings_config(holdings) + cashflows_cfg = _cashflows_config(flows) + income_cfg = _income_config(income_by_period) + return {"holdings": holdings_cfg, "cashflows": cashflows_cfg, "income": income_cfg} + + +def _holdings_config(holdings: pd.DataFrame) -> dict | None: + if holdings is None or holdings.empty: + return None + alloc_col = "market_value" if "market_value" in holdings.columns else "cost_basis" + filtered = holdings.loc[holdings[alloc_col] > 0] + if filtered.empty: + return None + values = _round_series(filtered[alloc_col]) + return { + "type": "doughnut", + "data": { + "labels": [str(t) for t in filtered["ticker"].tolist()], + "datasets": [{ + "data": values, + "backgroundColor": [DOUGHNUT_COLORS[i % len(DOUGHNUT_COLORS)] + for i in range(len(values))], + }], + }, + "options": { + "responsive": True, + "maintainAspectRatio": False, + "plugins": {"legend": {"position": "right", + "labels": {"boxWidth": 12, "font": {"size": 11}}}}, + }, + } + + +def _cashflows_config(flows: dict[str, float]) -> dict | None: + if not flows: + return None + items = { + "Deposits": float(flows["deposits"]), + "Withdrawals": -float(flows["withdrawals"]), + "Interest": float(flows["interest"]), + "Dividends": float(flows["dividends"]), + "Div.tax": float(flows["dividend_tax"]), + "Invested": -float(flows["invested"]), + "Proceeds": float(flows["proceeds"]), + "FX fees": float(flows["conversion_fees"]), + "Fees": -float(flows["fees"]), + } + items = {k: v for k, v in items.items() if abs(v) > 1e-9} + if not items: + return None + labels = list(items.keys()) + values = _round_series(items.values()) + colors = ["#2ecc71" if v >= 0 else "#e74c3c" for v in items.values()] + return { + "type": "bar", + "data": {"labels": labels, + "datasets": [{"label": "Cash flows", "data": values, + "backgroundColor": colors}]}, + "options": { + "responsive": True, + "maintainAspectRatio": False, + "plugins": {"legend": {"display": False}}, + "scales": {"x": {"ticks": {"maxRotation": 30, "autoSkip": False}}, + "y": {"beginAtZero": True}}, + }, + } + + +def _income_config(income_by_period: pd.Series) -> dict | None: + if income_by_period is None or income_by_period.empty: + return None + return { + "type": "bar", + "data": { + "labels": [str(i) for i in income_by_period.index], + "datasets": [{"label": "Income", + "data": _round_series(income_by_period.tolist()), + "backgroundColor": "#3498db"}], + }, + "options": { + "responsive": True, + "maintainAspectRatio": False, + "plugins": {"legend": {"display": False}}, + "scales": {"x": {"ticks": {"maxRotation": 45, "autoSkip": False}}, + "y": {"beginAtZero": True}}, + }, + } + + +_RENDER_SCRIPT = r""" +function _bootPortfolioCharts() { + var block = document.getElementById('chart-data'); + if (!block) { return; } + var data = JSON.parse(block.textContent); + var ccy = data.currency || 'EUR'; + function fmt(v) { + try { return new Intl.NumberFormat('en-US', {style: 'currency', currency: ccy}).format(v); } + catch (e) { return String(v); } + } + function applyTooltip(cfg) { + if (!cfg || !cfg.options) { return; } + cfg.options.plugins = cfg.options.plugins || {}; + cfg.options.plugins.tooltip = cfg.options.plugins.tooltip || {}; + cfg.options.plugins.tooltip.callbacks = cfg.options.plugins.tooltip.callbacks || {}; + if (cfg.type === 'doughnut' || cfg.type === 'pie') { + cfg.options.plugins.tooltip.callbacks.label = function (ctx) { + var total = (ctx.dataset && ctx.dataset.data) + ? ctx.dataset.data.reduce(function (a, b) { return a + (typeof b === 'number' ? b : 0); }, 0) + : 0; + var v = (typeof ctx.parsed === 'number') ? ctx.parsed : ctx.raw; + var pct = total > 0 ? (v / total * 100) : 0; + return (ctx.label ? ctx.label + ': ' : '') + fmt(v) + ' (' + pct.toFixed(1) + '%)'; + }; + return; + } + cfg.options.plugins.tooltip.callbacks.label = function (ctx) { + var label = (ctx.dataset && ctx.dataset.label) ? ctx.dataset.label : ''; + var v = (ctx.parsed && Object.prototype.hasOwnProperty.call(ctx.parsed, 'y')) + ? ctx.parsed.y : (typeof ctx.parsed === 'number' ? ctx.parsed : ctx.raw); + return label ? (label + ': ' + fmt(v)) : fmt(v); + }; + } + function mount(id, cfg, plugins) { + if (!cfg) { return; } + var el = document.getElementById(id); + if (!el) { return; } + applyTooltip(cfg); + var config = {type: cfg.type, data: cfg.data, options: cfg.options}; + if (plugins && plugins.length) { config.plugins = plugins; } + new Chart(el.getContext('2d'), config); + } + var gainLossPlugin = { + id: 'gainLoss', + beforeDatasetsDraw: function (chart) { + var ds = chart.data.datasets; + if (ds.length < 2) { return; } + var meta0 = chart.getDatasetMeta(0); + var meta1 = chart.getDatasetMeta(1); + var cost = ds[0].data; + var value = ds[1].data; + if (!meta0 || !meta1 || !meta0.data || !meta1.data) { return; } + var ctx = chart.ctx; + ctx.save(); + for (var i = 0; i < value.length - 1; i++) { + var a0 = meta0.data[i], a1 = meta0.data[i + 1]; + var b0 = meta1.data[i], b1 = meta1.data[i + 1]; + if (!a0 || !a1 || !b0 || !b1) { continue; } + var gain = (value[i] >= cost[i] && value[i + 1] >= cost[i + 1]); + ctx.beginPath(); + ctx.moveTo(a0.x, a0.y); ctx.lineTo(a1.x, a1.y); + ctx.lineTo(b1.x, b1.y); ctx.lineTo(b0.x, b0.y); + ctx.closePath(); + ctx.fillStyle = gain ? 'rgba(31,157,85,0.25)' : 'rgba(227,52,47,0.25)'; + ctx.fill(); + } + ctx.restore(); + } + }; + mount('evolution-chart', data.evolution, [gainLossPlugin]); + mount('holdings-chart', data.holdings); + mount('cashflows-chart', data.cashflows); + mount('income-chart', data.income); +} +if (document.readyState !== 'loading') { _bootPortfolioCharts(); } +else { document.addEventListener('DOMContentLoaded', _bootPortfolioCharts); } +""" + + +def render_charts_block( + evolution_cfg: dict | None, review_cfg: dict, currency: str +) -> str: + """Return the HTML fragment: canvases + inlined Chart.js + JSON + render script. + + Returns "" when there is nothing to render. + """ + holdings_cfg = review_cfg.get("holdings") if review_cfg else None + cashflows_cfg = review_cfg.get("cashflows") if review_cfg else None + income_cfg = review_cfg.get("income") if review_cfg else None + + if evolution_cfg is None and not any([holdings_cfg, cashflows_cfg, income_cfg]): + return "" + + parts: list[str] = [] + + if evolution_cfg is not None: + parts.append( + "
\n" + "

Portfolio Evolution — Cost vs Value

\n" + "
" + "
\n" + "
" + ) + + grid_cells = [] + if holdings_cfg is not None: + grid_cells.append( + "

Holdings Allocation

" + "
" + "
" + ) + else: + grid_cells.append("

Holdings Allocation

" + "

No open positions.

") + if cashflows_cfg is not None: + grid_cells.append( + "

Cash Flows

" + "
" + "
" + ) + else: + grid_cells.append("

Cash Flows

" + "

No cash flows.

") + # Income is optional: the income cell is omitted entirely when there is no + # income data, unlike holdings/cashflows which always render a cell with a + # muted fallback. + if income_cfg is not None: + grid_cells.append( + "

Income Over Time

" + "
" + "
" + ) + charts_id_attr = " id='charts'" if evolution_cfg is None else "" + parts.append( + f"
\n" + "

Charts

\n" + "
\n " + + "\n ".join(grid_cells) + "\n
\n" + "
" + ) + + payload = { + "currency": currency, + "evolution": evolution_cfg, + "holdings": holdings_cfg, + "cashflows": cashflows_cfg, + "income": income_cfg, + } + # Escape < and > so the JSON is always safe to inline inside a ". + data_json = json.dumps(payload).replace("<", "\\u003c").replace(">", "\\u003e") + + parts.append( + "\n" + "\n" + "" + ) + return "\n".join(parts) diff --git a/skills/xtb-portfolio-review/scripts/main.py b/skills/xtb-portfolio-review/scripts/main.py new file mode 100644 index 0000000..ba4699a --- /dev/null +++ b/skills/xtb-portfolio-review/scripts/main.py @@ -0,0 +1,2185 @@ +import argparse +import contextlib +import io +import re +from dataclasses import dataclass, field +from datetime import datetime, date, timedelta +from html import escape +from pathlib import Path + +import pandas as pd + +import html_charts + +REPORT_FILE: Path | None = None # resolved per run via resolve_report_file() +POSITIONS_SHEET = "Closed Positions" +OPEN_POSITIONS_SHEET = "Open Positions" +CASH_SHEET = "Cash Operations" +HEADER_ROW = 4 +RESULTS_DIR = Path("results") + +# XTB ticker codes that don't resolve on Yahoo → verified same-fund Yahoo symbols. +# Only add mappings confirmed to be the SAME fund (same ISIN/share class), never a +# proxy from a different fund (would produce wrong absolute prices). +# MEUD.FR = Amundi Core STOXX Europe 600 UCITS ETF (Euronext Paris: .FR / .PA) +SYMBOL_ALIASES = { + "MEUD.FR": "MEUD.PA", +} + +# Tickers intentionally left at cost (no trusted live price). `reason` is surfaced +# in the report so the decision is documented. A same-ISIN Yahoo symbol that +# *diverges* from the broker is NOT a valid price source — it's a different share +# class and would distort the valuation, so we hold at cost instead. +COST_FALLBACK_NOTES = { + "SXXPIEX.DE": ( + "no trusted live price; the same-ISIN Yahoo symbol EXSA.DE diverges from " + "the broker (different share class), so held at cost" + ), +} + +# XTB "Type" values that represent trading activity (not cash transfers). +TRADE_TYPE_RE = re.compile( + r"stock\s*(purchase|sale|buy|sell)|\bopen\b|\bclose\b", + re.IGNORECASE, +) +# XTB comment: "OPEN BUY 6 @ 301.50", "CLOSE SELL 2 @ 14.31", ... +TRADE_COMMENT_RE = re.compile(r"(OPEN|CLOSE)\s+(BUY|SELL)\b", re.IGNORECASE) +PRICE_RE = re.compile(r"@\s*([\d.,]+)") +QTY_RE = re.compile(r"(?:OPEN|CLOSE)\s+(?:BUY|SELL)\s+([\d./]+)", re.IGNORECASE) +DIVIDEND_RE = re.compile(r"\bdividend|dywidend|dividende\b", re.IGNORECASE) +DIVIDEND_TAX_RE = re.compile(r"dividend\s*tax|tax.*dividend|withholding", re.IGNORECASE) +INTEREST_RE = re.compile(r"interest|free.?funds", re.IGNORECASE) +DEPOSIT_RE = re.compile(r"deposit|top.?up|deposit.?funds", re.IGNORECASE) +WITHDRAW_RE = re.compile(r"withdraw|withdrawal|payout", re.IGNORECASE) +CONVERSION_RE = re.compile(r"currency\s*conversion|conversion\s*fee|fx", re.IGNORECASE) + + +def resolve_report_file(path: Path | str | None = None) -> Path: + """Resolve the XTB report file to process. + + Preference: + 1. An explicit ``path`` (from the CLI or a library call). + 2. The single ``.xlsx`` in the current working directory (auto-detect), + skipping Excel lock files (``~$...``) and dotfiles. + + Raises FileNotFoundError when there is no candidate and ValueError when + several candidates make the choice ambiguous. Works with any same-format + XTB export regardless of account or period. + """ + if path is not None: + return Path(path) + + candidates = [ + p for p in sorted(Path.cwd().glob("*.xlsx")) + if not p.name.startswith(("~$", ".")) + ] + if not candidates: + raise FileNotFoundError( + "No .xlsx report found in the current directory. " + "Pass it explicitly, e.g.: python main.py " + ) + if len(candidates) > 1: + names = ", ".join(p.name for p in candidates) + raise ValueError( + f"Multiple .xlsx files found ({names}). " + "Specify which one to use, e.g.: python main.py " + ) + return candidates[0] + + +def detect_currency() -> str: + name = REPORT_FILE.stem.upper() + for prefix in ("EUR", "USD", "GBP", "PLN", "CHF", "JPY", "AUD", "CAD", "CZK", "HUF"): + if name.startswith(prefix): + return prefix + return "EUR" + + +# --------------------------------------------------------------------------- +# Generic helpers +# --------------------------------------------------------------------------- +def clean_columns(df: pd.DataFrame) -> pd.DataFrame: + df = df.copy() + df.columns = ( + df.columns.astype(str) + .str.strip() + .str.lower() + .str.replace(r"\s+", "_", regex=True) + .str.replace(r"[^\w_]", "", regex=True) + ) + return df + + +def find_column(df: pd.DataFrame, candidates: list[str], required: bool = True) -> str | None: + normalized = {col.lower(): col for col in df.columns} + for candidate in candidates: + if candidate in normalized: + return normalized[candidate] + for col in df.columns: + for candidate in candidates: + if candidate in col: + return col + if required: + raise ValueError( + f"Could not find any of these columns: {candidates}. " + f"Available columns: {list(df.columns)}" + ) + return None + + +def parse_numeric(series: pd.Series) -> pd.Series: + return ( + series.astype(str) + .str.replace(",", ".", regex=False) + .str.replace(r"[^\d.\-]", "", regex=True) + .replace("", pd.NA) + .pipe(pd.to_numeric, errors="coerce") + .fillna(0.0) + ) + + +def money(value: float) -> str: + return f"{value:,.2f}" + + +# --------------------------------------------------------------------------- +# Loading +# --------------------------------------------------------------------------- +def load_meta() -> dict[str, str]: + raw = pd.read_excel(REPORT_FILE, sheet_name=CASH_SHEET, header=None, nrows=4) + meta = {"account": "", "period_from": "", "period_to": ""} + for _, row in raw.iterrows(): + key = str(row.iloc[0]).strip().lower() + val = "" if pd.isna(row.iloc[1]) else str(row.iloc[1]).strip() + if "account" in key: + meta["account"] = val + elif "from" in key: + meta["period_from"] = val + elif "to" in key: + meta["period_to"] = val + return meta + + +def load_data() -> tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame, float]: + if not REPORT_FILE.exists(): + raise FileNotFoundError(f"Could not find {REPORT_FILE.resolve()}") + + sheet_names = pd.ExcelFile(REPORT_FILE).sheet_names + + positions = pd.read_excel(REPORT_FILE, sheet_name=POSITIONS_SHEET, header=HEADER_ROW) + cash_ops = pd.read_excel(REPORT_FILE, sheet_name=CASH_SHEET, header=HEADER_ROW) + open_positions = ( + pd.read_excel(REPORT_FILE, sheet_name=OPEN_POSITIONS_SHEET, header=HEADER_ROW) + if OPEN_POSITIONS_SHEET in sheet_names + else pd.DataFrame() + ) + + positions = clean_columns(positions).dropna(how="all") + cash_ops = clean_columns(cash_ops).dropna(how="all") + open_positions = clean_columns(open_positions).dropna(how="all") + + # Capture the broker-reported "Total" row before dropping it (for reconciliation). + broker_total = 0.0 + type_col = find_column(cash_ops, ["type", "operation"], required=False) + amount_col = find_column( + cash_ops, ["amount", "value", "net_amount", "cash", "change", "payment"], + required=False, + ) + if type_col and amount_col: + total_mask = cash_ops[type_col].astype(str).str.strip().str.match( + r"(?i)total", na=False + ) + if total_mask.any(): + broker_total = float(parse_numeric(cash_ops.loc[total_mask, amount_col]).iloc[0]) + + # Drop summary/total rows that carry no per-row detail. + pos_col = find_column(positions, ["instrument"], required=False) + if pos_col is not None: + positions = positions.loc[ + ~positions[pos_col].astype(str).str.strip().str.match( + r"(?i)total|profit/?loss|totals", na=False + ) + ].copy() + if type_col is not None: + cash_ops = cash_ops.loc[ + ~cash_ops[type_col].astype(str).str.strip().str.match( + r"(?i)total|profit/?loss|totals", na=False + ) + ].copy() + + return positions, cash_ops, open_positions, broker_total + + +# --------------------------------------------------------------------------- +# Trade parsing (from Cash Operations comments) +# --------------------------------------------------------------------------- +@dataclass +class Trade: + ticker: str + action: str # "open" or "close" + side: str # "buy" or "sell" + shares: float + price: float + value: float # gross cash magnitude (always positive) + date: pd.Timestamp | None = None + name: str = "" # descriptive instrument label (e.g. "S&P 500") + + +def parse_quantity(token: str) -> float: + token = token.strip() + if "/" in token: + num, den = token.split("/", 1) + try: + return float(num) / float(den) if float(den) != 0 else 0.0 + except ValueError: + return 0.0 + try: + return float(token.replace(",", ".")) + except ValueError: + return 0.0 + + +def normalize_trade_side(type_val: str, action: str, side: str) -> str: + """Return economic side for XTB trade rows.""" + lowered_type = type_val.lower() + if action == "close" and side == "buy" and "sell" in lowered_type: + return "sell" + return side + + +def parse_executed_quantity(comment: str, value: float, price: float) -> float: + match = QTY_RE.search(comment) + if match: + token = match.group(1) + if "/" in token: + try: + numerator = float(token.split("/", 1)[0].replace(",", ".")) + if numerator > 0: + return numerator + except ValueError: + pass + parsed = parse_quantity(token) + if parsed > 0: + return parsed + return round(abs(value) / price, 6) if price > 0 else 0.0 + + +def extract_trades(cash_ops: pd.DataFrame) -> list[Trade]: + type_col = find_column(cash_ops, ["type", "operation"], required=False) + ticker_col = find_column( + cash_ops, ["ticker", "symbol", "instrument", "market"], required=False + ) + name_col = find_column(cash_ops, ["instrument", "name", "description"], required=False) + amount_col = find_column( + cash_ops, ["amount", "value", "net_amount", "cash", "change", "payment"], + required=False, + ) + date_col = find_column( + cash_ops, ["time", "date", "operation_date", "booking_date", "transaction_date"], + required=False, + ) + comment_col = find_column(cash_ops, ["comment", "description", "details"], required=False) + + if not (type_col and ticker_col and amount_col): + return [] + + trades: list[Trade] = [] + for _, row in cash_ops.iterrows(): + type_val = str(row.get(type_col, "")).strip() + comment = str(row.get(comment_col, "")) if comment_col else "" + + is_trade = bool(TRADE_TYPE_RE.search(type_val)) or bool(TRADE_COMMENT_RE.search(comment)) + if not is_trade: + continue + if "dividend" in type_val.lower() or "interest" in type_val.lower(): + continue + + match = TRADE_COMMENT_RE.search(comment) + if match: + action = match.group(1).lower() + side = normalize_trade_side(type_val, action, match.group(2).lower()) + else: + action = "open" + lowered = type_val.lower() + side = "buy" if any(t in lowered for t in ("buy", "purchase")) else "sell" + + value = parse_numeric(pd.Series([row[amount_col]])).iloc[0] + value = abs(float(value)) + if value <= 0: + continue + + price = 0.0 + price_match = PRICE_RE.search(comment) + if price_match: + price = parse_numeric(pd.Series([price_match.group(1)])).iloc[0] + + shares = parse_executed_quantity(comment, value, price) + + dt = pd.to_datetime(row.get(date_col), errors="coerce") if date_col else pd.NaT + raw_name = "" + if name_col: + nv = row.get(name_col) + raw_name = "" if pd.isna(nv) else str(nv).strip() + trades.append( + Trade( + ticker=str(row[ticker_col]).strip(), + action=action, + side=side, + shares=float(shares), + price=float(price), + value=float(value), + date=None if pd.isna(dt) else dt, + name=raw_name, + ) + ) + return trades + + +# --------------------------------------------------------------------------- +# Live market prices (yfinance) +# --------------------------------------------------------------------------- +def _parse_as_of(meta: dict[str, str]) -> date: + """Valuation date = report 'Date to'. Falls back to today.""" + raw = meta.get("period_to", "") + ts = pd.to_datetime(raw, errors="coerce") + if pd.isna(ts): + return date.today() + return ts.date() + + +def _yf(): + """Lazy import so tests / offline runs don't require yfinance.""" + import yfinance as yf + return yf + + +def _history(ticker, **kwargs): + """Call yfinance history while suppressing noisy transport diagnostics.""" + with contextlib.redirect_stderr(io.StringIO()): + return ticker.history(**kwargs) + + +_PRICE_CACHE: dict[str, dict | None] = {} + + +def fetch_prices( + tickers: list[str], + as_of: date, + account_currency: str, +) -> dict[str, dict | None]: + """Fetch last available close on/before `as_of` for each ticker. + + Returns {ticker: {"price", "currency", "fx", "as_of", "source"} | None}. + Never raises — failed lookups map to None (caller falls back to cost). + """ + out: dict[str, dict | None] = {} + missing = [t for t in tickers if t not in _PRICE_CACHE] + if missing: + try: + yf = _yf() + except Exception: + for t in missing: + _PRICE_CACHE[t] = None + else: + start = as_of - timedelta(days=14) + end = as_of + timedelta(days=1) # history `end` is exclusive + for t in missing: + fetch_sym = SYMBOL_ALIASES.get(t, t) + _PRICE_CACHE[t] = _fetch_one( + yf, fetch_sym, start, end, as_of, account_currency + ) + for t in tickers: + out[t] = _PRICE_CACHE.get(t) + return out + + +def _fetch_one(yf, ticker, start, end, as_of, account_currency) -> dict | None: + sym = ticker.strip().upper() + if not sym or sym == "NAN": + return None + for _attempt in range(2): # one retry on transient failure + try: + tk = yf.Ticker(sym) + hist = _history(tk, start=start, end=end, auto_adjust=False) + if hist is None or hist.empty: + continue + # Normalize to naive dates for comparison (history is tz-aware). + idx_naive = pd.to_datetime(hist.index).tz_localize(None) + mask = idx_naive <= pd.Timestamp(as_of) + hist = hist.loc[mask] + if hist.empty: + continue + close = float(hist["Close"].iloc[-1]) + price_date = pd.to_datetime(hist.index[-1]).tz_localize(None).date() + try: + cur = (tk.fast_info.get("currency") or "").upper() + except Exception: + cur = "" + if not cur: + cur = account_currency.upper() + fx = 1.0 + if cur and cur != account_currency.upper(): + fx = _fx_rate(yf, cur, account_currency.upper()) + if fx is None: + return None + return { + "price": close, + "currency": cur, + "fx": fx, + "price_local": close, + "as_of": price_date, + "source": "live", + } + except Exception: + continue + return None + + +def _fx_rate(yf, from_cur: str, to_cur: str) -> float | None: + pair = f"{from_cur}{to_cur}=X" + try: + tk = yf.Ticker(pair) + hist = _history(tk, period="5d", auto_adjust=False) + if hist is None or hist.empty: + return None + return float(hist["Close"].iloc[-1]) + except Exception: + return None + + +# --------------------------------------------------------------------------- +# Historical daily closes (for the evolution chart) +# --------------------------------------------------------------------------- +_PRICE_HISTORY_CACHE: dict[str, pd.Series | None] = {} + + +def fetch_price_history( + tickers: list[str], + start: date, + end: date, + account_currency: str, +) -> dict[str, pd.Series | None]: + """Fetch daily closes in account currency for each ticker over [start, end]. + + Returns {ticker: pd.Series (naive-date index -> close in acct ccy) | None}. + Never raises — failed lookups map to None (caller falls back to cost). + Only call for tickers already valued "live"; cost-fallback tickers are held + flat at cost by ``build_evolution_series``. + """ + out: dict[str, pd.Series | None] = {} + missing = [t for t in tickers if t not in _PRICE_HISTORY_CACHE] + if missing: + try: + yf = _yf() + except Exception: + for t in missing: + _PRICE_HISTORY_CACHE[t] = None + else: + # Pad a week back so `asof` has a prior close on the first trade day. + fetch_start = start - timedelta(days=7) + fetch_end = end + timedelta(days=1) # history `end` is exclusive + for t in missing: + _PRICE_HISTORY_CACHE[t] = _fetch_history_one( + yf, t, fetch_start, fetch_end, account_currency + ) + for t in tickers: + out[t] = _PRICE_HISTORY_CACHE.get(t) + return out + + +def _fetch_history_one( + yf, ticker: str, start: date, end: date, account_currency: str +) -> pd.Series | None: + sym = SYMBOL_ALIASES.get(ticker, ticker).strip().upper() + if not sym or sym == "NAN": + return None + try: + tk = yf.Ticker(sym) + hist = _history(tk, start=start, end=end, auto_adjust=False) + if hist is None or hist.empty: + return None + idx = pd.to_datetime(hist.index).tz_localize(None) + closes = pd.Series(hist["Close"].values, index=idx, name=ticker) + try: + cur = (tk.fast_info.get("currency") or "").upper() + except Exception: + cur = "" + if not cur: + cur = account_currency.upper() + if cur and cur != account_currency.upper(): + fx_series = _fx_history(yf, cur, account_currency.upper(), start, end) + if fx_series is None: + return None + fx_vals = fx_series.reindex(closes.index, method="ffill") + closes = (closes * fx_vals).dropna() + return closes.sort_index() + except Exception: + return None + + +def _fx_history(yf, from_cur: str, to_cur: str, start: date, end: date) -> pd.Series | None: + pair = f"{from_cur}{to_cur}=X" + try: + tk = yf.Ticker(pair) + hist = _history(tk, start=start, end=end, auto_adjust=False) + if hist is None or hist.empty: + return None + idx = pd.to_datetime(hist.index).tz_localize(None) + return pd.Series(hist["Close"].values, index=idx).sort_index() + except Exception: + return None + + +# --------------------------------------------------------------------------- +# Live valuation per holding +# --------------------------------------------------------------------------- +def valuate_holdings( + holdings: pd.DataFrame, + prices: dict[str, dict | None], +) -> pd.DataFrame: + """Add last_price, market_value, unrealized_pl, return_pct, price_source, weight_pct.""" + df = holdings.copy() + if df.empty: + for col in ("last_price", "market_value", "unrealized_pl", + "return_pct", "price_source", "weight_pct"): + df[col] = pd.Series(dtype=float if col != "price_source" else object) + return df + + last_price = [] + market_value = [] + unrealized_pl = [] + source = [] + for _, row in df.iterrows(): + info = prices.get(row["ticker"]) + if info and info.get("price"): + price = float(info["price"]) * float(info.get("fx", 1.0)) + mv = float(row["shares"]) * price + last_price.append(round(price, 6)) + market_value.append(round(mv, 4)) + unrealized_pl.append(round(mv - float(row["cost_basis"]), 4)) + source.append("live") + else: + last_price.append(float(row["avg_price"])) + market_value.append(float(row["cost_basis"])) + unrealized_pl.append(0.0) + source.append("cost") + df["last_price"] = last_price + df["market_value"] = market_value + df["unrealized_pl"] = unrealized_pl + df["price_source"] = source + df["return_pct"] = df.apply( + lambda r: round(r["unrealized_pl"] / r["cost_basis"] * 100, 2) + if r["cost_basis"] else 0.0, + axis=1, + ) + total_mv = df["market_value"].sum() + df["weight_pct"] = ( + (df["market_value"] / total_mv * 100).round(2) if total_mv else 0.0 + ) + return df + + +# --------------------------------------------------------------------------- +# Holdings + realized P/L (FIFO) +# --------------------------------------------------------------------------- +def analyze_holdings( + trades: list[Trade], +) -> tuple[pd.DataFrame, pd.DataFrame]: + """Return (open_holdings, realized_pl) using FIFO lot matching. + + realized_pl covers every ticker that had a closing trade, including those + now fully closed (which no longer appear in open_holdings). + + Trades are processed in chronological order: a position cannot be closed + before it is opened, and XTB sheets sometimes list close legs before their + open legs (stable sort preserves sheet order for equal/unknown timestamps). + """ + lots: dict[str, list[tuple[float, float]]] = {} # ticker -> [(shares, price)] + names: dict[str, str] = {} # ticker -> display name + realized: dict[str, float] = {} + + _sort_key = lambda t: t.date if t.date is not None else pd.Timestamp.min + for t in sorted(trades, key=_sort_key): + bucket = lots.setdefault(t.ticker, []) + if t.name and t.ticker not in names: + names[t.ticker] = t.name + if t.action == "open": + if t.side == "buy": + bucket.append((t.shares, t.price)) + else: # opening a short + bucket.append((-t.shares, t.price)) + else: # close + to_close = t.shares + close_value = t.value + cost_consumed = 0.0 + while to_close > 1e-9 and bucket: + lot_shares, lot_price = bucket[0] + if abs(lot_shares) < 1e-9: + bucket.pop(0) + continue + # lot sign indicates long(+)/short(-); closing uses same magnitude. + magnitude = min(abs(lot_shares), to_close) + cost_consumed += magnitude * lot_price + remaining = abs(lot_shares) - magnitude + sign = 1 if lot_shares >= 0 else -1 + if remaining > 1e-9: + bucket[0] = (sign * remaining, lot_price) + else: + bucket.pop(0) + to_close -= magnitude + # For a long close, proceeds (close_value) - cost = gain. + realized[t.ticker] = realized.get(t.ticker, 0.0) + (close_value - cost_consumed) + + rows = [] + for ticker, bucket in lots.items(): + net_shares = sum(s for s, _ in bucket) + if abs(net_shares) < 1e-4: + continue # fully closed (tolerance absorbs float residue) -> not an open holding + long_shares = sum(s for s, _ in bucket if s > 0) + cost_basis = sum(abs(s) * p for s, p in bucket) + avg_price = cost_basis / long_shares if long_shares > 0 else 0.0 + rows.append( + { + "ticker": ticker, + "name": names.get(ticker, ""), + "shares": round(net_shares, 6), + "cost_basis": round(cost_basis, 4), + "avg_price": round(avg_price, 4), + } + ) + + holdings_cols = ["ticker", "name", "shares", "cost_basis", "avg_price"] + if rows: + df = pd.DataFrame(rows).sort_values("cost_basis", ascending=False).reset_index(drop=True) + total_cost = df["cost_basis"].sum() + df["allocation_pct"] = ( + (df["cost_basis"] / total_cost * 100).round(2) if total_cost else 0.0 + ) + else: + df = pd.DataFrame(columns=holdings_cols + ["allocation_pct"]) + + realized_df = ( + pd.DataFrame( + [{"ticker": k, "realized_pl": round(v, 4)} for k, v in realized.items() if abs(v) > 1e-9] + ) + if realized + else pd.DataFrame(columns=["ticker", "realized_pl"]) + ) + return df, realized_df + + +def analyze_realized( + positions: pd.DataFrame, realized_from_trades: pd.DataFrame +) -> pd.DataFrame: + # Prefer the broker's Closed Positions Profit/Loss when available. + if not positions.empty: + ticker_col = find_column( + positions, ["ticker", "symbol", "instrument", "market"], required=False + ) + pl_col = find_column( + positions, ["profit_loss", "profitloss", "profit", "pnl", "result"], + required=False, + ) + if ticker_col and pl_col: + return ( + positions.assign(_pl=parse_numeric(positions[pl_col])) + .groupby(ticker_col)["_pl"] + .sum() + .reset_index() + .rename(columns={ticker_col: "ticker", "_pl": "realized_pl"}) + ) + + return realized_from_trades.reset_index(drop=True) + + +# --------------------------------------------------------------------------- +# Cash flow analysis +# --------------------------------------------------------------------------- +def analyze_cash_flows( + cash_ops: pd.DataFrame, trades: list[Trade] +) -> tuple[dict[str, float], float]: + type_col = find_column(cash_ops, ["type", "operation"], required=False) + amount_col = find_column( + cash_ops, ["amount", "value", "net_amount", "cash", "change", "payment"], + required=False, + ) + comment_col = find_column(cash_ops, ["comment", "description", "details"], required=False) + + flows = { + "deposits": 0.0, + "withdrawals": 0.0, + "interest": 0.0, + "dividends": 0.0, + "dividend_tax": 0.0, + "conversion_fees": 0.0, + "invested": 0.0, + "proceeds": 0.0, + "fees": 0.0, + } + + trade_ids = set() + if comment_col: + for _, row in cash_ops.iterrows(): + comment = str(row.get(comment_col, "")) + if TRADE_COMMENT_RE.search(comment): + trade_ids.add(row.name) + + if type_col and amount_col: + for idx, row in cash_ops.iterrows(): + if idx in trade_ids: + continue + type_val = str(row.get(type_col, "")).strip() + amount = float(parse_numeric(pd.Series([row[amount_col]])).iloc[0]) + text = f"{type_val} {row.get(comment_col, '')}".lower() + + if DIVIDEND_TAX_RE.search(text): + flows["dividend_tax"] += amount + elif "tax" in type_val.lower(): + flows["fees"] += abs(amount) + elif DIVIDEND_RE.search(text): + flows["dividends"] += amount + elif INTEREST_RE.search(text): + flows["interest"] += amount + elif CONVERSION_RE.search(text): + flows["conversion_fees"] += amount + elif WITHDRAW_RE.search(text): + flows["withdrawals"] += abs(amount) + elif DEPOSIT_RE.search(text): + flows["deposits"] += abs(amount) + + # Trading cash impact from parsed trades (separates buys vs sells). + for t in trades: + if t.action == "open": + if t.side == "buy": + flows["invested"] += t.value + else: + flows["proceeds"] += t.value # short sale proceeds + else: # close + if t.side == "sell": + flows["proceeds"] += t.value + else: + flows["invested"] += t.value # buying to cover + + net_deposited = flows["deposits"] - flows["withdrawals"] + ending_cash = ( + net_deposited + + flows["interest"] + + flows["dividends"] + + flows["dividend_tax"] + - flows["invested"] + + flows["proceeds"] + - flows["fees"] + + flows["conversion_fees"] + ) + return flows, ending_cash + + +def analyze_income(cash_ops: pd.DataFrame) -> tuple[float, float, pd.Series]: + type_col = find_column(cash_ops, ["type", "operation"], required=False) + amount_col = find_column( + cash_ops, ["amount", "value", "net_amount", "cash", "change", "payment"], + required=False, + ) + date_col = find_column( + cash_ops, ["time", "date", "operation_date", "booking_date", "transaction_date"], + required=False, + ) + comment_col = find_column(cash_ops, ["comment", "description", "details"], required=False) + + dividends = interest = 0.0 + monthly: dict[str, float] = {} + + if not (type_col and amount_col): + return 0.0, 0.0, pd.Series(dtype=float) + + for _, row in cash_ops.iterrows(): + text = f"{row.get(type_col, '')} {row.get(comment_col, '') if comment_col else ''}".lower() + amount = float(parse_numeric(pd.Series([row[amount_col]])).iloc[0]) + if DIVIDEND_RE.search(text): + dividends += amount + period = _period(row, date_col) + if period: + monthly[period] = monthly.get(period, 0.0) + amount + elif INTEREST_RE.search(text): + interest += amount + period = _period(row, date_col) + if period: + monthly[period] = monthly.get(period, 0.0) + amount + + series = ( + pd.Series(monthly, name="income").sort_index() if monthly else pd.Series(dtype=float) + ) + return dividends, interest, series + + +def _period(row: pd.Series, date_col: str | None) -> str | None: + if not date_col: + return None + dt = pd.to_datetime(row.get(date_col), errors="coerce") + if pd.isna(dt): + return None + return str(dt.to_period("M")) + + +# --------------------------------------------------------------------------- +# Reporting +# --------------------------------------------------------------------------- +def analyze_open_positions( + open_positions: pd.DataFrame, + valued_holdings: pd.DataFrame | None = None, +) -> pd.DataFrame: + """Live market value & unrealized P/L per ticker. + + Preference order: + 1. XTB 'Open Positions' sheet (broker live values) when present. + 2. Live-valued holdings (yfinance) when provided. + Otherwise returns an empty frame so callers fall back to cost basis. + """ + empty_cols = ["ticker", "current_value", "unrealized_pl"] + + if open_positions is not None and not open_positions.empty: + ticker_col = find_column( + open_positions, ["ticker", "symbol", "instrument", "market"], required=False + ) + value_col = find_column( + open_positions, ["current_value", "value", "market_value", "position_value"], + required=False, + ) + pl_col = find_column( + open_positions, ["profit_loss", "profitloss", "profit", "pnl", "result", "unrealized"], + required=False, + ) + if ticker_col is not None and value_col is not None: + df = open_positions.copy() + df["_value"] = parse_numeric(df[value_col]) + df["_pl"] = parse_numeric(df[pl_col]) if pl_col else 0.0 + return ( + df.groupby(ticker_col) + .agg(current_value=("_value", "sum"), unrealized_pl=("_pl", "sum")) + .reset_index() + .rename(columns={ticker_col: "ticker"}) + .sort_values("current_value", ascending=False) + .reset_index(drop=True) + ) + + if valued_holdings is not None and not valued_holdings.empty: + cols = {"ticker", "market_value", "unrealized_pl"} + if cols.issubset(valued_holdings.columns): + return ( + valued_holdings[["ticker", "market_value", "unrealized_pl"]] + .rename(columns={"market_value": "current_value"}) + .sort_values("current_value", ascending=False) + .reset_index(drop=True) + ) + + return pd.DataFrame(columns=empty_cols) + + +def compute_xirr(cash_flows: list[tuple[pd.Timestamp, float]]) -> float | None: + """Return annualized IRR for dated cash flows, or None when unsolvable.""" + dated = [ + (pd.Timestamp(d).normalize(), float(v)) + for d, v in cash_flows + if abs(float(v)) > 1e-9 + ] + if not dated: + return None + if not any(v > 0 for _, v in dated) or not any(v < 0 for _, v in dated): + return None + dated.sort(key=lambda item: item[0]) + start = dated[0][0] + if dated[-1][0] <= start: + return None + + def npv(rate: float) -> float: + total = 0.0 + for dt, amount in dated: + years = (dt - start).days / 365.0 + total += amount / ((1.0 + rate) ** years) + return total + + low = -0.9999 + high = 1.0 + low_val = npv(low) + high_val = npv(high) + while low_val * high_val > 0 and high < 1000.0: + high *= 2.0 + high_val = npv(high) + if low_val * high_val > 0: + return None + + for _ in range(100): + mid = (low + high) / 2.0 + mid_val = npv(mid) + if abs(mid_val) < 1e-7: + return mid + if low_val * mid_val <= 0: + high = mid + high_val = mid_val + else: + low = mid + low_val = mid_val + return (low + high) / 2.0 + + +def build_external_cash_flows( + cash_ops: pd.DataFrame, + terminal_value: float, + terminal_date: date, +) -> list[tuple[pd.Timestamp, float]]: + """Build investor-perspective external flows for money-weighted return.""" + type_col = find_column(cash_ops, ["type", "operation"], required=False) + amount_col = find_column( + cash_ops, ["amount", "value", "net_amount", "cash", "change", "payment"], + required=False, + ) + date_col = find_column( + cash_ops, ["time", "date", "operation_date", "booking_date", "transaction_date"], + required=False, + ) + comment_col = find_column(cash_ops, ["comment", "description", "details"], required=False) + if not (type_col and amount_col and date_col): + return [] + + flows: list[tuple[pd.Timestamp, float]] = [] + for _, row in cash_ops.iterrows(): + text = f"{row.get(type_col, '')} {row.get(comment_col, '') if comment_col else ''}".lower() + dt = pd.to_datetime(row.get(date_col), errors="coerce") + if pd.isna(dt): + continue + amount = float(parse_numeric(pd.Series([row[amount_col]])).iloc[0]) + if DEPOSIT_RE.search(text): + flows.append((pd.Timestamp(dt).normalize(), -abs(amount))) + elif WITHDRAW_RE.search(text): + flows.append((pd.Timestamp(dt).normalize(), abs(amount))) + + if terminal_value > 0: + flows.append((pd.Timestamp(terminal_date).normalize(), float(terminal_value))) + return sorted(flows, key=lambda item: item[0]) + + +def compute_performance( + holdings: pd.DataFrame, + open_positions: pd.DataFrame, + realized: pd.DataFrame, + flows: dict[str, float], + ending_cash: float, + broker_total: float, + cash_ops: pd.DataFrame | None = None, + terminal_date: date | None = None, +) -> dict[str, float | None]: + cost_basis = float(holdings["cost_basis"].sum()) if not holdings.empty else 0.0 + + market_value = cost_basis + unrealized_pl = 0.0 + if not open_positions.empty: + market_value = float(open_positions["current_value"].sum()) + unrealized_pl = float(open_positions["unrealized_pl"].sum()) + + realized_pl = float(realized["realized_pl"].sum()) if not realized.empty else 0.0 + income = flows["interest"] + flows["dividends"] + + portfolio_value = market_value + ending_cash + net_deposited = flows["deposits"] - flows["withdrawals"] + total_gain = unrealized_pl + realized_pl + income + total_return_pct = (total_gain / net_deposited * 100) if net_deposited else 0.0 + income_yield_pct = (income / cost_basis * 100) if cost_basis else 0.0 + money_weighted_return_pct = None + if cash_ops is not None and terminal_date is not None: + external_flows = build_external_cash_flows(cash_ops, portfolio_value, terminal_date) + xirr = compute_xirr(external_flows) + money_weighted_return_pct = xirr * 100 if xirr is not None else None + # XTB "Total" row = ending free cash, so reconcile cash (not portfolio value). + diff = ending_cash - broker_total if broker_total else None + + return { + "cost_basis": cost_basis, + "market_value": market_value, + "unrealized_pl": unrealized_pl, + "realized_pl": realized_pl, + "income": income, + "total_gain": total_gain, + "portfolio_value": portfolio_value, + "ending_cash": ending_cash, + "net_deposited": net_deposited, + "total_return_pct": total_return_pct, + "money_weighted_return_pct": money_weighted_return_pct, + "income_yield_pct": income_yield_pct, + "broker_total": broker_total, + "reconciliation_diff": diff, + } + + +def _holding_weights(holdings: pd.DataFrame) -> pd.Series: + if holdings is None or holdings.empty: + return pd.Series(dtype=float) + if "weight_pct" in holdings.columns: + return pd.to_numeric(holdings["weight_pct"], errors="coerce").fillna(0.0) + if "market_value" not in holdings.columns: + return pd.Series([0.0] * len(holdings), index=holdings.index) + market_values = pd.to_numeric(holdings["market_value"], errors="coerce").fillna(0.0) + total = float(market_values.sum()) + return market_values / total * 100 if total else market_values * 0.0 + + +def analyze_concentration(holdings: pd.DataFrame, perf: dict[str, float]) -> dict[str, float | int | str]: + """Summarize simple concentration and data-quality risk indicators.""" + weights = _holding_weights(holdings).sort_values(ascending=False) + top_1 = float(weights.head(1).sum()) if not weights.empty else 0.0 + top_3 = float(weights.head(3).sum()) if not weights.empty else 0.0 + top_5 = float(weights.head(5).sum()) if not weights.empty else 0.0 + portfolio_value = float(perf.get("portfolio_value", 0.0) or 0.0) + cash_weight = ( + float(perf.get("ending_cash", 0.0) or 0.0) / portfolio_value * 100 + if portfolio_value + else 0.0 + ) + over_20 = int((weights > 20.0).sum()) if not weights.empty else 0 + cost_priced = 0 + if holdings is not None and not holdings.empty and "price_source" in holdings.columns: + cost_priced = int((holdings["price_source"].astype(str) == "cost").sum()) + + if top_1 >= 50.0: + note = f"High concentration: top holding is {top_1:.2f}%." + elif top_3 >= 75.0: + note = f"Elevated concentration: top 3 holdings are {top_3:.2f}%." + elif cost_priced: + note = f"Data quality watch: {cost_priced} holding{'s' if cost_priced != 1 else ''} priced at cost." + else: + note = "No major concentration flags from position weights." + + return { + "top_1_weight_pct": top_1, + "top_3_weight_pct": top_3, + "top_5_weight_pct": top_5, + "cash_weight_pct": cash_weight, + "positions_over_20_pct": over_20, + "cost_priced_positions": cost_priced, + "risk_note": note, + } + + +def build_executive_summary( + holdings: pd.DataFrame, + realized: pd.DataFrame, + flows: dict[str, float], + perf: dict[str, float], +) -> list[tuple[str, str]]: + """Return short reader-facing observations for the top of the HTML report.""" + del realized, flows # Kept in the signature so callers pass the full analysis context. + if holdings is None or holdings.empty: + largest = "No open positions" + winner = "None" + loser = "None" + cost_priced = 0 + else: + weights = _holding_weights(holdings) + largest_row = holdings.loc[weights.idxmax()] + largest = f"{largest_row['ticker']} ({float(weights.loc[largest_row.name]):.2f}%)" + + unrealized = pd.to_numeric(holdings.get("unrealized_pl", 0.0), errors="coerce").fillna(0.0) + winner_idx = unrealized.idxmax() + loser_idx = unrealized.idxmin() + winner_val = float(unrealized.loc[winner_idx]) + loser_val = float(unrealized.loc[loser_idx]) + winner = ( + f"{holdings.loc[winner_idx, 'ticker']} ({winner_val:+.2f})" + if winner_val > 0 + else "None" + ) + loser = ( + f"{holdings.loc[loser_idx, 'ticker']} ({loser_val:+.2f})" + if loser_val < 0 + else "None" + ) + cost_priced = ( + int((holdings["price_source"].astype(str) == "cost").sum()) + if "price_source" in holdings.columns + else 0 + ) + + portfolio_value = float(perf.get("portfolio_value", 0.0) or 0.0) + cash_allocation = ( + float(perf.get("ending_cash", 0.0) or 0.0) / portfolio_value * 100 + if portfolio_value + else 0.0 + ) + diff = perf.get("reconciliation_diff") + if diff is None: + recon = "Skipped" + else: + recon = "OK" if abs(float(diff)) < 0.01 else "CHECK" + pricing = ( + "No cost-pricing fallbacks" + if cost_priced == 0 + else f"{cost_priced} holding{'s' if cost_priced != 1 else ''} priced at cost" + ) + + return [ + ("Largest holding", largest), + ("Top unrealized winner", winner), + ("Top unrealized loser", loser), + ("Cash allocation", f"{cash_allocation:.2f}%"), + ("Pricing warnings", pricing), + ("Reconciliation", recon), + ] + + +def analyze_income_quality( + flows: dict[str, float], + perf: dict[str, float], +) -> dict[str, float | str]: + """Summarize income, withholding/tax drag, and income yield on cost.""" + dividends = float(flows.get("dividends", 0.0) or 0.0) + interest = float(flows.get("interest", 0.0) or 0.0) + dividend_tax = abs(float(flows.get("dividend_tax", 0.0) or 0.0)) + gross_income = dividends + interest + net_income = gross_income - dividend_tax + tax_drag_pct = dividend_tax / gross_income * 100 if gross_income else 0.0 + cost_basis = float(perf.get("cost_basis", 0.0) or 0.0) + net_income_yield_pct = net_income / cost_basis * 100 if cost_basis else 0.0 + if gross_income: + dividend_mix = dividends / gross_income * 100 + interest_mix = interest / gross_income * 100 + income_mix = f"{dividend_mix:.2f}% dividends / {interest_mix:.2f}% interest" + else: + income_mix = "No income" + return { + "gross_income": gross_income, + "dividend_tax": dividend_tax, + "net_income": net_income, + "tax_drag_pct": tax_drag_pct, + "net_income_yield_pct": net_income_yield_pct, + "income_mix": income_mix, + } + + +def analyze_methodology_quality( + holdings: pd.DataFrame, + perf: dict[str, float], +) -> list[tuple[str, str]]: + """Return report-method and data-quality notes for the HTML summary.""" + live_count = cost_count = 0 + fallback_tickers = [] + if holdings is not None and not holdings.empty and "price_source" in holdings.columns: + src = holdings["price_source"].astype(str) + live_count = int((src == "live").sum()) + cost_count = int((src == "cost").sum()) + if cost_count and "ticker" in holdings.columns: + fallback_tickers = holdings.loc[src == "cost", "ticker"].astype(str).tolist() + + diff = perf.get("reconciliation_diff") + if diff is None: + recon = "Skipped" + else: + recon = "OK" if abs(float(diff)) < 0.01 else "CHECK" + + fallback_label = ", ".join(fallback_tickers) if fallback_tickers else "None" + fallback_word = "fallback" if cost_count == 1 else "fallbacks" + return [ + ("Pricing coverage", f"{live_count} live / {cost_count} cost {fallback_word}"), + ("Cost fallback tickers", fallback_label), + ("Cash reconciliation", recon), + ("Realized P/L method", "Broker closed positions preferred; FIFO fallback"), + ("Money-weighted return", "External deposits/withdrawals plus terminal portfolio value"), + ("Valuation caveat", "Cost fallback positions carry zero unrealized P/L"), + ] + + +def beginner_guide_rows() -> list[tuple[str, str]]: + """Plain-language explanations for readers new to investing terms.""" + return [ + ( + "Market value", + "Think of market value as today's estimated selling value. It is what the position appears to be worth now, not what you originally paid.", + ), + ( + "Unrealized profit", + "Unrealized profit is only a paper gain until you sell. The price can still move up or down before that gain becomes real cash.", + ), + ( + "Realized profit", + "Realized profit is the gain or loss after a position was sold. It is already locked in by a completed sale.", + ), + ( + "Money-weighted return", + "Money-weighted return is useful when you added money at different times. It gives more weight to money that was invested for longer.", + ), + ( + "Cost fallback", + "A cost fallback means the report could not find a trusted live price, so it uses what you paid. Treat those values as conservative placeholders, not confirmed market prices.", + ), + ( + "Concentration", + "Concentration tells you whether too much of the portfolio depends on only a few holdings. A high number is not automatically bad, but it means those holdings matter more.", + ), + ] + + +def analyze_return_contributions( + holdings: pd.DataFrame, + realized: pd.DataFrame, + perf: dict[str, float], +) -> pd.DataFrame: + """Return ticker-level realized + unrealized contribution to total gain.""" + rows: dict[str, dict[str, float | str]] = {} + if holdings is not None and not holdings.empty: + for _, row in holdings.iterrows(): + ticker = str(row.get("ticker", "")).strip() + if not ticker: + continue + rows[ticker] = { + "Ticker": ticker, + "Market Value": float(row.get("market_value", 0.0) or 0.0), + "Unrealized P/L": float(row.get("unrealized_pl", 0.0) or 0.0), + "Realized P/L": 0.0, + } + + if realized is not None and not realized.empty and {"ticker", "realized_pl"}.issubset(realized.columns): + grouped = realized.groupby("ticker")["realized_pl"].sum() + for ticker, realized_pl in grouped.items(): + key = str(ticker) + rows.setdefault( + key, + { + "Ticker": key, + "Market Value": 0.0, + "Unrealized P/L": 0.0, + "Realized P/L": 0.0, + }, + ) + rows[key]["Realized P/L"] = float(realized_pl) + + if not rows: + return pd.DataFrame( + columns=[ + "Ticker", "Market Value", "Unrealized P/L", + "Realized P/L", "Total Contribution", "Contribution %", + ] + ) + + total_gain = float(perf.get("total_gain", 0.0) or 0.0) + out = pd.DataFrame(rows.values()) + out["Total Contribution"] = out["Unrealized P/L"] + out["Realized P/L"] + out["Contribution %"] = ( + out["Total Contribution"] / total_gain * 100 if abs(total_gain) > 1e-9 else 0.0 + ) + return out.sort_values("Total Contribution", ascending=False).reset_index(drop=True) + + +# --------------------------------------------------------------------------- +# Portfolio evolution (cost vs realized + unrealized value over time) +# --------------------------------------------------------------------------- +def _replay_trade( + lots: dict[str, list[tuple[float, float]]], + realized: dict[str, float], + trade: Trade, +) -> None: + """Apply one trade to the open-lots state (mutates lots + realized).""" + bucket = lots.setdefault(trade.ticker, []) + if trade.action == "open": + bucket.append((trade.shares, trade.price) if trade.side == "buy" + else (-trade.shares, trade.price)) + return + # close + to_close = trade.shares + close_value = trade.value + cost_consumed = 0.0 + while to_close > 1e-9 and bucket: + lot_shares, lot_price = bucket[0] + if abs(lot_shares) < 1e-9: + bucket.pop(0) + continue + magnitude = min(abs(lot_shares), to_close) + cost_consumed += magnitude * lot_price + remaining = abs(lot_shares) - magnitude + sign = 1 if lot_shares >= 0 else -1 + if remaining > 1e-9: + bucket[0] = (sign * remaining, lot_price) + else: + bucket.pop(0) + to_close -= magnitude + realized[trade.ticker] = realized.get(trade.ticker, 0.0) + (close_value - cost_consumed) + + +def build_evolution_series( + trades: list[Trade], + price_history: dict[str, pd.Series | None], + end_date: date, +) -> pd.DataFrame: + """Replay trades daily and compute cost / market value / realized P/L series. + + Returns a DataFrame indexed by date with columns: + ``cost`` (open cost basis), ``market_value`` (open lots at historical + close, falling back to cost when no live series), ``realized_pl`` + (cumulative), ``total_value`` (market_value + realized_pl). + + The gap between ``cost`` and ``total_value`` is the total investment gain / + loss. Tickers without a live price series contribute their open cost basis + as market value (i.e. zero unrealized P/L), matching the holdings table. + """ + empty = pd.DataFrame( + columns=["cost", "market_value", "realized_pl", "total_value"] + ) + dated = [t for t in trades if t.date is not None] + if not dated: + return empty + + sorted_trades = sorted(dated, key=lambda t: t.date) + start_date = pd.Timestamp(sorted_trades[0].date).normalize() + end_ts = pd.Timestamp(end_date) + if end_ts < start_date: + end_ts = start_date + dates = pd.date_range(start=start_date, end=end_ts, freq="D") + + lots: dict[str, list[tuple[float, float]]] = {} + realized: dict[str, float] = {} + trade_idx = 0 + n = len(sorted_trades) + rows = [] + for d in dates: + while trade_idx < n and pd.Timestamp(sorted_trades[trade_idx].date).normalize() <= d: + _replay_trade(lots, realized, sorted_trades[trade_idx]) + trade_idx += 1 + cost = 0.0 + market_value = 0.0 + for ticker, bucket in lots.items(): + series = price_history.get(ticker) + for shares, lot_price in bucket: + lot_cost = abs(shares) * lot_price + cost += lot_cost + if series is not None and len(series): + close = series.asof(d) + if close is not None and not pd.isna(close): + market_value += shares * float(close) + else: + market_value += lot_cost + else: + market_value += lot_cost + realized_total = sum(realized.values()) + rows.append({ + "cost": round(cost, 4), + "market_value": round(market_value, 4), + "realized_pl": round(realized_total, 4), + "total_value": round(market_value + realized_total, 4), + }) + df = pd.DataFrame(rows, index=dates) + df.index.name = "date" + return df + + +def print_report( + currency: str, + meta: dict[str, str], + flows: dict[str, float], + ending_cash: float, + holdings: pd.DataFrame, + open_positions: pd.DataFrame, + realized: pd.DataFrame, + perf: dict[str, float], + dividends: float, + interest: float, + as_of: date | None = None, + cost_fallback_tickers: list[str] | None = None, +) -> None: + cost_fallback_tickers = cost_fallback_tickers or [] + print(f"\nPORTFOLIO REVIEW — XTB account {meta.get('account', '?')}") + print("=" * 80) + print( + f"Period: {meta.get('period_from', '?')} → {meta.get('period_to', '?')} " + f"({currency})" + ) + val_date = as_of.isoformat() if as_of else meta.get("period_to", "?") + print(f"Generated: {datetime.now():%Y-%m-%d %H:%M:%S} Valuation date: {val_date}") + + print("\nCASH FLOWS") + print("-" * 80) + print(f" Deposits: {money(flows['deposits']):>14}") + print(f" Withdrawals: {money(-flows['withdrawals']):>14}") + print(f" Free-funds interest: {money(flows['interest']):>14}") + print(f" Dividends received: {money(flows['dividends']):>14}") + print(f" Dividend tax: {money(flows['dividend_tax']):>14}") + print(f" Invested (buys): {money(-flows['invested']):>14}") + print(f" Proceeds (sales): {money(flows['proceeds']):>14}") + print(f" FX conversion fees: {money(flows['conversion_fees']):>14}") + print(f" Fees / commissions: {money(-flows['fees']):>14}") + print(f" Ending cash balance: {money(ending_cash):>14}") + + print("\nHOLDINGS (live market value)") + print("-" * 80) + if holdings.empty or holdings["market_value"].sum() == 0: + print(" No open positions.") + else: + view = holdings[["ticker", "name", "shares", "last_price", + "market_value", "unrealized_pl", "return_pct", + "weight_pct", "price_source"]].copy() + view.columns = ["Ticker", "Name", "Shares", "Last Price", + "Market Value", "Unrealized P/L", "Return %", + "Weight %", "Src"] + print(view.to_string(index=False)) + print(f"\n Total cost basis: {money(perf['cost_basis']):>14}") + print(f" Total market value: {money(perf['market_value']):>14}") + if cost_fallback_tickers: + print(f" (priced at cost: {', '.join(cost_fallback_tickers)})") + for tk in cost_fallback_tickers: + if tk in COST_FALLBACK_NOTES: + print(f" · {tk}: {COST_FALLBACK_NOTES[tk]}") + + print("\nOPEN POSITIONS (market value)") + print("-" * 80) + if open_positions is None or open_positions.empty: + print(" No open positions.") + else: + view = open_positions.copy() + view["weight_pct"] = ( + view["current_value"] / view["current_value"].sum() * 100 + ).round(2) + view.columns = ["Ticker", "Market Value", "Unrealized P/L", "Weight %"] + print(view.to_string(index=False)) + print( + f"\n Total market value: {money(perf['market_value']):>14}" + f" Unrealized P/L: {money(perf['unrealized_pl']):>12}" + ) + + print("\nREALIZED P/L (closed positions)") + print("-" * 80) + if realized.empty or (realized["realized_pl"].abs().sum() == 0): + print(" No realized gains/losses in this period.") + else: + print(realized.to_string(index=False)) + print(f"\n Total realized P/L: {money(perf['realized_pl']):>14}") + + print("\nPERFORMANCE") + print("-" * 80) + print(f" Portfolio value: {money(perf['portfolio_value']):>14}") + print(f" of which market val: {money(perf['market_value']):>14}") + print(f" of which cash: {money(perf['ending_cash']):>14}") + print(f" of which cost basis: {money(perf['cost_basis']):>14}") + print(f" Net deposited: {money(perf['net_deposited']):>14}") + print(f" Unrealized P/L: {money(perf['unrealized_pl']):>14}") + print(f" Realized P/L: {money(perf['realized_pl']):>14}") + print(f" Income (int. + div.): {money(perf['income']):>14}") + print(f" Total gain: {money(perf['total_gain']):>14}") + print(f" Total return: {perf['total_return_pct']:>13.2f}%") + if perf.get("money_weighted_return_pct") is not None: + print(f" Money-weighted return: {perf['money_weighted_return_pct']:>13.2f}%") + else: + print(f" Money-weighted return: {'n/a':>14}") + print(f" Income yield (on cost):{perf['income_yield_pct']:>12.2f}%") + + print("\nRECONCILIATION") + print("-" * 80) + if perf["broker_total"]: + diff = perf["reconciliation_diff"] + status = "OK" if abs(diff) < 0.01 else "CHECK" + print( + f" Computed ending cash: {money(perf['ending_cash']):>10}\n" + f" Broker 'Total' (cash): {money(perf['broker_total']):>10}\n" + f" Difference: {money(diff):>10} [{status}]" + ) + else: + print(" Broker 'Total' row not found — reconciliation skipped.") + print() + + +# --------------------------------------------------------------------------- +# HTML report +# --------------------------------------------------------------------------- +TERM_TOOLTIPS = { + "Ticker": "A ticker is the short code used by markets and brokers to identify an investment, like a label on an exchange.", + "ticker": "A ticker is the short code used by markets and brokers to identify an investment, like a label on an exchange.", + "Name": "The longer human-readable name of the investment.", + "Shares": "How many units of the investment you currently hold.", + "Last Price": "The latest price used for one share or unit. If no trusted live price exists, this may be the average cost.", + "Src": "Source of the price: live means fetched from market data; cost means the report used what you paid.", + "Portfolio value": "What your portfolio is worth after including market value and cash.", + "Market Value": "Today's estimated value for a holding. If the report cannot find a trusted price, it uses your original cost instead.", + "market_value": "Market value is today's estimated value for a holding. If no trusted price is found, the report may use cost instead.", + "Net deposited": "Total money added to the account minus withdrawals.", + "Deposits": "Money you added to the brokerage account.", + "Withdrawals": "Money you took out of the brokerage account.", + "Free-funds interest": "Small interest paid by the broker on cash that was not invested.", + "Dividends received": "Cash paid by investments, usually from company profits or fund distributions.", + "Invested (buys)": "Money spent buying investments. It reduces cash but increases holdings.", + "Proceeds (sales)": "Money received from selling investments. It increases cash.", + "FX conversion fees": "Costs or adjustments from converting between currencies.", + "Fees / commissions": "Broker or transaction costs paid for account activity.", + "Ending cash balance": "Cash left in the account after all deposits, withdrawals, trades, income, and fees.", + "Total gain": "Unrealized gains plus realized gains plus income.", + "Total return": "Total gain divided by net deposited. Simple return, not adjusted for deposit timing.", + "Money-weighted return": "This answers: how did my money do, considering the dates I added or withdrew cash? It is useful when deposits happened at different times.", + "Income yield (on cost)": "Income divided by the cost basis of current holdings.", + "Cost basis": "The amount paid for the open position before any current market gain or loss.", + "cost basis": "The amount paid for the open position before any current market gain or loss.", + "Unrealized P/L": "Profit or loss on positions you still hold. It is not locked in until you sell.", + "unrealized_pl": "unrealized_pl means unrealized profit or loss: the gain or loss on positions you still hold.", + "Realized P/L": "Profit or loss from positions that were sold or closed.", + "realized_pl": "realized_pl means realized profit or loss: the gain or loss already locked in by selling or closing a position.", + "Return %": "Unrealized profit or loss divided by cost basis.", + "Weight %": "The holding's share of total current market value.", + "Cash allocation": "The share of the portfolio currently held as cash.", + "Pricing warnings": "Positions that could not be priced from a trusted live source.", + "Pricing coverage": "How many holdings use live prices versus cost fallback values.", + "Cost fallback tickers": "Tickers valued at cost because a trusted live price was unavailable. Their real market value may be higher or lower.", + "Cost fallback positions": "Positions valued at cost because a trusted live price was unavailable. Their real market value may be higher or lower.", + "Reconciliation": "A check that computed ending cash matches the broker's reported cash total.", + "Cash reconciliation": "A check that computed ending cash matches the broker's reported cash total.", + "Computed ending cash": "Computed ending cash is the cash balance calculated from all cash operations in the report.", + "Broker 'Total' (cash)": "Broker 'Total' (cash) is the cash total reported by XTB at the end of the Cash Operations sheet.", + "Difference": "Difference shows computed cash minus broker-reported cash. A value near zero means the calculation reconciles.", + "Status": "Status tells you whether the reconciliation check passed or needs attention.", + "Gross income": "Dividends plus interest before dividend tax.", + "Dividend tax": "Tax withheld from dividend payments.", + "Net income": "Income remaining after dividend tax.", + "Tax drag": "Dividend tax as a share of gross income.", + "Net income yield": "Net income divided by the cost basis of current holdings.", + "Income mix": "How much income came from dividends versus interest.", + "Top 1 holding weight": "The largest single holding's share of current market value.", + "Top 3 holdings weight": "The three largest holdings' combined share of current market value.", + "Top 5 holdings weight": "The five largest holdings' combined share of current market value.", + "Positions above 20%": "Number of holdings that each exceed 20% of current market value.", + "Return Contribution": "How much each ticker contributed to total gain.", + "Total Contribution": "Realized plus unrealized profit or loss for the ticker.", + "Contribution %": "The ticker's contribution as a share of total gain.", + "FIFO": "First in, first out: older purchase lots are treated as sold first.", + "XIRR": "Annualized money-weighted return for cash flows on different dates.", +} + +_TERM_TOOLTIP_SEQ = 0 + + +def _label_html(label: str) -> str: + global _TERM_TOOLTIP_SEQ + text = str(label) + help_text = TERM_TOOLTIPS.get(text.strip()) + if not help_text: + return escape(text) + slug = re.sub(r"[^a-z0-9]+", "-", text.strip().lower()).strip("-") or "term" + _TERM_TOOLTIP_SEQ += 1 + tip_id = f"term-tip-{slug}-{_TERM_TOOLTIP_SEQ}" + return ( + f"" + f"{escape(text)}" + f"" + f"" + f"{escape(help_text)}" + ) + + +def _kv_table(rows: list[tuple[str, str]]) -> str: + out = [""] + for label, value in rows: + cls = " class='neg'" if value.strip().startswith("-") else "" + out.append(f"{escape(value)}") + out.append("
{_label_html(label)}
") + return "\n".join(out) + + +def _df_to_html( + df: pd.DataFrame, + formats: dict[str, str] | None = None, + colored_cols: set[str] | None = None, +) -> str: + """Render a DataFrame to an HTML table. + + ``colored_cols`` (column labels) get ``pos``/``neg`` cell classes based on + the cell's sign (green for >= 0, red for < 0) so P/L-style columns can be + highlighted independently of other numeric columns. + """ + formats = formats or {} + colored_cols = colored_cols or set() + if df.empty: + return "

No data.

" + header = "".join( + f"{_label_html(str(c))}" + for c in df.columns + ) + body = [] + for _, row in df.iterrows(): + cells = [] + for col in df.columns: + val = row[col] + spec = formats.get(col) + text = f"{val:{spec}}" if spec else ( + f"{val:,.2f}" if isinstance(val, float) else str(val) + ) + cls = "" + if col in colored_cols and isinstance(val, (int, float)): + cls = " class='pos'" if val >= 0 else " class='neg'" + elif isinstance(val, (int, float)) and val < 0: + cls = " class='neg'" + cells.append(f"{escape(text)}") + body.append("" + "".join(cells) + "") + return ( + "" + f"{header}{''.join(body)}
" + ) + + +SORTABLE_TABLES_SCRIPT = r""" +function _bootSortableTables() { + function cellValue(row, index) { + return (row.children[index] && row.children[index].textContent || '').trim(); + } + function numericValue(text) { + var normalized = text.replace(/[%\s,]/g, ''); + if (normalized === '') { return null; } + var value = Number(normalized); + return Number.isFinite(value) ? value : null; + } + function sortTable(table, index, direction) { + var tbody = table.tBodies[0]; + if (!tbody) { return; } + var rows = Array.prototype.slice.call(tbody.rows); + rows.sort(function (a, b) { + var av = cellValue(a, index); + var bv = cellValue(b, index); + var an = numericValue(av); + var bn = numericValue(bv); + var result; + if (an !== null && bn !== null) { + result = an - bn; + } else { + result = av.localeCompare(bv, undefined, {numeric: true, sensitivity: 'base'}); + } + return direction === 'asc' ? result : -result; + }); + rows.forEach(function (row) { tbody.appendChild(row); }); + } + document.querySelectorAll('table.data-table th[data-sortable="1"]').forEach(function (th) { + function activate() { + var table = th.closest('table'); + var current = th.getAttribute('aria-sort') || 'none'; + var next = current === 'ascending' ? 'desc' : 'asc'; + table.querySelectorAll('th[aria-sort]').forEach(function (other) { + other.setAttribute('aria-sort', 'none'); + }); + th.setAttribute('aria-sort', next === 'asc' ? 'ascending' : 'descending'); + sortTable(table, th.cellIndex, next); + } + th.addEventListener('click', activate); + th.addEventListener('keydown', function (event) { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + activate(); + } + }); + }); +} +if (document.readyState !== 'loading') { _bootSortableTables(); } +else { document.addEventListener('DOMContentLoaded', _bootSortableTables); } +""" + + +TABLE_FILTERS_SCRIPT = r""" +function _bootTableFilters() { + document.querySelectorAll('table.data-table').forEach(function (table, index) { + if (table.dataset.filterReady === '1') { return; } + table.dataset.filterReady = '1'; + var input = document.createElement('input'); + input.className = 'table-filter'; + input.type = 'search'; + input.placeholder = 'Filter table'; + input.setAttribute('aria-label', 'Filter table'); + input.setAttribute('data-table-filter', String(index)); + table.parentNode.insertBefore(input, table); + input.addEventListener('input', function () { + var body = table.tBodies[0]; + if (!body) { return; } + var query = input.value.trim().toLowerCase(); + Array.prototype.forEach.call(body.rows, function (row) { + var text = row.textContent.toLowerCase(); + row.style.display = !query || text.indexOf(query) !== -1 ? '' : 'none'; + }); + }); + }); +} +if (document.readyState !== 'loading') { _bootTableFilters(); } +else { document.addEventListener('DOMContentLoaded', _bootTableFilters); } +""" + + +def build_html_report( + currency: str, + meta: dict[str, str], + flows: dict[str, float], + ending_cash: float, + holdings: pd.DataFrame, + open_positions: pd.DataFrame, + realized: pd.DataFrame, + perf: dict[str, float], + evolution_cfg: dict | None, + review_cfg: dict, + as_of: date | None = None, + cost_fallback_tickers: list[str] | None = None, +) -> str: + cost_fallback_tickers = cost_fallback_tickers or [] + diff = perf["reconciliation_diff"] + recon_status = "OK" if (diff is None or abs(diff) < 0.01) else "CHECK" + + has_open = not (open_positions is None or open_positions.empty) + has_realized = not (realized.empty or realized["realized_pl"].abs().sum() == 0) + val_date = as_of.isoformat() if as_of else meta.get("period_to", "") + + flows_rows = [ + ("Deposits", money(flows["deposits"])), + ("Withdrawals", money(-flows["withdrawals"])), + ("Free-funds interest", money(flows["interest"])), + ("Dividends received", money(flows["dividends"])), + ("Dividend tax", money(flows["dividend_tax"])), + ("Invested (buys)", money(-flows["invested"])), + ("Proceeds (sales)", money(flows["proceeds"])), + ("FX conversion fees", money(flows["conversion_fees"])), + ("Fees / commissions", money(-flows["fees"])), + ("Ending cash balance", money(ending_cash)), + ] + perf_rows = [ + ("Portfolio value", f"{money(perf['portfolio_value'])} {currency}"), + (" of which market value", money(perf["market_value"])), + (" of which cash", money(perf["ending_cash"])), + (" cost basis", money(perf["cost_basis"])), + ("Net deposited", money(perf["net_deposited"])), + ("Unrealized P/L", money(perf["unrealized_pl"])), + ("Realized P/L", money(perf["realized_pl"])), + ("Income (int. + div.)", money(perf["income"])), + ("Total gain", money(perf["total_gain"])), + ("Total return", f"{perf['total_return_pct']:.2f} %"), + ( + "Money-weighted return", + ( + f"{perf['money_weighted_return_pct']:+.2f} %" + if perf.get("money_weighted_return_pct") is not None + else "n/a" + ), + ), + ("Income yield (on cost)", f"{perf['income_yield_pct']:.2f} %"), + ] + recon_rows = ( + [ + ("Computed ending cash", money(perf["ending_cash"])), + ("Broker 'Total' (cash)", money(perf["broker_total"])), + ("Difference", money(diff)), + ("Status", recon_status), + ] + if perf["broker_total"] + else [("Status", "Broker 'Total' not found")] + ) + + holdings_cols = ["ticker", "name", "shares", "last_price", "market_value", + "unrealized_pl", "return_pct", "weight_pct", "price_source"] + holdings_rename = { + "ticker": "Ticker", "name": "Name", "shares": "Shares", + "last_price": "Last Price", "market_value": "Market Value", + "unrealized_pl": "Unrealized P/L", "return_pct": "Return %", + "weight_pct": "Weight %", "price_source": "Src", + } + if not holdings.empty and set(holdings_cols).issubset(holdings.columns): + holdings_view = holdings[holdings_cols].rename(columns=holdings_rename) + else: + holdings_view = pd.DataFrame(columns=list(holdings_rename.values())) + + if has_open: + total_val = float(open_positions["current_value"].sum()) or 1.0 + op_view = open_positions.assign( + weight_pct=open_positions["current_value"] / total_val * 100 + ).rename(columns={ + "ticker": "Ticker", "current_value": "Market Value", + "unrealized_pl": "Unrealized P/L", "weight_pct": "Weight %", + }) + op_html = _df_to_html(op_view, {"Market Value": ".2f", "Unrealized P/L": ".2f", "Weight %": ".2f"}) + else: + op_view = pd.DataFrame(columns=["Ticker", "Market Value", "Unrealized P/L", "Weight %"]) + op_html = '

No open positions.

' + + realized_html = ( + _df_to_html(realized, {"realized_pl": ".2f"}) + if has_realized + else '

No realized gains/losses in this period.

' + ) + + summary_rows = build_executive_summary(holdings, realized, flows, perf) + concentration = analyze_concentration(holdings, perf) + concentration_rows = [ + ("Top 1 holding weight", f"{concentration['top_1_weight_pct']:.2f} %"), + ("Top 3 holdings weight", f"{concentration['top_3_weight_pct']:.2f} %"), + ("Top 5 holdings weight", f"{concentration['top_5_weight_pct']:.2f} %"), + ("Cash allocation", f"{concentration['cash_weight_pct']:.2f} %"), + ("Positions above 20%", str(concentration["positions_over_20_pct"])), + ("Priced at cost", str(concentration["cost_priced_positions"])), + ("Risk note", str(concentration["risk_note"])), + ] + income_quality = analyze_income_quality(flows, perf) + dividend_tax_display = ( + 0.0 + if abs(float(income_quality["dividend_tax"])) < 0.005 + else -float(income_quality["dividend_tax"]) + ) + income_quality_rows = [ + ("Gross income", money(float(income_quality["gross_income"]))), + ("Dividend tax", money(dividend_tax_display)), + ("Net income", money(float(income_quality["net_income"]))), + ("Tax drag", f"{income_quality['tax_drag_pct']:.2f} %"), + ("Net income yield", f"{income_quality['net_income_yield_pct']:.2f} %"), + ("Income mix", str(income_quality["income_mix"])), + ] + methodology_rows = analyze_methodology_quality(holdings, perf) + guide_rows = beginner_guide_rows() + contributions = analyze_return_contributions(holdings, realized, perf) + contribution_html = _df_to_html( + contributions, + { + "Market Value": ".2f", + "Unrealized P/L": ".2f", + "Realized P/L": ".2f", + "Total Contribution": ".2f", + "Contribution %": ".2f", + }, + colored_cols={"Unrealized P/L", "Realized P/L", "Total Contribution", "Contribution %"}, + ) + + charts_block = html_charts.render_charts_block( + evolution_cfg, review_cfg, currency) + + return f""" + + + + +Portfolio Review — {escape(meta.get('account', ''))} + + + +
+
+

Portfolio Review

+
XTB account {escape(meta.get('account', '?'))} · + {escape(meta.get('period_from', '?'))} → {escape(meta.get('period_to', '?'))} · {currency}
+
Generated {escape(datetime.now().strftime('%Y-%m-%d %H:%M:%S'))} · Valuation date {escape(val_date)}
+
+ + + +
+
{_label_html('Portfolio value')}
+
{money(perf['portfolio_value'])}
+
{_label_html('Net deposited')}
+
{money(perf['net_deposited'])}
+
{_label_html('Total gain')}
+
+ {money(perf['total_gain'])}
+
{_label_html('Total return')}
+
+ {perf['total_return_pct']:+.2f}%
+
+ +
+
+

Executive Summary

+ {_kv_table(summary_rows)} +
+
+

Concentration & Risk

+ {_kv_table(concentration_rows)} +
+
+

Income Quality

+ {_kv_table(income_quality_rows)} +
+
+

Methodology & Data Quality

+ {_kv_table(methodology_rows)} +
+
+

Beginner Guide

+ {_kv_table(guide_rows)} +
+
+

Return Contribution

+ {contribution_html} +
+
+ + {charts_block} + +
+
+

Holdings (live market value)

+ {_df_to_html(holdings_view, {'Last Price':'.4f', 'Market Value':'.2f', 'Unrealized P/L':'.2f', 'Return %':'.2f', 'Weight %':'.2f'}, colored_cols={'Unrealized P/L', 'Return %'})} +
+
+

Cash flows

+ {_kv_table(flows_rows)} +
+ +
+

Open positions (market value)

+ {op_html} +
+ +
+

Realized P/L (closed positions)

+ {realized_html} +
+ +
+

Performance

+ {_kv_table(perf_rows)} +
+ +
+

Reconciliation

+ {_kv_table(recon_rows)} +

XTB "Total" row reflects ending free cash; reconciled against computed cash balance.

+
+
+ +
Generated from {escape(REPORT_FILE.name)} on {escape(datetime.now().strftime('%Y-%m-%d %H:%M:%S'))} · live prices via yfinance (as of {escape(val_date)}){' · priced at cost: ' + escape(', '.join(cost_fallback_tickers)) if cost_fallback_tickers else ''}{'
' + '
'.join(escape(f'{t}: {COST_FALLBACK_NOTES[t]}') for t in cost_fallback_tickers if t in COST_FALLBACK_NOTES) if any(t in COST_FALLBACK_NOTES for t in cost_fallback_tickers) else ''}
+
+ + +""" + + +def _output_name(descriptor: str, ext: str) -> Path: + """Path in RESULTS_DIR named after the input report's stem. + + e.g. input ``EUR_SAMPLE_2026-01-01_2026-06-20.xlsx`` with + ``("review", "html")`` -> ``results/EUR_SAMPLE_2026-01-01_2026-06-20_review.html``. + Falls back to a ``portfolio`` stem when ``REPORT_FILE`` is unset. + """ + stem = REPORT_FILE.stem if REPORT_FILE else "portfolio" + return RESULTS_DIR / f"{stem}_{descriptor}.{ext}" + + +def write_html_report(html: str, path: Path | str | None = None) -> Path: + path = Path(path) if path else _output_name("review", "html") + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(html, encoding="utf-8") + return path + + +def _persist_outputs( + holdings: pd.DataFrame, + open_positions: pd.DataFrame, + realized: pd.DataFrame, + flows: dict[str, float], + perf: dict[str, float], + income_by_period: pd.Series, + evolution_df: pd.DataFrame | None = None, + as_of: date | None = None, + write_csv: bool = True, +) -> None: + RESULTS_DIR.mkdir(parents=True, exist_ok=True) + if not write_csv: + return + out_holdings = holdings.drop( + columns=[c for c in holdings.columns if c.startswith("_")], errors="ignore" + ) + out_holdings.to_csv(_output_name("holdings", "csv"), index=False) + + op_out = open_positions.copy() + if as_of is not None: + op_out = op_out.assign(as_of=as_of.isoformat()) + op_out.to_csv(_output_name("open_positions", "csv"), index=False) + realized.to_csv(_output_name("realized_pl", "csv"), index=False) + pd.DataFrame([flows]).to_csv(_output_name("cash_flows", "csv"), index=False) + perf_row = dict(perf) + if as_of is not None: + perf_row["valuation_as_of"] = as_of.isoformat() + pd.DataFrame([perf_row]).to_csv(_output_name("performance", "csv"), index=False) + income_by_period.rename("income").to_csv(_output_name("income", "csv")) + if evolution_df is not None and not evolution_df.empty: + evolution_df.to_csv(_output_name("evolution", "csv")) + + +def main( + xlsx_path: Path | str | None = None, write_csv: bool = False +) -> None: + global REPORT_FILE + REPORT_FILE = resolve_report_file(xlsx_path) + RESULTS_DIR.mkdir(parents=True, exist_ok=True) + currency = detect_currency() + meta = load_meta() + as_of = _parse_as_of(meta) + positions, cash_ops, open_positions_raw, broker_total = load_data() + + trades = extract_trades(cash_ops) + holdings, realized_from_trades = analyze_holdings(trades) + realized = analyze_realized(positions, realized_from_trades) + + prices = fetch_prices( + holdings["ticker"].tolist(), as_of, currency + ) if not holdings.empty else {} + valued_holdings = valuate_holdings(holdings, prices) + cost_fallback_tickers = list( + valued_holdings.loc[valued_holdings["price_source"] == "cost", "ticker"] + ) + + open_positions = analyze_open_positions(open_positions_raw, valued_holdings) + flows, ending_cash = analyze_cash_flows(cash_ops, trades) + dividends, interest, income_by_period = analyze_income(cash_ops) + perf = compute_performance( + holdings, open_positions, realized, flows, ending_cash, broker_total, + cash_ops=cash_ops, terminal_date=as_of, + ) + + print_report( + currency, meta, flows, ending_cash, valued_holdings, + open_positions, realized, perf, dividends, interest, + as_of=as_of, cost_fallback_tickers=cost_fallback_tickers, + ) + + # Evolution chart: cost vs realized + unrealized value over time. + # Only live-valued tickers get history; cost-fallback ones stay flat. + evolution_df = pd.DataFrame() + live_tickers = list( + valued_holdings.loc[valued_holdings["price_source"] == "live", "ticker"] + ) + first_trade_date = min( + (t.date for t in trades if t.date is not None), default=None + ) + if live_tickers and first_trade_date is not None: + price_history = fetch_price_history( + live_tickers, first_trade_date.date(), as_of, currency + ) + evolution_df = build_evolution_series(trades, price_history, as_of) + + _persist_outputs( + valued_holdings, open_positions, realized, flows, perf, + income_by_period, evolution_df, as_of, write_csv=write_csv, + ) + + # Charts: interactive Chart.js, inlined into the self-contained HTML. + evolution_cfg = html_charts.evolution_chart_config(evolution_df, currency) + review_cfg = html_charts.review_charts_config( + valued_holdings, flows, income_by_period, currency) + + # HTML report (self-contained, offline). + html = build_html_report( + currency, meta, flows, ending_cash, valued_holdings, + open_positions, realized, perf, evolution_cfg, review_cfg, + as_of=as_of, cost_fallback_tickers=cost_fallback_tickers, + ) + out = write_html_report(html) + print(f"HTML report written to {out}") + + +def main_cli() -> None: + parser = argparse.ArgumentParser( + description="Generate a portfolio review from an XTB .xlsx report." + ) + parser.add_argument( + "input", nargs="?", default=None, + help="Path to the XTB .xlsx report. If omitted, the single .xlsx in " + "the current directory is used automatically.", + ) + parser.add_argument( + "--csv", action="store_true", + help="Also write CSV outputs (holdings, cash flows, performance, etc.). " + "By default only the HTML report is written.", + ) + args = parser.parse_args() + try: + main(args.input, write_csv=args.csv) + except (FileNotFoundError, ValueError) as exc: + parser.error(str(exc)) + + +if __name__ == "__main__": + main_cli() diff --git a/skills/xtb-portfolio-review/scripts/requirements.txt b/skills/xtb-portfolio-review/scripts/requirements.txt new file mode 100644 index 0000000..3ccc673 --- /dev/null +++ b/skills/xtb-portfolio-review/scripts/requirements.txt @@ -0,0 +1,4 @@ +pandas>=2.2,<4 +numpy>=1.26,<3 +openpyxl>=3.1,<4 +yfinance>=0.2,<2 diff --git a/skills/xtb-portfolio-review/scripts/run-review.sh b/skills/xtb-portfolio-review/scripts/run-review.sh new file mode 100755 index 0000000..cc0f647 --- /dev/null +++ b/skills/xtb-portfolio-review/scripts/run-review.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +if [[ -n "${PYTHON:-}" ]]; then + PYTHON_BIN="$PYTHON" +elif [[ -x ".venv/bin/python" ]]; then + PYTHON_BIN=".venv/bin/python" +else + PYTHON_BIN="python3" +fi + +exec "$PYTHON_BIN" "$SCRIPT_DIR/main.py" "$@" --csv diff --git a/skills/xtb-portfolio-review/scripts/setup-env.sh b/skills/xtb-portfolio-review/scripts/setup-env.sh new file mode 100755 index 0000000..6980d57 --- /dev/null +++ b/skills/xtb-portfolio-review/scripts/setup-env.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +VENV_DIR="${VENV_DIR:-.venv}" +PYTHON_BOOTSTRAP="${PYTHON:-python3}" + +if [[ ! -x "$VENV_DIR/bin/python" ]]; then + "$PYTHON_BOOTSTRAP" -m venv "$VENV_DIR" +fi + +"$VENV_DIR/bin/python" -m pip install --upgrade pip +"$VENV_DIR/bin/python" -m pip install -r "$SCRIPT_DIR/requirements.txt" + +echo "Environment ready: $VENV_DIR" diff --git a/skills/xtb-portfolio-review/scripts/validate-review.sh b/skills/xtb-portfolio-review/scripts/validate-review.sh new file mode 100755 index 0000000..26b4010 --- /dev/null +++ b/skills/xtb-portfolio-review/scripts/validate-review.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +if [[ -n "${PYTHON:-}" ]]; then + PYTHON_BIN="$PYTHON" +elif [[ -x ".venv/bin/python" ]]; then + PYTHON_BIN=".venv/bin/python" +else + PYTHON_BIN="python3" +fi + +"$PYTHON_BIN" - </scripts/setup-env.sh` +3. Validate the bundled tools: + `/scripts/validate-export.sh` +4. Create the Wealthfolio CSV from the directory where outputs should be written: + `/scripts/export-wealthfolio.sh ` +5. If the user needs a custom path, run: + `/scripts/export-wealthfolio.sh -o ` +6. Inspect the generated CSV header and a sample of rows before saying it is import-ready. +7. If row classification looks suspicious, read `references/wealthfolio-csv.md` and compare activity mappings. + +## Bundled Tools + +- `scripts/exporter.py`: standalone XTB to Wealthfolio CSV exporter. +- `scripts/main.py`: shared XTB parsing helpers used by the exporter. +- `scripts/html_charts.py` and `scripts/assets/`: bundled because `main.py` imports the report helper. +- `scripts/export-wealthfolio.sh`: shell wrapper that runs the bundled exporter. +- `scripts/validate-export.sh`: dependency and schema smoke check. +- `scripts/setup-env.sh`: creates `.venv` in the current working directory and installs dependencies. +- `scripts/requirements.txt`: Python dependencies. + +## References + +- Read `references/wealthfolio-csv.md` for Wealthfolio schema, XTB activity mapping, and known XTB comment quirks. + +## Guardrails + +- Do not hand-edit exported CSV rows unless the user asks; prefer fixing `scripts/exporter.py` when mappings are wrong. +- Keep `BUY` and `SELL` trade rows with blank `amount`; Wealthfolio calculates trade amount from `quantity * unitPrice`. +- For pure cash activities, use `$CASH-` and set `quantity = 1`, `unitPrice = 1`, and `amount` to the absolute cash value. diff --git a/skills/xtb-wealthfolio-export/references/wealthfolio-csv.md b/skills/xtb-wealthfolio-export/references/wealthfolio-csv.md new file mode 100644 index 0000000..8fd2440 --- /dev/null +++ b/skills/xtb-wealthfolio-export/references/wealthfolio-csv.md @@ -0,0 +1,62 @@ +# Wealthfolio CSV Mapping + +Load this when validating or debugging XTB to Wealthfolio exports. + +## Required Header + +`date,symbol,quantity,activityType,unitPrice,currency,fee,amount` + +## XTB To Wealthfolio Mapping + +- `Stock purchase` or `OPEN BUY` -> `BUY` +- `Stock sale`, `CLOSE SELL`, or `OPEN SELL` -> `SELL` +- `Stock sell` with `CLOSE BUY` -> `SELL` because XTB can encode sale close legs this way +- `Deposit` -> `DEPOSIT` +- `Withdrawal` -> `WITHDRAWAL` +- `Dividend` -> `DIVIDEND` +- `Dividend tax` -> `TAX` +- `Free funds interest` -> `INTEREST` +- `Currency conversion` -> `FEE` + +## Row Rules + +- `BUY` and `SELL`: + - `symbol`: real ticker when available + - `quantity`: parsed share count + - `unitPrice`: parsed `@ price` + - `fee`: inline trading fee if supported by the exporter, otherwise `0.00` + - `amount`: blank +- Cash activities (`DEPOSIT`, `WITHDRAWAL`, `INTEREST`, `TAX`, `FEE`): + - `symbol`: `$CASH-` + - `quantity`: `1` + - `unitPrice`: `1` + - `amount`: absolute cash value +- `DIVIDEND`: + - Keep the real security ticker when available + - Use `quantity = 1`, `unitPrice = 1`, and `amount` as the absolute dividend cash value + +## Quantity Parsing + +- For comments like `OPEN BUY 6 @ 301.50`, quantity is `6`. +- For split fills like `OPEN BUY 1/100 @ 14.3130`, quantity is the numerator `1`, not `0.01`. +- If no parseable quantity exists, the exporter may fall back to `abs(amount) / price`. + +## Validation Commands + +- Install dependencies: + `/scripts/setup-env.sh` +- Validate bundled tools: + `/scripts/validate-export.sh` +- Generate default CSV: + `/scripts/export-wealthfolio.sh ` +- If working inside the original project repository, full tests are also useful: + `.venv/bin/python -m pytest -q` + +## Import Readiness Checks + +- Header exactly matches the required schema. +- Activity types are among Wealthfolio-supported values used by the exporter. +- Trade rows have blank `amount`. +- Cash rows have nonblank positive `amount` and `$CASH-` unless dividend ticker retention applies. +- `CLOSE BUY` stock-sale rows export as `SELL`. +- Split-fill rows use numerator quantity. diff --git a/skills/xtb-wealthfolio-export/scripts/assets/chartjs.VERSION b/skills/xtb-wealthfolio-export/scripts/assets/chartjs.VERSION new file mode 100644 index 0000000..4404a17 --- /dev/null +++ b/skills/xtb-wealthfolio-export/scripts/assets/chartjs.VERSION @@ -0,0 +1 @@ +4.5.1 diff --git a/skills/xtb-wealthfolio-export/scripts/assets/chartjs.umd.min.js b/skills/xtb-wealthfolio-export/scripts/assets/chartjs.umd.min.js new file mode 100644 index 0000000..008464f --- /dev/null +++ b/skills/xtb-wealthfolio-export/scripts/assets/chartjs.umd.min.js @@ -0,0 +1,14 @@ +/*! + * Chart.js v4.5.1 + * https://www.chartjs.org + * (c) 2025 Chart.js Contributors + * Released under the MIT License + */ +!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t="undefined"!=typeof globalThis?globalThis:t||self).Chart=e()}(this,(function(){"use strict";var t=Object.freeze({__proto__:null,get Colors(){return Jo},get Decimation(){return ta},get Filler(){return ba},get Legend(){return Ma},get SubTitle(){return Pa},get Title(){return ka},get Tooltip(){return Na}});function e(){}const i=(()=>{let t=0;return()=>t++})();function s(t){return null==t}function n(t){if(Array.isArray&&Array.isArray(t))return!0;const e=Object.prototype.toString.call(t);return"[object"===e.slice(0,7)&&"Array]"===e.slice(-6)}function o(t){return null!==t&&"[object Object]"===Object.prototype.toString.call(t)}function a(t){return("number"==typeof t||t instanceof Number)&&isFinite(+t)}function r(t,e){return a(t)?t:e}function l(t,e){return void 0===t?e:t}const h=(t,e)=>"string"==typeof t&&t.endsWith("%")?parseFloat(t)/100:+t/e,c=(t,e)=>"string"==typeof t&&t.endsWith("%")?parseFloat(t)/100*e:+t;function d(t,e,i){if(t&&"function"==typeof t.call)return t.apply(i,e)}function u(t,e,i,s){let a,r,l;if(n(t))if(r=t.length,s)for(a=r-1;a>=0;a--)e.call(i,t[a],a);else for(a=0;at,x:t=>t.x,y:t=>t.y};function v(t){const e=t.split("."),i=[];let s="";for(const t of e)s+=t,s.endsWith("\\")?s=s.slice(0,-1)+".":(i.push(s),s="");return i}function M(t,e){const i=y[e]||(y[e]=function(t){const e=v(t);return t=>{for(const i of e){if(""===i)break;t=t&&t[i]}return t}}(e));return i(t)}function w(t){return t.charAt(0).toUpperCase()+t.slice(1)}const k=t=>void 0!==t,S=t=>"function"==typeof t,P=(t,e)=>{if(t.size!==e.size)return!1;for(const i of t)if(!e.has(i))return!1;return!0};function D(t){return"mouseup"===t.type||"click"===t.type||"contextmenu"===t.type}const C=Math.PI,O=2*C,A=O+C,T=Number.POSITIVE_INFINITY,L=C/180,E=C/2,R=C/4,I=2*C/3,z=Math.log10,F=Math.sign;function V(t,e,i){return Math.abs(t-e)t-e)).pop(),e}function N(t){return!function(t){return"symbol"==typeof t||"object"==typeof t&&null!==t&&!(Symbol.toPrimitive in t||"toString"in t||"valueOf"in t)}(t)&&!isNaN(parseFloat(t))&&isFinite(t)}function H(t,e){const i=Math.round(t);return i-e<=t&&i+e>=t}function j(t,e,i){let s,n,o;for(s=0,n=t.length;sl&&h=Math.min(e,i)-s&&t<=Math.max(e,i)+s}function et(t,e,i){i=i||(i=>t[i]1;)s=o+n>>1,i(s)?o=s:n=s;return{lo:o,hi:n}}const it=(t,e,i,s)=>et(t,i,s?s=>{const n=t[s][e];return nt[s][e]et(t,i,(s=>t[s][e]>=i));function nt(t,e,i){let s=0,n=t.length;for(;ss&&t[n-1]>i;)n--;return s>0||n{const i="_onData"+w(e),s=t[e];Object.defineProperty(t,e,{configurable:!0,enumerable:!1,value(...e){const n=s.apply(this,e);return t._chartjs.listeners.forEach((t=>{"function"==typeof t[i]&&t[i](...e)})),n}})})))}function rt(t,e){const i=t._chartjs;if(!i)return;const s=i.listeners,n=s.indexOf(e);-1!==n&&s.splice(n,1),s.length>0||(ot.forEach((e=>{delete t[e]})),delete t._chartjs)}function lt(t){const e=new Set(t);return e.size===t.length?t:Array.from(e)}const ht="undefined"==typeof window?function(t){return t()}:window.requestAnimationFrame;function ct(t,e){let i=[],s=!1;return function(...n){i=n,s||(s=!0,ht.call(window,(()=>{s=!1,t.apply(e,i)})))}}function dt(t,e){let i;return function(...s){return e?(clearTimeout(i),i=setTimeout(t,e,s)):t.apply(this,s),e}}const ut=t=>"start"===t?"left":"end"===t?"right":"center",ft=(t,e,i)=>"start"===t?e:"end"===t?i:(e+i)/2,gt=(t,e,i,s)=>t===(s?"left":"right")?i:"center"===t?(e+i)/2:e;function pt(t,e,i){const n=e.length;let o=0,a=n;if(t._sorted){const{iScale:r,vScale:l,_parsed:h}=t,c=t.dataset&&t.dataset.options?t.dataset.options.spanGaps:null,d=r.axis,{min:u,max:f,minDefined:g,maxDefined:p}=r.getUserBounds();if(g){if(o=Math.min(it(h,d,u).lo,i?n:it(e,d,r.getPixelForValue(u)).lo),c){const t=h.slice(0,o+1).reverse().findIndex((t=>!s(t[l.axis])));o-=Math.max(0,t)}o=Z(o,0,n-1)}if(p){let t=Math.max(it(h,r.axis,f,!0).hi+1,i?0:it(e,d,r.getPixelForValue(f),!0).hi+1);if(c){const e=h.slice(t-1).findIndex((t=>!s(t[l.axis])));t+=Math.max(0,e)}a=Z(t,o,n)-o}else a=n-o}return{start:o,count:a}}function mt(t){const{xScale:e,yScale:i,_scaleRanges:s}=t,n={xmin:e.min,xmax:e.max,ymin:i.min,ymax:i.max};if(!s)return t._scaleRanges=n,!0;const o=s.xmin!==e.min||s.xmax!==e.max||s.ymin!==i.min||s.ymax!==i.max;return Object.assign(s,n),o}class xt{constructor(){this._request=null,this._charts=new Map,this._running=!1,this._lastDate=void 0}_notify(t,e,i,s){const n=e.listeners[s],o=e.duration;n.forEach((s=>s({chart:t,initial:e.initial,numSteps:o,currentStep:Math.min(i-e.start,o)})))}_refresh(){this._request||(this._running=!0,this._request=ht.call(window,(()=>{this._update(),this._request=null,this._running&&this._refresh()})))}_update(t=Date.now()){let e=0;this._charts.forEach(((i,s)=>{if(!i.running||!i.items.length)return;const n=i.items;let o,a=n.length-1,r=!1;for(;a>=0;--a)o=n[a],o._active?(o._total>i.duration&&(i.duration=o._total),o.tick(t),r=!0):(n[a]=n[n.length-1],n.pop());r&&(s.draw(),this._notify(s,i,t,"progress")),n.length||(i.running=!1,this._notify(s,i,t,"complete"),i.initial=!1),e+=n.length})),this._lastDate=t,0===e&&(this._running=!1)}_getAnims(t){const e=this._charts;let i=e.get(t);return i||(i={running:!1,initial:!0,items:[],listeners:{complete:[],progress:[]}},e.set(t,i)),i}listen(t,e,i){this._getAnims(t).listeners[e].push(i)}add(t,e){e&&e.length&&this._getAnims(t).items.push(...e)}has(t){return this._getAnims(t).items.length>0}start(t){const e=this._charts.get(t);e&&(e.running=!0,e.start=Date.now(),e.duration=e.items.reduce(((t,e)=>Math.max(t,e._duration)),0),this._refresh())}running(t){if(!this._running)return!1;const e=this._charts.get(t);return!!(e&&e.running&&e.items.length)}stop(t){const e=this._charts.get(t);if(!e||!e.items.length)return;const i=e.items;let s=i.length-1;for(;s>=0;--s)i[s].cancel();e.items=[],this._notify(t,e,Date.now(),"complete")}remove(t){return this._charts.delete(t)}}var bt=new xt; +/*! + * @kurkle/color v0.3.2 + * https://github.com/kurkle/color#readme + * (c) 2023 Jukka Kurkela + * Released under the MIT License + */function _t(t){return t+.5|0}const yt=(t,e,i)=>Math.max(Math.min(t,i),e);function vt(t){return yt(_t(2.55*t),0,255)}function Mt(t){return yt(_t(255*t),0,255)}function wt(t){return yt(_t(t/2.55)/100,0,1)}function kt(t){return yt(_t(100*t),0,100)}const St={0:0,1:1,2:2,3:3,4:4,5:5,6:6,7:7,8:8,9:9,A:10,B:11,C:12,D:13,E:14,F:15,a:10,b:11,c:12,d:13,e:14,f:15},Pt=[..."0123456789ABCDEF"],Dt=t=>Pt[15&t],Ct=t=>Pt[(240&t)>>4]+Pt[15&t],Ot=t=>(240&t)>>4==(15&t);function At(t){var e=(t=>Ot(t.r)&&Ot(t.g)&&Ot(t.b)&&Ot(t.a))(t)?Dt:Ct;return t?"#"+e(t.r)+e(t.g)+e(t.b)+((t,e)=>t<255?e(t):"")(t.a,e):void 0}const Tt=/^(hsla?|hwb|hsv)\(\s*([-+.e\d]+)(?:deg)?[\s,]+([-+.e\d]+)%[\s,]+([-+.e\d]+)%(?:[\s,]+([-+.e\d]+)(%)?)?\s*\)$/;function Lt(t,e,i){const s=e*Math.min(i,1-i),n=(e,n=(e+t/30)%12)=>i-s*Math.max(Math.min(n-3,9-n,1),-1);return[n(0),n(8),n(4)]}function Et(t,e,i){const s=(s,n=(s+t/60)%6)=>i-i*e*Math.max(Math.min(n,4-n,1),0);return[s(5),s(3),s(1)]}function Rt(t,e,i){const s=Lt(t,1,.5);let n;for(e+i>1&&(n=1/(e+i),e*=n,i*=n),n=0;n<3;n++)s[n]*=1-e-i,s[n]+=e;return s}function It(t){const e=t.r/255,i=t.g/255,s=t.b/255,n=Math.max(e,i,s),o=Math.min(e,i,s),a=(n+o)/2;let r,l,h;return n!==o&&(h=n-o,l=a>.5?h/(2-n-o):h/(n+o),r=function(t,e,i,s,n){return t===n?(e-i)/s+(e>16&255,o>>8&255,255&o]}return t}(),Ht.transparent=[0,0,0,0]);const e=Ht[t.toLowerCase()];return e&&{r:e[0],g:e[1],b:e[2],a:4===e.length?e[3]:255}}const $t=/^rgba?\(\s*([-+.\d]+)(%)?[\s,]+([-+.e\d]+)(%)?[\s,]+([-+.e\d]+)(%)?(?:[\s,/]+([-+.e\d]+)(%)?)?\s*\)$/;const Yt=t=>t<=.0031308?12.92*t:1.055*Math.pow(t,1/2.4)-.055,Ut=t=>t<=.04045?t/12.92:Math.pow((t+.055)/1.055,2.4);function Xt(t,e,i){if(t){let s=It(t);s[e]=Math.max(0,Math.min(s[e]+s[e]*i,0===e?360:1)),s=Ft(s),t.r=s[0],t.g=s[1],t.b=s[2]}}function qt(t,e){return t?Object.assign(e||{},t):t}function Kt(t){var e={r:0,g:0,b:0,a:255};return Array.isArray(t)?t.length>=3&&(e={r:t[0],g:t[1],b:t[2],a:255},t.length>3&&(e.a=Mt(t[3]))):(e=qt(t,{r:0,g:0,b:0,a:1})).a=Mt(e.a),e}function Gt(t){return"r"===t.charAt(0)?function(t){const e=$t.exec(t);let i,s,n,o=255;if(e){if(e[7]!==i){const t=+e[7];o=e[8]?vt(t):yt(255*t,0,255)}return i=+e[1],s=+e[3],n=+e[5],i=255&(e[2]?vt(i):yt(i,0,255)),s=255&(e[4]?vt(s):yt(s,0,255)),n=255&(e[6]?vt(n):yt(n,0,255)),{r:i,g:s,b:n,a:o}}}(t):Bt(t)}class Jt{constructor(t){if(t instanceof Jt)return t;const e=typeof t;let i;var s,n,o;"object"===e?i=Kt(t):"string"===e&&(o=(s=t).length,"#"===s[0]&&(4===o||5===o?n={r:255&17*St[s[1]],g:255&17*St[s[2]],b:255&17*St[s[3]],a:5===o?17*St[s[4]]:255}:7!==o&&9!==o||(n={r:St[s[1]]<<4|St[s[2]],g:St[s[3]]<<4|St[s[4]],b:St[s[5]]<<4|St[s[6]],a:9===o?St[s[7]]<<4|St[s[8]]:255})),i=n||jt(t)||Gt(t)),this._rgb=i,this._valid=!!i}get valid(){return this._valid}get rgb(){var t=qt(this._rgb);return t&&(t.a=wt(t.a)),t}set rgb(t){this._rgb=Kt(t)}rgbString(){return this._valid?(t=this._rgb)&&(t.a<255?`rgba(${t.r}, ${t.g}, ${t.b}, ${wt(t.a)})`:`rgb(${t.r}, ${t.g}, ${t.b})`):void 0;var t}hexString(){return this._valid?At(this._rgb):void 0}hslString(){return this._valid?function(t){if(!t)return;const e=It(t),i=e[0],s=kt(e[1]),n=kt(e[2]);return t.a<255?`hsla(${i}, ${s}%, ${n}%, ${wt(t.a)})`:`hsl(${i}, ${s}%, ${n}%)`}(this._rgb):void 0}mix(t,e){if(t){const i=this.rgb,s=t.rgb;let n;const o=e===n?.5:e,a=2*o-1,r=i.a-s.a,l=((a*r==-1?a:(a+r)/(1+a*r))+1)/2;n=1-l,i.r=255&l*i.r+n*s.r+.5,i.g=255&l*i.g+n*s.g+.5,i.b=255&l*i.b+n*s.b+.5,i.a=o*i.a+(1-o)*s.a,this.rgb=i}return this}interpolate(t,e){return t&&(this._rgb=function(t,e,i){const s=Ut(wt(t.r)),n=Ut(wt(t.g)),o=Ut(wt(t.b));return{r:Mt(Yt(s+i*(Ut(wt(e.r))-s))),g:Mt(Yt(n+i*(Ut(wt(e.g))-n))),b:Mt(Yt(o+i*(Ut(wt(e.b))-o))),a:t.a+i*(e.a-t.a)}}(this._rgb,t._rgb,e)),this}clone(){return new Jt(this.rgb)}alpha(t){return this._rgb.a=Mt(t),this}clearer(t){return this._rgb.a*=1-t,this}greyscale(){const t=this._rgb,e=_t(.3*t.r+.59*t.g+.11*t.b);return t.r=t.g=t.b=e,this}opaquer(t){return this._rgb.a*=1+t,this}negate(){const t=this._rgb;return t.r=255-t.r,t.g=255-t.g,t.b=255-t.b,this}lighten(t){return Xt(this._rgb,2,t),this}darken(t){return Xt(this._rgb,2,-t),this}saturate(t){return Xt(this._rgb,1,t),this}desaturate(t){return Xt(this._rgb,1,-t),this}rotate(t){return function(t,e){var i=It(t);i[0]=Vt(i[0]+e),i=Ft(i),t.r=i[0],t.g=i[1],t.b=i[2]}(this._rgb,t),this}}function Zt(t){if(t&&"object"==typeof t){const e=t.toString();return"[object CanvasPattern]"===e||"[object CanvasGradient]"===e}return!1}function Qt(t){return Zt(t)?t:new Jt(t)}function te(t){return Zt(t)?t:new Jt(t).saturate(.5).darken(.1).hexString()}const ee=["x","y","borderWidth","radius","tension"],ie=["color","borderColor","backgroundColor"];const se=new Map;function ne(t,e,i){return function(t,e){e=e||{};const i=t+JSON.stringify(e);let s=se.get(i);return s||(s=new Intl.NumberFormat(t,e),se.set(i,s)),s}(e,i).format(t)}const oe={values:t=>n(t)?t:""+t,numeric(t,e,i){if(0===t)return"0";const s=this.chart.options.locale;let n,o=t;if(i.length>1){const e=Math.max(Math.abs(i[0].value),Math.abs(i[i.length-1].value));(e<1e-4||e>1e15)&&(n="scientific"),o=function(t,e){let i=e.length>3?e[2].value-e[1].value:e[1].value-e[0].value;Math.abs(i)>=1&&t!==Math.floor(t)&&(i=t-Math.floor(t));return i}(t,i)}const a=z(Math.abs(o)),r=isNaN(a)?1:Math.max(Math.min(-1*Math.floor(a),20),0),l={notation:n,minimumFractionDigits:r,maximumFractionDigits:r};return Object.assign(l,this.options.ticks.format),ne(t,s,l)},logarithmic(t,e,i){if(0===t)return"0";const s=i[e].significand||t/Math.pow(10,Math.floor(z(t)));return[1,2,3,5,10,15].includes(s)||e>.8*i.length?oe.numeric.call(this,t,e,i):""}};var ae={formatters:oe};const re=Object.create(null),le=Object.create(null);function he(t,e){if(!e)return t;const i=e.split(".");for(let e=0,s=i.length;et.chart.platform.getDevicePixelRatio(),this.elements={},this.events=["mousemove","mouseout","click","touchstart","touchmove"],this.font={family:"'Helvetica Neue', 'Helvetica', 'Arial', sans-serif",size:12,style:"normal",lineHeight:1.2,weight:null},this.hover={},this.hoverBackgroundColor=(t,e)=>te(e.backgroundColor),this.hoverBorderColor=(t,e)=>te(e.borderColor),this.hoverColor=(t,e)=>te(e.color),this.indexAxis="x",this.interaction={mode:"nearest",intersect:!0,includeInvisible:!1},this.maintainAspectRatio=!0,this.onHover=null,this.onClick=null,this.parsing=!0,this.plugins={},this.responsive=!0,this.scale=void 0,this.scales={},this.showLine=!0,this.drawActiveElementsOnTop=!0,this.describe(t),this.apply(e)}set(t,e){return ce(this,t,e)}get(t){return he(this,t)}describe(t,e){return ce(le,t,e)}override(t,e){return ce(re,t,e)}route(t,e,i,s){const n=he(this,t),a=he(this,i),r="_"+e;Object.defineProperties(n,{[r]:{value:n[e],writable:!0},[e]:{enumerable:!0,get(){const t=this[r],e=a[s];return o(t)?Object.assign({},e,t):l(t,e)},set(t){this[r]=t}}})}apply(t){t.forEach((t=>t(this)))}}var ue=new de({_scriptable:t=>!t.startsWith("on"),_indexable:t=>"events"!==t,hover:{_fallback:"interaction"},interaction:{_scriptable:!1,_indexable:!1}},[function(t){t.set("animation",{delay:void 0,duration:1e3,easing:"easeOutQuart",fn:void 0,from:void 0,loop:void 0,to:void 0,type:void 0}),t.describe("animation",{_fallback:!1,_indexable:!1,_scriptable:t=>"onProgress"!==t&&"onComplete"!==t&&"fn"!==t}),t.set("animations",{colors:{type:"color",properties:ie},numbers:{type:"number",properties:ee}}),t.describe("animations",{_fallback:"animation"}),t.set("transitions",{active:{animation:{duration:400}},resize:{animation:{duration:0}},show:{animations:{colors:{from:"transparent"},visible:{type:"boolean",duration:0}}},hide:{animations:{colors:{to:"transparent"},visible:{type:"boolean",easing:"linear",fn:t=>0|t}}}})},function(t){t.set("layout",{autoPadding:!0,padding:{top:0,right:0,bottom:0,left:0}})},function(t){t.set("scale",{display:!0,offset:!1,reverse:!1,beginAtZero:!1,bounds:"ticks",clip:!0,grace:0,grid:{display:!0,lineWidth:1,drawOnChartArea:!0,drawTicks:!0,tickLength:8,tickWidth:(t,e)=>e.lineWidth,tickColor:(t,e)=>e.color,offset:!1},border:{display:!0,dash:[],dashOffset:0,width:1},title:{display:!1,text:"",padding:{top:4,bottom:4}},ticks:{minRotation:0,maxRotation:50,mirror:!1,textStrokeWidth:0,textStrokeColor:"",padding:3,display:!0,autoSkip:!0,autoSkipPadding:3,labelOffset:0,callback:ae.formatters.values,minor:{},major:{},align:"center",crossAlign:"near",showLabelBackdrop:!1,backdropColor:"rgba(255, 255, 255, 0.75)",backdropPadding:2}}),t.route("scale.ticks","color","","color"),t.route("scale.grid","color","","borderColor"),t.route("scale.border","color","","borderColor"),t.route("scale.title","color","","color"),t.describe("scale",{_fallback:!1,_scriptable:t=>!t.startsWith("before")&&!t.startsWith("after")&&"callback"!==t&&"parser"!==t,_indexable:t=>"borderDash"!==t&&"tickBorderDash"!==t&&"dash"!==t}),t.describe("scales",{_fallback:"scale"}),t.describe("scale.ticks",{_scriptable:t=>"backdropPadding"!==t&&"callback"!==t,_indexable:t=>"backdropPadding"!==t})}]);function fe(){return"undefined"!=typeof window&&"undefined"!=typeof document}function ge(t){let e=t.parentNode;return e&&"[object ShadowRoot]"===e.toString()&&(e=e.host),e}function pe(t,e,i){let s;return"string"==typeof t?(s=parseInt(t,10),-1!==t.indexOf("%")&&(s=s/100*e.parentNode[i])):s=t,s}const me=t=>t.ownerDocument.defaultView.getComputedStyle(t,null);function xe(t,e){return me(t).getPropertyValue(e)}const be=["top","right","bottom","left"];function _e(t,e,i){const s={};i=i?"-"+i:"";for(let n=0;n<4;n++){const o=be[n];s[o]=parseFloat(t[e+"-"+o+i])||0}return s.width=s.left+s.right,s.height=s.top+s.bottom,s}const ye=(t,e,i)=>(t>0||e>0)&&(!i||!i.shadowRoot);function ve(t,e){if("native"in t)return t;const{canvas:i,currentDevicePixelRatio:s}=e,n=me(i),o="border-box"===n.boxSizing,a=_e(n,"padding"),r=_e(n,"border","width"),{x:l,y:h,box:c}=function(t,e){const i=t.touches,s=i&&i.length?i[0]:t,{offsetX:n,offsetY:o}=s;let a,r,l=!1;if(ye(n,o,t.target))a=n,r=o;else{const t=e.getBoundingClientRect();a=s.clientX-t.left,r=s.clientY-t.top,l=!0}return{x:a,y:r,box:l}}(t,i),d=a.left+(c&&r.left),u=a.top+(c&&r.top);let{width:f,height:g}=e;return o&&(f-=a.width+r.width,g-=a.height+r.height),{x:Math.round((l-d)/f*i.width/s),y:Math.round((h-u)/g*i.height/s)}}const Me=t=>Math.round(10*t)/10;function we(t,e,i,s){const n=me(t),o=_e(n,"margin"),a=pe(n.maxWidth,t,"clientWidth")||T,r=pe(n.maxHeight,t,"clientHeight")||T,l=function(t,e,i){let s,n;if(void 0===e||void 0===i){const o=t&&ge(t);if(o){const t=o.getBoundingClientRect(),a=me(o),r=_e(a,"border","width"),l=_e(a,"padding");e=t.width-l.width-r.width,i=t.height-l.height-r.height,s=pe(a.maxWidth,o,"clientWidth"),n=pe(a.maxHeight,o,"clientHeight")}else e=t.clientWidth,i=t.clientHeight}return{width:e,height:i,maxWidth:s||T,maxHeight:n||T}}(t,e,i);let{width:h,height:c}=l;if("content-box"===n.boxSizing){const t=_e(n,"border","width"),e=_e(n,"padding");h-=e.width+t.width,c-=e.height+t.height}h=Math.max(0,h-o.width),c=Math.max(0,s?h/s:c-o.height),h=Me(Math.min(h,a,l.maxWidth)),c=Me(Math.min(c,r,l.maxHeight)),h&&!c&&(c=Me(h/2));return(void 0!==e||void 0!==i)&&s&&l.height&&c>l.height&&(c=l.height,h=Me(Math.floor(c*s))),{width:h,height:c}}function ke(t,e,i){const s=e||1,n=Me(t.height*s),o=Me(t.width*s);t.height=Me(t.height),t.width=Me(t.width);const a=t.canvas;return a.style&&(i||!a.style.height&&!a.style.width)&&(a.style.height=`${t.height}px`,a.style.width=`${t.width}px`),(t.currentDevicePixelRatio!==s||a.height!==n||a.width!==o)&&(t.currentDevicePixelRatio=s,a.height=n,a.width=o,t.ctx.setTransform(s,0,0,s,0,0),!0)}const Se=function(){let t=!1;try{const e={get passive(){return t=!0,!1}};fe()&&(window.addEventListener("test",null,e),window.removeEventListener("test",null,e))}catch(t){}return t}();function Pe(t,e){const i=xe(t,e),s=i&&i.match(/^(\d+)(\.\d+)?px$/);return s?+s[1]:void 0}function De(t){return!t||s(t.size)||s(t.family)?null:(t.style?t.style+" ":"")+(t.weight?t.weight+" ":"")+t.size+"px "+t.family}function Ce(t,e,i,s,n){let o=e[n];return o||(o=e[n]=t.measureText(n).width,i.push(n)),o>s&&(s=o),s}function Oe(t,e,i,s){let o=(s=s||{}).data=s.data||{},a=s.garbageCollect=s.garbageCollect||[];s.font!==e&&(o=s.data={},a=s.garbageCollect=[],s.font=e),t.save(),t.font=e;let r=0;const l=i.length;let h,c,d,u,f;for(h=0;hi.length){for(h=0;h0&&t.stroke()}}function Re(t,e,i){return i=i||.5,!e||t&&t.x>e.left-i&&t.xe.top-i&&t.y0&&""!==r.strokeColor;let c,d;for(t.save(),t.font=a.string,function(t,e){e.translation&&t.translate(e.translation[0],e.translation[1]),s(e.rotation)||t.rotate(e.rotation),e.color&&(t.fillStyle=e.color),e.textAlign&&(t.textAlign=e.textAlign),e.textBaseline&&(t.textBaseline=e.textBaseline)}(t,r),c=0;ct[0])){const o=i||t;void 0===s&&(s=ti("_fallback",t));const a={[Symbol.toStringTag]:"Object",_cacheable:!0,_scopes:t,_rootScopes:o,_fallback:s,_getTarget:n,override:i=>je([i,...t],e,o,s)};return new Proxy(a,{deleteProperty:(e,i)=>(delete e[i],delete e._keys,delete t[0][i],!0),get:(i,s)=>qe(i,s,(()=>function(t,e,i,s){let n;for(const o of e)if(n=ti(Ue(o,t),i),void 0!==n)return Xe(t,n)?Ze(i,s,t,n):n}(s,e,t,i))),getOwnPropertyDescriptor:(t,e)=>Reflect.getOwnPropertyDescriptor(t._scopes[0],e),getPrototypeOf:()=>Reflect.getPrototypeOf(t[0]),has:(t,e)=>ei(t).includes(e),ownKeys:t=>ei(t),set(t,e,i){const s=t._storage||(t._storage=n());return t[e]=s[e]=i,delete t._keys,!0}})}function $e(t,e,i,s){const a={_cacheable:!1,_proxy:t,_context:e,_subProxy:i,_stack:new Set,_descriptors:Ye(t,s),setContext:e=>$e(t,e,i,s),override:n=>$e(t.override(n),e,i,s)};return new Proxy(a,{deleteProperty:(e,i)=>(delete e[i],delete t[i],!0),get:(t,e,i)=>qe(t,e,(()=>function(t,e,i){const{_proxy:s,_context:a,_subProxy:r,_descriptors:l}=t;let h=s[e];S(h)&&l.isScriptable(e)&&(h=function(t,e,i,s){const{_proxy:n,_context:o,_subProxy:a,_stack:r}=i;if(r.has(t))throw new Error("Recursion detected: "+Array.from(r).join("->")+"->"+t);r.add(t);let l=e(o,a||s);r.delete(t),Xe(t,l)&&(l=Ze(n._scopes,n,t,l));return l}(e,h,t,i));n(h)&&h.length&&(h=function(t,e,i,s){const{_proxy:n,_context:a,_subProxy:r,_descriptors:l}=i;if(void 0!==a.index&&s(t))return e[a.index%e.length];if(o(e[0])){const i=e,s=n._scopes.filter((t=>t!==i));e=[];for(const o of i){const i=Ze(s,n,t,o);e.push($e(i,a,r&&r[t],l))}}return e}(e,h,t,l.isIndexable));Xe(e,h)&&(h=$e(h,a,r&&r[e],l));return h}(t,e,i))),getOwnPropertyDescriptor:(e,i)=>e._descriptors.allKeys?Reflect.has(t,i)?{enumerable:!0,configurable:!0}:void 0:Reflect.getOwnPropertyDescriptor(t,i),getPrototypeOf:()=>Reflect.getPrototypeOf(t),has:(e,i)=>Reflect.has(t,i),ownKeys:()=>Reflect.ownKeys(t),set:(e,i,s)=>(t[i]=s,delete e[i],!0)})}function Ye(t,e={scriptable:!0,indexable:!0}){const{_scriptable:i=e.scriptable,_indexable:s=e.indexable,_allKeys:n=e.allKeys}=t;return{allKeys:n,scriptable:i,indexable:s,isScriptable:S(i)?i:()=>i,isIndexable:S(s)?s:()=>s}}const Ue=(t,e)=>t?t+w(e):e,Xe=(t,e)=>o(e)&&"adapters"!==t&&(null===Object.getPrototypeOf(e)||e.constructor===Object);function qe(t,e,i){if(Object.prototype.hasOwnProperty.call(t,e)||"constructor"===e)return t[e];const s=i();return t[e]=s,s}function Ke(t,e,i){return S(t)?t(e,i):t}const Ge=(t,e)=>!0===t?e:"string"==typeof t?M(e,t):void 0;function Je(t,e,i,s,n){for(const o of e){const e=Ge(i,o);if(e){t.add(e);const o=Ke(e._fallback,i,n);if(void 0!==o&&o!==i&&o!==s)return o}else if(!1===e&&void 0!==s&&i!==s)return null}return!1}function Ze(t,e,i,s){const a=e._rootScopes,r=Ke(e._fallback,i,s),l=[...t,...a],h=new Set;h.add(s);let c=Qe(h,l,i,r||i,s);return null!==c&&((void 0===r||r===i||(c=Qe(h,l,r,c,s),null!==c))&&je(Array.from(h),[""],a,r,(()=>function(t,e,i){const s=t._getTarget();e in s||(s[e]={});const a=s[e];if(n(a)&&o(i))return i;return a||{}}(e,i,s))))}function Qe(t,e,i,s,n){for(;i;)i=Je(t,e,i,s,n);return i}function ti(t,e){for(const i of e){if(!i)continue;const e=i[t];if(void 0!==e)return e}}function ei(t){let e=t._keys;return e||(e=t._keys=function(t){const e=new Set;for(const i of t)for(const t of Object.keys(i).filter((t=>!t.startsWith("_"))))e.add(t);return Array.from(e)}(t._scopes)),e}function ii(t,e,i,s){const{iScale:n}=t,{key:o="r"}=this._parsing,a=new Array(s);let r,l,h,c;for(r=0,l=s;re"x"===t?"y":"x";function ai(t,e,i,s){const n=t.skip?e:t,o=e,a=i.skip?e:i,r=q(o,n),l=q(a,o);let h=r/(r+l),c=l/(r+l);h=isNaN(h)?0:h,c=isNaN(c)?0:c;const d=s*h,u=s*c;return{previous:{x:o.x-d*(a.x-n.x),y:o.y-d*(a.y-n.y)},next:{x:o.x+u*(a.x-n.x),y:o.y+u*(a.y-n.y)}}}function ri(t,e="x"){const i=oi(e),s=t.length,n=Array(s).fill(0),o=Array(s);let a,r,l,h=ni(t,0);for(a=0;a!t.skip))),"monotone"===e.cubicInterpolationMode)ri(t,n);else{let i=s?t[t.length-1]:t[0];for(o=0,a=t.length;o0===t||1===t,di=(t,e,i)=>-Math.pow(2,10*(t-=1))*Math.sin((t-e)*O/i),ui=(t,e,i)=>Math.pow(2,-10*t)*Math.sin((t-e)*O/i)+1,fi={linear:t=>t,easeInQuad:t=>t*t,easeOutQuad:t=>-t*(t-2),easeInOutQuad:t=>(t/=.5)<1?.5*t*t:-.5*(--t*(t-2)-1),easeInCubic:t=>t*t*t,easeOutCubic:t=>(t-=1)*t*t+1,easeInOutCubic:t=>(t/=.5)<1?.5*t*t*t:.5*((t-=2)*t*t+2),easeInQuart:t=>t*t*t*t,easeOutQuart:t=>-((t-=1)*t*t*t-1),easeInOutQuart:t=>(t/=.5)<1?.5*t*t*t*t:-.5*((t-=2)*t*t*t-2),easeInQuint:t=>t*t*t*t*t,easeOutQuint:t=>(t-=1)*t*t*t*t+1,easeInOutQuint:t=>(t/=.5)<1?.5*t*t*t*t*t:.5*((t-=2)*t*t*t*t+2),easeInSine:t=>1-Math.cos(t*E),easeOutSine:t=>Math.sin(t*E),easeInOutSine:t=>-.5*(Math.cos(C*t)-1),easeInExpo:t=>0===t?0:Math.pow(2,10*(t-1)),easeOutExpo:t=>1===t?1:1-Math.pow(2,-10*t),easeInOutExpo:t=>ci(t)?t:t<.5?.5*Math.pow(2,10*(2*t-1)):.5*(2-Math.pow(2,-10*(2*t-1))),easeInCirc:t=>t>=1?t:-(Math.sqrt(1-t*t)-1),easeOutCirc:t=>Math.sqrt(1-(t-=1)*t),easeInOutCirc:t=>(t/=.5)<1?-.5*(Math.sqrt(1-t*t)-1):.5*(Math.sqrt(1-(t-=2)*t)+1),easeInElastic:t=>ci(t)?t:di(t,.075,.3),easeOutElastic:t=>ci(t)?t:ui(t,.075,.3),easeInOutElastic(t){const e=.1125;return ci(t)?t:t<.5?.5*di(2*t,e,.45):.5+.5*ui(2*t-1,e,.45)},easeInBack(t){const e=1.70158;return t*t*((e+1)*t-e)},easeOutBack(t){const e=1.70158;return(t-=1)*t*((e+1)*t+e)+1},easeInOutBack(t){let e=1.70158;return(t/=.5)<1?t*t*((1+(e*=1.525))*t-e)*.5:.5*((t-=2)*t*((1+(e*=1.525))*t+e)+2)},easeInBounce:t=>1-fi.easeOutBounce(1-t),easeOutBounce(t){const e=7.5625,i=2.75;return t<1/i?e*t*t:t<2/i?e*(t-=1.5/i)*t+.75:t<2.5/i?e*(t-=2.25/i)*t+.9375:e*(t-=2.625/i)*t+.984375},easeInOutBounce:t=>t<.5?.5*fi.easeInBounce(2*t):.5*fi.easeOutBounce(2*t-1)+.5};function gi(t,e,i,s){return{x:t.x+i*(e.x-t.x),y:t.y+i*(e.y-t.y)}}function pi(t,e,i,s){return{x:t.x+i*(e.x-t.x),y:"middle"===s?i<.5?t.y:e.y:"after"===s?i<1?t.y:e.y:i>0?e.y:t.y}}function mi(t,e,i,s){const n={x:t.cp2x,y:t.cp2y},o={x:e.cp1x,y:e.cp1y},a=gi(t,n,i),r=gi(n,o,i),l=gi(o,e,i),h=gi(a,r,i),c=gi(r,l,i);return gi(h,c,i)}const xi=/^(normal|(\d+(?:\.\d+)?)(px|em|%)?)$/,bi=/^(normal|italic|initial|inherit|unset|(oblique( -?[0-9]?[0-9]deg)?))$/;function _i(t,e){const i=(""+t).match(xi);if(!i||"normal"===i[1])return 1.2*e;switch(t=+i[2],i[3]){case"px":return t;case"%":t/=100}return e*t}const yi=t=>+t||0;function vi(t,e){const i={},s=o(e),n=s?Object.keys(e):e,a=o(t)?s?i=>l(t[i],t[e[i]]):e=>t[e]:()=>t;for(const t of n)i[t]=yi(a(t));return i}function Mi(t){return vi(t,{top:"y",right:"x",bottom:"y",left:"x"})}function wi(t){return vi(t,["topLeft","topRight","bottomLeft","bottomRight"])}function ki(t){const e=Mi(t);return e.width=e.left+e.right,e.height=e.top+e.bottom,e}function Si(t,e){t=t||{},e=e||ue.font;let i=l(t.size,e.size);"string"==typeof i&&(i=parseInt(i,10));let s=l(t.style,e.style);s&&!(""+s).match(bi)&&(console.warn('Invalid font style specified: "'+s+'"'),s=void 0);const n={family:l(t.family,e.family),lineHeight:_i(l(t.lineHeight,e.lineHeight),i),size:i,style:s,weight:l(t.weight,e.weight),string:""};return n.string=De(n),n}function Pi(t,e,i,s){let o,a,r,l=!0;for(o=0,a=t.length;oi&&0===t?0:t+e;return{min:a(s,-Math.abs(o)),max:a(n,o)}}function Ci(t,e){return Object.assign(Object.create(t),e)}function Oi(t,e,i){return t?function(t,e){return{x:i=>t+t+e-i,setWidth(t){e=t},textAlign:t=>"center"===t?t:"right"===t?"left":"right",xPlus:(t,e)=>t-e,leftForLtr:(t,e)=>t-e}}(e,i):{x:t=>t,setWidth(t){},textAlign:t=>t,xPlus:(t,e)=>t+e,leftForLtr:(t,e)=>t}}function Ai(t,e){let i,s;"ltr"!==e&&"rtl"!==e||(i=t.canvas.style,s=[i.getPropertyValue("direction"),i.getPropertyPriority("direction")],i.setProperty("direction",e,"important"),t.prevTextDirection=s)}function Ti(t,e){void 0!==e&&(delete t.prevTextDirection,t.canvas.style.setProperty("direction",e[0],e[1]))}function Li(t){return"angle"===t?{between:J,compare:K,normalize:G}:{between:tt,compare:(t,e)=>t-e,normalize:t=>t}}function Ei({start:t,end:e,count:i,loop:s,style:n}){return{start:t%i,end:e%i,loop:s&&(e-t+1)%i==0,style:n}}function Ri(t,e,i){if(!i)return[t];const{property:s,start:n,end:o}=i,a=e.length,{compare:r,between:l,normalize:h}=Li(s),{start:c,end:d,loop:u,style:f}=function(t,e,i){const{property:s,start:n,end:o}=i,{between:a,normalize:r}=Li(s),l=e.length;let h,c,{start:d,end:u,loop:f}=t;if(f){for(d+=l,u+=l,h=0,c=l;hb||l(n,x,p)&&0!==r(n,x),v=()=>!b||0===r(o,p)||l(o,x,p);for(let t=c,i=c;t<=d;++t)m=e[t%a],m.skip||(p=h(m[s]),p!==x&&(b=l(p,n,o),null===_&&y()&&(_=0===r(p,n)?t:i),null!==_&&v()&&(g.push(Ei({start:_,end:t,loop:u,count:a,style:f})),_=null),i=t,x=p));return null!==_&&g.push(Ei({start:_,end:d,loop:u,count:a,style:f})),g}function Ii(t,e){const i=[],s=t.segments;for(let n=0;nn&&t[o%e].skip;)o--;return o%=e,{start:n,end:o}}(i,n,o,s);if(!0===s)return Fi(t,[{start:a,end:r,loop:o}],i,e);return Fi(t,function(t,e,i,s){const n=t.length,o=[];let a,r=e,l=t[e];for(a=e+1;a<=i;++a){const i=t[a%n];i.skip||i.stop?l.skip||(s=!1,o.push({start:e%n,end:(a-1)%n,loop:s}),e=r=i.stop?a:null):(r=a,l.skip&&(e=a)),l=i}return null!==r&&o.push({start:e%n,end:r%n,loop:s}),o}(i,a,r!s(t[e.axis])));n.lo-=Math.max(0,a);const r=i.slice(n.hi).findIndex((t=>!s(t[e.axis])));n.hi+=Math.max(0,r)}return n}if(o._sharedOptions){const t=a[0],s="function"==typeof t.getRange&&t.getRange(e);if(s){const t=r(a,e,i-s),n=r(a,e,i+s);return{lo:t.lo,hi:n.hi}}}}return{lo:0,hi:a.length-1}}function $i(t,e,i,s,n){const o=t.getSortedVisibleDatasetMetas(),a=i[e];for(let t=0,i=o.length;t{t[a]&&t[a](e[i],n)&&(o.push({element:t,datasetIndex:s,index:l}),r=r||t.inRange(e.x,e.y,n))})),s&&!r?[]:o}var Ki={evaluateInteractionItems:$i,modes:{index(t,e,i,s){const n=ve(e,t),o=i.axis||"x",a=i.includeInvisible||!1,r=i.intersect?Yi(t,n,o,s,a):Xi(t,n,o,!1,s,a),l=[];return r.length?(t.getSortedVisibleDatasetMetas().forEach((t=>{const e=r[0].index,i=t.data[e];i&&!i.skip&&l.push({element:i,datasetIndex:t.index,index:e})})),l):[]},dataset(t,e,i,s){const n=ve(e,t),o=i.axis||"xy",a=i.includeInvisible||!1;let r=i.intersect?Yi(t,n,o,s,a):Xi(t,n,o,!1,s,a);if(r.length>0){const e=r[0].datasetIndex,i=t.getDatasetMeta(e).data;r=[];for(let t=0;tYi(t,ve(e,t),i.axis||"xy",s,i.includeInvisible||!1),nearest(t,e,i,s){const n=ve(e,t),o=i.axis||"xy",a=i.includeInvisible||!1;return Xi(t,n,o,i.intersect,s,a)},x:(t,e,i,s)=>qi(t,ve(e,t),"x",i.intersect,s),y:(t,e,i,s)=>qi(t,ve(e,t),"y",i.intersect,s)}};const Gi=["left","top","right","bottom"];function Ji(t,e){return t.filter((t=>t.pos===e))}function Zi(t,e){return t.filter((t=>-1===Gi.indexOf(t.pos)&&t.box.axis===e))}function Qi(t,e){return t.sort(((t,i)=>{const s=e?i:t,n=e?t:i;return s.weight===n.weight?s.index-n.index:s.weight-n.weight}))}function ts(t,e){const i=function(t){const e={};for(const i of t){const{stack:t,pos:s,stackWeight:n}=i;if(!t||!Gi.includes(s))continue;const o=e[t]||(e[t]={count:0,placed:0,weight:0,size:0});o.count++,o.weight+=n}return e}(t),{vBoxMaxWidth:s,hBoxMaxHeight:n}=e;let o,a,r;for(o=0,a=t.length;o{s[t]=Math.max(e[t],i[t])})),s}return s(t?["left","right"]:["top","bottom"])}function os(t,e,i,s){const n=[];let o,a,r,l,h,c;for(o=0,a=t.length,h=0;ot.box.fullSize)),!0),s=Qi(Ji(e,"left"),!0),n=Qi(Ji(e,"right")),o=Qi(Ji(e,"top"),!0),a=Qi(Ji(e,"bottom")),r=Zi(e,"x"),l=Zi(e,"y");return{fullSize:i,leftAndTop:s.concat(o),rightAndBottom:n.concat(l).concat(a).concat(r),chartArea:Ji(e,"chartArea"),vertical:s.concat(n).concat(l),horizontal:o.concat(a).concat(r)}}(t.boxes),l=r.vertical,h=r.horizontal;u(t.boxes,(t=>{"function"==typeof t.beforeLayout&&t.beforeLayout()}));const c=l.reduce(((t,e)=>e.box.options&&!1===e.box.options.display?t:t+1),0)||1,d=Object.freeze({outerWidth:e,outerHeight:i,padding:n,availableWidth:o,availableHeight:a,vBoxMaxWidth:o/2/c,hBoxMaxHeight:a/2}),f=Object.assign({},n);is(f,ki(s));const g=Object.assign({maxPadding:f,w:o,h:a,x:n.left,y:n.top},n),p=ts(l.concat(h),d);os(r.fullSize,g,d,p),os(l,g,d,p),os(h,g,d,p)&&os(l,g,d,p),function(t){const e=t.maxPadding;function i(i){const s=Math.max(e[i]-t[i],0);return t[i]+=s,s}t.y+=i("top"),t.x+=i("left"),i("right"),i("bottom")}(g),rs(r.leftAndTop,g,d,p),g.x+=g.w,g.y+=g.h,rs(r.rightAndBottom,g,d,p),t.chartArea={left:g.left,top:g.top,right:g.left+g.w,bottom:g.top+g.h,height:g.h,width:g.w},u(r.chartArea,(e=>{const i=e.box;Object.assign(i,t.chartArea),i.update(g.w,g.h,{left:0,top:0,right:0,bottom:0})}))}};class hs{acquireContext(t,e){}releaseContext(t){return!1}addEventListener(t,e,i){}removeEventListener(t,e,i){}getDevicePixelRatio(){return 1}getMaximumSize(t,e,i,s){return e=Math.max(0,e||t.width),i=i||t.height,{width:e,height:Math.max(0,s?Math.floor(e/s):i)}}isAttached(t){return!0}updateConfig(t){}}class cs extends hs{acquireContext(t){return t&&t.getContext&&t.getContext("2d")||null}updateConfig(t){t.options.animation=!1}}const ds="$chartjs",us={touchstart:"mousedown",touchmove:"mousemove",touchend:"mouseup",pointerenter:"mouseenter",pointerdown:"mousedown",pointermove:"mousemove",pointerup:"mouseup",pointerleave:"mouseout",pointerout:"mouseout"},fs=t=>null===t||""===t;const gs=!!Se&&{passive:!0};function ps(t,e,i){t&&t.canvas&&t.canvas.removeEventListener(e,i,gs)}function ms(t,e){for(const i of t)if(i===e||i.contains(e))return!0}function xs(t,e,i){const s=t.canvas,n=new MutationObserver((t=>{let e=!1;for(const i of t)e=e||ms(i.addedNodes,s),e=e&&!ms(i.removedNodes,s);e&&i()}));return n.observe(document,{childList:!0,subtree:!0}),n}function bs(t,e,i){const s=t.canvas,n=new MutationObserver((t=>{let e=!1;for(const i of t)e=e||ms(i.removedNodes,s),e=e&&!ms(i.addedNodes,s);e&&i()}));return n.observe(document,{childList:!0,subtree:!0}),n}const _s=new Map;let ys=0;function vs(){const t=window.devicePixelRatio;t!==ys&&(ys=t,_s.forEach(((e,i)=>{i.currentDevicePixelRatio!==t&&e()})))}function Ms(t,e,i){const s=t.canvas,n=s&&ge(s);if(!n)return;const o=ct(((t,e)=>{const s=n.clientWidth;i(t,e),s{const e=t[0],i=e.contentRect.width,s=e.contentRect.height;0===i&&0===s||o(i,s)}));return a.observe(n),function(t,e){_s.size||window.addEventListener("resize",vs),_s.set(t,e)}(t,o),a}function ws(t,e,i){i&&i.disconnect(),"resize"===e&&function(t){_s.delete(t),_s.size||window.removeEventListener("resize",vs)}(t)}function ks(t,e,i){const s=t.canvas,n=ct((e=>{null!==t.ctx&&i(function(t,e){const i=us[t.type]||t.type,{x:s,y:n}=ve(t,e);return{type:i,chart:e,native:t,x:void 0!==s?s:null,y:void 0!==n?n:null}}(e,t))}),t);return function(t,e,i){t&&t.addEventListener(e,i,gs)}(s,e,n),n}class Ss extends hs{acquireContext(t,e){const i=t&&t.getContext&&t.getContext("2d");return i&&i.canvas===t?(function(t,e){const i=t.style,s=t.getAttribute("height"),n=t.getAttribute("width");if(t[ds]={initial:{height:s,width:n,style:{display:i.display,height:i.height,width:i.width}}},i.display=i.display||"block",i.boxSizing=i.boxSizing||"border-box",fs(n)){const e=Pe(t,"width");void 0!==e&&(t.width=e)}if(fs(s))if(""===t.style.height)t.height=t.width/(e||2);else{const e=Pe(t,"height");void 0!==e&&(t.height=e)}}(t,e),i):null}releaseContext(t){const e=t.canvas;if(!e[ds])return!1;const i=e[ds].initial;["height","width"].forEach((t=>{const n=i[t];s(n)?e.removeAttribute(t):e.setAttribute(t,n)}));const n=i.style||{};return Object.keys(n).forEach((t=>{e.style[t]=n[t]})),e.width=e.width,delete e[ds],!0}addEventListener(t,e,i){this.removeEventListener(t,e);const s=t.$proxies||(t.$proxies={}),n={attach:xs,detach:bs,resize:Ms}[e]||ks;s[e]=n(t,e,i)}removeEventListener(t,e){const i=t.$proxies||(t.$proxies={}),s=i[e];if(!s)return;({attach:ws,detach:ws,resize:ws}[e]||ps)(t,e,s),i[e]=void 0}getDevicePixelRatio(){return window.devicePixelRatio}getMaximumSize(t,e,i,s){return we(t,e,i,s)}isAttached(t){const e=t&&ge(t);return!(!e||!e.isConnected)}}function Ps(t){return!fe()||"undefined"!=typeof OffscreenCanvas&&t instanceof OffscreenCanvas?cs:Ss}var Ds=Object.freeze({__proto__:null,BasePlatform:hs,BasicPlatform:cs,DomPlatform:Ss,_detectPlatform:Ps});const Cs="transparent",Os={boolean:(t,e,i)=>i>.5?e:t,color(t,e,i){const s=Qt(t||Cs),n=s.valid&&Qt(e||Cs);return n&&n.valid?n.mix(s,i).hexString():e},number:(t,e,i)=>t+(e-t)*i};class As{constructor(t,e,i,s){const n=e[i];s=Pi([t.to,s,n,t.from]);const o=Pi([t.from,n,s]);this._active=!0,this._fn=t.fn||Os[t.type||typeof o],this._easing=fi[t.easing]||fi.linear,this._start=Math.floor(Date.now()+(t.delay||0)),this._duration=this._total=Math.floor(t.duration),this._loop=!!t.loop,this._target=e,this._prop=i,this._from=o,this._to=s,this._promises=void 0}active(){return this._active}update(t,e,i){if(this._active){this._notify(!1);const s=this._target[this._prop],n=i-this._start,o=this._duration-n;this._start=i,this._duration=Math.floor(Math.max(o,t.duration)),this._total+=n,this._loop=!!t.loop,this._to=Pi([t.to,e,s,t.from]),this._from=Pi([t.from,s,e])}}cancel(){this._active&&(this.tick(Date.now()),this._active=!1,this._notify(!1))}tick(t){const e=t-this._start,i=this._duration,s=this._prop,n=this._from,o=this._loop,a=this._to;let r;if(this._active=n!==a&&(o||e1?2-r:r,r=this._easing(Math.min(1,Math.max(0,r))),this._target[s]=this._fn(n,a,r))}wait(){const t=this._promises||(this._promises=[]);return new Promise(((e,i)=>{t.push({res:e,rej:i})}))}_notify(t){const e=t?"res":"rej",i=this._promises||[];for(let t=0;t{const a=t[s];if(!o(a))return;const r={};for(const t of e)r[t]=a[t];(n(a.properties)&&a.properties||[s]).forEach((t=>{t!==s&&i.has(t)||i.set(t,r)}))}))}_animateOptions(t,e){const i=e.options,s=function(t,e){if(!e)return;let i=t.options;if(!i)return void(t.options=e);i.$shared&&(t.options=i=Object.assign({},i,{$shared:!1,$animations:{}}));return i}(t,i);if(!s)return[];const n=this._createAnimations(s,i);return i.$shared&&function(t,e){const i=[],s=Object.keys(e);for(let e=0;e{t.options=i}),(()=>{})),n}_createAnimations(t,e){const i=this._properties,s=[],n=t.$animations||(t.$animations={}),o=Object.keys(e),a=Date.now();let r;for(r=o.length-1;r>=0;--r){const l=o[r];if("$"===l.charAt(0))continue;if("options"===l){s.push(...this._animateOptions(t,e));continue}const h=e[l];let c=n[l];const d=i.get(l);if(c){if(d&&c.active()){c.update(d,h,a);continue}c.cancel()}d&&d.duration?(n[l]=c=new As(d,t,l,h),s.push(c)):t[l]=h}return s}update(t,e){if(0===this._properties.size)return void Object.assign(t,e);const i=this._createAnimations(t,e);return i.length?(bt.add(this._chart,i),!0):void 0}}function Ls(t,e){const i=t&&t.options||{},s=i.reverse,n=void 0===i.min?e:0,o=void 0===i.max?e:0;return{start:s?o:n,end:s?n:o}}function Es(t,e){const i=[],s=t._getSortedDatasetMetas(e);let n,o;for(n=0,o=s.length;n0||!i&&e<0)return n.index}return null}function Vs(t,e){const{chart:i,_cachedMeta:s}=t,n=i._stacks||(i._stacks={}),{iScale:o,vScale:a,index:r}=s,l=o.axis,h=a.axis,c=function(t,e,i){return`${t.id}.${e.id}.${i.stack||i.type}`}(o,a,s),d=e.length;let u;for(let t=0;ti[t].axis===e)).shift()}function Ws(t,e){const i=t.controller.index,s=t.vScale&&t.vScale.axis;if(s){e=e||t._parsed;for(const t of e){const e=t._stacks;if(!e||void 0===e[s]||void 0===e[s][i])return;delete e[s][i],void 0!==e[s]._visualValues&&void 0!==e[s]._visualValues[i]&&delete e[s]._visualValues[i]}}}const Ns=t=>"reset"===t||"none"===t,Hs=(t,e)=>e?t:Object.assign({},t);class js{static defaults={};static datasetElementType=null;static dataElementType=null;constructor(t,e){this.chart=t,this._ctx=t.ctx,this.index=e,this._cachedDataOpts={},this._cachedMeta=this.getMeta(),this._type=this._cachedMeta.type,this.options=void 0,this._parsing=!1,this._data=void 0,this._objectData=void 0,this._sharedOptions=void 0,this._drawStart=void 0,this._drawCount=void 0,this.enableOptionSharing=!1,this.supportsDecimation=!1,this.$context=void 0,this._syncList=[],this.datasetElementType=new.target.datasetElementType,this.dataElementType=new.target.dataElementType,this.initialize()}initialize(){const t=this._cachedMeta;this.configure(),this.linkScales(),t._stacked=Is(t.vScale,t),this.addElements(),this.options.fill&&!this.chart.isPluginEnabled("filler")&&console.warn("Tried to use the 'fill' option without the 'Filler' plugin enabled. Please import and register the 'Filler' plugin and make sure it is not disabled in the options")}updateIndex(t){this.index!==t&&Ws(this._cachedMeta),this.index=t}linkScales(){const t=this.chart,e=this._cachedMeta,i=this.getDataset(),s=(t,e,i,s)=>"x"===t?e:"r"===t?s:i,n=e.xAxisID=l(i.xAxisID,Bs(t,"x")),o=e.yAxisID=l(i.yAxisID,Bs(t,"y")),a=e.rAxisID=l(i.rAxisID,Bs(t,"r")),r=e.indexAxis,h=e.iAxisID=s(r,n,o,a),c=e.vAxisID=s(r,o,n,a);e.xScale=this.getScaleForId(n),e.yScale=this.getScaleForId(o),e.rScale=this.getScaleForId(a),e.iScale=this.getScaleForId(h),e.vScale=this.getScaleForId(c)}getDataset(){return this.chart.data.datasets[this.index]}getMeta(){return this.chart.getDatasetMeta(this.index)}getScaleForId(t){return this.chart.scales[t]}_getOtherScale(t){const e=this._cachedMeta;return t===e.iScale?e.vScale:e.iScale}reset(){this._update("reset")}_destroy(){const t=this._cachedMeta;this._data&&rt(this._data,this),t._stacked&&Ws(t)}_dataCheck(){const t=this.getDataset(),e=t.data||(t.data=[]),i=this._data;if(o(e)){const t=this._cachedMeta;this._data=function(t,e){const{iScale:i,vScale:s}=e,n="x"===i.axis?"x":"y",o="x"===s.axis?"x":"y",a=Object.keys(t),r=new Array(a.length);let l,h,c;for(l=0,h=a.length;l0&&i._parsed[t-1];if(!1===this._parsing)i._parsed=s,i._sorted=!0,d=s;else{d=n(s[t])?this.parseArrayData(i,s,t,e):o(s[t])?this.parseObjectData(i,s,t,e):this.parsePrimitiveData(i,s,t,e);const a=()=>null===c[l]||f&&c[l]t&&!e.hidden&&e._stacked&&{keys:Es(i,!0),values:null})(e,i,this.chart),h={min:Number.POSITIVE_INFINITY,max:Number.NEGATIVE_INFINITY},{min:c,max:d}=function(t){const{min:e,max:i,minDefined:s,maxDefined:n}=t.getUserBounds();return{min:s?e:Number.NEGATIVE_INFINITY,max:n?i:Number.POSITIVE_INFINITY}}(r);let u,f;function g(){f=s[u];const e=f[r.axis];return!a(f[t.axis])||c>e||d=0;--u)if(!g()){this.updateRangeFromParsed(h,t,f,l);break}return h}getAllParsedValues(t){const e=this._cachedMeta._parsed,i=[];let s,n,o;for(s=0,n=e.length;s=0&&tthis.getContext(i,s,e)),c);return f.$shared&&(f.$shared=r,n[o]=Object.freeze(Hs(f,r))),f}_resolveAnimations(t,e,i){const s=this.chart,n=this._cachedDataOpts,o=`animation-${e}`,a=n[o];if(a)return a;let r;if(!1!==s.options.animation){const s=this.chart.config,n=s.datasetAnimationScopeKeys(this._type,e),o=s.getOptionScopes(this.getDataset(),n);r=s.createResolver(o,this.getContext(t,i,e))}const l=new Ts(s,r&&r.animations);return r&&r._cacheable&&(n[o]=Object.freeze(l)),l}getSharedOptions(t){if(t.$shared)return this._sharedOptions||(this._sharedOptions=Object.assign({},t))}includeOptions(t,e){return!e||Ns(t)||this.chart._animationsDisabled}_getSharedOptions(t,e){const i=this.resolveDataElementOptions(t,e),s=this._sharedOptions,n=this.getSharedOptions(i),o=this.includeOptions(e,n)||n!==s;return this.updateSharedOptions(n,e,i),{sharedOptions:n,includeOptions:o}}updateElement(t,e,i,s){Ns(s)?Object.assign(t,i):this._resolveAnimations(e,s).update(t,i)}updateSharedOptions(t,e,i){t&&!Ns(e)&&this._resolveAnimations(void 0,e).update(t,i)}_setStyle(t,e,i,s){t.active=s;const n=this.getStyle(e,s);this._resolveAnimations(e,i,s).update(t,{options:!s&&this.getSharedOptions(n)||n})}removeHoverStyle(t,e,i){this._setStyle(t,i,"active",!1)}setHoverStyle(t,e,i){this._setStyle(t,i,"active",!0)}_removeDatasetHoverStyle(){const t=this._cachedMeta.dataset;t&&this._setStyle(t,void 0,"active",!1)}_setDatasetHoverStyle(){const t=this._cachedMeta.dataset;t&&this._setStyle(t,void 0,"active",!0)}_resyncElements(t){const e=this._data,i=this._cachedMeta.data;for(const[t,e,i]of this._syncList)this[t](e,i);this._syncList=[];const s=i.length,n=e.length,o=Math.min(n,s);o&&this.parse(0,o),n>s?this._insertElements(s,n-s,t):n{for(t.length+=e,a=t.length-1;a>=o;a--)t[a]=t[a-e]};for(r(n),a=t;a{s[t]=i[t]&&i[t].active()?i[t]._to:this[t]})),s}}function Ys(t,e){const i=t.options.ticks,n=function(t){const e=t.options.offset,i=t._tickSize(),s=t._length/i+(e?0:1),n=t._maxLength/i;return Math.floor(Math.min(s,n))}(t),o=Math.min(i.maxTicksLimit||n,n),a=i.major.enabled?function(t){const e=[];let i,s;for(i=0,s=t.length;io)return function(t,e,i,s){let n,o=0,a=i[0];for(s=Math.ceil(s),n=0;nn)return e}return Math.max(n,1)}(a,e,o);if(r>0){let t,i;const n=r>1?Math.round((h-l)/(r-1)):null;for(Us(e,c,d,s(n)?0:l-n,l),t=0,i=r-1;t"top"===e||"left"===e?t[e]+i:t[e]-i,qs=(t,e)=>Math.min(e||t,t);function Ks(t,e){const i=[],s=t.length/e,n=t.length;let o=0;for(;oa+r)))return h}function Js(t){return t.drawTicks?t.tickLength:0}function Zs(t,e){if(!t.display)return 0;const i=Si(t.font,e),s=ki(t.padding);return(n(t.text)?t.text.length:1)*i.lineHeight+s.height}function Qs(t,e,i){let s=ut(t);return(i&&"right"!==e||!i&&"right"===e)&&(s=(t=>"left"===t?"right":"right"===t?"left":t)(s)),s}class tn extends $s{constructor(t){super(),this.id=t.id,this.type=t.type,this.options=void 0,this.ctx=t.ctx,this.chart=t.chart,this.top=void 0,this.bottom=void 0,this.left=void 0,this.right=void 0,this.width=void 0,this.height=void 0,this._margins={left:0,right:0,top:0,bottom:0},this.maxWidth=void 0,this.maxHeight=void 0,this.paddingTop=void 0,this.paddingBottom=void 0,this.paddingLeft=void 0,this.paddingRight=void 0,this.axis=void 0,this.labelRotation=void 0,this.min=void 0,this.max=void 0,this._range=void 0,this.ticks=[],this._gridLineItems=null,this._labelItems=null,this._labelSizes=null,this._length=0,this._maxLength=0,this._longestTextCache={},this._startPixel=void 0,this._endPixel=void 0,this._reversePixels=!1,this._userMax=void 0,this._userMin=void 0,this._suggestedMax=void 0,this._suggestedMin=void 0,this._ticksLength=0,this._borderValue=0,this._cache={},this._dataLimitsCached=!1,this.$context=void 0}init(t){this.options=t.setContext(this.getContext()),this.axis=t.axis,this._userMin=this.parse(t.min),this._userMax=this.parse(t.max),this._suggestedMin=this.parse(t.suggestedMin),this._suggestedMax=this.parse(t.suggestedMax)}parse(t,e){return t}getUserBounds(){let{_userMin:t,_userMax:e,_suggestedMin:i,_suggestedMax:s}=this;return t=r(t,Number.POSITIVE_INFINITY),e=r(e,Number.NEGATIVE_INFINITY),i=r(i,Number.POSITIVE_INFINITY),s=r(s,Number.NEGATIVE_INFINITY),{min:r(t,i),max:r(e,s),minDefined:a(t),maxDefined:a(e)}}getMinMax(t){let e,{min:i,max:s,minDefined:n,maxDefined:o}=this.getUserBounds();if(n&&o)return{min:i,max:s};const a=this.getMatchingVisibleMetas();for(let r=0,l=a.length;rs?s:i,s=n&&i>s?i:s,{min:r(i,r(s,i)),max:r(s,r(i,s))}}getPadding(){return{left:this.paddingLeft||0,top:this.paddingTop||0,right:this.paddingRight||0,bottom:this.paddingBottom||0}}getTicks(){return this.ticks}getLabels(){const t=this.chart.data;return this.options.labels||(this.isHorizontal()?t.xLabels:t.yLabels)||t.labels||[]}getLabelItems(t=this.chart.chartArea){return this._labelItems||(this._labelItems=this._computeLabelItems(t))}beforeLayout(){this._cache={},this._dataLimitsCached=!1}beforeUpdate(){d(this.options.beforeUpdate,[this])}update(t,e,i){const{beginAtZero:s,grace:n,ticks:o}=this.options,a=o.sampleSize;this.beforeUpdate(),this.maxWidth=t,this.maxHeight=e,this._margins=i=Object.assign({left:0,right:0,top:0,bottom:0},i),this.ticks=null,this._labelSizes=null,this._gridLineItems=null,this._labelItems=null,this.beforeSetDimensions(),this.setDimensions(),this.afterSetDimensions(),this._maxLength=this.isHorizontal()?this.width+i.left+i.right:this.height+i.top+i.bottom,this._dataLimitsCached||(this.beforeDataLimits(),this.determineDataLimits(),this.afterDataLimits(),this._range=Di(this,n,s),this._dataLimitsCached=!0),this.beforeBuildTicks(),this.ticks=this.buildTicks()||[],this.afterBuildTicks();const r=a=n||i<=1||!this.isHorizontal())return void(this.labelRotation=s);const h=this._getLabelSizes(),c=h.widest.width,d=h.highest.height,u=Z(this.chart.width-c,0,this.maxWidth);o=t.offset?this.maxWidth/i:u/(i-1),c+6>o&&(o=u/(i-(t.offset?.5:1)),a=this.maxHeight-Js(t.grid)-e.padding-Zs(t.title,this.chart.options.font),r=Math.sqrt(c*c+d*d),l=Y(Math.min(Math.asin(Z((h.highest.height+6)/o,-1,1)),Math.asin(Z(a/r,-1,1))-Math.asin(Z(d/r,-1,1)))),l=Math.max(s,Math.min(n,l))),this.labelRotation=l}afterCalculateLabelRotation(){d(this.options.afterCalculateLabelRotation,[this])}afterAutoSkip(){}beforeFit(){d(this.options.beforeFit,[this])}fit(){const t={width:0,height:0},{chart:e,options:{ticks:i,title:s,grid:n}}=this,o=this._isVisible(),a=this.isHorizontal();if(o){const o=Zs(s,e.options.font);if(a?(t.width=this.maxWidth,t.height=Js(n)+o):(t.height=this.maxHeight,t.width=Js(n)+o),i.display&&this.ticks.length){const{first:e,last:s,widest:n,highest:o}=this._getLabelSizes(),r=2*i.padding,l=$(this.labelRotation),h=Math.cos(l),c=Math.sin(l);if(a){const e=i.mirror?0:c*n.width+h*o.height;t.height=Math.min(this.maxHeight,t.height+e+r)}else{const e=i.mirror?0:h*n.width+c*o.height;t.width=Math.min(this.maxWidth,t.width+e+r)}this._calculatePadding(e,s,c,h)}}this._handleMargins(),a?(this.width=this._length=e.width-this._margins.left-this._margins.right,this.height=t.height):(this.width=t.width,this.height=this._length=e.height-this._margins.top-this._margins.bottom)}_calculatePadding(t,e,i,s){const{ticks:{align:n,padding:o},position:a}=this.options,r=0!==this.labelRotation,l="top"!==a&&"x"===this.axis;if(this.isHorizontal()){const a=this.getPixelForTick(0)-this.left,h=this.right-this.getPixelForTick(this.ticks.length-1);let c=0,d=0;r?l?(c=s*t.width,d=i*e.height):(c=i*t.height,d=s*e.width):"start"===n?d=e.width:"end"===n?c=t.width:"inner"!==n&&(c=t.width/2,d=e.width/2),this.paddingLeft=Math.max((c-a+o)*this.width/(this.width-a),0),this.paddingRight=Math.max((d-h+o)*this.width/(this.width-h),0)}else{let i=e.height/2,s=t.height/2;"start"===n?(i=0,s=t.height):"end"===n&&(i=e.height,s=0),this.paddingTop=i+o,this.paddingBottom=s+o}}_handleMargins(){this._margins&&(this._margins.left=Math.max(this.paddingLeft,this._margins.left),this._margins.top=Math.max(this.paddingTop,this._margins.top),this._margins.right=Math.max(this.paddingRight,this._margins.right),this._margins.bottom=Math.max(this.paddingBottom,this._margins.bottom))}afterFit(){d(this.options.afterFit,[this])}isHorizontal(){const{axis:t,position:e}=this.options;return"top"===e||"bottom"===e||"x"===t}isFullSize(){return this.options.fullSize}_convertTicksToLabels(t){let e,i;for(this.beforeTickToLabelConversion(),this.generateTickLabels(t),e=0,i=t.length;e{const i=t.gc,s=i.length/2;let n;if(s>e){for(n=0;n({width:r[t]||0,height:l[t]||0});return{first:P(0),last:P(e-1),widest:P(k),highest:P(S),widths:r,heights:l}}getLabelForValue(t){return t}getPixelForValue(t,e){return NaN}getValueForPixel(t){}getPixelForTick(t){const e=this.ticks;return t<0||t>e.length-1?null:this.getPixelForValue(e[t].value)}getPixelForDecimal(t){this._reversePixels&&(t=1-t);const e=this._startPixel+t*this._length;return Q(this._alignToPixels?Ae(this.chart,e,0):e)}getDecimalForPixel(t){const e=(t-this._startPixel)/this._length;return this._reversePixels?1-e:e}getBasePixel(){return this.getPixelForValue(this.getBaseValue())}getBaseValue(){const{min:t,max:e}=this;return t<0&&e<0?e:t>0&&e>0?t:0}getContext(t){const e=this.ticks||[];if(t>=0&&ta*s?a/i:r/s:r*s0}_computeGridLineItems(t){const e=this.axis,i=this.chart,s=this.options,{grid:n,position:a,border:r}=s,h=n.offset,c=this.isHorizontal(),d=this.ticks.length+(h?1:0),u=Js(n),f=[],g=r.setContext(this.getContext()),p=g.display?g.width:0,m=p/2,x=function(t){return Ae(i,t,p)};let b,_,y,v,M,w,k,S,P,D,C,O;if("top"===a)b=x(this.bottom),w=this.bottom-u,S=b-m,D=x(t.top)+m,O=t.bottom;else if("bottom"===a)b=x(this.top),D=t.top,O=x(t.bottom)-m,w=b+m,S=this.top+u;else if("left"===a)b=x(this.right),M=this.right-u,k=b-m,P=x(t.left)+m,C=t.right;else if("right"===a)b=x(this.left),P=t.left,C=x(t.right)-m,M=b+m,k=this.left+u;else if("x"===e){if("center"===a)b=x((t.top+t.bottom)/2+.5);else if(o(a)){const t=Object.keys(a)[0],e=a[t];b=x(this.chart.scales[t].getPixelForValue(e))}D=t.top,O=t.bottom,w=b+m,S=w+u}else if("y"===e){if("center"===a)b=x((t.left+t.right)/2);else if(o(a)){const t=Object.keys(a)[0],e=a[t];b=x(this.chart.scales[t].getPixelForValue(e))}M=b-m,k=M-u,P=t.left,C=t.right}const A=l(s.ticks.maxTicksLimit,d),T=Math.max(1,Math.ceil(d/A));for(_=0;_0&&(o-=s/2)}d={left:o,top:n,width:s+e.width,height:i+e.height,color:t.backdropColor}}x.push({label:v,font:P,textOffset:O,options:{rotation:m,color:i,strokeColor:o,strokeWidth:h,textAlign:f,textBaseline:A,translation:[M,w],backdrop:d}})}return x}_getXAxisLabelAlignment(){const{position:t,ticks:e}=this.options;if(-$(this.labelRotation))return"top"===t?"left":"right";let i="center";return"start"===e.align?i="left":"end"===e.align?i="right":"inner"===e.align&&(i="inner"),i}_getYAxisLabelAlignment(t){const{position:e,ticks:{crossAlign:i,mirror:s,padding:n}}=this.options,o=t+n,a=this._getLabelSizes().widest.width;let r,l;return"left"===e?s?(l=this.right+n,"near"===i?r="left":"center"===i?(r="center",l+=a/2):(r="right",l+=a)):(l=this.right-o,"near"===i?r="right":"center"===i?(r="center",l-=a/2):(r="left",l=this.left)):"right"===e?s?(l=this.left+n,"near"===i?r="right":"center"===i?(r="center",l-=a/2):(r="left",l-=a)):(l=this.left+o,"near"===i?r="left":"center"===i?(r="center",l+=a/2):(r="right",l=this.right)):r="right",{textAlign:r,x:l}}_computeLabelArea(){if(this.options.ticks.mirror)return;const t=this.chart,e=this.options.position;return"left"===e||"right"===e?{top:0,left:this.left,bottom:t.height,right:this.right}:"top"===e||"bottom"===e?{top:this.top,left:0,bottom:this.bottom,right:t.width}:void 0}drawBackground(){const{ctx:t,options:{backgroundColor:e},left:i,top:s,width:n,height:o}=this;e&&(t.save(),t.fillStyle=e,t.fillRect(i,s,n,o),t.restore())}getLineWidthForValue(t){const e=this.options.grid;if(!this._isVisible()||!e.display)return 0;const i=this.ticks.findIndex((e=>e.value===t));if(i>=0){return e.setContext(this.getContext(i)).lineWidth}return 0}drawGrid(t){const e=this.options.grid,i=this.ctx,s=this._gridLineItems||(this._gridLineItems=this._computeGridLineItems(t));let n,o;const a=(t,e,s)=>{s.width&&s.color&&(i.save(),i.lineWidth=s.width,i.strokeStyle=s.color,i.setLineDash(s.borderDash||[]),i.lineDashOffset=s.borderDashOffset,i.beginPath(),i.moveTo(t.x,t.y),i.lineTo(e.x,e.y),i.stroke(),i.restore())};if(e.display)for(n=0,o=s.length;n{this.drawBackground(),this.drawGrid(t),this.drawTitle()}},{z:s,draw:()=>{this.drawBorder()}},{z:e,draw:t=>{this.drawLabels(t)}}]:[{z:e,draw:t=>{this.draw(t)}}]}getMatchingVisibleMetas(t){const e=this.chart.getSortedVisibleDatasetMetas(),i=this.axis+"AxisID",s=[];let n,o;for(n=0,o=e.length;n{const s=i.split("."),n=s.pop(),o=[t].concat(s).join("."),a=e[i].split("."),r=a.pop(),l=a.join(".");ue.route(o,n,l,r)}))}(e,t.defaultRoutes);t.descriptors&&ue.describe(e,t.descriptors)}(t,o,i),this.override&&ue.override(t.id,t.overrides)),o}get(t){return this.items[t]}unregister(t){const e=this.items,i=t.id,s=this.scope;i in e&&delete e[i],s&&i in ue[s]&&(delete ue[s][i],this.override&&delete re[i])}}class sn{constructor(){this.controllers=new en(js,"datasets",!0),this.elements=new en($s,"elements"),this.plugins=new en(Object,"plugins"),this.scales=new en(tn,"scales"),this._typedRegistries=[this.controllers,this.scales,this.elements]}add(...t){this._each("register",t)}remove(...t){this._each("unregister",t)}addControllers(...t){this._each("register",t,this.controllers)}addElements(...t){this._each("register",t,this.elements)}addPlugins(...t){this._each("register",t,this.plugins)}addScales(...t){this._each("register",t,this.scales)}getController(t){return this._get(t,this.controllers,"controller")}getElement(t){return this._get(t,this.elements,"element")}getPlugin(t){return this._get(t,this.plugins,"plugin")}getScale(t){return this._get(t,this.scales,"scale")}removeControllers(...t){this._each("unregister",t,this.controllers)}removeElements(...t){this._each("unregister",t,this.elements)}removePlugins(...t){this._each("unregister",t,this.plugins)}removeScales(...t){this._each("unregister",t,this.scales)}_each(t,e,i){[...e].forEach((e=>{const s=i||this._getRegistryForType(e);i||s.isForType(e)||s===this.plugins&&e.id?this._exec(t,s,e):u(e,(e=>{const s=i||this._getRegistryForType(e);this._exec(t,s,e)}))}))}_exec(t,e,i){const s=w(t);d(i["before"+s],[],i),e[t](i),d(i["after"+s],[],i)}_getRegistryForType(t){for(let e=0;et.filter((t=>!e.some((e=>t.plugin.id===e.plugin.id))));this._notify(s(e,i),t,"stop"),this._notify(s(i,e),t,"start")}}function an(t,e){return e||!1!==t?!0===t?{}:t:null}function rn(t,{plugin:e,local:i},s,n){const o=t.pluginScopeKeys(e),a=t.getOptionScopes(s,o);return i&&e.defaults&&a.push(e.defaults),t.createResolver(a,n,[""],{scriptable:!1,indexable:!1,allKeys:!0})}function ln(t,e){const i=ue.datasets[t]||{};return((e.datasets||{})[t]||{}).indexAxis||e.indexAxis||i.indexAxis||"x"}function hn(t){if("x"===t||"y"===t||"r"===t)return t}function cn(t,...e){if(hn(t))return t;for(const s of e){const e=s.axis||("top"===(i=s.position)||"bottom"===i?"x":"left"===i||"right"===i?"y":void 0)||t.length>1&&hn(t[0].toLowerCase());if(e)return e}var i;throw new Error(`Cannot determine type of '${t}' axis. Please provide 'axis' or 'position' option.`)}function dn(t,e,i){if(i[e+"AxisID"]===t)return{axis:e}}function un(t,e){const i=re[t.type]||{scales:{}},s=e.scales||{},n=ln(t.type,e),a=Object.create(null);return Object.keys(s).forEach((e=>{const r=s[e];if(!o(r))return console.error(`Invalid scale configuration for scale: ${e}`);if(r._proxy)return console.warn(`Ignoring resolver passed as options for scale: ${e}`);const l=cn(e,r,function(t,e){if(e.data&&e.data.datasets){const i=e.data.datasets.filter((e=>e.xAxisID===t||e.yAxisID===t));if(i.length)return dn(t,"x",i[0])||dn(t,"y",i[0])}return{}}(e,t),ue.scales[r.type]),h=function(t,e){return t===e?"_index_":"_value_"}(l,n),c=i.scales||{};a[e]=b(Object.create(null),[{axis:l},r,c[l],c[h]])})),t.data.datasets.forEach((i=>{const n=i.type||t.type,o=i.indexAxis||ln(n,e),r=(re[n]||{}).scales||{};Object.keys(r).forEach((t=>{const e=function(t,e){let i=t;return"_index_"===t?i=e:"_value_"===t&&(i="x"===e?"y":"x"),i}(t,o),n=i[e+"AxisID"]||e;a[n]=a[n]||Object.create(null),b(a[n],[{axis:e},s[n],r[t]])}))})),Object.keys(a).forEach((t=>{const e=a[t];b(e,[ue.scales[e.type],ue.scale])})),a}function fn(t){const e=t.options||(t.options={});e.plugins=l(e.plugins,{}),e.scales=un(t,e)}function gn(t){return(t=t||{}).datasets=t.datasets||[],t.labels=t.labels||[],t}const pn=new Map,mn=new Set;function xn(t,e){let i=pn.get(t);return i||(i=e(),pn.set(t,i),mn.add(i)),i}const bn=(t,e,i)=>{const s=M(e,i);void 0!==s&&t.add(s)};class _n{constructor(t){this._config=function(t){return(t=t||{}).data=gn(t.data),fn(t),t}(t),this._scopeCache=new Map,this._resolverCache=new Map}get platform(){return this._config.platform}get type(){return this._config.type}set type(t){this._config.type=t}get data(){return this._config.data}set data(t){this._config.data=gn(t)}get options(){return this._config.options}set options(t){this._config.options=t}get plugins(){return this._config.plugins}update(){const t=this._config;this.clearCache(),fn(t)}clearCache(){this._scopeCache.clear(),this._resolverCache.clear()}datasetScopeKeys(t){return xn(t,(()=>[[`datasets.${t}`,""]]))}datasetAnimationScopeKeys(t,e){return xn(`${t}.transition.${e}`,(()=>[[`datasets.${t}.transitions.${e}`,`transitions.${e}`],[`datasets.${t}`,""]]))}datasetElementScopeKeys(t,e){return xn(`${t}-${e}`,(()=>[[`datasets.${t}.elements.${e}`,`datasets.${t}`,`elements.${e}`,""]]))}pluginScopeKeys(t){const e=t.id;return xn(`${this.type}-plugin-${e}`,(()=>[[`plugins.${e}`,...t.additionalOptionScopes||[]]]))}_cachedScopes(t,e){const i=this._scopeCache;let s=i.get(t);return s&&!e||(s=new Map,i.set(t,s)),s}getOptionScopes(t,e,i){const{options:s,type:n}=this,o=this._cachedScopes(t,i),a=o.get(e);if(a)return a;const r=new Set;e.forEach((e=>{t&&(r.add(t),e.forEach((e=>bn(r,t,e)))),e.forEach((t=>bn(r,s,t))),e.forEach((t=>bn(r,re[n]||{},t))),e.forEach((t=>bn(r,ue,t))),e.forEach((t=>bn(r,le,t)))}));const l=Array.from(r);return 0===l.length&&l.push(Object.create(null)),mn.has(e)&&o.set(e,l),l}chartOptionScopes(){const{options:t,type:e}=this;return[t,re[e]||{},ue.datasets[e]||{},{type:e},ue,le]}resolveNamedOptions(t,e,i,s=[""]){const o={$shared:!0},{resolver:a,subPrefixes:r}=yn(this._resolverCache,t,s);let l=a;if(function(t,e){const{isScriptable:i,isIndexable:s}=Ye(t);for(const o of e){const e=i(o),a=s(o),r=(a||e)&&t[o];if(e&&(S(r)||vn(r))||a&&n(r))return!0}return!1}(a,e)){o.$shared=!1;l=$e(a,i=S(i)?i():i,this.createResolver(t,i,r))}for(const t of e)o[t]=l[t];return o}createResolver(t,e,i=[""],s){const{resolver:n}=yn(this._resolverCache,t,i);return o(e)?$e(n,e,void 0,s):n}}function yn(t,e,i){let s=t.get(e);s||(s=new Map,t.set(e,s));const n=i.join();let o=s.get(n);if(!o){o={resolver:je(e,i),subPrefixes:i.filter((t=>!t.toLowerCase().includes("hover")))},s.set(n,o)}return o}const vn=t=>o(t)&&Object.getOwnPropertyNames(t).some((e=>S(t[e])));const Mn=["top","bottom","left","right","chartArea"];function wn(t,e){return"top"===t||"bottom"===t||-1===Mn.indexOf(t)&&"x"===e}function kn(t,e){return function(i,s){return i[t]===s[t]?i[e]-s[e]:i[t]-s[t]}}function Sn(t){const e=t.chart,i=e.options.animation;e.notifyPlugins("afterRender"),d(i&&i.onComplete,[t],e)}function Pn(t){const e=t.chart,i=e.options.animation;d(i&&i.onProgress,[t],e)}function Dn(t){return fe()&&"string"==typeof t?t=document.getElementById(t):t&&t.length&&(t=t[0]),t&&t.canvas&&(t=t.canvas),t}const Cn={},On=t=>{const e=Dn(t);return Object.values(Cn).filter((t=>t.canvas===e)).pop()};function An(t,e,i){const s=Object.keys(t);for(const n of s){const s=+n;if(s>=e){const o=t[n];delete t[n],(i>0||s>e)&&(t[s+i]=o)}}}class Tn{static defaults=ue;static instances=Cn;static overrides=re;static registry=nn;static version="4.5.1";static getChart=On;static register(...t){nn.add(...t),Ln()}static unregister(...t){nn.remove(...t),Ln()}constructor(t,e){const s=this.config=new _n(e),n=Dn(t),o=On(n);if(o)throw new Error("Canvas is already in use. Chart with ID '"+o.id+"' must be destroyed before the canvas with ID '"+o.canvas.id+"' can be reused.");const a=s.createResolver(s.chartOptionScopes(),this.getContext());this.platform=new(s.platform||Ps(n)),this.platform.updateConfig(s);const r=this.platform.acquireContext(n,a.aspectRatio),l=r&&r.canvas,h=l&&l.height,c=l&&l.width;this.id=i(),this.ctx=r,this.canvas=l,this.width=c,this.height=h,this._options=a,this._aspectRatio=this.aspectRatio,this._layers=[],this._metasets=[],this._stacks=void 0,this.boxes=[],this.currentDevicePixelRatio=void 0,this.chartArea=void 0,this._active=[],this._lastEvent=void 0,this._listeners={},this._responsiveListeners=void 0,this._sortedMetasets=[],this.scales={},this._plugins=new on,this.$proxies={},this._hiddenIndices={},this.attached=!1,this._animationsDisabled=void 0,this.$context=void 0,this._doResize=dt((t=>this.update(t)),a.resizeDelay||0),this._dataChanges=[],Cn[this.id]=this,r&&l?(bt.listen(this,"complete",Sn),bt.listen(this,"progress",Pn),this._initialize(),this.attached&&this.update()):console.error("Failed to create chart: can't acquire context from the given item")}get aspectRatio(){const{options:{aspectRatio:t,maintainAspectRatio:e},width:i,height:n,_aspectRatio:o}=this;return s(t)?e&&o?o:n?i/n:null:t}get data(){return this.config.data}set data(t){this.config.data=t}get options(){return this._options}set options(t){this.config.options=t}get registry(){return nn}_initialize(){return this.notifyPlugins("beforeInit"),this.options.responsive?this.resize():ke(this,this.options.devicePixelRatio),this.bindEvents(),this.notifyPlugins("afterInit"),this}clear(){return Te(this.canvas,this.ctx),this}stop(){return bt.stop(this),this}resize(t,e){bt.running(this)?this._resizeBeforeDraw={width:t,height:e}:this._resize(t,e)}_resize(t,e){const i=this.options,s=this.canvas,n=i.maintainAspectRatio&&this.aspectRatio,o=this.platform.getMaximumSize(s,t,e,n),a=i.devicePixelRatio||this.platform.getDevicePixelRatio(),r=this.width?"resize":"attach";this.width=o.width,this.height=o.height,this._aspectRatio=this.aspectRatio,ke(this,a,!0)&&(this.notifyPlugins("resize",{size:o}),d(i.onResize,[this,o],this),this.attached&&this._doResize(r)&&this.render())}ensureScalesHaveIDs(){u(this.options.scales||{},((t,e)=>{t.id=e}))}buildOrUpdateScales(){const t=this.options,e=t.scales,i=this.scales,s=Object.keys(i).reduce(((t,e)=>(t[e]=!1,t)),{});let n=[];e&&(n=n.concat(Object.keys(e).map((t=>{const i=e[t],s=cn(t,i),n="r"===s,o="x"===s;return{options:i,dposition:n?"chartArea":o?"bottom":"left",dtype:n?"radialLinear":o?"category":"linear"}})))),u(n,(e=>{const n=e.options,o=n.id,a=cn(o,n),r=l(n.type,e.dtype);void 0!==n.position&&wn(n.position,a)===wn(e.dposition)||(n.position=e.dposition),s[o]=!0;let h=null;if(o in i&&i[o].type===r)h=i[o];else{h=new(nn.getScale(r))({id:o,type:r,ctx:this.ctx,chart:this}),i[h.id]=h}h.init(n,t)})),u(s,((t,e)=>{t||delete i[e]})),u(i,(t=>{ls.configure(this,t,t.options),ls.addBox(this,t)}))}_updateMetasets(){const t=this._metasets,e=this.data.datasets.length,i=t.length;if(t.sort(((t,e)=>t.index-e.index)),i>e){for(let t=e;te.length&&delete this._stacks,t.forEach(((t,i)=>{0===e.filter((e=>e===t._dataset)).length&&this._destroyDatasetMeta(i)}))}buildOrUpdateControllers(){const t=[],e=this.data.datasets;let i,s;for(this._removeUnreferencedMetasets(),i=0,s=e.length;i{this.getDatasetMeta(e).controller.reset()}),this)}reset(){this._resetElements(),this.notifyPlugins("reset")}update(t){const e=this.config;e.update();const i=this._options=e.createResolver(e.chartOptionScopes(),this.getContext()),s=this._animationsDisabled=!i.animation;if(this._updateScales(),this._checkEventBindings(),this._updateHiddenIndices(),this._plugins.invalidate(),!1===this.notifyPlugins("beforeUpdate",{mode:t,cancelable:!0}))return;const n=this.buildOrUpdateControllers();this.notifyPlugins("beforeElementsUpdate");let o=0;for(let t=0,e=this.data.datasets.length;t{t.reset()})),this._updateDatasets(t),this.notifyPlugins("afterUpdate",{mode:t}),this._layers.sort(kn("z","_idx"));const{_active:a,_lastEvent:r}=this;r?this._eventHandler(r,!0):a.length&&this._updateHoverStyles(a,a,!0),this.render()}_updateScales(){u(this.scales,(t=>{ls.removeBox(this,t)})),this.ensureScalesHaveIDs(),this.buildOrUpdateScales()}_checkEventBindings(){const t=this.options,e=new Set(Object.keys(this._listeners)),i=new Set(t.events);P(e,i)&&!!this._responsiveListeners===t.responsive||(this.unbindEvents(),this.bindEvents())}_updateHiddenIndices(){const{_hiddenIndices:t}=this,e=this._getUniformDataChanges()||[];for(const{method:i,start:s,count:n}of e){An(t,s,"_removeElements"===i?-n:n)}}_getUniformDataChanges(){const t=this._dataChanges;if(!t||!t.length)return;this._dataChanges=[];const e=this.data.datasets.length,i=e=>new Set(t.filter((t=>t[0]===e)).map(((t,e)=>e+","+t.splice(1).join(",")))),s=i(0);for(let t=1;tt.split(","))).map((t=>({method:t[1],start:+t[2],count:+t[3]})))}_updateLayout(t){if(!1===this.notifyPlugins("beforeLayout",{cancelable:!0}))return;ls.update(this,this.width,this.height,t);const e=this.chartArea,i=e.width<=0||e.height<=0;this._layers=[],u(this.boxes,(t=>{i&&"chartArea"===t.position||(t.configure&&t.configure(),this._layers.push(...t._layers()))}),this),this._layers.forEach(((t,e)=>{t._idx=e})),this.notifyPlugins("afterLayout")}_updateDatasets(t){if(!1!==this.notifyPlugins("beforeDatasetsUpdate",{mode:t,cancelable:!0})){for(let t=0,e=this.data.datasets.length;t=0;--e)this._drawDataset(t[e]);this.notifyPlugins("afterDatasetsDraw")}_drawDataset(t){const e=this.ctx,i={meta:t,index:t.index,cancelable:!0},s=Ni(this,t);!1!==this.notifyPlugins("beforeDatasetDraw",i)&&(s&&Ie(e,s),t.controller.draw(),s&&ze(e),i.cancelable=!1,this.notifyPlugins("afterDatasetDraw",i))}isPointInArea(t){return Re(t,this.chartArea,this._minPadding)}getElementsAtEventForMode(t,e,i,s){const n=Ki.modes[e];return"function"==typeof n?n(this,t,i,s):[]}getDatasetMeta(t){const e=this.data.datasets[t],i=this._metasets;let s=i.filter((t=>t&&t._dataset===e)).pop();return s||(s={type:null,data:[],dataset:null,controller:null,hidden:null,xAxisID:null,yAxisID:null,order:e&&e.order||0,index:t,_dataset:e,_parsed:[],_sorted:!1},i.push(s)),s}getContext(){return this.$context||(this.$context=Ci(null,{chart:this,type:"chart"}))}getVisibleDatasetCount(){return this.getSortedVisibleDatasetMetas().length}isDatasetVisible(t){const e=this.data.datasets[t];if(!e)return!1;const i=this.getDatasetMeta(t);return"boolean"==typeof i.hidden?!i.hidden:!e.hidden}setDatasetVisibility(t,e){this.getDatasetMeta(t).hidden=!e}toggleDataVisibility(t){this._hiddenIndices[t]=!this._hiddenIndices[t]}getDataVisibility(t){return!this._hiddenIndices[t]}_updateVisibility(t,e,i){const s=i?"show":"hide",n=this.getDatasetMeta(t),o=n.controller._resolveAnimations(void 0,s);k(e)?(n.data[e].hidden=!i,this.update()):(this.setDatasetVisibility(t,i),o.update(n,{visible:i}),this.update((e=>e.datasetIndex===t?s:void 0)))}hide(t,e){this._updateVisibility(t,e,!1)}show(t,e){this._updateVisibility(t,e,!0)}_destroyDatasetMeta(t){const e=this._metasets[t];e&&e.controller&&e.controller._destroy(),delete this._metasets[t]}_stop(){let t,e;for(this.stop(),bt.remove(this),t=0,e=this.data.datasets.length;t{e.addEventListener(this,i,s),t[i]=s},s=(t,e,i)=>{t.offsetX=e,t.offsetY=i,this._eventHandler(t)};u(this.options.events,(t=>i(t,s)))}bindResponsiveEvents(){this._responsiveListeners||(this._responsiveListeners={});const t=this._responsiveListeners,e=this.platform,i=(i,s)=>{e.addEventListener(this,i,s),t[i]=s},s=(i,s)=>{t[i]&&(e.removeEventListener(this,i,s),delete t[i])},n=(t,e)=>{this.canvas&&this.resize(t,e)};let o;const a=()=>{s("attach",a),this.attached=!0,this.resize(),i("resize",n),i("detach",o)};o=()=>{this.attached=!1,s("resize",n),this._stop(),this._resize(0,0),i("attach",a)},e.isAttached(this.canvas)?a():o()}unbindEvents(){u(this._listeners,((t,e)=>{this.platform.removeEventListener(this,e,t)})),this._listeners={},u(this._responsiveListeners,((t,e)=>{this.platform.removeEventListener(this,e,t)})),this._responsiveListeners=void 0}updateHoverStyle(t,e,i){const s=i?"set":"remove";let n,o,a,r;for("dataset"===e&&(n=this.getDatasetMeta(t[0].datasetIndex),n.controller["_"+s+"DatasetHoverStyle"]()),a=0,r=t.length;a{const i=this.getDatasetMeta(t);if(!i)throw new Error("No dataset found at index "+t);return{datasetIndex:t,element:i.data[e],index:e}}));!f(i,e)&&(this._active=i,this._lastEvent=null,this._updateHoverStyles(i,e))}notifyPlugins(t,e,i){return this._plugins.notify(this,t,e,i)}isPluginEnabled(t){return 1===this._plugins._cache.filter((e=>e.plugin.id===t)).length}_updateHoverStyles(t,e,i){const s=this.options.hover,n=(t,e)=>t.filter((t=>!e.some((e=>t.datasetIndex===e.datasetIndex&&t.index===e.index)))),o=n(e,t),a=i?t:n(t,e);o.length&&this.updateHoverStyle(o,s.mode,!1),a.length&&s.mode&&this.updateHoverStyle(a,s.mode,!0)}_eventHandler(t,e){const i={event:t,replay:e,cancelable:!0,inChartArea:this.isPointInArea(t)},s=e=>(e.options.events||this.options.events).includes(t.native.type);if(!1===this.notifyPlugins("beforeEvent",i,s))return;const n=this._handleEvent(t,e,i.inChartArea);return i.cancelable=!1,this.notifyPlugins("afterEvent",i,s),(n||i.changed)&&this.render(),this}_handleEvent(t,e,i){const{_active:s=[],options:n}=this,o=e,a=this._getActiveElements(t,s,i,o),r=D(t),l=function(t,e,i,s){return i&&"mouseout"!==t.type?s?e:t:null}(t,this._lastEvent,i,r);i&&(this._lastEvent=null,d(n.onHover,[t,a,this],this),r&&d(n.onClick,[t,a,this],this));const h=!f(a,s);return(h||e)&&(this._active=a,this._updateHoverStyles(a,s,e)),this._lastEvent=l,h}_getActiveElements(t,e,i,s){if("mouseout"===t.type)return[];if(!i)return e;const n=this.options.hover;return this.getElementsAtEventForMode(t,n.mode,n,s)}}function Ln(){return u(Tn.instances,(t=>t._plugins.invalidate()))}function En(){throw new Error("This method is not implemented: Check that a complete date adapter is provided.")}class Rn{static override(t){Object.assign(Rn.prototype,t)}options;constructor(t){this.options=t||{}}init(){}formats(){return En()}parse(){return En()}format(){return En()}add(){return En()}diff(){return En()}startOf(){return En()}endOf(){return En()}}var In={_date:Rn};function zn(t){const e=t.iScale,i=function(t,e){if(!t._cache.$bar){const i=t.getMatchingVisibleMetas(e);let s=[];for(let e=0,n=i.length;et-e)))}return t._cache.$bar}(e,t.type);let s,n,o,a,r=e._length;const l=()=>{32767!==o&&-32768!==o&&(k(a)&&(r=Math.min(r,Math.abs(o-a)||r)),a=o)};for(s=0,n=i.length;sMath.abs(r)&&(l=r,h=a),e[i.axis]=h,e._custom={barStart:l,barEnd:h,start:n,end:o,min:a,max:r}}(t,e,i,s):e[i.axis]=i.parse(t,s),e}function Vn(t,e,i,s){const n=t.iScale,o=t.vScale,a=n.getLabels(),r=n===o,l=[];let h,c,d,u;for(h=i,c=i+s;ht.x,i="left",s="right"):(e=t.base"spacing"!==t,_indexable:t=>"spacing"!==t&&!t.startsWith("borderDash")&&!t.startsWith("hoverBorderDash")};static overrides={aspectRatio:1,plugins:{legend:{labels:{generateLabels(t){const e=t.data,{labels:{pointStyle:i,textAlign:s,color:n,useBorderRadius:o,borderRadius:a}}=t.legend.options;return e.labels.length&&e.datasets.length?e.labels.map(((e,r)=>{const l=t.getDatasetMeta(0).controller.getStyle(r);return{text:e,fillStyle:l.backgroundColor,fontColor:n,hidden:!t.getDataVisibility(r),lineDash:l.borderDash,lineDashOffset:l.borderDashOffset,lineJoin:l.borderJoinStyle,lineWidth:l.borderWidth,strokeStyle:l.borderColor,textAlign:s,pointStyle:i,borderRadius:o&&(a||l.borderRadius),index:r}})):[]}},onClick(t,e,i){i.chart.toggleDataVisibility(e.index),i.chart.update()}}}};constructor(t,e){super(t,e),this.enableOptionSharing=!0,this.innerRadius=void 0,this.outerRadius=void 0,this.offsetX=void 0,this.offsetY=void 0}linkScales(){}parse(t,e){const i=this.getDataset().data,s=this._cachedMeta;if(!1===this._parsing)s._parsed=i;else{let n,a,r=t=>+i[t];if(o(i[t])){const{key:t="value"}=this._parsing;r=e=>+M(i[e],t)}for(n=t,a=t+e;nJ(t,r,l,!0)?1:Math.max(e,e*i,s,s*i),g=(t,e,s)=>J(t,r,l,!0)?-1:Math.min(e,e*i,s,s*i),p=f(0,h,d),m=f(E,c,u),x=g(C,h,d),b=g(C+E,c,u);s=(p-x)/2,n=(m-b)/2,o=-(p+x)/2,a=-(m+b)/2}return{ratioX:s,ratioY:n,offsetX:o,offsetY:a}}(u,d,r),x=(i.width-o)/f,b=(i.height-o)/g,_=Math.max(Math.min(x,b)/2,0),y=c(this.options.radius,_),v=(y-Math.max(y*r,0))/this._getVisibleDatasetWeightTotal();this.offsetX=p*y,this.offsetY=m*y,s.total=this.calculateTotal(),this.outerRadius=y-v*this._getRingWeightOffset(this.index),this.innerRadius=Math.max(this.outerRadius-v*l,0),this.updateElements(n,0,n.length,t)}_circumference(t,e){const i=this.options,s=this._cachedMeta,n=this._getCircumference();return e&&i.animation.animateRotate||!this.chart.getDataVisibility(t)||null===s._parsed[t]||s.data[t].hidden?0:this.calculateCircumference(s._parsed[t]*n/O)}updateElements(t,e,i,s){const n="reset"===s,o=this.chart,a=o.chartArea,r=o.options.animation,l=(a.left+a.right)/2,h=(a.top+a.bottom)/2,c=n&&r.animateScale,d=c?0:this.innerRadius,u=c?0:this.outerRadius,{sharedOptions:f,includeOptions:g}=this._getSharedOptions(e,s);let p,m=this._getRotation();for(p=0;p0&&!isNaN(t)?O*(Math.abs(t)/e):0}getLabelAndValue(t){const e=this._cachedMeta,i=this.chart,s=i.data.labels||[],n=ne(e._parsed[t],i.options.locale);return{label:s[t]||"",value:n}}getMaxBorderWidth(t){let e=0;const i=this.chart;let s,n,o,a,r;if(!t)for(s=0,n=i.data.datasets.length;s{const o=t.getDatasetMeta(0).controller.getStyle(n);return{text:e,fillStyle:o.backgroundColor,strokeStyle:o.borderColor,fontColor:s,lineWidth:o.borderWidth,pointStyle:i,hidden:!t.getDataVisibility(n),index:n}}))}return[]}},onClick(t,e,i){i.chart.toggleDataVisibility(e.index),i.chart.update()}}},scales:{r:{type:"radialLinear",angleLines:{display:!1},beginAtZero:!0,grid:{circular:!0},pointLabels:{display:!1},startAngle:0}}};constructor(t,e){super(t,e),this.innerRadius=void 0,this.outerRadius=void 0}getLabelAndValue(t){const e=this._cachedMeta,i=this.chart,s=i.data.labels||[],n=ne(e._parsed[t].r,i.options.locale);return{label:s[t]||"",value:n}}parseObjectData(t,e,i,s){return ii.bind(this)(t,e,i,s)}update(t){const e=this._cachedMeta.data;this._updateRadius(),this.updateElements(e,0,e.length,t)}getMinMax(){const t=this._cachedMeta,e={min:Number.POSITIVE_INFINITY,max:Number.NEGATIVE_INFINITY};return t.data.forEach(((t,i)=>{const s=this.getParsed(i).r;!isNaN(s)&&this.chart.getDataVisibility(i)&&(se.max&&(e.max=s))})),e}_updateRadius(){const t=this.chart,e=t.chartArea,i=t.options,s=Math.min(e.right-e.left,e.bottom-e.top),n=Math.max(s/2,0),o=(n-Math.max(i.cutoutPercentage?n/100*i.cutoutPercentage:1,0))/t.getVisibleDatasetCount();this.outerRadius=n-o*this.index,this.innerRadius=this.outerRadius-o}updateElements(t,e,i,s){const n="reset"===s,o=this.chart,a=o.options.animation,r=this._cachedMeta.rScale,l=r.xCenter,h=r.yCenter,c=r.getIndexAngle(0)-.5*C;let d,u=c;const f=360/this.countVisibleElements();for(d=0;d{!isNaN(this.getParsed(i).r)&&this.chart.getDataVisibility(i)&&e++})),e}_computeAngle(t,e,i){return this.chart.getDataVisibility(t)?$(this.resolveDataElementOptions(t,e).angle||i):0}}var Un=Object.freeze({__proto__:null,BarController:class extends js{static id="bar";static defaults={datasetElementType:!1,dataElementType:"bar",categoryPercentage:.8,barPercentage:.9,grouped:!0,animations:{numbers:{type:"number",properties:["x","y","base","width","height"]}}};static overrides={scales:{_index_:{type:"category",offset:!0,grid:{offset:!0}},_value_:{type:"linear",beginAtZero:!0}}};parsePrimitiveData(t,e,i,s){return Vn(t,e,i,s)}parseArrayData(t,e,i,s){return Vn(t,e,i,s)}parseObjectData(t,e,i,s){const{iScale:n,vScale:o}=t,{xAxisKey:a="x",yAxisKey:r="y"}=this._parsing,l="x"===n.axis?a:r,h="x"===o.axis?a:r,c=[];let d,u,f,g;for(d=i,u=i+s;dt.controller.options.grouped)),o=i.options.stacked,a=[],r=this._cachedMeta.controller.getParsed(e),l=r&&r[i.axis],h=t=>{const e=t._parsed.find((t=>t[i.axis]===l)),n=e&&e[t.vScale.axis];if(s(n)||isNaN(n))return!0};for(const i of n)if((void 0===e||!h(i))&&((!1===o||-1===a.indexOf(i.stack)||void 0===o&&void 0===i.stack)&&a.push(i.stack),i.index===t))break;return a.length||a.push(void 0),a}_getStackCount(t){return this._getStacks(void 0,t).length}_getAxisCount(){return this._getAxis().length}getFirstScaleIdForIndexAxis(){const t=this.chart.scales,e=this.chart.options.indexAxis;return Object.keys(t).filter((i=>t[i].axis===e)).shift()}_getAxis(){const t={},e=this.getFirstScaleIdForIndexAxis();for(const i of this.chart.data.datasets)t[l("x"===this.chart.options.indexAxis?i.xAxisID:i.yAxisID,e)]=!0;return Object.keys(t)}_getStackIndex(t,e,i){const s=this._getStacks(t,i),n=void 0!==e?s.indexOf(e):-1;return-1===n?s.length-1:n}_getRuler(){const t=this.options,e=this._cachedMeta,i=e.iScale,s=[];let n,o;for(n=0,o=e.data.length;n=i?1:-1)}(u,e,r)*a,f===r&&(x-=u/2);const t=e.getPixelForDecimal(0),s=e.getPixelForDecimal(1),o=Math.min(t,s),h=Math.max(t,s);x=Math.max(Math.min(x,h),o),d=x+u,i&&!c&&(l._stacks[e.axis]._visualValues[n]=e.getValueForPixel(d)-e.getValueForPixel(x))}if(x===e.getPixelForValue(r)){const t=F(u)*e.getLineWidthForValue(r)/2;x+=t,u-=t}return{size:u,base:x,head:d,center:d+u/2}}_calculateBarIndexPixels(t,e){const i=e.scale,n=this.options,o=n.skipNull,a=l(n.maxBarThickness,1/0);let r,h;const c=this._getAxisCount();if(e.grouped){const i=o?this._getStackCount(t):e.stackCount,d="flex"===n.barThickness?function(t,e,i,s){const n=e.pixels,o=n[t];let a=t>0?n[t-1]:null,r=t=0;--i)e=Math.max(e,t[i].size(this.resolveDataElementOptions(i))/2);return e>0&&e}getLabelAndValue(t){const e=this._cachedMeta,i=this.chart.data.labels||[],{xScale:s,yScale:n}=e,o=this.getParsed(t),a=s.getLabelForValue(o.x),r=n.getLabelForValue(o.y),l=o._custom;return{label:i[t]||"",value:"("+a+", "+r+(l?", "+l:"")+")"}}update(t){const e=this._cachedMeta.data;this.updateElements(e,0,e.length,t)}updateElements(t,e,i,s){const n="reset"===s,{iScale:o,vScale:a}=this._cachedMeta,{sharedOptions:r,includeOptions:l}=this._getSharedOptions(e,s),h=o.axis,c=a.axis;for(let d=e;d0&&this.getParsed(e-1);for(let i=0;i<_;++i){const g=t[i],_=x?g:{};if(i=b){_.skip=!0;continue}const v=this.getParsed(i),M=s(v[f]),w=_[u]=a.getPixelForValue(v[u],i),k=_[f]=o||M?r.getBasePixel():r.getPixelForValue(l?this.applyStack(r,v,l):v[f],i);_.skip=isNaN(w)||isNaN(k)||M,_.stop=i>0&&Math.abs(v[u]-y[u])>m,p&&(_.parsed=v,_.raw=h.data[i]),d&&(_.options=c||this.resolveDataElementOptions(i,g.active?"active":n)),x||this.updateElement(g,i,_,n),y=v}}getMaxOverflow(){const t=this._cachedMeta,e=t.dataset,i=e.options&&e.options.borderWidth||0,s=t.data||[];if(!s.length)return i;const n=s[0].size(this.resolveDataElementOptions(0)),o=s[s.length-1].size(this.resolveDataElementOptions(s.length-1));return Math.max(i,n,o)/2}draw(){const t=this._cachedMeta;t.dataset.updateControlPoints(this.chart.chartArea,t.iScale.axis),super.draw()}},PieController:class extends $n{static id="pie";static defaults={cutout:0,rotation:0,circumference:360,radius:"100%"}},PolarAreaController:Yn,RadarController:class extends js{static id="radar";static defaults={datasetElementType:"line",dataElementType:"point",indexAxis:"r",showLine:!0,elements:{line:{fill:"start"}}};static overrides={aspectRatio:1,scales:{r:{type:"radialLinear"}}};getLabelAndValue(t){const e=this._cachedMeta.vScale,i=this.getParsed(t);return{label:e.getLabels()[t],value:""+e.getLabelForValue(i[e.axis])}}parseObjectData(t,e,i,s){return ii.bind(this)(t,e,i,s)}update(t){const e=this._cachedMeta,i=e.dataset,s=e.data||[],n=e.iScale.getLabels();if(i.points=s,"resize"!==t){const e=this.resolveDatasetElementOptions(t);this.options.showLine||(e.borderWidth=0);const o={_loop:!0,_fullLoop:n.length===s.length,options:e};this.updateElement(i,void 0,o,t)}this.updateElements(s,0,s.length,t)}updateElements(t,e,i,s){const n=this._cachedMeta.rScale,o="reset"===s;for(let a=e;a0&&this.getParsed(e-1);for(let c=e;c0&&Math.abs(i[f]-_[f])>x,m&&(p.parsed=i,p.raw=h.data[c]),u&&(p.options=d||this.resolveDataElementOptions(c,e.active?"active":n)),b||this.updateElement(e,c,p,n),_=i}this.updateSharedOptions(d,n,c)}getMaxOverflow(){const t=this._cachedMeta,e=t.data||[];if(!this.options.showLine){let t=0;for(let i=e.length-1;i>=0;--i)t=Math.max(t,e[i].size(this.resolveDataElementOptions(i))/2);return t>0&&t}const i=t.dataset,s=i.options&&i.options.borderWidth||0;if(!e.length)return s;const n=e[0].size(this.resolveDataElementOptions(0)),o=e[e.length-1].size(this.resolveDataElementOptions(e.length-1));return Math.max(s,n,o)/2}}});function Xn(t,e,i,s){const n=vi(t.options.borderRadius,["outerStart","outerEnd","innerStart","innerEnd"]);const o=(i-e)/2,a=Math.min(o,s*e/2),r=t=>{const e=(i-Math.min(o,t))*s/2;return Z(t,0,Math.min(o,e))};return{outerStart:r(n.outerStart),outerEnd:r(n.outerEnd),innerStart:Z(n.innerStart,0,a),innerEnd:Z(n.innerEnd,0,a)}}function qn(t,e,i,s){return{x:i+t*Math.cos(e),y:s+t*Math.sin(e)}}function Kn(t,e,i,s,n,o){const{x:a,y:r,startAngle:l,pixelMargin:h,innerRadius:c}=e,d=Math.max(e.outerRadius+s+i-h,0),u=c>0?c+s+i+h:0;let f=0;const g=n-l;if(s){const t=((c>0?c-s:0)+(d>0?d-s:0))/2;f=(g-(0!==t?g*t/(t+s):g))/2}const p=(g-Math.max(.001,g*d-i/C)/d)/2,m=l+p+f,x=n-p-f,{outerStart:b,outerEnd:_,innerStart:y,innerEnd:v}=Xn(e,u,d,x-m),M=d-b,w=d-_,k=m+b/M,S=x-_/w,P=u+y,D=u+v,O=m+y/P,A=x-v/D;if(t.beginPath(),o){const e=(k+S)/2;if(t.arc(a,r,d,k,e),t.arc(a,r,d,e,S),_>0){const e=qn(w,S,a,r);t.arc(e.x,e.y,_,S,x+E)}const i=qn(D,x,a,r);if(t.lineTo(i.x,i.y),v>0){const e=qn(D,A,a,r);t.arc(e.x,e.y,v,x+E,A+Math.PI)}const s=(x-v/u+(m+y/u))/2;if(t.arc(a,r,u,x-v/u,s,!0),t.arc(a,r,u,s,m+y/u,!0),y>0){const e=qn(P,O,a,r);t.arc(e.x,e.y,y,O+Math.PI,m-E)}const n=qn(M,m,a,r);if(t.lineTo(n.x,n.y),b>0){const e=qn(M,k,a,r);t.arc(e.x,e.y,b,m-E,k)}}else{t.moveTo(a,r);const e=Math.cos(k)*d+a,i=Math.sin(k)*d+r;t.lineTo(e,i);const s=Math.cos(S)*d+a,n=Math.sin(S)*d+r;t.lineTo(s,n)}t.closePath()}function Gn(t,e,i,s,n){const{fullCircles:o,startAngle:a,circumference:r,options:l}=e,{borderWidth:h,borderJoinStyle:c,borderDash:d,borderDashOffset:u,borderRadius:f}=l,g="inner"===l.borderAlign;if(!h)return;t.setLineDash(d||[]),t.lineDashOffset=u,g?(t.lineWidth=2*h,t.lineJoin=c||"round"):(t.lineWidth=h,t.lineJoin=c||"bevel");let p=e.endAngle;if(o){Kn(t,e,i,s,p,n);for(let e=0;en?(h=n/l,t.arc(o,a,l,i+h,s-h,!0)):t.arc(o,a,n,i+E,s-E),t.closePath(),t.clip()}(t,e,p),l.selfJoin&&p-a>=C&&0===f&&"miter"!==c&&function(t,e,i){const{startAngle:s,x:n,y:o,outerRadius:a,innerRadius:r,options:l}=e,{borderWidth:h,borderJoinStyle:c}=l,d=Math.min(h/a,G(s-i));if(t.beginPath(),t.arc(n,o,a-h/2,s+d/2,i-d/2),r>0){const e=Math.min(h/r,G(s-i));t.arc(n,o,r+h/2,i-e/2,s+e/2,!0)}else{const e=Math.min(h/2,a*G(s-i));if("round"===c)t.arc(n,o,e,i-C/2,s+C/2,!0);else if("bevel"===c){const a=2*e*e,r=-a*Math.cos(i+C/2)+n,l=-a*Math.sin(i+C/2)+o,h=a*Math.cos(s+C/2)+n,c=a*Math.sin(s+C/2)+o;t.lineTo(r,l),t.lineTo(h,c)}}t.closePath(),t.moveTo(0,0),t.rect(0,0,t.canvas.width,t.canvas.height),t.clip("evenodd")}(t,e,p),o||(Kn(t,e,i,s,p,n),t.stroke())}function Jn(t,e,i=e){t.lineCap=l(i.borderCapStyle,e.borderCapStyle),t.setLineDash(l(i.borderDash,e.borderDash)),t.lineDashOffset=l(i.borderDashOffset,e.borderDashOffset),t.lineJoin=l(i.borderJoinStyle,e.borderJoinStyle),t.lineWidth=l(i.borderWidth,e.borderWidth),t.strokeStyle=l(i.borderColor,e.borderColor)}function Zn(t,e,i){t.lineTo(i.x,i.y)}function Qn(t,e,i={}){const s=t.length,{start:n=0,end:o=s-1}=i,{start:a,end:r}=e,l=Math.max(n,a),h=Math.min(o,r),c=nr&&o>r;return{count:s,start:l,loop:e.loop,ilen:h(a+(h?r-t:t))%o,_=()=>{f!==g&&(t.lineTo(m,g),t.lineTo(m,f),t.lineTo(m,p))};for(l&&(d=n[b(0)],t.moveTo(d.x,d.y)),c=0;c<=r;++c){if(d=n[b(c)],d.skip)continue;const e=d.x,i=d.y,s=0|e;s===u?(ig&&(g=i),m=(x*m+e)/++x):(_(),t.lineTo(e,i),u=s,x=0,f=g=i),p=i}_()}function io(t){const e=t.options,i=e.borderDash&&e.borderDash.length;return!(t._decimated||t._loop||e.tension||"monotone"===e.cubicInterpolationMode||e.stepped||i)?eo:to}const so="function"==typeof Path2D;function no(t,e,i,s){so&&!e.options.segment?function(t,e,i,s){let n=e._path;n||(n=e._path=new Path2D,e.path(n,i,s)&&n.closePath()),Jn(t,e.options),t.stroke(n)}(t,e,i,s):function(t,e,i,s){const{segments:n,options:o}=e,a=io(e);for(const r of n)Jn(t,o,r.style),t.beginPath(),a(t,e,r,{start:i,end:i+s-1})&&t.closePath(),t.stroke()}(t,e,i,s)}class oo extends $s{static id="line";static defaults={borderCapStyle:"butt",borderDash:[],borderDashOffset:0,borderJoinStyle:"miter",borderWidth:3,capBezierPoints:!0,cubicInterpolationMode:"default",fill:!1,spanGaps:!1,stepped:!1,tension:0};static defaultRoutes={backgroundColor:"backgroundColor",borderColor:"borderColor"};static descriptors={_scriptable:!0,_indexable:t=>"borderDash"!==t&&"fill"!==t};constructor(t){super(),this.animated=!0,this.options=void 0,this._chart=void 0,this._loop=void 0,this._fullLoop=void 0,this._path=void 0,this._points=void 0,this._segments=void 0,this._decimated=!1,this._pointsUpdated=!1,this._datasetIndex=void 0,t&&Object.assign(this,t)}updateControlPoints(t,e){const i=this.options;if((i.tension||"monotone"===i.cubicInterpolationMode)&&!i.stepped&&!this._pointsUpdated){const s=i.spanGaps?this._loop:this._fullLoop;hi(this._points,i,t,s,e),this._pointsUpdated=!0}}set points(t){this._points=t,delete this._segments,delete this._path,this._pointsUpdated=!1}get points(){return this._points}get segments(){return this._segments||(this._segments=zi(this,this.options.segment))}first(){const t=this.segments,e=this.points;return t.length&&e[t[0].start]}last(){const t=this.segments,e=this.points,i=t.length;return i&&e[t[i-1].end]}interpolate(t,e){const i=this.options,s=t[e],n=this.points,o=Ii(this,{property:e,start:s,end:s});if(!o.length)return;const a=[],r=function(t){return t.stepped?pi:t.tension||"monotone"===t.cubicInterpolationMode?mi:gi}(i);let l,h;for(l=0,h=o.length;l"borderDash"!==t};circumference;endAngle;fullCircles;innerRadius;outerRadius;pixelMargin;startAngle;constructor(t){super(),this.options=void 0,this.circumference=void 0,this.startAngle=void 0,this.endAngle=void 0,this.innerRadius=void 0,this.outerRadius=void 0,this.pixelMargin=0,this.fullCircles=0,t&&Object.assign(this,t)}inRange(t,e,i){const s=this.getProps(["x","y"],i),{angle:n,distance:o}=X(s,{x:t,y:e}),{startAngle:a,endAngle:r,innerRadius:h,outerRadius:c,circumference:d}=this.getProps(["startAngle","endAngle","innerRadius","outerRadius","circumference"],i),u=(this.options.spacing+this.options.borderWidth)/2,f=l(d,r-a),g=J(n,a,r)&&a!==r,p=f>=O||g,m=tt(o,h+u,c+u);return p&&m}getCenterPoint(t){const{x:e,y:i,startAngle:s,endAngle:n,innerRadius:o,outerRadius:a}=this.getProps(["x","y","startAngle","endAngle","innerRadius","outerRadius"],t),{offset:r,spacing:l}=this.options,h=(s+n)/2,c=(o+a+l+r)/2;return{x:e+Math.cos(h)*c,y:i+Math.sin(h)*c}}tooltipPosition(t){return this.getCenterPoint(t)}draw(t){const{options:e,circumference:i}=this,s=(e.offset||0)/4,n=(e.spacing||0)/2,o=e.circular;if(this.pixelMargin="inner"===e.borderAlign?.33:0,this.fullCircles=i>O?Math.floor(i/O):0,0===i||this.innerRadius<0||this.outerRadius<0)return;t.save();const a=(this.startAngle+this.endAngle)/2;t.translate(Math.cos(a)*s,Math.sin(a)*s);const r=s*(1-Math.sin(Math.min(C,i||0)));t.fillStyle=e.backgroundColor,t.strokeStyle=e.borderColor,function(t,e,i,s,n){const{fullCircles:o,startAngle:a,circumference:r}=e;let l=e.endAngle;if(o){Kn(t,e,i,s,l,n);for(let e=0;e("string"==typeof e?(i=t.push(e)-1,s.unshift({index:i,label:e})):isNaN(e)&&(i=null),i))(t,e,i,s);return n!==t.lastIndexOf(e)?i:n}function mo(t){const e=this.getLabels();return t>=0&&ts=e?s:t,a=t=>n=i?n:t;if(t){const t=F(s),e=F(n);t<0&&e<0?a(0):t>0&&e>0&&o(0)}if(s===n){let e=0===n?1:Math.abs(.05*n);a(n+e),t||o(s-e)}this.min=s,this.max=n}getTickLimit(){const t=this.options.ticks;let e,{maxTicksLimit:i,stepSize:s}=t;return s?(e=Math.ceil(this.max/s)-Math.floor(this.min/s)+1,e>1e3&&(console.warn(`scales.${this.id}.ticks.stepSize: ${s} would result generating up to ${e} ticks. Limiting to 1000.`),e=1e3)):(e=this.computeTickLimit(),i=i||11),i&&(e=Math.min(i,e)),e}computeTickLimit(){return Number.POSITIVE_INFINITY}buildTicks(){const t=this.options,e=t.ticks;let i=this.getTickLimit();i=Math.max(2,i);const n=function(t,e){const i=[],{bounds:n,step:o,min:a,max:r,precision:l,count:h,maxTicks:c,maxDigits:d,includeBounds:u}=t,f=o||1,g=c-1,{min:p,max:m}=e,x=!s(a),b=!s(r),_=!s(h),y=(m-p)/(d+1);let v,M,w,k,S=B((m-p)/g/f)*f;if(S<1e-14&&!x&&!b)return[{value:p},{value:m}];k=Math.ceil(m/S)-Math.floor(p/S),k>g&&(S=B(k*S/g/f)*f),s(l)||(v=Math.pow(10,l),S=Math.ceil(S*v)/v),"ticks"===n?(M=Math.floor(p/S)*S,w=Math.ceil(m/S)*S):(M=p,w=m),x&&b&&o&&H((r-a)/o,S/1e3)?(k=Math.round(Math.min((r-a)/S,c)),S=(r-a)/k,M=a,w=r):_?(M=x?a:M,w=b?r:w,k=h-1,S=(w-M)/k):(k=(w-M)/S,k=V(k,Math.round(k),S/1e3)?Math.round(k):Math.ceil(k));const P=Math.max(U(S),U(M));v=Math.pow(10,s(l)?P:l),M=Math.round(M*v)/v,w=Math.round(w*v)/v;let D=0;for(x&&(u&&M!==a?(i.push({value:a}),Mr)break;i.push({value:t})}return b&&u&&w!==r?i.length&&V(i[i.length-1].value,r,xo(r,y,t))?i[i.length-1].value=r:i.push({value:r}):b&&w!==r||i.push({value:w}),i}({maxTicks:i,bounds:t.bounds,min:t.min,max:t.max,precision:e.precision,step:e.stepSize,count:e.count,maxDigits:this._maxDigits(),horizontal:this.isHorizontal(),minRotation:e.minRotation||0,includeBounds:!1!==e.includeBounds},this._range||this);return"ticks"===t.bounds&&j(n,this,"value"),t.reverse?(n.reverse(),this.start=this.max,this.end=this.min):(this.start=this.min,this.end=this.max),n}configure(){const t=this.ticks;let e=this.min,i=this.max;if(super.configure(),this.options.offset&&t.length){const s=(i-e)/Math.max(t.length-1,1)/2;e-=s,i+=s}this._startValue=e,this._endValue=i,this._valueRange=i-e}getLabelForValue(t){return ne(t,this.chart.options.locale,this.options.ticks.format)}}class _o extends bo{static id="linear";static defaults={ticks:{callback:ae.formatters.numeric}};determineDataLimits(){const{min:t,max:e}=this.getMinMax(!0);this.min=a(t)?t:0,this.max=a(e)?e:1,this.handleTickRangeOptions()}computeTickLimit(){const t=this.isHorizontal(),e=t?this.width:this.height,i=$(this.options.ticks.minRotation),s=(t?Math.sin(i):Math.cos(i))||.001,n=this._resolveTickFontOptions(0);return Math.ceil(e/Math.min(40,n.lineHeight/s))}getPixelForValue(t){return null===t?NaN:this.getPixelForDecimal((t-this._startValue)/this._valueRange)}getValueForPixel(t){return this._startValue+this.getDecimalForPixel(t)*this._valueRange}}const yo=t=>Math.floor(z(t)),vo=(t,e)=>Math.pow(10,yo(t)+e);function Mo(t){return 1===t/Math.pow(10,yo(t))}function wo(t,e,i){const s=Math.pow(10,i),n=Math.floor(t/s);return Math.ceil(e/s)-n}function ko(t,{min:e,max:i}){e=r(t.min,e);const s=[],n=yo(e);let o=function(t,e){let i=yo(e-t);for(;wo(t,e,i)>10;)i++;for(;wo(t,e,i)<10;)i--;return Math.min(i,yo(t))}(e,i),a=o<0?Math.pow(10,Math.abs(o)):1;const l=Math.pow(10,o),h=n>o?Math.pow(10,n):0,c=Math.round((e-h)*a)/a,d=Math.floor((e-h)/l/10)*l*10;let u=Math.floor((c-d)/Math.pow(10,o)),f=r(t.min,Math.round((h+d+u*Math.pow(10,o))*a)/a);for(;f=10?u=u<15?15:20:u++,u>=20&&(o++,u=2,a=o>=0?1:a),f=Math.round((h+d+u*Math.pow(10,o))*a)/a;const g=r(t.max,f);return s.push({value:g,major:Mo(g),significand:u}),s}class So extends tn{static id="logarithmic";static defaults={ticks:{callback:ae.formatters.logarithmic,major:{enabled:!0}}};constructor(t){super(t),this.start=void 0,this.end=void 0,this._startValue=void 0,this._valueRange=0}parse(t,e){const i=bo.prototype.parse.apply(this,[t,e]);if(0!==i)return a(i)&&i>0?i:null;this._zero=!0}determineDataLimits(){const{min:t,max:e}=this.getMinMax(!0);this.min=a(t)?Math.max(0,t):null,this.max=a(e)?Math.max(0,e):null,this.options.beginAtZero&&(this._zero=!0),this._zero&&this.min!==this._suggestedMin&&!a(this._userMin)&&(this.min=t===vo(this.min,0)?vo(this.min,-1):vo(this.min,0)),this.handleTickRangeOptions()}handleTickRangeOptions(){const{minDefined:t,maxDefined:e}=this.getUserBounds();let i=this.min,s=this.max;const n=e=>i=t?i:e,o=t=>s=e?s:t;i===s&&(i<=0?(n(1),o(10)):(n(vo(i,-1)),o(vo(s,1)))),i<=0&&n(vo(s,-1)),s<=0&&o(vo(i,1)),this.min=i,this.max=s}buildTicks(){const t=this.options,e=ko({min:this._userMin,max:this._userMax},this);return"ticks"===t.bounds&&j(e,this,"value"),t.reverse?(e.reverse(),this.start=this.max,this.end=this.min):(this.start=this.min,this.end=this.max),e}getLabelForValue(t){return void 0===t?"0":ne(t,this.chart.options.locale,this.options.ticks.format)}configure(){const t=this.min;super.configure(),this._startValue=z(t),this._valueRange=z(this.max)-z(t)}getPixelForValue(t){return void 0!==t&&0!==t||(t=this.min),null===t||isNaN(t)?NaN:this.getPixelForDecimal(t===this.min?0:(z(t)-this._startValue)/this._valueRange)}getValueForPixel(t){const e=this.getDecimalForPixel(t);return Math.pow(10,this._startValue+e*this._valueRange)}}function Po(t){const e=t.ticks;if(e.display&&t.display){const t=ki(e.backdropPadding);return l(e.font&&e.font.size,ue.font.size)+t.height}return 0}function Do(t,e,i,s,n){return t===s||t===n?{start:e-i/2,end:e+i/2}:tn?{start:e-i,end:e}:{start:e,end:e+i}}function Co(t){const e={l:t.left+t._padding.left,r:t.right-t._padding.right,t:t.top+t._padding.top,b:t.bottom-t._padding.bottom},i=Object.assign({},e),s=[],o=[],a=t._pointLabels.length,r=t.options.pointLabels,l=r.centerPointLabels?C/a:0;for(let u=0;ue.r&&(r=(s.end-e.r)/o,t.r=Math.max(t.r,e.r+r)),n.starte.b&&(l=(n.end-e.b)/a,t.b=Math.max(t.b,e.b+l))}function Ao(t,e,i){const s=t.drawingArea,{extra:n,additionalAngle:o,padding:a,size:r}=i,l=t.getPointPosition(e,s+n+a,o),h=Math.round(Y(G(l.angle+E))),c=function(t,e,i){90===i||270===i?t-=e/2:(i>270||i<90)&&(t-=e);return t}(l.y,r.h,h),d=function(t){if(0===t||180===t)return"center";if(t<180)return"left";return"right"}(h),u=function(t,e,i){"right"===i?t-=e:"center"===i&&(t-=e/2);return t}(l.x,r.w,d);return{visible:!0,x:l.x,y:c,textAlign:d,left:u,top:c,right:u+r.w,bottom:c+r.h}}function To(t,e){if(!e)return!0;const{left:i,top:s,right:n,bottom:o}=t;return!(Re({x:i,y:s},e)||Re({x:i,y:o},e)||Re({x:n,y:s},e)||Re({x:n,y:o},e))}function Lo(t,e,i){const{left:n,top:o,right:a,bottom:r}=i,{backdropColor:l}=e;if(!s(l)){const i=wi(e.borderRadius),s=ki(e.backdropPadding);t.fillStyle=l;const h=n-s.left,c=o-s.top,d=a-n+s.width,u=r-o+s.height;Object.values(i).some((t=>0!==t))?(t.beginPath(),He(t,{x:h,y:c,w:d,h:u,radius:i}),t.fill()):t.fillRect(h,c,d,u)}}function Eo(t,e,i,s){const{ctx:n}=t;if(i)n.arc(t.xCenter,t.yCenter,e,0,O);else{let i=t.getPointPosition(0,e);n.moveTo(i.x,i.y);for(let o=1;ot,padding:5,centerPointLabels:!1}};static defaultRoutes={"angleLines.color":"borderColor","pointLabels.color":"color","ticks.color":"color"};static descriptors={angleLines:{_fallback:"grid"}};constructor(t){super(t),this.xCenter=void 0,this.yCenter=void 0,this.drawingArea=void 0,this._pointLabels=[],this._pointLabelItems=[]}setDimensions(){const t=this._padding=ki(Po(this.options)/2),e=this.width=this.maxWidth-t.width,i=this.height=this.maxHeight-t.height;this.xCenter=Math.floor(this.left+e/2+t.left),this.yCenter=Math.floor(this.top+i/2+t.top),this.drawingArea=Math.floor(Math.min(e,i)/2)}determineDataLimits(){const{min:t,max:e}=this.getMinMax(!1);this.min=a(t)&&!isNaN(t)?t:0,this.max=a(e)&&!isNaN(e)?e:0,this.handleTickRangeOptions()}computeTickLimit(){return Math.ceil(this.drawingArea/Po(this.options))}generateTickLabels(t){bo.prototype.generateTickLabels.call(this,t),this._pointLabels=this.getLabels().map(((t,e)=>{const i=d(this.options.pointLabels.callback,[t,e],this);return i||0===i?i:""})).filter(((t,e)=>this.chart.getDataVisibility(e)))}fit(){const t=this.options;t.display&&t.pointLabels.display?Co(this):this.setCenterPoint(0,0,0,0)}setCenterPoint(t,e,i,s){this.xCenter+=Math.floor((t-e)/2),this.yCenter+=Math.floor((i-s)/2),this.drawingArea-=Math.min(this.drawingArea/2,Math.max(t,e,i,s))}getIndexAngle(t){return G(t*(O/(this._pointLabels.length||1))+$(this.options.startAngle||0))}getDistanceFromCenterForValue(t){if(s(t))return NaN;const e=this.drawingArea/(this.max-this.min);return this.options.reverse?(this.max-t)*e:(t-this.min)*e}getValueForDistanceFromCenter(t){if(s(t))return NaN;const e=t/(this.drawingArea/(this.max-this.min));return this.options.reverse?this.max-e:this.min+e}getPointLabelContext(t){const e=this._pointLabels||[];if(t>=0&&t=0;n--){const e=t._pointLabelItems[n];if(!e.visible)continue;const o=s.setContext(t.getPointLabelContext(n));Lo(i,o,e);const a=Si(o.font),{x:r,y:l,textAlign:h}=e;Ne(i,t._pointLabels[n],r,l+a.lineHeight/2,a,{color:o.color,textAlign:h,textBaseline:"middle"})}}(this,o),s.display&&this.ticks.forEach(((t,e)=>{if(0!==e||0===e&&this.min<0){r=this.getDistanceFromCenterForValue(t.value);const i=this.getContext(e),a=s.setContext(i),l=n.setContext(i);!function(t,e,i,s,n){const o=t.ctx,a=e.circular,{color:r,lineWidth:l}=e;!a&&!s||!r||!l||i<0||(o.save(),o.strokeStyle=r,o.lineWidth=l,o.setLineDash(n.dash||[]),o.lineDashOffset=n.dashOffset,o.beginPath(),Eo(t,i,a,s),o.closePath(),o.stroke(),o.restore())}(this,a,r,o,l)}})),i.display){for(t.save(),a=o-1;a>=0;a--){const s=i.setContext(this.getPointLabelContext(a)),{color:n,lineWidth:o}=s;o&&n&&(t.lineWidth=o,t.strokeStyle=n,t.setLineDash(s.borderDash),t.lineDashOffset=s.borderDashOffset,r=this.getDistanceFromCenterForValue(e.reverse?this.min:this.max),l=this.getPointPosition(a,r),t.beginPath(),t.moveTo(this.xCenter,this.yCenter),t.lineTo(l.x,l.y),t.stroke())}t.restore()}}drawBorder(){}drawLabels(){const t=this.ctx,e=this.options,i=e.ticks;if(!i.display)return;const s=this.getIndexAngle(0);let n,o;t.save(),t.translate(this.xCenter,this.yCenter),t.rotate(s),t.textAlign="center",t.textBaseline="middle",this.ticks.forEach(((s,a)=>{if(0===a&&this.min>=0&&!e.reverse)return;const r=i.setContext(this.getContext(a)),l=Si(r.font);if(n=this.getDistanceFromCenterForValue(this.ticks[a].value),r.showLabelBackdrop){t.font=l.string,o=t.measureText(s.label).width,t.fillStyle=r.backdropColor;const e=ki(r.backdropPadding);t.fillRect(-o/2-e.left,-n-l.size/2-e.top,o+e.width,l.size+e.height)}Ne(t,s.label,0,-n,l,{color:r.color,strokeColor:r.textStrokeColor,strokeWidth:r.textStrokeWidth})})),t.restore()}drawTitle(){}}const Io={millisecond:{common:!0,size:1,steps:1e3},second:{common:!0,size:1e3,steps:60},minute:{common:!0,size:6e4,steps:60},hour:{common:!0,size:36e5,steps:24},day:{common:!0,size:864e5,steps:30},week:{common:!1,size:6048e5,steps:4},month:{common:!0,size:2628e6,steps:12},quarter:{common:!1,size:7884e6,steps:4},year:{common:!0,size:3154e7}},zo=Object.keys(Io);function Fo(t,e){return t-e}function Vo(t,e){if(s(e))return null;const i=t._adapter,{parser:n,round:o,isoWeekday:r}=t._parseOpts;let l=e;return"function"==typeof n&&(l=n(l)),a(l)||(l="string"==typeof n?i.parse(l,n):i.parse(l)),null===l?null:(o&&(l="week"!==o||!N(r)&&!0!==r?i.startOf(l,o):i.startOf(l,"isoWeek",r)),+l)}function Bo(t,e,i,s){const n=zo.length;for(let o=zo.indexOf(t);o=e?i[s]:i[n]]=!0}}else t[e]=!0}function No(t,e,i){const s=[],n={},o=e.length;let a,r;for(a=0;a=0&&(e[l].major=!0);return e}(t,s,n,i):s}class Ho extends tn{static id="time";static defaults={bounds:"data",adapters:{},time:{parser:!1,unit:!1,round:!1,isoWeekday:!1,minUnit:"millisecond",displayFormats:{}},ticks:{source:"auto",callback:!1,major:{enabled:!1}}};constructor(t){super(t),this._cache={data:[],labels:[],all:[]},this._unit="day",this._majorUnit=void 0,this._offsets={},this._normalized=!1,this._parseOpts=void 0}init(t,e={}){const i=t.time||(t.time={}),s=this._adapter=new In._date(t.adapters.date);s.init(e),b(i.displayFormats,s.formats()),this._parseOpts={parser:i.parser,round:i.round,isoWeekday:i.isoWeekday},super.init(t),this._normalized=e.normalized}parse(t,e){return void 0===t?null:Vo(this,t)}beforeLayout(){super.beforeLayout(),this._cache={data:[],labels:[],all:[]}}determineDataLimits(){const t=this.options,e=this._adapter,i=t.time.unit||"day";let{min:s,max:n,minDefined:o,maxDefined:r}=this.getUserBounds();function l(t){o||isNaN(t.min)||(s=Math.min(s,t.min)),r||isNaN(t.max)||(n=Math.max(n,t.max))}o&&r||(l(this._getLabelBounds()),"ticks"===t.bounds&&"labels"===t.ticks.source||l(this.getMinMax(!1))),s=a(s)&&!isNaN(s)?s:+e.startOf(Date.now(),i),n=a(n)&&!isNaN(n)?n:+e.endOf(Date.now(),i)+1,this.min=Math.min(s,n-1),this.max=Math.max(s+1,n)}_getLabelBounds(){const t=this.getLabelTimestamps();let e=Number.POSITIVE_INFINITY,i=Number.NEGATIVE_INFINITY;return t.length&&(e=t[0],i=t[t.length-1]),{min:e,max:i}}buildTicks(){const t=this.options,e=t.time,i=t.ticks,s="labels"===i.source?this.getLabelTimestamps():this._generate();"ticks"===t.bounds&&s.length&&(this.min=this._userMin||s[0],this.max=this._userMax||s[s.length-1]);const n=this.min,o=nt(s,n,this.max);return this._unit=e.unit||(i.autoSkip?Bo(e.minUnit,this.min,this.max,this._getLabelCapacity(n)):function(t,e,i,s,n){for(let o=zo.length-1;o>=zo.indexOf(i);o--){const i=zo[o];if(Io[i].common&&t._adapter.diff(n,s,i)>=e-1)return i}return zo[i?zo.indexOf(i):0]}(this,o.length,e.minUnit,this.min,this.max)),this._majorUnit=i.major.enabled&&"year"!==this._unit?function(t){for(let e=zo.indexOf(t)+1,i=zo.length;e+t.value)))}initOffsets(t=[]){let e,i,s=0,n=0;this.options.offset&&t.length&&(e=this.getDecimalForValue(t[0]),s=1===t.length?1-e:(this.getDecimalForValue(t[1])-e)/2,i=this.getDecimalForValue(t[t.length-1]),n=1===t.length?i:(i-this.getDecimalForValue(t[t.length-2]))/2);const o=t.length<3?.5:.25;s=Z(s,0,o),n=Z(n,0,o),this._offsets={start:s,end:n,factor:1/(s+1+n)}}_generate(){const t=this._adapter,e=this.min,i=this.max,s=this.options,n=s.time,o=n.unit||Bo(n.minUnit,e,i,this._getLabelCapacity(e)),a=l(s.ticks.stepSize,1),r="week"===o&&n.isoWeekday,h=N(r)||!0===r,c={};let d,u,f=e;if(h&&(f=+t.startOf(f,"isoWeek",r)),f=+t.startOf(f,h?"day":o),t.diff(i,e,o)>1e5*a)throw new Error(e+" and "+i+" are too far apart with stepSize of "+a+" "+o);const g="data"===s.ticks.source&&this.getDataTimestamps();for(d=f,u=0;d+t))}getLabelForValue(t){const e=this._adapter,i=this.options.time;return i.tooltipFormat?e.format(t,i.tooltipFormat):e.format(t,i.displayFormats.datetime)}format(t,e){const i=this.options.time.displayFormats,s=this._unit,n=e||i[s];return this._adapter.format(t,n)}_tickFormatFunction(t,e,i,s){const n=this.options,o=n.ticks.callback;if(o)return d(o,[t,e,i],this);const a=n.time.displayFormats,r=this._unit,l=this._majorUnit,h=r&&a[r],c=l&&a[l],u=i[e],f=l&&c&&u&&u.major;return this._adapter.format(t,s||(f?c:h))}generateTickLabels(t){let e,i,s;for(e=0,i=t.length;e0?a:1}getDataTimestamps(){let t,e,i=this._cache.data||[];if(i.length)return i;const s=this.getMatchingVisibleMetas();if(this._normalized&&s.length)return this._cache.data=s[0].controller.getAllParsedValues(this);for(t=0,e=s.length;t=t[r].pos&&e<=t[l].pos&&({lo:r,hi:l}=it(t,"pos",e)),({pos:s,time:o}=t[r]),({pos:n,time:a}=t[l])):(e>=t[r].time&&e<=t[l].time&&({lo:r,hi:l}=it(t,"time",e)),({time:s,pos:o}=t[r]),({time:n,pos:a}=t[l]));const h=n-s;return h?o+(a-o)*(e-s)/h:o}var $o=Object.freeze({__proto__:null,CategoryScale:class extends tn{static id="category";static defaults={ticks:{callback:mo}};constructor(t){super(t),this._startValue=void 0,this._valueRange=0,this._addedLabels=[]}init(t){const e=this._addedLabels;if(e.length){const t=this.getLabels();for(const{index:i,label:s}of e)t[i]===s&&t.splice(i,1);this._addedLabels=[]}super.init(t)}parse(t,e){if(s(t))return null;const i=this.getLabels();return((t,e)=>null===t?null:Z(Math.round(t),0,e))(e=isFinite(e)&&i[e]===t?e:po(i,t,l(e,t),this._addedLabels),i.length-1)}determineDataLimits(){const{minDefined:t,maxDefined:e}=this.getUserBounds();let{min:i,max:s}=this.getMinMax(!0);"ticks"===this.options.bounds&&(t||(i=0),e||(s=this.getLabels().length-1)),this.min=i,this.max=s}buildTicks(){const t=this.min,e=this.max,i=this.options.offset,s=[];let n=this.getLabels();n=0===t&&e===n.length-1?n:n.slice(t,e+1),this._valueRange=Math.max(n.length-(i?0:1),1),this._startValue=this.min-(i?.5:0);for(let i=t;i<=e;i++)s.push({value:i});return s}getLabelForValue(t){return mo.call(this,t)}configure(){super.configure(),this.isHorizontal()||(this._reversePixels=!this._reversePixels)}getPixelForValue(t){return"number"!=typeof t&&(t=this.parse(t)),null===t?NaN:this.getPixelForDecimal((t-this._startValue)/this._valueRange)}getPixelForTick(t){const e=this.ticks;return t<0||t>e.length-1?null:this.getPixelForValue(e[t].value)}getValueForPixel(t){return Math.round(this._startValue+this.getDecimalForPixel(t)*this._valueRange)}getBasePixel(){return this.bottom}},LinearScale:_o,LogarithmicScale:So,RadialLinearScale:Ro,TimeScale:Ho,TimeSeriesScale:class extends Ho{static id="timeseries";static defaults=Ho.defaults;constructor(t){super(t),this._table=[],this._minPos=void 0,this._tableRange=void 0}initOffsets(){const t=this._getTimestampsForTable(),e=this._table=this.buildLookupTable(t);this._minPos=jo(e,this.min),this._tableRange=jo(e,this.max)-this._minPos,super.initOffsets(t)}buildLookupTable(t){const{min:e,max:i}=this,s=[],n=[];let o,a,r,l,h;for(o=0,a=t.length;o=e&&l<=i&&s.push(l);if(s.length<2)return[{time:e,pos:0},{time:i,pos:1}];for(o=0,a=s.length;ot-e))}_getTimestampsForTable(){let t=this._cache.all||[];if(t.length)return t;const e=this.getDataTimestamps(),i=this.getLabelTimestamps();return t=e.length&&i.length?this.normalize(e.concat(i)):e.length?e:i,t=this._cache.all=t,t}getDecimalForValue(t){return(jo(this._table,t)-this._minPos)/this._tableRange}getValueForPixel(t){const e=this._offsets,i=this.getDecimalForPixel(t)/e.factor-e.end;return jo(this._table,i*this._tableRange+this._minPos,!0)}}});const Yo=["rgb(54, 162, 235)","rgb(255, 99, 132)","rgb(255, 159, 64)","rgb(255, 205, 86)","rgb(75, 192, 192)","rgb(153, 102, 255)","rgb(201, 203, 207)"],Uo=Yo.map((t=>t.replace("rgb(","rgba(").replace(")",", 0.5)")));function Xo(t){return Yo[t%Yo.length]}function qo(t){return Uo[t%Uo.length]}function Ko(t){let e=0;return(i,s)=>{const n=t.getDatasetMeta(s).controller;n instanceof $n?e=function(t,e){return t.backgroundColor=t.data.map((()=>Xo(e++))),e}(i,e):n instanceof Yn?e=function(t,e){return t.backgroundColor=t.data.map((()=>qo(e++))),e}(i,e):n&&(e=function(t,e){return t.borderColor=Xo(e),t.backgroundColor=qo(e),++e}(i,e))}}function Go(t){let e;for(e in t)if(t[e].borderColor||t[e].backgroundColor)return!0;return!1}var Jo={id:"colors",defaults:{enabled:!0,forceOverride:!1},beforeLayout(t,e,i){if(!i.enabled)return;const{data:{datasets:s},options:n}=t.config,{elements:o}=n,a=Go(s)||(r=n)&&(r.borderColor||r.backgroundColor)||o&&Go(o)||"rgba(0,0,0,0.1)"!==ue.borderColor||"rgba(0,0,0,0.1)"!==ue.backgroundColor;var r;if(!i.forceOverride&&a)return;const l=Ko(t);s.forEach(l)}};function Zo(t){if(t._decimated){const e=t._data;delete t._decimated,delete t._data,Object.defineProperty(t,"data",{configurable:!0,enumerable:!0,writable:!0,value:e})}}function Qo(t){t.data.datasets.forEach((t=>{Zo(t)}))}var ta={id:"decimation",defaults:{algorithm:"min-max",enabled:!1},beforeElementsUpdate:(t,e,i)=>{if(!i.enabled)return void Qo(t);const n=t.width;t.data.datasets.forEach(((e,o)=>{const{_data:a,indexAxis:r}=e,l=t.getDatasetMeta(o),h=a||e.data;if("y"===Pi([r,t.options.indexAxis]))return;if(!l.controller.supportsDecimation)return;const c=t.scales[l.xAxisID];if("linear"!==c.type&&"time"!==c.type)return;if(t.options.parsing)return;let{start:d,count:u}=function(t,e){const i=e.length;let s,n=0;const{iScale:o}=t,{min:a,max:r,minDefined:l,maxDefined:h}=o.getUserBounds();return l&&(n=Z(it(e,o.axis,a).lo,0,i-1)),s=h?Z(it(e,o.axis,r).hi+1,n,i)-n:i-n,{start:n,count:s}}(l,h);if(u<=(i.threshold||4*n))return void Zo(e);let f;switch(s(a)&&(e._data=h,delete e.data,Object.defineProperty(e,"data",{configurable:!0,enumerable:!0,get:function(){return this._decimated},set:function(t){this._data=t}})),i.algorithm){case"lttb":f=function(t,e,i,s,n){const o=n.samples||s;if(o>=i)return t.slice(e,e+i);const a=[],r=(i-2)/(o-2);let l=0;const h=e+i-1;let c,d,u,f,g,p=e;for(a[l++]=t[p],c=0;cu&&(u=f,d=t[s],g=s);a[l++]=d,p=g}return a[l++]=t[h],a}(h,d,u,n,i);break;case"min-max":f=function(t,e,i,n){let o,a,r,l,h,c,d,u,f,g,p=0,m=0;const x=[],b=e+i-1,_=t[e].x,y=t[b].x-_;for(o=e;og&&(g=l,d=o),p=(m*p+a.x)/++m;else{const i=o-1;if(!s(c)&&!s(d)){const e=Math.min(c,d),s=Math.max(c,d);e!==u&&e!==i&&x.push({...t[e],x:p}),s!==u&&s!==i&&x.push({...t[s],x:p})}o>0&&i!==u&&x.push(t[i]),x.push(a),h=e,m=0,f=g=l,c=d=u=o}}return x}(h,d,u,n);break;default:throw new Error(`Unsupported decimation algorithm '${i.algorithm}'`)}e._decimated=f}))},destroy(t){Qo(t)}};function ea(t,e,i,s){if(s)return;let n=e[t],o=i[t];return"angle"===t&&(n=G(n),o=G(o)),{property:t,start:n,end:o}}function ia(t,e,i){for(;e>t;e--){const t=i[e];if(!isNaN(t.x)&&!isNaN(t.y))break}return e}function sa(t,e,i,s){return t&&e?s(t[i],e[i]):t?t[i]:e?e[i]:0}function na(t,e){let i=[],s=!1;return n(t)?(s=!0,i=t):i=function(t,e){const{x:i=null,y:s=null}=t||{},n=e.points,o=[];return e.segments.forEach((({start:t,end:e})=>{e=ia(t,e,n);const a=n[t],r=n[e];null!==s?(o.push({x:a.x,y:s}),o.push({x:r.x,y:s})):null!==i&&(o.push({x:i,y:a.y}),o.push({x:i,y:r.y}))})),o}(t,e),i.length?new oo({points:i,options:{tension:0},_loop:s,_fullLoop:s}):null}function oa(t){return t&&!1!==t.fill}function aa(t,e,i){let s=t[e].fill;const n=[e];let o;if(!i)return s;for(;!1!==s&&-1===n.indexOf(s);){if(!a(s))return s;if(o=t[s],!o)return!1;if(o.visible)return s;n.push(s),s=o.fill}return!1}function ra(t,e,i){const s=function(t){const e=t.options,i=e.fill;let s=l(i&&i.target,i);void 0===s&&(s=!!e.backgroundColor);if(!1===s||null===s)return!1;if(!0===s)return"origin";return s}(t);if(o(s))return!isNaN(s.value)&&s;let n=parseFloat(s);return a(n)&&Math.floor(n)===n?function(t,e,i,s){"-"!==t&&"+"!==t||(i=e+i);if(i===e||i<0||i>=s)return!1;return i}(s[0],e,n,i):["origin","start","end","stack","shape"].indexOf(s)>=0&&s}function la(t,e,i){const s=[];for(let n=0;n=0;--e){const i=n[e].$filler;i&&(i.line.updateControlPoints(o,i.axis),s&&i.fill&&ua(t.ctx,i,o))}},beforeDatasetsDraw(t,e,i){if("beforeDatasetsDraw"!==i.drawTime)return;const s=t.getSortedVisibleDatasetMetas();for(let e=s.length-1;e>=0;--e){const i=s[e].$filler;oa(i)&&ua(t.ctx,i,t.chartArea)}},beforeDatasetDraw(t,e,i){const s=e.meta.$filler;oa(s)&&"beforeDatasetDraw"===i.drawTime&&ua(t.ctx,s,t.chartArea)},defaults:{propagate:!0,drawTime:"beforeDatasetDraw"}};const _a=(t,e)=>{let{boxHeight:i=e,boxWidth:s=e}=t;return t.usePointStyle&&(i=Math.min(i,e),s=t.pointStyleWidth||Math.min(s,e)),{boxWidth:s,boxHeight:i,itemHeight:Math.max(e,i)}};class ya extends $s{constructor(t){super(),this._added=!1,this.legendHitBoxes=[],this._hoveredItem=null,this.doughnutMode=!1,this.chart=t.chart,this.options=t.options,this.ctx=t.ctx,this.legendItems=void 0,this.columnSizes=void 0,this.lineWidths=void 0,this.maxHeight=void 0,this.maxWidth=void 0,this.top=void 0,this.bottom=void 0,this.left=void 0,this.right=void 0,this.height=void 0,this.width=void 0,this._margins=void 0,this.position=void 0,this.weight=void 0,this.fullSize=void 0}update(t,e,i){this.maxWidth=t,this.maxHeight=e,this._margins=i,this.setDimensions(),this.buildLabels(),this.fit()}setDimensions(){this.isHorizontal()?(this.width=this.maxWidth,this.left=this._margins.left,this.right=this.width):(this.height=this.maxHeight,this.top=this._margins.top,this.bottom=this.height)}buildLabels(){const t=this.options.labels||{};let e=d(t.generateLabels,[this.chart],this)||[];t.filter&&(e=e.filter((e=>t.filter(e,this.chart.data)))),t.sort&&(e=e.sort(((e,i)=>t.sort(e,i,this.chart.data)))),this.options.reverse&&e.reverse(),this.legendItems=e}fit(){const{options:t,ctx:e}=this;if(!t.display)return void(this.width=this.height=0);const i=t.labels,s=Si(i.font),n=s.size,o=this._computeTitleHeight(),{boxWidth:a,itemHeight:r}=_a(i,n);let l,h;e.font=s.string,this.isHorizontal()?(l=this.maxWidth,h=this._fitRows(o,n,a,r)+10):(h=this.maxHeight,l=this._fitCols(o,s,a,r)+10),this.width=Math.min(l,t.maxWidth||this.maxWidth),this.height=Math.min(h,t.maxHeight||this.maxHeight)}_fitRows(t,e,i,s){const{ctx:n,maxWidth:o,options:{labels:{padding:a}}}=this,r=this.legendHitBoxes=[],l=this.lineWidths=[0],h=s+a;let c=t;n.textAlign="left",n.textBaseline="middle";let d=-1,u=-h;return this.legendItems.forEach(((t,f)=>{const g=i+e/2+n.measureText(t.text).width;(0===f||l[l.length-1]+g+2*a>o)&&(c+=h,l[l.length-(f>0?0:1)]=0,u+=h,d++),r[f]={left:0,top:u,row:d,width:g,height:s},l[l.length-1]+=g+a})),c}_fitCols(t,e,i,s){const{ctx:n,maxHeight:o,options:{labels:{padding:a}}}=this,r=this.legendHitBoxes=[],l=this.columnSizes=[],h=o-t;let c=a,d=0,u=0,f=0,g=0;return this.legendItems.forEach(((t,o)=>{const{itemWidth:p,itemHeight:m}=function(t,e,i,s,n){const o=function(t,e,i,s){let n=t.text;n&&"string"!=typeof n&&(n=n.reduce(((t,e)=>t.length>e.length?t:e)));return e+i.size/2+s.measureText(n).width}(s,t,e,i),a=function(t,e,i){let s=t;"string"!=typeof e.text&&(s=va(e,i));return s}(n,s,e.lineHeight);return{itemWidth:o,itemHeight:a}}(i,e,n,t,s);o>0&&u+m+2*a>h&&(c+=d+a,l.push({width:d,height:u}),f+=d+a,g++,d=u=0),r[o]={left:f,top:u,col:g,width:p,height:m},d=Math.max(d,p),u+=m+a})),c+=d,l.push({width:d,height:u}),c}adjustHitBoxes(){if(!this.options.display)return;const t=this._computeTitleHeight(),{legendHitBoxes:e,options:{align:i,labels:{padding:s},rtl:n}}=this,o=Oi(n,this.left,this.width);if(this.isHorizontal()){let n=0,a=ft(i,this.left+s,this.right-this.lineWidths[n]);for(const r of e)n!==r.row&&(n=r.row,a=ft(i,this.left+s,this.right-this.lineWidths[n])),r.top+=this.top+t+s,r.left=o.leftForLtr(o.x(a),r.width),a+=r.width+s}else{let n=0,a=ft(i,this.top+t+s,this.bottom-this.columnSizes[n].height);for(const r of e)r.col!==n&&(n=r.col,a=ft(i,this.top+t+s,this.bottom-this.columnSizes[n].height)),r.top=a,r.left+=this.left+s,r.left=o.leftForLtr(o.x(r.left),r.width),a+=r.height+s}}isHorizontal(){return"top"===this.options.position||"bottom"===this.options.position}draw(){if(this.options.display){const t=this.ctx;Ie(t,this),this._draw(),ze(t)}}_draw(){const{options:t,columnSizes:e,lineWidths:i,ctx:s}=this,{align:n,labels:o}=t,a=ue.color,r=Oi(t.rtl,this.left,this.width),h=Si(o.font),{padding:c}=o,d=h.size,u=d/2;let f;this.drawTitle(),s.textAlign=r.textAlign("left"),s.textBaseline="middle",s.lineWidth=.5,s.font=h.string;const{boxWidth:g,boxHeight:p,itemHeight:m}=_a(o,d),x=this.isHorizontal(),b=this._computeTitleHeight();f=x?{x:ft(n,this.left+c,this.right-i[0]),y:this.top+c+b,line:0}:{x:this.left+c,y:ft(n,this.top+b+c,this.bottom-e[0].height),line:0},Ai(this.ctx,t.textDirection);const _=m+c;this.legendItems.forEach(((y,v)=>{s.strokeStyle=y.fontColor,s.fillStyle=y.fontColor;const M=s.measureText(y.text).width,w=r.textAlign(y.textAlign||(y.textAlign=o.textAlign)),k=g+u+M;let S=f.x,P=f.y;r.setWidth(this.width),x?v>0&&S+k+c>this.right&&(P=f.y+=_,f.line++,S=f.x=ft(n,this.left+c,this.right-i[f.line])):v>0&&P+_>this.bottom&&(S=f.x=S+e[f.line].width+c,f.line++,P=f.y=ft(n,this.top+b+c,this.bottom-e[f.line].height));if(function(t,e,i){if(isNaN(g)||g<=0||isNaN(p)||p<0)return;s.save();const n=l(i.lineWidth,1);if(s.fillStyle=l(i.fillStyle,a),s.lineCap=l(i.lineCap,"butt"),s.lineDashOffset=l(i.lineDashOffset,0),s.lineJoin=l(i.lineJoin,"miter"),s.lineWidth=n,s.strokeStyle=l(i.strokeStyle,a),s.setLineDash(l(i.lineDash,[])),o.usePointStyle){const a={radius:p*Math.SQRT2/2,pointStyle:i.pointStyle,rotation:i.rotation,borderWidth:n},l=r.xPlus(t,g/2);Ee(s,a,l,e+u,o.pointStyleWidth&&g)}else{const o=e+Math.max((d-p)/2,0),a=r.leftForLtr(t,g),l=wi(i.borderRadius);s.beginPath(),Object.values(l).some((t=>0!==t))?He(s,{x:a,y:o,w:g,h:p,radius:l}):s.rect(a,o,g,p),s.fill(),0!==n&&s.stroke()}s.restore()}(r.x(S),P,y),S=gt(w,S+g+u,x?S+k:this.right,t.rtl),function(t,e,i){Ne(s,i.text,t,e+m/2,h,{strikethrough:i.hidden,textAlign:r.textAlign(i.textAlign)})}(r.x(S),P,y),x)f.x+=k+c;else if("string"!=typeof y.text){const t=h.lineHeight;f.y+=va(y,t)+c}else f.y+=_})),Ti(this.ctx,t.textDirection)}drawTitle(){const t=this.options,e=t.title,i=Si(e.font),s=ki(e.padding);if(!e.display)return;const n=Oi(t.rtl,this.left,this.width),o=this.ctx,a=e.position,r=i.size/2,l=s.top+r;let h,c=this.left,d=this.width;if(this.isHorizontal())d=Math.max(...this.lineWidths),h=this.top+l,c=ft(t.align,c,this.right-d);else{const e=this.columnSizes.reduce(((t,e)=>Math.max(t,e.height)),0);h=l+ft(t.align,this.top,this.bottom-e-t.labels.padding-this._computeTitleHeight())}const u=ft(a,c,c+d);o.textAlign=n.textAlign(ut(a)),o.textBaseline="middle",o.strokeStyle=e.color,o.fillStyle=e.color,o.font=i.string,Ne(o,e.text,u,h,i)}_computeTitleHeight(){const t=this.options.title,e=Si(t.font),i=ki(t.padding);return t.display?e.lineHeight+i.height:0}_getLegendItemAt(t,e){let i,s,n;if(tt(t,this.left,this.right)&&tt(e,this.top,this.bottom))for(n=this.legendHitBoxes,i=0;it.chart.options.color,boxWidth:40,padding:10,generateLabels(t){const e=t.data.datasets,{labels:{usePointStyle:i,pointStyle:s,textAlign:n,color:o,useBorderRadius:a,borderRadius:r}}=t.legend.options;return t._getSortedDatasetMetas().map((t=>{const l=t.controller.getStyle(i?0:void 0),h=ki(l.borderWidth);return{text:e[t.index].label,fillStyle:l.backgroundColor,fontColor:o,hidden:!t.visible,lineCap:l.borderCapStyle,lineDash:l.borderDash,lineDashOffset:l.borderDashOffset,lineJoin:l.borderJoinStyle,lineWidth:(h.width+h.height)/4,strokeStyle:l.borderColor,pointStyle:s||l.pointStyle,rotation:l.rotation,textAlign:n||l.textAlign,borderRadius:a&&(r||l.borderRadius),datasetIndex:t.index}}),this)}},title:{color:t=>t.chart.options.color,display:!1,position:"center",text:""}},descriptors:{_scriptable:t=>!t.startsWith("on"),labels:{_scriptable:t=>!["generateLabels","filter","sort"].includes(t)}}};class wa extends $s{constructor(t){super(),this.chart=t.chart,this.options=t.options,this.ctx=t.ctx,this._padding=void 0,this.top=void 0,this.bottom=void 0,this.left=void 0,this.right=void 0,this.width=void 0,this.height=void 0,this.position=void 0,this.weight=void 0,this.fullSize=void 0}update(t,e){const i=this.options;if(this.left=0,this.top=0,!i.display)return void(this.width=this.height=this.right=this.bottom=0);this.width=this.right=t,this.height=this.bottom=e;const s=n(i.text)?i.text.length:1;this._padding=ki(i.padding);const o=s*Si(i.font).lineHeight+this._padding.height;this.isHorizontal()?this.height=o:this.width=o}isHorizontal(){const t=this.options.position;return"top"===t||"bottom"===t}_drawArgs(t){const{top:e,left:i,bottom:s,right:n,options:o}=this,a=o.align;let r,l,h,c=0;return this.isHorizontal()?(l=ft(a,i,n),h=e+t,r=n-i):("left"===o.position?(l=i+t,h=ft(a,s,e),c=-.5*C):(l=n-t,h=ft(a,e,s),c=.5*C),r=s-e),{titleX:l,titleY:h,maxWidth:r,rotation:c}}draw(){const t=this.ctx,e=this.options;if(!e.display)return;const i=Si(e.font),s=i.lineHeight/2+this._padding.top,{titleX:n,titleY:o,maxWidth:a,rotation:r}=this._drawArgs(s);Ne(t,e.text,0,0,i,{color:e.color,maxWidth:a,rotation:r,textAlign:ut(e.align),textBaseline:"middle",translation:[n,o]})}}var ka={id:"title",_element:wa,start(t,e,i){!function(t,e){const i=new wa({ctx:t.ctx,options:e,chart:t});ls.configure(t,i,e),ls.addBox(t,i),t.titleBlock=i}(t,i)},stop(t){const e=t.titleBlock;ls.removeBox(t,e),delete t.titleBlock},beforeUpdate(t,e,i){const s=t.titleBlock;ls.configure(t,s,i),s.options=i},defaults:{align:"center",display:!1,font:{weight:"bold"},fullSize:!0,padding:10,position:"top",text:"",weight:2e3},defaultRoutes:{color:"color"},descriptors:{_scriptable:!0,_indexable:!1}};const Sa=new WeakMap;var Pa={id:"subtitle",start(t,e,i){const s=new wa({ctx:t.ctx,options:i,chart:t});ls.configure(t,s,i),ls.addBox(t,s),Sa.set(t,s)},stop(t){ls.removeBox(t,Sa.get(t)),Sa.delete(t)},beforeUpdate(t,e,i){const s=Sa.get(t);ls.configure(t,s,i),s.options=i},defaults:{align:"center",display:!1,font:{weight:"normal"},fullSize:!0,padding:0,position:"top",text:"",weight:1500},defaultRoutes:{color:"color"},descriptors:{_scriptable:!0,_indexable:!1}};const Da={average(t){if(!t.length)return!1;let e,i,s=new Set,n=0,o=0;for(e=0,i=t.length;et+e))/s.size,y:n/o}},nearest(t,e){if(!t.length)return!1;let i,s,n,o=e.x,a=e.y,r=Number.POSITIVE_INFINITY;for(i=0,s=t.length;i-1?t.split("\n"):t}function Aa(t,e){const{element:i,datasetIndex:s,index:n}=e,o=t.getDatasetMeta(s).controller,{label:a,value:r}=o.getLabelAndValue(n);return{chart:t,label:a,parsed:o.getParsed(n),raw:t.data.datasets[s].data[n],formattedValue:r,dataset:o.getDataset(),dataIndex:n,datasetIndex:s,element:i}}function Ta(t,e){const i=t.chart.ctx,{body:s,footer:n,title:o}=t,{boxWidth:a,boxHeight:r}=e,l=Si(e.bodyFont),h=Si(e.titleFont),c=Si(e.footerFont),d=o.length,f=n.length,g=s.length,p=ki(e.padding);let m=p.height,x=0,b=s.reduce(((t,e)=>t+e.before.length+e.lines.length+e.after.length),0);if(b+=t.beforeBody.length+t.afterBody.length,d&&(m+=d*h.lineHeight+(d-1)*e.titleSpacing+e.titleMarginBottom),b){m+=g*(e.displayColors?Math.max(r,l.lineHeight):l.lineHeight)+(b-g)*l.lineHeight+(b-1)*e.bodySpacing}f&&(m+=e.footerMarginTop+f*c.lineHeight+(f-1)*e.footerSpacing);let _=0;const y=function(t){x=Math.max(x,i.measureText(t).width+_)};return i.save(),i.font=h.string,u(t.title,y),i.font=l.string,u(t.beforeBody.concat(t.afterBody),y),_=e.displayColors?a+2+e.boxPadding:0,u(s,(t=>{u(t.before,y),u(t.lines,y),u(t.after,y)})),_=0,i.font=c.string,u(t.footer,y),i.restore(),x+=p.width,{width:x,height:m}}function La(t,e,i,s){const{x:n,width:o}=i,{width:a,chartArea:{left:r,right:l}}=t;let h="center";return"center"===s?h=n<=(r+l)/2?"left":"right":n<=o/2?h="left":n>=a-o/2&&(h="right"),function(t,e,i,s){const{x:n,width:o}=s,a=i.caretSize+i.caretPadding;return"left"===t&&n+o+a>e.width||"right"===t&&n-o-a<0||void 0}(h,t,e,i)&&(h="center"),h}function Ea(t,e,i){const s=i.yAlign||e.yAlign||function(t,e){const{y:i,height:s}=e;return it.height-s/2?"bottom":"center"}(t,i);return{xAlign:i.xAlign||e.xAlign||La(t,e,i,s),yAlign:s}}function Ra(t,e,i,s){const{caretSize:n,caretPadding:o,cornerRadius:a}=t,{xAlign:r,yAlign:l}=i,h=n+o,{topLeft:c,topRight:d,bottomLeft:u,bottomRight:f}=wi(a);let g=function(t,e){let{x:i,width:s}=t;return"right"===e?i-=s:"center"===e&&(i-=s/2),i}(e,r);const p=function(t,e,i){let{y:s,height:n}=t;return"top"===e?s+=i:s-="bottom"===e?n+i:n/2,s}(e,l,h);return"center"===l?"left"===r?g+=h:"right"===r&&(g-=h):"left"===r?g-=Math.max(c,u)+n:"right"===r&&(g+=Math.max(d,f)+n),{x:Z(g,0,s.width-e.width),y:Z(p,0,s.height-e.height)}}function Ia(t,e,i){const s=ki(i.padding);return"center"===e?t.x+t.width/2:"right"===e?t.x+t.width-s.right:t.x+s.left}function za(t){return Ca([],Oa(t))}function Fa(t,e){const i=e&&e.dataset&&e.dataset.tooltip&&e.dataset.tooltip.callbacks;return i?t.override(i):t}const Va={beforeTitle:e,title(t){if(t.length>0){const e=t[0],i=e.chart.data.labels,s=i?i.length:0;if(this&&this.options&&"dataset"===this.options.mode)return e.dataset.label||"";if(e.label)return e.label;if(s>0&&e.dataIndex{const e={before:[],lines:[],after:[]},n=Fa(i,t);Ca(e.before,Oa(Ba(n,"beforeLabel",this,t))),Ca(e.lines,Ba(n,"label",this,t)),Ca(e.after,Oa(Ba(n,"afterLabel",this,t))),s.push(e)})),s}getAfterBody(t,e){return za(Ba(e.callbacks,"afterBody",this,t))}getFooter(t,e){const{callbacks:i}=e,s=Ba(i,"beforeFooter",this,t),n=Ba(i,"footer",this,t),o=Ba(i,"afterFooter",this,t);let a=[];return a=Ca(a,Oa(s)),a=Ca(a,Oa(n)),a=Ca(a,Oa(o)),a}_createItems(t){const e=this._active,i=this.chart.data,s=[],n=[],o=[];let a,r,l=[];for(a=0,r=e.length;at.filter(e,s,n,i)))),t.itemSort&&(l=l.sort(((e,s)=>t.itemSort(e,s,i)))),u(l,(e=>{const i=Fa(t.callbacks,e);s.push(Ba(i,"labelColor",this,e)),n.push(Ba(i,"labelPointStyle",this,e)),o.push(Ba(i,"labelTextColor",this,e))})),this.labelColors=s,this.labelPointStyles=n,this.labelTextColors=o,this.dataPoints=l,l}update(t,e){const i=this.options.setContext(this.getContext()),s=this._active;let n,o=[];if(s.length){const t=Da[i.position].call(this,s,this._eventPosition);o=this._createItems(i),this.title=this.getTitle(o,i),this.beforeBody=this.getBeforeBody(o,i),this.body=this.getBody(o,i),this.afterBody=this.getAfterBody(o,i),this.footer=this.getFooter(o,i);const e=this._size=Ta(this,i),a=Object.assign({},t,e),r=Ea(this.chart,i,a),l=Ra(i,a,r,this.chart);this.xAlign=r.xAlign,this.yAlign=r.yAlign,n={opacity:1,x:l.x,y:l.y,width:e.width,height:e.height,caretX:t.x,caretY:t.y}}else 0!==this.opacity&&(n={opacity:0});this._tooltipItems=o,this.$context=void 0,n&&this._resolveAnimations().update(this,n),t&&i.external&&i.external.call(this,{chart:this.chart,tooltip:this,replay:e})}drawCaret(t,e,i,s){const n=this.getCaretPosition(t,i,s);e.lineTo(n.x1,n.y1),e.lineTo(n.x2,n.y2),e.lineTo(n.x3,n.y3)}getCaretPosition(t,e,i){const{xAlign:s,yAlign:n}=this,{caretSize:o,cornerRadius:a}=i,{topLeft:r,topRight:l,bottomLeft:h,bottomRight:c}=wi(a),{x:d,y:u}=t,{width:f,height:g}=e;let p,m,x,b,_,y;return"center"===n?(_=u+g/2,"left"===s?(p=d,m=p-o,b=_+o,y=_-o):(p=d+f,m=p+o,b=_-o,y=_+o),x=p):(m="left"===s?d+Math.max(r,h)+o:"right"===s?d+f-Math.max(l,c)-o:this.caretX,"top"===n?(b=u,_=b-o,p=m-o,x=m+o):(b=u+g,_=b+o,p=m+o,x=m-o),y=b),{x1:p,x2:m,x3:x,y1:b,y2:_,y3:y}}drawTitle(t,e,i){const s=this.title,n=s.length;let o,a,r;if(n){const l=Oi(i.rtl,this.x,this.width);for(t.x=Ia(this,i.titleAlign,i),e.textAlign=l.textAlign(i.titleAlign),e.textBaseline="middle",o=Si(i.titleFont),a=i.titleSpacing,e.fillStyle=i.titleColor,e.font=o.string,r=0;r0!==t))?(t.beginPath(),t.fillStyle=n.multiKeyBackground,He(t,{x:e,y:g,w:h,h:l,radius:r}),t.fill(),t.stroke(),t.fillStyle=a.backgroundColor,t.beginPath(),He(t,{x:i,y:g+1,w:h-2,h:l-2,radius:r}),t.fill()):(t.fillStyle=n.multiKeyBackground,t.fillRect(e,g,h,l),t.strokeRect(e,g,h,l),t.fillStyle=a.backgroundColor,t.fillRect(i,g+1,h-2,l-2))}t.fillStyle=this.labelTextColors[i]}drawBody(t,e,i){const{body:s}=this,{bodySpacing:n,bodyAlign:o,displayColors:a,boxHeight:r,boxWidth:l,boxPadding:h}=i,c=Si(i.bodyFont);let d=c.lineHeight,f=0;const g=Oi(i.rtl,this.x,this.width),p=function(i){e.fillText(i,g.x(t.x+f),t.y+d/2),t.y+=d+n},m=g.textAlign(o);let x,b,_,y,v,M,w;for(e.textAlign=o,e.textBaseline="middle",e.font=c.string,t.x=Ia(this,m,i),e.fillStyle=i.bodyColor,u(this.beforeBody,p),f=a&&"right"!==m?"center"===o?l/2+h:l+2+h:0,y=0,M=s.length;y0&&e.stroke()}_updateAnimationTarget(t){const e=this.chart,i=this.$animations,s=i&&i.x,n=i&&i.y;if(s||n){const i=Da[t.position].call(this,this._active,this._eventPosition);if(!i)return;const o=this._size=Ta(this,t),a=Object.assign({},i,this._size),r=Ea(e,t,a),l=Ra(t,a,r,e);s._to===l.x&&n._to===l.y||(this.xAlign=r.xAlign,this.yAlign=r.yAlign,this.width=o.width,this.height=o.height,this.caretX=i.x,this.caretY=i.y,this._resolveAnimations().update(this,l))}}_willRender(){return!!this.opacity}draw(t){const e=this.options.setContext(this.getContext());let i=this.opacity;if(!i)return;this._updateAnimationTarget(e);const s={width:this.width,height:this.height},n={x:this.x,y:this.y};i=Math.abs(i)<.001?0:i;const o=ki(e.padding),a=this.title.length||this.beforeBody.length||this.body.length||this.afterBody.length||this.footer.length;e.enabled&&a&&(t.save(),t.globalAlpha=i,this.drawBackground(n,t,s,e),Ai(t,e.textDirection),n.y+=o.top,this.drawTitle(n,t,e),this.drawBody(n,t,e),this.drawFooter(n,t,e),Ti(t,e.textDirection),t.restore())}getActiveElements(){return this._active||[]}setActiveElements(t,e){const i=this._active,s=t.map((({datasetIndex:t,index:e})=>{const i=this.chart.getDatasetMeta(t);if(!i)throw new Error("Cannot find a dataset at index "+t);return{datasetIndex:t,element:i.data[e],index:e}})),n=!f(i,s),o=this._positionChanged(s,e);(n||o)&&(this._active=s,this._eventPosition=e,this._ignoreReplayEvents=!0,this.update(!0))}handleEvent(t,e,i=!0){if(e&&this._ignoreReplayEvents)return!1;this._ignoreReplayEvents=!1;const s=this.options,n=this._active||[],o=this._getActiveElements(t,n,e,i),a=this._positionChanged(o,t),r=e||!f(o,n)||a;return r&&(this._active=o,(s.enabled||s.external)&&(this._eventPosition={x:t.x,y:t.y},this.update(!0,e))),r}_getActiveElements(t,e,i,s){const n=this.options;if("mouseout"===t.type)return[];if(!s)return e.filter((t=>this.chart.data.datasets[t.datasetIndex]&&void 0!==this.chart.getDatasetMeta(t.datasetIndex).controller.getParsed(t.index)));const o=this.chart.getElementsAtEventForMode(t,n.mode,n,i);return n.reverse&&o.reverse(),o}_positionChanged(t,e){const{caretX:i,caretY:s,options:n}=this,o=Da[n.position].call(this,t,e);return!1!==o&&(i!==o.x||s!==o.y)}}var Na={id:"tooltip",_element:Wa,positioners:Da,afterInit(t,e,i){i&&(t.tooltip=new Wa({chart:t,options:i}))},beforeUpdate(t,e,i){t.tooltip&&t.tooltip.initialize(i)},reset(t,e,i){t.tooltip&&t.tooltip.initialize(i)},afterDraw(t){const e=t.tooltip;if(e&&e._willRender()){const i={tooltip:e};if(!1===t.notifyPlugins("beforeTooltipDraw",{...i,cancelable:!0}))return;e.draw(t.ctx),t.notifyPlugins("afterTooltipDraw",i)}},afterEvent(t,e){if(t.tooltip){const i=e.replay;t.tooltip.handleEvent(e.event,i,e.inChartArea)&&(e.changed=!0)}},defaults:{enabled:!0,external:null,position:"average",backgroundColor:"rgba(0,0,0,0.8)",titleColor:"#fff",titleFont:{weight:"bold"},titleSpacing:2,titleMarginBottom:6,titleAlign:"left",bodyColor:"#fff",bodySpacing:2,bodyFont:{},bodyAlign:"left",footerColor:"#fff",footerSpacing:2,footerMarginTop:6,footerFont:{weight:"bold"},footerAlign:"left",padding:6,caretPadding:2,caretSize:5,cornerRadius:6,boxHeight:(t,e)=>e.bodyFont.size,boxWidth:(t,e)=>e.bodyFont.size,multiKeyBackground:"#fff",displayColors:!0,boxPadding:0,borderColor:"rgba(0,0,0,0)",borderWidth:0,animation:{duration:400,easing:"easeOutQuart"},animations:{numbers:{type:"number",properties:["x","y","width","height","caretX","caretY"]},opacity:{easing:"linear",duration:200}},callbacks:Va},defaultRoutes:{bodyFont:"font",footerFont:"font",titleFont:"font"},descriptors:{_scriptable:t=>"filter"!==t&&"itemSort"!==t&&"external"!==t,_indexable:!1,callbacks:{_scriptable:!1,_indexable:!1},animation:{_fallback:!1},animations:{_fallback:"animation"}},additionalOptionScopes:["interaction"]};return Tn.register(Un,$o,go,t),Tn.helpers={...Hi},Tn._adapters=In,Tn.Animation=As,Tn.Animations=Ts,Tn.animator=bt,Tn.controllers=nn.controllers.items,Tn.DatasetController=js,Tn.Element=$s,Tn.elements=go,Tn.Interaction=Ki,Tn.layouts=ls,Tn.platforms=Ds,Tn.Scale=tn,Tn.Ticks=ae,Object.assign(Tn,Un,$o,go,t,Ds),Tn.Chart=Tn,"undefined"!=typeof window&&(window.Chart=Tn),Tn})); +//# sourceMappingURL=chart.umd.min.js.map diff --git a/skills/xtb-wealthfolio-export/scripts/export-wealthfolio.sh b/skills/xtb-wealthfolio-export/scripts/export-wealthfolio.sh new file mode 100755 index 0000000..f622179 --- /dev/null +++ b/skills/xtb-wealthfolio-export/scripts/export-wealthfolio.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +if [[ -n "${PYTHON:-}" ]]; then + PYTHON_BIN="$PYTHON" +elif [[ -x ".venv/bin/python" ]]; then + PYTHON_BIN=".venv/bin/python" +else + PYTHON_BIN="python3" +fi + +exec "$PYTHON_BIN" "$SCRIPT_DIR/exporter.py" "$@" diff --git a/skills/xtb-wealthfolio-export/scripts/exporter.py b/skills/xtb-wealthfolio-export/scripts/exporter.py new file mode 100644 index 0000000..440eacb --- /dev/null +++ b/skills/xtb-wealthfolio-export/scripts/exporter.py @@ -0,0 +1,219 @@ +"""XTB report → Wealthfolio CSV exporter. + +Wealthfolio expects a CSV with this header: + date,symbol,quantity,activityType,unitPrice,currency,fee,amount + +Activity-type mapping from an XTB "Cash Operations" sheet: + Stock purchase (OPEN BUY ...) -> BUY (qty=shares, unitPrice=price) + Stock sale (CLOSE SELL ...) -> SELL (qty=shares, unitPrice=price) + Stock sale (OPEN SELL ...) -> SELL (short open, qty=shares) + Deposit -> DEPOSIT + Withdrawal -> WITHDRAWAL + Dividend -> DIVIDEND + Dividend tax -> TAX + Free funds interest -> INTEREST + Currency conversion -> FEE + +Cash activities (DEPOSIT/WITHDRAWAL/DIVIDEND/INTEREST/TAX/FEE) carry their +value in `amount` with `quantity=1`, `unitPrice=1`; `symbol` is `$CASH-` +for pure-cash rows and the real ticker for dividends. The `fee` column is only +used for inline BUY/SELL commissions; trades leave `amount` blank (it is +auto-calculated as quantity * unitPrice by Wealthfolio). + +Run: + python exporter.py # writes results/_wealthfolio.csv + python exporter.py -o my.csv EUR_xxx.xlsx +""" +import argparse +import csv +from pathlib import Path + +import pandas as pd + +import main +from main import ( + CONVERSION_RE, + DEPOSIT_RE, + DIVIDEND_RE, + DIVIDEND_TAX_RE, + INTEREST_RE, + PRICE_RE, + TRADE_COMMENT_RE, + WITHDRAW_RE, + find_column, + parse_numeric, +) + +FIELDS = ["date", "symbol", "quantity", "activityType", "unitPrice", "currency", "fee", "amount"] + +# XTB trade comment captures both the action (OPEN/CLOSE) and side (BUY/SELL). +SHORT_OPEN_RE = __import__("re").compile(r"OPEN\s+SELL", __import__("re").IGNORECASE) +QTY_RE = __import__("re").compile(r"(?:OPEN|CLOSE)\s+(?:BUY|SELL)\s+([\d./]+)", __import__("re").IGNORECASE) + + +def _trade_quantity(comment: str, value: float, price: float) -> float: + """Derive executed shares from an XTB trade comment. + + XTB writes split fills as "N/M @ price" where N is this fill's share count + and M the parent order size (e.g. "1/100" = 1 share). Prefer the numerator; + fall back to cash / price. + """ + m = QTY_RE.search(comment) + if m: + token = m.group(1) + if "/" in token: + try: + numerator = float(token.split("/", 1)[0]) + if numerator > 0: + return numerator + except ValueError: + pass + else: + try: + return float(token.replace(",", ".")) + except ValueError: + pass + return round(abs(value) / price, 6) if price > 0 else 0.0 + + +def classify(type_val: str, comment: str) -> str | None: + text = f"{type_val} {comment}".lower() + if DIVIDEND_TAX_RE.search(text): + return "TAX" + if DIVIDEND_RE.search(text): + return "DIVIDEND" + if INTEREST_RE.search(text): + return "INTEREST" + if CONVERSION_RE.search(text): + return "FEE" + if WITHDRAW_RE.search(text): + return "WITHDRAWAL" + if DEPOSIT_RE.search(text): + return "DEPOSIT" + if TRADE_COMMENT_RE.search(comment): + lowered_comment = comment.lower() + lowered_type = type_val.lower() + is_sell = ( + "close sell" in lowered_comment + or SHORT_OPEN_RE.search(comment) + or ("close buy" in lowered_comment and "sell" in lowered_type) + ) + return "SELL" if is_sell else "BUY" + return None + + +def _fmt_date(val) -> str: + dt = pd.to_datetime(val, errors="coerce") + if pd.isna(dt): + return "" + return dt.strftime("%Y-%m-%d") + + +def build_rows( + cash_ops: pd.DataFrame, currency: str +) -> list[dict[str, str | float]]: + type_col = find_column(cash_ops, ["type", "operation"], required=False) + ticker_col = find_column( + cash_ops, ["ticker", "symbol", "instrument", "market"], required=False + ) + amount_col = find_column( + cash_ops, ["amount", "value", "net_amount", "cash", "change", "payment"], + required=False, + ) + date_col = find_column( + cash_ops, ["time", "date", "operation_date", "booking_date", "transaction_date"], + required=False, + ) + comment_col = find_column(cash_ops, ["comment", "description", "details"], required=False) + + if not (type_col and amount_col): + return [] + + rows: list[dict[str, str | float]] = [] + for _, row in cash_ops.iterrows(): + type_val = str(row.get(type_col, "")).strip() + comment = str(row.get(comment_col, "")) if comment_col else "" + activity = classify(type_val, comment) + if activity is None: + continue + + amount = float(parse_numeric(pd.Series([row[amount_col]])).iloc[0]) + date = _fmt_date(row.get(date_col)) if date_col else "" + cash_sym = f"$CASH-{currency}" + ticker = str(row[ticker_col]).strip() if ticker_col and pd.notna(row.get(ticker_col)) else "" + + if activity in ("BUY", "SELL"): + price = 0.0 + m = PRICE_RE.search(comment) + if m: + price = float(parse_numeric(pd.Series([m.group(1)])).iloc[0]) + quantity = _trade_quantity(comment, amount, price) + rows.append({ + "date": date, "symbol": ticker or cash_sym, "quantity": quantity, + "activityType": activity, "unitPrice": round(price, 6), + "currency": currency, "fee": 0.0, "amount": "", + }) + elif activity == "DIVIDEND": + rows.append({ + "date": date, "symbol": ticker or cash_sym, "quantity": 1.0, + "activityType": activity, "unitPrice": 1.0, + "currency": currency, "fee": 0.0, "amount": round(abs(amount), 6), + }) + elif activity in ("DEPOSIT", "WITHDRAWAL", "INTEREST", "TAX", "FEE"): + rows.append({ + "date": date, "symbol": cash_sym, "quantity": 1.0, + "activityType": activity, "unitPrice": 1.0, + "currency": currency, "fee": 0.0, "amount": round(abs(amount), 6), + }) + return rows + + +def export( + xlsx_path: Path | str | None = None, + output_path: Path | str | None = None, +) -> Path: + main.REPORT_FILE = main.resolve_report_file(xlsx_path) + currency = main.detect_currency() + _, cash_ops, _, _ = main.load_data() + rows = build_rows(cash_ops, currency) + + if output_path: + out = Path(output_path) + else: + stem = main.REPORT_FILE.stem if main.REPORT_FILE else "portfolio" + out = main.RESULTS_DIR / f"{stem}_wealthfolio.csv" + out.parent.mkdir(parents=True, exist_ok=True) + with out.open("w", newline="", encoding="utf-8") as f: + writer = csv.DictWriter(f, fieldnames=FIELDS) + writer.writeheader() + for r in rows: + amt = r["amount"] + writer.writerow({ + "date": r["date"], + "symbol": r["symbol"], + "quantity": f"{r['quantity']:.6f}".rstrip("0").rstrip("."), + "activityType": r["activityType"], + "unitPrice": f"{r['unitPrice']:.6f}".rstrip("0").rstrip("."), + "currency": r["currency"], + "fee": f"{r['fee']:.2f}", + "amount": "" if amt == "" else f"{amt:.6f}".rstrip("0").rstrip("."), + }) + return out + + +def main_cli() -> None: + p = argparse.ArgumentParser(description="Export XTB xlsx to Wealthfolio CSV.") + p.add_argument("input", nargs="?", default=None, + help="Path to the XTB .xlsx report (auto-detected if omitted)") + p.add_argument("-o", "--output", default=None, + help="Output CSV path (default: results/_wealthfolio.csv)") + args = p.parse_args() + try: + out = export(args.input, args.output) + except (FileNotFoundError, ValueError) as exc: + p.error(str(exc)) + print(f"Wrote {out.resolve()} ({out.stat().st_size} bytes)") + + +if __name__ == "__main__": + main_cli() diff --git a/skills/xtb-wealthfolio-export/scripts/html_charts.py b/skills/xtb-wealthfolio-export/scripts/html_charts.py new file mode 100644 index 0000000..da953be --- /dev/null +++ b/skills/xtb-wealthfolio-export/scripts/html_charts.py @@ -0,0 +1,365 @@ +"""Interactive Chart.js charts for the self-contained HTML report. + +This module is the only place that knows about Chart.js. It reads the vendored +UMD bundle from assets/ and builds Chart.js config dicts (pure functions) plus +an HTML fragment that inlines the bundle, the data (JSON), and a render script. +""" +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import pandas as pd + +ASSETS_DIR = Path(__file__).resolve().parent / "assets" +CHARTJS_PATH = ASSETS_DIR / "chartjs.umd.min.js" +CHARTJS_VERSION_PATH = ASSETS_DIR / "chartjs.VERSION" + + +def load_chartjs_inline() -> str: + """Return the minified Chart.js UMD source, vendored under assets/.""" + if not CHARTJS_PATH.exists(): + raise FileNotFoundError( + f"Chart.js bundle not found at {CHARTJS_PATH}. " + "Re-vendor it (see assets/chartjs.VERSION)." + ) + return CHARTJS_PATH.read_text(encoding="utf-8") + + +def _iso(value: Any) -> str: + if hasattr(value, "isoformat"): + return value.isoformat()[:10] + return str(value) + + +def _round_series(values) -> list[float]: + return [round(float(v), 2) for v in values] + + +def evolution_chart_config(evolution_df: pd.DataFrame, currency: str) -> dict | None: + """Build a Chart.js line-chart config for cost vs value over time. + + Returns None when there is no evolution data (caller omits the card). + """ + if evolution_df is None or evolution_df.empty: + return None + labels = [_iso(d) for d in evolution_df.index] + return { + "type": "line", + "data": { + "labels": labels, + "datasets": [ + { + "label": "Cost (invested)", + "data": _round_series(evolution_df["cost"]), + "borderColor": "#6b7280", + "backgroundColor": "#6b7280", + "borderWidth": 2, + "fill": False, + "pointRadius": 0, + "tension": 0.1, + }, + { + "label": "Value (realized + unrealized)", + "data": _round_series(evolution_df["total_value"]), + "borderColor": "#2c5282", + "backgroundColor": "#2c5282", + "borderWidth": 2, + "fill": False, + "pointRadius": 0, + "tension": 0.1, + }, + { + "label": "Cumulative realized P/L", + "data": _round_series(evolution_df["realized_pl"]), + "borderColor": "#f39c12", + "backgroundColor": "#f39c12", + "borderWidth": 1.5, + "borderDash": [6, 4], + "fill": False, + "pointRadius": 0, + "tension": 0.1, + }, + ], + }, + "options": { + "responsive": True, + "maintainAspectRatio": False, + "interaction": {"mode": "index", "intersect": False}, + "plugins": { + "legend": {"position": "bottom", + "labels": {"boxWidth": 12, "font": {"size": 12}}}, + }, + "scales": { + "x": {"ticks": {"maxRotation": 45, "autoSkip": True}}, + "y": {"beginAtZero": False}, + }, + }, + } + + +DOUGHNUT_COLORS = [ + "#2c5282", "#1f9d55", "#f39c12", "#3498db", "#9b59b6", + "#e67e22", "#16a085", "#34495e", "#e3342f", "#7f8c8d", +] + + +def review_charts_config( + holdings: pd.DataFrame, + flows: dict[str, float], + income_by_period: pd.Series, + currency: str, +) -> dict: + """Build Chart.js configs for the three review charts. + + Returns {'holdings': cfg|None, 'cashflows': cfg|None, 'income': cfg|None}. + Each is None when its source data is empty. + """ + holdings_cfg = _holdings_config(holdings) + cashflows_cfg = _cashflows_config(flows) + income_cfg = _income_config(income_by_period) + return {"holdings": holdings_cfg, "cashflows": cashflows_cfg, "income": income_cfg} + + +def _holdings_config(holdings: pd.DataFrame) -> dict | None: + if holdings is None or holdings.empty: + return None + alloc_col = "market_value" if "market_value" in holdings.columns else "cost_basis" + filtered = holdings.loc[holdings[alloc_col] > 0] + if filtered.empty: + return None + values = _round_series(filtered[alloc_col]) + return { + "type": "doughnut", + "data": { + "labels": [str(t) for t in filtered["ticker"].tolist()], + "datasets": [{ + "data": values, + "backgroundColor": [DOUGHNUT_COLORS[i % len(DOUGHNUT_COLORS)] + for i in range(len(values))], + }], + }, + "options": { + "responsive": True, + "maintainAspectRatio": False, + "plugins": {"legend": {"position": "right", + "labels": {"boxWidth": 12, "font": {"size": 11}}}}, + }, + } + + +def _cashflows_config(flows: dict[str, float]) -> dict | None: + if not flows: + return None + items = { + "Deposits": float(flows["deposits"]), + "Withdrawals": -float(flows["withdrawals"]), + "Interest": float(flows["interest"]), + "Dividends": float(flows["dividends"]), + "Div.tax": float(flows["dividend_tax"]), + "Invested": -float(flows["invested"]), + "Proceeds": float(flows["proceeds"]), + "FX fees": float(flows["conversion_fees"]), + "Fees": -float(flows["fees"]), + } + items = {k: v for k, v in items.items() if abs(v) > 1e-9} + if not items: + return None + labels = list(items.keys()) + values = _round_series(items.values()) + colors = ["#2ecc71" if v >= 0 else "#e74c3c" for v in items.values()] + return { + "type": "bar", + "data": {"labels": labels, + "datasets": [{"label": "Cash flows", "data": values, + "backgroundColor": colors}]}, + "options": { + "responsive": True, + "maintainAspectRatio": False, + "plugins": {"legend": {"display": False}}, + "scales": {"x": {"ticks": {"maxRotation": 30, "autoSkip": False}}, + "y": {"beginAtZero": True}}, + }, + } + + +def _income_config(income_by_period: pd.Series) -> dict | None: + if income_by_period is None or income_by_period.empty: + return None + return { + "type": "bar", + "data": { + "labels": [str(i) for i in income_by_period.index], + "datasets": [{"label": "Income", + "data": _round_series(income_by_period.tolist()), + "backgroundColor": "#3498db"}], + }, + "options": { + "responsive": True, + "maintainAspectRatio": False, + "plugins": {"legend": {"display": False}}, + "scales": {"x": {"ticks": {"maxRotation": 45, "autoSkip": False}}, + "y": {"beginAtZero": True}}, + }, + } + + +_RENDER_SCRIPT = r""" +function _bootPortfolioCharts() { + var block = document.getElementById('chart-data'); + if (!block) { return; } + var data = JSON.parse(block.textContent); + var ccy = data.currency || 'EUR'; + function fmt(v) { + try { return new Intl.NumberFormat('en-US', {style: 'currency', currency: ccy}).format(v); } + catch (e) { return String(v); } + } + function applyTooltip(cfg) { + if (!cfg || !cfg.options) { return; } + cfg.options.plugins = cfg.options.plugins || {}; + cfg.options.plugins.tooltip = cfg.options.plugins.tooltip || {}; + cfg.options.plugins.tooltip.callbacks = cfg.options.plugins.tooltip.callbacks || {}; + if (cfg.type === 'doughnut' || cfg.type === 'pie') { + cfg.options.plugins.tooltip.callbacks.label = function (ctx) { + var total = (ctx.dataset && ctx.dataset.data) + ? ctx.dataset.data.reduce(function (a, b) { return a + (typeof b === 'number' ? b : 0); }, 0) + : 0; + var v = (typeof ctx.parsed === 'number') ? ctx.parsed : ctx.raw; + var pct = total > 0 ? (v / total * 100) : 0; + return (ctx.label ? ctx.label + ': ' : '') + fmt(v) + ' (' + pct.toFixed(1) + '%)'; + }; + return; + } + cfg.options.plugins.tooltip.callbacks.label = function (ctx) { + var label = (ctx.dataset && ctx.dataset.label) ? ctx.dataset.label : ''; + var v = (ctx.parsed && Object.prototype.hasOwnProperty.call(ctx.parsed, 'y')) + ? ctx.parsed.y : (typeof ctx.parsed === 'number' ? ctx.parsed : ctx.raw); + return label ? (label + ': ' + fmt(v)) : fmt(v); + }; + } + function mount(id, cfg, plugins) { + if (!cfg) { return; } + var el = document.getElementById(id); + if (!el) { return; } + applyTooltip(cfg); + var config = {type: cfg.type, data: cfg.data, options: cfg.options}; + if (plugins && plugins.length) { config.plugins = plugins; } + new Chart(el.getContext('2d'), config); + } + var gainLossPlugin = { + id: 'gainLoss', + beforeDatasetsDraw: function (chart) { + var ds = chart.data.datasets; + if (ds.length < 2) { return; } + var meta0 = chart.getDatasetMeta(0); + var meta1 = chart.getDatasetMeta(1); + var cost = ds[0].data; + var value = ds[1].data; + if (!meta0 || !meta1 || !meta0.data || !meta1.data) { return; } + var ctx = chart.ctx; + ctx.save(); + for (var i = 0; i < value.length - 1; i++) { + var a0 = meta0.data[i], a1 = meta0.data[i + 1]; + var b0 = meta1.data[i], b1 = meta1.data[i + 1]; + if (!a0 || !a1 || !b0 || !b1) { continue; } + var gain = (value[i] >= cost[i] && value[i + 1] >= cost[i + 1]); + ctx.beginPath(); + ctx.moveTo(a0.x, a0.y); ctx.lineTo(a1.x, a1.y); + ctx.lineTo(b1.x, b1.y); ctx.lineTo(b0.x, b0.y); + ctx.closePath(); + ctx.fillStyle = gain ? 'rgba(31,157,85,0.25)' : 'rgba(227,52,47,0.25)'; + ctx.fill(); + } + ctx.restore(); + } + }; + mount('evolution-chart', data.evolution, [gainLossPlugin]); + mount('holdings-chart', data.holdings); + mount('cashflows-chart', data.cashflows); + mount('income-chart', data.income); +} +if (document.readyState !== 'loading') { _bootPortfolioCharts(); } +else { document.addEventListener('DOMContentLoaded', _bootPortfolioCharts); } +""" + + +def render_charts_block( + evolution_cfg: dict | None, review_cfg: dict, currency: str +) -> str: + """Return the HTML fragment: canvases + inlined Chart.js + JSON + render script. + + Returns "" when there is nothing to render. + """ + holdings_cfg = review_cfg.get("holdings") if review_cfg else None + cashflows_cfg = review_cfg.get("cashflows") if review_cfg else None + income_cfg = review_cfg.get("income") if review_cfg else None + + if evolution_cfg is None and not any([holdings_cfg, cashflows_cfg, income_cfg]): + return "" + + parts: list[str] = [] + + if evolution_cfg is not None: + parts.append( + "
\n" + "

Portfolio Evolution — Cost vs Value

\n" + "
" + "
\n" + "
" + ) + + grid_cells = [] + if holdings_cfg is not None: + grid_cells.append( + "

Holdings Allocation

" + "
" + "
" + ) + else: + grid_cells.append("

Holdings Allocation

" + "

No open positions.

") + if cashflows_cfg is not None: + grid_cells.append( + "

Cash Flows

" + "
" + "
" + ) + else: + grid_cells.append("

Cash Flows

" + "

No cash flows.

") + # Income is optional: the income cell is omitted entirely when there is no + # income data, unlike holdings/cashflows which always render a cell with a + # muted fallback. + if income_cfg is not None: + grid_cells.append( + "

Income Over Time

" + "
" + "
" + ) + charts_id_attr = " id='charts'" if evolution_cfg is None else "" + parts.append( + f"
\n" + "

Charts

\n" + "
\n " + + "\n ".join(grid_cells) + "\n
\n" + "
" + ) + + payload = { + "currency": currency, + "evolution": evolution_cfg, + "holdings": holdings_cfg, + "cashflows": cashflows_cfg, + "income": income_cfg, + } + # Escape < and > so the JSON is always safe to inline inside a ". + data_json = json.dumps(payload).replace("<", "\\u003c").replace(">", "\\u003e") + + parts.append( + "\n" + "\n" + "" + ) + return "\n".join(parts) diff --git a/skills/xtb-wealthfolio-export/scripts/main.py b/skills/xtb-wealthfolio-export/scripts/main.py new file mode 100644 index 0000000..ba4699a --- /dev/null +++ b/skills/xtb-wealthfolio-export/scripts/main.py @@ -0,0 +1,2185 @@ +import argparse +import contextlib +import io +import re +from dataclasses import dataclass, field +from datetime import datetime, date, timedelta +from html import escape +from pathlib import Path + +import pandas as pd + +import html_charts + +REPORT_FILE: Path | None = None # resolved per run via resolve_report_file() +POSITIONS_SHEET = "Closed Positions" +OPEN_POSITIONS_SHEET = "Open Positions" +CASH_SHEET = "Cash Operations" +HEADER_ROW = 4 +RESULTS_DIR = Path("results") + +# XTB ticker codes that don't resolve on Yahoo → verified same-fund Yahoo symbols. +# Only add mappings confirmed to be the SAME fund (same ISIN/share class), never a +# proxy from a different fund (would produce wrong absolute prices). +# MEUD.FR = Amundi Core STOXX Europe 600 UCITS ETF (Euronext Paris: .FR / .PA) +SYMBOL_ALIASES = { + "MEUD.FR": "MEUD.PA", +} + +# Tickers intentionally left at cost (no trusted live price). `reason` is surfaced +# in the report so the decision is documented. A same-ISIN Yahoo symbol that +# *diverges* from the broker is NOT a valid price source — it's a different share +# class and would distort the valuation, so we hold at cost instead. +COST_FALLBACK_NOTES = { + "SXXPIEX.DE": ( + "no trusted live price; the same-ISIN Yahoo symbol EXSA.DE diverges from " + "the broker (different share class), so held at cost" + ), +} + +# XTB "Type" values that represent trading activity (not cash transfers). +TRADE_TYPE_RE = re.compile( + r"stock\s*(purchase|sale|buy|sell)|\bopen\b|\bclose\b", + re.IGNORECASE, +) +# XTB comment: "OPEN BUY 6 @ 301.50", "CLOSE SELL 2 @ 14.31", ... +TRADE_COMMENT_RE = re.compile(r"(OPEN|CLOSE)\s+(BUY|SELL)\b", re.IGNORECASE) +PRICE_RE = re.compile(r"@\s*([\d.,]+)") +QTY_RE = re.compile(r"(?:OPEN|CLOSE)\s+(?:BUY|SELL)\s+([\d./]+)", re.IGNORECASE) +DIVIDEND_RE = re.compile(r"\bdividend|dywidend|dividende\b", re.IGNORECASE) +DIVIDEND_TAX_RE = re.compile(r"dividend\s*tax|tax.*dividend|withholding", re.IGNORECASE) +INTEREST_RE = re.compile(r"interest|free.?funds", re.IGNORECASE) +DEPOSIT_RE = re.compile(r"deposit|top.?up|deposit.?funds", re.IGNORECASE) +WITHDRAW_RE = re.compile(r"withdraw|withdrawal|payout", re.IGNORECASE) +CONVERSION_RE = re.compile(r"currency\s*conversion|conversion\s*fee|fx", re.IGNORECASE) + + +def resolve_report_file(path: Path | str | None = None) -> Path: + """Resolve the XTB report file to process. + + Preference: + 1. An explicit ``path`` (from the CLI or a library call). + 2. The single ``.xlsx`` in the current working directory (auto-detect), + skipping Excel lock files (``~$...``) and dotfiles. + + Raises FileNotFoundError when there is no candidate and ValueError when + several candidates make the choice ambiguous. Works with any same-format + XTB export regardless of account or period. + """ + if path is not None: + return Path(path) + + candidates = [ + p for p in sorted(Path.cwd().glob("*.xlsx")) + if not p.name.startswith(("~$", ".")) + ] + if not candidates: + raise FileNotFoundError( + "No .xlsx report found in the current directory. " + "Pass it explicitly, e.g.: python main.py " + ) + if len(candidates) > 1: + names = ", ".join(p.name for p in candidates) + raise ValueError( + f"Multiple .xlsx files found ({names}). " + "Specify which one to use, e.g.: python main.py " + ) + return candidates[0] + + +def detect_currency() -> str: + name = REPORT_FILE.stem.upper() + for prefix in ("EUR", "USD", "GBP", "PLN", "CHF", "JPY", "AUD", "CAD", "CZK", "HUF"): + if name.startswith(prefix): + return prefix + return "EUR" + + +# --------------------------------------------------------------------------- +# Generic helpers +# --------------------------------------------------------------------------- +def clean_columns(df: pd.DataFrame) -> pd.DataFrame: + df = df.copy() + df.columns = ( + df.columns.astype(str) + .str.strip() + .str.lower() + .str.replace(r"\s+", "_", regex=True) + .str.replace(r"[^\w_]", "", regex=True) + ) + return df + + +def find_column(df: pd.DataFrame, candidates: list[str], required: bool = True) -> str | None: + normalized = {col.lower(): col for col in df.columns} + for candidate in candidates: + if candidate in normalized: + return normalized[candidate] + for col in df.columns: + for candidate in candidates: + if candidate in col: + return col + if required: + raise ValueError( + f"Could not find any of these columns: {candidates}. " + f"Available columns: {list(df.columns)}" + ) + return None + + +def parse_numeric(series: pd.Series) -> pd.Series: + return ( + series.astype(str) + .str.replace(",", ".", regex=False) + .str.replace(r"[^\d.\-]", "", regex=True) + .replace("", pd.NA) + .pipe(pd.to_numeric, errors="coerce") + .fillna(0.0) + ) + + +def money(value: float) -> str: + return f"{value:,.2f}" + + +# --------------------------------------------------------------------------- +# Loading +# --------------------------------------------------------------------------- +def load_meta() -> dict[str, str]: + raw = pd.read_excel(REPORT_FILE, sheet_name=CASH_SHEET, header=None, nrows=4) + meta = {"account": "", "period_from": "", "period_to": ""} + for _, row in raw.iterrows(): + key = str(row.iloc[0]).strip().lower() + val = "" if pd.isna(row.iloc[1]) else str(row.iloc[1]).strip() + if "account" in key: + meta["account"] = val + elif "from" in key: + meta["period_from"] = val + elif "to" in key: + meta["period_to"] = val + return meta + + +def load_data() -> tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame, float]: + if not REPORT_FILE.exists(): + raise FileNotFoundError(f"Could not find {REPORT_FILE.resolve()}") + + sheet_names = pd.ExcelFile(REPORT_FILE).sheet_names + + positions = pd.read_excel(REPORT_FILE, sheet_name=POSITIONS_SHEET, header=HEADER_ROW) + cash_ops = pd.read_excel(REPORT_FILE, sheet_name=CASH_SHEET, header=HEADER_ROW) + open_positions = ( + pd.read_excel(REPORT_FILE, sheet_name=OPEN_POSITIONS_SHEET, header=HEADER_ROW) + if OPEN_POSITIONS_SHEET in sheet_names + else pd.DataFrame() + ) + + positions = clean_columns(positions).dropna(how="all") + cash_ops = clean_columns(cash_ops).dropna(how="all") + open_positions = clean_columns(open_positions).dropna(how="all") + + # Capture the broker-reported "Total" row before dropping it (for reconciliation). + broker_total = 0.0 + type_col = find_column(cash_ops, ["type", "operation"], required=False) + amount_col = find_column( + cash_ops, ["amount", "value", "net_amount", "cash", "change", "payment"], + required=False, + ) + if type_col and amount_col: + total_mask = cash_ops[type_col].astype(str).str.strip().str.match( + r"(?i)total", na=False + ) + if total_mask.any(): + broker_total = float(parse_numeric(cash_ops.loc[total_mask, amount_col]).iloc[0]) + + # Drop summary/total rows that carry no per-row detail. + pos_col = find_column(positions, ["instrument"], required=False) + if pos_col is not None: + positions = positions.loc[ + ~positions[pos_col].astype(str).str.strip().str.match( + r"(?i)total|profit/?loss|totals", na=False + ) + ].copy() + if type_col is not None: + cash_ops = cash_ops.loc[ + ~cash_ops[type_col].astype(str).str.strip().str.match( + r"(?i)total|profit/?loss|totals", na=False + ) + ].copy() + + return positions, cash_ops, open_positions, broker_total + + +# --------------------------------------------------------------------------- +# Trade parsing (from Cash Operations comments) +# --------------------------------------------------------------------------- +@dataclass +class Trade: + ticker: str + action: str # "open" or "close" + side: str # "buy" or "sell" + shares: float + price: float + value: float # gross cash magnitude (always positive) + date: pd.Timestamp | None = None + name: str = "" # descriptive instrument label (e.g. "S&P 500") + + +def parse_quantity(token: str) -> float: + token = token.strip() + if "/" in token: + num, den = token.split("/", 1) + try: + return float(num) / float(den) if float(den) != 0 else 0.0 + except ValueError: + return 0.0 + try: + return float(token.replace(",", ".")) + except ValueError: + return 0.0 + + +def normalize_trade_side(type_val: str, action: str, side: str) -> str: + """Return economic side for XTB trade rows.""" + lowered_type = type_val.lower() + if action == "close" and side == "buy" and "sell" in lowered_type: + return "sell" + return side + + +def parse_executed_quantity(comment: str, value: float, price: float) -> float: + match = QTY_RE.search(comment) + if match: + token = match.group(1) + if "/" in token: + try: + numerator = float(token.split("/", 1)[0].replace(",", ".")) + if numerator > 0: + return numerator + except ValueError: + pass + parsed = parse_quantity(token) + if parsed > 0: + return parsed + return round(abs(value) / price, 6) if price > 0 else 0.0 + + +def extract_trades(cash_ops: pd.DataFrame) -> list[Trade]: + type_col = find_column(cash_ops, ["type", "operation"], required=False) + ticker_col = find_column( + cash_ops, ["ticker", "symbol", "instrument", "market"], required=False + ) + name_col = find_column(cash_ops, ["instrument", "name", "description"], required=False) + amount_col = find_column( + cash_ops, ["amount", "value", "net_amount", "cash", "change", "payment"], + required=False, + ) + date_col = find_column( + cash_ops, ["time", "date", "operation_date", "booking_date", "transaction_date"], + required=False, + ) + comment_col = find_column(cash_ops, ["comment", "description", "details"], required=False) + + if not (type_col and ticker_col and amount_col): + return [] + + trades: list[Trade] = [] + for _, row in cash_ops.iterrows(): + type_val = str(row.get(type_col, "")).strip() + comment = str(row.get(comment_col, "")) if comment_col else "" + + is_trade = bool(TRADE_TYPE_RE.search(type_val)) or bool(TRADE_COMMENT_RE.search(comment)) + if not is_trade: + continue + if "dividend" in type_val.lower() or "interest" in type_val.lower(): + continue + + match = TRADE_COMMENT_RE.search(comment) + if match: + action = match.group(1).lower() + side = normalize_trade_side(type_val, action, match.group(2).lower()) + else: + action = "open" + lowered = type_val.lower() + side = "buy" if any(t in lowered for t in ("buy", "purchase")) else "sell" + + value = parse_numeric(pd.Series([row[amount_col]])).iloc[0] + value = abs(float(value)) + if value <= 0: + continue + + price = 0.0 + price_match = PRICE_RE.search(comment) + if price_match: + price = parse_numeric(pd.Series([price_match.group(1)])).iloc[0] + + shares = parse_executed_quantity(comment, value, price) + + dt = pd.to_datetime(row.get(date_col), errors="coerce") if date_col else pd.NaT + raw_name = "" + if name_col: + nv = row.get(name_col) + raw_name = "" if pd.isna(nv) else str(nv).strip() + trades.append( + Trade( + ticker=str(row[ticker_col]).strip(), + action=action, + side=side, + shares=float(shares), + price=float(price), + value=float(value), + date=None if pd.isna(dt) else dt, + name=raw_name, + ) + ) + return trades + + +# --------------------------------------------------------------------------- +# Live market prices (yfinance) +# --------------------------------------------------------------------------- +def _parse_as_of(meta: dict[str, str]) -> date: + """Valuation date = report 'Date to'. Falls back to today.""" + raw = meta.get("period_to", "") + ts = pd.to_datetime(raw, errors="coerce") + if pd.isna(ts): + return date.today() + return ts.date() + + +def _yf(): + """Lazy import so tests / offline runs don't require yfinance.""" + import yfinance as yf + return yf + + +def _history(ticker, **kwargs): + """Call yfinance history while suppressing noisy transport diagnostics.""" + with contextlib.redirect_stderr(io.StringIO()): + return ticker.history(**kwargs) + + +_PRICE_CACHE: dict[str, dict | None] = {} + + +def fetch_prices( + tickers: list[str], + as_of: date, + account_currency: str, +) -> dict[str, dict | None]: + """Fetch last available close on/before `as_of` for each ticker. + + Returns {ticker: {"price", "currency", "fx", "as_of", "source"} | None}. + Never raises — failed lookups map to None (caller falls back to cost). + """ + out: dict[str, dict | None] = {} + missing = [t for t in tickers if t not in _PRICE_CACHE] + if missing: + try: + yf = _yf() + except Exception: + for t in missing: + _PRICE_CACHE[t] = None + else: + start = as_of - timedelta(days=14) + end = as_of + timedelta(days=1) # history `end` is exclusive + for t in missing: + fetch_sym = SYMBOL_ALIASES.get(t, t) + _PRICE_CACHE[t] = _fetch_one( + yf, fetch_sym, start, end, as_of, account_currency + ) + for t in tickers: + out[t] = _PRICE_CACHE.get(t) + return out + + +def _fetch_one(yf, ticker, start, end, as_of, account_currency) -> dict | None: + sym = ticker.strip().upper() + if not sym or sym == "NAN": + return None + for _attempt in range(2): # one retry on transient failure + try: + tk = yf.Ticker(sym) + hist = _history(tk, start=start, end=end, auto_adjust=False) + if hist is None or hist.empty: + continue + # Normalize to naive dates for comparison (history is tz-aware). + idx_naive = pd.to_datetime(hist.index).tz_localize(None) + mask = idx_naive <= pd.Timestamp(as_of) + hist = hist.loc[mask] + if hist.empty: + continue + close = float(hist["Close"].iloc[-1]) + price_date = pd.to_datetime(hist.index[-1]).tz_localize(None).date() + try: + cur = (tk.fast_info.get("currency") or "").upper() + except Exception: + cur = "" + if not cur: + cur = account_currency.upper() + fx = 1.0 + if cur and cur != account_currency.upper(): + fx = _fx_rate(yf, cur, account_currency.upper()) + if fx is None: + return None + return { + "price": close, + "currency": cur, + "fx": fx, + "price_local": close, + "as_of": price_date, + "source": "live", + } + except Exception: + continue + return None + + +def _fx_rate(yf, from_cur: str, to_cur: str) -> float | None: + pair = f"{from_cur}{to_cur}=X" + try: + tk = yf.Ticker(pair) + hist = _history(tk, period="5d", auto_adjust=False) + if hist is None or hist.empty: + return None + return float(hist["Close"].iloc[-1]) + except Exception: + return None + + +# --------------------------------------------------------------------------- +# Historical daily closes (for the evolution chart) +# --------------------------------------------------------------------------- +_PRICE_HISTORY_CACHE: dict[str, pd.Series | None] = {} + + +def fetch_price_history( + tickers: list[str], + start: date, + end: date, + account_currency: str, +) -> dict[str, pd.Series | None]: + """Fetch daily closes in account currency for each ticker over [start, end]. + + Returns {ticker: pd.Series (naive-date index -> close in acct ccy) | None}. + Never raises — failed lookups map to None (caller falls back to cost). + Only call for tickers already valued "live"; cost-fallback tickers are held + flat at cost by ``build_evolution_series``. + """ + out: dict[str, pd.Series | None] = {} + missing = [t for t in tickers if t not in _PRICE_HISTORY_CACHE] + if missing: + try: + yf = _yf() + except Exception: + for t in missing: + _PRICE_HISTORY_CACHE[t] = None + else: + # Pad a week back so `asof` has a prior close on the first trade day. + fetch_start = start - timedelta(days=7) + fetch_end = end + timedelta(days=1) # history `end` is exclusive + for t in missing: + _PRICE_HISTORY_CACHE[t] = _fetch_history_one( + yf, t, fetch_start, fetch_end, account_currency + ) + for t in tickers: + out[t] = _PRICE_HISTORY_CACHE.get(t) + return out + + +def _fetch_history_one( + yf, ticker: str, start: date, end: date, account_currency: str +) -> pd.Series | None: + sym = SYMBOL_ALIASES.get(ticker, ticker).strip().upper() + if not sym or sym == "NAN": + return None + try: + tk = yf.Ticker(sym) + hist = _history(tk, start=start, end=end, auto_adjust=False) + if hist is None or hist.empty: + return None + idx = pd.to_datetime(hist.index).tz_localize(None) + closes = pd.Series(hist["Close"].values, index=idx, name=ticker) + try: + cur = (tk.fast_info.get("currency") or "").upper() + except Exception: + cur = "" + if not cur: + cur = account_currency.upper() + if cur and cur != account_currency.upper(): + fx_series = _fx_history(yf, cur, account_currency.upper(), start, end) + if fx_series is None: + return None + fx_vals = fx_series.reindex(closes.index, method="ffill") + closes = (closes * fx_vals).dropna() + return closes.sort_index() + except Exception: + return None + + +def _fx_history(yf, from_cur: str, to_cur: str, start: date, end: date) -> pd.Series | None: + pair = f"{from_cur}{to_cur}=X" + try: + tk = yf.Ticker(pair) + hist = _history(tk, start=start, end=end, auto_adjust=False) + if hist is None or hist.empty: + return None + idx = pd.to_datetime(hist.index).tz_localize(None) + return pd.Series(hist["Close"].values, index=idx).sort_index() + except Exception: + return None + + +# --------------------------------------------------------------------------- +# Live valuation per holding +# --------------------------------------------------------------------------- +def valuate_holdings( + holdings: pd.DataFrame, + prices: dict[str, dict | None], +) -> pd.DataFrame: + """Add last_price, market_value, unrealized_pl, return_pct, price_source, weight_pct.""" + df = holdings.copy() + if df.empty: + for col in ("last_price", "market_value", "unrealized_pl", + "return_pct", "price_source", "weight_pct"): + df[col] = pd.Series(dtype=float if col != "price_source" else object) + return df + + last_price = [] + market_value = [] + unrealized_pl = [] + source = [] + for _, row in df.iterrows(): + info = prices.get(row["ticker"]) + if info and info.get("price"): + price = float(info["price"]) * float(info.get("fx", 1.0)) + mv = float(row["shares"]) * price + last_price.append(round(price, 6)) + market_value.append(round(mv, 4)) + unrealized_pl.append(round(mv - float(row["cost_basis"]), 4)) + source.append("live") + else: + last_price.append(float(row["avg_price"])) + market_value.append(float(row["cost_basis"])) + unrealized_pl.append(0.0) + source.append("cost") + df["last_price"] = last_price + df["market_value"] = market_value + df["unrealized_pl"] = unrealized_pl + df["price_source"] = source + df["return_pct"] = df.apply( + lambda r: round(r["unrealized_pl"] / r["cost_basis"] * 100, 2) + if r["cost_basis"] else 0.0, + axis=1, + ) + total_mv = df["market_value"].sum() + df["weight_pct"] = ( + (df["market_value"] / total_mv * 100).round(2) if total_mv else 0.0 + ) + return df + + +# --------------------------------------------------------------------------- +# Holdings + realized P/L (FIFO) +# --------------------------------------------------------------------------- +def analyze_holdings( + trades: list[Trade], +) -> tuple[pd.DataFrame, pd.DataFrame]: + """Return (open_holdings, realized_pl) using FIFO lot matching. + + realized_pl covers every ticker that had a closing trade, including those + now fully closed (which no longer appear in open_holdings). + + Trades are processed in chronological order: a position cannot be closed + before it is opened, and XTB sheets sometimes list close legs before their + open legs (stable sort preserves sheet order for equal/unknown timestamps). + """ + lots: dict[str, list[tuple[float, float]]] = {} # ticker -> [(shares, price)] + names: dict[str, str] = {} # ticker -> display name + realized: dict[str, float] = {} + + _sort_key = lambda t: t.date if t.date is not None else pd.Timestamp.min + for t in sorted(trades, key=_sort_key): + bucket = lots.setdefault(t.ticker, []) + if t.name and t.ticker not in names: + names[t.ticker] = t.name + if t.action == "open": + if t.side == "buy": + bucket.append((t.shares, t.price)) + else: # opening a short + bucket.append((-t.shares, t.price)) + else: # close + to_close = t.shares + close_value = t.value + cost_consumed = 0.0 + while to_close > 1e-9 and bucket: + lot_shares, lot_price = bucket[0] + if abs(lot_shares) < 1e-9: + bucket.pop(0) + continue + # lot sign indicates long(+)/short(-); closing uses same magnitude. + magnitude = min(abs(lot_shares), to_close) + cost_consumed += magnitude * lot_price + remaining = abs(lot_shares) - magnitude + sign = 1 if lot_shares >= 0 else -1 + if remaining > 1e-9: + bucket[0] = (sign * remaining, lot_price) + else: + bucket.pop(0) + to_close -= magnitude + # For a long close, proceeds (close_value) - cost = gain. + realized[t.ticker] = realized.get(t.ticker, 0.0) + (close_value - cost_consumed) + + rows = [] + for ticker, bucket in lots.items(): + net_shares = sum(s for s, _ in bucket) + if abs(net_shares) < 1e-4: + continue # fully closed (tolerance absorbs float residue) -> not an open holding + long_shares = sum(s for s, _ in bucket if s > 0) + cost_basis = sum(abs(s) * p for s, p in bucket) + avg_price = cost_basis / long_shares if long_shares > 0 else 0.0 + rows.append( + { + "ticker": ticker, + "name": names.get(ticker, ""), + "shares": round(net_shares, 6), + "cost_basis": round(cost_basis, 4), + "avg_price": round(avg_price, 4), + } + ) + + holdings_cols = ["ticker", "name", "shares", "cost_basis", "avg_price"] + if rows: + df = pd.DataFrame(rows).sort_values("cost_basis", ascending=False).reset_index(drop=True) + total_cost = df["cost_basis"].sum() + df["allocation_pct"] = ( + (df["cost_basis"] / total_cost * 100).round(2) if total_cost else 0.0 + ) + else: + df = pd.DataFrame(columns=holdings_cols + ["allocation_pct"]) + + realized_df = ( + pd.DataFrame( + [{"ticker": k, "realized_pl": round(v, 4)} for k, v in realized.items() if abs(v) > 1e-9] + ) + if realized + else pd.DataFrame(columns=["ticker", "realized_pl"]) + ) + return df, realized_df + + +def analyze_realized( + positions: pd.DataFrame, realized_from_trades: pd.DataFrame +) -> pd.DataFrame: + # Prefer the broker's Closed Positions Profit/Loss when available. + if not positions.empty: + ticker_col = find_column( + positions, ["ticker", "symbol", "instrument", "market"], required=False + ) + pl_col = find_column( + positions, ["profit_loss", "profitloss", "profit", "pnl", "result"], + required=False, + ) + if ticker_col and pl_col: + return ( + positions.assign(_pl=parse_numeric(positions[pl_col])) + .groupby(ticker_col)["_pl"] + .sum() + .reset_index() + .rename(columns={ticker_col: "ticker", "_pl": "realized_pl"}) + ) + + return realized_from_trades.reset_index(drop=True) + + +# --------------------------------------------------------------------------- +# Cash flow analysis +# --------------------------------------------------------------------------- +def analyze_cash_flows( + cash_ops: pd.DataFrame, trades: list[Trade] +) -> tuple[dict[str, float], float]: + type_col = find_column(cash_ops, ["type", "operation"], required=False) + amount_col = find_column( + cash_ops, ["amount", "value", "net_amount", "cash", "change", "payment"], + required=False, + ) + comment_col = find_column(cash_ops, ["comment", "description", "details"], required=False) + + flows = { + "deposits": 0.0, + "withdrawals": 0.0, + "interest": 0.0, + "dividends": 0.0, + "dividend_tax": 0.0, + "conversion_fees": 0.0, + "invested": 0.0, + "proceeds": 0.0, + "fees": 0.0, + } + + trade_ids = set() + if comment_col: + for _, row in cash_ops.iterrows(): + comment = str(row.get(comment_col, "")) + if TRADE_COMMENT_RE.search(comment): + trade_ids.add(row.name) + + if type_col and amount_col: + for idx, row in cash_ops.iterrows(): + if idx in trade_ids: + continue + type_val = str(row.get(type_col, "")).strip() + amount = float(parse_numeric(pd.Series([row[amount_col]])).iloc[0]) + text = f"{type_val} {row.get(comment_col, '')}".lower() + + if DIVIDEND_TAX_RE.search(text): + flows["dividend_tax"] += amount + elif "tax" in type_val.lower(): + flows["fees"] += abs(amount) + elif DIVIDEND_RE.search(text): + flows["dividends"] += amount + elif INTEREST_RE.search(text): + flows["interest"] += amount + elif CONVERSION_RE.search(text): + flows["conversion_fees"] += amount + elif WITHDRAW_RE.search(text): + flows["withdrawals"] += abs(amount) + elif DEPOSIT_RE.search(text): + flows["deposits"] += abs(amount) + + # Trading cash impact from parsed trades (separates buys vs sells). + for t in trades: + if t.action == "open": + if t.side == "buy": + flows["invested"] += t.value + else: + flows["proceeds"] += t.value # short sale proceeds + else: # close + if t.side == "sell": + flows["proceeds"] += t.value + else: + flows["invested"] += t.value # buying to cover + + net_deposited = flows["deposits"] - flows["withdrawals"] + ending_cash = ( + net_deposited + + flows["interest"] + + flows["dividends"] + + flows["dividend_tax"] + - flows["invested"] + + flows["proceeds"] + - flows["fees"] + + flows["conversion_fees"] + ) + return flows, ending_cash + + +def analyze_income(cash_ops: pd.DataFrame) -> tuple[float, float, pd.Series]: + type_col = find_column(cash_ops, ["type", "operation"], required=False) + amount_col = find_column( + cash_ops, ["amount", "value", "net_amount", "cash", "change", "payment"], + required=False, + ) + date_col = find_column( + cash_ops, ["time", "date", "operation_date", "booking_date", "transaction_date"], + required=False, + ) + comment_col = find_column(cash_ops, ["comment", "description", "details"], required=False) + + dividends = interest = 0.0 + monthly: dict[str, float] = {} + + if not (type_col and amount_col): + return 0.0, 0.0, pd.Series(dtype=float) + + for _, row in cash_ops.iterrows(): + text = f"{row.get(type_col, '')} {row.get(comment_col, '') if comment_col else ''}".lower() + amount = float(parse_numeric(pd.Series([row[amount_col]])).iloc[0]) + if DIVIDEND_RE.search(text): + dividends += amount + period = _period(row, date_col) + if period: + monthly[period] = monthly.get(period, 0.0) + amount + elif INTEREST_RE.search(text): + interest += amount + period = _period(row, date_col) + if period: + monthly[period] = monthly.get(period, 0.0) + amount + + series = ( + pd.Series(monthly, name="income").sort_index() if monthly else pd.Series(dtype=float) + ) + return dividends, interest, series + + +def _period(row: pd.Series, date_col: str | None) -> str | None: + if not date_col: + return None + dt = pd.to_datetime(row.get(date_col), errors="coerce") + if pd.isna(dt): + return None + return str(dt.to_period("M")) + + +# --------------------------------------------------------------------------- +# Reporting +# --------------------------------------------------------------------------- +def analyze_open_positions( + open_positions: pd.DataFrame, + valued_holdings: pd.DataFrame | None = None, +) -> pd.DataFrame: + """Live market value & unrealized P/L per ticker. + + Preference order: + 1. XTB 'Open Positions' sheet (broker live values) when present. + 2. Live-valued holdings (yfinance) when provided. + Otherwise returns an empty frame so callers fall back to cost basis. + """ + empty_cols = ["ticker", "current_value", "unrealized_pl"] + + if open_positions is not None and not open_positions.empty: + ticker_col = find_column( + open_positions, ["ticker", "symbol", "instrument", "market"], required=False + ) + value_col = find_column( + open_positions, ["current_value", "value", "market_value", "position_value"], + required=False, + ) + pl_col = find_column( + open_positions, ["profit_loss", "profitloss", "profit", "pnl", "result", "unrealized"], + required=False, + ) + if ticker_col is not None and value_col is not None: + df = open_positions.copy() + df["_value"] = parse_numeric(df[value_col]) + df["_pl"] = parse_numeric(df[pl_col]) if pl_col else 0.0 + return ( + df.groupby(ticker_col) + .agg(current_value=("_value", "sum"), unrealized_pl=("_pl", "sum")) + .reset_index() + .rename(columns={ticker_col: "ticker"}) + .sort_values("current_value", ascending=False) + .reset_index(drop=True) + ) + + if valued_holdings is not None and not valued_holdings.empty: + cols = {"ticker", "market_value", "unrealized_pl"} + if cols.issubset(valued_holdings.columns): + return ( + valued_holdings[["ticker", "market_value", "unrealized_pl"]] + .rename(columns={"market_value": "current_value"}) + .sort_values("current_value", ascending=False) + .reset_index(drop=True) + ) + + return pd.DataFrame(columns=empty_cols) + + +def compute_xirr(cash_flows: list[tuple[pd.Timestamp, float]]) -> float | None: + """Return annualized IRR for dated cash flows, or None when unsolvable.""" + dated = [ + (pd.Timestamp(d).normalize(), float(v)) + for d, v in cash_flows + if abs(float(v)) > 1e-9 + ] + if not dated: + return None + if not any(v > 0 for _, v in dated) or not any(v < 0 for _, v in dated): + return None + dated.sort(key=lambda item: item[0]) + start = dated[0][0] + if dated[-1][0] <= start: + return None + + def npv(rate: float) -> float: + total = 0.0 + for dt, amount in dated: + years = (dt - start).days / 365.0 + total += amount / ((1.0 + rate) ** years) + return total + + low = -0.9999 + high = 1.0 + low_val = npv(low) + high_val = npv(high) + while low_val * high_val > 0 and high < 1000.0: + high *= 2.0 + high_val = npv(high) + if low_val * high_val > 0: + return None + + for _ in range(100): + mid = (low + high) / 2.0 + mid_val = npv(mid) + if abs(mid_val) < 1e-7: + return mid + if low_val * mid_val <= 0: + high = mid + high_val = mid_val + else: + low = mid + low_val = mid_val + return (low + high) / 2.0 + + +def build_external_cash_flows( + cash_ops: pd.DataFrame, + terminal_value: float, + terminal_date: date, +) -> list[tuple[pd.Timestamp, float]]: + """Build investor-perspective external flows for money-weighted return.""" + type_col = find_column(cash_ops, ["type", "operation"], required=False) + amount_col = find_column( + cash_ops, ["amount", "value", "net_amount", "cash", "change", "payment"], + required=False, + ) + date_col = find_column( + cash_ops, ["time", "date", "operation_date", "booking_date", "transaction_date"], + required=False, + ) + comment_col = find_column(cash_ops, ["comment", "description", "details"], required=False) + if not (type_col and amount_col and date_col): + return [] + + flows: list[tuple[pd.Timestamp, float]] = [] + for _, row in cash_ops.iterrows(): + text = f"{row.get(type_col, '')} {row.get(comment_col, '') if comment_col else ''}".lower() + dt = pd.to_datetime(row.get(date_col), errors="coerce") + if pd.isna(dt): + continue + amount = float(parse_numeric(pd.Series([row[amount_col]])).iloc[0]) + if DEPOSIT_RE.search(text): + flows.append((pd.Timestamp(dt).normalize(), -abs(amount))) + elif WITHDRAW_RE.search(text): + flows.append((pd.Timestamp(dt).normalize(), abs(amount))) + + if terminal_value > 0: + flows.append((pd.Timestamp(terminal_date).normalize(), float(terminal_value))) + return sorted(flows, key=lambda item: item[0]) + + +def compute_performance( + holdings: pd.DataFrame, + open_positions: pd.DataFrame, + realized: pd.DataFrame, + flows: dict[str, float], + ending_cash: float, + broker_total: float, + cash_ops: pd.DataFrame | None = None, + terminal_date: date | None = None, +) -> dict[str, float | None]: + cost_basis = float(holdings["cost_basis"].sum()) if not holdings.empty else 0.0 + + market_value = cost_basis + unrealized_pl = 0.0 + if not open_positions.empty: + market_value = float(open_positions["current_value"].sum()) + unrealized_pl = float(open_positions["unrealized_pl"].sum()) + + realized_pl = float(realized["realized_pl"].sum()) if not realized.empty else 0.0 + income = flows["interest"] + flows["dividends"] + + portfolio_value = market_value + ending_cash + net_deposited = flows["deposits"] - flows["withdrawals"] + total_gain = unrealized_pl + realized_pl + income + total_return_pct = (total_gain / net_deposited * 100) if net_deposited else 0.0 + income_yield_pct = (income / cost_basis * 100) if cost_basis else 0.0 + money_weighted_return_pct = None + if cash_ops is not None and terminal_date is not None: + external_flows = build_external_cash_flows(cash_ops, portfolio_value, terminal_date) + xirr = compute_xirr(external_flows) + money_weighted_return_pct = xirr * 100 if xirr is not None else None + # XTB "Total" row = ending free cash, so reconcile cash (not portfolio value). + diff = ending_cash - broker_total if broker_total else None + + return { + "cost_basis": cost_basis, + "market_value": market_value, + "unrealized_pl": unrealized_pl, + "realized_pl": realized_pl, + "income": income, + "total_gain": total_gain, + "portfolio_value": portfolio_value, + "ending_cash": ending_cash, + "net_deposited": net_deposited, + "total_return_pct": total_return_pct, + "money_weighted_return_pct": money_weighted_return_pct, + "income_yield_pct": income_yield_pct, + "broker_total": broker_total, + "reconciliation_diff": diff, + } + + +def _holding_weights(holdings: pd.DataFrame) -> pd.Series: + if holdings is None or holdings.empty: + return pd.Series(dtype=float) + if "weight_pct" in holdings.columns: + return pd.to_numeric(holdings["weight_pct"], errors="coerce").fillna(0.0) + if "market_value" not in holdings.columns: + return pd.Series([0.0] * len(holdings), index=holdings.index) + market_values = pd.to_numeric(holdings["market_value"], errors="coerce").fillna(0.0) + total = float(market_values.sum()) + return market_values / total * 100 if total else market_values * 0.0 + + +def analyze_concentration(holdings: pd.DataFrame, perf: dict[str, float]) -> dict[str, float | int | str]: + """Summarize simple concentration and data-quality risk indicators.""" + weights = _holding_weights(holdings).sort_values(ascending=False) + top_1 = float(weights.head(1).sum()) if not weights.empty else 0.0 + top_3 = float(weights.head(3).sum()) if not weights.empty else 0.0 + top_5 = float(weights.head(5).sum()) if not weights.empty else 0.0 + portfolio_value = float(perf.get("portfolio_value", 0.0) or 0.0) + cash_weight = ( + float(perf.get("ending_cash", 0.0) or 0.0) / portfolio_value * 100 + if portfolio_value + else 0.0 + ) + over_20 = int((weights > 20.0).sum()) if not weights.empty else 0 + cost_priced = 0 + if holdings is not None and not holdings.empty and "price_source" in holdings.columns: + cost_priced = int((holdings["price_source"].astype(str) == "cost").sum()) + + if top_1 >= 50.0: + note = f"High concentration: top holding is {top_1:.2f}%." + elif top_3 >= 75.0: + note = f"Elevated concentration: top 3 holdings are {top_3:.2f}%." + elif cost_priced: + note = f"Data quality watch: {cost_priced} holding{'s' if cost_priced != 1 else ''} priced at cost." + else: + note = "No major concentration flags from position weights." + + return { + "top_1_weight_pct": top_1, + "top_3_weight_pct": top_3, + "top_5_weight_pct": top_5, + "cash_weight_pct": cash_weight, + "positions_over_20_pct": over_20, + "cost_priced_positions": cost_priced, + "risk_note": note, + } + + +def build_executive_summary( + holdings: pd.DataFrame, + realized: pd.DataFrame, + flows: dict[str, float], + perf: dict[str, float], +) -> list[tuple[str, str]]: + """Return short reader-facing observations for the top of the HTML report.""" + del realized, flows # Kept in the signature so callers pass the full analysis context. + if holdings is None or holdings.empty: + largest = "No open positions" + winner = "None" + loser = "None" + cost_priced = 0 + else: + weights = _holding_weights(holdings) + largest_row = holdings.loc[weights.idxmax()] + largest = f"{largest_row['ticker']} ({float(weights.loc[largest_row.name]):.2f}%)" + + unrealized = pd.to_numeric(holdings.get("unrealized_pl", 0.0), errors="coerce").fillna(0.0) + winner_idx = unrealized.idxmax() + loser_idx = unrealized.idxmin() + winner_val = float(unrealized.loc[winner_idx]) + loser_val = float(unrealized.loc[loser_idx]) + winner = ( + f"{holdings.loc[winner_idx, 'ticker']} ({winner_val:+.2f})" + if winner_val > 0 + else "None" + ) + loser = ( + f"{holdings.loc[loser_idx, 'ticker']} ({loser_val:+.2f})" + if loser_val < 0 + else "None" + ) + cost_priced = ( + int((holdings["price_source"].astype(str) == "cost").sum()) + if "price_source" in holdings.columns + else 0 + ) + + portfolio_value = float(perf.get("portfolio_value", 0.0) or 0.0) + cash_allocation = ( + float(perf.get("ending_cash", 0.0) or 0.0) / portfolio_value * 100 + if portfolio_value + else 0.0 + ) + diff = perf.get("reconciliation_diff") + if diff is None: + recon = "Skipped" + else: + recon = "OK" if abs(float(diff)) < 0.01 else "CHECK" + pricing = ( + "No cost-pricing fallbacks" + if cost_priced == 0 + else f"{cost_priced} holding{'s' if cost_priced != 1 else ''} priced at cost" + ) + + return [ + ("Largest holding", largest), + ("Top unrealized winner", winner), + ("Top unrealized loser", loser), + ("Cash allocation", f"{cash_allocation:.2f}%"), + ("Pricing warnings", pricing), + ("Reconciliation", recon), + ] + + +def analyze_income_quality( + flows: dict[str, float], + perf: dict[str, float], +) -> dict[str, float | str]: + """Summarize income, withholding/tax drag, and income yield on cost.""" + dividends = float(flows.get("dividends", 0.0) or 0.0) + interest = float(flows.get("interest", 0.0) or 0.0) + dividend_tax = abs(float(flows.get("dividend_tax", 0.0) or 0.0)) + gross_income = dividends + interest + net_income = gross_income - dividend_tax + tax_drag_pct = dividend_tax / gross_income * 100 if gross_income else 0.0 + cost_basis = float(perf.get("cost_basis", 0.0) or 0.0) + net_income_yield_pct = net_income / cost_basis * 100 if cost_basis else 0.0 + if gross_income: + dividend_mix = dividends / gross_income * 100 + interest_mix = interest / gross_income * 100 + income_mix = f"{dividend_mix:.2f}% dividends / {interest_mix:.2f}% interest" + else: + income_mix = "No income" + return { + "gross_income": gross_income, + "dividend_tax": dividend_tax, + "net_income": net_income, + "tax_drag_pct": tax_drag_pct, + "net_income_yield_pct": net_income_yield_pct, + "income_mix": income_mix, + } + + +def analyze_methodology_quality( + holdings: pd.DataFrame, + perf: dict[str, float], +) -> list[tuple[str, str]]: + """Return report-method and data-quality notes for the HTML summary.""" + live_count = cost_count = 0 + fallback_tickers = [] + if holdings is not None and not holdings.empty and "price_source" in holdings.columns: + src = holdings["price_source"].astype(str) + live_count = int((src == "live").sum()) + cost_count = int((src == "cost").sum()) + if cost_count and "ticker" in holdings.columns: + fallback_tickers = holdings.loc[src == "cost", "ticker"].astype(str).tolist() + + diff = perf.get("reconciliation_diff") + if diff is None: + recon = "Skipped" + else: + recon = "OK" if abs(float(diff)) < 0.01 else "CHECK" + + fallback_label = ", ".join(fallback_tickers) if fallback_tickers else "None" + fallback_word = "fallback" if cost_count == 1 else "fallbacks" + return [ + ("Pricing coverage", f"{live_count} live / {cost_count} cost {fallback_word}"), + ("Cost fallback tickers", fallback_label), + ("Cash reconciliation", recon), + ("Realized P/L method", "Broker closed positions preferred; FIFO fallback"), + ("Money-weighted return", "External deposits/withdrawals plus terminal portfolio value"), + ("Valuation caveat", "Cost fallback positions carry zero unrealized P/L"), + ] + + +def beginner_guide_rows() -> list[tuple[str, str]]: + """Plain-language explanations for readers new to investing terms.""" + return [ + ( + "Market value", + "Think of market value as today's estimated selling value. It is what the position appears to be worth now, not what you originally paid.", + ), + ( + "Unrealized profit", + "Unrealized profit is only a paper gain until you sell. The price can still move up or down before that gain becomes real cash.", + ), + ( + "Realized profit", + "Realized profit is the gain or loss after a position was sold. It is already locked in by a completed sale.", + ), + ( + "Money-weighted return", + "Money-weighted return is useful when you added money at different times. It gives more weight to money that was invested for longer.", + ), + ( + "Cost fallback", + "A cost fallback means the report could not find a trusted live price, so it uses what you paid. Treat those values as conservative placeholders, not confirmed market prices.", + ), + ( + "Concentration", + "Concentration tells you whether too much of the portfolio depends on only a few holdings. A high number is not automatically bad, but it means those holdings matter more.", + ), + ] + + +def analyze_return_contributions( + holdings: pd.DataFrame, + realized: pd.DataFrame, + perf: dict[str, float], +) -> pd.DataFrame: + """Return ticker-level realized + unrealized contribution to total gain.""" + rows: dict[str, dict[str, float | str]] = {} + if holdings is not None and not holdings.empty: + for _, row in holdings.iterrows(): + ticker = str(row.get("ticker", "")).strip() + if not ticker: + continue + rows[ticker] = { + "Ticker": ticker, + "Market Value": float(row.get("market_value", 0.0) or 0.0), + "Unrealized P/L": float(row.get("unrealized_pl", 0.0) or 0.0), + "Realized P/L": 0.0, + } + + if realized is not None and not realized.empty and {"ticker", "realized_pl"}.issubset(realized.columns): + grouped = realized.groupby("ticker")["realized_pl"].sum() + for ticker, realized_pl in grouped.items(): + key = str(ticker) + rows.setdefault( + key, + { + "Ticker": key, + "Market Value": 0.0, + "Unrealized P/L": 0.0, + "Realized P/L": 0.0, + }, + ) + rows[key]["Realized P/L"] = float(realized_pl) + + if not rows: + return pd.DataFrame( + columns=[ + "Ticker", "Market Value", "Unrealized P/L", + "Realized P/L", "Total Contribution", "Contribution %", + ] + ) + + total_gain = float(perf.get("total_gain", 0.0) or 0.0) + out = pd.DataFrame(rows.values()) + out["Total Contribution"] = out["Unrealized P/L"] + out["Realized P/L"] + out["Contribution %"] = ( + out["Total Contribution"] / total_gain * 100 if abs(total_gain) > 1e-9 else 0.0 + ) + return out.sort_values("Total Contribution", ascending=False).reset_index(drop=True) + + +# --------------------------------------------------------------------------- +# Portfolio evolution (cost vs realized + unrealized value over time) +# --------------------------------------------------------------------------- +def _replay_trade( + lots: dict[str, list[tuple[float, float]]], + realized: dict[str, float], + trade: Trade, +) -> None: + """Apply one trade to the open-lots state (mutates lots + realized).""" + bucket = lots.setdefault(trade.ticker, []) + if trade.action == "open": + bucket.append((trade.shares, trade.price) if trade.side == "buy" + else (-trade.shares, trade.price)) + return + # close + to_close = trade.shares + close_value = trade.value + cost_consumed = 0.0 + while to_close > 1e-9 and bucket: + lot_shares, lot_price = bucket[0] + if abs(lot_shares) < 1e-9: + bucket.pop(0) + continue + magnitude = min(abs(lot_shares), to_close) + cost_consumed += magnitude * lot_price + remaining = abs(lot_shares) - magnitude + sign = 1 if lot_shares >= 0 else -1 + if remaining > 1e-9: + bucket[0] = (sign * remaining, lot_price) + else: + bucket.pop(0) + to_close -= magnitude + realized[trade.ticker] = realized.get(trade.ticker, 0.0) + (close_value - cost_consumed) + + +def build_evolution_series( + trades: list[Trade], + price_history: dict[str, pd.Series | None], + end_date: date, +) -> pd.DataFrame: + """Replay trades daily and compute cost / market value / realized P/L series. + + Returns a DataFrame indexed by date with columns: + ``cost`` (open cost basis), ``market_value`` (open lots at historical + close, falling back to cost when no live series), ``realized_pl`` + (cumulative), ``total_value`` (market_value + realized_pl). + + The gap between ``cost`` and ``total_value`` is the total investment gain / + loss. Tickers without a live price series contribute their open cost basis + as market value (i.e. zero unrealized P/L), matching the holdings table. + """ + empty = pd.DataFrame( + columns=["cost", "market_value", "realized_pl", "total_value"] + ) + dated = [t for t in trades if t.date is not None] + if not dated: + return empty + + sorted_trades = sorted(dated, key=lambda t: t.date) + start_date = pd.Timestamp(sorted_trades[0].date).normalize() + end_ts = pd.Timestamp(end_date) + if end_ts < start_date: + end_ts = start_date + dates = pd.date_range(start=start_date, end=end_ts, freq="D") + + lots: dict[str, list[tuple[float, float]]] = {} + realized: dict[str, float] = {} + trade_idx = 0 + n = len(sorted_trades) + rows = [] + for d in dates: + while trade_idx < n and pd.Timestamp(sorted_trades[trade_idx].date).normalize() <= d: + _replay_trade(lots, realized, sorted_trades[trade_idx]) + trade_idx += 1 + cost = 0.0 + market_value = 0.0 + for ticker, bucket in lots.items(): + series = price_history.get(ticker) + for shares, lot_price in bucket: + lot_cost = abs(shares) * lot_price + cost += lot_cost + if series is not None and len(series): + close = series.asof(d) + if close is not None and not pd.isna(close): + market_value += shares * float(close) + else: + market_value += lot_cost + else: + market_value += lot_cost + realized_total = sum(realized.values()) + rows.append({ + "cost": round(cost, 4), + "market_value": round(market_value, 4), + "realized_pl": round(realized_total, 4), + "total_value": round(market_value + realized_total, 4), + }) + df = pd.DataFrame(rows, index=dates) + df.index.name = "date" + return df + + +def print_report( + currency: str, + meta: dict[str, str], + flows: dict[str, float], + ending_cash: float, + holdings: pd.DataFrame, + open_positions: pd.DataFrame, + realized: pd.DataFrame, + perf: dict[str, float], + dividends: float, + interest: float, + as_of: date | None = None, + cost_fallback_tickers: list[str] | None = None, +) -> None: + cost_fallback_tickers = cost_fallback_tickers or [] + print(f"\nPORTFOLIO REVIEW — XTB account {meta.get('account', '?')}") + print("=" * 80) + print( + f"Period: {meta.get('period_from', '?')} → {meta.get('period_to', '?')} " + f"({currency})" + ) + val_date = as_of.isoformat() if as_of else meta.get("period_to", "?") + print(f"Generated: {datetime.now():%Y-%m-%d %H:%M:%S} Valuation date: {val_date}") + + print("\nCASH FLOWS") + print("-" * 80) + print(f" Deposits: {money(flows['deposits']):>14}") + print(f" Withdrawals: {money(-flows['withdrawals']):>14}") + print(f" Free-funds interest: {money(flows['interest']):>14}") + print(f" Dividends received: {money(flows['dividends']):>14}") + print(f" Dividend tax: {money(flows['dividend_tax']):>14}") + print(f" Invested (buys): {money(-flows['invested']):>14}") + print(f" Proceeds (sales): {money(flows['proceeds']):>14}") + print(f" FX conversion fees: {money(flows['conversion_fees']):>14}") + print(f" Fees / commissions: {money(-flows['fees']):>14}") + print(f" Ending cash balance: {money(ending_cash):>14}") + + print("\nHOLDINGS (live market value)") + print("-" * 80) + if holdings.empty or holdings["market_value"].sum() == 0: + print(" No open positions.") + else: + view = holdings[["ticker", "name", "shares", "last_price", + "market_value", "unrealized_pl", "return_pct", + "weight_pct", "price_source"]].copy() + view.columns = ["Ticker", "Name", "Shares", "Last Price", + "Market Value", "Unrealized P/L", "Return %", + "Weight %", "Src"] + print(view.to_string(index=False)) + print(f"\n Total cost basis: {money(perf['cost_basis']):>14}") + print(f" Total market value: {money(perf['market_value']):>14}") + if cost_fallback_tickers: + print(f" (priced at cost: {', '.join(cost_fallback_tickers)})") + for tk in cost_fallback_tickers: + if tk in COST_FALLBACK_NOTES: + print(f" · {tk}: {COST_FALLBACK_NOTES[tk]}") + + print("\nOPEN POSITIONS (market value)") + print("-" * 80) + if open_positions is None or open_positions.empty: + print(" No open positions.") + else: + view = open_positions.copy() + view["weight_pct"] = ( + view["current_value"] / view["current_value"].sum() * 100 + ).round(2) + view.columns = ["Ticker", "Market Value", "Unrealized P/L", "Weight %"] + print(view.to_string(index=False)) + print( + f"\n Total market value: {money(perf['market_value']):>14}" + f" Unrealized P/L: {money(perf['unrealized_pl']):>12}" + ) + + print("\nREALIZED P/L (closed positions)") + print("-" * 80) + if realized.empty or (realized["realized_pl"].abs().sum() == 0): + print(" No realized gains/losses in this period.") + else: + print(realized.to_string(index=False)) + print(f"\n Total realized P/L: {money(perf['realized_pl']):>14}") + + print("\nPERFORMANCE") + print("-" * 80) + print(f" Portfolio value: {money(perf['portfolio_value']):>14}") + print(f" of which market val: {money(perf['market_value']):>14}") + print(f" of which cash: {money(perf['ending_cash']):>14}") + print(f" of which cost basis: {money(perf['cost_basis']):>14}") + print(f" Net deposited: {money(perf['net_deposited']):>14}") + print(f" Unrealized P/L: {money(perf['unrealized_pl']):>14}") + print(f" Realized P/L: {money(perf['realized_pl']):>14}") + print(f" Income (int. + div.): {money(perf['income']):>14}") + print(f" Total gain: {money(perf['total_gain']):>14}") + print(f" Total return: {perf['total_return_pct']:>13.2f}%") + if perf.get("money_weighted_return_pct") is not None: + print(f" Money-weighted return: {perf['money_weighted_return_pct']:>13.2f}%") + else: + print(f" Money-weighted return: {'n/a':>14}") + print(f" Income yield (on cost):{perf['income_yield_pct']:>12.2f}%") + + print("\nRECONCILIATION") + print("-" * 80) + if perf["broker_total"]: + diff = perf["reconciliation_diff"] + status = "OK" if abs(diff) < 0.01 else "CHECK" + print( + f" Computed ending cash: {money(perf['ending_cash']):>10}\n" + f" Broker 'Total' (cash): {money(perf['broker_total']):>10}\n" + f" Difference: {money(diff):>10} [{status}]" + ) + else: + print(" Broker 'Total' row not found — reconciliation skipped.") + print() + + +# --------------------------------------------------------------------------- +# HTML report +# --------------------------------------------------------------------------- +TERM_TOOLTIPS = { + "Ticker": "A ticker is the short code used by markets and brokers to identify an investment, like a label on an exchange.", + "ticker": "A ticker is the short code used by markets and brokers to identify an investment, like a label on an exchange.", + "Name": "The longer human-readable name of the investment.", + "Shares": "How many units of the investment you currently hold.", + "Last Price": "The latest price used for one share or unit. If no trusted live price exists, this may be the average cost.", + "Src": "Source of the price: live means fetched from market data; cost means the report used what you paid.", + "Portfolio value": "What your portfolio is worth after including market value and cash.", + "Market Value": "Today's estimated value for a holding. If the report cannot find a trusted price, it uses your original cost instead.", + "market_value": "Market value is today's estimated value for a holding. If no trusted price is found, the report may use cost instead.", + "Net deposited": "Total money added to the account minus withdrawals.", + "Deposits": "Money you added to the brokerage account.", + "Withdrawals": "Money you took out of the brokerage account.", + "Free-funds interest": "Small interest paid by the broker on cash that was not invested.", + "Dividends received": "Cash paid by investments, usually from company profits or fund distributions.", + "Invested (buys)": "Money spent buying investments. It reduces cash but increases holdings.", + "Proceeds (sales)": "Money received from selling investments. It increases cash.", + "FX conversion fees": "Costs or adjustments from converting between currencies.", + "Fees / commissions": "Broker or transaction costs paid for account activity.", + "Ending cash balance": "Cash left in the account after all deposits, withdrawals, trades, income, and fees.", + "Total gain": "Unrealized gains plus realized gains plus income.", + "Total return": "Total gain divided by net deposited. Simple return, not adjusted for deposit timing.", + "Money-weighted return": "This answers: how did my money do, considering the dates I added or withdrew cash? It is useful when deposits happened at different times.", + "Income yield (on cost)": "Income divided by the cost basis of current holdings.", + "Cost basis": "The amount paid for the open position before any current market gain or loss.", + "cost basis": "The amount paid for the open position before any current market gain or loss.", + "Unrealized P/L": "Profit or loss on positions you still hold. It is not locked in until you sell.", + "unrealized_pl": "unrealized_pl means unrealized profit or loss: the gain or loss on positions you still hold.", + "Realized P/L": "Profit or loss from positions that were sold or closed.", + "realized_pl": "realized_pl means realized profit or loss: the gain or loss already locked in by selling or closing a position.", + "Return %": "Unrealized profit or loss divided by cost basis.", + "Weight %": "The holding's share of total current market value.", + "Cash allocation": "The share of the portfolio currently held as cash.", + "Pricing warnings": "Positions that could not be priced from a trusted live source.", + "Pricing coverage": "How many holdings use live prices versus cost fallback values.", + "Cost fallback tickers": "Tickers valued at cost because a trusted live price was unavailable. Their real market value may be higher or lower.", + "Cost fallback positions": "Positions valued at cost because a trusted live price was unavailable. Their real market value may be higher or lower.", + "Reconciliation": "A check that computed ending cash matches the broker's reported cash total.", + "Cash reconciliation": "A check that computed ending cash matches the broker's reported cash total.", + "Computed ending cash": "Computed ending cash is the cash balance calculated from all cash operations in the report.", + "Broker 'Total' (cash)": "Broker 'Total' (cash) is the cash total reported by XTB at the end of the Cash Operations sheet.", + "Difference": "Difference shows computed cash minus broker-reported cash. A value near zero means the calculation reconciles.", + "Status": "Status tells you whether the reconciliation check passed or needs attention.", + "Gross income": "Dividends plus interest before dividend tax.", + "Dividend tax": "Tax withheld from dividend payments.", + "Net income": "Income remaining after dividend tax.", + "Tax drag": "Dividend tax as a share of gross income.", + "Net income yield": "Net income divided by the cost basis of current holdings.", + "Income mix": "How much income came from dividends versus interest.", + "Top 1 holding weight": "The largest single holding's share of current market value.", + "Top 3 holdings weight": "The three largest holdings' combined share of current market value.", + "Top 5 holdings weight": "The five largest holdings' combined share of current market value.", + "Positions above 20%": "Number of holdings that each exceed 20% of current market value.", + "Return Contribution": "How much each ticker contributed to total gain.", + "Total Contribution": "Realized plus unrealized profit or loss for the ticker.", + "Contribution %": "The ticker's contribution as a share of total gain.", + "FIFO": "First in, first out: older purchase lots are treated as sold first.", + "XIRR": "Annualized money-weighted return for cash flows on different dates.", +} + +_TERM_TOOLTIP_SEQ = 0 + + +def _label_html(label: str) -> str: + global _TERM_TOOLTIP_SEQ + text = str(label) + help_text = TERM_TOOLTIPS.get(text.strip()) + if not help_text: + return escape(text) + slug = re.sub(r"[^a-z0-9]+", "-", text.strip().lower()).strip("-") or "term" + _TERM_TOOLTIP_SEQ += 1 + tip_id = f"term-tip-{slug}-{_TERM_TOOLTIP_SEQ}" + return ( + f"" + f"{escape(text)}" + f"" + f"" + f"{escape(help_text)}" + ) + + +def _kv_table(rows: list[tuple[str, str]]) -> str: + out = [""] + for label, value in rows: + cls = " class='neg'" if value.strip().startswith("-") else "" + out.append(f"{escape(value)}") + out.append("
{_label_html(label)}
") + return "\n".join(out) + + +def _df_to_html( + df: pd.DataFrame, + formats: dict[str, str] | None = None, + colored_cols: set[str] | None = None, +) -> str: + """Render a DataFrame to an HTML table. + + ``colored_cols`` (column labels) get ``pos``/``neg`` cell classes based on + the cell's sign (green for >= 0, red for < 0) so P/L-style columns can be + highlighted independently of other numeric columns. + """ + formats = formats or {} + colored_cols = colored_cols or set() + if df.empty: + return "

No data.

" + header = "".join( + f"{_label_html(str(c))}" + for c in df.columns + ) + body = [] + for _, row in df.iterrows(): + cells = [] + for col in df.columns: + val = row[col] + spec = formats.get(col) + text = f"{val:{spec}}" if spec else ( + f"{val:,.2f}" if isinstance(val, float) else str(val) + ) + cls = "" + if col in colored_cols and isinstance(val, (int, float)): + cls = " class='pos'" if val >= 0 else " class='neg'" + elif isinstance(val, (int, float)) and val < 0: + cls = " class='neg'" + cells.append(f"{escape(text)}") + body.append("" + "".join(cells) + "") + return ( + "" + f"{header}{''.join(body)}
" + ) + + +SORTABLE_TABLES_SCRIPT = r""" +function _bootSortableTables() { + function cellValue(row, index) { + return (row.children[index] && row.children[index].textContent || '').trim(); + } + function numericValue(text) { + var normalized = text.replace(/[%\s,]/g, ''); + if (normalized === '') { return null; } + var value = Number(normalized); + return Number.isFinite(value) ? value : null; + } + function sortTable(table, index, direction) { + var tbody = table.tBodies[0]; + if (!tbody) { return; } + var rows = Array.prototype.slice.call(tbody.rows); + rows.sort(function (a, b) { + var av = cellValue(a, index); + var bv = cellValue(b, index); + var an = numericValue(av); + var bn = numericValue(bv); + var result; + if (an !== null && bn !== null) { + result = an - bn; + } else { + result = av.localeCompare(bv, undefined, {numeric: true, sensitivity: 'base'}); + } + return direction === 'asc' ? result : -result; + }); + rows.forEach(function (row) { tbody.appendChild(row); }); + } + document.querySelectorAll('table.data-table th[data-sortable="1"]').forEach(function (th) { + function activate() { + var table = th.closest('table'); + var current = th.getAttribute('aria-sort') || 'none'; + var next = current === 'ascending' ? 'desc' : 'asc'; + table.querySelectorAll('th[aria-sort]').forEach(function (other) { + other.setAttribute('aria-sort', 'none'); + }); + th.setAttribute('aria-sort', next === 'asc' ? 'ascending' : 'descending'); + sortTable(table, th.cellIndex, next); + } + th.addEventListener('click', activate); + th.addEventListener('keydown', function (event) { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + activate(); + } + }); + }); +} +if (document.readyState !== 'loading') { _bootSortableTables(); } +else { document.addEventListener('DOMContentLoaded', _bootSortableTables); } +""" + + +TABLE_FILTERS_SCRIPT = r""" +function _bootTableFilters() { + document.querySelectorAll('table.data-table').forEach(function (table, index) { + if (table.dataset.filterReady === '1') { return; } + table.dataset.filterReady = '1'; + var input = document.createElement('input'); + input.className = 'table-filter'; + input.type = 'search'; + input.placeholder = 'Filter table'; + input.setAttribute('aria-label', 'Filter table'); + input.setAttribute('data-table-filter', String(index)); + table.parentNode.insertBefore(input, table); + input.addEventListener('input', function () { + var body = table.tBodies[0]; + if (!body) { return; } + var query = input.value.trim().toLowerCase(); + Array.prototype.forEach.call(body.rows, function (row) { + var text = row.textContent.toLowerCase(); + row.style.display = !query || text.indexOf(query) !== -1 ? '' : 'none'; + }); + }); + }); +} +if (document.readyState !== 'loading') { _bootTableFilters(); } +else { document.addEventListener('DOMContentLoaded', _bootTableFilters); } +""" + + +def build_html_report( + currency: str, + meta: dict[str, str], + flows: dict[str, float], + ending_cash: float, + holdings: pd.DataFrame, + open_positions: pd.DataFrame, + realized: pd.DataFrame, + perf: dict[str, float], + evolution_cfg: dict | None, + review_cfg: dict, + as_of: date | None = None, + cost_fallback_tickers: list[str] | None = None, +) -> str: + cost_fallback_tickers = cost_fallback_tickers or [] + diff = perf["reconciliation_diff"] + recon_status = "OK" if (diff is None or abs(diff) < 0.01) else "CHECK" + + has_open = not (open_positions is None or open_positions.empty) + has_realized = not (realized.empty or realized["realized_pl"].abs().sum() == 0) + val_date = as_of.isoformat() if as_of else meta.get("period_to", "") + + flows_rows = [ + ("Deposits", money(flows["deposits"])), + ("Withdrawals", money(-flows["withdrawals"])), + ("Free-funds interest", money(flows["interest"])), + ("Dividends received", money(flows["dividends"])), + ("Dividend tax", money(flows["dividend_tax"])), + ("Invested (buys)", money(-flows["invested"])), + ("Proceeds (sales)", money(flows["proceeds"])), + ("FX conversion fees", money(flows["conversion_fees"])), + ("Fees / commissions", money(-flows["fees"])), + ("Ending cash balance", money(ending_cash)), + ] + perf_rows = [ + ("Portfolio value", f"{money(perf['portfolio_value'])} {currency}"), + (" of which market value", money(perf["market_value"])), + (" of which cash", money(perf["ending_cash"])), + (" cost basis", money(perf["cost_basis"])), + ("Net deposited", money(perf["net_deposited"])), + ("Unrealized P/L", money(perf["unrealized_pl"])), + ("Realized P/L", money(perf["realized_pl"])), + ("Income (int. + div.)", money(perf["income"])), + ("Total gain", money(perf["total_gain"])), + ("Total return", f"{perf['total_return_pct']:.2f} %"), + ( + "Money-weighted return", + ( + f"{perf['money_weighted_return_pct']:+.2f} %" + if perf.get("money_weighted_return_pct") is not None + else "n/a" + ), + ), + ("Income yield (on cost)", f"{perf['income_yield_pct']:.2f} %"), + ] + recon_rows = ( + [ + ("Computed ending cash", money(perf["ending_cash"])), + ("Broker 'Total' (cash)", money(perf["broker_total"])), + ("Difference", money(diff)), + ("Status", recon_status), + ] + if perf["broker_total"] + else [("Status", "Broker 'Total' not found")] + ) + + holdings_cols = ["ticker", "name", "shares", "last_price", "market_value", + "unrealized_pl", "return_pct", "weight_pct", "price_source"] + holdings_rename = { + "ticker": "Ticker", "name": "Name", "shares": "Shares", + "last_price": "Last Price", "market_value": "Market Value", + "unrealized_pl": "Unrealized P/L", "return_pct": "Return %", + "weight_pct": "Weight %", "price_source": "Src", + } + if not holdings.empty and set(holdings_cols).issubset(holdings.columns): + holdings_view = holdings[holdings_cols].rename(columns=holdings_rename) + else: + holdings_view = pd.DataFrame(columns=list(holdings_rename.values())) + + if has_open: + total_val = float(open_positions["current_value"].sum()) or 1.0 + op_view = open_positions.assign( + weight_pct=open_positions["current_value"] / total_val * 100 + ).rename(columns={ + "ticker": "Ticker", "current_value": "Market Value", + "unrealized_pl": "Unrealized P/L", "weight_pct": "Weight %", + }) + op_html = _df_to_html(op_view, {"Market Value": ".2f", "Unrealized P/L": ".2f", "Weight %": ".2f"}) + else: + op_view = pd.DataFrame(columns=["Ticker", "Market Value", "Unrealized P/L", "Weight %"]) + op_html = '

No open positions.

' + + realized_html = ( + _df_to_html(realized, {"realized_pl": ".2f"}) + if has_realized + else '

No realized gains/losses in this period.

' + ) + + summary_rows = build_executive_summary(holdings, realized, flows, perf) + concentration = analyze_concentration(holdings, perf) + concentration_rows = [ + ("Top 1 holding weight", f"{concentration['top_1_weight_pct']:.2f} %"), + ("Top 3 holdings weight", f"{concentration['top_3_weight_pct']:.2f} %"), + ("Top 5 holdings weight", f"{concentration['top_5_weight_pct']:.2f} %"), + ("Cash allocation", f"{concentration['cash_weight_pct']:.2f} %"), + ("Positions above 20%", str(concentration["positions_over_20_pct"])), + ("Priced at cost", str(concentration["cost_priced_positions"])), + ("Risk note", str(concentration["risk_note"])), + ] + income_quality = analyze_income_quality(flows, perf) + dividend_tax_display = ( + 0.0 + if abs(float(income_quality["dividend_tax"])) < 0.005 + else -float(income_quality["dividend_tax"]) + ) + income_quality_rows = [ + ("Gross income", money(float(income_quality["gross_income"]))), + ("Dividend tax", money(dividend_tax_display)), + ("Net income", money(float(income_quality["net_income"]))), + ("Tax drag", f"{income_quality['tax_drag_pct']:.2f} %"), + ("Net income yield", f"{income_quality['net_income_yield_pct']:.2f} %"), + ("Income mix", str(income_quality["income_mix"])), + ] + methodology_rows = analyze_methodology_quality(holdings, perf) + guide_rows = beginner_guide_rows() + contributions = analyze_return_contributions(holdings, realized, perf) + contribution_html = _df_to_html( + contributions, + { + "Market Value": ".2f", + "Unrealized P/L": ".2f", + "Realized P/L": ".2f", + "Total Contribution": ".2f", + "Contribution %": ".2f", + }, + colored_cols={"Unrealized P/L", "Realized P/L", "Total Contribution", "Contribution %"}, + ) + + charts_block = html_charts.render_charts_block( + evolution_cfg, review_cfg, currency) + + return f""" + + + + +Portfolio Review — {escape(meta.get('account', ''))} + + + +
+
+

Portfolio Review

+
XTB account {escape(meta.get('account', '?'))} · + {escape(meta.get('period_from', '?'))} → {escape(meta.get('period_to', '?'))} · {currency}
+
Generated {escape(datetime.now().strftime('%Y-%m-%d %H:%M:%S'))} · Valuation date {escape(val_date)}
+
+ + + +
+
{_label_html('Portfolio value')}
+
{money(perf['portfolio_value'])}
+
{_label_html('Net deposited')}
+
{money(perf['net_deposited'])}
+
{_label_html('Total gain')}
+
+ {money(perf['total_gain'])}
+
{_label_html('Total return')}
+
+ {perf['total_return_pct']:+.2f}%
+
+ +
+
+

Executive Summary

+ {_kv_table(summary_rows)} +
+
+

Concentration & Risk

+ {_kv_table(concentration_rows)} +
+
+

Income Quality

+ {_kv_table(income_quality_rows)} +
+
+

Methodology & Data Quality

+ {_kv_table(methodology_rows)} +
+
+

Beginner Guide

+ {_kv_table(guide_rows)} +
+
+

Return Contribution

+ {contribution_html} +
+
+ + {charts_block} + +
+
+

Holdings (live market value)

+ {_df_to_html(holdings_view, {'Last Price':'.4f', 'Market Value':'.2f', 'Unrealized P/L':'.2f', 'Return %':'.2f', 'Weight %':'.2f'}, colored_cols={'Unrealized P/L', 'Return %'})} +
+
+

Cash flows

+ {_kv_table(flows_rows)} +
+ +
+

Open positions (market value)

+ {op_html} +
+ +
+

Realized P/L (closed positions)

+ {realized_html} +
+ +
+

Performance

+ {_kv_table(perf_rows)} +
+ +
+

Reconciliation

+ {_kv_table(recon_rows)} +

XTB "Total" row reflects ending free cash; reconciled against computed cash balance.

+
+
+ +
Generated from {escape(REPORT_FILE.name)} on {escape(datetime.now().strftime('%Y-%m-%d %H:%M:%S'))} · live prices via yfinance (as of {escape(val_date)}){' · priced at cost: ' + escape(', '.join(cost_fallback_tickers)) if cost_fallback_tickers else ''}{'
' + '
'.join(escape(f'{t}: {COST_FALLBACK_NOTES[t]}') for t in cost_fallback_tickers if t in COST_FALLBACK_NOTES) if any(t in COST_FALLBACK_NOTES for t in cost_fallback_tickers) else ''}
+
+ + +""" + + +def _output_name(descriptor: str, ext: str) -> Path: + """Path in RESULTS_DIR named after the input report's stem. + + e.g. input ``EUR_SAMPLE_2026-01-01_2026-06-20.xlsx`` with + ``("review", "html")`` -> ``results/EUR_SAMPLE_2026-01-01_2026-06-20_review.html``. + Falls back to a ``portfolio`` stem when ``REPORT_FILE`` is unset. + """ + stem = REPORT_FILE.stem if REPORT_FILE else "portfolio" + return RESULTS_DIR / f"{stem}_{descriptor}.{ext}" + + +def write_html_report(html: str, path: Path | str | None = None) -> Path: + path = Path(path) if path else _output_name("review", "html") + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(html, encoding="utf-8") + return path + + +def _persist_outputs( + holdings: pd.DataFrame, + open_positions: pd.DataFrame, + realized: pd.DataFrame, + flows: dict[str, float], + perf: dict[str, float], + income_by_period: pd.Series, + evolution_df: pd.DataFrame | None = None, + as_of: date | None = None, + write_csv: bool = True, +) -> None: + RESULTS_DIR.mkdir(parents=True, exist_ok=True) + if not write_csv: + return + out_holdings = holdings.drop( + columns=[c for c in holdings.columns if c.startswith("_")], errors="ignore" + ) + out_holdings.to_csv(_output_name("holdings", "csv"), index=False) + + op_out = open_positions.copy() + if as_of is not None: + op_out = op_out.assign(as_of=as_of.isoformat()) + op_out.to_csv(_output_name("open_positions", "csv"), index=False) + realized.to_csv(_output_name("realized_pl", "csv"), index=False) + pd.DataFrame([flows]).to_csv(_output_name("cash_flows", "csv"), index=False) + perf_row = dict(perf) + if as_of is not None: + perf_row["valuation_as_of"] = as_of.isoformat() + pd.DataFrame([perf_row]).to_csv(_output_name("performance", "csv"), index=False) + income_by_period.rename("income").to_csv(_output_name("income", "csv")) + if evolution_df is not None and not evolution_df.empty: + evolution_df.to_csv(_output_name("evolution", "csv")) + + +def main( + xlsx_path: Path | str | None = None, write_csv: bool = False +) -> None: + global REPORT_FILE + REPORT_FILE = resolve_report_file(xlsx_path) + RESULTS_DIR.mkdir(parents=True, exist_ok=True) + currency = detect_currency() + meta = load_meta() + as_of = _parse_as_of(meta) + positions, cash_ops, open_positions_raw, broker_total = load_data() + + trades = extract_trades(cash_ops) + holdings, realized_from_trades = analyze_holdings(trades) + realized = analyze_realized(positions, realized_from_trades) + + prices = fetch_prices( + holdings["ticker"].tolist(), as_of, currency + ) if not holdings.empty else {} + valued_holdings = valuate_holdings(holdings, prices) + cost_fallback_tickers = list( + valued_holdings.loc[valued_holdings["price_source"] == "cost", "ticker"] + ) + + open_positions = analyze_open_positions(open_positions_raw, valued_holdings) + flows, ending_cash = analyze_cash_flows(cash_ops, trades) + dividends, interest, income_by_period = analyze_income(cash_ops) + perf = compute_performance( + holdings, open_positions, realized, flows, ending_cash, broker_total, + cash_ops=cash_ops, terminal_date=as_of, + ) + + print_report( + currency, meta, flows, ending_cash, valued_holdings, + open_positions, realized, perf, dividends, interest, + as_of=as_of, cost_fallback_tickers=cost_fallback_tickers, + ) + + # Evolution chart: cost vs realized + unrealized value over time. + # Only live-valued tickers get history; cost-fallback ones stay flat. + evolution_df = pd.DataFrame() + live_tickers = list( + valued_holdings.loc[valued_holdings["price_source"] == "live", "ticker"] + ) + first_trade_date = min( + (t.date for t in trades if t.date is not None), default=None + ) + if live_tickers and first_trade_date is not None: + price_history = fetch_price_history( + live_tickers, first_trade_date.date(), as_of, currency + ) + evolution_df = build_evolution_series(trades, price_history, as_of) + + _persist_outputs( + valued_holdings, open_positions, realized, flows, perf, + income_by_period, evolution_df, as_of, write_csv=write_csv, + ) + + # Charts: interactive Chart.js, inlined into the self-contained HTML. + evolution_cfg = html_charts.evolution_chart_config(evolution_df, currency) + review_cfg = html_charts.review_charts_config( + valued_holdings, flows, income_by_period, currency) + + # HTML report (self-contained, offline). + html = build_html_report( + currency, meta, flows, ending_cash, valued_holdings, + open_positions, realized, perf, evolution_cfg, review_cfg, + as_of=as_of, cost_fallback_tickers=cost_fallback_tickers, + ) + out = write_html_report(html) + print(f"HTML report written to {out}") + + +def main_cli() -> None: + parser = argparse.ArgumentParser( + description="Generate a portfolio review from an XTB .xlsx report." + ) + parser.add_argument( + "input", nargs="?", default=None, + help="Path to the XTB .xlsx report. If omitted, the single .xlsx in " + "the current directory is used automatically.", + ) + parser.add_argument( + "--csv", action="store_true", + help="Also write CSV outputs (holdings, cash flows, performance, etc.). " + "By default only the HTML report is written.", + ) + args = parser.parse_args() + try: + main(args.input, write_csv=args.csv) + except (FileNotFoundError, ValueError) as exc: + parser.error(str(exc)) + + +if __name__ == "__main__": + main_cli() diff --git a/skills/xtb-wealthfolio-export/scripts/requirements.txt b/skills/xtb-wealthfolio-export/scripts/requirements.txt new file mode 100644 index 0000000..3ccc673 --- /dev/null +++ b/skills/xtb-wealthfolio-export/scripts/requirements.txt @@ -0,0 +1,4 @@ +pandas>=2.2,<4 +numpy>=1.26,<3 +openpyxl>=3.1,<4 +yfinance>=0.2,<2 diff --git a/skills/xtb-wealthfolio-export/scripts/setup-env.sh b/skills/xtb-wealthfolio-export/scripts/setup-env.sh new file mode 100755 index 0000000..6980d57 --- /dev/null +++ b/skills/xtb-wealthfolio-export/scripts/setup-env.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +VENV_DIR="${VENV_DIR:-.venv}" +PYTHON_BOOTSTRAP="${PYTHON:-python3}" + +if [[ ! -x "$VENV_DIR/bin/python" ]]; then + "$PYTHON_BOOTSTRAP" -m venv "$VENV_DIR" +fi + +"$VENV_DIR/bin/python" -m pip install --upgrade pip +"$VENV_DIR/bin/python" -m pip install -r "$SCRIPT_DIR/requirements.txt" + +echo "Environment ready: $VENV_DIR" diff --git a/skills/xtb-wealthfolio-export/scripts/validate-export.sh b/skills/xtb-wealthfolio-export/scripts/validate-export.sh new file mode 100755 index 0000000..450cb9f --- /dev/null +++ b/skills/xtb-wealthfolio-export/scripts/validate-export.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +if [[ -n "${PYTHON:-}" ]]; then + PYTHON_BIN="$PYTHON" +elif [[ -x ".venv/bin/python" ]]; then + PYTHON_BIN=".venv/bin/python" +else + PYTHON_BIN="python3" +fi + +"$PYTHON_BIN" - < 100000 # minified UMD is ~200 KB + assert "Chart" in src # Chart.js UMD defines Chart + + +def test_load_chartjs_inline_missing_file_raises(tmp_path, monkeypatch): + import pytest + missing = tmp_path / "nope.js" + monkeypatch.setattr(html_charts, "CHARTJS_PATH", missing) + with pytest.raises(FileNotFoundError, match=r"(?i)(assets|chart)"): + html_charts.load_chartjs_inline() + + +import pandas as pd + + +def _evolution_df(): + idx = pd.to_datetime(["2024-01-01", "2024-02-01", "2024-03-01"]) + return pd.DataFrame( + {"cost": [1000.0, 1000.0, 1000.0], + "realized_pl": [0.0, 10.0, 20.0], + "total_value": [1000.0, 1050.0, 1080.0]}, + index=idx, + ) + + +def test_evolution_chart_config_empty_returns_none(): + assert html_charts.evolution_chart_config(pd.DataFrame(), "EUR") is None + assert html_charts.evolution_chart_config(None, "EUR") is None + + +def test_evolution_chart_config_builds_line_chart(): + cfg = html_charts.evolution_chart_config(_evolution_df(), "EUR") + assert cfg["type"] == "line" + assert cfg["data"]["labels"] == ["2024-01-01", "2024-02-01", "2024-03-01"] + ds = cfg["data"]["datasets"] + assert len(ds) == 3 + assert ds[0]["label"] == "Cost (invested)" + assert ds[0]["borderColor"] == "#6b7280" + assert ds[1]["label"] == "Value (realized + unrealized)" + assert ds[1]["borderColor"] == "#2c5282" + assert ds[2]["label"] == "Cumulative realized P/L" + assert ds[2]["borderColor"] == "#f39c12" + assert ds[2]["borderDash"] == [6, 4] + assert cfg["options"]["responsive"] is True + assert cfg["options"]["maintainAspectRatio"] is False + + +def _holdings_df(): + return pd.DataFrame( + {"ticker": ["A", "B", "C"], + "name": ["Alpha", "Beta", "Gamma"], + "market_value": [1200.0, 0.0, 800.0]} + ) + + +def _flows(): + return {"deposits": 1000.0, "withdrawals": 50.0, "interest": 0.0, + "dividends": 4.0, "dividend_tax": 0.0, "conversion_fees": 0.0, + "invested": 1500.0, "proceeds": 0.0, "fees": 1.0} + + +def _empty_flows(): + return {"deposits": 0.0, "withdrawals": 0.0, "interest": 0.0, + "dividends": 0.0, "dividend_tax": 0.0, "conversion_fees": 0.0, + "invested": 0.0, "proceeds": 0.0, "fees": 0.0} + + +def test_review_holdings_doughnut_filters_zero(): + cfg = html_charts.review_charts_config( + _holdings_df(), _flows(), pd.Series(dtype=float), "EUR") + h = cfg["holdings"] + assert h["type"] == "doughnut" + assert h["data"]["labels"] == ["A", "C"] # B has market_value 0, dropped + assert h["data"]["datasets"][0]["data"] == [1200.0, 800.0] + assert len(h["data"]["datasets"][0]["backgroundColor"]) == len(h["data"]["datasets"][0]["data"]) + + +def test_review_cashflows_signed_and_filtered(): + cfg = html_charts.review_charts_config( + _holdings_df(), _flows(), pd.Series(dtype=float), "EUR") + cf = cfg["cashflows"] + assert cf["type"] == "bar" + items = dict(zip(cf["data"]["labels"], cf["data"]["datasets"][0]["data"])) + assert items["Withdrawals"] == -50.0 # negated + assert items["Invested"] == -1500.0 # negated + assert items["Fees"] == -1.0 # negated + assert items["Dividends"] == 4.0 + # near-zero items (interest 0, div.tax 0, fx fees 0, proceeds 0) dropped + assert "Interest" not in items + assert "Proceeds" not in items + colors = dict(zip(cf["data"]["labels"], cf["data"]["datasets"][0]["backgroundColor"])) + assert colors["Invested"] == "#e74c3c" # negative -> red + assert colors["Deposits"] == "#2ecc71" # positive -> green + + +def test_review_income_bar_mirrors_series(): + income = pd.Series([1.5, 2.5], index=["2024-01", "2024-02"]) + cfg = html_charts.review_charts_config( + _holdings_df(), _empty_flows(), income, "EUR") + inc = cfg["income"] + assert inc["type"] == "bar" + assert inc["data"]["labels"] == ["2024-01", "2024-02"] + assert inc["data"]["datasets"][0]["data"] == [1.5, 2.5] + assert inc["data"]["datasets"][0]["backgroundColor"] == "#3498db" + + +def test_review_empty_holdings_and_flows_are_none(): + empty_holdings = pd.DataFrame(columns=["ticker", "market_value"]) + cfg = html_charts.review_charts_config( + empty_holdings, _empty_flows(), pd.Series(dtype=float), "EUR") + assert cfg["holdings"] is None + assert cfg["cashflows"] is None + assert cfg["income"] is None + + +def test_review_holdings_doughnut_cycles_colors_past_palette(): + many = pd.DataFrame({ + "ticker": [f"T{i}" for i in range(12)], + "market_value": [100.0] * 12, + }) + cfg = html_charts.review_charts_config( + many, _flows(), pd.Series(dtype=float), "EUR") + h = cfg["holdings"] + assert len(h["data"]["labels"]) == 12 + bg = h["data"]["datasets"][0]["backgroundColor"] + assert len(bg) == 12 # not truncated to 10 + assert bg[0] == bg[10] # cycles: index 10 wraps to index 0 + + +def test_review_cashflows_near_zero_boundary(): + flows = {"deposits": 0.0, "withdrawals": 0.0, "interest": 2e-9, + "dividends": 0.0, "dividend_tax": 0.0, "conversion_fees": 0.0, + "invested": 0.0, "proceeds": 0.0, "fees": 0.0} + cfg = html_charts.review_charts_config( + _holdings_df(), flows, pd.Series(dtype=float), "EUR") + # interest 2e-9 > 1e-9 stays; everything else is <= 1e-9 and dropped + assert cfg["cashflows"]["data"]["labels"] == ["Interest"] + + +def test_review_holdings_all_zero_returns_none(): + all_zero = pd.DataFrame({"ticker": ["A", "B"], "market_value": [0.0, 0.0]}) + cfg = html_charts.review_charts_config( + all_zero, _flows(), pd.Series(dtype=float), "EUR") + assert cfg["holdings"] is None + + +def _full_review_cfg(): + return html_charts.review_charts_config( + _holdings_df(), _flows(), + pd.Series([1.0], index=["2024-01"]), "EUR") + + +def _full_evolution_cfg(): + return html_charts.evolution_chart_config(_evolution_df(), "EUR") + + +def test_render_charts_block_empty_when_nothing_to_show(): + empty_review = html_charts.review_charts_config( + pd.DataFrame(columns=["ticker", "market_value"]), + _empty_flows(), pd.Series(dtype=float), "EUR") + block = html_charts.render_charts_block(None, empty_review, "EUR") + assert block == "" + + +def test_render_charts_block_contains_canvases_scripts_and_data(): + block = html_charts.render_charts_block( + _full_evolution_cfg(), _full_review_cfg(), "EUR") + assert "" in block + assert "" in block + assert "" in block + assert "" in block + assert "id='chart-data'" in block + # inlined Chart.js bundle present + assert html_charts.load_chartjs_inline()[:200] in block + # render script with gain/loss plugin present + assert "gainLoss" in block + assert "beforeDatasetsDraw" in block + assert "new Chart(" in block + # no PNG embedding + assert "data:image/png;base64" not in block + + +def test_render_charts_block_omits_evolution_when_none(): + block = html_charts.render_charts_block(None, _full_review_cfg(), "EUR") + assert "" not in block + assert "" in block + + +def test_render_charts_block_json_payload_parses(): + block = html_charts.render_charts_block( + _full_evolution_cfg(), _full_review_cfg(), "EUR") + m = re.search( + r"", + block, re.S) + assert m, "chart-data JSON block not found" + payload = json.loads(m.group(1)) + assert set(payload) == {"currency", "evolution", "holdings", "cashflows", "income"} + assert payload["currency"] == "EUR" + assert payload["evolution"]["type"] == "line" + assert payload["holdings"]["type"] == "doughnut" + + +def test_render_charts_block_empty_state_fallbacks(): + # holdings None, cashflows None, income None — but evolution present so the + # Charts card renders with muted fallbacks for holdings/cashflows. + review = html_charts.review_charts_config( + pd.DataFrame(columns=["ticker", "market_value"]), + _empty_flows(), pd.Series(dtype=float), "EUR") + block = html_charts.render_charts_block(_full_evolution_cfg(), review, "EUR") + assert "No open positions." in block + assert "No cash flows." in block + assert "" not in block diff --git a/test_portfolio.py b/test_portfolio.py new file mode 100644 index 0000000..79539c0 --- /dev/null +++ b/test_portfolio.py @@ -0,0 +1,1330 @@ +import pandas as pd +import pytest + +import main +from main import ( + Trade, + analyze_cash_flows, + analyze_holdings, + analyze_income, + analyze_open_positions, + analyze_realized, + analyze_concentration, + analyze_income_quality, + analyze_methodology_quality, + analyze_return_contributions, + build_executive_summary, + build_external_cash_flows, + clean_columns, + compute_performance, + compute_xirr, + build_evolution_series, + detect_currency, + extract_trades, + find_column, + parse_numeric, + parse_quantity, + valuate_holdings, +) + + +# --------------------------------------------------------------------------- +# Fixtures / helpers +# --------------------------------------------------------------------------- +def make_cash_ops(rows): + cols = ["Type", "Instrument", "Time", "Amount", "Comment", "Product"] + return clean_columns(pd.DataFrame(rows, columns=cols)) + + +def cash_row(type_, instrument, amount, comment="", time="2026-01-15 10:00:00"): + return [type_, instrument, time, amount, comment, "My Trades"] + + +# --------------------------------------------------------------------------- +# Generic helpers +# --------------------------------------------------------------------------- +class TestHelpers: + def test_clean_columns_normalizes(self): + df = pd.DataFrame(columns=["Open Price", "Profit/Loss", " Ticker "]) + out = clean_columns(df) + assert list(out.columns) == ["open_price", "profitloss", "ticker"] + + def test_find_column_exact_and_partial(self): + df = pd.DataFrame(columns=["ticker", "open_price"]) + assert find_column(df, ["ticker"]) == "ticker" + assert find_column(df, ["price"]) == "open_price" + assert find_column(df, ["missing"], required=False) is None + + def test_find_column_required_raises(self): + df = pd.DataFrame(columns=["a"]) + with pytest.raises(ValueError): + find_column(df, ["b"]) + + def test_parse_numeric_european_and_dirty(self): + # Comma-decimal supported; thousand-separators are intentionally NOT + # supported (ambiguous with decimal dot). + s = pd.Series(["1234,56", "-1809", " 12,5 €", "", "N/A"]) + out = parse_numeric(s).tolist() + assert out == [1234.56, -1809.0, 12.5, 0.0, 0.0] + + +# --------------------------------------------------------------------------- +# parse_quantity +# --------------------------------------------------------------------------- +class TestParseQuantity: + def test_integer(self): + assert parse_quantity("6") == 6.0 + + def test_decimal_comma(self): + assert parse_quantity("12,5") == 12.5 + + def test_fraction(self): + assert parse_quantity("1/100") == 0.01 + + def test_zero_denominator(self): + assert parse_quantity("5/0") == 0.0 + + def test_garbage(self): + assert parse_quantity("abc") == 0.0 + + +# --------------------------------------------------------------------------- +# detect_currency +# --------------------------------------------------------------------------- +class TestDetectCurrency: + def test_from_filename(self, monkeypatch): + monkeypatch.setattr(main, "REPORT_FILE", main.Path("USD_12345.xlsx")) + assert detect_currency() == "USD" + + def test_default_eur(self, monkeypatch): + monkeypatch.setattr(main, "REPORT_FILE", main.Path("report.xlsx")) + assert detect_currency() == "EUR" + + +# --------------------------------------------------------------------------- +# extract_trades +# --------------------------------------------------------------------------- +class TestExtractTrades: + def test_parses_open_buy(self): + ops = make_cash_ops([ + cash_row("Stock purchase", "S&P 500", -14.31, "OPEN BUY 1/100 @ 14.3130"), + ]) + trades = extract_trades(ops) + assert len(trades) == 1 + t = trades[0] + assert t.action == "open" + assert t.side == "buy" + # Fixture has no "Ticker" column, so find_column falls back to Instrument. + assert t.ticker == "S&P 500" + assert t.value == pytest.approx(14.31) + assert t.price == pytest.approx(14.313) + # split-fill notation uses the executed numerator, not rounded cash / price + assert t.shares == pytest.approx(1.0) + + def test_split_fill_uses_numerator_not_cash_over_price(self): + ops = make_cash_ops([ + cash_row("Stock purchase", "A", -14.31, "OPEN BUY 1/100 @ 14.3130"), + ]) + trades = extract_trades(ops) + assert trades[0].shares == pytest.approx(1.0) + + def test_ignores_deposits_and_interest(self): + ops = make_cash_ops([ + cash_row("Deposit", "", 4000, "JP_MORGAN deposit"), + cash_row("Free funds interest", "", 0.01), + cash_row("Stock purchase", "AAPL", -100, "OPEN BUY 1 @ 100.00"), + ]) + trades = extract_trades(ops) + assert len(trades) == 1 + assert trades[0].ticker == "AAPL" + + def test_excludes_dividend_type(self): + ops = make_cash_ops([ + cash_row("Dividend", "AAPL", 5.0, "Dividend payment"), + ]) + assert extract_trades(ops) == [] + + def test_close_sell_recognized(self): + ops = make_cash_ops([ + cash_row("Stock sale", "AAPL", 110.0, "CLOSE SELL 1 @ 110.00"), + ]) + trades = extract_trades(ops) + assert trades[0].action == "close" + assert trades[0].side == "sell" + + def test_close_buy_stock_sell_is_sale_close(self): + ops = make_cash_ops([ + cash_row("Stock sell", "A", 150.0, "CLOSE BUY 1 @ 150.00"), + ]) + trades = extract_trades(ops) + assert len(trades) == 1 + assert trades[0].action == "close" + assert trades[0].side == "sell" + assert trades[0].value == pytest.approx(150.0) + + def test_missing_columns_returns_empty(self): + ops = clean_columns(pd.DataFrame(columns=["a", "b"])) + assert extract_trades(ops) == [] + + def test_prefers_ticker_column(self): + # Real XTB exports carry both `Ticker` (e.g. SPYL.DE) and `Instrument` + # (descriptive). The real symbol must win so grouping/price lookup work. + ops = clean_columns(pd.DataFrame( + [["Stock purchase", "SPYL.DE", "S&P 500", "2026-01-15 10:00:00", + -15.73, "OPEN BUY 1 @ 15.7300", "My Trades"]], + columns=["Type", "Ticker", "Instrument", "Time", "Amount", + "Comment", "Product"], + )) + trades = extract_trades(ops) + assert trades[0].ticker == "SPYL.DE" + assert trades[0].name == "S&P 500" + + +# --------------------------------------------------------------------------- +# analyze_holdings (FIFO realized P/L) +# --------------------------------------------------------------------------- +class TestAnalyzeHoldings: + def test_open_only(self): + trades = [ + Trade("AAPL", "open", "buy", shares=10, price=100.0, value=1000.0), + Trade("MSFT", "open", "buy", shares=5, price=200.0, value=1000.0), + ] + h, _ = analyze_holdings(trades) + assert set(h["ticker"]) == {"AAPL", "MSFT"} + aapl = h[h["ticker"] == "AAPL"].iloc[0] + assert aapl["shares"] == 10.0 + assert aapl["cost_basis"] == pytest.approx(1000.0) + assert aapl["avg_price"] == pytest.approx(100.0) + + def test_allocation_pct_sums_to_100(self): + trades = [ + Trade("A", "open", "buy", shares=10, price=100.0, value=1000.0), + Trade("B", "open", "buy", shares=5, price=200.0, value=1000.0), + ] + h, _ = analyze_holdings(trades) + assert h["allocation_pct"].sum() == pytest.approx(100.0) + + def test_partial_close_fifo_realized(self): + # Buy 10 @ 100, then close 4 @ 150 -> realized = 4*50 = 200, 6 left. + trades = [ + Trade("AAPL", "open", "buy", shares=10, price=100.0, value=1000.0), + Trade("AAPL", "close", "sell", shares=4, price=150.0, value=600.0), + ] + h, realized = analyze_holdings(trades) + aapl = h[h["ticker"] == "AAPL"].iloc[0] + assert aapl["shares"] == pytest.approx(6.0) + assert aapl["cost_basis"] == pytest.approx(600.0) + assert realized[realized["ticker"] == "AAPL"]["realized_pl"].iloc[0] == pytest.approx(200.0) + + def test_full_close_drops_from_holdings_keeps_realized(self): + trades = [ + Trade("AAPL", "open", "buy", shares=10, price=100.0, value=1000.0), + Trade("AAPL", "close", "sell", shares=10, price=120.0, value=1200.0), + ] + h, realized = analyze_holdings(trades) + assert h.empty # fully closed -> not an open holding + assert set(realized["ticker"]) == {"AAPL"} + assert realized["realized_pl"].iloc[0] == pytest.approx(200.0) + + def test_full_close_keeps_other_tickers(self): + trades = [ + Trade("AAPL", "open", "buy", shares=10, price=100.0, value=1000.0), + Trade("AAPL", "close", "sell", shares=10, price=130.0, value=1300.0), + Trade("MSFT", "open", "buy", shares=2, price=50.0, value=100.0), + ] + h, realized = analyze_holdings(trades) + assert set(h["ticker"]) == {"MSFT"} + assert set(realized["ticker"]) == {"AAPL"} + + def test_multi_lot_fifo(self): + # Lot1: 5 @ 100, Lot2: 5 @ 110. Close 6 -> 5 from lot1 + 1 from lot2. + # cost = 500 + 110 = 610. proceeds 6*120=720. realized = 110. + trades = [ + Trade("X", "open", "buy", shares=5, price=100.0, value=500.0), + Trade("X", "open", "buy", shares=5, price=110.0, value=550.0), + Trade("X", "close", "sell", shares=6, price=120.0, value=720.0), + ] + h, realized = analyze_holdings(trades) + x = h[h["ticker"] == "X"].iloc[0] + assert x["shares"] == pytest.approx(4.0) + # remaining: 4 @ 110 = 440 + assert x["cost_basis"] == pytest.approx(440.0) + assert realized[realized["ticker"] == "X"]["realized_pl"].iloc[0] == pytest.approx(110.0) + + def test_empty(self): + h, realized = analyze_holdings([]) + assert h.empty + assert realized.empty + + +# --------------------------------------------------------------------------- +# analyze_realized +# --------------------------------------------------------------------------- +class TestAnalyzeRealized: + def test_from_closed_positions_sheet(self): + positions = clean_columns( + pd.DataFrame( + { + "Instrument": ["AAPL", "MSFT"], + "Profit/Loss": [50.0, -20.0], + } + ) + ) + out = analyze_realized(positions, pd.DataFrame()) + assert len(out) == 2 + assert out["realized_pl"].sum() == pytest.approx(30.0) + + def test_fallback_to_trades_realized(self): + realized_from_trades = pd.DataFrame( + {"ticker": ["AAPL"], "realized_pl": [200.0]} + ) + out = analyze_realized(pd.DataFrame(), realized_from_trades) + assert out["realized_pl"].iloc[0] == pytest.approx(200.0) + + +# --------------------------------------------------------------------------- +# analyze_cash_flows +# --------------------------------------------------------------------------- +class TestAnalyzeCashFlows: + def test_categorization(self): + ops = make_cash_ops([ + cash_row("Stock purchase", "A", -100, "OPEN BUY 1 @ 100.00"), + cash_row("Deposit", "", 1000, "deposit"), + cash_row("Withdrawal", "", -200, "payout"), + cash_row("Free funds interest", "", 0.5), + cash_row("Dividend", "A", 10.0, "Dividend"), + cash_row("Dividend tax", "A", -1.5, "Dividend tax"), + cash_row("Currency conversion", "", -2.0, "fx"), + ]) + trades = extract_trades(ops) + flows, ending = analyze_cash_flows(ops, trades) + assert flows["deposits"] == pytest.approx(1000.0) + assert flows["withdrawals"] == pytest.approx(200.0) + assert flows["interest"] == pytest.approx(0.5) + assert flows["dividends"] == pytest.approx(10.0) + assert flows["dividend_tax"] == pytest.approx(-1.5) + assert flows["conversion_fees"] == pytest.approx(-2.0) + assert flows["invested"] == pytest.approx(100.0) + # ending = 1000 - 200 + 0.5 + 10 - 1.5 - 100 + (-2) = 707 + assert ending == pytest.approx(707.0) + + def test_sale_proceeds(self): + ops = make_cash_ops([ + cash_row("Stock purchase", "A", -100, "OPEN BUY 1 @ 100.00"), + cash_row("Stock sale", "A", 150, "CLOSE SELL 1 @ 150.00"), + ]) + trades = extract_trades(ops) + flows, ending = analyze_cash_flows(ops, trades) + assert flows["invested"] == pytest.approx(100.0) + assert flows["proceeds"] == pytest.approx(150.0) + assert ending == pytest.approx(50.0) + + def test_close_buy_stock_sell_counts_as_proceeds(self): + ops = make_cash_ops([ + cash_row("Stock purchase", "A", -100.0, "OPEN BUY 1 @ 100.00"), + cash_row("Stock sell", "A", 150.0, "CLOSE BUY 1 @ 150.00"), + ]) + trades = extract_trades(ops) + flows, ending = analyze_cash_flows(ops, trades) + assert flows["invested"] == pytest.approx(100.0) + assert flows["proceeds"] == pytest.approx(150.0) + assert ending == pytest.approx(50.0) + + +# --------------------------------------------------------------------------- +# analyze_income +# --------------------------------------------------------------------------- +class TestAnalyzeIncome: + def test_dividends_interest_and_monthly(self): + ops = make_cash_ops([ + cash_row("Dividend", "A", 10.0, "Dividend", "2026-01-10 09:00:00"), + cash_row("Free funds interest", "", 0.5, "", "2026-02-01 09:00:00"), + cash_row("Deposit", "", 1000.0, "", "2026-01-05 09:00:00"), + ]) + dividends, interest, series = analyze_income(ops) + assert dividends == pytest.approx(10.0) + assert interest == pytest.approx(0.5) + assert "2026-01" in series.index + assert series["2026-01"] == pytest.approx(10.0) + assert series["2026-02"] == pytest.approx(0.5) + + +# --------------------------------------------------------------------------- +# analyze_open_positions +# --------------------------------------------------------------------------- +class TestAnalyzeOpenPositions: + def test_aggregates_value_and_pl(self): + op = clean_columns( + pd.DataFrame( + { + "Instrument": ["A", "A", "B"], + "Current Value": [100.0, 50.0, 200.0], + "Profit/Loss": [10.0, -5.0, 20.0], + } + ) + ) + out = analyze_open_positions(op) + a = out[out["ticker"] == "A"].iloc[0] + assert a["current_value"] == pytest.approx(150.0) + assert a["unrealized_pl"] == pytest.approx(5.0) + assert out["current_value"].sum() == pytest.approx(350.0) + + def test_empty(self): + out = analyze_open_positions(pd.DataFrame()) + assert out.empty + + def test_falls_back_to_valued_holdings(self): + # No XTB Open Positions sheet, but live-valued holdings provided. + valued = pd.DataFrame( + {"ticker": ["A", "B"], + "market_value": [1200.0, 800.0], + "unrealized_pl": [200.0, -50.0]} + ) + out = analyze_open_positions(pd.DataFrame(), valued) + assert not out.empty + a = out[out["ticker"] == "A"].iloc[0] + assert a["current_value"] == pytest.approx(1200.0) + assert a["unrealized_pl"] == pytest.approx(200.0) + assert out["current_value"].sum() == pytest.approx(2000.0) + + +# --------------------------------------------------------------------------- +# compute_performance +# --------------------------------------------------------------------------- +class TestComputePerformance: + def test_cost_basis_mode(self): + holdings = pd.DataFrame( + {"ticker": ["A"], "cost_basis": [1000.0]} + ) + flows = { + "deposits": 1500.0, "withdrawals": 0.0, "interest": 5.0, + "dividends": 0.0, "dividend_tax": 0.0, "conversion_fees": 0.0, + "invested": 1000.0, "proceeds": 0.0, "fees": 0.0, + } + perf = compute_performance(holdings, pd.DataFrame(), pd.DataFrame(), flows, 505.0, 505.0) + assert perf["market_value"] == pytest.approx(1000.0) # cost basis (no live sheet) + assert perf["unrealized_pl"] == 0.0 + assert perf["portfolio_value"] == pytest.approx(1505.0) + assert perf["net_deposited"] == pytest.approx(1500.0) + # total_gain = unrealized + realized + income = 0 + 0 + 5 = 5 + assert perf["total_gain"] == pytest.approx(5.0) + assert perf["reconciliation_diff"] == pytest.approx(0.0) + + def test_live_market_value(self): + holdings = pd.DataFrame({"ticker": ["A"], "cost_basis": [1000.0]}) + op = pd.DataFrame({"ticker": ["A"], "current_value": [1200.0], "unrealized_pl": [200.0]}) + realized = pd.DataFrame({"ticker": ["B"], "realized_pl": [50.0]}) + flows = { + "deposits": 1000.0, "withdrawals": 0.0, "interest": 0.0, + "dividends": 10.0, "dividend_tax": 0.0, "conversion_fees": 0.0, + "invested": 1000.0, "proceeds": 0.0, "fees": 0.0, + } + perf = compute_performance(holdings, op, realized, flows, 0.0, 0.0) + assert perf["market_value"] == pytest.approx(1200.0) + assert perf["total_gain"] == pytest.approx(200 + 50 + 10) + + +# --------------------------------------------------------------------------- +# Money-weighted return / XIRR +# --------------------------------------------------------------------------- +class TestMoneyWeightedReturn: + def test_compute_xirr_one_year_gain(self): + flows = [ + (pd.Timestamp("2024-01-01"), -1000.0), + (pd.Timestamp("2025-01-01"), 1100.0), + ] + assert compute_xirr(flows) == pytest.approx(0.10, abs=0.001) + + def test_compute_xirr_requires_positive_and_negative_flows(self): + assert compute_xirr([(pd.Timestamp("2024-01-01"), 1000.0)]) is None + assert compute_xirr([(pd.Timestamp("2024-01-01"), -1000.0)]) is None + + def test_build_external_cash_flows_uses_deposits_withdrawals_and_terminal_value(self): + cash_ops = make_cash_ops([ + cash_row("Deposit", "", 1000.0, "deposit", time="2024-01-01 10:00:00"), + cash_row("Withdrawal", "", -100.0, "withdrawal", time="2024-06-01 10:00:00"), + cash_row("Dividend", "AAA", 5.0, "Dividend", time="2024-07-01 10:00:00"), + ]) + flows = build_external_cash_flows( + cash_ops, terminal_value=1200.0, terminal_date=__import__("datetime").date(2025, 1, 1) + ) + assert flows == [ + (pd.Timestamp("2024-01-01"), -1000.0), + (pd.Timestamp("2024-06-01"), 100.0), + (pd.Timestamp("2025-01-01"), 1200.0), + ] + + def test_compute_performance_includes_money_weighted_return_when_cash_ops_provided(self): + holdings = pd.DataFrame({"ticker": ["A"], "cost_basis": [1000.0]}) + op = pd.DataFrame({"ticker": ["A"], "current_value": [1100.0], "unrealized_pl": [100.0]}) + flows = { + "deposits": 1000.0, "withdrawals": 0.0, "interest": 0.0, + "dividends": 0.0, "dividend_tax": 0.0, "conversion_fees": 0.0, + "invested": 1000.0, "proceeds": 0.0, "fees": 0.0, + } + cash_ops = make_cash_ops([ + cash_row("Deposit", "", 1000.0, "deposit", time="2024-01-01 10:00:00"), + ]) + perf = compute_performance( + holdings, op, pd.DataFrame(), flows, ending_cash=0.0, broker_total=0.0, + cash_ops=cash_ops, terminal_date=__import__("datetime").date(2025, 1, 1), + ) + assert perf["money_weighted_return_pct"] == pytest.approx(10.0, abs=0.1) + + +# --------------------------------------------------------------------------- +# Portfolio analysis summary helpers +# --------------------------------------------------------------------------- +class TestPortfolioAnalysisHelpers: + def _holdings(self): + return pd.DataFrame( + [ + { + "ticker": "AAA", + "name": "Alpha", + "market_value": 1400.0, + "cost_basis": 1000.0, + "unrealized_pl": 400.0, + "return_pct": 40.0, + "weight_pct": 70.0, + "price_source": "live", + }, + { + "ticker": "BBB", + "name": "Beta", + "market_value": 600.0, + "cost_basis": 800.0, + "unrealized_pl": -200.0, + "return_pct": -25.0, + "weight_pct": 30.0, + "price_source": "cost", + }, + ] + ) + + def _flows(self): + return { + "deposits": 2500.0, + "withdrawals": 0.0, + "interest": 5.0, + "dividends": 10.0, + "dividend_tax": -2.0, + "conversion_fees": 0.0, + "invested": 1800.0, + "proceeds": 0.0, + "fees": 0.0, + } + + def _perf(self): + return { + "cost_basis": 1800.0, + "market_value": 2000.0, + "unrealized_pl": 200.0, + "realized_pl": 50.0, + "income": 15.0, + "total_gain": 265.0, + "portfolio_value": 2700.0, + "ending_cash": 700.0, + "net_deposited": 2500.0, + "total_return_pct": 10.6, + "income_yield_pct": 0.83, + "broker_total": 700.0, + "reconciliation_diff": 0.0, + } + + def test_build_executive_summary_surfaces_key_observations(self): + rows = build_executive_summary( + self._holdings(), + pd.DataFrame({"ticker": ["ZZZ"], "realized_pl": [50.0]}), + self._flows(), + self._perf(), + ) + summary = dict(rows) + assert summary["Largest holding"] == "AAA (70.00%)" + assert summary["Top unrealized winner"] == "AAA (+400.00)" + assert summary["Top unrealized loser"] == "BBB (-200.00)" + assert summary["Cash allocation"] == "25.93%" + assert summary["Pricing warnings"] == "1 holding priced at cost" + assert summary["Reconciliation"] == "OK" + + def test_analyze_concentration_flags_large_positions_and_cost_pricing(self): + risk = analyze_concentration(self._holdings(), self._perf()) + assert risk["top_1_weight_pct"] == pytest.approx(70.0) + assert risk["top_3_weight_pct"] == pytest.approx(100.0) + assert risk["cash_weight_pct"] == pytest.approx(700 / 2700 * 100) + assert risk["positions_over_20_pct"] == 2 + assert risk["cost_priced_positions"] == 1 + assert risk["risk_note"] == "High concentration: top holding is 70.00%." + + def test_analyze_return_contributions_combines_unrealized_and_realized(self): + realized = pd.DataFrame( + {"ticker": ["AAA", "ZZZ"], "realized_pl": [25.0, 50.0]} + ) + out = analyze_return_contributions(self._holdings(), realized, self._perf()) + aaa = out[out["Ticker"] == "AAA"].iloc[0] + zzz = out[out["Ticker"] == "ZZZ"].iloc[0] + assert aaa["Unrealized P/L"] == pytest.approx(400.0) + assert aaa["Realized P/L"] == pytest.approx(25.0) + assert aaa["Total Contribution"] == pytest.approx(425.0) + assert aaa["Contribution %"] == pytest.approx(425 / 265 * 100) + assert zzz["Market Value"] == pytest.approx(0.0) + assert zzz["Total Contribution"] == pytest.approx(50.0) + + def test_analyze_income_quality_summarizes_tax_drag_and_yield(self): + quality = analyze_income_quality(self._flows(), self._perf()) + assert quality["gross_income"] == pytest.approx(15.0) + assert quality["dividend_tax"] == pytest.approx(2.0) + assert quality["net_income"] == pytest.approx(13.0) + assert quality["tax_drag_pct"] == pytest.approx(2 / 15 * 100) + assert quality["net_income_yield_pct"] == pytest.approx(13 / 1800 * 100) + assert quality["income_mix"] == "66.67% dividends / 33.33% interest" + + def test_analyze_methodology_quality_summarizes_pricing_and_methods(self): + quality = analyze_methodology_quality(self._holdings(), self._perf()) + assert quality == [ + ("Pricing coverage", "1 live / 1 cost fallback"), + ("Cost fallback tickers", "BBB"), + ("Cash reconciliation", "OK"), + ("Realized P/L method", "Broker closed positions preferred; FIFO fallback"), + ("Money-weighted return", "External deposits/withdrawals plus terminal portfolio value"), + ("Valuation caveat", "Cost fallback positions carry zero unrealized P/L"), + ] + + +# --------------------------------------------------------------------------- +# Live valuation (yfinance + math) +# --------------------------------------------------------------------------- +class TestValuateHoldings: + def _holdings(self): + return pd.DataFrame([ + {"ticker": "A", "name": "Alpha", "shares": 10.0, + "cost_basis": 1000.0, "avg_price": 100.0, "allocation_pct": 50.0}, + {"ticker": "B", "name": "Beta", "shares": 5.0, + "cost_basis": 1000.0, "avg_price": 200.0, "allocation_pct": 50.0}, + ]) + + def test_live_and_cost_fallback(self): + prices = { + "A": {"price": 120.0, "fx": 1.0, "source": "live"}, + "B": None, + } + out = valuate_holdings(self._holdings(), prices) + a = out[out["ticker"] == "A"].iloc[0] + b = out[out["ticker"] == "B"].iloc[0] + assert a["price_source"] == "live" + assert a["last_price"] == pytest.approx(120.0) + assert a["market_value"] == pytest.approx(1200.0) + assert a["unrealized_pl"] == pytest.approx(200.0) + assert b["price_source"] == "cost" + assert b["last_price"] == pytest.approx(200.0) + assert b["market_value"] == pytest.approx(1000.0) + assert b["unrealized_pl"] == 0.0 + + def test_weight_pct_by_market_value(self): + prices = { + "A": {"price": 120.0, "fx": 1.0, "source": "live"}, + "B": None, + } + out = valuate_holdings(self._holdings(), prices) + # A mv=1200, B mv=1000 -> total 2200 (weight_pct rounded to 2 dp) + a = out[out["ticker"] == "A"].iloc[0] + b = out[out["ticker"] == "B"].iloc[0] + assert a["weight_pct"] == pytest.approx(1200 / 2200 * 100, abs=0.01) + assert b["weight_pct"] == pytest.approx(1000 / 2200 * 100, abs=0.01) + assert out["weight_pct"].sum() == pytest.approx(100.0) + + def test_fx_conversion_applied(self): + prices = { + "A": {"price": 100.0, "fx": 0.9, "source": "live"}, # EUR-priced acnt + "B": None, + } + out = valuate_holdings(self._holdings(), prices) + a = out[out["ticker"] == "A"].iloc[0] + assert a["last_price"] == pytest.approx(90.0) + assert a["market_value"] == pytest.approx(900.0) + + def test_return_pct_computed(self): + # A live-priced at +20% (mv 1200 vs cost 1000); B cost-fallback -> 0%. + prices = { + "A": {"price": 120.0, "fx": 1.0, "source": "live"}, + "B": None, + } + out = valuate_holdings(self._holdings(), prices) + a = out[out["ticker"] == "A"].iloc[0] + b = out[out["ticker"] == "B"].iloc[0] + assert a["return_pct"] == pytest.approx(20.0) + assert b["return_pct"] == pytest.approx(0.0) + + +# --------------------------------------------------------------------------- +# _df_to_html per-column coloring +# --------------------------------------------------------------------------- +class TestDfToHtmlColoring: + def test_colored_cols_get_pos_and_neg_classes(self): + df = pd.DataFrame({"Name": ["A", "B"], "Return %": [5.0, -3.0]}) + html = main._df_to_html(df, {"Return %": ".2f"}, colored_cols={"Return %"}) + assert "class='pos'" in html + assert "class='neg'" in html + + def test_non_colored_positive_value_no_pos_class(self): + df = pd.DataFrame({"Shares": [10], "Return %": [5.0]}) + html = main._df_to_html(df, colored_cols={"Return %"}) + # Shares column is positive but not in colored_cols -> no pos class for it. + # The Return % cell does get pos. Count exactly one 'pos'. + assert html.count("class='pos'") == 1 + assert html.count("class='neg'") == 0 + + def test_data_tables_are_marked_sortable(self): + df = pd.DataFrame({"Ticker": ["B", "A"], "Market Value": [2.0, 10.0]}) + html = main._df_to_html(df) + assert "" in html + assert "data-sortable='1'" in html + + def test_data_table_headers_include_term_tooltips(self): + df = pd.DataFrame({"Unrealized P/L": [10.0], "Plain": ["x"]}) + html = main._df_to_html(df) + assert "class='term-help'" in html + assert "class='term-tip'" in html + assert "Profit or loss on positions you still hold" in html + assert "" in html + + +class TestFetchPrices: + def test_returns_none_when_yfinance_missing(self, monkeypatch): + import builtins + real_import = builtins.__import__ + + def fake_import(name, *a, **k): + if name == "yfinance": + raise ImportError("no yfinance") + return real_import(name, *a, **k) + + monkeypatch.setattr(builtins, "__import__", fake_import) + main._PRICE_CACHE.clear() + out = main.fetch_prices(["SPYL.DE"], __import__("datetime").date(2026, 6, 20), "EUR") + assert out["SPYL.DE"] is None + + def test_uses_mocked_yfinance(self, monkeypatch): + import datetime as dt + main._PRICE_CACHE.clear() + + class FakeHist(dict): + @property + def empty(self): + return False + def __getitem__(self, k): + return {"close": {0: 16.0}} + @property + def columns(self): + return ["Close"] + def __iter__(self): + return iter([]) + def keys(self): + return [] + @property + def index(self): + idx = pd.DatetimeIndex(["2026-06-19"]).tz_localize("Europe/Berlin") + return idx + def loc(self, *a, **k): + return self + + class FakeTicker: + def __init__(self, sym): + self.sym = sym + def history(self, **kw): + df = pd.DataFrame( + {"Close": [16.0, 16.1, 16.15]}, + index=pd.DatetimeIndex( + ["2026-06-17", "2026-06-18", "2026-06-19"] + ).tz_localize("Europe/Berlin"), + ) + return df + @property + def fast_info(self): + return {"currency": "EUR"} + + class FakeYF: + def Ticker(self, sym): + return FakeTicker(sym) + + monkeypatch.setattr(main, "_yf", lambda: FakeYF()) + out = main.fetch_prices(["SPYL.DE"], dt.date(2026, 6, 20), "EUR") + info = out["SPYL.DE"] + assert info is not None + assert info["price"] == pytest.approx(16.15) + assert info["currency"] == "EUR" + assert info["fx"] == pytest.approx(1.0) + assert info["as_of"] == dt.date(2026, 6, 19) + assert info["source"] == "live" + + def test_failed_yfinance_lookup_returns_none_without_raising(self, monkeypatch): + import datetime as dt + main._PRICE_CACHE.clear() + + class FakeTicker: + @property + def fast_info(self): + return {"currency": "EUR"} + + def history(self, **kw): + raise RuntimeError("network unavailable") + + class FakeYF: + def Ticker(self, sym): + return FakeTicker() + + monkeypatch.setattr(main, "_yf", lambda: FakeYF()) + out = main.fetch_prices(["SPYL.DE"], dt.date(2026, 6, 20), "EUR") + assert out["SPYL.DE"] is None + + +# --------------------------------------------------------------------------- +# Integration against the synthetic report file +# --------------------------------------------------------------------------- +class TestSyntheticReport: + def test_reconciliation_matches_broker_total(self): + _, cash_ops, _, broker_total = main.load_data() + trades = extract_trades(cash_ops) + flows, ending = analyze_cash_flows(cash_ops, trades) + assert broker_total == pytest.approx(748.5) + assert ending == pytest.approx(broker_total, abs=0.01) + assert flows["deposits"] == pytest.approx(1000.0) + assert flows["dividends"] == pytest.approx(10.0) + assert flows["dividend_tax"] == pytest.approx(-1.5) + assert flows["invested"] == pytest.approx(500.0) + assert flows["proceeds"] == pytest.approx(240.0) + + def test_holdings_keyed_by_real_ticker(self): + _, cash_ops, _, _ = main.load_data() + holdings, _ = analyze_holdings(extract_trades(cash_ops)) + assert set(holdings["ticker"]) == {"DEMO.DE"} + assert holdings.loc[0, "shares"] == pytest.approx(3.0) + assert holdings["cost_basis"].sum() == pytest.approx(300.0) + assert "name" in holdings.columns + assert holdings["allocation_pct"].sum() == pytest.approx(100.0, abs=0.05) + + +# --------------------------------------------------------------------------- +# HTML report +# --------------------------------------------------------------------------- +class TestHtmlReport: + def _minimal_perf(self): + return { + "cost_basis": 1000.0, "market_value": 1000.0, "unrealized_pl": 0.0, + "realized_pl": 0.0, "income": 0.01, "total_gain": 0.01, + "portfolio_value": 1000.0, "ending_cash": 0.0, "net_deposited": 1000.0, + "total_return_pct": 0.0, "income_yield_pct": 0.0, + "broker_total": 0.0, "reconciliation_diff": 0.0, + } + + def test_build_html_is_self_contained(self): + holdings = pd.DataFrame( + {"ticker": ["A"], "name": ["Alpha"], "shares": [10], + "avg_price": [100.0], "cost_basis": [1000.0], + "allocation_pct": [100.0], "last_price": [120.0], + "market_value": [1200.0], "unrealized_pl": [200.0], + "weight_pct": [100.0], "price_source": ["live"]} + ) + review_cfg = main.html_charts.review_charts_config( + holdings, + {"deposits": 1000.0, "withdrawals": 0.0, "interest": 0.01, + "dividends": 0.0, "dividend_tax": 0.0, "conversion_fees": 0.0, + "invested": 1000.0, "proceeds": 0.0, "fees": 0.0}, + pd.Series(dtype=float), "EUR") + html = main.build_html_report( + "EUR", {"account": "1", "period_from": "x", "period_to": "y"}, + {"deposits": 1000.0, "withdrawals": 0.0, "interest": 0.01, + "dividends": 0.0, "dividend_tax": 0.0, "conversion_fees": 0.0, + "invested": 1000.0, "proceeds": 0.0, "fees": 0.0}, + 0.0, holdings, pd.DataFrame(columns=["ticker", "current_value", "unrealized_pl"]), + pd.DataFrame(columns=["ticker", "realized_pl"]), + self._minimal_perf(), None, review_cfg, + ) + assert html.startswith("") + assert "data:image/png;base64" not in html + assert "" in html + assert "data:image/png;base64" not in html + + def test_return_pct_column_in_holdings_html(self): + holdings = pd.DataFrame( + {"ticker": ["A", "B"], "name": ["Alpha", "Beta"], "shares": [10, 5], + "avg_price": [100.0, 200.0], "cost_basis": [1000.0, 1000.0], + "allocation_pct": [50.0, 50.0], "last_price": [120.0, 200.0], + "market_value": [1200.0, 1000.0], "unrealized_pl": [200.0, 0.0], + "return_pct": [20.0, 0.0], "weight_pct": [54.5, 45.5], + "price_source": ["live", "cost"]} + ) + review_cfg = main.html_charts.review_charts_config( + holdings, + {"deposits": 1000.0, "withdrawals": 0.0, "interest": 0.0, + "dividends": 0.0, "dividend_tax": 0.0, "conversion_fees": 0.0, + "invested": 1000.0, "proceeds": 0.0, "fees": 0.0}, + pd.Series(dtype=float), "EUR") + html = main.build_html_report( + "EUR", {"account": "1", "period_from": "x", "period_to": "y"}, + {"deposits": 1000.0, "withdrawals": 0.0, "interest": 0.0, + "dividends": 0.0, "dividend_tax": 0.0, "conversion_fees": 0.0, + "invested": 1000.0, "proceeds": 0.0, "fees": 0.0}, + 0.0, holdings, pd.DataFrame(columns=["ticker", "current_value", "unrealized_pl"]), + pd.DataFrame(columns=["ticker", "realized_pl"]), + self._minimal_perf(), None, review_cfg, + ) + assert "Return %" in html + # A's +20% return gets a pos (green) class. + assert "class='pos'" in html + + def test_html_includes_analysis_upgrade_sections(self): + holdings = pd.DataFrame( + {"ticker": ["A", "B"], "name": ["Alpha", "Beta"], "shares": [10, 5], + "avg_price": [100.0, 200.0], "cost_basis": [1000.0, 1000.0], + "allocation_pct": [50.0, 50.0], "last_price": [140.0, 160.0], + "market_value": [1400.0, 800.0], "unrealized_pl": [400.0, -200.0], + "return_pct": [40.0, -20.0], "weight_pct": [63.64, 36.36], + "price_source": ["live", "cost"]} + ) + flows = {"deposits": 3000.0, "withdrawals": 0.0, "interest": 5.0, + "dividends": 10.0, "dividend_tax": -2.0, "conversion_fees": 0.0, + "invested": 2000.0, "proceeds": 0.0, "fees": 0.0} + perf = { + "cost_basis": 2000.0, "market_value": 2200.0, "unrealized_pl": 200.0, + "realized_pl": 0.0, "income": 15.0, "total_gain": 215.0, + "portfolio_value": 3200.0, "ending_cash": 1000.0, + "net_deposited": 3000.0, "total_return_pct": 7.17, + "income_yield_pct": 0.75, "broker_total": 1000.0, + "reconciliation_diff": 0.0, + } + review_cfg = main.html_charts.review_charts_config( + holdings, flows, pd.Series(dtype=float), "EUR") + html = main.build_html_report( + "EUR", {"account": "1", "period_from": "x", "period_to": "y"}, + flows, 1000.0, holdings, + pd.DataFrame(columns=["ticker", "current_value", "unrealized_pl"]), + pd.DataFrame(columns=["ticker", "realized_pl"]), + perf, None, review_cfg, + ) + assert "Executive Summary" in html + assert "Concentration & Risk" in html + assert "Return Contribution" in html + assert "Largest holding" in html + assert "High concentration" in html + + def test_html_includes_money_weighted_return(self): + holdings = pd.DataFrame( + {"ticker": ["A"], "name": ["Alpha"], "shares": [10], + "avg_price": [100.0], "cost_basis": [1000.0], + "allocation_pct": [100.0], "last_price": [110.0], + "market_value": [1100.0], "unrealized_pl": [100.0], + "return_pct": [10.0], "weight_pct": [100.0], + "price_source": ["live"]} + ) + flows = {"deposits": 1000.0, "withdrawals": 0.0, "interest": 0.0, + "dividends": 0.0, "dividend_tax": 0.0, "conversion_fees": 0.0, + "invested": 1000.0, "proceeds": 0.0, "fees": 0.0} + perf = self._minimal_perf() | {"money_weighted_return_pct": 10.0} + review_cfg = main.html_charts.review_charts_config( + holdings, flows, pd.Series(dtype=float), "EUR") + html = main.build_html_report( + "EUR", {"account": "1", "period_from": "x", "period_to": "y"}, + flows, 0.0, holdings, + pd.DataFrame(columns=["ticker", "current_value", "unrealized_pl"]), + pd.DataFrame(columns=["ticker", "realized_pl"]), + perf, None, review_cfg, + ) + assert "Money-weighted return" in html + assert "+10.00 %" in html + + def test_html_includes_income_quality_section(self): + holdings = pd.DataFrame( + {"ticker": ["A"], "name": ["Alpha"], "shares": [10], + "avg_price": [100.0], "cost_basis": [1000.0], + "allocation_pct": [100.0], "last_price": [110.0], + "market_value": [1100.0], "unrealized_pl": [100.0], + "return_pct": [10.0], "weight_pct": [100.0], + "price_source": ["live"]} + ) + flows = {"deposits": 1000.0, "withdrawals": 0.0, "interest": 5.0, + "dividends": 10.0, "dividend_tax": -2.0, "conversion_fees": 0.0, + "invested": 1000.0, "proceeds": 0.0, "fees": 0.0} + perf = self._minimal_perf() | { + "cost_basis": 1000.0, + "income": 15.0, + "income_yield_pct": 1.5, + "money_weighted_return_pct": None, + } + review_cfg = main.html_charts.review_charts_config( + holdings, flows, pd.Series(dtype=float), "EUR") + html = main.build_html_report( + "EUR", {"account": "1", "period_from": "x", "period_to": "y"}, + flows, 0.0, holdings, + pd.DataFrame(columns=["ticker", "current_value", "unrealized_pl"]), + pd.DataFrame(columns=["ticker", "realized_pl"]), + perf, None, review_cfg, + ) + assert "Income Quality" in html + assert "Tax drag" in html + assert "Net income yield" in html + + def test_html_includes_methodology_data_quality_section(self): + holdings = pd.DataFrame( + {"ticker": ["A", "B"], "name": ["Alpha", "Beta"], "shares": [10, 5], + "avg_price": [100.0, 200.0], "cost_basis": [1000.0, 1000.0], + "allocation_pct": [50.0, 50.0], "last_price": [140.0, 200.0], + "market_value": [1400.0, 1000.0], "unrealized_pl": [400.0, 0.0], + "return_pct": [40.0, 0.0], "weight_pct": [58.33, 41.67], + "price_source": ["live", "cost"]} + ) + flows = {"deposits": 3000.0, "withdrawals": 0.0, "interest": 0.0, + "dividends": 0.0, "dividend_tax": 0.0, "conversion_fees": 0.0, + "invested": 2000.0, "proceeds": 0.0, "fees": 0.0} + perf = self._minimal_perf() | {"reconciliation_diff": 0.0} + review_cfg = main.html_charts.review_charts_config( + holdings, flows, pd.Series(dtype=float), "EUR") + html = main.build_html_report( + "EUR", {"account": "1", "period_from": "x", "period_to": "y"}, + flows, 0.0, holdings, + pd.DataFrame(columns=["ticker", "current_value", "unrealized_pl"]), + pd.DataFrame(columns=["ticker", "realized_pl"]), + perf, None, review_cfg, + ) + assert "Methodology & Data Quality" in html + assert "Pricing coverage" in html + assert "1 live / 1 cost fallback" in html + assert "Cost fallback positions carry zero unrealized P/L" in html + + def test_html_embeds_sortable_table_script(self): + holdings = pd.DataFrame( + {"ticker": ["B", "A"], "name": ["Beta", "Alpha"], "shares": [5, 10], + "avg_price": [200.0, 100.0], "cost_basis": [1000.0, 1000.0], + "allocation_pct": [50.0, 50.0], "last_price": [200.0, 110.0], + "market_value": [1000.0, 1100.0], "unrealized_pl": [0.0, 100.0], + "return_pct": [0.0, 10.0], "weight_pct": [47.62, 52.38], + "price_source": ["cost", "live"]} + ) + flows = {"deposits": 2000.0, "withdrawals": 0.0, "interest": 0.0, + "dividends": 0.0, "dividend_tax": 0.0, "conversion_fees": 0.0, + "invested": 2000.0, "proceeds": 0.0, "fees": 0.0} + review_cfg = main.html_charts.review_charts_config( + holdings, flows, pd.Series(dtype=float), "EUR") + html = main.build_html_report( + "EUR", {"account": "1", "period_from": "x", "period_to": "y"}, + flows, 0.0, holdings, + pd.DataFrame(columns=["ticker", "current_value", "unrealized_pl"]), + pd.DataFrame(columns=["ticker", "realized_pl"]), + self._minimal_perf(), None, review_cfg, + ) + assert "function _bootSortableTables()" in html + assert "data-sortable='1'" in html + assert "aria-sort" in html + + def test_html_embeds_table_filter_script(self): + holdings = pd.DataFrame( + {"ticker": ["A", "B"], "name": ["Alpha", "Beta"], "shares": [10, 5], + "avg_price": [100.0, 200.0], "cost_basis": [1000.0, 1000.0], + "allocation_pct": [50.0, 50.0], "last_price": [110.0, 200.0], + "market_value": [1100.0, 1000.0], "unrealized_pl": [100.0, 0.0], + "return_pct": [10.0, 0.0], "weight_pct": [52.38, 47.62], + "price_source": ["live", "cost"]} + ) + flows = {"deposits": 2000.0, "withdrawals": 0.0, "interest": 0.0, + "dividends": 0.0, "dividend_tax": 0.0, "conversion_fees": 0.0, + "invested": 2000.0, "proceeds": 0.0, "fees": 0.0} + review_cfg = main.html_charts.review_charts_config( + holdings, flows, pd.Series(dtype=float), "EUR") + html = main.build_html_report( + "EUR", {"account": "1", "period_from": "x", "period_to": "y"}, + flows, 0.0, holdings, + pd.DataFrame(columns=["ticker", "current_value", "unrealized_pl"]), + pd.DataFrame(columns=["ticker", "realized_pl"]), + self._minimal_perf(), None, review_cfg, + ) + assert "function _bootTableFilters()" in html + assert "table-filter" in html + assert "Filter table" in html + + def test_html_includes_sticky_section_navigation(self): + holdings = pd.DataFrame( + {"ticker": ["A"], "name": ["Alpha"], "shares": [10], + "avg_price": [100.0], "cost_basis": [1000.0], + "allocation_pct": [100.0], "last_price": [110.0], + "market_value": [1100.0], "unrealized_pl": [100.0], + "return_pct": [10.0], "weight_pct": [100.0], + "price_source": ["live"]} + ) + flows = {"deposits": 1000.0, "withdrawals": 0.0, "interest": 0.0, + "dividends": 0.0, "dividend_tax": 0.0, "conversion_fees": 0.0, + "invested": 1000.0, "proceeds": 0.0, "fees": 0.0} + review_cfg = main.html_charts.review_charts_config( + holdings, flows, pd.Series(dtype=float), "EUR") + html = main.build_html_report( + "EUR", {"account": "1", "period_from": "x", "period_to": "y"}, + flows, 0.0, holdings, + pd.DataFrame(columns=["ticker", "current_value", "unrealized_pl"]), + pd.DataFrame(columns=["ticker", "realized_pl"]), + self._minimal_perf(), None, review_cfg, + ) + assert "
Plain