@onride/agent 0.0.0-stage → 0.5.1-rc.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/.env.example +31 -0
  2. package/LICENSE +21 -0
  3. package/README.md +174 -2
  4. package/dist/account_balances.js +435 -0
  5. package/dist/account_reads.js +199 -0
  6. package/dist/agent/adapter.js +115 -0
  7. package/dist/agent/analytics.js +174 -0
  8. package/dist/agent/backend.js +237 -0
  9. package/dist/agent/card_hosts.js +9 -0
  10. package/dist/agent/contracts.js +276 -0
  11. package/dist/agent/conversational.js +86 -0
  12. package/dist/agent/core_bridge.js +1155 -0
  13. package/dist/agent/deposits.js +309 -0
  14. package/dist/agent/discovery.js +205 -0
  15. package/dist/agent/domain.js +507 -0
  16. package/dist/agent/firebase.js +34 -0
  17. package/dist/agent/funding.js +230 -0
  18. package/dist/agent/http.js +40 -0
  19. package/dist/agent/human.js +298 -0
  20. package/dist/agent/human_handoffs.js +39 -0
  21. package/dist/agent/ledger.js +137 -0
  22. package/dist/agent/mcp.js +245 -0
  23. package/dist/agent/order_rules.js +61 -0
  24. package/dist/agent/perps_account.js +41 -0
  25. package/dist/agent/prediction.js +607 -0
  26. package/dist/agent/prediction_equity_history.js +910 -0
  27. package/dist/agent/prediction_history.js +336 -0
  28. package/dist/agent/preferences.js +114 -0
  29. package/dist/agent/repository.js +106 -0
  30. package/dist/agent/runner.js +683 -0
  31. package/dist/agent/service.js +1347 -0
  32. package/dist/backend.js +6 -0
  33. package/dist/bridge.js +6 -0
  34. package/dist/cli/clients.js +99 -0
  35. package/dist/cli/commands.js +332 -0
  36. package/dist/cli/onboarding.js +53 -0
  37. package/dist/cli/session.js +224 -0
  38. package/dist/cli/setup.js +256 -0
  39. package/dist/cli.js +16 -0
  40. package/dist/guide.js +92 -0
  41. package/dist/http_security.js +79 -0
  42. package/dist/mcp-cli.js +7 -0
  43. package/dist/oauth.js +1032 -0
  44. package/dist/server.js +171 -0
  45. package/dist/start.js +18 -0
  46. package/dist/version.js +25 -0
  47. package/docs/confirmation-cards.md +118 -0
  48. package/docs/conversation-v050.md +30 -0
  49. package/docs/conversational-v17.md +48 -0
  50. package/docs/deployment.md +58 -0
  51. package/docs/perps-leverage.md +43 -0
  52. package/docs/prediction-history.md +28 -0
  53. package/docs/preferences-funding-v042.md +17 -0
  54. package/docs/ride-agent-migration.md +127 -0
  55. package/docs/v17-completion-checklist.md +31 -0
  56. package/docs/v17-rollout.md +44 -0
  57. package/docs/widget-account-v043.md +17 -0
  58. package/package.json +75 -4
  59. package/public/agent.html +2313 -0
  60. package/public/confirm.html +2373 -0
  61. package/public/fund.html +563 -0
  62. package/skills/ride/SKILL.md +18 -0
  63. package/skills/ride/references/commands.md +47 -0
  64. package/skills/ride/references/workflow.md +47 -0
