Complete Phase 3, Phase 5 polish and hardening

Phase 3 — Investments:
- Multi-currency support: holdings track purchase currency, FX rates convert to base for totals
- Capital gains report using UK Section 104 pool method, grouped by tax year
- Capital Gains tab added to Reports page

Phase 5 — Polish & Hardening:
- Mobile-responsive layout: bottom nav, sidebar hidden on mobile, logo in TopBar, compact header buttons, hover-only actions now always visible on touch
- Backup system: encrypted GPG backups via backup.sh, nightly scheduler job, admin API (list/trigger/download/restore), Settings UI with drag-to-restore confirmation
- Docker entrypoint with gosu privilege drop to fix bind-mount ownership on fresh deployments
- OWASP fixes: refresh token now bound to its session (new refresh_token_hash column + migration), CSRF secure flag tied to environment, IP-level rate limiting on login, TOTPEnableRequest Pydantic schema replaces raw dict
- AES-256-GCM key rotation script (rotate_keys.py) with dry-run mode and atomic DB transaction
- CLAUDE.md added for AI-assisted development context
- README updated: correct reverse proxy port, accurate backup/restore commands, key rotation instructions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
megaproxy 2026-04-22 14:59:11 +00:00
parent 74e57a35c0
commit fe4e69b9ad
40 changed files with 2079 additions and 127 deletions

View file

