Files
personal-site/main.go
T
2026-03-16 13:08:54 +01:00

362 lines
9.7 KiB
Go

package main
import (
"encoding/csv"
"encoding/json"
"fmt"
"log"
"net/http"
"strings"
"sync"
"time"
)
// ---------- HTTP Handlers ----------
func HelloHandler(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/hello" {
http.Error(w, "404 not found", http.StatusNotFound)
return
}
if r.Method != "GET" {
http.Error(w, "method is not supported", http.StatusNotFound)
return
}
fmt.Fprintf(w, "Hello You")
}
func Home(w http.ResponseWriter, r *http.Request) { http.ServeFile(w, r, "./static/index.html") }
func Portfolio(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "./static/portfolio.html")
}
func Infra(w http.ResponseWriter, r *http.Request) { http.ServeFile(w, r, "./static/infra.html") }
func Cyber(w http.ResponseWriter, r *http.Request) { http.ServeFile(w, r, "./static/cyber.html") }
func About(w http.ResponseWriter, r *http.Request) { http.ServeFile(w, r, "./static/about.html") }
func GitPage(w http.ResponseWriter, r *http.Request) { http.ServeFile(w, r, "./static/gitpage.html") }
func GitPageTicTacToe(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "./static/gitpages/TicTacToe.html")
}
func Styles(w http.ResponseWriter, r *http.Request) { http.ServeFile(w, r, "./static/styles.css") }
// TradesHandler handles POST /trades
// It accepts a CSV file upload (field name: "file") and returns enriched trades as JSON.
//
// Example curl:
//
// curl -X POST http://localhost:8081/trades -F "file=@trades.csv"
func TradesHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "only POST is supported", http.StatusMethodNotAllowed)
return
}
// Parse multipart form (max 10MB)
if err := r.ParseMultipartForm(10 << 20); err != nil {
http.Error(w, "failed to parse form: "+err.Error(), http.StatusBadRequest)
return
}
file, _, err := r.FormFile("Trades.csc")
if err != nil {
http.Error(w, `missing "file" field in form-data`, http.StatusBadRequest)
return
}
defer file.Close()
// Parse CSV
reader := csv.NewReader(file)
reader.LazyQuotes = true
reader.TrimLeadingSpace = true
records, err := reader.ReadAll()
if err != nil {
http.Error(w, "invalid CSV: "+err.Error(), http.StatusBadRequest)
return
}
// Use most recent weekday as price date
priceDate := lastWeekday(time.Now().UTC().AddDate(0, 0, -1))
// Enrich and return as JSON
enriched, err := enrichRecords(records, priceDate)
if err != nil {
http.Error(w, "enrichment failed: "+err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(enriched)
}
// ---------- Main ----------
func main() {
http.HandleFunc("/styles.css", Styles)
http.HandleFunc("/", Home)
http.HandleFunc("/about", About)
http.HandleFunc("/portfolio", Portfolio)
http.HandleFunc("/infra", Infra)
http.HandleFunc("/cyber", Cyber)
http.HandleFunc("/gitpage", GitPage)
http.HandleFunc("/gitpage/TicTacToe", GitPageTicTacToe)
http.HandleFunc("/trades", TradesHandler)
http.HandleFunc("/api/prices", PricesHandler)
fmt.Println("running on http://localhost:8081/")
if err := http.ListenAndServe(":8081", nil); err != nil {
log.Fatal(err)
}
}
// ---------- Trade type ----------
type Trade struct {
DataDiscriminator string `json:"data_discriminator"`
AssetCategory string `json:"asset_category"`
Currency string `json:"currency"`
Symbol string `json:"symbol"`
DateTime string `json:"date_time"`
Quantity string `json:"quantity"`
TradePrice string `json:"trade_price"`
CurrentPrice float64 `json:"current_price"` // enriched from EOD
Proceeds string `json:"proceeds"`
CommFee string `json:"comm_fee"`
Basis string `json:"basis"`
RealizedPL string `json:"realized_pl"`
MTMLPL string `json:"mtm_pl"`
Code string `json:"code"`
}
// ---------- Enrich logic ----------
func enrichRecords(records [][]string, priceDate time.Time) ([]Trade, error) {
// Collect unique tickers from Data/Order rows
type key struct{ symbol, currency string }
needed := map[key]struct{}{}
for _, row := range records {
if len(row) <= colCPrice {
continue
}
if row[colType] == "Trades" && row[colHeader] == "Data" && row[colDataDisc] == "Order" {
sym := strings.TrimSpace(row[colSymbol])
cur := strings.TrimSpace(row[colCurrency])
if t := toYahooTicker(sym, cur); t != "" {
needed[key{t, cur}] = struct{}{}
}
}
}
// Fetch prices concurrently
prices := map[string]float64{}
var mu sync.Mutex
var wg sync.WaitGroup
for k := range needed {
wg.Add(1)
go func(ticker string) {
defer wg.Done()
eod, err := FetchEOD(ticker, priceDate)
mu.Lock()
defer mu.Unlock()
if err != nil {
log.Printf("[warn] %s: %v", ticker, err)
} else {
prices[ticker] = eod.Close
}
}(k.symbol)
}
wg.Wait()
// Build response
var trades []Trade
for _, row := range records {
if len(row) <= colCPrice {
continue
}
if row[colType] != "Trades" || row[colHeader] != "Data" || row[colDataDisc] != "Order" {
continue
}
sym := strings.TrimSpace(row[colSymbol])
cur := strings.TrimSpace(row[colCurrency])
ticker := toYahooTicker(sym, cur)
var currentPrice float64
if ticker != "" {
currentPrice = prices[ticker]
}
t := Trade{
DataDiscriminator: strings.TrimSpace(row[colDataDisc]),
AssetCategory: strings.TrimSpace(row[colAssetCategory]),
Currency: cur,
Symbol: sym,
DateTime: safeCol(row, colDateTime),
Quantity: safeCol(row, colQuantity),
TradePrice: safeCol(row, colTPrice),
CurrentPrice: currentPrice,
Proceeds: safeCol(row, 10),
CommFee: safeCol(row, 11),
Basis: safeCol(row, 12),
RealizedPL: safeCol(row, 13),
MTMLPL: safeCol(row, 14),
Code: safeCol(row, 15),
}
trades = append(trades, t)
}
return trades, nil
}
func safeCol(row []string, i int) string {
if i < len(row) {
return strings.TrimSpace(row[i])
}
return ""
}
func lastWeekday(t time.Time) time.Time {
for t.Weekday() == time.Saturday || t.Weekday() == time.Sunday {
t = t.AddDate(0, 0, -1)
}
return t
}
const (
colType = 0
colHeader = 1
colDataDisc = 2
colAssetCategory = 3
colCurrency = 4
colSymbol = 5
colDateTime = 6
colQuantity = 7
colTPrice = 8
colCPrice = 9
)
var tickerOverrides = map[string]string{
"NOVOBc": "NOVO-B.CO",
}
func toYahooTicker(ibkrSymbol, currency string) string {
if override, ok := tickerOverrides[ibkrSymbol]; ok {
return override
}
if strings.Contains(ibkrSymbol, " ") {
return "" // skip options
}
return ibkrSymbol
}
type EODPrice struct {
Ticker string
Date string
Open float64
High float64
Low float64
Close float64
Currency string
LatestPrice float64 // regularMarketPrice from meta — most recent quote
LatestTime time.Time
PreviousClose float64
ChangePercent float64
}
func FetchEOD(ticker string, date time.Time) (*EODPrice, error) {
start := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, time.UTC)
end := start.Add(24 * time.Hour)
url := fmt.Sprintf(
"https://query1.finance.yahoo.com/v8/finance/chart/%s?period1=%d&period2=%d&interval=1d",
ticker, start.Unix(), end.Unix(),
)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "Mozilla/5.0")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var payload struct {
Chart struct {
Result []struct {
Meta struct {
Currency string `json:"currency"`
RegularMarketPrice float64 `json:"regularMarketPrice"`
RegularMarketTime int64 `json:"regularMarketTime"`
ChartPreviousClose float64 `json:"chartPreviousClose"`
RegularMarketChangePercent float64 `json:"regularMarketChangePercent"`
} `json:"meta"`
Indicators struct {
Quote []struct {
Open []float64 `json:"open"`
High []float64 `json:"high"`
Low []float64 `json:"low"`
Close []float64 `json:"close"`
} `json:"quote"`
} `json:"indicators"`
} `json:"result"`
Error *struct {
Description string `json:"description"`
} `json:"error"`
} `json:"chart"`
}
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
return nil, err
}
if payload.Chart.Error != nil {
return nil, fmt.Errorf("yahoo: %s", payload.Chart.Error.Description)
}
if len(payload.Chart.Result) == 0 {
return nil, fmt.Errorf("no data for %s on %s", ticker, date.Format("2006-01-02"))
}
r := payload.Chart.Result[0]
q := r.Indicators.Quote[0]
if len(q.Close) == 0 || q.Close[0] == 0 {
return nil, fmt.Errorf("no close price for %s on %s", ticker, date.Format("2006-01-02"))
}
return &EODPrice{
Ticker: ticker,
Date: date.Format("2006-01-02"),
Open: q.Open[0],
High: q.High[0],
Low: q.Low[0],
Close: q.Close[0],
Currency: r.Meta.Currency,
LatestPrice: r.Meta.RegularMarketPrice,
LatestTime: time.Unix(r.Meta.RegularMarketTime, 0).UTC(),
PreviousClose: r.Meta.ChartPreviousClose,
ChangePercent: r.Meta.RegularMarketChangePercent,
}, nil
}
func PricesHandler(w http.ResponseWriter, r *http.Request) {
var req struct {
Tickers []string `json:"tickers"`
}
json.NewDecoder(r.Body).Decode(&req)
priceDate := lastWeekday(time.Now().UTC().AddDate(0, 0, -1))
var results []EODPrice
for _, t := range req.Tickers {
eod, err := FetchEOD(t, priceDate)
if err == nil {
results = append(results, *eod)
}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(results)
}