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"} />
+
+
+
+
+
+ {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 || "—"}
+
+
+
+
+
+ | Ticker |
+ Qty |
+ Cost |
+ Value |
+ Gain |
+ Gain / Share |
+
+
+
+ {(data?.positions || []).map((position) => (
+
+ | {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"