mirror of
https://github.com/farcasclaudiu/xtb-investment-tools.git
synced 2026-06-22 07:01:58 +03:00
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:
+32
-2
@@ -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:
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# XTB Portfolio Review & Wealthfolio Exporter
|
# XTB Portfolio Review & CSV Exporters
|
||||||
|
|
||||||
[](https://skills.sh/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
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
if [[ -n "${PYTHON:-}" ]]; then
|
||||||
|
PYTHON_BIN="$PYTHON"
|
||||||
|
elif [[ -x ".venv/bin/python" ]]; then
|
||||||
|
PYTHON_BIN=".venv/bin/python"
|
||||||
|
else
|
||||||
|
PYTHON_BIN="python3"
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec "$PYTHON_BIN" "$SCRIPT_DIR/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
|
||||||
@@ -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() == ""
|
||||||
Reference in New Issue
Block a user