mirror of https://github.com/ghostfolio/ghostfolio
committed by
GitHub
1 changed files with 194 additions and 0 deletions
@ -0,0 +1,194 @@ |
|||
# Volatility Estimate + Cash Allocation — Scoring Logic Spec |
|||
|
|||
**Author:** sesha siva sankar (member 1) |
|||
**Date:** Fri, Sep 18 |
|||
**Task:** Spec volatility estimate + cash allocation scoring logic |
|||
|
|||
--- |
|||
|
|||
## 1. Two separate checks, bundled in one doc |
|||
|
|||
These are two different signals that don't share a formula (unlike Thursday's |
|||
concentration rules, which were all one formula). I'm speccing them together because |
|||
they're on the same day of the plan, not because they're related: |
|||
|
|||
- **Volatility estimate** — how bumpy is this portfolio likely to be, based on price |
|||
history? |
|||
- **Cash allocation** — is too much (or too little) of the portfolio just sitting in cash? |
|||
|
|||
## 2. Volatility Estimate |
|||
|
|||
### 2.1 What data we actually have to work with |
|||
|
|||
Checked the schema — `MarketData` (`prisma/schema.prisma:161`) stores one row per |
|||
`(dataSource, symbol, date)` with a `marketPrice`. That's daily closing prices per symbol, |
|||
already being collected for every symbol in a user's holdings (it's how the performance |
|||
charts work today). So there's no new data collection needed — this is a calculation |
|||
on top of existing price history, same relationship as concentration risk was to |
|||
`assetProfile`. |
|||
|
|||
### 2.2 The formula |
|||
|
|||
Standard approach, nothing exotic — annualized volatility from daily returns: |
|||
|
|||
``` |
|||
1. For each holding, pull the last N days of MarketData.marketPrice (I'd default N = 90 |
|||
trading days -- long enough to be stable, short enough to reflect current risk, not |
|||
ancient history). |
|||
2. Compute daily returns: r[i] = ( price[i] - price[i-1] ) / price[i-1] |
|||
3. Compute the standard deviation of those daily returns -> dailyVolatility |
|||
4. Annualize it: annualizedVolatility = dailyVolatility × sqrt(252) |
|||
(252 = approx. number of trading days in a year -- standard convention) |
|||
5. That gives per-holding volatility. To get PORTFOLIO volatility (not just an average of |
|||
the holdings), we need the weighted combination accounting for how holdings move |
|||
together: |
|||
|
|||
portfolioVolatility = sqrt( Σ Σ w_i × w_j × cov(i, j) ) |
|||
|
|||
where w_i is holding i's weight (% of portfolio) and cov(i,j) is the covariance between |
|||
holding i and j's daily returns (cov(i,i) is just that holding's own variance). |
|||
``` |
|||
|
|||
### 2.3 Why the covariance step matters (and can't be skipped) |
|||
|
|||
If I just averaged each holding's own volatility weighted by portfolio %, that would |
|||
overstate risk for a diversified portfolio — two volatile stocks that move in _opposite_ |
|||
directions cancel each other out and the portfolio is calmer than either stock alone. This |
|||
is literally the mathematical reason diversification works. Skipping it would make the |
|||
score effectively useless for rewarding diversification, so it's worth the extra |
|||
complexity even though it's the harder part to implement (an N×N covariance matrix |
|||
instead of N single numbers). |
|||
|
|||
### 2.4 Turning volatility into a pass/fail (or a scaled score) |
|||
|
|||
Unlike concentration (single ceiling), volatility should probably be a **band**, same |
|||
pattern as the Asset Class / Market cluster rules from Tuesday's notes — because both too |
|||
high AND too low can be "wrong" depending on the user's risk profile: |
|||
|
|||
- Default band: 10%-25% annualized volatility (roughly "balanced portfolio" territory — |
|||
pure bonds sit lower, all-crypto sits way higher). |
|||
- **Important open question**: this really should key off the user's declared risk |
|||
tolerance (if Ghostfolio has that setting) rather than one fixed band for everyone — a |
|||
25-year-old aggressive investor and someone near retirement shouldn't be held to the |
|||
same volatility target. Flagging this for the team rather than deciding solo, since I |
|||
didn't find a risk-tolerance field on the user profile during my read-through — that |
|||
might need its own small spec if we want to do this properly. |
|||
- Output shape matches every other rule (`EvaluationResult`): `value: false`, |
|||
`evaluation: "Your portfolio's estimated annualized volatility is 31.4%, above the 25% |
|||
upper band."`, plus the raw number so the UI can show a proper gauge, not just pass/fail. |
|||
|
|||
### 2.5 Performance note (implementation concern, not a design one) |
|||
|
|||
Computing an N×N covariance matrix on every X-Ray request for a portfolio with, say, 50 |
|||
holdings is 2,500 covariance calculations, each needing 90 days of price history pulled |
|||
per pair. That's expensive to do live on every page load. Recommending this be |
|||
**precomputed/cached** (e.g. nightly job, same idea as other market-data refresh jobs in |
|||
this codebase) rather than calculated synchronously in the rule's `evaluate()` — flagging |
|||
this now so it doesn't get missed when someone picks this up for implementation. |
|||
|
|||
## 3. Cash Allocation Scoring |
|||
|
|||
### 3.1 How this differs from the existing "Buying Power" rule |
|||
|
|||
Tuesday's notes already found a `BuyingPower` rule — but that only checks "is your cash |
|||
above a minimum floor" (protects against having _too little_ liquidity). It says nothing |
|||
about the _other_ failure mode: too much cash sitting uninvested, which is its own drag on |
|||
long-term returns (cash loses to inflation over time). So cash allocation is really a |
|||
**band check**, same shape as volatility above, not a second liquidity-floor check. |
|||
|
|||
### 3.2 The formula |
|||
|
|||
``` |
|||
cashRatio = totalCashBalance / totalPortfolioValue (holdings + cash combined) |
|||
|
|||
Fail if cashRatio > thresholdMax (too much idle cash, e.g. > 15%) |
|||
Fail if cashRatio < thresholdMin (not enough of a buffer, e.g. < 2%) |
|||
``` |
|||
|
|||
- `totalCashBalance` and portfolio value are both already computed today (this is the same |
|||
`summary.cash` figure the `BuyingPower` rule already reads, per Tuesday's notes on |
|||
`apps/api/src/models/rules/liquidity/buying-power.ts`) — no new data needed here either. |
|||
- Default band: 2%-15%. Below 2% and the emergency-fund/liquidity checks are likely also |
|||
already failing (some overlap with Emergency Fund Coverage from Tuesday is expected and |
|||
fine — they're answering slightly different questions: "do you have money for emergencies" |
|||
vs "is your invested/cash split efficient"). |
|||
|
|||
### 3.3 Relationship to Buying Power — keep both, don't merge |
|||
|
|||
Worth being explicit about this since it's a natural question: Buying Power (existing) and |
|||
Cash Allocation (new) sound similar but aren't redundant — |
|||
|
|||
- **Buying Power** = "do you have enough cash on hand right now" (an absolute dollar |
|||
floor, configurable in currency units) |
|||
- **Cash Allocation** = "is the proportion of cash-to-invested reasonable" (a ratio/band, |
|||
same shape as every cluster-risk rule) |
|||
|
|||
Someone with $500K invested and $5K cash might pass Buying Power (enough for near-term |
|||
needs) but still fail Cash Allocation on the low end if their target buffer is higher in |
|||
percentage terms — that's a legitimate, different signal, not double-counting. |
|||
|
|||
## 4. Output shape (both, same pattern as every other rule) |
|||
|
|||
```jsonc |
|||
{ |
|||
"key": "PortfolioVolatility", |
|||
"name": "Volatility Estimate", |
|||
"isActive": true, |
|||
"value": false, |
|||
"evaluation": "Estimated annualized volatility is 31.4%, above the 25% upper band.", |
|||
"configuration": { |
|||
"threshold": { |
|||
"min": { "unit": "%", "value": 0.1 }, |
|||
"max": { "unit": "%", "value": 0.25 } |
|||
} |
|||
} |
|||
} |
|||
``` |
|||
|
|||
```jsonc |
|||
{ |
|||
"key": "CashAllocation", |
|||
"name": "Cash Allocation", |
|||
"isActive": true, |
|||
"value": true, |
|||
"evaluation": "5.1% of your portfolio is in cash, within the 2%-15% target band.", |
|||
"configuration": { |
|||
"threshold": { |
|||
"min": { "unit": "%", "value": 0.02 }, |
|||
"max": { "unit": "%", "value": 0.15 } |
|||
} |
|||
} |
|||
} |
|||
``` |
|||
|
|||
## 5. How these fold into the Health Score model (Wednesday's doc) |
|||
|
|||
Both slot in as two more rows in the weight table from |
|||
[02-health-score-data-model.md](02-health-score-data-model.md): |
|||
|
|||
| Category | Weight (revised) | Why | |
|||
| ------------------- | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | |
|||
| Volatility Estimate | 10 | New category — meaningful risk signal, but I'm pulling points from Regional Market Cluster Risk (15 -> ~10-ish split) rather than inventing new total budget, to keep the grand total at 100 | |
|||
| Cash Allocation | 5 | Folds into the existing **Liquidity** category alongside Buying Power (5+5=10 total for Liquidity) since they're clearly related | |
|||
|
|||
Same caveat as Thursday's concentration doc: these are my proposed numbers, not locked — |
|||
worth a quick team gut-check on final weights once all 4-5 new rules from this week are on |
|||
the table together, rather than re-balancing the 100-point pie one doc at a time. |
|||
|
|||
## 6. Edge cases |
|||
|
|||
- **New symbol with < 90 days of price history** (recently added holding, or the platform |
|||
just recently added its data): fall back to whatever history exists (even 20-30 days) |
|||
rather than failing to compute — flag in the evaluation message that the estimate is |
|||
"based on limited history" so it doesn't look falsely precise. |
|||
- **Single-holding portfolio**: covariance matrix degenerates to just that one holding's |
|||
own variance — formula still works, no special-casing needed. |
|||
- **All-cash portfolio (0 holdings)**: Volatility rule shouldn't run at all (nothing to |
|||
measure) — same `hasOpenHoldings` gate as other rules. Cash Allocation, however, SHOULD |
|||
still run (100% cash is a valid, checkable state — it'd fail the upper band, correctly). |
|||
|
|||
## 7. What's next |
|||
|
|||
Monday's task is deviation-from-target-allocation logic — that's a genuinely different |
|||
shape again (comparing actual vs. a user-defined target, not just a fixed threshold), so |
|||
that'll need its own spec rather than extending this one. |
|||
Loading…
Reference in new issue