370 lines
10 KiB
Go
370 lines
10 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/csv"
|
|
"encoding/json"
|
|
"flag"
|
|
"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 Finance(w http.ResponseWriter, r *http.Request) { http.ServeFile(w, r, "./static/finance.html") }
|
|
func Cinema(w http.ResponseWriter, r *http.Request) { http.ServeFile(w, r, "./static/cinema.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 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() {
|
|
port := flag.String("port", "8081", "port to listen on")
|
|
flag.Parse()
|
|
|
|
http.HandleFunc("/styles.css", Styles)
|
|
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/" {
|
|
http.Redirect(w, r, "/", http.StatusFound)
|
|
return
|
|
}
|
|
About(w, r)
|
|
})
|
|
//http.HandleFunc("/portfolio", Portfolio)
|
|
http.HandleFunc("/infra", Infra)
|
|
http.HandleFunc("/finance", Finance)
|
|
http.HandleFunc("/cinema", Cinema)
|
|
http.HandleFunc("/cyber", Cyber)
|
|
http.HandleFunc("/git", func(w http.ResponseWriter, r *http.Request) {
|
|
http.Redirect(w, r, "https://git.samantha42.xyz", http.StatusFound)
|
|
})
|
|
|
|
fmt.Printf("running on http://localhost:%s/\n", *port)
|
|
if err := http.ListenAndServe(":"+*port, 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)
|
|
}
|