@@ -0,0 +1,1347 @@
1
+ import { inputs, preferences, AgentError, result, } from "./contracts.js";
2
+ import { D, cash, uid, fingerprint, now, fresh, recommend, assetAllowed, recommendedLeverage, defaultLeverageMode, maximumUserLeverage, minimumAllocation, eligibleCandidates, recommendationDiagnostics, aggregate, risk, pnl, } from "./domain.js";
3
+ import { performanceView, recordConfigurations } from "./analytics.js";
4
+ import { compatibility, alternatives, basketLimits, validateCopySettings, } from "./conversational.js";
5
+ import { depositView } from "./deposits.js";
6
+ import { localDay } from "./analytics.js";
7
+ import { preferenceCollection, completePreferences, normalizedAssetPreferences, savePreferenceCollection, } from "./preferences.js";
8
+ import { browse, choosePlanPreferences, saveDiscovery, conversationView, } from "./discovery.js";
9
+ import { FundingService } from "./funding.js";
10
+ export class AgentService {
11
+ repository;
12
+ adapter;
13
+ confirmationBase;
14
+ cursor;
15
+ get funding() {
16
+ return new FundingService(this.repository, this.adapter, this.confirmationBase);
17
+ }
18
+ constructor(repository, adapter, confirmationBase, cursor) {
19
+ this.repository = repository;
20
+ this.adapter = adapter;
21
+ this.confirmationBase = confirmationBase;
22
+ this.cursor = cursor;
23
+ }
24
+ async tool(user, name, input) {
25
+ const args = inputs[name].parse(input);
26
+ if (name === "prepare_deposit")
27
+ return this.funding.prepare(user, args.deposit_id, String(args.amount_usdc));
28
+ if (name === "get_preferences")
29
+ return this.repository.transact(user, async (s) => {
30
+ const collection = preferenceCollection(s);
31
+ return result({ preference_collection: collection }, collection.ready ? "ok" : "requires_preferences");
32
+ });
33
+ if (name === "get_deposit_options") {
34
+ if (!this.adapter.depositOptions)
35
+ throw new AgentError("DEPOSIT_UNAVAILABLE", "Funding details are unavailable on this adapter.", true, 503);
36
+ return result({ deposit_options: await this.adapter.depositOptions() });
37
+ }
38
+ if (name === "get_trader_profile") {
39
+ const c = await this.adapter.profile(args.trader_id);
40
+ if (args.market && c.market !== args.market)
41
+ throw new AgentError("NOT_FOUND", "Trader does not belong to this market.", false, 404);
42
+ return this.repository.transact(user, async (state) => result({
43
+ trader: c,
44
+ preferences: preferenceCollection(state).answers,
45
+ saved_plan: state.portfolios.some((p) => p.state !== "closed")
46
+ ? null
47
+ : (state.plans.at(-1) ?? null),
48
+ }));
49
+ }
50
+ if (name === "browse_traders") {
51
+ return this.repository.transact(user, async (s) => {
52
+ const pref = completePreferences(s);
53
+ if (!pref)
54
+ return result({ preference_collection: preferenceCollection(s) }, "requires_preferences");
55
+ if (!s.discovery && !args.cursor)
56
+ saveDiscovery(s, pref, await this.adapter.candidates(pref.market));
57
+ return result(browse(s, pref, args, user, this.cursor));
58
+ });
59
+ }
60
+ if (name === "recommend_traders") {
61
+ return this.repository.transact(user, async (s) => {
62
+ const answers = (args.preferences ?? {});
63
+ const collection = preferenceCollection(s, answers);
64
+ savePreferenceCollection(s, collection);
65
+ if (!collection.ready) {
66
+ return result({ preference_collection: collection }, "requires_preferences");
67
+ }
68
+ const savedPref = s.preferences;
69
+ if (args.selection === "system")
70
+ s.selected_trader_ids = undefined;
71
+ if (args.trader_ids)
72
+ s.selected_trader_ids = [...new Set(args.trader_ids)];
73
+ const selectedIds = s.selected_trader_ids;
74
+ let pool;
75
+ let sourceReadFailed = false;
76
+ try {
77
+ pool = await this.adapter.candidates(savedPref.market);
78
+ }
79
+ catch {
80
+ sourceReadFailed = true;
81
+ pool = [];
82
+ }
83
+ const planningPool = selectedIds
84
+ ? pool.filter((c) => selectedIds.includes(c.id))
85
+ : pool;
86
+ const pref = choosePlanPreferences(savedPref, planningPool, selectedIds);
87
+ saveDiscovery(s, savedPref, pool);
88
+ const candidatePage = s.discovery.expires_at > now()
89
+ ? browse(s, savedPref, { limit: 5 }, user, this.cursor)
90
+ .candidates_meta
91
+ : null;
92
+ let displayedPool = eligibleCandidates(pref, pool);
93
+ if (args.batch_action) {
94
+ const hash = fingerprint(pref), old = s.recommendation_batches;
95
+ if (!old ||
96
+ (args.batch_action === "first" && old.preference_hash !== hash)) {
97
+ s.recommendation_batches = {
98
+ preference_hash: hash,
99
+ batches: [displayedPool.slice(0, 8).map((c) => c.id)],
100
+ change_used: old?.change_used ?? false,
101
+ active_index: 0,
102
+ };
103
+ }
104
+ const batches = s.recommendation_batches;
105
+ if (batches.preference_hash !== hash)
106
+ throw new AgentError("PREFERENCES_CHANGED", "Refresh the first page with the current preferences.");
107
+ if (args.batch_action === "next") {
108
+ const excluded = new Set(batches.batches.flat());
109
+ const next = displayedPool
110
+ .filter((c) => !excluded.has(c.id))
111
+ .slice(0, 8);
112
+ if (!next.length)
113
+ throw new AgentError("NO_NEW_BATCH", "No additional fresh eligible traders are available.", true);
114
+ batches.batches.push(next.map((c) => c.id));
115
+ batches.change_used = true;
116
+ batches.active_index = batches.batches.length - 1;
117
+ }
118
+ else if (args.batch_action === "saved") {
119
+ if (!batches.batches[Number(args.batch_index ?? 0)])
120
+ throw new AgentError("NOT_FOUND", "Saved batch not found.", false, 404);
121
+ batches.active_index = Number(args.batch_index ?? 0);
122
+ }
123
+ const selectedIds = new Set(batches.batches[batches.active_index]);
124
+ displayedPool = displayedPool.filter((c) => selectedIds.has(c.id));
125
+ }
126
+ let plan;
127
+ try {
128
+ if (selectedIds && planningPool.length !== selectedIds.length)
129
+ throw new AgentError("SELECTED_TRADERS_UNAVAILABLE", "Some selected traders are absent from the current source pool; review the missing selected IDs without substituting traders.");
130
+ const saved = s.plans.at(-1);
131
+ plan =
132
+ args.batch_action &&
133
+ saved &&
134
+ fingerprint(saved.preferences) === fingerprint(pref)
135
+ ? structuredClone(saved)
136
+ : recommend(pref, planningPool);
137
+ // Browsing must preserve a user's edited plan. Preference changes keep
138
+ // custom weights if still valid, or explain the conflict instead of resetting them.
139
+ if (selectedIds &&
140
+ saved &&
141
+ !args.batch_action &&
142
+ fingerprint(saved.allocations.map((a) => a.trader.id).sort()) ===
143
+ fingerprint([...selectedIds].sort())) {
144
+ const total = saved.allocations.reduce((n, a) => n.plus(a.amount_usdc), D(0));
145
+ for (const a of plan.allocations) {
146
+ const old = saved.allocations.find((x) => x.trader.id === a.trader.id);
147
+ if (total.gt(pref.budget_usdc) ||
148
+ D(old.amount_usdc).lt(minimumAllocation(pref, a.trader)) ||
149
+ (old.leverage_cap != null &&
150
+ old.leverage_cap >
151
+ (old.leverage_mode === "custom_cap"
152
+ ? (maximumUserLeverage(a.trader) ?? 0)
153
+ : (a.leverage_cap ?? Number.POSITIVE_INFINITY))))
154
+ throw new AgentError("ALLOCATION_REVIEW_REQUIRED", "The saved amounts or leverage no longer fit the updated budget/risk constraints. Review the saved plan and explicitly adjust allocations; other choices were preserved.");
155
+ Object.assign(a, {
156
+ amount_usdc: old.amount_usdc,
157
+ leverage_cap: old.leverage_cap,
158
+ leverage_mode: old.leverage_mode,
159
+ stop_loss_pct: old.stop_loss_pct,
160
+ });
161
+ for (const key of [
162
+ "copy_mode",
163
+ "fixed_margin_usdc",
164
+ "proportional_multiplier",
165
+ "loss_policy",
166
+ "emergency_exit_pct",
167
+ ])
168
+ if (!(key in answers) && old[key] != null)
169
+ a[key] = old[key];
170
+ }
171
+ }
172
+ plan.preference_sources = collection.sources;
173
+ plan.allocations.forEach(validateCopySettings);
174
+ }
175
+ catch (error) {
176
+ const diagnostics = recommendationDiagnostics(pref, planningPool);
177
+ const sourceUnavailable = [
178
+ "source_data_unavailable",
179
+ "market_source_data_unavailable",
180
+ "stale_source_scores",
181
+ ].includes(diagnostics.blocker ?? "");
182
+ if (sourceReadFailed)
183
+ diagnostics.stage = "candidate_source_fetch";
184
+ const diagnosticCode = error instanceof AgentError
185
+ ? error.code
186
+ : "RECOMMENDATION_SYSTEM_ERROR";
187
+ if (diagnosticCode !== "NO_EXECUTABLE_PLAN") {
188
+ diagnostics.stage = "plan_validation";
189
+ diagnostics.blocker = diagnosticCode;
190
+ }
191
+ if (diagnosticCode === "ALLOCATION_REVIEW_REQUIRED") {
192
+ diagnostics.stage = "saved_allocation_validation";
193
+ diagnostics.rules.push({
194
+ trader_id: "plan",
195
+ rule: diagnosticCode,
196
+ field: "allocations/budget_usdc/leverage_cap",
197
+ current: s.plans.at(-1)?.allocations.map((a) => ({
198
+ trader_id: a.trader.id,
199
+ amount_usdc: a.amount_usdc,
200
+ leverage_cap: a.leverage_cap,
201
+ })),
202
+ required: {
203
+ budget_usdc: pref.budget_usdc,
204
+ limits: planningPool.map((c) => ({
205
+ trader_id: c.id,
206
+ minimum_usdc: cash(minimumAllocation(pref, c)),
207
+ leverage_cap: defaultLeverageMode(pref, c) === "follow_source"
208
+ ? null
209
+ : recommendedLeverage(pref, c),
210
+ })),
211
+ },
212
+ message: "Saved allocations no longer meet the updated amount or leverage limits. Edit only the conflicting amounts/caps through recalculate_plan; no risk or budget was increased.",
213
+ });
214
+ }
215
+ if (diagnosticCode === "SELECTED_TRADERS_UNAVAILABLE") {
216
+ diagnostics.stage = "selected_trader_lookup";
217
+ diagnostics.rules.push({
218
+ trader_id: "plan",
219
+ rule: diagnosticCode,
220
+ field: "trader_ids",
221
+ current: selectedIds?.filter((id) => !planningPool.some((c) => c.id === id)),
222
+ required: "Fresh verified source profile for every selected trader",
223
+ message: "Refresh the source or explicitly change the unavailable trader selection; no substitute was selected.",
224
+ });
225
+ }
226
+ console.warn(JSON.stringify({
227
+ event: "recommendation_failed",
228
+ diagnostic_id: diagnostics.diagnostic_id,
229
+ stage: diagnostics.stage,
230
+ error_code: diagnosticCode,
231
+ blocker: diagnostics.blocker,
232
+ requested_count: diagnostics.requested_basket_size,
233
+ }));
234
+ const message = sourceUnavailable
235
+ ? "Current trader source data is unavailable. Funding is not required; retry when source data is refreshed."
236
+ : `The requested ${diagnostics.requested_basket_size}-trader plan could not be generated: ${diagnostics.blocker ?? diagnosticCode}. ${diagnostics.feasible_basket_sizes.length ? "Feasible trader counts: " + diagnostics.feasible_basket_sizes.join(", ") + ". Review an alternative without changing risk settings." : "Review the per-trader rules and affected fields below."} Diagnostic: ${diagnostics.diagnostic_id}.`;
237
+ return {
238
+ ...result({
239
+ ...conversationView(null, collection, eligibleCandidates(pref, pool), !!selectedIds),
240
+ preferences: pref,
241
+ candidate_page: candidatePage,
242
+ plan: null,
243
+ saved_plan_id: s.plans.at(-1)?.id ?? null,
244
+ missing_selected_trader_ids: selectedIds?.filter((id) => !planningPool.some((c) => c.id === id)) ?? [],
245
+ account: { authenticated: true, user_id: user },
246
+ candidates: eligibleCandidates(pref, pool),
247
+ recommendation_diagnostics: diagnostics,
248
+ candidate_limits: eligibleCandidates(pref, pool).map((c) => ({
249
+ trader_id: c.id,
250
+ leverage_cap: defaultLeverageMode(pref, c) === "follow_source"
251
+ ? null
252
+ : recommendedLeverage(pref, c),
253
+ maximum_leverage_cap: maximumUserLeverage(c),
254
+ })),
255
+ candidates_meta: {
256
+ total: eligibleCandidates(pref, pool).length,
257
+ returned: eligibleCandidates(pref, pool).length,
258
+ has_more: false,
259
+ },
260
+ }, sourceUnavailable ? "stale" : "empty"),
261
+ error: {
262
+ code: diagnosticCode,
263
+ message,
264
+ retryable: sourceUnavailable ||
265
+ diagnosticCode === "RECOMMENDATION_SYSTEM_ERROR",
266
+ },
267
+ };
268
+ }
269
+ plan.allocations.forEach(validateCopySettings);
270
+ s.plans = s.plans
271
+ .filter((x) => x.created_at > now() - 90 * 86400_000)
272
+ .slice(-99);
273
+ s.plans.push(plan);
274
+ return result({
275
+ ...conversationView(plan, collection, eligibleCandidates(pref, pool), !!selectedIds),
276
+ plan,
277
+ preferences: pref,
278
+ candidate_page: candidatePage,
279
+ recommendation_diagnostics: recommendationDiagnostics(pref, planningPool),
280
+ market_coverage: {
281
+ requested: pref.market,
282
+ selected: [
283
+ ...new Set(plan.allocations.map((a) => a.trader.market)),
284
+ ],
285
+ meaning: "Selected markets are the allowed search universe; a plan may use only one market.",
286
+ },
287
+ candidates_meta: {
288
+ total: eligibleCandidates(pref, pool).length,
289
+ returned: eligibleCandidates(pref, pool).length,
290
+ has_more: false,
291
+ },
292
+ candidates: eligibleCandidates(pref, pool),
293
+ candidate_limits: eligibleCandidates(pref, pool).map((c) => ({
294
+ trader_id: c.id,
295
+ leverage_cap: defaultLeverageMode(pref, c) === "follow_source"
296
+ ? null
297
+ : recommendedLeverage(pref, c),
298
+ maximum_leverage_cap: maximumUserLeverage(c),
299
+ })),
300
+ portfolio_historical_drawdown_pct: null,
301
+ compatibility: compatibility(plan),
302
+ alternatives: alternatives(plan, eligibleCandidates(pref, pool)),
303
+ ...(args.batch_action
304
+ ? {
305
+ recommendation_batches: {
306
+ ...s.recommendation_batches,
307
+ preview_quota_applies: false,
308
+ displayed_candidates: displayedPool,
309
+ },
310
+ }
311
+ : {}),
312
+ reasons: plan.allocations.map((x) => ({
313
+ trader_id: x.trader.id,
314
+ reason: `Copy Score ${x.trader.score}; source drawdown ${x.trader.source_drawdown_pct}%; ${x.trader.assets.join("/")}; ${x.trader.style}; ${x.trader.direction}; allocation ${x.amount_usdc} USDC, ${x.leverage_cap == null ? "follow source leverage without an extra user cap" : `user leverage cap ${x.leverage_cap}x`}.`,
315
+ })),
316
+ }, "ok", [
317
+ "Historical rankings under your preferences are not investment advice; past performance does not guarantee future results.",
318
+ ]);
319
+ });
320
+ }
321
+ return this.repository.transact(user, async (s) => {
322
+ if (name === "get_deposit_address") {
323
+ if (!this.adapter.depositAddress)
324
+ throw new AgentError("DEPOSIT_UNAVAILABLE", "Deposit addresses are unavailable.", true, 503);
325
+ const ownedPortfolio = s.portfolios.find((p) => p.state !== "closed");
326
+ const deposit = await this.adapter.depositAddress(user, args, ownedPortfolio?.id);
327
+ (s.deposits ??= []).push(deposit);
328
+ s.deposits = s.deposits.slice(-50);
329
+ return result({ deposit: await depositView(deposit) });
330
+ }
331
+ if (name === "check_deposit_status") {
332
+ const deposit = s.deposits?.find((d) => d.id === args.deposit_id);
333
+ if (!deposit)
334
+ throw new AgentError("NOT_FOUND", "Deposit request not found for this account.", false, 404);
335
+ if (!this.adapter.depositStatus)
336
+ throw new AgentError("DEPOSIT_UNAVAILABLE", "Deposit tracking is unavailable.", true, 503);
337
+ const status = await this.adapter.depositStatus(deposit, user);
338
+ if (deposit.funding) {
339
+ if (status.funding_credit_verified)
340
+ deposit.funding.status = "credited";
341
+ else if (status.funding_receipt === "reverted")
342
+ deposit.funding.status = "failed";
343
+ status.funding_status = deposit.funding.status;
344
+ }
345
+ const savedPlan = s.plans.at(-1);
346
+ if (savedPlan && status.trading_balance_usdc != null) {
347
+ const required = savedPlan.allocations
348
+ .filter((a) => a.trader.market === deposit.market)
349
+ .reduce((total, a) => total.plus(a.amount_usdc), D(0));
350
+ status.trading_account_ready = status.available_to_copy === true;
351
+ status.required_for_saved_plan_usdc = cash(required);
352
+ status.saved_plan_funded =
353
+ status.trading_account_ready &&
354
+ D(status.trading_balance_usdc).gte(required);
355
+ status.available_to_copy = status.saved_plan_funded;
356
+ status.saved_plan_shortfall_usdc = cash(D.max(0, required.minus(status.trading_balance_usdc)));
357
+ if (status.trading_account_ready && !status.saved_plan_funded)
358
+ status.next_action = `Trading balance is verified, but the saved plan still needs ${status.saved_plan_shortfall_usdc} USDC. Fund the difference or explicitly choose a smaller plan, then refresh it.`;
359
+ }
360
+ return result({
361
+ deposit_status: status,
362
+ saved_plan_id: s.plans.at(-1)?.id ?? null,
363
+ });
364
+ }
365
+ if (name === "get_daily_checkin") {
366
+ const timezone = String(args.timezone ?? s.notification_preferences?.timezone ?? "UTC");
367
+ try {
368
+ new Intl.DateTimeFormat("en", { timeZone: timezone });
369
+ }
370
+ catch {
371
+ throw new AgentError("INVALID_TIMEZONE", "Use an IANA timezone.", false, 400);
372
+ }
373
+ const p = findPortfolio(s), day = localDay(now(), timezone).day, seen = (s.daily_checkin_days ??= {});
374
+ const offer = !!p && seen[timezone] !== day;
375
+ if (p)
376
+ seen[timezone] = day;
377
+ return result({
378
+ daily_checkin: {
379
+ offer_review: offer,
380
+ day,
381
+ timezone,
382
+ portfolio_id: p?.id ?? null,
383
+ performance: p && offer ? performanceView(s, p, "daily") : null,
384
+ },
385
+ });
386
+ }
387
+ if (name === "set_preferences") {
388
+ const collection = preferenceCollection(s, args);
389
+ savePreferenceCollection(s, collection);
390
+ if (!collection.ready) {
391
+ return result({
392
+ preference_collection: collection,
393
+ existing_portfolios_unchanged: true,
394
+ }, "requires_preferences");
395
+ }
396
+ return result({
397
+ preference_saved: true,
398
+ preferences: s.preferences,
399
+ preference_collection: preferenceCollection(s),
400
+ existing_portfolios_unchanged: true,
401
+ });
402
+ }
403
+ if (name === "recalculate_plan") {
404
+ const previous = s.plans.find((x) => x.id === args.plan_id);
405
+ if (!previous)
406
+ throw new AgentError("NOT_FOUND", "Saved plan not found for this account.", false, 404);
407
+ const previousPreferences = normalizedAssetPreferences(previous.preferences, previous.asset_filter_version ?? 1);
408
+ const editCollection = preferenceCollection({
409
+ ...s,
410
+ preferences: previousPreferences,
411
+ preference_sources: previous.preference_sources ?? s.preference_sources,
412
+ preference_draft: undefined,
413
+ }, (args.preferences ?? {}));
414
+ const pref = args.preferences
415
+ ? preferences.parse(editCollection.answers)
416
+ : previousPreferences;
417
+ const requested = (args.allocations ??
418
+ previous.allocations.map((x) => ({
419
+ trader_id: x.trader.id,
420
+ amount_usdc: x.amount_usdc,
421
+ leverage_cap: x.leverage_cap,
422
+ stop_loss_pct: x.stop_loss_pct,
423
+ })));
424
+ if (new Set(requested.map((x) => x.trader_id)).size !== requested.length)
425
+ throw new AgentError("DUPLICATE_TRADER", "Select each trader only once.", false, 400);
426
+ const [minCount, maxCount] = basketLimits(pref);
427
+ if (requested.length < minCount || requested.length > maxCount)
428
+ throw new AgentError("INVALID_BASKET", `Select ${minCount}–${maxCount} traders for this plan.`, false, 400);
429
+ const effectivePref = pref.portfolio_kind
430
+ ? { ...pref, basket_size: requested.length }
431
+ : pref;
432
+ const pool = await Promise.all(requested.map((x) => this.adapter.profile(x.trader_id)));
433
+ const plan = recommend(effectivePref, pool);
434
+ plan.preference_sources = args.preferences
435
+ ? editCollection.sources
436
+ : previous.preference_sources;
437
+ if (plan.allocations.length !== requested.length)
438
+ throw new AgentError("PLAN_CHANGED", "A selected trader is no longer eligible for these preferences.");
439
+ const total = requested.reduce((n, x) => n.plus(x.amount_usdc), D(0));
440
+ if (total.gt(pref.budget_usdc))
441
+ throw new AgentError("BUDGET_EXCEEDED", "Allocations exceed the requested budget.");
442
+ plan.allocations = requested.map((x, i) => {
443
+ const prior = previous.allocations.find((a) => a.trader.id === x.trader_id);
444
+ const settings = {
445
+ ...prior,
446
+ ...(args.preferences ?? {}),
447
+ ...x,
448
+ };
449
+ const modeChanged = (x.copy_mode ??
450
+ args.preferences
451
+ ?.copy_mode) != null &&
452
+ settings.copy_mode !==
453
+ (prior?.copy_mode ?? previous.preferences.copy_mode);
454
+ const capEdited = args.allocations != null && x.leverage_cap !== undefined;
455
+ const leverageMode = pool[i].market === "prediction"
456
+ ? "cap"
457
+ : capEdited && x.leverage_cap === null
458
+ ? "follow_source"
459
+ : capEdited
460
+ ? "custom_cap"
461
+ : prior && !modeChanged
462
+ ? (prior.leverage_mode ?? "cap")
463
+ : defaultLeverageMode({
464
+ ...pref,
465
+ copy_mode: settings.copy_mode ?? pref.copy_mode,
466
+ }, pool[i]);
467
+ const cap = leverageMode === "custom_cap"
468
+ ? maximumUserLeverage(pool[i])
469
+ : recommendedLeverage(pref, pool[i], leverageMode);
470
+ const leverage = x.leverage_cap !== undefined
471
+ ? x.leverage_cap
472
+ : prior
473
+ ? prior.leverage_cap
474
+ : leverageMode === "follow_source"
475
+ ? null
476
+ : cap;
477
+ if (cap == null ||
478
+ (leverage == null
479
+ ? leverageMode !== "follow_source"
480
+ : leverage > cap) ||
481
+ (pool[i].market === "prediction" && leverage !== 1))
482
+ throw new AgentError("INVALID_LEVERAGE", "Perps leverage caps must use the App-supported 1–40x range; Prediction requires 1x.");
483
+ if (D(x.amount_usdc).lt(minimumAllocation(pref, pool[i])) ||
484
+ (pref.portfolio_kind !== "single" &&
485
+ D(x.amount_usdc).gt(total.times("0.55"))))
486
+ throw new AgentError("INVALID_ALLOCATION", "Each allocation must meet the minimum and stay at or below 55% of selected capital.");
487
+ return {
488
+ ...plan.allocations.find((a) => a.trader.id === x.trader_id),
489
+ amount_usdc: cash(x.amount_usdc),
490
+ leverage_cap: leverage,
491
+ leverage_mode: leverageMode === "cap" ? undefined : leverageMode,
492
+ stop_loss_pct: x.stop_loss_pct ?? prior?.stop_loss_pct ?? 30,
493
+ copy_mode: settings.copy_mode ?? pref.copy_mode,
494
+ fixed_margin_usdc: settings.fixed_margin_usdc == null
495
+ ? undefined
496
+ : cash(settings.fixed_margin_usdc),
497
+ proportional_multiplier: settings.proportional_multiplier ?? pref.proportional_multiplier,
498
+ loss_policy: settings.loss_policy ?? pref.loss_policy,
499
+ emergency_exit_pct: settings.emergency_exit_pct ?? pref.emergency_exit_pct,
500
+ };
501
+ });
502
+ s.plans = s.plans
503
+ .filter((x) => x.created_at > now() - 90 * 86400_000)
504
+ .slice(-99);
505
+ plan.allocations.forEach(validateCopySettings);
506
+ s.plans.push(plan);
507
+ if (args.preferences)
508
+ savePreferenceCollection(s, preferenceCollection(s, args.preferences));
509
+ if (args.allocations && pref.portfolio_kind)
510
+ s.selected_trader_ids = requested.map((x) => x.trader_id);
511
+ const sourcePool = await this.adapter.candidates(pref.market);
512
+ const candidates = eligibleCandidates(pref, sourcePool);
513
+ const savedPreferences = completePreferences(s);
514
+ saveDiscovery(s, savedPreferences, sourcePool);
515
+ return result({
516
+ ...conversationView(plan, editCollection, candidates, !!s.selected_trader_ids),
517
+ plan,
518
+ candidates,
519
+ candidate_limits: candidates.map((c) => ({
520
+ trader_id: c.id,
521
+ leverage_cap: defaultLeverageMode(pref, c) === "follow_source"
522
+ ? null
523
+ : recommendedLeverage(pref, c),
524
+ maximum_leverage_cap: maximumUserLeverage(c),
525
+ })),
526
+ compatibility: compatibility(plan),
527
+ alternatives: alternatives(plan, candidates),
528
+ recommendation_batches: s.recommendation_batches
529
+ ? {
530
+ ...s.recommendation_batches,
531
+ preview_quota_applies: false,
532
+ displayed_candidates: candidates.filter((c) => s.recommendation_batches.batches[s.recommendation_batches.active_index].includes(c.id)),
533
+ }
534
+ : undefined,
535
+ candidate_page: s.discovery.expires_at > now()
536
+ ? browse(s, savedPreferences, { limit: 5 }, user, this.cursor)
537
+ .candidates_meta
538
+ : null,
539
+ previous_plan_id: previous.id,
540
+ requires_new_confirmation: true,
541
+ unallocated_usdc: cash(D(pref.budget_usdc).minus(total)),
542
+ portfolio_historical_drawdown_pct: null,
543
+ }, "ok", [
544
+ "Capital weights are not live exposure. Source entry and actual gross concentration are checked before execution.",
545
+ "Loss triggers initiate exits; slippage can exceed the threshold.",
546
+ ]);
547
+ }
548
+ if (name === "get_performance") {
549
+ const p = findPortfolio(s, args.portfolio_id);
550
+ if (!p)
551
+ return result({ performance: null }, "empty");
552
+ const performance = performanceView(s, p, args.period);
553
+ return result({ performance }, performance.coverage === "complete" ? "ok" : "stale");
554
+ }
555
+ if (name === "diagnose_copy") {
556
+ const p = findByOwnership(s, args);
557
+ if (!p)
558
+ throw new AgentError("NOT_FOUND", "Owned copy not found.", false, 404);
559
+ const sleeve = p.sleeves.find((x) => x.copy_id === args.copy_id);
560
+ const start = args.start_at, end = args.end_at;
561
+ if (end > now())
562
+ throw new AgentError("INVALID_WINDOW", "The diagnostic window cannot extend into the future.", false, 400);
563
+ let source = {
564
+ fills: [],
565
+ complete: false,
566
+ reason: "Source fill history unavailable.",
567
+ };
568
+ try {
569
+ source =
570
+ (await this.adapter.sourceFills?.(sleeve.trader, start, end)) ??
571
+ source;
572
+ }
573
+ catch {
574
+ /* Explicit missing evidence below. */
575
+ }
576
+ const receipts = (p.receipts ?? []).filter((x) => x.copy_id === sleeve.copy_id &&
577
+ x.fill.at >= start &&
578
+ x.fill.at < end);
579
+ const decisions = (p.decisions ?? []).filter((x) => x.copy_id === sleeve.copy_id && x.at >= start && x.at < end);
580
+ const began = p.audit_started_at ?? Infinity;
581
+ const followerComplete = start >= began && (p.audit_verified_at ?? 0) >= end;
582
+ return result({
583
+ diagnostic: {
584
+ copy_id: sleeve.copy_id,
585
+ trader: sleeve.trader.handle,
586
+ window: { start_at: start, end_at: end },
587
+ source_fills: source.fills,
588
+ source_coverage: source.complete ? "complete" : "incomplete",
589
+ follower_verified_through: p.audit_verified_at ?? null,
590
+ source_missing_reason: source.reason ?? null,
591
+ follower_coverage: followerComplete ? "complete" : "incomplete",
592
+ follower_fills: receipts,
593
+ decisions,
594
+ current_configuration: {
595
+ amount_usdc: sleeve.amount_usdc,
596
+ leverage_cap: sleeve.leverage_cap,
597
+ ...(sleeve.leverage_mode
598
+ ? { leverage_mode: sleeve.leverage_mode }
599
+ : {}),
600
+ stop_loss_pct: sleeve.stop_loss_pct,
601
+ },
602
+ funding: (p.funding_receipts ?? []).filter((x) => x.copy_id === sleeve.copy_id && x.at >= start && x.at < end),
603
+ executions: p.executions
604
+ .filter((e) => e.created_at >= start &&
605
+ e.created_at < end &&
606
+ (sleeve.copy_id in e.allocations ||
607
+ sleeve.copy_id in (e.unfilled_allocations ?? {})))
608
+ .map(({ allocations, unfilled_allocations, fingerprint, ...e }) => e),
609
+ events: s.updates
610
+ .filter((e) => e.portfolio_id === p.id &&
611
+ e.at >= start &&
612
+ e.at < end &&
613
+ (!e.data.copy_id || e.data.copy_id === sleeve.copy_id))
614
+ .map(({ notification, ...e }) => e),
615
+ fees_usdc: receipts.length
616
+ ? cash(receipts.reduce((n, x) => n.plus(x.fee_usdc), D(0)))
617
+ : followerComplete
618
+ ? "0.000000"
619
+ : null,
620
+ profit_difference_usdc: null,
621
+ missing_reason: "Source and follower fills are separate evidence. No counterfactual profit or fill pairing is inferred; missing historical evidence remains unavailable.",
622
+ },
623
+ }, source.complete && followerComplete ? "ok" : "stale");
624
+ }
625
+ if (name === "get_notification_preferences" ||
626
+ name === "set_notification_preferences") {
627
+ if (name === "set_notification_preferences")
628
+ s.notification_preferences = {
629
+ ...args,
630
+ enabled_at: now(),
631
+ last_digest_day: s.notification_preferences?.last_digest_day,
632
+ };
633
+ let delivery = { configured: false, device_registered: false };
634
+ try {
635
+ delivery =
636
+ (await this.adapter.notificationStatus?.(user)) ?? delivery;
637
+ }
638
+ catch {
639
+ /* Report unavailable without claiming delivery. */
640
+ }
641
+ return result({
642
+ notification_preferences: s.notification_preferences ?? {
643
+ daily_digest: false,
644
+ local_time: "09:00",
645
+ timezone: "UTC",
646
+ },
647
+ delivery: {
648
+ ...delivery,
649
+ channel: "Ride App",
650
+ status: delivery.configured && delivery.device_registered
651
+ ? "ready"
652
+ : "unavailable",
653
+ },
654
+ monitoring: "server",
655
+ critical_risk_alerts: "enabled",
656
+ });
657
+ }
658
+ if (name === "get_updates") {
659
+ const c = args.cursor
660
+ ? this.cursor.decode(user, args.cursor)
661
+ : { at: 0, id: "" };
662
+ const events = s.updates
663
+ .filter((x) => x.at > c.at || (x.at === c.at && x.id > c.id))
664
+ .sort((a, b) => a.at - b.at || a.id.localeCompare(b.id));
665
+ const page = events.slice(0, args.limit);
666
+ const last = page.at(-1);
667
+ return result({
668
+ updates: page.map(({ notification, ...event }) => event),
669
+ next_cursor: last
670
+ ? this.cursor.encode(user, last.at, last.id)
671
+ : (args.cursor ?? null),
672
+ has_more: events.length > page.length,
673
+ }, page.length ? "ok" : "empty");
674
+ }
675
+ if (name === "get_portfolio") {
676
+ const p = findPortfolio(s, args.portfolio_id);
677
+ if (!p) {
678
+ if (args.portfolio_id)
679
+ return result({ portfolio: null }, "empty");
680
+ const plan = s.plans.at(-1);
681
+ return plan
682
+ ? result({
683
+ portfolio: null,
684
+ plan,
685
+ compatibility: compatibility(plan),
686
+ recommendation_batches: s.recommendation_batches,
687
+ saved_preview: true,
688
+ plan_status: plan.expires_at > now() ? "ready" : "expired",
689
+ candidates: plan.allocations.map((a) => a.trader),
690
+ unallocated_usdc: cash(D(plan.preferences.budget_usdc).minus(plan.allocations.reduce((n, a) => n.plus(a.amount_usdc), D(0)))),
691
+ }, "empty")
692
+ : result({
693
+ portfolio: null,
694
+ preference_collection: preferenceCollection(s),
695
+ }, "empty");
696
+ }
697
+ try {
698
+ p.snapshot = await this.adapter.account(user, p);
699
+ }
700
+ catch {
701
+ /* Return explicitly stale stored snapshot; runner owns settlement. */
702
+ }
703
+ return portfolioView(p);
704
+ }
705
+ if (name === "review_portfolio") {
706
+ const p = findPortfolio(s, args.portfolio_id);
707
+ if (!p)
708
+ return result({ portfolio: null, observations: [], suggestions: [] }, "empty");
709
+ return result({
710
+ portfolio_id: p.id,
711
+ observations: review(p),
712
+ suggestions: review(p).map((x) => ({
713
+ observation: x,
714
+ requires_confirmation: true,
715
+ })),
716
+ as_of: p.snapshot?.observed_at ?? null,
717
+ });
718
+ }
719
+ return this.propose(user, s, name, args);
720
+ });
721
+ }
722
+ async propose(user, s, name, args) {
723
+ let p, actionAmount = "0";
724
+ if (name === "start_copy") {
725
+ const plan = s.plans.find((x) => x.id === args.plan_id);
726
+ if (!plan)
727
+ throw new AgentError("NOT_FOUND", "Plan not found for this account.", false, 404);
728
+ if (plan.expires_at <= now())
729
+ throw new AgentError("PLAN_EXPIRED", "Refresh trader recommendations.");
730
+ await this.validatePlan(plan);
731
+ if (plan.preferences.portfolio_kind &&
732
+ compatibility(plan).warnings &&
733
+ args.compatibility_ack !== compatibility(plan).fingerprint)
734
+ throw new AgentError("COMPATIBILITY_REVIEW_REQUIRED", "Review the overlap warnings and acknowledge this exact plan before starting.");
735
+ if (s.portfolios.some((x) => x.state !== "closed"))
736
+ throw new AgentError("OWNERSHIP_CONFLICT", "An existing Agent portfolio must finish closing before starting another.");
737
+ p = fromPlan(plan);
738
+ actionAmount = cash(plan.preferences.budget_usdc);
739
+ }
740
+ else {
741
+ const owned = findByOwnership(s, args);
742
+ if (!owned)
743
+ throw new AgentError("NOT_FOUND", "Owned copy or position not found.", false, 404);
744
+ p = structuredClone(owned);
745
+ if (args.replacement_trader_id) {
746
+ args = {
747
+ ...args,
748
+ replacement_copy_id: uid("copy"),
749
+ replacement_at: now(),
750
+ };
751
+ }
752
+ await this.mutatePreview(p, args, name);
753
+ const copy = owned.sleeves.find((x) => x.copy_id === args.copy_id);
754
+ actionAmount =
755
+ name === "update_copy"
756
+ ? cash(D(args.amount_usdc ?? copy?.amount_usdc ?? 0)
757
+ .minus(copy?.amount_usdc ?? 0)
758
+ .abs())
759
+ : "0.000000";
760
+ }
761
+ const snapshot = await this.adapter.preflight(user, p);
762
+ if (!fresh(snapshot.observed_at) ||
763
+ !snapshot.eligible ||
764
+ !snapshot.dedicated)
765
+ throw new AgentError("ACCOUNT_NOT_READY", "A fresh, eligible dedicated trading account is required.");
766
+ if (D(actionAmount).gt(snapshot.available_usdc))
767
+ throw new AgentError("INSUFFICIENT_FUNDS", "Verified available funds do not cover this change.");
768
+ for (const market of ["perps", "prediction"]) {
769
+ const allocation = p.sleeves
770
+ .filter((s) => s.trader.market === market && s.state !== "closed")
771
+ .reduce((n, s) => n.plus(s.amount_usdc), D(0));
772
+ if (snapshot.available_by_market?.[market] != null &&
773
+ name === "start_copy" &&
774
+ allocation.gt(snapshot.available_by_market[market]))
775
+ throw new AgentError("INSUFFICIENT_FUNDS", "Each market requires its own verified available funds.");
776
+ }
777
+ if (name === "start_copy")
778
+ this.assertScopedFunding(p, snapshot);
779
+ p.snapshot = snapshot;
780
+ const proposedTargets = name === "start_copy" ? [] : risk(p, snapshot.prices, true);
781
+ if (name !== "start_copy") {
782
+ const actual = aggregate(p, snapshot.prices, "actual");
783
+ const additional = proposedTargets.reduce((n, a) => n.plus(D.max(0, D(a.net_notional_usdc)
784
+ .abs()
785
+ .minus(D(actual.find((x) => x.key === a.key)?.net_notional_usdc ?? 0).abs())).div(Math.min(...p.sleeves
786
+ .filter((s) => s.intents[a.key])
787
+ .map((s) => Math.min(s.leverage_cap ?? Number.POSITIVE_INFINITY, s.intents[a.key].source_leverage)), p.sleeves.some((s) => s.leverage_mode != null)
788
+ ? (snapshot.max_leverages[a.key] ?? 1)
789
+ : 8))), D(0));
790
+ actionAmount = cash(D.max(actionAmount, additional.times("1.003")));
791
+ if (D(actionAmount).gt(snapshot.available_usdc))
792
+ throw new AgentError("INSUFFICIENT_FUNDS", "Removing this hedge requires additional verified margin.");
793
+ }
794
+ const preview = {
795
+ portfolio: portfolioView(p).data,
796
+ proposed_positions: proposedTargets,
797
+ action: name,
798
+ parameters: args,
799
+ loss_trigger_amount_usdc: cash(D(p.preferences.budget_usdc)
800
+ .times(p.preferences.loss_trigger_pct)
801
+ .div(100)),
802
+ existing_entry_max_deviation_pct: 0.2,
803
+ fees: { estimate_usdc: null, exact_fee_at_settlement: true },
804
+ execution_state: "not_submitted",
805
+ };
806
+ const existing = s.proposals.find((x) => x.status === "requires_confirmation" &&
807
+ x.expires_at > now() &&
808
+ x.action === name &&
809
+ fingerprint(publicArguments(x.arguments)) ===
810
+ fingerprint(publicArguments(args)) &&
811
+ x.expected_revision === p.revision);
812
+ const proposal = existing ?? {
813
+ id: uid("proposal"),
814
+ action: name,
815
+ arguments: args,
816
+ portfolio_id: p.id,
817
+ expected_revision: p.revision,
818
+ created_at: now(),
819
+ expires_at: now() + 5 * 60_000,
820
+ status: "requires_confirmation",
821
+ snapshot_hash: fingerprint({
822
+ account_id: snapshot.account_id,
823
+ positions: snapshot.positions,
824
+ }),
825
+ amount_usdc: actionAmount,
826
+ warnings: [
827
+ "Loss thresholds trigger best-effort exits; losses can exceed the threshold.",
828
+ "Activation is not proof of a fill.",
829
+ ],
830
+ preview,
831
+ };
832
+ if (!existing)
833
+ s.proposals.push(proposal);
834
+ const d = s.delegation;
835
+ if (d && this.delegationAllows(d, p, proposal)) {
836
+ await this.commit(user, s, proposal);
837
+ return this.proposalView(proposal, s);
838
+ }
839
+ return this.proposalView(proposal, s);
840
+ }
841
+ async proposal(user, id) {
842
+ return this.repository.transact(user, async (s) => {
843
+ const p = s.proposals.find((x) => x.id === id);
844
+ if (!p)
845
+ throw new AgentError("NOT_FOUND", "Proposal not found.", false, 404);
846
+ return this.proposalView(p, s);
847
+ });
848
+ }
849
+ async update(user, eventId) {
850
+ return this.repository.transact(user, async (s) => {
851
+ const update = s.updates.find((x) => x.id === eventId);
852
+ if (!update)
853
+ throw new AgentError("NOT_FOUND", "Report not found for this Ride account.", false, 404);
854
+ const { notification, notification_attempts, next_notification_at, notification_error, ...record } = update;
855
+ return result({ update: record });
856
+ });
857
+ }
858
+ async confirm(user, id, hash) {
859
+ return this.repository.transact(user, async (s) => {
860
+ const p = s.proposals.find((x) => x.id === id);
861
+ if (!p)
862
+ throw new AgentError("NOT_FOUND", "Proposal not found.", false, 404);
863
+ if (hash !== fingerprint(p.preview))
864
+ throw new AgentError("PROPOSAL_CHANGED", "The displayed proposal does not match the frozen confirmation.");
865
+ await this.commit(user, s, p);
866
+ return this.proposalView(p, s);
867
+ });
868
+ }
869
+ async reject(user, id) {
870
+ return this.repository.transact(user, async (s) => {
871
+ const p = s.proposals.find((x) => x.id === id);
872
+ if (!p)
873
+ throw new AgentError("NOT_FOUND", "Proposal not found.", false, 404);
874
+ if (p.status === "requires_confirmation")
875
+ p.status = "rejected";
876
+ return this.proposalView(p, s);
877
+ });
878
+ }
879
+ async authorize(user, input) {
880
+ return this.repository.transact(user, async (s) => {
881
+ const p = findPortfolio(s, input.portfolio_id);
882
+ if (!p)
883
+ throw new AgentError("NOT_FOUND", "Portfolio not found.", false, 404);
884
+ if (input.expires_at <= now() ||
885
+ input.expires_at > now() + 30 * 86400_000 ||
886
+ D(input.per_action_usdc).gt(input.daily_usdc) ||
887
+ D(input.daily_usdc).gt(input.total_usdc) ||
888
+ D(input.total_usdc).gt(p.preferences.budget_usdc) ||
889
+ input.leverage_cap < 1 ||
890
+ input.leverage_cap > 40)
891
+ throw new AgentError("INVALID_AUTHORITY", "Authorization limits exceed the portfolio or expiry constraints.", false, 400);
892
+ s.delegation = {
893
+ ...input,
894
+ id: uid("grant"),
895
+ used_usdc: "0",
896
+ daily_used_usdc: "0",
897
+ day: new Date().toISOString().slice(0, 10),
898
+ revoked: false,
899
+ };
900
+ return result({ authorization: s.delegation });
901
+ });
902
+ }
903
+ async revoke(user) {
904
+ return this.repository.transact(user, async (s) => {
905
+ if (s.delegation)
906
+ s.delegation.revoked = true;
907
+ return result({ revoked: true });
908
+ });
909
+ }
910
+ delegationAllows(d, p, q) {
911
+ const day = new Date().toISOString().slice(0, 10);
912
+ if (d.day !== day) {
913
+ d.day = day;
914
+ d.daily_used_usdc = "0";
915
+ }
916
+ return (!d.revoked &&
917
+ d.expires_at > now() &&
918
+ d.portfolio_id === p.id &&
919
+ p.sleeves
920
+ .filter((s) => s.state !== "closed")
921
+ .every((s) => d.markets.includes(s.trader.market) &&
922
+ s.leverage_cap != null &&
923
+ s.leverage_cap <= d.leverage_cap &&
924
+ s.trader.assets.every((a) => d.assets.includes(a))) &&
925
+ D(q.amount_usdc).lte(d.per_action_usdc) &&
926
+ D(d.used_usdc).plus(q.amount_usdc).lte(d.total_usdc) &&
927
+ D(d.daily_used_usdc).plus(q.amount_usdc).lte(d.daily_usdc));
928
+ }
929
+ async commit(user, s, q) {
930
+ if (q.status === "queued" || q.status === "completed")
931
+ return;
932
+ if (q.status !== "requires_confirmation" || q.expires_at <= now())
933
+ throw new AgentError("PROPOSAL_EXPIRED", "Create a fresh proposal.");
934
+ let p = s.portfolios.find((x) => x.id === q.portfolio_id);
935
+ if (q.action === "start_copy") {
936
+ const plan = s.plans.find((x) => x.id === q.arguments.plan_id);
937
+ if (!plan ||
938
+ plan.expires_at <= now() ||
939
+ s.portfolios.some((x) => x.state !== "closed"))
940
+ throw new AgentError("PLAN_CHANGED", "Plan expired or account ownership changed.");
941
+ p = fromPlan(plan, q.portfolio_id);
942
+ await this.validatePlan(plan);
943
+ }
944
+ if (!p || p.revision !== q.expected_revision)
945
+ throw new AgentError("REVISION_CHANGED", "Portfolio changed; create a new proposal.");
946
+ const snapshot = await this.adapter.preflight(user, p);
947
+ if (!fresh(snapshot.observed_at) ||
948
+ !snapshot.eligible ||
949
+ !snapshot.dedicated ||
950
+ q.snapshot_hash !==
951
+ fingerprint({
952
+ account_id: snapshot.account_id,
953
+ positions: snapshot.positions,
954
+ }))
955
+ throw new AgentError("ACCOUNT_CHANGED", "Account state changed; review a fresh proposal.");
956
+ if (q.action === "start_copy")
957
+ for (const market of ["perps", "prediction"]) {
958
+ const allocation = p.sleeves
959
+ .filter((s) => s.trader.market === market)
960
+ .reduce((n, s) => n.plus(s.amount_usdc), D(0));
961
+ if (snapshot.available_by_market?.[market] != null &&
962
+ allocation.gt(snapshot.available_by_market[market]))
963
+ throw new AgentError("INSUFFICIENT_FUNDS", "Market available funds changed; review a fresh proposal.");
964
+ }
965
+ if (q.action === "start_copy")
966
+ this.assertScopedFunding(p, snapshot);
967
+ const pending = p.executions
968
+ .filter((e) => !["completed", "failed", "superseded"].includes(e.status))
969
+ .reduce((n, e) => n.plus(e.reserve_usdc), D(0));
970
+ if (D(q.amount_usdc).plus(pending).gt(snapshot.available_usdc))
971
+ throw new AgentError("INSUFFICIENT_FUNDS", "Available funds changed or are reserved by pending execution.");
972
+ if (q.action !== "start_copy")
973
+ await this.mutatePreview(p, q.arguments, q.action);
974
+ p.snapshot = snapshot;
975
+ p.revision++;
976
+ p.control_id = q.id;
977
+ recordConfigurations(p);
978
+ q.committed_revision = p.revision;
979
+ // Before changing target policy, reconcile Core's old tasks. The adapter
980
+ // fences old revision work; no stale increasing order may survive a stop.
981
+ if (p.account_id)
982
+ await this.adapter.fence(user, p);
983
+ if (!s.portfolios.some((x) => x.id === p.id))
984
+ s.portfolios.push(p);
985
+ q.status = "queued";
986
+ if (s.delegation && this.delegationAllows(s.delegation, p, q)) {
987
+ s.delegation.used_usdc = cash(D(s.delegation.used_usdc).plus(q.amount_usdc));
988
+ s.delegation.daily_used_usdc = cash(D(s.delegation.daily_used_usdc).plus(q.amount_usdc));
989
+ }
990
+ event(s, "proposal_confirmed", p.id, "Confirmed parameters queued for execution.", { proposal_id: q.id });
991
+ }
992
+ assertScopedFunding(p, snapshot) {
993
+ if (p.sleeves.some((s) => s.trader.asset_category === "rwa" ||
994
+ Object.values(s.trader.asset_categories ?? {}).includes("rwa"))) {
995
+ const required = new Map();
996
+ for (const sleeve of p.sleeves) {
997
+ const scopes = [
998
+ ...new Set(sleeve.trader.assets
999
+ .filter((a) => (sleeve.trader.asset_categories?.[a] ??
1000
+ sleeve.trader.asset_category) === "rwa")
1001
+ .map((a) => (a.includes(":") ? a.split(":")[0] : "core"))),
1002
+ ];
1003
+ for (const scope of scopes)
1004
+ required.set(scope, (required.get(scope) ?? D(0)).plus(sleeve.amount_usdc));
1005
+ }
1006
+ for (const [scope, amount] of required)
1007
+ if (amount.gt(snapshot.available_by_scope?.[scope] ?? 0))
1008
+ throw new AgentError("SCOPED_COLLATERAL_REQUIRED", "RWA allocation needs verified available USDC in each selected DEX. Fund its trading scope in Ride before confirmation.");
1009
+ }
1010
+ }
1011
+ async validatePlan(plan) {
1012
+ const rows = await Promise.all(plan.allocations.map((a) => this.adapter.profile(a.trader.id)));
1013
+ const allowed = eligibleCandidates(plan.preferences, rows, plan.asset_filter_version ?? 1);
1014
+ if (allowed.length !== plan.allocations.length ||
1015
+ plan.allocations.some((a, i) => (a.leverage_cap == null
1016
+ ? a.leverage_mode !== "follow_source"
1017
+ : a.leverage_cap >
1018
+ ((a.leverage_mode === "custom_cap"
1019
+ ? maximumUserLeverage(rows[i])
1020
+ : recommendedLeverage(plan.preferences, rows[i], a.leverage_mode ?? "cap")) ?? 0)) ||
1021
+ D(a.amount_usdc).lt(plan.preferences.portfolio_kind && rows[i].market === "perps"
1022
+ ? minimumAllocation(plan.preferences, rows[i])
1023
+ : rows[i].min_notional_usdc)))
1024
+ throw new AgentError("PLAN_CHANGED", "Trader eligibility or execution limits changed; recalculate and review a new plan.");
1025
+ }
1026
+ async mutatePreview(p, args, name) {
1027
+ if (name === "update_portfolio") {
1028
+ if (p.loss_latched)
1029
+ throw new AgentError("LOSS_LATCHED", "A triggered portfolio stop must finish exiting; changing the threshold cannot restart it.");
1030
+ p.preferences = {
1031
+ ...p.preferences,
1032
+ loss_trigger_pct: args.loss_trigger_pct,
1033
+ };
1034
+ return;
1035
+ }
1036
+ let s = p.sleeves.find((x) => x.copy_id === args.copy_id);
1037
+ if (name === "close_position") {
1038
+ const positions = aggregate(p, p.snapshot?.prices ?? {}, "actual");
1039
+ const a = positions.find((x) => x.position_id === args.position_id);
1040
+ if (!a)
1041
+ throw new AgentError("NOT_FOUND", "Owned aggregate position not found.", false, 404);
1042
+ for (const sleeve of p.sleeves) {
1043
+ if (sleeve.intents[a.key])
1044
+ sleeve.intents[a.key].quantity = "0";
1045
+ if (!sleeve.suppressed.includes(a.key))
1046
+ sleeve.suppressed.push(a.key);
1047
+ }
1048
+ risk(p, p.snapshot.prices, true);
1049
+ return;
1050
+ }
1051
+ if (!s)
1052
+ throw new AgentError("NOT_FOUND", "Copy not found.", false, 404);
1053
+ if (name === "stop_copy") {
1054
+ s.state = args.mode === "close_now" ? "closing" : "wind_down";
1055
+ if (args.mode === "close_now")
1056
+ for (const x of Object.values(s.intents))
1057
+ x.quantity = "0";
1058
+ }
1059
+ if (name === "update_copy") {
1060
+ if (args.replacement_trader_id) {
1061
+ if (s.state !== "closed" ||
1062
+ Object.values(s.lots).some((x) => !D(x.quantity).isZero()) ||
1063
+ p.reconciliation !== "complete" ||
1064
+ p.executions.some((e) => !["completed", "failed", "superseded"].includes(e.status)))
1065
+ throw new AgentError("FUNDS_NOT_RELEASED", "Wind down and reconcile the old copy before replacing its allocation.");
1066
+ if (p.sleeves.some((x) => x.replaced_at != null && now() - x.replaced_at < 7 * 86400_000))
1067
+ throw new AgentError("REPLACEMENT_LIMIT", "One replacement is allowed per seven days.");
1068
+ const trader = await this.adapter.profile(args.replacement_trader_id);
1069
+ const replacementCap = s.leverage_mode === "custom_cap"
1070
+ ? maximumUserLeverage(trader)
1071
+ : recommendedLeverage(p.preferences, trader, s.leverage_mode ?? "cap");
1072
+ if (!eligibleCandidates(p.preferences, [trader], p.asset_filter_version ?? 1).length ||
1073
+ replacementCap == null)
1074
+ throw new AgentError("TRADER_INELIGIBLE", "Replacement needs fresh, eligible scores and verified risk limits.");
1075
+ if (trader.eligibility !== "PASS" ||
1076
+ trader.expires_at <= now() ||
1077
+ (p.preferences.market !== "both" &&
1078
+ trader.market !== p.preferences.market) ||
1079
+ (trader.market === "perps" &&
1080
+ !trader.assets.every((a) => assetAllowed(p.preferences, a, trader.asset_categories?.[a] ?? trader.asset_category, p.asset_filter_version ?? 1))))
1081
+ throw new AgentError("TRADER_INELIGIBLE", "Replacement is not eligible for this portfolio.");
1082
+ const replacement = {
1083
+ ...s,
1084
+ leverage_cap: s.leverage_cap == null
1085
+ ? null
1086
+ : Math.min(s.leverage_cap, replacementCap),
1087
+ copy_id: String(args.replacement_copy_id ?? uid("copy")),
1088
+ trader,
1089
+ state: "active",
1090
+ lots: {},
1091
+ intents: {},
1092
+ initialized: false,
1093
+ suppressed: [],
1094
+ source_revision: null,
1095
+ replaced_at: Number(args.replacement_at ?? now()),
1096
+ };
1097
+ p.sleeves.push(replacement);
1098
+ s.replaced_at = replacement.replaced_at;
1099
+ s = replacement;
1100
+ }
1101
+ if (args.amount_usdc !== undefined) {
1102
+ const next = D(args.amount_usdc), ratio = next.div(s.amount_usdc);
1103
+ const total = p.sleeves
1104
+ .filter((x) => x.state !== "closed")
1105
+ .reduce((n, x) => n.plus(x.copy_id === s.copy_id ? next : x.amount_usdc), D(0));
1106
+ if (total.gt(p.preferences.budget_usdc))
1107
+ throw new AgentError("BUDGET_EXCEEDED", "Sleeve allocations exceed the approved budget.");
1108
+ for (const x of Object.values(s.intents))
1109
+ x.quantity = D(x.quantity)
1110
+ .times(ratio)
1111
+ .toDecimalPlaces(p.snapshot?.quantity_decimals?.[x.key] ?? 8)
1112
+ .toString();
1113
+ s.amount_usdc = cash(next);
1114
+ s.source_revision = null;
1115
+ }
1116
+ if (args.leverage_cap !== undefined) {
1117
+ if (s.trader.market === "prediction" && args.leverage_cap !== 1)
1118
+ throw new AgentError("INVALID_LEVERAGE", "Prediction positions require leverage 1.");
1119
+ const current = await this.adapter.profile(s.trader.id);
1120
+ const allowed = maximumUserLeverage(current);
1121
+ if (args.leverage_cap > (allowed ?? 0))
1122
+ throw new AgentError("INVALID_LEVERAGE", "Perps leverage caps must use the App-supported 1–40x range; Prediction requires 1x.");
1123
+ s.leverage_cap = args.leverage_cap;
1124
+ if (s.trader.market === "perps")
1125
+ s.leverage_mode =
1126
+ s.leverage_cap == null ? "follow_source" : "custom_cap";
1127
+ for (const x of Object.values(s.intents)) {
1128
+ const leverage = Math.min(s.leverage_cap ?? Number.POSITIVE_INFINITY, x.source_leverage);
1129
+ x.quantity = D(x.quantity)
1130
+ .times(leverage)
1131
+ .div(x.source_leverage)
1132
+ .toDecimalPlaces(p.snapshot?.quantity_decimals?.[x.key] ?? 8)
1133
+ .toString();
1134
+ x.source_leverage = leverage;
1135
+ }
1136
+ // Re-evaluate this source even if it has not traded since the user
1137
+ // changed the allocation or cap. Parameters must affect actual targets.
1138
+ s.source_revision = null;
1139
+ }
1140
+ if (args.stop_loss_pct !== undefined)
1141
+ s.stop_loss_pct = args.stop_loss_pct;
1142
+ const priorMode = s.copy_mode ?? "ride_recommends";
1143
+ const priorMultiplier = s.proportional_multiplier ?? 1;
1144
+ for (const key of [
1145
+ "copy_mode",
1146
+ "fixed_margin_usdc",
1147
+ "proportional_multiplier",
1148
+ "loss_policy",
1149
+ "emergency_exit_pct",
1150
+ ])
1151
+ if (args[key] !== undefined)
1152
+ s[key] = args[key];
1153
+ validateCopySettings(s);
1154
+ if (["copy_mode", "fixed_margin_usdc", "proportional_multiplier"].some((key) => args[key] !== undefined)) {
1155
+ s.source_revision = null;
1156
+ if ((s.copy_mode ?? "ride_recommends") !== priorMode) {
1157
+ s.fixed_targets = {};
1158
+ s.source_quantities = {};
1159
+ s.fixed_seen_fills = [];
1160
+ s.fixed_last_fill_at = undefined;
1161
+ }
1162
+ else if (s.copy_mode !== "fixed" &&
1163
+ args.proportional_multiplier !== undefined) {
1164
+ for (const intent of Object.values(s.intents))
1165
+ intent.quantity = D(intent.quantity)
1166
+ .times(s.proportional_multiplier ?? 1)
1167
+ .div(priorMultiplier)
1168
+ .toDecimalPlaces(p.snapshot?.quantity_decimals?.[intent.key] ?? 8)
1169
+ .toString();
1170
+ }
1171
+ }
1172
+ if (args.resume_new_trades) {
1173
+ const net = pnl(p).sleeves.find((x) => x.copy_id === s.copy_id)?.net_usdc;
1174
+ if (s.state !== "risk_off" ||
1175
+ net == null ||
1176
+ D(net).lte(D(s.amount_usdc).times(s.stop_loss_pct).div(100).neg()))
1177
+ throw new AgentError("RESUME_BLOCKED", "Fresh reconciled profit must be above the pause threshold before resuming.");
1178
+ s.state = "active";
1179
+ s.source_revision = null;
1180
+ }
1181
+ }
1182
+ if (p.snapshot)
1183
+ risk(p, p.snapshot.prices, true);
1184
+ }
1185
+ proposalView(p, state) {
1186
+ const portfolio = state.portfolios.find((x) => x.id === p.portfolio_id);
1187
+ const tasks = portfolio?.executions
1188
+ .filter((e) => e.revision === (p.committed_revision ?? p.expected_revision + 1))
1189
+ .map(({ allocations, fingerprint, ...e }) => e) ?? [];
1190
+ return result({
1191
+ proposal: {
1192
+ ...p,
1193
+ preview_hash: fingerprint(p.preview),
1194
+ confirmation_url: new URL(`/agent/confirm/${p.id}`, this.confirmationBase).href,
1195
+ execution_tasks: tasks,
1196
+ portfolio_state: portfolio?.state ?? null,
1197
+ },
1198
+ }, p.status === "requires_confirmation"
1199
+ ? "requires_confirmation"
1200
+ : p.status === "queued"
1201
+ ? "queued"
1202
+ : p.status === "completed"
1203
+ ? "completed"
1204
+ : "failed", p.warnings);
1205
+ }
1206
+ }
1207
+ export function fromPlan(plan, id = uid("portfolio")) {
1208
+ return {
1209
+ id,
1210
+ account_id: null,
1211
+ revision: 0,
1212
+ state: "activating",
1213
+ preferences: plan.preferences,
1214
+ asset_filter_version: plan.asset_filter_version,
1215
+ sleeves: plan.allocations.map((a) => ({
1216
+ ...a,
1217
+ state: "active",
1218
+ lots: {},
1219
+ intents: {},
1220
+ initialized: false,
1221
+ suppressed: [],
1222
+ source_revision: null,
1223
+ last_source_at: null,
1224
+ replaced_at: null,
1225
+ })),
1226
+ executions: [],
1227
+ snapshot: null,
1228
+ initial_equity: null,
1229
+ created_at: now(),
1230
+ ledger_started_at: null,
1231
+ seen_fills: [],
1232
+ seen_funding: [],
1233
+ seen_flows: [],
1234
+ flow_total: "0",
1235
+ reconciliation: "pending",
1236
+ loss_latched: false,
1237
+ };
1238
+ }
1239
+ function findPortfolio(s, id) {
1240
+ return id
1241
+ ? s.portfolios.find((x) => x.id === id)
1242
+ : ([...s.portfolios].reverse().find((x) => x.state !== "closed") ??
1243
+ s.portfolios.at(-1));
1244
+ }
1245
+ function findByOwnership(s, args) {
1246
+ return s.portfolios.find((p) => args.portfolio_id
1247
+ ? p.id === args.portfolio_id
1248
+ : args.copy_id
1249
+ ? p.sleeves.some((x) => x.copy_id === args.copy_id)
1250
+ : aggregate(p, p.snapshot?.prices ?? {}, "actual").some((x) => x.position_id === args.position_id));
1251
+ }
1252
+ export function portfolioView(p) {
1253
+ const snap = p.snapshot, stats = pnl(p), stale = !snap || !fresh(snap.observed_at);
1254
+ let positions = [];
1255
+ try {
1256
+ positions = aggregate(p, snap?.prices ?? {}, "actual");
1257
+ }
1258
+ catch {
1259
+ /* unavailable */
1260
+ }
1261
+ const pending = p.executions.filter((e) => !["completed", "failed", "superseded"].includes(e.status));
1262
+ return result({
1263
+ portfolio: {
1264
+ id: p.id,
1265
+ revision: p.revision,
1266
+ state: p.state,
1267
+ preferences: p.preferences,
1268
+ created_at: p.created_at,
1269
+ ledger_started_at: p.ledger_started_at,
1270
+ account_value_usdc: snap?.account_value_usdc ?? null,
1271
+ available_usdc: stale ? null : (snap?.available_usdc ?? null),
1272
+ reserved_usdc: cash(pending.reduce((n, e) => n.plus(e.reserve_usdc), D(0))),
1273
+ allocated_usdc: cash(p.sleeves
1274
+ .filter((s) => s.state !== "closed")
1275
+ .reduce((n, s) => n.plus(s.amount_usdc), D(0))),
1276
+ net_profit_usdc: stats.net_usdc,
1277
+ external_flows_usdc: stats.external_flows_usdc,
1278
+ reconciliation: p.reconciliation,
1279
+ sleeves: p.sleeves.map((s) => ({
1280
+ copy_id: s.copy_id,
1281
+ trader: s.trader,
1282
+ amount_usdc: s.amount_usdc,
1283
+ leverage_cap: s.leverage_cap,
1284
+ ...(s.leverage_mode ? { leverage_mode: s.leverage_mode } : {}),
1285
+ stop_loss_pct: s.stop_loss_pct,
1286
+ state: s.state,
1287
+ net_profit_usdc: stats.sleeves.find((x) => x.copy_id === s.copy_id)?.net_usdc ??
1288
+ null,
1289
+ })),
1290
+ positions,
1291
+ executions: p.executions.map(({ allocations, fingerprint, ...e }) => e),
1292
+ as_of: snap?.observed_at ?? null,
1293
+ loss_trigger_amount_usdc: cash(D(p.preferences.budget_usdc)
1294
+ .times(p.preferences.loss_trigger_pct)
1295
+ .div(100)),
1296
+ },
1297
+ }, stale
1298
+ ? "stale"
1299
+ : p.executions.some((e) => e.status === "uncertain")
1300
+ ? "uncertain"
1301
+ : pending.some((e) => e.status === "partially_filled")
1302
+ ? "partially_filled"
1303
+ : pending.length
1304
+ ? "executing"
1305
+ : "ok");
1306
+ }
1307
+ export function review(p) {
1308
+ const out = [];
1309
+ for (const s of p.sleeves) {
1310
+ if (s.last_source_at == null || !fresh(s.last_source_at, 60_000))
1311
+ out.push({ kind: "source_stale", copy_id: s.copy_id });
1312
+ const inactive = s.trader.last_trade_at == null ? null : now() - s.trader.last_trade_at;
1313
+ const normal = Math.max(7 * 86400_000, (s.trader.median_hold_hours ?? 168) * 3600_000 * 2);
1314
+ if (inactive != null && inactive > normal)
1315
+ out.push({
1316
+ kind: "inactivity",
1317
+ copy_id: s.copy_id,
1318
+ inactive_days: Math.floor(inactive / 86400_000),
1319
+ normal_hold_hours: s.trader.median_hold_hours,
1320
+ });
1321
+ if (s.trader.expires_at <= now())
1322
+ out.push({ kind: "score_stale", copy_id: s.copy_id });
1323
+ }
1324
+ if (p.reconciliation !== "complete")
1325
+ out.push({ kind: "accounting_incomplete" });
1326
+ if (p.loss_latched)
1327
+ out.push({
1328
+ kind: "loss_triggered",
1329
+ remaining_positions: aggregate(p, p.snapshot?.prices ?? {}, "actual"),
1330
+ });
1331
+ return out;
1332
+ }
1333
+ export function event(s, kind, portfolioId, message, data = {}) {
1334
+ s.updates.push({
1335
+ id: uid("update"),
1336
+ at: now(),
1337
+ kind,
1338
+ portfolio_id: portfolioId,
1339
+ message,
1340
+ data,
1341
+ notification: "pending",
1342
+ });
1343
+ }
1344
+ function publicArguments(args) {
1345
+ const { replacement_copy_id, replacement_at, ...publicArgs } = args;
1346
+ return publicArgs;
1347
+ }