Initial
This commit is contained in:
37
.gitignore
vendored
Normal file
37
.gitignore
vendored
Normal 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
38
Dockerfile
Normal 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
222
README.md
Normal 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
13
docker-compose.yml
Normal 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
|
||||
518
index.html
Normal file
518
index.html
Normal 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
180
main.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user