Restructured

This commit is contained in:
Eden Kirin
2025-10-12 00:44:13 +02:00
parent e9360cbc08
commit 87b1cee957
5 changed files with 3 additions and 525 deletions

View File

@ -1,7 +1,7 @@
# Stage 1: Build static content (Node.js not needed, just copy HTML) # Stage 1: Build static content (Node.js not needed, just copy HTML)
FROM alpine:3.18 AS static-builder FROM alpine:3.18 AS static-builder
WORKDIR /build WORKDIR /build
COPY index.html . COPY static/index.html .
RUN mkdir -p /build/static && cp index.html /build/static/ RUN mkdir -p /build/static && cp index.html /build/static/
# Stage 2: Build Go backend # Stage 2: Build Go backend
@ -9,11 +9,9 @@ FROM golang:1.21-alpine AS go-builder
WORKDIR /app WORKDIR /app
# Copy go.mod and go.sum if they exist, otherwise they'll be created # Copy go.mod and go.sum if they exist, otherwise they'll be created
COPY go.mod go.sum* ./
RUN go mod download || true
# Copy the Go source code # Copy the Go source code
COPY main.go . COPY backend/. .
RUN go mod download || true
# Build the Go binary # Build the Go binary
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o stock-analyzer . RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o stock-analyzer .

View File

@ -1,5 +1,3 @@
version: "3.8"
services: services:
stock-analyzer: stock-analyzer:
build: build:

View File

