Shop It Docs
Stock Analysis (Fundamentals)

Architecture

Package layout, bundle wiring, and the compute-on-read pipeline for the stock-analysis umbrella

The stock-analysis umbrella is one parent package at internal/modules/nepse/fundamentals/stock_analysis/ with one sub-package per feature. Each sub-package owns its own DTOs, Service, Handler, Store interface, and tests. A single Bundle constructor wires all eight into one routing surface so cmd/server/main.go and the router stay short.

Package layout

internal/modules/nepse/fundamentals/stock_analysis/
├── cachekey.go          # Centralised Redis key prefixes (pinned by tests)
├── deps.go              # Bundle type + NewBundle constructor
├── routes.go            # Per-symbol + global chi route tables
├── doc.go               # Umbrella package doc
├── snapshot/            # /fundamentals/snapshot
├── statements/          # /fundamentals/statements
├── ratios/              # /fundamentals/ratios (canonical ratio engine)
├── dividends/           # /fundamentals/dividends + /upcoming-book-closes (global)
├── ownership/           # /fundamentals/ownership
├── peers/               # /fundamentals/peers (imports ratios.ComputeTab)
├── announcements/       # /fundamentals/announcements
├── fair_value/          # /fundamentals/fair-value (imports ratios helpers)
└── internal/
    ├── store/           # SnapshotReader interface (3 shared sqlc reads)
    └── goldenjson/      # Cross-feature JSON-shape pin tests

stock_analysis/ sits under the nepse/fundamentals/ umbrella alongside future siblings (sector_analysis/, compare_stocks/, advanced_screener/, pro_fundamentals/) — see docs/FUNDAMENTALS_REFACTOR_PLAN.md §3.

Per-feature contract

Each sub-package follows the same five-file shape so a fresh reader can navigate any of them by muscle memory:

FilePurpose
types.goResponse DTO, request-shape constants, package doc
service.goCompute-on-read logic; takes a Store + taxonomyRegistryProvider
store.goType alias over sqlc.Queries (or internalstore.SnapshotReader)
handler.goHTTP entrypoint + Redis cache wrapper + query-string parsing
service_test.goTable-driven tests against a fake store

The handler exposes a single method HTTP(w, r) registered by the parent Routes() function. Caches are keyed off package-local cachePrefix constants that mirror the umbrella's cachekey.go — drift is caught by the observability_pins_test.

Bundle wiring

deps.go constructs every service + handler in one call:

type Bundle struct {
    Snapshot      *snapshot.Handler
    Statements    *statements.Handler
    Ratios        *ratios.Handler
    Dividends     *dividends.Handler
    Ownership     *ownership.Handler
    Peers         *peers.Handler
    Announcements *announcements.Handler
    FairValue     *fairvalue.Handler
}

func NewBundle(q *sqlc.Queries, tax *taxonomy.Manager, c *cache.Cache) *Bundle

cmd/server/main.go:

stockAnalysisBundle := stockanalysis.NewBundle(queries, taxonomyManager, cacheLayer)
// → passed into router deps

internal/http/router/router.go:

r.Group(func(r chi.Router) {
    r.Use(rateLimit(rl30))
    // ...
    if deps.StockAnalysis != nil {
        stockanalysis.Routes(r, deps.StockAnalysis)         // 8 per-symbol routes
        stockanalysis.GlobalRoutes(r, deps.StockAnalysis)   // /upcoming-book-closes
    }
})

The per-symbol routes are registered flat (full path on each r.Get(...)) rather than via r.Route("/companies/{symbol}/fundamentals", ...) — see the comment in routes.go for the historical reason (legacy facade coexistence during P2–P9).

The compute-on-read pipeline

Every endpoint follows the same shape:

HTTP request

Handler — extract symbol (case-folded) + query params (clamped)

cache.GetOrSetTyped(key, ttl, loader)   ← singleflight-coalesced
   ↓ (miss only)
Service.<Feature>(ctx, …)
   ├─ Store.GetFundamentalsSnapshotCore(symbol)        → 404 on pgx.ErrNoRows
   ├─ Store.GetFundamentalsSnapshotReports(symbol)     → cached items_map JSON
   ├─ Store.GetFundamentalsSnapshotDividends(symbol)
   └─ feature-specific reads (Phase A overrides, peer cohort, etc.)

compute.* helpers + taxonomy.Registry.LookupValue(...)

Response DTO with MetricFloat64 envelopes + DataAsOf/ServedAt

response.JSON(w, 200, "ok", out)

Three queries — core, reports, dividends — are shared across snapshot, ratios, and peers via the internalstore.SnapshotReader interface so a column rename in fundamentals_snapshot.sql ripples to one seam.

The taxonomy registry

Financial-report line items arrive from upstream (NEPSE MDP) as items_map JSONB blobs with arbitrary sector-specific keys. The taxonomy registry (internal/modules/nepse/financial_reports/taxonomy/) maps those raw keys to a canonical row vocabulary (Revenue, NetInterestIncome, TotalAssets, EarningsPerShare, …) per sector.

reg.LookupValue(items, precisionValue, "Income Statement", taxonomy.NetProfit, sector) (float64, bool)

Each service holds a taxonomyRegistryProvider (interface with Registry() *taxonomy.Registry) that production wires to *taxonomy.Manager (hot-reloadable) and tests stub with a fixed Registry. The 3-line provider interface is replicated per-feature rather than shared — see plan §4 inventory.

v4.5 status

The plan's v4.5 milestone (current) intentionally limits several surfaces until multi-quarter / multi-year coverage exists:

  • Ratios.growth tab — every row returns status: insufficient_history.
  • Ratios.sectorAvg — null on every row; Note explains the deferral.
  • Statements.cashflow — short-circuits to available:false.
  • Statements.period=annual — reads from nepse_audited_annual_overrides (Phase A operator uploads); empty until backfill.
  • Dividends.totalReturnIndex — needs both start_open_price and latest_close_price from the TR-anchor query; renders null when either is missing.

The JSON shape is stable across the v4.5 boundary — features light up without a contract change once data lands.

Module size

~3,500 LOC across 8 feature packages + umbrella, with ~60% of that being
the service.go compute-on-read pipeline and the rest split between DTOs,
handlers, and table-driven tests.