Yahoo stopped returning capitalGain events for some funds (2026); a blind os.Create + write of an empty event set overwrote the only copy of that history. When the new download has zero rows of an event kind and the existing CSV has data rows, keep the existing file. (gmp/f data.py also falls back to overrides/event-backup/ as a second line of defense.)
272 lines
6.8 KiB
Go
272 lines
6.8 KiB
Go
package ohlc
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// exchangeTimezone returns the exchange's IANA zone (from meta), falling back
|
|
// to the most recent gmtoffset, then UTC. The old code formatted dates in the
|
|
// machine's local timezone, which shifts dates by a day for any exchange in
|
|
// a different zone.
|
|
func exchangeTimezone(result map[string]interface{}) *time.Location {
|
|
if meta, ok := result["meta"].(map[string]interface{}); ok {
|
|
if name, ok := meta["exchangeTimezoneName"].(string); ok && name != "" {
|
|
if loc, err := time.LoadLocation(name); err == nil {
|
|
return loc
|
|
}
|
|
}
|
|
if off, ok := meta["gmtoffset"].(float64); ok {
|
|
return time.FixedZone("gmt", int(off))
|
|
}
|
|
}
|
|
return time.UTC
|
|
}
|
|
|
|
// eventRow is one dividend/capital-gain/split, flattened for sorting.
|
|
type eventRow struct {
|
|
epoch float64
|
|
date string
|
|
amount float64
|
|
ratio string // splits only
|
|
}
|
|
|
|
// eventRows flattens an events map, sorts rows by date (JSON object iteration
|
|
// order is random, so sorting is required for stable output) and formats each
|
|
// date in the exchange zone. kind is used in error messages.
|
|
func eventRows(name, kind string, events map[string]interface{}, loc *time.Location) []eventRow {
|
|
rows := make([]eventRow, 0, len(events))
|
|
for _, v := range events {
|
|
v2, ok := v.(map[string]interface{})
|
|
if !ok {
|
|
fmt.Println(name + ": Bad format (" + kind + ")")
|
|
return nil
|
|
}
|
|
t, ok := v2["date"].(float64)
|
|
if !ok {
|
|
fmt.Println(name + ": Bad format (" + kind + " date)")
|
|
return nil
|
|
}
|
|
r := eventRow{epoch: t, date: time.Unix(int64(t), 0).In(loc).Format("2006-01-02")}
|
|
if v2["amount"] != nil {
|
|
r.amount = v2["amount"].(float64)
|
|
} else if kind != "split" {
|
|
fmt.Println(name + ": Bad format (" + kind + " amount)")
|
|
return nil
|
|
}
|
|
if v2["splitRatio"] != nil {
|
|
r.ratio = v2["splitRatio"].(string)
|
|
} else if kind == "split" {
|
|
fmt.Println(name + ": Bad format (split ratio)")
|
|
return nil
|
|
}
|
|
rows = append(rows, r)
|
|
}
|
|
sort.Slice(rows, func(i, j int) bool { return rows[i].epoch < rows[j].epoch })
|
|
return rows
|
|
}
|
|
|
|
func Conv(name string) {
|
|
text, err := os.ReadFile(name + ".json")
|
|
if err != nil {
|
|
fmt.Printf("%s: %s\n", name, err)
|
|
return
|
|
}
|
|
|
|
var i interface{}
|
|
err = json.Unmarshal(text, &i)
|
|
if err != nil {
|
|
fmt.Printf("%s: %s\n", name, err)
|
|
return
|
|
}
|
|
|
|
j, ok := i.(map[string]interface{})
|
|
if !ok {
|
|
fmt.Println(name + ": Bad format")
|
|
return
|
|
}
|
|
|
|
j, ok = j["chart"].(map[string]interface{})
|
|
if !ok {
|
|
fmt.Println(name + ": Bad format (chart)")
|
|
return
|
|
}
|
|
|
|
x, ok := j["result"].([]interface{})
|
|
if !ok {
|
|
fmt.Println(name + ": Bad format (result)")
|
|
return
|
|
}
|
|
|
|
result, ok := x[0].(map[string]interface{})
|
|
if !ok {
|
|
fmt.Println(name + ": Bad format (result[0])")
|
|
return
|
|
}
|
|
|
|
timestamp, ok := result["timestamp"].([]interface{})
|
|
if !ok {
|
|
fmt.Println(name + ": Bad format (timestamp)")
|
|
return
|
|
}
|
|
|
|
indicators, ok := result["indicators"].(map[string]interface{})
|
|
if !ok {
|
|
fmt.Println(name + ": Bad format (indicators)")
|
|
return
|
|
}
|
|
|
|
events, ok := result["events"].(map[string]interface{})
|
|
|
|
dividends, ok := events["dividends"].(map[string]interface{})
|
|
|
|
capgains, ok := events["capitalGains"].(map[string]interface{})
|
|
|
|
splits, ok := events["splits"].(map[string]interface{})
|
|
|
|
x, ok = indicators["quote"].([]interface{})
|
|
if !ok {
|
|
fmt.Println(name + ": Bad format (quote)")
|
|
return
|
|
}
|
|
|
|
quote, ok := x[0].(map[string]interface{})
|
|
if !ok {
|
|
fmt.Println(name + ": Bad format (quote[0])")
|
|
return
|
|
}
|
|
|
|
x, ok = indicators["adjclose"].([]interface{})
|
|
if !ok {
|
|
fmt.Println(name + ": Bad format (adjclose)")
|
|
return
|
|
}
|
|
|
|
a, ok := x[0].(map[string]interface{})
|
|
if !ok {
|
|
fmt.Println(name + ": Bad format (adjclose[0])")
|
|
return
|
|
}
|
|
|
|
adjclose, ok := a["adjclose"].([]interface{})
|
|
if !ok {
|
|
fmt.Println(name + ": Bad format (adjclose[adjclose])")
|
|
return
|
|
}
|
|
|
|
open, ok := quote["open"].([]interface{})
|
|
if !ok {
|
|
fmt.Println(name + ": Bad format (open)")
|
|
return
|
|
}
|
|
|
|
high, ok := quote["high"].([]interface{})
|
|
if !ok {
|
|
fmt.Println(name + ": Bad format (high)")
|
|
return
|
|
}
|
|
|
|
low, ok := quote["low"].([]interface{})
|
|
if !ok {
|
|
fmt.Println(name + ": Bad format (low)")
|
|
return
|
|
}
|
|
|
|
close, ok := quote["close"].([]interface{})
|
|
if !ok {
|
|
fmt.Println(name + ": Bad format (close)")
|
|
return
|
|
}
|
|
|
|
volume, ok := quote["volume"].([]interface{})
|
|
if !ok {
|
|
fmt.Println(name + ": Bad format (volume)")
|
|
return
|
|
}
|
|
|
|
loc := exchangeTimezone(result)
|
|
|
|
out, err := os.Create(name + "-history.csv")
|
|
if err != nil {
|
|
fmt.Printf("%s: %s\n", name, err)
|
|
os.Exit(-1)
|
|
}
|
|
defer out.Close()
|
|
|
|
out.WriteString("Date,Open,High,Low,Close,Adj Close,Volume\n")
|
|
// Yahoo occasionally emits the same timestamp twice; keep the last one.
|
|
seen := make(map[int64]int, len(timestamp))
|
|
for k, v := range timestamp {
|
|
seen[int64(v.(float64))] = k
|
|
}
|
|
for k, v := range timestamp {
|
|
if seen[int64(v.(float64))] != k {
|
|
continue
|
|
}
|
|
if open[k] == nil {
|
|
continue
|
|
}
|
|
vol := 0
|
|
if volume[k] != nil {
|
|
vol = int(volume[k].(float64))
|
|
}
|
|
out.WriteString(fmt.Sprintf("%s,%f,%f,%f,%f,%f,%d\n",
|
|
time.Unix(int64(v.(float64)), 0).In(loc).Format("2006-01-02"),
|
|
open[k], high[k], low[k], close[k],
|
|
adjclose[k], vol))
|
|
}
|
|
if dividends != nil {
|
|
writeEvents(name, name+"-dividend.csv", "Date,Dividends",
|
|
eventRows(name, "dividend", dividends, loc),
|
|
func(r eventRow) string { return fmt.Sprintf("%s,%f", r.date, r.amount) })
|
|
}
|
|
|
|
if capgains != nil {
|
|
writeEvents(name, name+"-capitalGain.csv", "Date,Capital Gains",
|
|
eventRows(name, "capital gain", capgains, loc),
|
|
func(r eventRow) string { return fmt.Sprintf("%s,%f", r.date, r.amount) })
|
|
}
|
|
|
|
if splits != nil {
|
|
writeEvents(name, name+"-split.csv", "Date,Stock Splits",
|
|
eventRows(name, "split", splits, loc),
|
|
func(r eventRow) string { return fmt.Sprintf("%s,%s", r.date, r.ratio) })
|
|
}
|
|
}
|
|
|
|
// fileHasDataRows reports whether path exists and has a row beyond the header.
|
|
func fileHasDataRows(path string) bool {
|
|
b, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
lines := strings.Split(strings.TrimSpace(string(b)), "\n")
|
|
return len(lines) > 1 && strings.TrimSpace(lines[1]) != ""
|
|
}
|
|
|
|
// writeEvents writes the event rows to path, unless the download carries no
|
|
// rows of this kind while an existing populated file is present: Yahoo has
|
|
// stopped returning capitalGain events for some funds (2026), and a blind
|
|
// overwrite would wipe the only copy of that history. (The consumer side,
|
|
// gmp/f data.py, additionally falls back to a backup directory.)
|
|
func writeEvents(name, path, header string, rows []eventRow, format func(eventRow) string) {
|
|
if len(rows) == 0 && fileHasDataRows(path) {
|
|
fmt.Printf("%s: no %s events in download; keeping existing %s\n", name, header, path)
|
|
return
|
|
}
|
|
out, err := os.Create(path)
|
|
if err != nil {
|
|
fmt.Printf("%s: %s\n", name, err)
|
|
os.Exit(-1)
|
|
}
|
|
defer out.Close()
|
|
out.WriteString(header + "\n")
|
|
for _, r := range rows {
|
|
out.WriteString(format(r) + "\n")
|
|
}
|
|
}
|