@perpscope/percolator-adapter 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,80 @@
1
+ # @perpscope/percolator-adapter
2
+
3
+ Read-only adapter helpers for Solana perps terminals that want PerpScope DTOs without adopting the cockpit UI.
4
+
5
+ ```js
6
+ import {
7
+ buildPercolatorCompatibilityReport,
8
+ buildReadOnlyRpcSnapshot,
9
+ buildWatchtowerSignals,
10
+ normalizeFundingSkewHistory,
11
+ normalizePercolatorSnapshot,
12
+ simulatePriceShock
13
+ } from "@perpscope/percolator-adapter";
14
+
15
+ const snapshot = normalizePercolatorSnapshot(decodedPercolatorJson);
16
+ const market = snapshot.markets[0];
17
+ const stress = simulatePriceShock(market, -5);
18
+ const compatibility = buildPercolatorCompatibilityReport(decodedPercolatorJson, snapshot);
19
+ const watchtower = buildWatchtowerSignals(market, stress);
20
+ const carryHistory = normalizeFundingSkewHistory(market.history.fundingSkew, market);
21
+ ```
22
+
23
+ ## Boundary
24
+
25
+ The package exposes pure read-only helpers:
26
+
27
+ - `normalizePercolatorSnapshot()`
28
+ - `buildPercolatorCompatibilityReport()`
29
+ - `detectPercolatorInputShape()`
30
+ - `parsePercolatorJson()`
31
+ - `simulatePriceShock()`
32
+ - `toTerminalMarketDto()`
33
+ - `buildReadOnlyRpcSnapshot()`
34
+ - `fetchReadOnlyRpcSnapshot()`
35
+ - `validateReadOnlyRpcRequest()`
36
+ - `summarizeReadOnlyRpcDeployment()`
37
+ - `buildWatchtowerSignals()`
38
+ - `normalizeFundingSkewHistory()`
39
+ - `summarizeFundingSkewHistory()`
40
+
41
+ It does not connect wallets, sign, send, route, place orders, or submit transactions.
42
+
43
+ ## Compatibility Report
44
+
45
+ `buildPercolatorCompatibilityReport(input, snapshot?)` turns decoded JSON or captured stdout into a compact field map:
46
+
47
+ - `status`: `compatible`, `partial`, `unknown`, or `rejected`
48
+ - `score`: 0-100 scan score for terminal readiness
49
+ - `recognizedSections`: mapped market, price, engine, account, execution, receipt, history, and provenance sections
50
+ - `missingFields`: useful fields the cockpit can render better when supplied
51
+ - `ignoredFields`: top-level fields or command names preserved as provenance but not mapped yet
52
+
53
+ The full field-level contract is documented in `../../docs/field-compatibility-map.md`, with a machine-readable manifest at `../../examples/field-compatibility-map.json`.
54
+
55
+ ## DTO Example
56
+
57
+ ```js
58
+ {
59
+ source: { label: "Percolator CLI demo", mode: "read-only", commandSet: ["slab:get"] },
60
+ cluster: "mainnet-beta",
61
+ currentSlot: 346892110,
62
+ markets: [
63
+ {
64
+ id: "sol-perp",
65
+ name: "SOL-PERP",
66
+ slab: "PERCOLAT_SOL_8k4q...Qp2",
67
+ program: "Perco1ator111111111111111111111111111111111",
68
+ status: "stable",
69
+ price: { mark: 181.61, freshnessScore: 74, publishAgeSec: 2.1 },
70
+ funding: { bpsPerHour: 0.82, dailyUsd: 150.13 },
71
+ marketStructure: { oiSkewPct: 8.64, stressUsedPct: 23.6 },
72
+ history: {
73
+ fundingSkew: [
74
+ { fundingBpsPerHour: 0.82, oiSkewPct: 8.64, stressUsedPct: 23.6, oracleAgeSec: 2.1 }
75
+ ]
76
+ }
77
+ }
78
+ ]
79
+ }
80
+ ```
package/index.js ADDED
@@ -0,0 +1,24 @@
1
+ export {
2
+ assertReadOnlySnapshot,
3
+ buildPercolatorCompatibilityReport,
4
+ detectPercolatorInputShape,
5
+ normalizePercolatorCliBundle,
6
+ normalizePercolatorSnapshot,
7
+ parsePercolatorJson,
8
+ simulatePriceShock,
9
+ toTerminalMarketDto
10
+ } from "./src/lib/percolator-adapter.js";
11
+
12
+ export {
13
+ buildReadOnlyRpcSnapshot,
14
+ fetchReadOnlyRpcSnapshot,
15
+ summarizeReadOnlyRpcDeployment,
16
+ validateReadOnlyRpcRequest
17
+ } from "./src/lib/read-only-rpc-fetcher.js";
18
+
19
+ export {
20
+ normalizeFundingSkewHistory,
21
+ summarizeFundingSkewHistory
22
+ } from "./src/lib/funding-history.js";
23
+
24
+ export { buildWatchtowerSignals } from "./src/lib/watchtower-signals.js";
package/package.json ADDED
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "@perpscope/percolator-adapter",
3
+ "version": "0.4.0",
4
+ "type": "module",
5
+ "description": "Read-only Percolator adapter helpers for Solana perps terminals.",
6
+ "main": "./index.js",
7
+ "exports": {
8
+ ".": "./index.js"
9
+ },
10
+ "keywords": [
11
+ "solana",
12
+ "perps",
13
+ "percolator",
14
+ "risk",
15
+ "terminal-adapter"
16
+ ],
17
+ "license": "MIT",
18
+ "sideEffects": false
19
+ }
@@ -0,0 +1,282 @@
1
+ const HISTORY_COMMANDS = new Set([
2
+ "fundinghistory",
3
+ "fundingskewhistory",
4
+ "fundingrates",
5
+ "skewhistory",
6
+ "marketcarryhistory"
7
+ ]);
8
+ const MUTATING_KEYS = new Set([
9
+ "instruction",
10
+ "instructions",
11
+ "order",
12
+ "orders",
13
+ "send",
14
+ "sendtransaction",
15
+ "sign",
16
+ "secretkey",
17
+ "signature",
18
+ "signer",
19
+ "signtransaction",
20
+ "transaction",
21
+ "transactions",
22
+ "privatekey",
23
+ "keypair",
24
+ "mnemonic",
25
+ "seed",
26
+ "wallet",
27
+ "walletadapter",
28
+ "walletpath"
29
+ ]);
30
+
31
+ export function normalizeFundingSkewHistory(input, fallbackMarket = {}) {
32
+ const source = typeof input === "string" ? parseHistoryJson(input) : input;
33
+ assertReadOnlyHistory(source, "fundingHistory");
34
+ assertReadOnlyHistory(fallbackMarket, "fundingHistory.fallbackMarket");
35
+ const rows = fundingRowsOf(source);
36
+ const fallbackRows = rows.length ? rows : fallbackRowsOf(fallbackMarket);
37
+ return fallbackRows
38
+ .map((row, index) => normalizeFundingRow(row, fallbackMarket, index))
39
+ .sort(compareHistoryRows)
40
+ .slice(-48);
41
+ }
42
+
43
+ export function summarizeFundingSkewHistory(input, fallbackMarket = {}) {
44
+ const rows = (Array.isArray(input) ? input.slice() : normalizeFundingSkewHistory(input, fallbackMarket))
45
+ .sort(compareHistoryRows);
46
+ const latest = rows[rows.length - 1] || normalizeFundingRow({}, fallbackMarket, 0);
47
+ return {
48
+ latest,
49
+ count: rows.length,
50
+ fundingAvgBpsPerHour: mean(rows.map((row) => row.fundingBpsPerHour)),
51
+ oiSkewAvgPct: mean(rows.map((row) => row.oiSkewPct)),
52
+ stressMaxPct: Math.max(0, ...rows.map((row) => row.stressUsedPct)),
53
+ oracleAgeMaxSec: Math.max(0, ...rows.map((row) => row.oracleAgeSec)),
54
+ tone: historyTone(latest)
55
+ };
56
+ }
57
+
58
+ function fundingRowsOf(value) {
59
+ if (!value) return [];
60
+ if (Array.isArray(value)) return rowsOf(value);
61
+ if (typeof value !== "object") return [];
62
+
63
+ if (value.command && isHistoryCommand(value.command)) {
64
+ return rowsOf(value.output ?? value.data ?? value.result ?? value.stdout ?? value.stdoutText ?? value.rows ?? value.items);
65
+ }
66
+
67
+ for (const entry of value.commands || value.outputs || []) {
68
+ const name = entry?.command || entry?.name || entry?.label || entry?.kind;
69
+ if (!isHistoryCommand(name)) continue;
70
+ const output = entry.output ?? entry.data ?? entry.result ?? entry.stdout ?? entry.stdoutText ?? entry.rows ?? entry.items;
71
+ const parsed = typeof output === "string" ? parseHistoryJson(output) : output;
72
+ assertReadOnlyHistory(parsed, `fundingHistory.${name || "command"}`);
73
+ const rows = rowsOf(parsed);
74
+ if (rows.length) return rows;
75
+ }
76
+
77
+ for (const key of ["fundingSkew", "fundingHistory", "fundingSkewHistory", "history", "rows", "items"]) {
78
+ const rows = rowsOf(value[key]);
79
+ if (rows.length) return rows;
80
+ }
81
+
82
+ return [];
83
+ }
84
+
85
+ function fallbackRowsOf(market) {
86
+ const rows = rowsOf(market.history?.fundingSkew || market.history);
87
+ if (rows.length) return rows;
88
+ if (!market || typeof market !== "object") return [];
89
+ return [market];
90
+ }
91
+
92
+ function normalizeFundingRow(row, market, index) {
93
+ const longOi = firstNumber(row.longOpenInterestUsd, row.longOiUsd, row.long_oi_usd, row.marketStructure?.longOpenInterestUsd, market.marketStructure?.longOpenInterestUsd);
94
+ const shortOi = firstNumber(row.shortOpenInterestUsd, row.shortOiUsd, row.short_oi_usd, row.marketStructure?.shortOpenInterestUsd, market.marketStructure?.shortOpenInterestUsd);
95
+ const openInterest = firstNumber(row.openInterestUsd, row.oiUsd, row.open_interest_usd, row.marketStructure?.openInterestUsd, longOi + shortOi, market.marketStructure?.openInterestUsd);
96
+ const stressUsedPct = firstNumber(
97
+ row.stressUsedPct,
98
+ row.stressPct,
99
+ pctFromBps(row.stressConsumedBps, row.stressLimitBps),
100
+ market.marketStructure?.stressUsedPct
101
+ );
102
+ const oracleAgeSec = firstNumber(row.oracleAgeSec, row.publishAgeSec, row.ageSecs, row.priceAgeSec, market.price?.publishAgeSec);
103
+
104
+ return {
105
+ id: stringOf(row.id, row.label, `history-${index + 1}`),
106
+ label: stringOf(row.label, row.window, row.sourceTimestamp, `point ${index + 1}`),
107
+ source: stringOf(row.source, row.origin, market.sourceStatus, "adapter"),
108
+ sourceTimestamp: stringOf(row.sourceTimestamp, row.timestamp, row.observedAt, row.ts, ""),
109
+ slot: firstNumber(row.slot, row.sourceSlot, row.marketSlot, market.currentSlot),
110
+ fundingBpsPerHour: firstNumber(row.fundingBpsPerHour, row.fundingRateBpsPerHour, row.funding_bps_per_hour, market.funding?.bpsPerHour),
111
+ longOpenInterestUsd: longOi,
112
+ shortOpenInterestUsd: shortOi,
113
+ openInterestUsd: openInterest,
114
+ oiSkewPct: firstNumber(row.oiSkewPct, row.skewPct, pct(longOi - shortOi, openInterest), market.marketStructure?.oiSkewPct),
115
+ stressUsedPct,
116
+ oracleAgeSec
117
+ };
118
+ }
119
+
120
+ function historyTone(row) {
121
+ if (Math.abs(row.fundingBpsPerHour) >= 3.2 || Math.abs(row.oiSkewPct) >= 48 || row.stressUsedPct >= 78 || row.oracleAgeSec >= 8) {
122
+ return "danger";
123
+ }
124
+ if (Math.abs(row.fundingBpsPerHour) >= 1.4 || Math.abs(row.oiSkewPct) >= 24 || row.stressUsedPct >= 54 || row.oracleAgeSec >= 5) {
125
+ return "warning";
126
+ }
127
+ return "good";
128
+ }
129
+
130
+ function isHistoryCommand(value) {
131
+ return HISTORY_COMMANDS.has(normalizeKey(value || ""));
132
+ }
133
+
134
+ function rowsOf(value) {
135
+ if (typeof value === "string") {
136
+ const parsed = parseHistoryJson(value);
137
+ assertReadOnlyHistory(parsed, "fundingHistory.stdout");
138
+ return rowsOf(parsed);
139
+ }
140
+ if (Array.isArray(value)) return value.filter((item) => item && typeof item === "object");
141
+ if (Array.isArray(value?.rows)) return rowsOf(value.rows);
142
+ if (Array.isArray(value?.items)) return rowsOf(value.items);
143
+ if (Array.isArray(value?.history)) return rowsOf(value.history);
144
+ if (Array.isArray(value?.fundingSkew)) return rowsOf(value.fundingSkew);
145
+ if (Array.isArray(value?.fundingHistory)) return rowsOf(value.fundingHistory);
146
+ if (Array.isArray(value?.fundingSkewHistory)) return rowsOf(value.fundingSkewHistory);
147
+ return [];
148
+ }
149
+
150
+ function pct(value, base) {
151
+ const denominator = Math.abs(number(base));
152
+ return denominator ? (number(value) / denominator) * 100 : 0;
153
+ }
154
+
155
+ function pctFromBps(value, limit) {
156
+ const denominator = Math.abs(number(limit));
157
+ if (!denominator) return undefined;
158
+ return (number(value) / denominator) * 100;
159
+ }
160
+
161
+ function mean(values) {
162
+ const clean = values.map(number).filter(Number.isFinite);
163
+ if (!clean.length) return 0;
164
+ return clean.reduce((sum, value) => sum + value, 0) / clean.length;
165
+ }
166
+
167
+ function firstNumber(...values) {
168
+ for (const value of values) {
169
+ if (value === undefined || value === null || value === "") continue;
170
+ const next = number(value);
171
+ if (Number.isFinite(next)) return next;
172
+ }
173
+ return 0;
174
+ }
175
+
176
+ function stringOf(...values) {
177
+ for (const value of values) {
178
+ if (value !== undefined && value !== null && value !== "") return String(value);
179
+ }
180
+ return "";
181
+ }
182
+
183
+ function normalizeKey(value) {
184
+ return String(value).toLowerCase().replace(/[^a-z0-9]/g, "");
185
+ }
186
+
187
+ function number(value) {
188
+ const next = Number(typeof value === "string" ? value.replace(/[$,%_\s,]/g, "") : value);
189
+ return Number.isFinite(next) ? next : 0;
190
+ }
191
+
192
+ function compareHistoryRows(a, b) {
193
+ const slotA = maybeNumber(a?.slot);
194
+ const slotB = maybeNumber(b?.slot);
195
+ if (slotA !== undefined && slotB !== undefined && (slotA || slotB)) return slotA - slotB;
196
+ const timeA = maybeTime(a?.sourceTimestamp);
197
+ const timeB = maybeTime(b?.sourceTimestamp);
198
+ if (timeA !== undefined && timeB !== undefined) return timeA - timeB;
199
+ return 0;
200
+ }
201
+
202
+ function maybeNumber(value) {
203
+ if (value === undefined || value === null || value === "") return undefined;
204
+ const next = number(value);
205
+ return Number.isFinite(next) ? next : undefined;
206
+ }
207
+
208
+ function maybeTime(value) {
209
+ if (!value) return undefined;
210
+ const next = Date.parse(value);
211
+ return Number.isFinite(next) ? next : undefined;
212
+ }
213
+
214
+ function assertReadOnlyHistory(value, path = "fundingHistory") {
215
+ if (!value || typeof value !== "object") return;
216
+ for (const [key, child] of Object.entries(value)) {
217
+ const normalized = normalizeKey(key);
218
+ const nextPath = `${path}.${key}`;
219
+ if (isMutatingKey(normalized)) {
220
+ throw new Error(`Refusing mutating field in funding history: ${nextPath}`);
221
+ }
222
+ if (child && typeof child === "object") assertReadOnlyHistory(child, nextPath);
223
+ }
224
+ }
225
+
226
+ function isMutatingKey(key) {
227
+ if (MUTATING_KEYS.has(key)) return true;
228
+ return key.endsWith("secret") || key.endsWith("privatekey") || key.endsWith("keypair") || key.endsWith("mnemonic") || key.endsWith("seed");
229
+ }
230
+
231
+ function parseHistoryJson(text) {
232
+ const source = String(text).trim();
233
+ try {
234
+ return JSON.parse(source);
235
+ } catch {
236
+ const extracted = extractJsonPayload(source);
237
+ if (!extracted) throw new Error("No JSON payload found.");
238
+ return JSON.parse(extracted);
239
+ }
240
+ }
241
+
242
+ function extractJsonPayload(source) {
243
+ const starts = ["{", "["]
244
+ .map((char) => source.indexOf(char))
245
+ .filter((index) => index >= 0)
246
+ .sort((a, b) => a - b);
247
+ for (const start of starts) {
248
+ const end = findJsonEnd(source, start);
249
+ if (end > start) return source.slice(start, end + 1);
250
+ }
251
+ return "";
252
+ }
253
+
254
+ function findJsonEnd(source, start) {
255
+ const open = source[start];
256
+ const close = open === "{" ? "}" : "]";
257
+ let depth = 0;
258
+ let inString = false;
259
+ let escaped = false;
260
+ for (let index = start; index < source.length; index += 1) {
261
+ const char = source[index];
262
+ if (inString) {
263
+ if (escaped) {
264
+ escaped = false;
265
+ } else if (char === "\\") {
266
+ escaped = true;
267
+ } else if (char === "\"") {
268
+ inString = false;
269
+ }
270
+ continue;
271
+ }
272
+ if (char === "\"") {
273
+ inString = true;
274
+ } else if (char === open) {
275
+ depth += 1;
276
+ } else if (char === close) {
277
+ depth -= 1;
278
+ if (depth === 0) return index;
279
+ }
280
+ }
281
+ return -1;
282
+ }