@ -1,518 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Stock Tax Analyzer</title>
<script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body>
<div id="root"></div>
<script type="text/babel">
const { useState } = React;
// Lucide icons as inline SVG components
const Upload = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="17 8 12 3 7 8"/>
<line x1="12" y1="3" x2="12" y2="15"/>
</svg>
);
const Loader2 = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="animate-spin">
<path d="M21 12a9 9 0 1 1-6.219-8.56"/>
</svg>
);
function StockTaxAnalyzer() {
const [results, setResults] = useState(null);
const [error, setError] = useState(null);
const [loading, setLoading] = useState(false);
const knownSplits = {
'NVDA': [
{ date: '2024-06-10', ratio: 10 },
{ date: '2021-07-20', ratio: 4 },
{ date: '2007-09-11', ratio: 1.5 },
{ date: '2006-04-07', ratio: 2 },
{ date: '2001-09-17', ratio: 2 },
{ date: '2000-06-27', ratio: 2 }
],
'TSLA': [
{ date: '2022-08-25', ratio: 3 },
{ date: '2020-08-31', ratio: 5 }
],
'AAPL': [
{ date: '2020-08-31', ratio: 4 },
{ date: '2014-06-09', ratio: 7 },
{ date: '2005-02-28', ratio: 2 },
{ date: '2000-06-21', ratio: 2 },
{ date: '1987-06-16', ratio: 2 }
],
'GOOGL': [
{ date: '2022-07-18', ratio: 20 }
],
'AMZN': [
{ date: '2022-06-06', ratio: 20 },
{ date: '1999-09-02', ratio: 2 },
{ date: '1999-01-05', ratio: 3 },
{ date: '1998-06-02', ratio: 2 }
],
'MSFT': [],
'NFLX': [],
'AMD': [],
'COIN': []
};
const fetchStockPrices = async (tickers) => {
try {
const response = await fetch('/api/stock-prices', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ tickers }),
});
if (!response.ok) {
throw new Error('Failed to fetch stock prices');
}
const data = await response.json();
return { prices: data.prices, exchangeRate: data.exchangeRate };
} catch (err) {
console.error('Error fetching prices:', err);
return { prices: {}, exchangeRate: 0.92, error: err.message };
}
};
const handleFileUpload = async (event) => {
const file = event.target.files[0];
if (!file) return;
setLoading(true);
setError(null);
try {
const text = await file.text();
const lines = text.split('\n');
const headers = lines[0].split(',').map(h => h.trim().replace(/"/g, ''));
const data = lines.slice(1).map(line => {
const values = line.split(',').map(v => v.trim().replace(/"/g, ''));
const row = {};
headers.forEach((header, i) => {
row[header] = values[i];
});
return row;
}).filter(row => row.Date);
data.sort((a, b) => new Date(a.Date) - new Date(b.Date));
const today = new Date();
const twoYearsAgo = new Date(today);
twoYearsAgo.setFullYear(today.getFullYear() - 2);
const allBuys = data.filter(row => row.Type === 'BUY - MARKET' && row.Ticker);
const oldBuys = allBuys.filter(row => new Date(row.Date) < twoYearsAgo);
const processHoldings = (buys) => {
const holdings = {};
buys.forEach(buy => {
const ticker = buy.Ticker;
if (!holdings[ticker]) {
holdings[ticker] = {
quantity: 0,
totalCost: 0,
transactions: [],
dates: [],
appliedSplits: []
};
}
let quantity = parseFloat(buy.Quantity) || 0;
const buyDate = new Date(buy.Date);
const priceStr = buy['Price per share'] || '';
let price = parseFloat(priceStr.replace(/[$,]/g, ''));
let splitMultiplier = 1;
if (knownSplits[ticker]) {
const relevantSplits = knownSplits[ticker].filter(split =>
new Date(split.date) > buyDate
);
relevantSplits.forEach(split => {
splitMultiplier *= split.ratio;
const splitExists = holdings[ticker].appliedSplits.some(
s => s.date === split.date
);
if (!splitExists) {
holdings[ticker].appliedSplits.push(split);
}
});
}
const adjustedQuantity = quantity * splitMultiplier;
holdings[ticker].quantity += adjustedQuantity;
if (!isNaN(price)) {
holdings[ticker].totalCost += price * quantity;
}
holdings[ticker].transactions.push(buy);
holdings[ticker].dates.push(buyDate);
});
return holdings;
};
const allHoldings = processHoldings(allBuys);
const oldHoldings = processHoldings(oldBuys);
const allTickers = [...new Set([...Object.keys(allHoldings), ...Object.keys(oldHoldings)])];
const { prices, exchangeRate } = await fetchStockPrices(allTickers);
const formattedHoldings = allTickers.map(ticker => {
const allInfo = allHoldings[ticker] || { quantity: 0, totalCost: 0, transactions: [], dates: [], appliedSplits: [] };
const oldInfo = oldHoldings[ticker] || { quantity: 0, totalCost: 0, transactions: [], dates: [], appliedSplits: [] };
const currentPrice = prices[ticker];
const totalQuantity = allInfo.quantity;
const totalCostEur = allInfo.totalCost * exchangeRate;
const totalCurrentValueEur = currentPrice?.success ? totalQuantity * currentPrice.eur : null;
const totalGainLossEur = totalCurrentValueEur ? totalCurrentValueEur - totalCostEur : null;
const totalGainLossPercent = totalGainLossEur && totalCostEur ? (totalGainLossEur / totalCostEur) * 100 : null;
const oldQuantity = oldInfo.quantity;
const oldCostEur = oldInfo.totalCost * exchangeRate;
const oldCurrentValueEur = currentPrice?.success ? oldQuantity * currentPrice.eur : null;
const oldGainLossEur = oldCurrentValueEur ? oldCurrentValueEur - oldCostEur : null;
const oldGainLossPercent = oldGainLossEur && oldCostEur ? (oldGainLossEur / oldCostEur) * 100 : null;
return {
ticker,
totalQuantity,
totalCostEur,
totalCurrentValueEur,
totalGainLossEur,
totalGainLossPercent,
totalTransactions: allInfo.transactions.length,
oldQuantity,
oldCostEur,
oldCurrentValueEur,
oldGainLossEur,
oldGainLossPercent,
oldTransactions: oldInfo.transactions.length,
oldestDate: oldInfo.dates.length > 0 ? new Date(Math.min(...oldInfo.dates)) : null,
newestOldDate: oldInfo.dates.length > 0 ? new Date(Math.max(...oldInfo.dates)) : null,
currentPriceEur: currentPrice?.eur,
avgPriceEur: totalQuantity > 0 ? (allInfo.totalCost / totalQuantity) * exchangeRate : 0,
oldAvgPriceEur: oldQuantity > 0 ? (oldInfo.totalCost / oldQuantity) * exchangeRate : 0,
priceAvailable: currentPrice?.success || false,
splits: oldInfo.appliedSplits.sort((a, b) => new Date(a.date) - new Date(b.date)),
hasSplitData: knownSplits[ticker] !== undefined
};
}).sort((a, b) => a.ticker.localeCompare(b.ticker));
const totalAllInvestedEur = formattedHoldings.reduce((sum, h) => sum + h.totalCostEur, 0);
const totalAllCurrentValueEur = formattedHoldings.reduce((sum, h) => sum + (h.totalCurrentValueEur || 0), 0);
const totalAllGainLossEur = totalAllCurrentValueEur - totalAllInvestedEur;
const totalAllGainLossPercent = (totalAllGainLossEur / totalAllInvestedEur) * 100;
const totalOldInvestedEur = formattedHoldings.reduce((sum, h) => sum + h.oldCostEur, 0);
const totalOldCurrentValueEur = formattedHoldings.reduce((sum, h) => sum + (h.oldCurrentValueEur || 0), 0);
const totalOldGainLossEur = totalOldCurrentValueEur - totalOldInvestedEur;
const totalOldGainLossPercent = totalOldInvestedEur > 0 ? (totalOldGainLossEur / totalOldInvestedEur) * 100 : 0;
setResults({
holdings: formattedHoldings,
totalAllInvestedEur,
totalAllCurrentValueEur,
totalAllGainLossEur,
totalAllGainLossPercent,
totalOldInvestedEur,
totalOldCurrentValueEur,
totalOldGainLossEur,
totalOldGainLossPercent,
exchangeRate,
cutoffDate: twoYearsAgo
});
setLoading(false);
} catch (err) {
setError('Error parsing file: ' + err.message);
setResults(null);
setLoading(false);
}
};
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 p-8">
<div className="max-w-7xl mx-auto">
<div className="bg-white rounded-lg shadow-lg p-8">
<h1 className="text-3xl font-bold text-gray-800 mb-2">
Stock Tax Analyzer
</h1>
<p className="text-gray-600 mb-2">
Compare total holdings vs. stocks bought more than 2 years ago
</p>
<p className="text-sm text-indigo-600 mb-6">
Real-time prices in EUR Split adjustments Tax planning
</p>
<div className="mb-8">
<label className="flex items-center justify-center w-full h-32 px-4 transition bg-white border-2 border-gray-300 border-dashed rounded-lg appearance-none cursor-pointer hover:border-indigo-400 focus:outline-none">
<span className="flex items-center space-x-2">
{loading ? (
<>
<Loader2 />
<span className="font-medium text-indigo-600">Loading prices...</span>
</>
) : (
<>
<Upload />
<span className="font-medium text-gray-600">
Click to upload CSV file
</span>
</>
)}
</span>
<input
type="file"
accept=".csv"
onChange={handleFileUpload}
className="hidden"
disabled={loading}
/>
</label>
</div>
{error && (
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg text-red-700">
{error}
</div>
)}
{results && (
<div>
<div className="bg-gradient-to-r from-indigo-50 to-purple-50 rounded-lg p-6 mb-6">
<h2 className="text-2xl font-bold text-gray-800 mb-4">Portfolio Summary</h2>
<div className="mb-6">
<h3 className="text-lg font-semibold text-gray-700 mb-3">All Holdings (Total Portfolio)</h3>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="bg-white rounded-lg p-4 shadow">
<div className="text-sm text-gray-600 mb-1">Stocks</div>
<div className="text-2xl font-bold text-indigo-600">
{results.holdings.length}
</div>
</div>
<div className="bg-white rounded-lg p-4 shadow">
<div className="text-sm text-gray-600 mb-1">Total Invested</div>
<div className="text-2xl font-bold text-blue-600">
{results.totalAllInvestedEur.toFixed(2)}
</div>
</div>
<div className="bg-white rounded-lg p-4 shadow">
<div className="text-sm text-gray-600 mb-1">Current Value</div>
<div className="text-2xl font-bold text-green-600">
{results.totalAllCurrentValueEur.toFixed(2)}
</div>
</div>
<div className="bg-white rounded-lg p-4 shadow">
<div className="text-sm text-gray-600 mb-1">Total Gain/Loss</div>
<div className={`text-2xl font-bold ${results.totalAllGainLossEur >= 0 ? 'text-green-600' : 'text-red-600'}`}>
{results.totalAllGainLossEur >= 0 ? '+' : ''}{results.totalAllGainLossEur.toFixed(2)}
</div>
<div className={`text-sm font-semibold ${results.totalAllGainLossPercent >= 0 ? 'text-green-600' : 'text-red-600'}`}>
{results.totalAllGainLossPercent >= 0 ? '+' : ''}{results.totalAllGainLossPercent.toFixed(2)}%
</div>
</div>
</div>
</div>
<div className="border-t-2 border-indigo-200 pt-6">
<h3 className="text-lg font-semibold text-amber-700 mb-1">
🏆 Tax-Eligible Holdings (Bought Before {results.cutoffDate.toLocaleDateString()})
</h3>
<p className="text-xs text-amber-600 mb-3">Stocks held for more than 2 years</p>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="bg-amber-50 rounded-lg p-4 shadow border-2 border-amber-200">
<div className="text-sm text-amber-700 mb-1">Stocks</div>
<div className="text-2xl font-bold text-amber-700">
{results.holdings.filter(h => h.oldQuantity > 0).length}
</div>
</div>
<div className="bg-amber-50 rounded-lg p-4 shadow border-2 border-amber-200">
<div className="text-sm text-amber-700 mb-1">Total Invested</div>
<div className="text-2xl font-bold text-blue-700">
{results.totalOldInvestedEur.toFixed(2)}
</div>
</div>
<div className="bg-amber-50 rounded-lg p-4 shadow border-2 border-amber-200">
<div className="text-sm text-amber-700 mb-1">Current Value</div>
<div className="text-2xl font-bold text-green-700">
{results.totalOldCurrentValueEur.toFixed(2)}
</div>
</div>
<div className="bg-amber-50 rounded-lg p-4 shadow border-2 border-amber-200">
<div className="text-sm text-amber-700 mb-1">Total Gain/Loss</div>
<div className={`text-2xl font-bold ${results.totalOldGainLossEur >= 0 ? 'text-green-700' : 'text-red-700'}`}>
{results.totalOldGainLossEur >= 0 ? '+' : ''}{results.totalOldGainLossEur.toFixed(2)}
</div>
<div className={`text-sm font-semibold ${results.totalOldGainLossPercent >= 0 ? 'text-green-700' : 'text-red-700'}`}>
{results.totalOldGainLossPercent >= 0 ? '+' : ''}{results.totalOldGainLossPercent.toFixed(2)}%
</div>
</div>
</div>
</div>
<div className="mt-4 text-xs text-gray-600">
Exchange rate: 1 USD = {results.exchangeRate.toFixed(4)}
</div>
</div>
<h2 className="text-xl font-semibold text-gray-800 mb-4">
Detailed Stock Breakdown
</h2>
<div className="space-y-4">
{results.holdings.map(holding => (
<div key={holding.ticker} className="bg-white rounded-lg p-6 border-2 border-gray-200 shadow-sm">
<div className="flex justify-between items-start mb-4">
<div>
<h3 className="text-2xl font-bold text-indigo-700">{holding.ticker}</h3>
{holding.splits.length > 0 && (
<div className="text-xs text-green-600 font-semibold mt-1">
Split-adjusted ({holding.splits.reduce((acc, s) => acc * s.ratio, 1)}x total)
</div>
)}
</div>
<div className="text-right">
{holding.priceAvailable && (
<div>
<div className="text-xs text-gray-600">Current Price</div>
<div className="text-xl font-bold text-gray-900">
{holding.currentPriceEur?.toFixed(2)}
</div>
</div>
)}
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="bg-blue-50 rounded-lg p-4 border border-blue-200">
<h4 className="font-bold text-blue-900 mb-3 flex items-center gap-2">
📊 Total Holdings
<span className="text-xs font-normal text-blue-700">({holding.totalTransactions} purchases)</span>
</h4>
<div className="grid grid-cols-2 gap-3 text-sm">
<div>
<div className="text-blue-700 mb-1">Shares</div>
<div className="font-bold text-gray-900">{holding.totalQuantity.toFixed(4)}</div>
</div>
<div>
<div className="text-blue-700 mb-1">Avg Cost</div>
<div className="font-semibold text-gray-800">{holding.avgPriceEur.toFixed(2)}</div>
</div>
<div>
<div className="text-blue-700 mb-1">Cost Basis</div>
<div className="font-bold text-blue-800">{holding.totalCostEur.toFixed(2)}</div>
</div>
<div>
<div className="text-blue-700 mb-1">Current Value</div>
<div className="font-bold text-green-700">
{holding.priceAvailable ? `${holding.totalCurrentValueEur.toFixed(2)}` : 'N/A'}
</div>
</div>
<div className="col-span-2">
<div className="text-blue-700 mb-1">Gain/Loss</div>
<div className={`font-bold text-lg ${holding.totalGainLossEur >= 0 ? 'text-green-600' : 'text-red-600'}`}>
{holding.priceAvailable ? (
<>
{holding.totalGainLossEur >= 0 ? '+' : ''}{holding.totalGainLossEur.toFixed(2)}
<span className="text-sm ml-2">
({holding.totalGainLossPercent >= 0 ? '+' : ''}{holding.totalGainLossPercent.toFixed(1)}%)
</span>
</>
) : 'N/A'}
</div>
</div>
</div>
</div>
<div className={`rounded-lg p-4 border-2 ${holding.oldQuantity > 0 ? 'bg-amber-50 border-amber-300' : 'bg-gray-50 border-gray-200'}`}>
<h4 className={`font-bold mb-3 flex items-center gap-2 ${holding.oldQuantity > 0 ? 'text-amber-900' : 'text-gray-500'}`}>
{holding.oldQuantity > 0 ? '🏆 Tax-Eligible (>2 years)' : '⏳ No Old Holdings'}
{holding.oldQuantity > 0 && (
<span className="text-xs font-normal text-amber-700">
({holding.oldTransactions} purchases {((holding.oldQuantity / holding.totalQuantity) * 100).toFixed(1)}% of total)
</span>
)}
</h4>
{holding.oldQuantity > 0 ? (
<div className="grid grid-cols-2 gap-3 text-sm">
<div>
<div className="text-amber-700 mb-1">Shares</div>
<div className="font-bold text-gray-900">{holding.oldQuantity.toFixed(4)}</div>
</div>
<div>
<div className="text-amber-700 mb-1">Avg Cost</div>
<div className="font-semibold text-gray-800">{holding.oldAvgPriceEur.toFixed(2)}</div>
</div>
<div>
<div className="text-amber-700 mb-1">Cost Basis</div>
<div className="font-bold text-blue-800">{holding.oldCostEur.toFixed(2)}</div>
</div>
<div>
<div className="text-amber-700 mb-1">Current Value</div>
<div className="font-bold text-green-700">
{holding.priceAvailable ? `${holding.oldCurrentValueEur.toFixed(2)}` : 'N/A'}
</div>
</div>
<div className="col-span-2">
<div className="text-amber-700 mb-1">Gain/Loss</div>
<div className={`font-bold text-lg ${holding.oldGainLossEur >= 0 ? 'text-green-600' : 'text-red-600'}`}>
{holding.priceAvailable ? (
<>
{holding.oldGainLossEur >= 0 ? '+' : ''}{holding.oldGainLossEur.toFixed(2)}
<span className="text-sm ml-2">
({holding.oldGainLossPercent >= 0 ? '+' : ''}{holding.oldGainLossPercent.toFixed(1)}%)
</span>
</>
) : 'N/A'}
</div>
</div>
{holding.oldestDate && (
<div className="col-span-2 text-xs text-amber-700 mt-1">
Purchased: {holding.oldestDate.toLocaleDateString()} - {holding.newestOldDate.toLocaleDateString()}
</div>
)}
</div>
) : (
<div className="text-sm text-gray-500 italic">
All shares were bought within the last 2 years
</div>
)}
</div>
</div>
</div>
))}
</div>
</div>
)}
</div>
</div>
</div>
);
}
ReactDOM.render(<StockTaxAnalyzer />, document.getElementById('root'));
</script>
</body>
</html>