diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..6e75827
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,53 @@
+name: CI
+
+on:
+ push:
+ branches: [ main, master ]
+ pull_request:
+ branches: [ main, master ]
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ strategy:
+ matrix:
+ python-version: ["3.13"]
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up Python ${{ matrix.python-version }}
+ uses: actions/setup-python@v5
+ with:
+ python-version: ${{ matrix.python-version }}
+ cache: 'pip'
+
+ - name: Install system dependencies
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y poppler-utils
+
+ - name: Install dependencies
+ run: |
+ python -m pip install --upgrade pip
+ pip install -e ".[dev]"
+
+ - name: Run ruff
+ run: python -m ruff check src/ tests/
+
+ - name: Run ruff format check
+ run: python -m ruff format --check src/ tests/
+
+ - name: Run pyright
+ run: python -m pyright
+
+ - name: Run pytest with coverage
+ run: python -m pytest tests/ -x -vv --cov=decaf --cov-report=xml --cov-report=term-missing
+
+ - name: Upload coverage to Codecov
+ uses: codecov/codecov-action@v4
+ with:
+ file: ./coverage.xml
+ fail_ci_if_error: false
+ env:
+ CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
diff --git a/README.md b/README.md
index 2079842..6649c4a 100644
--- a/README.md
+++ b/README.md
@@ -4,6 +4,11 @@
# decaf
+[](https://github.com/vjt/decaf/actions/workflows/ci.yml)
+[](https://codecov.io/gh/vjt/decaf)
+[](https://github.com/vjt/decaf/releases)
+[](LICENSE)
+
**De-CAF** — Generatore di report fiscale per investimenti esteri. Niente commercialista.
diff --git a/pyproject.toml b/pyproject.toml
index 89f219e..6bf27cd 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -5,11 +5,10 @@ description = "De-CAF: Italian tax report generator for foreign investments. Mod
readme = "README.md"
license = "MIT"
authors = [{ name = "Marcello Barnaba", email = "vjt@openssl.it" }]
-requires-python = ">=3.12"
+requires-python = ">=3.13"
keywords = ["italian-tax", "ivafe", "quadro-rw", "quadro-rt", "quadro-rl", "modello-redditi", "ibkr", "schwab", "ecb", "forex", "fifo"]
classifiers = [
"Programming Language :: Python :: 3",
- "Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Topic :: Office/Business :: Financial :: Accounting",
"Topic :: Office/Business :: Financial :: Investment",
@@ -34,6 +33,7 @@ dev = [
"pytest>=8.0",
"pytest-asyncio>=0.24",
"pytest-timeout>=2.3",
+ "pytest-cov>=5.0",
"ruff>=0.8",
"pyright>=1.1",
"reportlab>=4.0", # used by scripts/gen_schwab_pdfs.py for synthetic fixtures
@@ -61,7 +61,7 @@ asyncio_mode = "auto"
timeout = 10
[tool.ruff]
-target-version = "py312"
+target-version = "py313"
line-length = 100
[tool.ruff.lint]
@@ -84,7 +84,7 @@ ignore = [
known-first-party = ["decaf"]
[tool.pyright]
-pythonVersion = "3.12"
+pythonVersion = "3.13"
typeCheckingMode = "standard"
include = ["src"]
venvPath = "."
diff --git a/src/decaf/cli.py b/src/decaf/cli.py
index b54b928..36f4336 100644
--- a/src/decaf/cli.py
+++ b/src/decaf/cli.py
@@ -49,11 +49,13 @@ def main() -> None:
description="De-CAF: Italian tax report generator. No commercialista needed.",
)
parser.add_argument(
- "--verbose", action="store_true",
+ "--verbose",
+ action="store_true",
help="Enable debug logging",
)
parser.add_argument(
- "--db", type=Path,
+ "--db",
+ type=Path,
default=_DEFAULT_CACHE_DIR / "statements.db",
help="Path to statement SQLite database",
)
@@ -66,27 +68,39 @@ def main() -> None:
help="Load broker data and store in local database",
)
load_p.add_argument(
- "--broker", choices=["ibkr", "schwab"], default="ibkr",
+ "--broker",
+ choices=["ibkr", "schwab"],
+ default="ibkr",
help="Broker source (default: ibkr)",
)
load_p.add_argument(
- "--file", type=Path, default=None,
+ "--file",
+ type=Path,
+ default=None,
help="Import from local file (IBKR: FlexQuery XML, Schwab: JSON export)",
)
load_p.add_argument(
- "--gains-pdfs", type=Path, nargs="+", default=None,
+ "--gains-pdfs",
+ type=Path,
+ nargs="+",
+ default=None,
help="Schwab Year-End Summary PDFs (realized gains per lot)",
)
load_p.add_argument(
- "--vest-pdfs", type=Path, nargs="+", default=None,
+ "--vest-pdfs",
+ type=Path,
+ nargs="+",
+ default=None,
help="Schwab Annual Withholding Statement PDFs (vest FMVs for open positions)",
)
load_p.add_argument(
- "--token", default=None,
+ "--token",
+ default=None,
help="IBKR Flex token (default: IBKR_TOKEN env var)",
)
load_p.add_argument(
- "--query-id", default=None,
+ "--query-id",
+ default=None,
help="IBKR Flex Query ID (default: IBKR_QUERY_ID env var)",
)
@@ -96,27 +110,34 @@ def main() -> None:
help="Run pipeline over a directory of broker exports and diff vs committed YAML",
)
backtest_p.add_argument(
- "directory", type=Path,
+ "directory",
+ type=Path,
help="Directory containing broker exports and decaf_.yaml oracles",
)
backtest_p.add_argument(
- "--year", type=int, default=None,
+ "--year",
+ type=int,
+ default=None,
help="Restrict to a single tax year (default: all years found)",
)
backtest_p.add_argument(
- "--update", action="store_true",
+ "--update",
+ action="store_true",
help="Write fresh YAML oracles instead of comparing",
)
backtest_p.add_argument(
- "--token", default=None,
+ "--token",
+ default=None,
help="IBKR Flex token (default: IBKR_TOKEN env var; used only when no XML present)",
)
backtest_p.add_argument(
- "--query-id", default=None,
+ "--query-id",
+ default=None,
help="IBKR Flex Query ID (default: IBKR_QUERY_ID env var)",
)
backtest_p.add_argument(
- "--ecb-db", type=Path,
+ "--ecb-db",
+ type=Path,
default=_DEFAULT_CACHE_DIR / "ecb_rates.db",
help="Path to ECB rates SQLite cache",
)
@@ -127,15 +148,20 @@ def main() -> None:
# --- decaf report ---
report_p = sub.add_parser("report", help="Generate tax report from stored data")
report_p.add_argument(
- "--year", type=int, required=True,
+ "--year",
+ type=int,
+ required=True,
help="Tax year to report on (e.g., 2025)",
)
report_p.add_argument(
- "--output-dir", type=Path, default=Path("."),
+ "--output-dir",
+ type=Path,
+ default=Path("."),
help="Directory for output files (default: current dir)",
)
report_p.add_argument(
- "--ecb-db", type=Path,
+ "--ecb-db",
+ type=Path,
default=_DEFAULT_CACHE_DIR / "ecb_rates.db",
help="Path to ECB rates SQLite cache",
)
@@ -191,10 +217,7 @@ async def _cmd_load(args: argparse.Namespace) -> None:
else:
data = await _fetch_ibkr(args)
- print(
- f"Parsed: {data.account.account_id} | "
- f"{data.statement_from} to {data.statement_to}"
- )
+ print(f"Parsed: {data.account.account_id} | {data.statement_from} to {data.statement_to}")
print(
f" Trades: {len(data.trades)} "
f"Positions: {len(data.positions)} lots "
@@ -329,7 +352,8 @@ async def _build_report(
year_end = date(tax_year, 12, 31)
prior_year_end = date(tax_year - 1, 12, 31)
held_at_year_end, carried_from_prior = symbols_needing_prices(
- data.trades, tax_year,
+ data.trades,
+ tax_year,
)
# Build symbol -> (currency, isin, exchange) from trades + positions
@@ -339,7 +363,9 @@ async def _build_report(
trade.symbol not in stk_info or trade.listing_exchange
):
stk_info[trade.symbol] = (
- trade.currency, trade.isin, trade.listing_exchange,
+ trade.currency,
+ trade.isin,
+ trade.listing_exchange,
)
for pos in data.positions:
if pos.listing_exchange and pos.symbol in stk_info:
@@ -363,17 +389,15 @@ async def _build_report(
# Fetch year-end prices (overrides + broker cover most; yfinance fills gaps)
ye_info = {
- s: stk_info[s] for s in held_at_year_end
+ s: stk_info[s]
+ for s in held_at_year_end
if s in stk_info and s not in broker_marks and s not in overrides
}
prior_info = {
- s: stk_info[s] for s in carried_from_prior
- if s in stk_info and s not in prior_overrides
+ s: stk_info[s] for s in carried_from_prior if s in stk_info and s not in prior_overrides
}
try:
- year_end_prices = (
- fetch_year_end_prices(ye_info, year_end) if ye_info else {}
- )
+ year_end_prices = fetch_year_end_prices(ye_info, year_end) if ye_info else {}
except PriceFetchError as exc:
print(f"\nWARN: {exc}")
print("Falling back to broker-provided mark prices where available.")
@@ -391,14 +415,13 @@ async def _build_report(
sys.exit(1)
try:
- prior_year_prices = (
- fetch_year_end_prices(prior_info, prior_year_end)
- if prior_info else {}
- )
+ prior_year_prices = fetch_year_end_prices(prior_info, prior_year_end) if prior_info else {}
except PriceFetchError as exc:
print(f"\nWARN: {exc}")
- print("Prior-year prices unavailable; initial_value will fall back "
- "to acquisition cost per symbol. IVAFE is unaffected.")
+ print(
+ "Prior-year prices unavailable; initial_value will fall back "
+ "to acquisition cost per symbol. IVAFE is unaffected."
+ )
prior_year_prices = {}
prior_year_prices.update(prior_overrides)
@@ -410,7 +433,10 @@ async def _build_report(
# Forex threshold (uses ALL cash txns for carry-over balance)
forex = analyze_forex_threshold(
- data.trades, data.cash_transactions, fx, tax_year,
+ data.trades,
+ data.cash_transactions,
+ fx,
+ tax_year,
)
print(
f" Forex threshold: "
@@ -420,8 +446,12 @@ async def _build_report(
# Quadro RW
rw_lines = compute_rw(
- data.positions, data.trades, data.cash_report, data.cash_transactions,
- fx, tax_year,
+ data.positions,
+ data.trades,
+ data.cash_report,
+ data.cash_transactions,
+ fx,
+ tax_year,
mark_prices=year_end_prices,
prior_year_prices=prior_year_prices,
)
@@ -436,13 +466,13 @@ async def _build_report(
# Forex LIFO gains per account (only when threshold breached)
if forex.threshold_breached:
forex_entries = compute_forex_gains(
- data.trades, data.cash_transactions, fx, tax_year,
+ data.trades,
+ data.cash_transactions,
+ fx,
+ tax_year,
)
net_forex = sum((e.gain_eur for e in forex_entries), Decimal(0))
- print(
- f" Forex gains: {len(forex_entries)} LIFO entries, "
- f"net: EUR {net_forex:.2f}"
- )
+ print(f" Forex gains: {len(forex_entries)} LIFO entries, net: EUR {net_forex:.2f}")
rt_lines.extend(forex_gains_to_rt_lines(forex_entries))
# Quadro RL
@@ -467,7 +497,8 @@ async def _build_report(
if t.trade_datetime.year != tax_year:
continue
if not (
- t.is_buy and t.currency == "USD"
+ t.is_buy
+ and t.currency == "USD"
and t.fx_rate_to_base == 0
and t.commission == 0
and t.broker_pnl_realized == 0
@@ -481,8 +512,7 @@ async def _build_report(
rsu_count += 1
if rsu_count:
print(
- f" RSU vests: {rsu_count}, reddito EUR {rsu_income_eur:.2f} "
- f"(cross-check CU punto 1)"
+ f" RSU vests: {rsu_count}, reddito EUR {rsu_income_eur:.2f} (cross-check CU punto 1)"
)
# --- Step 6: Assemble report ---
@@ -643,7 +673,9 @@ async def _cmd_backtest(args: argparse.Namespace) -> int:
for year in target_years:
print(f"\n--- Year {year} ---")
report, _data = await _load_and_build_report(
- tmp_db, args.ecb_db, year,
+ tmp_db,
+ args.ecb_db,
+ year,
price_overrides=price_overrides_by_year,
)
@@ -701,9 +733,7 @@ def _diff_reports(expected: object, actual: object, path: str) -> list[str]:
if isinstance(expected, list):
assert isinstance(actual, list)
if len(expected) != len(actual):
- return [
- f"{path}: list length {len(expected)} != {len(actual)}"
- ]
+ return [f"{path}: list length {len(expected)} != {len(actual)}"]
msgs = []
for i, (e, a) in enumerate(zip(expected, actual, strict=True)):
msgs.extend(_diff_reports(e, a, f"{path}[{i}]"))
@@ -721,8 +751,7 @@ def _diff_reports(expected: object, actual: object, path: str) -> list[str]:
async def _fetch_from_ibkr(args: argparse.Namespace) -> str:
"""Fetch FlexQuery XML from IBKR API."""
vendor_path = (
- Path(__file__).resolve().parent.parent.parent
- / "vendor" / "ibkr-flex-client" / "src"
+ Path(__file__).resolve().parent.parent.parent / "vendor" / "ibkr-flex-client" / "src"
)
sys.path.insert(0, str(vendor_path))
from ibkr_flex_client import FlexClient
@@ -741,8 +770,5 @@ async def _fetch_from_ibkr(args: argparse.Namespace) -> str:
async with aiohttp.ClientSession() as session:
statement = await client.fetch(session)
- print(
- f" Received {len(statement.xml)} bytes, "
- f"{statement.from_date} to {statement.to_date}"
- )
+ print(f" Received {len(statement.xml)} bytes, {statement.from_date} to {statement.to_date}")
return statement.xml
diff --git a/src/decaf/ecb_cache.py b/src/decaf/ecb_cache.py
index 4a6b23d..644f61d 100644
--- a/src/decaf/ecb_cache.py
+++ b/src/decaf/ecb_cache.py
@@ -91,7 +91,10 @@ async def get_rate(self, currency: str, d: date) -> Decimal | None:
return Decimal(row[0]) if row else None
async def get_rate_fill_forward(
- self, currency: str, d: date, max_lookback: int = 5,
+ self,
+ currency: str,
+ d: date,
+ max_lookback: int = 5,
) -> Decimal | None:
"""Get the ECB rate, looking back up to N days for weekends/holidays."""
if currency == "EUR":
@@ -133,12 +136,12 @@ async def get_dec31_rate(self, currency: str, year: int) -> Decimal:
if row is not None:
return Decimal(row[0])
- raise ValueError(
- f"No ECB rate found for {currency} in {year}"
- )
+ raise ValueError(f"No ECB rate found for {currency} in {year}")
async def get_all_rates_for_year(
- self, currency: str, year: int,
+ self,
+ currency: str,
+ year: int,
) -> dict[date, Decimal]:
"""Get all cached rates for a currency in a year.
@@ -165,8 +168,7 @@ async def _year_complete(self, year: int) -> bool:
"""Check if we have rates near Dec 31 for the year."""
assert self._db is not None
cursor = await self._db.execute(
- "SELECT COUNT(*) FROM ecb_rates "
- "WHERE rate_date >= ? AND rate_date <= ?",
+ "SELECT COUNT(*) FROM ecb_rates WHERE rate_date >= ? AND rate_date <= ?",
(f"{year}-12-28", f"{year}-12-31"),
)
row = await cursor.fetchone()
@@ -190,8 +192,7 @@ async def _store(self, days: list[EcbDailyRates]) -> None:
for currency, rate in day.rates.items()
]
await self._db.executemany(
- "INSERT OR REPLACE INTO ecb_rates (currency, rate_date, rate) "
- "VALUES (?, ?, ?)",
+ "INSERT OR REPLACE INTO ecb_rates (currency, rate_date, rate) VALUES (?, ?, ?)",
rows,
)
await self._db.commit()
diff --git a/src/decaf/forex.py b/src/decaf/forex.py
index bef20de..01b47fa 100644
--- a/src/decaf/forex.py
+++ b/src/decaf/forex.py
@@ -72,14 +72,16 @@ def analyze_forex_threshold(
eur_equiv = usd_balance / jan1_rate if usd_balance else Decimal(0)
- records.append(ForexDayRecord(
- date=current,
- usd_balance=usd_balance,
- eur_equivalent=eur_equiv,
- fx_rate=jan1_rate,
- is_business_day=biz_day,
- above_threshold=eur_equiv > threshold_eur,
- ))
+ records.append(
+ ForexDayRecord(
+ date=current,
+ usd_balance=usd_balance,
+ eur_equivalent=eur_equiv,
+ fx_rate=jan1_rate,
+ is_business_day=biz_day,
+ above_threshold=eur_equiv > threshold_eur,
+ )
+ )
current += timedelta(days=1)
# Step 4: find max consecutive business days above threshold
@@ -89,15 +91,16 @@ def analyze_forex_threshold(
if breached:
logger.info(
- "Forex threshold BREACHED: %d consecutive business days above €%s "
- "(first breach: %s)",
- max_run, threshold_eur, first_breach,
+ "Forex threshold BREACHED: %d consecutive business days above €%s (first breach: %s)",
+ max_run,
+ threshold_eur,
+ first_breach,
)
else:
logger.info(
- "Forex threshold NOT breached: max %d consecutive business days "
- "(need %d)",
- max_run, min_consecutive_days,
+ "Forex threshold NOT breached: max %d consecutive business days (need %d)",
+ max_run,
+ min_consecutive_days,
)
return ForexAnalysis(
@@ -127,16 +130,18 @@ def _reconstruct_daily_usd_balance(
for ct in cash_transactions:
if ct.currency == "USD":
- raw_events.append((
- ct.settle_date, ct.amount,
- f"{ct.tx_type}: {ct.description} [{ct.account_id}]",
- ))
+ raw_events.append(
+ (
+ ct.settle_date,
+ ct.amount,
+ f"{ct.tx_type}: {ct.description} [{ct.account_id}]",
+ )
+ )
# Track which accounts have "Sell Proceeds" cash transactions
# (Schwab — sells are already captured as cash txns including sell-to-cover)
accounts_with_sell_proceeds = {
- ct.account_id for ct in cash_transactions
- if ct.tx_type == "Sell Proceeds"
+ ct.account_id for ct in cash_transactions if ct.tx_type == "Sell Proceeds"
}
for t in trades:
@@ -149,17 +154,23 @@ def _reconstruct_daily_usd_balance(
if t.account_id in accounts_with_sell_proceeds:
continue
net = t.proceeds + t.commission
- raw_events.append((
- t.settle_date, net,
- f"{t.buy_sell} {t.symbol} qty={t.quantity} [{t.account_id}]",
- ))
+ raw_events.append(
+ (
+ t.settle_date,
+ net,
+ f"{t.buy_sell} {t.symbol} qty={t.quantity} [{t.account_id}]",
+ )
+ )
elif t.asset_category == "CASH" and "USD" in t.symbol:
if t.currency == "USD":
net = t.proceeds + t.commission
- raw_events.append((
- t.settle_date, net,
- f"FX {t.buy_sell} {t.symbol} qty={t.quantity} [{t.account_id}]",
- ))
+ raw_events.append(
+ (
+ t.settle_date,
+ net,
+ f"FX {t.buy_sell} {t.symbol} qty={t.quantity} [{t.account_id}]",
+ )
+ )
# Sort by date, credits before debits on same day (WHT + dividend
# are one atomic event — Schwab nets tax from dividend)
@@ -179,10 +190,14 @@ def _reconstruct_daily_usd_balance(
if balance != 0:
logger.info("USD carry-over balance on %s: %.2f", start_year, float(balance))
- usd_events.append(UsdEvent(
- date=start_year, amount=Decimal(0), balance=balance,
- description="Riporto da anni precedenti",
- ))
+ usd_events.append(
+ UsdEvent(
+ date=start_year,
+ amount=Decimal(0),
+ balance=balance,
+ description="Riporto da anni precedenti",
+ )
+ )
if balance < 0:
logger.warning(
diff --git a/src/decaf/forex_gains.py b/src/decaf/forex_gains.py
index 2787c0b..e89232b 100644
--- a/src/decaf/forex_gains.py
+++ b/src/decaf/forex_gains.py
@@ -49,25 +49,26 @@
_USD_INCOME_TYPES = {
"Dividends",
"Broker Interest Received",
- "Broker Interest Paid", # negative amounts, handled naturally
+ "Broker Interest Paid", # negative amounts, handled naturally
"Payment In Lieu Of Dividends",
- "Sell Proceeds", # Schwab sells (includes sell-to-cover)
+ "Sell Proceeds", # Schwab sells (includes sell-to-cover)
}
# Cash transaction types that represent wire transfers OUT (disposals)
_WIRE_TRANSFER_TYPES = {
"Wire Sent",
"Wire Funds Sent",
- "Deposits/Withdrawals", # negative amounts = withdrawals
+ "Deposits/Withdrawals", # negative amounts = withdrawals
}
@dataclass
class _UsdLot:
"""A lot of USD in an account's LIFO queue."""
+
date: date
remaining: Decimal # USD still available in this lot
- ecb_rate: Decimal # EUR/USD rate at acquisition
+ ecb_rate: Decimal # EUR/USD rate at acquisition
def compute_forex_gains(
@@ -115,11 +116,13 @@ def compute_forex_gains(
q.pop()
to_transfer -= moved_usd
else:
- moved_lots.append(_UsdLot(
- date=lot.date,
- remaining=moved_usd,
- ecb_rate=lot.ecb_rate,
- ))
+ moved_lots.append(
+ _UsdLot(
+ date=lot.date,
+ remaining=moved_usd,
+ ecb_rate=lot.ecb_rate,
+ )
+ )
lot.remaining -= moved_usd
to_transfer = Decimal(0)
@@ -128,7 +131,9 @@ def compute_forex_gains(
"Giroconto source queue exhausted for %s: %.2f USD "
"unmatched on %s. Possible missing prior data in "
"source account.",
- event.account_id, float(to_transfer), event.date,
+ event.account_id,
+ float(to_transfer),
+ event.date,
)
if moved_lots:
@@ -144,11 +149,13 @@ def compute_forex_gains(
continue
if not event.is_disposal:
- q.append(_UsdLot(
- date=event.date,
- remaining=event.usd_amount,
- ecb_rate=event.ecb_rate,
- ))
+ q.append(
+ _UsdLot(
+ date=event.date,
+ remaining=event.usd_amount,
+ ecb_rate=event.ecb_rate,
+ )
+ )
total_usd_acquired += event.usd_amount
continue
@@ -163,18 +170,21 @@ def compute_forex_gains(
eur_at_disposal = consumed / event.ecb_rate
eur_at_acquisition = consumed / lot.ecb_rate
gain_eur = (eur_at_disposal - eur_at_acquisition).quantize(
- Decimal("0.01"), rounding=ROUND_HALF_UP,
+ Decimal("0.01"),
+ rounding=ROUND_HALF_UP,
)
if event.date.year == tax_year:
- gains.append(ForexGainEntry(
- disposal_date=event.date,
- usd_amount=consumed,
- acquisition_date=lot.date,
- ecb_rate_acquisition=lot.ecb_rate,
- ecb_rate_disposal=event.ecb_rate,
- gain_eur=gain_eur,
- ))
+ gains.append(
+ ForexGainEntry(
+ disposal_date=event.date,
+ usd_amount=consumed,
+ acquisition_date=lot.date,
+ ecb_rate_acquisition=lot.ecb_rate,
+ ecb_rate_disposal=event.ecb_rate,
+ gain_eur=gain_eur,
+ )
+ )
lot.remaining -= consumed
to_dispose -= consumed
@@ -188,18 +198,21 @@ def compute_forex_gains(
"on %s without matching acquisitions in the same account. "
"Cross-account transfers (Risoluzione 60/E/2024) are not "
"matched: handle manually if this is a giroconto.",
- event.account_id, float(to_dispose), event.date,
+ event.account_id,
+ float(to_dispose),
+ event.date,
)
per_account_residual = {
- acct: sum((lot.remaining for lot in q), Decimal(0))
- for acct, q in queues.items()
+ acct: sum((lot.remaining for lot in q), Decimal(0)) for acct, q in queues.items()
}
logger.info(
"Forex LIFO: %.2f USD acquired, %.2f USD disposed, "
"%d gain entries for %d, residual per account: %s",
- float(total_usd_acquired), float(total_usd_disposed),
- len(gains), tax_year,
+ float(total_usd_acquired),
+ float(total_usd_disposed),
+ len(gains),
+ tax_year,
{acct: float(r) for acct, r in per_account_residual.items()},
)
@@ -211,27 +224,27 @@ def forex_gains_to_rt_lines(entries: list[ForexGainEntry]) -> list[RTLine]:
_q = Decimal("0.01")
lines: list[RTLine] = []
for entry in entries:
- eur_at_disposal = (
- entry.usd_amount / entry.ecb_rate_disposal
- ).quantize(_q, ROUND_HALF_UP)
- eur_at_acquisition = (
- entry.usd_amount / entry.ecb_rate_acquisition
- ).quantize(_q, ROUND_HALF_UP)
- lines.append(RTLine(
- symbol="EUR.USD",
- isin="",
- long_description="Plusvalenza valutaria USD (LIFO per conto)",
- acquisition_date=entry.acquisition_date,
- sell_date=entry.disposal_date,
- quantity=entry.usd_amount,
- proceeds_eur=eur_at_disposal,
- cost_basis_eur=eur_at_acquisition,
- gain_loss_eur=entry.gain_eur,
- ecb_rate=entry.ecb_rate_disposal,
- is_forex=True,
- broker_pnl=Decimal(0),
- broker_pnl_eur=Decimal(0),
- ))
+ eur_at_disposal = (entry.usd_amount / entry.ecb_rate_disposal).quantize(_q, ROUND_HALF_UP)
+ eur_at_acquisition = (entry.usd_amount / entry.ecb_rate_acquisition).quantize(
+ _q, ROUND_HALF_UP
+ )
+ lines.append(
+ RTLine(
+ symbol="EUR.USD",
+ isin="",
+ long_description="Plusvalenza valutaria USD (LIFO per conto)",
+ acquisition_date=entry.acquisition_date,
+ sell_date=entry.disposal_date,
+ quantity=entry.usd_amount,
+ proceeds_eur=eur_at_disposal,
+ cost_basis_eur=eur_at_acquisition,
+ gain_loss_eur=entry.gain_eur,
+ ecb_rate=entry.ecb_rate_disposal,
+ is_forex=True,
+ broker_pnl=Decimal(0),
+ broker_pnl_eur=Decimal(0),
+ )
+ )
return lines
@@ -243,14 +256,15 @@ def forex_gains_to_rt_lines(entries: list[ForexGainEntry]) -> list[RTLine]:
@dataclass(frozen=True, slots=True)
class _UsdEvent:
"""A USD cash flow event for LIFO processing."""
+
date: date
- account_id: str # isolates lot matching per-account; source account for transfers
+ account_id: str # isolates lot matching per-account; source account for transfers
usd_amount: Decimal # always positive
- ecb_rate: Decimal # EUR/USD at event date (unused for transfers)
- is_disposal: bool # True = USD leaving, False = USD entering
- description: str # for debugging
- is_transfer: bool = False # True = giroconto cross-account (Ris. 60/E)
- dst_account_id: str | None = None # destination account; only when is_transfer
+ ecb_rate: Decimal # EUR/USD at event date (unused for transfers)
+ is_disposal: bool # True = USD leaving, False = USD entering
+ description: str # for debugging
+ is_transfer: bool = False # True = giroconto cross-account (Ris. 60/E)
+ dst_account_id: str | None = None # destination account; only when is_transfer
# Giroconto matching tolerances (Ris. AdE 60/E del 09/12/2024):
@@ -312,16 +326,21 @@ def _match_giroconto_pairs(
logger.info(
"Giroconto matched: %.2f USD from %s (%s) to %s (%s)",
float(abs(ct_neg.amount)),
- ct_neg.account_id, ct_neg.settle_date,
- ct_pos.account_id, ct_pos.settle_date,
+ ct_neg.account_id,
+ ct_neg.settle_date,
+ ct_pos.account_id,
+ ct_pos.settle_date,
)
elif len(candidates) > 1:
logger.warning(
"Ambiguous giroconto: wire-out %s %.2f USD at %s on %s has "
"%d positive candidates — falling back to disposal, user "
"must rectify manually per Ris. 60/E.",
- ct_neg.tx_type, float(abs(ct_neg.amount)),
- ct_neg.account_id, ct_neg.settle_date, len(candidates),
+ ct_neg.tx_type,
+ float(abs(ct_neg.amount)),
+ ct_neg.account_id,
+ ct_neg.settle_date,
+ len(candidates),
)
return consumed, pairs
@@ -338,8 +357,7 @@ def _collect_usd_events(
# Accounts with "Sell Proceeds" cash txns — skip their stock sells
# to avoid double-counting (Schwab sells include sell-to-cover)
accounts_with_sell_proceeds = {
- ct.account_id for ct in cash_transactions
- if ct.tx_type == "Sell Proceeds"
+ ct.account_id for ct in cash_transactions if ct.tx_type == "Sell Proceeds"
}
# Giroconto matching (Ris. 60/E): identify wire-out/wire-in pairs
@@ -349,16 +367,18 @@ def _collect_usd_events(
for ct_neg, ct_pos in giro_pairs:
ecb_rate = _get_ecb_rate(fx, ct_neg.settle_date) or Decimal(1)
- events.append(_UsdEvent(
- date=ct_neg.settle_date,
- account_id=ct_neg.account_id,
- usd_amount=abs(ct_neg.amount),
- ecb_rate=ecb_rate,
- is_disposal=False,
- description=f"TRANSFER {ct_neg.account_id} -> {ct_pos.account_id}",
- is_transfer=True,
- dst_account_id=ct_pos.account_id,
- ))
+ events.append(
+ _UsdEvent(
+ date=ct_neg.settle_date,
+ account_id=ct_neg.account_id,
+ usd_amount=abs(ct_neg.amount),
+ ecb_rate=ecb_rate,
+ is_disposal=False,
+ description=f"TRANSFER {ct_neg.account_id} -> {ct_pos.account_id}",
+ is_transfer=True,
+ dst_account_id=ct_pos.account_id,
+ )
+ )
for t in trades:
if t.asset_category == "STK" and t.currency == "USD" and t.is_sell:
@@ -369,14 +389,16 @@ def _collect_usd_events(
if usd_amount > 0:
ecb_rate = _get_ecb_rate(fx, t.settle_date)
if ecb_rate:
- events.append(_UsdEvent(
- date=t.settle_date,
- account_id=t.account_id,
- usd_amount=usd_amount,
- ecb_rate=ecb_rate,
- is_disposal=False,
- description=f"SELL {t.symbol} {t.quantity}",
- ))
+ events.append(
+ _UsdEvent(
+ date=t.settle_date,
+ account_id=t.account_id,
+ usd_amount=usd_amount,
+ ecb_rate=ecb_rate,
+ is_disposal=False,
+ description=f"SELL {t.symbol} {t.quantity}",
+ )
+ )
elif t.asset_category == "CASH" and "USD" in t.symbol and t.currency == "USD":
# EUR.USD forex conversion
@@ -385,26 +407,30 @@ def _collect_usd_events(
# BUY EUR.USD → spending USD → disposal
ecb_rate = _get_ecb_rate(fx, t.settle_date)
if ecb_rate:
- events.append(_UsdEvent(
- date=t.settle_date,
- account_id=t.account_id,
- usd_amount=abs(net_usd),
- ecb_rate=ecb_rate,
- is_disposal=True,
- description=f"EUR.USD conversion {t.quantity}",
- ))
+ events.append(
+ _UsdEvent(
+ date=t.settle_date,
+ account_id=t.account_id,
+ usd_amount=abs(net_usd),
+ ecb_rate=ecb_rate,
+ is_disposal=True,
+ description=f"EUR.USD conversion {t.quantity}",
+ )
+ )
elif net_usd > 0:
# SELL EUR.USD → receiving USD → acquisition
ecb_rate = _get_ecb_rate(fx, t.settle_date)
if ecb_rate:
- events.append(_UsdEvent(
- date=t.settle_date,
- account_id=t.account_id,
- usd_amount=net_usd,
- ecb_rate=ecb_rate,
- is_disposal=False,
- description=f"EUR.USD conversion {t.quantity}",
- ))
+ events.append(
+ _UsdEvent(
+ date=t.settle_date,
+ account_id=t.account_id,
+ usd_amount=net_usd,
+ ecb_rate=ecb_rate,
+ is_disposal=False,
+ description=f"EUR.USD conversion {t.quantity}",
+ )
+ )
for i, ct in enumerate(cash_transactions):
if ct.currency != "USD":
@@ -417,27 +443,31 @@ def _collect_usd_events(
# Dividend / interest → USD acquired
ecb_rate = _get_ecb_rate(fx, ct.settle_date)
if ecb_rate:
- events.append(_UsdEvent(
- date=ct.settle_date,
- account_id=ct.account_id,
- usd_amount=ct.amount,
- ecb_rate=ecb_rate,
- is_disposal=False,
- description=f"{ct.tx_type}: {ct.description}",
- ))
+ events.append(
+ _UsdEvent(
+ date=ct.settle_date,
+ account_id=ct.account_id,
+ usd_amount=ct.amount,
+ ecb_rate=ecb_rate,
+ is_disposal=False,
+ description=f"{ct.tx_type}: {ct.description}",
+ )
+ )
elif ct.tx_type in _WIRE_TRANSFER_TYPES and ct.amount < 0:
# Wire transfer out → USD disposed
ecb_rate = _get_ecb_rate(fx, ct.settle_date)
if ecb_rate:
- events.append(_UsdEvent(
- date=ct.settle_date,
- account_id=ct.account_id,
- usd_amount=abs(ct.amount),
- ecb_rate=ecb_rate,
- is_disposal=True,
- description=f"{ct.tx_type}: {ct.description}",
- ))
+ events.append(
+ _UsdEvent(
+ date=ct.settle_date,
+ account_id=ct.account_id,
+ usd_amount=abs(ct.amount),
+ ecb_rate=ecb_rate,
+ is_disposal=True,
+ description=f"{ct.tx_type}: {ct.description}",
+ )
+ )
return events
diff --git a/src/decaf/fx.py b/src/decaf/fx.py
index bdaaf43..27c6c36 100644
--- a/src/decaf/fx.py
+++ b/src/decaf/fx.py
@@ -76,7 +76,9 @@ def to_eur(self, amount: Decimal, currency: str, d: date) -> Decimal:
if ib_rate is not None:
logger.warning(
"Using IB rate (no ECB rate) for %s on %s: %s",
- currency, d, ib_rate,
+ currency,
+ d,
+ ib_rate,
)
return amount * ib_rate
@@ -95,7 +97,10 @@ def ib_rate(self, currency: str, d: date) -> Decimal | None:
return self._get_ib_rate(currency, d)
def _get_ecb_rate(
- self, currency: str, d: date, max_lookback: int = 5,
+ self,
+ currency: str,
+ d: date,
+ max_lookback: int = 5,
) -> Decimal | None:
"""ECB rate with fill-forward for weekends/holidays."""
for offset in range(max_lookback + 1):
@@ -105,7 +110,9 @@ def _get_ecb_rate(
return None
def _get_ecb_rate_best_effort(
- self, currency: str, d: date,
+ self,
+ currency: str,
+ d: date,
) -> Decimal | None:
"""ECB rate with unlimited lookback - latest available rate <= d.
@@ -116,20 +123,23 @@ def _get_ecb_rate_best_effort(
if rate is not None:
return rate
- candidates = [
- rd for ccy, rd in self._ecb if ccy == currency and rd <= d
- ]
+ candidates = [rd for ccy, rd in self._ecb if ccy == currency and rd <= d]
if candidates:
latest = max(candidates)
logger.warning(
"Using ECB rate from %s for %s (latest available before %s)",
- latest, currency, d,
+ latest,
+ currency,
+ d,
)
return self._ecb[(currency, latest)]
return None
def _get_ib_rate(
- self, currency: str, d: date, max_lookback: int = 5,
+ self,
+ currency: str,
+ d: date,
+ max_lookback: int = 5,
) -> Decimal | None:
"""IB rate with fill-forward for weekends/holidays."""
for offset in range(max_lookback + 1):
@@ -139,8 +149,11 @@ def _get_ib_rate(
return None
def _check_discrepancy(
- self, currency: str, d: date,
- ecb_rate: Decimal, ib_rate: Decimal,
+ self,
+ currency: str,
+ d: date,
+ ecb_rate: Decimal,
+ ib_rate: Decimal,
) -> None:
"""Log a warning if ECB and IB rates disagree significantly."""
if ecb_rate == 0 or ib_rate == 0:
@@ -151,6 +164,9 @@ def _check_discrepancy(
if relative_diff > _DISCREPANCY_THRESHOLD:
logger.warning(
"FX discrepancy for %s on %s: ECB=1/%s, IB=%s, diff=%.2f%%",
- currency, d, ecb_rate, ib_rate,
+ currency,
+ d,
+ ecb_rate,
+ ib_rate,
float(relative_diff * 100),
)
diff --git a/src/decaf/holidays.py b/src/decaf/holidays.py
index 00a1c6d..95d2e0e 100644
--- a/src/decaf/holidays.py
+++ b/src/decaf/holidays.py
@@ -37,18 +37,18 @@ def italian_holidays(year: int) -> set[date]:
easter_monday = easter + timedelta(days=1)
return {
- date(year, 1, 1), # Capodanno
- date(year, 1, 6), # Epifania
- easter, # Pasqua
- easter_monday, # Lunedì dell'Angelo
- date(year, 4, 25), # Festa della Liberazione
- date(year, 5, 1), # Festa del Lavoro
- date(year, 6, 2), # Festa della Repubblica
- date(year, 8, 15), # Ferragosto
- date(year, 11, 1), # Tutti i Santi
- date(year, 12, 8), # Immacolata Concezione
- date(year, 12, 25), # Natale
- date(year, 12, 26), # Santo Stefano
+ date(year, 1, 1), # Capodanno
+ date(year, 1, 6), # Epifania
+ easter, # Pasqua
+ easter_monday, # Lunedì dell'Angelo
+ date(year, 4, 25), # Festa della Liberazione
+ date(year, 5, 1), # Festa del Lavoro
+ date(year, 6, 2), # Festa della Repubblica
+ date(year, 8, 15), # Ferragosto
+ date(year, 11, 1), # Tutti i Santi
+ date(year, 12, 8), # Immacolata Concezione
+ date(year, 12, 25), # Natale
+ date(year, 12, 26), # Santo Stefano
}
diff --git a/src/decaf/models.py b/src/decaf/models.py
index a623e78..28a744d 100644
--- a/src/decaf/models.py
+++ b/src/decaf/models.py
@@ -37,24 +37,24 @@ class Trade(_Frozen):
"""A single executed trade (stock/ETF or forex conversion)."""
account_id: str
- asset_category: str # "STK" or "CASH" (forex)
+ asset_category: str # "STK" or "CASH" (forex)
symbol: str
- isin: str # empty string for forex trades
+ isin: str # empty string for forex trades
description: str
currency: str
fx_rate_to_base: Decimal
- trade_datetime: date # parsed from dateTime (date part only)
- settle_date: date # from settleDateTarget
- buy_sell: str # "BUY" or "SELL"
- quantity: Decimal # positive for buys, negative for sells
+ trade_datetime: date # parsed from dateTime (date part only)
+ settle_date: date # from settleDateTarget
+ buy_sell: str # "BUY" or "SELL"
+ quantity: Decimal # positive for buys, negative for sells
trade_price: Decimal
- proceeds: Decimal # positive for sells, negative for buys
- cost: Decimal # broker's FIFO cost basis (negative for sells)
- commission: Decimal # always negative (cost to trader)
+ proceeds: Decimal # positive for sells, negative for buys
+ cost: Decimal # broker's FIFO cost basis (negative for sells)
+ commission: Decimal # always negative (cost to trader)
commission_currency: str
- broker_pnl_realized: Decimal # broker's computed FIFO P/L
- listing_exchange: str # IBKR listing exchange (LSEETF, IBIS2, NYSE...)
- acquisition_date: date # lot acquisition date (sell: which lot; buy: = trade date)
+ broker_pnl_realized: Decimal # broker's computed FIFO P/L
+ listing_exchange: str # IBKR listing exchange (LSEETF, IBIS2, NYSE...)
+ acquisition_date: date # lot acquisition date (sell: which lot; buy: = trade date)
@property
def is_forex(self) -> bool:
@@ -85,17 +85,17 @@ class OpenPositionLot(_Frozen):
fx_rate_to_base: Decimal
quantity: Decimal
mark_price: Decimal
- position_value: Decimal # quantity * mark_price in local currency
- cost_basis_money: Decimal # total cost basis in local currency
- open_datetime: date # when this lot was acquired (trade date)
- listing_exchange: str # IBKR exchange code (LSEETF, IBIS2, NASDAQ...)
+ position_value: Decimal # quantity * mark_price in local currency
+ cost_basis_money: Decimal # total cost basis in local currency
+ open_datetime: date # when this lot was acquired (trade date)
+ listing_exchange: str # IBKR exchange code (LSEETF, IBIS2, NASDAQ...)
class CashTransaction(_Frozen):
"""A cash movement: interest, withholding tax, deposit, fee, etc."""
account_id: str
- tx_type: str # "Broker Interest Received", "Withholding Tax", etc.
+ tx_type: str # "Broker Interest Received", "Withholding Tax", etc.
currency: str
fx_rate_to_base: Decimal
date_time: date
@@ -129,24 +129,24 @@ class CashReportEntry(_Frozen):
class RWLine(_Frozen):
"""One line of Quadro RW (foreign asset monitoring)."""
- codice_investimento: int # 1 = bank account, 20 = security
+ codice_investimento: int # 1 = bank account, 20 = security
isin: str
symbol: str
description: str
- long_description: str = "" # broker-provided company name, for xls/pdf columns
- currency: str # original currency (USD, EUR)
- country: str # derived from ISIN prefix (IE, US, etc.)
- quantity: Decimal # number of shares
- acquisition_date: date | None # when acquired (None for cash)
- disposed_date: date | None # when sold (None = held at year-end)
- initial_value: Decimal # in original currency
- final_value: Decimal # in original currency
- ecb_rate_initial: Decimal # ECB rate used for initial value conversion
- ecb_rate_final: Decimal # ECB rate used for final value conversion
+ long_description: str = "" # broker-provided company name, for xls/pdf columns
+ currency: str # original currency (USD, EUR)
+ country: str # derived from ISIN prefix (IE, US, etc.)
+ quantity: Decimal # number of shares
+ acquisition_date: date | None # when acquired (None for cash)
+ disposed_date: date | None # when sold (None = held at year-end)
+ initial_value: Decimal # in original currency
+ final_value: Decimal # in original currency
+ ecb_rate_initial: Decimal # ECB rate used for initial value conversion
+ ecb_rate_final: Decimal # ECB rate used for final value conversion
initial_value_eur: Decimal
final_value_eur: Decimal
days_held: int
- ownership_pct: Decimal # always 100 for individual accounts
+ ownership_pct: Decimal # always 100 for individual accounts
ivafe_due: Decimal
@@ -155,17 +155,17 @@ class RTLine(_Frozen):
symbol: str
isin: str
- long_description: str = "" # broker-provided company name, for xls/pdf columns
- acquisition_date: date # when the lot was acquired
+ long_description: str = "" # broker-provided company name, for xls/pdf columns
+ acquisition_date: date # when the lot was acquired
sell_date: date
quantity: Decimal
proceeds_eur: Decimal
cost_basis_eur: Decimal
gain_loss_eur: Decimal
- ecb_rate: Decimal # ECB rate used for EUR conversion
+ ecb_rate: Decimal # ECB rate used for EUR conversion
is_forex: bool
- broker_pnl: Decimal # broker's original value for cross-check
- broker_pnl_eur: Decimal # broker's value converted to EUR
+ broker_pnl: Decimal # broker's original value for cross-check
+ broker_pnl_eur: Decimal # broker's value converted to EUR
class RLLine(_Frozen):
@@ -184,19 +184,19 @@ class ForexGainEntry(_Frozen):
"""A single forex LIFO gain/loss from converting USD to EUR."""
disposal_date: date
- usd_amount: Decimal # USD disposed in this entry
- acquisition_date: date # from the LIFO lot consumed
- ecb_rate_acquisition: Decimal # EUR/USD at acquisition
- ecb_rate_disposal: Decimal # EUR/USD at disposal
- gain_eur: Decimal # positive = gain, negative = loss
+ usd_amount: Decimal # USD disposed in this entry
+ acquisition_date: date # from the LIFO lot consumed
+ ecb_rate_acquisition: Decimal # EUR/USD at acquisition
+ ecb_rate_disposal: Decimal # EUR/USD at disposal
+ gain_eur: Decimal # positive = gain, negative = loss
class UsdEvent(_Frozen):
"""A single USD cash flow event for the forex timeline."""
date: date
- amount: Decimal # positive = inflow, negative = outflow
- balance: Decimal # running balance after this event
+ amount: Decimal # positive = inflow, negative = outflow
+ balance: Decimal # running balance after this event
description: str
diff --git a/src/decaf/output_cli.py b/src/decaf/output_cli.py
index bdbbd11..7a93f48 100644
--- a/src/decaf/output_cli.py
+++ b/src/decaf/output_cli.py
@@ -38,8 +38,7 @@ def print_report(report: TaxReport) -> None:
net_rt = report.net_capital_gain_loss
rt_style = "red" if net_rt < 0 else "green"
- summary.add_row("Plusvalenze (Quadro RT)",
- Text(f"EUR {_eur(net_rt)}", style=rt_style))
+ summary.add_row("Plusvalenze (Quadro RT)", Text(f"EUR {_eur(net_rt)}", style=rt_style))
summary.add_row(
"Redditi di capitale (Quadro RL)",
@@ -47,11 +46,13 @@ def print_report(report: TaxReport) -> None:
)
summary.add_row("Ritenute estere (Quadro RL)", f"EUR {_eur(report.total_wht_eur)}")
- breach_text = Text("SUPERATA", style="bold red") if report.forex_threshold_breached \
+ breach_text = (
+ Text("SUPERATA", style="bold red")
+ if report.forex_threshold_breached
else Text("NON SUPERATA", style="green")
+ )
summary.add_row("Soglia valutaria", breach_text)
- summary.add_row(" Giorni lavorativi consecutivi",
- f"{report.forex_max_consecutive_days} / 7")
+ summary.add_row(" Giorni lavorativi consecutivi", f"{report.forex_max_consecutive_days} / 7")
if report.rsu_vest_count:
summary.add_row(
@@ -70,17 +71,19 @@ def print_report(report: TaxReport) -> None:
"[italic]Valore Normale (ITA FMV x net shares) x cambio BCE "
"del giorno di vest[/italic].\n"
"Questo numero deve essere un [bold]sottoinsieme[/bold] del punto 1 "
- "della tua Certificazione Unica \"Redditi di lavoro dipendente\". "
+ 'della tua Certificazione Unica "Redditi di lavoro dipendente". '
"Differenza = stipendio + bonus + altri compensi.\n"
"Se non combacia, verifica che la colonna [italic]ITA FMV[/italic] "
"sull'Annual Withholding Statement sia stata letta correttamente "
- "(grep log per \"vest FMV\")."
+ '(grep log per "vest FMV").'
+ )
+ console.print(
+ Panel(
+ Text.from_markup(msg),
+ title="Sanity check - Valore Normale RSU ex art. 9 c. 4 TUIR",
+ border_style="yellow",
+ )
)
- console.print(Panel(
- Text.from_markup(msg),
- title="Sanity check - Valore Normale RSU ex art. 9 c. 4 TUIR",
- border_style="yellow",
- ))
# --- Quadro RW ---
if report.rw_lines:
@@ -127,17 +130,25 @@ def print_report(report: TaxReport) -> None:
# Year-end portfolio value (only held lots)
held = [
- rw for rw in report.rw_lines
+ rw
+ for rw in report.rw_lines
if rw.codice_investimento == 20 and rw.disposed_date is None
]
eoy_eur = sum((rw.final_value_eur for rw in held), Decimal(0))
eoy_shares = sum((rw.quantity for rw in held), Decimal(0))
rw.add_section()
- rw.add_row("", "", "", "31/12", f"{eoy_shares:,.0f}",
- "", "",
- Text(_eur(eoy_eur), style="bold"),
- Text(_eur(report.total_ivafe), style="bold green"))
+ rw.add_row(
+ "",
+ "",
+ "",
+ "31/12",
+ f"{eoy_shares:,.0f}",
+ "",
+ "",
+ Text(_eur(eoy_eur), style="bold"),
+ Text(_eur(report.total_ivafe), style="bold green"),
+ )
console.print(rw)
console.print()
@@ -185,10 +196,18 @@ def print_report(report: TaxReport) -> None:
total_cost = sum((rt.cost_basis_eur for rt in report.rt_lines), Decimal(0))
rt.add_section()
net_style = "red" if net_rt < 0 else "green"
- rt.add_row("", "", "", "", "TOTALI",
- Text(_eur(total_proceeds), style="bold"),
- Text(_eur(total_cost), style="bold"),
- Text(_eur(net_rt), style=f"bold {net_style}"), "", "")
+ rt.add_row(
+ "",
+ "",
+ "",
+ "",
+ "TOTALI",
+ Text(_eur(total_proceeds), style="bold"),
+ Text(_eur(total_cost), style="bold"),
+ Text(_eur(net_rt), style=f"bold {net_style}"),
+ "",
+ "",
+ )
console.print(rt)
console.print()
else:
@@ -230,11 +249,15 @@ def print_report(report: TaxReport) -> None:
total_net = report.total_gross_interest_eur - report.total_wht_eur
rl.add_section()
- rl.add_row("", "TOTALI", "",
- Text(_eur(report.total_gross_interest_eur), style="bold"),
- "",
- Text(_eur(report.total_wht_eur), style="bold red"),
- Text(_eur(total_net), style="bold green"))
+ rl.add_row(
+ "",
+ "TOTALI",
+ "",
+ Text(_eur(report.total_gross_interest_eur), style="bold"),
+ "",
+ Text(_eur(report.total_wht_eur), style="bold red"),
+ Text(_eur(total_net), style="bold green"),
+ )
console.print(rl)
console.print()
else:
@@ -243,24 +266,28 @@ def print_report(report: TaxReport) -> None:
# --- Forex threshold ---
fx_label = "Soglia valutaria (art. 67(1)(c-ter) TUIR)"
if report.forex_threshold_breached:
- console.print(Panel(
- "[bold red]SOGLIA SUPERATA[/bold red]\n"
- f"Giacenza in valuta estera > EUR 51.645,69 per "
- f"{report.forex_max_consecutive_days} giorni lavorativi consecutivi "
- f"(soglia: 7).\n"
- "Le plusvalenze da cessione di valuta estera sono tassabili al 26%.",
- title=fx_label,
- border_style="red",
- ))
+ console.print(
+ Panel(
+ "[bold red]SOGLIA SUPERATA[/bold red]\n"
+ f"Giacenza in valuta estera > EUR 51.645,69 per "
+ f"{report.forex_max_consecutive_days} giorni lavorativi consecutivi "
+ f"(soglia: 7).\n"
+ "Le plusvalenze da cessione di valuta estera sono tassabili al 26%.",
+ title=fx_label,
+ border_style="red",
+ )
+ )
else:
- console.print(Panel(
- "[green]Soglia non superata[/green]\n"
- f"Max {report.forex_max_consecutive_days} giorni lavorativi "
- f"consecutivi sopra soglia (servono 7).\n"
- "Le plusvalenze da conversione valutaria sono esenti.",
- title=fx_label,
- border_style="green",
- ))
+ console.print(
+ Panel(
+ "[green]Soglia non superata[/green]\n"
+ f"Max {report.forex_max_consecutive_days} giorni lavorativi "
+ f"consecutivi sopra soglia (servono 7).\n"
+ "Le plusvalenze da conversione valutaria sono esenti.",
+ title=fx_label,
+ border_style="green",
+ )
+ )
# --- Forex daily detail ---
if report.forex_daily_records:
@@ -282,6 +309,7 @@ def _print_forex_detail(console: Console, report: TaxReport) -> None:
# USD event timeline
border = "red" if report.forex_threshold_breached else "green"
from decaf.forex import THRESHOLD_EUR
+
threshold_eur = THRESHOLD_EUR
tl = Table(
@@ -299,7 +327,7 @@ def _print_forex_detail(console: Console, report: TaxReport) -> None:
# Show every event, but only show balance on the last event of each day
prev_date = None
for i, ev in enumerate(events):
- is_last_of_day = (i + 1 >= len(events) or events[i + 1].date != ev.date)
+ is_last_of_day = i + 1 >= len(events) or events[i + 1].date != ev.date
eod_balance = ev.balance if is_last_of_day else None
amt_str = f"{ev.amount:+,.2f}" if ev.amount != 0 else ""
diff --git a/src/decaf/output_pdf.py b/src/decaf/output_pdf.py
index 0983bc0..962cb21 100644
--- a/src/decaf/output_pdf.py
+++ b/src/decaf/output_pdf.py
@@ -41,21 +41,26 @@ def header(self) -> None:
self.set_text_color(*_WHITE)
self.set_y(4)
self.cell(
- 0, 8,
+ 0,
+ 8,
f"Dichiarazione dei Redditi {self._report.tax_year}",
- new_x="LMARGIN", new_y="NEXT", align="L",
+ new_x="LMARGIN",
+ new_y="NEXT",
+ align="L",
)
self.set_font("Helvetica", "", 8)
self.set_text_color(200, 210, 230)
acct = self._report.account
self.cell(
- 0, 5,
+ 0,
+ 5,
f"{acct.broker_name} | "
f"Conto {acct.account_id} | "
f"{acct.holder_name} | "
f"{acct.country} | "
f"{acct.base_currency}",
- new_x="LMARGIN", new_y="NEXT",
+ new_x="LMARGIN",
+ new_y="NEXT",
)
self.ln(6)
@@ -64,10 +69,7 @@ def footer(self) -> None:
repo_url = "https://github.com/vjt/decaf"
prefix = "Generato da "
version_label = f"decaf v{__version__}"
- suffix = (
- f" | {date.today().isoformat()}"
- f" | Pagina {self.page_no()}/{{nb}}"
- )
+ suffix = f" | {date.today().isoformat()} | Pagina {self.page_no()}/{{nb}}"
# Measure each piece with its own style so total width is accurate
self.set_font("Helvetica", "", 6.5)
w_prefix = self.get_string_width(prefix)
@@ -194,12 +196,14 @@ def write_pdf(report: TaxReport, path: Path) -> None:
net_rt = report.net_capital_gain_loss
rt_sign = "+" if net_rt >= 0 else ""
- pdf.summary_kv([
- ("IVAFE totale (Quadro RW)", f"EUR {_eur(report.total_ivafe)}"),
- ("Plusvalenze nette (Quadro RT)", f"EUR {rt_sign}{_eur(net_rt)}"),
- ("Redditi lordi (Quadro RL)", f"EUR {_eur(report.total_gross_interest_eur)}"),
- ("Ritenute estere (Quadro RL)", f"EUR {_eur(report.total_wht_eur)}"),
- ])
+ pdf.summary_kv(
+ [
+ ("IVAFE totale (Quadro RW)", f"EUR {_eur(report.total_ivafe)}"),
+ ("Plusvalenze nette (Quadro RT)", f"EUR {rt_sign}{_eur(net_rt)}"),
+ ("Redditi lordi (Quadro RL)", f"EUR {_eur(report.total_gross_interest_eur)}"),
+ ("Ritenute estere (Quadro RL)", f"EUR {_eur(report.total_wht_eur)}"),
+ ]
+ )
pdf.ln(2)
pdf.section_title(
@@ -207,15 +211,18 @@ def write_pdf(report: TaxReport, path: Path) -> None:
"Art. 67(1)(c-ter) TUIR - giacenza in valuta estera > EUR 51.645,69",
)
breach = report.forex_threshold_breached
- pdf.summary_kv([
- ("Risultato",
- "SUPERATA" if breach else "NON SUPERATA"),
- ("Giorni lavorativi consecutivi",
- f"{report.forex_max_consecutive_days} / 7"),
- ("Data prima violazione",
- report.forex_first_breach_date.isoformat()
- if report.forex_first_breach_date else "-"),
- ])
+ pdf.summary_kv(
+ [
+ ("Risultato", "SUPERATA" if breach else "NON SUPERATA"),
+ ("Giorni lavorativi consecutivi", f"{report.forex_max_consecutive_days} / 7"),
+ (
+ "Data prima violazione",
+ report.forex_first_breach_date.isoformat()
+ if report.forex_first_breach_date
+ else "-",
+ ),
+ ]
+ )
if report.rsu_vest_count:
pdf.ln(2)
@@ -223,17 +230,18 @@ def write_pdf(report: TaxReport, path: Path) -> None:
"Controllo coerenza RSU",
"Valore Normale ex art. 9 c. 4 lett. a) + art. 68 c. 6 TUIR",
)
- pdf.summary_kv([
- ("Vest events nell'anno", f"{report.rsu_vest_count}"),
- ("Reddito RSU tassato",
- f"EUR {_eur(report.rsu_income_eur)}"),
- ])
+ pdf.summary_kv(
+ [
+ ("Vest events nell'anno", f"{report.rsu_vest_count}"),
+ ("Reddito RSU tassato", f"EUR {_eur(report.rsu_income_eur)}"),
+ ]
+ )
pdf.ln(1)
pdf.set_font("Helvetica", "I", 7.5)
pdf.set_text_color(*_DARK_GRAY)
note = (
"Cross-check: questo valore deve essere un sottoinsieme del "
- "punto 1 della Certificazione Unica \"Redditi di lavoro dipendente\". "
+ 'punto 1 della Certificazione Unica "Redditi di lavoro dipendente". '
"Differenza = stipendio + bonus + altri compensi. Calcolato come "
"sum(ITA FMV x net shares) convertito al cambio BCE del giorno di vest; "
"la colonna ITA FMV dell'Annual Withholding Statement Schwab e' il Valore "
@@ -247,36 +255,73 @@ def write_pdf(report: TaxReport, path: Path) -> None:
"Investimenti e attivita finanziarie all'estero (D.L. 201/2011)",
)
rw_headers = [
- "Cod.", "ISIN", "Simbolo", "Azienda", "Val.", "Paese", "Qty",
- "Acquisto", "Vendita",
- "Val. iniz. EUR", "Val. fin. EUR",
- "Giorni", "IVAFE EUR",
+ "Cod.",
+ "ISIN",
+ "Simbolo",
+ "Azienda",
+ "Val.",
+ "Paese",
+ "Qty",
+ "Acquisto",
+ "Vendita",
+ "Val. iniz. EUR",
+ "Val. fin. EUR",
+ "Giorni",
+ "IVAFE EUR",
]
rw_widths = [
- 10.0, 26.0, 16.0, 38.0, 12.0, 13.0, 16.0,
- 20.0, 20.0,
- 26.0, 26.0,
- 14.0, 22.0,
+ 10.0,
+ 26.0,
+ 16.0,
+ 38.0,
+ 12.0,
+ 13.0,
+ 16.0,
+ 20.0,
+ 20.0,
+ 26.0,
+ 26.0,
+ 14.0,
+ 22.0,
]
# Pre-truncate the Azienda column against the actual font metrics.
# Width minus ~1mm of cell padding keeps text off the border.
pdf.set_font("Helvetica", "", 6.5)
rw_rows = [
[
- str(rw.codice_investimento), rw.isin, rw.symbol,
+ str(rw.codice_investimento),
+ rw.isin,
+ rw.symbol,
pdf.fit_to_width(rw.long_description, 38.0),
- rw.currency, rw.country, f"{rw.quantity:,.0f}",
+ rw.currency,
+ rw.country,
+ f"{rw.quantity:,.0f}",
rw.acquisition_date.isoformat() if rw.acquisition_date else "",
rw.disposed_date.isoformat() if rw.disposed_date else "",
- _eur(rw.initial_value_eur), _eur(rw.final_value_eur),
- str(rw.days_held), _eur(rw.ivafe_due),
+ _eur(rw.initial_value_eur),
+ _eur(rw.final_value_eur),
+ str(rw.days_held),
+ _eur(rw.ivafe_due),
]
for rw in report.rw_lines
]
- rw_rows.append([
- "", "", "", "", "", "", "", "", "TOTALE",
- "", "", "", _eur(report.total_ivafe),
- ])
+ rw_rows.append(
+ [
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "TOTALE",
+ "",
+ "",
+ "",
+ _eur(report.total_ivafe),
+ ]
+ )
pdf.data_table(rw_headers, rw_widths, rw_rows)
# --- Quadro RT ---
@@ -286,24 +331,44 @@ def write_pdf(report: TaxReport, path: Path) -> None:
)
if report.rt_lines:
rt_headers = [
- "Simbolo", "ISIN", "Azienda", "Acquisto", "Vendita", "Qty",
- "Corrispettivo", "Costo", "+/- EUR",
- "Cambio", "Fx", "P/L broker",
+ "Simbolo",
+ "ISIN",
+ "Azienda",
+ "Acquisto",
+ "Vendita",
+ "Qty",
+ "Corrispettivo",
+ "Costo",
+ "+/- EUR",
+ "Cambio",
+ "Fx",
+ "P/L broker",
]
rt_widths = [
- 14.0, 26.0, 38.0, 20.0, 20.0, 14.0,
- 26.0, 26.0, 24.0,
- 15.0, 8.0, 22.0,
+ 14.0,
+ 26.0,
+ 38.0,
+ 20.0,
+ 20.0,
+ 14.0,
+ 26.0,
+ 26.0,
+ 24.0,
+ 15.0,
+ 8.0,
+ 22.0,
]
pdf.set_font("Helvetica", "", 6.5)
rt_rows = [
[
- rt.symbol, rt.isin,
+ rt.symbol,
+ rt.isin,
pdf.fit_to_width(rt.long_description, 38.0),
rt.acquisition_date.isoformat(),
rt.sell_date.isoformat(),
f"{rt.quantity:,.0f}",
- _eur(rt.proceeds_eur), _eur(rt.cost_basis_eur),
+ _eur(rt.proceeds_eur),
+ _eur(rt.cost_basis_eur),
_eur(rt.gain_loss_eur),
f"{rt.ecb_rate:.4f}" if rt.ecb_rate != 1 else "",
"Si" if rt.is_forex else "",
@@ -311,18 +376,32 @@ def write_pdf(report: TaxReport, path: Path) -> None:
]
for rt in report.rt_lines
]
- rt_rows.append([
- "", "", "", "", "", "", "", "NETTO",
- _eur(report.net_capital_gain_loss), "", "", "",
- ])
+ rt_rows.append(
+ [
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "NETTO",
+ _eur(report.net_capital_gain_loss),
+ "",
+ "",
+ "",
+ ]
+ )
pdf.data_table(rt_headers, rt_widths, rt_rows)
else:
pdf.set_font("Helvetica", "I", 8)
pdf.set_text_color(*_MED_GRAY)
pdf.cell(
- 0, 6,
+ 0,
+ 6,
"Nessuna plusvalenza o minusvalenza realizzata.",
- new_x="LMARGIN", new_y="NEXT",
+ new_x="LMARGIN",
+ new_y="NEXT",
)
# --- Quadro RL ---
@@ -332,34 +411,49 @@ def write_pdf(report: TaxReport, path: Path) -> None:
)
if report.rl_lines:
rl_headers = [
- "Descrizione", "Valuta", "Lordo",
- "Lordo EUR", "Ritenuta", "Ritenuta EUR", "Netto EUR",
+ "Descrizione",
+ "Valuta",
+ "Lordo",
+ "Lordo EUR",
+ "Ritenuta",
+ "Ritenuta EUR",
+ "Netto EUR",
]
rl_widths = [68.0, 16.0, 24.0, 27.0, 24.0, 27.0, 27.0]
rl_rows = [
[
- rl.description[:45], rl.currency,
- _eur(rl.gross_amount), _eur(rl.gross_amount_eur),
- _eur(rl.wht_amount), _eur(rl.wht_amount_eur),
+ rl.description[:45],
+ rl.currency,
+ _eur(rl.gross_amount),
+ _eur(rl.gross_amount_eur),
+ _eur(rl.wht_amount),
+ _eur(rl.wht_amount_eur),
_eur(rl.net_amount_eur),
]
for rl in report.rl_lines
]
total_net = report.total_gross_interest_eur - report.total_wht_eur
- rl_rows.append([
- "", "TOTALI", "",
- _eur(report.total_gross_interest_eur), "",
- _eur(report.total_wht_eur),
- _eur(total_net),
- ])
+ rl_rows.append(
+ [
+ "",
+ "TOTALI",
+ "",
+ _eur(report.total_gross_interest_eur),
+ "",
+ _eur(report.total_wht_eur),
+ _eur(total_net),
+ ]
+ )
pdf.data_table(rl_headers, rl_widths, rl_rows)
else:
pdf.set_font("Helvetica", "I", 8)
pdf.set_text_color(*_MED_GRAY)
pdf.cell(
- 0, 6,
+ 0,
+ 6,
"Nessun reddito di capitale.",
- new_x="LMARGIN", new_y="NEXT",
+ new_x="LMARGIN",
+ new_y="NEXT",
)
path.parent.mkdir(parents=True, exist_ok=True)
diff --git a/src/decaf/output_xls.py b/src/decaf/output_xls.py
index 6a2dd76..01e7ef6 100644
--- a/src/decaf/output_xls.py
+++ b/src/decaf/output_xls.py
@@ -14,7 +14,7 @@
_HEADER_FONT = Font(bold=True, size=11)
_HEADER_FILL = PatternFill(start_color="D9E1F2", end_color="D9E1F2", fill_type="solid")
-_MONEY_FMT = '#,##0.00'
+_MONEY_FMT = "#,##0.00"
_THIN_BORDER = Border(
bottom=Side(style="thin", color="B0B0B0"),
)
@@ -86,16 +86,20 @@ def _write_summary(ws: Worksheet, report: TaxReport) -> None:
ws.append(["Controllo coerenza RSU (art. 9 c. 4 TUIR)"])
ws.cell(row=header_row, column=1).font = Font(bold=True, size=12)
ws.append(["Vest events nell'anno", report.rsu_vest_count])
- ws.append([
- "Reddito RSU tassato (EUR)",
- float(report.rsu_income_eur),
- ])
+ ws.append(
+ [
+ "Reddito RSU tassato (EUR)",
+ float(report.rsu_income_eur),
+ ]
+ )
ws.cell(row=ws.max_row, column=2).number_format = _MONEY_FMT
note_row = ws.max_row + 1
- ws.append([
- "Cross-check: deve essere sottoinsieme del punto 1 CU "
- "\"Redditi di lavoro dipendente\". Differenza = stipendio + bonus."
- ])
+ ws.append(
+ [
+ "Cross-check: deve essere sottoinsieme del punto 1 CU "
+ '"Redditi di lavoro dipendente". Differenza = stipendio + bonus.'
+ ]
+ )
ws.cell(row=note_row, column=1).font = Font(italic=True, size=9)
ws.merge_cells(start_row=note_row, end_row=note_row, start_column=1, end_column=4)
@@ -105,34 +109,77 @@ def _write_summary(ws: Worksheet, report: TaxReport) -> None:
def _write_rw(ws: Worksheet, report: TaxReport) -> None:
headers = [
- "Cod.", "ISIN", "Simbolo", "Azienda", "Descrizione", "Valuta", "Paese",
- "Quantita", "Acquisto", "Vendita",
- "Val. iniz. orig.", "Val. fin. orig.",
- "Cambio iniz.", "Cambio fin.",
- "Val. iniz. EUR", "Val. fin. EUR",
- "Giorni", "Quota %", "IVAFE",
+ "Cod.",
+ "ISIN",
+ "Simbolo",
+ "Azienda",
+ "Descrizione",
+ "Valuta",
+ "Paese",
+ "Quantita",
+ "Acquisto",
+ "Vendita",
+ "Val. iniz. orig.",
+ "Val. fin. orig.",
+ "Cambio iniz.",
+ "Cambio fin.",
+ "Val. iniz. EUR",
+ "Val. fin. EUR",
+ "Giorni",
+ "Quota %",
+ "IVAFE",
]
_write_header(ws, headers)
for line in report.rw_lines:
row = [
- line.codice_investimento, line.isin, line.symbol,
- line.long_description, line.description,
- line.currency, line.country,
+ line.codice_investimento,
+ line.isin,
+ line.symbol,
+ line.long_description,
+ line.description,
+ line.currency,
+ line.country,
float(line.quantity),
line.acquisition_date.isoformat() if line.acquisition_date else "",
line.disposed_date.isoformat() if line.disposed_date else "",
- float(line.initial_value), float(line.final_value),
- float(line.ecb_rate_initial), float(line.ecb_rate_final),
- float(line.initial_value_eur), float(line.final_value_eur),
- line.days_held, float(line.ownership_pct), float(line.ivafe_due),
+ float(line.initial_value),
+ float(line.final_value),
+ float(line.ecb_rate_initial),
+ float(line.ecb_rate_final),
+ float(line.initial_value_eur),
+ float(line.final_value_eur),
+ line.days_held,
+ float(line.ownership_pct),
+ float(line.ivafe_due),
]
ws.append(row)
ws.append([])
total_row = ws.max_row + 1
- ws.append(["", "", "", "", "", "", "", "", "", "TOTALE", "", "",
- "", "", "", "", "", "", float(report.total_ivafe)])
+ ws.append(
+ [
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "TOTALE",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ float(report.total_ivafe),
+ ]
+ )
ws.cell(row=total_row, column=19).number_format = _MONEY_FMT
ws.cell(row=total_row, column=19).font = _HEADER_FONT
@@ -142,24 +189,40 @@ def _write_rw(ws: Worksheet, report: TaxReport) -> None:
def _write_rt(ws: Worksheet, report: TaxReport) -> None:
headers = [
- "Simbolo", "ISIN", "Azienda",
- "Data acquisto", "Data vendita", "Quantita",
- "Corrispettivo EUR", "Costo EUR", "+/- EUR",
- "Cambio BCE", "Forex", "P/L broker", "P/L broker EUR",
+ "Simbolo",
+ "ISIN",
+ "Azienda",
+ "Data acquisto",
+ "Data vendita",
+ "Quantita",
+ "Corrispettivo EUR",
+ "Costo EUR",
+ "+/- EUR",
+ "Cambio BCE",
+ "Forex",
+ "P/L broker",
+ "P/L broker EUR",
]
_write_header(ws, headers)
for line in report.rt_lines:
- ws.append([
- line.symbol, line.isin, line.long_description,
- line.acquisition_date.isoformat(), line.sell_date.isoformat(),
- float(line.quantity),
- float(line.proceeds_eur), float(line.cost_basis_eur),
- float(line.gain_loss_eur),
- float(line.ecb_rate),
- "Si" if line.is_forex else "No",
- float(line.broker_pnl), float(line.broker_pnl_eur),
- ])
+ ws.append(
+ [
+ line.symbol,
+ line.isin,
+ line.long_description,
+ line.acquisition_date.isoformat(),
+ line.sell_date.isoformat(),
+ float(line.quantity),
+ float(line.proceeds_eur),
+ float(line.cost_basis_eur),
+ float(line.gain_loss_eur),
+ float(line.ecb_rate),
+ "Si" if line.is_forex else "No",
+ float(line.broker_pnl),
+ float(line.broker_pnl_eur),
+ ]
+ )
ws.append([])
total_row = ws.max_row + 1
@@ -173,27 +236,42 @@ def _write_rt(ws: Worksheet, report: TaxReport) -> None:
def _write_rl(ws: Worksheet, report: TaxReport) -> None:
headers = [
- "Descrizione", "Valuta", "Lordo",
- "Lordo EUR", "Ritenuta", "Ritenuta EUR", "Netto EUR",
+ "Descrizione",
+ "Valuta",
+ "Lordo",
+ "Lordo EUR",
+ "Ritenuta",
+ "Ritenuta EUR",
+ "Netto EUR",
]
_write_header(ws, headers)
for line in report.rl_lines:
- ws.append([
- line.description, line.currency,
- float(line.gross_amount),
- float(line.gross_amount_eur), float(line.wht_amount),
- float(line.wht_amount_eur), float(line.net_amount_eur),
- ])
+ ws.append(
+ [
+ line.description,
+ line.currency,
+ float(line.gross_amount),
+ float(line.gross_amount_eur),
+ float(line.wht_amount),
+ float(line.wht_amount_eur),
+ float(line.net_amount_eur),
+ ]
+ )
ws.append([])
total_row = ws.max_row + 1
- ws.append([
- "", "", "TOTALI",
- float(report.total_gross_interest_eur), "",
- float(report.total_wht_eur),
- float(report.total_gross_interest_eur - report.total_wht_eur),
- ])
+ ws.append(
+ [
+ "",
+ "",
+ "TOTALI",
+ float(report.total_gross_interest_eur),
+ "",
+ float(report.total_wht_eur),
+ float(report.total_gross_interest_eur - report.total_wht_eur),
+ ]
+ )
for col in (4, 6, 7):
ws.cell(row=total_row, column=col).number_format = _MONEY_FMT
ws.cell(row=total_row, column=col).font = _HEADER_FONT
@@ -205,32 +283,40 @@ def _write_rl(ws: Worksheet, report: TaxReport) -> None:
def _write_forex(ws: Worksheet, report: TaxReport) -> None:
ws.append(["Analisi Soglia Valutaria", "", f"Anno fiscale {report.tax_year}"])
ws["A1"].font = Font(bold=True, size=12)
- ws.append([
- "Soglia: EUR 51.645,69",
- "",
- f"Risultato: {'SUPERATA' if report.forex_threshold_breached else 'NON SUPERATA'}",
- "",
- f"Massimo consecutivo: {report.forex_max_consecutive_days} giorni",
- ])
+ ws.append(
+ [
+ "Soglia: EUR 51.645,69",
+ "",
+ f"Risultato: {'SUPERATA' if report.forex_threshold_breached else 'NON SUPERATA'}",
+ "",
+ f"Massimo consecutivo: {report.forex_max_consecutive_days} giorni",
+ ]
+ )
ws.append([])
headers = [
- "Data", "Saldo USD", "Equiv. EUR", "Cambio",
- "Giorno lavorativo", "Sopra soglia",
+ "Data",
+ "Saldo USD",
+ "Equiv. EUR",
+ "Cambio",
+ "Giorno lavorativo",
+ "Sopra soglia",
]
_write_header(ws, headers, start_row=4)
for rec in report.forex_daily_records:
if rec.usd_balance == 0 and not rec.above_threshold:
continue # skip zero-balance days to keep sheet manageable
- ws.append([
- rec.date.isoformat(),
- float(rec.usd_balance),
- float(rec.eur_equivalent),
- float(rec.fx_rate),
- "Si" if rec.is_business_day else "",
- "SI" if rec.above_threshold else "",
- ])
+ ws.append(
+ [
+ rec.date.isoformat(),
+ float(rec.usd_balance),
+ float(rec.eur_equivalent),
+ float(rec.fx_rate),
+ "Si" if rec.is_business_day else "",
+ "SI" if rec.above_threshold else "",
+ ]
+ )
_format_money_columns(ws, [2, 3], 5, ws.max_row)
_auto_width(ws)
@@ -240,6 +326,7 @@ def _write_forex(ws: Worksheet, report: TaxReport) -> None:
# Helpers
# ---------------------------------------------------------------------------
+
def _write_header(ws: Worksheet, headers: list[str], start_row: int = 1) -> None:
for col_idx, header in enumerate(headers, 1):
cell = ws.cell(row=start_row, column=col_idx, value=header)
diff --git a/src/decaf/parse.py b/src/decaf/parse.py
index b62392b..fdecf44 100644
--- a/src/decaf/parse.py
+++ b/src/decaf/parse.py
@@ -42,10 +42,7 @@ class ParsedData:
def parse_statement(xml_text: str, tax_year: int) -> ParsedData:
"""Parse a FlexQuery XML and filter cash transactions to tax_year."""
data = parse_statement_all(xml_text)
- filtered_cash = [
- ct for ct in data.cash_transactions
- if ct.date_time.year == tax_year
- ]
+ filtered_cash = [ct for ct in data.cash_transactions if ct.date_time.year == tax_year]
return ParsedData(
account=data.account,
trades=data.trades,
@@ -166,9 +163,7 @@ def _parse_trades(stmt: ET.Element) -> Iterator[Trade]:
tag = elem.tag
if tag == "Lot":
if pending_sell is None:
- raise ValueError(
- f"Lot sibling without parent SELL: {elem.get('symbol', '?')}"
- )
+ raise ValueError(f"Lot sibling without parent SELL: {elem.get('symbol', '?')}")
pending_lots.append(elem)
continue
@@ -180,10 +175,7 @@ def _parse_trades(stmt: ET.Element) -> Iterator[Trade]:
if tag != "Trade":
raise ValueError(f"Unexpected element inside : {tag}")
- is_stk_sell = (
- elem.get("buySell") == "SELL"
- and elem.get("assetCategory") == "STK"
- )
+ is_stk_sell = elem.get("buySell") == "SELL" and elem.get("assetCategory") == "STK"
if is_stk_sell:
pending_sell = elem
else:
@@ -223,7 +215,8 @@ def _trade_from_element(elem: ET.Element) -> Trade:
def _emit_sell_with_lots(
- sell_el: ET.Element, lot_els: list[ET.Element],
+ sell_el: ET.Element,
+ lot_els: list[ET.Element],
) -> Iterator[Trade]:
"""Emit one Trade per sibling of a SELL .
@@ -320,7 +313,8 @@ def _parse_positions(stmt: ET.Element) -> Iterator[OpenPositionLot]:
except (ValueError, InvalidOperation) as e:
logger.warning(
"Skipping unparseable position: %s (%s)",
- elem.get("symbol", "?"), e,
+ elem.get("symbol", "?"),
+ e,
)
@@ -344,7 +338,8 @@ def _parse_cash_transactions(stmt: ET.Element) -> Iterator[CashTransaction]:
except (ValueError, InvalidOperation) as e:
logger.warning(
"Skipping unparseable cash transaction: %s (%s)",
- elem.get("type", "?"), e,
+ elem.get("type", "?"),
+ e,
)
diff --git a/src/decaf/prices.py b/src/decaf/prices.py
index ea1dbbe..1e66429 100644
--- a/src/decaf/prices.py
+++ b/src/decaf/prices.py
@@ -25,11 +25,17 @@ def __init__(self, failed_symbols: list[str]) -> None:
# IBKR listingExchange -> Yahoo Finance suffix
EXCHANGE_TO_YF: dict[str, str] = {
# US exchanges
- "NASDAQ": "", "NYSE": "", "ARCA": "", "AMEX": "", "BATS": "",
+ "NASDAQ": "",
+ "NYSE": "",
+ "ARCA": "",
+ "AMEX": "",
+ "BATS": "",
# London
- "LSEETF": ".L", "LSE": ".L",
+ "LSEETF": ".L",
+ "LSE": ".L",
# XETRA
- "IBIS": ".DE", "IBIS2": ".DE",
+ "IBIS": ".DE",
+ "IBIS2": ".DE",
# Amsterdam
"AEB": ".AS",
# Paris
@@ -89,7 +95,8 @@ def fetch_year_end_prices(
# published, not a figure that keeps shrinking every time the
# company declares a future dividend.
hist = ticker.history(
- start=start.isoformat(), end=end.isoformat(),
+ start=start.isoformat(),
+ end=end.isoformat(),
auto_adjust=False,
)
if hist.empty:
diff --git a/src/decaf/quadro_rl.py b/src/decaf/quadro_rl.py
index 0c623ce..e60257d 100644
--- a/src/decaf/quadro_rl.py
+++ b/src/decaf/quadro_rl.py
@@ -27,13 +27,15 @@ def compute_rl(
double-counting when multiple income entries fall in the same month.
"""
income_entries = [
- ct for ct in cash_transactions
+ ct
+ for ct in cash_transactions
if ct.date_time.year == tax_year
and ("Interest" in ct.tx_type or "Dividends" in ct.tx_type)
and ct.amount > 0
]
wht_entries = [
- ct for ct in cash_transactions
+ ct
+ for ct in cash_transactions
if ct.date_time.year == tax_year and "Withholding" in ct.tx_type
]
@@ -58,16 +60,19 @@ def compute_rl(
gross_eur = fx.to_eur(income.amount, income.currency, income.settle_date)
wht_eur = fx.to_eur(abs(matched_wht), income.currency, income.settle_date)
- lines.append(RLLine(
- description=income.description,
- currency=income.currency,
- gross_amount=income.amount,
- gross_amount_eur=gross_eur.quantize(_Q, rounding=ROUND_HALF_UP),
- wht_amount=abs(matched_wht),
- wht_amount_eur=wht_eur.quantize(_Q, rounding=ROUND_HALF_UP),
- net_amount_eur=(gross_eur - wht_eur).quantize(
- _Q, rounding=ROUND_HALF_UP,
- ),
- ))
+ lines.append(
+ RLLine(
+ description=income.description,
+ currency=income.currency,
+ gross_amount=income.amount,
+ gross_amount_eur=gross_eur.quantize(_Q, rounding=ROUND_HALF_UP),
+ wht_amount=abs(matched_wht),
+ wht_amount_eur=wht_eur.quantize(_Q, rounding=ROUND_HALF_UP),
+ net_amount_eur=(gross_eur - wht_eur).quantize(
+ _Q,
+ rounding=ROUND_HALF_UP,
+ ),
+ )
+ )
return lines
diff --git a/src/decaf/quadro_rt.py b/src/decaf/quadro_rt.py
index d5d2d54..22e3e01 100644
--- a/src/decaf/quadro_rt.py
+++ b/src/decaf/quadro_rt.py
@@ -92,24 +92,29 @@ def compute_rt(
logger.warning(
"Quadro RT %s %s: missing ECB rate on %s or %s, "
"fell back to broker fxRateToBase %s",
- t.symbol, t.trade_datetime,
- t.settle_date, t.acquisition_date, t.fx_rate_to_base,
+ t.symbol,
+ t.trade_datetime,
+ t.settle_date,
+ t.acquisition_date,
+ t.fx_rate_to_base,
)
- lines.append(RTLine(
- symbol=t.symbol,
- isin=t.isin,
- long_description=t.description,
- acquisition_date=t.acquisition_date,
- sell_date=t.settle_date,
- quantity=abs(t.quantity),
- proceeds_eur=proceeds_eur,
- cost_basis_eur=cost_eur,
- gain_loss_eur=pnl_eur,
- ecb_rate=rate_used,
- is_forex=False,
- broker_pnl=t.broker_pnl_realized,
- broker_pnl_eur=broker_pnl_converted,
- ))
+ lines.append(
+ RTLine(
+ symbol=t.symbol,
+ isin=t.isin,
+ long_description=t.description,
+ acquisition_date=t.acquisition_date,
+ sell_date=t.settle_date,
+ quantity=abs(t.quantity),
+ proceeds_eur=proceeds_eur,
+ cost_basis_eur=cost_eur,
+ gain_loss_eur=pnl_eur,
+ ecb_rate=rate_used,
+ is_forex=False,
+ broker_pnl=t.broker_pnl_realized,
+ broker_pnl_eur=broker_pnl_converted,
+ )
+ )
return lines
diff --git a/src/decaf/quadro_rw.py b/src/decaf/quadro_rw.py
index ead3523..e2edc2f 100644
--- a/src/decaf/quadro_rw.py
+++ b/src/decaf/quadro_rw.py
@@ -94,30 +94,33 @@ def compute_rw(
final_eur = fx.to_eur(final, s.currency, year_end)
ivafe = (final_eur * _IVAFE_RATE * days_held / year_days).quantize(
- _Q, rounding=ROUND_HALF_UP,
+ _Q,
+ rounding=ROUND_HALF_UP,
)
- lines.append(RWLine(
- codice_investimento=20,
- isin=s.isin,
- symbol=s.symbol,
- description=f"{s.symbol} ({s.acquired.isoformat()})",
- long_description=s.long_description,
- currency=s.currency,
- country=country,
- quantity=s.quantity,
- acquisition_date=s.acquired,
- disposed_date=s.disposed if s.disposed and s.disposed <= year_end else None,
- initial_value=initial.quantize(_Q, ROUND_HALF_UP),
- final_value=final.quantize(_Q, ROUND_HALF_UP),
- ecb_rate_initial=ecb_init,
- ecb_rate_final=ecb_fin,
- initial_value_eur=initial_eur.quantize(_Q, ROUND_HALF_UP),
- final_value_eur=final_eur.quantize(_Q, ROUND_HALF_UP),
- days_held=days_held,
- ownership_pct=Decimal("100"),
- ivafe_due=ivafe,
- ))
+ lines.append(
+ RWLine(
+ codice_investimento=20,
+ isin=s.isin,
+ symbol=s.symbol,
+ description=f"{s.symbol} ({s.acquired.isoformat()})",
+ long_description=s.long_description,
+ currency=s.currency,
+ country=country,
+ quantity=s.quantity,
+ acquisition_date=s.acquired,
+ disposed_date=s.disposed if s.disposed and s.disposed <= year_end else None,
+ initial_value=initial.quantize(_Q, ROUND_HALF_UP),
+ final_value=final.quantize(_Q, ROUND_HALF_UP),
+ ecb_rate_initial=ecb_init,
+ ecb_rate_final=ecb_fin,
+ initial_value_eur=initial_eur.quantize(_Q, ROUND_HALF_UP),
+ final_value_eur=final_eur.quantize(_Q, ROUND_HALF_UP),
+ days_held=days_held,
+ ownership_pct=Decimal("100"),
+ ivafe_due=ivafe,
+ )
+ )
# --- Foreign currency cash (codice investimento 1) ---
_add_cash_lines(lines, cash_report, cash_transactions, fx, tax_year, year_days)
@@ -138,14 +141,8 @@ def symbols_needing_prices(
year_end = date(tax_year, 12, 31)
slices = _reconstruct_lot_slices(trades, tax_year)
- held_at_year_end = {
- s.symbol for s in slices
- if s.disposed is None or s.disposed > year_end
- }
- carried_from_prior = {
- s.symbol for s in slices
- if s.acquired < year_start
- }
+ held_at_year_end = {s.symbol for s in slices if s.disposed is None or s.disposed > year_end}
+ carried_from_prior = {s.symbol for s in slices if s.acquired < year_start}
return held_at_year_end, carried_from_prior
@@ -163,11 +160,11 @@ class _LotSlice:
isin: str
currency: str
quantity: Decimal
- cost_price: Decimal # per-share acquisition cost
- acquired: date # settlement date
- disposed: date | None # settlement date of sale (None = still held)
- sell_proceeds: Decimal # total USD proceeds if sold
- long_description: str = "" # company name from broker, for xls/pdf
+ cost_price: Decimal # per-share acquisition cost
+ acquired: date # settlement date
+ disposed: date | None # settlement date of sale (None = still held)
+ sell_proceeds: Decimal # total USD proceeds if sold
+ long_description: str = "" # company name from broker, for xls/pdf
def _reconstruct_lot_slices(
@@ -193,9 +190,14 @@ def _reconstruct_lot_slices(
key = (t.acquisition_date, t.symbol)
if key not in acq_lots:
acq_lots[key] = _AcqLot(
- symbol=t.symbol, isin=t.isin, currency=t.currency,
- total_qty=Decimal(0), cost_price=t.trade_price,
- acquired=t.settle_date, long_description=t.description, sells=[],
+ symbol=t.symbol,
+ isin=t.isin,
+ currency=t.currency,
+ total_qty=Decimal(0),
+ cost_price=t.trade_price,
+ acquired=t.settle_date,
+ long_description=t.description,
+ sells=[],
)
acq_lots[key].total_qty += t.quantity
@@ -209,17 +211,22 @@ def _reconstruct_lot_slices(
# Schwab: exact lot match via date_acquired in description
key = (acq_date, t.symbol)
if key in acq_lots:
- acq_lots[key].sells.append(_SellEvent(
- quantity=abs(t.quantity),
- settle_date=t.settle_date,
- proceeds_per_share=(t.proceeds / abs(t.quantity) if t.quantity else Decimal(0)),
- ))
+ acq_lots[key].sells.append(
+ _SellEvent(
+ quantity=abs(t.quantity),
+ settle_date=t.settle_date,
+ proceeds_per_share=(
+ t.proceeds / abs(t.quantity) if t.quantity else Decimal(0)
+ ),
+ )
+ )
else:
# IBKR: LIFO — most recently acquired lot sold first
# (Circolare 38/E par. 1.4.1, Istruzioni RW 2025)
candidates = sorted(
[v for v in acq_lots.values() if v.symbol == t.symbol and v.remaining > 0],
- key=lambda v: v.acquired, reverse=True,
+ key=lambda v: v.acquired,
+ reverse=True,
)
remaining = abs(t.quantity)
pps = t.proceeds / abs(t.quantity) if t.quantity else Decimal(0)
@@ -227,11 +234,13 @@ def _reconstruct_lot_slices(
if remaining <= 0:
break
consumed = min(lot.remaining, remaining)
- lot.sells.append(_SellEvent(
- quantity=consumed,
- settle_date=t.settle_date,
- proceeds_per_share=pps,
- ))
+ lot.sells.append(
+ _SellEvent(
+ quantity=consumed,
+ settle_date=t.settle_date,
+ proceeds_per_share=pps,
+ )
+ )
remaining -= consumed
# Step 3: Generate slices from lots
@@ -241,9 +250,9 @@ def _reconstruct_lot_slices(
# Step 4: Filter to slices overlapping with tax year
return [
- s for s in slices
- if s.acquired <= year_end
- and (s.disposed is None or s.disposed >= year_start)
+ s
+ for s in slices
+ if s.acquired <= year_end and (s.disposed is None or s.disposed >= year_start)
]
@@ -285,38 +294,44 @@ def to_slices(self, year_end: date) -> list[_LotSlice]:
if year_sells:
qty_sold = sum((s.quantity for s in year_sells), Decimal(0))
proceeds = sum(
- (s.quantity * s.proceeds_per_share for s in year_sells), Decimal(0),
+ (s.quantity * s.proceeds_per_share for s in year_sells),
+ Decimal(0),
)
last_sell = max(s.settle_date for s in year_sells)
- result.append(_LotSlice(
- symbol=self.symbol,
- isin=self.isin,
- currency=self.currency,
- quantity=qty_sold,
- cost_price=self.cost_price,
- acquired=self.acquired,
- disposed=last_sell,
- sell_proceeds=proceeds,
- long_description=self.long_description,
- ))
+ result.append(
+ _LotSlice(
+ symbol=self.symbol,
+ isin=self.isin,
+ currency=self.currency,
+ quantity=qty_sold,
+ cost_price=self.cost_price,
+ acquired=self.acquired,
+ disposed=last_sell,
+ sell_proceeds=proceeds,
+ long_description=self.long_description,
+ )
+ )
# Portion still held at year-end: total - all sells through year-end
sold_thru_year = sum(
- (s.quantity for s in self.sells if s.settle_date <= year_end), Decimal(0),
+ (s.quantity for s in self.sells if s.settle_date <= year_end),
+ Decimal(0),
)
rem = self.total_qty - sold_thru_year
if rem > 0:
- result.append(_LotSlice(
- symbol=self.symbol,
- isin=self.isin,
- currency=self.currency,
- quantity=rem,
- cost_price=self.cost_price,
- acquired=self.acquired,
- disposed=None,
- sell_proceeds=Decimal(0),
- long_description=self.long_description,
- ))
+ result.append(
+ _LotSlice(
+ symbol=self.symbol,
+ isin=self.isin,
+ currency=self.currency,
+ quantity=rem,
+ cost_price=self.cost_price,
+ acquired=self.acquired,
+ disposed=None,
+ sell_proceeds=Decimal(0),
+ long_description=self.long_description,
+ )
+ )
return result
@@ -360,8 +375,11 @@ def _add_cash_lines(
hold_start = year_start
else:
first_usd = min(
- (ct.settle_date for ct in cash_transactions
- if ct.currency == cr.currency and year_start <= ct.settle_date <= year_end),
+ (
+ ct.settle_date
+ for ct in cash_transactions
+ if ct.currency == cr.currency and year_start <= ct.settle_date <= year_end
+ ),
default=year_start,
)
hold_start = first_usd
@@ -372,29 +390,32 @@ def _add_cash_lines(
initial_eur = fx.to_eur(cr.starting_cash, cr.currency, year_start)
ivafe = (final_eur * _IVAFE_RATE * days_held / year_days).quantize(
- _Q, rounding=ROUND_HALF_UP,
+ _Q,
+ rounding=ROUND_HALF_UP,
)
- lines.append(RWLine(
- codice_investimento=1,
- isin="",
- symbol=cr.currency,
- description=f"Cash balance ({cr.currency})",
- currency=cr.currency,
- country="IE",
- quantity=cr.ending_cash,
- acquisition_date=None,
- disposed_date=None,
- initial_value=cr.starting_cash,
- final_value=cr.ending_cash,
- ecb_rate_initial=fx.ecb_rate(cr.currency, year_start) or Decimal(1),
- ecb_rate_final=fx.ecb_rate(cr.currency, year_end) or Decimal(1),
- initial_value_eur=initial_eur.quantize(_Q, ROUND_HALF_UP),
- final_value_eur=final_eur.quantize(_Q, ROUND_HALF_UP),
- days_held=days_held,
- ownership_pct=Decimal("100"),
- ivafe_due=ivafe,
- ))
+ lines.append(
+ RWLine(
+ codice_investimento=1,
+ isin="",
+ symbol=cr.currency,
+ description=f"Cash balance ({cr.currency})",
+ currency=cr.currency,
+ country="IE",
+ quantity=cr.ending_cash,
+ acquisition_date=None,
+ disposed_date=None,
+ initial_value=cr.starting_cash,
+ final_value=cr.ending_cash,
+ ecb_rate_initial=fx.ecb_rate(cr.currency, year_start) or Decimal(1),
+ ecb_rate_final=fx.ecb_rate(cr.currency, year_end) or Decimal(1),
+ initial_value_eur=initial_eur.quantize(_Q, ROUND_HALF_UP),
+ final_value_eur=final_eur.quantize(_Q, ROUND_HALF_UP),
+ days_held=days_held,
+ ownership_pct=Decimal("100"),
+ ivafe_due=ivafe,
+ )
+ )
# ---------------------------------------------------------------------------
diff --git a/src/decaf/schwab_auth.py b/src/decaf/schwab_auth.py
index 4344c9d..9892868 100644
--- a/src/decaf/schwab_auth.py
+++ b/src/decaf/schwab_auth.py
@@ -136,11 +136,7 @@ async def handle_callback(request: web.Request) -> web.Response:
site = web.TCPSite(runner, "127.0.0.1", _CALLBACK_PORT, ssl_context=ssl_ctx)
await site.start()
- auth_url = (
- f"{_AUTH_URL}"
- f"?client_id={self._client_id}"
- f"&redirect_uri={_CALLBACK_URL}"
- )
+ auth_url = f"{_AUTH_URL}?client_id={self._client_id}&redirect_uri={_CALLBACK_URL}"
print("\nOpen this URL in your browser to authorize:\n")
print(f" {auth_url}\n")
@@ -159,7 +155,9 @@ async def handle_callback(request: web.Request) -> web.Response:
return await self._exchange_code(session, code)
async def _exchange_code(
- self, session: aiohttp.ClientSession, code: str,
+ self,
+ session: aiohttp.ClientSession,
+ code: str,
) -> _OAuthTokens:
"""Exchange authorization code for access + refresh tokens."""
async with session.post(
@@ -176,16 +174,16 @@ async def _exchange_code(
) as resp:
if resp.status != 200:
body = await resp.text()
- raise RuntimeError(
- f"Schwab token exchange failed ({resp.status}): {body}"
- )
+ raise RuntimeError(f"Schwab token exchange failed ({resp.status}): {body}")
tokens = await resp.json()
tokens["expires_at"] = time.time() + tokens.get("expires_in", 1800)
return tokens
async def _refresh(
- self, session: aiohttp.ClientSession, refresh_token: str,
+ self,
+ session: aiohttp.ClientSession,
+ refresh_token: str,
) -> _OAuthTokens:
"""Refresh the access token using the refresh token."""
async with session.post(
@@ -201,9 +199,7 @@ async def _refresh(
) as resp:
if resp.status != 200:
body = await resp.text()
- raise RuntimeError(
- f"Schwab token refresh failed ({resp.status}): {body}"
- )
+ raise RuntimeError(f"Schwab token refresh failed ({resp.status}): {body}")
tokens = await resp.json()
tokens["expires_at"] = time.time() + tokens.get("expires_in", 1800)
@@ -242,12 +238,22 @@ def _ensure_cert(self) -> None:
logger.info("Generating self-signed certificate for Schwab callback...")
subprocess.run(
[
- "openssl", "req", "-x509", "-newkey", "rsa:2048",
- "-keyout", str(self._key_path),
- "-out", str(self._cert_path),
- "-days", "3650", "-nodes",
- "-subj", "/CN=127.0.0.1",
- "-addext", "subjectAltName=IP:127.0.0.1",
+ "openssl",
+ "req",
+ "-x509",
+ "-newkey",
+ "rsa:2048",
+ "-keyout",
+ str(self._key_path),
+ "-out",
+ str(self._cert_path),
+ "-days",
+ "3650",
+ "-nodes",
+ "-subj",
+ "/CN=127.0.0.1",
+ "-addext",
+ "subjectAltName=IP:127.0.0.1",
],
check=True,
capture_output=True,
diff --git a/src/decaf/schwab_client.py b/src/decaf/schwab_client.py
index 3e1d6b1..83e0398 100644
--- a/src/decaf/schwab_client.py
+++ b/src/decaf/schwab_client.py
@@ -30,7 +30,8 @@ def __init__(self, auth: SchwabAuth) -> None:
self._auth = auth
async def get_account_numbers(
- self, session: aiohttp.ClientSession,
+ self,
+ session: aiohttp.ClientSession,
) -> list[dict[str, str]]:
"""Get account number to hash mapping.
@@ -40,7 +41,9 @@ async def get_account_numbers(
return await self._get(session, "/accounts/accountNumbers")
async def get_account(
- self, session: aiohttp.ClientSession, account_hash: str,
+ self,
+ session: aiohttp.ClientSession,
+ account_hash: str,
) -> dict[str, Any]:
"""Get account details including current positions.
@@ -95,9 +98,7 @@ async def _get(
) as resp:
if resp.status != 200:
body = await resp.text()
- raise RuntimeError(
- f"Schwab API error {resp.status} for {path}: {body}"
- )
+ raise RuntimeError(f"Schwab API error {resp.status} for {path}: {body}")
return await resp.json()
diff --git a/src/decaf/schwab_gains_pdf.py b/src/decaf/schwab_gains_pdf.py
index 8e7b67b..67dccf6 100644
--- a/src/decaf/schwab_gains_pdf.py
+++ b/src/decaf/schwab_gains_pdf.py
@@ -49,7 +49,10 @@ def parse_realized_gains(pdf_paths: list[Path]) -> list[RealizedLot]:
long_term = sum(1 for lot in lots if lot.is_long_term)
logger.info(
"Parsed %s: %d lots (%d short-term, %d long-term)",
- path.name, len(lots), short, long_term,
+ path.name,
+ len(lots),
+ short,
+ long_term,
)
return result
@@ -71,15 +74,15 @@ def _parse_single_pdf(pdf_path: Path) -> list[RealizedLot]:
# Parse transaction lines: symbol + CUSIP + numbers
# Pattern: description CUSIP qty date_acq date_sold $ proceeds $ cost -- $ gain
m = re.match(
- r'\s*(.+?)\s{2,}' # Description (e.g., "META PLATFORMS INC CLASS")
- r'(\d{5}[A-Z]\d{3})\s+' # CUSIP (e.g., 30303M102)
- r'([\d.]+)\s+' # Quantity
- r'(\d{2}/\d{2}/\d{2})\s+' # Date Acquired
- r'(\d{2}/\d{2}/\d{2})\s+' # Date Sold
- r'\$\s*([\d,.]+)\s+' # Total Proceeds
- r'\$\s*([\d,.]+)\s+' # Cost Basis
- r'--\s+' # Wash Sale (-- = none)
- r'\$\s*([\d,.()]+)', # Realized Gain or (Loss)
+ r"\s*(.+?)\s{2,}" # Description (e.g., "META PLATFORMS INC CLASS")
+ r"(\d{5}[A-Z]\d{3})\s+" # CUSIP (e.g., 30303M102)
+ r"([\d.]+)\s+" # Quantity
+ r"(\d{2}/\d{2}/\d{2})\s+" # Date Acquired
+ r"(\d{2}/\d{2}/\d{2})\s+" # Date Sold
+ r"\$\s*([\d,.]+)\s+" # Total Proceeds
+ r"\$\s*([\d,.]+)\s+" # Cost Basis
+ r"--\s+" # Wash Sale (-- = none)
+ r"\$\s*([\d,.()]+)", # Realized Gain or (Loss)
line,
)
if not m:
@@ -97,18 +100,20 @@ def _parse_single_pdf(pdf_path: Path) -> list[RealizedLot]:
# Extract ticker from description
symbol = _extract_symbol(description)
- lots.append(RealizedLot(
- symbol=symbol,
- cusip=cusip,
- quantity=quantity,
- date_acquired=date_acquired,
- date_sold=date_sold,
- proceeds=proceeds,
- cost_basis=cost_basis,
- wash_sale_adj=Decimal(0),
- gain_loss=gain_loss,
- is_long_term=is_long_term,
- ))
+ lots.append(
+ RealizedLot(
+ symbol=symbol,
+ cusip=cusip,
+ quantity=quantity,
+ date_acquired=date_acquired,
+ date_sold=date_sold,
+ proceeds=proceeds,
+ cost_basis=cost_basis,
+ wash_sale_adj=Decimal(0),
+ gain_loss=gain_loss,
+ is_long_term=is_long_term,
+ )
+ )
return lots
diff --git a/src/decaf/schwab_parse.py b/src/decaf/schwab_parse.py
index f056c76..c7b700a 100644
--- a/src/decaf/schwab_parse.py
+++ b/src/decaf/schwab_parse.py
@@ -141,11 +141,13 @@ def parse_schwab(
broker_name="Charles Schwab",
)
- cash_report = [CashReportEntry(
- currency="USD",
- starting_cash=Decimal(0),
- ending_cash=max(cash_balance, Decimal(0)),
- )]
+ cash_report = [
+ CashReportEntry(
+ currency="USD",
+ starting_cash=Decimal(0),
+ ending_cash=max(cash_balance, Decimal(0)),
+ )
+ ]
return ParsedData(
account=account,
@@ -194,8 +196,12 @@ def _lot_to_trade(
logger.info(
"Cost basis substituted to Normal Value for %s lot %s: "
"$%s -> $%s (qty %s x ITA FMV $%s)",
- lot.symbol, lot.date_acquired, cost_basis, substituted,
- lot.quantity, normal_value,
+ lot.symbol,
+ lot.date_acquired,
+ cost_basis,
+ substituted,
+ lot.quantity,
+ normal_value,
)
cost_basis = substituted
# broker_pnl_realized is kept as the broker's original number for
@@ -227,7 +233,8 @@ def _lot_to_trade(
def _lookup_normal_value(
- vest_fmvs: dict[date, Decimal], acquisition_date: date,
+ vest_fmvs: dict[date, Decimal],
+ acquisition_date: date,
) -> Decimal | None:
"""Find the Valore Normale (ITA FMV) for a vest date, with ±3d window.
@@ -452,21 +459,23 @@ def _compute_open_positions(
qty = lot["quantity"]
if qty <= 0:
continue
- result.append(OpenPositionLot(
- account_id=account_id,
- asset_category="STK",
- symbol=symbol,
- isin=lot["isin"],
- description=lot["description"],
- currency=lot["currency"],
- fx_rate_to_base=Decimal(0),
- quantity=qty,
- mark_price=lot["price"],
- position_value=qty * lot["price"],
- cost_basis_money=qty * lot["price"],
- open_datetime=lot["settle_date"],
- listing_exchange="", # Schwab/US stock — routed via ISIN prefix
- ))
+ result.append(
+ OpenPositionLot(
+ account_id=account_id,
+ asset_category="STK",
+ symbol=symbol,
+ isin=lot["isin"],
+ description=lot["description"],
+ currency=lot["currency"],
+ fx_rate_to_base=Decimal(0),
+ quantity=qty,
+ mark_price=lot["price"],
+ position_value=qty * lot["price"],
+ cost_basis_money=qty * lot["price"],
+ open_datetime=lot["settle_date"],
+ listing_exchange="", # Schwab/US stock — routed via ISIN prefix
+ )
+ )
return result
@@ -507,7 +516,9 @@ def cusip_to_isin(cusip: str, country: str = "US") -> str:
def _lookup_vest_price(
- vest_prices: dict[date, Decimal], vest_date: date, trade_date: date,
+ vest_prices: dict[date, Decimal],
+ vest_date: date,
+ trade_date: date,
) -> tuple[Decimal, date] | None:
"""Look up vest FMV from the Annual Withholding PDF.
@@ -531,7 +542,9 @@ def _lookup_vest_price(
if d in vest_prices:
logger.info(
"Vest date reconciled: JSON %s → FMV PDF %s (offset %dd)",
- vest_date, d, abs((d - vest_date).days),
+ vest_date,
+ d,
+ abs((d - vest_date).days),
)
return vest_prices[d], d
return None
diff --git a/src/decaf/schwab_vest_pdf.py b/src/decaf/schwab_vest_pdf.py
index 3d124ee..1451b57 100644
--- a/src/decaf/schwab_vest_pdf.py
+++ b/src/decaf/schwab_vest_pdf.py
@@ -58,6 +58,7 @@ def _parse_single_pdf(pdf_path: Path) -> dict[date, Decimal]:
# Group vest_awards by vest_date to know how many blocks per date
from itertools import groupby
+
for vest_date, group in groupby(vest_awards, key=lambda x: x[0]):
awards_in_date = list(group)
@@ -85,7 +86,7 @@ def _parse_share_transactions(text: str) -> list[tuple[date, str]]:
"""Extract (vest_date, award_id) pairs from the Share Transaction table."""
results = []
for m in re.finditer(
- r'^\s+(\d{2}/\d{2}/\d{2})\s+(\d{7})\s+(\d{9})\b',
+ r"^\s+(\d{2}/\d{2}/\d{2})\s+(\d{7})\s+(\d{9})\b",
text,
re.MULTILINE,
):
@@ -106,14 +107,14 @@ def _parse_tax_details(text: str) -> list[_TaxDetailBlock]:
for line in text.split("\n"):
# New award block: line starts with award ID (9-digit number)
- award_match = re.match(r'\s+(\d{9})\s+', line)
+ award_match = re.match(r"\s+(\d{9})\s+", line)
if award_match:
if current_raw is not None:
blocks.append(cast(_TaxDetailBlock, current_raw))
current_raw = {}
# Extract FMV and jurisdiction from this line
- fmv_match = re.search(r'\$([\d.]+)\s+(IRL(?:-1)?|ITA)\b(?!\s*Social)', line)
+ fmv_match = re.search(r"\$([\d.]+)\s+(IRL(?:-1)?|ITA)\b(?!\s*Social)", line)
if fmv_match and current_raw is not None:
jur = fmv_match.group(2)
fmv = Decimal(fmv_match.group(1))
diff --git a/src/decaf/statement_store.py b/src/decaf/statement_store.py
index 8975171..2e5c186 100644
--- a/src/decaf/statement_store.py
+++ b/src/decaf/statement_store.py
@@ -171,14 +171,19 @@ def store(self, data: ParsedData) -> None:
data.statement_from.isoformat(),
data.statement_to.isoformat(),
data.account.account_id,
- n_trades, n_pos, n_cash,
+ n_trades,
+ n_pos,
+ n_cash,
),
)
self._db.commit()
logger.info(
"Stored: %d new trades, %d new cash txns, %d positions (load %s)",
- n_trades, n_cash, n_pos, fetch_date,
+ n_trades,
+ n_cash,
+ n_pos,
+ fetch_date,
)
def load_for_year(self, tax_year: int) -> ParsedData:
@@ -275,13 +280,25 @@ def _store_trades(self, trades: list[Trade]) -> int:
" listing_exchange, acquisition_date) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(
- t.account_id, t.asset_category, t.symbol, t.isin,
- t.description, t.currency, str(t.fx_rate_to_base),
- t.trade_datetime.isoformat(), t.settle_date.isoformat(),
- t.buy_sell, str(t.quantity), str(t.trade_price),
- str(t.proceeds), str(t.cost), str(t.commission),
- t.commission_currency, str(t.broker_pnl_realized),
- t.listing_exchange, t.acquisition_date.isoformat(),
+ t.account_id,
+ t.asset_category,
+ t.symbol,
+ t.isin,
+ t.description,
+ t.currency,
+ str(t.fx_rate_to_base),
+ t.trade_datetime.isoformat(),
+ t.settle_date.isoformat(),
+ t.buy_sell,
+ str(t.quantity),
+ str(t.trade_price),
+ str(t.proceeds),
+ str(t.cost),
+ str(t.commission),
+ t.commission_currency,
+ str(t.broker_pnl_realized),
+ t.listing_exchange,
+ t.acquisition_date.isoformat(),
),
)
if self._db.execute("SELECT changes()").fetchone()[0] > 0:
@@ -301,10 +318,14 @@ def _store_cash_transactions(self, txns: list[CashTransaction]) -> int:
" date_time, settle_date, amount, description) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
(
- ct.account_id, ct.tx_type, ct.currency,
+ ct.account_id,
+ ct.tx_type,
+ ct.currency,
str(ct.fx_rate_to_base),
- ct.date_time.isoformat(), ct.settle_date.isoformat(),
- str(ct.amount), ct.description,
+ ct.date_time.isoformat(),
+ ct.settle_date.isoformat(),
+ str(ct.amount),
+ ct.description,
),
)
if self._db.execute("SELECT changes()").fetchone()[0] > 0:
@@ -319,8 +340,10 @@ def _store_conversion_rates(self, rates: list[ConversionRate]) -> None:
"INSERT OR IGNORE INTO conversion_rates VALUES (?, ?, ?, ?)",
[
(
- cr.report_date.isoformat(), cr.from_currency,
- cr.to_currency, str(cr.rate),
+ cr.report_date.isoformat(),
+ cr.from_currency,
+ cr.to_currency,
+ str(cr.rate),
)
for cr in rates
],
@@ -344,10 +367,19 @@ def _store_positions(self, positions: list[OpenPositionLot], fetch_date: str) ->
" listing_exchange) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(
- fetch_date, p.account_id, p.asset_category, p.symbol,
- p.isin, p.description, p.currency, str(p.fx_rate_to_base),
- str(p.quantity), str(p.mark_price), str(p.position_value),
- str(p.cost_basis_money), p.open_datetime.isoformat(),
+ fetch_date,
+ p.account_id,
+ p.asset_category,
+ p.symbol,
+ p.isin,
+ p.description,
+ p.currency,
+ str(p.fx_rate_to_base),
+ str(p.quantity),
+ str(p.mark_price),
+ str(p.position_value),
+ str(p.cost_basis_money),
+ p.open_datetime.isoformat(),
p.listing_exchange,
),
)
@@ -395,14 +427,23 @@ def _load_trades(self) -> list[Trade]:
).fetchall()
return [
Trade(
- account_id=r[0], asset_category=r[1], symbol=r[2], isin=r[3],
- description=r[4], currency=r[5], fx_rate_to_base=Decimal(r[6]),
+ account_id=r[0],
+ asset_category=r[1],
+ symbol=r[2],
+ isin=r[3],
+ description=r[4],
+ currency=r[5],
+ fx_rate_to_base=Decimal(r[6]),
trade_datetime=date.fromisoformat(r[7]),
settle_date=date.fromisoformat(r[8]),
- buy_sell=r[9], quantity=Decimal(r[10]),
- trade_price=Decimal(r[11]), proceeds=Decimal(r[12]),
- cost=Decimal(r[13]), commission=Decimal(r[14]),
- commission_currency=r[15], broker_pnl_realized=Decimal(r[16]),
+ buy_sell=r[9],
+ quantity=Decimal(r[10]),
+ trade_price=Decimal(r[11]),
+ proceeds=Decimal(r[12]),
+ cost=Decimal(r[13]),
+ commission=Decimal(r[14]),
+ commission_currency=r[15],
+ broker_pnl_realized=Decimal(r[16]),
listing_exchange=r[17],
acquisition_date=date.fromisoformat(r[18]),
)
@@ -419,11 +460,14 @@ def load_all_cash_transactions(self) -> list[CashTransaction]:
).fetchall()
return [
CashTransaction(
- account_id=r[0], tx_type=r[1], currency=r[2],
+ account_id=r[0],
+ tx_type=r[1],
+ currency=r[2],
fx_rate_to_base=Decimal(r[3]),
date_time=date.fromisoformat(r[4]),
settle_date=date.fromisoformat(r[5]),
- amount=Decimal(r[6]), description=r[7],
+ amount=Decimal(r[6]),
+ description=r[7],
)
for r in rows
]
@@ -437,7 +481,8 @@ def _load_conversion_rates(self) -> list[ConversionRate]:
return [
ConversionRate(
report_date=date.fromisoformat(r[0]),
- from_currency=r[1], to_currency=r[2],
+ from_currency=r[1],
+ to_currency=r[2],
rate=Decimal(r[3]),
)
for r in rows
@@ -452,8 +497,7 @@ def _load_latest_positions(self) -> list[OpenPositionLot]:
assert self._db is not None
# Get the latest fetch date per account
rows = self._db.execute(
- "SELECT account_id, MAX(fetch_date) "
- "FROM position_lots GROUP BY account_id",
+ "SELECT account_id, MAX(fetch_date) FROM position_lots GROUP BY account_id",
).fetchall()
if not rows:
return []
@@ -470,10 +514,17 @@ def _load_latest_positions(self) -> list[OpenPositionLot]:
).fetchall()
result.extend(
OpenPositionLot(
- account_id=r[0], asset_category=r[1], symbol=r[2], isin=r[3],
- description=r[4], currency=r[5], fx_rate_to_base=Decimal(r[6]),
- quantity=Decimal(r[7]), mark_price=Decimal(r[8]),
- position_value=Decimal(r[9]), cost_basis_money=Decimal(r[10]),
+ account_id=r[0],
+ asset_category=r[1],
+ symbol=r[2],
+ isin=r[3],
+ description=r[4],
+ currency=r[5],
+ fx_rate_to_base=Decimal(r[6]),
+ quantity=Decimal(r[7]),
+ mark_price=Decimal(r[8]),
+ position_value=Decimal(r[9]),
+ cost_basis_money=Decimal(r[10]),
open_datetime=date.fromisoformat(r[11]),
listing_exchange=r[12],
)
@@ -491,8 +542,7 @@ def _load_latest_cash_report(self) -> list[CashReportEntry]:
fetch_date = row[0]
rows = self._db.execute(
- "SELECT currency, starting_cash, ending_cash "
- "FROM cash_report WHERE fetch_date = ?",
+ "SELECT currency, starting_cash, ending_cash FROM cash_report WHERE fetch_date = ?",
(fetch_date,),
).fetchall()
return [
diff --git a/tests/reference/mascetti/build_schwab.py b/tests/reference/mascetti/build_schwab.py
index 875170f..5eafbbd 100644
--- a/tests/reference/mascetti/build_schwab.py
+++ b/tests/reference/mascetti/build_schwab.py
@@ -149,7 +149,9 @@ def main() -> int:
]
write_year_end_summary(
_HERE / "Year-End Summary - 2024_2025-01-24_066.PDF",
- 2024, ACCOUNT, lots=lots_2024,
+ 2024,
+ ACCOUNT,
+ lots=lots_2024,
)
# 2025 YES — Oct 15 sell of 40 drawn FIFO from 2024 leftovers:
@@ -180,7 +182,9 @@ def main() -> int:
]
write_year_end_summary(
_HERE / "Year-End Summary - 2025_2026-01-24_066.PDF",
- 2025, ACCOUNT, lots=lots_2025,
+ 2025,
+ ACCOUNT,
+ lots=lots_2025,
)
# 2024 AWH — 4 quarterly vests, original award (granted 2023-03-20)
@@ -232,7 +236,10 @@ def main() -> int:
]
write_annual_withholding(
_HERE / "Annual Withholding Statement_2024-12-31.PDF",
- 2024, HOLDER, ADDRESS, vests_2024,
+ 2024,
+ HOLDER,
+ ADDRESS,
+ vests_2024,
)
# 2025 AWH — 4 quarterly vests, new award (granted 2024-03-20)
@@ -284,7 +291,10 @@ def main() -> int:
]
write_annual_withholding(
_HERE / "Annual Withholding Statement_2025-12-31.PDF",
- 2025, HOLDER, ADDRESS, vests_2025,
+ 2025,
+ HOLDER,
+ ADDRESS,
+ vests_2025,
)
print(f"Wrote Mascetti Schwab fixture to {_HERE}")
return 0
diff --git a/tests/reference/mosconi/build_schwab.py b/tests/reference/mosconi/build_schwab.py
index 8e10e4c..0a7ddc0 100644
--- a/tests/reference/mosconi/build_schwab.py
+++ b/tests/reference/mosconi/build_schwab.py
@@ -143,7 +143,9 @@ def main() -> int:
]
write_year_end_summary(
_HERE / "Year-End Summary - 2024_2025-01-24_666.PDF",
- 2024, ACCOUNT, lots=lots_2024,
+ 2024,
+ ACCOUNT,
+ lots=lots_2024,
)
vests = [
@@ -194,7 +196,10 @@ def main() -> int:
]
write_annual_withholding(
_HERE / "Annual Withholding Statement_2024-12-31.PDF",
- 2024, HOLDER, ADDRESS, vests,
+ 2024,
+ HOLDER,
+ ADDRESS,
+ vests,
)
print(f"Wrote Mosconi Schwab fixture to {_HERE}")
return 0
diff --git a/tests/test_architecture.py b/tests/test_architecture.py
index 47eb6b7..4d252bc 100644
--- a/tests/test_architecture.py
+++ b/tests/test_architecture.py
@@ -20,7 +20,7 @@
# Files excluded from type enforcement (kept for future use, not actively maintained)
_EXCLUDED_FILES = {
- "schwab_auth.py", # Future Schwab API OAuth -- aiohttp types resolve at runtime
+ "schwab_auth.py", # Future Schwab API OAuth -- aiohttp types resolve at runtime
"schwab_client.py", # Future Schwab API client -- same
}
@@ -34,8 +34,8 @@ class _ParsedSource:
"""All .py source in SRC_DIR -- read and AST-parsed exactly once."""
def __init__(self) -> None:
- self.content: dict[str, str] = {} # relative_path -> source text
- self.trees: dict[str, ast.Module] = {} # relative_path -> parsed AST
+ self.content: dict[str, str] = {} # relative_path -> source text
+ self.trees: dict[str, ast.Module] = {} # relative_path -> parsed AST
for path in sorted(SRC_DIR.rglob("*.py")):
if "__pycache__" in path.parts:
@@ -128,9 +128,8 @@ def test_no_any_in_annotations(self) -> None:
if "'Any'" in ann_dump:
violations.append(f"{filename}:{lineno}")
- assert not violations, (
- "Any used in type annotation -- use concrete types:\n"
- + "\n".join(f" {v}" for v in violations)
+ assert not violations, "Any used in type annotation -- use concrete types:\n" + "\n".join(
+ f" {v}" for v in violations
)
@@ -162,9 +161,8 @@ def test_no_bare_list_annotation(self) -> None:
if "Name(id='list')" in ann_dump and "Subscript" not in ann_dump:
violations.append(f"{filename}:{lineno}")
- assert not violations, (
- "Bare 'list' in annotation -- use list[T]:\n"
- + "\n".join(f" {v}" for v in violations)
+ assert not violations, "Bare 'list' in annotation -- use list[T]:\n" + "\n".join(
+ f" {v}" for v in violations
)
def test_no_bare_tuple_annotation(self) -> None:
@@ -176,9 +174,8 @@ def test_no_bare_tuple_annotation(self) -> None:
if "Name(id='tuple')" in ann_dump and "Subscript" not in ann_dump:
violations.append(f"{filename}:{lineno}")
- assert not violations, (
- "Bare 'tuple' in annotation -- use tuple[T, ...]:\n"
- + "\n".join(f" {v}" for v in violations)
+ assert not violations, "Bare 'tuple' in annotation -- use tuple[T, ...]:\n" + "\n".join(
+ f" {v}" for v in violations
)
def test_no_bare_set_annotation(self) -> None:
@@ -190,9 +187,8 @@ def test_no_bare_set_annotation(self) -> None:
if "Name(id='set')" in ann_dump and "Subscript" not in ann_dump:
violations.append(f"{filename}:{lineno}")
- assert not violations, (
- "Bare 'set' in annotation -- use set[T]:\n"
- + "\n".join(f" {v}" for v in violations)
+ assert not violations, "Bare 'set' in annotation -- use set[T]:\n" + "\n".join(
+ f" {v}" for v in violations
)
@@ -221,13 +217,11 @@ def test_no_object_param_type(self) -> None:
continue
if arg.annotation and ast.dump(arg.annotation) == "Name(id='object')":
violations.append(
- f"{filename}:{arg.annotation.lineno}: "
- f"{node.name}({arg.arg}: object)"
+ f"{filename}:{arg.annotation.lineno}: {node.name}({arg.arg}: object)"
)
- assert not violations, (
- "Parameter typed as 'object' -- use a concrete type:\n"
- + "\n".join(f" {v}" for v in violations)
+ assert not violations, "Parameter typed as 'object' -- use a concrete type:\n" + "\n".join(
+ f" {v}" for v in violations
)
@@ -241,9 +235,17 @@ def test_sum_over_decimal_has_start(self) -> None:
violations = []
# Known Decimal attribute patterns from our models
decimal_attrs = {
- "ivafe_due", "gain_loss_eur", "gross_amount_eur", "wht_amount_eur",
- "final_value_eur", "initial_value_eur", "proceeds_eur", "cost_basis_eur",
- "quantity", "amount", "remaining",
+ "ivafe_due",
+ "gain_loss_eur",
+ "gross_amount_eur",
+ "wht_amount_eur",
+ "final_value_eur",
+ "initial_value_eur",
+ "proceeds_eur",
+ "cost_basis_eur",
+ "quantity",
+ "amount",
+ "remaining",
}
for filename, tree in _parsed_src.trees.items():
@@ -263,9 +265,7 @@ def test_sum_over_decimal_has_start(self) -> None:
# Must have 2 args: sum(generator, start_value)
if len(node.args) < 2:
- violations.append(
- f"{filename}:{node.lineno}: sum() without Decimal(0) start"
- )
+ violations.append(f"{filename}:{node.lineno}: sum() without Decimal(0) start")
assert not violations, (
"sum() over Decimal fields without start value -- "
@@ -320,9 +320,9 @@ class TestNoFloat:
# ONLY files that must pass floats to external serialization APIs
_FLOAT_ALLOWED_FILES: ClassVar[set[str]] = {
- "output_xls.py", # openpyxl cell values must be float
- "output_pdf.py", # fpdf2 values must be float
- "output_json.py", # json.JSONEncoder.default() can't serialize Decimal
+ "output_xls.py", # openpyxl cell values must be float
+ "output_pdf.py", # fpdf2 values must be float
+ "output_json.py", # json.JSONEncoder.default() can't serialize Decimal
}
def test_no_float_on_decimal_in_computation(self) -> None:
@@ -371,17 +371,14 @@ def test_all_functions_have_return_type(self) -> None:
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
continue
# Skip property getters (return type inferred)
- decorators = [
- ast.dump(d) for d in node.decorator_list
- ]
+ decorators = [ast.dump(d) for d in node.decorator_list]
if any("property" in d for d in decorators):
continue
if node.returns is None:
violations.append(f"{filename}:{node.lineno}: {node.name}()")
- assert not violations, (
- "Function missing return type annotation:\n"
- + "\n".join(f" {v}" for v in violations)
+ assert not violations, "Function missing return type annotation:\n" + "\n".join(
+ f" {v}" for v in violations
)
def test_all_params_have_type(self) -> None:
@@ -400,11 +397,9 @@ def test_all_params_have_type(self) -> None:
continue
if arg.annotation is None:
violations.append(
- f"{filename}:{node.lineno}: "
- f"{node.name}({arg.arg}) missing type"
+ f"{filename}:{node.lineno}: {node.name}({arg.arg}) missing type"
)
- assert not violations, (
- "Function parameter missing type annotation:\n"
- + "\n".join(f" {v}" for v in violations)
+ assert not violations, "Function parameter missing type annotation:\n" + "\n".join(
+ f" {v}" for v in violations
)
diff --git a/tests/test_e2e.py b/tests/test_e2e.py
index b66caf4..30c10f6 100644
--- a/tests/test_e2e.py
+++ b/tests/test_e2e.py
@@ -83,7 +83,10 @@ async def _get_report(fixture: str, year: int) -> dict:
prices = _load_prices(fixture_dir)
try:
report, _data = await _load_and_build_report(
- db, _ECB_DB, year, price_overrides=prices,
+ db,
+ _ECB_DB,
+ year,
+ price_overrides=prices,
)
finally:
db.unlink(missing_ok=True)
diff --git a/tests/test_forex.py b/tests/test_forex.py
index bca4602..15b4443 100644
--- a/tests/test_forex.py
+++ b/tests/test_forex.py
@@ -10,9 +10,13 @@
def _usd_deposit(settle: str, amount: str) -> CashTransaction:
return CashTransaction(
- account_id="U1", tx_type="Deposits/Withdrawals", currency="USD",
- fx_rate_to_base=Decimal("0.92"), date_time=date.fromisoformat(settle),
- settle_date=date.fromisoformat(settle), amount=Decimal(amount),
+ account_id="U1",
+ tx_type="Deposits/Withdrawals",
+ currency="USD",
+ fx_rate_to_base=Decimal("0.92"),
+ date_time=date.fromisoformat(settle),
+ settle_date=date.fromisoformat(settle),
+ amount=Decimal(amount),
description="DEPOSIT",
)
@@ -116,16 +120,25 @@ class TestRsuVestExclusion:
def test_rsu_vest_does_not_affect_balance(self) -> None:
"""RSU vests (shares granted, no cash) must not change USD balance."""
vest = Trade(
- account_id="XXX666", asset_category="STK", symbol="MOSC",
- isin="US0000000010", description="Stock Plan Activity",
- currency="USD", fx_rate_to_base=Decimal(0),
- trade_datetime=date(2025, 5, 15), settle_date=date(2025, 5, 16),
- buy_sell="BUY", quantity=Decimal("10"),
+ account_id="XXX666",
+ asset_category="STK",
+ symbol="MOSC",
+ isin="US0000000010",
+ description="Stock Plan Activity",
+ currency="USD",
+ fx_rate_to_base=Decimal(0),
+ trade_datetime=date(2025, 5, 15),
+ settle_date=date(2025, 5, 16),
+ buy_sell="BUY",
+ quantity=Decimal("10"),
trade_price=Decimal("500"),
- proceeds=Decimal("-5000"), cost=Decimal("-5000"),
- commission=Decimal(0), commission_currency="USD",
+ proceeds=Decimal("-5000"),
+ cost=Decimal("-5000"),
+ commission=Decimal(0),
+ commission_currency="USD",
broker_pnl_realized=Decimal(0),
- listing_exchange="", acquisition_date=date(2025, 3, 3),
+ listing_exchange="",
+ acquisition_date=date(2025, 3, 3),
)
result = analyze_forex_threshold([vest], [], _fx_service(), 2025)
# Vest should not create any USD balance
@@ -135,16 +148,25 @@ def test_rsu_vest_does_not_affect_balance(self) -> None:
def test_real_stock_buy_does_affect_balance(self) -> None:
"""A real market buy (with commission) DOES decrease USD cash."""
buy = Trade(
- account_id="U1", asset_category="STK", symbol="LLY",
- isin="US5324571083", description="BUY LLY",
- currency="USD", fx_rate_to_base=Decimal("0.92"),
- trade_datetime=date(2025, 5, 15), settle_date=date(2025, 5, 16),
- buy_sell="BUY", quantity=Decimal("10"),
+ account_id="U1",
+ asset_category="STK",
+ symbol="LLY",
+ isin="US5324571083",
+ description="BUY LLY",
+ currency="USD",
+ fx_rate_to_base=Decimal("0.92"),
+ trade_datetime=date(2025, 5, 15),
+ settle_date=date(2025, 5, 16),
+ buy_sell="BUY",
+ quantity=Decimal("10"),
trade_price=Decimal("500"),
- proceeds=Decimal("-5000"), cost=Decimal("-5005"),
- commission=Decimal("-5"), commission_currency="USD",
+ proceeds=Decimal("-5000"),
+ cost=Decimal("-5005"),
+ commission=Decimal("-5"),
+ commission_currency="USD",
broker_pnl_realized=Decimal(0),
- listing_exchange="", acquisition_date=date(2025, 3, 3),
+ listing_exchange="",
+ acquisition_date=date(2025, 3, 3),
)
# First deposit USD, then buy stock
txns = [_usd_deposit("2025-01-02", "10000")]
diff --git a/tests/test_forex_gains.py b/tests/test_forex_gains.py
index 1941bd8..238462a 100644
--- a/tests/test_forex_gains.py
+++ b/tests/test_forex_gains.py
@@ -27,19 +27,31 @@ def _fx_service(rates: dict[date, Decimal] | None = None) -> FxService:
return FxService([], ecb)
-def _stock_sell(settle: str, proceeds: str, symbol: str = "META",
- commission: str = "0", account: str = "U1") -> Trade:
+def _stock_sell(
+ settle: str, proceeds: str, symbol: str = "META", commission: str = "0", account: str = "U1"
+) -> Trade:
"""USD stock sell — acquires USD."""
d = date.fromisoformat(settle)
return Trade(
- account_id=account, asset_category="STK", symbol=symbol, isin="",
- description=f"SELL {symbol}", currency="USD",
- fx_rate_to_base=Decimal(0), trade_datetime=d, settle_date=d,
- buy_sell="SELL", quantity=Decimal("-10"),
- trade_price=Decimal("100"), proceeds=Decimal(proceeds),
- cost=Decimal("-500"), commission=Decimal(commission),
- commission_currency="USD", broker_pnl_realized=Decimal("500"),
- listing_exchange="", acquisition_date=date(2025, 3, 3),
+ account_id=account,
+ asset_category="STK",
+ symbol=symbol,
+ isin="",
+ description=f"SELL {symbol}",
+ currency="USD",
+ fx_rate_to_base=Decimal(0),
+ trade_datetime=d,
+ settle_date=d,
+ buy_sell="SELL",
+ quantity=Decimal("-10"),
+ trade_price=Decimal("100"),
+ proceeds=Decimal(proceeds),
+ cost=Decimal("-500"),
+ commission=Decimal(commission),
+ commission_currency="USD",
+ broker_pnl_realized=Decimal("500"),
+ listing_exchange="",
+ acquisition_date=date(2025, 3, 3),
)
@@ -47,24 +59,39 @@ def _forex_buy_eur(settle: str, usd_amount: str, account: str = "U1") -> Trade:
"""BUY EUR.USD — disposes USD (proceeds negative)."""
d = date.fromisoformat(settle)
return Trade(
- account_id=account, asset_category="CASH", symbol="EUR.USD", isin="",
- description="EUR.USD", currency="USD",
- fx_rate_to_base=Decimal(0), trade_datetime=d, settle_date=d,
- buy_sell="BUY", quantity=Decimal("1000"),
+ account_id=account,
+ asset_category="CASH",
+ symbol="EUR.USD",
+ isin="",
+ description="EUR.USD",
+ currency="USD",
+ fx_rate_to_base=Decimal(0),
+ trade_datetime=d,
+ settle_date=d,
+ buy_sell="BUY",
+ quantity=Decimal("1000"),
trade_price=Decimal("1.10"),
proceeds=Decimal(f"-{usd_amount}"),
- cost=Decimal(0), commission=Decimal(0),
- commission_currency="USD", broker_pnl_realized=Decimal(0),
- listing_exchange="", acquisition_date=date(2025, 3, 3),
+ cost=Decimal(0),
+ commission=Decimal(0),
+ commission_currency="USD",
+ broker_pnl_realized=Decimal(0),
+ listing_exchange="",
+ acquisition_date=date(2025, 3, 3),
)
def _dividend(settle: str, amount: str, account: str = "U1") -> CashTransaction:
d = date.fromisoformat(settle)
return CashTransaction(
- account_id=account, tx_type="Dividends", currency="USD",
- fx_rate_to_base=Decimal(0), date_time=d, settle_date=d,
- amount=Decimal(amount), description="DIVIDEND",
+ account_id=account,
+ tx_type="Dividends",
+ currency="USD",
+ fx_rate_to_base=Decimal(0),
+ date_time=d,
+ settle_date=d,
+ amount=Decimal(amount),
+ description="DIVIDEND",
)
@@ -72,9 +99,14 @@ def _wire_sent(settle: str, amount: str, account: str = "U1") -> CashTransaction
"""Wire transfer out (negative amount)."""
d = date.fromisoformat(settle)
return CashTransaction(
- account_id=account, tx_type="Wire Sent", currency="USD",
- fx_rate_to_base=Decimal(0), date_time=d, settle_date=d,
- amount=Decimal(amount), description="WIRE OUT",
+ account_id=account,
+ tx_type="Wire Sent",
+ currency="USD",
+ fx_rate_to_base=Decimal(0),
+ date_time=d,
+ settle_date=d,
+ amount=Decimal(amount),
+ description="WIRE OUT",
)
@@ -143,13 +175,13 @@ def test_newest_lot_consumed_first(self) -> None:
le valute ... acquisite in data piu' recente'.
"""
rates = {
- date(2025, 1, 2): Decimal("1.10"), # first lot (older)
- date(2025, 2, 3): Decimal("1.05"), # second lot (newer)
- date(2025, 6, 2): Decimal("1.08"), # disposal
+ date(2025, 1, 2): Decimal("1.10"), # first lot (older)
+ date(2025, 2, 3): Decimal("1.05"), # second lot (newer)
+ date(2025, 6, 2): Decimal("1.08"), # disposal
}
trades = [
- _stock_sell("2025-01-02", "500"), # older lot
- _stock_sell("2025-02-03", "500"), # newer lot
+ _stock_sell("2025-01-02", "500"), # older lot
+ _stock_sell("2025-02-03", "500"), # newer lot
_forex_buy_eur("2025-06-02", "500"), # disposal
]
gains = compute_forex_gains(trades, [], _fx_service(rates), 2025)
@@ -298,8 +330,8 @@ def test_prior_year_disposal_not_reported(self) -> None:
}
trades = [
_stock_sell("2024-01-02", "5000"),
- _forex_buy_eur("2024-06-03", "2000"), # 2024 disposal
- _forex_buy_eur("2025-03-03", "1000"), # 2025 disposal
+ _forex_buy_eur("2024-06-03", "2000"), # 2024 disposal
+ _forex_buy_eur("2025-03-03", "1000"), # 2025 disposal
]
gains = compute_forex_gains(trades, [], _fx_service(rates), 2025)
@@ -340,6 +372,7 @@ def test_acquisitions_do_not_cross_accounts(self) -> None:
def test_two_accounts_matched_independently(self, caplog) -> None:
"""Each account runs LIFO over its own lots only."""
import logging
+
rates = {
date(2025, 1, 2): Decimal("1.10"),
date(2025, 2, 3): Decimal("1.05"),
@@ -371,9 +404,9 @@ def test_two_accounts_matched_independently(self, caplog) -> None:
def test_lifo_applied_per_account(self) -> None:
"""LIFO ordering is scoped to each account separately."""
rates = {
- date(2025, 1, 2): Decimal("1.10"), # IBKR older
- date(2025, 2, 3): Decimal("1.05"), # IBKR newer
- date(2025, 3, 3): Decimal("1.08"), # SCHWAB only
+ date(2025, 1, 2): Decimal("1.10"), # IBKR older
+ date(2025, 2, 3): Decimal("1.05"), # IBKR newer
+ date(2025, 3, 3): Decimal("1.08"), # SCHWAB only
date(2025, 6, 2): Decimal("1.07"),
}
trades = [
@@ -410,6 +443,7 @@ def test_acquisitions_only_no_gains(self) -> None:
def test_lifo_exhausted_logs_warning(self, caplog) -> None:
"""Disposing more than acquired should warn, not crash."""
import logging
+
rates = {
date(2025, 1, 2): Decimal("1.10"),
date(2025, 6, 2): Decimal("1.08"),
@@ -441,9 +475,13 @@ def test_same_day_acquisition_before_disposal(self) -> None:
def test_eur_cash_transactions_ignored(self) -> None:
"""EUR-denominated cash transactions should not create USD lots."""
eur_dividend = CashTransaction(
- account_id="U1", tx_type="Dividends", currency="EUR",
- fx_rate_to_base=Decimal("1"), date_time=date(2025, 3, 3),
- settle_date=date(2025, 3, 3), amount=Decimal("500"),
+ account_id="U1",
+ tx_type="Dividends",
+ currency="EUR",
+ fx_rate_to_base=Decimal("1"),
+ date_time=date(2025, 3, 3),
+ settle_date=date(2025, 3, 3),
+ amount=Decimal("500"),
description="EUR DIVIDEND",
)
gains = compute_forex_gains([], [eur_dividend], _fx_service(), 2025)
@@ -452,15 +490,25 @@ def test_eur_cash_transactions_ignored(self) -> None:
def test_stock_buy_not_acquisition(self) -> None:
"""Buying USD stock is NOT a forex acquisition (just exchanging USD for stock)."""
buy = Trade(
- account_id="U1", asset_category="STK", symbol="META", isin="",
- description="BUY META", currency="USD",
- fx_rate_to_base=Decimal(0), trade_datetime=date(2025, 3, 3),
- settle_date=date(2025, 3, 3), buy_sell="BUY",
- quantity=Decimal("10"), trade_price=Decimal("500"),
- proceeds=Decimal("-5000"), cost=Decimal("-5000"),
- commission=Decimal("-5"), commission_currency="USD",
+ account_id="U1",
+ asset_category="STK",
+ symbol="META",
+ isin="",
+ description="BUY META",
+ currency="USD",
+ fx_rate_to_base=Decimal(0),
+ trade_datetime=date(2025, 3, 3),
+ settle_date=date(2025, 3, 3),
+ buy_sell="BUY",
+ quantity=Decimal("10"),
+ trade_price=Decimal("500"),
+ proceeds=Decimal("-5000"),
+ cost=Decimal("-5000"),
+ commission=Decimal("-5"),
+ commission_currency="USD",
broker_pnl_realized=Decimal(0),
- listing_exchange="", acquisition_date=date(2025, 3, 3),
+ listing_exchange="",
+ acquisition_date=date(2025, 3, 3),
)
gains = compute_forex_gains([buy], [], _fx_service(), 2025)
assert gains == []
@@ -487,7 +535,7 @@ def test_exact_gain_calculation(self) -> None:
assert len(gains) == 1
# 10000 × (1/1.05 - 1/1.10) = 10000 × (0.952381 - 0.909091)
# = 10000 × 0.043290 = 432.90
- expected = (Decimal("10000") / Decimal("1.05") - Decimal("10000") / Decimal("1.10"))
+ expected = Decimal("10000") / Decimal("1.05") - Decimal("10000") / Decimal("1.10")
assert gains[0].gain_eur == expected.quantize(Decimal("0.01"))
def test_commission_reduces_acquisition(self) -> None:
@@ -513,15 +561,22 @@ def test_commission_reduces_acquisition(self) -> None:
def _wire_received(
- settle: str, amount: str, account: str = "U2",
+ settle: str,
+ amount: str,
+ account: str = "U2",
tx_type: str = "Deposits/Withdrawals",
) -> CashTransaction:
"""Wire transfer in (positive amount) — Deposits/Withdrawals positive."""
d = date.fromisoformat(settle)
return CashTransaction(
- account_id=account, tx_type=tx_type, currency="USD",
- fx_rate_to_base=Decimal(0), date_time=d, settle_date=d,
- amount=Decimal(amount), description="WIRE IN",
+ account_id=account,
+ tx_type=tx_type,
+ currency="USD",
+ fx_rate_to_base=Decimal(0),
+ date_time=d,
+ settle_date=d,
+ amount=Decimal(amount),
+ description="WIRE IN",
)
@@ -548,9 +603,7 @@ def test_exact_same_day_same_amount_pair_is_neutral(self) -> None:
# Neutro: no ForexGainEntry for the transfer day.
same_day = [e for e in entries if e.disposal_date == date(2025, 6, 10)]
- assert not same_day, (
- f"Giroconto should not produce a forex gain entry; got: {entries}"
- )
+ assert not same_day, f"Giroconto should not produce a forex gain entry; got: {entries}"
def test_tolerates_3_business_day_gap(self) -> None:
"""Wire-out 2025-06-10 at Schwab, wire-in 2025-06-12 at IBKR (2 biz
@@ -568,13 +621,8 @@ def test_tolerates_3_business_day_gap(self) -> None:
entries = compute_forex_gains([], cash_txns, _fx_service(rates), 2025)
- window = [
- e for e in entries
- if date(2025, 6, 9) <= e.disposal_date <= date(2025, 6, 13)
- ]
- assert not window, (
- f"3-biz-day tolerance should match; got: {entries}"
- )
+ window = [e for e in entries if date(2025, 6, 9) <= e.disposal_date <= date(2025, 6, 13)]
+ assert not window, f"3-biz-day tolerance should match; got: {entries}"
def test_ambiguous_match_falls_back_and_warns(self, caplog) -> None:
"""Two positive wire-ins same day, same amount, different accounts
@@ -595,22 +643,20 @@ def test_ambiguous_match_falls_back_and_warns(self, caplog) -> None:
with caplog.at_level(logging.WARNING, logger="decaf.forex_gains"):
entries = compute_forex_gains(
- [], cash_txns, _fx_service(rates), 2025,
+ [],
+ cash_txns,
+ _fx_service(rates),
+ 2025,
)
# Fallback: wire-out treated as disposal → entry on 2025-06-10.
same_day = [e for e in entries if e.disposal_date == date(2025, 6, 10)]
- assert same_day, (
- "Ambiguous match should fall back to disposal behavior"
- )
+ assert same_day, "Ambiguous match should fall back to disposal behavior"
# Warning logged.
assert any(
"ambiguous" in r.message.lower() or "giroconto" in r.message.lower()
for r in caplog.records
- ), (
- f"Expected ambiguity warning; got logs: "
- f"{[r.message for r in caplog.records]}"
- )
+ ), f"Expected ambiguity warning; got logs: {[r.message for r in caplog.records]}"
def test_transferred_lots_preserve_original_acquisition(self) -> None:
"""Acquisition at A on D1 at rate R1. Transfer A→B on D2. Later
@@ -631,14 +677,11 @@ def test_transferred_lots_preserve_original_acquisition(self) -> None:
entries = compute_forex_gains([], cash_txns, _fx_service(rates), 2025)
disp = [e for e in entries if e.disposal_date == date(2025, 9, 1)]
- assert len(disp) == 1, (
- f"Expected 1 disposal on 2025-09-01; got: {entries}"
- )
+ assert len(disp) == 1, f"Expected 1 disposal on 2025-09-01; got: {entries}"
assert disp[0].acquisition_date == date(2025, 1, 15), (
f"Transferred lot must keep original acquisition date "
f"(2025-01-15), got {disp[0].acquisition_date}."
)
assert disp[0].ecb_rate_acquisition == Decimal("1.10"), (
- f"Transferred lot must keep original ECB rate 1.10, "
- f"got {disp[0].ecb_rate_acquisition}."
+ f"Transferred lot must keep original ECB rate 1.10, got {disp[0].ecb_rate_acquisition}."
)
diff --git a/tests/test_holidays.py b/tests/test_holidays.py
index 7712b0e..7269d99 100644
--- a/tests/test_holidays.py
+++ b/tests/test_holidays.py
@@ -27,21 +27,21 @@ def test_2023(self) -> None:
class TestItalianHolidays:
def test_2025_fixed_holidays(self) -> None:
holidays = italian_holidays(2025)
- assert date(2025, 1, 1) in holidays # Capodanno
- assert date(2025, 1, 6) in holidays # Epifania
- assert date(2025, 4, 25) in holidays # Liberazione
- assert date(2025, 5, 1) in holidays # Lavoro
- assert date(2025, 6, 2) in holidays # Repubblica
- assert date(2025, 8, 15) in holidays # Ferragosto
- assert date(2025, 11, 1) in holidays # Tutti i Santi
- assert date(2025, 12, 8) in holidays # Immacolata
+ assert date(2025, 1, 1) in holidays # Capodanno
+ assert date(2025, 1, 6) in holidays # Epifania
+ assert date(2025, 4, 25) in holidays # Liberazione
+ assert date(2025, 5, 1) in holidays # Lavoro
+ assert date(2025, 6, 2) in holidays # Repubblica
+ assert date(2025, 8, 15) in holidays # Ferragosto
+ assert date(2025, 11, 1) in holidays # Tutti i Santi
+ assert date(2025, 12, 8) in holidays # Immacolata
assert date(2025, 12, 25) in holidays # Natale
assert date(2025, 12, 26) in holidays # Santo Stefano
def test_2025_easter(self) -> None:
holidays = italian_holidays(2025)
- assert date(2025, 4, 20) in holidays # Easter Sunday
- assert date(2025, 4, 21) in holidays # Easter Monday
+ assert date(2025, 4, 20) in holidays # Easter Sunday
+ assert date(2025, 4, 21) in holidays # Easter Monday
def test_count(self) -> None:
assert len(italian_holidays(2025)) == 12
diff --git a/tests/test_parse.py b/tests/test_parse.py
index c38dde3..e204cbe 100644
--- a/tests/test_parse.py
+++ b/tests/test_parse.py
@@ -29,10 +29,10 @@ def _wrap_statement(
f''
- f'{body}'
- f''
- f''
- f''
+ f"{body}"
+ f""
+ f""
+ f""
)
@@ -57,7 +57,7 @@ def test_statement_dates(self) -> None:
class TestParseTrades:
TRADE_BUY = (
- ''
+ ""
''
- ''
+ ""
)
TRADE_SELL = (
- ''
+ ""
''
- ''
+ ""
)
TRADE_FOREX = (
- ''
+ ""
''
- ''
+ ""
)
def test_buy_trade(self) -> None:
@@ -146,7 +146,7 @@ def test_forex_trade(self) -> None:
def test_filters_by_year(self) -> None:
# Trade in 2026 — cash_transactions filter, but trades list keeps all
trade_2026 = (
- ''
+ ""
' None:
'buySell="BUY" quantity="10" tradePrice="150" '
'proceeds="-1500" cost="1501" ibCommission="-1" '
'ibCommissionCurrency="EUR" fifoPnlRealized="0" />'
- ''
+ ""
)
data = parse_statement(_wrap_statement(trade_2026), 2025)
# All trades are kept (for FIFO context), even outside tax year
@@ -174,7 +174,7 @@ def test_sell_with_closed_lots_emits_one_trade_per_lot(self) -> None:
an empty proceeds attribute; proceeds is pro-rata from parent.
"""
sell_with_lots = (
- ''
+ ""
' None:
'proceeds="" cost="10200" ibCommission="" '
'ibCommissionCurrency="" fifoPnlRealized="1799.00" '
'openDateTime="20240515;100000" />'
- ''
+ ""
)
data = parse_statement(_wrap_statement(sell_with_lots), 2025)
sells = [t for t in data.trades if t.is_sell]
@@ -238,7 +238,7 @@ def test_sell_without_closed_lots_raises(self) -> None:
acquisition_date = trade_datetime (art. 9 c. 2 non-compliant).
"""
sell_no_lots = (
- ''
+ ""
' None:
'buySell="SELL" quantity="-30" tradePrice="600" '
'proceeds="18000" cost="-15000" ibCommission="0" '
'ibCommissionCurrency="USD" fifoPnlRealized="3000" />'
- ''
+ ""
)
with pytest.raises(ValueError, match="Closed Lots"):
parse_statement(_wrap_statement(sell_no_lots), 2025)
@@ -254,13 +254,13 @@ def test_sell_without_closed_lots_raises(self) -> None:
class TestParsePositions:
POSITION_LOT = (
- ''
+ ""
''
- ''
+ ""
)
def test_lot_fields(self) -> None:
@@ -279,7 +279,7 @@ def test_lot_fields(self) -> None:
def test_multiple_lots(self) -> None:
xml = _wrap_statement(
- ''
+ ""
' None:
'currency="EUR" fxRateToBase="1" '
'position="50" markPrice="75" positionValue="3750" '
'costBasisMoney="3700" openDateTime="20250901;100000" />'
- ''
+ ""
)
data = parse_statement(xml, 2025)
assert len(data.positions) == 2
@@ -300,7 +300,7 @@ def test_multiple_lots(self) -> None:
class TestParseCashTransactions:
INTEREST_AND_WHT = (
- ''
+ ""
''
- ''
+ ""
)
def test_filters_to_tax_year(self) -> None:
@@ -337,14 +337,14 @@ def test_wht_fields(self) -> None:
class TestParseCashReport:
CASH_REPORT = (
- ''
+ ""
''
''
''
- ''
+ ""
)
def test_skips_base_summary(self) -> None:
@@ -363,12 +363,12 @@ def test_per_currency_entries(self) -> None:
class TestParseConversionRates:
RATES = (
- ''
+ ""
''
''
- ''
+ ""
)
def test_parses_rates(self) -> None:
@@ -389,7 +389,7 @@ def test_no_account_info_raises(self) -> None:
xml = (
''
''
- ''
+ ""
)
with pytest.raises(ValueError, match="No AccountInformation"):
parse_statement(xml, 2025)
diff --git a/tests/test_prices.py b/tests/test_prices.py
index d633c0c..9538d91 100644
--- a/tests/test_prices.py
+++ b/tests/test_prices.py
@@ -53,14 +53,14 @@ def test_us_exchanges_have_no_suffix(self) -> None:
def test_european_exchanges_have_suffix(self) -> None:
expected = {
- "LSEETF": ".L", "LSE": ".L",
- "IBIS": ".DE", "IBIS2": ".DE",
+ "LSEETF": ".L",
+ "LSE": ".L",
+ "IBIS": ".DE",
+ "IBIS2": ".DE",
"AEB": ".AS",
"SBF": ".PA",
"BVME": ".MI",
"EBS": ".SW",
}
for exchange, suffix in expected.items():
- assert EXCHANGE_TO_YF[exchange] == suffix, (
- f"{exchange} should map to {suffix}"
- )
+ assert EXCHANGE_TO_YF[exchange] == suffix, f"{exchange} should map to {suffix}"
diff --git a/tests/test_quadro_rt.py b/tests/test_quadro_rt.py
index 08a4565..00952db 100644
--- a/tests/test_quadro_rt.py
+++ b/tests/test_quadro_rt.py
@@ -79,15 +79,9 @@ def test_per_lot_ecb_conversion_uses_acquisition_rate(self) -> None:
lines = compute_rt([trade], _fx(rates), 2025)
assert len(lines) == 1
line = lines[0]
- assert line.cost_basis_eur == Decimal("462.96"), (
- f"cost: {line.cost_basis_eur}"
- )
- assert line.proceeds_eur == Decimal("535.71"), (
- f"proceeds: {line.proceeds_eur}"
- )
- assert line.gain_loss_eur == Decimal("72.75"), (
- f"gain: {line.gain_loss_eur}"
- )
+ assert line.cost_basis_eur == Decimal("462.96"), f"cost: {line.cost_basis_eur}"
+ assert line.proceeds_eur == Decimal("535.71"), f"proceeds: {line.proceeds_eur}"
+ assert line.gain_loss_eur == Decimal("72.75"), f"gain: {line.gain_loss_eur}"
def test_same_day_acquisition_and_sell_matches_single_rate(self) -> None:
"""When acquisition_date == settle_date and ECB rate is the same,
diff --git a/tests/test_statement_store.py b/tests/test_statement_store.py
index 49fef95..70ea8b3 100644
--- a/tests/test_statement_store.py
+++ b/tests/test_statement_store.py
@@ -68,7 +68,8 @@ def _make_trade(
commission=Decimal("-1.50"),
commission_currency="EUR",
broker_pnl_realized=Decimal("0"),
- listing_exchange="IBIS2", acquisition_date=date.fromisoformat("2025-08-01"),
+ listing_exchange="IBIS2",
+ acquisition_date=date.fromisoformat("2025-08-01"),
)
@@ -124,10 +125,14 @@ def _make_parsed_data(
cash_transactions=cash_txns or [],
cash_report=[
CashReportEntry(
- currency="EUR", starting_cash=Decimal("1000"), ending_cash=Decimal("500"),
+ currency="EUR",
+ starting_cash=Decimal("1000"),
+ ending_cash=Decimal("500"),
),
CashReportEntry(
- currency="USD", starting_cash=Decimal("0"), ending_cash=Decimal("200"),
+ currency="USD",
+ starting_cash=Decimal("0"),
+ ending_cash=Decimal("200"),
),
],
conversion_rates=[
@@ -290,14 +295,18 @@ def test_multiple_accounts_stored(self, store: StatementStore):
acct1 = _make_account("U11111111")
acct2 = _make_account("U22222222")
- store.store(_make_parsed_data(
- account=acct1,
- trades=[_make_trade(account_id="U11111111")],
- ))
- store.store(_make_parsed_data(
- account=acct2,
- trades=[_make_trade(symbol="META", account_id="U22222222")],
- ))
+ store.store(
+ _make_parsed_data(
+ account=acct1,
+ trades=[_make_trade(account_id="U11111111")],
+ )
+ )
+ store.store(
+ _make_parsed_data(
+ account=acct2,
+ trades=[_make_trade(symbol="META", account_id="U22222222")],
+ )
+ )
loaded = store.load_for_year(2025)
assert len(loaded.trades) == 2