diff --git a/atlas-terminal/apps/web/src/app/portfolio/page.tsx b/atlas-terminal/apps/web/src/app/portfolio/page.tsx index 67b486c..2a01074 100644 --- a/atlas-terminal/apps/web/src/app/portfolio/page.tsx +++ b/atlas-terminal/apps/web/src/app/portfolio/page.tsx @@ -2,6 +2,7 @@ import { useState, useEffect } from "react"; import { AlertTriangle, Check, CheckCircle2, CircleX, Pencil, Trash2 } from "lucide-react"; import { CorrelationMatrix } from "../components/portfolio/CorrelationMatrix"; +import { flags } from "../lib/flags"; interface Position { id?: string; @@ -298,16 +299,23 @@ export default function PortfolioPage() {

Portfolio

-
- {["USD", "GBP", "KRW", "EUR", "JPY"].map((cur) => ( - - ))} +
+ {flags.cgt && ( + + UK CGT + + )} +
+ {["USD", "GBP", "KRW", "EUR", "JPY"].map((cur) => ( + + ))} +
diff --git a/atlas-terminal/apps/web/src/app/portfolio/tax/page.tsx b/atlas-terminal/apps/web/src/app/portfolio/tax/page.tsx new file mode 100644 index 0000000..58dce21 --- /dev/null +++ b/atlas-terminal/apps/web/src/app/portfolio/tax/page.tsx @@ -0,0 +1,163 @@ +"use client"; + +import { useState } from "react"; +import { ErrorBanner } from "../../components/ui/ErrorBanner"; +import { LoadingPulse } from "../../components/ui/LoadingPulse"; +import { StatCard } from "../../components/ui/StatCard"; +import { flags } from "../../lib/flags"; +import { useApi } from "../../lib/use-api"; + +type IncomeBand = "basic" | "higher"; + +interface CgtPosition { + ticker: string; + quantity: number; + avg_price: number; + current_price: number; + cost_currency: string; + current_currency: string; + cost_gbp: number; + value_gbp: number; + gain_gbp: number; + gain_per_share_gbp: number; +} + +interface OptimalRealization { + ticker: string; + shares_to_sell: number; + estimated_gain_gbp: number; +} + +interface CgtResponse { + income_band: IncomeBand; + total_unrealized_gain_gbp: number; + allowance: number; + allowance_remaining: number; + taxable_if_sold_all: number; + tax_if_sold_all: number; + rate: number; + optimal_realization: OptimalRealization[]; + positions: CgtPosition[]; + tax_year_end: string; + fx_source: string; + disclaimer: string; +} + +function gbp(value: number): string { + return `£${value.toLocaleString(undefined, { minimumFractionDigits: 0, maximumFractionDigits: 0 })}`; +} + +function statusFor(data: CgtResponse | null): { label: string; tone: "positive" | "negative" | "accent" } { + if (!data) return { label: "—", tone: "accent" }; + if (data.total_unrealized_gain_gbp <= data.allowance * 0.75) return { label: "Under allowance", tone: "positive" }; + if (data.total_unrealized_gain_gbp <= data.allowance) return { label: "Approaching limit", tone: "accent" }; + return { label: "Allowance exceeded", tone: "negative" }; +} + +export default function PortfolioTaxPage() { + const [incomeBand, setIncomeBand] = useState("higher"); + const url = flags.cgt ? `/api/tax/uk/cgt/local?income_band=${incomeBand}` : null; + const { data, loading, error } = useApi(url, { cacheTtlMs: 60_000 }); + const status = statusFor(data); + + if (!flags.cgt) { + return ( +
+ +
+ ); + } + + return ( +
+
+
+

UK CGT Calculator

+

Allowance simulation for current portfolio positions.

+
+
+ {(["basic", "higher"] as IncomeBand[]).map((band) => ( + + ))} +
+
+ + + + + {loading ? : ( + <> +
+ + + + 0 ? "negative" : "default"} /> +
+ +
+
+

Allowance Use Plan

+

Greedy estimate of sales that could use the annual allowance without exceeding it.

+
+
+ {data?.optimal_realization.length ? ( +
+ {data.optimal_realization.map((item) => ( +
+
{item.ticker}
+
+ Sell {item.shares_to_sell.toLocaleString()} shares for about {gbp(item.estimated_gain_gbp)} gain. +
+
+ ))} +
+ ) : ( +
No positive gains available to realize against the allowance.
+ )} +
+
+ +
+
+

Position Gains

+

FX source: {data?.fx_source || "—"} · Tax year end: {data?.tax_year_end || "—"}

+
+
+ + + + + + + + + + + + + {(data?.positions || []).map((position) => ( + + + + + + + + + ))} + +
TickerQtyCostValueGainGain / Share
{position.ticker}{position.quantity.toLocaleString()}{gbp(position.cost_gbp)}{gbp(position.value_gbp)}= 0 ? "text-fin-positive" : "text-fin-negative"}`}>{gbp(position.gain_gbp)}£{position.gain_per_share_gbp.toFixed(4)}
+
+
+ + )} +
+ ); +} diff --git a/atlas-terminal/server/main.py b/atlas-terminal/server/main.py index 3e6993c..8289948 100644 --- a/atlas-terminal/server/main.py +++ b/atlas-terminal/server/main.py @@ -81,7 +81,7 @@ app.add_middleware( ) # --- Mount routers --- -from server.routers import edgar, analysis, valuation, market_data, news, crypto, fx, portfolio, technical, financials, estimates, earnings, insider, screener, markets, fmp, macro, dart, edinet, research, chat, copilot, credentials, calendar # noqa: E402 +from server.routers import edgar, analysis, valuation, market_data, news, crypto, fx, portfolio, technical, financials, estimates, earnings, insider, screener, markets, fmp, macro, dart, edinet, research, chat, copilot, credentials, calendar, tax # noqa: E402 app.include_router(edgar.router, prefix="/api/edgar", tags=["SEC EDGAR"]) app.include_router(analysis.router, prefix="/api/analysis", tags=["AI Analysis"]) @@ -107,6 +107,7 @@ app.include_router(chat.router, prefix="/api/chat", tags=["Chat"]) app.include_router(copilot.router, prefix="/api/copilot", tags=["Copilot"]) app.include_router(credentials.router, prefix="/api/credentials", tags=["Credentials"]) app.include_router(calendar.router, prefix="/api/calendar", tags=["Calendar"]) +app.include_router(tax.router, prefix="/api/tax", tags=["Tax"]) @app.get("/health") diff --git a/atlas-terminal/server/routers/tax.py b/atlas-terminal/server/routers/tax.py new file mode 100644 index 0000000..1966efd --- /dev/null +++ b/atlas-terminal/server/routers/tax.py @@ -0,0 +1,113 @@ +"""Tax simulation endpoints. + +These endpoints are educational calculators for personal planning. They are +not tax advice and intentionally keep assumptions explicit in the payload. +""" + +from __future__ import annotations + +from typing import Literal + +from fastapi import APIRouter, Query + +from server.routers.portfolio import _get_current_quote, _load_positions + +router = APIRouter() + +UK_CGT_ALLOWANCE_2026 = 3000.0 +UK_CGT_BASIC_RATE = 0.10 +UK_CGT_HIGHER_RATE = 0.20 + +_FX_TO_GBP = { + "GBP": 1.0, + "GBX": 0.01, + "USD": 0.80, + "EUR": 0.86, + "DKK": 0.115, + "JPY": 0.0053, + "KRW": 0.00058, + "CHF": 0.90, + "CAD": 0.58, + "AUD": 0.52, +} + + +def _fx_to_gbp(currency: str) -> float: + return _FX_TO_GBP.get((currency or "USD").upper(), _FX_TO_GBP["USD"]) + + +def _position_gain(position: dict) -> dict: + ticker = str(position.get("ticker", "")).upper() + quantity = float(position.get("quantity") or 0) + avg_price = float(position.get("avg_price") or 0) + exchange = str(position.get("exchange") or "") + quote = _get_current_quote(ticker, exchange) + current_price = float(quote.get("price") or avg_price) + cost_currency = str(position.get("currency") or position.get("avg_price_currency") or quote.get("currency") or "USD").upper() + current_currency = str(quote.get("currency") or cost_currency).upper() + cost_gbp = quantity * avg_price * _fx_to_gbp(cost_currency) + value_gbp = quantity * current_price * _fx_to_gbp(current_currency) + gain_gbp = value_gbp - cost_gbp + return { + "ticker": ticker, + "quantity": quantity, + "avg_price": avg_price, + "current_price": current_price, + "cost_currency": cost_currency, + "current_currency": current_currency, + "cost_gbp": round(cost_gbp, 2), + "value_gbp": round(value_gbp, 2), + "gain_gbp": round(gain_gbp, 2), + "gain_per_share_gbp": round(gain_gbp / quantity, 4) if quantity else 0, + } + + +def _optimal_realization(gains: list[dict], allowance: float) -> list[dict]: + remaining = allowance + suggestions = [] + positive = [item for item in gains if item["gain_gbp"] > 0 and item["quantity"] > 0] + for item in sorted(positive, key=lambda row: row["gain_gbp"]): + if remaining <= 0: + break + gain_per_share = item["gain_gbp"] / item["quantity"] + if gain_per_share <= 0: + continue + shares = min(item["quantity"], remaining / gain_per_share) + realized_gain = min(item["gain_gbp"], shares * gain_per_share) + suggestions.append( + { + "ticker": item["ticker"], + "shares_to_sell": round(shares, 6), + "estimated_gain_gbp": round(realized_gain, 2), + } + ) + remaining -= realized_gain + return suggestions + + +@router.get("/uk/cgt/{user_id}", summary="UK CGT allowance simulator") +async def simulate_uk_cgt( + user_id: str, + income_band: Literal["basic", "higher"] = Query("higher"), +): + positions = _load_positions() + gains = [_position_gain(position) for position in positions if position.get("ticker")] + total_gain = round(sum(item["gain_gbp"] for item in gains if item["gain_gbp"] > 0), 2) + taxable = round(max(0.0, total_gain - UK_CGT_ALLOWANCE_2026), 2) + rate = UK_CGT_HIGHER_RATE if income_band == "higher" else UK_CGT_BASIC_RATE + tax = round(taxable * rate, 2) + return { + "user_id": user_id, + "income_band": income_band, + "total_unrealized_gain_gbp": total_gain, + "allowance": UK_CGT_ALLOWANCE_2026, + "allowance_remaining": round(max(0.0, UK_CGT_ALLOWANCE_2026 - total_gain), 2), + "taxable_if_sold_all": taxable, + "tax_if_sold_all": tax, + "rate": rate, + "optimal_realization": _optimal_realization(gains, UK_CGT_ALLOWANCE_2026), + "positions": gains, + "tax_year_end": "2026-04-05", + "fx_source": "static_estimate", + "disclaimer": "Educational estimate only. Not tax advice. Consult HMRC guidance or a qualified adviser.", + } diff --git a/atlas-terminal/tests/test_smoke.py b/atlas-terminal/tests/test_smoke.py index 857bae1..ba4e91a 100644 --- a/atlas-terminal/tests/test_smoke.py +++ b/atlas-terminal/tests/test_smoke.py @@ -33,6 +33,7 @@ EXPECTED_PREFIXES = [ "/api/research", "/api/screener", "/api/technical", + "/api/tax", "/api/valuation", ] @@ -259,3 +260,25 @@ def test_portfolio_correlation_uses_gateway_history(monkeypatch) -> None: assert data["available"] is True assert data["tickers"] == ["AAA", "BBB"] assert data["matrix"][0][0] == 1.0 + + +def test_uk_cgt_calculator_uses_portfolio_positions(monkeypatch) -> None: + from server.routers import tax + + monkeypatch.setattr( + tax, + "_load_positions", + lambda: [ + {"ticker": "AAPL", "quantity": 10, "avg_price": 100, "currency": "USD"}, + ], + ) + monkeypatch.setattr(tax, "_get_current_quote", lambda ticker, exchange="": {"price": 200, "currency": "USD"}) + + with TestClient(app) as client: + response = client.get("/api/tax/uk/cgt/local?income_band=higher") + + assert response.status_code == 200 + data = response.json() + assert data["total_unrealized_gain_gbp"] == 800.0 + assert data["tax_if_sold_all"] == 0.0 + assert data["positions"][0]["ticker"] == "AAPL"