lovewebconnection 0.0.0-stage → 0.2.1
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/.env.example +38 -0
- package/LICENSE +21 -0
- package/README.md +1937 -2
- package/dist/alert-queue.d.ts +72 -0
- package/dist/alert-queue.js +155 -0
- package/dist/alert-store.d.ts +29 -0
- package/dist/alert-store.js +75 -0
- package/dist/balances.d.ts +221 -0
- package/dist/balances.js +1232 -0
- package/dist/cache.d.ts +37 -0
- package/dist/cache.js +128 -0
- package/dist/call.d.ts +46 -0
- package/dist/call.js +377 -0
- package/dist/chain.d.ts +97 -0
- package/dist/chain.js +419 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +1011 -0
- package/dist/compare-history.d.ts +82 -0
- package/dist/compare-history.js +340 -0
- package/dist/compare.d.ts +98 -0
- package/dist/compare.js +492 -0
- package/dist/contract.d.ts +107 -0
- package/dist/contract.js +162 -0
- package/dist/default-abi.d.ts +53 -0
- package/dist/default-abi.js +85 -0
- package/dist/history.d.ts +83 -0
- package/dist/history.js +422 -0
- package/dist/index.d.ts +54 -0
- package/dist/index.js +27 -0
- package/dist/market-page.d.ts +8 -0
- package/dist/market-page.js +554 -0
- package/dist/market-server.d.ts +46 -0
- package/dist/market-server.js +473 -0
- package/dist/market.d.ts +323 -0
- package/dist/market.js +835 -0
- package/dist/metadata-cache.d.ts +72 -0
- package/dist/metadata-cache.js +197 -0
- package/dist/metrics.d.ts +64 -0
- package/dist/metrics.js +138 -0
- package/dist/plot.d.ts +60 -0
- package/dist/plot.js +317 -0
- package/dist/presets-history.d.ts +372 -0
- package/dist/presets-history.js +1243 -0
- package/dist/presets-summary.d.ts +17 -0
- package/dist/presets-summary.js +55 -0
- package/dist/presets.d.ts +59 -0
- package/dist/presets.js +181 -0
- package/dist/prices.d.ts +63 -0
- package/dist/prices.js +154 -0
- package/dist/rotate.d.ts +19 -0
- package/dist/rotate.js +34 -0
- package/dist/scan.d.ts +81 -0
- package/dist/scan.js +501 -0
- package/dist/sessions.d.ts +104 -0
- package/dist/sessions.js +515 -0
- package/dist/signatures.d.ts +13 -0
- package/dist/signatures.js +43 -0
- package/dist/tokenfile.d.ts +50 -0
- package/dist/tokenfile.js +218 -0
- package/dist/views.d.ts +63 -0
- package/dist/views.js +415 -0
- package/dist/watch.d.ts +306 -0
- package/dist/watch.js +1121 -0
- package/dist/webhook.d.ts +17 -0
- package/dist/webhook.js +35 -0
- package/examples/erc20.abi.json +57 -0
- package/examples/xlayer.tokens.json +4 -0
- package/package.json +77 -4
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { type Address } from "viem";
|
|
2
|
+
/** The immutable parts of an ERC-20's identity, read once and reused. */
|
|
3
|
+
export interface TokenMetadata {
|
|
4
|
+
symbol: string;
|
|
5
|
+
decimals: number;
|
|
6
|
+
}
|
|
7
|
+
/** Cached metadata plus when it was last verified; null for legacy entries. */
|
|
8
|
+
export interface CachedTokenMetadata extends TokenMetadata {
|
|
9
|
+
cachedAt: number | null;
|
|
10
|
+
}
|
|
11
|
+
/** A cached entry paired with its checksummed token address. */
|
|
12
|
+
export interface ListedTokenMetadata extends CachedTokenMetadata {
|
|
13
|
+
address: Address;
|
|
14
|
+
}
|
|
15
|
+
export interface MetadataCacheOptions {
|
|
16
|
+
/** Only entries for this chain are returned. */
|
|
17
|
+
chainId: number;
|
|
18
|
+
/** File to load from and save to; ignored when `persist` is false. */
|
|
19
|
+
path?: string;
|
|
20
|
+
/** False keeps everything in memory and never touches the disk. */
|
|
21
|
+
persist?: boolean;
|
|
22
|
+
/** Clock used for entry timestamps; injectable for tests. */
|
|
23
|
+
now?: () => number;
|
|
24
|
+
}
|
|
25
|
+
export declare const DEFAULT_CACHE_PATH = ".lwc-cache/token-metadata.json";
|
|
26
|
+
export type CacheAgeParse = {
|
|
27
|
+
ok: true;
|
|
28
|
+
ms: number;
|
|
29
|
+
} | {
|
|
30
|
+
ok: false;
|
|
31
|
+
message: string;
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* Parse cache-age flags (`--max-cache-age`, `--stale-after`): a positive whole
|
|
35
|
+
* number with a unit, for example `30m`, `12h`, `7d`, or `2w`. A bare number is
|
|
36
|
+
* rejected so seconds and milliseconds are never guessed.
|
|
37
|
+
*/
|
|
38
|
+
export declare function parseCacheAge(raw: string, flag?: string): CacheAgeParse;
|
|
39
|
+
/** Flag value wins, then LWC_CACHE_FILE, then the project-local default. */
|
|
40
|
+
export declare function resolveCachePath(flagPath?: string | null, env?: NodeJS.ProcessEnv): string;
|
|
41
|
+
/**
|
|
42
|
+
* `decimals` and `symbol` are cached per chain and reused across runs and across
|
|
43
|
+
* owners within a scan. Entries record when they were last verified so a stale
|
|
44
|
+
* symbol can be spotted and refreshed. Only clean metadata is stored: a token
|
|
45
|
+
* whose symbol or decimals did not decode is left out, so a malformed contract
|
|
46
|
+
* is re-examined rather than remembered as broken.
|
|
47
|
+
*/
|
|
48
|
+
export declare class MetadataCache {
|
|
49
|
+
private readonly chainId;
|
|
50
|
+
private readonly path;
|
|
51
|
+
private readonly persist;
|
|
52
|
+
private readonly now;
|
|
53
|
+
private readonly entries;
|
|
54
|
+
private dirty;
|
|
55
|
+
constructor(options: MetadataCacheOptions);
|
|
56
|
+
get(token: string): TokenMetadata | null;
|
|
57
|
+
/** Every entry for this cache's chain, oldest first (undated entries first). */
|
|
58
|
+
list(): ListedTokenMetadata[];
|
|
59
|
+
/** How old this entry is in milliseconds; null when it has no timestamp. */
|
|
60
|
+
ageMs(token: string, now?: number): number | null;
|
|
61
|
+
set(token: string, metadata: TokenMetadata): void;
|
|
62
|
+
/**
|
|
63
|
+
* Drop every entry older than `maxAgeMs`, plus undated entries whose age
|
|
64
|
+
* cannot be checked. Returns how many entries were removed.
|
|
65
|
+
*/
|
|
66
|
+
invalidateStale(maxAgeMs: number, now?: number): number;
|
|
67
|
+
/** Drop an entry so the next read re-fetches it; true when one was removed. */
|
|
68
|
+
invalidate(token: string): boolean;
|
|
69
|
+
/** Persist changes; returns a warning message instead of throwing on failure. */
|
|
70
|
+
save(): string | null;
|
|
71
|
+
private load;
|
|
72
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import { getAddress, isAddress } from "viem";
|
|
4
|
+
export const DEFAULT_CACHE_PATH = ".lwc-cache/token-metadata.json";
|
|
5
|
+
/**
|
|
6
|
+
* Parse cache-age flags (`--max-cache-age`, `--stale-after`): a positive whole
|
|
7
|
+
* number with a unit, for example `30m`, `12h`, `7d`, or `2w`. A bare number is
|
|
8
|
+
* rejected so seconds and milliseconds are never guessed.
|
|
9
|
+
*/
|
|
10
|
+
export function parseCacheAge(raw, flag = "--max-cache-age") {
|
|
11
|
+
const match = /^(\d+)([smhdw])$/.exec(raw.trim());
|
|
12
|
+
if (!match) {
|
|
13
|
+
return {
|
|
14
|
+
ok: false,
|
|
15
|
+
message: `${flag} must look like 30m, 12h, 7d, or 2w, got "${raw}".`,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
const value = Number(match[1]);
|
|
19
|
+
const unit = match[2];
|
|
20
|
+
const factor = unit === "s"
|
|
21
|
+
? 1_000
|
|
22
|
+
: unit === "m"
|
|
23
|
+
? 60_000
|
|
24
|
+
: unit === "h"
|
|
25
|
+
? 3_600_000
|
|
26
|
+
: unit === "d"
|
|
27
|
+
? 86_400_000
|
|
28
|
+
: 604_800_000;
|
|
29
|
+
const ms = value * factor;
|
|
30
|
+
if (ms <= 0 || !Number.isSafeInteger(ms)) {
|
|
31
|
+
return { ok: false, message: `${flag} must be a positive duration, got "${raw}".` };
|
|
32
|
+
}
|
|
33
|
+
return { ok: true, ms };
|
|
34
|
+
}
|
|
35
|
+
/** Flag value wins, then LWC_CACHE_FILE, then the project-local default. */
|
|
36
|
+
export function resolveCachePath(flagPath = null, env = process.env) {
|
|
37
|
+
const value = flagPath ?? env.LWC_CACHE_FILE;
|
|
38
|
+
return value && value.trim().length > 0 ? value : DEFAULT_CACHE_PATH;
|
|
39
|
+
}
|
|
40
|
+
function usableEntry(value) {
|
|
41
|
+
const entry = value;
|
|
42
|
+
const symbol = typeof entry?.symbol === "string" ? entry.symbol.trim() : "";
|
|
43
|
+
const decimals = entry?.decimals;
|
|
44
|
+
if (!symbol || typeof decimals !== "number")
|
|
45
|
+
return null;
|
|
46
|
+
if (!Number.isInteger(decimals) || decimals < 0 || decimals > 36)
|
|
47
|
+
return null;
|
|
48
|
+
const cachedAt = typeof entry?.cachedAt === "number" && Number.isFinite(entry.cachedAt) && entry.cachedAt >= 0
|
|
49
|
+
? entry.cachedAt
|
|
50
|
+
: null;
|
|
51
|
+
return { symbol, decimals, cachedAt };
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* `decimals` and `symbol` are cached per chain and reused across runs and across
|
|
55
|
+
* owners within a scan. Entries record when they were last verified so a stale
|
|
56
|
+
* symbol can be spotted and refreshed. Only clean metadata is stored: a token
|
|
57
|
+
* whose symbol or decimals did not decode is left out, so a malformed contract
|
|
58
|
+
* is re-examined rather than remembered as broken.
|
|
59
|
+
*/
|
|
60
|
+
export class MetadataCache {
|
|
61
|
+
chainId;
|
|
62
|
+
path;
|
|
63
|
+
persist;
|
|
64
|
+
now;
|
|
65
|
+
entries = new Map();
|
|
66
|
+
dirty = false;
|
|
67
|
+
constructor(options) {
|
|
68
|
+
this.chainId = options.chainId;
|
|
69
|
+
this.path = options.path ?? DEFAULT_CACHE_PATH;
|
|
70
|
+
this.persist = options.persist ?? true;
|
|
71
|
+
this.now = options.now ?? (() => Date.now());
|
|
72
|
+
if (this.persist)
|
|
73
|
+
this.load();
|
|
74
|
+
}
|
|
75
|
+
get(token) {
|
|
76
|
+
const entry = this.entries.get(String(this.chainId))?.get(token.toLowerCase());
|
|
77
|
+
return entry ? { symbol: entry.symbol, decimals: entry.decimals } : null;
|
|
78
|
+
}
|
|
79
|
+
/** Every entry for this cache's chain, oldest first (undated entries first). */
|
|
80
|
+
list() {
|
|
81
|
+
const scoped = this.entries.get(String(this.chainId));
|
|
82
|
+
if (!scoped)
|
|
83
|
+
return [];
|
|
84
|
+
return [...scoped.entries()]
|
|
85
|
+
.map(([address, entry]) => ({ address: getAddress(address), ...entry }))
|
|
86
|
+
.sort((a, b) => (a.cachedAt ?? 0) - (b.cachedAt ?? 0) || a.address.localeCompare(b.address));
|
|
87
|
+
}
|
|
88
|
+
/** How old this entry is in milliseconds; null when it has no timestamp. */
|
|
89
|
+
ageMs(token, now = Date.now()) {
|
|
90
|
+
const entry = this.entries.get(String(this.chainId))?.get(token.toLowerCase());
|
|
91
|
+
if (!entry || entry.cachedAt === null)
|
|
92
|
+
return null;
|
|
93
|
+
return Math.max(0, now - entry.cachedAt);
|
|
94
|
+
}
|
|
95
|
+
set(token, metadata) {
|
|
96
|
+
const usable = usableEntry({ ...metadata, cachedAt: this.now() });
|
|
97
|
+
if (!usable || !isAddress(token))
|
|
98
|
+
return;
|
|
99
|
+
const chain = String(this.chainId);
|
|
100
|
+
let scoped = this.entries.get(chain);
|
|
101
|
+
if (!scoped) {
|
|
102
|
+
scoped = new Map();
|
|
103
|
+
this.entries.set(chain, scoped);
|
|
104
|
+
}
|
|
105
|
+
const key = token.toLowerCase();
|
|
106
|
+
const existing = scoped.get(key);
|
|
107
|
+
if (existing && existing.symbol === usable.symbol && existing.decimals === usable.decimals) {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
scoped.set(key, usable);
|
|
111
|
+
this.dirty = true;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Drop every entry older than `maxAgeMs`, plus undated entries whose age
|
|
115
|
+
* cannot be checked. Returns how many entries were removed.
|
|
116
|
+
*/
|
|
117
|
+
invalidateStale(maxAgeMs, now = Date.now()) {
|
|
118
|
+
const scoped = this.entries.get(String(this.chainId));
|
|
119
|
+
if (!scoped)
|
|
120
|
+
return 0;
|
|
121
|
+
let removed = 0;
|
|
122
|
+
for (const [address, entry] of scoped) {
|
|
123
|
+
if (entry.cachedAt === null || now - entry.cachedAt > maxAgeMs) {
|
|
124
|
+
scoped.delete(address);
|
|
125
|
+
removed += 1;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
if (removed > 0)
|
|
129
|
+
this.dirty = true;
|
|
130
|
+
return removed;
|
|
131
|
+
}
|
|
132
|
+
/** Drop an entry so the next read re-fetches it; true when one was removed. */
|
|
133
|
+
invalidate(token) {
|
|
134
|
+
const scoped = this.entries.get(String(this.chainId));
|
|
135
|
+
if (!scoped?.delete(token.toLowerCase()))
|
|
136
|
+
return false;
|
|
137
|
+
this.dirty = true;
|
|
138
|
+
return true;
|
|
139
|
+
}
|
|
140
|
+
/** Persist changes; returns a warning message instead of throwing on failure. */
|
|
141
|
+
save() {
|
|
142
|
+
if (!this.persist || !this.dirty)
|
|
143
|
+
return null;
|
|
144
|
+
try {
|
|
145
|
+
const chains = {};
|
|
146
|
+
for (const [chain, scoped] of this.entries) {
|
|
147
|
+
const record = {};
|
|
148
|
+
for (const [address, metadata] of scoped)
|
|
149
|
+
record[address] = metadata;
|
|
150
|
+
chains[chain] = record;
|
|
151
|
+
}
|
|
152
|
+
const payload = { version: 2, chains };
|
|
153
|
+
mkdirSync(dirname(this.path), { recursive: true });
|
|
154
|
+
const temporary = `${this.path}.tmp`;
|
|
155
|
+
writeFileSync(temporary, `${JSON.stringify(payload, null, 2)}\n`);
|
|
156
|
+
renameSync(temporary, this.path);
|
|
157
|
+
this.dirty = false;
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
catch (error) {
|
|
161
|
+
return error instanceof Error ? error.message : String(error);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
load() {
|
|
165
|
+
let raw;
|
|
166
|
+
try {
|
|
167
|
+
raw = readFileSync(this.path, "utf8");
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
return; // No cache yet is the normal first-run case.
|
|
171
|
+
}
|
|
172
|
+
let parsed;
|
|
173
|
+
try {
|
|
174
|
+
parsed = JSON.parse(raw);
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
return; // A damaged cache is worth ignoring, never worth failing a read.
|
|
178
|
+
}
|
|
179
|
+
const chains = parsed?.chains;
|
|
180
|
+
if (!chains || typeof chains !== "object")
|
|
181
|
+
return;
|
|
182
|
+
for (const [chain, record] of Object.entries(chains)) {
|
|
183
|
+
if (!/^\d+$/.test(chain) || !record || typeof record !== "object")
|
|
184
|
+
continue;
|
|
185
|
+
const scoped = new Map();
|
|
186
|
+
for (const [address, value] of Object.entries(record)) {
|
|
187
|
+
if (!isAddress(address))
|
|
188
|
+
continue;
|
|
189
|
+
const metadata = usableEntry(value);
|
|
190
|
+
if (metadata)
|
|
191
|
+
scoped.set(address.toLowerCase(), metadata);
|
|
192
|
+
}
|
|
193
|
+
if (scoped.size > 0)
|
|
194
|
+
this.entries.set(chain, scoped);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
export interface LatencyStats {
|
|
2
|
+
count: number;
|
|
3
|
+
p50: number;
|
|
4
|
+
p95: number;
|
|
5
|
+
min: number;
|
|
6
|
+
max: number;
|
|
7
|
+
mean: number;
|
|
8
|
+
}
|
|
9
|
+
export interface BlockTimeStats {
|
|
10
|
+
count: number;
|
|
11
|
+
medianSeconds: number;
|
|
12
|
+
minSeconds: number;
|
|
13
|
+
maxSeconds: number;
|
|
14
|
+
meanSeconds: number;
|
|
15
|
+
}
|
|
16
|
+
export interface WatchMetrics {
|
|
17
|
+
samples: number;
|
|
18
|
+
windowSize: number;
|
|
19
|
+
latency: LatencyStats;
|
|
20
|
+
blockTime: BlockTimeStats;
|
|
21
|
+
windowFailures: number;
|
|
22
|
+
windowPolls: number;
|
|
23
|
+
/** Fraction of polls in the window that failed, 0..1. */
|
|
24
|
+
errorRate: number;
|
|
25
|
+
}
|
|
26
|
+
export interface MetricsState {
|
|
27
|
+
windowSize: number;
|
|
28
|
+
latencies: number[];
|
|
29
|
+
blockTimes: number[];
|
|
30
|
+
outcomes: boolean[];
|
|
31
|
+
samples: number;
|
|
32
|
+
errors: number;
|
|
33
|
+
/** Untrimmed history for session totals; absent on window-only states. */
|
|
34
|
+
sessionLatencies?: number[];
|
|
35
|
+
sessionBlockTimes?: number[];
|
|
36
|
+
}
|
|
37
|
+
/** Whole-run totals, as opposed to the rolling `WatchMetrics` window. */
|
|
38
|
+
export interface SessionMetrics {
|
|
39
|
+
/** Successful polls in the run. */
|
|
40
|
+
samples: number;
|
|
41
|
+
/** Failed polls in the run. */
|
|
42
|
+
failures: number;
|
|
43
|
+
/** Successful plus failed polls. */
|
|
44
|
+
polls: number;
|
|
45
|
+
errorRate: number;
|
|
46
|
+
latency: LatencyStats;
|
|
47
|
+
blockTime: BlockTimeStats;
|
|
48
|
+
}
|
|
49
|
+
export declare const DEFAULT_METRICS_WINDOW = 20;
|
|
50
|
+
export declare function createMetricsState(windowSize?: number): MetricsState;
|
|
51
|
+
export declare function recordLatency(state: MetricsState, latencyMs: number): MetricsState;
|
|
52
|
+
export declare function recordFailure(state: MetricsState): MetricsState;
|
|
53
|
+
export declare function recordBlockTime(state: MetricsState, seconds: number): MetricsState;
|
|
54
|
+
/** Nearest-rank percentile over an already sorted array. */
|
|
55
|
+
export declare function percentile(sorted: number[], p: number): number;
|
|
56
|
+
export declare function computeMetrics(state: MetricsState): WatchMetrics;
|
|
57
|
+
/** Totals over the whole run, independent of the rolling window. */
|
|
58
|
+
export declare function computeSessionMetrics(state: MetricsState): SessionMetrics;
|
|
59
|
+
export declare function formatSeconds(seconds: number): string;
|
|
60
|
+
/**
|
|
61
|
+
* One-line metrics report: the rolling window, plus whole-session totals when
|
|
62
|
+
* they are supplied, so recent health and the full run appear together.
|
|
63
|
+
*/
|
|
64
|
+
export declare function formatMetrics(metrics: WatchMetrics, session?: SessionMetrics): string;
|
package/dist/metrics.js
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
export const DEFAULT_METRICS_WINDOW = 20;
|
|
2
|
+
function emptyLatency() {
|
|
3
|
+
return { count: 0, p50: 0, p95: 0, min: 0, max: 0, mean: 0 };
|
|
4
|
+
}
|
|
5
|
+
function emptyBlockTime() {
|
|
6
|
+
return { count: 0, medianSeconds: 0, minSeconds: 0, maxSeconds: 0, meanSeconds: 0 };
|
|
7
|
+
}
|
|
8
|
+
export function createMetricsState(windowSize = DEFAULT_METRICS_WINDOW) {
|
|
9
|
+
return {
|
|
10
|
+
windowSize,
|
|
11
|
+
latencies: [],
|
|
12
|
+
blockTimes: [],
|
|
13
|
+
outcomes: [],
|
|
14
|
+
samples: 0,
|
|
15
|
+
errors: 0,
|
|
16
|
+
sessionLatencies: [],
|
|
17
|
+
sessionBlockTimes: [],
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
function trim(values, windowSize) {
|
|
21
|
+
return values.length > windowSize ? values.slice(-windowSize) : values;
|
|
22
|
+
}
|
|
23
|
+
export function recordLatency(state, latencyMs) {
|
|
24
|
+
const value = Math.max(0, Math.round(latencyMs));
|
|
25
|
+
return {
|
|
26
|
+
...state,
|
|
27
|
+
latencies: trim([...state.latencies, value], state.windowSize),
|
|
28
|
+
outcomes: trim([...state.outcomes, true], state.windowSize),
|
|
29
|
+
sessionLatencies: [...(state.sessionLatencies ?? []), value],
|
|
30
|
+
samples: state.samples + 1,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
export function recordFailure(state) {
|
|
34
|
+
return {
|
|
35
|
+
...state,
|
|
36
|
+
outcomes: trim([...state.outcomes, false], state.windowSize),
|
|
37
|
+
errors: state.errors + 1,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
export function recordBlockTime(state, seconds) {
|
|
41
|
+
const value = Math.max(0, seconds);
|
|
42
|
+
return {
|
|
43
|
+
...state,
|
|
44
|
+
blockTimes: trim([...state.blockTimes, value], state.windowSize),
|
|
45
|
+
sessionBlockTimes: [...(state.sessionBlockTimes ?? []), value],
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
/** Nearest-rank percentile over an already sorted array. */
|
|
49
|
+
export function percentile(sorted, p) {
|
|
50
|
+
if (sorted.length === 0)
|
|
51
|
+
return 0;
|
|
52
|
+
const rank = Math.ceil((p / 100) * sorted.length);
|
|
53
|
+
const index = Math.min(sorted.length - 1, Math.max(0, rank - 1));
|
|
54
|
+
return sorted[index] ?? 0;
|
|
55
|
+
}
|
|
56
|
+
function latencyStatsFrom(sorted) {
|
|
57
|
+
if (sorted.length === 0)
|
|
58
|
+
return emptyLatency();
|
|
59
|
+
return {
|
|
60
|
+
count: sorted.length,
|
|
61
|
+
p50: percentile(sorted, 50),
|
|
62
|
+
p95: percentile(sorted, 95),
|
|
63
|
+
min: sorted[0] ?? 0,
|
|
64
|
+
max: sorted[sorted.length - 1] ?? 0,
|
|
65
|
+
mean: Math.round(sorted.reduce((sum, value) => sum + value, 0) / sorted.length),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
function blockTimeStatsFrom(sorted) {
|
|
69
|
+
if (sorted.length === 0)
|
|
70
|
+
return emptyBlockTime();
|
|
71
|
+
return {
|
|
72
|
+
count: sorted.length,
|
|
73
|
+
medianSeconds: percentile(sorted, 50),
|
|
74
|
+
minSeconds: sorted[0] ?? 0,
|
|
75
|
+
maxSeconds: sorted[sorted.length - 1] ?? 0,
|
|
76
|
+
meanSeconds: Number((sorted.reduce((sum, value) => sum + value, 0) / sorted.length).toFixed(3)),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
export function computeMetrics(state) {
|
|
80
|
+
const latencies = [...state.latencies].sort((a, b) => a - b);
|
|
81
|
+
const blockTimes = [...state.blockTimes].sort((a, b) => a - b);
|
|
82
|
+
const windowPolls = state.outcomes.length;
|
|
83
|
+
const windowFailures = state.outcomes.filter((ok) => !ok).length;
|
|
84
|
+
return {
|
|
85
|
+
samples: state.samples,
|
|
86
|
+
windowSize: state.windowSize,
|
|
87
|
+
latency: latencyStatsFrom(latencies),
|
|
88
|
+
blockTime: blockTimeStatsFrom(blockTimes),
|
|
89
|
+
windowFailures,
|
|
90
|
+
windowPolls,
|
|
91
|
+
errorRate: windowPolls === 0 ? 0 : windowFailures / windowPolls,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
/** Totals over the whole run, independent of the rolling window. */
|
|
95
|
+
export function computeSessionMetrics(state) {
|
|
96
|
+
const latencies = [...(state.sessionLatencies ?? state.latencies)].sort((a, b) => a - b);
|
|
97
|
+
const blockTimes = [...(state.sessionBlockTimes ?? state.blockTimes)].sort((a, b) => a - b);
|
|
98
|
+
const polls = state.samples + state.errors;
|
|
99
|
+
return {
|
|
100
|
+
samples: state.samples,
|
|
101
|
+
failures: state.errors,
|
|
102
|
+
polls,
|
|
103
|
+
errorRate: polls === 0 ? 0 : state.errors / polls,
|
|
104
|
+
latency: latencyStatsFrom(latencies),
|
|
105
|
+
blockTime: blockTimeStatsFrom(blockTimes),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
export function formatSeconds(seconds) {
|
|
109
|
+
if (seconds < 60)
|
|
110
|
+
return `${seconds.toFixed(1)}s`;
|
|
111
|
+
const minutes = Math.floor(seconds / 60);
|
|
112
|
+
const rest = Math.round(seconds % 60);
|
|
113
|
+
return `${minutes}m${rest.toString().padStart(2, "0")}s`;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* One-line metrics report: the rolling window, plus whole-session totals when
|
|
117
|
+
* they are supplied, so recent health and the full run appear together.
|
|
118
|
+
*/
|
|
119
|
+
export function formatMetrics(metrics, session) {
|
|
120
|
+
const parts = [];
|
|
121
|
+
parts.push(metrics.latency.count > 0
|
|
122
|
+
? `latency p50 ${metrics.latency.p50} ms, p95 ${metrics.latency.p95} ms, max ${metrics.latency.max} ms`
|
|
123
|
+
: "latency n/a");
|
|
124
|
+
parts.push(metrics.blockTime.count > 0
|
|
125
|
+
? `block time median ${formatSeconds(metrics.blockTime.medianSeconds)}`
|
|
126
|
+
: "block time n/a");
|
|
127
|
+
parts.push(`errors ${metrics.windowFailures}/${metrics.windowPolls}`);
|
|
128
|
+
if (session) {
|
|
129
|
+
parts.push(session.latency.count > 0
|
|
130
|
+
? `session latency p50 ${session.latency.p50} ms, p95 ${session.latency.p95} ms, max ${session.latency.max} ms`
|
|
131
|
+
: "session latency n/a");
|
|
132
|
+
parts.push(session.blockTime.count > 0
|
|
133
|
+
? `session block time median ${formatSeconds(session.blockTime.medianSeconds)}`
|
|
134
|
+
: "session block time n/a");
|
|
135
|
+
parts.push(`session errors ${session.failures}/${session.polls}`);
|
|
136
|
+
}
|
|
137
|
+
return parts.join(" | ");
|
|
138
|
+
}
|
package/dist/plot.d.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
export declare class PlotArgError extends Error {
|
|
2
|
+
constructor(message: string);
|
|
3
|
+
}
|
|
4
|
+
export interface PlotArgs {
|
|
5
|
+
/** History CSV written by `history --csv`, in long or `--wide` format. */
|
|
6
|
+
path: string;
|
|
7
|
+
/** Sparkline width in characters. */
|
|
8
|
+
width: number;
|
|
9
|
+
}
|
|
10
|
+
export interface PlotSample {
|
|
11
|
+
session: string;
|
|
12
|
+
/** Epoch milliseconds of the portfolio sample. */
|
|
13
|
+
at: number;
|
|
14
|
+
/** USD total of the priced holdings. */
|
|
15
|
+
total: number;
|
|
16
|
+
}
|
|
17
|
+
export interface PlotReport {
|
|
18
|
+
path: string;
|
|
19
|
+
width: number;
|
|
20
|
+
samples: PlotSample[];
|
|
21
|
+
/** Sessions that contributed samples, in time order. */
|
|
22
|
+
sessions: string[];
|
|
23
|
+
/** Samples whose holdings were all unpriced. */
|
|
24
|
+
skippedSamples: number;
|
|
25
|
+
firstAt: number | null;
|
|
26
|
+
lastAt: number | null;
|
|
27
|
+
first: number | null;
|
|
28
|
+
last: number | null;
|
|
29
|
+
min: number | null;
|
|
30
|
+
max: number | null;
|
|
31
|
+
change: number | null;
|
|
32
|
+
changePercent: number | null;
|
|
33
|
+
/** Sparkline of the totals, one character per sample or bucket. */
|
|
34
|
+
chart: string;
|
|
35
|
+
}
|
|
36
|
+
export declare const DEFAULT_PLOT_WIDTH = 48;
|
|
37
|
+
export declare function parsePlotArgs(argv: string[]): PlotArgs;
|
|
38
|
+
export declare function readHistoryCsv(path: string): string;
|
|
39
|
+
/**
|
|
40
|
+
* Minimal RFC 4180 reader: quoted cells may hold commas, newlines, and doubled
|
|
41
|
+
* quotes, which is exactly what the history CSV writes for labels and flags.
|
|
42
|
+
*/
|
|
43
|
+
export declare function parseCsv(text: string): string[][];
|
|
44
|
+
/**
|
|
45
|
+
* Turn a history CSV into one point per portfolio sample. The long format
|
|
46
|
+
* repeats `usd_total` on every asset row of a sample, so rows are grouped by
|
|
47
|
+
* session and timestamp; when `usd_total` is empty, that sample's row-level
|
|
48
|
+
* `usd` values are summed instead so a partly priced portfolio still charts.
|
|
49
|
+
* The `--wide` pivot is read the same way, except its `usd_total` column is
|
|
50
|
+
* already one value per sample: `--wide`, `--wide --usd`, and `--wide
|
|
51
|
+
* --usd-only` all chart from it without summing asset columns.
|
|
52
|
+
*/
|
|
53
|
+
export declare function buildPlotReport(text: string, options: {
|
|
54
|
+
path: string;
|
|
55
|
+
width?: number;
|
|
56
|
+
}): PlotReport;
|
|
57
|
+
/** One line of block characters: the smallest value is lowest, the largest is full. */
|
|
58
|
+
export declare function sparkline(values: number[], width?: number): string;
|
|
59
|
+
export declare function formatPlot(report: PlotReport): string;
|
|
60
|
+
export declare function toPlotJson(report: PlotReport): Record<string, unknown>;
|