Clarify XTB currency conversion fees

This commit is contained in:
2026-06-22 08:30:11 +03:00
parent 0e18db3666
commit e3c84baf7e
4 changed files with 96 additions and 17 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
--- ---
name: xtb-portfolio-review 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. 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 # XTB Portfolio Review
@@ -153,15 +153,16 @@ def _cashflows_config(flows: dict[str, float]) -> dict | None:
if not flows: if not flows:
return None return None
items = { items = {
"Deposits": float(flows["deposits"]), "Deposits": float(flows.get("deposits", 0.0)),
"Withdrawals": -float(flows["withdrawals"]), "Withdrawals": -float(flows.get("withdrawals", 0.0)),
"Interest": float(flows["interest"]), "Interest": float(flows.get("interest", 0.0)),
"Dividends": float(flows["dividends"]), "Dividends": float(flows.get("dividends", 0.0)),
"Div.tax": float(flows["dividend_tax"]), "Div.tax": float(flows.get("dividend_tax", 0.0)),
"Invested": -float(flows["invested"]), "Currency conversions": float(flows.get("currency_conversions", 0.0)),
"Proceeds": float(flows["proceeds"]), "Invested": -float(flows.get("invested", 0.0)),
"FX fees": float(flows["conversion_fees"]), "Proceeds": float(flows.get("proceeds", 0.0)),
"Fees": -float(flows["fees"]), "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} items = {k: v for k, v in items.items() if abs(v) > 1e-9}
if not items: if not items:
+49 -7
View File
@@ -19,6 +19,7 @@ OPEN_POSITIONS_SHEET = "Open Positions"
CASH_SHEET = "Cash Operations" CASH_SHEET = "Cash Operations"
HEADER_ROW = 4 HEADER_ROW = 4
RESULTS_DIR = Path("results") 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. # 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 # 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) INTEREST_RE = re.compile(r"interest|free.?funds", re.IGNORECASE)
DEPOSIT_RE = re.compile(r"deposit|top.?up|deposit.?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) 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: 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, "interest": 0.0,
"dividends": 0.0, "dividends": 0.0,
"dividend_tax": 0.0, "dividend_tax": 0.0,
"currency_conversions": 0.0,
"conversion_fees": 0.0, "conversion_fees": 0.0,
"estimated_embedded_fx_fees": 0.0,
"invested": 0.0, "invested": 0.0,
"proceeds": 0.0, "proceeds": 0.0,
"fees": 0.0, "fees": 0.0,
@@ -761,8 +772,13 @@ def analyze_cash_flows(
flows["dividends"] += amount flows["dividends"] += amount
elif INTEREST_RE.search(text): elif INTEREST_RE.search(text):
flows["interest"] += amount flows["interest"] += amount
elif CONVERSION_RE.search(text): elif CONVERSION_FEE_RE.search(text):
flows["conversion_fees"] += amount 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): elif WITHDRAW_RE.search(text):
flows["withdrawals"] += abs(amount) flows["withdrawals"] += abs(amount)
elif DEPOSIT_RE.search(text): elif DEPOSIT_RE.search(text):
@@ -781,7 +797,11 @@ def analyze_cash_flows(
else: else:
flows["invested"] += t.value # buying to cover 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 = ( ending_cash = (
net_deposited net_deposited
+ flows["interest"] + flows["interest"]
@@ -973,6 +993,8 @@ def build_external_cash_flows(
flows.append((pd.Timestamp(dt).normalize(), -abs(amount))) flows.append((pd.Timestamp(dt).normalize(), -abs(amount)))
elif WITHDRAW_RE.search(text): elif WITHDRAW_RE.search(text):
flows.append((pd.Timestamp(dt).normalize(), abs(amount))) 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: if terminal_value > 0:
flows.append((pd.Timestamp(terminal_date).normalize(), float(terminal_value))) flows.append((pd.Timestamp(terminal_date).normalize(), float(terminal_value)))
@@ -1001,7 +1023,11 @@ def compute_performance(
income = flows["interest"] + flows["dividends"] income = flows["interest"] + flows["dividends"]
portfolio_value = market_value + ending_cash 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_gain = unrealized_pl + realized_pl + income
total_return_pct = (total_gain / net_deposited * 100) if net_deposited else 0.0 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 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" Free-funds interest: {money(flows['interest']):>14}")
print(f" Dividends received: {money(flows['dividends']):>14}") print(f" Dividends received: {money(flows['dividends']):>14}")
print(f" Dividend tax: {money(flows['dividend_tax']):>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" Invested (buys): {money(-flows['invested']):>14}")
print(f" Proceeds (sales): {money(flows['proceeds']):>14}") print(f" Proceeds (sales): {money(flows['proceeds']):>14}")
print(f" FX conversion fees: {money(flows['conversion_fees']):>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.", "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": "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.", "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.", "Deposits": "Money you added to the brokerage account.",
"Withdrawals": "Money you took out of 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.", "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.", "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.", "Invested (buys)": "Money spent buying investments. It reduces cash but increases holdings.",
"Proceeds (sales)": "Money received from selling investments. It increases cash.", "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.", "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.", "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.", "Total gain": "Unrealized gains plus realized gains plus income.",
@@ -1786,6 +1819,11 @@ def build_html_report(
("Free-funds interest", money(flows["interest"])), ("Free-funds interest", money(flows["interest"])),
("Dividends received", money(flows["dividends"])), ("Dividends received", money(flows["dividends"])),
("Dividend tax", money(flows["dividend_tax"])), ("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"])), ("Invested (buys)", money(-flows["invested"])),
("Proceeds (sales)", money(flows["proceeds"])), ("Proceeds (sales)", money(flows["proceeds"])),
("FX conversion fees", money(flows["conversion_fees"])), ("FX conversion fees", money(flows["conversion_fees"])),
@@ -2181,7 +2219,11 @@ def write_summary_json(
}, },
"cash_flows": { "cash_flows": {
key: _json_number(flows.get(key)) 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, "top_holdings": top_holdings,
"cost_fallback_tickers": [str(ticker) for ticker in cost_fallback_tickers], "cost_fallback_tickers": [str(ticker) for ticker in cost_fallback_tickers],
+36
View File
@@ -358,6 +358,26 @@ class TestAnalyzeCashFlows:
# ending = 1000 - 200 + 0.5 + 10 - 1.5 - 100 + (-2) = 707 # ending = 1000 - 200 + 0.5 + 10 - 1.5 - 100 + (-2) = 707
assert ending == pytest.approx(707.0) 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): def test_sale_proceeds(self):
ops = make_cash_ops([ ops = make_cash_ops([
cash_row("Stock purchase", "A", -100, "OPEN BUY 1 @ 100.00"), 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["market_value"] == pytest.approx(1200.0)
assert perf["total_gain"] == pytest.approx(200 + 50 + 10) 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 # Money-weighted return / XIRR