// Package quotes retrieves daily closing prices for listed instruments so a // holding can be valued without anyone typing a price by hand. Prices enter the // journal as exact decimals: a float would make two runs of the same valuation // disagree in the last cents. package quotes import ( "context" "encoding/json" "fmt" "io" "net/http" "net/url" "regexp" "strconv" "strings" "time" "finance-duck/internal/domain" ) // Client fetches the latest close for a market symbol. It holds no mutable // state, so a zero Client is usable and a copy is as good as the original. type Client struct { HTTPClient *http.Client BaseURL string // defaults to https://query1.finance.yahoo.com } // Quote is one instrument's latest close. Symbol is the caller's own symbol // rather than the one echoed by the provider, so nothing derived from response // text can end up keyed against an instrument. type Quote struct { Symbol string Price domain.Quantity Currency string Day string // YYYY-MM-DD } // Error reports a price lookup that failed for a reason Finance Duck // determined itself: the provider could not be reached, or its response could // not be used. Reason is written here and never taken from provider response // text, so callers may show the whole message to the user. Returning it for // every provider failure lets a caller tell provider trouble apart from a // programming error such as an unusable base URL. type Error struct { Symbol string Reason string } func (e Error) Error() string { if e.Symbol == "" { return "price lookup failed: " + e.Reason } return "price lookup for " + e.Symbol + " failed: " + e.Reason } // symbolPattern admits the listing symbols the chart endpoint uses, including // exchange suffixes ("VWCE.DE"), share classes ("BRK-B"), indices ("^GSPC") // and currency pairs ("EURUSD=X"). Anything else is rejected before a request // is built, so no caller-supplied text can reshape the request path. var symbolPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9.=^-]{0,31}$`) var currencyPattern = regexp.MustCompile(`^[A-Z]{3}$`) // defaultTimeout caps a lookup including the response read. A scheduled // refresh walks many instruments, so one unresponsive symbol must not hold the // whole run. const defaultTimeout = 15 * time.Second // A version-pinned desktop agent, not a bare "Mozilla/5.0": a real-looking // string is what the endpoint serves, and it carries no identifying data. const userAgent = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36" // maxResponse bounds the chart response. Five daily candles are a few kilobytes // even with the metadata Yahoo attaches; a megabyte is a decoding accident. const maxResponse = 1 << 20 // Latest returns the most recent usable close for symbol. A day whose close is // still null (today before the exchange settles, or a holiday) is skipped, so // the five-day window is what makes a Monday morning refresh return Friday's // price instead of nothing. func (c Client) Latest(ctx context.Context, symbol string) (Quote, error) { if !symbolPattern.MatchString(symbol) || strings.Contains(symbol, "..") { return Quote{}, Error{Symbol: symbol, Reason: "the symbol is not a valid market listing"} } base := strings.TrimRight(c.BaseURL, "/") if base == "" { base = "https://query1.finance.yahoo.com" } endpoint, err := url.Parse(base) if err != nil || endpoint.Host == "" || endpoint.User != nil || endpoint.RawQuery != "" || endpoint.Fragment != "" { return Quote{}, Error{Symbol: symbol, Reason: "the configured price provider address is invalid"} } // Plain HTTP is allowed only for a loopback stub; a real lookup must not // take prices from an unauthenticated connection. if endpoint.Scheme != "https" && !(endpoint.Scheme == "http" && (endpoint.Hostname() == "localhost" || endpoint.Hostname() == "127.0.0.1" || endpoint.Hostname() == "::1")) { return Quote{}, Error{Symbol: symbol, Reason: "the price provider address must use HTTPS"} } request, err := http.NewRequestWithContext(ctx, http.MethodGet, base+"/v8/finance/chart/"+url.PathEscape(symbol)+"?range=5d&interval=1d", nil) if err != nil { return Quote{}, Error{Symbol: symbol, Reason: "the price request could not be created"} } request.Header.Set("Accept", "application/json") // The endpoint answers 429 to every request whose User-Agent names a // programming language, whatever the rate: an empty or Go-default agent is // refused on the first call of the day, a browser agent is served. This is // the price of an unkeyed provider and the only reason a real symbol // resolves at all. request.Header.Set("User-Agent", userAgent) client := http.Client{Timeout: defaultTimeout} if c.HTTPClient != nil { client = *c.HTTPClient if client.Timeout <= 0 { client.Timeout = defaultTimeout } } // A redirect to a consent or login page would answer with HTML that only // fails later and less clearly than the redirect status itself. client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse } response, err := client.Do(request) if err != nil { // Cancellation and deadlines keep their identity: a caller shutting the // scheduler down must not read that as the provider being broken. if cause := ctx.Err(); cause != nil { return Quote{}, cause } return Quote{}, Error{Symbol: symbol, Reason: "the price provider could not be reached"} } defer response.Body.Close() if response.StatusCode != http.StatusOK { return Quote{}, Error{Symbol: symbol, Reason: fmt.Sprintf("the price provider returned HTTP %d", response.StatusCode)} } var envelope struct { Chart struct { Result []struct { Meta struct { Currency string `json:"currency"` } `json:"meta"` Timestamp []int64 `json:"timestamp"` Indicators struct { Quote []struct { // json.Number keeps the provider's own decimal text: the // price must never pass through a float. A null close // decodes as the empty string and means "no trading". Close []json.Number `json:"close"` } `json:"quote"` } `json:"indicators"` } `json:"result"` Error json.RawMessage `json:"error"` } `json:"chart"` } // Unknown keys are tolerated because Yahoo adds metadata freely, but the // fields read below are decoded strictly. The limit bounds the decode // itself, so an oversized response fails as a truncated document. decoder := json.NewDecoder(io.LimitReader(response.Body, maxResponse)) if err := decoder.Decode(&envelope); err != nil { if cause := ctx.Err(); cause != nil { return Quote{}, cause } return Quote{}, Error{Symbol: symbol, Reason: "the price provider sent a response that could not be read"} } if len(envelope.Chart.Error) > 0 && string(envelope.Chart.Error) != "null" { return Quote{}, Error{Symbol: symbol, Reason: "the price provider reported an error for this symbol"} } if len(envelope.Chart.Result) == 0 { return Quote{}, Error{Symbol: symbol, Reason: "the price provider knows no data for this symbol"} } result := envelope.Chart.Result[0] if !currencyPattern.MatchString(result.Meta.Currency) { return Quote{}, Error{Symbol: symbol, Reason: "the price provider did not report a currency"} } if len(result.Indicators.Quote) == 0 { return Quote{}, Error{Symbol: symbol, Reason: "the price provider returned no closing prices"} } closes := result.Indicators.Quote[0].Close // Walk backwards for the newest close that actually traded, and keep the // timestamp of that same candle: the day shown must be the day priced. for i := len(closes) - 1; i >= 0; i-- { if closes[i] == "" { continue } if i >= len(result.Timestamp) || result.Timestamp[i] <= 0 { return Quote{}, Error{Symbol: symbol, Reason: "the price provider returned a closing price without a date"} } price, err := decimalQuantity(string(closes[i])) if err != nil { return Quote{}, Error{Symbol: symbol, Reason: "the price provider returned an unusable closing price"} } if units, err := price.Units(); err != nil || units <= 0 { return Quote{}, Error{Symbol: symbol, Reason: "the price provider returned a closing price that is not positive"} } return Quote{ Symbol: symbol, Price: price, Currency: result.Meta.Currency, Day: time.Unix(result.Timestamp[i], 0).UTC().Format("2006-01-02"), }, nil } return Quote{}, Error{Symbol: symbol, Reason: "the price provider returned no closing price for the last five days"} } // quantityScale is the journal's eight fractional places, and maxUnitDigits // bounds the scaled result: a price needing more than eight digits before the // point is not a security price, and the bound keeps the value inside the // signed 64-bit units the journal stores. const quantityScale = 8 const maxUnitDigits = 8 + quantityScale // significantDigits is where a provider price stops being price and starts // being float noise. Yahoo's closes are 32-bit floats widened to 64: a real // response carries 165.26 as "165.25999450683594" and 9.408 as // "9.4079999923706". A 32-bit float holds 24 bits of mantissa, which is 7.22 // decimal digits, so the eighth digit onwards is an artefact of the encoding // and never a figure that traded - rounding at eight would keep the visible // nonsense "165.25999". Seven recovers the decimal the exchange published for // every price quoted to cents, which is every equity and fund price, and is // still four orders of magnitude finer than a price needs to value a holding. const significantDigits = 7 // decimalQuantity converts a provider's decimal literal to the journal's // eight-place scale, working on the digit text so the value never passes // through binary floating point. It rounds to significantDigits and then to // eight fractional places, half rounding away from zero both times. Exponent // notation is rejected rather than guessed at: the endpoint does not use it, // and a price misread by a factor of ten is worse than a failed refresh. func decimalQuantity(text string) (domain.Quantity, error) { invalid := fmt.Errorf("invalid decimal price") negative := strings.HasPrefix(text, "-") literal := strings.TrimPrefix(text, "-") whole, fraction, point := strings.Cut(literal, ".") // A trailing or repeated point, or digits absent on either side, is not a // number this endpoint produces; so is exponent notation, caught by the // digit scan below. if whole == "" || (point && fraction == "") || strings.Contains(fraction, ".") { return "", invalid } digits := whole + fraction for i := range len(digits) { if digits[i] < '0' || digits[i] > '9' { return "", invalid } } // value holds the significant digits and exponent counts how many of them // stand before the decimal point, so the point can move under rounding // without the digits being re-parsed. value := []byte(strings.TrimLeft(digits, "0")) exponent := len(whole) - (len(digits) - len(value)) if len(value) == 0 { return domain.FormatQuantity(0), nil } if len(value) > significantDigits { roundUp := value[significantDigits] >= '5' value = value[:significantDigits] if roundUp { // A carry off the front ("99999999" to "100000000") moves the point. if value = increment(value); len(value) > significantDigits { exponent++ } } } // Scale to hundred-millionths: appending zeros multiplies, and dropping // digits divides with the same half-away-from-zero rounding. if shift := exponent - len(value) + quantityScale; shift >= 0 { value = append(value, strings.Repeat("0", shift)...) } else if keep := len(value) + shift; keep < 0 { value = []byte("0") } else { roundUp := value[keep] >= '5' value = value[:keep] if len(value) == 0 { value = []byte("0") } if roundUp { value = increment(value) } } if len(value) > maxUnitDigits { return "", invalid } units, err := strconv.ParseInt(string(value), 10, 64) if err != nil { return "", invalid } if negative { units = -units } return domain.FormatQuantity(units), nil } // increment adds one to a decimal digit string, growing it when the carry runs // off the front ("999" becomes "1000"). Rounding up the last kept place of // 0.99999999|9 has to carry into the whole part, not wrap it. func increment(digits []byte) []byte { for i := len(digits) - 1; i >= 0; i-- { if digits[i] != '9' { digits[i]++ return digits } digits[i] = '0' } return append([]byte{'1'}, digits...) }