Documented, reasonable assumptions made while implementing features, so a reviewer can override any of them without archaeology.
-
strategyConfigserializes exactly{ strategyName, targetAllocations?, riskCeiling? }— the three keyssrc/agent/loop.tsactually reads.riskTolerancestays personal to the follower and is never copied. -
The risk-free rate for Sharpe is 0, exposed as
STRATEGY_RISK_FREE_RATE. Stating it explicitly beats a hidden non-zero assumption. Sourcing a live rate is deferred. -
Metric recompute interval defaults to 6 h (
STRATEGY_METRICS_INTERVAL_MS), matchingconfig.protocolRisk.intervalMs. Figures derive from hourly snapshots, so a faster cadence buys nothing. -
Publishing does not require an eligible track record. A publisher simply does not appear on the leaderboard until they have one. This keeps publishing and ranking independent.
-
GOAL_TRACKINGis not publishable. It is driven by the publisher's ownSavingsGoaltarget and date — personal figures that mean nothing to a follower and would have to be copied for the strategy to work at all. Followers with their own goal already get goal precedence. -
POST /strategies/publishaccepts an explicitstrategyConfig, and snapshots the caller'sUser.rebalanceStrategy/User.strategyConfigwhen it is omitted. Nothing insrc/currently writes those User columns, so an explicit body is the only way the feature is usable today; the snapshot path is there for when a "set my strategy" endpoint lands. -
TARGET_ALLOCATIONweights must sum to 100. Followers inherit them verbatim, so a set that does not sum to 100 would quietly under- or over-allocate someone else's funds. -
DEVIATION from the plan's signature:
computeStrategyMetricstakes an input object with a separatefirstObservedAtrather than(points, now?). The window governs the return statistics; the track record governs trust. Deriving the track record from the windowed series would make the 30-day window structurally incapable of ever reaching a 30-day track record (its oldest sample sits ~30 days back and floors to 29), leaving the 30-day leaderboard permanently empty. -
Follower notifications dispatch once per follower, not once per event, because a webhook consumer needs to know whose agent is affected — matching the per-user payload convention of
alert_rule.triggered. A strategy with many followers therefore produces a proportional webhook fan-out. -
Unfollow tolerates an orphaned follow. When a publisher deletes their account,
onDelete: SetNullleaves the follower with no strategy id to name.POST /strategies/:id/unfollowreleases the caller's active follow when itspublishedStrategyIdis null, regardless of the id supplied. It only ever touches the caller's own single active row, so the looser match is not a privilege boundary. -
The metrics job computes for unpublished strategies too, so re-publishing is ranked immediately instead of waiting up to a full interval. The marketplace query, not the job, applies the
isPublishedfilter.
-
Σ is the covariance of ANNUAL RATE LEVELS, not of daily returns, and λ is log-spaced over
[5, 500]rather than linear over[1, 25]. Both deviate from the issue plan, and they are one decision, not two. Under the plan's scaling (Σ = cov(apy/100/365.25) × 365.25) the risk term sits five to six orders of magnitude below the return term for realistic APY dispersion, so(λ/2)wᵀΣwcan never offsetμᵀwat any λ in[1,25]: the optimum is always the max-return corner and the efficient frontier collapses to a point. The chosen Σ is exactly the plan's matrix × 365.25, which also makesexpectedVolatilityread in the same units as the APY. Worked numbers indocs/PORTFOLIO_OPTIMIZATION.md. -
A default 60% per-protocol concentration cap is applied, raised to
1/non a small universe. Unconstrained mean-variance is corner-seeking; measured on this repo's scale, everyriskTolerancefrom 3 to 10 returned a single protocol at 100%. That is a true optimum of the stated objective and simultaneously terrible advice for an endpoint whose purpose is to suggest a diversified allocation. Applied as an explicit, overridablebounds.maxdefault rather than by distorting μ or λ until the answer looked reasonable. -
The stable-group floor uses an exact disjoint-group split projection, not the Dykstra alternating projections the plan called for. For a convex set ∩ halfspace the projection is either the plain capped-simplex projection or lies on
Σ_S w = f, which splits into two capped simplices over disjoint index groups — exact and non-iterative. Dykstra is correct for a general intersection but only iteratively convergent, and the first draft left the iterate on the wrong side of the floor (returning 0.30 for a 0.60 floor). -
insufficient_universecarries an optionalbindingConstraint. A too-tight risk ceiling empties the universe rather than emptying the feasible set, so reporting it asinfeasiblewould mislabel it. The extra field lets the API nameriskCeilingas the cause while keeping the outcome structurally accurate. -
The backtest window is trimmed to the first populated day.
runBacktesttreats an empty first day asinsufficient_historyand abandons the entire run, so a first observation landing minutes after the window's opening midnight would discard ~89 otherwise-usable days. Trimming makes the comparison depend on the data that exists rather than on when the rate scanner happened to run. -
The scheduled job stores suggestions without backtest legs. They are two full historical replays per user and are only interesting when a human is looking, which is exactly when the POST endpoint computes them live. Rows written by the job carry a null
backtest; that is expected, not a failure. -
prisma/migrations/20260728000000_add_sub_accounts/rollback.sqlwas written here despite being out of scope for #322. It was missing, which failsscripts/check-migration-rollback.shonmainand would have left this branch's CI red for an unrelated reason. Flagged in the PR description.
- The endpoint is a pure read — zero side effects. It never writes an
OutboxOp,AgentLog,Transaction,RebalanceDecision,User,PublishedStrategy, orPositionrow.src/strategy/simulation-service.tsenforces this by convention;src/agent/simulate.tsenforces it structurally (nosrc/stellar/*import, no db, no outbox/event writer), verified by a test that reads the module source. - All accrual math is
Prisma.Decimal. Daily return is simple (non-compounding)apy/100/365.25applied to the running value — deliberately not the compounding the backtest engine uses, and never a plain float. See the #344 request: momentum must never come from float drift. - The historical leg reuses the live cost model with the backtest engine's
amount encoding. Move amounts are encoded as
value × 10^18(thesrc/agent/backtest.tsconvention) soestimateRebalanceCostdivides back to human units and yields realistic fee percentages. We deliberately do NOT mirror the latent plain-integer encoding insrc/agent/tools/actionTools.ts:265(which would drive network-fee percent toward ~1e15 and block every move). - The counterfactual is a clean hold. Same starting value in the same starting protocol for the whole window, never rebalancing, never paying fees. It answers "what if I had just left it alone?" in direct side-by-side with the strategy leg.
- A protocol with no retained history is treated as unavailable — never
zero-filled or extrapolated. Missing protocols and a truncated window are
surfaced as
dataCaveats, not silently hidden. - Replay bounds.
historyWindowDaysis capped at 180 (SIMULATE_MAX_WINDOW_DAYS) at both the validator and the service, and the replay series is clamped at both ends to retained observations. - Resolution precedence and risk tightening follow
resolveEffectiveConfig(#285): followed config → submitted inline config → caller's own config, and the effective risk ceiling is always the stricter of the caller's and any applied ceiling. A simulation can only tighten exposure. - Short-TTL cache. Results are cached 120 s under
strategy-simulate:{userId}:{simulationToken}wheresimulationTokenis a sha-256 of the canonical config + window, so identical previews are cheap and never leak across users.
-
The durable stream is Postgres, not a Redis Stream.
src/config/redis.tsdegrades to a no-op whenREDIS_URLis unset — the configuration most environments and the whole test suite run — so a Redis-backed stream would makeresume afterSeqsilently unavailable exactly where it is hardest to notice, and would put durability on a store we treat elsewhere as a cache. Redis stays in the design as the cross-pod transport. Justified in code onmodel UserEventinprisma/schema.prisma. -
Stream retention defaults to 7 days and 5000 rows per user. This table exists to close a reconnect gap, not to be a second event log —
TransactionandProcessedEventremain the durable record. Both bounds are needed: age alone lets one pathological account grow without limit inside the window. -
seqis exposed to clients as a JSONnumber, not a string. Postgres returnsBIGINTand Prisma maps it tobigint, whichJSON.stringifyrefuses. A per-user counter would need to pass 2^53 events before the conversion could lose precision. -
subscribestarts at "now"; onlyresumereplays. A client that wants history asks for it. Makingsubscribereplay by default would turn every fresh connection into a retention-sized read. -
Coalescing is opt-in per subscription and lossy for
resume. Suppressed events stay in the store but are not redelivered, because the client'safterSeqhas already moved past them. Default-off makes that the client's trade, not the server's. -
A revoked session is caught by polling, not by a push.
WS_SESSION_RECHECK_MS(60s) re-verifies each live socket. Polling is the one mechanism that covers every way a session can die — logout, expiry, deactivation, admin action — without each of those code paths needing to know sockets exist. Logout additionally closes sockets immediately on the pod handling it. -
Delegated topic mapping:
VIEW→ portfolio/transactions/agent/alerts,MANAGE_STRATEGY→ strategies.DEPOSIT/WITHDRAWadd no topics of their own — the confirmations they produce are already covered bytransactionsunderVIEW. -
The webhook leg still receives the unredacted payload. Webhook subscriptions are operator-scoped and their endpoints are trusted servers; an end user's browser is not. Only the socket payload is projected onto the per-event-type allowlist.
-
agent.rebalancedfromsrc/stellar/events.tshas no user stream. The contract event is protocol-wide with no user to address, so it publishes with an empty user list and reaches the webhook channel alone. The per-user view of a rebalance comes fromsrc/agent/loop.ts, which knows whose positions moved. -
A
?token=query parameter is deliberately unsupported. URLs reach access logs, proxy logs, and referrers, and the handshake token is a live session. Browsers use theSec-WebSocket-Protocol: bearer, <jwt>pair.
- Default 3-tranche
{0d, 30d, 90d}vesting schedule with exact remainder on final tranche. Each leg (referrerandreferred) is split intoREFERRAL_VESTING_TRANCHES(default3) atREFERRAL_VESTING_SCHEDULE_DAYS(0,30,90), with 8-decimalROUND_DOWNon tranches1..N-1and the exact remainder on trancheNso tranche sums equal the exact leg total with zero float drift. - Consecutive-snapshot guard (
M = 3) prevents rebalance dip false positives.evaluateReferredBalanceCheckpointrequiresMconsecutive hourly snapshot buckets belowrequiresBalanceAtLeastbefore forfeiting a tranche or triggering an early-withdrawal clawback. A user who withdraws and re-deposits beforevestsAtpasses the checkpoint so long as the snapshots atvestsAtmeetrequiresBalanceAtLeast. - Clawbacks are strictly off-chain future-reward offsets capped at
CLAWBACK_MAX_PER_REFERRER($200) and written off afterCLAWBACK_WRITEOFF_DAYS(90days). The platform never initiates an on-chain pull from a referrer's wallet. Excess above the per-referrer cap or unrecovered balances past 90 days are recorded inwrittenOffAmount(status = 'WRITTEN_OFF') and audited in#315AuditPayloadHash. - Referred user balance privacy.
GET /api/v1/referrals/meandreferral.tranche_forfeitedexpose only tranche status (PENDING | VESTED_PAID | FORFEITED | CLAWED_BACK) and reason codes — never the referred user's balance or position sizes.