commit e9360cbc08797468ac93d45b50892554bd25eab7 Author: Eden Kirin Date: Sun Oct 12 00:34:03 2025 +0200 Initial diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5aafc97 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5ec7c43 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..a19df30 --- /dev/null +++ b/README.md @@ -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 +``` diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..7fc815d --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..7e3f0b8 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module stock-analyzer + +go 1.21 diff --git a/index.html b/index.html new file mode 100644 index 0000000..3596005 --- /dev/null +++ b/index.html @@ -0,0 +1,518 @@ + + + + + + Stock Tax Analyzer + + + + + + +
+ + + + diff --git a/main.go b/main.go new file mode 100644 index 0000000..a97fef1 --- /dev/null +++ b/main.go @@ -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) + } +}