@perpscope/percolator-adapter 0.4.0 → 0.6.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
@@ -2,11 +2,16 @@
2
2
 
3
3
  Read-only adapter helpers for Solana perps terminals that want PerpScope DTOs without adopting the cockpit UI.
4
4
 
5
+ ```bash
6
+ npm install @perpscope/percolator-adapter
7
+ ```
8
+
5
9
  ```js
6
10
  import {
7
11
  buildPercolatorCompatibilityReport,
8
12
  buildReadOnlyRpcSnapshot,
9
13
  buildWatchtowerSignals,
14
+ compareCompatibilityReports,
10
15
  normalizeFundingSkewHistory,
11
16
  normalizePercolatorSnapshot,
12
17
  simulatePriceShock
@@ -16,6 +21,7 @@ const snapshot = normalizePercolatorSnapshot(decodedPercolatorJson);
16
21
  const market = snapshot.markets[0];
17
22
  const stress = simulatePriceShock(market, -5);
18
23
  const compatibility = buildPercolatorCompatibilityReport(decodedPercolatorJson, snapshot);
24
+ const drift = compareCompatibilityReports(previousCompatibility, compatibility);
19
25
  const watchtower = buildWatchtowerSignals(market, stress);
20
26
  const carryHistory = normalizeFundingSkewHistory(market.history.fundingSkew, market);
21
27
  ```
@@ -26,6 +32,8 @@ The package exposes pure read-only helpers:
26
32
 
27
33
  - `normalizePercolatorSnapshot()`
28
34
  - `buildPercolatorCompatibilityReport()`
35
+ - `exportCompatibilityReport()`
36
+ - `compareCompatibilityReports()`
29
37
  - `detectPercolatorInputShape()`
30
38
  - `parsePercolatorJson()`
31
39
  - `simulatePriceShock()`
@@ -49,9 +57,12 @@ It does not connect wallets, sign, send, route, place orders, or submit transact
49
57
  - `recognizedSections`: mapped market, price, engine, account, execution, receipt, history, and provenance sections
50
58
  - `missingFields`: useful fields the cockpit can render better when supplied
51
59
  - `ignoredFields`: top-level fields or command names preserved as provenance but not mapped yet
60
+ - `aliasSuggestions`: candidate mappings such as `oraclePriceUsd -> price.mark`
52
61
 
53
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`.
54
63
 
64
+ `compareCompatibilityReports(previous, current)` returns `perpscope.compatibility-diff` with score delta, status change, new/resolved fields, section drift, and merged alias suggestions.
65
+
55
66
  ## DTO Example
56
67
 
57
68
  ```js
package/index.js CHANGED
@@ -1,9 +1,13 @@
1
1
  export {
2
2
  assertReadOnlySnapshot,
3
3
  buildPercolatorCompatibilityReport,
4
+ compareCompatibilityReports,
4
5
  detectPercolatorInputShape,
6
+ exportCompatibilityReport,
7
+ exportCompatibilityReportFromReport,
5
8
  normalizePercolatorCliBundle,
6
9
  normalizePercolatorSnapshot,
10
+ PERPSCOPE_ADAPTER_VERSION,
7
11
  parsePercolatorJson,
8
12
  simulatePriceShock,
9
13
  toTerminalMarketDto
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@perpscope/percolator-adapter",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "type": "module",
5
5
  "description": "Read-only Percolator adapter helpers for Solana perps terminals.",
6
6
  "main": "./index.js",
@@ -1,5 +1,7 @@
1
1
  import { normalizeFundingSkewHistory } from "./funding-history.js";
2
2
 
3
+ export const PERPSCOPE_ADAPTER_VERSION = "0.6.0";
4
+
3
5
  const KEYPAIR_FIELD_PATTERN = /(^|_)(secret|private|keypair|mnemonic|seed|walletPath|wallet)(_|$)/i;
4
6
  const HISTORY_COMMAND_KEYS = new Set([
5
7
  "fundinghistory",
@@ -96,6 +98,7 @@ export function buildPercolatorCompatibilityReport(input, normalizedSnapshot) {
96
98
  detail: field.detail
97
99
  }));
98
100
  const ignoredFields = ignoredCompatibilityFields(input);
101
+ const aliasSuggestions = buildCompatibilityAliasSuggestions(missingFields, ignoredFields);
99
102
  const dangerCount = missingFields.filter((field) => field.severity === "danger").length;
100
103
  const warningCount = missingFields.length - dangerCount;
101
104
  const recognizedDataCount = recognizedSections.filter((section) => section.id !== "safety").length;
@@ -117,6 +120,7 @@ export function buildPercolatorCompatibilityReport(input, normalizedSnapshot) {
117
120
  recognizedSections,
118
121
  missingFields,
119
122
  ignoredFields,
123
+ aliasSuggestions,
120
124
  source: {
121
125
  label: source.label || stringOf(scope, ["label", "sourceLabel"], "decoded capture"),
122
126
  mode: source.mode || "read-only",
@@ -131,12 +135,102 @@ export function buildPercolatorCompatibilityReport(input, normalizedSnapshot) {
131
135
  recognizedCount: recognizedSections.length,
132
136
  missingCount: missingFields.length,
133
137
  ignoredCount: ignoredFields.length,
138
+ suggestionCount: aliasSuggestions.length,
134
139
  marketCount: snapshot.markets.length,
135
140
  commandCount: commandSet.length
136
141
  }
137
142
  };
138
143
  }
139
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
+
198
+ export function exportCompatibilityReport(input, normalizedSnapshot, options = {}) {
199
+ const snapshot = normalizedSnapshot || normalizePercolatorSnapshot(input);
200
+ const report = buildPercolatorCompatibilityReport(input, snapshot);
201
+ return exportCompatibilityReportFromReport(report, options);
202
+ }
203
+
204
+ export function exportCompatibilityReportFromReport(report, options = {}) {
205
+ return {
206
+ schema: "perpscope.compatibility-report",
207
+ version: 1,
208
+ package: {
209
+ name: "@perpscope/percolator-adapter",
210
+ version: options.packageVersion || PERPSCOPE_ADAPTER_VERSION
211
+ },
212
+ generatedAt: options.generatedAt || new Date().toISOString(),
213
+ safety: {
214
+ mode: "read-only",
215
+ rejected: report.status === "rejected"
216
+ },
217
+ shape: report.shape,
218
+ status: report.status,
219
+ compatible: report.compatible,
220
+ tone: report.tone,
221
+ score: report.score,
222
+ recognizedSections: report.recognizedSections,
223
+ missingFields: report.missingFields,
224
+ ignoredFields: report.ignoredFields,
225
+ aliasSuggestions: report.aliasSuggestions || buildCompatibilityAliasSuggestions(report.missingFields, report.ignoredFields),
226
+ source: report.source,
227
+ summary: {
228
+ ...report.summary,
229
+ suggestionCount: report.summary?.suggestionCount ?? (report.aliasSuggestions || []).length
230
+ }
231
+ };
232
+ }
233
+
140
234
  const COMPATIBILITY_SECTION_SPECS = [
141
235
  {
142
236
  id: "safety",
@@ -409,6 +503,177 @@ function ignoredCompatibilityFields(input) {
409
503
  return ignored.slice(0, 8);
410
504
  }
411
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
+
412
677
  function assertReadOnlyCompatibilityCapture(value, path = "capture") {
413
678
  if (!value || typeof value !== "object") return;
414
679
  for (const [key, child] of Object.entries(value)) {