@reefclaw/openclaw-plugin 0.1.5 → 0.1.7
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/bridge/gateway/event-parser.d.ts +6 -1
- package/bridge/gateway/event-parser.js +19 -2
- package/bridge/gateway/poller.d.ts +1 -0
- package/bridge/gateway/poller.js +14 -2
- package/bridge/providers/gateway.d.ts +22 -2
- package/bridge/providers/gateway.js +67 -9
- package/ccxt/binance-private.d.ts +21 -0
- package/ccxt/binance-private.js +132 -22
- package/ccxt/public-market-data-api.d.ts +14 -0
- package/ccxt/public-market-data-api.js +15 -1
- package/config/plugin-config-io.d.ts +13 -0
- package/config/plugin-config-io.js +15 -0
- package/config/tool-gate.js +3 -0
- package/exchange-adapter.d.ts +16 -0
- package/index.js +658 -83
- package/ingest/position-auto-capture.d.ts +68 -0
- package/ingest/position-auto-capture.js +321 -23
- package/ingest/position-decisions-client.d.ts +7 -2
- package/ingest/position-decisions-client.js +13 -3
- package/ingest/reconcile-db-vs-exchange.d.ts +39 -1
- package/ingest/reconcile-db-vs-exchange.js +66 -10
- package/lifecycle/trading-operation-lock.d.ts +17 -0
- package/lifecycle/trading-operation-lock.js +14 -0
- package/live/bracket-id.d.ts +2 -3
- package/live/bracket-id.js +22 -9
- package/live/fill-price.d.ts +13 -0
- package/live/fill-price.js +37 -0
- package/live/live-adapter.d.ts +57 -2
- package/live/live-adapter.js +290 -49
- package/live/local-signal-service.js +11 -6
- package/live/local-strategy-evaluator.js +4 -0
- package/live/position-state-store.d.ts +4 -0
- package/live/proposal-decision-listener.d.ts +6 -0
- package/live/proposal-decision-listener.js +4 -0
- package/live/stop-watcher.d.ts +35 -2
- package/live/stop-watcher.js +63 -3
- package/onboarding/runtime.d.ts +19 -0
- package/onboarding/runtime.js +34 -3
- package/openclaw.plugin.json +1 -0
- package/package.json +2 -2
- package/portfolio/reentry-tracker.d.ts +36 -0
- package/portfolio/reentry-tracker.js +127 -0
- package/portfolio/wave9-admission.d.ts +67 -0
- package/portfolio/wave9-admission.js +262 -0
- package/portfolio/wave9-policy.d.ts +36 -0
- package/portfolio/wave9-policy.js +183 -0
- package/signals/conditions/registry.js +61 -2
- package/signals/strategy-adapter.js +17 -7
- package/simulator/exchange-simulator.d.ts +12 -0
- package/simulator/exchange-simulator.js +78 -3
- package/simulator/fill-engine.js +5 -1
- package/simulator/types.d.ts +10 -1
- package/skills/reefclaw/SKILL.md +2 -0
- package/strategy/evaluator.d.ts +3 -0
- package/strategy/evaluator.js +5 -0
- package/tools/assessment-validation.d.ts +23 -0
- package/tools/assessment-validation.js +58 -0
- package/tools/attach-brackets.d.ts +7 -2
- package/tools/attach-brackets.js +201 -0
- package/tools/audit-bracket-protection.js +157 -1
- package/tools/bracket-control.d.ts +12 -0
- package/tools/bracket-control.js +35 -0
- package/tools/cancel-all-orders.d.ts +2 -0
- package/tools/cancel-all-orders.js +4 -1
- package/tools/cancel-order.d.ts +4 -0
- package/tools/cancel-order.js +49 -3
- package/tools/close-position.d.ts +23 -0
- package/tools/close-position.js +286 -13
- package/tools/create-order.d.ts +28 -0
- package/tools/create-order.js +1390 -190
- package/tools/get-analytics.js +2 -2
- package/tools/get-basis.js +2 -2
- package/tools/get-cascade-risk.js +2 -2
- package/tools/get-crypto-metrics.js +14 -4
- package/tools/get-cvd.js +2 -2
- package/tools/get-divergences.js +2 -2
- package/tools/get-funding-context.js +2 -2
- package/tools/get-liquidation-levels.js +2 -2
- package/tools/get-liquidation-pulse.js +2 -2
- package/tools/get-pattern-scan.js +2 -2
- package/tools/get-regime.js +2 -2
- package/tools/get-resting-liquidity.js +2 -2
- package/tools/get-risk-scenario.js +2 -2
- package/tools/get-session-review.js +2 -2
- package/tools/get-setup-detail.js +21 -2
- package/tools/get-signals.js +2 -2
- package/tools/get-sizing.js +2 -2
- package/tools/get-trade-feedback.js +2 -2
- package/tools/get-trade-flow.js +2 -2
- package/tools/get-volume-profile.js +2 -2
- package/tools/get-wave9-status.d.ts +127 -0
- package/tools/get-wave9-status.js +796 -0
- package/tools/intel-api.d.ts +20 -0
- package/tools/intel-api.js +67 -0
- package/tools/intel-cache.d.ts +1 -1
- package/tools/intel-cache.js +20 -5
- package/tools/list-strategies.d.ts +11 -1
- package/tools/list-strategies.js +17 -0
- package/tools/modify-stop.d.ts +4 -0
- package/tools/modify-stop.js +63 -24
- package/tools/modify-target.d.ts +4 -0
- package/tools/modify-target.js +62 -23
- package/tools/scan-pairs.d.ts +4 -0
- package/tools/scan-pairs.js +22 -8
- package/tools/toggle-strategy.js +7 -0
- package/types.d.ts +5 -0
- package/venues/hyperliquid/hl-balance.d.ts +116 -0
- package/venues/hyperliquid/hl-balance.js +145 -0
- package/venues/hyperliquid/hl-bracket-coordinator.d.ts +123 -0
- package/venues/hyperliquid/hl-bracket-coordinator.js +533 -0
- package/venues/hyperliquid/hl-brackets.d.ts +102 -0
- package/venues/hyperliquid/hl-brackets.js +172 -0
- package/venues/hyperliquid/hl-cloid.d.ts +22 -0
- package/venues/hyperliquid/hl-cloid.js +82 -0
- package/venues/hyperliquid/hl-info-cache.d.ts +46 -0
- package/venues/hyperliquid/hl-info-cache.js +125 -0
- package/venues/hyperliquid/hl-live-adapter.d.ts +146 -0
- package/venues/hyperliquid/hl-live-adapter.js +728 -0
- package/venues/hyperliquid/hl-precision.d.ts +61 -0
- package/venues/hyperliquid/hl-precision.js +176 -0
- package/venues/hyperliquid/hl-private.d.ts +88 -0
- package/venues/hyperliquid/hl-private.js +357 -0
- package/venues/hyperliquid/hl-public.d.ts +31 -4
- package/venues/hyperliquid/hl-public.js +163 -12
- package/venues/hyperliquid/hl-rate-gate.d.ts +57 -0
- package/venues/hyperliquid/hl-rate-gate.js +220 -0
- package/venues/hyperliquid/hl-user-stream.d.ts +90 -0
- package/venues/hyperliquid/hl-user-stream.js +220 -0
- package/venues/registry.d.ts +23 -9
- package/venues/registry.js +12 -13
- package/venues/symbols.d.ts +43 -0
- package/venues/symbols.js +107 -0
- package/wave9/live-account-capture.d.ts +67 -0
- package/wave9/live-account-capture.js +435 -0
- package/wave9/live-autonomous-protection.d.ts +39 -0
- package/wave9/live-autonomous-protection.js +112 -0
- package/wave9/live-durable-reconciliation-scheduler.d.ts +33 -0
- package/wave9/live-durable-reconciliation-scheduler.js +115 -0
- package/wave9/live-execution-ledger.d.ts +107 -0
- package/wave9/live-execution-ledger.js +498 -0
- package/wave9/live-position-confirmation.d.ts +18 -0
- package/wave9/live-position-confirmation.js +111 -0
- package/wave9/live-residual-protection.d.ts +18 -0
- package/wave9/live-residual-protection.js +250 -0
- package/wave9/live-startup-reconciliation.d.ts +38 -0
- package/wave9/live-startup-reconciliation.js +454 -0
- package/wave9/live-symbol-ownership.d.ts +20 -0
- package/wave9/live-symbol-ownership.js +132 -0
- package/wave9/paper-admission-guard.d.ts +199 -0
- package/wave9/paper-admission-guard.js +650 -0
- package/wave9/usdm-evidence-provider.d.ts +42 -0
- package/wave9/usdm-evidence-provider.js +133 -0
|
@@ -0,0 +1,650 @@
|
|
|
1
|
+
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import { WAVE9_SYMBOL_PRIORITY } from '../portfolio/wave9-policy.js';
|
|
3
|
+
/** One catalog toggle represents both frozen Wave 9 strategy legs. */
|
|
4
|
+
export const WAVE9_BUNDLE_SETUP_TYPE = 'wave9_28d_momentum_reversal';
|
|
5
|
+
export const WAVE9_MECHANICAL_EXIT_REASON = 'wave9_signal_reversal';
|
|
6
|
+
/** Conservative shared identity check for current, legacy, or Wave 9-mission positions. */
|
|
7
|
+
export function isWave9ManagedPosition(position) {
|
|
8
|
+
return position.setupType === WAVE9_BUNDLE_SETUP_TYPE
|
|
9
|
+
|| position.setupType === 'wave9_daily_tsmom'
|
|
10
|
+
|| position.missionId?.startsWith('wave9_') === true;
|
|
11
|
+
}
|
|
12
|
+
const TOKEN_PATTERN = /^w9[ex]_[A-Za-z0-9_-]{43}$/;
|
|
13
|
+
const FINGERPRINT_PATTERN = /^[a-f0-9]{64}$/;
|
|
14
|
+
const DECIMAL_PATTERN = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$/;
|
|
15
|
+
const MAX_ID_LENGTH = 512;
|
|
16
|
+
export class Wave9PaperAdmissionGuardError extends Error {
|
|
17
|
+
constructor(message) {
|
|
18
|
+
super(message);
|
|
19
|
+
this.name = 'Wave9PaperAdmissionGuardError';
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function canonicalSymbol(symbol) {
|
|
23
|
+
return symbol.toUpperCase().replace(/[^A-Z0-9]/g, '').replace(/USDTUSDT$/, 'USDT');
|
|
24
|
+
}
|
|
25
|
+
function requireFinite(value, label) {
|
|
26
|
+
if (!Number.isFinite(value))
|
|
27
|
+
throw new Wave9PaperAdmissionGuardError(`${label} must be finite`);
|
|
28
|
+
}
|
|
29
|
+
function finiteOrNull(value) {
|
|
30
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
|
31
|
+
}
|
|
32
|
+
function exactPositiveNumber(value, label) {
|
|
33
|
+
if (typeof value !== 'string' || !DECIMAL_PATTERN.test(value)) {
|
|
34
|
+
throw new Wave9PaperAdmissionGuardError(`${label} must be an exact decimal string`);
|
|
35
|
+
}
|
|
36
|
+
const parsed = Number(value);
|
|
37
|
+
if (!Number.isFinite(parsed) || parsed <= 0 || String(parsed) !== value) {
|
|
38
|
+
throw new Wave9PaperAdmissionGuardError(`${label} must be the canonical string form of a positive finite number`);
|
|
39
|
+
}
|
|
40
|
+
return parsed;
|
|
41
|
+
}
|
|
42
|
+
function exactText(value, label) {
|
|
43
|
+
if (typeof value !== 'string' || value.length === 0 || value.length > MAX_ID_LENGTH || value.trim() !== value) {
|
|
44
|
+
throw new Wave9PaperAdmissionGuardError(`${label} is malformed`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function requireExecutionMode(value) {
|
|
48
|
+
if (value !== 'PAPER' && value !== 'LIVE') {
|
|
49
|
+
throw new Wave9PaperAdmissionGuardError('Wave 9 execution mode must be PAPER or LIVE');
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
function sameSecret(left, right) {
|
|
53
|
+
const a = Buffer.from(left);
|
|
54
|
+
const b = Buffer.from(right);
|
|
55
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
56
|
+
}
|
|
57
|
+
function stableMissionId(candidateId) {
|
|
58
|
+
return `wave9_${createHash('sha256').update(candidateId).digest('hex').slice(0, 32)}`;
|
|
59
|
+
}
|
|
60
|
+
function parseEntryCandidate(decision) {
|
|
61
|
+
const parts = decision.candidateId.split('|');
|
|
62
|
+
if (parts.length !== 4)
|
|
63
|
+
throw new Wave9PaperAdmissionGuardError('candidateId is not a frozen Wave 9 identity');
|
|
64
|
+
const [eventTime, symbol, strategy, direction] = parts;
|
|
65
|
+
const eventMs = Date.parse(eventTime);
|
|
66
|
+
if (!Number.isFinite(eventMs) || new Date(eventMs).toISOString() !== eventTime) {
|
|
67
|
+
throw new Wave9PaperAdmissionGuardError('candidateId event time is not canonical ISO');
|
|
68
|
+
}
|
|
69
|
+
if (symbol !== decision.symbol || !['LONG', 'SHORT'].includes(direction)) {
|
|
70
|
+
throw new Wave9PaperAdmissionGuardError('candidateId does not match symbol/direction');
|
|
71
|
+
}
|
|
72
|
+
if ((direction === 'LONG' && strategy !== 'tsmom_28d_long_flat_reversal_1d')
|
|
73
|
+
|| (direction === 'SHORT' && strategy !== 'tsmom_28d_short_flat_reversal_1d')) {
|
|
74
|
+
throw new Wave9PaperAdmissionGuardError('candidateId does not name a frozen Wave 9 leg');
|
|
75
|
+
}
|
|
76
|
+
if ((direction === 'LONG' ? 'BUY' : 'SELL') !== decision.side) {
|
|
77
|
+
throw new Wave9PaperAdmissionGuardError('entry side does not match candidate direction');
|
|
78
|
+
}
|
|
79
|
+
return { eventTime, direction: direction };
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Capture the complete process-local book inputs used to defend a Wave 9
|
|
83
|
+
* admission against local TOCTOU. This is deliberately labelled non-
|
|
84
|
+
* authoritative: the production paper deployment has no cross-process owner
|
|
85
|
+
* lease, so a matching hash is useful fail-closed protection, never parity or
|
|
86
|
+
* promotion evidence.
|
|
87
|
+
*/
|
|
88
|
+
export function captureWave9PaperAccountSnapshot(adapter) {
|
|
89
|
+
if (adapter.isLive) {
|
|
90
|
+
throw new Wave9PaperAdmissionGuardError('Wave 9 paper account capture hard-refuses a live adapter');
|
|
91
|
+
}
|
|
92
|
+
const simulator = adapter.getSimulator?.();
|
|
93
|
+
if (!simulator) {
|
|
94
|
+
throw new Wave9PaperAdmissionGuardError('Wave 9 account fingerprint requires a local paper simulator');
|
|
95
|
+
}
|
|
96
|
+
const state = simulator.getState();
|
|
97
|
+
const quoteCurrency = state.config.quoteCurrency;
|
|
98
|
+
const positions = state.positions.map((position) => {
|
|
99
|
+
const markPrice = simulator.getLastTicker(position.symbol)?.last ?? position.entryPrice;
|
|
100
|
+
const stopPrice = finiteOrNull(position.metadata?.originalStopPrice ?? position.metadata?.stopPrice);
|
|
101
|
+
return {
|
|
102
|
+
symbol: position.symbol,
|
|
103
|
+
side: position.side,
|
|
104
|
+
quantity: position.quantity,
|
|
105
|
+
entryPrice: position.entryPrice,
|
|
106
|
+
markPrice,
|
|
107
|
+
openedAt: position.openedAt,
|
|
108
|
+
missionId: position.metadata?.missionId ?? null,
|
|
109
|
+
setupType: position.metadata?.setupType ?? null,
|
|
110
|
+
candidateId: position.metadata?.candidateId ?? null,
|
|
111
|
+
strategy: position.metadata?.strategy ?? null,
|
|
112
|
+
accruedFundingUsd: finiteOrNull(position.metadata?.accruedFundingUsd),
|
|
113
|
+
fundingThroughTime: position.metadata?.fundingThroughTime ?? null,
|
|
114
|
+
stopPrice,
|
|
115
|
+
initialOpenRiskUsd: stopPrice === null
|
|
116
|
+
? null
|
|
117
|
+
: Math.abs(position.entryPrice - stopPrice) * position.quantity,
|
|
118
|
+
};
|
|
119
|
+
}).sort((left, right) => left.symbol.localeCompare(right.symbol)
|
|
120
|
+
|| left.side.localeCompare(right.side)
|
|
121
|
+
|| left.openedAt.localeCompare(right.openedAt));
|
|
122
|
+
const openOrders = state.openOrders.map((order) => ({
|
|
123
|
+
id: order.id,
|
|
124
|
+
symbol: order.symbol,
|
|
125
|
+
side: order.side,
|
|
126
|
+
type: order.type,
|
|
127
|
+
amount: order.amount,
|
|
128
|
+
filled: order.filled,
|
|
129
|
+
price: finiteOrNull(order.price),
|
|
130
|
+
})).sort((left, right) => left.id.localeCompare(right.id));
|
|
131
|
+
const snapshot = {
|
|
132
|
+
schema: 'wave9-paper-account-v1',
|
|
133
|
+
savedAt: finiteOrNull(state.savedAt),
|
|
134
|
+
quoteCurrency,
|
|
135
|
+
wallet: Object.entries(state.wallet).map(([currency, balance]) => ({
|
|
136
|
+
currency,
|
|
137
|
+
total: balance.total,
|
|
138
|
+
available: balance.available,
|
|
139
|
+
locked: balance.locked,
|
|
140
|
+
})).sort((left, right) => left.currency.localeCompare(right.currency)),
|
|
141
|
+
equityUsd: simulator.computeEquity(),
|
|
142
|
+
sessionStartNavUsd: finiteOrNull(state.sessionStartNav),
|
|
143
|
+
positions,
|
|
144
|
+
openOrders,
|
|
145
|
+
closedWave9CandidateIds: [...new Set(state.tradeHistory
|
|
146
|
+
.filter((trade) => isWave9ManagedPosition(trade.metadata ?? {}))
|
|
147
|
+
.map((trade) => trade.metadata?.candidateId)
|
|
148
|
+
.filter((candidateId) => typeof candidateId === 'string' && candidateId.length > 0))]
|
|
149
|
+
.sort((left, right) => left.localeCompare(right)),
|
|
150
|
+
};
|
|
151
|
+
const fingerprint = createHash('sha256').update(JSON.stringify(snapshot)).digest('hex');
|
|
152
|
+
return {
|
|
153
|
+
fingerprint,
|
|
154
|
+
snapshot,
|
|
155
|
+
authoritative: false,
|
|
156
|
+
runtimeParity: false,
|
|
157
|
+
provenance: 'process_local_unlocked_simulator',
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
/** Stable held-position identity for a mechanical reversal close. */
|
|
161
|
+
export function captureWave9PaperPositionFingerprint(adapter, symbol) {
|
|
162
|
+
const account = captureWave9PaperAccountSnapshot(adapter);
|
|
163
|
+
const canonical = canonicalSymbol(symbol);
|
|
164
|
+
const matches = account.snapshot.positions.filter((position) => canonicalSymbol(position.symbol) === canonical);
|
|
165
|
+
if (matches.length !== 1) {
|
|
166
|
+
throw new Wave9PaperAdmissionGuardError(`Wave 9 exit fingerprint requires exactly one held ${canonical} position`);
|
|
167
|
+
}
|
|
168
|
+
const row = matches[0];
|
|
169
|
+
const snapshot = {
|
|
170
|
+
symbol: row.symbol,
|
|
171
|
+
side: row.side,
|
|
172
|
+
quantity: row.quantity,
|
|
173
|
+
entryPrice: row.entryPrice,
|
|
174
|
+
openedAt: row.openedAt,
|
|
175
|
+
missionId: row.missionId,
|
|
176
|
+
setupType: row.setupType,
|
|
177
|
+
candidateId: row.candidateId,
|
|
178
|
+
strategy: row.strategy,
|
|
179
|
+
stopPrice: row.stopPrice,
|
|
180
|
+
initialOpenRiskUsd: row.initialOpenRiskUsd,
|
|
181
|
+
};
|
|
182
|
+
return {
|
|
183
|
+
fingerprint: createHash('sha256').update(JSON.stringify(snapshot)).digest('hex'),
|
|
184
|
+
snapshot,
|
|
185
|
+
authoritative: false,
|
|
186
|
+
runtimeParity: false,
|
|
187
|
+
provenance: 'process_local_unlocked_simulator',
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
/** In-memory capability guard shared by get_wave9_status and mutation tools. */
|
|
191
|
+
export class Wave9PaperAdmissionGuard {
|
|
192
|
+
now;
|
|
193
|
+
tokenTtlMs;
|
|
194
|
+
batches = new Map();
|
|
195
|
+
events = new Map();
|
|
196
|
+
entriesByToken = new Map();
|
|
197
|
+
exitsByIdentity = new Map();
|
|
198
|
+
exitsByToken = new Map();
|
|
199
|
+
constructor(options = {}) {
|
|
200
|
+
this.now = options.now ?? Date.now;
|
|
201
|
+
this.tokenTtlMs = options.tokenTtlMs ?? 120_000;
|
|
202
|
+
if (!Number.isFinite(this.tokenTtlMs) || this.tokenTtlMs <= 0 || this.tokenTtlMs > 10 * 60_000) {
|
|
203
|
+
throw new Wave9PaperAdmissionGuardError('tokenTtlMs must be in (0, 600000]');
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
issueBatch(decisions) {
|
|
207
|
+
if (decisions.length === 0)
|
|
208
|
+
throw new Wave9PaperAdmissionGuardError('cannot issue an empty admission batch');
|
|
209
|
+
if (decisions.length !== 1) {
|
|
210
|
+
throw new Wave9PaperAdmissionGuardError('only the next accepted Wave 9 candidate may be authorized; re-evaluate the actual book after its fill');
|
|
211
|
+
}
|
|
212
|
+
const sorted = decisions.map((decision) => ({ ...decision })).sort((a, b) => a.sequence - b.sequence);
|
|
213
|
+
const first = sorted[0];
|
|
214
|
+
exactText(first.batchId, 'batchId');
|
|
215
|
+
requireExecutionMode(first.tradingMode);
|
|
216
|
+
if (!FINGERPRINT_PATTERN.test(first.accountFingerprint)) {
|
|
217
|
+
throw new Wave9PaperAdmissionGuardError('accountFingerprint is malformed');
|
|
218
|
+
}
|
|
219
|
+
let eventTime = '';
|
|
220
|
+
const identities = new Set();
|
|
221
|
+
for (const decision of sorted) {
|
|
222
|
+
if (!Number.isInteger(decision.sequence) || decision.sequence < 0) {
|
|
223
|
+
throw new Wave9PaperAdmissionGuardError('entry sequence must be a non-negative integer');
|
|
224
|
+
}
|
|
225
|
+
requireExecutionMode(decision.tradingMode);
|
|
226
|
+
if (decision.batchId !== first.batchId
|
|
227
|
+
|| decision.accountFingerprint !== first.accountFingerprint
|
|
228
|
+
|| decision.tradingMode !== first.tradingMode) {
|
|
229
|
+
throw new Wave9PaperAdmissionGuardError('one batch must share batchId, accountFingerprint, and tradingMode');
|
|
230
|
+
}
|
|
231
|
+
exactText(decision.candidateId, 'candidateId');
|
|
232
|
+
exactText(decision.missionId, 'missionId');
|
|
233
|
+
if (identities.has(decision.candidateId))
|
|
234
|
+
throw new Wave9PaperAdmissionGuardError('duplicate candidateId');
|
|
235
|
+
identities.add(decision.candidateId);
|
|
236
|
+
if (!WAVE9_SYMBOL_PRIORITY.includes(decision.symbol)) {
|
|
237
|
+
throw new Wave9PaperAdmissionGuardError(`unsupported Wave 9 symbol ${decision.symbol}`);
|
|
238
|
+
}
|
|
239
|
+
const parsed = parseEntryCandidate(decision);
|
|
240
|
+
if (eventTime && parsed.eventTime !== eventTime) {
|
|
241
|
+
throw new Wave9PaperAdmissionGuardError('one admission batch must share an eventTime');
|
|
242
|
+
}
|
|
243
|
+
eventTime = parsed.eventTime;
|
|
244
|
+
if (decision.missionId !== stableMissionId(decision.candidateId)) {
|
|
245
|
+
throw new Wave9PaperAdmissionGuardError('missionId does not match stable candidate identity');
|
|
246
|
+
}
|
|
247
|
+
exactPositiveNumber(decision.quantity, 'quantity');
|
|
248
|
+
exactPositiveNumber(decision.stopPrice, 'stopPrice');
|
|
249
|
+
this.validateWindow(decision.notBeforeMs, decision.deadlineMs);
|
|
250
|
+
}
|
|
251
|
+
const event = this.events.get(eventTime) ?? { consumedCandidateIds: new Set() };
|
|
252
|
+
this.events.set(eventTime, event);
|
|
253
|
+
if (event.latchedReason || event.inFlightCandidateId)
|
|
254
|
+
return [];
|
|
255
|
+
if (event.consumedCandidateIds.has(first.candidateId))
|
|
256
|
+
return [];
|
|
257
|
+
let batch = this.batches.get(first.batchId);
|
|
258
|
+
if (!batch) {
|
|
259
|
+
// Invalidate every untouched capability for this event before binding a
|
|
260
|
+
// newly evaluated fingerprint. Only one current mutation capability may
|
|
261
|
+
// exist even when status uses fingerprinted batch IDs.
|
|
262
|
+
for (const prior of this.batches.values()) {
|
|
263
|
+
if (prior.eventTime !== eventTime)
|
|
264
|
+
continue;
|
|
265
|
+
for (const record of prior.records) {
|
|
266
|
+
if (record.status !== 'issued')
|
|
267
|
+
continue;
|
|
268
|
+
this.entriesByToken.delete(record.token);
|
|
269
|
+
record.status = 'consumed';
|
|
270
|
+
record.retryableAfterAbort = false;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
batch = {
|
|
274
|
+
batchId: first.batchId,
|
|
275
|
+
accountFingerprint: first.accountFingerprint,
|
|
276
|
+
eventTime,
|
|
277
|
+
nextSequence: first.sequence,
|
|
278
|
+
records: sorted.map((decision) => this.newEntryRecord(decision)),
|
|
279
|
+
};
|
|
280
|
+
this.batches.set(first.batchId, batch);
|
|
281
|
+
for (const record of batch.records)
|
|
282
|
+
this.entriesByToken.set(record.token, record);
|
|
283
|
+
}
|
|
284
|
+
else {
|
|
285
|
+
const old = batch.records[0];
|
|
286
|
+
const same = batch.eventTime === eventTime
|
|
287
|
+
&& batch.accountFingerprint === first.accountFingerprint
|
|
288
|
+
&& this.sameEntryDecision(old, first);
|
|
289
|
+
if (same && old.status === 'consumed' && old.retryableAfterAbort) {
|
|
290
|
+
this.entriesByToken.delete(old.token);
|
|
291
|
+
batch = {
|
|
292
|
+
batchId: first.batchId,
|
|
293
|
+
accountFingerprint: first.accountFingerprint,
|
|
294
|
+
eventTime,
|
|
295
|
+
nextSequence: first.sequence,
|
|
296
|
+
records: [this.newEntryRecord(first)],
|
|
297
|
+
};
|
|
298
|
+
this.batches.set(first.batchId, batch);
|
|
299
|
+
this.entriesByToken.set(batch.records[0].token, batch.records[0]);
|
|
300
|
+
}
|
|
301
|
+
else if (!same) {
|
|
302
|
+
if (old.status === 'in_flight') {
|
|
303
|
+
throw new Wave9PaperAdmissionGuardError('cannot replace an in-flight Wave 9 admission');
|
|
304
|
+
}
|
|
305
|
+
if (old.status === 'consumed'
|
|
306
|
+
&& !old.retryableAfterAbort
|
|
307
|
+
&& !event.consumedCandidateIds.has(old.candidateId)) {
|
|
308
|
+
throw new Wave9PaperAdmissionGuardError('cannot replace an attempted Wave 9 admission');
|
|
309
|
+
}
|
|
310
|
+
this.entriesByToken.delete(old.token);
|
|
311
|
+
batch = {
|
|
312
|
+
batchId: first.batchId,
|
|
313
|
+
accountFingerprint: first.accountFingerprint,
|
|
314
|
+
eventTime,
|
|
315
|
+
nextSequence: first.sequence,
|
|
316
|
+
records: [this.newEntryRecord(first)],
|
|
317
|
+
};
|
|
318
|
+
this.batches.set(first.batchId, batch);
|
|
319
|
+
this.entriesByToken.set(batch.records[0].token, batch.records[0]);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
if (batch.latchedReason)
|
|
323
|
+
return [];
|
|
324
|
+
const now = this.now();
|
|
325
|
+
const issued = [];
|
|
326
|
+
for (const record of batch.records) {
|
|
327
|
+
if (record.status === 'consumed' || record.sequence < batch.nextSequence || record.deadlineMs <= now)
|
|
328
|
+
continue;
|
|
329
|
+
if (record.status === 'issued' && record.tokenDeadlineMs <= now)
|
|
330
|
+
this.rotateEntryToken(record);
|
|
331
|
+
if (record.status !== 'issued')
|
|
332
|
+
continue;
|
|
333
|
+
issued.push({
|
|
334
|
+
token: record.token,
|
|
335
|
+
batchId: record.batchId,
|
|
336
|
+
candidateId: record.candidateId,
|
|
337
|
+
missionId: record.missionId,
|
|
338
|
+
sequence: record.sequence,
|
|
339
|
+
deadlineMs: record.tokenDeadlineMs,
|
|
340
|
+
accountFingerprint: record.accountFingerprint,
|
|
341
|
+
tradingMode: record.tradingMode,
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
return issued;
|
|
345
|
+
}
|
|
346
|
+
issueExitBatch(decisions) {
|
|
347
|
+
if (decisions.length === 0)
|
|
348
|
+
throw new Wave9PaperAdmissionGuardError('cannot issue an empty exit batch');
|
|
349
|
+
const now = this.now();
|
|
350
|
+
const seen = new Set();
|
|
351
|
+
const issued = [];
|
|
352
|
+
for (const input of decisions) {
|
|
353
|
+
const decision = { ...input };
|
|
354
|
+
this.validateExitDecision(decision);
|
|
355
|
+
const identity = this.exitIdentity(decision);
|
|
356
|
+
if (seen.has(identity))
|
|
357
|
+
throw new Wave9PaperAdmissionGuardError('duplicate exit identity');
|
|
358
|
+
seen.add(identity);
|
|
359
|
+
let record = this.exitsByIdentity.get(identity);
|
|
360
|
+
if (record) {
|
|
361
|
+
// A reduce-only LIVE close can have an ambiguous response or leave a
|
|
362
|
+
// partial position. Once its in-flight lease is explicitly aborted as
|
|
363
|
+
// retryable, a fresh trusted position snapshot must be able to issue a
|
|
364
|
+
// new authorization even though the residual fingerprint changed.
|
|
365
|
+
// Evaluate that terminal retry state before exact-decision replacement;
|
|
366
|
+
// otherwise the changed fingerprint is rejected as an attempted exit.
|
|
367
|
+
if (record.status === 'consumed' && record.retryableAfterAbort) {
|
|
368
|
+
this.exitsByToken.delete(record.token);
|
|
369
|
+
record = this.newExitRecord(decision);
|
|
370
|
+
this.exitsByIdentity.set(identity, record);
|
|
371
|
+
this.exitsByToken.set(record.token, record);
|
|
372
|
+
}
|
|
373
|
+
else {
|
|
374
|
+
const same = this.sameExitDecision(record, decision);
|
|
375
|
+
if (!same && record.status === 'issued') {
|
|
376
|
+
this.exitsByToken.delete(record.token);
|
|
377
|
+
record = this.newExitRecord(decision);
|
|
378
|
+
this.exitsByIdentity.set(identity, record);
|
|
379
|
+
this.exitsByToken.set(record.token, record);
|
|
380
|
+
}
|
|
381
|
+
else if (!same) {
|
|
382
|
+
throw new Wave9PaperAdmissionGuardError('cannot replace an attempted Wave 9 exit authorization');
|
|
383
|
+
}
|
|
384
|
+
if (record.status === 'consumed')
|
|
385
|
+
continue;
|
|
386
|
+
if (record.status === 'issued' && record.tokenDeadlineMs <= now && record.deadlineMs > now) {
|
|
387
|
+
this.rotateExitToken(record);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
else {
|
|
392
|
+
record = this.newExitRecord(decision);
|
|
393
|
+
this.exitsByIdentity.set(identity, record);
|
|
394
|
+
this.exitsByToken.set(record.token, record);
|
|
395
|
+
}
|
|
396
|
+
if (record.status === 'issued' && record.deadlineMs > now) {
|
|
397
|
+
issued.push({
|
|
398
|
+
token: record.token,
|
|
399
|
+
candidateId: record.candidateId,
|
|
400
|
+
missionId: record.missionId,
|
|
401
|
+
deadlineMs: record.tokenDeadlineMs,
|
|
402
|
+
positionFingerprint: record.positionFingerprint,
|
|
403
|
+
tradingMode: record.tradingMode,
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
return issued;
|
|
408
|
+
}
|
|
409
|
+
beginEntry(request) {
|
|
410
|
+
this.validateTokenShape(request.token, 'entry');
|
|
411
|
+
const record = this.entriesByToken.get(request.token);
|
|
412
|
+
if (!record || !sameSecret(record.token, request.token))
|
|
413
|
+
throw new Wave9PaperAdmissionGuardError('unknown Wave 9 admission token');
|
|
414
|
+
const batch = this.batches.get(record.batchId);
|
|
415
|
+
if (!batch)
|
|
416
|
+
throw new Wave9PaperAdmissionGuardError('Wave 9 admission batch is unavailable');
|
|
417
|
+
if (batch.latchedReason)
|
|
418
|
+
throw new Wave9PaperAdmissionGuardError(`Wave 9 admission batch is latched: ${batch.latchedReason}`);
|
|
419
|
+
this.assertUsable(record);
|
|
420
|
+
if (record.sequence !== batch.nextSequence) {
|
|
421
|
+
throw new Wave9PaperAdmissionGuardError(`Wave 9 admission sequence ${record.sequence} cannot run before ${batch.nextSequence}`);
|
|
422
|
+
}
|
|
423
|
+
const event = this.events.get(batch.eventTime);
|
|
424
|
+
if (!event || event.latchedReason || event.inFlightCandidateId) {
|
|
425
|
+
throw new Wave9PaperAdmissionGuardError('Wave 9 daily event is unavailable or already in flight');
|
|
426
|
+
}
|
|
427
|
+
if (request.setupType !== WAVE9_BUNDLE_SETUP_TYPE)
|
|
428
|
+
throw new Wave9PaperAdmissionGuardError('Wave 9 setup_type mismatch');
|
|
429
|
+
requireExecutionMode(request.tradingMode);
|
|
430
|
+
if (request.type.toLowerCase() !== 'market')
|
|
431
|
+
throw new Wave9PaperAdmissionGuardError('Wave 9 entries must use immediate market execution');
|
|
432
|
+
if (canonicalSymbol(request.symbol) !== record.symbol
|
|
433
|
+
|| request.side.toUpperCase() !== record.side
|
|
434
|
+
|| request.quantity !== exactPositiveNumber(record.quantity, 'quantity')
|
|
435
|
+
|| request.stopPrice !== exactPositiveNumber(record.stopPrice, 'stopPrice')
|
|
436
|
+
|| request.candidateId !== record.candidateId
|
|
437
|
+
|| request.missionId !== record.missionId
|
|
438
|
+
|| request.tradingMode !== record.tradingMode) {
|
|
439
|
+
throw new Wave9PaperAdmissionGuardError('Wave 9 order does not exactly match its frozen admission');
|
|
440
|
+
}
|
|
441
|
+
this.assertFingerprint(record.accountFingerprint, request.accountFingerprint);
|
|
442
|
+
event.inFlightCandidateId = record.candidateId;
|
|
443
|
+
return this.lease(record, 'entry');
|
|
444
|
+
}
|
|
445
|
+
beginExit(request) {
|
|
446
|
+
this.validateTokenShape(request.token, 'exit');
|
|
447
|
+
const record = this.exitsByToken.get(request.token);
|
|
448
|
+
if (!record || !sameSecret(record.token, request.token))
|
|
449
|
+
throw new Wave9PaperAdmissionGuardError('unknown Wave 9 exit token');
|
|
450
|
+
this.assertUsable(record);
|
|
451
|
+
requireExecutionMode(request.tradingMode);
|
|
452
|
+
if (!isWave9ManagedPosition({ setupType: request.setupType, missionId: request.missionId })
|
|
453
|
+
|| request.reason !== WAVE9_MECHANICAL_EXIT_REASON
|
|
454
|
+
|| canonicalSymbol(request.symbol) !== record.symbol
|
|
455
|
+
|| request.positionSide !== record.positionSide
|
|
456
|
+
|| request.candidateId !== record.candidateId
|
|
457
|
+
|| request.missionId !== record.missionId
|
|
458
|
+
|| request.tradingMode !== record.tradingMode) {
|
|
459
|
+
throw new Wave9PaperAdmissionGuardError('Wave 9 close does not exactly match its reversal authorization');
|
|
460
|
+
}
|
|
461
|
+
this.assertFingerprint(record.positionFingerprint, request.positionFingerprint);
|
|
462
|
+
return this.lease(record, 'exit');
|
|
463
|
+
}
|
|
464
|
+
revalidate(lease, fingerprint) {
|
|
465
|
+
const record = this.recordForLease(lease);
|
|
466
|
+
if (record.status !== 'in_flight' || record.leaseId !== lease.leaseId) {
|
|
467
|
+
throw new Wave9PaperAdmissionGuardError('Wave 9 authorization lease is no longer active');
|
|
468
|
+
}
|
|
469
|
+
if (this.now() >= record.tokenDeadlineMs || this.now() >= record.deadlineMs) {
|
|
470
|
+
throw new Wave9PaperAdmissionGuardError('Wave 9 authorization expired before mutation');
|
|
471
|
+
}
|
|
472
|
+
const expected = lease.kind === 'entry'
|
|
473
|
+
? record.accountFingerprint
|
|
474
|
+
: record.positionFingerprint;
|
|
475
|
+
this.assertFingerprint(expected, fingerprint);
|
|
476
|
+
}
|
|
477
|
+
commit(lease) {
|
|
478
|
+
const record = this.recordForLease(lease);
|
|
479
|
+
if (record.status !== 'in_flight' || record.leaseId !== lease.leaseId) {
|
|
480
|
+
throw new Wave9PaperAdmissionGuardError('Wave 9 authorization lease is no longer active');
|
|
481
|
+
}
|
|
482
|
+
record.status = 'consumed';
|
|
483
|
+
record.leaseId = undefined;
|
|
484
|
+
if (lease.kind === 'entry') {
|
|
485
|
+
const entry = record;
|
|
486
|
+
const batch = this.batches.get(entry.batchId);
|
|
487
|
+
if (!batch || entry.sequence !== batch.nextSequence) {
|
|
488
|
+
throw new Wave9PaperAdmissionGuardError('Wave 9 entry committed outside its frozen sequence');
|
|
489
|
+
}
|
|
490
|
+
batch.nextSequence += 1;
|
|
491
|
+
const event = this.events.get(batch.eventTime);
|
|
492
|
+
if (event) {
|
|
493
|
+
event.inFlightCandidateId = undefined;
|
|
494
|
+
event.consumedCandidateIds.add(entry.candidateId);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
else {
|
|
498
|
+
record.retryableAfterAbort = false;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
/**
|
|
502
|
+
* Consume a failed capability. Entry failure latches the whole projected
|
|
503
|
+
* batch because later sizing assumed every earlier accepted entry. Exit
|
|
504
|
+
* retry is allowed when the caller explicitly proves another attempt stays
|
|
505
|
+
* risk-reducing, including a residual/unknown result after a reduce-only
|
|
506
|
+
* LIVE close submission.
|
|
507
|
+
*/
|
|
508
|
+
abort(lease, reason, options = {}) {
|
|
509
|
+
const record = this.recordForLease(lease);
|
|
510
|
+
if (record.status !== 'in_flight' || record.leaseId !== lease.leaseId)
|
|
511
|
+
return;
|
|
512
|
+
record.status = 'consumed';
|
|
513
|
+
record.leaseId = undefined;
|
|
514
|
+
if (lease.kind === 'entry') {
|
|
515
|
+
const entry = record;
|
|
516
|
+
entry.retryableAfterAbort = options.retryable === true;
|
|
517
|
+
const batch = this.batches.get(entry.batchId);
|
|
518
|
+
if (batch) {
|
|
519
|
+
const event = this.events.get(batch.eventTime);
|
|
520
|
+
if (event)
|
|
521
|
+
event.inFlightCandidateId = undefined;
|
|
522
|
+
if (options.latchEvent !== false) {
|
|
523
|
+
batch.latchedReason = reason.slice(0, 300);
|
|
524
|
+
if (event) {
|
|
525
|
+
event.latchedReason = batch.latchedReason;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
else {
|
|
531
|
+
record.retryableAfterAbort = options.retryable === true;
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
validateWindow(notBeforeMs, deadlineMs) {
|
|
535
|
+
requireFinite(notBeforeMs, 'notBeforeMs');
|
|
536
|
+
requireFinite(deadlineMs, 'deadlineMs');
|
|
537
|
+
if (!Number.isInteger(notBeforeMs) || !Number.isInteger(deadlineMs) || deadlineMs < notBeforeMs) {
|
|
538
|
+
throw new Wave9PaperAdmissionGuardError('authorization window is malformed');
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
newEntryRecord(decision) {
|
|
542
|
+
const token = this.newToken('entry');
|
|
543
|
+
return {
|
|
544
|
+
...decision,
|
|
545
|
+
token,
|
|
546
|
+
tokenDeadlineMs: Math.min(decision.deadlineMs, this.now() + this.tokenTtlMs),
|
|
547
|
+
status: 'issued',
|
|
548
|
+
retryableAfterAbort: false,
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
newExitRecord(decision) {
|
|
552
|
+
const token = this.newToken('exit');
|
|
553
|
+
return {
|
|
554
|
+
...decision,
|
|
555
|
+
token,
|
|
556
|
+
tokenDeadlineMs: Math.min(decision.deadlineMs, this.now() + this.tokenTtlMs),
|
|
557
|
+
status: 'issued',
|
|
558
|
+
retryableAfterAbort: false,
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
rotateEntryToken(record) {
|
|
562
|
+
this.entriesByToken.delete(record.token);
|
|
563
|
+
record.token = this.newToken('entry');
|
|
564
|
+
record.tokenDeadlineMs = Math.min(record.deadlineMs, this.now() + this.tokenTtlMs);
|
|
565
|
+
this.entriesByToken.set(record.token, record);
|
|
566
|
+
}
|
|
567
|
+
rotateExitToken(record) {
|
|
568
|
+
this.exitsByToken.delete(record.token);
|
|
569
|
+
record.token = this.newToken('exit');
|
|
570
|
+
record.tokenDeadlineMs = Math.min(record.deadlineMs, this.now() + this.tokenTtlMs);
|
|
571
|
+
this.exitsByToken.set(record.token, record);
|
|
572
|
+
}
|
|
573
|
+
newToken(kind) {
|
|
574
|
+
return `w9${kind === 'entry' ? 'e' : 'x'}_${randomBytes(32).toString('base64url')}`;
|
|
575
|
+
}
|
|
576
|
+
validateTokenShape(token, kind) {
|
|
577
|
+
if (typeof token !== 'string' || !TOKEN_PATTERN.test(token) || !token.startsWith(kind === 'entry' ? 'w9e_' : 'w9x_')) {
|
|
578
|
+
throw new Wave9PaperAdmissionGuardError(`malformed Wave 9 ${kind} token`);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
assertUsable(record) {
|
|
582
|
+
const now = this.now();
|
|
583
|
+
if (record.status !== 'issued')
|
|
584
|
+
throw new Wave9PaperAdmissionGuardError('Wave 9 authorization token is already used');
|
|
585
|
+
if (now < record.notBeforeMs)
|
|
586
|
+
throw new Wave9PaperAdmissionGuardError('Wave 9 authorization is not active yet');
|
|
587
|
+
if (now >= record.tokenDeadlineMs || now >= record.deadlineMs)
|
|
588
|
+
throw new Wave9PaperAdmissionGuardError('Wave 9 authorization token expired');
|
|
589
|
+
}
|
|
590
|
+
lease(record, kind) {
|
|
591
|
+
const leaseId = randomBytes(24).toString('base64url');
|
|
592
|
+
record.status = 'in_flight';
|
|
593
|
+
record.leaseId = leaseId;
|
|
594
|
+
return Object.freeze({ leaseId, kind, token: record.token });
|
|
595
|
+
}
|
|
596
|
+
recordForLease(lease) {
|
|
597
|
+
const record = lease.kind === 'entry'
|
|
598
|
+
? this.entriesByToken.get(lease.token)
|
|
599
|
+
: this.exitsByToken.get(lease.token);
|
|
600
|
+
if (!record || record.leaseId !== lease.leaseId) {
|
|
601
|
+
throw new Wave9PaperAdmissionGuardError('unknown Wave 9 authorization lease');
|
|
602
|
+
}
|
|
603
|
+
return record;
|
|
604
|
+
}
|
|
605
|
+
assertFingerprint(expected, actual) {
|
|
606
|
+
if (!FINGERPRINT_PATTERN.test(actual) || !sameSecret(expected, actual)) {
|
|
607
|
+
throw new Wave9PaperAdmissionGuardError('Wave 9 account changed after authorization; mutation refused');
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
sameEntryDecision(record, decision) {
|
|
611
|
+
return ['candidateId', 'missionId', 'symbol', 'side', 'quantity', 'stopPrice', 'sequence', 'notBeforeMs', 'deadlineMs', 'accountFingerprint', 'tradingMode']
|
|
612
|
+
.every((key) => record[key] === decision[key]);
|
|
613
|
+
}
|
|
614
|
+
validateExitDecision(decision) {
|
|
615
|
+
exactText(decision.candidateId, 'exit candidateId');
|
|
616
|
+
exactText(decision.missionId, 'missionId');
|
|
617
|
+
requireExecutionMode(decision.tradingMode);
|
|
618
|
+
if (decision.reason !== 'signal_reversal')
|
|
619
|
+
throw new Wave9PaperAdmissionGuardError('Wave 9 exit reason must be signal_reversal');
|
|
620
|
+
if (!WAVE9_SYMBOL_PRIORITY.includes(decision.symbol)) {
|
|
621
|
+
throw new Wave9PaperAdmissionGuardError(`unsupported Wave 9 symbol ${decision.symbol}`);
|
|
622
|
+
}
|
|
623
|
+
if (!['long', 'short'].includes(decision.positionSide))
|
|
624
|
+
throw new Wave9PaperAdmissionGuardError('positionSide is malformed');
|
|
625
|
+
if (decision.missionId !== stableMissionId(decision.candidateId)) {
|
|
626
|
+
throw new Wave9PaperAdmissionGuardError('exit missionId does not match stable candidate identity');
|
|
627
|
+
}
|
|
628
|
+
const parts = decision.candidateId.split('|');
|
|
629
|
+
const expectedStrategy = decision.positionSide === 'long'
|
|
630
|
+
? 'tsmom_28d_long_flat_reversal_1d'
|
|
631
|
+
: 'tsmom_28d_short_flat_reversal_1d';
|
|
632
|
+
const expectedDirection = decision.positionSide === 'long' ? 'LONG' : 'SHORT';
|
|
633
|
+
if (parts.length !== 4
|
|
634
|
+
|| parts[1] !== decision.symbol
|
|
635
|
+
|| parts[2] !== expectedStrategy
|
|
636
|
+
|| parts[3] !== expectedDirection) {
|
|
637
|
+
throw new Wave9PaperAdmissionGuardError('exit candidateId does not match held Wave 9 leg');
|
|
638
|
+
}
|
|
639
|
+
if (!FINGERPRINT_PATTERN.test(decision.positionFingerprint))
|
|
640
|
+
throw new Wave9PaperAdmissionGuardError('positionFingerprint is malformed');
|
|
641
|
+
this.validateWindow(decision.notBeforeMs, decision.deadlineMs);
|
|
642
|
+
}
|
|
643
|
+
exitIdentity(decision) {
|
|
644
|
+
return `${decision.candidateId}|${decision.missionId}|${decision.symbol}|${decision.positionSide}|${decision.tradingMode}`;
|
|
645
|
+
}
|
|
646
|
+
sameExitDecision(record, decision) {
|
|
647
|
+
return ['candidateId', 'missionId', 'symbol', 'positionSide', 'reason', 'notBeforeMs', 'deadlineMs', 'positionFingerprint', 'tradingMode']
|
|
648
|
+
.every((key) => record[key] === decision[key]);
|
|
649
|
+
}
|
|
650
|
+
}
|