@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,683 @@
1
+ import { AgentError } from "./contracts.js";
2
+ import { D, cash, uid, now, fresh, fingerprint, risk, pnl, assetAllowed, } from "./domain.js";
3
+ import { applyFill, applyFunding, internalCross, reconcileQuantities, } from "./ledger.js";
4
+ import { event } from "./service.js";
5
+ import { checkPerpsOrder } from "./order_rules.js";
6
+ import { recordPerformance, recordReceipt, recordDecision, localDay, performanceView, } from "./analytics.js";
7
+ export class AgentRunner {
8
+ service;
9
+ constructor(service) {
10
+ this.service = service;
11
+ }
12
+ async tick(user) {
13
+ const { repository, adapter } = this.service;
14
+ // Planning is committed before any submission. A process crash cannot
15
+ // erase a possibly submitted execution identifier or its frozen payload.
16
+ await repository.transact(user, async (state) => {
17
+ for (const p of state.portfolios.filter((p) => p.state !== "closed")) {
18
+ try {
19
+ if (!p.account_id) {
20
+ const snap = await adapter.bind(user, p);
21
+ if (!snap.dedicated ||
22
+ !snap.eligible ||
23
+ !fresh(snap.observed_at) ||
24
+ Object.values(snap.positions).some((q) => !D(q).isZero()) ||
25
+ snap.orders.length)
26
+ throw new AgentError("OWNERSHIP_CONFLICT", "Dedicated account must be empty before initial ownership is established.");
27
+ p.account_id = snap.account_id;
28
+ p.venue_accounts = snap.venue_accounts;
29
+ p.ledger_chain_block =
30
+ snap.chain_block == null ? undefined : snap.chain_block + 1;
31
+ p.initial_equity = snap.account_value_usdc;
32
+ p.ledger_started_at = snap.observed_at;
33
+ p.snapshot = snap;
34
+ p.state = "active";
35
+ p.reconciliation = "complete";
36
+ p.audit_started_at = snap.observed_at;
37
+ recordPerformance(state, p);
38
+ event(state, "portfolio_active", p.id, "Configuration activated; entries are evaluated separately.");
39
+ }
40
+ const snap = await adapter.account(user, p);
41
+ if (!fresh(snap.observed_at) || !snap.eligible || !snap.dedicated)
42
+ throw new AgentError("ACCOUNT_STALE", "Fresh eligible owned account evidence is required.", true);
43
+ p.snapshot = snap;
44
+ p.audit_started_at ??= snap.observed_at;
45
+ if (p.state === "attention")
46
+ p.state = p.loss_latched ? "loss_triggered" : "active";
47
+ for (const e of p.executions.filter((e) => e.task_id &&
48
+ !["completed", "failed", "superseded"].includes(e.status))) {
49
+ const observed = await adapter.execution(user, p, e);
50
+ e.status = observed.status;
51
+ e.observed_quantity = observed.observed_quantity;
52
+ if (e.status === "completed" && !observed.settled)
53
+ e.status = "settling";
54
+ }
55
+ for (const fill of [...snap.fills]
56
+ .filter((f) => f.key.startsWith("prediction:") ||
57
+ f.at >= (p.ledger_started_at ?? Infinity))
58
+ .sort((a, b) => a.at - b.at || a.id.localeCompare(b.id))) {
59
+ if (!p.seen_fills.includes(fill.id)) {
60
+ const before = structuredClone(p.sleeves);
61
+ applyFill(p, fill);
62
+ recordReceipt(p, fill, before);
63
+ checkpoint(state, p, fill.key, fill.at);
64
+ }
65
+ }
66
+ for (const f of snap.funding
67
+ .filter((f) => f.at >= (p.ledger_started_at ?? Infinity))
68
+ .sort((a, b) => a.at - b.at)) {
69
+ if (p.seen_funding.includes(f.id))
70
+ continue;
71
+ const history = [...state.checkpoints]
72
+ .reverse()
73
+ .filter((c) => c.key === `${p.id}:${f.key}` && c.at <= f.at)
74
+ .sort((a, b) => b.at - a.at)[0];
75
+ const before = p.sleeves.map((s) => ({
76
+ copy_id: s.copy_id,
77
+ funding: s.lots[f.key]?.funding ?? "0",
78
+ }));
79
+ applyFunding(p, f, history?.quantities ?? {});
80
+ for (const old of before) {
81
+ const sleeve = p.sleeves.find((s) => s.copy_id === old.copy_id);
82
+ const delta = D(sleeve.lots[f.key]?.funding ?? 0).minus(old.funding);
83
+ if (!delta.isZero())
84
+ (p.funding_receipts ??= []).push({
85
+ copy_id: old.copy_id,
86
+ id: f.id,
87
+ key: f.key,
88
+ at: f.at,
89
+ amount_usdc: delta.toString(),
90
+ });
91
+ }
92
+ }
93
+ for (const flow of snap.flows.filter((f) => f.id.startsWith("poly_flow_") ||
94
+ f.at >= (p.ledger_started_at ?? Infinity))) {
95
+ if (p.seen_flows.includes(flow.id))
96
+ continue;
97
+ p.flow_total = D(p.flow_total).plus(flow.amount_usdc).toString();
98
+ p.seen_flows.push(flow.id);
99
+ }
100
+ p.reconciliation = reconcileQuantities(p) ? "complete" : "incomplete";
101
+ if (p.reconciliation === "complete")
102
+ p.audit_verified_at = snap.observed_at;
103
+ for (const e of p.executions.filter((e) => e.status === "completed")) {
104
+ if (p.reconciliation === "complete") {
105
+ e.unfilled_allocations = structuredClone(e.allocations);
106
+ e.allocations = {};
107
+ }
108
+ }
109
+ const profit = pnl(p);
110
+ recordPerformance(state, p);
111
+ if (profit.net_usdc != null &&
112
+ D(profit.net_usdc).lte(D(p.preferences.budget_usdc)
113
+ .times(p.preferences.loss_trigger_pct)
114
+ .div(100)
115
+ .neg()) &&
116
+ !p.loss_latched) {
117
+ p.loss_latched = true;
118
+ p.state = "loss_triggered";
119
+ p.revision++;
120
+ p.control_id = uid("control");
121
+ for (const s of p.sleeves) {
122
+ s.state = "loss_triggered";
123
+ for (const x of Object.values(s.intents))
124
+ x.quantity = "0";
125
+ }
126
+ for (const e of p.executions)
127
+ if (e.status === "queued")
128
+ e.status = "superseded";
129
+ event(state, "loss_triggered", p.id, "Loss threshold reached. Risk increases blocked; owned exits remain under reconciliation.");
130
+ }
131
+ if (p.loss_latched) {
132
+ await adapter.fence(user, p);
133
+ await adapter.cancelIncreasing(user, p);
134
+ }
135
+ const plannedChanges = new Map();
136
+ for (const s of p.sleeves) {
137
+ if (s.state === "closed")
138
+ continue;
139
+ if (["closing", "loss_triggered"].includes(s.state)) {
140
+ for (const x of Object.values(s.intents))
141
+ x.quantity = "0";
142
+ if (s.state === "loss_triggered" && !p.loss_latched)
143
+ await adapter.fence(user, p);
144
+ continue;
145
+ }
146
+ const currentProfit = profit.sleeves.find((x) => x.copy_id === s.copy_id)?.net_usdc;
147
+ if (currentProfit != null &&
148
+ D(currentProfit).lte(D(s.amount_usdc).times(s.stop_loss_pct).div(100).neg())) {
149
+ const emergency = s.loss_policy !== "pause_then_exit" ||
150
+ D(currentProfit).lte(D(s.amount_usdc)
151
+ .times(s.emergency_exit_pct ?? 70)
152
+ .div(100)
153
+ .neg());
154
+ if (emergency || s.state !== "risk_off") {
155
+ s.state = emergency ? "loss_triggered" : "risk_off";
156
+ if (emergency)
157
+ for (const x of Object.values(s.intents))
158
+ x.quantity = "0";
159
+ p.revision++;
160
+ p.control_id = uid("control");
161
+ for (const execution of p.executions)
162
+ if (execution.status === "queued")
163
+ execution.status = "superseded";
164
+ await adapter.fence(user, p);
165
+ await adapter.cancelIncreasing(user, p);
166
+ event(state, emergency ? "sleeve_loss_triggered" : "sleeve_risk_off", p.id, emergency
167
+ ? "Emergency threshold reached; owned exits are pending reconciliation."
168
+ : "Trader pause threshold reached; new exposure is blocked while reductions and closes continue.", { copy_id: s.copy_id });
169
+ }
170
+ if (emergency)
171
+ continue;
172
+ }
173
+ if (now() - (s.reviewed_at ?? 0) >= 86400_000) {
174
+ const updated = await adapter.profile(s.trader.id);
175
+ if (updated.scored_at >= s.trader.scored_at) {
176
+ const inactive = updated.last_trade_at == null
177
+ ? null
178
+ : now() - updated.last_trade_at;
179
+ const normal = Math.max(7 * 86400_000, (updated.median_hold_hours ?? 168) * 3600_000 * 2);
180
+ if (updated.eligibility !== "PASS" ||
181
+ updated.score < s.trader.score - 10 ||
182
+ updated.style !== s.trader.style ||
183
+ (inactive != null && inactive > normal))
184
+ eventOnce(state, "daily_review", p.id, "Trader score, style or trading activity needs review.", {
185
+ copy_id: s.copy_id,
186
+ previous_score: s.trader.score,
187
+ current_score: updated.score,
188
+ previous_style: s.trader.style,
189
+ current_style: updated.style,
190
+ inactive_days: inactive != null && inactive > normal
191
+ ? Math.floor(inactive / 86400_000)
192
+ : null,
193
+ });
194
+ s.trader = updated;
195
+ s.reviewed_at = now();
196
+ }
197
+ }
198
+ if (p.reconciliation !== "complete" && s.initialized)
199
+ continue;
200
+ const source = await adapter.source(s.trader);
201
+ if (!source.complete ||
202
+ !fresh(source.observed_at) ||
203
+ !D(source.account_value_usdc).gt(0))
204
+ throw new AgentError("SOURCE_STALE", "Source coverage or equity is incomplete.", true);
205
+ s.last_source_at = source.observed_at;
206
+ const leverageRules = s.leverage_mode == null
207
+ ? undefined
208
+ : fingerprint(source.positions.map((x) => [
209
+ x.key,
210
+ snap.max_leverages[x.key] ?? null,
211
+ ]));
212
+ if (source.revision === s.source_revision &&
213
+ s.copy_mode !== "fixed" &&
214
+ (leverageRules == null ||
215
+ leverageRules === s.leverage_rules_revision))
216
+ continue;
217
+ const trial = structuredClone(p), t = trial.sleeves.find((x) => x.copy_id === s.copy_id);
218
+ if (t.copy_mode === "fixed" && t.fixed_last_fill_at != null) {
219
+ if (!adapter.sourceFills)
220
+ throw new AgentError("SOURCE_FILL_EVIDENCE_MISSING", "Fixed copying requires verified source fill history.", true);
221
+ const fills = await adapter.sourceFills(t.trader, t.fixed_last_fill_at, source.observed_at);
222
+ if (!fills.complete)
223
+ throw new AgentError("SOURCE_FILL_EVIDENCE_MISSING", "Source fills are incomplete; fixed-copy targets remain unchanged.", true);
224
+ for (const fill of fills.fills.sort((a, b) => a.at - b.at || a.id.localeCompare(b.id))) {
225
+ if ((t.fixed_seen_fills ?? []).includes(fill.id))
226
+ continue;
227
+ if (fill.start_quantity == null)
228
+ throw new AgentError("SOURCE_FILL_EVIDENCE_MISSING", "A fixed-copy fill lacks its pre-trade source position.", true);
229
+ const previousSource = D(fill.start_quantity), currentSource = previousSource.plus(fill.quantity), previousTarget = D(t.fixed_targets?.[fill.key] ?? 0);
230
+ const position = source.positions.find((x) => x.key === fill.key);
231
+ const leverage = Math.min(t.leverage_cap ?? Number.POSITIVE_INFINITY, position?.source_leverage ??
232
+ t.intents[fill.key]?.source_leverage ??
233
+ 1, snap.max_leverages[fill.key] ?? 1);
234
+ const unit = D(t.fixed_margin_usdc ?? 0)
235
+ .times(leverage)
236
+ .div(fill.price);
237
+ let target = previousTarget;
238
+ if (currentSource.isZero())
239
+ target = D(0);
240
+ else if (previousSource.isZero() ||
241
+ previousSource.isPositive() !== currentSource.isPositive())
242
+ target = unit.times(currentSource.isPositive() ? 1 : -1);
243
+ else if (currentSource.abs().lt(previousSource.abs()))
244
+ target = previousTarget.times(currentSource.abs().div(previousSource.abs()));
245
+ else
246
+ target = previousTarget.plus(unit.times(currentSource.isPositive() ? 1 : -1));
247
+ (t.fixed_targets ??= {})[fill.key] = target.toString();
248
+ (t.source_quantities ??= {})[fill.key] =
249
+ currentSource.toString();
250
+ (t.fixed_seen_fills ??= []).push(fill.id);
251
+ }
252
+ }
253
+ const permitted = source.positions.filter((x) => x.market === "prediction" ||
254
+ assetAllowed(p.preferences, x.asset, s.trader.asset_categories?.[x.asset] ??
255
+ (p.asset_filter_version === 2
256
+ ? s.trader.asset_category
257
+ : undefined), p.asset_filter_version ?? 1));
258
+ const activeKeys = new Set(permitted
259
+ .filter((x) => !D(x.quantity).isZero())
260
+ .map((x) => x.key));
261
+ t.suppressed = t.suppressed.filter((key) => activeKeys.has(key));
262
+ for (const old of Object.values(t.intents))
263
+ if (!activeKeys.has(old.key)) {
264
+ old.quantity = "0";
265
+ if (t.source_quantities)
266
+ t.source_quantities[old.key] = "0";
267
+ if (t.fixed_targets)
268
+ t.fixed_targets[old.key] = "0";
269
+ }
270
+ const raw = permitted.map((x) => {
271
+ const px = snap.prices[x.key];
272
+ if (!px || !fresh(x.observed_at))
273
+ throw new AgentError("STALE_PRICE", "Current source and follower prices are required.", true);
274
+ const newPolicy = t.leverage_mode != null && x.market === "perps";
275
+ const zeroSource = D(x.quantity).isZero();
276
+ if (newPolicy &&
277
+ !zeroSource &&
278
+ (x.source_leverage_verified === false ||
279
+ !Number.isInteger(x.source_leverage) ||
280
+ x.source_leverage < 1))
281
+ throw new AgentError("SOURCE_LEVERAGE_UNAVAILABLE", "Verified source leverage is required before following a Perps position.", true);
282
+ if (newPolicy &&
283
+ !zeroSource &&
284
+ (!Number.isFinite(snap.max_leverages[x.key]) ||
285
+ snap.max_leverages[x.key] < 1))
286
+ throw new AgentError("VENUE_RULES_UNAVAILABLE", "A fresh per-asset venue leverage limit is required.", true);
287
+ const sourceLeverage = newPolicy && zeroSource ? 1 : x.source_leverage;
288
+ const leverage = Math.min(t.leverage_cap ?? Number.POSITIVE_INFINITY, sourceLeverage, snap.max_leverages[x.key] ?? 1);
289
+ let quantity = D(x.quantity)
290
+ .times(D(t.amount_usdc).div(source.account_value_usdc))
291
+ .times(D(leverage).div(Math.max(1, sourceLeverage)))
292
+ .times(t.proportional_multiplier ?? 1);
293
+ if (t.copy_mode === "fixed") {
294
+ const previousSource = D(t.source_quantities?.[x.key] ?? 0), previousTarget = D(t.fixed_targets?.[x.key] ?? 0), currentSource = D(x.quantity);
295
+ const unit = D(t.fixed_margin_usdc ?? 0)
296
+ .times(leverage)
297
+ .div(px);
298
+ if (t.fixed_last_fill_at != null &&
299
+ !currentSource.eq(previousSource))
300
+ throw new AgentError("SOURCE_FILL_EVIDENCE_MISSING", "Source position changes are not fully explained by verified fills.", true);
301
+ if (currentSource.isZero())
302
+ quantity = D(0);
303
+ else if (previousSource.isZero() ||
304
+ previousSource.isPositive() !== currentSource.isPositive())
305
+ quantity = unit.times(currentSource.isPositive() ? 1 : -1);
306
+ else {
307
+ if (!currentSource.eq(previousSource))
308
+ throw new AgentError("SOURCE_FILL_EVIDENCE_MISSING", "Source position changes are not fully explained by verified fills.", true);
309
+ quantity = previousTarget;
310
+ }
311
+ (t.source_quantities ??= {})[x.key] = x.quantity;
312
+ (t.fixed_targets ??= {})[x.key] = quantity.toString();
313
+ }
314
+ return {
315
+ ...x,
316
+ quantity: quantity.toString(),
317
+ raw_source_quantity: x.quantity,
318
+ source_leverage: leverage,
319
+ price: px,
320
+ };
321
+ });
322
+ const margin = raw.reduce((n, x) => n.plus(D(x.quantity).abs().times(x.price).div(x.source_leverage)), D(0)), scale = margin.gt(t.amount_usdc)
323
+ ? D(t.amount_usdc).div(margin)
324
+ : D(1);
325
+ for (const x of raw) {
326
+ if (t.suppressed.includes(x.key))
327
+ continue;
328
+ if (!t.initialized &&
329
+ !D(x.quantity).isZero() &&
330
+ (x.source_entry_price == null ||
331
+ D(x.price)
332
+ .minus(x.source_entry_price)
333
+ .abs()
334
+ .div(x.source_entry_price)
335
+ .gt("0.002"))) {
336
+ t.suppressed.push(x.key);
337
+ recordDecision(p, t, x, x.price, null, "ENTRY_DEVIATION_OR_MISSING_SOURCE_PRICE");
338
+ continue;
339
+ }
340
+ let q = D(x.quantity)
341
+ .times(scale)
342
+ .toDecimalPlaces(snap.quantity_decimals?.[x.key] ?? 8);
343
+ const current = D(t.lots[x.key]?.quantity ?? 0);
344
+ if (p.preferences.portfolio_kind &&
345
+ x.market === "perps" &&
346
+ !D(x.quantity).isZero() &&
347
+ q.isZero() &&
348
+ current.isZero()) {
349
+ t.intents[x.key] = { ...x, quantity: "0" };
350
+ recordDecision(p, t, x, x.price, null, "BELOW_MINIMUM_QUANTITY");
351
+ continue;
352
+ }
353
+ if (["wind_down", "risk_off"].includes(t.state)) {
354
+ if (current.isZero() || current.isPositive() !== q.isPositive())
355
+ q = D(0);
356
+ else {
357
+ const previous = s.intents[x.key]?.quantity;
358
+ const ceiling = previous == null
359
+ ? current.abs()
360
+ : D.min(current.abs(), D(previous).abs());
361
+ q = D.min(q.abs(), ceiling).times(q.isPositive() ? 1 : -1);
362
+ }
363
+ }
364
+ if (!(p.preferences.portfolio_kind && x.market === "perps") &&
365
+ !q.isZero() &&
366
+ q
367
+ .abs()
368
+ .times(x.price)
369
+ .lt(snap.min_notionals[x.key] ?? t.trader.min_notional_usdc)) {
370
+ if (current.isZero()) {
371
+ recordDecision(p, t, x, x.price, null, "BELOW_MINIMUM_NOTIONAL");
372
+ continue;
373
+ }
374
+ q = D(0);
375
+ }
376
+ t.intents[x.key] = { ...x, quantity: q.toString() };
377
+ recordDecision(p, t, x, x.price, q.toString(), "TARGET_EVALUATED");
378
+ }
379
+ t.initialized = true;
380
+ if (t.copy_mode === "fixed")
381
+ t.fixed_last_fill_at = source.observed_at;
382
+ t.source_revision = source.revision;
383
+ if (leverageRules != null)
384
+ t.leverage_rules_revision = leverageRules;
385
+ const sleeveProfit = profit.sleeves.find((x) => x.copy_id === s.copy_id)?.net_usdc;
386
+ if (t.loss_policy !== "pause_then_exit" &&
387
+ sleeveProfit != null &&
388
+ D(sleeveProfit).lte(D(t.amount_usdc).times(t.stop_loss_pct).div(100).neg())) {
389
+ t.state = "loss_triggered";
390
+ for (const x of Object.values(t.intents))
391
+ x.quantity = "0";
392
+ event(state, "sleeve_loss_triggered", p.id, "Sleeve loss threshold reached; exit is pending verification.", { copy_id: t.copy_id });
393
+ }
394
+ plannedChanges.set(s.copy_id, t);
395
+ }
396
+ if (plannedChanges.size) {
397
+ const basket = structuredClone(p);
398
+ for (const sleeve of basket.sleeves) {
399
+ const change = plannedChanges.get(sleeve.copy_id);
400
+ if (change)
401
+ Object.assign(sleeve, change);
402
+ }
403
+ try {
404
+ risk(basket, snap.prices, true);
405
+ p.sleeves = basket.sleeves;
406
+ }
407
+ catch (error) {
408
+ if (error instanceof AgentError)
409
+ eventOnce(state, error.code, p.id, error.message);
410
+ else
411
+ throw error;
412
+ }
413
+ }
414
+ const targets = risk(p, snap.prices, true);
415
+ const keys = new Set([
416
+ ...targets.map((x) => x.key),
417
+ ...p.sleeves.flatMap((s) => Object.keys(s.lots)),
418
+ ]);
419
+ for (const key of keys) {
420
+ if (p.executions.some((e) => e.key === key &&
421
+ !["completed", "failed", "superseded"].includes(e.status)))
422
+ continue;
423
+ const target = targets.find((x) => x.key === key), observed = snap.positions[key] ?? "0", price = snap.prices[key];
424
+ let q = target?.quantity ?? "0";
425
+ if (!price)
426
+ continue;
427
+ let allocations = internalCross(p, key, price);
428
+ checkpoint(state, p, key, now());
429
+ if (D(q).eq(observed))
430
+ continue;
431
+ if (p.preferences.portfolio_kind && key.startsWith("perps:")) {
432
+ const check = checkPerpsOrder(snap, key, q);
433
+ if (!check.ok) {
434
+ const { ok: _, ...details } = check;
435
+ if (!state.updates.some((u) => u.kind === check.code &&
436
+ u.portfolio_id === p.id &&
437
+ u.data.key === key &&
438
+ now() - u.at < 3600_000)) {
439
+ event(state, check.code, p.id, check.message, {
440
+ key,
441
+ target_quantity: q,
442
+ ...details,
443
+ });
444
+ // Routine skipped orders are visible in history, not push alerts.
445
+ state.updates.at(-1).notification = "cancelled";
446
+ }
447
+ for (const sleeve of p.sleeves) {
448
+ const intent = sleeve.intents[key];
449
+ if (intent)
450
+ recordDecision(p, sleeve, intent, price, null, check.code);
451
+ }
452
+ if (check.code === "BELOW_MINIMUM_NOTIONAL" &&
453
+ !D(observed).isZero() &&
454
+ !D(q).isZero() &&
455
+ D(observed).isPositive() !== D(q).isPositive()) {
456
+ // A flip is a close followed by a new opening leg. Preserve
457
+ // the valid close when the new direction is below minimum.
458
+ q = "0";
459
+ for (const sleeve of p.sleeves)
460
+ if (sleeve.intents[key])
461
+ sleeve.intents[key].quantity = "0";
462
+ allocations = internalCross(p, key, price);
463
+ checkpoint(state, p, key, now());
464
+ }
465
+ else
466
+ continue;
467
+ }
468
+ }
469
+ else if (!D(q).isZero() &&
470
+ D(q)
471
+ .minus(observed)
472
+ .abs()
473
+ .times(price)
474
+ .lt(snap.min_notionals[key] ?? "0"))
475
+ continue;
476
+ if (p.reconciliation !== "complete" &&
477
+ D(q).abs().gt(D(observed).abs()))
478
+ continue;
479
+ const delta = D(q).minus(observed), reserve = delta
480
+ .abs()
481
+ .times(price)
482
+ .div(Math.max(1, Math.min(p.sleeves.some((s) => s.leverage_mode != null)
483
+ ? Number.POSITIVE_INFINITY
484
+ : 8, ...(p.sleeves.some((s) => s.leverage_mode != null) &&
485
+ key.startsWith("perps:")
486
+ ? [snap.max_leverages[key] ?? 1]
487
+ : []), ...p.sleeves
488
+ .filter((s) => s.intents[key])
489
+ .map((s) => Math.min(s.leverage_cap ?? Number.POSITIVE_INFINITY, s.intents[key].source_leverage)))))
490
+ .times("1.003");
491
+ const outstanding = p.executions
492
+ .filter((e) => !["completed", "failed", "superseded"].includes(e.status) &&
493
+ e.key.split(":")[0] === key.split(":")[0])
494
+ .reduce((n, e) => n.plus(e.reserve_usdc), D(0));
495
+ const reduction = D(q).abs().lte(D(observed).abs()) &&
496
+ (D(q).isZero() || D(q).isPositive() === D(observed).isPositive());
497
+ if (!reduction &&
498
+ outstanding
499
+ .plus(reserve)
500
+ .gt(snap.available_by_market?.[key.startsWith("prediction:") ? "prediction" : "perps"] ?? snap.available_usdc)) {
501
+ eventOnce(state, "FUNDS_RESERVED", p.id, "Target waits for verified available funds.");
502
+ continue;
503
+ }
504
+ const id = uid("execution");
505
+ const frozen = {
506
+ id,
507
+ key,
508
+ target_quantity: q,
509
+ allocations,
510
+ revision: p.revision,
511
+ created_at: now(),
512
+ expires_at: now() + 60_000,
513
+ };
514
+ p.executions.push({
515
+ ...frozen,
516
+ fingerprint: fingerprint(frozen),
517
+ status: "queued",
518
+ task_id: null,
519
+ observed_quantity: null,
520
+ reserve_usdc: reduction ? "0.000000" : cash(reserve),
521
+ error: null,
522
+ });
523
+ }
524
+ for (const s of p.sleeves.filter((s) => ["wind_down", "risk_off", "closing", "loss_triggered"].includes(s.state))) {
525
+ if (Object.values(s.lots).every((l) => D(l.quantity).isZero()) &&
526
+ Object.values(s.intents).every((l) => D(l.quantity).isZero()) &&
527
+ p.reconciliation === "complete" &&
528
+ !p.executions.some((e) => !["completed", "failed", "superseded"].includes(e.status)))
529
+ s.state = "closed";
530
+ }
531
+ if (p.sleeves.every((s) => s.state === "closed") &&
532
+ p.reconciliation === "complete") {
533
+ await adapter.release(user, p);
534
+ p.state = "closed";
535
+ eventOnce(state, "portfolio_closed", p.id, "All owned positions and pending executions have settled.");
536
+ }
537
+ for (const proposal of state.proposals.filter((q) => q.portfolio_id === p.id && q.status === "queued")) {
538
+ const tasks = p.executions.filter((e) => e.revision ===
539
+ (proposal.committed_revision ?? proposal.expected_revision + 1));
540
+ if (p.sleeves.every((s) => s.initialized || s.state === "closed") &&
541
+ tasks.every((e) => ["completed", "superseded"].includes(e.status)) &&
542
+ p.reconciliation === "complete" &&
543
+ targets.every((t) => D(t.quantity).eq(snap.positions[t.key] ?? 0)))
544
+ proposal.status = "completed";
545
+ }
546
+ }
547
+ catch (e) {
548
+ const err = e instanceof AgentError
549
+ ? e
550
+ : new AgentError("MONITOR_UNAVAILABLE", "Portfolio observation failed; increasing execution is suspended.", true);
551
+ p.state = p.loss_latched ? "loss_triggered" : "attention";
552
+ eventOnce(state, err.code, p.id, err.message);
553
+ }
554
+ recordPerformance(state, p);
555
+ }
556
+ });
557
+ await repository.transact(user, async (state) => {
558
+ for (const p of state.portfolios)
559
+ for (const e of p.executions.filter((e) => ["queued", "uncertain"].includes(e.status) && !e.task_id)) {
560
+ if (e.status === "queued" &&
561
+ (e.revision !== p.revision || p.state === "attention")) {
562
+ e.status = "superseded";
563
+ continue;
564
+ }
565
+ if (e.status === "queued" && e.expires_at <= now()) {
566
+ e.status = "failed";
567
+ e.error = "Target expired before submission";
568
+ continue;
569
+ }
570
+ try {
571
+ if (e.status === "uncertain") {
572
+ const observed = await adapter.execution(user, p, e);
573
+ if (observed.status !== "failed") {
574
+ e.status = observed.status;
575
+ e.task_id = observed.task_id ?? e.task_id;
576
+ e.observed_quantity = observed.observed_quantity;
577
+ continue;
578
+ }
579
+ if (e.revision !== p.revision || p.state === "attention") {
580
+ e.status = "superseded";
581
+ continue;
582
+ }
583
+ }
584
+ // The bridge revalidates source revision, ownership, authority, price
585
+ // deviation, live margin and Core policy immediately before signing.
586
+ const submit = await adapter.submit(user, p, e);
587
+ e.task_id = submit.task_id;
588
+ e.status = "submitted";
589
+ event(state, "execution_submitted", p.id, "Order submitted; fill and settlement are pending.", { execution_id: e.id });
590
+ }
591
+ catch (error) {
592
+ if (error instanceof AgentError &&
593
+ error.code === "EXECUTION_REJECTED") {
594
+ e.status = "failed";
595
+ e.error = error.message;
596
+ }
597
+ else {
598
+ e.status = "uncertain";
599
+ e.error =
600
+ "Submission result unknown; same execution identifier must be reconciled.";
601
+ }
602
+ eventOnce(state, e.status === "failed"
603
+ ? "EXECUTION_REJECTED"
604
+ : "EXECUTION_UNCERTAIN", p.id, e.error, { execution_id: e.id });
605
+ }
606
+ }
607
+ const prefs = state.notification_preferences;
608
+ if (prefs?.daily_digest) {
609
+ const local = localDay(now(), prefs.timezone), enabled = localDay(prefs.enabled_at, prefs.timezone);
610
+ if (local.time >= prefs.local_time &&
611
+ prefs.last_digest_day !== local.day &&
612
+ (enabled.day < local.day || enabled.time < prefs.local_time)) {
613
+ const p = state.portfolios.at(-1);
614
+ if (p) {
615
+ const performance = performanceView(state, p, "daily");
616
+ event(state, "daily_digest", p.id, `Ride daily report · ${local.day} · net profit ${performance.net_profit_usdc ?? "unavailable"} USDC · ${performance.coverage} coverage.`, { performance, timezone: prefs.timezone, digest_day: local.day });
617
+ }
618
+ prefs.last_digest_day = local.day;
619
+ }
620
+ }
621
+ for (const update of state.updates.filter((e) => e.notification === "pending" &&
622
+ (e.next_notification_at ?? 0) <= now()))
623
+ try {
624
+ // Disabling an opted-in digest cancels queued digest delivery only.
625
+ if (update.kind === "daily_digest" &&
626
+ (!prefs?.daily_digest ||
627
+ update.data.digest_day !== localDay(now(), prefs.timezone).day)) {
628
+ update.notification = "cancelled";
629
+ continue;
630
+ }
631
+ await adapter.notify(user, update);
632
+ update.notification = "delivered";
633
+ update.notification_error = undefined;
634
+ update.next_notification_at = undefined;
635
+ }
636
+ catch (error) {
637
+ update.notification_attempts =
638
+ (update.notification_attempts ?? 0) + 1;
639
+ update.next_notification_at =
640
+ now() +
641
+ Math.min(3600_000, 5000 * 2 ** Math.min(10, update.notification_attempts));
642
+ update.notification_error =
643
+ error instanceof AgentError ? error.code : "PUSH_UNAVAILABLE";
644
+ /* Durable retry on the next tick. */
645
+ }
646
+ });
647
+ }
648
+ async run(signal, intervalMs = 5000) {
649
+ while (!signal.aborted) {
650
+ for (const user of await this.service.repository.users()) {
651
+ try {
652
+ await this.tick(user);
653
+ }
654
+ catch {
655
+ console.error("Ride Agent monitoring cycle failed; retained for retry.");
656
+ }
657
+ }
658
+ await new Promise((resolve) => {
659
+ const done = () => {
660
+ clearTimeout(timer);
661
+ signal.removeEventListener("abort", done);
662
+ resolve();
663
+ };
664
+ const timer = setTimeout(done, intervalMs);
665
+ signal.addEventListener("abort", done, { once: true });
666
+ });
667
+ }
668
+ }
669
+ }
670
+ function checkpoint(s, p, key, at) {
671
+ s.checkpoints.push({
672
+ at,
673
+ key: `${p.id}:${key}`,
674
+ quantities: Object.fromEntries(p.sleeves.map((x) => [x.copy_id, x.lots[key]?.quantity ?? "0"])),
675
+ });
676
+ }
677
+ function eventOnce(s, kind, pid, message, data = {}) {
678
+ if (!s.updates.some((e) => e.kind === kind &&
679
+ e.portfolio_id === pid &&
680
+ fingerprint(e.data) === fingerprint(data) &&
681
+ now() - e.at < 3600_000))
682
+ event(s, kind, pid, message, data);
683
+ }