first
This commit is contained in:
@@ -0,0 +1,361 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user