This commit is contained in:
Eden Kirin
2025-10-12 00:34:03 +02:00
commit e9360cbc08
7 changed files with 1011 additions and 0 deletions

37
.gitignore vendored Normal file
View File

@ -0,0 +1,37 @@
# Binaries
stock-analyzer
*.exe
*.exe~
*.dll
*.so
*.dylib
# Go
*.test
*.out
go.sum
# IDEs
.idea/
.vscode/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Docker
.dockerignore
# Logs
*.log
# CSV files (user data)
*.csv
# Static build artifacts
static/
dist/
build/

38
Dockerfile Normal file
View File

@ -0,0 +1,38 @@
# Stage 1: Build static content (Node.js not needed, just copy HTML)
FROM alpine:3.18 AS static-builder
WORKDIR /build
COPY index.html .
RUN mkdir -p /build/static && cp index.html /build/static/
# Stage 2: Build Go backend
FROM golang:1.21-alpine AS go-builder
WORKDIR /app
# 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 main.go .
# Build the Go binary
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o stock-analyzer .
# Stage 3: Final clean stage
FROM alpine:3.18
WORKDIR /app
# Install CA certificates for HTTPS requests
RUN apk --no-cache add ca-certificates
# Copy the Go binary from stage 2
COPY --from=go-builder /app/stock-analyzer /app/stock-analyzer
# Copy static files from stage 1
COPY --from=static-builder /build/static /app/static
# Expose port 8080
EXPOSE 8080
# Run the binary
CMD ["/app/stock-analyzer"]

222
README.md Normal file
View File

@ -0,0 +1,222 @@
# Stock Tax Analyzer - Deployment Guide
A complete stock portfolio analyzer with tax-eligible holdings calculation, real-time prices, and stock split adjustments.
## Project Structure
```
stock-tax-analyzer/
├── main.go # Go backend server
├── go.mod # Go module file
├── index.html # React frontend app
├── Dockerfile # Multi-stage Docker build
├── docker-compose.yml # Docker Compose configuration
└── README.md # This file
```
## Features
- 📊 Analyze Revolut trading statements
- 💰 Real-time stock prices in EUR
- 🏆 Tax-eligible holdings (stocks held >2 years)
- 📈 Stock split adjustments (NVDA, TSLA, AAPL, GOOGL, AMZN)
- 💵 Portfolio valuation with gain/loss calculations
- 🔒 Runs entirely on your server - no data leaves your infrastructure
## Quick Start
### Option 1: Using Docker Compose (Recommended)
1. Create a directory and copy all files:
```bash
mkdir stock-tax-analyzer
cd stock-tax-analyzer
# Copy: main.go, go.mod, index.html, Dockerfile, docker-compose.yml
```
2. Build and run:
```bash
docker-compose up -d
```
3. Access the application:
```
http://localhost:8080
```
### Option 2: Using Docker
1. Build the image:
```bash
docker build -t stock-analyzer .
```
2. Run the container:
```bash
docker run -d -p 8080:8080 --name stock-analyzer stock-analyzer
```
3. Access the application:
```
http://localhost:8080
```
### Option 3: Manual Build (without Docker)
1. Install Go 1.21 or higher
2. Build the Go backend:
```bash
go build -o stock-analyzer main.go
```
3. Create static directory:
```bash
mkdir -p static
cp index.html static/
```
4. Run the server:
```bash
./stock-analyzer
```
5. Access the application:
```
http://localhost:8080
```
## How to Use
1. Open the application in your browser
2. Click "Click to upload CSV file"
3. Select your Revolut trading statement CSV file
4. Wait for the analysis to complete
5. Review your portfolio:
- **Total Holdings**: All your stocks
- **Tax-Eligible Holdings**: Stocks bought more than 2 years ago
## API Endpoint
### POST /api/stock-prices
Fetches current stock prices from Yahoo Finance.
**Request:**
```json
{
"tickers": ["AAPL", "MSFT", "GOOGL"]
}
```
**Response:**
```json
{
"prices": {
"AAPL": {
"ticker": "AAPL",
"usd": 150.25,
"eur": 138.23,
"success": true
}
},
"exchangeRate": 0.92
}
```
## Configuration
### Environment Variables
- `PORT`: Server port (default: 8080)
- `TZ`: Timezone (default: Europe/Zagreb)
### Supported Stock Splits
The application includes historical stock split data for:
- **NVDA**: 6 splits (including 10:1 in 2024)
- **TSLA**: 2 splits (3:1 and 5:1)
- **AAPL**: 5 splits (including 4:1 in 2020)
- **GOOGL**: 1 split (20:1 in 2022)
- **AMZN**: 4 splits (including 20:1 in 2022)
## Architecture
### Multi-stage Docker Build
1. **Stage 1 (static-builder)**: Prepares static HTML files
2. **Stage 2 (go-builder)**: Compiles Go backend
3. **Stage 3 (final)**: Clean Alpine image with binary and static files
Benefits:
- Small final image size (~15MB)
- No build tools in production image
- Secure and efficient
### Backend (Go)
- HTTP server on port 8080
- Fetches stock prices from Yahoo Finance
- Handles CORS and rate limiting
- Serves static frontend files
### Frontend (React)
- Pure client-side React app
- No build step required
- Processes CSV files in browser
- Calls backend API for stock prices
## Troubleshooting
### Stock prices showing as N/A
- Check if Yahoo Finance is accessible from your server
- Verify network connectivity
- Check Docker logs: `docker-compose logs -f`
### Port already in use
Change the port in docker-compose.yml:
```yaml
ports:
- "9090:8080" # Use port 9090 instead
```
### File upload not working
- Ensure the CSV file is a valid Revolut trading statement
- Check browser console for errors
- Verify file size is reasonable (<10MB)
## Security Notes
- Application runs entirely on your server
- CSV files are processed in the browser (never sent to server)
- Only stock tickers are sent to backend for price fetching
- No data is stored or logged
## Development
### Running locally for development
```bash
# Terminal 1: Run Go backend
go run main.go
# Terminal 2: Serve static files (or just open index.html)
python3 -m http.server 8000
```
Then open http://localhost:8080 (backend serves both API and static files)
## License
MIT License - Feel free to modify and use as needed.
## Support
For issues or questions, check the application logs:
```bash
docker-compose logs -f stock-analyzer
```

