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