Fair Value
Two-method estimate (Graham + Earnings-based) with average and delta vs LTP — no verdict, FE renders the under/fair/over cue from deltaPercent
The fair-value endpoint returns two independent valuation methods, their average, and the percent delta vs LTP. The endpoint does not emit a categorical verdict label ("under-valued" / "fair" / "over-valued") — FE renders the indicator from deltaPercent to keep the categorical bands a frontend concern.
| Method | Path | Cache TTL |
|---|---|---|
GET | /api/nepse/companies/{symbol}/fundamentals/fair-value | 10 min |
Request
No query parameters.
curl "$BASE/api/nepse/companies/NABIL/fundamentals/fair-value"Response
{
"message": "ok",
"data": {
"symbol": "NABIL",
"sector": "Commercial Banks",
"ltp": 542.5,
"graham": {
"value": { "value": 309.23, "source": "computed", "status": "valid" },
"formula": "sqrt(22.5 * eps * bookValuePerShare)",
"inputs": {
"eps": { "value": 42.5, "source": "mdp", "status": "valid" },
"bookValuePerShare": { "value": 100, "source": "computed", "status": "valid" },
"sectorCohortN": 0
}
},
"earnings": {
"value": { "source": "computed", "status": "insufficient_cohort" },
"formula": "sectorAvgPe * eps",
"inputs": {
"eps": { "value": 42.5, "source": "mdp", "status": "valid" },
"sectorAvgPe": { "source": "computed", "status": "insufficient_cohort" },
"sectorCohortN": 0
}
},
"average": {
"value": { "value": 309.23, "source": "computed", "status": "valid" },
"deltaPercent": -42.99
},
"disclaimer": "Indicative estimate. Not financial advice.",
"dataAsOf": "2026-05-23T14:55:00+05:45",
"servedAt": "2026-05-23T14:56:30+05:45"
}
}The two methods
Graham
fairValue = sqrt(22.5 × EPS × BookValuePerShare)Benjamin Graham's classic conservative anchor (22.5 = "P/E of 15 × P/B of 1.5 = defensively priced stock"). The formula is undefined for non-positive inputs — graham.value.status becomes negative_input when either EPS or BVPS is ≤ 0.
Earnings-based
fairValue = sectorAvgPE × EPSsectorAvgPE is the unweighted mean of valid P/Es across the focal's sector, with the focal symbol excluded. Cohort gating:
- Skip peers with
ltp ≤ 0,eps ≤ 0, or unparseableitems_map. - Require ≥ 3 valid peers (focal excluded). Below that,
sectorAvgPE.status = insufficient_cohortand the earnings-based method collapses too.
inputs.sectorCohortN reports the count of peers that survived gating so FE can tooltip "based on N peers".
Average + delta
if both methods valid: avg = (graham + earnings) / 2
elif only graham valid: avg = graham
elif only earnings valid: avg = earnings
else: avg.status = upstream_missing
deltaPercent = (avg - LTP) / LTP × 100 if LTP > 0 else nulldeltaPercent is positive when fair value > LTP (under-valued cue) and negative when fair value < LTP (over-valued cue).
The endpoint deliberately ships only a number. The FE chooses the buckets (< -10% = over-valued, > +10% = under-valued, between = fair) so the threshold can evolve without an API change.
Method anatomy
Both graham and earnings follow the same envelope:
type Method = {
value: MetricFloat64;
formula: string; // "sqrt(22.5 * eps * bookValuePerShare)" or "sectorAvgPe * eps"
inputs: {
eps?: MetricFloat64;
bookValuePerShare?: MetricFloat64; // Graham only
sectorAvgPe?: MetricFloat64; // Earnings only
sectorCohortN: number; // 0 on Graham; cohort count on Earnings
};
};Returning the inputs alongside the result lets FE render the breakdown (EPS 42.30 × Sector P/E 11.80 = 502.14) without a second round-trip.
Status precedence
When inputs disagree, the worst input wins:
| Graham inputs | graham.value.status |
|---|---|
Both valid (positive) | valid |
Either ≤ 0 | negative_input |
Either upstream_missing | upstream_missing |
Either insufficient_history | insufficient_history |
| Earnings inputs | earnings.value.status |
|---|---|
Both valid (EPS positive) | valid |
EPS ≤ 0 | negative_input |
sectorAvgPe.status set | that status (e.g. insufficient_cohort) |
EPS upstream_missing | upstream_missing |
Caching
| Key | TTL |
|---|---|
nepse:fundamentals:fair-value:{SYMBOL} | 10 min |
10 minutes matches the other taxonomy-driven endpoints since the inputs (LTP + financial reports + sector cohort) refresh on the same cadence.
Errors
| Code | Reason |
|---|---|
200 | OK (even when both methods collapse to invalid) |
404 | Symbol not in nepse_companies |
500 | DB error during snapshot or cohort reads |
dataAsOf
The oldest signal across:
- The focal's
LTP fetched_atand reportupdated_attimestamps. - Every cohort peer's
LTP fetched_atandreport.updated_at.
A 6-day-old peer IS makes the sector P/E stale — surfacing the peer's staleness on the focal's response is the honest signal.