13
docker-compose.yml Normal file
View File

@ -0,0 +1,13 @@
version: "3.8"
services:
stock-analyzer:
build:
context: .
dockerfile: Dockerfile
ports:
- "8080:8080"
restart: unless-stopped
environment:
- TZ=Europe/Zagreb
container_name: stock-tax-analyzer

3
go.mod Normal file
View File

@ -0,0 +1,3 @@
module stock-analyzer
go 1.21

518
index.html Normal file
View File

@ -0,0 +1,518 @@
<!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>

180
main.go Normal file
View File

@ -0,0 +1,180 @@
package main
import (
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"strings"
"time"
)
type StockPriceRequest struct {
Tickers []string `json:"tickers"`
}
type StockPrice struct {
Ticker string `json:"ticker"`
USD float64 `json:"usd"`
EUR float64 `json:"eur"`
Success bool `json:"success"`
Error string `json:"error,omitempty"`
}
type StockPricesResponse struct {
Prices map[string]StockPrice `json:"prices"`
ExchangeRate float64 `json:"exchangeRate"`
}
type YahooChartResponse struct {
Chart struct {
Result []struct {
Meta struct {
RegularMarketPrice float64 `json:"regularMarketPrice"`
} `json:"meta"`
} `json:"result"`
Error *struct {
Description string `json:"description"`
} `json:"error"`
} `json:"chart"`
}
type ExchangeRateResponse struct {
Rates map[string]float64 `json:"rates"`
}
func fetchExchangeRate() (float64, error) {
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Get("https://api.exchangerate-api.com/v4/latest/USD")
if err != nil {
return 0.92, fmt.Errorf("failed to fetch exchange rate: %w", err)
}
defer resp.Body.Close()
var rateData ExchangeRateResponse
if err := json.NewDecoder(resp.Body).Decode(&rateData); err != nil {
return 0.92, fmt.Errorf("failed to decode exchange rate: %w", err)
}
if eurRate, ok := rateData.Rates["EUR"]; ok {
return eurRate, nil
}
return 0.92, fmt.Errorf("EUR rate not found")
}
func fetchStockPrice(ticker string) (float64, error) {
client := &http.Client{Timeout: 10 * time.Second}
// Try Yahoo Finance API
url := fmt.Sprintf("https://query1.finance.yahoo.com/v8/finance/chart/%s?interval=1d&range=1d", ticker)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return 0, err
}
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
resp, err := client.Do(req)
if err != nil {
return 0, fmt.Errorf("failed to fetch from Yahoo: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return 0, fmt.Errorf("failed to read response: %w", err)
}
var chartData YahooChartResponse
if err := json.Unmarshal(body, &chartData); err != nil {
return 0, fmt.Errorf("failed to decode Yahoo response: %w", err)
}
if chartData.Chart.Error != nil {
return 0, fmt.Errorf("Yahoo API error: %s", chartData.Chart.Error.Description)
}
if len(chartData.Chart.Result) > 0 {
price := chartData.Chart.Result[0].Meta.RegularMarketPrice
if price > 0 {
return price, nil
}
}
return 0, fmt.Errorf("price not found in response")
}
func stockPricesHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req StockPriceRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
// Fetch exchange rate
exchangeRate, err := fetchExchangeRate()
if err != nil {
log.Printf("Warning: Using fallback exchange rate: %v", err)
exchangeRate = 0.92 // Fallback
}
prices := make(map[string]StockPrice)
// Fetch prices for each ticker
for _, ticker := range req.Tickers {
ticker = strings.TrimSpace(ticker)
if ticker == "" {
continue
}
usdPrice, err := fetchStockPrice(ticker)
if err != nil {
log.Printf("Error fetching price for %s: %v", ticker, err)
prices[ticker] = StockPrice{
Ticker: ticker,
Success: false,
Error: err.Error(),
}
} else {
prices[ticker] = StockPrice{
Ticker: ticker,
USD: usdPrice,
EUR: usdPrice * exchangeRate,
Success: true,
}
}
// Small delay to avoid rate limiting
time.Sleep(100 * time.Millisecond)
}
response := StockPricesResponse{
Prices: prices,
ExchangeRate: exchangeRate,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
func main() {
// API endpoint
http.HandleFunc("/api/stock-prices", stockPricesHandler)
// Serve static files from /app/static directory
fs := http.FileServer(http.Dir("/app/static"))
http.Handle("/", fs)
port := ":8080"
log.Printf("Server starting on port %s", port)
if err := http.ListenAndServe(port, nil); err != nil {
log.Fatal(err)
}
}