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 +[![CI](https://github.com/vjt/decaf/actions/workflows/ci.yml/badge.svg)](https://github.com/vjt/decaf/actions/workflows/ci.yml) +[![codecov](https://codecov.io/gh/vjt/decaf/branch/master/graph/badge.svg)](https://codecov.io/gh/vjt/decaf) +[![GitHub Release](https://img.shields.io/github/v/release/vjt/decaf?include_prereleases&sort=semver)](https://github.com/vjt/decaf/releases) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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