pandora-cli-skills 1.1.31 → 1.1.32

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.
@@ -0,0 +1,331 @@
1
+ const { saveState, pruneIdempotencyKeys } = require('../mirror_state_store.cjs');
2
+ const { readTradingCredsFromEnv } = require('../polymarket_trade_adapter.cjs');
3
+ const { toNumber, round } = require('../shared/utils.cjs');
4
+
5
+ function buildIdempotencyKey(options, snapshot, nowMs) {
6
+ const bucketSize = Math.max(1_000, Number(options.cooldownMs) || 60_000);
7
+ const bucket = Math.floor(nowMs / bucketSize);
8
+ const metrics = snapshot && snapshot.metrics ? snapshot.metrics : {};
9
+ const actionPlan = snapshot && snapshot.actionPlan ? snapshot.actionPlan : {};
10
+ const rebalanceUsdc = Math.round((toNumber(actionPlan.rebalanceUsdc) || 0) * 100) / 100;
11
+ const hedgeUsdc = Math.round((toNumber(actionPlan.hedgeUsdc) || 0) * 100) / 100;
12
+
13
+ return [
14
+ String(options.pandoraMarketAddress || '').toLowerCase(),
15
+ String(options.polymarketMarketId || options.polymarketSlug || '').toLowerCase(),
16
+ metrics.driftTriggered ? 'drift' : 'no-drift',
17
+ metrics.hedgeTriggered ? 'hedge' : 'no-hedge',
18
+ actionPlan.rebalanceSide || 'rebalance:none',
19
+ actionPlan.hedgeTokenSide || 'hedge:none',
20
+ String(rebalanceUsdc),
21
+ String(hedgeUsdc),
22
+ String(bucket),
23
+ ].join('|');
24
+ }
25
+
26
+ /**
27
+ * Create an executable action envelope for a triggered tick.
28
+ * @param {{options: object, idempotencyKey: string, gate: object}} params
29
+ * @returns {object}
30
+ */
31
+ function buildExecutableAction(params) {
32
+ const { options, idempotencyKey, gate } = params;
33
+ return {
34
+ mode: options.executeLive ? 'live' : 'paper',
35
+ status: options.executeLive ? 'executed' : 'simulated',
36
+ idempotencyKey,
37
+ forcedGateBypass: gate.bypassedFailedChecks.length > 0,
38
+ failedChecks: gate.failedChecks,
39
+ failedChecksRaw: gate.failedChecksRaw,
40
+ bypassedFailedChecks: gate.bypassedFailedChecks,
41
+ rebalance: null,
42
+ hedge: null,
43
+ };
44
+ }
45
+
46
+ /**
47
+ * Normalize thrown execution errors into service result payloads.
48
+ * @param {any} err
49
+ * @returns {{ok: false, error: {code: string|null, message: string}}}
50
+ */
51
+ function normalizeExecutionFailure(err) {
52
+ return {
53
+ ok: false,
54
+ error: {
55
+ code: err && err.code ? String(err.code) : null,
56
+ message: err && err.message ? String(err.message) : String(err),
57
+ },
58
+ };
59
+ }
60
+
61
+ /**
62
+ * Execute or simulate rebalance leg and mutate action/state accordingly.
63
+ * @param {{options: object, action: object, plan: object, snapshotMetrics: object, rebalanceFn: Function, state: object}} params
64
+ * @returns {Promise<number>} Executed rebalance notional in USDC.
65
+ */
66
+ async function executeRebalanceLeg(params) {
67
+ const { options, action, plan, snapshotMetrics, rebalanceFn, state } = params;
68
+ if (!(snapshotMetrics.driftTriggered && plan.plannedRebalanceUsdc > 0)) return 0;
69
+
70
+ let rebalanceResultOk = true;
71
+ if (options.executeLive) {
72
+ let rebalanceResult;
73
+ try {
74
+ rebalanceResult = await rebalanceFn({
75
+ marketAddress: options.pandoraMarketAddress,
76
+ side: plan.rebalanceSide,
77
+ amountUsdc: plan.plannedRebalanceUsdc,
78
+ });
79
+ } catch (err) {
80
+ rebalanceResult = normalizeExecutionFailure(err);
81
+ }
82
+ action.rebalance = {
83
+ side: plan.rebalanceSide,
84
+ amountUsdc: plan.plannedRebalanceUsdc,
85
+ result: rebalanceResult,
86
+ };
87
+ rebalanceResultOk = Boolean(rebalanceResult && rebalanceResult.ok !== false);
88
+ } else {
89
+ action.rebalance = {
90
+ side: plan.rebalanceSide,
91
+ amountUsdc: plan.plannedRebalanceUsdc,
92
+ result: { status: 'simulated' },
93
+ };
94
+ }
95
+
96
+ if (rebalanceResultOk) {
97
+ state.cumulativeLpFeesApproxUsdc =
98
+ round((toNumber(state.cumulativeLpFeesApproxUsdc) || 0) + plan.plannedRebalanceUsdc * 0.003, 6) || 0;
99
+ return plan.plannedRebalanceUsdc;
100
+ }
101
+
102
+ action.status = 'failed';
103
+ const rebalanceError =
104
+ action.rebalance && action.rebalance.result && action.rebalance.result.error
105
+ ? action.rebalance.result.error
106
+ : { message: 'Pandora rebalance execution failed.' };
107
+ action.error = {
108
+ code: 'REBALANCE_EXECUTION_FAILED',
109
+ message: rebalanceError.message || 'Pandora rebalance execution failed.',
110
+ details: rebalanceError,
111
+ };
112
+ return 0;
113
+ }
114
+
115
+ /**
116
+ * Execute or simulate hedge leg and mutate action/state accordingly.
117
+ * @param {{options: object, action: object, plan: object, verifyPayload: object, depth: object, hedgeFn: Function, state: object}} params
118
+ * @returns {Promise<number>} Executed hedge notional in USDC.
119
+ */
120
+ async function executeHedgeLeg(params) {
121
+ const { options, action, plan, verifyPayload, depth, hedgeFn, state } = params;
122
+ if (!(plan.hedgeTriggered && plan.plannedHedgeUsdc > 0)) return 0;
123
+
124
+ const hedgeSide = 'buy';
125
+ const tokenId = plan.gapUsdc >= 0 ? verifyPayload.sourceMarket.yesTokenId : verifyPayload.sourceMarket.noTokenId;
126
+ const hedgeDepth = plan.gapUsdc >= 0 ? depth.yesDepth : depth.noDepth;
127
+
128
+ if (options.executeLive) {
129
+ const envCreds = readTradingCredsFromEnv();
130
+ let hedgeResult;
131
+ if (!options.privateKey && envCreds.privateKeyInvalid) {
132
+ hedgeResult = {
133
+ ok: false,
134
+ error: {
135
+ code: 'INVALID_ENV',
136
+ message: 'POLYMARKET_PRIVATE_KEY must be a valid private key (0x + 64 hex chars).',
137
+ },
138
+ };
139
+ } else {
140
+ try {
141
+ hedgeResult = await hedgeFn({
142
+ host: options.polymarketHost,
143
+ mockUrl: options.polymarketMockUrl,
144
+ tokenId,
145
+ side: hedgeSide,
146
+ amountUsd: plan.plannedHedgeUsdc,
147
+ privateKey: options.privateKey || envCreds.privateKey,
148
+ funder: options.funder || envCreds.funder,
149
+ apiKey: envCreds.apiKey,
150
+ apiSecret: envCreds.apiSecret,
151
+ apiPassphrase: envCreds.apiPassphrase,
152
+ });
153
+ } catch (err) {
154
+ hedgeResult = normalizeExecutionFailure(err);
155
+ }
156
+ }
157
+ action.hedge = {
158
+ tokenId,
159
+ side: hedgeSide,
160
+ amountUsdc: plan.plannedHedgeUsdc,
161
+ result: hedgeResult,
162
+ };
163
+ } else {
164
+ action.hedge = {
165
+ tokenId,
166
+ side: hedgeSide,
167
+ amountUsdc: plan.plannedHedgeUsdc,
168
+ result: { status: 'simulated' },
169
+ };
170
+ }
171
+
172
+ const hedgeResultOk = !options.executeLive || (action.hedge && action.hedge.result && action.hedge.result.ok !== false);
173
+ if (hedgeResultOk) {
174
+ const direction = plan.gapUsdc >= 0 ? 1 : -1;
175
+ state.currentHedgeUsdc =
176
+ round((toNumber(state.currentHedgeUsdc) || 0) + direction * plan.plannedHedgeUsdc, 6) || 0;
177
+ state.cumulativeHedgeNotionalUsdc =
178
+ round((toNumber(state.cumulativeHedgeNotionalUsdc) || 0) + plan.plannedHedgeUsdc, 6) || 0;
179
+ const slippageRatio =
180
+ hedgeDepth && hedgeDepth.midPrice !== null && hedgeDepth.worstPrice !== null && hedgeDepth.midPrice > 0
181
+ ? Math.max(0, Math.abs(hedgeDepth.worstPrice - hedgeDepth.midPrice) / hedgeDepth.midPrice)
182
+ : 0;
183
+ const hedgeCostApprox = plan.plannedHedgeUsdc * slippageRatio;
184
+ state.cumulativeHedgeCostApproxUsdc =
185
+ round((toNumber(state.cumulativeHedgeCostApproxUsdc) || 0) + hedgeCostApprox, 6) || 0;
186
+ return plan.plannedHedgeUsdc;
187
+ }
188
+
189
+ action.status = 'failed';
190
+ const hedgeError =
191
+ action.hedge && action.hedge.result && action.hedge.result.error
192
+ ? action.hedge.result.error
193
+ : action.hedge && action.hedge.result && action.hedge.result.response && action.hedge.result.response.error
194
+ ? { message: String(action.hedge.result.response.error) }
195
+ : { message: 'Polymarket hedge execution failed.' };
196
+ action.error = {
197
+ code: 'HEDGE_EXECUTION_FAILED',
198
+ message: hedgeError.message || 'Polymarket hedge execution failed.',
199
+ details: hedgeError,
200
+ };
201
+ return 0;
202
+ }
203
+
204
+ /**
205
+ * Apply executed leg totals to persistent state.
206
+ * @param {{state: object, action: object, idempotencyKey: string, actualRebalanceUsdc: number, actualHedgeUsdc: number}} params
207
+ * @returns {void}
208
+ */
209
+ function finalizeExecutedActionState(params) {
210
+ const { state, action, idempotencyKey, actualRebalanceUsdc, actualHedgeUsdc } = params;
211
+ const actualSpendUsdc = round(actualRebalanceUsdc + actualHedgeUsdc, 6) || 0;
212
+ state.dailySpendUsdc = round((toNumber(state.dailySpendUsdc) || 0) + actualSpendUsdc, 6) || 0;
213
+ const executedLegCount = (actualRebalanceUsdc > 0 ? 1 : 0) + (actualHedgeUsdc > 0 ? 1 : 0);
214
+ if (executedLegCount > 0) {
215
+ state.idempotencyKeys.push(idempotencyKey);
216
+ pruneIdempotencyKeys(state);
217
+ }
218
+ state.tradesToday += executedLegCount;
219
+ state.lastExecution = action;
220
+ }
221
+
222
+ /**
223
+ * Execute triggered action path (skip/blocked/executed) for a tick.
224
+ * @param {{options: object, state: object, snapshot: object, plan: object, gate: object, tickAt: Date, loadedFilePath: string, rebalanceFn: Function, hedgeFn: Function, sendWebhook: Function|null, strategyHash: string, iteration: number, actions: Array<object>, webhookReports: Array<object>, snapshotMetrics: object, verifyPayload: object, depth: object}} params
225
+ * @returns {Promise<void>}
226
+ */
227
+ async function processTriggeredAction(params) {
228
+ const {
229
+ options,
230
+ state,
231
+ snapshot,
232
+ plan,
233
+ gate,
234
+ tickAt,
235
+ loadedFilePath,
236
+ rebalanceFn,
237
+ hedgeFn,
238
+ sendWebhook,
239
+ strategyHash: hash,
240
+ iteration,
241
+ actions,
242
+ webhookReports,
243
+ snapshotMetrics,
244
+ verifyPayload,
245
+ depth,
246
+ } = params;
247
+
248
+ const idempotencyKey = buildIdempotencyKey(options, snapshot, tickAt.getTime());
249
+ if ((state.idempotencyKeys || []).includes(idempotencyKey)) {
250
+ snapshot.action = {
251
+ mode: options.executeLive ? 'live' : 'paper',
252
+ status: 'skipped',
253
+ reason: 'Duplicate trigger bucket (idempotency key already processed).',
254
+ idempotencyKey,
255
+ };
256
+ return;
257
+ }
258
+
259
+ if (!gate.ok) {
260
+ snapshot.action = {
261
+ mode: options.executeLive ? 'live' : 'paper',
262
+ status: 'blocked',
263
+ reason: 'Strict gate blocked execution.',
264
+ idempotencyKey,
265
+ failedChecks: gate.failedChecks,
266
+ failedChecksRaw: gate.failedChecksRaw,
267
+ bypassedFailedChecks: gate.bypassedFailedChecks,
268
+ };
269
+ return;
270
+ }
271
+
272
+ const action = buildExecutableAction({ options, idempotencyKey, gate });
273
+ state.lastExecution = {
274
+ mode: action.mode,
275
+ status: 'pending',
276
+ idempotencyKey,
277
+ startedAt: tickAt.toISOString(),
278
+ };
279
+ saveState(loadedFilePath, state);
280
+
281
+ const actualRebalanceUsdc = await executeRebalanceLeg({
282
+ options,
283
+ action,
284
+ plan,
285
+ snapshotMetrics,
286
+ rebalanceFn,
287
+ state,
288
+ });
289
+ const actualHedgeUsdc = await executeHedgeLeg({
290
+ options,
291
+ action,
292
+ plan,
293
+ verifyPayload,
294
+ depth,
295
+ hedgeFn,
296
+ state,
297
+ });
298
+
299
+ finalizeExecutedActionState({
300
+ state,
301
+ action,
302
+ idempotencyKey,
303
+ actualRebalanceUsdc,
304
+ actualHedgeUsdc,
305
+ });
306
+
307
+ snapshot.action = action;
308
+ actions.push(action);
309
+
310
+ if (sendWebhook) {
311
+ const report = await sendWebhook({
312
+ event: 'mirror.sync.trigger',
313
+ strategyHash: hash,
314
+ iteration,
315
+ message: `[Pandora Mirror] action=${action.status} drift=${snapshotMetrics.driftBps} hedgeGap=${plan.gapUsdc}`,
316
+ action,
317
+ snapshot,
318
+ });
319
+ webhookReports.push(report);
320
+ }
321
+ }
322
+
323
+ module.exports = {
324
+ buildIdempotencyKey,
325
+ buildExecutableAction,
326
+ normalizeExecutionFailure,
327
+ executeRebalanceLeg,
328
+ executeHedgeLeg,
329
+ finalizeExecutedActionState,
330
+ processTriggeredAction,
331
+ };
@@ -0,0 +1,282 @@
1
+ const { toNumber, round } = require('../shared/utils.cjs');
2
+
3
+ const MIRROR_SYNC_GATE_CODES = Object.freeze([
4
+ 'MATCH_AND_RULES',
5
+ 'POLYMARKET_SOURCE_FRESH',
6
+ 'CLOSE_TIME_DELTA',
7
+ 'DEPTH_COVERAGE',
8
+ 'MAX_OPEN_EXPOSURE',
9
+ 'MAX_TRADES_PER_DAY',
10
+ 'MIN_TIME_TO_EXPIRY',
11
+ ]);
12
+
13
+ /**
14
+ * Read min-time-to-expiry from verify payload in seconds.
15
+ * @param {object} verifyPayload
16
+ * @returns {number|null}
17
+ */
18
+ function readMinTimeToExpirySec(verifyPayload) {
19
+ return verifyPayload && verifyPayload.expiry && Number.isFinite(Number(verifyPayload.expiry.minTimeToExpirySec))
20
+ ? Number(verifyPayload.expiry.minTimeToExpirySec)
21
+ : null;
22
+ }
23
+
24
+ /**
25
+ * Derive sync metrics from verification payloads.
26
+ * `driftBps` is in basis points; reserve/delta/hedge fields are decimal USDC.
27
+ * @param {object} verifyPayload
28
+ * @param {{driftTriggerBps: number}} options
29
+ * @returns {{
30
+ * sourceYesPct: number|null,
31
+ * pandoraYesPct: number|null,
32
+ * driftBps: number|null,
33
+ * driftTriggered: boolean,
34
+ * reserveYesUsdc: number|null,
35
+ * reserveNoUsdc: number|null,
36
+ * reserveTotalUsdc: number|null,
37
+ * deltaLpUsdc: number|null,
38
+ * targetHedgeUsdc: number|null,
39
+ * minTimeToExpirySec: number|null
40
+ * }}
41
+ */
42
+ function evaluateSnapshot(verifyPayload, options) {
43
+ const pandora = verifyPayload.pandora || {};
44
+ const source = verifyPayload.sourceMarket || {};
45
+
46
+ const sourceYes = toNumber(source.yesPct);
47
+ const pandoraYes = toNumber(pandora.yesPct);
48
+
49
+ const driftBps = sourceYes !== null && pandoraYes !== null ? Math.abs(sourceYes - pandoraYes) * 100 : null;
50
+ const driftTriggered = driftBps !== null && driftBps >= options.driftTriggerBps;
51
+
52
+ const reserveYes = toNumber(pandora.reserveYes);
53
+ const reserveNo = toNumber(pandora.reserveNo);
54
+ const reserveTotalUsdc = reserveYes !== null && reserveNo !== null ? round(reserveYes + reserveNo, 6) : null;
55
+ const deltaLpUsdc = reserveYes !== null && reserveNo !== null ? round(reserveYes - reserveNo, 6) : null;
56
+ const targetHedgeUsdc = deltaLpUsdc === null ? null : round(-deltaLpUsdc, 6);
57
+ const minTimeToExpirySec = readMinTimeToExpirySec(verifyPayload);
58
+
59
+ return {
60
+ sourceYesPct: sourceYes,
61
+ pandoraYesPct: pandoraYes,
62
+ driftBps,
63
+ driftTriggered,
64
+ reserveYesUsdc: reserveYes,
65
+ reserveNoUsdc: reserveNo,
66
+ reserveTotalUsdc,
67
+ deltaLpUsdc,
68
+ targetHedgeUsdc,
69
+ minTimeToExpirySec,
70
+ };
71
+ }
72
+
73
+ /**
74
+ * Evaluate hard execution gates before submitting live sync actions.
75
+ * @param {object} context
76
+ * @returns {{ok: boolean, failedChecks: string[], checks: Array<{code: string, ok: boolean, message: string, details: any}>}}
77
+ */
78
+ function evaluateStrictGates(context) {
79
+ const failures = [];
80
+ const checks = [];
81
+
82
+ const add = (code, ok, message, details = null) => {
83
+ checks.push({ code, ok, message, details });
84
+ if (!ok) failures.push(code);
85
+ };
86
+
87
+ const verify = context.verifyPayload;
88
+ const verifyGate = verify && verify.gateResult ? verify.gateResult : null;
89
+ add('MATCH_AND_RULES', Boolean(verifyGate && verifyGate.ok), 'Mirror match/rules gates must pass.', {
90
+ failedChecks: verifyGate ? verifyGate.failedChecks : ['UNKNOWN'],
91
+ });
92
+
93
+ const sourceType = String((verify && verify.sourceMarket && verify.sourceMarket.source) || '').toLowerCase();
94
+ const sourceIsCached = sourceType === 'polymarket:cache';
95
+ add(
96
+ 'POLYMARKET_SOURCE_FRESH',
97
+ context.executeLive ? !sourceIsCached : true,
98
+ 'Live mode requires fresh Polymarket source data (cached source is blocked).',
99
+ {
100
+ sourceType: sourceType || null,
101
+ },
102
+ );
103
+
104
+ const closeDeltaCheck =
105
+ verifyGate && Array.isArray(verifyGate.checks)
106
+ ? verifyGate.checks.find((item) => item.code === 'CLOSE_TIME_DELTA')
107
+ : null;
108
+ add(
109
+ 'CLOSE_TIME_DELTA',
110
+ closeDeltaCheck ? closeDeltaCheck.ok : true,
111
+ 'Close-time delta must be within strict threshold.',
112
+ closeDeltaCheck && closeDeltaCheck.meta ? closeDeltaCheck.meta : null,
113
+ );
114
+
115
+ const depthRequired = toNumber(context.plannedHedgeUsdc) || 0;
116
+ const explicitHedgeDepth = toNumber(context.hedgeDepthWithinSlippageUsd);
117
+ const depthAvailable = explicitHedgeDepth === null ? toNumber(context.depthWithinSlippageUsd) || 0 : explicitHedgeDepth;
118
+ add(
119
+ 'DEPTH_COVERAGE',
120
+ depthRequired <= 0 ? true : depthAvailable >= depthRequired,
121
+ 'Source depth must cover hedge notional at configured slippage.',
122
+ {
123
+ depthRequired,
124
+ depthAvailable,
125
+ slippageBps: context.depthSlippageBps,
126
+ },
127
+ );
128
+
129
+ const state = context.state;
130
+ const postTradeHedgeExposure = Math.abs(
131
+ (toNumber(state.currentHedgeUsdc) || 0) + (toNumber(context.plannedHedgeSignedUsdc) || 0),
132
+ );
133
+ const maxExposure = toNumber(context.maxOpenExposureUsdc);
134
+ add(
135
+ 'MAX_OPEN_EXPOSURE',
136
+ maxExposure === null ? true : postTradeHedgeExposure <= maxExposure,
137
+ 'Max open exposure must not be exceeded.',
138
+ {
139
+ postTradeHedgeExposure: round(postTradeHedgeExposure, 6),
140
+ maxOpenExposureUsdc: maxExposure,
141
+ },
142
+ );
143
+
144
+ add(
145
+ 'MAX_TRADES_PER_DAY',
146
+ (toNumber(state.tradesToday) || 0) < context.maxTradesPerDay,
147
+ 'Daily trade cap must allow another execution.',
148
+ {
149
+ tradesToday: state.tradesToday,
150
+ maxTradesPerDay: context.maxTradesPerDay,
151
+ },
152
+ );
153
+
154
+ add(
155
+ 'MIN_TIME_TO_EXPIRY',
156
+ context.minTimeToExpirySec === null ? true : context.minTimeToExpirySec >= context.minimumTimeToCloseSec,
157
+ `Minimum time-to-expiry must be >= ${context.minimumTimeToCloseSec}s for sync runtime.`,
158
+ {
159
+ minTimeToExpirySec: context.minTimeToExpirySec,
160
+ minimumTimeToCloseSec: context.minimumTimeToCloseSec,
161
+ },
162
+ );
163
+
164
+ return {
165
+ ok: failures.length === 0,
166
+ failedChecks: failures,
167
+ checks,
168
+ };
169
+ }
170
+
171
+ function normalizeSkipGateChecks(rawChecks) {
172
+ if (!Array.isArray(rawChecks)) return [];
173
+ const allowed = new Set(MIRROR_SYNC_GATE_CODES);
174
+ return Array.from(
175
+ new Set(
176
+ rawChecks
177
+ .map((value) => String(value || '').trim().toUpperCase())
178
+ .filter((value) => value && allowed.has(value)),
179
+ ),
180
+ );
181
+ }
182
+
183
+ function applyGateBypassPolicy(gate, options) {
184
+ const failedChecksRaw = Array.isArray(gate && gate.failedChecks) ? [...gate.failedChecks] : [];
185
+ const forceGate = Boolean(options && options.forceGate);
186
+ const skipGateChecks = forceGate ? [] : normalizeSkipGateChecks(options && options.skipGateChecks);
187
+ const skipSet = new Set(skipGateChecks);
188
+ const bypassedFailedChecks = forceGate
189
+ ? failedChecksRaw
190
+ : failedChecksRaw.filter((code) => skipSet.has(code));
191
+ const failedChecks = forceGate
192
+ ? []
193
+ : failedChecksRaw.filter((code) => !skipSet.has(code));
194
+
195
+ return {
196
+ ...(gate || { checks: [] }),
197
+ ok: failedChecks.length === 0,
198
+ failedChecks,
199
+ failedChecksRaw,
200
+ bypassedFailedChecks,
201
+ skipGateChecksApplied: forceGate ? ['*'] : skipGateChecks,
202
+ forceGate,
203
+ };
204
+ }
205
+
206
+ /**
207
+ * Resolve runtime minimum time-to-close guard in seconds.
208
+ * @param {object} options
209
+ * @returns {number}
210
+ */
211
+ function resolveMinimumTimeToCloseSec(options) {
212
+ const configuredMinTimeToCloseSec = Number.isInteger(Number(options.minTimeToCloseSec))
213
+ ? Math.max(60, Math.trunc(Number(options.minTimeToCloseSec)))
214
+ : 1800;
215
+ return Math.max(
216
+ Math.ceil((options.intervalMs || 5_000) / 1000) * 2,
217
+ configuredMinTimeToCloseSec,
218
+ );
219
+ }
220
+
221
+ /**
222
+ * Build strict-gate evaluation context for a tick.
223
+ * @param {{verifyPayload: object, options: object, state: object, plan: object, snapshotMetrics: object, depth: object, minimumTimeToCloseSec: number}} params
224
+ * @returns {object}
225
+ */
226
+ function buildTickGateContext(params) {
227
+ const { verifyPayload, options, state, plan, snapshotMetrics, depth, minimumTimeToCloseSec } = params;
228
+ return {
229
+ verifyPayload,
230
+ executeLive: options.executeLive,
231
+ state,
232
+ plannedHedgeUsdc: plan.plannedHedgeUsdc,
233
+ plannedSpendUsdc: plan.plannedSpendUsdc,
234
+ plannedHedgeSignedUsdc: plan.plannedHedgeSignedUsdc,
235
+ minTimeToExpirySec: snapshotMetrics.minTimeToExpirySec,
236
+ minimumTimeToCloseSec,
237
+ depthWithinSlippageUsd: depth.depthWithinSlippageUsd,
238
+ hedgeDepthWithinSlippageUsd:
239
+ plan.plannedHedgeUsdc > 0
240
+ ? plan.gapUsdc >= 0
241
+ ? depth.yesDepth && depth.yesDepth.depthUsd
242
+ : depth.noDepth && depth.noDepth.depthUsd
243
+ : null,
244
+ depthSlippageBps: options.depthSlippageBps,
245
+ maxOpenExposureUsdc: options.maxOpenExposureUsdc,
246
+ maxTradesPerDay: options.maxTradesPerDay,
247
+ };
248
+ }
249
+
250
+ /**
251
+ * Verify mirror pair once at startup and enforce expiry guard.
252
+ * @param {{verifyFn: Function, options: object, minimumTimeToCloseSec: number, buildVerifyRequest: Function, createServiceError: Function}} params
253
+ * @returns {Promise<object>}
254
+ */
255
+ async function runStartupVerify(params) {
256
+ const { verifyFn, options, minimumTimeToCloseSec, buildVerifyRequest, createServiceError } = params;
257
+ const payload = await verifyFn(buildVerifyRequest(options));
258
+ const startupMinTime = readMinTimeToExpirySec(payload);
259
+ if (startupMinTime !== null && startupMinTime < minimumTimeToCloseSec) {
260
+ throw createServiceError(
261
+ 'MIRROR_EXPIRY_TOO_CLOSE',
262
+ `Mirror sync refused to start because market expiry is too close (${startupMinTime}s < ${minimumTimeToCloseSec}s).`,
263
+ {
264
+ startupMinTimeToExpirySec: startupMinTime,
265
+ minimumTimeToCloseSec,
266
+ },
267
+ );
268
+ }
269
+ return payload;
270
+ }
271
+
272
+ module.exports = {
273
+ MIRROR_SYNC_GATE_CODES,
274
+ readMinTimeToExpirySec,
275
+ evaluateSnapshot,
276
+ evaluateStrictGates,
277
+ normalizeSkipGateChecks,
278
+ applyGateBypassPolicy,
279
+ resolveMinimumTimeToCloseSec,
280
+ buildTickGateContext,
281
+ runStartupVerify,
282
+ };