@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.
- package/.env.example +31 -0
- package/LICENSE +21 -0
- package/README.md +174 -2
- package/dist/account_balances.js +435 -0
- package/dist/account_reads.js +199 -0
- package/dist/agent/adapter.js +115 -0
- package/dist/agent/analytics.js +174 -0
- package/dist/agent/backend.js +237 -0
- package/dist/agent/card_hosts.js +9 -0
- package/dist/agent/contracts.js +276 -0
- package/dist/agent/conversational.js +86 -0
- package/dist/agent/core_bridge.js +1155 -0
- package/dist/agent/deposits.js +309 -0
- package/dist/agent/discovery.js +205 -0
- package/dist/agent/domain.js +507 -0
- package/dist/agent/firebase.js +34 -0
- package/dist/agent/funding.js +230 -0
- package/dist/agent/http.js +40 -0
- package/dist/agent/human.js +298 -0
- package/dist/agent/human_handoffs.js +39 -0
- package/dist/agent/ledger.js +137 -0
- package/dist/agent/mcp.js +245 -0
- package/dist/agent/order_rules.js +61 -0
- package/dist/agent/perps_account.js +41 -0
- package/dist/agent/prediction.js +607 -0
- package/dist/agent/prediction_equity_history.js +910 -0
- package/dist/agent/prediction_history.js +336 -0
- package/dist/agent/preferences.js +114 -0
- package/dist/agent/repository.js +106 -0
- package/dist/agent/runner.js +683 -0
- package/dist/agent/service.js +1347 -0
- package/dist/backend.js +6 -0
- package/dist/bridge.js +6 -0
- package/dist/cli/clients.js +99 -0
- package/dist/cli/commands.js +332 -0
- package/dist/cli/onboarding.js +53 -0
- package/dist/cli/session.js +224 -0
- package/dist/cli/setup.js +256 -0
- package/dist/cli.js +16 -0
- package/dist/guide.js +92 -0
- package/dist/http_security.js +79 -0
- package/dist/mcp-cli.js +7 -0
- package/dist/oauth.js +1032 -0
- package/dist/server.js +171 -0
- package/dist/start.js +18 -0
- package/dist/version.js +25 -0
- package/docs/confirmation-cards.md +118 -0
- package/docs/conversation-v050.md +30 -0
- package/docs/conversational-v17.md +48 -0
- package/docs/deployment.md +58 -0
- package/docs/perps-leverage.md +43 -0
- package/docs/prediction-history.md +28 -0
- package/docs/preferences-funding-v042.md +17 -0
- package/docs/ride-agent-migration.md +127 -0
- package/docs/v17-completion-checklist.md +31 -0
- package/docs/v17-rollout.md +44 -0
- package/docs/widget-account-v043.md +17 -0
- package/package.json +75 -4
- package/public/agent.html +2313 -0
- package/public/confirm.html +2373 -0
- package/public/fund.html +563 -0
- package/skills/ride/SKILL.md +18 -0
- package/skills/ride/references/commands.md +47 -0
- 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
|
+
}
|