@perpscope/percolator-adapter 0.5.0 → 0.7.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 CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  buildPercolatorCompatibilityReport,
12
12
  buildReadOnlyRpcSnapshot,
13
13
  buildWatchtowerSignals,
14
+ compareCompatibilityReports,
14
15
  normalizeFundingSkewHistory,
15
16
  normalizePercolatorSnapshot,
16
17
  simulatePriceShock
@@ -20,6 +21,7 @@ const snapshot = normalizePercolatorSnapshot(decodedPercolatorJson);
20
21
  const market = snapshot.markets[0];
21
22
  const stress = simulatePriceShock(market, -5);
22
23
  const compatibility = buildPercolatorCompatibilityReport(decodedPercolatorJson, snapshot);
24
+ const drift = compareCompatibilityReports(previousCompatibility, compatibility);
23
25
  const watchtower = buildWatchtowerSignals(market, stress);
24
26
  const carryHistory = normalizeFundingSkewHistory(market.history.fundingSkew, market);
25
27
  ```
@@ -30,6 +32,8 @@ The package exposes pure read-only helpers:
30
32
 
31
33
  - `normalizePercolatorSnapshot()`
32
34
  - `buildPercolatorCompatibilityReport()`
35
+ - `exportCompatibilityReport()`
36
+ - `compareCompatibilityReports()`
33
37
  - `detectPercolatorInputShape()`
34
38
  - `parsePercolatorJson()`
35
39
  - `simulatePriceShock()`
@@ -53,9 +57,25 @@ It does not connect wallets, sign, send, route, place orders, or submit transact
53
57
  - `recognizedSections`: mapped market, price, engine, account, execution, receipt, history, and provenance sections
54
58
  - `missingFields`: useful fields the cockpit can render better when supplied
55
59
  - `ignoredFields`: top-level fields or command names preserved as provenance but not mapped yet
60
+ - `aliasSuggestions`: candidate mappings such as `oraclePriceUsd -> price.mark`
56
61
 
57
62
  The full field-level contract is documented in `../../docs/field-compatibility-map.md`, with a machine-readable manifest at `../../examples/field-compatibility-map.json`.
58
63
 
64
+ `compareCompatibilityReports(previous, current)` returns `perpscope.compatibility-diff` with score delta, status change, new/resolved fields, section drift, and merged alias suggestions.
65
+
66
+ ## CLI
67
+
68
+ ```bash
69
+ perpscope compat report capture.json
70
+ perpscope compat diff previous.json current.json
71
+ ```
72
+
73
+ Try it locally with:
74
+
75
+ ```bash
76
+ perpscope compat diff ../../examples/fixture-pack-minimal-terminal.json ../../examples/fixture-pack-drifted-aliases.json
77
+ ```
78
+
59
79
  ## DTO Example
60
80
 
61
81
  ```js