@ -10,11 +10,14 @@ from app.db.models.asset_price import AssetPrice
from app.db.models.investment_holding import InvestmentHolding
from app.db.models.investment_transaction import InvestmentTransaction
from app.schemas.investment import (
CapitalGainsDisposal,
CapitalGainsReport,
HoldingCreate,
HoldingResponse,
InvestmentTxnCreate,
PerformanceMetrics,
PortfolioSummary,
TaxYearSummary,
)
@ -23,10 +26,37 @@ async def _get_asset(db: AsyncSession, asset_id: uuid.UUID) -> Asset | None:
return result.scalar_one_or_none()
def _holding_to_response(holding: InvestmentHolding, asset: Asset) -> HoldingResponse:
async def _fetch_fx_rate(db: AsyncSession, from_currency: str, to_currency: str) -> Decimal:
if from_currency == to_currency:
return Decimal("1")
from app.db.models.currency import ExchangeRate
result = await db.execute(
select(ExchangeRate)
.where(ExchangeRate.base_currency == from_currency, ExchangeRate.quote_currency == to_currency)
.order_by(ExchangeRate.fetched_at.desc())
.limit(1)
)
er = result.scalar_one_or_none()
return er.rate if er else Decimal("1")
def _holding_to_response(
holding: InvestmentHolding,
asset: Asset,
fx_rates: dict[tuple[str, str], Decimal] | None = None,
) -> HoldingResponse:
fx_rates = fx_rates or {}
cost_basis_total = holding.quantity * holding.avg_cost_basis
current_price = asset.last_price
current_value = holding.quantity * current_price if current_price else None
# Convert asset's last_price to the holding's currency so P&L is comparable
current_price_native = asset.last_price
if current_price_native is not None and asset.currency != holding.currency:
rate = fx_rates.get((asset.currency, holding.currency), Decimal("1"))
current_price = current_price_native * rate
else:
current_price = current_price_native
current_value = holding.quantity * current_price if current_price is not None else None
unrealised_gain = (current_value - cost_basis_total) if current_value is not None else None
unrealised_gain_pct = None
if unrealised_gain is not None and cost_basis_total > 0:
@ -51,7 +81,7 @@ def _holding_to_response(holding: InvestmentHolding, asset: Asset) -> HoldingRes
)
async def get_portfolio(db: AsyncSession, user_id: uuid.UUID) -> PortfolioSummary:
async def get_portfolio(db: AsyncSession, user_id: uuid.UUID, base_currency: str = "GBP") -> PortfolioSummary:
result = await db.execute(
select(InvestmentHolding).where(
InvestmentHolding.user_id == user_id,
@ -60,19 +90,44 @@ async def get_portfolio(db: AsyncSession, user_id: uuid.UUID) -> PortfolioSummar
)
holdings = result.scalars().all()
# Pre-fetch all assets and determine which FX pairs we need
assets: dict[uuid.UUID, Asset] = {}
for h in holdings:
if h.asset_id not in assets:
asset = await _get_asset(db, h.asset_id)
if asset:
assets[h.asset_id] = asset
pairs_needed: set[tuple[str, str]] = set()
for h in holdings:
asset = assets.get(h.asset_id)
if not asset:
continue
if asset.currency != h.currency:
pairs_needed.add((asset.currency, h.currency))
if h.currency != base_currency:
pairs_needed.add((h.currency, base_currency))
fx_rates: dict[tuple[str, str], Decimal] = {}
for from_curr, to_curr in pairs_needed:
fx_rates[(from_curr, to_curr)] = await _fetch_fx_rate(db, from_curr, to_curr)
responses = []
total_value = Decimal("0")
total_cost = Decimal("0")
for h in holdings:
asset = await _get_asset(db, h.asset_id)
asset = assets.get(h.asset_id)
if not asset:
continue
r = _holding_to_response(h, asset)
r = _holding_to_response(h, asset, fx_rates)
responses.append(r)
total_cost += r.cost_basis_total
# Convert each holding to base_currency for the portfolio totals
to_base = fx_rates.get((h.currency, base_currency), Decimal("1")) if h.currency != base_currency else Decimal("1")
total_cost += r.cost_basis_total * to_base
if r.current_value is not None:
total_value += r.current_value
total_value += r.current_value * to_base
total_gain = total_value - total_cost
total_gain_pct = (total_gain / total_cost * 100).quantize(Decimal("0.01")) if total_cost > 0 else Decimal("0")
@ -82,7 +137,7 @@ async def get_portfolio(db: AsyncSession, user_id: uuid.UUID) -> PortfolioSummar
total_cost=total_cost,
total_gain=total_gain,
total_gain_pct=total_gain_pct,
currency="GBP",
currency=base_currency,
holdings=responses,
)
@ -189,18 +244,131 @@ async def list_investment_transactions(
return list(result.scalars().all())
async def get_performance(db: AsyncSession, user_id: uuid.UUID) -> PerformanceMetrics:
portfolio = await get_portfolio(db, user_id)
async def get_performance(db: AsyncSession, user_id: uuid.UUID, base_currency: str = "GBP") -> PerformanceMetrics:
portfolio = await get_portfolio(db, user_id, base_currency)
total_return = portfolio.total_gain
total_return_pct = portfolio.total_gain_pct
return PerformanceMetrics(
twrr=None, # full TWRR requires snapshot history — placeholder
total_return=total_return,
total_return_pct=total_return_pct,
currency="GBP",
currency=base_currency,
)
def _uk_tax_year(d: date) -> str:
"""Return the UK tax year string for a given date (e.g. '2024/25')."""
if d >= date(d.year, 4, 6):
return f"{d.year}/{str(d.year + 1)[2:]}"
return f"{d.year - 1}/{str(d.year)[2:]}"
async def get_capital_gains(
db: AsyncSession, user_id: uuid.UUID, base_currency: str = "GBP"
) -> CapitalGainsReport:
"""
Compute capital gains using the UK Section 104 pool method.
Each asset's transactions are replayed chronologically; on each sell
the cost of disposal is (sold_qty / pool_qty) * pool_cost.
All values are converted to base_currency using current FX rates.
"""
holdings_result = await db.execute(
select(InvestmentHolding).where(InvestmentHolding.user_id == user_id)
)
holdings = holdings_result.scalars().all()
# Pre-fetch assets and FX rates
assets: dict[uuid.UUID, Asset] = {}
holding_currencies: set[str] = set()
for h in holdings:
if h.asset_id not in assets:
a = await _get_asset(db, h.asset_id)
if a:
assets[h.asset_id] = a
holding_currencies.add(h.currency)
fx_rates: dict[tuple[str, str], Decimal] = {}
for curr in holding_currencies:
if curr != base_currency:
fx_rates[(curr, base_currency)] = await _fetch_fx_rate(db, curr, base_currency)
disposals_by_year: dict[str, list[CapitalGainsDisposal]] = {}
for h in holdings:
asset = assets.get(h.asset_id)
if not asset:
continue
txns_result = await db.execute(
select(InvestmentTransaction)
.where(InvestmentTransaction.holding_id == h.id)
.order_by(InvestmentTransaction.date.asc(), InvestmentTransaction.created_at.asc())
)
txns = txns_result.scalars().all()
pool_qty = Decimal("0")
pool_cost = Decimal("0") # in holding.currency
for txn in txns:
if txn.type in ("buy", "transfer_in"):
cost_of_purchase = txn.quantity * txn.price + txn.fees
pool_qty += txn.quantity
pool_cost += cost_of_purchase
elif txn.type in ("sell", "transfer_out") and pool_qty > 0:
sell_qty = min(txn.quantity, pool_qty)
cost_per_unit = pool_cost / pool_qty
cost_of_disposal = cost_per_unit * sell_qty
proceeds = txn.price * sell_qty - txn.fees
# Convert to base_currency
to_base = fx_rates.get((h.currency, base_currency), Decimal("1")) if h.currency != base_currency else Decimal("1")
proceeds_base = (proceeds * to_base).quantize(Decimal("0.01"))
cost_base = (cost_of_disposal * to_base).quantize(Decimal("0.01"))
gain_base = proceeds_base - cost_base
tax_year = _uk_tax_year(txn.date)
disposals_by_year.setdefault(tax_year, []).append(
CapitalGainsDisposal(
date=txn.date,
symbol=asset.symbol,
asset_name=asset.name,
quantity=sell_qty,
proceeds=proceeds_base,
cost=cost_base,
gain=gain_base,
currency=base_currency,
)
)
pool_qty -= sell_qty
pool_cost -= cost_of_disposal
if pool_qty <= 0:
pool_qty = Decimal("0")
pool_cost = Decimal("0")
elif txn.type == "split" and txn.price > 0:
pool_qty = pool_qty * txn.quantity
# pool_cost stays the same; avg cost per unit changes
tax_years: list[TaxYearSummary] = []
for year_label in sorted(disposals_by_year.keys(), reverse=True):
year_disposals = sorted(disposals_by_year[year_label], key=lambda d: d.date)
total_proceeds = sum(d.proceeds for d in year_disposals)
total_cost = sum(d.cost for d in year_disposals)
total_gain = total_proceeds - total_cost
tax_years.append(TaxYearSummary(
tax_year=year_label,
disposals=year_disposals,
total_proceeds=total_proceeds,
total_cost=total_cost,
total_gain=total_gain,
currency=base_currency,
))
return CapitalGainsReport(tax_years=tax_years, currency=base_currency)
async def get_or_create_asset(
db: AsyncSession, symbol: str, name: str, asset_type: str,
currency: str, data_source: str, data_source_id: str | None,