Shop It Docs
Stock Analysis (Fundamentals)

Caching

Redis cache keys, TTLs, and singleflight coalescing across all stock-analysis endpoints

Every stock-analysis endpoint is Redis-backed. Cache keys are centralised in the umbrella's cachekey.go; per-feature handlers mirror the same string locally to avoid an import cycle into the parent package. A observability_pins_test walks the tree and asserts both copies stay byte-equivalent.

TTL summary

EndpointKey patternTTL
GET /companies/{symbol}/fundamentals/snapshotnepse:fundamentals:snapshot:{SYM}5 min
GET /companies/{symbol}/fundamentals/statementsnepse:fundamentals:statements:{SYM}:{type}:{period}:y{years}10 min
GET /companies/{symbol}/fundamentals/ratiosnepse:fundamentals:ratios:{SYM}:{tab}10 min
GET /companies/{symbol}/fundamentals/dividendsnepse:fundamentals:dividends:{SYM}:y{years}:d{upcomingDays}30 min
GET /companies/{symbol}/fundamentals/ownershipnepse:fundamentals:ownership:{SYM}1 hr
GET /companies/{symbol}/fundamentals/peersnepse:fundamentals:peers:{FOCAL}:{sortedPeers}10 min
GET /companies/{symbol}/fundamentals/announcementsnepse:fundamentals:announcements:{SYM}:t{sortedTypes}:f{from}:x{to}:l{limit}5 min
GET /companies/{symbol}/fundamentals/fair-valuenepse:fundamentals:fair-value:{SYM}10 min
GET /upcoming-book-closesnepse:fundamentals:upcoming-book-closes:d{days}:l{limit}1 hr

Cache-prefix constants

Defined once in internal/modules/nepse/fundamentals/stock_analysis/cachekey.go:

const (
    PrefixSnapshot           = "nepse:fundamentals:snapshot:"
    PrefixDividends          = "nepse:fundamentals:dividends:"
    PrefixUpcomingBookCloses = "nepse:fundamentals:upcoming-book-closes:"
    PrefixStatements         = "nepse:fundamentals:statements:"
    PrefixRatios             = "nepse:fundamentals:ratios:"
    PrefixOwnership          = "nepse:fundamentals:ownership:"
    PrefixPeers              = "nepse:fundamentals:peers:"
    PrefixAnnouncements      = "nepse:fundamentals:announcements:"
    PrefixFairValue          = "nepse:fundamentals:fair-value:"
)

Each handler keeps a local const cachePrefix = "..." mirror to avoid importing the parent package (cyclic). Drift between the two is caught by internal/goldenjson/observability_pins_test.go.

Singleflight coalescing

Every endpoint uses cache.GetOrSetTyped(ctx, cache, key, ttl, loader). The helper de-duplicates concurrent requests for the same key — a stampede on a popular ticker triggers exactly one DB round-trip set, and every concurrent caller receives the same response.

val, _, err := cache.GetOrSetTyped(
    ctx, h.cache, key, cacheTTL,
    func(ctx context.Context) (*Response, error) {
        return h.svc.<Feature>(ctx, …)
    },
)

No write-side invalidation

The stock-analysis module is read-only. There are no write endpoints, and the cache has no manual bust path — TTL alone governs freshness.

The cadences are:

Upstream signalRefresh intervalAffected endpoint TTL
Live price (LTP) pollerevery few seconds5 min on snapshot, 10 min on the rest
Financial reports syncweekly5–60 min
Dividend / corporate-action syncnightly30 min – 1 hr
Phase A operator uploadsmanual1 hr (ownership)
Company profile (BOD, management)weekly1 hr (ownership)

The TTLs are intentionally short for surfaces with fast-moving inputs (LTP → P/E ratio) and long for surfaces with slow-moving inputs (BOD, company profile).

When the cache layer is nil (test harness, local dev with CACHE_DISABLED=true), handlers fall through to direct service calls. No code path silently 500s because of an absent Redis.

Pagination + sorting affect the cache key

Query parameters that change the response shape are part of the cache key:

  • statements: type, period, years
  • ratios: tab
  • dividends: years, upcomingDays
  • peers: focal symbol + the sorted comma-joined peer list (so different orderings collapse to one entry)
  • announcements: sorted chip set, from, to, limit
  • upcoming-book-closes (global): days, limit

Sorting before forming the cache key is a deliberate cache-hit optimisation — ?symbols=A,B,C and ?symbols=C,B,A produce the same comparison; they should hit the same cache entry.

Cache miss handling

A miss never blocks the response. cache.GetOrSetTyped falls through to the loader on any cache error and logs a slog.Warn — Postgres remains the source of truth.

val, _, err := cache.GetOrSetTyped(ctx, h.cache, key, ttl, loader)
// err != nil only when both cache AND loader failed.