From e3c84baf7e71c3c89300b3c91ee55df948e1d20a Mon Sep 17 00:00:00 2001 From: Claudiu Farcas Date: Mon, 22 Jun 2026 08:30:11 +0300 Subject: [PATCH] Clarify XTB currency conversion fees --- skills/xtb-portfolio-review/SKILL.md | 2 +- .../scripts/html_charts.py | 19 ++++--- skills/xtb-portfolio-review/scripts/main.py | 56 ++++++++++++++++--- test_portfolio.py | 36 ++++++++++++ 4 files changed, 96 insertions(+), 17 deletions(-) diff --git a/skills/xtb-portfolio-review/SKILL.md b/skills/xtb-portfolio-review/SKILL.md index a669bb1..60e0d45 100644 --- a/skills/xtb-portfolio-review/SKILL.md +++ b/skills/xtb-portfolio-review/SKILL.md @@ -1,7 +1,7 @@ --- name: xtb-portfolio-review description: Use when analyzing XTB brokerage .xlsx exports, creating investment portfolio analysis reports, generating HTML/CSV outputs, validating cash reconciliation, reviewing holdings, dividends, risk, income, performance, or explaining report outputs from main.py. -version: 1.0.1 +version: 1.0.2 --- # XTB Portfolio Review diff --git a/skills/xtb-portfolio-review/scripts/html_charts.py b/skills/xtb-portfolio-review/scripts/html_charts.py index da953be..5d0507f 100644 --- a/skills/xtb-portfolio-review/scripts/html_charts.py +++ b/skills/xtb-portfolio-review/scripts/html_charts.py @@ -153,15 +153,16 @@ 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"]), + "Deposits": float(flows.get("deposits", 0.0)), + "Withdrawals": -float(flows.get("withdrawals", 0.0)), + "Interest": float(flows.get("interest", 0.0)), + "Dividends": float(flows.get("dividends", 0.0)), + "Div.tax": float(flows.get("dividend_tax", 0.0)), + "Currency conversions": float(flows.get("currency_conversions", 0.0)), + "Invested": -float(flows.get("invested", 0.0)), + "Proceeds": float(flows.get("proceeds", 0.0)), + "FX fees": float(flows.get("conversion_fees", 0.0)), + "Fees": -float(flows.get("fees", 0.0)), } items = {k: v for k, v in items.items() if abs(v) > 1e-9} if not items: diff --git a/skills/xtb-portfolio-review/scripts/main.py b/skills/xtb-portfolio-review/scripts/main.py index 43a1a56..c3cfc70 100644 --- a/skills/xtb-portfolio-review/scripts/main.py +++ b/skills/xtb-portfolio-review/scripts/main.py @@ -19,6 +19,7 @@ OPEN_POSITIONS_SHEET = "Open Positions" CASH_SHEET = "Cash Operations" HEADER_ROW = 4 RESULTS_DIR = Path("results") +DEFAULT_EMBEDDED_FX_FEE_RATE = 0.005 # XTB ticker codes that don't resolve on Yahoo → verified same-fund Yahoo symbols. # Only add mappings confirmed to be the SAME fund (same ISIN/share class), never a @@ -65,7 +66,15 @@ DIVIDEND_TAX_RE = re.compile(r"dividend\s*tax|tax.*dividend|withholding", re.IGN INTEREST_RE = re.compile(r"interest|free.?funds", re.IGNORECASE) DEPOSIT_RE = re.compile(r"deposit|top.?up|deposit.?funds", re.IGNORECASE) WITHDRAW_RE = re.compile(r"withdraw|withdrawal|payout", re.IGNORECASE) -CONVERSION_RE = re.compile(r"currency\s*conversion|conversion\s*fee|fx", re.IGNORECASE) +CURRENCY_CONVERSION_RE = re.compile(r"currency\s*conversion", re.IGNORECASE) +CONVERSION_FEE_RE = re.compile( + r"(conversion|fx).*(fee|commission)|fee.*(conversion|fx)|\bfx\b", + re.IGNORECASE, +) +CONVERSION_RE = re.compile( + r"currency\s*conversion|conversion\s*fee|fx", + re.IGNORECASE, +) def resolve_report_file(path: Path | str | None = None, *, auto_detect: bool = False) -> Path: @@ -732,7 +741,9 @@ def analyze_cash_flows( "interest": 0.0, "dividends": 0.0, "dividend_tax": 0.0, + "currency_conversions": 0.0, "conversion_fees": 0.0, + "estimated_embedded_fx_fees": 0.0, "invested": 0.0, "proceeds": 0.0, "fees": 0.0, @@ -761,8 +772,13 @@ def analyze_cash_flows( flows["dividends"] += amount elif INTEREST_RE.search(text): flows["interest"] += amount - elif CONVERSION_RE.search(text): + elif CONVERSION_FEE_RE.search(text): flows["conversion_fees"] += amount + elif CURRENCY_CONVERSION_RE.search(text): + flows["currency_conversions"] += amount + flows["estimated_embedded_fx_fees"] += ( + abs(amount) * DEFAULT_EMBEDDED_FX_FEE_RATE + ) elif WITHDRAW_RE.search(text): flows["withdrawals"] += abs(amount) elif DEPOSIT_RE.search(text): @@ -781,7 +797,11 @@ def analyze_cash_flows( else: flows["invested"] += t.value # buying to cover - net_deposited = flows["deposits"] - flows["withdrawals"] + net_deposited = ( + flows["deposits"] + + flows.get("currency_conversions", 0.0) + - flows["withdrawals"] + ) ending_cash = ( net_deposited + flows["interest"] @@ -973,6 +993,8 @@ def build_external_cash_flows( flows.append((pd.Timestamp(dt).normalize(), -abs(amount))) elif WITHDRAW_RE.search(text): flows.append((pd.Timestamp(dt).normalize(), abs(amount))) + elif CURRENCY_CONVERSION_RE.search(text): + flows.append((pd.Timestamp(dt).normalize(), -amount)) if terminal_value > 0: flows.append((pd.Timestamp(terminal_date).normalize(), float(terminal_value))) @@ -1001,7 +1023,11 @@ def compute_performance( income = flows["interest"] + flows["dividends"] portfolio_value = market_value + ending_cash - net_deposited = flows["deposits"] - flows["withdrawals"] + net_deposited = ( + flows["deposits"] + + flows.get("currency_conversions", 0.0) + - flows["withdrawals"] + ) total_gain = unrealized_pl + realized_pl + income total_return_pct = (total_gain / net_deposited * 100) if net_deposited else 0.0 income_yield_pct = (income / cost_basis * 100) if cost_basis else 0.0 @@ -1420,6 +1446,11 @@ def print_report( print(f" Free-funds interest: {money(flows['interest']):>14}") print(f" Dividends received: {money(flows['dividends']):>14}") print(f" Dividend tax: {money(flows['dividend_tax']):>14}") + print(f" Currency conversions: {money(flows.get('currency_conversions', 0.0)):>14}") + print( + " Est. embedded FX fee: " + f"{money(-flows.get('estimated_embedded_fx_fees', 0.0)):>14}" + ) print(f" Invested (buys): {money(-flows['invested']):>14}") print(f" Proceeds (sales): {money(flows['proceeds']):>14}") print(f" FX conversion fees: {money(flows['conversion_fees']):>14}") @@ -1516,14 +1547,16 @@ TERM_TOOLTIPS = { "Portfolio value": "What your portfolio is worth after including market value and cash.", "Market Value": "Today's estimated value for a holding. If the report cannot find a trusted price, it uses your original cost instead.", "market_value": "Market value is today's estimated value for a holding. If no trusted price is found, the report may use cost instead.", - "Net deposited": "Total money added to the account minus withdrawals.", + "Net deposited": "Total money added to the account, including converted cash credited into this account, minus withdrawals.", "Deposits": "Money you added to the brokerage account.", "Withdrawals": "Money you took out of the brokerage account.", "Free-funds interest": "Small interest paid by the broker on cash that was not invested.", "Dividends received": "Cash paid by investments, usually from company profits or fund distributions.", "Invested (buys)": "Money spent buying investments. It reduces cash but increases holdings.", "Proceeds (sales)": "Money received from selling investments. It increases cash.", - "FX conversion fees": "Costs or adjustments from converting between currencies.", + "Currency conversions": "Cash credited to or debited from this account after converting another currency. This is funding principal, not a fee.", + "FX conversion fees": "Explicit broker costs for currency conversion, when XTB exports them separately from the converted principal.", + "Estimated embedded FX cost": "Estimated currency-conversion cost using the default 0.5% XTB rate. It is informational only because this EUR export does not contain a separate fee row.", "Fees / commissions": "Broker or transaction costs paid for account activity.", "Ending cash balance": "Cash left in the account after all deposits, withdrawals, trades, income, and fees.", "Total gain": "Unrealized gains plus realized gains plus income.", @@ -1786,6 +1819,11 @@ def build_html_report( ("Free-funds interest", money(flows["interest"])), ("Dividends received", money(flows["dividends"])), ("Dividend tax", money(flows["dividend_tax"])), + ("Currency conversions", money(flows.get("currency_conversions", 0.0))), + ( + "Estimated embedded FX cost", + money(-flows.get("estimated_embedded_fx_fees", 0.0)), + ), ("Invested (buys)", money(-flows["invested"])), ("Proceeds (sales)", money(flows["proceeds"])), ("FX conversion fees", money(flows["conversion_fees"])), @@ -2181,7 +2219,11 @@ def write_summary_json( }, "cash_flows": { key: _json_number(flows.get(key)) - for key in ("deposits", "withdrawals", "buys", "sells", "dividends", "taxes") + for key in ( + "deposits", "withdrawals", "currency_conversions", "interest", + "dividends", "dividend_tax", "invested", "proceeds", + "conversion_fees", "estimated_embedded_fx_fees", "fees", + ) }, "top_holdings": top_holdings, "cost_fallback_tickers": [str(ticker) for ticker in cost_fallback_tickers], diff --git a/test_portfolio.py b/test_portfolio.py index 31f50c8..9e3ff3d 100644 --- a/test_portfolio.py +++ b/test_portfolio.py @@ -358,6 +358,26 @@ class TestAnalyzeCashFlows: # ending = 1000 - 200 + 0.5 + 10 - 1.5 - 100 + (-2) = 707 assert ending == pytest.approx(707.0) + def test_currency_conversion_principal_is_not_an_fx_fee(self): + ops = make_cash_ops([ + cash_row("Deposit", "", 8000.0, "deposit"), + cash_row( + "Transfer", "", 980.34, + "Currency conversion, RON to EUR from TA: 53074242 to: 53143415, " + "Exchange rate:0.195505", + ), + cash_row("Stock purchase", "A", -8891.39, "OPEN BUY 1 @ 8891.39"), + cash_row("Free funds interest", "", 1.36, "Free-funds Interest"), + ]) + trades = extract_trades(ops) + flows, ending = analyze_cash_flows(ops, trades) + + assert flows["deposits"] == pytest.approx(8000.0) + assert flows["currency_conversions"] == pytest.approx(980.34) + assert flows["conversion_fees"] == pytest.approx(0.0) + assert flows["estimated_embedded_fx_fees"] == pytest.approx(4.9017) + assert ending == pytest.approx(90.31) + def test_sale_proceeds(self): ops = make_cash_ops([ cash_row("Stock purchase", "A", -100, "OPEN BUY 1 @ 100.00"), @@ -473,6 +493,22 @@ class TestComputePerformance: assert perf["market_value"] == pytest.approx(1200.0) assert perf["total_gain"] == pytest.approx(200 + 50 + 10) + def test_net_deposited_includes_currency_conversion_principal(self): + holdings = pd.DataFrame({"ticker": ["A"], "cost_basis": [8891.39]}) + op = pd.DataFrame( + {"ticker": ["A"], "current_value": [9996.19], "unrealized_pl": [1104.80]} + ) + flows = { + "deposits": 8000.0, "withdrawals": 0.0, "interest": 1.36, + "dividends": 0.0, "dividend_tax": 0.0, "currency_conversions": 980.34, + "conversion_fees": 0.0, "invested": 8891.39, "proceeds": 0.0, "fees": 0.0, + } + + perf = compute_performance(holdings, op, pd.DataFrame(), flows, 90.31, 90.31) + + assert perf["net_deposited"] == pytest.approx(8980.34) + assert perf["total_return_pct"] == pytest.approx(1106.16 / 8980.34 * 100) + # --------------------------------------------------------------------------- # Money-weighted return / XIRR