@@ -0,0 +1,61 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync } from "node:fs";
3
+ import {
4
+ buildPercolatorCompatibilityReport,
5
+ compareCompatibilityReports,
6
+ exportCompatibilityReport,
7
+ normalizePercolatorSnapshot,
8
+ parsePercolatorJson
9
+ } from "../index.js";
10
+
11
+ const [, , ...args] = process.argv;
12
+
13
+ function usage() {
14
+ return [
15
+ "Usage:",
16
+ " perpscope compat report <capture.json>",
17
+ " perpscope compat diff <previous.json> <current.json>",
18
+ "",
19
+ "Read-only only: the adapter rejects wallet, signer, transaction, instruction, order, private key, seed, mnemonic, and API key fields."
20
+ ].join("\n");
21
+ }
22
+
23
+ function readCapture(path) {
24
+ if (!path) throw new Error("Missing capture path.");
25
+ return parsePercolatorJson(readFileSync(path, "utf8"));
26
+ }
27
+
28
+ function buildReport(input) {
29
+ const snapshot = normalizePercolatorSnapshot(input);
30
+ return buildPercolatorCompatibilityReport(input, snapshot);
31
+ }
32
+
33
+ function main() {
34
+ const [scope, command, ...rest] = args;
35
+ if (!scope || scope === "--help" || scope === "-h") {
36
+ console.log(usage());
37
+ return;
38
+ }
39
+ if (scope !== "compat") throw new Error(`Unknown scope: ${scope}`);
40
+ if (command === "report") {
41
+ const input = readCapture(rest[0]);
42
+ console.log(JSON.stringify(exportCompatibilityReport(input), null, 2));
43
+ return;
44
+ }
45
+ if (command === "diff") {
46
+ const previous = buildReport(readCapture(rest[0]));
47
+ const current = buildReport(readCapture(rest[1]));
48
+ console.log(JSON.stringify(compareCompatibilityReports(previous, current), null, 2));
49
+ return;
50
+ }
51
+ throw new Error(`Unknown compat command: ${command || ""}`.trim());
52
+ }
53
+
54
+ try {
55
+ main();
56
+ } catch (error) {
57
+ console.error(error.message);
58
+ console.error("");
59
+ console.error(usage());
60
+ process.exitCode = 1;
61
+ }
package/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  export {
2
2
  assertReadOnlySnapshot,
3
3
  buildPercolatorCompatibilityReport,
4
+ compareCompatibilityReports,
4
5
  detectPercolatorInputShape,
5
6
  exportCompatibilityReport,
6
7
  exportCompatibilityReportFromReport,
package/package.json CHANGED
@@ -1,9 +1,12 @@
1
1
  {
2
2
  "name": "@perpscope/percolator-adapter",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "type": "module",
5
5
  "description": "Read-only Percolator adapter helpers for Solana perps terminals.",
6
6
  "main": "./index.js",
7
+ "bin": {
8
+ "perpscope": "bin/perpscope.mjs"
9
+ },
7
10
  "exports": {
8
11
  ".": "./index.js"
9
12
  },
@@ -1,6 +1,6 @@
1
1
  import { normalizeFundingSkewHistory } from "./funding-history.js";
2
2
 
3
- export const PERPSCOPE_ADAPTER_VERSION = "0.5.0";
3
+ export const PERPSCOPE_ADAPTER_VERSION = "0.7.0";
4
4
 
5
5
  const KEYPAIR_FIELD_PATTERN = /(^|_)(secret|private|keypair|mnemonic|seed|walletPath|wallet)(_|$)/i;
6
6
  const HISTORY_COMMAND_KEYS = new Set([
@@ -98,6 +98,7 @@ export function buildPercolatorCompatibilityReport(input, normalizedSnapshot) {
98
98
  detail: field.detail
99
99
  }));
100
100
  const ignoredFields = ignoredCompatibilityFields(input);
101
+ const aliasSuggestions = buildCompatibilityAliasSuggestions(missingFields, ignoredFields);
101
102
  const dangerCount = missingFields.filter((field) => field.severity === "danger").length;
102
103
  const warningCount = missingFields.length - dangerCount;
103
104
  const recognizedDataCount = recognizedSections.filter((section) => section.id !== "safety").length;
@@ -119,6 +120,7 @@ export function buildPercolatorCompatibilityReport(input, normalizedSnapshot) {
119
120
  recognizedSections,
120
121
  missingFields,
121
122
  ignoredFields,
123
+ aliasSuggestions,
122
124
  source: {
123
125
  label: source.label || stringOf(scope, ["label", "sourceLabel"], "decoded capture"),
124
126
  mode: source.mode || "read-only",
@@ -133,12 +135,66 @@ export function buildPercolatorCompatibilityReport(input, normalizedSnapshot) {
133
135
  recognizedCount: recognizedSections.length,
134
136
  missingCount: missingFields.length,
135
137
  ignoredCount: ignoredFields.length,
138
+ suggestionCount: aliasSuggestions.length,
136
139
  marketCount: snapshot.markets.length,
137
140
  commandCount: commandSet.length
138
141
  }
139
142
  };
140
143
  }
141
144
 
145
+ export function compareCompatibilityReports(previousReport, currentReport, options = {}) {
146
+ const previous = normalizeCompatibilityReport(previousReport);
147
+ const current = normalizeCompatibilityReport(currentReport);
148
+ const resolvedMissing = differenceByField(previous.missingFields, current.missingFields);
149
+ const newMissing = differenceByField(current.missingFields, previous.missingFields);
150
+ const resolvedIgnored = differenceByPath(previous.ignoredFields, current.ignoredFields);
151
+ const newIgnored = differenceByPath(current.ignoredFields, previous.ignoredFields);
152
+ const addedSections = differenceById(current.recognizedSections, previous.recognizedSections);
153
+ const removedSections = differenceById(previous.recognizedSections, current.recognizedSections);
154
+ const scoreDelta = Number(current.score || 0) - Number(previous.score || 0);
155
+ const statusChanged = previous.status !== current.status;
156
+ const suggestionSet = mergeAliasSuggestions(
157
+ current.aliasSuggestions || buildCompatibilityAliasSuggestions(current.missingFields, current.ignoredFields),
158
+ buildCompatibilityAliasSuggestions(newMissing, newIgnored)
159
+ );
160
+ const tone = current.status === "rejected" || newMissing.some((field) => field.severity === "danger")
161
+ ? "danger"
162
+ : scoreDelta < 0 || newMissing.length || newIgnored.length || removedSections.length
163
+ ? "warning"
164
+ : "good";
165
+
166
+ return {
167
+ schema: "perpscope.compatibility-diff",
168
+ version: 1,
169
+ package: {
170
+ name: "@perpscope/percolator-adapter",
171
+ version: options.packageVersion || PERPSCOPE_ADAPTER_VERSION
172
+ },
173
+ generatedAt: options.generatedAt || new Date().toISOString(),
174
+ tone,
175
+ scoreDelta,
176
+ statusChanged,
177
+ previous: compatibilityDiffSummary(previous),
178
+ current: compatibilityDiffSummary(current),
179
+ resolvedMissing,
180
+ newMissing,
181
+ resolvedIgnored,
182
+ newIgnored,
183
+ addedSections,
184
+ removedSections,
185
+ aliasSuggestions: suggestionSet,
186
+ summary: {
187
+ resolvedMissingCount: resolvedMissing.length,
188
+ newMissingCount: newMissing.length,
189
+ resolvedIgnoredCount: resolvedIgnored.length,
190
+ newIgnoredCount: newIgnored.length,
191
+ addedSectionCount: addedSections.length,
192
+ removedSectionCount: removedSections.length,
193
+ suggestionCount: suggestionSet.length
194
+ }
195
+ };
196
+ }
197
+
142
198
  export function exportCompatibilityReport(input, normalizedSnapshot, options = {}) {
143
199
  const snapshot = normalizedSnapshot || normalizePercolatorSnapshot(input);
144
200
  const report = buildPercolatorCompatibilityReport(input, snapshot);
@@ -166,8 +222,12 @@ export function exportCompatibilityReportFromReport(report, options = {}) {
166
222
  recognizedSections: report.recognizedSections,
167
223
  missingFields: report.missingFields,
168
224
  ignoredFields: report.ignoredFields,
225
+ aliasSuggestions: report.aliasSuggestions || buildCompatibilityAliasSuggestions(report.missingFields, report.ignoredFields),
169
226
  source: report.source,
170
- summary: report.summary
227
+ summary: {
228
+ ...report.summary,
229
+ suggestionCount: report.summary?.suggestionCount ?? (report.aliasSuggestions || []).length
230
+ }
171
231
  };
172
232
  }
173
233
 
@@ -443,6 +503,177 @@ function ignoredCompatibilityFields(input) {
443
503
  return ignored.slice(0, 8);
444
504
  }
445
505
 
506
+ const COMPATIBILITY_ALIAS_HINTS = [
507
+ {
508
+ field: "market.slab",
509
+ tokens: ["slab", "marketaddress", "marketpubkey", "pubkey", "address"],
510
+ reason: "Use this as the market slab anchor."
511
+ },
512
+ {
513
+ field: "market.program",
514
+ tokens: ["program", "programid", "owner"],
515
+ reason: "Use this as the decoded account owner/program id."
516
+ },
517
+ {
518
+ field: "price.mark",
519
+ tokens: ["mark", "markprice", "oracleprice", "oraclepriceusd", "priceusd", "mid", "midprice"],
520
+ reason: "Map this into the mark/oracle price lane."
521
+ },
522
+ {
523
+ field: "price.publishAgeSec",
524
+ tokens: ["age", "agesec", "agesecs", "publishage", "publishagesec", "oracleage", "staleness"],
525
+ reason: "Map this into oracle freshness."
526
+ },
527
+ {
528
+ field: "crank.ageSlots",
529
+ tokens: ["crank", "crankage", "ageslots", "lastcrank", "lastmarketslot"],
530
+ reason: "Map this into crank freshness."
531
+ },
532
+ {
533
+ field: "funding.bpsPerHour",
534
+ tokens: ["funding", "fundingrate", "fundingbps", "premium", "carry"],
535
+ reason: "Map this into the carry-rate card."
536
+ },
537
+ {
538
+ field: "marketStructure.openInterestUsd",
539
+ tokens: ["openinterest", "oi", "oiusd", "longoi", "shortoi"],
540
+ reason: "Map this into OI, skew, and stress pressure."
541
+ },
542
+ {
543
+ field: "account.positionNotionalUsd",
544
+ tokens: ["notional", "positionnotional", "position", "basesize", "positionbase"],
545
+ reason: "Map this into account runway."
546
+ },
547
+ {
548
+ field: "execution.bestBid/bestAsk",
549
+ tokens: ["bestbid", "bestask", "bid", "ask", "quote", "book", "orderbook"],
550
+ reason: "Map this into spread and execution quality."
551
+ },
552
+ {
553
+ field: "execution.receipts",
554
+ tokens: ["receipt", "receipts", "fills", "fill", "execution"],
555
+ reason: "Map this into the fill receipt timeline."
556
+ },
557
+ {
558
+ field: "history.fundingSkew",
559
+ tokens: ["history", "fundinghistory", "skewhistory", "fundingskew", "rows"],
560
+ reason: "Map this into the carry-history sparklines."
561
+ }
562
+ ];
563
+
564
+ function buildCompatibilityAliasSuggestions(missingFields = [], ignoredFields = []) {
565
+ const suggestions = [];
566
+ for (const missing of missingFields || []) {
567
+ const candidates = (ignoredFields || [])
568
+ .map((ignored) => aliasSuggestionFor(missing, ignored))
569
+ .filter(Boolean)
570
+ .sort((a, b) => b.score - a.score)
571
+ .slice(0, 2);
572
+ if (candidates.length) {
573
+ suggestions.push(...candidates);
574
+ } else if (missing.severity === "danger") {
575
+ suggestions.push({
576
+ field: missing.field,
577
+ candidatePath: "",
578
+ confidence: "needs-input",
579
+ score: 0,
580
+ action: "add-field",
581
+ reason: `${missing.label} is still required for a trusted terminal view.`
582
+ });
583
+ }
584
+ }
585
+ return dedupeAliasSuggestions(suggestions).slice(0, 8);
586
+ }
587
+
588
+ function aliasSuggestionFor(missing, ignored) {
589
+ const hint = COMPATIBILITY_ALIAS_HINTS.find((entry) => entry.field === missing.field);
590
+ if (!hint || !ignored) return null;
591
+ const haystack = normalizeKey(`${ignored.path || ""} ${ignored.label || ""}`);
592
+ const score = hint.tokens.reduce((best, token) => {
593
+ const normalizedToken = normalizeKey(token);
594
+ if (haystack === normalizedToken) return Math.max(best, 100);
595
+ if (haystack.includes(normalizedToken) || normalizedToken.includes(haystack)) return Math.max(best, 86);
596
+ return best;
597
+ }, 0);
598
+ if (!score) return null;
599
+ return {
600
+ field: missing.field,
601
+ candidatePath: ignored.path,
602
+ confidence: score >= 90 ? "high" : "medium",
603
+ score,
604
+ action: "map-alias",
605
+ reason: hint.reason
606
+ };
607
+ }
608
+
609
+ function dedupeAliasSuggestions(suggestions) {
610
+ const seen = new Set();
611
+ return suggestions.filter((suggestion) => {
612
+ const key = `${suggestion.field}:${suggestion.candidatePath}:${suggestion.action}`;
613
+ if (seen.has(key)) return false;
614
+ seen.add(key);
615
+ return true;
616
+ });
617
+ }
618
+
619
+ function mergeAliasSuggestions(...groups) {
620
+ return dedupeAliasSuggestions(groups.flat().filter(Boolean))
621
+ .sort((a, b) => (b.score || 0) - (a.score || 0))
622
+ .slice(0, 8);
623
+ }
624
+
625
+ function normalizeCompatibilityReport(report = {}) {
626
+ return {
627
+ shape: report.shape || "unknown",
628
+ status: report.status || "unknown",
629
+ compatible: Boolean(report.compatible),
630
+ tone: report.tone || "neutral",
631
+ score: Number(report.score || 0),
632
+ recognizedSections: report.recognizedSections || [],
633
+ missingFields: report.missingFields || [],
634
+ ignoredFields: report.ignoredFields || [],
635
+ aliasSuggestions: report.aliasSuggestions || [],
636
+ source: report.source || {},
637
+ summary: report.summary || {}
638
+ };
639
+ }
640
+
641
+ function compatibilityDiffSummary(report) {
642
+ return {
643
+ shape: report.shape,
644
+ status: report.status,
645
+ compatible: report.compatible,
646
+ score: report.score,
647
+ source: {
648
+ label: report.source?.label || "decoded capture",
649
+ cluster: report.source?.cluster || "unknown",
650
+ commandCount: Array.isArray(report.source?.commandSet) ? report.source.commandSet.length : 0,
651
+ marketCount: report.source?.marketCount || report.summary?.marketCount || 0
652
+ },
653
+ summary: {
654
+ recognizedCount: report.summary?.recognizedCount || report.recognizedSections.length,
655
+ missingCount: report.summary?.missingCount || report.missingFields.length,
656
+ ignoredCount: report.summary?.ignoredCount || report.ignoredFields.length,
657
+ suggestionCount: report.summary?.suggestionCount || report.aliasSuggestions.length
658
+ }
659
+ };
660
+ }
661
+
662
+ function differenceByField(left = [], right = []) {
663
+ const rightFields = new Set((right || []).map((entry) => entry.field));
664
+ return (left || []).filter((entry) => !rightFields.has(entry.field));
665
+ }
666
+
667
+ function differenceByPath(left = [], right = []) {
668
+ const rightPaths = new Set((right || []).map((entry) => entry.path));
669
+ return (left || []).filter((entry) => !rightPaths.has(entry.path));
670
+ }
671
+
672
+ function differenceById(left = [], right = []) {
673
+ const rightIds = new Set((right || []).map((entry) => entry.id));
674
+ return (left || []).filter((entry) => !rightIds.has(entry.id));
675
+ }
676
+
446
677
  function assertReadOnlyCompatibilityCapture(value, path = "capture") {
447
678
  if (!value || typeof value !== "object") return;
448
679
  for (const [key, child] of Object.entries(value)) {