Problem Statement
Users can set a TARGET_ALLOCATION (User.strategyConfig.targetAllocations) and the agent rebalances toward it — but the platform never suggests a good allocation, and the "Blend": 50, "Stellar DEX": 30, "Luma": 20-style maps are hand-written guesses. There is no answer to "given my risk tolerance, what should my allocation be?" This issue adds a portfolio-optimization engine: given the historical ProtocolRate series, per-protocol risk scores, and the user's risk profile, compute an optimal weight vector (and an efficient frontier) that the product can offer as a one-tap suggestion — while keeping the suggestion advisory, audited, and impossible to turn into an unauthorized rebalance.
Current State
ProtocolRate carries per-protocol APY history; ProtocolRiskScore (src/agent/riskScoring.ts) carries a 0–100 normalized score (higher = lower risk) with volatility/trend factors and insufficientHistory flags; User.riskTolerance (1–10) exists.
src/agent/strategies.ts's applyRiskCeiling enforces the risk boundary; src/agent/router.ts resolves a user's effective strategy; src/agent/backtest.ts can replay a strategy against history (the natural validation harness for a suggestion).
src/agent/strategyMetrics.ts establishes the canonical period-returns-from-value-series discipline and the inferPeriodsPerYear annualization. src/jobs/strategyMetrics.ts and src/jobs/protocolRiskScoring.ts establish the scheduled-persist pattern.
- The trap to avoid again: optimization inputs (expected returns, covariance) must derive from value/period-return series, not from the smoothed cumulative
YieldSnapshot.apy column (docs/STRATEGY_MARKETPLACE.md §2).
Proposed Solution
1. Pure optimization core (src/analytics/optimizer.ts)
Zero-I/O, deterministic, unit-tested:
- Inputs: per-protocol expected return + covariance/volatility estimates (built from
ProtocolRate history with a documented estimation method and lookback), the user's riskTolerance mapped to a risk-aversion parameter (a documented, linear-ish mapping — state it), and optional hard constraints: min/max per-protocol weight, "keep stablecoin floor", riskCeiling-derived max portfolio risk score.
- Objective (choose one, state it, test it):
- Mean-variance (MVO): maximize
μ − (λ/2)σ² subject to the constraints, via a documented solver (a well-known convex-optimization approach; note the codebase has no numeric solver dependency today — either add a vetted one and document the supply-chain review, or implement a bounded gradient/coordinate-descent that provably respects the constraints and document the convergence guarantee).
- Or maximizing expected return at a target volatility, or minimizing CVaR — whichever is chosen must produce weights that sum to 1, respect all constraints, and handle infeasible cases (return
infeasible + reason, never a garbage vector).
- Efficient frontier: a small set of frontier points (configurable granularity) so the product can plot "here's where risk tolerance X sits".
- Degenerate handling: a protocol with
insufficientHistory is excluded from the optimization universe with a documented reason; a universe with < 2 eligible protocols returns insufficient_universe.
- Determinism: same inputs → same output (fixed tie-breaking; no randomness in the solver).
2. Suggestion service (src/analytics/service.ts or src/strategy/)
suggestAllocation(userId) composes the pure core with the user's current data: reads the user's riskTolerance, riskCeiling, active follow (a follow's riskCeiling interplay: the suggestion must respect the stricter ceiling — same Math.max rule as docs/STRATEGY_MARKETPLACE.md), and active SavingsGoal (a goal's riskCeiling + target constraints may tighten the feasible set).
- Validation harness: run
runBacktest (from src/agent/backtest.ts) on the suggested allocation vs. the user's current allocation over the available history and report both — so the suggestion is shown with its simulated consequences, not a naked number.
- Persist suggestions (new
AllocationSuggestion model: user, input snapshot hash, weight vector, frontier, backtest summary, createdAt) — advisory history so a user can compare "what the optimizer said last month vs. now".
3. API surface (docs/openapi.yaml)
POST /api/v1/portfolio/suggest-allocation — authenticated, owner-scoped: returns the optimal weights, the frontier (bounded size), the backtest comparison, and explicit isSuggestion disclaimers.
GET /api/v1/portfolio/suggestions — the user's past suggestions.
- Apply path is deliberately separated: applying a suggestion is not part of this issue beyond validating that
targetAllocations accepts the suggested shape (the existing strategyConfig update path already exists and is strategy-engine-validated). The optimizer must not mutate User.strategyConfig by itself — a human tap does that.
4. Scheduled refresh
- A job (
src/jobs/allocationSuggestions.ts, registered like the others) that precomputes the per-user suggestion on a schedule (6h, configurable) so reads are cheap and consistent; on-demand recompute allowed behind a per-user concurrency bound + rate limit (optimization is CPU-bound; protect it).
Edge Cases & Failure Modes
- Infeasible constraints (e.g. risk ceiling too tight for the universe): return
infeasible with a human-readable reason listing the binding constraint; never return weights that violate the ceiling.
- Universe < 2 eligible protocols:
insufficient_universe.
- riskTolerance change: suggestion recomputes; old suggestions remain as history (the persisted snapshot hash makes comparisons meaningful).
- Active follow with looser ceiling: suggestion clamps to the stricter ceiling (never widens exposure — same invariant as the strategy engine).
- Active SavingsGoal: goal constraints participate in the feasible set; if the goal makes the problem infeasible, surface why (binding constraint = the goal).
- Optimizer non-convergence: after a bounded iteration budget, return
non_converged with the best feasible vector found — never silently present a converged-looking answer.
- Numerical precision: weights must sum to 1.0 within documented tolerance; use
Decimal for storage and a documented float tolerance for display.
Security & Privacy Considerations
- Suggestions are computed from the user's own data + public
ProtocolRate history only — no cross-user data.
- Owner-scoped endpoints with
enforceUserAccess; sub-account-aware (a parent may request a suggestion for a child via actingAsUserId with VIEW-class authority).
- The apply path remains user-initiated; the suggestion service must have no write path to
User.strategyConfig (structural test: the optimizer/service imports nothing from src/agent/'s write path or src/stellar/).
- CPU bounds: per-user concurrency limit, global worker budget, documented in the PR with measurements.
Out of Scope
- Automated execution of suggestions (a human applies them).
- Live market-data feeds beyond
ProtocolRate history (the estimation method stays history-based; stated).
- Multi-asset-class/correlation-heavy models beyond the protocol universe.
- ML predictions of returns.
Suggested Implementation Plan
- Pure optimizer core + efficient frontier + feasibility/degenerate handling (extensive property tests: weights sum to 1, constraints respected, determinism, infeasibility).
- Estimation module (expected returns/covariance from
ProtocolRate history, stated method).
suggestAllocation service + backtest validation harness + persistence model/migration.
- Routes + rate/concurrency bounds +
docs/openapi.yaml.
- Scheduled refresh job; docs writeup of the objective + mapping choices.
Acceptance Criteria
Problem Statement
Users can set a
TARGET_ALLOCATION(User.strategyConfig.targetAllocations) and the agent rebalances toward it — but the platform never suggests a good allocation, and the"Blend": 50, "Stellar DEX": 30, "Luma": 20-style maps are hand-written guesses. There is no answer to "given my risk tolerance, what should my allocation be?" This issue adds a portfolio-optimization engine: given the historicalProtocolRateseries, per-protocol risk scores, and the user's risk profile, compute an optimal weight vector (and an efficient frontier) that the product can offer as a one-tap suggestion — while keeping the suggestion advisory, audited, and impossible to turn into an unauthorized rebalance.Current State
ProtocolRatecarries per-protocol APY history;ProtocolRiskScore(src/agent/riskScoring.ts) carries a 0–100 normalized score (higher = lower risk) with volatility/trend factors andinsufficientHistoryflags;User.riskTolerance(1–10) exists.src/agent/strategies.ts'sapplyRiskCeilingenforces the risk boundary;src/agent/router.tsresolves a user's effective strategy;src/agent/backtest.tscan replay a strategy against history (the natural validation harness for a suggestion).src/agent/strategyMetrics.tsestablishes the canonical period-returns-from-value-series discipline and theinferPeriodsPerYearannualization.src/jobs/strategyMetrics.tsandsrc/jobs/protocolRiskScoring.tsestablish the scheduled-persist pattern.YieldSnapshot.apycolumn (docs/STRATEGY_MARKETPLACE.md§2).Proposed Solution
1. Pure optimization core (
src/analytics/optimizer.ts)Zero-I/O, deterministic, unit-tested:
ProtocolRatehistory with a documented estimation method and lookback), the user'sriskTolerancemapped to a risk-aversion parameter (a documented, linear-ish mapping — state it), and optional hard constraints: min/max per-protocol weight, "keep stablecoin floor",riskCeiling-derived max portfolio risk score.μ − (λ/2)σ²subject to the constraints, via a documented solver (a well-known convex-optimization approach; note the codebase has no numeric solver dependency today — either add a vetted one and document the supply-chain review, or implement a bounded gradient/coordinate-descent that provably respects the constraints and document the convergence guarantee).infeasible+ reason, never a garbage vector).insufficientHistoryis excluded from the optimization universe with a documented reason; a universe with < 2 eligible protocols returnsinsufficient_universe.2. Suggestion service (
src/analytics/service.tsorsrc/strategy/)suggestAllocation(userId)composes the pure core with the user's current data: reads the user'sriskTolerance,riskCeiling, active follow (a follow'sriskCeilinginterplay: the suggestion must respect the stricter ceiling — sameMath.maxrule asdocs/STRATEGY_MARKETPLACE.md), and activeSavingsGoal(a goal'sriskCeiling+ target constraints may tighten the feasible set).runBacktest(fromsrc/agent/backtest.ts) on the suggested allocation vs. the user's current allocation over the available history and report both — so the suggestion is shown with its simulated consequences, not a naked number.AllocationSuggestionmodel: user, input snapshot hash, weight vector, frontier, backtest summary, createdAt) — advisory history so a user can compare "what the optimizer said last month vs. now".3. API surface (
docs/openapi.yaml)POST /api/v1/portfolio/suggest-allocation— authenticated, owner-scoped: returns the optimal weights, the frontier (bounded size), the backtest comparison, and explicitisSuggestiondisclaimers.GET /api/v1/portfolio/suggestions— the user's past suggestions.targetAllocationsaccepts the suggested shape (the existingstrategyConfigupdate path already exists and is strategy-engine-validated). The optimizer must not mutateUser.strategyConfigby itself — a human tap does that.4. Scheduled refresh
src/jobs/allocationSuggestions.ts, registered like the others) that precomputes the per-user suggestion on a schedule (6h, configurable) so reads are cheap and consistent; on-demand recompute allowed behind a per-user concurrency bound + rate limit (optimization is CPU-bound; protect it).Edge Cases & Failure Modes
infeasiblewith a human-readable reason listing the binding constraint; never return weights that violate the ceiling.insufficient_universe.non_convergedwith the best feasible vector found — never silently present a converged-looking answer.Decimalfor storage and a documented float tolerance for display.Security & Privacy Considerations
ProtocolRatehistory only — no cross-user data.enforceUserAccess; sub-account-aware (a parent may request a suggestion for a child viaactingAsUserIdwithVIEW-class authority).User.strategyConfig(structural test: the optimizer/service imports nothing fromsrc/agent/'s write path orsrc/stellar/).Out of Scope
ProtocolRatehistory (the estimation method stays history-based; stated).Suggested Implementation Plan
ProtocolRatehistory, stated method).suggestAllocationservice + backtest validation harness + persistence model/migration.docs/openapi.yaml.Acceptance Criteria
riskCeilinginfeasible,insufficient_universe, andnon_convergedoutcomes with binding-constraint reasons — never a silently bad vectorrunBacktestUser.strategyConfig(structural test)docs/openapi.yamlupdated; unit + integration tests green