Add scripts for environment setup and validation, and implement tests for portfolio performance exporter

- Created requirements.txt for dependencies including pandas, numpy, openpyxl, and yfinance.
- Added setup-env.sh script to set up a Python virtual environment and install required packages.
- Introduced validate-export.sh script to validate the exporter module and check expected fields.
- Implemented test cases in test_portfolio_performance_exporter.py to ensure correct CSV export functionality and data handling.
This commit is contained in:
2026-06-21 21:06:08 +03:00
parent c40724eae6
commit 68cfec926e
14 changed files with 3333 additions and 10 deletions
+32 -2
View File
@@ -2,10 +2,11 @@
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. 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: The repository ships three standalone, harness-neutral skill folders:
- `skills/xtb-portfolio-review` - `skills/xtb-portfolio-review`
- `skills/xtb-wealthfolio-export` - `skills/xtb-wealthfolio-export`
- `skills/xtb-portfolio-performance-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. 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.
@@ -30,6 +31,7 @@ For Codex:
mkdir -p "$HOME/.codex/skills" mkdir -p "$HOME/.codex/skills"
cp -R skills/xtb-portfolio-review "$HOME/.codex/skills/" cp -R skills/xtb-portfolio-review "$HOME/.codex/skills/"
cp -R skills/xtb-wealthfolio-export "$HOME/.codex/skills/" cp -R skills/xtb-wealthfolio-export "$HOME/.codex/skills/"
cp -R skills/xtb-portfolio-performance-export "$HOME/.codex/skills/"
``` ```
For a generic agent workspace, copy the skill folders to a user-chosen directory: For a generic agent workspace, copy the skill folders to a user-chosen directory:
@@ -38,6 +40,7 @@ For a generic agent workspace, copy the skill folders to a user-chosen directory
mkdir -p ./agent-skills mkdir -p ./agent-skills
cp -R skills/xtb-portfolio-review ./agent-skills/ cp -R skills/xtb-portfolio-review ./agent-skills/
cp -R skills/xtb-wealthfolio-export ./agent-skills/ cp -R skills/xtb-wealthfolio-export ./agent-skills/
cp -R skills/xtb-portfolio-performance-export ./agent-skills/
``` ```
If only one workflow is needed, copy only that folder. If only one workflow is needed, copy only that folder.
@@ -65,6 +68,14 @@ For Wealthfolio export:
/path/to/xtb-wealthfolio-export/scripts/export-wealthfolio.sh /path/to/report.xlsx /path/to/xtb-wealthfolio-export/scripts/export-wealthfolio.sh /path/to/report.xlsx
``` ```
For Portfolio Performance export:
```bash
/path/to/xtb-portfolio-performance-export/scripts/setup-env.sh
/path/to/xtb-portfolio-performance-export/scripts/validate-export.sh
/path/to/xtb-portfolio-performance-export/scripts/export-portfolio-performance.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`. 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 ## Use Without Installing
@@ -87,6 +98,10 @@ Read skills/xtb-portfolio-review/SKILL.md and use that skill to generate a portf
Read skills/xtb-wealthfolio-export/SKILL.md and use that skill to create a Wealthfolio CSV from my XTB export. Read skills/xtb-wealthfolio-export/SKILL.md and use that skill to create a Wealthfolio CSV from my XTB export.
``` ```
```text
Read skills/xtb-portfolio-performance-export/SKILL.md and use that skill to create Portfolio Performance CSV files from my XTB export.
```
## Skill Contents ## Skill Contents
Expected portable structure: Expected portable structure:
@@ -117,9 +132,23 @@ skills/
html_charts.py html_charts.py
requirements.txt requirements.txt
assets/ assets/
xtb-portfolio-performance-export/
SKILL.md
references/
scripts/
setup-env.sh
validate-export.sh
export-portfolio-performance.sh
exporter.py
main.py
html_charts.py
requirements.txt
``` ```
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. Do not require the root-level `main.py`, `exporter.py`,
`portfolio_performance_exporter.py`, or `html_charts.py` for copied skill
usage. Those root files are repository compatibility shims only.
## Verification Commands ## Verification Commands
@@ -128,6 +157,7 @@ From the repository root:
```bash ```bash
skills/xtb-portfolio-review/scripts/validate-review.sh skills/xtb-portfolio-review/scripts/validate-review.sh
skills/xtb-wealthfolio-export/scripts/validate-export.sh skills/xtb-wealthfolio-export/scripts/validate-export.sh
skills/xtb-portfolio-performance-export/scripts/validate-export.sh
``` ```
If the full repository test suite is available: If the full repository test suite is available:
+97 -6
View File
@@ -1,4 +1,4 @@
# XTB Portfolio Review & Wealthfolio Exporter # XTB Portfolio Review & CSV Exporters
[![skills.sh](https://skills.sh/b/farcasclaudiu/xtb-investment-tools)](https://skills.sh/farcasclaudiu/xtb-investment-tools) [![skills.sh](https://skills.sh/b/farcasclaudiu/xtb-investment-tools)](https://skills.sh/farcasclaudiu/xtb-investment-tools)
@@ -6,6 +6,8 @@ A set of Python tools that turn an **XTB brokerage report** (`.xlsx` export) int
1. A complete, human-readable **portfolio review** (console and a self-contained HTML report with interactive, offline charts and analysis tables). 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. 2. A **Wealthfolio-compatible CSV** so the same XTB history can be imported into the [Wealthfolio](https://wealthfolio.app/) portfolio tracker.
3. **Portfolio Performance-compatible CSVs** split into Portfolio Transactions
and Account Transactions for import into [Portfolio Performance](https://www.portfolio-performance.info/).
The parser is generic for XTB exports in this format. Tests generate a small The parser is generic for XTB exports in this format. Tests generate a small
synthetic workbook at runtime, while personal brokerage exports should stay synthetic workbook at runtime, while personal brokerage exports should stay
@@ -67,6 +69,23 @@ Use the XTB Wealthfolio export skill with EUR_demo_report.xlsx as the input file
and write the Wealthfolio CSV to results/EUR_demo_report_wealthfolio.csv. and write the Wealthfolio CSV to results/EUR_demo_report_wealthfolio.csv.
``` ```
Portfolio Performance export prompt examples:
```text
Use the XTB Portfolio Performance export skill to create and validate CSV files
from my XTB workbook.
```
```text
Use the XTB Portfolio Performance export skill and explain how to import the two
generated CSV files into Portfolio Performance.
```
```text
Use the XTB Portfolio Performance export skill with EUR_demo_report.xlsx as the
input file and write the CSV files to results/.
```
### Run the tools directly ### Run the tools directly
From the repository root: From the repository root:
@@ -78,12 +97,16 @@ python3 -m venv .venv
.venv/bin/python main.py path/to/xtb-report.xlsx .venv/bin/python main.py path/to/xtb-report.xlsx
.venv/bin/python exporter.py path/to/xtb-report.xlsx .venv/bin/python exporter.py path/to/xtb-report.xlsx
.venv/bin/python portfolio_performance_exporter.py path/to/xtb-report.xlsx
``` ```
Outputs are written to `results/`, including Outputs are written to `results/`, including
`results/<stem>_review.html` for the portfolio review and `results/<stem>_review.html` for the portfolio review and
`results/<stem>_wealthfolio.csv` for the Wealthfolio import file. If there is `results/<stem>_wealthfolio.csv` for the Wealthfolio import file. The Portfolio
exactly one `.xlsx` file in the current folder, both tools can auto-detect it Performance exporter writes
`results/<stem>_portfolio_performance_portfolio_transactions.csv` and
`results/<stem>_portfolio_performance_account_transactions.csv`. If there is
exactly one `.xlsx` file in the current folder, the tools can auto-detect it
when the path is omitted. Add `--csv` to the portfolio review command only when when the path is omitted. Add `--csv` to the portfolio review command only when
you want the extra per-section CSV exports. you want the extra per-section CSV exports.
@@ -113,7 +136,7 @@ Two quirks the code handles explicitly:
- **Stock-sale close notation**: some XTB stock-sale rows are written as - **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 `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 sale proceeds. The tools treat these as sales for holdings, cash flows, and
Wealthfolio export. Wealthfolio and Portfolio Performance export.
--- ---
@@ -124,8 +147,9 @@ Two quirks the code handles explicitly:
| File | Purpose | | 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-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`). | | `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. | | `skills/xtb-portfolio-performance-export/scripts/exporter.py` | **XTB -> Portfolio Performance CSV exporter.** Splits XTB cash operations into Portfolio Transactions and Account Transactions CSV files. |
| `main.py`, `exporter.py`, `portfolio_performance_exporter.py`, `html_charts.py` | Thin compatibility entry points that preserve the original repo commands/imports while delegating to the bundled skill implementations. |
### Tests ### Tests
@@ -133,6 +157,7 @@ Two quirks the code handles explicitly:
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `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_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). | | `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). |
| `test_portfolio_performance_exporter.py` | Tests for `portfolio_performance_exporter.py` (two-file CSV export, semicolon schema, transaction separation, account labels, split fills, empty-input handling). |
### Local inputs ### Local inputs
@@ -157,6 +182,8 @@ automatically) and **named after the input report**: for input
| `results/<stem>_income.csv` | `main.py` | Income (dividends + interest) by month. | | `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>_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. | | `results/<stem>_wealthfolio.csv` | `exporter.py` | Wealthfolio-importable transaction history. |
| `results/<stem>_portfolio_performance_portfolio_transactions.csv` | `portfolio_performance_exporter.py` | Portfolio Performance `Portfolio Transactions` import file for buys and sells. |
| `results/<stem>_portfolio_performance_account_transactions.csv` | `portfolio_performance_exporter.py` | Portfolio Performance `Account Transactions` import file for deposits, dividends, taxes, interest, fees, and transfers. |
--- ---
@@ -187,6 +214,7 @@ copy/install/use instructions.
| ----- | ------- | | ----- | ------- |
| `xtb-portfolio-review` | Generate and verify XTB portfolio review reports, including reconciliation, holdings, performance, income, risk, and data-quality caveats. | | `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. | | `xtb-wealthfolio-export` | Export and validate Wealthfolio-compatible CSV files from XTB reports, including activity mappings and import-readiness checks. |
| `xtb-portfolio-performance-export` | Export and validate Portfolio Performance-compatible CSV files from XTB reports, including import workflow instructions. |
Use the skill folder directly, or copy it into the skill/instruction directory 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 your harness. With a generic LLM, ask it to read the relevant `SKILL.md`.
@@ -196,6 +224,7 @@ it in a new session:
```text ```text
Use $xtb-portfolio-review to generate and verify an XTB portfolio report. 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. Use $xtb-wealthfolio-export to create and validate a Wealthfolio CSV from an XTB report.
Use $xtb-portfolio-performance-export to create and validate Portfolio Performance CSV files from an XTB report.
``` ```
Each copied skill folder includes `scripts/requirements.txt` plus shell wrappers Each copied skill folder includes `scripts/requirements.txt` plus shell wrappers
@@ -205,6 +234,7 @@ want `.venv` and `results/` to live, install dependencies with:
```bash ```bash
skills/xtb-portfolio-review/scripts/setup-env.sh skills/xtb-portfolio-review/scripts/setup-env.sh
skills/xtb-wealthfolio-export/scripts/setup-env.sh skills/xtb-wealthfolio-export/scripts/setup-env.sh
skills/xtb-portfolio-performance-export/scripts/setup-env.sh
``` ```
## Usage ## Usage
@@ -254,6 +284,49 @@ The generated review HTML is a single offline file. It includes:
.venv/bin/python exporter.py EUR_other.xlsx -o my.csv # explicit input/output .venv/bin/python exporter.py EUR_other.xlsx -o my.csv # explicit input/output
``` ```
### Export to Portfolio Performance CSV
```bash
.venv/bin/python portfolio_performance_exporter.py EUR_demo_report.xlsx
.venv/bin/python portfolio_performance_exporter.py EUR_demo_report.xlsx -o results
.venv/bin/python portfolio_performance_exporter.py EUR_demo_report.xlsx --securities-account "XTB" --cash-account "XTB (EUR)"
```
The exporter writes two UTF-8 semicolon-delimited files:
- `results/<stem>_portfolio_performance_portfolio_transactions.csv`
- `results/<stem>_portfolio_performance_account_transactions.csv`
Import them into Portfolio Performance in this order:
1. In Portfolio Performance, create or open the target portfolio file.
2. Ensure the Portfolio Performance `Securities Account` and `Deposit Account`
exist, or use the importer's account selection step to create/select them.
Defaults expected from the CSV are `XTB` and `XTB (<CCY>)`.
3. Import `results/<stem>_portfolio_performance_portfolio_transactions.csv`
first via `File > Import > CSV files`.
4. In the CSV wizard, select type `Portfolio Transactions`.
5. Use `UTF-8`, delimiter `semicolon`, and enable `First line contains header`.
6. Confirm mappings for `Date`, `Type`, `Shares`, `Ticker Symbol`,
`Security Name`, `Value`, `Fees`, `Taxes`, `Securities Account`, and
`Cash Account`. In the CSV importer, `Cash Account` maps to the Portfolio
Performance deposit account.
7. Finish that import and resolve any security matching prompts before
continuing.
8. Import `results/<stem>_portfolio_performance_account_transactions.csv` via
`File > Import > CSV files`.
9. In the CSV wizard, select type `Account Transactions`.
10. Use the same CSV settings: `UTF-8`, semicolon delimiter, first line header.
11. Confirm mappings for `Date`, `Type`, `Value`, `Ticker Symbol`,
`Security Name`, `Shares`, `Gross Amount`, `Currency Gross Amount`,
`Cash Account`, and `Offset Account`. In the CSV importer, `Cash Account`
maps to the Portfolio Performance deposit account.
12. Review Portfolio Performance's preview/status column before finishing,
especially transfers, taxes, and dividends.
Portfolio transactions should usually be imported before account transactions
so referenced securities exist before dividend rows are processed.
### Run the tests ### Run the tests
```bash ```bash
@@ -318,6 +391,24 @@ only used for inline `BUY`/`SELL` commissions. Pure-cash rows use the `$CASH-<CC
(e.g. `$CASH-EUR`), while `DIVIDEND` keeps the security's real ticker. `BUY`/`SELL` leave (e.g. `$CASH-EUR`), while `DIVIDEND` keeps the security's real ticker. `BUY`/`SELL` leave
`amount` blank — Wealthfolio auto-calculates it as `quantity × unitPrice`. `amount` blank — Wealthfolio auto-calculates it as `quantity × unitPrice`.
### Portfolio Performance activity mapping
| XTB operation | Portfolio Performance import row |
| -------------------------------------------------- | -------------------------------- |
| `Stock purchase` / `OPEN BUY` | Portfolio `Buy` |
| `Stock sale` / `Stock sell` / `CLOSE SELL` / `OPEN SELL` | Portfolio `Sell` |
| `Stock sell` with `CLOSE BUY` | Portfolio `Sell` |
| `Deposit` / `Withdrawal` | Account `Deposit` / `Withdrawal` |
| `Dividend` | Account `Dividend` |
| `Free funds interest` | Account `Interest` |
| `Dividend tax` / `RO tax` / interest tax rows | Account `Taxes` |
| `Currency conversion` | Account `Fees` |
| `Subaccount transfer` / `Transfer` | Account `Transfer (Inbound/Outbound)` |
The Portfolio Performance exporter follows the app's documented import split:
use the `Portfolio Transactions` CSV for buys/sells and the `Account
Transactions` CSV for cash movements, income, taxes, fees, and transfers.
--- ---
## Notes & limitations ## Notes & limitations
+48
View File
@@ -0,0 +1,48 @@
"""Compatibility entry point for the XTB to Portfolio Performance export skill.
The canonical implementation lives in
`skills/xtb-portfolio-performance-export/scripts/exporter.py` so the skill
folder can be copied and used standalone by an LLM agent. This shim preserves
the repo API: `import portfolio_performance_exporter` and
`python portfolio_performance_exporter.py`.
"""
from __future__ import annotations
import importlib.util
import sys
from pathlib import Path
_IMPL_PATH = (
Path(__file__).resolve().parent
/ "skills"
/ "xtb-portfolio-performance-export"
/ "scripts"
/ "exporter.py"
)
def _load_impl():
module_name = (
__name__
if __name__ != "__main__"
else "_xtb_portfolio_performance_exporter_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(module_name, _IMPL_PATH)
if spec is None or spec.loader is None:
raise ImportError(f"Could not load XTB Portfolio Performance implementation at {_IMPL_PATH}")
module = importlib.util.module_from_spec(spec)
sys.modules[module_name] = module
if __name__ != "__main__":
sys.modules[__name__] = module
spec.loader.exec_module(module)
return module
_impl = _load_impl()
if __name__ == "__main__":
_impl.main_cli()
+3 -2
View File
@@ -4,10 +4,11 @@
"groupings": [ "groupings": [
{ {
"title": "XTB Tools", "title": "XTB Tools",
"description": "Skills for analyzing XTB brokerage exports and preparing Wealthfolio imports.", "description": "Skills for analyzing XTB brokerage exports and preparing portfolio-app imports.",
"skills": [ "skills": [
"xtb-portfolio-review", "xtb-portfolio-review",
"xtb-wealthfolio-export" "xtb-wealthfolio-export",
"xtb-portfolio-performance-export"
] ]
} }
] ]
@@ -0,0 +1,43 @@
---
name: xtb-portfolio-performance-export
description: Use when converting XTB brokerage .xlsx exports to Portfolio Performance-compatible CSV files, validating Portfolio Transactions and Account Transactions outputs, or explaining the Portfolio Performance import workflow.
---
# XTB Portfolio Performance Export
Use this skill to create and validate Portfolio Performance CSV files from XTB
`Cash Operations` data using the bundled `exporter.py`.
## Workflow
1. Identify the target workbook. If omitted and exactly one non-lock `.xlsx`
exists, the exporter can auto-detect it.
2. Run exporter validation before trusting an import file:
`<skill-folder>/scripts/validate-export.sh`
3. Create the Portfolio Performance CSV files:
`<skill-folder>/scripts/export-portfolio-performance.sh <report.xlsx>`
4. If the user needs a custom directory, run:
`<skill-folder>/scripts/export-portfolio-performance.sh <report.xlsx> -o <output-dir>`
5. Inspect the generated CSV headers and a sample of rows before saying they
are import-ready.
6. Read `references/portfolio-performance-csv.md` before explaining import
steps, transaction mappings, or limitations.
## Outputs
- `results/<stem>_portfolio_performance_portfolio_transactions.csv`
- `results/<stem>_portfolio_performance_account_transactions.csv`
## Guardrails
- Import Portfolio Transactions before Account Transactions so securities
exist before dividend rows are matched.
- Use UTF-8, semicolon delimiter, and first-line header in Portfolio
Performance.
- Refer to Portfolio Performance UI accounts as `Deposit Account` and
`Securities Account`; keep CSV field names literal as `Cash Account` and
`Securities Account`.
- Do not claim the generated files are fully imported until the user has
reviewed Portfolio Performance's wizard preview/status column.
- Keep multi-currency caveats visible: this first exporter uses the account
currency and deterministic account labels, with optional CLI overrides.
@@ -0,0 +1,63 @@
# Portfolio Performance CSV Mapping
Load this when validating or explaining XTB to Portfolio Performance exports.
## Generated Files
- `<stem>_portfolio_performance_portfolio_transactions.csv`
- Import type: `Portfolio Transactions`
- Header: `Date;Type;Shares;Ticker Symbol;Security Name;Value;Fees;Taxes;Note;Securities Account;Cash Account`
- `<stem>_portfolio_performance_account_transactions.csv`
- Import type: `Account Transactions`
- Header: `Date;Type;Value;Ticker Symbol;Security Name;Shares;Gross Amount;Currency Gross Amount;Note;Cash Account;Offset Account`
Both files are UTF-8 CSV files with semicolon delimiters and a first-line
header.
Portfolio Performance's UI uses `Deposit Accounts` for cash/deposit accounts
and `Securities Accounts` for custody accounts. The CSV importer still names
the deposit-account field `Cash Account`; keep that header literal.
## XTB To Portfolio Performance Mapping
- `Stock purchase` or `OPEN BUY` -> Portfolio `Buy`
- `Stock sale`, `Stock sell`, `CLOSE SELL`, or `OPEN SELL` -> Portfolio `Sell`
- `Stock sell` with `CLOSE BUY` -> Portfolio `Sell`
- `Deposit` -> Account `Deposit`
- `Withdrawal` -> Account `Withdrawal`
- `Dividend` -> Account `Dividend`
- `Dividend tax`, `RO tax`, `Free funds interest tax`, or other tax-like rows -> Account `Taxes`
- `Free funds interest` -> Account `Interest`
- `Currency conversion` -> Account `Fees`
- `Subaccount transfer` or `Transfer` -> Account `Transfer (Inbound)` or `Transfer (Outbound)` by amount sign
## Import Steps
1. In Portfolio Performance, create or open the target portfolio file.
2. Ensure the Portfolio Performance `Securities Account` and `Deposit Account`
exist, or select/create them in the import wizard. The default CSV names are
`XTB` and `XTB (<CCY>)`.
3. Import the portfolio transactions CSV first with `File > Import > CSV files`.
4. Select type `Portfolio Transactions`.
5. Use `UTF-8`, delimiter `semicolon`, and enable `First line contains header`.
6. Confirm mappings for `Date`, `Type`, `Shares`, `Ticker Symbol`,
`Security Name`, `Value`, `Fees`, `Taxes`, `Securities Account`, and
`Cash Account`. In the CSV importer, `Cash Account` maps to the Portfolio
Performance deposit account.
7. Finish that import and resolve any security matching prompts.
8. Import the account transactions CSV with `File > Import > CSV files`.
9. Select type `Account Transactions`.
10. Use the same CSV settings: `UTF-8`, semicolon delimiter, first line header.
11. Confirm mappings for `Date`, `Type`, `Value`, `Ticker Symbol`,
`Security Name`, `Shares`, `Gross Amount`, `Currency Gross Amount`,
`Cash Account`, and `Offset Account`. In the CSV importer, `Cash Account`
maps to the Portfolio Performance deposit account.
12. Review the preview/status column before finishing, especially transfers,
taxes, and dividends.
## Limitations
- The exporter does not create Portfolio Performance `.xml` portfolio files.
- It does not generate Portfolio Performance JSON import configurations.
- Multi-currency gross amount and exchange-rate fields are left blank unless a
future XTB mapping can populate them safely.
@@ -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,305 @@
"""XTB report -> Portfolio Performance CSV exporter.
Portfolio Performance imports CSV files by import type. This exporter writes
two semicolon-delimited UTF-8 files:
* Portfolio Transactions: buys and sells
* Account Transactions: deposits, dividends, taxes, interest, and transfers
Run:
python exporter.py report.xlsx
python exporter.py report.xlsx --output-dir results
"""
from __future__ import annotations
import argparse
import csv
import re
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_executed_quantity,
parse_numeric,
)
PORTFOLIO_FIELDS = [
"Date",
"Type",
"Shares",
"Ticker Symbol",
"Security Name",
"Value",
"Fees",
"Taxes",
"Note",
"Securities Account",
"Cash Account",
]
ACCOUNT_FIELDS = [
"Date",
"Type",
"Value",
"Ticker Symbol",
"Security Name",
"Shares",
"Gross Amount",
"Currency Gross Amount",
"Note",
"Cash Account",
"Offset Account",
]
SHORT_OPEN_RE = re.compile(r"OPEN\s+SELL", re.IGNORECASE)
TRANSFER_RE = re.compile(r"\b(subaccount\s+transfer|transfer)\b", re.IGNORECASE)
TAX_RE = re.compile(r"\btax\b|withholding", re.IGNORECASE)
def default_cash_account(currency: str, account_prefix: str = "XTB") -> str:
return f"{account_prefix} ({currency})"
def _fmt_date(val) -> str:
dt = pd.to_datetime(val, errors="coerce")
if pd.isna(dt):
return ""
return dt.strftime("%Y-%m-%d")
def _fmt_decimal(val) -> str:
if val == "" or val is None:
return ""
num = float(val)
return f"{num:.6f}".rstrip("0").rstrip(".")
def _clean_text(val) -> str:
if val is None or pd.isna(val):
return ""
text = str(val).strip()
return "" if text.lower() == "nan" else text
def _trade_type(type_val: str, comment: str) -> str | None:
if not TRADE_COMMENT_RE.search(comment):
return None
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"
def _account_type(type_val: str, comment: str, amount: float) -> str | None:
text = f"{type_val} {comment}".lower()
if DIVIDEND_TAX_RE.search(text) or TAX_RE.search(text):
return "Taxes"
if DIVIDEND_RE.search(text):
return "Dividend"
if INTEREST_RE.search(text):
return "Interest"
if CONVERSION_RE.search(text):
return "Fees"
if WITHDRAW_RE.search(text):
return "Withdrawal"
if DEPOSIT_RE.search(text):
return "Deposit"
if TRANSFER_RE.search(text):
return "Transfer (Inbound)" if amount >= 0 else "Transfer (Outbound)"
return None
def build_rows(
cash_ops: pd.DataFrame,
currency: str,
*,
securities_account: str = "XTB",
cash_account: str | None = None,
account_prefix: str = "XTB",
) -> tuple[list[dict[str, str | float]], list[dict[str, str | float]]]:
"""Build Portfolio Performance portfolio/account transaction rows."""
cash_account = cash_account or default_cash_account(currency, account_prefix)
type_col = find_column(cash_ops, ["type", "operation"], required=False)
ticker_col = find_column(
cash_ops, ["ticker", "symbol", "instrument", "market"], required=False
)
name_col = find_column(cash_ops, ["instrument", "name", "description"], required=False)
amount_col = find_column(
cash_ops, ["amount", "value", "net_amount", "cash", "change", "payment"],
required=False,
)
date_col = find_column(
cash_ops, ["time", "date", "operation_date", "booking_date", "transaction_date"],
required=False,
)
comment_col = find_column(cash_ops, ["comment", "description", "details"], required=False)
if not (type_col and amount_col):
return [], []
portfolio_rows: list[dict[str, str | float]] = []
account_rows: list[dict[str, str | float]] = []
for _, row in cash_ops.iterrows():
type_val = _clean_text(row.get(type_col))
comment = _clean_text(row.get(comment_col)) if comment_col else ""
amount = float(parse_numeric(pd.Series([row[amount_col]])).iloc[0])
date = _fmt_date(row.get(date_col)) if date_col else ""
ticker = _clean_text(row.get(ticker_col)) if ticker_col else ""
security_name = _clean_text(row.get(name_col)) if name_col else ""
trade_type = _trade_type(type_val, comment)
if trade_type:
price = 0.0
price_match = PRICE_RE.search(comment)
if price_match:
price = float(parse_numeric(pd.Series([price_match.group(1)])).iloc[0])
shares = parse_executed_quantity(comment, amount, price)
portfolio_rows.append({
"Date": date,
"Type": trade_type,
"Shares": shares,
"Ticker Symbol": ticker,
"Security Name": security_name,
"Value": round(abs(amount), 6),
"Fees": "",
"Taxes": "",
"Note": comment,
"Securities Account": securities_account,
"Cash Account": cash_account,
})
continue
account_type = _account_type(type_val, comment, amount)
if account_type is None:
continue
account_rows.append({
"Date": date,
"Type": account_type,
"Value": round(abs(amount), 6),
"Ticker Symbol": ticker if account_type == "Dividend" else "",
"Security Name": security_name if account_type == "Dividend" else "",
"Shares": "",
"Gross Amount": "",
"Currency Gross Amount": "",
"Note": comment or type_val,
"Cash Account": cash_account,
"Offset Account": "",
})
return portfolio_rows, account_rows
def _write_csv(path: Path, fields: list[str], rows: list[dict[str, str | float]]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", newline="", encoding="utf-8") as f:
writer = csv.DictWriter(f, fieldnames=fields, delimiter=";")
writer.writeheader()
for row in rows:
writer.writerow({
field: _fmt_decimal(row[field])
if isinstance(row.get(field), float)
else row.get(field, "")
for field in fields
})
def export(
xlsx_path: Path | str | None = None,
output_dir: Path | str | None = None,
*,
securities_account: str = "XTB",
cash_account: str | None = None,
account_prefix: str = "XTB",
) -> dict[str, Path]:
main.REPORT_FILE = main.resolve_report_file(xlsx_path)
currency = main.detect_currency()
_, cash_ops, _, _ = main.load_data()
portfolio_rows, account_rows = build_rows(
cash_ops,
currency,
securities_account=securities_account,
cash_account=cash_account,
account_prefix=account_prefix,
)
out_dir = Path(output_dir) if output_dir is not None else main.RESULTS_DIR
stem = main.REPORT_FILE.stem if main.REPORT_FILE else "portfolio"
outputs = {
"portfolio_transactions": out_dir
/ f"{stem}_portfolio_performance_portfolio_transactions.csv",
"account_transactions": out_dir
/ f"{stem}_portfolio_performance_account_transactions.csv",
}
_write_csv(outputs["portfolio_transactions"], PORTFOLIO_FIELDS, portfolio_rows)
_write_csv(outputs["account_transactions"], ACCOUNT_FIELDS, account_rows)
return outputs
def main_cli() -> None:
parser = argparse.ArgumentParser(description="Export XTB xlsx to Portfolio Performance CSVs.")
parser.add_argument(
"input",
nargs="?",
default=None,
help="Path to the XTB .xlsx report (auto-detected if omitted)",
)
parser.add_argument(
"-o",
"--output-dir",
default=None,
help="Output directory (default: results)",
)
parser.add_argument(
"--securities-account",
default="XTB",
help="Portfolio Performance securities account name (default: XTB)",
)
parser.add_argument(
"--cash-account",
default=None,
help="Portfolio Performance cash account name (default: XTB (<CCY>))",
)
parser.add_argument(
"--account-prefix",
default="XTB",
help="Prefix for the default cash account name (default: XTB)",
)
args = parser.parse_args()
try:
outputs = export(
args.input,
args.output_dir,
securities_account=args.securities_account,
cash_account=args.cash_account,
account_prefix=args.account_prefix,
)
except (FileNotFoundError, ValueError) as exc:
parser.error(str(exc))
for label, path in outputs.items():
print(f"Wrote {label}: {path.resolve()} ({path.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
@@ -0,0 +1,12 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PYTHON_BIN="${PYTHON:-python3}"
if [[ ! -d ".venv" ]]; then
"$PYTHON_BIN" -m venv .venv
fi
.venv/bin/python -m pip install --upgrade pip
.venv/bin/python -m pip install -r "$SCRIPT_DIR/requirements.txt"
@@ -0,0 +1,24 @@
#!/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
PYTHONDONTWRITEBYTECODE=1 "$PYTHON_BIN" - <<PY
import importlib.util
from pathlib import Path
script = Path("$SCRIPT_DIR") / "exporter.py"
spec = importlib.util.spec_from_file_location("xtb_portfolio_performance_exporter", script)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
assert module.PORTFOLIO_FIELDS[0] == "Date"
assert module.ACCOUNT_FIELDS[0] == "Date"
print("Portfolio Performance exporter loaded")
PY
+126
View File
@@ -0,0 +1,126 @@
import csv
import pandas as pd
import main
import portfolio_performance_exporter as pp
def _cash_ops(rows):
cols = ["Type", "Instrument", "Ticker", "Time", "Amount", "Comment", "Product"]
return main.clean_columns(pd.DataFrame(rows, columns=cols))
def _row(type_, instr="", ticker="", amount=0.0, comment="", time="2026-02-18 09:00:00"):
return [type_, instr, ticker, time, amount, comment, "My Trades"]
def _read_semicolon_csv(path):
with path.open(newline="", encoding="utf-8") as f:
return list(csv.DictReader(f, delimiter=";"))
def test_export_writes_two_semicolon_csvs_with_expected_headers(tmp_path):
outputs = pp.export(main.REPORT_FILE, output_dir=tmp_path)
portfolio_path = outputs["portfolio_transactions"]
account_path = outputs["account_transactions"]
assert portfolio_path.name == (
f"{main.REPORT_FILE.stem}_portfolio_performance_portfolio_transactions.csv"
)
assert account_path.name == (
f"{main.REPORT_FILE.stem}_portfolio_performance_account_transactions.csv"
)
with portfolio_path.open(encoding="utf-8") as f:
assert f.readline().strip() == ";".join(pp.PORTFOLIO_FIELDS)
with account_path.open(encoding="utf-8") as f:
assert f.readline().strip() == ";".join(pp.ACCOUNT_FIELDS)
portfolio_rows = _read_semicolon_csv(portfolio_path)
account_rows = _read_semicolon_csv(account_path)
assert [row["Type"] for row in portfolio_rows] == ["Buy", "Sell"]
assert [row["Type"] for row in account_rows] == ["Deposit", "Dividend", "Taxes"]
def test_build_rows_separates_trade_and_cash_activity():
ops = _cash_ops([
_row("Deposit", amount=4000.0, comment="deposit funds"),
_row("Stock purchase", "Demo Equity", "DEMO.DE", -500.0, "OPEN BUY 5 @ 100.00"),
_row("Dividend", "Demo Equity", "DEMO.DE", 10.0, "Dividend"),
_row("Dividend tax", "Demo Equity", "DEMO.DE", -1.5, "Dividend tax"),
_row("Free funds interest", amount=0.03, comment="Free funds interest"),
_row("RO tax", amount=-0.01, comment="Tax"),
_row("Stock sell", "Demo Equity", "DEMO.DE", 240.0, "CLOSE BUY 2 @ 120.00"),
])
portfolio_rows, account_rows = pp.build_rows(ops, "EUR")
assert [row["Type"] for row in portfolio_rows] == ["Buy", "Sell"]
assert portfolio_rows[0]["Shares"] == 5.0
assert portfolio_rows[0]["Ticker Symbol"] == "DEMO.DE"
assert portfolio_rows[0]["Security Name"] == "Demo Equity"
assert portfolio_rows[0]["Value"] == 500.0
assert portfolio_rows[0]["Securities Account"] == "XTB"
assert portfolio_rows[0]["Cash Account"] == "XTB (EUR)"
assert portfolio_rows[1]["Type"] == "Sell"
assert [row["Type"] for row in account_rows] == [
"Deposit",
"Dividend",
"Taxes",
"Interest",
"Taxes",
]
dividend = account_rows[1]
assert dividend["Ticker Symbol"] == "DEMO.DE"
assert dividend["Security Name"] == "Demo Equity"
assert dividend["Value"] == 10.0
def test_split_fill_quantity_uses_numerator():
ops = _cash_ops([
_row("Stock purchase", "S&P 500", "SPY.US", -14.31, "OPEN BUY 1/100 @ 14.3130"),
_row("Stock purchase", "S&P 500", "SPY.US", -1416.99, "OPEN BUY 99/100 @ 14.3130"),
])
portfolio_rows, account_rows = pp.build_rows(ops, "EUR")
assert account_rows == []
assert [row["Shares"] for row in portfolio_rows] == [1.0, 99.0]
def test_custom_account_names_are_used_in_rows():
ops = _cash_ops([
_row("Stock purchase", "Demo Equity", "DEMO.DE", -500.0, "OPEN BUY 5 @ 100.00"),
_row("Deposit", amount=4000.0, comment="deposit funds"),
])
portfolio_rows, account_rows = pp.build_rows(
ops,
"EUR",
securities_account="Broker Securities",
cash_account="Broker Cash EUR",
)
assert portfolio_rows[0]["Securities Account"] == "Broker Securities"
assert portfolio_rows[0]["Cash Account"] == "Broker Cash EUR"
assert account_rows[0]["Cash Account"] == "Broker Cash EUR"
def test_empty_input_writes_headers_only(tmp_path, monkeypatch):
empty = main.clean_columns(
pd.DataFrame(columns=["Type", "Instrument", "Ticker", "Time", "Amount", "Comment", "Product"])
)
monkeypatch.setattr(main, "load_data", lambda: (pd.DataFrame(), empty, pd.DataFrame(), 0.0))
outputs = pp.export(main.REPORT_FILE, output_dir=tmp_path)
for key, fields in (
("portfolio_transactions", pp.PORTFOLIO_FIELDS),
("account_transactions", pp.ACCOUNT_FIELDS),
):
with outputs[key].open(encoding="utf-8") as f:
assert f.readline().strip() == ";".join(fields)
assert f.read() == ""