mirror of
https://github.com/farcasclaudiu/xtb-investment-tools.git
synced 2026-06-29 03:02:11 +03:00
Initial commit
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
---
|
||||
name: xtb-portfolio-review
|
||||
description: Use when analyzing XTB brokerage .xlsx exports with the local portfolio review tool, generating or checking HTML/CSV reports, validating cash reconciliation, reviewing holdings, risk, income, performance, or explaining report outputs from main.py.
|
||||
---
|
||||
|
||||
# XTB Portfolio Review
|
||||
|
||||
Use this skill to run and assess XTB portfolio reviews from a copied skill folder. The skill bundles the required Python tools in `scripts/`, so it can run without the original repository as long as Python dependencies are installed.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Identify the target workbook. If the user does not name one and exactly one non-lock `.xlsx` exists in the current working directory, use it.
|
||||
2. Ensure dependencies are available:
|
||||
`<skill-folder>/scripts/setup-env.sh`
|
||||
3. Validate the bundled tools:
|
||||
`<skill-folder>/scripts/validate-review.sh`
|
||||
4. Generate the review from the directory where outputs should be written:
|
||||
`<skill-folder>/scripts/run-review.sh <report.xlsx>`
|
||||
5. Inspect `results/` outputs named from the workbook stem, especially `_review.html`, `_holdings.csv`, `_cash_flows.csv`, `_performance.csv`, `_income.csv`, and `_evolution.csv`.
|
||||
6. Check whether computed ending cash reconciles to the broker `Total` row within EUR/USD/etc. `0.01`.
|
||||
7. Report findings with caveats: cost-priced tickers, missing live prices, cash mismatch, XIRR availability, concentration, income tax drag, and any generated file paths.
|
||||
|
||||
## Bundled Tools
|
||||
|
||||
- `scripts/main.py`: standalone XTB portfolio review generator.
|
||||
- `scripts/html_charts.py`: offline Chart.js report rendering helper.
|
||||
- `scripts/assets/chartjs.umd.min.js`: vendored Chart.js bundle for self-contained HTML.
|
||||
- `scripts/run-review.sh`: shell wrapper that runs the bundled review tool with `--csv`.
|
||||
- `scripts/validate-review.sh`: dependency and asset smoke check.
|
||||
- `scripts/setup-env.sh`: creates `.venv` in the current working directory and installs dependencies.
|
||||
- `scripts/requirements.txt`: Python dependencies.
|
||||
|
||||
## References
|
||||
|
||||
- Read `references/xtb-format.md` when parsing behavior, report assumptions, or XTB edge cases matter.
|
||||
- Read `references/validation-checklist.md` before claiming a generated portfolio review is correct or ready to use.
|
||||
|
||||
## Guardrails
|
||||
|
||||
- Do not treat the generated report as investment advice; describe what the tool computed and the data-quality limits.
|
||||
- Prefer the bundled validation script and generated CSVs over eyeballing the HTML alone.
|
||||
- Preserve offline/self-contained HTML behavior; do not introduce CDN dependencies when modifying the report.
|
||||
@@ -0,0 +1,36 @@
|
||||
# Portfolio Review Validation Checklist
|
||||
|
||||
Load this before saying an XTB portfolio review is ready.
|
||||
|
||||
## Commands
|
||||
|
||||
- Install dependencies:
|
||||
`<skill-folder>/scripts/setup-env.sh`
|
||||
- Validate bundled tools:
|
||||
`<skill-folder>/scripts/validate-review.sh`
|
||||
- Generate report and CSVs:
|
||||
`<skill-folder>/scripts/run-review.sh <report.xlsx>`
|
||||
- If working inside the original project repository, full tests are also useful:
|
||||
`.venv/bin/python -m pytest -q`
|
||||
|
||||
## Required Checks
|
||||
|
||||
- The command exits successfully and writes `results/<stem>_review.html`.
|
||||
- CSV side outputs exist when `--csv` was used.
|
||||
- Cash reconciliation is `[OK]` or the mismatch is explicitly reported.
|
||||
- Holdings with live-price failures are visible as cost fallbacks.
|
||||
- The HTML remains self-contained/offline: no CDN script or stylesheet dependency.
|
||||
- The report includes methodology/data-quality notes for pricing and reconciliation.
|
||||
|
||||
## Useful Output Files
|
||||
|
||||
- `_holdings.csv`: shares, cost basis, market value, allocation, unrealized P/L, price source.
|
||||
- `_cash_flows.csv`: deposits, withdrawals, invested, proceeds, dividends, tax, fees, ending cash.
|
||||
- `_realized_pl.csv`: realized profit/loss by ticker.
|
||||
- `_performance.csv`: portfolio value, total gain, return metrics, income yield.
|
||||
- `_income.csv`: dividend and interest income over time.
|
||||
- `_evolution.csv`: daily cost/value/realized series for charts.
|
||||
|
||||
## Reporting Style
|
||||
|
||||
Summarize computed facts and data-quality status. Avoid recommendations to buy, sell, rebalance, or time markets unless the user explicitly asks for financial planning context, and still frame it as educational analysis rather than advice.
|
||||
@@ -0,0 +1,33 @@
|
||||
# XTB Report Format Notes
|
||||
|
||||
Load this when XTB parsing details matter.
|
||||
|
||||
## Workbook Layout
|
||||
|
||||
- XTB exports are `.xlsx` files.
|
||||
- Metadata is in rows 1-4.
|
||||
- Column headers begin on row 5, so pandas should use `header=4`.
|
||||
- Main sheets:
|
||||
- `Cash Operations`: trades, deposits, withdrawals, dividends, taxes, interest, conversions, and broker `Total` row.
|
||||
- `Closed Positions`: realized trade summary; can be empty for still-open accounts.
|
||||
- `Open Positions`: optional live/open-position sheet.
|
||||
|
||||
## Trade Reconstruction
|
||||
|
||||
- The review reconstructs trades primarily from `Cash Operations` comments such as `OPEN BUY 6 @ 301.50` and `CLOSE SELL 2 @ 100.00`.
|
||||
- Use the real `Ticker` column as the instrument key, not only descriptive instrument text.
|
||||
- Process trades chronologically before FIFO matching.
|
||||
- Split-fill notation like `OPEN BUY 1/100 @ 14.3130` means executed quantity is `1`; use the numerator, not `0.01`.
|
||||
- Some XTB stock sales appear as `CLOSE BUY` while the row type is `Stock sell` and amount is positive. Treat these economically as sales.
|
||||
|
||||
## Valuation
|
||||
|
||||
- Live prices come from `yfinance` daily closes at or before the report end date.
|
||||
- Use trusted same-instrument symbol aliases only. Do not substitute a different share class as a proxy.
|
||||
- If no trusted price exists, hold the ticker at cost and surface `price_source = cost` plus the reason.
|
||||
|
||||
## Cash And Performance
|
||||
|
||||
- Reconciliation compares computed ending cash with the broker `Total` row.
|
||||
- Dividends and interest are internal cash flows unless withdrawn; do not count them as external cash flows for XIRR.
|
||||
- XIRR may be `n/a` if the cash-flow signs or solver conditions are insufficient.
|
||||
@@ -0,0 +1 @@
|
||||
4.5.1
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,365 @@
|
||||
"""Interactive Chart.js charts for the self-contained HTML report.
|
||||
|
||||
This module is the only place that knows about Chart.js. It reads the vendored
|
||||
UMD bundle from assets/ and builds Chart.js config dicts (pure functions) plus
|
||||
an HTML fragment that inlines the bundle, the data (JSON), and a render script.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pandas as pd
|
||||
|
||||
ASSETS_DIR = Path(__file__).resolve().parent / "assets"
|
||||
CHARTJS_PATH = ASSETS_DIR / "chartjs.umd.min.js"
|
||||
CHARTJS_VERSION_PATH = ASSETS_DIR / "chartjs.VERSION"
|
||||
|
||||
|
||||
def load_chartjs_inline() -> str:
|
||||
"""Return the minified Chart.js UMD source, vendored under assets/."""
|
||||
if not CHARTJS_PATH.exists():
|
||||
raise FileNotFoundError(
|
||||
f"Chart.js bundle not found at {CHARTJS_PATH}. "
|
||||
"Re-vendor it (see assets/chartjs.VERSION)."
|
||||
)
|
||||
return CHARTJS_PATH.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _iso(value: Any) -> str:
|
||||
if hasattr(value, "isoformat"):
|
||||
return value.isoformat()[:10]
|
||||
return str(value)
|
||||
|
||||
|
||||
def _round_series(values) -> list[float]:
|
||||
return [round(float(v), 2) for v in values]
|
||||
|
||||
|
||||
def evolution_chart_config(evolution_df: pd.DataFrame, currency: str) -> dict | None:
|
||||
"""Build a Chart.js line-chart config for cost vs value over time.
|
||||
|
||||
Returns None when there is no evolution data (caller omits the card).
|
||||
"""
|
||||
if evolution_df is None or evolution_df.empty:
|
||||
return None
|
||||
labels = [_iso(d) for d in evolution_df.index]
|
||||
return {
|
||||
"type": "line",
|
||||
"data": {
|
||||
"labels": labels,
|
||||
"datasets": [
|
||||
{
|
||||
"label": "Cost (invested)",
|
||||
"data": _round_series(evolution_df["cost"]),
|
||||
"borderColor": "#6b7280",
|
||||
"backgroundColor": "#6b7280",
|
||||
"borderWidth": 2,
|
||||
"fill": False,
|
||||
"pointRadius": 0,
|
||||
"tension": 0.1,
|
||||
},
|
||||
{
|
||||
"label": "Value (realized + unrealized)",
|
||||
"data": _round_series(evolution_df["total_value"]),
|
||||
"borderColor": "#2c5282",
|
||||
"backgroundColor": "#2c5282",
|
||||
"borderWidth": 2,
|
||||
"fill": False,
|
||||
"pointRadius": 0,
|
||||
"tension": 0.1,
|
||||
},
|
||||
{
|
||||
"label": "Cumulative realized P/L",
|
||||
"data": _round_series(evolution_df["realized_pl"]),
|
||||
"borderColor": "#f39c12",
|
||||
"backgroundColor": "#f39c12",
|
||||
"borderWidth": 1.5,
|
||||
"borderDash": [6, 4],
|
||||
"fill": False,
|
||||
"pointRadius": 0,
|
||||
"tension": 0.1,
|
||||
},
|
||||
],
|
||||
},
|
||||
"options": {
|
||||
"responsive": True,
|
||||
"maintainAspectRatio": False,
|
||||
"interaction": {"mode": "index", "intersect": False},
|
||||
"plugins": {
|
||||
"legend": {"position": "bottom",
|
||||
"labels": {"boxWidth": 12, "font": {"size": 12}}},
|
||||
},
|
||||
"scales": {
|
||||
"x": {"ticks": {"maxRotation": 45, "autoSkip": True}},
|
||||
"y": {"beginAtZero": False},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
DOUGHNUT_COLORS = [
|
||||
"#2c5282", "#1f9d55", "#f39c12", "#3498db", "#9b59b6",
|
||||
"#e67e22", "#16a085", "#34495e", "#e3342f", "#7f8c8d",
|
||||
]
|
||||
|
||||
|
||||
def review_charts_config(
|
||||
holdings: pd.DataFrame,
|
||||
flows: dict[str, float],
|
||||
income_by_period: pd.Series,
|
||||
currency: str,
|
||||
) -> dict:
|
||||
"""Build Chart.js configs for the three review charts.
|
||||
|
||||
Returns {'holdings': cfg|None, 'cashflows': cfg|None, 'income': cfg|None}.
|
||||
Each is None when its source data is empty.
|
||||
"""
|
||||
holdings_cfg = _holdings_config(holdings)
|
||||
cashflows_cfg = _cashflows_config(flows)
|
||||
income_cfg = _income_config(income_by_period)
|
||||
return {"holdings": holdings_cfg, "cashflows": cashflows_cfg, "income": income_cfg}
|
||||
|
||||
|
||||
def _holdings_config(holdings: pd.DataFrame) -> dict | None:
|
||||
if holdings is None or holdings.empty:
|
||||
return None
|
||||
alloc_col = "market_value" if "market_value" in holdings.columns else "cost_basis"
|
||||
filtered = holdings.loc[holdings[alloc_col] > 0]
|
||||
if filtered.empty:
|
||||
return None
|
||||
values = _round_series(filtered[alloc_col])
|
||||
return {
|
||||
"type": "doughnut",
|
||||
"data": {
|
||||
"labels": [str(t) for t in filtered["ticker"].tolist()],
|
||||
"datasets": [{
|
||||
"data": values,
|
||||
"backgroundColor": [DOUGHNUT_COLORS[i % len(DOUGHNUT_COLORS)]
|
||||
for i in range(len(values))],
|
||||
}],
|
||||
},
|
||||
"options": {
|
||||
"responsive": True,
|
||||
"maintainAspectRatio": False,
|
||||
"plugins": {"legend": {"position": "right",
|
||||
"labels": {"boxWidth": 12, "font": {"size": 11}}}},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _cashflows_config(flows: dict[str, float]) -> dict | None:
|
||||
if not flows:
|
||||
return None
|
||||
items = {
|
||||
"Deposits": float(flows["deposits"]),
|
||||
"Withdrawals": -float(flows["withdrawals"]),
|
||||
"Interest": float(flows["interest"]),
|
||||
"Dividends": float(flows["dividends"]),
|
||||
"Div.tax": float(flows["dividend_tax"]),
|
||||
"Invested": -float(flows["invested"]),
|
||||
"Proceeds": float(flows["proceeds"]),
|
||||
"FX fees": float(flows["conversion_fees"]),
|
||||
"Fees": -float(flows["fees"]),
|
||||
}
|
||||
items = {k: v for k, v in items.items() if abs(v) > 1e-9}
|
||||
if not items:
|
||||
return None
|
||||
labels = list(items.keys())
|
||||
values = _round_series(items.values())
|
||||
colors = ["#2ecc71" if v >= 0 else "#e74c3c" for v in items.values()]
|
||||
return {
|
||||
"type": "bar",
|
||||
"data": {"labels": labels,
|
||||
"datasets": [{"label": "Cash flows", "data": values,
|
||||
"backgroundColor": colors}]},
|
||||
"options": {
|
||||
"responsive": True,
|
||||
"maintainAspectRatio": False,
|
||||
"plugins": {"legend": {"display": False}},
|
||||
"scales": {"x": {"ticks": {"maxRotation": 30, "autoSkip": False}},
|
||||
"y": {"beginAtZero": True}},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _income_config(income_by_period: pd.Series) -> dict | None:
|
||||
if income_by_period is None or income_by_period.empty:
|
||||
return None
|
||||
return {
|
||||
"type": "bar",
|
||||
"data": {
|
||||
"labels": [str(i) for i in income_by_period.index],
|
||||
"datasets": [{"label": "Income",
|
||||
"data": _round_series(income_by_period.tolist()),
|
||||
"backgroundColor": "#3498db"}],
|
||||
},
|
||||
"options": {
|
||||
"responsive": True,
|
||||
"maintainAspectRatio": False,
|
||||
"plugins": {"legend": {"display": False}},
|
||||
"scales": {"x": {"ticks": {"maxRotation": 45, "autoSkip": False}},
|
||||
"y": {"beginAtZero": True}},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
_RENDER_SCRIPT = r"""
|
||||
function _bootPortfolioCharts() {
|
||||
var block = document.getElementById('chart-data');
|
||||
if (!block) { return; }
|
||||
var data = JSON.parse(block.textContent);
|
||||
var ccy = data.currency || 'EUR';
|
||||
function fmt(v) {
|
||||
try { return new Intl.NumberFormat('en-US', {style: 'currency', currency: ccy}).format(v); }
|
||||
catch (e) { return String(v); }
|
||||
}
|
||||
function applyTooltip(cfg) {
|
||||
if (!cfg || !cfg.options) { return; }
|
||||
cfg.options.plugins = cfg.options.plugins || {};
|
||||
cfg.options.plugins.tooltip = cfg.options.plugins.tooltip || {};
|
||||
cfg.options.plugins.tooltip.callbacks = cfg.options.plugins.tooltip.callbacks || {};
|
||||
if (cfg.type === 'doughnut' || cfg.type === 'pie') {
|
||||
cfg.options.plugins.tooltip.callbacks.label = function (ctx) {
|
||||
var total = (ctx.dataset && ctx.dataset.data)
|
||||
? ctx.dataset.data.reduce(function (a, b) { return a + (typeof b === 'number' ? b : 0); }, 0)
|
||||
: 0;
|
||||
var v = (typeof ctx.parsed === 'number') ? ctx.parsed : ctx.raw;
|
||||
var pct = total > 0 ? (v / total * 100) : 0;
|
||||
return (ctx.label ? ctx.label + ': ' : '') + fmt(v) + ' (' + pct.toFixed(1) + '%)';
|
||||
};
|
||||
return;
|
||||
}
|
||||
cfg.options.plugins.tooltip.callbacks.label = function (ctx) {
|
||||
var label = (ctx.dataset && ctx.dataset.label) ? ctx.dataset.label : '';
|
||||
var v = (ctx.parsed && Object.prototype.hasOwnProperty.call(ctx.parsed, 'y'))
|
||||
? ctx.parsed.y : (typeof ctx.parsed === 'number' ? ctx.parsed : ctx.raw);
|
||||
return label ? (label + ': ' + fmt(v)) : fmt(v);
|
||||
};
|
||||
}
|
||||
function mount(id, cfg, plugins) {
|
||||
if (!cfg) { return; }
|
||||
var el = document.getElementById(id);
|
||||
if (!el) { return; }
|
||||
applyTooltip(cfg);
|
||||
var config = {type: cfg.type, data: cfg.data, options: cfg.options};
|
||||
if (plugins && plugins.length) { config.plugins = plugins; }
|
||||
new Chart(el.getContext('2d'), config);
|
||||
}
|
||||
var gainLossPlugin = {
|
||||
id: 'gainLoss',
|
||||
beforeDatasetsDraw: function (chart) {
|
||||
var ds = chart.data.datasets;
|
||||
if (ds.length < 2) { return; }
|
||||
var meta0 = chart.getDatasetMeta(0);
|
||||
var meta1 = chart.getDatasetMeta(1);
|
||||
var cost = ds[0].data;
|
||||
var value = ds[1].data;
|
||||
if (!meta0 || !meta1 || !meta0.data || !meta1.data) { return; }
|
||||
var ctx = chart.ctx;
|
||||
ctx.save();
|
||||
for (var i = 0; i < value.length - 1; i++) {
|
||||
var a0 = meta0.data[i], a1 = meta0.data[i + 1];
|
||||
var b0 = meta1.data[i], b1 = meta1.data[i + 1];
|
||||
if (!a0 || !a1 || !b0 || !b1) { continue; }
|
||||
var gain = (value[i] >= cost[i] && value[i + 1] >= cost[i + 1]);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(a0.x, a0.y); ctx.lineTo(a1.x, a1.y);
|
||||
ctx.lineTo(b1.x, b1.y); ctx.lineTo(b0.x, b0.y);
|
||||
ctx.closePath();
|
||||
ctx.fillStyle = gain ? 'rgba(31,157,85,0.25)' : 'rgba(227,52,47,0.25)';
|
||||
ctx.fill();
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
};
|
||||
mount('evolution-chart', data.evolution, [gainLossPlugin]);
|
||||
mount('holdings-chart', data.holdings);
|
||||
mount('cashflows-chart', data.cashflows);
|
||||
mount('income-chart', data.income);
|
||||
}
|
||||
if (document.readyState !== 'loading') { _bootPortfolioCharts(); }
|
||||
else { document.addEventListener('DOMContentLoaded', _bootPortfolioCharts); }
|
||||
"""
|
||||
|
||||
|
||||
def render_charts_block(
|
||||
evolution_cfg: dict | None, review_cfg: dict, currency: str
|
||||
) -> str:
|
||||
"""Return the HTML fragment: canvases + inlined Chart.js + JSON + render script.
|
||||
|
||||
Returns "" when there is nothing to render.
|
||||
"""
|
||||
holdings_cfg = review_cfg.get("holdings") if review_cfg else None
|
||||
cashflows_cfg = review_cfg.get("cashflows") if review_cfg else None
|
||||
income_cfg = review_cfg.get("income") if review_cfg else None
|
||||
|
||||
if evolution_cfg is None and not any([holdings_cfg, cashflows_cfg, income_cfg]):
|
||||
return ""
|
||||
|
||||
parts: list[str] = []
|
||||
|
||||
if evolution_cfg is not None:
|
||||
parts.append(
|
||||
"<div class='card chart full' id='charts'>\n"
|
||||
" <h2>Portfolio Evolution — Cost vs Value</h2>\n"
|
||||
" <div class='chart-wrap' style='height:380px'>"
|
||||
"<canvas id='evolution-chart'></canvas></div>\n"
|
||||
"</div>"
|
||||
)
|
||||
|
||||
grid_cells = []
|
||||
if holdings_cfg is not None:
|
||||
grid_cells.append(
|
||||
"<div><h3>Holdings Allocation</h3>"
|
||||
"<div class='chart-wrap' style='height:300px'>"
|
||||
"<canvas id='holdings-chart'></canvas></div></div>"
|
||||
)
|
||||
else:
|
||||
grid_cells.append("<div><h3>Holdings Allocation</h3>"
|
||||
"<p class='muted'>No open positions.</p></div>")
|
||||
if cashflows_cfg is not None:
|
||||
grid_cells.append(
|
||||
"<div><h3>Cash Flows</h3>"
|
||||
"<div class='chart-wrap' style='height:300px'>"
|
||||
"<canvas id='cashflows-chart'></canvas></div></div>"
|
||||
)
|
||||
else:
|
||||
grid_cells.append("<div><h3>Cash Flows</h3>"
|
||||
"<p class='muted'>No cash flows.</p></div>")
|
||||
# Income is optional: the income cell is omitted entirely when there is no
|
||||
# income data, unlike holdings/cashflows which always render a cell with a
|
||||
# muted fallback.
|
||||
if income_cfg is not None:
|
||||
grid_cells.append(
|
||||
"<div><h3>Income Over Time</h3>"
|
||||
"<div class='chart-wrap' style='height:300px'>"
|
||||
"<canvas id='income-chart'></canvas></div></div>"
|
||||
)
|
||||
charts_id_attr = " id='charts'" if evolution_cfg is None else ""
|
||||
parts.append(
|
||||
f"<div class='card chart full'{charts_id_attr}>\n"
|
||||
" <h2>Charts</h2>\n"
|
||||
" <div class='chart-grid'>\n " +
|
||||
"\n ".join(grid_cells) + "\n </div>\n"
|
||||
"</div>"
|
||||
)
|
||||
|
||||
payload = {
|
||||
"currency": currency,
|
||||
"evolution": evolution_cfg,
|
||||
"holdings": holdings_cfg,
|
||||
"cashflows": cashflows_cfg,
|
||||
"income": income_cfg,
|
||||
}
|
||||
# Escape < and > so the JSON is always safe to inline inside a <script>
|
||||
# block, even if a label ever contained the literal "</script>".
|
||||
data_json = json.dumps(payload).replace("<", "\\u003c").replace(">", "\\u003e")
|
||||
|
||||
parts.append(
|
||||
"<script>\n" + load_chartjs_inline() + "\n</script>\n"
|
||||
"<script type='application/json' id='chart-data'>" + data_json + "</script>\n"
|
||||
"<script>\n" + _RENDER_SCRIPT + "\n</script>"
|
||||
)
|
||||
return "\n".join(parts)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,4 @@
|
||||
pandas>=2.2,<4
|
||||
numpy>=1.26,<3
|
||||
openpyxl>=3.1,<4
|
||||
yfinance>=0.2,<2
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
if [[ -n "${PYTHON:-}" ]]; then
|
||||
PYTHON_BIN="$PYTHON"
|
||||
elif [[ -x ".venv/bin/python" ]]; then
|
||||
PYTHON_BIN=".venv/bin/python"
|
||||
else
|
||||
PYTHON_BIN="python3"
|
||||
fi
|
||||
|
||||
exec "$PYTHON_BIN" "$SCRIPT_DIR/main.py" "$@" --csv
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
VENV_DIR="${VENV_DIR:-.venv}"
|
||||
PYTHON_BOOTSTRAP="${PYTHON:-python3}"
|
||||
|
||||
if [[ ! -x "$VENV_DIR/bin/python" ]]; then
|
||||
"$PYTHON_BOOTSTRAP" -m venv "$VENV_DIR"
|
||||
fi
|
||||
|
||||
"$VENV_DIR/bin/python" -m pip install --upgrade pip
|
||||
"$VENV_DIR/bin/python" -m pip install -r "$SCRIPT_DIR/requirements.txt"
|
||||
|
||||
echo "Environment ready: $VENV_DIR"
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
if [[ -n "${PYTHON:-}" ]]; then
|
||||
PYTHON_BIN="$PYTHON"
|
||||
elif [[ -x ".venv/bin/python" ]]; then
|
||||
PYTHON_BIN=".venv/bin/python"
|
||||
else
|
||||
PYTHON_BIN="python3"
|
||||
fi
|
||||
|
||||
"$PYTHON_BIN" - <<PY
|
||||
import importlib.util
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
script_dir = Path("$SCRIPT_DIR")
|
||||
sys.path.insert(0, str(script_dir))
|
||||
|
||||
for module in ("pandas", "openpyxl", "yfinance"):
|
||||
if importlib.util.find_spec(module) is None:
|
||||
raise SystemExit(
|
||||
f"Missing dependency: {module}. Install with: "
|
||||
f"{sys.executable} -m pip install -r {script_dir / 'requirements.txt'}"
|
||||
)
|
||||
|
||||
import main
|
||||
import html_charts
|
||||
|
||||
if not html_charts.CHARTJS_PATH.exists():
|
||||
raise SystemExit(f"Missing Chart.js asset: {html_charts.CHARTJS_PATH}")
|
||||
|
||||
print("XTB portfolio review skill tools are importable.")
|
||||
PY
|
||||
@@ -0,0 +1,42 @@
|
||||
---
|
||||
name: xtb-wealthfolio-export
|
||||
description: Use when converting XTB brokerage .xlsx exports to Wealthfolio-compatible CSV, validating Wealthfolio import rows, checking transaction activity mappings, or debugging exporter.py output.
|
||||
---
|
||||
|
||||
# XTB Wealthfolio Export
|
||||
|
||||
Use this skill to create and validate Wealthfolio CSV files from XTB `Cash Operations` data from a copied skill folder. The skill bundles the required Python tools in `scripts/`, so it can run without the original repository as long as Python dependencies are installed.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Identify the target workbook. If omitted and exactly one non-lock `.xlsx` exists in the current working directory, the exporter can auto-detect it.
|
||||
2. Ensure dependencies are available:
|
||||
`<skill-folder>/scripts/setup-env.sh`
|
||||
3. Validate the bundled tools:
|
||||
`<skill-folder>/scripts/validate-export.sh`
|
||||
4. Create the Wealthfolio CSV from the directory where outputs should be written:
|
||||
`<skill-folder>/scripts/export-wealthfolio.sh <report.xlsx>`
|
||||
5. If the user needs a custom path, run:
|
||||
`<skill-folder>/scripts/export-wealthfolio.sh <report.xlsx> -o <output.csv>`
|
||||
6. Inspect the generated CSV header and a sample of rows before saying it is import-ready.
|
||||
7. If row classification looks suspicious, read `references/wealthfolio-csv.md` and compare activity mappings.
|
||||
|
||||
## Bundled Tools
|
||||
|
||||
- `scripts/exporter.py`: standalone XTB to Wealthfolio CSV exporter.
|
||||
- `scripts/main.py`: shared XTB parsing helpers used by the exporter.
|
||||
- `scripts/html_charts.py` and `scripts/assets/`: bundled because `main.py` imports the report helper.
|
||||
- `scripts/export-wealthfolio.sh`: shell wrapper that runs the bundled exporter.
|
||||
- `scripts/validate-export.sh`: dependency and schema smoke check.
|
||||
- `scripts/setup-env.sh`: creates `.venv` in the current working directory and installs dependencies.
|
||||
- `scripts/requirements.txt`: Python dependencies.
|
||||
|
||||
## References
|
||||
|
||||
- Read `references/wealthfolio-csv.md` for Wealthfolio schema, XTB activity mapping, and known XTB comment quirks.
|
||||
|
||||
## Guardrails
|
||||
|
||||
- Do not hand-edit exported CSV rows unless the user asks; prefer fixing `scripts/exporter.py` when mappings are wrong.
|
||||
- Keep `BUY` and `SELL` trade rows with blank `amount`; Wealthfolio calculates trade amount from `quantity * unitPrice`.
|
||||
- For pure cash activities, use `$CASH-<CCY>` and set `quantity = 1`, `unitPrice = 1`, and `amount` to the absolute cash value.
|
||||
@@ -0,0 +1,62 @@
|
||||
# Wealthfolio CSV Mapping
|
||||
|
||||
Load this when validating or debugging XTB to Wealthfolio exports.
|
||||
|
||||
## Required Header
|
||||
|
||||
`date,symbol,quantity,activityType,unitPrice,currency,fee,amount`
|
||||
|
||||
## XTB To Wealthfolio Mapping
|
||||
|
||||
- `Stock purchase` or `OPEN BUY` -> `BUY`
|
||||
- `Stock sale`, `CLOSE SELL`, or `OPEN SELL` -> `SELL`
|
||||
- `Stock sell` with `CLOSE BUY` -> `SELL` because XTB can encode sale close legs this way
|
||||
- `Deposit` -> `DEPOSIT`
|
||||
- `Withdrawal` -> `WITHDRAWAL`
|
||||
- `Dividend` -> `DIVIDEND`
|
||||
- `Dividend tax` -> `TAX`
|
||||
- `Free funds interest` -> `INTEREST`
|
||||
- `Currency conversion` -> `FEE`
|
||||
|
||||
## Row Rules
|
||||
|
||||
- `BUY` and `SELL`:
|
||||
- `symbol`: real ticker when available
|
||||
- `quantity`: parsed share count
|
||||
- `unitPrice`: parsed `@ price`
|
||||
- `fee`: inline trading fee if supported by the exporter, otherwise `0.00`
|
||||
- `amount`: blank
|
||||
- Cash activities (`DEPOSIT`, `WITHDRAWAL`, `INTEREST`, `TAX`, `FEE`):
|
||||
- `symbol`: `$CASH-<CCY>`
|
||||
- `quantity`: `1`
|
||||
- `unitPrice`: `1`
|
||||
- `amount`: absolute cash value
|
||||
- `DIVIDEND`:
|
||||
- Keep the real security ticker when available
|
||||
- Use `quantity = 1`, `unitPrice = 1`, and `amount` as the absolute dividend cash value
|
||||
|
||||
## Quantity Parsing
|
||||
|
||||
- For comments like `OPEN BUY 6 @ 301.50`, quantity is `6`.
|
||||
- For split fills like `OPEN BUY 1/100 @ 14.3130`, quantity is the numerator `1`, not `0.01`.
|
||||
- If no parseable quantity exists, the exporter may fall back to `abs(amount) / price`.
|
||||
|
||||
## Validation Commands
|
||||
|
||||
- Install dependencies:
|
||||
`<skill-folder>/scripts/setup-env.sh`
|
||||
- Validate bundled tools:
|
||||
`<skill-folder>/scripts/validate-export.sh`
|
||||
- Generate default CSV:
|
||||
`<skill-folder>/scripts/export-wealthfolio.sh <report.xlsx>`
|
||||
- If working inside the original project repository, full tests are also useful:
|
||||
`.venv/bin/python -m pytest -q`
|
||||
|
||||
## Import Readiness Checks
|
||||
|
||||
- Header exactly matches the required schema.
|
||||
- Activity types are among Wealthfolio-supported values used by the exporter.
|
||||
- Trade rows have blank `amount`.
|
||||
- Cash rows have nonblank positive `amount` and `$CASH-<CCY>` unless dividend ticker retention applies.
|
||||
- `CLOSE BUY` stock-sale rows export as `SELL`.
|
||||
- Split-fill rows use numerator quantity.
|
||||
@@ -0,0 +1 @@
|
||||
4.5.1
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,13 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
if [[ -n "${PYTHON:-}" ]]; then
|
||||
PYTHON_BIN="$PYTHON"
|
||||
elif [[ -x ".venv/bin/python" ]]; then
|
||||
PYTHON_BIN=".venv/bin/python"
|
||||
else
|
||||
PYTHON_BIN="python3"
|
||||
fi
|
||||
|
||||
exec "$PYTHON_BIN" "$SCRIPT_DIR/exporter.py" "$@"
|
||||
@@ -0,0 +1,219 @@
|
||||
"""XTB report → Wealthfolio CSV exporter.
|
||||
|
||||
Wealthfolio expects a CSV with this header:
|
||||
date,symbol,quantity,activityType,unitPrice,currency,fee,amount
|
||||
|
||||
Activity-type mapping from an XTB "Cash Operations" sheet:
|
||||
Stock purchase (OPEN BUY ...) -> BUY (qty=shares, unitPrice=price)
|
||||
Stock sale (CLOSE SELL ...) -> SELL (qty=shares, unitPrice=price)
|
||||
Stock sale (OPEN SELL ...) -> SELL (short open, qty=shares)
|
||||
Deposit -> DEPOSIT
|
||||
Withdrawal -> WITHDRAWAL
|
||||
Dividend -> DIVIDEND
|
||||
Dividend tax -> TAX
|
||||
Free funds interest -> INTEREST
|
||||
Currency conversion -> FEE
|
||||
|
||||
Cash activities (DEPOSIT/WITHDRAWAL/DIVIDEND/INTEREST/TAX/FEE) carry their
|
||||
value in `amount` with `quantity=1`, `unitPrice=1`; `symbol` is `$CASH-<CCY>`
|
||||
for pure-cash rows and the real ticker for dividends. The `fee` column is only
|
||||
used for inline BUY/SELL commissions; trades leave `amount` blank (it is
|
||||
auto-calculated as quantity * unitPrice by Wealthfolio).
|
||||
|
||||
Run:
|
||||
python exporter.py # writes results/<stem>_wealthfolio.csv
|
||||
python exporter.py -o my.csv EUR_xxx.xlsx
|
||||
"""
|
||||
import argparse
|
||||
import csv
|
||||
from pathlib import Path
|
||||
|
||||
import pandas as pd
|
||||
|
||||
import main
|
||||
from main import (
|
||||
CONVERSION_RE,
|
||||
DEPOSIT_RE,
|
||||
DIVIDEND_RE,
|
||||
DIVIDEND_TAX_RE,
|
||||
INTEREST_RE,
|
||||
PRICE_RE,
|
||||
TRADE_COMMENT_RE,
|
||||
WITHDRAW_RE,
|
||||
find_column,
|
||||
parse_numeric,
|
||||
)
|
||||
|
||||
FIELDS = ["date", "symbol", "quantity", "activityType", "unitPrice", "currency", "fee", "amount"]
|
||||
|
||||
# XTB trade comment captures both the action (OPEN/CLOSE) and side (BUY/SELL).
|
||||
SHORT_OPEN_RE = __import__("re").compile(r"OPEN\s+SELL", __import__("re").IGNORECASE)
|
||||
QTY_RE = __import__("re").compile(r"(?:OPEN|CLOSE)\s+(?:BUY|SELL)\s+([\d./]+)", __import__("re").IGNORECASE)
|
||||
|
||||
|
||||
def _trade_quantity(comment: str, value: float, price: float) -> float:
|
||||
"""Derive executed shares from an XTB trade comment.
|
||||
|
||||
XTB writes split fills as "N/M @ price" where N is this fill's share count
|
||||
and M the parent order size (e.g. "1/100" = 1 share). Prefer the numerator;
|
||||
fall back to cash / price.
|
||||
"""
|
||||
m = QTY_RE.search(comment)
|
||||
if m:
|
||||
token = m.group(1)
|
||||
if "/" in token:
|
||||
try:
|
||||
numerator = float(token.split("/", 1)[0])
|
||||
if numerator > 0:
|
||||
return numerator
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
try:
|
||||
return float(token.replace(",", "."))
|
||||
except ValueError:
|
||||
pass
|
||||
return round(abs(value) / price, 6) if price > 0 else 0.0
|
||||
|
||||
|
||||
def classify(type_val: str, comment: str) -> str | None:
|
||||
text = f"{type_val} {comment}".lower()
|
||||
if DIVIDEND_TAX_RE.search(text):
|
||||
return "TAX"
|
||||
if DIVIDEND_RE.search(text):
|
||||
return "DIVIDEND"
|
||||
if INTEREST_RE.search(text):
|
||||
return "INTEREST"
|
||||
if CONVERSION_RE.search(text):
|
||||
return "FEE"
|
||||
if WITHDRAW_RE.search(text):
|
||||
return "WITHDRAWAL"
|
||||
if DEPOSIT_RE.search(text):
|
||||
return "DEPOSIT"
|
||||
if TRADE_COMMENT_RE.search(comment):
|
||||
lowered_comment = comment.lower()
|
||||
lowered_type = type_val.lower()
|
||||
is_sell = (
|
||||
"close sell" in lowered_comment
|
||||
or SHORT_OPEN_RE.search(comment)
|
||||
or ("close buy" in lowered_comment and "sell" in lowered_type)
|
||||
)
|
||||
return "SELL" if is_sell else "BUY"
|
||||
return None
|
||||
|
||||
|
||||
def _fmt_date(val) -> str:
|
||||
dt = pd.to_datetime(val, errors="coerce")
|
||||
if pd.isna(dt):
|
||||
return ""
|
||||
return dt.strftime("%Y-%m-%d")
|
||||
|
||||
|
||||
def build_rows(
|
||||
cash_ops: pd.DataFrame, currency: str
|
||||
) -> list[dict[str, str | float]]:
|
||||
type_col = find_column(cash_ops, ["type", "operation"], required=False)
|
||||
ticker_col = find_column(
|
||||
cash_ops, ["ticker", "symbol", "instrument", "market"], required=False
|
||||
)
|
||||
amount_col = find_column(
|
||||
cash_ops, ["amount", "value", "net_amount", "cash", "change", "payment"],
|
||||
required=False,
|
||||
)
|
||||
date_col = find_column(
|
||||
cash_ops, ["time", "date", "operation_date", "booking_date", "transaction_date"],
|
||||
required=False,
|
||||
)
|
||||
comment_col = find_column(cash_ops, ["comment", "description", "details"], required=False)
|
||||
|
||||
if not (type_col and amount_col):
|
||||
return []
|
||||
|
||||
rows: list[dict[str, str | float]] = []
|
||||
for _, row in cash_ops.iterrows():
|
||||
type_val = str(row.get(type_col, "")).strip()
|
||||
comment = str(row.get(comment_col, "")) if comment_col else ""
|
||||
activity = classify(type_val, comment)
|
||||
if activity is None:
|
||||
continue
|
||||
|
||||
amount = float(parse_numeric(pd.Series([row[amount_col]])).iloc[0])
|
||||
date = _fmt_date(row.get(date_col)) if date_col else ""
|
||||
cash_sym = f"$CASH-{currency}"
|
||||
ticker = str(row[ticker_col]).strip() if ticker_col and pd.notna(row.get(ticker_col)) else ""
|
||||
|
||||
if activity in ("BUY", "SELL"):
|
||||
price = 0.0
|
||||
m = PRICE_RE.search(comment)
|
||||
if m:
|
||||
price = float(parse_numeric(pd.Series([m.group(1)])).iloc[0])
|
||||
quantity = _trade_quantity(comment, amount, price)
|
||||
rows.append({
|
||||
"date": date, "symbol": ticker or cash_sym, "quantity": quantity,
|
||||
"activityType": activity, "unitPrice": round(price, 6),
|
||||
"currency": currency, "fee": 0.0, "amount": "",
|
||||
})
|
||||
elif activity == "DIVIDEND":
|
||||
rows.append({
|
||||
"date": date, "symbol": ticker or cash_sym, "quantity": 1.0,
|
||||
"activityType": activity, "unitPrice": 1.0,
|
||||
"currency": currency, "fee": 0.0, "amount": round(abs(amount), 6),
|
||||
})
|
||||
elif activity in ("DEPOSIT", "WITHDRAWAL", "INTEREST", "TAX", "FEE"):
|
||||
rows.append({
|
||||
"date": date, "symbol": cash_sym, "quantity": 1.0,
|
||||
"activityType": activity, "unitPrice": 1.0,
|
||||
"currency": currency, "fee": 0.0, "amount": round(abs(amount), 6),
|
||||
})
|
||||
return rows
|
||||
|
||||
|
||||
def export(
|
||||
xlsx_path: Path | str | None = None,
|
||||
output_path: Path | str | None = None,
|
||||
) -> Path:
|
||||
main.REPORT_FILE = main.resolve_report_file(xlsx_path)
|
||||
currency = main.detect_currency()
|
||||
_, cash_ops, _, _ = main.load_data()
|
||||
rows = build_rows(cash_ops, currency)
|
||||
|
||||
if output_path:
|
||||
out = Path(output_path)
|
||||
else:
|
||||
stem = main.REPORT_FILE.stem if main.REPORT_FILE else "portfolio"
|
||||
out = main.RESULTS_DIR / f"{stem}_wealthfolio.csv"
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
with out.open("w", newline="", encoding="utf-8") as f:
|
||||
writer = csv.DictWriter(f, fieldnames=FIELDS)
|
||||
writer.writeheader()
|
||||
for r in rows:
|
||||
amt = r["amount"]
|
||||
writer.writerow({
|
||||
"date": r["date"],
|
||||
"symbol": r["symbol"],
|
||||
"quantity": f"{r['quantity']:.6f}".rstrip("0").rstrip("."),
|
||||
"activityType": r["activityType"],
|
||||
"unitPrice": f"{r['unitPrice']:.6f}".rstrip("0").rstrip("."),
|
||||
"currency": r["currency"],
|
||||
"fee": f"{r['fee']:.2f}",
|
||||
"amount": "" if amt == "" else f"{amt:.6f}".rstrip("0").rstrip("."),
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def main_cli() -> None:
|
||||
p = argparse.ArgumentParser(description="Export XTB xlsx to Wealthfolio CSV.")
|
||||
p.add_argument("input", nargs="?", default=None,
|
||||
help="Path to the XTB .xlsx report (auto-detected if omitted)")
|
||||
p.add_argument("-o", "--output", default=None,
|
||||
help="Output CSV path (default: results/<stem>_wealthfolio.csv)")
|
||||
args = p.parse_args()
|
||||
try:
|
||||
out = export(args.input, args.output)
|
||||
except (FileNotFoundError, ValueError) as exc:
|
||||
p.error(str(exc))
|
||||
print(f"Wrote {out.resolve()} ({out.stat().st_size} bytes)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main_cli()
|
||||
@@ -0,0 +1,365 @@
|
||||
"""Interactive Chart.js charts for the self-contained HTML report.
|
||||
|
||||
This module is the only place that knows about Chart.js. It reads the vendored
|
||||
UMD bundle from assets/ and builds Chart.js config dicts (pure functions) plus
|
||||
an HTML fragment that inlines the bundle, the data (JSON), and a render script.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pandas as pd
|
||||
|
||||
ASSETS_DIR = Path(__file__).resolve().parent / "assets"
|
||||
CHARTJS_PATH = ASSETS_DIR / "chartjs.umd.min.js"
|
||||
CHARTJS_VERSION_PATH = ASSETS_DIR / "chartjs.VERSION"
|
||||
|
||||
|
||||
def load_chartjs_inline() -> str:
|
||||
"""Return the minified Chart.js UMD source, vendored under assets/."""
|
||||
if not CHARTJS_PATH.exists():
|
||||
raise FileNotFoundError(
|
||||
f"Chart.js bundle not found at {CHARTJS_PATH}. "
|
||||
"Re-vendor it (see assets/chartjs.VERSION)."
|
||||
)
|
||||
return CHARTJS_PATH.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _iso(value: Any) -> str:
|
||||
if hasattr(value, "isoformat"):
|
||||
return value.isoformat()[:10]
|
||||
return str(value)
|
||||
|
||||
|
||||
def _round_series(values) -> list[float]:
|
||||
return [round(float(v), 2) for v in values]
|
||||
|
||||
|
||||
def evolution_chart_config(evolution_df: pd.DataFrame, currency: str) -> dict | None:
|
||||
"""Build a Chart.js line-chart config for cost vs value over time.
|
||||
|
||||
Returns None when there is no evolution data (caller omits the card).
|
||||
"""
|
||||
if evolution_df is None or evolution_df.empty:
|
||||
return None
|
||||
labels = [_iso(d) for d in evolution_df.index]
|
||||
return {
|
||||
"type": "line",
|
||||
"data": {
|
||||
"labels": labels,
|
||||
"datasets": [
|
||||
{
|
||||
"label": "Cost (invested)",
|
||||
"data": _round_series(evolution_df["cost"]),
|
||||
"borderColor": "#6b7280",
|
||||
"backgroundColor": "#6b7280",
|
||||
"borderWidth": 2,
|
||||
"fill": False,
|
||||
"pointRadius": 0,
|
||||
"tension": 0.1,
|
||||
},
|
||||
{
|
||||
"label": "Value (realized + unrealized)",
|
||||
"data": _round_series(evolution_df["total_value"]),
|
||||
"borderColor": "#2c5282",
|
||||
"backgroundColor": "#2c5282",
|
||||
"borderWidth": 2,
|
||||
"fill": False,
|
||||
"pointRadius": 0,
|
||||
"tension": 0.1,
|
||||
},
|
||||
{
|
||||
"label": "Cumulative realized P/L",
|
||||
"data": _round_series(evolution_df["realized_pl"]),
|
||||
"borderColor": "#f39c12",
|
||||
"backgroundColor": "#f39c12",
|
||||
"borderWidth": 1.5,
|
||||
"borderDash": [6, 4],
|
||||
"fill": False,
|
||||
"pointRadius": 0,
|
||||
"tension": 0.1,
|
||||
},
|
||||
],
|
||||
},
|
||||
"options": {
|
||||
"responsive": True,
|
||||
"maintainAspectRatio": False,
|
||||
"interaction": {"mode": "index", "intersect": False},
|
||||
"plugins": {
|
||||
"legend": {"position": "bottom",
|
||||
"labels": {"boxWidth": 12, "font": {"size": 12}}},
|
||||
},
|
||||
"scales": {
|
||||
"x": {"ticks": {"maxRotation": 45, "autoSkip": True}},
|
||||
"y": {"beginAtZero": False},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
DOUGHNUT_COLORS = [
|
||||
"#2c5282", "#1f9d55", "#f39c12", "#3498db", "#9b59b6",
|
||||
"#e67e22", "#16a085", "#34495e", "#e3342f", "#7f8c8d",
|
||||
]
|
||||
|
||||
|
||||
def review_charts_config(
|
||||
holdings: pd.DataFrame,
|
||||
flows: dict[str, float],
|
||||
income_by_period: pd.Series,
|
||||
currency: str,
|
||||
) -> dict:
|
||||
"""Build Chart.js configs for the three review charts.
|
||||
|
||||
Returns {'holdings': cfg|None, 'cashflows': cfg|None, 'income': cfg|None}.
|
||||
Each is None when its source data is empty.
|
||||
"""
|
||||
holdings_cfg = _holdings_config(holdings)
|
||||
cashflows_cfg = _cashflows_config(flows)
|
||||
income_cfg = _income_config(income_by_period)
|
||||
return {"holdings": holdings_cfg, "cashflows": cashflows_cfg, "income": income_cfg}
|
||||
|
||||
|
||||
def _holdings_config(holdings: pd.DataFrame) -> dict | None:
|
||||
if holdings is None or holdings.empty:
|
||||
return None
|
||||
alloc_col = "market_value" if "market_value" in holdings.columns else "cost_basis"
|
||||
filtered = holdings.loc[holdings[alloc_col] > 0]
|
||||
if filtered.empty:
|
||||
return None
|
||||
values = _round_series(filtered[alloc_col])
|
||||
return {
|
||||
"type": "doughnut",
|
||||
"data": {
|
||||
"labels": [str(t) for t in filtered["ticker"].tolist()],
|
||||
"datasets": [{
|
||||
"data": values,
|
||||
"backgroundColor": [DOUGHNUT_COLORS[i % len(DOUGHNUT_COLORS)]
|
||||
for i in range(len(values))],
|
||||
}],
|
||||
},
|
||||
"options": {
|
||||
"responsive": True,
|
||||
"maintainAspectRatio": False,
|
||||
"plugins": {"legend": {"position": "right",
|
||||
"labels": {"boxWidth": 12, "font": {"size": 11}}}},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _cashflows_config(flows: dict[str, float]) -> dict | None:
|
||||
if not flows:
|
||||
return None
|
||||
items = {
|
||||
"Deposits": float(flows["deposits"]),
|
||||
"Withdrawals": -float(flows["withdrawals"]),
|
||||
"Interest": float(flows["interest"]),
|
||||
"Dividends": float(flows["dividends"]),
|
||||
"Div.tax": float(flows["dividend_tax"]),
|
||||
"Invested": -float(flows["invested"]),
|
||||
"Proceeds": float(flows["proceeds"]),
|
||||
"FX fees": float(flows["conversion_fees"]),
|
||||
"Fees": -float(flows["fees"]),
|
||||
}
|
||||
items = {k: v for k, v in items.items() if abs(v) > 1e-9}
|
||||
if not items:
|
||||
return None
|
||||
labels = list(items.keys())
|
||||
values = _round_series(items.values())
|
||||
colors = ["#2ecc71" if v >= 0 else "#e74c3c" for v in items.values()]
|
||||
return {
|
||||
"type": "bar",
|
||||
"data": {"labels": labels,
|
||||
"datasets": [{"label": "Cash flows", "data": values,
|
||||
"backgroundColor": colors}]},
|
||||
"options": {
|
||||
"responsive": True,
|
||||
"maintainAspectRatio": False,
|
||||
"plugins": {"legend": {"display": False}},
|
||||
"scales": {"x": {"ticks": {"maxRotation": 30, "autoSkip": False}},
|
||||
"y": {"beginAtZero": True}},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _income_config(income_by_period: pd.Series) -> dict | None:
|
||||
if income_by_period is None or income_by_period.empty:
|
||||
return None
|
||||
return {
|
||||
"type": "bar",
|
||||
"data": {
|
||||
"labels": [str(i) for i in income_by_period.index],
|
||||
"datasets": [{"label": "Income",
|
||||
"data": _round_series(income_by_period.tolist()),
|
||||
"backgroundColor": "#3498db"}],
|
||||
},
|
||||
"options": {
|
||||
"responsive": True,
|
||||
"maintainAspectRatio": False,
|
||||
"plugins": {"legend": {"display": False}},
|
||||
"scales": {"x": {"ticks": {"maxRotation": 45, "autoSkip": False}},
|
||||
"y": {"beginAtZero": True}},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
_RENDER_SCRIPT = r"""
|
||||
function _bootPortfolioCharts() {
|
||||
var block = document.getElementById('chart-data');
|
||||
if (!block) { return; }
|
||||
var data = JSON.parse(block.textContent);
|
||||
var ccy = data.currency || 'EUR';
|
||||
function fmt(v) {
|
||||
try { return new Intl.NumberFormat('en-US', {style: 'currency', currency: ccy}).format(v); }
|
||||
catch (e) { return String(v); }
|
||||
}
|
||||
function applyTooltip(cfg) {
|
||||
if (!cfg || !cfg.options) { return; }
|
||||
cfg.options.plugins = cfg.options.plugins || {};
|
||||
cfg.options.plugins.tooltip = cfg.options.plugins.tooltip || {};
|
||||
cfg.options.plugins.tooltip.callbacks = cfg.options.plugins.tooltip.callbacks || {};
|
||||
if (cfg.type === 'doughnut' || cfg.type === 'pie') {
|
||||
cfg.options.plugins.tooltip.callbacks.label = function (ctx) {
|
||||
var total = (ctx.dataset && ctx.dataset.data)
|
||||
? ctx.dataset.data.reduce(function (a, b) { return a + (typeof b === 'number' ? b : 0); }, 0)
|
||||
: 0;
|
||||
var v = (typeof ctx.parsed === 'number') ? ctx.parsed : ctx.raw;
|
||||
var pct = total > 0 ? (v / total * 100) : 0;
|
||||
return (ctx.label ? ctx.label + ': ' : '') + fmt(v) + ' (' + pct.toFixed(1) + '%)';
|
||||
};
|
||||
return;
|
||||
}
|
||||
cfg.options.plugins.tooltip.callbacks.label = function (ctx) {
|
||||
var label = (ctx.dataset && ctx.dataset.label) ? ctx.dataset.label : '';
|
||||
var v = (ctx.parsed && Object.prototype.hasOwnProperty.call(ctx.parsed, 'y'))
|
||||
? ctx.parsed.y : (typeof ctx.parsed === 'number' ? ctx.parsed : ctx.raw);
|
||||
return label ? (label + ': ' + fmt(v)) : fmt(v);
|
||||
};
|
||||
}
|
||||
function mount(id, cfg, plugins) {
|
||||
if (!cfg) { return; }
|
||||
var el = document.getElementById(id);
|
||||
if (!el) { return; }
|
||||
applyTooltip(cfg);
|
||||
var config = {type: cfg.type, data: cfg.data, options: cfg.options};
|
||||
if (plugins && plugins.length) { config.plugins = plugins; }
|
||||
new Chart(el.getContext('2d'), config);
|
||||
}
|
||||
var gainLossPlugin = {
|
||||
id: 'gainLoss',
|
||||
beforeDatasetsDraw: function (chart) {
|
||||
var ds = chart.data.datasets;
|
||||
if (ds.length < 2) { return; }
|
||||
var meta0 = chart.getDatasetMeta(0);
|
||||
var meta1 = chart.getDatasetMeta(1);
|
||||
var cost = ds[0].data;
|
||||
var value = ds[1].data;
|
||||
if (!meta0 || !meta1 || !meta0.data || !meta1.data) { return; }
|
||||
var ctx = chart.ctx;
|
||||
ctx.save();
|
||||
for (var i = 0; i < value.length - 1; i++) {
|
||||
var a0 = meta0.data[i], a1 = meta0.data[i + 1];
|
||||
var b0 = meta1.data[i], b1 = meta1.data[i + 1];
|
||||
if (!a0 || !a1 || !b0 || !b1) { continue; }
|
||||
var gain = (value[i] >= cost[i] && value[i + 1] >= cost[i + 1]);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(a0.x, a0.y); ctx.lineTo(a1.x, a1.y);
|
||||
ctx.lineTo(b1.x, b1.y); ctx.lineTo(b0.x, b0.y);
|
||||
ctx.closePath();
|
||||
ctx.fillStyle = gain ? 'rgba(31,157,85,0.25)' : 'rgba(227,52,47,0.25)';
|
||||
ctx.fill();
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
};
|
||||
mount('evolution-chart', data.evolution, [gainLossPlugin]);
|
||||
mount('holdings-chart', data.holdings);
|
||||
mount('cashflows-chart', data.cashflows);
|
||||
mount('income-chart', data.income);
|
||||
}
|
||||
if (document.readyState !== 'loading') { _bootPortfolioCharts(); }
|
||||
else { document.addEventListener('DOMContentLoaded', _bootPortfolioCharts); }
|
||||
"""
|
||||
|
||||
|
||||
def render_charts_block(
|
||||
evolution_cfg: dict | None, review_cfg: dict, currency: str
|
||||
) -> str:
|
||||
"""Return the HTML fragment: canvases + inlined Chart.js + JSON + render script.
|
||||
|
||||
Returns "" when there is nothing to render.
|
||||
"""
|
||||
holdings_cfg = review_cfg.get("holdings") if review_cfg else None
|
||||
cashflows_cfg = review_cfg.get("cashflows") if review_cfg else None
|
||||
income_cfg = review_cfg.get("income") if review_cfg else None
|
||||
|
||||
if evolution_cfg is None and not any([holdings_cfg, cashflows_cfg, income_cfg]):
|
||||
return ""
|
||||
|
||||
parts: list[str] = []
|
||||
|
||||
if evolution_cfg is not None:
|
||||
parts.append(
|
||||
"<div class='card chart full' id='charts'>\n"
|
||||
" <h2>Portfolio Evolution — Cost vs Value</h2>\n"
|
||||
" <div class='chart-wrap' style='height:380px'>"
|
||||
"<canvas id='evolution-chart'></canvas></div>\n"
|
||||
"</div>"
|
||||
)
|
||||
|
||||
grid_cells = []
|
||||
if holdings_cfg is not None:
|
||||
grid_cells.append(
|
||||
"<div><h3>Holdings Allocation</h3>"
|
||||
"<div class='chart-wrap' style='height:300px'>"
|
||||
"<canvas id='holdings-chart'></canvas></div></div>"
|
||||
)
|
||||
else:
|
||||
grid_cells.append("<div><h3>Holdings Allocation</h3>"
|
||||
"<p class='muted'>No open positions.</p></div>")
|
||||
if cashflows_cfg is not None:
|
||||
grid_cells.append(
|
||||
"<div><h3>Cash Flows</h3>"
|
||||
"<div class='chart-wrap' style='height:300px'>"
|
||||
"<canvas id='cashflows-chart'></canvas></div></div>"
|
||||
)
|
||||
else:
|
||||
grid_cells.append("<div><h3>Cash Flows</h3>"
|
||||
"<p class='muted'>No cash flows.</p></div>")
|
||||
# Income is optional: the income cell is omitted entirely when there is no
|
||||
# income data, unlike holdings/cashflows which always render a cell with a
|
||||
# muted fallback.
|
||||
if income_cfg is not None:
|
||||
grid_cells.append(
|
||||
"<div><h3>Income Over Time</h3>"
|
||||
"<div class='chart-wrap' style='height:300px'>"
|
||||
"<canvas id='income-chart'></canvas></div></div>"
|
||||
)
|
||||
charts_id_attr = " id='charts'" if evolution_cfg is None else ""
|
||||
parts.append(
|
||||
f"<div class='card chart full'{charts_id_attr}>\n"
|
||||
" <h2>Charts</h2>\n"
|
||||
" <div class='chart-grid'>\n " +
|
||||
"\n ".join(grid_cells) + "\n </div>\n"
|
||||
"</div>"
|
||||
)
|
||||
|
||||
payload = {
|
||||
"currency": currency,
|
||||
"evolution": evolution_cfg,
|
||||
"holdings": holdings_cfg,
|
||||
"cashflows": cashflows_cfg,
|
||||
"income": income_cfg,
|
||||
}
|
||||
# Escape < and > so the JSON is always safe to inline inside a <script>
|
||||
# block, even if a label ever contained the literal "</script>".
|
||||
data_json = json.dumps(payload).replace("<", "\\u003c").replace(">", "\\u003e")
|
||||
|
||||
parts.append(
|
||||
"<script>\n" + load_chartjs_inline() + "\n</script>\n"
|
||||
"<script type='application/json' id='chart-data'>" + data_json + "</script>\n"
|
||||
"<script>\n" + _RENDER_SCRIPT + "\n</script>"
|
||||
)
|
||||
return "\n".join(parts)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,4 @@
|
||||
pandas>=2.2,<4
|
||||
numpy>=1.26,<3
|
||||
openpyxl>=3.1,<4
|
||||
yfinance>=0.2,<2
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
VENV_DIR="${VENV_DIR:-.venv}"
|
||||
PYTHON_BOOTSTRAP="${PYTHON:-python3}"
|
||||
|
||||
if [[ ! -x "$VENV_DIR/bin/python" ]]; then
|
||||
"$PYTHON_BOOTSTRAP" -m venv "$VENV_DIR"
|
||||
fi
|
||||
|
||||
"$VENV_DIR/bin/python" -m pip install --upgrade pip
|
||||
"$VENV_DIR/bin/python" -m pip install -r "$SCRIPT_DIR/requirements.txt"
|
||||
|
||||
echo "Environment ready: $VENV_DIR"
|
||||
@@ -0,0 +1,35 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
if [[ -n "${PYTHON:-}" ]]; then
|
||||
PYTHON_BIN="$PYTHON"
|
||||
elif [[ -x ".venv/bin/python" ]]; then
|
||||
PYTHON_BIN=".venv/bin/python"
|
||||
else
|
||||
PYTHON_BIN="python3"
|
||||
fi
|
||||
|
||||
"$PYTHON_BIN" - <<PY
|
||||
import importlib.util
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
script_dir = Path("$SCRIPT_DIR")
|
||||
sys.path.insert(0, str(script_dir))
|
||||
|
||||
for module in ("pandas", "openpyxl"):
|
||||
if importlib.util.find_spec(module) is None:
|
||||
raise SystemExit(
|
||||
f"Missing dependency: {module}. Install with: "
|
||||
f"{sys.executable} -m pip install -r {script_dir / 'requirements.txt'}"
|
||||
)
|
||||
|
||||
import exporter
|
||||
|
||||
required = ["date", "symbol", "quantity", "activityType", "unitPrice", "currency", "fee", "amount"]
|
||||
if exporter.FIELDS != required:
|
||||
raise SystemExit(f"Unexpected Wealthfolio fields: {exporter.FIELDS}")
|
||||
|
||||
print("XTB Wealthfolio export skill tools are importable.")
|
||||
PY
|
||||
Reference in New Issue
Block a user