mirror of
https://github.com/farcasclaudiu/xtb-investment-tools.git
synced 2026-06-29 07:01:59 +03:00
Clarify XTB currency conversion fees
This commit is contained in:
@@ -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:
|
||||||
|
|||||||
@@ -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],
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user