mirror of
https://github.com/shawnkim1997/All-in-one-Financial-Analysis.git
synced 2026-08-21 14:48:05 +00:00
phase 6: uk cgt calculator
This commit is contained in:
@@ -2,6 +2,7 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { AlertTriangle, Check, CheckCircle2, CircleX, Pencil, Trash2 } from "lucide-react";
|
import { AlertTriangle, Check, CheckCircle2, CircleX, Pencil, Trash2 } from "lucide-react";
|
||||||
import { CorrelationMatrix } from "../components/portfolio/CorrelationMatrix";
|
import { CorrelationMatrix } from "../components/portfolio/CorrelationMatrix";
|
||||||
|
import { flags } from "../lib/flags";
|
||||||
|
|
||||||
interface Position {
|
interface Position {
|
||||||
id?: string;
|
id?: string;
|
||||||
@@ -298,16 +299,23 @@ export default function PortfolioPage() {
|
|||||||
<div>
|
<div>
|
||||||
<div className="portfolio-header">
|
<div className="portfolio-header">
|
||||||
<h1 className="text-2xl font-bold">Portfolio</h1>
|
<h1 className="text-2xl font-bold">Portfolio</h1>
|
||||||
<div className="currency-toggle">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
{["USD", "GBP", "KRW", "EUR", "JPY"].map((cur) => (
|
{flags.cgt && (
|
||||||
<button
|
<a href="/portfolio/tax" className="rounded border border-brand-gold bg-brand-gold/15 px-3 py-2 text-xs font-semibold text-brand-navy hover:bg-brand-gold/25">
|
||||||
key={cur}
|
UK CGT
|
||||||
className={`currency-btn ${displayCurrency === cur ? "active" : ""}`}
|
</a>
|
||||||
onClick={() => setDisplayCurrency(cur)}
|
)}
|
||||||
>
|
<div className="currency-toggle">
|
||||||
{getCurrencySymbol(cur)} {cur}
|
{["USD", "GBP", "KRW", "EUR", "JPY"].map((cur) => (
|
||||||
</button>
|
<button
|
||||||
))}
|
key={cur}
|
||||||
|
className={`currency-btn ${displayCurrency === cur ? "active" : ""}`}
|
||||||
|
onClick={() => setDisplayCurrency(cur)}
|
||||||
|
>
|
||||||
|
{getCurrencySymbol(cur)} {cur}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -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<IncomeBand>("higher");
|
||||||
|
const url = flags.cgt ? `/api/tax/uk/cgt/local?income_band=${incomeBand}` : null;
|
||||||
|
const { data, loading, error } = useApi<CgtResponse>(url, { cacheTtlMs: 60_000 });
|
||||||
|
const status = statusFor(data);
|
||||||
|
|
||||||
|
if (!flags.cgt) {
|
||||||
|
return (
|
||||||
|
<div className="atlas-page">
|
||||||
|
<ErrorBanner variant="info" message="UK CGT calculator is disabled. Set NEXT_PUBLIC_FLAG_CGT=true to enable it." />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="atlas-page">
|
||||||
|
<div className="mb-6 flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 className="font-serif text-3xl font-bold text-brand-navy">UK CGT Calculator</h1>
|
||||||
|
<p className="mt-1 text-sm text-text-secondary">Allowance simulation for current portfolio positions.</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{(["basic", "higher"] as IncomeBand[]).map((band) => (
|
||||||
|
<button
|
||||||
|
key={band}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIncomeBand(band)}
|
||||||
|
className={`rounded border px-4 py-2 text-xs font-semibold capitalize ${incomeBand === band ? "border-brand-navy bg-brand-navy text-white" : "border-border text-text-secondary hover:bg-surface-sunken"}`}
|
||||||
|
>
|
||||||
|
{band} rate
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ErrorBanner variant="warning" message="Not tax advice. This uses static FX estimates and simplified Section 104 pooling assumptions. Check HMRC guidance before acting." className="mb-5" />
|
||||||
|
<ErrorBanner variant="error" message={error} className="mb-5" />
|
||||||
|
|
||||||
|
{loading ? <LoadingPulse height="h-48" label="Calculating CGT..." /> : (
|
||||||
|
<>
|
||||||
|
<div className="mb-6 grid grid-cols-1 gap-3 md:grid-cols-4">
|
||||||
|
<StatCard label="Status" value={status.label} tone={status.tone} />
|
||||||
|
<StatCard label="Unrealized Gain" value={data ? gbp(data.total_unrealized_gain_gbp) : "—"} />
|
||||||
|
<StatCard label="Allowance Left" value={data ? gbp(data.allowance_remaining) : "—"} tone={data && data.allowance_remaining <= 0 ? "negative" : "positive"} />
|
||||||
|
<StatCard label="Tax If Sold All" value={data ? gbp(data.tax_if_sold_all) : "—"} tone={data && data.tax_if_sold_all > 0 ? "negative" : "default"} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="atlas-card mb-6">
|
||||||
|
<header className="border-b border-border px-5 py-4">
|
||||||
|
<h2 className="font-serif text-lg font-bold text-brand-navy">Allowance Use Plan</h2>
|
||||||
|
<p className="mt-1 text-xs text-text-muted">Greedy estimate of sales that could use the annual allowance without exceeding it.</p>
|
||||||
|
</header>
|
||||||
|
<div className="p-5">
|
||||||
|
{data?.optimal_realization.length ? (
|
||||||
|
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{data.optimal_realization.map((item) => (
|
||||||
|
<div key={item.ticker} className="rounded-md border border-border bg-surface-sunken p-4">
|
||||||
|
<div className="font-mono text-lg font-bold text-brand-navy">{item.ticker}</div>
|
||||||
|
<div className="mt-2 text-sm text-text-secondary">
|
||||||
|
Sell <span className="font-mono text-text-primary">{item.shares_to_sell.toLocaleString()}</span> shares for about <span className="font-mono text-fin-positive">{gbp(item.estimated_gain_gbp)}</span> gain.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-sm text-text-muted">No positive gains available to realize against the allowance.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="atlas-table-shell">
|
||||||
|
<div className="border-b border-border px-5 py-4">
|
||||||
|
<h2 className="font-serif text-lg font-bold text-brand-navy">Position Gains</h2>
|
||||||
|
<p className="mt-1 text-xs text-text-muted">FX source: {data?.fx_source || "—"} · Tax year end: {data?.tax_year_end || "—"}</p>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full min-w-[860px] text-sm">
|
||||||
|
<thead className="bg-surface-sunken">
|
||||||
|
<tr className="border-y border-border-strong text-[11px] uppercase tracking-[0.12em] text-brand-navy">
|
||||||
|
<th className="px-5 py-3 text-left font-semibold">Ticker</th>
|
||||||
|
<th className="px-3 py-3 text-right font-semibold">Qty</th>
|
||||||
|
<th className="px-3 py-3 text-right font-semibold">Cost</th>
|
||||||
|
<th className="px-3 py-3 text-right font-semibold">Value</th>
|
||||||
|
<th className="px-3 py-3 text-right font-semibold">Gain</th>
|
||||||
|
<th className="px-5 py-3 text-right font-semibold">Gain / Share</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{(data?.positions || []).map((position) => (
|
||||||
|
<tr key={position.ticker} className="border-b border-border/60 hover:bg-surface-sunken">
|
||||||
|
<td className="px-5 py-3 font-mono font-bold text-brand-navy">{position.ticker}</td>
|
||||||
|
<td className="px-3 py-3 text-right font-mono tabular-nums text-text-primary">{position.quantity.toLocaleString()}</td>
|
||||||
|
<td className="px-3 py-3 text-right font-mono tabular-nums text-text-secondary">{gbp(position.cost_gbp)}</td>
|
||||||
|
<td className="px-3 py-3 text-right font-mono tabular-nums text-text-secondary">{gbp(position.value_gbp)}</td>
|
||||||
|
<td className={`px-3 py-3 text-right font-mono tabular-nums ${position.gain_gbp >= 0 ? "text-fin-positive" : "text-fin-negative"}`}>{gbp(position.gain_gbp)}</td>
|
||||||
|
<td className="px-5 py-3 text-right font-mono tabular-nums text-text-primary">£{position.gain_per_share_gbp.toFixed(4)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -81,7 +81,7 @@ app.add_middleware(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# --- Mount routers ---
|
# --- 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(edgar.router, prefix="/api/edgar", tags=["SEC EDGAR"])
|
||||||
app.include_router(analysis.router, prefix="/api/analysis", tags=["AI Analysis"])
|
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(copilot.router, prefix="/api/copilot", tags=["Copilot"])
|
||||||
app.include_router(credentials.router, prefix="/api/credentials", tags=["Credentials"])
|
app.include_router(credentials.router, prefix="/api/credentials", tags=["Credentials"])
|
||||||
app.include_router(calendar.router, prefix="/api/calendar", tags=["Calendar"])
|
app.include_router(calendar.router, prefix="/api/calendar", tags=["Calendar"])
|
||||||
|
app.include_router(tax.router, prefix="/api/tax", tags=["Tax"])
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
}
|
||||||
@@ -33,6 +33,7 @@ EXPECTED_PREFIXES = [
|
|||||||
"/api/research",
|
"/api/research",
|
||||||
"/api/screener",
|
"/api/screener",
|
||||||
"/api/technical",
|
"/api/technical",
|
||||||
|
"/api/tax",
|
||||||
"/api/valuation",
|
"/api/valuation",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -259,3 +260,25 @@ def test_portfolio_correlation_uses_gateway_history(monkeypatch) -> None:
|
|||||||
assert data["available"] is True
|
assert data["available"] is True
|
||||||
assert data["tickers"] == ["AAA", "BBB"]
|
assert data["tickers"] == ["AAA", "BBB"]
|
||||||
assert data["matrix"][0][0] == 1.0
|
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"
|
||||||
|
|||||||
Reference in New Issue
Block a user