mirror of
https://github.com/farcasclaudiu/xtb-investment-tools.git
synced 2026-06-22 05:01:57 +03:00
Initial commit
This commit is contained in:
+23
@@ -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
|
||||||
@@ -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.
|
||||||
|
```
|
||||||
@@ -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/<stem>_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/<stem>_holdings.csv` | `main.py` | Open holdings: ticker, shares, avg cost, cost basis, return %, allocation %.|
|
||||||
|
| `results/<stem>_cash_flows.csv` | `main.py` | Aggregated cash flows (deposits, interest, dividends, invested, …). |
|
||||||
|
| `results/<stem>_realized_pl.csv` | `main.py` | Realized P/L per ticker. |
|
||||||
|
| `results/<stem>_open_positions.csv` | `main.py` | Live market value / unrealized P/L (when an `Open Positions` sheet exists). |
|
||||||
|
| `results/<stem>_performance.csv` | `main.py` | Performance metrics (portfolio value, returns, yield). |
|
||||||
|
| `results/<stem>_income.csv` | `main.py` | Income (dividends + interest) by month. |
|
||||||
|
| `results/<stem>_evolution.csv` | `main.py` | Daily cost / market value / realized P/L series (drives the evolution chart).|
|
||||||
|
| `results/<stem>_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/<stem>_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/<stem>_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-<CCY>` 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`).
|
||||||
+87
@@ -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
|
||||||
+40
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
pandas
|
||||||
|
openpyxl
|
||||||
|
yfinance
|
||||||
|
pytest
|
||||||
@@ -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:
|
||||||
|
`<skill-folder>/scripts/setup-env.sh`
|
||||||
|
3. Validate the bundled tools:
|
||||||
|
`<skill-folder>/scripts/validate-review.sh`
|
||||||
|
4. Generate the review from the directory where outputs should be written:
|
||||||
|
`<skill-folder>/scripts/run-review.sh <report.xlsx>`
|
||||||
|
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.
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# Portfolio Review Validation Checklist
|
||||||
|
|
||||||
|
Load this before saying an XTB portfolio review is ready.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
- Install dependencies:
|
||||||
|
`<skill-folder>/scripts/setup-env.sh`
|
||||||
|
- Validate bundled tools:
|
||||||
|
`<skill-folder>/scripts/validate-review.sh`
|
||||||
|
- Generate report and CSVs:
|
||||||
|
`<skill-folder>/scripts/run-review.sh <report.xlsx>`
|
||||||
|
- 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/<stem>_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.
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
4.5.1
|
||||||
File diff suppressed because one or more lines are too long
@@ -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(
|
||||||
|
"<div class='card chart full' id='charts'>\n"
|
||||||
|
" <h2>Portfolio Evolution — Cost vs Value</h2>\n"
|
||||||
|
" <div class='chart-wrap' style='height:380px'>"
|
||||||
|
"<canvas id='evolution-chart'></canvas></div>\n"
|
||||||
|
"</div>"
|
||||||
|
)
|
||||||
|
|
||||||
|
grid_cells = []
|
||||||
|
if holdings_cfg is not None:
|
||||||
|
grid_cells.append(
|
||||||
|
"<div><h3>Holdings Allocation</h3>"
|
||||||
|
"<div class='chart-wrap' style='height:300px'>"
|
||||||
|
"<canvas id='holdings-chart'></canvas></div></div>"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
grid_cells.append("<div><h3>Holdings Allocation</h3>"
|
||||||
|
"<p class='muted'>No open positions.</p></div>")
|
||||||
|
if cashflows_cfg is not None:
|
||||||
|
grid_cells.append(
|
||||||
|
"<div><h3>Cash Flows</h3>"
|
||||||
|
"<div class='chart-wrap' style='height:300px'>"
|
||||||
|
"<canvas id='cashflows-chart'></canvas></div></div>"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
grid_cells.append("<div><h3>Cash Flows</h3>"
|
||||||
|
"<p class='muted'>No cash flows.</p></div>")
|
||||||
|
# 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(
|
||||||
|
"<div><h3>Income Over Time</h3>"
|
||||||
|
"<div class='chart-wrap' style='height:300px'>"
|
||||||
|
"<canvas id='income-chart'></canvas></div></div>"
|
||||||
|
)
|
||||||
|
charts_id_attr = " id='charts'" if evolution_cfg is None else ""
|
||||||
|
parts.append(
|
||||||
|
f"<div class='card chart full'{charts_id_attr}>\n"
|
||||||
|
" <h2>Charts</h2>\n"
|
||||||
|
" <div class='chart-grid'>\n " +
|
||||||
|
"\n ".join(grid_cells) + "\n </div>\n"
|
||||||
|
"</div>"
|
||||||
|
)
|
||||||
|
|
||||||
|
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 <script>
|
||||||
|
# block, even if a label ever contained the literal "</script>".
|
||||||
|
data_json = json.dumps(payload).replace("<", "\\u003c").replace(">", "\\u003e")
|
||||||
|
|
||||||
|
parts.append(
|
||||||
|
"<script>\n" + load_chartjs_inline() + "\n</script>\n"
|
||||||
|
"<script type='application/json' id='chart-data'>" + data_json + "</script>\n"
|
||||||
|
"<script>\n" + _RENDER_SCRIPT + "\n</script>"
|
||||||
|
)
|
||||||
|
return "\n".join(parts)
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,4 @@
|
|||||||
|
pandas>=2.2,<4
|
||||||
|
numpy>=1.26,<3
|
||||||
|
openpyxl>=3.1,<4
|
||||||
|
yfinance>=0.2,<2
|
||||||
+13
@@ -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
|
||||||
+15
@@ -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"
|
||||||
+35
@@ -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" - <<PY
|
||||||
|
import importlib.util
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
script_dir = Path("$SCRIPT_DIR")
|
||||||
|
sys.path.insert(0, str(script_dir))
|
||||||
|
|
||||||
|
for module in ("pandas", "openpyxl", "yfinance"):
|
||||||
|
if importlib.util.find_spec(module) is None:
|
||||||
|
raise SystemExit(
|
||||||
|
f"Missing dependency: {module}. Install with: "
|
||||||
|
f"{sys.executable} -m pip install -r {script_dir / 'requirements.txt'}"
|
||||||
|
)
|
||||||
|
|
||||||
|
import main
|
||||||
|
import html_charts
|
||||||
|
|
||||||
|
if not html_charts.CHARTJS_PATH.exists():
|
||||||
|
raise SystemExit(f"Missing Chart.js asset: {html_charts.CHARTJS_PATH}")
|
||||||
|
|
||||||
|
print("XTB portfolio review skill tools are importable.")
|
||||||
|
PY
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
---
|
||||||
|
name: xtb-wealthfolio-export
|
||||||
|
description: Use when converting XTB brokerage .xlsx exports to Wealthfolio-compatible CSV, validating Wealthfolio import rows, checking transaction activity mappings, or debugging exporter.py output.
|
||||||
|
---
|
||||||
|
|
||||||
|
# XTB Wealthfolio Export
|
||||||
|
|
||||||
|
Use this skill to create and validate Wealthfolio CSV files from XTB `Cash Operations` data 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 omitted and exactly one non-lock `.xlsx` exists in the current working directory, the exporter can auto-detect it.
|
||||||
|
2. Ensure dependencies are available:
|
||||||
|
`<skill-folder>/scripts/setup-env.sh`
|
||||||
|
3. Validate the bundled tools:
|
||||||
|
`<skill-folder>/scripts/validate-export.sh`
|
||||||
|
4. Create the Wealthfolio CSV from the directory where outputs should be written:
|
||||||
|
`<skill-folder>/scripts/export-wealthfolio.sh <report.xlsx>`
|
||||||
|
5. If the user needs a custom path, run:
|
||||||
|
`<skill-folder>/scripts/export-wealthfolio.sh <report.xlsx> -o <output.csv>`
|
||||||
|
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-<CCY>` and set `quantity = 1`, `unitPrice = 1`, and `amount` to the absolute cash value.
|
||||||
@@ -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-<CCY>`
|
||||||
|
- `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:
|
||||||
|
`<skill-folder>/scripts/setup-env.sh`
|
||||||
|
- Validate bundled tools:
|
||||||
|
`<skill-folder>/scripts/validate-export.sh`
|
||||||
|
- Generate default CSV:
|
||||||
|
`<skill-folder>/scripts/export-wealthfolio.sh <report.xlsx>`
|
||||||
|
- 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-<CCY>` unless dividend ticker retention applies.
|
||||||
|
- `CLOSE BUY` stock-sale rows export as `SELL`.
|
||||||
|
- Split-fill rows use numerator quantity.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
4.5.1
|
||||||
File diff suppressed because one or more lines are too long
@@ -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" "$@"
|
||||||
@@ -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-<CCY>`
|
||||||
|
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/<stem>_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/<stem>_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()
|
||||||
@@ -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(
|
||||||
|
"<div class='card chart full' id='charts'>\n"
|
||||||
|
" <h2>Portfolio Evolution — Cost vs Value</h2>\n"
|
||||||
|
" <div class='chart-wrap' style='height:380px'>"
|
||||||
|
"<canvas id='evolution-chart'></canvas></div>\n"
|
||||||
|
"</div>"
|
||||||
|
)
|
||||||
|
|
||||||
|
grid_cells = []
|
||||||
|
if holdings_cfg is not None:
|
||||||
|
grid_cells.append(
|
||||||
|
"<div><h3>Holdings Allocation</h3>"
|
||||||
|
"<div class='chart-wrap' style='height:300px'>"
|
||||||
|
"<canvas id='holdings-chart'></canvas></div></div>"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
grid_cells.append("<div><h3>Holdings Allocation</h3>"
|
||||||
|
"<p class='muted'>No open positions.</p></div>")
|
||||||
|
if cashflows_cfg is not None:
|
||||||
|
grid_cells.append(
|
||||||
|
"<div><h3>Cash Flows</h3>"
|
||||||
|
"<div class='chart-wrap' style='height:300px'>"
|
||||||
|
"<canvas id='cashflows-chart'></canvas></div></div>"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
grid_cells.append("<div><h3>Cash Flows</h3>"
|
||||||
|
"<p class='muted'>No cash flows.</p></div>")
|
||||||
|
# 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(
|
||||||
|
"<div><h3>Income Over Time</h3>"
|
||||||
|
"<div class='chart-wrap' style='height:300px'>"
|
||||||
|
"<canvas id='income-chart'></canvas></div></div>"
|
||||||
|
)
|
||||||
|
charts_id_attr = " id='charts'" if evolution_cfg is None else ""
|
||||||
|
parts.append(
|
||||||
|
f"<div class='card chart full'{charts_id_attr}>\n"
|
||||||
|
" <h2>Charts</h2>\n"
|
||||||
|
" <div class='chart-grid'>\n " +
|
||||||
|
"\n ".join(grid_cells) + "\n </div>\n"
|
||||||
|
"</div>"
|
||||||
|
)
|
||||||
|
|
||||||
|
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 <script>
|
||||||
|
# block, even if a label ever contained the literal "</script>".
|
||||||
|
data_json = json.dumps(payload).replace("<", "\\u003c").replace(">", "\\u003e")
|
||||||
|
|
||||||
|
parts.append(
|
||||||
|
"<script>\n" + load_chartjs_inline() + "\n</script>\n"
|
||||||
|
"<script type='application/json' id='chart-data'>" + data_json + "</script>\n"
|
||||||
|
"<script>\n" + _RENDER_SCRIPT + "\n</script>"
|
||||||
|
)
|
||||||
|
return "\n".join(parts)
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,4 @@
|
|||||||
|
pandas>=2.2,<4
|
||||||
|
numpy>=1.26,<3
|
||||||
|
openpyxl>=3.1,<4
|
||||||
|
yfinance>=0.2,<2
|
||||||
+15
@@ -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"
|
||||||
@@ -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" - <<PY
|
||||||
|
import importlib.util
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
script_dir = Path("$SCRIPT_DIR")
|
||||||
|
sys.path.insert(0, str(script_dir))
|
||||||
|
|
||||||
|
for module in ("pandas", "openpyxl"):
|
||||||
|
if importlib.util.find_spec(module) is None:
|
||||||
|
raise SystemExit(
|
||||||
|
f"Missing dependency: {module}. Install with: "
|
||||||
|
f"{sys.executable} -m pip install -r {script_dir / 'requirements.txt'}"
|
||||||
|
)
|
||||||
|
|
||||||
|
import exporter
|
||||||
|
|
||||||
|
required = ["date", "symbol", "quantity", "activityType", "unitPrice", "currency", "fee", "amount"]
|
||||||
|
if exporter.FIELDS != required:
|
||||||
|
raise SystemExit(f"Unexpected Wealthfolio fields: {exporter.FIELDS}")
|
||||||
|
|
||||||
|
print("XTB Wealthfolio export skill tools are importable.")
|
||||||
|
PY
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
import csv
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import exporter
|
||||||
|
from exporter import _trade_quantity, build_rows, classify, export
|
||||||
|
import main
|
||||||
|
|
||||||
|
|
||||||
|
def _cash_ops(rows):
|
||||||
|
cols = ["Type", "Instrument", "Time", "Amount", "Comment", "Product"]
|
||||||
|
return main.clean_columns(pd.DataFrame(rows, columns=cols))
|
||||||
|
|
||||||
|
|
||||||
|
def _row(type_, instr, amount, comment="", time="2026-02-18 09:00:00"):
|
||||||
|
return [type_, instr, time, amount, comment, "My Trades"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# classify
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
class TestClassify:
|
||||||
|
def test_buy(self):
|
||||||
|
assert classify("Stock purchase", "OPEN BUY 6 @ 301.50") == "BUY"
|
||||||
|
|
||||||
|
def test_close_sell(self):
|
||||||
|
assert classify("Stock sale", "CLOSE SELL 2 @ 100.00") == "SELL"
|
||||||
|
|
||||||
|
def test_close_buy_stock_sell(self):
|
||||||
|
assert classify("Stock sell", "CLOSE BUY 1 @ 150.00") == "SELL"
|
||||||
|
|
||||||
|
def test_open_sell_short(self):
|
||||||
|
assert classify("Stock purchase", "OPEN SELL 2 @ 100.00") == "SELL"
|
||||||
|
|
||||||
|
def test_deposit(self):
|
||||||
|
assert classify("Deposit", "deposit funds") == "DEPOSIT"
|
||||||
|
|
||||||
|
def test_withdrawal(self):
|
||||||
|
assert classify("Withdrawal", "payout") == "WITHDRAWAL"
|
||||||
|
|
||||||
|
def test_dividend(self):
|
||||||
|
assert classify("Dividend", "Dividend payment") == "DIVIDEND"
|
||||||
|
|
||||||
|
def test_dividend_tax(self):
|
||||||
|
assert classify("Dividend tax", "Dividend tax") == "TAX"
|
||||||
|
|
||||||
|
def test_interest(self):
|
||||||
|
assert classify("Free funds interest", "") == "INTEREST"
|
||||||
|
|
||||||
|
def test_fx(self):
|
||||||
|
assert classify("Currency conversion", "fx fee") == "FEE"
|
||||||
|
|
||||||
|
def test_unknown(self):
|
||||||
|
assert classify("Something else", "") is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _trade_quantity
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
class TestTradeQuantity:
|
||||||
|
def test_integer_token(self):
|
||||||
|
assert _trade_quantity("OPEN BUY 6 @ 301.50", 1809.0, 301.5) == 6.0
|
||||||
|
|
||||||
|
def test_fraction_token_numerator(self):
|
||||||
|
assert _trade_quantity("OPEN BUY 1/100 @ 14.3130", 14.31, 14.313) == 1.0
|
||||||
|
|
||||||
|
def test_fraction_with_rounded_cash_still_uses_numerator(self):
|
||||||
|
assert _trade_quantity("OPEN BUY 1/100 @ 14.3130", 14.31, 14.313) == 1.0
|
||||||
|
|
||||||
|
def test_fraction_99(self):
|
||||||
|
assert _trade_quantity("OPEN BUY 99/100 @ 14.3130", 1416.99, 14.313) == 99.0
|
||||||
|
|
||||||
|
def test_fallback_value_over_price(self):
|
||||||
|
assert _trade_quantity("no token here", 1000.0, 100.0) == 10.0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# build_rows
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
class TestBuildRows:
|
||||||
|
def test_full_mapping(self):
|
||||||
|
ops = _cash_ops([
|
||||||
|
_row("Stock purchase", "Stoxx Europe 600", -1809, "OPEN BUY 6 @ 301.50"),
|
||||||
|
_row("Stock purchase", "S&P 500", -14.31, "OPEN BUY 1/100 @ 14.3130"),
|
||||||
|
_row("Stock purchase", "S&P 500", -1416.99, "OPEN BUY 99/100 @ 14.3130"),
|
||||||
|
_row("Deposit", "", 4000, "JP_MORGAN deposit"),
|
||||||
|
_row("Free funds interest", "", 0.01, ""),
|
||||||
|
])
|
||||||
|
rows = build_rows(ops, "EUR")
|
||||||
|
assert [r["activityType"] for r in rows] == ["BUY", "BUY", "BUY", "DEPOSIT", "INTEREST"]
|
||||||
|
|
||||||
|
buys = [r for r in rows if r["activityType"] == "BUY"]
|
||||||
|
assert buys[0]["symbol"] == "Stoxx Europe 600"
|
||||||
|
assert buys[0]["quantity"] == 6.0
|
||||||
|
assert buys[0]["unitPrice"] == 301.5
|
||||||
|
assert buys[1]["symbol"] == "S&P 500"
|
||||||
|
assert buys[1]["quantity"] == 1.0
|
||||||
|
assert buys[2]["quantity"] == 99.0
|
||||||
|
|
||||||
|
deposit = next(r for r in rows if r["activityType"] == "DEPOSIT")
|
||||||
|
assert deposit["symbol"] == "$CASH-EUR"
|
||||||
|
assert deposit["quantity"] == 1.0
|
||||||
|
assert deposit["unitPrice"] == 1.0
|
||||||
|
assert deposit["amount"] == 4000.0
|
||||||
|
assert deposit["fee"] == 0.0
|
||||||
|
|
||||||
|
def test_dividend_and_tax(self):
|
||||||
|
ops = _cash_ops([
|
||||||
|
_row("Dividend", "AAPL", 10.0, "Dividend"),
|
||||||
|
_row("Dividend tax", "AAPL", -1.5, "Dividend tax"),
|
||||||
|
])
|
||||||
|
rows = build_rows(ops, "EUR")
|
||||||
|
div = next(r for r in rows if r["activityType"] == "DIVIDEND")
|
||||||
|
tax = next(r for r in rows if r["activityType"] == "TAX")
|
||||||
|
assert div["symbol"] == "AAPL"
|
||||||
|
assert div["quantity"] == 1.0
|
||||||
|
assert div["amount"] == 10.0
|
||||||
|
assert tax["symbol"] == "$CASH-EUR"
|
||||||
|
assert tax["amount"] == 1.5
|
||||||
|
assert tax["fee"] == 0.0
|
||||||
|
|
||||||
|
def test_close_buy_stock_sell_exports_sell_row(self):
|
||||||
|
ops = _cash_ops([
|
||||||
|
_row("Stock sell", "A", 150.0, "CLOSE BUY 1 @ 150.00"),
|
||||||
|
])
|
||||||
|
rows = build_rows(ops, "EUR")
|
||||||
|
assert len(rows) == 1
|
||||||
|
assert rows[0]["activityType"] == "SELL"
|
||||||
|
assert rows[0]["symbol"] == "A"
|
||||||
|
assert rows[0]["quantity"] == 1.0
|
||||||
|
assert rows[0]["unitPrice"] == 150.0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# export (file output + schema)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
class TestExport:
|
||||||
|
def test_synthetic_report(self, tmp_path):
|
||||||
|
out = export(main.REPORT_FILE, tmp_path / "wf.csv")
|
||||||
|
with out.open() as f:
|
||||||
|
reader = csv.DictReader(f)
|
||||||
|
assert reader.fieldnames == [
|
||||||
|
"date", "symbol", "quantity", "activityType",
|
||||||
|
"unitPrice", "currency", "fee", "amount",
|
||||||
|
]
|
||||||
|
rows = list(reader)
|
||||||
|
|
||||||
|
buys = [r for r in rows if r["activityType"] == "BUY"]
|
||||||
|
assert buys[0]["quantity"] == "5"
|
||||||
|
assert buys[0]["symbol"] == "DEMO.DE"
|
||||||
|
assert buys[0]["amount"] == "" # trades: amount auto-calculated by Wealthfolio
|
||||||
|
sells = [r for r in rows if r["activityType"] == "SELL"]
|
||||||
|
assert sells[0]["quantity"] == "2"
|
||||||
|
assert sells[0]["unitPrice"] == "120"
|
||||||
|
assert all(r["currency"] == "EUR" for r in rows)
|
||||||
|
for r in rows:
|
||||||
|
for k in ("date", "symbol", "quantity", "activityType", "unitPrice", "currency", "fee"):
|
||||||
|
assert r[k] != ""
|
||||||
|
if r["activityType"] not in ("BUY", "SELL"):
|
||||||
|
assert r["amount"] != ""
|
||||||
|
|
||||||
|
def test_default_output_stems_from_input(self, tmp_path):
|
||||||
|
prev = main.RESULTS_DIR
|
||||||
|
main.RESULTS_DIR = tmp_path
|
||||||
|
try:
|
||||||
|
out = export(main.REPORT_FILE)
|
||||||
|
finally:
|
||||||
|
main.RESULTS_DIR = prev
|
||||||
|
expected = f"{main.REPORT_FILE.stem}_wealthfolio.csv"
|
||||||
|
assert out.name == expected
|
||||||
|
assert out.parent == tmp_path
|
||||||
|
|
||||||
|
def test_empty_input(self, tmp_path, monkeypatch):
|
||||||
|
empty = main.clean_columns(
|
||||||
|
pd.DataFrame(columns=["Type", "Instrument", "Time", "Amount", "Comment", "Product"])
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(exporter, "build_rows", lambda *a, **k: [])
|
||||||
|
monkeypatch.setattr(main, "load_data", lambda: (pd.DataFrame(), empty, pd.DataFrame(), 0.0))
|
||||||
|
out = export(main.REPORT_FILE, tmp_path / "empty.csv")
|
||||||
|
with out.open() as f:
|
||||||
|
assert f.readline().strip() == ",".join(exporter.FIELDS)
|
||||||
|
assert f.read() == ""
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
import html_charts
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_chartjs_inline_returns_bundle():
|
||||||
|
src = html_charts.load_chartjs_inline()
|
||||||
|
assert isinstance(src, str)
|
||||||
|
assert len(src) > 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 "<canvas id='evolution-chart'></canvas>" in block
|
||||||
|
assert "<canvas id='holdings-chart'></canvas>" in block
|
||||||
|
assert "<canvas id='cashflows-chart'></canvas>" in block
|
||||||
|
assert "<canvas id='income-chart'></canvas>" 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 "<canvas id='evolution-chart'></canvas>" not in block
|
||||||
|
assert "<canvas id='holdings-chart'></canvas>" 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"<script type='application/json' id='chart-data'>(.*?)</script>",
|
||||||
|
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 "<canvas id='income-chart'></canvas>" not in block
|
||||||
+1330
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user