diff --git a/ASSUMPTIONS.md b/ASSUMPTIONS.md new file mode 100644 index 0000000..ef56e5d --- /dev/null +++ b/ASSUMPTIONS.md @@ -0,0 +1,63 @@ +# Assumptions + +Documented, reasonable assumptions made while implementing features, so a +reviewer can override any of them without archaeology. + +--- + +## Issue #285 — Strategy Marketplace / Opt-In Copy-Trading + +1. **`strategyConfig` serializes exactly `{ strategyName, targetAllocations?, +riskCeiling? }`** — the three keys `src/agent/loop.ts` actually reads. + `riskTolerance` stays personal to the follower and is never copied. + +2. **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. + +3. **Metric recompute interval defaults to 6 h** + (`STRATEGY_METRICS_INTERVAL_MS`), matching `config.protocolRisk.intervalMs`. + Figures derive from hourly snapshots, so a faster cadence buys nothing. + +4. **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. + +5. **`GOAL_TRACKING` is not publishable.** It is driven by the publisher's own + `SavingsGoal` target 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. + +6. **`POST /strategies/publish` accepts an explicit `strategyConfig`, and + snapshots the caller's `User.rebalanceStrategy` / `User.strategyConfig` when + it is omitted.** Nothing in `src/` 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. + +7. **`TARGET_ALLOCATION` weights 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. + +8. **DEVIATION from the plan's signature:** `computeStrategyMetrics` takes an + input object with a separate `firstObservedAt` rather 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. + +9. **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. + +10. **Unfollow tolerates an orphaned follow.** When a publisher deletes their + account, `onDelete: SetNull` leaves the follower with no strategy id to + name. `POST /strategies/:id/unfollow` releases the caller's active follow + when its `publishedStrategyId` is 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. + +11. **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 `isPublished` filter. diff --git a/docs/DOCUMENTATION_INDEX.md b/docs/DOCUMENTATION_INDEX.md index 1aaeaab..aa966dc 100644 --- a/docs/DOCUMENTATION_INDEX.md +++ b/docs/DOCUMENTATION_INDEX.md @@ -9,6 +9,7 @@ - **[IMPLEMENTATION_DETAILS.md](IMPLEMENTATION_DETAILS.md)** - Deep dive into implementation - **[API_REFERENCE.md](API_REFERENCE.md)** - Complete backend endpoint reference - **[TAX_REPORT.md](TAX_REPORT.md)** - Tax reporting & FIFO cost-basis lot tracking (#284) +- **[STRATEGY_MARKETPLACE.md](STRATEGY_MARKETPLACE.md)** - Strategy marketplace / opt-in copy-trading: metric formula, eligibility gate, privacy & custody boundaries (#285) ### For DevOps/Deployment @@ -272,9 +273,9 @@ grep "RPC" logs/*.log ### For Code Review 1. CODE_STRUCTURE.md - Architecture -3. IMPLEMENTATION_DETAILS.md - Technical details -4. Review src/stellar/events.ts - Code review -5. Review tests - Test coverage +2. IMPLEMENTATION_DETAILS.md - Technical details +3. Review src/stellar/events.ts - Code review +4. Review tests - Test coverage --- diff --git a/docs/STRATEGY_MARKETPLACE.md b/docs/STRATEGY_MARKETPLACE.md new file mode 100644 index 0000000..de65e74 --- /dev/null +++ b/docs/STRATEGY_MARKETPLACE.md @@ -0,0 +1,334 @@ +# Strategy Marketplace / Opt-In Copy-Trading (#285) + +A user publishes an anonymized snapshot of their agent **configuration**, others +follow it, and a follower's agent applies that configuration on its next +scheduled run. + +**Configuration only — never funds, never custody, never keys.** + +--- + +## 1. The two boundaries + +These are the load-bearing properties of the feature. Both are enforced +structurally, not by convention, and both have tests that fail if the structure +changes. + +### Custody boundary + +A follow copies configuration and nothing else. `src/strategy/service.ts` +imports nothing from `src/stellar/` — no wallet, no contract, no key custody — so +there is **no code path from a follow to another user's funds**. + +Asserted by `tests/integration/agent/strategy-follow.integration.test.ts`, which +reads the import graph of `src/strategy/service.ts` and +`src/agent/effectiveStrategy.ts` and fails if a +stellar/wallet/db import appears. The same suite drives a real +`rebalanceCheckJob` with a follow in place and asserts that no wallet is opened, +no key is read, `db.custodialWallet` is never touched, and no downstream call +carries the publisher's user id. + +### Anonymization boundary + +Three independent layers, because this is the first feature exposing one user's +data to another: + +| Layer | Where | What it does | +| --------- | ---------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | +| 1. Query | `marketplaceSelect` in `src/strategy/service.ts` | Never selects `userId`, never includes `user`. Follows the `alertSelect` pattern in `src/routes/alerts.ts`. | +| 2. Mapper | `mapMarketplaceEntryToResponse` / `mapFollowToResponse` in `src/utils/api-formatters.ts` | Hand-written allowlists, never a spread. Adding a field to a select does not silently publish it. | +| 3. Input | `strategyLabelSchema` in `src/validators/strategy-validators.ts` | `label` is the one place a user can self-doxx. Rejects Stellar addresses (`[GC]` + 55 base32 chars) and 32+ char hex runs; caps length at 60. | + +The publisher's `userId` **is** loaded in `followStrategy` — solely to compare +against the caller for the self-follow check. It never reaches a response. + +Displayed statistics are derived from the publisher's own aggregates only. The +response carries `apy` / `sharpe` / `trackRecordDays` / `sampleCount` and never +an absolute currency amount. + +--- + +## 2. The metric definition + +Lives in `src/agent/strategyMetrics.ts` — pure, zero I/O, unit tested against +fixture series. `src/jobs/strategyMetrics.ts` is the only thing that reads the DB +and calls into it. + +### The correctness trap + +`YieldSnapshot.apy` is **not** a period return. `src/agent/snapshotter.ts` +computes it as cumulative-yield-since-`openedAt`, annualized — so consecutive +values are a smoothed running average. Taking the standard deviation of that +column would badly understate volatility and produce a flattering, easily gamed +Sharpe ratio. + +The correct series is portfolio **value**: + +1. `value = principalAmount + yieldAmount`, summed across positions sharing an + exact `snapshotAt` so a point is the whole portfolio, not one position + (`bucketByInstant`; same aggregation as `valueByInstant` in + `src/jobs/alertRules.ts`). +2. Differenced into simple period returns (`periodReturns`). An interval whose + starting value is `<= 0` is **skipped** — a portfolio funded from empty is a + deposit, not a return, and letting it through would hand the publisher an + unbounded Sharpe. + +### Formulas + +**Annualized return** (`annualizedReturnPercent`) — simple, non-compounding, +matching `snapshotter.calculateApy` and `src/goals/service.ts` so marketplace +figures never disagree with the APY shown elsewhere in the product: + +``` +totalReturn = (lastValue - firstValue) / firstValue +years = max(elapsed / 365.25 days, 1/365) +apy = totalReturn / years * 100 +``` + +**Sharpe ratio** (`sharpeRatio`): + +``` +perPeriodRiskFree = riskFreeRate / periodsPerYear +sharpe = (mean(returns) - perPeriodRiskFree) / sampleStdev(returns) + * sqrt(periodsPerYear) +``` + +- `sampleStdev` is the **sample** standard deviation (n-1), the standard Sharpe + denominator. +- `periodsPerYear` is inferred from the **median** spacing of the series + (`inferPeriodsPerYear`), so a single data gap does not distort the + annualization factor. Snapshots are hourly, so this is ~8766 in practice. +- `riskFreeRate` defaults to **0**, configurable via + `STRATEGY_RISK_FREE_RATE`. Stating it explicitly beats a hidden non-zero + assumption. + +**Sharpe is `null` — never 0, never a sentinel — when it is not computable**: +fewer than two returns, or a zero/degenerate standard deviation. A flat series +has no risk-adjusted story to tell, and dividing by its zero stdev would produce +`Infinity`, which would sort straight to the top of the leaderboard. + +### Eligibility gate (anti-gaming) + +``` +trackRecordDays >= 30 (MIN_TRACK_RECORD_DAYS) +sampleCount >= 14 (MIN_SAMPLES) +sharpe !== null +``` + +Ineligible strategies are **excluded from the leaderboard entirely** rather than +shown with a low score, so a one-day strategy posting a misleading APY never +appears. Mirrors the `insufficientHistory` handling in +`src/agent/riskScoring.ts`. + +`trackRecordDays` is measured from the publisher's **earliest ever** snapshot, +supplied separately from the windowed series. This is deliberate: the window +governs the _return statistics_, the track record governs _trust_. Conflating +them would make the 30-day window structurally incapable of ever reaching a +30-day track record — its oldest in-window sample sits ~30 days back, floors to +29, and the 30-day leaderboard would stay permanently empty. + +### Windows: 30d and 90d only + +`src/agent/snapshotter.ts` hard-deletes `YieldSnapshot` rows past 90 days. A `1y` +window therefore has no data behind it and would return a 90-day figure +mislabelled as a year. It is rejected with a **400 naming the retention limit**, +never silently downgraded. + +### This is the #225 seam + +Issue #225 (portfolio analytics: Sharpe / Sortino / volatility) has not landed — +there is no analytics service in `src/`. `src/agent/strategyMetrics.ts` is the +**one** definition of risk-adjusted return in this codebase. When #225 lands, +re-point this module at that service. **Do not add a third definition.** + +--- + +## 3. Precedence + +Highest first, when the agent picks a strategy for a user: + +1. **Active `SavingsGoal`** — a stated personal target outranks a copied + configuration. Handled in `src/agent/router.ts`, unchanged by this feature. +2. **Followed strategy** — the follow's `appliedConfig` snapshot. +3. **The user's own** `User.rebalanceStrategy` / `User.strategyConfig`. + +Merging happens in `resolveEffectiveConfig` (`src/agent/effectiveStrategy.ts`). +A follow replaces the strategy and its allocations **wholesale**, not +key-by-key — pairing the publisher's strategy with the follower's leftover +allocations would produce a configuration neither party chose. + +### The risk-ceiling invariant + +`riskCeiling` is the one key a follow may **not** simply overwrite. Higher score += lower risk, so the stricter ceiling is the **larger** number, and that is what +wins (`Math.max`). + +> **A follow can only ever tighten a follower's risk exposure, never widen it.** + +Copying a stranger's looser ceiling onto someone's funds because they clicked +"follow" is not a trade-off this feature is allowed to make. Mirrors the +fail-closed contract in `applyRiskCeiling` (`src/agent/strategies.ts`). + +`riskTolerance` is never copied at all — it stays personal to each user. + +--- + +## 4. Agent-loop integration + +Two hazards in `src/agent/loop.ts`, both of which would silently corrupt +behavior: + +**Hazard 1 — batching collision.** `src/agent/router.ts` reads only +`userStrategyPreferences[0]`. The batching key was `${protocolName}:${strategy}`, +so two followers of _different_ published strategies sharing a protocol would +collapse into one batch and both receive index-0's config, including index-0's +risk ceiling. The key is now: + +``` +`${protocolName}:${effectiveStrategyName}:${followId ?? 'none'}` +``` + +Keyed on the per-user **follow id**, not the followed strategy id: two followers +of the _same_ strategy can still have different effective ceilings, because a +follow clamps to the stricter of publisher and follower. It costs some batching; +it buys risk correctness. + +**Hazard 2 — the preferences guard.** `src/agent/loop.ts` built `userStrategyPreferences` +only when `strategyName` was truthy; otherwise it passed `undefined` and +`executeRebalanceIfNeeded` skipped the strategy engine entirely. A follower whose +own `rebalanceStrategy` is null — the common case for a new user, and exactly who +this feature targets — would have had their followed config silently ignored. +The guard is now "an effective strategy **or** an active follow". + +### Backward-compatibility contract + +A user with no follow takes an identical code path, issues identical queries, and +produces identical decisions. Their own config is read with the exact same raw +expressions as before #285 (uncoerced), and the follow component of the batching +key is the constant `'none'`, so grouping is unchanged. Asserted by the +no-follow regression tests in +`tests/integration/agent/strategy-follow.integration.test.ts`. + +The follow lookup is **one query per tick** (`loadActiveFollowsForUsers`), not +one per user, and returns an empty Map when nobody in the batch follows anything. + +`followedStrategyId` is threaded into the `AgentLog` row for auditability only — +it never influences a decision. + +--- + +## 5. Data model + +| Model | Notes | +| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `PublishedStrategy` | One row per user (`userId @unique`) — publish always acts on the caller, so re-publishing upserts. `configVersion` bumps only on a **material** change to the three agent-relevant keys; a label edit is cosmetic. | +| `StrategyFollow` | Carries its own `appliedConfig` **snapshot**, not a live read-through. `publishedStrategyId` is nullable with `onDelete: SetNull` so a follower survives the publisher deleting their account. | +| `PublishedStrategyMetric` | One row per `(strategy, window)`. Precomputed because the leaderboard must `ORDER BY` the score with `skip`/`take` (a JS-computed value cannot be ordered in SQL) and recomputing every publisher's history per request would be a DoS vector. Same precedent as `ProtocolRiskScore` + `src/jobs/protocolRiskScoring.ts`. | + +**Partial unique index** (raw SQL in the migration — Prisma cannot express +partial uniques): + +```sql +CREATE UNIQUE INDEX "strategy_follows_active_follower_key" + ON "strategy_follows"("followerUserId") WHERE "unfollowedAt" IS NULL; +``` + +At most one active follow per user. The service swaps follows inside a +transaction; this index is the structural backstop if that is ever refactored +wrong. + +--- + +## 6. API + +All routes require `requireAuth`. Mounted at `/api/v1/strategies` (plus the +deprecated `/api/strategies` alias) via the `apiRoutes` table in `src/index.ts`. + +| Method | Path | Notes | +| ------ | -------------------------- | -------------------------------------------------- | +| `POST` | `/strategies/publish` | Upsert + publish the caller's own snapshot | +| `POST` | `/strategies/unpublish` | Immediate — drops from marketplace queries at once | +| `GET` | `/strategies/marketplace` | `?sortBy=apy\|sharpe&window=30d\|90d&page&limit` | +| `GET` | `/strategies/following` | The caller's active follow, or `{ follow: null }` | +| `POST` | `/strategies/:id/follow` | | +| `POST` | `/strategies/:id/unfollow` | Also releases an orphaned follow | + +**Not** `/api/agent/strategies/*` as the issue proposed: `src/routes/agent.ts` +sits behind `internalAuthGuard`, an operator/machine guard that authenticates by +`INTERNAL_SERVICE_TOKEN` and **sets no `req.userId`**. User-facing routes cannot +live there. + +The path param is `:id` (a `PublishedStrategy` id), deliberately **not** +`:userId` — `enforceUserAccess` compares `req.params.userId` against the caller +and would reject every follow request. Ownership is enforced inside the service +instead. + +Pagination defaults are local (`limit` 20, max 50), not `paginationSchema`'s +(`limit` 5): that schema is tuned for WhatsApp transaction lists, not a +leaderboard. The envelope follows the flat `{ page, limit, total, strategies }` +shape of `src/routes/transactions.ts`. + +**Self-follow** is disallowed → `StrategySelfFollowError` → 409. + +--- + +## 7. Notifications + +Two events on the `WEBHOOK_EVENTS` tuple in +`src/validators/webhook-validators.ts`: `strategy.updated`, +`strategy.unpublished`. + +Material-change detection compares the _normalized_ config (`strategyName`, +`targetAllocations` with sorted keys, `riskCeiling`). A label edit is cosmetic +and must not spam followers. + +On a material change, inside **one transaction**: bump `configVersion` and +rewrite every active follower's `appliedConfig` / `appliedConfigVersion` / +`appliedAt`. Notifications fire **outside** the transaction via +`Promise.allSettled`, fire-and-forget — a Twilio outage must not roll back a +publish. Dispatch is once per follower (the payload carries `followerUserId`, +matching the per-user convention of `alert_rule.triggered`), so a strategy with +many followers produces a proportional fan-out. + +A follower with no `phone` on file is warned-and-skipped, never thrown — +matching `src/jobs/alertRules.ts`. + +--- + +## 8. Edge cases + +| Case | Handling | +| ------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------- | +| Publisher unpublishes | `isPublished=false` → gone from marketplace queries immediately. Followers keep `appliedConfig`, get `strategy.unpublished`. | +| Publisher deletes account | `onDelete: SetNull` orphans the follow with its config intact; the agent keeps working. The follow can still be released via `/:id/unfollow`. | +| Self-follow | 409 `StrategySelfFollowError`. | +| Follow while already following | The service closes the previous follow inside one transaction; the partial unique index is the structural backstop. | +| Thin track record | Excluded from the leaderboard until 30 days **and** 14 samples **and** a computable Sharpe. | +| Follower has an active `SavingsGoal` | The goal still wins. | +| Publisher's `riskCeiling` looser than follower's | Clamped to the stricter. A follow never widens risk exposure. | +| Config references a delisted protocol | `applyRiskCeiling` already fails closed; the strategy returns `NO_ELIGIBLE_PROTOCOLS_REASON`. | +| Publisher closed all positions | The metrics job writes ineligible rows rather than leaving stale ones, so they drop off the board. | + +--- + +## 9. Configuration + +| Env var | Default | Meaning | +| ------------------------------ | ---------------- | ------------------------------------------------------------------------------------------------ | +| `STRATEGY_METRICS_INTERVAL_MS` | `21600000` (6 h) | Metric recompute cadence. Figures derive from hourly snapshots, so faster buys nothing but load. | +| `STRATEGY_RISK_FREE_RATE` | `0` | Annual risk-free rate for Sharpe, as a decimal (`0.04` = 4%). | + +--- + +## 10. Known follow-ups (deliberately out of scope here) + +1. **`src/jobs/alertRules.ts` duplicates the `valueByInstant` aggregation** that + `bucketByInstant` now generalizes. It is a tested delivery path and the + refactor is unrelated to this feature; left alone on purpose. +2. **Pre-existing:** the agent batches users by `(protocol, strategy)` and + `src/agent/router.ts` reads only `userStrategyPreferences[0]`, so two users with the + same strategy but _different_ `riskCeiling` values already collapse into one + batch and both get index-0's ceiling. This feature does not make it worse + (follows are split per-follow), but it should be fixed independently. +3. **`riskFreeRate`** is a deploy-time constant. Sourcing a live rate is + deliberately deferred. diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 8e9cb45..5319dd5 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -3065,6 +3065,278 @@ paths: '404': $ref: '#/components/responses/NotFound' + /api/v1/strategies/publish: + post: + tags: [strategies] + operationId: publishStrategy + summary: Publish the caller's strategy to the marketplace + description: > + Publishes (or re-publishes) an anonymized snapshot of the caller's own + agent configuration. One listing per user — a second call updates the + same listing. + + + `strategyConfig` carries only the three keys the agent acts on + (`strategyName`, `targetAllocations`, `riskCeiling`). `riskTolerance` is + never copied; it stays personal to each user. Omit `strategyConfig` to + snapshot whatever the caller is currently running. + + + `configVersion` increments only on a MATERIAL change to those three + keys — a label-only edit is cosmetic and notifies nobody. When it does + increment, every active follower's applied snapshot is rewritten in the + same transaction and they receive a `strategy.updated` notification. + + + `label` is free text shown to strangers and is rejected (not stripped) + if it contains a Stellar address or a long hexadecimal run. + security: + - BearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/PublishStrategyRequest' + responses: + '200': + description: The caller's listing + content: + application/json: + schema: + $ref: '#/components/schemas/PublishStrategyResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + + /api/v1/strategies/unpublish: + post: + tags: [strategies] + operationId: unpublishStrategy + summary: Delist the caller's strategy + description: > + Removes the caller's listing from every marketplace query immediately. + + + Existing follows are NOT severed. Followers keep the configuration + snapshot they already copied and their agent keeps running unchanged — + silently reverting someone's live configuration because a stranger + delisted theirs would change what the agent does with the follower's + money without the follower ever acting. Followers receive a + `strategy.unpublished` notification. + security: + - BearerAuth: [] + responses: + '200': + description: The delisted listing + content: + application/json: + schema: + type: object + required: [strategy] + properties: + strategy: + $ref: '#/components/schemas/PublishedStrategy' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + + /api/v1/strategies/marketplace: + get: + tags: [strategies] + operationId: getStrategyMarketplace + summary: Browse the ranked, anonymized leaderboard + description: > + Returns published strategies ranked by a precomputed, risk-adjusted + score. No response field identifies a publisher: no `userId`, no wallet + address, no absolute balance — only the configuration and derived + statistics. + + + Strategies below the eligibility gate (at least 30 days of track record, + at least 14 portfolio samples, and a computable Sharpe ratio) are + excluded ENTIRELY rather than ranked low, so a one-day strategy posting + a flattering APY never appears. + + + `window` accepts `30d` and `90d` only. Yield snapshots are retained for + 90 days, so a longer window has no data behind it and is rejected with a + 400 rather than silently downgraded. + security: + - BearerAuth: [] + parameters: + - in: query + name: sortBy + schema: + type: string + enum: [apy, sharpe] + default: sharpe + description: Ranking field. Defaults to the risk-adjusted score. + - in: query + name: window + schema: + type: string + enum: ['30d', '90d'] + default: '30d' + description: Statistics window. Longer windows are rejected (90-day snapshot retention). + - in: query + name: page + schema: + type: integer + minimum: 1 + default: 1 + - in: query + name: limit + schema: + type: integer + minimum: 1 + maximum: 50 + default: 20 + responses: + '200': + description: A page of the leaderboard + content: + application/json: + schema: + $ref: '#/components/schemas/StrategyMarketplaceResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + + /api/v1/strategies/following: + get: + tags: [strategies] + operationId: getFollowedStrategy + summary: What the caller currently follows + description: > + Returns the caller's single active follow, or `{ "follow": null }` when + they follow nothing — following nothing is a normal state, not a missing + resource. + + + `appliedConfig` is the snapshot the agent actually runs and may lag the + live listing (or outlive it entirely, if the publisher delisted or + deleted their account). `strategy` is null for such an orphaned follow. + security: + - BearerAuth: [] + responses: + '200': + description: The caller's active follow, or null + content: + application/json: + schema: + type: object + required: [follow] + properties: + follow: + type: object + nullable: true + allOf: + - $ref: '#/components/schemas/StrategyFollow' + '401': + $ref: '#/components/responses/Unauthorized' + + /api/v1/strategies/{id}/follow: + post: + tags: [strategies] + operationId: followStrategy + summary: Follow a published strategy + description: > + Copies the strategy's configuration into a snapshot the caller's agent + applies on its next scheduled run. Configuration only — no funds move, + no custody is transferred, and the publisher's identity is never + revealed. + + + A user may follow at most one strategy at a time; following while + already following swaps atomically. Following your own strategy is + rejected with 409. + + + The caller's own risk ceiling is never widened: the effective ceiling is + the STRICTER of the publisher's and the follower's. An active savings + goal continues to outrank a followed configuration. + security: + - BearerAuth: [] + parameters: + - in: path + name: id + required: true + schema: + type: string + format: uuid + description: The published strategy's id (NOT a user id) + responses: + '201': + description: The created follow + content: + application/json: + schema: + type: object + required: [follow] + properties: + follow: + $ref: '#/components/schemas/StrategyFollow' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + '409': + description: Self-follow is not permitted + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /api/v1/strategies/{id}/unfollow: + post: + tags: [strategies] + operationId: unfollowStrategy + summary: Release the caller's follow + description: > + Ends the caller's active follow. Their agent reverts to their own + `rebalanceStrategy`/`strategyConfig` on the next scheduled run. + + + Also releases an orphaned follow (one whose publisher deleted their + account, leaving no strategy id to name). + security: + - BearerAuth: [] + parameters: + - in: path + name: id + required: true + schema: + type: string + format: uuid + description: The published strategy's id + responses: + '200': + description: The released follow + content: + application/json: + schema: + type: object + required: [id, unfollowedAt] + properties: + id: + type: string + format: uuid + unfollowedAt: + type: string + format: date-time + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + components: securitySchemes: BearerAuth: @@ -3800,6 +4072,201 @@ components: type: string enum: [WEBHOOK, WHATSAPP, BOTH] description: Where a triggered alert is delivered. + StrategyConfig: + type: object + description: > + The three keys the agent acts on. Nothing else is ever copied to a + follower — notably `riskTolerance`, which stays personal. + required: [strategyName] + properties: + strategyName: + type: string + enum: [MAX_YIELD, TARGET_ALLOCATION] + description: > + GOAL_TRACKING is not publishable — it is driven by the publisher's + own savings goal, which means nothing to a follower. + targetAllocations: + type: object + additionalProperties: + type: number + minimum: 0 + maximum: 100 + description: > + Protocol name → percentage weight. Required when strategyName is + TARGET_ALLOCATION, and must sum to 100 — followers inherit these + weights verbatim. + example: + Blend: 60 + Luma: 40 + riskCeiling: + type: integer + minimum: 0 + maximum: 100 + description: > + Minimum acceptable protocol risk score (higher = lower risk). A + follower's effective ceiling is the stricter of theirs and the + publisher's. + + PublishStrategyRequest: + type: object + required: [label] + properties: + label: + type: string + maxLength: 60 + description: > + Public display name. Rejected if it contains a Stellar address or a + hexadecimal run of 32+ characters — published strategies are + anonymous. + example: Steady conservative yield + strategyConfig: + $ref: '#/components/schemas/StrategyConfig' + + PublishedStrategy: + type: object + description: > + A marketplace listing. Deliberately carries no `userId` — the publisher + is anonymous to everyone including followers. + required: [id, label, strategyConfig, configVersion, isPublished] + properties: + id: + type: string + format: uuid + label: + type: string + example: Steady conservative yield + strategyConfig: + $ref: '#/components/schemas/StrategyConfig' + configVersion: + type: integer + description: Incremented only on a material configuration change. + isPublished: + type: boolean + publishedAt: + type: string + format: date-time + nullable: true + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time + + PublishStrategyResponse: + type: object + required: [strategy, materialChange] + properties: + strategy: + $ref: '#/components/schemas/PublishedStrategy' + materialChange: + type: boolean + description: > + True when this publish changed the configuration and therefore + bumped `configVersion` and notified followers. + + StrategyMarketplaceEntry: + type: object + description: > + One leaderboard row. Statistics are derived and relative — never an + absolute balance, and never anything identifying the publisher. + required: + - strategyId + - label + - strategyConfig + - windowDays + - apy + - sampleCount + - trackRecordDays + properties: + strategyId: + type: string + format: uuid + label: + type: string + strategyConfig: + $ref: '#/components/schemas/StrategyConfig' + configVersion: + type: integer + publishedAt: + type: string + format: date-time + nullable: true + windowDays: + type: integer + enum: [30, 90] + apy: + type: number + description: Annualized return over the window, in percent (simple, non-compounding). + sharpe: + type: number + nullable: true + description: > + Risk-adjusted return. Null when not computable (too few samples, or a + zero-variance series) — never faked as 0. Eligible entries always + have a value. + sampleCount: + type: integer + trackRecordDays: + type: integer + description: Days of observed history. At least 30 for any listed entry. + computedAt: + type: string + format: date-time + + StrategyMarketplaceResponse: + type: object + required: [page, limit, total, window, sortBy, strategies] + properties: + page: + type: integer + limit: + type: integer + total: + type: integer + window: + type: string + enum: ['30d', '90d'] + sortBy: + type: string + enum: [apy, sharpe] + strategies: + type: array + items: + $ref: '#/components/schemas/StrategyMarketplaceEntry' + + StrategyFollow: + type: object + required: [id, appliedConfig, appliedConfigVersion, appliedAt, followedAt] + properties: + id: + type: string + format: uuid + strategyId: + type: string + format: uuid + nullable: true + description: Null when the publisher deleted their account (the follow is orphaned). + appliedConfig: + allOf: + - $ref: '#/components/schemas/StrategyConfig' + description: > + The snapshot the agent actually runs. May lag the live listing, and + outlives it if the publisher delists or deletes their account. + appliedConfigVersion: + type: integer + appliedAt: + type: string + format: date-time + followedAt: + type: string + format: date-time + strategy: + type: object + nullable: true + allOf: + - $ref: '#/components/schemas/PublishedStrategy' + AlertRule: type: object required: diff --git a/prisma/migrations/20260725000000_add_strategy_marketplace/migration.sql b/prisma/migrations/20260725000000_add_strategy_marketplace/migration.sql new file mode 100644 index 0000000..0ba46bc --- /dev/null +++ b/prisma/migrations/20260725000000_add_strategy_marketplace/migration.sql @@ -0,0 +1,102 @@ +-- Strategy marketplace / opt-in copy-trading (#285). +-- +-- Adds three tables so a user can publish an anonymized snapshot of their agent +-- *configuration* and others can follow it. Configuration only — no table here +-- references a wallet, a key, or a balance, and there is deliberately no column +-- linking a follow to the publisher's custody. +-- +-- Two schema decisions worth reading before altering this: +-- +-- 1. strategy_follows."publishedStrategyId" is NULLABLE with ON DELETE SET NULL, +-- and carries its own "appliedConfig" snapshot. A follower must keep running +-- on their last-applied config when the publisher unpublishes or deletes +-- their account; cascading the follow away would silently change what the +-- agent does with the follower's money. +-- +-- 2. "At most one active follow per user" is a PARTIAL unique index. Prisma +-- cannot express partial uniques, so it is created by hand below and is the +-- structural backstop behind the service-layer swap-in-a-transaction. + +-- CreateTable +CREATE TABLE "published_strategies" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "label" TEXT NOT NULL, + "strategyConfig" JSONB NOT NULL, + "configVersion" INTEGER NOT NULL DEFAULT 1, + "isPublished" BOOLEAN NOT NULL DEFAULT false, + "publishedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "published_strategies_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "strategy_follows" ( + "id" TEXT NOT NULL, + "followerUserId" TEXT NOT NULL, + "publishedStrategyId" TEXT, + "appliedConfig" JSONB NOT NULL, + "appliedConfigVersion" INTEGER NOT NULL, + "appliedAt" TIMESTAMP(3) NOT NULL, + "followedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "unfollowedAt" TIMESTAMP(3), + + CONSTRAINT "strategy_follows_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "published_strategy_metrics" ( + "id" TEXT NOT NULL, + "publishedStrategyId" TEXT NOT NULL, + "windowDays" INTEGER NOT NULL, + "apy" DOUBLE PRECISION NOT NULL, + "sharpe" DOUBLE PRECISION, + "sampleCount" INTEGER NOT NULL, + "trackRecordDays" INTEGER NOT NULL, + "isEligible" BOOLEAN NOT NULL DEFAULT false, + "computedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "published_strategy_metrics_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "published_strategies_userId_key" ON "published_strategies"("userId"); + +-- CreateIndex +CREATE INDEX "published_strategies_isPublished_idx" ON "published_strategies"("isPublished"); + +-- CreateIndex +CREATE INDEX "strategy_follows_followerUserId_unfollowedAt_idx" ON "strategy_follows"("followerUserId", "unfollowedAt"); + +-- CreateIndex +CREATE INDEX "strategy_follows_publishedStrategyId_unfollowedAt_idx" ON "strategy_follows"("publishedStrategyId", "unfollowedAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "published_strategy_metrics_publishedStrategyId_windowDays_key" ON "published_strategy_metrics"("publishedStrategyId", "windowDays"); + +-- CreateIndex +CREATE INDEX "published_strategy_metrics_windowDays_isEligible_sharpe_idx" ON "published_strategy_metrics"("windowDays", "isEligible", "sharpe"); + +-- CreateIndex +CREATE INDEX "published_strategy_metrics_windowDays_isEligible_apy_idx" ON "published_strategy_metrics"("windowDays", "isEligible", "apy"); + +-- CreateIndex +-- Partial unique index: at most one ACTIVE follow per user. Not expressible in +-- schema.prisma; the service layer swaps follows inside a transaction and this +-- is the structural guarantee behind it. +CREATE UNIQUE INDEX "strategy_follows_active_follower_key" + ON "strategy_follows"("followerUserId") WHERE "unfollowedAt" IS NULL; + +-- AddForeignKey +ALTER TABLE "published_strategies" ADD CONSTRAINT "published_strategies_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "strategy_follows" ADD CONSTRAINT "strategy_follows_followerUserId_fkey" FOREIGN KEY ("followerUserId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "strategy_follows" ADD CONSTRAINT "strategy_follows_publishedStrategyId_fkey" FOREIGN KEY ("publishedStrategyId") REFERENCES "published_strategies"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "published_strategy_metrics" ADD CONSTRAINT "published_strategy_metrics_publishedStrategyId_fkey" FOREIGN KEY ("publishedStrategyId") REFERENCES "published_strategies"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20260725000000_add_strategy_marketplace/rollback.sql b/prisma/migrations/20260725000000_add_strategy_marketplace/rollback.sql new file mode 100644 index 0000000..32cbab2 --- /dev/null +++ b/prisma/migrations/20260725000000_add_strategy_marketplace/rollback.sql @@ -0,0 +1,28 @@ +-- Rollback for 20260725000000_add_strategy_marketplace +-- Drops the strategy marketplace tables (#285). +-- +-- WARNING: Destroys every published strategy, every follow relationship, and +-- every precomputed leaderboard metric. Followers' agents revert to their own +-- User.rebalanceStrategy / User.strategyConfig on the next scheduled run — +-- no funds are moved by this rollback, but a follower's rebalance decisions +-- WILL change on the next tick. Announce before running in production. +-- +-- Safe to run before or after deploying the reverted application code: the +-- application only reads these tables through src/strategy/service.ts and +-- src/agent/loop.ts, both of which treat "no follow" as the default path. + +ALTER TABLE "published_strategy_metrics" DROP CONSTRAINT IF EXISTS "published_strategy_metrics_publishedStrategyId_fkey"; + +ALTER TABLE "strategy_follows" DROP CONSTRAINT IF EXISTS "strategy_follows_publishedStrategyId_fkey"; + +ALTER TABLE "strategy_follows" DROP CONSTRAINT IF EXISTS "strategy_follows_followerUserId_fkey"; + +ALTER TABLE "published_strategies" DROP CONSTRAINT IF EXISTS "published_strategies_userId_fkey"; + +DROP INDEX IF EXISTS "strategy_follows_active_follower_key"; + +DROP TABLE IF EXISTS "published_strategy_metrics"; + +DROP TABLE IF EXISTS "strategy_follows"; + +DROP TABLE IF EXISTS "published_strategies"; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f6eed1c..7893ca1 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -179,6 +179,8 @@ model User { costBasisLots CostBasisLot[] lotDisposals LotDisposal[] savingsGoals SavingsGoal[] + publishedStrategy PublishedStrategy? + strategyFollows StrategyFollow[] parentSubAccounts SubAccount[] @relation("ParentOf") childSubAccounts SubAccount[] @relation("ChildOf") @@ -765,3 +767,103 @@ model SavingsGoal { @@index([userId, status]) @@map("savings_goals") } + +/// Strategy marketplace (#285). An opt-in, anonymized snapshot of one user's +/// agent *configuration* — never funds, never custody, never keys. Publishing +/// copies nothing but `{ strategyName, targetAllocations?, riskCeiling? }`, the +/// exact three keys the agent loop reads off User.strategyConfig. +/// +/// One row per user (`userId @unique`): publish always acts on the caller, so +/// re-publishing upserts rather than accumulating rows. `label` is user-supplied +/// free text and is the one self-doxx vector — the Zod validator in +/// src/validators/strategy-validators.ts rejects Stellar addresses and long hex +/// runs. Nothing in this model or its query paths may expose `userId` to +/// another user; see docs/STRATEGY_MARKETPLACE.md for the privacy boundary. +/// +/// `configVersion` is bumped ONLY on a material config change (a `label` edit is +/// cosmetic and must not spam followers) and is what drives follower +/// notifications and re-snapshotting. +model PublishedStrategy { + id String @id @default(uuid()) + userId String @unique + label String + strategyConfig Json + configVersion Int @default(1) + isPublished Boolean @default(false) + publishedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + followers StrategyFollow[] + metrics PublishedStrategyMetric[] + + @@index([isPublished]) + @@map("published_strategies") +} + +/// A follower's opt-in link to a published strategy (#285). +/// +/// `appliedConfig` is a SNAPSHOT, not a live read-through: it is what the agent +/// actually applies. That is deliberate — when a publisher unpublishes or +/// deletes their account the follower must keep working on their last-applied +/// configuration rather than silently reverting. Hence `publishedStrategyId` is +/// nullable with `onDelete: SetNull`: deleting the publisher orphans the follow +/// with its config intact instead of cascading the follow row away. +/// +/// "At most one active follow per user" is enforced structurally by a raw-SQL +/// PARTIAL unique index (`WHERE "unfollowedAt" IS NULL`) added in the migration — +/// Prisma cannot express partial uniques, so it is not visible here. +model StrategyFollow { + id String @id @default(uuid()) + followerUserId String + publishedStrategyId String? + appliedConfig Json + appliedConfigVersion Int + appliedAt DateTime + followedAt DateTime @default(now()) + unfollowedAt DateTime? + + follower User @relation(fields: [followerUserId], references: [id], onDelete: Cascade) + publishedStrategy PublishedStrategy? @relation(fields: [publishedStrategyId], references: [id], onDelete: SetNull) + + @@index([followerUserId, unfollowedAt]) + @@index([publishedStrategyId, unfollowedAt]) + @@map("strategy_follows") +} + +/// Precomputed leaderboard metrics for a published strategy (#285), one row per +/// (strategy, window). +/// +/// Persisted rather than computed per request for two reasons: the leaderboard +/// must `ORDER BY` the score with `skip`/`take` (a JS-computed value cannot be +/// ordered in SQL), and recomputing every publisher's 90-day snapshot history on +/// a semi-public endpoint is a DoS vector. Same precedent as ProtocolRiskScore + +/// src/jobs/protocolRiskScoring.ts. +/// +/// `windowDays` is 30 or 90 only — src/agent/snapshotter.ts hard-deletes +/// YieldSnapshot rows past 90 days, so a "1y" window would silently return a +/// 90-day figure under a wrong label. +/// +/// `isEligible` is the anti-gaming gate (track record + sample count + a +/// computable Sharpe). Ineligible strategies are excluded from the leaderboard +/// entirely rather than ranked low, so a one-day strategy posting a flattering +/// APY never appears at all. +model PublishedStrategyMetric { + id String @id @default(uuid()) + publishedStrategyId String + windowDays Int + apy Float + sharpe Float? + sampleCount Int + trackRecordDays Int + isEligible Boolean @default(false) + computedAt DateTime @default(now()) + + publishedStrategy PublishedStrategy @relation(fields: [publishedStrategyId], references: [id], onDelete: Cascade) + + @@unique([publishedStrategyId, windowDays]) + @@index([windowDays, isEligible, sharpe]) + @@index([windowDays, isEligible, apy]) + @@map("published_strategy_metrics") +} diff --git a/src/agent/effectiveStrategy.ts b/src/agent/effectiveStrategy.ts new file mode 100644 index 0000000..3e957df --- /dev/null +++ b/src/agent/effectiveStrategy.ts @@ -0,0 +1,189 @@ +/** + * Own-vs-followed strategy config resolution — pure computation (#285). + * + * The agent loop reads exactly three keys off a user's strategy config: + * `strategyName`, `targetAllocations`, `riskCeiling` (see loop.ts). When that + * user follows a published strategy, this module decides which of the two + * configs each key comes from. Zero I/O so the precedence rules — the part that + * actually changes what the agent does with someone's money — are unit testable + * in isolation. + * + * Precedence, highest first (docs/STRATEGY_MARKETPLACE.md): + * + * 1. An ACTIVE SavingsGoal. Handled upstream in router.ts, not here — a + * stated personal target outranks a copied configuration, so a goal wins + * over a follow the same way it already wins over a stored preference. + * 2. The followed strategy's config. + * 3. The user's own User.rebalanceStrategy / User.strategyConfig. + * + * ─── THE RISK-CEILING INVARIANT ────────────────────────────────────────────── + * + * `riskCeiling` is the one key a follow may NOT simply overwrite. Higher score + * = lower risk, so the stricter of the two ceilings is the MAXIMUM, and that is + * what wins. A follow can only ever tighten a follower's risk exposure, never + * widen it. Copying a stranger's looser ceiling onto someone's funds because + * they clicked "follow" is not a trade-off this feature is allowed to make. + * Mirrors the fail-closed contract in applyRiskCeiling (strategies.ts). + * + * ─── THE NO-FOLLOW CONTRACT ────────────────────────────────────────────────── + * + * With no follow, the returned config is the caller's own values unchanged — + * the agent must take an identical code path and reach an identical decision + * for the overwhelming majority of users who never touch this feature. A + * regression test in tests/integration/agent/strategy-follow.integration.test.ts + * asserts exactly that. + */ + +import { StrategyName } from './types' + +/** The three-key shape stored in User.strategyConfig / PublishedStrategy.strategyConfig. */ +export interface StrategyConfigShape { + strategyName?: StrategyName | null + targetAllocations?: Record + riskCeiling?: number +} + +export interface EffectiveStrategyConfig { + strategyName: StrategyName | null + targetAllocations?: Record + riskCeiling?: number +} + +const KNOWN_STRATEGY_NAMES: readonly StrategyName[] = [ + 'MAX_YIELD', + 'TARGET_ALLOCATION', + 'GOAL_TRACKING', +] + +export function isKnownStrategyName(value: unknown): value is StrategyName { + return ( + typeof value === 'string' && + (KNOWN_STRATEGY_NAMES as readonly string[]).includes(value) + ) +} + +/** + * Coerce an untrusted `Json` column into the three-key shape, dropping anything + * unrecognized. + * + * Defensive on purpose: `strategyConfig` is a Prisma `Json` column with no + * database-level shape guarantee, and `appliedConfig` is a snapshot that may + * have been written by an older version of this code. Unknown strategy names + * become null (falling through to MAX_YIELD in router.ts) rather than being + * passed through to a strategy lookup that would silently mis-dispatch. + */ +export function parseStrategyConfig(value: unknown): StrategyConfigShape { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return {} + } + + const raw = value as Record + const config: StrategyConfigShape = {} + + if (isKnownStrategyName(raw.strategyName)) { + config.strategyName = raw.strategyName + } + + if ( + typeof raw.targetAllocations === 'object' && + raw.targetAllocations !== null && + !Array.isArray(raw.targetAllocations) + ) { + const allocations: Record = {} + for (const [protocol, weight] of Object.entries( + raw.targetAllocations as Record + )) { + if (typeof weight === 'number' && Number.isFinite(weight)) { + allocations[protocol] = weight + } + } + if (Object.keys(allocations).length > 0) { + config.targetAllocations = allocations + } + } + + if (typeof raw.riskCeiling === 'number' && Number.isFinite(raw.riskCeiling)) { + config.riskCeiling = raw.riskCeiling + } + + return config +} + +/** + * The stricter of two optional risk ceilings. Higher score = lower risk, so + * stricter is the larger number. Absent means "no ceiling", which is the + * loosest possible setting and therefore loses to any present ceiling. + */ +export function stricterRiskCeiling( + a: number | undefined, + b: number | undefined +): number | undefined { + if (a === undefined) return b + if (b === undefined) return a + return Math.max(a, b) +} + +/** + * Merge a follower's own config with the config they follow. + * + * With no follow this is the identity on `own` (see the no-follow contract in + * the header). With a follow, the published strategy replaces the strategy and + * its allocations WHOLESALE rather than key-by-key: pairing the publisher's + * strategy with the follower's leftover allocations would produce a + * configuration neither party chose. `riskCeiling` is the deliberate exception + * — it clamps to the stricter of the two. + */ +export function resolveEffectiveConfig( + own: StrategyConfigShape, + followed?: StrategyConfigShape | null +): EffectiveStrategyConfig { + if (!followed) { + return { + strategyName: own.strategyName ?? null, + targetAllocations: own.targetAllocations, + riskCeiling: own.riskCeiling, + } + } + + const followedHasStrategy = Boolean(followed.strategyName) + + return { + strategyName: followed.strategyName ?? own.strategyName ?? null, + targetAllocations: followedHasStrategy + ? followed.targetAllocations + : (followed.targetAllocations ?? own.targetAllocations), + riskCeiling: stricterRiskCeiling(own.riskCeiling, followed.riskCeiling), + } +} + +/** + * Canonical form used to decide whether a re-publish is a MATERIAL change. + * + * Only the three keys the agent actually acts on are compared — `label` is + * cosmetic, and bumping `configVersion` for a typo fix would spam every + * follower with a WhatsApp message and a webhook. Allocation keys are sorted so + * that re-serializing the same object in a different key order is not mistaken + * for a change. + */ +export function normalizeStrategyConfig(config: StrategyConfigShape): string { + const allocations = config.targetAllocations + ? Object.keys(config.targetAllocations) + .sort() + .map((k) => `${k}=${config.targetAllocations![k]}`) + .join(',') + : '' + + return JSON.stringify({ + strategyName: config.strategyName ?? null, + targetAllocations: allocations, + riskCeiling: config.riskCeiling ?? null, + }) +} + +/** True when two configs differ in a way followers must be told about. */ +export function isMaterialConfigChange( + previous: StrategyConfigShape, + next: StrategyConfigShape +): boolean { + return normalizeStrategyConfig(previous) !== normalizeStrategyConfig(next) +} diff --git a/src/agent/loop.ts b/src/agent/loop.ts index f7c17b8..6302aff 100644 --- a/src/agent/loop.ts +++ b/src/agent/loop.ts @@ -16,6 +16,12 @@ import { } from './router' import { dispatchWebhookEvent } from '../services/webhookDispatcher' import { captureAllUserBalances, cleanupOldSnapshots } from './snapshotter' +import { resolveEffectiveConfig } from './effectiveStrategy' +import { + loadActiveFollowsForUsers, + type ActiveFollowConfig, +} from '../strategy/service' +import type { StrategyName } from './types' import db from '../db' import { updateAgentHeartbeat as metricsUpdateAgentHeartbeat, @@ -110,38 +116,107 @@ async function rebalanceCheckJob(): Promise { return } - // Group by (protocol, strategy) so users with different strategies - // are evaluated independently type PositionWithUser = (typeof positions)[number] - const byProtocolAndStrategy = new Map() + + // Strategy marketplace (#285): one query for the whole tick, not one per + // user. Returns an empty Map when nobody in the batch follows anything — + // which is the path every user without a follow takes. + const userIds = Array.from( + new Set(positions.map((p: PositionWithUser) => p.userId)) + ) + const followsByUser = await loadActiveFollowsForUsers(userIds) + + // Resolve each user's effective config once. With no follow this is the + // user's own values read exactly as they were before #285 — the raw + // strategyConfig fields, uncoerced — so the no-follow path stays a + // byte-for-byte no-op (asserted by the regression test in + // tests/integration/agent/strategy-follow.integration.test.ts). + const effectiveByUser = new Map< + string, + { + config: ReturnType + follow?: ActiveFollowConfig + } + >() for (const pos of positions) { - const strategy = (pos.user as any).rebalanceStrategy || 'DEFAULT' - const key = `${pos.protocolName}:${strategy}` + if (effectiveByUser.has(pos.userId)) continue + const user = pos.user as any + const own = { + strategyName: (user.rebalanceStrategy || null) as StrategyName | null, + targetAllocations: + user.strategyConfig?.targetAllocations || undefined, + riskCeiling: user.strategyConfig?.riskCeiling, + } + const follow = followsByUser.get(pos.userId) + effectiveByUser.set(pos.userId, { + config: resolveEffectiveConfig(own, follow?.appliedConfig), + follow, + }) + } + + // Group by (protocol, effective strategy, follow) so users with different + // strategies are evaluated independently. + // + // HAZARD: router.ts reads only userStrategyPreferences[0]. Without the + // follow component in this key, two followers of DIFFERENT published + // strategies sharing a protocol would collapse into one batch and both be + // rebalanced on index 0's config — including index 0's risk ceiling. + // Keying on the per-user follow id (not the followed strategy id) is + // deliberate: two followers of the SAME strategy can still have different + // effective ceilings, because a follow clamps to the stricter of publisher + // and follower. It costs some batching; it buys risk correctness. + // + // For users without a follow the component is the constant 'none', so + // grouping is identical to before this feature. + const byProtocolAndStrategy = new Map< + string, + { protocol: string; positions: PositionWithUser[] } + >() + for (const pos of positions) { + const { config, follow } = effectiveByUser.get(pos.userId)! + const key = `${pos.protocolName}:${config.strategyName || 'DEFAULT'}:${ + follow?.followId ?? 'none' + }` if (!byProtocolAndStrategy.has(key)) { - byProtocolAndStrategy.set(key, []) + byProtocolAndStrategy.set(key, { + protocol: pos.protocolName, + positions: [], + }) } - byProtocolAndStrategy.get(key)!.push(pos) + byProtocolAndStrategy.get(key)!.positions.push(pos) } let rebalancesTriggered = 0 const thresholds = getThresholds() - for (const [key, protocolPositions] of byProtocolAndStrategy.entries()) { - const [protocol, strategyKey] = key.split(':') - const strategyName = strategyKey === 'DEFAULT' ? undefined : strategyKey - - // Build per-user strategy preferences - const userStrategyPreferences = strategyName - ? protocolPositions.map((p: any) => ({ - userId: p.userId, - strategyName: p.user.rebalanceStrategy || null, - targetAllocations: - p.user.strategyConfig?.targetAllocations || undefined, - riskTolerance: p.user.riskTolerance, - // Opt-in risk ceiling (0-100 min protocol risk score). Absent for - // users who never configured one, keeping agent behavior unchanged. - riskCeiling: p.user.strategyConfig?.riskCeiling, - })) + for (const batch of byProtocolAndStrategy.values()) { + const { protocol, positions: protocolPositions } = batch + const lead = effectiveByUser.get(protocolPositions[0].userId)! + + // HAZARD: this guard used to be `strategyName ? … : undefined`, and + // executeRebalanceIfNeeded skips the strategy engine entirely when + // preferences are undefined. A follower whose OWN rebalanceStrategy is + // null — the common case for a new user, and exactly who this feature + // targets — would have had their followed config silently ignored. + const hasStrategyContext = + Boolean(lead.config.strategyName) || Boolean(lead.follow) + + const userStrategyPreferences = hasStrategyContext + ? protocolPositions.map((p: PositionWithUser) => { + const { config, follow } = effectiveByUser.get(p.userId)! + return { + userId: p.userId, + strategyName: config.strategyName, + targetAllocations: config.targetAllocations, + riskTolerance: (p.user as any).riskTolerance, + // Opt-in risk ceiling (0-100 min protocol risk score). Absent for + // users who never configured one, keeping agent behavior unchanged. + // Under a follow this is already clamped to the STRICTER of + // publisher and follower — see resolveEffectiveConfig. + riskCeiling: config.riskCeiling, + followedStrategyId: follow?.followedStrategyId ?? undefined, + } + }) : undefined const result = await executeRebalanceIfNeeded( diff --git a/src/agent/router.ts b/src/agent/router.ts index 84308d6..5cd12b6 100644 --- a/src/agent/router.ts +++ b/src/agent/router.ts @@ -185,7 +185,13 @@ export async function triggerRebalance( toProtocol: string, amount: string, positionIds: string[] = [], - strategyInfo?: { name: string; reasoning: string; deviationTrigger?: string } + strategyInfo?: { + name: string + reasoning: string + deviationTrigger?: string + /** PublishedStrategy this config was copied from (#285). Attribution only. */ + followedStrategyId?: string | null + } ): Promise { const startTime = Date.now() @@ -279,6 +285,7 @@ export async function triggerRebalance( strategyName: strategyInfo?.name, reasoning: strategyInfo?.reasoning, deviationTrigger: strategyInfo?.deviationTrigger, + followedStrategyId: strategyInfo?.followedStrategyId, }, pos.userId, pos.id @@ -291,6 +298,7 @@ export async function triggerRebalance( strategyName: strategyInfo?.name, reasoning: strategyInfo?.reasoning, deviationTrigger: strategyInfo?.deviationTrigger, + followedStrategyId: strategyInfo?.followedStrategyId, }) } @@ -416,6 +424,11 @@ export async function executeRebalanceIfNeeded( name: strategy.name, reasoning: decision.reasoning, deviationTrigger: decision.deviationTrigger, + // Attribution only (#285): which published strategy this config was + // copied from. Never used to make a decision — the config was already + // merged and risk-clamped by loop.ts before it got here. Undefined for + // every user who follows nothing, leaving the log row unchanged. + followedStrategyId: userStrategyPreferences[0]?.followedStrategyId, } ) } diff --git a/src/agent/strategyMetrics.ts b/src/agent/strategyMetrics.ts new file mode 100644 index 0000000..ee7ae71 --- /dev/null +++ b/src/agent/strategyMetrics.ts @@ -0,0 +1,309 @@ +/** + * Strategy performance metrics — pure computation (#285). + * + * Turns a publisher's YieldSnapshot history into the anonymized figures the + * marketplace leaderboard ranks on: an annualized return, a risk-adjusted + * Sharpe ratio, and the eligibility gate that keeps thin track records off the + * board entirely. Deliberately free of I/O so it can be unit tested against + * fixture series; src/jobs/strategyMetrics.ts is the only thing that reads the + * DB and calls in here, mirroring riskScoring.ts / protocolRiskScoring.ts. + * + * ─── THE CORRECTNESS TRAP ──────────────────────────────────────────────────── + * + * `YieldSnapshot.apy` is NOT a period return. src/agent/snapshotter.ts computes + * it as cumulative-yield-since-openedAt annualized, so consecutive values are a + * smoothed running average. Taking stdev() of that column would badly + * understate volatility and produce a flattering, easily gamed Sharpe. + * + * The correct series is portfolio VALUE (`principalAmount + yieldAmount`), + * bucketed by exact `snapshotAt` so a point is the whole portfolio rather than + * one position, then differenced into period returns. That is what + * bucketByInstant + periodReturns below do. + * + * ─── THE #225 SEAM ─────────────────────────────────────────────────────────── + * + * Issue #225 (portfolio analytics: Sharpe/Sortino/volatility) has not landed — + * there is no analytics service in src/. This module is the ONE definition of + * risk-adjusted return in the codebase and is the single seam #225 replaces. + * When it lands, re-point this module at that service; do not add a third + * definition. See docs/STRATEGY_MARKETPLACE.md. + * + * Conventions, all documented in that doc: + * - Annualized return uses the same SIMPLE (non-compounding) convention as + * snapshotter.calculateApy and goals/service.ts, so marketplace figures + * never disagree with the APY shown elsewhere in the product. + * - Sharpe uses the SAMPLE standard deviation (n-1), the standard definition. + * - The risk-free rate defaults to 0. Stated explicitly beats a hidden + * non-zero assumption; it is a parameter so it can be sourced later. + */ + +/** A raw snapshot row, already narrowed to the three columns that matter. */ +export interface SnapshotRow { + snapshotAt: Date + principalAmount: number + yieldAmount: number +} + +/** Whole-portfolio value at one instant. */ +export interface PortfolioPoint { + at: Date + value: number +} + +export interface StrategyMetrics { + /** Annualized return, in percent. Simple (non-compounding). */ + apy: number + /** Risk-adjusted return. Null when it cannot be computed — never faked as 0. */ + sharpe: number | null + /** Number of portfolio points the figures were computed from. */ + sampleCount: number + /** Whole days of observed history behind the strategy. */ + trackRecordDays: number + /** True only when the anti-gaming gate below passes. */ + isEligible: boolean +} + +export interface StrategyMetricsInput { + /** The windowed portfolio series (30d or 90d), any order. */ + points: PortfolioPoint[] + /** + * Earliest snapshot ever observed for this strategy, which is what + * `trackRecordDays` measures from. Supplied separately from `points` on + * purpose: the window governs the RETURN statistics, the track record governs + * TRUST. Conflating them would make the 30-day window structurally incapable + * of ever reaching a 30-day track record (its first in-window sample sits + * ~30 days back, floors to 29, and the leaderboard stays permanently empty). + * Defaults to the first point when omitted. + */ + firstObservedAt?: Date | null + /** Reference "now", injected for deterministic tests. */ + now?: Date + /** + * Annual risk-free rate as a decimal (0.04 = 4%). Defaults to + * DEFAULT_RISK_FREE_RATE; the job sources it from + * config.strategyMarketplace.riskFreeRate so the baseline is a deploy-time + * knob rather than a constant buried here. + */ + riskFreeRate?: number +} + +// ── Tunables (mirror docs/STRATEGY_MARKETPLACE.md) ─────────────────────────── + +/** + * Minimum observed history before a strategy may appear on the leaderboard. + * Anti-gaming: a strategy that got lucky for a day must not be able to post a + * headline APY next to one with real history. + */ +export const MIN_TRACK_RECORD_DAYS = 30 + +/** + * Minimum number of portfolio points before volatility (and therefore Sharpe) + * means anything. Below this the distribution is not characterized, so Sharpe + * is reported as null and the strategy is ineligible — never scored on thin + * data, mirroring `insufficientHistory` in riskScoring.ts. + */ +export const MIN_SAMPLES = 14 + +/** Default annual risk-free rate used for Sharpe. See the header note. */ +export const DEFAULT_RISK_FREE_RATE = 0 + +const MS_PER_DAY = 24 * 60 * 60 * 1000 +const MS_PER_YEAR = 365.25 * MS_PER_DAY + +/** Fallback cadence when spacing cannot be inferred: hourly, per snapshotJob. */ +const HOURLY_PERIODS_PER_YEAR = MS_PER_YEAR / (60 * 60 * 1000) + +/** + * Floor on the elapsed-years divisor, mirroring snapshotter.calculateYearsActive. + * Keeps a sub-day series from annualizing into a meaningless number. + */ +const MIN_YEARS = 1 / 365 + +function mean(values: number[]): number { + if (values.length === 0) return 0 + return values.reduce((s, v) => s + v, 0) / values.length +} + +/** Sample standard deviation (n-1) — the standard Sharpe denominator. */ +function sampleStdev(values: number[]): number { + if (values.length < 2) return 0 + const m = mean(values) + const variance = + values.reduce((s, v) => s + (v - m) ** 2, 0) / (values.length - 1) + return Math.sqrt(variance) +} + +function median(values: number[]): number { + if (values.length === 0) return 0 + const sorted = [...values].sort((a, b) => a - b) + const mid = Math.floor(sorted.length / 2) + return sorted.length % 2 === 0 + ? (sorted[mid - 1] + sorted[mid]) / 2 + : sorted[mid] +} + +/** + * Collapse per-position snapshot rows into whole-portfolio points, keyed by the + * exact snapshot instant. A user with three positions produces three rows per + * tick; without this the "portfolio" series would be one position's value and + * the volatility would be wrong. Same aggregation as the valueByInstant pattern + * in src/jobs/alertRules.ts. Result is sorted ascending by time. + */ +export function bucketByInstant(snaps: SnapshotRow[]): PortfolioPoint[] { + const valueByInstant = new Map() + + for (const s of snaps) { + const value = s.principalAmount + s.yieldAmount + if (!Number.isFinite(value)) continue + const key = s.snapshotAt.getTime() + valueByInstant.set(key, (valueByInstant.get(key) ?? 0) + value) + } + + return Array.from(valueByInstant.entries()) + .sort((a, b) => a[0] - b[0]) + .map(([at, value]) => ({ at: new Date(at), value })) +} + +/** + * Difference the value series into simple period returns. + * + * An interval whose starting value is <= 0 is SKIPPED rather than yielding + * Infinity — a portfolio that was empty and then funded is a deposit, not a + * return, and letting it through would hand the publisher an unbounded Sharpe. + */ +export function periodReturns(points: PortfolioPoint[]): number[] { + const returns: number[] = [] + + for (let i = 1; i < points.length; i++) { + const prev = points[i - 1].value + const curr = points[i].value + if (!(prev > 0) || !Number.isFinite(curr)) continue + const r = (curr - prev) / prev + if (Number.isFinite(r)) returns.push(r) + } + + return returns +} + +/** + * Annualized return in percent across the whole series, using the same simple + * (non-compounding) convention as snapshotter.calculateApy. Returns 0 when the + * series cannot support a return (fewer than two points, or a non-positive + * starting value) — 0 is correct here because the caller's eligibility gate, + * not this number, decides whether the strategy is shown at all. + */ +export function annualizedReturnPercent(points: PortfolioPoint[]): number { + if (points.length < 2) return 0 + + const first = points[0] + const last = points[points.length - 1] + if (!(first.value > 0)) return 0 + + const totalReturn = (last.value - first.value) / first.value + const elapsedMs = last.at.getTime() - first.at.getTime() + if (elapsedMs <= 0) return 0 + + const years = Math.max(elapsedMs / MS_PER_YEAR, MIN_YEARS) + const apy = (totalReturn / years) * 100 + return Number.isFinite(apy) ? apy : 0 +} + +/** + * Infer how many return periods a year holds, from the median spacing of the + * series. Median rather than mean so a single data gap (a restart, a missed + * job) does not distort the annualization factor. + */ +export function inferPeriodsPerYear(points: PortfolioPoint[]): number { + if (points.length < 2) return HOURLY_PERIODS_PER_YEAR + + const intervals: number[] = [] + for (let i = 1; i < points.length; i++) { + const delta = points[i].at.getTime() - points[i - 1].at.getTime() + if (delta > 0) intervals.push(delta) + } + if (intervals.length === 0) return HOURLY_PERIODS_PER_YEAR + + const medianInterval = median(intervals) + if (!(medianInterval > 0)) return HOURLY_PERIODS_PER_YEAR + + return MS_PER_YEAR / medianInterval +} + +/** + * Annualized Sharpe ratio, or null when it is not computable. + * + * Null — never 0, never a sentinel — for: fewer than two returns, and a + * zero/degenerate standard deviation. A flat series has no risk-adjusted + * story to tell, and dividing by its zero stdev would produce Infinity, which + * would sort straight to the top of the leaderboard. + * + * @param riskFreeRate Annual rate as a decimal (0.04 = 4%). Defaults to 0. + */ +export function sharpeRatio( + returns: number[], + periodsPerYear: number, + riskFreeRate: number = DEFAULT_RISK_FREE_RATE +): number | null { + if (returns.length < 2) return null + if (!Number.isFinite(periodsPerYear) || periodsPerYear <= 0) return null + + const sd = sampleStdev(returns) + if (!Number.isFinite(sd) || sd === 0) return null + + const perPeriodRiskFree = riskFreeRate / periodsPerYear + const excess = mean(returns) - perPeriodRiskFree + const sharpe = (excess / sd) * Math.sqrt(periodsPerYear) + + return Number.isFinite(sharpe) ? sharpe : null +} + +/** + * Compute the full metric set + eligibility for one published strategy. + * + * Eligibility (the anti-gaming acceptance criterion) is all three of: + * trackRecordDays >= MIN_TRACK_RECORD_DAYS + * sampleCount >= MIN_SAMPLES + * sharpe !== null + * + * Ineligible strategies are excluded from the leaderboard ENTIRELY by the + * query layer rather than shown with a low score, so a one-day strategy posting + * a misleading APY never appears. + */ +export function computeStrategyMetrics( + input: StrategyMetricsInput +): StrategyMetrics { + const { points } = input + const now = input.now ?? new Date() + + const sampleCount = points.length + const trackRecordStart = + input.firstObservedAt ?? (points.length > 0 ? points[0].at : null) + + const trackRecordDays = trackRecordStart + ? Math.max( + 0, + Math.floor((now.getTime() - trackRecordStart.getTime()) / MS_PER_DAY) + ) + : 0 + + const apy = annualizedReturnPercent(points) + + // Below MIN_SAMPLES we refuse to characterize volatility at all, rather than + // computing a Sharpe from 3 points and letting the eligibility gate quietly + // hide how thin it was. + const sharpe = + sampleCount >= MIN_SAMPLES + ? sharpeRatio( + periodReturns(points), + inferPeriodsPerYear(points), + input.riskFreeRate ?? DEFAULT_RISK_FREE_RATE + ) + : null + + const isEligible = + trackRecordDays >= MIN_TRACK_RECORD_DAYS && + sampleCount >= MIN_SAMPLES && + sharpe !== null + + return { apy, sharpe, sampleCount, trackRecordDays, isEligible } +} diff --git a/src/agent/types.ts b/src/agent/types.ts index e34acd4..2a5b89f 100644 --- a/src/agent/types.ts +++ b/src/agent/types.ts @@ -136,4 +136,11 @@ export interface UserStrategyPreferences { * filtering, identical to prior behavior. */ riskCeiling?: number + /** + * Id of the PublishedStrategy this user's config was copied from (#285), when + * they follow one. Carried for AgentLog attribution only — it is never used to + * make a decision and never reaches another user's response. Absent for the + * overwhelming majority of users, who follow nothing. + */ + followedStrategyId?: string | null } diff --git a/src/config/env.ts b/src/config/env.ts index c4b827b..82f1494 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -481,6 +481,22 @@ export const config = { /** Interval between user alert-rule evaluation sweeps in ms (default: 1 minute). */ intervalMs: parseInt(process.env.ALERT_RULES_INTERVAL_MS || '60000'), }, + strategyMarketplace: { + /** + * Interval between strategy-marketplace metric recomputations in ms + * (default: 6 hours, matching protocolRisk). Leaderboard figures are + * derived from hourly snapshots, so a faster cadence buys nothing but load. + */ + metricsIntervalMs: parseInt( + process.env.STRATEGY_METRICS_INTERVAL_MS || '21600000' + ), + /** + * Annual risk-free rate used as the Sharpe baseline, as a decimal + * (0.04 = 4%). Defaults to 0 — stating it explicitly beats a hidden + * non-zero assumption. See docs/STRATEGY_MARKETPLACE.md. + */ + riskFreeRate: parseFloat(process.env.STRATEGY_RISK_FREE_RATE || '0'), + }, referral: { /** * Minimum confirmed deposit (in asset units) that a referred user must make diff --git a/src/controllers/strategy-controller.ts b/src/controllers/strategy-controller.ts new file mode 100644 index 0000000..6da02ad --- /dev/null +++ b/src/controllers/strategy-controller.ts @@ -0,0 +1,211 @@ +// src/controllers/strategy-controller.ts +// Strategy marketplace (#285): publish/unpublish a strategy, browse the +// leaderboard, follow/unfollow. HTTP shaping only — every business rule and +// every privacy decision lives in src/strategy/service.ts. +import { Request, Response } from 'express' +import { logger } from '../utils/logger' +import { + sendError, + sendNotFound, + sendUnauthorized, + sendConflict, +} from '../utils/errors' +import { + mapFollowToResponse, + mapMarketplaceEntryToResponse, + mapPublishedStrategyToResponse, +} from '../utils/api-formatters' +import { + publishStrategy, + unpublishStrategy, + getMarketplace, + getActiveFollow, + followStrategy, + unfollowStrategy, + StrategyNotFoundError, + StrategyFollowNotFoundError, + StrategySelfFollowError, + StrategyValidationError, +} from '../strategy/service' + +/** + * POST /api/v1/strategies/publish + * + * Publishes the caller's own strategy. There is no "publish on behalf of" + * form — the caller is always the publisher, which is why PublishedStrategy.userId + * is unique and this is an upsert. + */ +export async function publishStrategyHandler( + req: Request, + res: Response +): Promise { + const userId = req.auth?.userId + if (!userId) { + sendUnauthorized(res) + return + } + + try { + const { strategy, materialChange } = await publishStrategy(userId, req.body) + res.status(200).json({ + strategy: mapPublishedStrategyToResponse(strategy), + materialChange, + }) + } catch (error) { + if (error instanceof StrategyValidationError) { + sendError(res, 400, error.message) + return + } + if (error instanceof StrategyNotFoundError) { + sendNotFound(res, 'Strategy') + return + } + logger.error('[Strategy] Failed to publish strategy:', error) + sendError(res, 500, 'Failed to publish strategy') + } +} + +/** + * POST /api/v1/strategies/unpublish + * + * Delists immediately. Followers keep their applied config — see the service. + */ +export async function unpublishStrategyHandler( + req: Request, + res: Response +): Promise { + const userId = req.auth?.userId + if (!userId) { + sendUnauthorized(res) + return + } + + try { + const strategy = await unpublishStrategy(userId) + res.status(200).json({ strategy: mapPublishedStrategyToResponse(strategy) }) + } catch (error) { + if (error instanceof StrategyNotFoundError) { + sendNotFound(res, 'Published strategy') + return + } + logger.error('[Strategy] Failed to unpublish strategy:', error) + sendError(res, 500, 'Failed to unpublish strategy') + } +} + +/** + * GET /api/v1/strategies/marketplace + * + * Flat `{ page, limit, total, strategies }` envelope, matching the only other + * endpoint using shared pagination (src/routes/transactions.ts). + */ +export async function getMarketplaceHandler( + req: Request, + res: Response +): Promise { + try { + const query = req.query as unknown as Parameters[0] + const result = await getMarketplace(query) + + res.status(200).json({ + page: result.page, + limit: result.limit, + total: result.total, + window: result.window, + sortBy: result.sortBy, + strategies: result.entries.map(mapMarketplaceEntryToResponse), + }) + } catch (error) { + logger.error('[Strategy] Failed to load marketplace:', error) + sendError(res, 500, 'Failed to load strategy marketplace') + } +} + +/** + * GET /api/v1/strategies/following + * + * `{ follow: null }` rather than a 404 — "I follow nothing" is a normal state, + * not a missing resource. + */ +export async function getFollowingHandler( + req: Request, + res: Response +): Promise { + const userId = req.auth?.userId + if (!userId) { + sendUnauthorized(res) + return + } + + try { + const follow = await getActiveFollow(userId) + res + .status(200) + .json({ follow: follow ? mapFollowToResponse(follow) : null }) + } catch (error) { + logger.error('[Strategy] Failed to load active follow:', error) + sendError(res, 500, 'Failed to load followed strategy') + } +} + +/** + * POST /api/v1/strategies/:id/follow + * + * `:id` is a PublishedStrategy id, never a userId — see the note in + * src/validators/strategy-validators.ts about enforceUserAccess. + */ +export async function followStrategyHandler( + req: Request, + res: Response +): Promise { + const userId = req.auth?.userId + if (!userId) { + sendUnauthorized(res) + return + } + + try { + const follow = await followStrategy(userId, String(req.params.id)) + res.status(201).json({ follow: mapFollowToResponse(follow) }) + } catch (error) { + if (error instanceof StrategySelfFollowError) { + sendConflict(res, error.message) + return + } + if (error instanceof StrategyNotFoundError) { + sendNotFound(res, 'Published strategy') + return + } + logger.error('[Strategy] Failed to follow strategy:', error) + sendError(res, 500, 'Failed to follow strategy') + } +} + +/** + * POST /api/v1/strategies/:id/unfollow + */ +export async function unfollowStrategyHandler( + req: Request, + res: Response +): Promise { + const userId = req.auth?.userId + if (!userId) { + sendUnauthorized(res) + return + } + + try { + const result = await unfollowStrategy(userId, String(req.params.id)) + res.status(200).json({ + id: result.id, + unfollowedAt: result.unfollowedAt.toISOString(), + }) + } catch (error) { + if (error instanceof StrategyFollowNotFoundError) { + sendNotFound(res, 'Active follow') + return + } + logger.error('[Strategy] Failed to unfollow strategy:', error) + sendError(res, 500, 'Failed to unfollow strategy') + } +} diff --git a/src/index.ts b/src/index.ts index 9e4ef02..a6fcc9a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -50,6 +50,7 @@ import { scheduleFiatReconciliation } from './jobs/fiatReconciliation' import { scheduleReferralPayout } from './jobs/referralPayout' import { scheduleRecurringDeposits } from './jobs/recurringDeposits' import { scheduleAlertRules } from './jobs/alertRules' +import { scheduleStrategyMetrics } from './jobs/strategyMetrics' import { startEventListener, stopEventListener } from './stellar/events' import { validateStellarNetworkReady } from './config/readiness' import healthRouter from './routes/health' @@ -72,6 +73,7 @@ import fiatRouter from './routes/fiat' import referralsRouter from './routes/referrals' import recurringDepositRouter from './routes/recurring-deposits' import alertsRouter from './routes/alerts' +import strategiesRouter from './routes/strategies' import subAccountsRouter from './routes/sub-accounts' import { corsMiddleware, @@ -104,6 +106,7 @@ let fiatReconciliationHandle: NodeJS.Timeout | null = null let referralPayoutHandle: NodeJS.Timeout | null = null let recurringDepositsHandle: NodeJS.Timeout | null = null let alertRulesHandle: NodeJS.Timeout | null = null +let strategyMetricsHandle: NodeJS.Timeout | null = null function allServicesReady(): boolean { return Object.values(serviceStatus).every((s) => s.ready) @@ -283,6 +286,7 @@ const apiRoutes: ApiRoute[] = [ { path: 'referrals', handlers: [referralsRouter] }, { path: 'deposit/recurring', handlers: [recurringDepositRouter] }, { path: 'alerts', handlers: [alertsRouter] }, + { path: 'strategies', handlers: [strategiesRouter] }, { path: 'sub-accounts', handlers: [subAccountsRouter] }, { path: 'admin', handlers: [adminRateLimiter, adminRouter] }, ] @@ -359,6 +363,12 @@ async function gracefulShutdown(signal: string): Promise { logger.info('[Shutdown] Alert rules timer cleared') } + if (strategyMetricsHandle) { + clearInterval(strategyMetricsHandle) + strategyMetricsHandle = null + logger.info('[Shutdown] Strategy metrics timer cleared') + } + if (!httpServer) { logger.warn('[Shutdown] No HTTP server to close') process.exit(0) @@ -516,6 +526,7 @@ async function main(): Promise { referralPayoutHandle = scheduleReferralPayout() recurringDepositsHandle = scheduleRecurringDeposits() alertRulesHandle = scheduleAlertRules() + strategyMetricsHandle = scheduleStrategyMetrics() } // ── Process-level error guards ──────────────────────────────────────────────── diff --git a/src/jobs/strategyMetrics.ts b/src/jobs/strategyMetrics.ts new file mode 100644 index 0000000..fa00fb2 --- /dev/null +++ b/src/jobs/strategyMetrics.ts @@ -0,0 +1,198 @@ +import db from '../db' +import { logger, logBackgroundJob } from '../utils/logger' +import { + generateCorrelationId, + runWithCorrelationIdAsync, +} from '../utils/correlation' +import { config } from '../config/env' +import { recordBackgroundJob } from '../utils/metrics' +import { recordJobSuccess, recordJobFailure } from '../utils/job-metrics' +import { + bucketByInstant, + computeStrategyMetrics, + SnapshotRow, +} from '../agent/strategyMetrics' + +/** + * Strategy marketplace metrics job (#285). + * + * Recomputes each published strategy's leaderboard figures from its publisher's + * YieldSnapshot history and upserts them into PublishedStrategyMetric. All the + * math lives in src/agent/strategyMetrics.ts (pure + unit tested); this job is + * only DB glue and scheduling, mirroring src/jobs/protocolRiskScoring.ts. + * + * Why precompute at all: the leaderboard sorts on a risk-adjusted score with + * skip/take, which has to happen in SQL, and recomputing every publisher's + * 90-day snapshot history per request would turn a semi-public endpoint into a + * DoS vector. + * + * Windows are 30 and 90 days only — snapshotter.cleanupOldSnapshots hard-deletes + * YieldSnapshot rows past 90 days, so anything longer has no data behind it. + * Both windows come out of ONE 90-day query per strategy; the 30-day series is + * a filter over the same rows. + * + * Metrics are computed for unpublished strategies too, so re-publishing is + * ranked immediately instead of waiting up to a full interval for a cold start. + * The marketplace query, not this job, applies the isPublished filter. + */ + +const WINDOWS = [30, 90] as const + +const MS_PER_DAY = 24 * 60 * 60 * 1000 + +export async function computeStrategyMarketplaceMetrics( + now: Date = new Date() +): Promise { + const correlationId = generateCorrelationId() + return runWithCorrelationIdAsync(correlationId, async () => { + const startTime = Date.now() + const jobName = 'strategy_metrics' + + try { + const strategies = await db.publishedStrategy.findMany({ + select: { id: true, userId: true }, + }) + + let computed = 0 + + for (const strategy of strategies) { + const positions = await db.position.findMany({ + where: { userId: strategy.userId }, + select: { id: true }, + }) + + const positionIds = positions.map((p) => p.id) + + // No positions means no portfolio series and therefore no computable + // metrics. Write ineligible rows rather than leaving stale ones behind: + // a publisher who closed everything must drop off the board, not keep + // last week's numbers. + if (positionIds.length === 0) { + for (const windowDays of WINDOWS) { + await upsertMetric(strategy.id, windowDays, { + apy: 0, + sharpe: null, + sampleCount: 0, + trackRecordDays: 0, + isEligible: false, + computedAt: now, + }) + } + computed++ + continue + } + + const earliest = await db.yieldSnapshot.findFirst({ + where: { positionId: { in: positionIds } }, + orderBy: { snapshotAt: 'asc' }, + select: { snapshotAt: true }, + }) + + const maxWindow = Math.max(...WINDOWS) + const cutoff = new Date(now.getTime() - maxWindow * MS_PER_DAY) + + const snapshots = await db.yieldSnapshot.findMany({ + where: { + positionId: { in: positionIds }, + snapshotAt: { gte: cutoff }, + }, + select: { + snapshotAt: true, + principalAmount: true, + yieldAmount: true, + }, + }) + + const rows: SnapshotRow[] = snapshots.map((s) => ({ + snapshotAt: s.snapshotAt, + principalAmount: Number(s.principalAmount), + yieldAmount: Number(s.yieldAmount), + })) + + for (const windowDays of WINDOWS) { + const windowCutoff = new Date(now.getTime() - windowDays * MS_PER_DAY) + const points = bucketByInstant( + rows.filter((r) => r.snapshotAt >= windowCutoff) + ) + + const metrics = computeStrategyMetrics({ + points, + firstObservedAt: earliest?.snapshotAt ?? null, + now, + riskFreeRate: config.strategyMarketplace.riskFreeRate, + }) + + await upsertMetric(strategy.id, windowDays, { + ...metrics, + computedAt: now, + }) + } + + computed++ + } + + const durationMs = Date.now() - startTime + const duration = durationMs / 1000 + + logBackgroundJob(jobName, 'success', duration, correlationId, { + strategiesComputed: computed, + }) + recordBackgroundJob(jobName, 'success', duration) + recordJobSuccess(jobName, durationMs) + } catch (error) { + const durationMs = Date.now() - startTime + const duration = durationMs / 1000 + const errorMessage = + error instanceof Error ? error.message : 'Unknown error' + + logBackgroundJob(jobName, 'failed', duration, correlationId, { + error: errorMessage, + }) + recordBackgroundJob(jobName, 'failed', duration) + recordJobFailure(jobName, durationMs) + } + }) +} + +async function upsertMetric( + publishedStrategyId: string, + windowDays: number, + data: { + apy: number + sharpe: number | null + sampleCount: number + trackRecordDays: number + isEligible: boolean + computedAt: Date + } +): Promise { + await db.publishedStrategyMetric.upsert({ + where: { + publishedStrategyId_windowDays: { publishedStrategyId, windowDays }, + }, + create: { publishedStrategyId, windowDays, ...data }, + update: data, + }) +} + +/** + * Schedule the marketplace metrics job. Runs once on startup then on the + * configured interval (default 6 h, matching protocolRisk). + * + * @returns NodeJS.Timeout handle — pass to clearInterval() on shutdown. + */ +export function scheduleStrategyMetrics(): NodeJS.Timeout { + void computeStrategyMarketplaceMetrics() + + const intervalMs = config.strategyMarketplace.metricsIntervalMs + const handle = setInterval(() => { + void computeStrategyMarketplaceMetrics() + }, intervalMs) + + handle.unref?.() + + logger.info( + `[StrategyMetrics] Marketplace metrics scheduled every ${intervalMs / 3600000}h` + ) + return handle +} diff --git a/src/routes/strategies.ts b/src/routes/strategies.ts new file mode 100644 index 0000000..39f168e --- /dev/null +++ b/src/routes/strategies.ts @@ -0,0 +1,74 @@ +/** + * Strategy marketplace routes (#285). + * + * POST /api/v1/strategies/publish — auth; upsert + publish the caller's own strategy + * POST /api/v1/strategies/unpublish — auth; delist immediately + * GET /api/v1/strategies/marketplace — auth; ranked, anonymized leaderboard + * GET /api/v1/strategies/following — auth; what the caller currently follows + * POST /api/v1/strategies/:id/follow — auth + * POST /api/v1/strategies/:id/unfollow — auth + * + * A top-level resource rather than the issue's proposed /api/agent/strategies/*: + * src/routes/agent.ts sits behind internalAuthGuard, an operator/machine guard + * that authenticates by INTERNAL_SERVICE_TOKEN and sets no req.userId. These are + * user-facing routes and cannot live there. + * + * `:id` is a PublishedStrategy id, deliberately not `:userId` — + * enforceUserAccess compares req.params.userId against the caller and would + * reject every follow request. Ownership is instead enforced inside the service + * (publish/unpublish act on the caller; follow/unfollow act on the caller's own + * follow row). + */ +import { Router } from 'express' +import { requireAuth } from '../middleware/authenticate' +import { validate } from '../middleware/validate' +import { + publishStrategySchema, + marketplaceQuerySchema, + strategyIdParamSchema, +} from '../validators/strategy-validators' +import { + publishStrategyHandler, + unpublishStrategyHandler, + getMarketplaceHandler, + getFollowingHandler, + followStrategyHandler, + unfollowStrategyHandler, +} from '../controllers/strategy-controller' + +const router = Router() + +// Every route in this file is user-facing and owner-scoped. +router.use(requireAuth) + +// Literal segments first, so "publish"/"marketplace"/"following" are never +// captured as a strategy id by the /:id/* routes below. +router.post( + '/publish', + validate({ body: publishStrategySchema }), + publishStrategyHandler +) + +router.post('/unpublish', unpublishStrategyHandler) + +router.get( + '/marketplace', + validate({ query: marketplaceQuerySchema }), + getMarketplaceHandler +) + +router.get('/following', getFollowingHandler) + +router.post( + '/:id/follow', + validate({ params: strategyIdParamSchema }), + followStrategyHandler +) + +router.post( + '/:id/unfollow', + validate({ params: strategyIdParamSchema }), + unfollowStrategyHandler +) + +export default router diff --git a/src/strategy/service.ts b/src/strategy/service.ts new file mode 100644 index 0000000..448beb3 --- /dev/null +++ b/src/strategy/service.ts @@ -0,0 +1,569 @@ +/** + * Strategy marketplace (#285) — business logic for publishing, following, and + * ranking anonymized agent configurations. + * + * ─── THE CUSTODY BOUNDARY ──────────────────────────────────────────────────── + * + * A follow copies CONFIGURATION and nothing else. This module deliberately + * imports nothing from src/stellar/ — no wallet, no contract, no key custody — + * so there is no code path from a follow to another user's funds. That is a + * structural property, not a documented promise, and + * tests/integration/agent/strategy-follow.integration.test.ts asserts the + * import graph stays that way. Do not add a stellar import here. + * + * ─── THE ANONYMIZATION BOUNDARY ────────────────────────────────────────────── + * + * `marketplaceSelect` below is layer 1 of 3 (query → mapper → input; see + * docs/STRATEGY_MARKETPLACE.md). It never selects `userId` and never includes + * `user`. A publisher's id IS loaded in followStrategy — but only to compare + * against the caller for the self-follow check, and it never reaches a + * response. Follow the alertSelect pattern in src/routes/alerts.ts: add fields + * to the allowlist explicitly, never spread a row. + */ + +import { Prisma } from '@prisma/client' +import db from '../db' +import { logger } from '../utils/logger' +import { dispatchWebhookEvent } from '../services/webhookDispatcher' +import { sendWhatsAppMessage } from '../utils/twilio-client' +import { + formatStrategyUpdatedReply, + formatStrategyUnpublishedReply, +} from '../whatsapp/formatters' +import { + StrategyConfigShape, + isMaterialConfigChange, + parseStrategyConfig, +} from '../agent/effectiveStrategy' +import { + MarketplaceSortField, + MarketplaceWindow, +} from '../validators/strategy-validators' + +type Db = typeof db | Prisma.TransactionClient + +export class StrategyNotFoundError extends Error {} +export class StrategyFollowNotFoundError extends Error {} +export class StrategySelfFollowError extends Error {} +export class StrategyValidationError extends Error {} + +/** + * Fields any marketplace/following query may return about a published + * strategy. `userId` is absent by construction, not by omission. + */ +const marketplaceSelect = { + id: true, + label: true, + strategyConfig: true, + configVersion: true, + isPublished: true, + publishedAt: true, +} as const + +/** The publisher's own view — same fields; they already know who they are. */ +const ownerSelect = { + ...marketplaceSelect, + createdAt: true, + updatedAt: true, +} as const + +export const WINDOW_DAYS: Record = { + '30d': 30, + '90d': 90, +} + +export interface PublishStrategyInput { + label: string + strategyConfig?: StrategyConfigShape +} + +export interface MarketplaceQueryInput { + sortBy: MarketplaceSortField + window: MarketplaceWindow + page: number + limit: number +} + +/** + * The publisher's effective config: the body's `strategyConfig` when supplied, + * otherwise a snapshot of what they are actually running (User.rebalanceStrategy + * + User.strategyConfig). + * + * Only the three agent-relevant keys are ever taken. `riskTolerance` is + * explicitly NOT copied — it is a personal preference, not part of a strategy, + * and a follower's own tolerance must survive a follow. + */ +async function resolvePublishableConfig( + userId: string, + supplied: StrategyConfigShape | undefined, + database: Db +): Promise { + if (supplied) return supplied + + const user = await (database as typeof db).user.findUnique({ + where: { id: userId }, + select: { rebalanceStrategy: true, strategyConfig: true }, + }) + + if (!user) throw new StrategyNotFoundError('User not found') + + const stored = parseStrategyConfig(user.strategyConfig) + const config: StrategyConfigShape = { + ...stored, + strategyName: + stored.strategyName ?? + (parseStrategyConfig({ strategyName: user.rebalanceStrategy }) + .strategyName || + undefined), + } + + if (!config.strategyName) { + throw new StrategyValidationError( + 'No strategy to publish — configure a rebalance strategy first or supply strategyConfig in the request body' + ) + } + + return config +} + +/** + * Publish (or re-publish) the caller's strategy. Upsert, not insert: publish + * always acts on the caller, so PublishedStrategy.userId is unique and a second + * call updates the same row. + * + * Version bumping is the reason this is more than an upsert. `configVersion` + * increments ONLY on a material change to the three agent-relevant keys — a + * label edit is cosmetic and must not push a notification to every follower. + * When it does bump, every active follower's `appliedConfig` snapshot is + * rewritten in the SAME transaction as the bump, so a follower can never be + * left pointing at a version that no longer describes what they run. + * Notifications fire outside the transaction, fire-and-forget. + */ +export async function publishStrategy( + userId: string, + input: PublishStrategyInput +): Promise<{ strategy: any; materialChange: boolean }> { + const config = await resolvePublishableConfig( + userId, + input.strategyConfig, + db + ) + const now = new Date() + + const existing = await db.publishedStrategy.findUnique({ + where: { userId }, + select: { id: true, strategyConfig: true, configVersion: true }, + }) + + const materialChange = existing + ? isMaterialConfigChange( + parseStrategyConfig(existing.strategyConfig), + config + ) + : false + + const result = await db.$transaction(async (tx) => { + const nextVersion = existing + ? existing.configVersion + (materialChange ? 1 : 0) + : 1 + + const strategy = await tx.publishedStrategy.upsert({ + where: { userId }, + create: { + userId, + label: input.label, + strategyConfig: config as Prisma.InputJsonValue, + configVersion: 1, + isPublished: true, + publishedAt: now, + }, + update: { + label: input.label, + strategyConfig: config as Prisma.InputJsonValue, + configVersion: nextVersion, + isPublished: true, + // publishedAt marks the first time this strategy went live and is what + // "listed since" shows; re-publishing after an unpublish restores it + // only if it was never set. + ...(existing ? {} : { publishedAt: now }), + }, + select: ownerSelect, + }) + + if (materialChange) { + await tx.strategyFollow.updateMany({ + where: { publishedStrategyId: strategy.id, unfollowedAt: null }, + data: { + appliedConfig: config as Prisma.InputJsonValue, + appliedConfigVersion: nextVersion, + appliedAt: now, + }, + }) + } + + return strategy + }) + + logger.info('[Strategy] Strategy published', { + userId, + strategyId: result.id, + configVersion: result.configVersion, + materialChange, + strategyName: config.strategyName, + }) + + if (materialChange) { + void notifyFollowers(result.id, 'strategy.updated', { + label: result.label, + configVersion: result.configVersion, + }) + } + + return { strategy: result, materialChange } +} + +/** + * Unpublish the caller's strategy. Immediate: `isPublished=false` drops it from + * every marketplace query on the next request. + * + * Follows are NOT severed. A follower keeps their `appliedConfig` snapshot and + * their agent keeps working — silently reverting someone's live configuration + * because a stranger delisted theirs would change what the agent does with the + * follower's money without the follower ever acting. + */ +export async function unpublishStrategy(userId: string): Promise { + const existing = await db.publishedStrategy.findUnique({ + where: { userId }, + select: { id: true, isPublished: true }, + }) + + if (!existing) throw new StrategyNotFoundError('Published strategy not found') + + const updated = await db.publishedStrategy.update({ + where: { userId }, + data: { isPublished: false }, + select: ownerSelect, + }) + + logger.info('[Strategy] Strategy unpublished', { + userId, + strategyId: updated.id, + }) + + if (existing.isPublished) { + void notifyFollowers(updated.id, 'strategy.unpublished', { + label: updated.label, + }) + } + + return updated +} + +/** + * Leaderboard page. + * + * Rooted at PublishedStrategyMetric rather than PublishedStrategy so the sort + * happens in SQL against the (windowDays, isEligible, sharpe|apy) index — a + * JS-computed score cannot be ordered with skip/take, and recomputing every + * publisher's snapshot history per request would be a DoS vector on a + * semi-public endpoint. + * + * `isEligible: true` is what excludes thin track records ENTIRELY rather than + * ranking them low. A strategy with one lucky day never appears at all. + */ +export async function getMarketplace(input: MarketplaceQueryInput): Promise<{ + page: number + limit: number + total: number + window: MarketplaceWindow + sortBy: MarketplaceSortField + entries: any[] +}> { + const windowDays = WINDOW_DAYS[input.window] + const skip = (input.page - 1) * input.limit + + const where = { + windowDays, + isEligible: true, + publishedStrategy: { isPublished: true }, + } as const + + const [total, rows] = await Promise.all([ + db.publishedStrategyMetric.count({ where }), + db.publishedStrategyMetric.findMany({ + where, + orderBy: [{ [input.sortBy]: 'desc' }, { computedAt: 'desc' }], + skip, + take: input.limit, + select: { + apy: true, + sharpe: true, + sampleCount: true, + trackRecordDays: true, + windowDays: true, + computedAt: true, + publishedStrategy: { select: marketplaceSelect }, + }, + }), + ]) + + return { + page: input.page, + limit: input.limit, + total, + window: input.window, + sortBy: input.sortBy, + entries: rows, + } +} + +/** + * The caller's active follow, if any. Returns the follow's own `appliedConfig` + * snapshot alongside the (possibly delisted, possibly orphaned) strategy, since + * the snapshot — not the live strategy — is what their agent applies. + */ +export async function getActiveFollow(followerUserId: string): Promise { + return db.strategyFollow.findFirst({ + where: { followerUserId, unfollowedAt: null }, + select: { + id: true, + publishedStrategyId: true, + appliedConfig: true, + appliedConfigVersion: true, + appliedAt: true, + followedAt: true, + publishedStrategy: { select: marketplaceSelect }, + }, + }) +} + +/** + * Follow a published strategy. + * + * Following while already following someone else swaps atomically: the previous + * follow is closed and the new one created inside one transaction, so the + * partial unique index `strategy_follows_active_follower_key` (at most one row + * per user with `unfollowedAt IS NULL`) is never violated. That index is the + * structural backstop — if this transaction is ever refactored wrong, the + * database rejects the write rather than letting a user's agent see two + * conflicting configs. + */ +export async function followStrategy( + followerUserId: string, + strategyId: string +): Promise { + const strategy = await db.publishedStrategy.findFirst({ + where: { id: strategyId, isPublished: true }, + // userId is read ONLY for the self-follow comparison below and must not + // escape this function. + select: { + id: true, + userId: true, + label: true, + strategyConfig: true, + configVersion: true, + }, + }) + + if (!strategy) { + throw new StrategyNotFoundError('Published strategy not found') + } + + if (strategy.userId === followerUserId) { + throw new StrategySelfFollowError('You cannot follow your own strategy') + } + + const now = new Date() + + const follow = await db.$transaction(async (tx) => { + await tx.strategyFollow.updateMany({ + where: { followerUserId, unfollowedAt: null }, + data: { unfollowedAt: now }, + }) + + return tx.strategyFollow.create({ + data: { + followerUserId, + publishedStrategyId: strategy.id, + appliedConfig: strategy.strategyConfig as Prisma.InputJsonValue, + appliedConfigVersion: strategy.configVersion, + appliedAt: now, + }, + select: { + id: true, + publishedStrategyId: true, + appliedConfig: true, + appliedConfigVersion: true, + appliedAt: true, + followedAt: true, + publishedStrategy: { select: marketplaceSelect }, + }, + }) + }) + + logger.info('[Strategy] Follow created', { + followerUserId, + strategyId: strategy.id, + appliedConfigVersion: strategy.configVersion, + }) + + return follow +} + +/** + * Release the caller's active follow. + * + * Normally matched by strategy id. The orphan fallback exists because + * `publishedStrategyId` goes NULL when a publisher deletes their account + * (onDelete: SetNull) — without it the follower would have no id left to name + * and could never release the follow. Only ever touches the caller's own single + * active row, so the looser match is not a privilege boundary. + */ +export async function unfollowStrategy( + followerUserId: string, + strategyId: string +): Promise<{ id: string; unfollowedAt: Date }> { + const active = await db.strategyFollow.findFirst({ + where: { followerUserId, unfollowedAt: null }, + select: { id: true, publishedStrategyId: true }, + }) + + if ( + !active || + (active.publishedStrategyId !== null && + active.publishedStrategyId !== strategyId) + ) { + throw new StrategyFollowNotFoundError('Active follow not found') + } + + const now = new Date() + await db.strategyFollow.update({ + where: { id: active.id }, + data: { unfollowedAt: now }, + }) + + logger.info('[Strategy] Follow released', { + followerUserId, + strategyId: active.publishedStrategyId, + followId: active.id, + }) + + return { id: active.id, unfollowedAt: now } +} + +export interface ActiveFollowConfig { + followId: string + followedStrategyId: string | null + appliedConfig: StrategyConfigShape + appliedConfigVersion: number +} + +/** + * Bulk-load active follows for the agent loop, keyed by follower userId. + * + * One query for the whole tick rather than one per user. Returns an empty Map + * when no user in the batch follows anything, which is the path every user + * without a follow takes — see the no-follow contract in + * src/agent/effectiveStrategy.ts. + */ +export async function loadActiveFollowsForUsers( + userIds: string[] +): Promise> { + const byUser = new Map() + if (userIds.length === 0) return byUser + + const follows = await db.strategyFollow.findMany({ + where: { followerUserId: { in: userIds }, unfollowedAt: null }, + select: { + id: true, + followerUserId: true, + publishedStrategyId: true, + appliedConfig: true, + appliedConfigVersion: true, + }, + }) + + for (const follow of follows) { + byUser.set(follow.followerUserId, { + followId: follow.id, + followedStrategyId: follow.publishedStrategyId, + appliedConfig: parseStrategyConfig(follow.appliedConfig), + appliedConfigVersion: follow.appliedConfigVersion, + }) + } + + return byUser +} + +/** + * Best-effort notification fan-out to a strategy's active followers. + * + * Runs OUTSIDE the transaction that changed the strategy and never rethrows: + * a Twilio outage or a dead webhook endpoint must not roll back a publish or + * leave a follower's snapshot half-written. Same fire-and-forget convention as + * dispatchWebhookEvent(...).catch(() => {}) in agent/loop.ts. + * + * A follower with no phone on file is warned and skipped, never thrown — + * matching src/jobs/alertRules.ts. + */ +async function notifyFollowers( + strategyId: string, + event: 'strategy.updated' | 'strategy.unpublished', + details: { label: string; configVersion?: number } +): Promise { + try { + const followers = await db.strategyFollow.findMany({ + where: { publishedStrategyId: strategyId, unfollowedAt: null }, + select: { followerUserId: true, follower: { select: { phone: true } } }, + }) + + if (followers.length === 0) return + + await Promise.allSettled( + followers.map(async (follower) => { + const data = { + strategyId, + followerUserId: follower.followerUserId, + label: details.label, + ...(details.configVersion !== undefined + ? { configVersion: details.configVersion } + : {}), + } + + await dispatchWebhookEvent(event, data).catch((error) => { + logger.warn('[Strategy] Webhook dispatch failed', { + event, + strategyId, + error: error instanceof Error ? error.message : 'Unknown error', + }) + }) + + if (!follower.follower?.phone) { + logger.warn( + `[Strategy] Follower ${follower.followerUserId} has no phone on file — skipping WhatsApp for ${event}` + ) + return + } + + const body = + event === 'strategy.updated' + ? formatStrategyUpdatedReply({ + label: details.label, + configVersion: details.configVersion ?? 1, + }) + : formatStrategyUnpublishedReply({ label: details.label }) + + await sendWhatsAppMessage({ + to: `whatsapp:${follower.follower.phone}`, + body, + }) + }) + ) + } catch (error) { + logger.error('[Strategy] Follower notification fan-out failed', { + strategyId, + event, + error: error instanceof Error ? error.message : 'Unknown error', + }) + } +} diff --git a/src/utils/api-formatters.ts b/src/utils/api-formatters.ts index 2528f4d..1ab3417 100644 --- a/src/utils/api-formatters.ts +++ b/src/utils/api-formatters.ts @@ -18,6 +18,68 @@ export const mapPositionToResponse = (position: any) => ({ status: position.status, }) +/** + * Strategy marketplace (#285) — layer 2 of the anonymization boundary. + * + * Hand-written allowlists, deliberately never a spread. The query layer in + * src/strategy/service.ts already refuses to select `userId`; these mappers are + * the second independent barrier, so adding a field to a select does not + * silently publish it. If you add a key here, ask first whether it identifies + * the publisher. + */ + +/** One leaderboard row: config + derived stats, never absolute balances. */ +export const mapMarketplaceEntryToResponse = (metric: any) => ({ + strategyId: metric.publishedStrategy.id, + label: metric.publishedStrategy.label, + strategyConfig: metric.publishedStrategy.strategyConfig, + configVersion: metric.publishedStrategy.configVersion, + publishedAt: metric.publishedStrategy.publishedAt + ? metric.publishedStrategy.publishedAt.toISOString() + : null, + windowDays: metric.windowDays, + apy: metric.apy, + sharpe: metric.sharpe, + sampleCount: metric.sampleCount, + trackRecordDays: metric.trackRecordDays, + computedAt: metric.computedAt.toISOString(), +}) + +/** The publisher's own view of their listing. */ +export const mapPublishedStrategyToResponse = (strategy: any) => ({ + id: strategy.id, + label: strategy.label, + strategyConfig: strategy.strategyConfig, + configVersion: strategy.configVersion, + isPublished: strategy.isPublished, + publishedAt: strategy.publishedAt ? strategy.publishedAt.toISOString() : null, + createdAt: strategy.createdAt ? strategy.createdAt.toISOString() : undefined, + updatedAt: strategy.updatedAt ? strategy.updatedAt.toISOString() : undefined, +}) + +/** + * A follower's own follow. `appliedConfig` is the snapshot their agent runs — + * surfaced separately from the (possibly newer, possibly delisted, possibly + * orphaned) live strategy so the follower can see exactly what is in effect. + */ +export const mapFollowToResponse = (follow: any) => ({ + id: follow.id, + strategyId: follow.publishedStrategyId, + appliedConfig: follow.appliedConfig, + appliedConfigVersion: follow.appliedConfigVersion, + appliedAt: follow.appliedAt.toISOString(), + followedAt: follow.followedAt.toISOString(), + strategy: follow.publishedStrategy + ? { + id: follow.publishedStrategy.id, + label: follow.publishedStrategy.label, + strategyConfig: follow.publishedStrategy.strategyConfig, + configVersion: follow.publishedStrategy.configVersion, + isPublished: follow.publishedStrategy.isPublished, + } + : null, +}) + export const mapGoalToResponse = (goal: any) => ({ id: goal.id, userId: goal.userId, diff --git a/src/validators/strategy-validators.ts b/src/validators/strategy-validators.ts new file mode 100644 index 0000000..3f8dfe3 --- /dev/null +++ b/src/validators/strategy-validators.ts @@ -0,0 +1,142 @@ +import { z } from 'zod' + +/** + * Validators for the strategy marketplace (#285). + * + * This is the input layer of the three-layer anonymization boundary + * (query → mapper → input; see docs/STRATEGY_MARKETPLACE.md). The query and + * mapper layers stop the SERVER from leaking a publisher's identity; this layer + * stops the PUBLISHER from leaking it themselves. `label` is the only free text + * a user can put in front of strangers, so it is the only self-doxx vector in + * the feature. + */ + +/** + * Publishable strategies. GOAL_TRACKING is deliberately excluded: it is driven + * by the publisher's own SavingsGoal target and date, which are personal + * figures that mean nothing to a follower and would have to be copied to work + * at all. Followers with their own goal already get goal precedence anyway. + */ +export const PUBLISHABLE_STRATEGIES = [ + 'MAX_YIELD', + 'TARGET_ALLOCATION', +] as const + +export const MARKETPLACE_WINDOWS = ['30d', '90d'] as const +export const MARKETPLACE_SORT_FIELDS = ['apy', 'sharpe'] as const + +export type MarketplaceWindow = (typeof MARKETPLACE_WINDOWS)[number] +export type MarketplaceSortField = (typeof MARKETPLACE_SORT_FIELDS)[number] + +export const MAX_LABEL_LENGTH = 60 + +/** Stellar account (G…) or contract (C…) address: prefix + 55 base32 chars. */ +const STELLAR_ADDRESS_PATTERN = /[GC][A-Z2-7]{55}/ + +/** A 32+ char hex run — key material, tx hashes, and most secret formats. */ +const LONG_HEX_PATTERN = /[0-9a-fA-F]{32,}/ + +/** + * Free-text label shown to other users. Length-capped and screened for the two + * identifiers that would deanonymize the publisher if pasted in. Rejected + * rather than stripped: silently mangling someone's label hides that we found + * an address in it, and a user who pasted their wallet address needs to know. + */ +export const strategyLabelSchema = z + .string() + .trim() + .min(1, 'label is required') + .max(MAX_LABEL_LENGTH, `label must be at most ${MAX_LABEL_LENGTH} characters`) + .refine((v) => !STELLAR_ADDRESS_PATTERN.test(v), { + message: + 'label must not contain a Stellar address — published strategies are anonymous', + }) + .refine((v) => !LONG_HEX_PATTERN.test(v), { + message: + 'label must not contain long hexadecimal strings — published strategies are anonymous', + }) + +/** + * The exact three keys the agent loop reads. Nothing else is copied to a + * follower — notably `riskTolerance`, which stays personal to each user. + */ +export const publishableConfigSchema = z + .object({ + strategyName: z.enum(PUBLISHABLE_STRATEGIES), + targetAllocations: z + .record(z.string().min(1).max(100), z.number().finite().min(0).max(100)) + .optional(), + riskCeiling: z.number().int().min(0).max(100).optional(), + }) + .superRefine((data, ctx) => { + if (data.strategyName === 'TARGET_ALLOCATION') { + const entries = Object.entries(data.targetAllocations ?? {}) + if (entries.length === 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['targetAllocations'], + message: + 'targetAllocations is required when strategyName is TARGET_ALLOCATION', + }) + return + } + // Followers inherit these weights verbatim, so a set that does not sum to + // 100 would quietly under- or over-allocate someone else's funds. + const total = entries.reduce((sum, [, weight]) => sum + weight, 0) + if (Math.abs(total - 100) > 0.01) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['targetAllocations'], + message: `targetAllocations must sum to 100 (got ${total})`, + }) + } + } + }) + +/** + * POST /strategies/publish + * + * `strategyConfig` is optional: when omitted the caller's own + * User.rebalanceStrategy / User.strategyConfig is snapshotted instead, which is + * the "publish what I'm actually running" path. Supplying it explicitly is the + * only way to publish anything today, since nothing in src/ writes those User + * columns yet. + */ +export const publishStrategySchema = z.object({ + label: strategyLabelSchema, + strategyConfig: publishableConfigSchema.optional(), +}) + +/** + * GET /strategies/marketplace + * + * `window` accepts 30d and 90d ONLY. src/agent/snapshotter.ts hard-deletes + * YieldSnapshot rows past 90 days, so a longer window has no data behind it and + * would return a 90-day figure mislabelled as something else. Rejected with a + * 400 that names the retention limit rather than silently downgraded. + */ +export const marketplaceQuerySchema = z.object({ + sortBy: z.enum(MARKETPLACE_SORT_FIELDS).default('sharpe'), + window: z + .enum(MARKETPLACE_WINDOWS, { + error: + 'window must be "30d" or "90d". Longer windows are unavailable because yield snapshots are retained for 90 days.', + }) + .default('30d'), + // Deliberately NOT the shared paginationSchema defaults (limit 5 / max 50): + // those are tuned for WhatsApp transaction lists, not a leaderboard page. + page: z.coerce.number().int().min(1).default(1), + limit: z.coerce.number().int().min(1).max(50).default(20), +}) + +/** + * Path param for follow/unfollow. This is a PublishedStrategy id, never a + * userId — naming it `userId` would hand it to enforceUserAccess, which + * compares req.params.userId against the caller and would 403 every follow. + */ +export const strategyIdParamSchema = z.object({ + id: z.string().uuid('Invalid strategy ID'), +}) + +export type PublishStrategyInput = z.infer +export type MarketplaceQuery = z.infer diff --git a/src/validators/webhook-validators.ts b/src/validators/webhook-validators.ts index 9ad8418..de9f6b1 100644 --- a/src/validators/webhook-validators.ts +++ b/src/validators/webhook-validators.ts @@ -36,6 +36,11 @@ const WEBHOOK_EVENTS = [ 'recurring_deposit.executed', 'recurring_deposit.failed', 'alert_rule.triggered', + // Strategy marketplace (#285). Dispatched once per active follower, so the + // payload carries `followerUserId` — a subscriber needs to know whose agent + // is affected. Never carries the publisher's identity. + 'strategy.updated', + 'strategy.unpublished', ] as const export const createWebhookSchema = z.object({ diff --git a/src/whatsapp/formatters.ts b/src/whatsapp/formatters.ts index b8882ca..174d1ac 100644 --- a/src/whatsapp/formatters.ts +++ b/src/whatsapp/formatters.ts @@ -326,3 +326,33 @@ export function formatAlertDeletedReply(found: boolean): string { ? '🗑️ *Alert deleted.*' : "I couldn't find an alert with that ID that belongs to you." } + +/** + * Sent to a follower when a strategy they follow changes materially (#285). + * Names the label only — never the publisher, who is anonymous to followers. + */ +export function formatStrategyUpdatedReply(input: { + label: string + configVersion: number +}): string { + return [ + '🔄 *Strategy updated*', + `A strategy you follow — _${input.label}_ — changed its configuration (v${input.configVersion}).`, + 'Your agent will apply it on its next scheduled run. Your own risk ceiling still applies.', + ].join('\n') +} + +/** + * Sent to a follower when a strategy they follow is delisted (#285). The + * reassurance is the point: nothing about their agent changes, so the message + * must not read as an action item. + */ +export function formatStrategyUnpublishedReply(input: { + label: string +}): string { + return [ + '📴 *Strategy delisted*', + `_${input.label}_ is no longer listed on the marketplace.`, + 'Your agent keeps running the configuration you already copied — nothing changes unless you unfollow.', + ].join('\n') +} diff --git a/tests/integration/agent/strategy-follow.integration.test.ts b/tests/integration/agent/strategy-follow.integration.test.ts new file mode 100644 index 0000000..88354f1 --- /dev/null +++ b/tests/integration/agent/strategy-follow.integration.test.ts @@ -0,0 +1,404 @@ +/** + * #285 — Strategy marketplace ↔ agent loop integration. + * + * Drives the REAL rebalanceCheckJob against a mocked database, with the real + * strategy service and the real config-merge module in the path. It pins the + * three things that would be catastrophic to get wrong: + * + * 1. CUSTODY (acceptance criterion) — a follow moves configuration and + * NOTHING else. No wallet is opened, no key is read, no contract call + * carries the publisher's identity, and the follow path never touches + * db.custodialWallet. Also asserted structurally against the import graph. + * + * 2. NO-FOLLOW REGRESSION — a user without a follow produces an identical + * executeRebalanceIfNeeded call to the pre-#285 code. This is the bar the + * repo sets for every new agent capability. + * + * 3. NO CROSS-CONTAMINATION (hazard 1) — router.ts reads only + * userStrategyPreferences[0], so two followers of DIFFERENT published + * strategies must never land in the same batch. + */ +import fs from 'node:fs' +import path from 'node:path' + +const followerA = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' +const followerB = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' +const plainUser = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd' +const publisher1 = '11111111-1111-4111-8111-111111111111' +const publisher2 = '22222222-2222-4222-8222-222222222222' +const strategy1 = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc' +const strategy2 = 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee' + +// --- Stellar: fully mocked. Nothing here may be called by a follow. ---------- +const mockSubmitRebalance = jest.fn().mockResolvedValue({ hash: 'tx-hash' }) +jest.mock('../../../src/stellar/contract', () => ({ + triggerRebalance: (...args: unknown[]) => mockSubmitRebalance(...args), +})) + +const mockGetKeypairForUser = jest.fn() +const mockGetWalletByUserId = jest.fn() +jest.mock('../../../src/stellar/wallet', () => ({ + getKeypairForUser: (...args: unknown[]) => mockGetKeypairForUser(...args), + getWalletByUserId: (...args: unknown[]) => mockGetWalletByUserId(...args), +})) + +// --- Router: capture the preferences the loop hands it ----------------------- +const mockExecuteRebalanceIfNeeded = jest.fn().mockResolvedValue(null) +const mockLogAgentAction = jest.fn().mockResolvedValue(undefined) +jest.mock('../../../src/agent/router', () => ({ + executeRebalanceIfNeeded: (...args: unknown[]) => + mockExecuteRebalanceIfNeeded(...args), + logAgentAction: (...args: unknown[]) => mockLogAgentAction(...args), + getThresholds: () => ({ minimumImprovement: 0.5, maxGasPercent: 0.1 }), +})) + +jest.mock('../../../src/agent/snapshotter', () => ({ + captureAllUserBalances: jest.fn().mockResolvedValue(undefined), + cleanupOldSnapshots: jest.fn().mockResolvedValue(undefined), +})) +jest.mock('../../../src/agent/scanner', () => ({ + scanAllProtocols: jest.fn().mockResolvedValue([]), +})) +jest.mock('../../../src/services/webhookDispatcher', () => ({ + dispatchWebhookEvent: jest.fn().mockResolvedValue(undefined), +})) +jest.mock('../../../src/utils/twilio-client', () => ({ + sendWhatsAppMessage: jest.fn().mockResolvedValue('SM1'), +})) +jest.mock('../../../src/utils/logger', () => ({ + logger: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + }, + logBackgroundJob: jest.fn(), +})) + +jest.mock('../../../src/db', () => ({ __esModule: true, default: {} })) + +import db from '../../../src/db' +import { rebalanceCheckJob } from '../../../src/agent/loop' + +const mockDb = db as any + +/** A position row shaped as loop.ts's `include: { user: true }` produces it. */ +function position( + id: string, + userId: string, + protocolName: string, + user: Record = {} +) { + return { + id, + userId, + protocolName, + currentValue: { toString: () => '1000000000000000000' }, + user: { + id: userId, + riskTolerance: 5, + rebalanceStrategy: null, + strategyConfig: null, + ...user, + }, + } +} + +function follow( + id: string, + followerUserId: string, + publishedStrategyId: string | null, + appliedConfig: Record +) { + return { + id, + followerUserId, + publishedStrategyId, + appliedConfig, + appliedConfigVersion: 1, + } +} + +beforeEach(() => { + jest.clearAllMocks() + mockExecuteRebalanceIfNeeded.mockResolvedValue(null) + mockSubmitRebalance.mockResolvedValue({ hash: 'tx-hash' }) + mockDb.position = { findMany: jest.fn().mockResolvedValue([]) } + mockDb.strategyFollow = { findMany: jest.fn().mockResolvedValue([]) } + mockDb.custodialWallet = { + findUnique: jest.fn(), + findFirst: jest.fn(), + findMany: jest.fn(), + } +}) + +// ─── 2. No-follow regression ───────────────────────────────────────────────── + +describe('no-follow regression — behavior must be unchanged', () => { + it('passes undefined preferences for a user with no strategy and no follow', async () => { + mockDb.position.findMany.mockResolvedValue([ + position('pos-1', plainUser, 'Blend'), + ]) + + await rebalanceCheckJob() + + expect(mockExecuteRebalanceIfNeeded).toHaveBeenCalledTimes(1) + const [protocol, positions, thresholds, preferences] = + mockExecuteRebalanceIfNeeded.mock.calls[0] + expect(protocol).toBe('Blend') + expect(positions).toEqual([ + { id: 'pos-1', amount: '1000000000000000000', userId: plainUser }, + ]) + expect(thresholds).toEqual({ minimumImprovement: 0.5, maxGasPercent: 0.1 }) + // The pre-#285 code passed undefined here, which makes + // executeRebalanceIfNeeded skip the strategy engine entirely. + expect(preferences).toBeUndefined() + }) + + it('passes the user‘s own config verbatim when they have one but follow nothing', async () => { + mockDb.position.findMany.mockResolvedValue([ + position('pos-1', plainUser, 'Blend', { + rebalanceStrategy: 'TARGET_ALLOCATION', + strategyConfig: { + targetAllocations: { Blend: 60, Luma: 40 }, + riskCeiling: 55, + }, + }), + ]) + + await rebalanceCheckJob() + + const preferences = mockExecuteRebalanceIfNeeded.mock.calls[0][3] + expect(preferences).toEqual([ + { + userId: plainUser, + strategyName: 'TARGET_ALLOCATION', + targetAllocations: { Blend: 60, Luma: 40 }, + riskTolerance: 5, + riskCeiling: 55, + followedStrategyId: undefined, + }, + ]) + }) + + it('still groups two no-follow users with the same strategy into ONE batch', async () => { + mockDb.position.findMany.mockResolvedValue([ + position('pos-1', plainUser, 'Blend', { + rebalanceStrategy: 'MAX_YIELD', + }), + position('pos-2', followerA, 'Blend', { + rebalanceStrategy: 'MAX_YIELD', + }), + ]) + + await rebalanceCheckJob() + + expect(mockExecuteRebalanceIfNeeded).toHaveBeenCalledTimes(1) + expect(mockExecuteRebalanceIfNeeded.mock.calls[0][3]).toHaveLength(2) + }) + + it('issues exactly one follow lookup per tick, not one per user', async () => { + mockDb.position.findMany.mockResolvedValue([ + position('pos-1', plainUser, 'Blend'), + position('pos-2', followerA, 'Luma'), + position('pos-3', followerB, 'Blend'), + ]) + + await rebalanceCheckJob() + + expect(mockDb.strategyFollow.findMany).toHaveBeenCalledTimes(1) + }) +}) + +// ─── Followed config actually applied ──────────────────────────────────────── + +describe('a follower‘s config is applied on the next scheduled run', () => { + it('applies the followed strategy to a user whose OWN strategy is null', async () => { + // The common case for a new user, and exactly who this feature targets. + // Before the guard fix, preferences would have been undefined here and the + // followed config silently ignored. + mockDb.position.findMany.mockResolvedValue([ + position('pos-1', followerA, 'Blend'), + ]) + mockDb.strategyFollow.findMany.mockResolvedValue([ + follow('follow-1', followerA, strategy1, { + strategyName: 'TARGET_ALLOCATION', + targetAllocations: { Blend: 70, Luma: 30 }, + riskCeiling: 65, + }), + ]) + + await rebalanceCheckJob() + + const preferences = mockExecuteRebalanceIfNeeded.mock.calls[0][3] + expect(preferences).toEqual([ + { + userId: followerA, + strategyName: 'TARGET_ALLOCATION', + targetAllocations: { Blend: 70, Luma: 30 }, + riskTolerance: 5, + riskCeiling: 65, + followedStrategyId: strategy1, + }, + ]) + }) + + it('clamps the risk ceiling to the STRICTER of publisher and follower', async () => { + mockDb.position.findMany.mockResolvedValue([ + position('pos-1', followerA, 'Blend', { + rebalanceStrategy: 'MAX_YIELD', + strategyConfig: { riskCeiling: 85 }, + }), + ]) + mockDb.strategyFollow.findMany.mockResolvedValue([ + follow('follow-1', followerA, strategy1, { + strategyName: 'MAX_YIELD', + riskCeiling: 50, + }), + ]) + + await rebalanceCheckJob() + + // A follow may only ever tighten the follower's risk exposure. + expect(mockExecuteRebalanceIfNeeded.mock.calls[0][3][0].riskCeiling).toBe( + 85 + ) + }) + + it('keeps applying an orphaned follow after the publisher deleted their account', async () => { + mockDb.position.findMany.mockResolvedValue([ + position('pos-1', followerA, 'Blend'), + ]) + mockDb.strategyFollow.findMany.mockResolvedValue([ + follow('follow-1', followerA, null, { strategyName: 'MAX_YIELD' }), + ]) + + await rebalanceCheckJob() + + const preferences = mockExecuteRebalanceIfNeeded.mock.calls[0][3] + expect(preferences[0].strategyName).toBe('MAX_YIELD') + expect(preferences[0].followedStrategyId).toBeUndefined() + }) +}) + +// ─── 3. No cross-contamination (hazard 1) ──────────────────────────────────── + +describe('batching — followers of different strategies never cross-contaminate', () => { + it('splits two followers of different strategies on the same protocol', async () => { + mockDb.position.findMany.mockResolvedValue([ + position('pos-a', followerA, 'Blend'), + position('pos-b', followerB, 'Blend'), + ]) + mockDb.strategyFollow.findMany.mockResolvedValue([ + follow('follow-a', followerA, strategy1, { + strategyName: 'MAX_YIELD', + riskCeiling: 40, + }), + follow('follow-b', followerB, strategy2, { + strategyName: 'MAX_YIELD', + riskCeiling: 90, + }), + ]) + + await rebalanceCheckJob() + + // router.ts reads only userStrategyPreferences[0]; one shared batch would + // have applied A's ceiling of 40 to B, or B's 90 to A. + expect(mockExecuteRebalanceIfNeeded).toHaveBeenCalledTimes(2) + + const byUser = new Map() + for (const call of mockExecuteRebalanceIfNeeded.mock.calls) { + const prefs = call[3] + expect(prefs).toHaveLength(1) + byUser.set(prefs[0].userId, prefs[0]) + } + expect(byUser.get(followerA).riskCeiling).toBe(40) + expect(byUser.get(followerA).followedStrategyId).toBe(strategy1) + expect(byUser.get(followerB).riskCeiling).toBe(90) + expect(byUser.get(followerB).followedStrategyId).toBe(strategy2) + }) + + it('splits a follower from a no-follow user sharing protocol and strategy name', async () => { + mockDb.position.findMany.mockResolvedValue([ + position('pos-a', followerA, 'Blend'), + position('pos-d', plainUser, 'Blend', { rebalanceStrategy: 'MAX_YIELD' }), + ]) + mockDb.strategyFollow.findMany.mockResolvedValue([ + follow('follow-a', followerA, strategy1, { + strategyName: 'MAX_YIELD', + riskCeiling: 40, + }), + ]) + + await rebalanceCheckJob() + + expect(mockExecuteRebalanceIfNeeded).toHaveBeenCalledTimes(2) + }) +}) + +// ─── 1. Custody ────────────────────────────────────────────────────────────── + +describe('custody — a follow moves configuration and nothing else', () => { + it('never opens a wallet, reads a key, or touches custodialWallet', async () => { + mockDb.position.findMany.mockResolvedValue([ + position('pos-1', followerA, 'Blend'), + ]) + mockDb.strategyFollow.findMany.mockResolvedValue([ + follow('follow-1', followerA, strategy1, { strategyName: 'MAX_YIELD' }), + ]) + + await rebalanceCheckJob() + + expect(mockGetKeypairForUser).not.toHaveBeenCalled() + expect(mockGetWalletByUserId).not.toHaveBeenCalled() + expect(mockDb.custodialWallet.findUnique).not.toHaveBeenCalled() + expect(mockDb.custodialWallet.findFirst).not.toHaveBeenCalled() + expect(mockDb.custodialWallet.findMany).not.toHaveBeenCalled() + }) + + it('never passes the publisher‘s user id to any downstream call', async () => { + mockDb.position.findMany.mockResolvedValue([ + position('pos-1', followerA, 'Blend'), + ]) + mockDb.strategyFollow.findMany.mockResolvedValue([ + follow('follow-1', followerA, strategy1, { strategyName: 'MAX_YIELD' }), + ]) + + await rebalanceCheckJob() + + const everything = JSON.stringify([ + mockExecuteRebalanceIfNeeded.mock.calls, + mockSubmitRebalance.mock.calls, + mockDb.position.findMany.mock.calls, + ]) + expect(everything).not.toContain(publisher1) + expect(everything).not.toContain(publisher2) + }) + + it('structurally: src/strategy/service.ts imports nothing from src/stellar/', () => { + // The custody boundary is a property of the import graph, not a comment. + const source = fs.readFileSync( + path.join(__dirname, '../../../src/strategy/service.ts'), + 'utf8' + ) + const imports = Array.from( + source.matchAll(/from\s+['"]([^'"]+)['"]/g), + (m) => m[1] + ) + expect(imports.length).toBeGreaterThan(0) + expect(imports.filter((i) => i.includes('stellar'))).toEqual([]) + expect(imports.filter((i) => i.includes('wallet'))).toEqual([]) + }) + + it('structurally: src/agent/effectiveStrategy.ts is pure (no db, no stellar)', () => { + const source = fs.readFileSync( + path.join(__dirname, '../../../src/agent/effectiveStrategy.ts'), + 'utf8' + ) + const imports = Array.from( + source.matchAll(/from\s+['"]([^'"]+)['"]/g), + (m) => m[1] + ) + expect(imports.filter((i) => /stellar|\/db|wallet/.test(i))).toEqual([]) + }) +}) diff --git a/tests/integration/strategies.integration.test.ts b/tests/integration/strategies.integration.test.ts new file mode 100644 index 0000000..48316e1 --- /dev/null +++ b/tests/integration/strategies.integration.test.ts @@ -0,0 +1,508 @@ +/** + * #285 — Strategy marketplace routes integration test. + * + * Mounts the real router on a minimal Express app with only auth and the DB + * mocked, so the request travels through the REAL validators, the REAL service + * (query layer) and the REAL mappers. That matters: the acceptance criterion is + * that no marketplace response can identify a publisher, and mocking the + * service would test nothing but the mock. + * + * The two headline assertions are: + * * PII — no response carries userId, walletAddress, phone, email, a balance, + * or anything shaped like a Stellar address + * * eligibility/publication — ineligible and unpublished strategies are + * absent from the leaderboard entirely + */ +const mockUserId = '11111111-1111-4111-8111-111111111111' +const publisherUserId = '99999999-9999-4999-8999-999999999999' +const strategyId = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc' + +import request from 'supertest' +import express from 'express' + +// --- Auth: stub requireAuth to inject a fixed identity ----------------------- +jest.mock('../../src/middleware/authenticate', () => ({ + requireAuth: (req: any, _res: any, next: any) => { + req.userId = mockUserId + req.auth = { userId: mockUserId, walletAddress: 'GWALLET_USER_1' } + next() + }, + enforceUserAccess: (_req: any, _res: any, next: any) => next(), +})) + +jest.mock('../../src/utils/logger', () => ({ + logger: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + }, +})) + +jest.mock('../../src/services/webhookDispatcher', () => ({ + dispatchWebhookEvent: jest.fn().mockResolvedValue(undefined), +})) +jest.mock('../../src/utils/twilio-client', () => ({ + sendWhatsAppMessage: jest.fn().mockResolvedValue('SM1'), +})) + +jest.mock('../../src/db', () => ({ __esModule: true, default: {} })) + +import db from '../../src/db' +import strategiesRouter from '../../src/routes/strategies' + +const mockDb = db as any + +function buildApp() { + const app = express() + app.use(express.json()) + app.use('/api/v1/strategies', strategiesRouter) + return app +} + +const app = buildApp() + +/** A DB row shaped exactly as the service's select produces it. */ +function metricRow(overrides: Record = {}) { + return { + apy: 12.5, + sharpe: 1.8, + sampleCount: 640, + trackRecordDays: 45, + windowDays: 30, + computedAt: new Date('2026-07-25T00:00:00.000Z'), + publishedStrategy: { + id: strategyId, + label: 'Steady conservative yield', + strategyConfig: { strategyName: 'MAX_YIELD', riskCeiling: 70 }, + configVersion: 3, + isPublished: true, + publishedAt: new Date('2026-06-01T00:00:00.000Z'), + }, + ...overrides, + } +} + +beforeEach(() => { + mockDb.$transaction = jest.fn(async (fn: any) => fn(mockDb)) + mockDb.user = { findUnique: jest.fn() } + mockDb.publishedStrategy = { + findUnique: jest.fn().mockResolvedValue(null), + findFirst: jest.fn().mockResolvedValue(null), + upsert: jest.fn(), + update: jest.fn(), + } + mockDb.strategyFollow = { + findFirst: jest.fn().mockResolvedValue(null), + findMany: jest.fn().mockResolvedValue([]), + updateMany: jest.fn().mockResolvedValue({ count: 0 }), + create: jest.fn(), + update: jest.fn().mockResolvedValue({}), + } + mockDb.publishedStrategyMetric = { + count: jest.fn().mockResolvedValue(0), + findMany: jest.fn().mockResolvedValue([]), + } +}) + +describe('GET /api/v1/strategies/marketplace — anonymity', () => { + it('returns no publisher-identifying field on any entry', async () => { + mockDb.publishedStrategyMetric.count.mockResolvedValue(1) + mockDb.publishedStrategyMetric.findMany.mockResolvedValue([metricRow()]) + + const res = await request(app).get('/api/v1/strategies/marketplace') + + expect(res.status).toBe(200) + expect(res.body.strategies).toHaveLength(1) + + const entry = res.body.strategies[0] + for (const forbidden of [ + 'userId', + 'user', + 'walletAddress', + 'phone', + 'email', + 'displayName', + 'avatarUrl', + 'currentValue', + 'depositedAmount', + 'yieldEarned', + 'principalAmount', + 'balance', + ]) { + expect(entry).not.toHaveProperty(forbidden) + } + + // Only derived, relative statistics — never an absolute currency amount. + expect(entry).toMatchObject({ + strategyId, + label: 'Steady conservative yield', + apy: 12.5, + sharpe: 1.8, + trackRecordDays: 45, + sampleCount: 640, + windowDays: 30, + }) + }) + + it('sweeps the whole response body for Stellar addresses and the publisher id', async () => { + mockDb.publishedStrategyMetric.count.mockResolvedValue(1) + mockDb.publishedStrategyMetric.findMany.mockResolvedValue([metricRow()]) + + const res = await request(app).get('/api/v1/strategies/marketplace') + const raw = JSON.stringify(res.body) + + expect(raw).not.toMatch(/G[A-Z2-7]{55}/) + expect(raw).not.toMatch(/C[A-Z2-7]{55}/) + expect(raw).not.toContain(publisherUserId) + }) + + it('scopes the query to eligible metrics of published strategies only', async () => { + // Ineligible and unpublished rows never reach the mapper — they are + // excluded by the WHERE clause, not filtered out afterwards. + await request(app).get('/api/v1/strategies/marketplace') + + expect(mockDb.publishedStrategyMetric.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + windowDays: 30, + isEligible: true, + publishedStrategy: { isPublished: true }, + }, + }) + ) + }) + + it('returns an empty page rather than an error when nothing is eligible', async () => { + const res = await request(app).get('/api/v1/strategies/marketplace') + expect(res.status).toBe(200) + expect(res.body).toMatchObject({ total: 0, strategies: [] }) + }) +}) + +describe('GET /api/v1/strategies/marketplace — query validation', () => { + it('defaults to sharpe over a 30d window with a leaderboard-sized page', async () => { + const res = await request(app).get('/api/v1/strategies/marketplace') + expect(res.body).toMatchObject({ + page: 1, + limit: 20, + window: '30d', + sortBy: 'sharpe', + }) + }) + + it('accepts sortBy=apy and window=90d', async () => { + const res = await request(app).get( + '/api/v1/strategies/marketplace?sortBy=apy&window=90d&page=2&limit=5' + ) + expect(res.status).toBe(200) + expect(res.body).toMatchObject({ + page: 2, + limit: 5, + window: '90d', + sortBy: 'apy', + }) + expect(mockDb.publishedStrategyMetric.findMany).toHaveBeenCalledWith( + expect.objectContaining({ skip: 5, take: 5 }) + ) + }) + + it('rejects window=1y with a 400 naming the retention limit', async () => { + // Snapshots are hard-deleted at 90 days, so a 1y window would silently + // report a 90-day figure under a wrong label. + const res = await request(app).get( + '/api/v1/strategies/marketplace?window=1y' + ) + expect(res.status).toBe(400) + expect(JSON.stringify(res.body)).toContain('90 days') + }) + + it('rejects an unknown sort field', async () => { + const res = await request(app).get( + '/api/v1/strategies/marketplace?sortBy=totalReturn' + ) + expect(res.status).toBe(400) + }) +}) + +describe('POST /api/v1/strategies/publish', () => { + function publishedRow(overrides: Record = {}) { + return { + id: strategyId, + label: 'Steady conservative yield', + strategyConfig: { strategyName: 'MAX_YIELD' }, + configVersion: 1, + isPublished: true, + publishedAt: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + } + } + + it('publishes and echoes back no userId', async () => { + mockDb.publishedStrategy.upsert.mockResolvedValue(publishedRow()) + + const res = await request(app) + .post('/api/v1/strategies/publish') + .send({ + label: 'Steady conservative yield', + strategyConfig: { strategyName: 'MAX_YIELD' }, + }) + + expect(res.status).toBe(200) + expect(res.body.strategy).not.toHaveProperty('userId') + expect(res.body.strategy.id).toBe(strategyId) + }) + + it('rejects a label containing a Stellar address', async () => { + const res = await request(app) + .post('/api/v1/strategies/publish') + .send({ + label: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + strategyConfig: { strategyName: 'MAX_YIELD' }, + }) + + expect(res.status).toBe(400) + expect(JSON.stringify(res.body)).toMatch(/Stellar address/) + }) + + it('rejects a label containing a long hex run', async () => { + const res = await request(app) + .post('/api/v1/strategies/publish') + .send({ + label: `key ${'a1b2c3d4'.repeat(4)}`, + strategyConfig: { strategyName: 'MAX_YIELD' }, + }) + + expect(res.status).toBe(400) + expect(JSON.stringify(res.body)).toMatch(/hexadecimal/) + }) + + it('rejects an over-long label and an empty one', async () => { + const tooLong = await request(app) + .post('/api/v1/strategies/publish') + .send({ + label: 'x'.repeat(61), + strategyConfig: { strategyName: 'MAX_YIELD' }, + }) + expect(tooLong.status).toBe(400) + + const empty = await request(app) + .post('/api/v1/strategies/publish') + .send({ label: ' ', strategyConfig: { strategyName: 'MAX_YIELD' } }) + expect(empty.status).toBe(400) + }) + + it('rejects GOAL_TRACKING as unpublishable', async () => { + const res = await request(app) + .post('/api/v1/strategies/publish') + .send({ + label: 'Goal chaser', + strategyConfig: { strategyName: 'GOAL_TRACKING' }, + }) + expect(res.status).toBe(400) + }) + + it('rejects TARGET_ALLOCATION without allocations, and allocations that do not sum to 100', async () => { + const missing = await request(app) + .post('/api/v1/strategies/publish') + .send({ + label: 'Balanced', + strategyConfig: { strategyName: 'TARGET_ALLOCATION' }, + }) + expect(missing.status).toBe(400) + + const lopsided = await request(app) + .post('/api/v1/strategies/publish') + .send({ + label: 'Balanced', + strategyConfig: { + strategyName: 'TARGET_ALLOCATION', + targetAllocations: { Blend: 60, Luma: 60 }, + }, + }) + expect(lopsided.status).toBe(400) + expect(JSON.stringify(lopsided.body)).toContain('sum to 100') + }) + + it('accepts a valid TARGET_ALLOCATION config', async () => { + mockDb.publishedStrategy.upsert.mockResolvedValue( + publishedRow({ + strategyConfig: { + strategyName: 'TARGET_ALLOCATION', + targetAllocations: { Blend: 60, Luma: 40 }, + }, + }) + ) + + const res = await request(app) + .post('/api/v1/strategies/publish') + .send({ + label: 'Balanced', + strategyConfig: { + strategyName: 'TARGET_ALLOCATION', + targetAllocations: { Blend: 60, Luma: 40 }, + }, + }) + + expect(res.status).toBe(200) + }) +}) + +describe('POST /api/v1/strategies/unpublish', () => { + it('404s when the caller never published', async () => { + const res = await request(app).post('/api/v1/strategies/unpublish') + expect(res.status).toBe(404) + }) + + it('delists and reports isPublished false', async () => { + mockDb.publishedStrategy.findUnique.mockResolvedValue({ + id: strategyId, + isPublished: true, + }) + mockDb.publishedStrategy.update.mockResolvedValue({ + id: strategyId, + label: 'Steady conservative yield', + strategyConfig: { strategyName: 'MAX_YIELD' }, + configVersion: 2, + isPublished: false, + publishedAt: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + }) + + const res = await request(app).post('/api/v1/strategies/unpublish') + expect(res.status).toBe(200) + expect(res.body.strategy.isPublished).toBe(false) + expect(res.body.strategy).not.toHaveProperty('userId') + }) +}) + +describe('follow / unfollow', () => { + it('409s on a self-follow', async () => { + mockDb.publishedStrategy.findFirst.mockResolvedValue({ + id: strategyId, + userId: mockUserId, + label: 'Mine', + strategyConfig: { strategyName: 'MAX_YIELD' }, + configVersion: 1, + }) + + const res = await request(app).post( + `/api/v1/strategies/${strategyId}/follow` + ) + expect(res.status).toBe(409) + }) + + it('404s when following an unpublished or unknown strategy', async () => { + const res = await request(app).post( + `/api/v1/strategies/${strategyId}/follow` + ) + expect(res.status).toBe(404) + }) + + it('creates a follow and returns the applied snapshot without a publisher id', async () => { + mockDb.publishedStrategy.findFirst.mockResolvedValue({ + id: strategyId, + userId: publisherUserId, + label: 'Steady conservative yield', + strategyConfig: { strategyName: 'MAX_YIELD', riskCeiling: 70 }, + configVersion: 4, + }) + mockDb.strategyFollow.create.mockResolvedValue({ + id: 'follow-1', + publishedStrategyId: strategyId, + appliedConfig: { strategyName: 'MAX_YIELD', riskCeiling: 70 }, + appliedConfigVersion: 4, + appliedAt: new Date(), + followedAt: new Date(), + publishedStrategy: { + id: strategyId, + label: 'Steady conservative yield', + strategyConfig: { strategyName: 'MAX_YIELD', riskCeiling: 70 }, + configVersion: 4, + isPublished: true, + publishedAt: new Date(), + }, + }) + + const res = await request(app).post( + `/api/v1/strategies/${strategyId}/follow` + ) + + expect(res.status).toBe(201) + expect(res.body.follow.appliedConfigVersion).toBe(4) + expect(JSON.stringify(res.body)).not.toContain(publisherUserId) + expect(JSON.stringify(res.body)).not.toMatch(/G[A-Z2-7]{55}/) + }) + + it('rejects a non-uuid strategy id', async () => { + const res = await request(app).post('/api/v1/strategies/not-a-uuid/follow') + expect(res.status).toBe(400) + }) + + it('404s when unfollowing with no active follow', async () => { + const res = await request(app).post( + `/api/v1/strategies/${strategyId}/unfollow` + ) + expect(res.status).toBe(404) + }) +}) + +describe('GET /api/v1/strategies/following', () => { + it('returns { follow: null } rather than 404 when following nothing', async () => { + const res = await request(app).get('/api/v1/strategies/following') + expect(res.status).toBe(200) + expect(res.body).toEqual({ follow: null }) + }) + + it('surfaces the applied snapshot alongside a delisted strategy', async () => { + mockDb.strategyFollow.findFirst.mockResolvedValue({ + id: 'follow-1', + publishedStrategyId: strategyId, + appliedConfig: { strategyName: 'MAX_YIELD', riskCeiling: 70 }, + appliedConfigVersion: 4, + appliedAt: new Date(), + followedAt: new Date(), + publishedStrategy: { + id: strategyId, + label: 'Steady conservative yield', + strategyConfig: { strategyName: 'MAX_YIELD', riskCeiling: 85 }, + configVersion: 5, + isPublished: false, + publishedAt: new Date(), + }, + }) + + const res = await request(app).get('/api/v1/strategies/following') + + expect(res.status).toBe(200) + // The follower keeps running v4 even though the listing moved on and was + // delisted — that is the whole point of the snapshot. + expect(res.body.follow.appliedConfigVersion).toBe(4) + expect(res.body.follow.appliedConfig).toEqual({ + strategyName: 'MAX_YIELD', + riskCeiling: 70, + }) + expect(res.body.follow.strategy.isPublished).toBe(false) + expect(JSON.stringify(res.body)).not.toContain(publisherUserId) + }) + + it('reports an orphaned follow with a null strategy', async () => { + mockDb.strategyFollow.findFirst.mockResolvedValue({ + id: 'follow-1', + publishedStrategyId: null, + appliedConfig: { strategyName: 'MAX_YIELD' }, + appliedConfigVersion: 4, + appliedAt: new Date(), + followedAt: new Date(), + publishedStrategy: null, + }) + + const res = await request(app).get('/api/v1/strategies/following') + expect(res.status).toBe(200) + expect(res.body.follow.strategy).toBeNull() + expect(res.body.follow.appliedConfig).toEqual({ strategyName: 'MAX_YIELD' }) + }) +}) diff --git a/tests/unit/agent/effectiveStrategy.test.ts b/tests/unit/agent/effectiveStrategy.test.ts new file mode 100644 index 0000000..c2abf33 --- /dev/null +++ b/tests/unit/agent/effectiveStrategy.test.ts @@ -0,0 +1,242 @@ +/** + * #285 — own-vs-followed config resolution. + * + * This module decides what the agent does with someone's money when they follow + * a stranger's configuration, so the risk-ceiling invariant ("a follow may only + * tighten, never widen") and the no-follow identity are the load-bearing cases. + */ +import { + isKnownStrategyName, + isMaterialConfigChange, + normalizeStrategyConfig, + parseStrategyConfig, + resolveEffectiveConfig, + stricterRiskCeiling, +} from '../../../src/agent/effectiveStrategy' + +describe('isKnownStrategyName', () => { + it('accepts the three strategies the router can dispatch', () => { + expect(isKnownStrategyName('MAX_YIELD')).toBe(true) + expect(isKnownStrategyName('TARGET_ALLOCATION')).toBe(true) + expect(isKnownStrategyName('GOAL_TRACKING')).toBe(true) + }) + + it('rejects anything else', () => { + expect(isKnownStrategyName('SUPER_YIELD')).toBe(false) + expect(isKnownStrategyName(null)).toBe(false) + expect(isKnownStrategyName(42)).toBe(false) + }) +}) + +describe('parseStrategyConfig', () => { + it('keeps only the three agent-relevant keys', () => { + expect( + parseStrategyConfig({ + strategyName: 'TARGET_ALLOCATION', + targetAllocations: { Blend: 60, Luma: 40 }, + riskCeiling: 70, + riskTolerance: 9, + walletAddress: 'GABC', + }) + ).toEqual({ + strategyName: 'TARGET_ALLOCATION', + targetAllocations: { Blend: 60, Luma: 40 }, + riskCeiling: 70, + }) + }) + + it('drops an unknown strategy name rather than passing it to a lookup', () => { + expect(parseStrategyConfig({ strategyName: 'MOON_MODE' })).toEqual({}) + }) + + it('drops non-numeric allocation weights and an empty allocation map', () => { + expect( + parseStrategyConfig({ + strategyName: 'MAX_YIELD', + targetAllocations: { Blend: 'lots' as unknown as number }, + }) + ).toEqual({ strategyName: 'MAX_YIELD' }) + }) + + it('returns an empty config for null, arrays, and primitives', () => { + expect(parseStrategyConfig(null)).toEqual({}) + expect(parseStrategyConfig([1, 2])).toEqual({}) + expect(parseStrategyConfig('MAX_YIELD')).toEqual({}) + expect(parseStrategyConfig(undefined)).toEqual({}) + }) + + it('drops a non-finite risk ceiling', () => { + expect( + parseStrategyConfig({ strategyName: 'MAX_YIELD', riskCeiling: NaN }) + ).toEqual({ strategyName: 'MAX_YIELD' }) + }) +}) + +describe('stricterRiskCeiling', () => { + it('picks the higher value, since higher score = lower risk', () => { + expect(stricterRiskCeiling(60, 80)).toBe(80) + expect(stricterRiskCeiling(80, 60)).toBe(80) + }) + + it('treats absent as "no ceiling" and therefore the loosest', () => { + expect(stricterRiskCeiling(undefined, 70)).toBe(70) + expect(stricterRiskCeiling(70, undefined)).toBe(70) + expect(stricterRiskCeiling(undefined, undefined)).toBeUndefined() + }) +}) + +describe('resolveEffectiveConfig — no follow', () => { + it('returns the caller‘s own config unchanged', () => { + const own = { + strategyName: 'TARGET_ALLOCATION' as const, + targetAllocations: { Blend: 100 }, + riskCeiling: 55, + } + expect(resolveEffectiveConfig(own)).toEqual(own) + expect(resolveEffectiveConfig(own, null)).toEqual(own) + expect(resolveEffectiveConfig(own, undefined)).toEqual(own) + }) + + it('normalizes an absent strategy to null without inventing one', () => { + expect(resolveEffectiveConfig({})).toEqual({ + strategyName: null, + targetAllocations: undefined, + riskCeiling: undefined, + }) + }) +}) + +describe('resolveEffectiveConfig — with a follow', () => { + it('lets the followed strategy and its allocations win', () => { + const result = resolveEffectiveConfig( + { strategyName: 'MAX_YIELD' }, + { + strategyName: 'TARGET_ALLOCATION', + targetAllocations: { Blend: 70, Luma: 30 }, + } + ) + expect(result.strategyName).toBe('TARGET_ALLOCATION') + expect(result.targetAllocations).toEqual({ Blend: 70, Luma: 30 }) + }) + + it('does not pair the followed strategy with the follower‘s leftover allocations', () => { + // Mixing the two would produce a configuration neither party chose. + const result = resolveEffectiveConfig( + { + strategyName: 'TARGET_ALLOCATION', + targetAllocations: { Blend: 100 }, + }, + { strategyName: 'MAX_YIELD' } + ) + expect(result.strategyName).toBe('MAX_YIELD') + expect(result.targetAllocations).toBeUndefined() + }) + + it('falls back to the follower‘s own strategy when the snapshot has none', () => { + const result = resolveEffectiveConfig( + { strategyName: 'MAX_YIELD', targetAllocations: { Blend: 100 } }, + { riskCeiling: 80 } + ) + expect(result.strategyName).toBe('MAX_YIELD') + expect(result.targetAllocations).toEqual({ Blend: 100 }) + expect(result.riskCeiling).toBe(80) + }) + + it('adopts the followed strategy when the follower has none of their own', () => { + // The common case for a new user — exactly who this feature targets. + const result = resolveEffectiveConfig( + {}, + { strategyName: 'MAX_YIELD', riskCeiling: 65 } + ) + expect(result.strategyName).toBe('MAX_YIELD') + expect(result.riskCeiling).toBe(65) + }) +}) + +describe('resolveEffectiveConfig — the risk-ceiling invariant', () => { + it('tightens when the publisher is stricter', () => { + const result = resolveEffectiveConfig( + { strategyName: 'MAX_YIELD', riskCeiling: 50 }, + { strategyName: 'MAX_YIELD', riskCeiling: 85 } + ) + expect(result.riskCeiling).toBe(85) + }) + + it('NEVER widens when the publisher is looser', () => { + const result = resolveEffectiveConfig( + { strategyName: 'MAX_YIELD', riskCeiling: 85 }, + { strategyName: 'MAX_YIELD', riskCeiling: 50 } + ) + expect(result.riskCeiling).toBe(85) + }) + + it('applies the publisher‘s ceiling to a follower who had none', () => { + const result = resolveEffectiveConfig( + { strategyName: 'MAX_YIELD' }, + { strategyName: 'MAX_YIELD', riskCeiling: 70 } + ) + expect(result.riskCeiling).toBe(70) + }) + + it('keeps the follower‘s ceiling when the publisher set none', () => { + const result = resolveEffectiveConfig( + { strategyName: 'MAX_YIELD', riskCeiling: 70 }, + { strategyName: 'TARGET_ALLOCATION', targetAllocations: { Blend: 100 } } + ) + expect(result.riskCeiling).toBe(70) + }) +}) + +describe('material change detection', () => { + it('ignores allocation key ordering', () => { + expect( + normalizeStrategyConfig({ + strategyName: 'TARGET_ALLOCATION', + targetAllocations: { Luma: 40, Blend: 60 }, + }) + ).toBe( + normalizeStrategyConfig({ + strategyName: 'TARGET_ALLOCATION', + targetAllocations: { Blend: 60, Luma: 40 }, + }) + ) + }) + + it('is false for an identical config (a label-only edit must not notify)', () => { + const config = { + strategyName: 'TARGET_ALLOCATION' as const, + targetAllocations: { Blend: 60, Luma: 40 }, + riskCeiling: 70, + } + expect(isMaterialConfigChange(config, { ...config })).toBe(false) + }) + + it('is true when the strategy, an allocation weight, or the ceiling changes', () => { + const base = { + strategyName: 'TARGET_ALLOCATION' as const, + targetAllocations: { Blend: 60, Luma: 40 }, + riskCeiling: 70, + } + expect( + isMaterialConfigChange(base, { ...base, strategyName: 'MAX_YIELD' }) + ).toBe(true) + expect( + isMaterialConfigChange(base, { + ...base, + targetAllocations: { Blend: 50, Luma: 50 }, + }) + ).toBe(true) + expect(isMaterialConfigChange(base, { ...base, riskCeiling: 80 })).toBe( + true + ) + }) + + it('treats adding or removing a ceiling as material', () => { + expect( + isMaterialConfigChange( + { strategyName: 'MAX_YIELD' }, + { strategyName: 'MAX_YIELD', riskCeiling: 60 } + ) + ).toBe(true) + }) +}) diff --git a/tests/unit/agent/strategyMetrics.test.ts b/tests/unit/agent/strategyMetrics.test.ts new file mode 100644 index 0000000..410b1fd --- /dev/null +++ b/tests/unit/agent/strategyMetrics.test.ts @@ -0,0 +1,286 @@ +/** + * #285 — pure strategy metric computation. + * + * The whole point of this module is that a publisher cannot game the + * leaderboard, so these tests are weighted toward the boundaries where gaming + * would happen: thin history, degenerate series, and the exact eligibility + * cutoffs. + */ +import { + MIN_SAMPLES, + MIN_TRACK_RECORD_DAYS, + PortfolioPoint, + SnapshotRow, + annualizedReturnPercent, + bucketByInstant, + computeStrategyMetrics, + inferPeriodsPerYear, + periodReturns, + sharpeRatio, +} from '../../../src/agent/strategyMetrics' + +const HOUR = 60 * 60 * 1000 +const DAY = 24 * HOUR +const NOW = new Date('2026-07-25T00:00:00.000Z') + +function snap( + offsetMs: number, + principal: number, + yieldAmount: number +): SnapshotRow { + return { + snapshotAt: new Date(NOW.getTime() + offsetMs), + principalAmount: principal, + yieldAmount, + } +} + +/** An hourly series ending at NOW, values supplied oldest-first. */ +function seriesEndingNow(values: number[]): PortfolioPoint[] { + const n = values.length + return values.map((value, i) => ({ + at: new Date(NOW.getTime() - (n - 1 - i) * HOUR), + value, + })) +} + +describe('bucketByInstant', () => { + it('sums positions sharing an instant into one whole-portfolio point', () => { + const points = bucketByInstant([ + snap(0, 100, 5), + snap(0, 200, 10), // second position, same tick + snap(HOUR, 100, 6), + ]) + + expect(points).toHaveLength(2) + expect(points[0].value).toBe(315) + expect(points[1].value).toBe(106) + }) + + it('sorts ascending by time regardless of input order', () => { + const points = bucketByInstant([snap(2 * HOUR, 100, 0), snap(0, 50, 0)]) + expect(points.map((p) => p.value)).toEqual([50, 100]) + }) + + it('drops non-finite values rather than poisoning the series', () => { + const points = bucketByInstant([snap(0, Number.NaN, 0), snap(HOUR, 100, 0)]) + expect(points).toHaveLength(1) + expect(points[0].value).toBe(100) + }) + + it('returns an empty series for no snapshots', () => { + expect(bucketByInstant([])).toEqual([]) + }) +}) + +describe('periodReturns', () => { + it('differences consecutive values rather than reading a cumulative APY', () => { + const returns = periodReturns(seriesEndingNow([100, 110, 99])) + expect(returns).toHaveLength(2) + expect(returns[0]).toBeCloseTo(0.1, 10) + expect(returns[1]).toBeCloseTo(-0.1, 10) + }) + + it('skips intervals starting at zero instead of yielding Infinity', () => { + // A portfolio funded from empty is a deposit, not a return. Letting it + // through would hand the publisher an unbounded Sharpe. + const returns = periodReturns(seriesEndingNow([0, 1000, 1010])) + expect(returns).toHaveLength(1) + expect(returns[0]).toBeCloseTo(0.01, 10) + expect(returns.every(Number.isFinite)).toBe(true) + }) + + it('returns nothing for a single point', () => { + expect(periodReturns(seriesEndingNow([100]))).toEqual([]) + }) +}) + +describe('annualizedReturnPercent', () => { + it('annualizes a positive return with the simple (non-compounding) convention', () => { + // +10% over exactly 365.25 days → 10% annualized. + const points: PortfolioPoint[] = [ + { at: new Date(NOW.getTime() - 365.25 * DAY), value: 100 }, + { at: NOW, value: 110 }, + ] + expect(annualizedReturnPercent(points)).toBeCloseTo(10, 6) + }) + + it('annualizes a loss as a negative figure', () => { + const points: PortfolioPoint[] = [ + { at: new Date(NOW.getTime() - 365.25 * DAY), value: 100 }, + { at: NOW, value: 90 }, + ] + expect(annualizedReturnPercent(points)).toBeCloseTo(-10, 6) + }) + + it('returns 0 for a single point and for a non-positive start', () => { + expect(annualizedReturnPercent(seriesEndingNow([100]))).toBe(0) + expect(annualizedReturnPercent(seriesEndingNow([0, 100]))).toBe(0) + }) + + it('does not divide by zero when two points share an instant', () => { + const points: PortfolioPoint[] = [ + { at: NOW, value: 100 }, + { at: NOW, value: 200 }, + ] + expect(annualizedReturnPercent(points)).toBe(0) + }) +}) + +describe('inferPeriodsPerYear', () => { + it('infers an hourly cadence from an hourly series', () => { + expect(inferPeriodsPerYear(seriesEndingNow([1, 2, 3, 4]))).toBeCloseTo( + 8766, + 0 + ) + }) + + it('uses the median so a single gap does not distort annualization', () => { + const points: PortfolioPoint[] = [ + { at: new Date(NOW.getTime() - 100 * HOUR), value: 1 }, + { at: new Date(NOW.getTime() - 2 * HOUR), value: 2 }, + { at: new Date(NOW.getTime() - HOUR), value: 3 }, + { at: NOW, value: 4 }, + ] + expect(inferPeriodsPerYear(points)).toBeCloseTo(8766, 0) + }) + + it('falls back to hourly for a degenerate series', () => { + expect(inferPeriodsPerYear([])).toBeCloseTo(8766, 0) + expect(inferPeriodsPerYear(seriesEndingNow([1]))).toBeCloseTo(8766, 0) + }) +}) + +describe('sharpeRatio', () => { + it('is null — not zero — for a zero-variance series', () => { + // A flat portfolio would otherwise divide by a zero stdev and produce + // Infinity, which sorts straight to the top of the leaderboard. + expect(sharpeRatio([0.01, 0.01, 0.01, 0.01], 8766)).toBeNull() + }) + + it('is null with fewer than two returns', () => { + expect(sharpeRatio([], 8766)).toBeNull() + expect(sharpeRatio([0.01], 8766)).toBeNull() + }) + + it('is null for a non-positive periodsPerYear', () => { + expect(sharpeRatio([0.01, 0.02, 0.03], 0)).toBeNull() + expect(sharpeRatio([0.01, 0.02, 0.03], -1)).toBeNull() + }) + + it('is positive for a net-positive return series and negative for a losing one', () => { + const winning = sharpeRatio([0.01, 0.02, 0.015, 0.012], 8766) + const losing = sharpeRatio([-0.01, -0.02, -0.015, -0.012], 8766) + expect(winning).not.toBeNull() + expect(losing).not.toBeNull() + expect(winning!).toBeGreaterThan(0) + expect(losing!).toBeLessThan(0) + }) + + it('lowers the ratio as the risk-free baseline rises', () => { + const returns = [0.01, 0.02, 0.015, 0.012] + const atZero = sharpeRatio(returns, 8766, 0)! + const atFourPercent = sharpeRatio(returns, 8766, 0.04)! + expect(atFourPercent).toBeLessThan(atZero) + }) + + it('scales by sqrt(periodsPerYear)', () => { + const returns = [0.01, -0.005, 0.02, 0.001] + const hourly = sharpeRatio(returns, 8766, 0)! + const daily = sharpeRatio(returns, 365.25, 0)! + expect(hourly / daily).toBeCloseTo(Math.sqrt(8766 / 365.25), 6) + }) +}) + +describe('computeStrategyMetrics — eligibility gate', () => { + /** An hourly series with `count` points and a little variance. */ + function varyingSeries(count: number): PortfolioPoint[] { + return seriesEndingNow( + Array.from({ length: count }, (_, i) => 1000 + i * 3 + (i % 3)) + ) + } + + it('is ineligible with an empty series', () => { + const metrics = computeStrategyMetrics({ points: [], now: NOW }) + expect(metrics).toMatchObject({ + apy: 0, + sharpe: null, + sampleCount: 0, + trackRecordDays: 0, + isEligible: false, + }) + }) + + it('reports sharpe as null below MIN_SAMPLES even with a long track record', () => { + const metrics = computeStrategyMetrics({ + points: varyingSeries(MIN_SAMPLES - 1), + firstObservedAt: new Date(NOW.getTime() - 200 * DAY), + now: NOW, + }) + expect(metrics.sampleCount).toBe(MIN_SAMPLES - 1) + expect(metrics.sharpe).toBeNull() + expect(metrics.isEligible).toBe(false) + }) + + it('becomes eligible at exactly MIN_SAMPLES with a sufficient track record', () => { + const metrics = computeStrategyMetrics({ + points: varyingSeries(MIN_SAMPLES), + firstObservedAt: new Date(NOW.getTime() - MIN_TRACK_RECORD_DAYS * DAY), + now: NOW, + }) + expect(metrics.sampleCount).toBe(MIN_SAMPLES) + expect(metrics.trackRecordDays).toBe(MIN_TRACK_RECORD_DAYS) + expect(metrics.sharpe).not.toBeNull() + expect(metrics.isEligible).toBe(true) + }) + + it('is ineligible one day short of MIN_TRACK_RECORD_DAYS', () => { + const metrics = computeStrategyMetrics({ + points: varyingSeries(MIN_SAMPLES + 10), + firstObservedAt: new Date( + NOW.getTime() - (MIN_TRACK_RECORD_DAYS - 1) * DAY + ), + now: NOW, + }) + expect(metrics.trackRecordDays).toBe(MIN_TRACK_RECORD_DAYS - 1) + expect(metrics.isEligible).toBe(false) + }) + + it('is ineligible when the series is flat, however long the record', () => { + // Zero variance → no Sharpe → excluded. A publisher parking funds in a + // stable position must not rank above one taking real risk. + const flat = seriesEndingNow(Array.from({ length: 40 }, () => 1000)) + const metrics = computeStrategyMetrics({ + points: flat, + firstObservedAt: new Date(NOW.getTime() - 200 * DAY), + now: NOW, + }) + expect(metrics.sharpe).toBeNull() + expect(metrics.isEligible).toBe(false) + }) + + it('measures the track record from firstObservedAt, not the windowed series', () => { + // The 30-day window's oldest sample sits ~30 days back and would floor to + // 29 — which would make the 30-day leaderboard permanently empty if the + // track record were derived from the window. + const points = seriesEndingNow( + Array.from({ length: 20 }, (_, i) => 1000 + i) + ) + const metrics = computeStrategyMetrics({ + points, + firstObservedAt: new Date(NOW.getTime() - 45 * DAY), + now: NOW, + }) + expect(metrics.trackRecordDays).toBe(45) + }) + + it('never reports a negative track record for a future firstObservedAt', () => { + const metrics = computeStrategyMetrics({ + points: varyingSeries(20), + firstObservedAt: new Date(NOW.getTime() + 10 * DAY), + now: NOW, + }) + expect(metrics.trackRecordDays).toBe(0) + expect(metrics.isEligible).toBe(false) + }) +}) diff --git a/tests/unit/strategy/service.test.ts b/tests/unit/strategy/service.test.ts new file mode 100644 index 0000000..b79fbca --- /dev/null +++ b/tests/unit/strategy/service.test.ts @@ -0,0 +1,532 @@ +// Strategy marketplace service unit tests (#285). These pin the invariants that +// matter when one user's configuration starts driving another user's agent: +// * configVersion bumps ONLY on a material change, and when it does, every +// active follower's snapshot is rewritten in the SAME transaction +// * a label-only edit notifies nobody +// * unpublishing is immediate and does NOT sever follows +// * self-follow is refused +// * re-following swaps atomically (old follow closed, new one created) +// * an orphaned follow (publisher deleted) still resolves and can be released +// * no response path ever carries the publisher's userId +import db from '../../../src/db' +import { dispatchWebhookEvent } from '../../../src/services/webhookDispatcher' +import { sendWhatsAppMessage } from '../../../src/utils/twilio-client' +import { + StrategyFollowNotFoundError, + StrategyNotFoundError, + StrategySelfFollowError, + StrategyValidationError, + followStrategy, + getActiveFollow, + getMarketplace, + loadActiveFollowsForUsers, + publishStrategy, + unfollowStrategy, + unpublishStrategy, +} from '../../../src/strategy/service' + +jest.mock('../../../src/db', () => ({ __esModule: true, default: {} })) +jest.mock('../../../src/utils/logger', () => ({ + logger: { + warn: jest.fn(), + error: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + }, +})) +jest.mock('../../../src/services/webhookDispatcher', () => ({ + dispatchWebhookEvent: jest.fn().mockResolvedValue(undefined), +})) +jest.mock('../../../src/utils/twilio-client', () => ({ + sendWhatsAppMessage: jest.fn().mockResolvedValue('SM123'), +})) + +const mockDb = db as any +const mockDispatch = dispatchWebhookEvent as jest.Mock +const mockWhatsApp = sendWhatsAppMessage as jest.Mock + +const PUBLISHER = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' +const FOLLOWER = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' +const STRATEGY_ID = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc' + +/** Lets the tested code run its callback against the same mock client. */ +function transactionalDb() { + return jest.fn(async (fn: any) => fn(mockDb)) +} + +/** Drains the fire-and-forget notification fan-out. */ +async function flushNotifications(): Promise { + await new Promise((resolve) => setImmediate(resolve)) + await new Promise((resolve) => setImmediate(resolve)) +} + +beforeEach(() => { + mockDb.$transaction = transactionalDb() + mockDb.user = { findUnique: jest.fn() } + mockDb.publishedStrategy = { + findUnique: jest.fn(), + findFirst: jest.fn(), + upsert: jest.fn(), + update: jest.fn(), + findMany: jest.fn(), + } + mockDb.strategyFollow = { + findFirst: jest.fn(), + findMany: jest.fn().mockResolvedValue([]), + updateMany: jest.fn().mockResolvedValue({ count: 0 }), + create: jest.fn(), + update: jest.fn(), + } + mockDb.publishedStrategyMetric = { + count: jest.fn().mockResolvedValue(0), + findMany: jest.fn().mockResolvedValue([]), + } + mockDispatch.mockResolvedValue(undefined) + mockWhatsApp.mockResolvedValue('SM123') +}) + +describe('publishStrategy', () => { + const config = { strategyName: 'MAX_YIELD' as const, riskCeiling: 70 } + + it('creates a first listing at version 1 without notifying anyone', async () => { + mockDb.publishedStrategy.findUnique.mockResolvedValue(null) + mockDb.publishedStrategy.upsert.mockResolvedValue({ + id: STRATEGY_ID, + label: 'Steady yield', + strategyConfig: config, + configVersion: 1, + isPublished: true, + publishedAt: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + }) + + const result = await publishStrategy(PUBLISHER, { + label: 'Steady yield', + strategyConfig: config, + }) + + expect(result.materialChange).toBe(false) + expect(mockDb.publishedStrategy.upsert).toHaveBeenCalledWith( + expect.objectContaining({ where: { userId: PUBLISHER } }) + ) + await flushNotifications() + expect(mockDispatch).not.toHaveBeenCalled() + }) + + it('does NOT bump the version or notify for a label-only edit', async () => { + mockDb.publishedStrategy.findUnique.mockResolvedValue({ + id: STRATEGY_ID, + strategyConfig: config, + configVersion: 3, + }) + mockDb.publishedStrategy.upsert.mockResolvedValue({ + id: STRATEGY_ID, + label: 'Steady yield v2', + strategyConfig: config, + configVersion: 3, + isPublished: true, + publishedAt: new Date(), + }) + + const result = await publishStrategy(PUBLISHER, { + label: 'Steady yield v2', + strategyConfig: config, + }) + + expect(result.materialChange).toBe(false) + expect(mockDb.publishedStrategy.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + update: expect.objectContaining({ configVersion: 3 }), + }) + ) + // The follower snapshot rewrite must NOT run for a cosmetic edit. + expect(mockDb.strategyFollow.updateMany).not.toHaveBeenCalled() + await flushNotifications() + expect(mockDispatch).not.toHaveBeenCalled() + }) + + it('bumps the version and rewrites follower snapshots on a material change', async () => { + mockDb.publishedStrategy.findUnique.mockResolvedValue({ + id: STRATEGY_ID, + strategyConfig: config, + configVersion: 3, + }) + const next = { strategyName: 'MAX_YIELD' as const, riskCeiling: 85 } + mockDb.publishedStrategy.upsert.mockResolvedValue({ + id: STRATEGY_ID, + label: 'Steady yield', + strategyConfig: next, + configVersion: 4, + isPublished: true, + publishedAt: new Date(), + }) + mockDb.strategyFollow.findMany.mockResolvedValue([ + { followerUserId: FOLLOWER, follower: { phone: '+15550001111' } }, + ]) + + const result = await publishStrategy(PUBLISHER, { + label: 'Steady yield', + strategyConfig: next, + }) + + expect(result.materialChange).toBe(true) + expect(mockDb.strategyFollow.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { publishedStrategyId: STRATEGY_ID, unfollowedAt: null }, + data: expect.objectContaining({ appliedConfigVersion: 4 }), + }) + ) + + await flushNotifications() + expect(mockDispatch).toHaveBeenCalledWith( + 'strategy.updated', + expect.objectContaining({ followerUserId: FOLLOWER, configVersion: 4 }) + ) + expect(mockWhatsApp).toHaveBeenCalledWith( + expect.objectContaining({ to: 'whatsapp:+15550001111' }) + ) + }) + + it('never leaks the publisher userId into a notification payload', async () => { + mockDb.publishedStrategy.findUnique.mockResolvedValue({ + id: STRATEGY_ID, + strategyConfig: config, + configVersion: 1, + }) + mockDb.publishedStrategy.upsert.mockResolvedValue({ + id: STRATEGY_ID, + label: 'Steady yield', + strategyConfig: { strategyName: 'MAX_YIELD' }, + configVersion: 2, + isPublished: true, + publishedAt: new Date(), + }) + mockDb.strategyFollow.findMany.mockResolvedValue([ + { followerUserId: FOLLOWER, follower: { phone: null } }, + ]) + + await publishStrategy(PUBLISHER, { + label: 'Steady yield', + strategyConfig: { strategyName: 'MAX_YIELD' }, + }) + await flushNotifications() + + const payload = JSON.stringify(mockDispatch.mock.calls[0][1]) + expect(payload).not.toContain(PUBLISHER) + // A follower with no phone is skipped, not thrown. + expect(mockWhatsApp).not.toHaveBeenCalled() + }) + + it('snapshots the caller‘s own User config when the body omits one', async () => { + mockDb.user.findUnique.mockResolvedValue({ + rebalanceStrategy: 'TARGET_ALLOCATION', + strategyConfig: { targetAllocations: { Blend: 100 }, riskCeiling: 60 }, + }) + mockDb.publishedStrategy.findUnique.mockResolvedValue(null) + mockDb.publishedStrategy.upsert.mockResolvedValue({ + id: STRATEGY_ID, + label: 'Mine', + strategyConfig: {}, + configVersion: 1, + isPublished: true, + publishedAt: new Date(), + }) + + await publishStrategy(PUBLISHER, { label: 'Mine' }) + + expect(mockDb.publishedStrategy.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + create: expect.objectContaining({ + strategyConfig: { + strategyName: 'TARGET_ALLOCATION', + targetAllocations: { Blend: 100 }, + riskCeiling: 60, + }, + }), + }) + ) + }) + + it('rejects a body-less publish when the caller has no strategy configured', async () => { + mockDb.user.findUnique.mockResolvedValue({ + rebalanceStrategy: null, + strategyConfig: null, + }) + + await expect(publishStrategy(PUBLISHER, { label: 'Mine' })).rejects.toThrow( + StrategyValidationError + ) + }) +}) + +describe('unpublishStrategy', () => { + it('delists immediately and leaves follows intact', async () => { + mockDb.publishedStrategy.findUnique.mockResolvedValue({ + id: STRATEGY_ID, + isPublished: true, + }) + mockDb.publishedStrategy.update.mockResolvedValue({ + id: STRATEGY_ID, + label: 'Steady yield', + strategyConfig: {}, + configVersion: 1, + isPublished: false, + publishedAt: new Date(), + }) + mockDb.strategyFollow.findMany.mockResolvedValue([ + { followerUserId: FOLLOWER, follower: { phone: '+15550001111' } }, + ]) + + const result = await unpublishStrategy(PUBLISHER) + + expect(result.isPublished).toBe(false) + expect(mockDb.publishedStrategy.update).toHaveBeenCalledWith( + expect.objectContaining({ data: { isPublished: false } }) + ) + // Follows are never closed by an unpublish. + expect(mockDb.strategyFollow.updateMany).not.toHaveBeenCalled() + + await flushNotifications() + expect(mockDispatch).toHaveBeenCalledWith( + 'strategy.unpublished', + expect.objectContaining({ followerUserId: FOLLOWER }) + ) + }) + + it('does not re-notify when the strategy was already unpublished', async () => { + mockDb.publishedStrategy.findUnique.mockResolvedValue({ + id: STRATEGY_ID, + isPublished: false, + }) + mockDb.publishedStrategy.update.mockResolvedValue({ + id: STRATEGY_ID, + label: 'Steady yield', + strategyConfig: {}, + configVersion: 1, + isPublished: false, + publishedAt: new Date(), + }) + + await unpublishStrategy(PUBLISHER) + await flushNotifications() + expect(mockDispatch).not.toHaveBeenCalled() + }) + + it('404s when the caller has never published', async () => { + mockDb.publishedStrategy.findUnique.mockResolvedValue(null) + await expect(unpublishStrategy(PUBLISHER)).rejects.toThrow( + StrategyNotFoundError + ) + }) +}) + +describe('followStrategy', () => { + const published = { + id: STRATEGY_ID, + userId: PUBLISHER, + label: 'Steady yield', + strategyConfig: { strategyName: 'MAX_YIELD', riskCeiling: 70 }, + configVersion: 4, + } + + it('refuses a self-follow', async () => { + mockDb.publishedStrategy.findFirst.mockResolvedValue(published) + await expect(followStrategy(PUBLISHER, STRATEGY_ID)).rejects.toThrow( + StrategySelfFollowError + ) + expect(mockDb.strategyFollow.create).not.toHaveBeenCalled() + }) + + it('404s for an unknown or unpublished strategy', async () => { + mockDb.publishedStrategy.findFirst.mockResolvedValue(null) + await expect(followStrategy(FOLLOWER, STRATEGY_ID)).rejects.toThrow( + StrategyNotFoundError + ) + // The query itself is what enforces "published only". + expect(mockDb.publishedStrategy.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: STRATEGY_ID, isPublished: true }, + }) + ) + }) + + it('swaps an existing follow atomically and snapshots the config', async () => { + mockDb.publishedStrategy.findFirst.mockResolvedValue(published) + mockDb.strategyFollow.create.mockResolvedValue({ + id: 'follow-1', + publishedStrategyId: STRATEGY_ID, + appliedConfig: published.strategyConfig, + appliedConfigVersion: 4, + appliedAt: new Date(), + followedAt: new Date(), + publishedStrategy: { id: STRATEGY_ID, label: 'Steady yield' }, + }) + + const follow = await followStrategy(FOLLOWER, STRATEGY_ID) + + // Both writes go through the same transaction callback, so the partial + // unique index can never see two active rows for this user. + expect(mockDb.$transaction).toHaveBeenCalledTimes(1) + expect(mockDb.strategyFollow.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { followerUserId: FOLLOWER, unfollowedAt: null }, + }) + ) + expect(mockDb.strategyFollow.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + appliedConfig: published.strategyConfig, + appliedConfigVersion: 4, + }), + }) + ) + expect(JSON.stringify(follow)).not.toContain(PUBLISHER) + }) +}) + +describe('unfollowStrategy', () => { + it('releases the caller‘s active follow', async () => { + mockDb.strategyFollow.findFirst.mockResolvedValue({ + id: 'follow-1', + publishedStrategyId: STRATEGY_ID, + }) + mockDb.strategyFollow.update.mockResolvedValue({}) + + const result = await unfollowStrategy(FOLLOWER, STRATEGY_ID) + + expect(result.id).toBe('follow-1') + expect(mockDb.strategyFollow.update).toHaveBeenCalledWith( + expect.objectContaining({ where: { id: 'follow-1' } }) + ) + }) + + it('releases an ORPHANED follow whose publisher deleted their account', async () => { + // onDelete: SetNull leaves publishedStrategyId null; without this fallback + // the follower would have no id left to name and could never unfollow. + mockDb.strategyFollow.findFirst.mockResolvedValue({ + id: 'follow-1', + publishedStrategyId: null, + }) + mockDb.strategyFollow.update.mockResolvedValue({}) + + await expect( + unfollowStrategy(FOLLOWER, STRATEGY_ID) + ).resolves.toMatchObject({ id: 'follow-1' }) + }) + + it('404s when the caller follows nothing', async () => { + mockDb.strategyFollow.findFirst.mockResolvedValue(null) + await expect(unfollowStrategy(FOLLOWER, STRATEGY_ID)).rejects.toThrow( + StrategyFollowNotFoundError + ) + }) + + it('404s when the id names a strategy the caller does not follow', async () => { + mockDb.strategyFollow.findFirst.mockResolvedValue({ + id: 'follow-1', + publishedStrategyId: 'dddddddd-dddd-4ddd-8ddd-dddddddddddd', + }) + await expect(unfollowStrategy(FOLLOWER, STRATEGY_ID)).rejects.toThrow( + StrategyFollowNotFoundError + ) + expect(mockDb.strategyFollow.update).not.toHaveBeenCalled() + }) +}) + +describe('getMarketplace', () => { + it('queries only eligible metrics of published strategies, sorted in SQL', async () => { + await getMarketplace({ + sortBy: 'sharpe', + window: '90d', + page: 2, + limit: 20, + }) + + expect(mockDb.publishedStrategyMetric.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + windowDays: 90, + isEligible: true, + publishedStrategy: { isPublished: true }, + }, + orderBy: [{ sharpe: 'desc' }, { computedAt: 'desc' }], + skip: 20, + take: 20, + }) + ) + }) + + it('never selects userId on the joined strategy', async () => { + await getMarketplace({ sortBy: 'apy', window: '30d', page: 1, limit: 10 }) + + const select = + mockDb.publishedStrategyMetric.findMany.mock.calls[0][0].select + .publishedStrategy.select + expect(select).not.toHaveProperty('userId') + expect(select).not.toHaveProperty('user') + }) +}) + +describe('getActiveFollow / loadActiveFollowsForUsers', () => { + it('returns the follow snapshot without the publisher userId', async () => { + mockDb.strategyFollow.findFirst.mockResolvedValue({ + id: 'follow-1', + publishedStrategyId: STRATEGY_ID, + appliedConfig: { strategyName: 'MAX_YIELD' }, + }) + + await getActiveFollow(FOLLOWER) + + const select = mockDb.strategyFollow.findFirst.mock.calls[0][0].select + expect(select).not.toHaveProperty('followerUserId') + expect(select.publishedStrategy.select).not.toHaveProperty('userId') + }) + + it('short-circuits without a query for an empty user list', async () => { + const result = await loadActiveFollowsForUsers([]) + expect(result.size).toBe(0) + expect(mockDb.strategyFollow.findMany).not.toHaveBeenCalled() + }) + + it('keys follows by follower and sanitizes the stored snapshot', async () => { + mockDb.strategyFollow.findMany.mockResolvedValue([ + { + id: 'follow-1', + followerUserId: FOLLOWER, + publishedStrategyId: STRATEGY_ID, + // Written by an older version: an unknown strategy and a stray key. + appliedConfig: { strategyName: 'MOON_MODE', riskTolerance: 9 }, + appliedConfigVersion: 2, + }, + ]) + + const result = await loadActiveFollowsForUsers([FOLLOWER]) + + expect(result.get(FOLLOWER)).toEqual({ + followId: 'follow-1', + followedStrategyId: STRATEGY_ID, + appliedConfig: {}, + appliedConfigVersion: 2, + }) + }) + + it('resolves an orphaned follow with its config intact', async () => { + mockDb.strategyFollow.findMany.mockResolvedValue([ + { + id: 'follow-1', + followerUserId: FOLLOWER, + publishedStrategyId: null, + appliedConfig: { strategyName: 'MAX_YIELD', riskCeiling: 70 }, + appliedConfigVersion: 4, + }, + ]) + + const result = await loadActiveFollowsForUsers([FOLLOWER]) + + expect(result.get(FOLLOWER)).toMatchObject({ + followedStrategyId: null, + appliedConfig: { strategyName: 'MAX_YIELD', riskCeiling: 70 }, + }) + }) +})