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 testsstock_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:
| File | Purpose |
|---|---|
types.go | Response DTO, request-shape constants, package doc |
service.go | Compute-on-read logic; takes a Store + taxonomyRegistryProvider |
store.go | Type alias over sqlc.Queries (or internalstore.SnapshotReader) |
handler.go | HTTP entrypoint + Redis cache wrapper + query-string parsing |
service_test.go | Table-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) *Bundlecmd/server/main.go:
stockAnalysisBundle := stockanalysis.NewBundle(queries, taxonomyManager, cacheLayer)
// → passed into router depsinternal/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_priceandlatest_close_pricefrom the TR-anchor query; rendersnullwhen 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.