@oracle-agent/oracle 0.4.0 → 0.4.2

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.
@@ -0,0 +1,170 @@
1
+ // Pure portfolio exposure and coverage summary. This module performs no I/O.
2
+
3
+ export const DEFAULT_RISK_THRESHOLDS = Object.freeze({
4
+ assetHighPct: 50,
5
+ assetMediumPct: 25,
6
+ chainHighPct: 75,
7
+ venueHighPct: 60,
8
+ stablecoinHighPct: 70,
9
+ });
10
+
11
+ const STABLECOINS = new Set(["USDC", "USDT", "USDT0", "DAI", "FRAX", "LUSD", "TUSD", "USDP", "GUSD", "PYUSD", "USDE"]);
12
+
13
+ function finite(value) {
14
+ if (value === null || value === "" || value === undefined) return null;
15
+ const number = Number(value);
16
+ return Number.isFinite(number) && number >= 0 ? number : null;
17
+ }
18
+
19
+ function rounded(value) {
20
+ return Math.round((value + Number.EPSILON) * 1e8) / 1e8;
21
+ }
22
+
23
+ function pct(value, total) {
24
+ return total > 0 ? rounded((value / total) * 100) : null;
25
+ }
26
+
27
+ function addressOf(snapshot) {
28
+ return snapshot.address || snapshot.owner || snapshot.walletAddress || snapshot.wallet?.address || null;
29
+ }
30
+
31
+ function labelIndex(labels) {
32
+ const entries = Array.isArray(labels) ? labels : Array.isArray(labels?.entries) ? labels.entries : [];
33
+ const index = new Map();
34
+ for (const entry of entries) {
35
+ if (!entry?.address) continue;
36
+ const key = String(entry.address).toLowerCase();
37
+ if (!index.has(key)) index.set(key, entry);
38
+ }
39
+ return index;
40
+ }
41
+
42
+ function addAsset(output, raw, context = {}) {
43
+ if (!raw || typeof raw !== "object") return;
44
+ const symbol = String(raw.symbol ?? raw.coin ?? raw.name ?? raw.collection ?? raw.tokenId ?? "unknown");
45
+ const value = finite(raw.usdValue ?? raw.valueUsd ?? raw.knownUsd ?? raw.estimatedValueUsd ?? raw.floorValueUsd);
46
+ const kind = raw.kind === "nft" || raw.type === "nft" || raw.collection || raw.tokenId != null ? "nft" : "fungible";
47
+ output.push({
48
+ symbol,
49
+ usdValue: value,
50
+ chain: String(raw.chain ?? raw.chainName ?? context.chain ?? "unknown"),
51
+ venue: String(raw.venue ?? raw.protocol ?? raw.platform ?? context.venue ?? "wallet"),
52
+ address: raw.address ?? context.address ?? null,
53
+ kind,
54
+ stablecoin: raw.stablecoin === true || STABLECOINS.has(symbol.toUpperCase()),
55
+ });
56
+ }
57
+
58
+ function assetsFromSnapshot(snapshot) {
59
+ const output = [];
60
+ const rootAddress = addressOf(snapshot);
61
+ for (const asset of snapshot.assets || snapshot.holdings || []) addAsset(output, asset, { address: rootAddress, chain: snapshot.chain, venue: snapshot.venue });
62
+ for (const chain of snapshot.chains || []) {
63
+ const chainName = chain.chain ?? chain.name ?? chain.slug ?? (chain.chainId != null ? String(chain.chainId) : chain.family) ?? "unknown";
64
+ const context = { address: chain.address ?? rootAddress, chain: chainName, venue: chain.venue };
65
+ if (chain.native && (chain.native.amount == null || Number(chain.native.amount) !== 0)) {
66
+ addAsset(output, { ...chain.native, symbol: chain.native.symbol ?? chain.symbol ?? chainName }, context);
67
+ }
68
+ for (const asset of chain.assets || chain.fungibleTokens?.assets || []) addAsset(output, asset, context);
69
+ for (const asset of chain.collectibles?.assets || chain.nfts || []) addAsset(output, { ...asset, kind: "nft" }, context);
70
+ for (const asset of chain.spot?.balances || []) addAsset(output, asset, { ...context, venue: "Hyperliquid spot" });
71
+ const accountValue = finite(chain.perps?.accountValueUsd);
72
+ if (accountValue != null) addAsset(output, { symbol: "perps account", usdValue: accountValue }, { ...context, venue: "Hyperliquid perps" });
73
+ }
74
+ for (const item of snapshot.inventory?.items || snapshot.nfts?.items || []) addAsset(output, { ...item, kind: "nft" }, { address: rootAddress, chain: item.chain ?? snapshot.chain, venue: item.marketplace ?? snapshot.venue });
75
+ return output;
76
+ }
77
+
78
+ function snapshotKey(snapshot, index) {
79
+ const address = addressOf(snapshot);
80
+ const chain = snapshot.chain ?? snapshot.chainId ?? snapshot.family ?? "*";
81
+ return address ? `${String(address).toLowerCase()}|${String(chain).toLowerCase()}` : `snapshot:${index}`;
82
+ }
83
+
84
+ function buckets(assets, property, total) {
85
+ const map = new Map();
86
+ for (const asset of assets) {
87
+ if (asset.usdValue == null) continue;
88
+ const name = asset[property];
89
+ map.set(name, (map.get(name) || 0) + asset.usdValue);
90
+ }
91
+ return [...map].map(([name, knownUsd]) => ({ name, knownUsd: rounded(knownUsd), percentOfKnown: pct(knownUsd, total) }))
92
+ .sort((a, b) => b.knownUsd - a.knownUsd || a.name.localeCompare(b.name));
93
+ }
94
+
95
+ /**
96
+ * Summarize already-fetched portfolio snapshots. Accepts either
97
+ * `{ snapshots, labels, thresholds }` or an array plus an options object.
98
+ */
99
+ export function summarizePortfolioRisk(input = {}, options = {}) {
100
+ const snapshots = Array.isArray(input) ? input : Array.isArray(input.snapshots) ? input.snapshots : input.snapshot ? [input.snapshot] : [];
101
+ const labels = labelIndex(options.labels ?? options.addressBook ?? input.labels ?? input.addressBook);
102
+ const thresholds = { ...DEFAULT_RISK_THRESHOLDS, ...(input.thresholds || {}), ...(options.thresholds || {}) };
103
+ const unique = [];
104
+ const seen = new Set();
105
+ let duplicatesIgnored = 0;
106
+ snapshots.forEach((snapshot, index) => {
107
+ if (!snapshot || typeof snapshot !== "object") return;
108
+ const key = snapshotKey(snapshot, index);
109
+ if (seen.has(key)) duplicatesIgnored += 1;
110
+ else { seen.add(key); unique.push(snapshot); }
111
+ });
112
+
113
+ const assets = unique.flatMap(assetsFromSnapshot);
114
+ const priced = assets.filter((asset) => asset.usdValue != null);
115
+ const unknown = assets.filter((asset) => asset.usdValue == null);
116
+ const explicitUnknown = unique.reduce((sum, snapshot) => sum + Number(snapshot.valuation?.unpricedNonzeroItems || snapshot.unpricedNonzeroItems || 0), 0);
117
+ const totalKnownUsd = rounded(priced.reduce((sum, asset) => sum + asset.usdValue, 0));
118
+ const unknownItemCount = unknown.length + explicitUnknown;
119
+ const chainExposures = buckets(priced, "chain", totalKnownUsd);
120
+ const venueExposures = buckets(priced, "venue", totalKnownUsd);
121
+ const groupedAssets = buckets(priced, "symbol", totalKnownUsd);
122
+ for (const row of chainExposures) row.chain = row.name;
123
+ for (const row of venueExposures) row.venue = row.name;
124
+ for (const row of groupedAssets) {
125
+ row.asset = row.name;
126
+ row.symbol = row.name;
127
+ }
128
+ const stablecoinKnownUsd = rounded(priced.filter((asset) => asset.stablecoin).reduce((sum, asset) => sum + asset.usdValue, 0));
129
+ const nftAssets = assets.filter((asset) => asset.kind === "nft");
130
+ const snapshotNftUnvalued = unique.reduce((sum, snapshot) => sum + Number(snapshot.valuation?.nftUnvaluedItems || 0), 0);
131
+ const nftUnvaluedItems = nftAssets.filter((asset) => asset.usdValue == null).length + snapshotNftUnvalued;
132
+ const nftValuedItems = nftAssets.filter((asset) => asset.usdValue != null).length;
133
+ const top = groupedAssets[0] || null;
134
+ const concentrationLevel = !top ? "unknown" : top.percentOfKnown >= thresholds.assetHighPct ? "high" : top.percentOfKnown >= thresholds.assetMediumPct ? "medium" : "low";
135
+ const warnings = [];
136
+ if (unknownItemCount) warnings.push(`${unknownItemCount} nonzero holding(s) have unknown USD value; they are excluded from totals and percentages.`);
137
+ if (nftUnvaluedItems) warnings.push("NFT valuation coverage is partial; estimates are not executable bids and unvalued NFTs are not treated as zero.");
138
+ if (duplicatesIgnored) warnings.push(`${duplicatesIgnored} duplicate address/chain snapshot(s) were ignored.`);
139
+ if (top && concentrationLevel !== "low") warnings.push(`Asset concentration is ${concentrationLevel}: ${top.name} is ${top.percentOfKnown}% of known value.`);
140
+ if (chainExposures[0]?.percentOfKnown >= thresholds.chainHighPct) warnings.push(`Chain concentration is high: ${chainExposures[0].name} is ${chainExposures[0].percentOfKnown}% of known value.`);
141
+ if (venueExposures[0]?.percentOfKnown >= thresholds.venueHighPct) warnings.push(`Venue concentration is high: ${venueExposures[0].name} is ${venueExposures[0].percentOfKnown}% of known value.`);
142
+
143
+ const addresses = unique.map(addressOf).filter(Boolean).map((address) => {
144
+ const entry = labels.get(String(address).toLowerCase());
145
+ return { address, label: entry?.label ?? null, who: entry?.who ?? null, role: entry?.role ?? null };
146
+ });
147
+ return {
148
+ totalKnownUsd,
149
+ coverage: {
150
+ complete: unique.length > 0 && unknownItemCount === 0 && unique.every((snapshot) => snapshot.valuation?.complete !== false && snapshot.coverage?.complete !== false),
151
+ label: unknownItemCount === 0 && unique.length && unique.every((snapshot) => snapshot.valuation?.complete !== false && snapshot.coverage?.complete !== false)
152
+ ? "complete for the supplied snapshots" : "known priced value only; portfolio coverage is partial or unverified",
153
+ unknownUsd: unknownItemCount ? null : 0,
154
+ pricedItemCount: priced.length,
155
+ unknownItemCount,
156
+ snapshotCount: unique.length,
157
+ duplicatesIgnored,
158
+ },
159
+ chainExposures,
160
+ venueExposures,
161
+ topAssets: groupedAssets.slice(0, Math.max(1, Number(options.topAssets ?? input.topAssets ?? 10))),
162
+ concentrationRisk: { level: concentrationLevel, topAsset: top?.name ?? null, topAssetPercent: top?.percentOfKnown ?? null, thresholds: { mediumPct: thresholds.assetMediumPct, highPct: thresholds.assetHighPct } },
163
+ stablecoinExposure: { knownUsd: stablecoinKnownUsd, percentOfKnown: pct(stablecoinKnownUsd, totalKnownUsd), high: totalKnownUsd > 0 && pct(stablecoinKnownUsd, totalKnownUsd) >= thresholds.stablecoinHighPct },
164
+ nftCoverage: { status: nftAssets.length || snapshotNftUnvalued ? (nftUnvaluedItems ? "partial" : "valued") : "not-observed", valuedItems: nftValuedItems, unvaluedItems: nftUnvaluedItems, knownUsd: rounded(nftAssets.filter((asset) => asset.usdValue != null).reduce((sum, asset) => sum + asset.usdValue, 0)) },
165
+ addresses,
166
+ warnings: [...new Set(warnings)],
167
+ };
168
+ }
169
+
170
+ export const portfolioRiskSummary = summarizePortfolioRisk;
@@ -0,0 +1,146 @@
1
+ const SIGNAL_TYPES = Object.freeze([
2
+ "wallet",
3
+ "pool",
4
+ "nft_floor",
5
+ "hl_flow",
6
+ "prediction_market",
7
+ ]);
8
+
9
+ const TYPE_ALIASES = Object.freeze({
10
+ wallets: "wallet",
11
+ smart_wallet: "wallet",
12
+ pools: "pool",
13
+ new_pool: "pool",
14
+ nft: "nft_floor",
15
+ nft_floors: "nft_floor",
16
+ hyperliquid: "hl_flow",
17
+ prediction: "prediction_market",
18
+ prediction_markets: "prediction_market",
19
+ });
20
+
21
+ const clamp = (value, min = 0, max = 1) => Math.min(max, Math.max(min, value));
22
+ const finite = (value) => (Number.isFinite(Number(value)) ? Number(value) : null);
23
+ const round = (value) => Math.round(value * 10_000) / 10_000;
24
+
25
+ function typeOf(event) {
26
+ const raw = String(event?.type ?? event?.surface ?? event?.kind ?? "").toLowerCase();
27
+ return TYPE_ALIASES[raw] ?? raw;
28
+ }
29
+
30
+ function idOf(event, index) {
31
+ return String(event?.id ?? event?.signalId ?? event?.asset ?? event?.market ?? event?.pool ?? event?.collection ?? `${typeOf(event)}:${index}`);
32
+ }
33
+
34
+ function add(contributions, feature, value, observed = true) {
35
+ if (!observed || value === null || !Number.isFinite(value)) return;
36
+ contributions.push({ feature, value: round(value) });
37
+ }
38
+
39
+ function walletFeatures(event, out) {
40
+ const buys = finite(event.repeatBuys ?? event.repeat_buys ?? event.buyCount);
41
+ const winRate = finite(event.walletWinRate ?? event.winRate);
42
+ add(out, "repeat_buys", buys === null ? null : clamp((buys - 1) / 4) * 0.38);
43
+ add(out, "wallet_win_rate", winRate === null ? null : (clamp(winRate) - 0.5) * 0.5);
44
+ }
45
+
46
+ function poolFeatures(event, out) {
47
+ const age = finite(event.ageHours ?? event.poolAgeHours);
48
+ const liquidity = finite(event.liquidityUsd ?? event.liquidity);
49
+ const locked = event.liquidityLocked ?? event.locked;
50
+ add(out, "pool_age", age === null ? null : age < 24 ? -0.42 : clamp(age / 720) * 0.12);
51
+ add(out, "liquidity", liquidity === null ? null : (clamp(Math.log10(Math.max(1, liquidity)) / 7) - 0.5) * 0.28);
52
+ add(out, "liquidity_lock", locked == null ? null : locked ? 0.12 : -0.28);
53
+ }
54
+
55
+ function nftFeatures(event, out) {
56
+ const change = finite(event.floorChange ?? event.floorChangePct);
57
+ const sales = finite(event.sales ?? event.saleCount);
58
+ add(out, "floor_change", change === null ? null : clamp(change, -1, 1) * 0.35);
59
+ add(out, "sales_depth", sales === null ? null : clamp(sales / 50) * 0.18);
60
+ }
61
+
62
+ function hlFeatures(event, out) {
63
+ const imbalance = finite(event.flowImbalance ?? event.imbalance);
64
+ const funding = finite(event.fundingRate ?? event.funding);
65
+ add(out, "flow_imbalance", imbalance === null ? null : clamp(imbalance, -1, 1) * 0.42);
66
+ add(out, "funding", funding === null ? null : -clamp(funding * 100, -1, 1) * 0.12);
67
+ }
68
+
69
+ function predictionFeatures(event, out) {
70
+ const market = finite(event.marketProbability ?? event.marketPrice ?? event.probability);
71
+ const fair = finite(event.fairProbability ?? event.referenceProbability ?? event.fairValue);
72
+ add(out, "probability_mispricing", market === null || fair === null ? null : clamp(fair - market, -1, 1) * 0.65);
73
+ const liquidity = finite(event.liquidityUsd ?? event.liquidity);
74
+ add(out, "market_liquidity", liquidity === null ? null : clamp(Math.log10(Math.max(1, liquidity)) / 6) * 0.12);
75
+ }
76
+
77
+ const FEATURE_BUILDERS = Object.freeze({
78
+ wallet: walletFeatures,
79
+ pool: poolFeatures,
80
+ nft_floor: nftFeatures,
81
+ hl_flow: hlFeatures,
82
+ prediction_market: predictionFeatures,
83
+ });
84
+
85
+ function markoutFor(event, id, markouts) {
86
+ const rows = markouts.filter((row) => {
87
+ const target = row?.signalId ?? row?.eventId ?? row?.id ?? row?.asset ?? row?.market;
88
+ return target != null && String(target) === id;
89
+ });
90
+ if (rows.length === 0) return null;
91
+ const values = rows.map((row) => finite(row.return ?? row.markout ?? row.pnl ?? row.value)).filter((value) => value !== null);
92
+ if (values.length === 0) return null;
93
+ return values.reduce((sum, value) => sum + value, 0) / values.length;
94
+ }
95
+
96
+ /** Pure, deterministic scoring of observed onchain events. It never fetches data or executes. */
97
+ export function scoreSignals(events = [], markouts = [], options = {}) {
98
+ if (!Array.isArray(events) || !Array.isArray(markouts)) throw new TypeError("events and markouts must be arrays");
99
+ const minimumCoverage = clamp(finite(options.minimumCoverage) ?? 0.5);
100
+
101
+ return events.map((event, index) => {
102
+ const type = typeOf(event);
103
+ const id = idOf(event, index);
104
+ const builder = FEATURE_BUILDERS[type];
105
+ const contributions = [];
106
+ if (builder) builder(event, contributions);
107
+
108
+ const expected = type === "wallet" ? 2 : type === "pool" ? 3 : 2;
109
+ const coverage = round(clamp(contributions.length / expected));
110
+ const rawFeatureScore = contributions.reduce((sum, item) => sum + item.value, 0);
111
+ const markout = markoutFor(event, id, markouts);
112
+ const markoutContribution = markout === null ? 0 : clamp(markout, -1, 1) * 0.35;
113
+ if (markout !== null) add(contributions, "shadow_markout", markoutContribution);
114
+ const score = round(clamp(0.5 + rawFeatureScore + markoutContribution));
115
+ const markoutFactor = markout === null ? 0.8 : clamp(0.85 + markout * 0.3, 0.35, 1);
116
+ const confidence = round(clamp(coverage * markoutFactor));
117
+ const flags = ["COLD_INTELLIGENCE_ONLY", "NO_HOT_EXECUTION"];
118
+ if (!builder) flags.push("UNSUPPORTED_SIGNAL_TYPE");
119
+ if (coverage < minimumCoverage) flags.push("LOW_COVERAGE", "NO_TRADE");
120
+ if (confidence < 0.5) flags.push("LOW_CONFIDENCE", "NO_TRADE");
121
+ if (type === "pool" && (finite(event.ageHours ?? event.poolAgeHours) ?? Infinity) < 24) flags.push("NEW_POOL_RISK", "NO_TRADE");
122
+ if (markout !== null && markout < 0) flags.push("NEGATIVE_MARKOUT");
123
+
124
+ return {
125
+ id,
126
+ type,
127
+ score,
128
+ confidence,
129
+ coverage,
130
+ contributions,
131
+ flags: [...new Set(flags)],
132
+ noTrade: flags.includes("NO_TRADE"),
133
+ executionAllowed: false,
134
+ };
135
+ }).sort((a, b) => b.score - a.score || b.confidence - a.confidence || a.id.localeCompare(b.id));
136
+ }
137
+
138
+ export function createSignalsEngine(defaults = {}) {
139
+ return Object.freeze({
140
+ score(events, markouts = [], options = {}) {
141
+ return scoreSignals(events, markouts, { ...defaults, ...options });
142
+ },
143
+ });
144
+ }
145
+
146
+ export { SIGNAL_TYPES };
@@ -0,0 +1 @@
1
+ export { SIGNAL_TYPES, createSignalsEngine, scoreSignals } from "./engine.mjs";
@@ -0,0 +1,85 @@
1
+ /** Notification classes supported by Oracle watches. */
2
+ export const WATCH_CATEGORIES = Object.freeze([
3
+ 'price',
4
+ 'wallet',
5
+ 'risk',
6
+ 'execution',
7
+ 'security',
8
+ 'nft',
9
+ 'governance',
10
+ 'system',
11
+ ]);
12
+
13
+ const CATEGORY_SET = new Set(WATCH_CATEGORIES);
14
+
15
+ function assertCategory(category) {
16
+ if (!CATEGORY_SET.has(category)) {
17
+ throw new TypeError(`Unknown watch category: ${String(category)}`);
18
+ }
19
+ return category;
20
+ }
21
+
22
+ function subscribedCategories(preferences = {}) {
23
+ const categories = preferences.subscribedCategories ?? [];
24
+ if (!Array.isArray(categories)) {
25
+ throw new TypeError('subscribedCategories must be an array');
26
+ }
27
+ return categories;
28
+ }
29
+
30
+ /** New users receive no category notifications until they explicitly opt in. */
31
+ export function defaultPreferences() {
32
+ return { subscribedCategories: [] };
33
+ }
34
+
35
+ /** Return a new preference value with exactly one notification class enabled. */
36
+ export function subscribe(preferences, category) {
37
+ assertCategory(category);
38
+ const current = subscribedCategories(preferences);
39
+ if (current.includes(category)) return { ...preferences, subscribedCategories: [...current] };
40
+ return { ...preferences, subscribedCategories: [...current, category] };
41
+ }
42
+
43
+ /** Return a new preference value with the requested notification class disabled. */
44
+ export function unsubscribe(preferences, category) {
45
+ assertCategory(category);
46
+ const current = subscribedCategories(preferences);
47
+ return {
48
+ ...preferences,
49
+ subscribedCategories: current.filter((candidate) => candidate !== category),
50
+ };
51
+ }
52
+
53
+ /** Create a direct watch. Notifications are opt-in independently for every watch. */
54
+ export function createWatch({ category, notify = false, ...watch } = {}) {
55
+ assertCategory(category);
56
+ if (typeof notify !== 'boolean') throw new TypeError('watch notify must be a boolean');
57
+ return { ...watch, category, notify };
58
+ }
59
+
60
+ function isMatchingNotifyingWatch(alert, watch) {
61
+ if (!watch || watch.notify !== true || watch.category !== alert.category) return false;
62
+ if (alert.watchId !== undefined && watch.id !== alert.watchId) return false;
63
+ return true;
64
+ }
65
+
66
+ /**
67
+ * Decide whether an alert may be delivered. This function only describes policy;
68
+ * it performs no scheduling or delivery.
69
+ */
70
+ export function evaluateAlert(alert, preferences = defaultPreferences(), watch) {
71
+ if (!alert || typeof alert !== 'object') throw new TypeError('alert must be an object');
72
+ assertCategory(alert.category);
73
+
74
+ if (subscribedCategories(preferences).includes(alert.category)) {
75
+ return { deliver: true, reason: 'category-subscribed' };
76
+ }
77
+ if (isMatchingNotifyingWatch(alert, watch)) {
78
+ return { deliver: true, reason: 'watch-opt-in' };
79
+ }
80
+ return { deliver: false, reason: 'not-subscribed' };
81
+ }
82
+
83
+ export function shouldDeliverAlert(alert, preferences, watch) {
84
+ return evaluateAlert(alert, preferences, watch).deliver;
85
+ }