Skip to content

Portfolio Optimization & Optimal Allocation Suggestion Engine #322

Description

@robertocarlous

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

  1. Pure optimizer core + efficient frontier + feasibility/degenerate handling (extensive property tests: weights sum to 1, constraints respected, determinism, infeasibility).
  2. Estimation module (expected returns/covariance from ProtocolRate history, stated method).
  3. suggestAllocation service + backtest validation harness + persistence model/migration.
  4. Routes + rate/concurrency bounds + docs/openapi.yaml.
  5. Scheduled refresh job; docs writeup of the objective + mapping choices.

Acceptance Criteria

  • Deterministic optimizer returning valid weight vectors (sum to 1 within tolerance) respecting min/max weights, stablecoin floor, and the stricter of user/follow/goal riskCeiling
  • infeasible, insufficient_universe, and non_converged outcomes with binding-constraint reasons — never a silently bad vector
  • Efficient frontier output bounded and plottable
  • Suggestion paired with a backtest comparison (suggested vs. current allocation) using runBacktest
  • Suggestions persisted with input-snapshot hash for history/comparison; no write path to User.strategyConfig (structural test)
  • CPU concurrency bounds + rate limits; scheduled precompute job
  • docs/openapi.yaml updated; unit + integration tests green

Metadata

Metadata

Assignees

Labels

GrantFox OSSIssue tracked in GrantFox OSSMaybe RewardedIssue may be eligible for a GrantFox rewardOfficial Campaign | FWC26Campaign: Official Campaign | FWC26Third CampaignCampaign: Third CampaignenhancementNew feature or request

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions