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
@@ -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