Initial commit

This commit is contained in:
2026-06-21 13:00:30 +03:00
commit 73a0210002
33 changed files with 8067 additions and 0 deletions
+23
View File
@@ -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
+155
View File
@@ -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.
```
+258
View File
@@ -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 14**: 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
View File
@@ -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
View File
@@ -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()
+32
View File
@@ -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()
+36
View File
@@ -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()
+4
View File
@@ -0,0 +1,4 @@
pandas
openpyxl
yfinance
pytest
+42
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+42
View File
@@ -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
View File
@@ -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
View File
@@ -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
+183
View File
@@ -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() == ""
+221
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff