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