@coinrithm/mcp-trading 0.7.15 → 0.7.16
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/CHANGELOG.md +127 -8
- package/README.md +18 -3
- package/dist/agent/bench/bench.d.ts +59 -0
- package/dist/agent/bench/bench.js +319 -0
- package/dist/agent/bench/cassette.d.ts +97 -0
- package/dist/agent/bench/cassette.js +181 -0
- package/dist/agent/bench/costs.d.ts +28 -0
- package/dist/agent/bench/costs.js +60 -0
- package/dist/agent/bench/labelBuilder.d.ts +123 -0
- package/dist/agent/bench/labelBuilder.js +282 -0
- package/dist/agent/bench/labels.d.ts +75 -0
- package/dist/agent/bench/labels.js +259 -0
- package/dist/agent/bench/recordingClient.d.ts +51 -0
- package/dist/agent/bench/recordingClient.js +193 -0
- package/dist/agent/bench/replayClient.d.ts +41 -0
- package/dist/agent/bench/replayClient.js +262 -0
- package/dist/agent/bench/report.d.ts +115 -0
- package/dist/agent/bench/report.js +435 -0
- package/dist/agent/cli.d.ts +25 -0
- package/dist/agent/cli.js +300 -2
- package/dist/agent/client.d.ts +1 -0
- package/dist/agent/client.js +6 -0
- package/dist/agent/configurationWarnings.js +11 -0
- package/dist/agent/decision.d.ts +3 -0
- package/dist/agent/decision.js +85 -3
- package/dist/agent/decisionValidator.d.ts +30 -0
- package/dist/agent/decisionValidator.js +147 -2
- package/dist/agent/engine.d.ts +3 -3
- package/dist/agent/engine.js +3 -3
- package/dist/agent/observe.d.ts +27 -2
- package/dist/agent/observe.js +529 -89
- package/dist/agent/prompt.d.ts +4 -1
- package/dist/agent/prompt.js +98 -7
- package/dist/agent/promptTables.d.ts +1 -0
- package/dist/agent/promptTables.js +33 -0
- package/dist/agent/providerCapabilities.d.ts +6 -0
- package/dist/agent/providerCapabilities.js +23 -0
- package/dist/agent/providers.d.ts +25 -7
- package/dist/agent/providers.js +188 -48
- package/dist/agent/resolve.js +3 -0
- package/dist/agent/runner.d.ts +8 -0
- package/dist/agent/runner.js +59 -15
- package/dist/agent/setups.d.ts +2 -1
- package/dist/agent/setups.js +17 -16
- package/dist/agent/signals.d.ts +33 -0
- package/dist/agent/signals.js +89 -0
- package/dist/agent/skill.js +37 -0
- package/dist/agent/skillValidator.js +33 -0
- package/dist/agent/strictLint.js +29 -0
- package/dist/agent/types.d.ts +149 -12
- package/dist/agent/universe.d.ts +78 -0
- package/dist/agent/universe.js +215 -0
- package/package.json +3 -2
package/dist/agent/observe.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// Observe phase: read CoinRithm state into one Observation. Sync-polls /trades
|
|
2
2
|
// before any write (polledBeforeWrite=true only after that succeeds). If a
|
|
3
3
|
// required read fails, or no watchlist symbol resolves, the cycle SKIPS writes.
|
|
4
|
+
import { INDICATOR_RANGES, indicatorRangeOf, signalThresholdsOf, } from "./signals.js";
|
|
5
|
+
import { filterUniverseRows, scansUniverse, universeQuery, universeQueryParams, universeResolveTop, universeSectorsOf, UNIVERSE_MAX_SCAN_LIMIT, } from "./universe.js";
|
|
4
6
|
import { futuresEntryEligibilityOf } from "./futuresEligibility.js";
|
|
5
7
|
import { asObj, asArr, asNum, asStr } from "./extract.js";
|
|
6
8
|
import { computeIndicators } from "./indicators.js";
|
|
@@ -369,10 +371,9 @@ export async function readPmCalibration(client, trace, nowMs = Date.now()) {
|
|
|
369
371
|
return undefined;
|
|
370
372
|
}
|
|
371
373
|
}
|
|
372
|
-
// The
|
|
373
|
-
//
|
|
374
|
-
|
|
375
|
-
const INDICATOR_INTERVAL_SECONDS = 300;
|
|
374
|
+
// The candle range is the agent's data diet (spec.data.indicatorRange, default
|
|
375
|
+
// 1D = 288 five-minute bars; 1W = 15m, 1M = 1h, 3M = 4h). Timestamps and gaps
|
|
376
|
+
// are checked separately; requesting a range does not establish freshness.
|
|
376
377
|
// `universe_scan` bounds: how many top movers to pull, and how many of those
|
|
377
378
|
// to fully resolve into tradable watch entries (each resolved row costs a
|
|
378
379
|
// resolve + market [+ candles] call).
|
|
@@ -449,7 +450,7 @@ function candleTimestamp(value) {
|
|
|
449
450
|
? value
|
|
450
451
|
: undefined;
|
|
451
452
|
}
|
|
452
|
-
function candleIntervals(times) {
|
|
453
|
+
function candleIntervals(times, intervalSeconds) {
|
|
453
454
|
let timestampedBarCount = 0;
|
|
454
455
|
let checkedIntervalCount = 0;
|
|
455
456
|
let irregularIntervalCount = 0;
|
|
@@ -464,7 +465,7 @@ function candleIntervals(times) {
|
|
|
464
465
|
continue;
|
|
465
466
|
const gap = current - previous;
|
|
466
467
|
checkedIntervalCount++;
|
|
467
|
-
if (gap !==
|
|
468
|
+
if (gap !== intervalSeconds)
|
|
468
469
|
irregularIntervalCount++;
|
|
469
470
|
maxGapSeconds = Math.max(maxGapSeconds ?? 0, gap);
|
|
470
471
|
}
|
|
@@ -486,13 +487,14 @@ function candleIntervals(times) {
|
|
|
486
487
|
// the 24h volume. Tolerant by design: any failure (HTTP error, malformed/sparse
|
|
487
488
|
// candles) yields null indicators so the cycle proceeds with price-only context
|
|
488
489
|
// rather than skipping.
|
|
489
|
-
async function fetchCandleContext(client, coinId, trace) {
|
|
490
|
+
async function fetchCandleContext(client, coinId, range, trace) {
|
|
491
|
+
const intervalSeconds = INDICATOR_RANGES[range];
|
|
490
492
|
// The try honors the documented tolerance for SYNCHRONOUS throws too (an
|
|
491
493
|
// unexpected client error must degrade to price-only context, never kill
|
|
492
494
|
// the cycle).
|
|
493
495
|
let cr;
|
|
494
496
|
try {
|
|
495
|
-
cr = await client.candles(coinId,
|
|
497
|
+
cr = await client.candles(coinId, range, trace);
|
|
496
498
|
}
|
|
497
499
|
catch {
|
|
498
500
|
return { indicators: null };
|
|
@@ -526,13 +528,13 @@ async function fetchCandleContext(client, coinId, trace) {
|
|
|
526
528
|
: undefined;
|
|
527
529
|
}
|
|
528
530
|
const latestTime = times.at(-1);
|
|
529
|
-
const recent = candleIntervals(times.slice(-15));
|
|
531
|
+
const recent = candleIntervals(times.slice(-15), intervalSeconds);
|
|
530
532
|
return {
|
|
531
533
|
indicators: computeIndicators(candles),
|
|
532
534
|
indicatorContext: {
|
|
533
|
-
range
|
|
534
|
-
nominalIntervalSeconds:
|
|
535
|
-
...candleIntervals(times),
|
|
535
|
+
range,
|
|
536
|
+
nominalIntervalSeconds: intervalSeconds,
|
|
537
|
+
...candleIntervals(times, intervalSeconds),
|
|
536
538
|
...(latestTime === undefined
|
|
537
539
|
? {}
|
|
538
540
|
: { asOf: new Date(latestTime * 1000).toISOString() }),
|
|
@@ -566,10 +568,320 @@ function coinFundamentalsOf(m) {
|
|
|
566
568
|
out.marketCapUsd = marketCapUsd;
|
|
567
569
|
return Object.keys(out).length > 0 ? out : undefined;
|
|
568
570
|
}
|
|
571
|
+
// Perpetual open interest from the same /market context (no extra call).
|
|
572
|
+
// Keep venue coverage with each delta: it need not cover the whole total.
|
|
573
|
+
// A malformed or absent block is omitted; a real zero reading is valid.
|
|
574
|
+
export function openInterestOf(m, nowMs = Date.now()) {
|
|
575
|
+
const oi = asObj(asObj(m.derivatives).openInterest);
|
|
576
|
+
const totalUsd = asNum(oi.totalUsd);
|
|
577
|
+
const asOf = asStr(oi.asOf);
|
|
578
|
+
const sourceMs = asOf ? Date.parse(asOf) : NaN;
|
|
579
|
+
if (totalUsd == null ||
|
|
580
|
+
totalUsd < 0 ||
|
|
581
|
+
!asOf ||
|
|
582
|
+
!Number.isFinite(sourceMs) ||
|
|
583
|
+
sourceMs > nowMs + 60_000)
|
|
584
|
+
return undefined;
|
|
585
|
+
const names = (v) => Array.isArray(v)
|
|
586
|
+
? [
|
|
587
|
+
...new Set(v.filter((x) => typeof x === "string" && x.length > 0)),
|
|
588
|
+
].sort()
|
|
589
|
+
: [];
|
|
590
|
+
const change1hVenues = names(oi.change1hVenues);
|
|
591
|
+
const change24hVenues = names(oi.change24hVenues);
|
|
592
|
+
const pct = (v) => {
|
|
593
|
+
const n = asNum(v);
|
|
594
|
+
return n == null ? null : n;
|
|
595
|
+
};
|
|
596
|
+
return {
|
|
597
|
+
totalUsd,
|
|
598
|
+
venues: names(asArr(oi.venues).map((v) => asObj(v).venue)),
|
|
599
|
+
change1hPct: change1hVenues.length ? pct(oi.change1hPct) : null,
|
|
600
|
+
change1hVenues,
|
|
601
|
+
change24hPct: change24hVenues.length ? pct(oi.change24hPct) : null,
|
|
602
|
+
change24hVenues,
|
|
603
|
+
asOf,
|
|
604
|
+
stale: oi.stale !== false || nowMs - sourceMs > 45 * 60_000,
|
|
605
|
+
};
|
|
606
|
+
}
|
|
607
|
+
// A provider time we can show: parseable and not more than 60 s ahead.
|
|
608
|
+
const shownTime = (v, nowMs) => {
|
|
609
|
+
const s = asStr(v);
|
|
610
|
+
if (!s)
|
|
611
|
+
return undefined;
|
|
612
|
+
const ms = Date.parse(s);
|
|
613
|
+
return Number.isFinite(ms) && ms <= nowMs + 60_000 ? s : undefined;
|
|
614
|
+
};
|
|
615
|
+
// A dated metric from the API's {value, asOf, stale}; omitted when malformed,
|
|
616
|
+
// negative or dated in the future. A real zero is valid.
|
|
617
|
+
const datedValueOf = (v, nowMs) => {
|
|
618
|
+
const o = asObj(v);
|
|
619
|
+
const value = asNum(o.value);
|
|
620
|
+
const asOf = shownTime(o.asOf, nowMs);
|
|
621
|
+
if (value == null || value < 0 || !asOf)
|
|
622
|
+
return undefined;
|
|
623
|
+
return { value, asOf, stale: o.stale !== false };
|
|
624
|
+
};
|
|
625
|
+
// Binance positioning ratios from the same /market context (no extra call).
|
|
626
|
+
// Each metric keeps its own provider time; malformed metrics are dropped and
|
|
627
|
+
// the block is omitted when none is usable.
|
|
628
|
+
export function positioningOf(m, nowMs = Date.now()) {
|
|
629
|
+
const p = asObj(asObj(m.derivatives).positioning);
|
|
630
|
+
const venue = asStr(p.venue);
|
|
631
|
+
const symbol = asStr(p.symbol);
|
|
632
|
+
if (!venue || !symbol)
|
|
633
|
+
return undefined;
|
|
634
|
+
const out = { venue, symbol };
|
|
635
|
+
const a = datedValueOf(p.longShortAccountRatio, nowMs);
|
|
636
|
+
const l = datedValueOf(p.longAccountPct, nowMs);
|
|
637
|
+
const t = datedValueOf(p.topTraderPositionRatio, nowMs);
|
|
638
|
+
const k = datedValueOf(p.takerBuySellRatio, nowMs);
|
|
639
|
+
if (a)
|
|
640
|
+
out.longShortAccountRatio = a;
|
|
641
|
+
if (l && l.value <= 100)
|
|
642
|
+
out.longAccountPct = l;
|
|
643
|
+
if (t)
|
|
644
|
+
out.topTraderPositionRatio = t;
|
|
645
|
+
if (k)
|
|
646
|
+
out.takerBuySellRatio = k;
|
|
647
|
+
return Object.keys(out).length > 2 ? out : undefined;
|
|
648
|
+
}
|
|
649
|
+
const liquidationWindowOf = (v) => {
|
|
650
|
+
const w = asObj(v);
|
|
651
|
+
const longU = asNum(w.longLiquidatedUsdt);
|
|
652
|
+
const shortU = asNum(w.shortLiquidatedUsdt);
|
|
653
|
+
const events = asNum(w.events);
|
|
654
|
+
const captured = asNum(w.capturedPct);
|
|
655
|
+
if (longU == null ||
|
|
656
|
+
shortU == null ||
|
|
657
|
+
events == null ||
|
|
658
|
+
captured == null ||
|
|
659
|
+
longU < 0 ||
|
|
660
|
+
shortU < 0 ||
|
|
661
|
+
events < 0 ||
|
|
662
|
+
!Number.isInteger(events) ||
|
|
663
|
+
captured < 0 ||
|
|
664
|
+
captured > 100)
|
|
665
|
+
return undefined;
|
|
666
|
+
return {
|
|
667
|
+
longLiquidatedUsdt: longU,
|
|
668
|
+
shortLiquidatedUsdt: shortU,
|
|
669
|
+
events,
|
|
670
|
+
capturedPct: captured,
|
|
671
|
+
};
|
|
672
|
+
};
|
|
673
|
+
// OKX liquidations from the same /market context. Both windows must be well
|
|
674
|
+
// formed; capturedPct travels with every sum (a lower bound of our capture
|
|
675
|
+
// uptime, not exchange completeness). Event counts must be whole numbers.
|
|
676
|
+
export function liquidationsOf(m, nowMs = Date.now()) {
|
|
677
|
+
const q = asObj(asObj(m.derivatives).liquidations);
|
|
678
|
+
const venue = asStr(q.venue);
|
|
679
|
+
const instId = asStr(q.instId);
|
|
680
|
+
const last1h = liquidationWindowOf(q.last1h);
|
|
681
|
+
const last24h = liquidationWindowOf(q.last24h);
|
|
682
|
+
if (!venue || !instId || !last1h || !last24h)
|
|
683
|
+
return undefined;
|
|
684
|
+
const lastEventAt = q.lastEventAt == null ? null : (shownTime(q.lastEventAt, nowMs) ?? null);
|
|
685
|
+
return { venue, instId, last1h, last24h, lastEventAt };
|
|
686
|
+
}
|
|
687
|
+
// Macro proxies from the /market context (same block for every coin). Quotes
|
|
688
|
+
// that are malformed or future-dated are dropped; omitted when none remain.
|
|
689
|
+
const optPct = (v) => {
|
|
690
|
+
const n = asNum(v);
|
|
691
|
+
return n == null ? null : n;
|
|
692
|
+
};
|
|
693
|
+
const depthSideOf = (v) => {
|
|
694
|
+
const o = asObj(v);
|
|
695
|
+
const usd = asNum(o.usd);
|
|
696
|
+
const reachPct = asNum(o.reachPct);
|
|
697
|
+
const levels = asNum(o.levels);
|
|
698
|
+
if (usd == null || usd < 0 || reachPct == null || reachPct < 0)
|
|
699
|
+
return null;
|
|
700
|
+
if (levels == null || !Number.isInteger(levels) || levels < 1 || levels > 20)
|
|
701
|
+
return null;
|
|
702
|
+
if (typeof o.complete !== "boolean")
|
|
703
|
+
return null;
|
|
704
|
+
if (o.complete && levels >= 20)
|
|
705
|
+
return null; // a full side is never whole
|
|
706
|
+
return { usd, reachPct, levels, complete: o.complete };
|
|
707
|
+
};
|
|
708
|
+
// Observed depth from the same /market context (no extra call). One venue's
|
|
709
|
+
// visible book; omitted when malformed, future-dated or without any side.
|
|
710
|
+
/**
|
|
711
|
+
* `{ coins }` from an additive `relatedCoins` array (direct coin slugs), or
|
|
712
|
+
* nothing: non-arrays and non-string/blank entries are dropped, duplicates
|
|
713
|
+
* merged. Every valid slug is kept: the API never truncates the list, and
|
|
714
|
+
* dropping a linked coin could let a bet bypass that coin's cap. An empty
|
|
715
|
+
* result adds no field.
|
|
716
|
+
*/
|
|
717
|
+
export function coinsField(value) {
|
|
718
|
+
if (!Array.isArray(value))
|
|
719
|
+
return {};
|
|
720
|
+
const coins = [
|
|
721
|
+
...new Set(value
|
|
722
|
+
.filter((c) => typeof c === "string")
|
|
723
|
+
.map((c) => c.trim().toLowerCase())
|
|
724
|
+
.filter((c) => c.length > 0 && c.length <= 100)),
|
|
725
|
+
];
|
|
726
|
+
return coins.length ? { coins } : {};
|
|
727
|
+
}
|
|
728
|
+
export function fundingByVenueOf(m, nowMs = Date.now()) {
|
|
729
|
+
const block = asObj(asObj(m.derivatives).fundingByVenue);
|
|
730
|
+
if (block.sameTime !== false ||
|
|
731
|
+
!Array.isArray(block.rates) ||
|
|
732
|
+
block.rates.length > 2)
|
|
733
|
+
return undefined;
|
|
734
|
+
const rates = [];
|
|
735
|
+
const venues = new Set();
|
|
736
|
+
for (const value of block.rates) {
|
|
737
|
+
const r = asObj(value);
|
|
738
|
+
const role = r.role, venue = asStr(r.venue), symbol = asStr(r.symbol);
|
|
739
|
+
const fetchedAt = shownTime(r.fetchedAt, nowMs), rateFraction = asNum(r.rateFraction);
|
|
740
|
+
if ((role !== "context_only" && role !== "settlement_reference") ||
|
|
741
|
+
!venue ||
|
|
742
|
+
!symbol ||
|
|
743
|
+
!fetchedAt ||
|
|
744
|
+
rateFraction === undefined ||
|
|
745
|
+
r.sourceAt !== null ||
|
|
746
|
+
r.freshnessBasis !== "collection_time")
|
|
747
|
+
continue;
|
|
748
|
+
const source = role === "context_only"
|
|
749
|
+
? "hyperliquid_predicted_fundings"
|
|
750
|
+
: "paper_futures_reference";
|
|
751
|
+
if (r.source !== source ||
|
|
752
|
+
(role === "context_only" &&
|
|
753
|
+
(venue !== "hyperliquid" ||
|
|
754
|
+
r.intervalHours !== 1 ||
|
|
755
|
+
Math.abs(rateFraction) > 0.04)))
|
|
756
|
+
continue;
|
|
757
|
+
const intervalHours = typeof r.intervalHours === "number" &&
|
|
758
|
+
Number.isInteger(r.intervalHours) &&
|
|
759
|
+
r.intervalHours > 0 &&
|
|
760
|
+
r.intervalHours <= 24
|
|
761
|
+
? r.intervalHours
|
|
762
|
+
: null;
|
|
763
|
+
const hourlyEquivalentFraction = intervalHours === null ? null : rateFraction / intervalHours;
|
|
764
|
+
if (hourlyEquivalentFraction !== null &&
|
|
765
|
+
!Number.isFinite(hourlyEquivalentFraction))
|
|
766
|
+
continue;
|
|
767
|
+
// A duplicate venue makes identity ambiguous; never pick the first silently.
|
|
768
|
+
if (venues.has(venue))
|
|
769
|
+
return undefined;
|
|
770
|
+
venues.add(venue);
|
|
771
|
+
const ageSeconds = Math.max(0, Math.floor((nowMs - Date.parse(fetchedAt)) / 1000));
|
|
772
|
+
const next = typeof r.nextFundingTime === "string"
|
|
773
|
+
? Date.parse(r.nextFundingTime)
|
|
774
|
+
: NaN;
|
|
775
|
+
const nextFundingTime = Number.isFinite(next) &&
|
|
776
|
+
next > nowMs &&
|
|
777
|
+
next <=
|
|
778
|
+
Date.parse(fetchedAt) +
|
|
779
|
+
(role === "context_only" ? 61 * 60_000 : 24 * 3_600_000)
|
|
780
|
+
? new Date(next).toISOString()
|
|
781
|
+
: null;
|
|
782
|
+
rates.push({
|
|
783
|
+
role,
|
|
784
|
+
venue,
|
|
785
|
+
symbol,
|
|
786
|
+
source,
|
|
787
|
+
rateFraction,
|
|
788
|
+
intervalHours,
|
|
789
|
+
hourlyEquivalentFraction,
|
|
790
|
+
nextFundingTime,
|
|
791
|
+
fetchedAt,
|
|
792
|
+
sourceAt: null,
|
|
793
|
+
ageSeconds,
|
|
794
|
+
stale: r.stale !== false || ageSeconds > 1800,
|
|
795
|
+
freshnessBasis: "collection_time",
|
|
796
|
+
});
|
|
797
|
+
}
|
|
798
|
+
return rates.some((r) => r.role === "context_only")
|
|
799
|
+
? { rates, sameTime: false }
|
|
800
|
+
: undefined;
|
|
801
|
+
}
|
|
802
|
+
export function depthOf(m, nowMs = Date.now()) {
|
|
803
|
+
const d = asObj(asObj(m.derivatives).depth);
|
|
804
|
+
const venue = asStr(d.venue);
|
|
805
|
+
const asOf = shownTime(d.asOf, nowMs);
|
|
806
|
+
if (!venue || !asOf)
|
|
807
|
+
return undefined;
|
|
808
|
+
const bid = depthSideOf(d.bid);
|
|
809
|
+
const ask = depthSideOf(d.ask);
|
|
810
|
+
if (!bid && !ask)
|
|
811
|
+
return undefined;
|
|
812
|
+
return { venue, bid, ask, asOf, stale: d.stale !== false };
|
|
813
|
+
}
|
|
814
|
+
// Chain TVL from the same /market context (no extra call). Omitted when the
|
|
815
|
+
// coin has no verified chain association or the block is malformed. publishedAt is
|
|
816
|
+
// response provenance, never the TVL's observation time (unknown, null); a
|
|
817
|
+
// future-dated time is never shown and an unknown one stays stale.
|
|
818
|
+
export function chainTvlOf(m, nowMs = Date.now()) {
|
|
819
|
+
const c = asObj(asObj(m.defi).chainTvl);
|
|
820
|
+
const chain = asStr(c.chain);
|
|
821
|
+
const tvl = asNum(c.tvlUsd);
|
|
822
|
+
if (!chain || tvl == null || tvl < 0)
|
|
823
|
+
return undefined;
|
|
824
|
+
const publishedAt = c.publishedAt == null ? null : (shownTime(c.publishedAt, nowMs) ?? null);
|
|
825
|
+
const fetchedAt = c.fetchedAt == null ? null : (shownTime(c.fetchedAt, nowMs) ?? null);
|
|
826
|
+
const dayAt = c.dayAt == null ? null : (shownTime(c.dayAt, nowMs) ?? null);
|
|
827
|
+
return {
|
|
828
|
+
chain,
|
|
829
|
+
tvlUsd: tvl,
|
|
830
|
+
publishedAt,
|
|
831
|
+
fetchedAt,
|
|
832
|
+
sourceObservedAt: null,
|
|
833
|
+
// Unknown publication OR collection time is never fresh (root 56890).
|
|
834
|
+
stale: publishedAt == null || fetchedAt == null || c.stale !== false,
|
|
835
|
+
dayAt,
|
|
836
|
+
change1dPct: dayAt ? optPct(c.change1dPct) : null,
|
|
837
|
+
change7dPct: dayAt ? optPct(c.change7dPct) : null,
|
|
838
|
+
};
|
|
839
|
+
}
|
|
840
|
+
// Market-wide stablecoin supply from the /market context (same for every coin).
|
|
841
|
+
export function stablecoinSupplyOf(m, nowMs = Date.now()) {
|
|
842
|
+
const s = asObj(asObj(m.defi).stablecoinSupply);
|
|
843
|
+
const total = asNum(s.totalUsd);
|
|
844
|
+
const dayAt = shownTime(s.dayAt, nowMs);
|
|
845
|
+
if (total == null || total < 0 || !dayAt)
|
|
846
|
+
return undefined;
|
|
847
|
+
return {
|
|
848
|
+
totalUsd: total,
|
|
849
|
+
dayAt,
|
|
850
|
+
change1dPct: optPct(s.change1dPct),
|
|
851
|
+
change7dPct: optPct(s.change7dPct),
|
|
852
|
+
stale: s.stale !== false,
|
|
853
|
+
};
|
|
854
|
+
}
|
|
855
|
+
export function macroOf(m, nowMs = Date.now()) {
|
|
856
|
+
const mc = asObj(m.macro);
|
|
857
|
+
const note = asStr(mc.note);
|
|
858
|
+
const quotes = [];
|
|
859
|
+
for (const raw of asArr(mc.quotes)) {
|
|
860
|
+
const q = asObj(raw);
|
|
861
|
+
const symbol = asStr(q.symbol);
|
|
862
|
+
const label = asStr(q.label);
|
|
863
|
+
const kind = asStr(q.kind);
|
|
864
|
+
const price = asNum(q.price);
|
|
865
|
+
const asOf = shownTime(q.asOf, nowMs);
|
|
866
|
+
if (!symbol || !label || !kind || price == null || price <= 0 || !asOf)
|
|
867
|
+
continue;
|
|
868
|
+
const change = asNum(q.change24hPct);
|
|
869
|
+
quotes.push({
|
|
870
|
+
symbol,
|
|
871
|
+
label,
|
|
872
|
+
kind,
|
|
873
|
+
price,
|
|
874
|
+
change24hPct: change == null ? null : change,
|
|
875
|
+
asOf,
|
|
876
|
+
stale: q.stale !== false,
|
|
877
|
+
});
|
|
878
|
+
}
|
|
879
|
+
return note && quotes.length > 0 ? { note, quotes } : undefined;
|
|
880
|
+
}
|
|
569
881
|
// Enrich a watch entry with what the candles fetch yields (indicators + 24h
|
|
570
882
|
// volume) when the `indicators` capability is on. One call, both fields.
|
|
571
|
-
async function enrichFromCandles(client, entry, coinId, trace) {
|
|
572
|
-
const cc = await fetchCandleContext(client, coinId, trace);
|
|
883
|
+
async function enrichFromCandles(client, entry, coinId, range, trace) {
|
|
884
|
+
const cc = await fetchCandleContext(client, coinId, range, trace);
|
|
573
885
|
if (cc.indicators)
|
|
574
886
|
entry.indicators = cc.indicators;
|
|
575
887
|
if (cc.indicatorContext)
|
|
@@ -588,6 +900,22 @@ async function enrichFromCandles(client, entry, coinId, trace) {
|
|
|
588
900
|
}
|
|
589
901
|
}
|
|
590
902
|
const HEADLINES_PER_COIN = 3;
|
|
903
|
+
const NEWS_URL_MAX_CHARS = 300;
|
|
904
|
+
// A source link is kept only when it is a plain http(s) URL of bounded length;
|
|
905
|
+
// anything else (javascript:, data:, oversized) is dropped, never rewritten.
|
|
906
|
+
function newsUrl(raw) {
|
|
907
|
+
if (!raw || raw.length > NEWS_URL_MAX_CHARS)
|
|
908
|
+
return undefined;
|
|
909
|
+
try {
|
|
910
|
+
const url = new URL(raw);
|
|
911
|
+
return url.protocol === "https:" || url.protocol === "http:"
|
|
912
|
+
? url.toString()
|
|
913
|
+
: undefined;
|
|
914
|
+
}
|
|
915
|
+
catch {
|
|
916
|
+
return undefined;
|
|
917
|
+
}
|
|
918
|
+
}
|
|
591
919
|
const HEADLINE_TITLE_CHARS = 110;
|
|
592
920
|
const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
593
921
|
// Attribute the fetched news to the coins on watch: by the curated slug link
|
|
@@ -709,6 +1037,7 @@ function expandPmMarkets(discData, heldPmKeys) {
|
|
|
709
1037
|
// resolution date and the venue-reported liquidity (USD).
|
|
710
1038
|
endDate: asStr(ev.endDate) ?? undefined,
|
|
711
1039
|
liquidityUsd: asNum(ev.liquidity) ?? undefined,
|
|
1040
|
+
...coinsField(ev.relatedCoins),
|
|
712
1041
|
...(consensus !== undefined ? { consensus } : {}),
|
|
713
1042
|
...(rules !== undefined ? { rules } : {}),
|
|
714
1043
|
...(outcomeRule ? { outcomeRule } : {}),
|
|
@@ -741,7 +1070,7 @@ function emptyObservation(state, scopes = []) {
|
|
|
741
1070
|
polledBeforeWrite: false,
|
|
742
1071
|
};
|
|
743
1072
|
}
|
|
744
|
-
export async function observe(client, spec, state, trace) {
|
|
1073
|
+
export async function observe(client, spec, state, trace, options = {}) {
|
|
745
1074
|
const meR = await client.me(trace);
|
|
746
1075
|
if (!meR.ok)
|
|
747
1076
|
return {
|
|
@@ -834,6 +1163,8 @@ export async function observe(client, spec, state, trace) {
|
|
|
834
1163
|
// Bounded RAG: the market-wide Fear & Greed regime, captured once from the first
|
|
835
1164
|
// coin's /market context (it's market-wide, identical across coins).
|
|
836
1165
|
let marketMood;
|
|
1166
|
+
let macro;
|
|
1167
|
+
let stablecoinSupply;
|
|
837
1168
|
const wantIndicators = spec.capabilities.includes("indicators");
|
|
838
1169
|
const wantNews = spec.capabilities.includes("news");
|
|
839
1170
|
for (const symbol of spec.risk.watchlist) {
|
|
@@ -870,6 +1201,29 @@ export async function observe(client, spec, state, trace) {
|
|
|
870
1201
|
const fundamentals = coinFundamentalsOf(m);
|
|
871
1202
|
if (fundamentals)
|
|
872
1203
|
entry.fundamentals = fundamentals;
|
|
1204
|
+
const openInterest = openInterestOf(m);
|
|
1205
|
+
if (openInterest)
|
|
1206
|
+
entry.openInterest = openInterest;
|
|
1207
|
+
const positioning = positioningOf(m);
|
|
1208
|
+
if (positioning)
|
|
1209
|
+
entry.positioning = positioning;
|
|
1210
|
+
const liquidations = liquidationsOf(m);
|
|
1211
|
+
if (liquidations)
|
|
1212
|
+
entry.liquidations = liquidations;
|
|
1213
|
+
const chainTvl = chainTvlOf(m);
|
|
1214
|
+
if (chainTvl)
|
|
1215
|
+
entry.chainTvl = chainTvl;
|
|
1216
|
+
const depth = depthOf(m);
|
|
1217
|
+
if (depth)
|
|
1218
|
+
entry.depth = depth;
|
|
1219
|
+
const fundingByVenue = fundingByVenueOf(m);
|
|
1220
|
+
if (fundingByVenue)
|
|
1221
|
+
entry.fundingByVenue = fundingByVenue;
|
|
1222
|
+
// Macro proxies are the same for every coin: keep the first usable block.
|
|
1223
|
+
if (!macro)
|
|
1224
|
+
macro = macroOf(m);
|
|
1225
|
+
if (!stablecoinSupply)
|
|
1226
|
+
stablecoinSupply = stablecoinSupplyOf(m);
|
|
873
1227
|
// Capture the market-wide Fear & Greed regime once (same across coins).
|
|
874
1228
|
if (!marketMood) {
|
|
875
1229
|
const fg = asObj(m.fearGreed);
|
|
@@ -886,7 +1240,7 @@ export async function observe(client, spec, state, trace) {
|
|
|
886
1240
|
// model reasons over structure (trend/momentum/volatility/breakout) instead
|
|
887
1241
|
// of price + %change alone. Backed by the candles endpoint's shared cache.
|
|
888
1242
|
if (wantIndicators)
|
|
889
|
-
await enrichFromCandles(client, entry, coinId, trace);
|
|
1243
|
+
await enrichFromCandles(client, entry, coinId, indicatorRangeOf(spec), trace);
|
|
890
1244
|
watch.push(entry);
|
|
891
1245
|
}
|
|
892
1246
|
// `universe_scan` capability (2026-08-18, direct user request): discover the
|
|
@@ -899,79 +1253,159 @@ export async function observe(client, spec, state, trace) {
|
|
|
899
1253
|
// never a skipped cycle. Watchlist + blocklist symbols are excluded up
|
|
900
1254
|
// front so a discovered row can never duplicate or bypass the deny-list.
|
|
901
1255
|
let universeMovers;
|
|
902
|
-
if (spec
|
|
903
|
-
const
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
1256
|
+
if (scansUniverse(spec)) {
|
|
1257
|
+
const excluded = new Set([...spec.risk.watchlist, ...(spec.risk.blocklist ?? [])].map((s) => s.toUpperCase()));
|
|
1258
|
+
// Declared boundaries ask the screener; otherwise keep the original
|
|
1259
|
+
// top-gainers scan exactly. Either way the rows share one resolve path.
|
|
1260
|
+
let rows = [];
|
|
1261
|
+
let resolveTopCount = UNIVERSE_RESOLVE_TOP;
|
|
1262
|
+
if (spec.universe) {
|
|
1263
|
+
const q = universeQuery(spec.universe);
|
|
1264
|
+
resolveTopCount = universeResolveTop(spec.universe);
|
|
1265
|
+
const ur = await client.agentUniverse(universeQueryParams(q), trace);
|
|
1266
|
+
if (ur.ok) {
|
|
1267
|
+
rows = filterUniverseRows(asArr(asObj(ur.data).rows)
|
|
1268
|
+
.map(asObj)
|
|
1269
|
+
.map((r) => ({
|
|
1270
|
+
symbol: (asStr(r.symbol) ?? "").toUpperCase(),
|
|
1271
|
+
name: asStr(r.name),
|
|
1272
|
+
slug: asStr(r.slug),
|
|
1273
|
+
coinId: asStr(r.ucid),
|
|
1274
|
+
marketCapRank: asNum(r.marketCapRank) ?? undefined,
|
|
1275
|
+
priceUsd: asNumLoose(r.priceUsd),
|
|
1276
|
+
change24h: asNumLoose(r.change24h),
|
|
1277
|
+
volume24hUsd: asNumLoose(r.volume24hUsd),
|
|
1278
|
+
// Missing stays missing: the filter must not read an absent
|
|
1279
|
+
// sector list as "no excluded sector".
|
|
1280
|
+
sectors: universeSectorsOf(r.sectors),
|
|
1281
|
+
})), q);
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
else {
|
|
1285
|
+
const mv = await client.cryptoMovers("gainers", UNIVERSE_SCAN_LIMIT, trace);
|
|
1286
|
+
if (mv.ok && Array.isArray(mv.data)) {
|
|
1287
|
+
rows = mv.data.map(asObj).map((r) => ({
|
|
1288
|
+
symbol: (asStr(r.symbol) ?? "").toUpperCase(),
|
|
1289
|
+
name: asStr(r.name),
|
|
1290
|
+
// Both serialize as decimal STRINGS on the live feed (openapi
|
|
1291
|
+
// PublicCryptoMover; probed 2026-09-02: "72.34"), so the strict
|
|
1292
|
+
// asNum read left them undefined. Parse the numeric string.
|
|
1293
|
+
change24h: asNumLoose(r.change24h),
|
|
1294
|
+
priceUsd: asNumLoose(r.currentPrice),
|
|
1295
|
+
slug: asStr(r.slug),
|
|
1296
|
+
// The movers row already carries the ucid, which IS the coinId every
|
|
1297
|
+
// downstream call takes. Kept so the resolve round-trip below can be
|
|
1298
|
+
// skipped — see the comment there.
|
|
1299
|
+
coinId: asStr(r.ucid),
|
|
1300
|
+
}));
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
rows = rows.filter((r) => r.symbol && !excluded.has(r.symbol));
|
|
1304
|
+
for (const row of rows.slice(0, resolveTopCount)) {
|
|
1305
|
+
// Prefer the ucid the feed already gave us. Resolving the SYMBOL
|
|
1306
|
+
// instead was both a wasted call per discovered mover and a correctness
|
|
1307
|
+
// hazard: symbols collide across listings, so the resolver could hand
|
|
1308
|
+
// back a different coin than the one that actually moved, and the
|
|
1309
|
+
// agent would analyze (and trade) that other coin.
|
|
1310
|
+
let coinId = row.coinId;
|
|
1311
|
+
let resolvedName;
|
|
1312
|
+
if (!coinId) {
|
|
1313
|
+
const rs = await client.resolve(row.symbol, trace);
|
|
1314
|
+
const match = asObj(asObj(rs.data).match);
|
|
1315
|
+
coinId =
|
|
1316
|
+
rs.ok && match.coinId != null ? String(match.coinId) : undefined;
|
|
1317
|
+
resolvedName = asStr(match.name);
|
|
1318
|
+
}
|
|
1319
|
+
if (!coinId)
|
|
1320
|
+
continue;
|
|
1321
|
+
const mk = await client.market(coinId, trace);
|
|
1322
|
+
const m = asObj(mk.data);
|
|
1323
|
+
const price = asObj(m.price);
|
|
1324
|
+
const entry = {
|
|
1325
|
+
symbol: row.symbol,
|
|
1326
|
+
coinId,
|
|
1327
|
+
name: resolvedName ?? row.name ?? undefined,
|
|
1328
|
+
priceUsd: asNum(price.usd) ?? row.priceUsd,
|
|
1329
|
+
change1h: asNum(price.change1h),
|
|
1330
|
+
change24h: asNum(price.change24h) ?? row.change24h,
|
|
1331
|
+
change7d: asNum(price.change7d),
|
|
1332
|
+
...sentimentContextOf(m),
|
|
1333
|
+
freshness: freshnessOf(asObj(m.observation)),
|
|
1334
|
+
futuresEntryEligibility: futuresEntryEligibilityOf(m),
|
|
1335
|
+
discovered: true,
|
|
1336
|
+
slug: row.slug ?? asStr(asObj(asObj(m.observation).dataset).coinSlug),
|
|
1337
|
+
};
|
|
1338
|
+
const fundamentals = coinFundamentalsOf(m);
|
|
1339
|
+
if (fundamentals)
|
|
1340
|
+
entry.fundamentals = fundamentals;
|
|
1341
|
+
const openInterest = openInterestOf(m);
|
|
1342
|
+
if (openInterest)
|
|
1343
|
+
entry.openInterest = openInterest;
|
|
1344
|
+
const positioning = positioningOf(m);
|
|
1345
|
+
if (positioning)
|
|
1346
|
+
entry.positioning = positioning;
|
|
1347
|
+
const liquidations = liquidationsOf(m);
|
|
1348
|
+
if (liquidations)
|
|
1349
|
+
entry.liquidations = liquidations;
|
|
1350
|
+
const chainTvl = chainTvlOf(m);
|
|
1351
|
+
if (chainTvl)
|
|
1352
|
+
entry.chainTvl = chainTvl;
|
|
1353
|
+
const depth = depthOf(m);
|
|
1354
|
+
if (depth)
|
|
1355
|
+
entry.depth = depth;
|
|
1356
|
+
const fundingByVenue = fundingByVenueOf(m);
|
|
1357
|
+
if (fundingByVenue)
|
|
1358
|
+
entry.fundingByVenue = fundingByVenue;
|
|
1359
|
+
if (!macro)
|
|
1360
|
+
macro = macroOf(m);
|
|
1361
|
+
if (!stablecoinSupply)
|
|
1362
|
+
stablecoinSupply = stablecoinSupplyOf(m);
|
|
1363
|
+
if (wantIndicators)
|
|
1364
|
+
await enrichFromCandles(client, entry, coinId, indicatorRangeOf(spec), trace);
|
|
1365
|
+
watch.push(entry);
|
|
1366
|
+
}
|
|
1367
|
+
const context = rows
|
|
1368
|
+
.slice(resolveTopCount)
|
|
1369
|
+
.map(({ symbol, name, change24h, priceUsd }) => ({
|
|
1370
|
+
symbol,
|
|
1371
|
+
name,
|
|
1372
|
+
change24hPct: change24h,
|
|
1373
|
+
priceUsd,
|
|
1374
|
+
}));
|
|
1375
|
+
if (context.length > 0)
|
|
1376
|
+
universeMovers = context;
|
|
1377
|
+
// Watchlist coins must also prove they are inside declared boundaries
|
|
1378
|
+
// before a new entry (universeEntryBlock). Bounded membership batches use
|
|
1379
|
+
// the same filters; a failed batch leaves its coins unverified (no entries),
|
|
1380
|
+
// never silently eligible. Held positions stay closable either way.
|
|
1381
|
+
if (spec.universe) {
|
|
1382
|
+
const toCheck = watch.filter((w) => !w.discovered && w.coinId);
|
|
1383
|
+
const ids = [...new Set(toCheck.map((w) => w.coinId))];
|
|
1384
|
+
for (let offset = 0; offset < ids.length; offset += UNIVERSE_MAX_SCAN_LIMIT) {
|
|
1385
|
+
const batch = ids.slice(offset, offset + UNIVERSE_MAX_SCAN_LIMIT);
|
|
1386
|
+
const q = {
|
|
1387
|
+
...universeQuery(spec.universe),
|
|
1388
|
+
sort: "rank",
|
|
1389
|
+
limit: batch.length,
|
|
957
1390
|
};
|
|
958
|
-
const
|
|
959
|
-
if (
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
1391
|
+
const mr = await client.agentUniverse(universeQueryParams(q, batch), trace);
|
|
1392
|
+
if (mr.ok) {
|
|
1393
|
+
const inside = new Set(filterUniverseRows(asArr(asObj(mr.data).rows)
|
|
1394
|
+
.map(asObj)
|
|
1395
|
+
.map((r) => ({
|
|
1396
|
+
symbol: (asStr(r.symbol) ?? "").toUpperCase(),
|
|
1397
|
+
coinId: asStr(r.ucid),
|
|
1398
|
+
marketCapRank: asNum(r.marketCapRank) ?? undefined,
|
|
1399
|
+
volume24hUsd: asNumLoose(r.volume24hUsd),
|
|
1400
|
+
sectors: universeSectorsOf(r.sectors),
|
|
1401
|
+
})), q).map((r) => r.coinId));
|
|
1402
|
+
const checked = new Set(batch);
|
|
1403
|
+
for (const w of toCheck) {
|
|
1404
|
+
if (checked.has(w.coinId))
|
|
1405
|
+
w.withinBoundaries = inside.has(w.coinId);
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
964
1408
|
}
|
|
965
|
-
const context = rows
|
|
966
|
-
.slice(UNIVERSE_RESOLVE_TOP)
|
|
967
|
-
.map(({ symbol, name, change24hPct, priceUsd }) => ({
|
|
968
|
-
symbol,
|
|
969
|
-
name,
|
|
970
|
-
change24hPct,
|
|
971
|
-
priceUsd,
|
|
972
|
-
}));
|
|
973
|
-
if (context.length > 0)
|
|
974
|
-
universeMovers = context;
|
|
975
1409
|
}
|
|
976
1410
|
}
|
|
977
1411
|
// Spot resting orders (for cancel + affordability) — only if spot is enabled.
|
|
@@ -1005,7 +1439,7 @@ export async function observe(client, spec, state, trace) {
|
|
|
1005
1439
|
if (wantPm) {
|
|
1006
1440
|
// Curated board for every non-mechanical agent: churn rows removed and a
|
|
1007
1441
|
// deeper page so the filter does not empty it (see PM_CALIBRATION_CHURN_RE).
|
|
1008
|
-
const curatedPmBoard = spec.model?.provider !== "mechanical";
|
|
1442
|
+
const curatedPmBoard = options.curatedPmBoard ?? spec.model?.provider !== "mechanical";
|
|
1009
1443
|
const primaryDiscoveryLimit = curatedPmBoard ? 30 : 12;
|
|
1010
1444
|
// Bias PM discovery toward CRYPTO markets the agent has a price view on, the
|
|
1011
1445
|
// only PM markets where a price agent's view is even relevant (probed
|
|
@@ -1070,6 +1504,8 @@ export async function observe(client, spec, state, trace) {
|
|
|
1070
1504
|
entryProbability: asNum(p.entryProbability),
|
|
1071
1505
|
currentProbability: asNum(p.currentProbability),
|
|
1072
1506
|
openedAt: asStr(p.openedAt),
|
|
1507
|
+
...coinsField(p.relatedCoins),
|
|
1508
|
+
...(asStr(p.eventEndDate) ? { endDate: asStr(p.eventEndDate) } : {}),
|
|
1073
1509
|
}));
|
|
1074
1510
|
// Settlement-feedback loop: the SAME /positions/pm response carries an
|
|
1075
1511
|
// additive `recentlyResolved` array — the agent's OWN bets that settled
|
|
@@ -1200,6 +1636,8 @@ export async function observe(client, spec, state, trace) {
|
|
|
1200
1636
|
importance: asNum(it.importance) ?? undefined,
|
|
1201
1637
|
ageHours: ((a) => a == null ? undefined : Math.round((a / 60) * 10) / 10)(asNum(it.ageMinutes)),
|
|
1202
1638
|
publishedAt: asStr(it.publishedAt) ?? undefined,
|
|
1639
|
+
url: newsUrl(asStr(it.url)),
|
|
1640
|
+
category: asStr(it.category)?.slice(0, 40) ?? undefined,
|
|
1203
1641
|
coins: asArr(it.coins)
|
|
1204
1642
|
.map((c) => asStr(c))
|
|
1205
1643
|
.filter((c) => !!c),
|
|
@@ -1234,8 +1672,10 @@ export async function observe(client, spec, state, trace) {
|
|
|
1234
1672
|
// model acts on these instead of re-deciding "is there a setup?" from scratch.
|
|
1235
1673
|
// openPositions are passed so setups on a held symbol are tagged "manage,
|
|
1236
1674
|
// don't re-open".
|
|
1237
|
-
setups: scanSetups(watch, openPositions),
|
|
1675
|
+
setups: scanSetups(watch, openPositions, signalThresholdsOf(spec)),
|
|
1238
1676
|
marketMood,
|
|
1677
|
+
...(macro ? { macro } : {}),
|
|
1678
|
+
...(stablecoinSupply ? { stablecoinSupply } : {}),
|
|
1239
1679
|
syncCursor,
|
|
1240
1680
|
newClosedTrades,
|
|
1241
1681
|
polledBeforeWrite,
|