@coinrithm/mcp-trading 0.7.7 → 0.7.8
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 +83 -0
- package/dist/agent/act.js +19 -6
- package/dist/agent/capitalSizing.d.ts +32 -0
- package/dist/agent/capitalSizing.js +257 -0
- package/dist/agent/decision.d.ts +392 -0
- package/dist/agent/decision.js +177 -0
- package/dist/agent/decisionReceipt.d.ts +45 -0
- package/dist/agent/decisionReceipt.js +595 -0
- package/dist/agent/decisionValidator.d.ts +19 -2
- package/dist/agent/decisionValidator.js +74 -3
- package/dist/agent/engine.d.ts +1 -0
- package/dist/agent/engine.js +1 -0
- package/dist/agent/observe.js +175 -34
- package/dist/agent/pmContext.d.ts +13 -0
- package/dist/agent/pmContext.js +136 -0
- package/dist/agent/prompt.d.ts +11 -1
- package/dist/agent/prompt.js +135 -7
- package/dist/agent/providerCapabilities.d.ts +3 -0
- package/dist/agent/providerCapabilities.js +41 -3
- package/dist/agent/providers.js +119 -73
- package/dist/agent/resolve.js +1 -0
- package/dist/agent/runner.d.ts +4 -1
- package/dist/agent/runner.js +360 -34
- package/dist/agent/scorecard.js +7 -1
- package/dist/agent/skill.js +17 -0
- package/dist/agent/skillValidator.d.ts +1 -0
- package/dist/agent/skillValidator.js +56 -0
- package/dist/agent/state.js +6 -0
- package/dist/agent/strictLint.js +19 -0
- package/dist/agent/thesis.d.ts +40 -0
- package/dist/agent/thesis.js +319 -0
- package/dist/agent/types.d.ts +127 -0
- package/package.json +1 -1
|
@@ -4,7 +4,43 @@
|
|
|
4
4
|
// market text cannot widen a limit or force a trade. Covers futures + spot + PM.
|
|
5
5
|
import { ok, fail, actionVenue, spotBuyCost, } from "./types.js";
|
|
6
6
|
const SERVER_MAX_LEVERAGE = 20;
|
|
7
|
+
// Minimum edge, in probability POINTS, between the model's own forecast for
|
|
8
|
+
// the outcome it is backing and what that outcome currently costs. Live
|
|
9
|
+
// 2026-09-02: of 7 executed pm_opens in the first release window, 3 backed an
|
|
10
|
+
// outcome their own forecast priced at or BELOW the market (worst -53 points,
|
|
11
|
+
// mean -0.7), i.e. an agent paid 65 for something it thought was worth 45. A
|
|
12
|
+
// few points of cushion also covers spread and fees rather than trading a
|
|
13
|
+
// rounding difference. Env-tunable for a fleet retune without a redeploy.
|
|
14
|
+
const PM_MIN_FORECAST_EDGE_POINTS = (() => {
|
|
15
|
+
const raw = Number(process.env.AGENT_PM_MIN_FORECAST_EDGE_POINTS);
|
|
16
|
+
return Number.isFinite(raw) && raw >= 0 ? raw : 2;
|
|
17
|
+
})();
|
|
7
18
|
const PM_MIN_STAKE_MUSD = 10; // server minimum prediction-market stake
|
|
19
|
+
// Entry budgets are exposure budgets, not emergency-action budgets. Closing a
|
|
20
|
+
// futures position, updating its protection, cancelling an order, or selling
|
|
21
|
+
// spot reduces/contains risk and must remain available after an entry cap.
|
|
22
|
+
/**
|
|
23
|
+
* True when a thesis says, in so many words, that it is betting AGAINST the
|
|
24
|
+
* outcome the action is buying. Deliberately narrow: it fires only when the
|
|
25
|
+
* negation names the backed outcome directly ("betting against the Up
|
|
26
|
+
* outcome" while buying Up), because a false positive here silences a
|
|
27
|
+
* legitimate trade. Live shape 2026-09-02, cycle 863728.
|
|
28
|
+
*/
|
|
29
|
+
export function thesisContradictsOutcome(summary, outcomeName) {
|
|
30
|
+
if (!summary || !outcomeName)
|
|
31
|
+
return false;
|
|
32
|
+
const outcome = outcomeName.trim().toLowerCase();
|
|
33
|
+
if (!outcome)
|
|
34
|
+
return false;
|
|
35
|
+
const escaped = outcome.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
36
|
+
const negation = new RegExp("\\b(?:against|fade|fading)\\s+(?:the\\s+)?[\"']?" + escaped + "\\b", "i");
|
|
37
|
+
return negation.test(summary);
|
|
38
|
+
}
|
|
39
|
+
export function isRiskIncreasingAction(action) {
|
|
40
|
+
return (action.type === "futures_open" ||
|
|
41
|
+
action.type === "pm_open" ||
|
|
42
|
+
(action.type === "spot_order" && action.side === "buy"));
|
|
43
|
+
}
|
|
8
44
|
export function validateAction(action, ctx) {
|
|
9
45
|
const { spec, observation } = ctx;
|
|
10
46
|
const venue = actionVenue(action);
|
|
@@ -14,15 +50,18 @@ export function validateAction(action, ctx) {
|
|
|
14
50
|
if (spec.sync.requirePollBeforeWrite && !observation.polledBeforeWrite) {
|
|
15
51
|
return fail("no_poll_before_write", "must successfully poll /trades before writing");
|
|
16
52
|
}
|
|
17
|
-
|
|
53
|
+
const increasesRisk = isRiskIncreasingAction(action);
|
|
54
|
+
if (increasesRisk &&
|
|
55
|
+
ctx.riskIncreasesThisCycle >= spec.limits.maxWritesPerCycle) {
|
|
18
56
|
return fail("write_budget_exceeded", `maxWritesPerCycle ${spec.limits.maxWritesPerCycle} reached`);
|
|
19
57
|
}
|
|
20
58
|
// maxTradesPerDay <= 0 means UNLIMITED daily trade count — house agents are never
|
|
21
59
|
// throttled (we want an active Arena), and hosted agents only when the customer sets a
|
|
22
60
|
// positive cap. The risk caps below (daily loss, open margin, leverage, stops) are the
|
|
23
61
|
// real guardrails and always apply regardless of the trade-count cap.
|
|
24
|
-
if (
|
|
25
|
-
|
|
62
|
+
if (increasesRisk &&
|
|
63
|
+
spec.limits.maxTradesPerDay > 0 &&
|
|
64
|
+
ctx.riskIncreasesToday >= spec.limits.maxTradesPerDay) {
|
|
26
65
|
return fail("daily_trade_cap", `maxTradesPerDay ${spec.limits.maxTradesPerDay} reached`);
|
|
27
66
|
}
|
|
28
67
|
// Deny-list: an open on a blocked symbol is rejected up front (deny wins over
|
|
@@ -273,6 +312,38 @@ export function validateAction(action, ctx) {
|
|
|
273
312
|
if (!ctx.quote.freshness || ctx.quote.freshness.status !== "fresh") {
|
|
274
313
|
return fail("stale_quote", `quote freshness ${ctx.quote.freshness?.status ?? "missing"} (need fresh)`);
|
|
275
314
|
}
|
|
315
|
+
// Forecast consistency. By prompt contract forecastProbability is the
|
|
316
|
+
// model's own probability (1-99) that the outcome IT IS BACKING wins, so
|
|
317
|
+
// buying that outcome only makes sense when the forecast clears what the
|
|
318
|
+
// market charges for it. An ABSENT forecast still never blocks a bet (the
|
|
319
|
+
// prompt promises that); a PRESENT one that contradicts the trade does.
|
|
320
|
+
// The API's entryProbability is the RAW mid, and executionModel's effective
|
|
321
|
+
// probability excludes fee. Total stake / net shares is the fee-inclusive
|
|
322
|
+
// break-even cost. Do not guess units or fall back to a discovery mid.
|
|
323
|
+
if (!ctx.mechanical && action.forecastProbability != null) {
|
|
324
|
+
const stake = ctx.quote.stakeMusd;
|
|
325
|
+
const shares = ctx.quote.sharesEstimate;
|
|
326
|
+
const entryPct = typeof stake === "number" &&
|
|
327
|
+
Number.isFinite(stake) &&
|
|
328
|
+
stake > 0 &&
|
|
329
|
+
stake === action.stakeMusd &&
|
|
330
|
+
typeof shares === "number" &&
|
|
331
|
+
Number.isFinite(shares) &&
|
|
332
|
+
shares > 0
|
|
333
|
+
? (stake / shares) * 100
|
|
334
|
+
: NaN;
|
|
335
|
+
if (!Number.isFinite(entryPct) || entryPct <= 0) {
|
|
336
|
+
return fail("pm_quote_cost_unavailable", "forecast edge requires a matching stake and positive finite net share estimate");
|
|
337
|
+
}
|
|
338
|
+
const edge = action.forecastProbability - entryPct;
|
|
339
|
+
// A cost over 100 is possible; do not clamp an uneconomic quote into range.
|
|
340
|
+
if (edge + 1e-9 < PM_MIN_FORECAST_EDGE_POINTS) {
|
|
341
|
+
return fail("forecast_no_positive_edge", `forecast ${action.forecastProbability} vs entry ${entryPct.toFixed(1)} = ${edge.toFixed(1)}pt edge, under the ${PM_MIN_FORECAST_EDGE_POINTS}pt minimum`);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
if (thesisContradictsOutcome(action.thesis?.summary, mkt.outcomeName)) {
|
|
345
|
+
return fail("thesis_action_conflict", `thesis bets against ${mkt.outcomeName}, which this action buys`);
|
|
346
|
+
}
|
|
276
347
|
return ok();
|
|
277
348
|
}
|
|
278
349
|
return fail("unknown_action", "unsupported action type");
|
package/dist/agent/engine.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { runCycle, type RunnerDeps } from "./runner.js";
|
|
2
|
+
export { DECISION_INPUT_MAX_BYTES, sanitizeDecisionInputRecord, type DecisionInputRecord, } from "./decisionReceipt.js";
|
|
2
3
|
export { selectProvider, providerForRoute, type ProviderEnv, type Provider, type DecideInput, type DecideResult, type DecideRouteAttempt, type DecideRouteMeta, } from "./providers.js";
|
|
3
4
|
export { parseDecision } from "./decision.js";
|
|
4
5
|
export { chatShapeFor, buildChatBody, type ChatShape, } from "./providerCapabilities.js";
|
package/dist/agent/engine.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// This barrel is the ONE import a host scheduler needs; it re-exports only the
|
|
7
7
|
// stable engine pieces, never the CLI.
|
|
8
8
|
export { runCycle } from "./runner.js";
|
|
9
|
+
export { DECISION_INPUT_MAX_BYTES, sanitizeDecisionInputRecord, } from "./decisionReceipt.js";
|
|
9
10
|
export { selectProvider, providerForRoute, } from "./providers.js";
|
|
10
11
|
export { parseDecision } from "./decision.js";
|
|
11
12
|
// Reliability slice A: the declarative request-capability table and the
|
package/dist/agent/observe.js
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
import { asObj, asArr, asNum, asStr } from "./extract.js";
|
|
5
5
|
import { computeIndicators } from "./indicators.js";
|
|
6
6
|
import { scanSetups } from "./setups.js";
|
|
7
|
+
import { freshnessOf, pmQualityOf, pmDecisionSupportOf } from "./pmContext.js";
|
|
8
|
+
import { deriveCapitalBook, usesCapitalSizing } from "./capitalSizing.js";
|
|
7
9
|
// Candle granularity feeding the indicators: the 1D range = 5-minute candles
|
|
8
10
|
// (~5-min fresh, ~288 bars — ample for EMA50/RSI14/Bollinger20), which suits the
|
|
9
11
|
// short cadence the hosted house agents run on. Probe-verified 2026-06-17.
|
|
@@ -31,6 +33,16 @@ const INDICATOR_RANGE = "1D";
|
|
|
31
33
|
// that declare universe_scan.
|
|
32
34
|
const UNIVERSE_SCAN_LIMIT = 15;
|
|
33
35
|
const UNIVERSE_RESOLVE_TOP = 6;
|
|
36
|
+
// A number that may arrive as a decimal string (the public movers feed).
|
|
37
|
+
const asNumLoose = (v) => {
|
|
38
|
+
if (typeof v === "number")
|
|
39
|
+
return Number.isFinite(v) ? v : undefined;
|
|
40
|
+
if (typeof v === "string" && v.trim() !== "") {
|
|
41
|
+
const n = Number(v);
|
|
42
|
+
return Number.isFinite(n) ? n : undefined;
|
|
43
|
+
}
|
|
44
|
+
return undefined;
|
|
45
|
+
};
|
|
34
46
|
// Watchlist symbols -> the coin NAMES prediction-market titles use, so an agent
|
|
35
47
|
// discovers PM markets about the coins it actually has a price view on.
|
|
36
48
|
const PM_COIN_NAMES = {
|
|
@@ -61,10 +73,11 @@ const PM_CALIBRATION_CHURN_RE = /(updown|up-or-down|-5-?min|-5m-|-15m|15m(?:-|$)
|
|
|
61
73
|
export function isCalibrationChurnMarket(market) {
|
|
62
74
|
return PM_CALIBRATION_CHURN_RE.test(`${market.slug ?? ""} ${market.title ?? ""}`);
|
|
63
75
|
}
|
|
64
|
-
// Fetch candles for one coin and reduce them to a compact indicator bundle
|
|
65
|
-
// Tolerant by design: any failure (HTTP error, malformed/sparse
|
|
66
|
-
// null so the cycle proceeds with price-only context
|
|
67
|
-
|
|
76
|
+
// Fetch candles for one coin and reduce them to a compact indicator bundle plus
|
|
77
|
+
// the 24h volume. Tolerant by design: any failure (HTTP error, malformed/sparse
|
|
78
|
+
// candles) yields null indicators so the cycle proceeds with price-only context
|
|
79
|
+
// rather than skipping.
|
|
80
|
+
async function fetchCandleContext(client, coinId, trace) {
|
|
68
81
|
// The try honors the documented tolerance for SYNCHRONOUS throws too (an
|
|
69
82
|
// unexpected client error must degrade to price-only context, never kill
|
|
70
83
|
// the cycle).
|
|
@@ -73,10 +86,10 @@ async function fetchIndicators(client, coinId, trace) {
|
|
|
73
86
|
cr = await client.candles(coinId, INDICATOR_RANGE, trace);
|
|
74
87
|
}
|
|
75
88
|
catch {
|
|
76
|
-
return null;
|
|
89
|
+
return { indicators: null };
|
|
77
90
|
}
|
|
78
91
|
if (!cr.ok)
|
|
79
|
-
return null;
|
|
92
|
+
return { indicators: null };
|
|
80
93
|
// Endpoint shape: { candles: [{ t, o, h, l, c, v }] } ascending (oldest first).
|
|
81
94
|
const candles = [];
|
|
82
95
|
for (const raw of asArr(asObj(cr.data).candles)) {
|
|
@@ -89,12 +102,78 @@ async function fetchIndicators(client, coinId, trace) {
|
|
|
89
102
|
continue;
|
|
90
103
|
candles.push({ open, high, low, close, volume: asNum(c.v) ?? undefined });
|
|
91
104
|
}
|
|
92
|
-
|
|
105
|
+
const lastVolume = candles.length > 0 ? candles[candles.length - 1].volume : undefined;
|
|
106
|
+
return {
|
|
107
|
+
indicators: computeIndicators(candles),
|
|
108
|
+
volume24hUsd: typeof lastVolume === "number" && lastVolume > 0 ? lastVolume : undefined,
|
|
109
|
+
};
|
|
93
110
|
}
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
111
|
+
// The fundamentals leg of a watch entry, read from the /market context the
|
|
112
|
+
// entry is already built from (coin.categories, coin.marketCapRank,
|
|
113
|
+
// price.marketCapUsd). Absent fields stay absent.
|
|
114
|
+
function coinFundamentalsOf(m) {
|
|
115
|
+
const coin = asObj(m.coin);
|
|
116
|
+
const price = asObj(m.price);
|
|
117
|
+
const out = {};
|
|
118
|
+
const categories = asArr(coin.categories)
|
|
119
|
+
.map((c) => asStr(c))
|
|
120
|
+
.filter((c) => !!c)
|
|
121
|
+
.slice(0, 3);
|
|
122
|
+
if (categories.length > 0)
|
|
123
|
+
out.categories = categories;
|
|
124
|
+
const rank = asNum(coin.marketCapRank);
|
|
125
|
+
if (rank != null)
|
|
126
|
+
out.marketCapRank = rank;
|
|
127
|
+
const marketCapUsd = asNum(price.marketCapUsd);
|
|
128
|
+
if (marketCapUsd != null)
|
|
129
|
+
out.marketCapUsd = marketCapUsd;
|
|
130
|
+
return Object.keys(out).length > 0 ? out : undefined;
|
|
131
|
+
}
|
|
132
|
+
// Enrich a watch entry with what the candles fetch yields (indicators + 24h
|
|
133
|
+
// volume) when the `indicators` capability is on. One call, both fields.
|
|
134
|
+
async function enrichFromCandles(client, entry, coinId, trace) {
|
|
135
|
+
const cc = await fetchCandleContext(client, coinId, trace);
|
|
136
|
+
if (cc.indicators)
|
|
137
|
+
entry.indicators = cc.indicators;
|
|
138
|
+
if (cc.volume24hUsd != null) {
|
|
139
|
+
entry.fundamentals = {
|
|
140
|
+
...(entry.fundamentals ?? {}),
|
|
141
|
+
volume24hUsd: cc.volume24hUsd,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
const HEADLINES_PER_COIN = 3;
|
|
146
|
+
const HEADLINE_TITLE_CHARS = 110;
|
|
147
|
+
const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
148
|
+
// Attribute the fetched news to the coins on watch: by the curated slug link
|
|
149
|
+
// when the entry's slug is known (the graph, never a fuzzy match), else by a
|
|
150
|
+
// case-insensitive coin-name or exact-case ticker mention in the title. At most
|
|
151
|
+
// HEADLINES_PER_COIN per coin, in the API's importance-then-recency order.
|
|
152
|
+
function attachHeadlines(watch, items) {
|
|
153
|
+
for (const w of watch) {
|
|
154
|
+
const slug = (w.slug ?? "").toLowerCase();
|
|
155
|
+
const name = (w.name ?? "").toLowerCase();
|
|
156
|
+
const tickerRe = new RegExp(`\\b${escapeRegExp(w.symbol)}\\b`);
|
|
157
|
+
const mine = items
|
|
158
|
+
.filter((it) => {
|
|
159
|
+
if (slug)
|
|
160
|
+
return (it.coins ?? []).some((c) => c.toLowerCase() === slug);
|
|
161
|
+
const title = it.title.toLowerCase();
|
|
162
|
+
return ((name.length >= 3 && title.includes(name)) || tickerRe.test(it.title));
|
|
163
|
+
})
|
|
164
|
+
.slice(0, HEADLINES_PER_COIN);
|
|
165
|
+
if (mine.length === 0)
|
|
166
|
+
continue;
|
|
167
|
+
w.fundamentals = {
|
|
168
|
+
...(w.fundamentals ?? {}),
|
|
169
|
+
headlines: mine.map((it) => ({
|
|
170
|
+
title: it.title.slice(0, HEADLINE_TITLE_CHARS),
|
|
171
|
+
...(it.publishedAt ? { at: it.publishedAt } : {}),
|
|
172
|
+
...(it.importance != null ? { importance: it.importance } : {}),
|
|
173
|
+
...(it.sentiment ? { sentiment: it.sentiment } : {}),
|
|
174
|
+
})),
|
|
175
|
+
};
|
|
176
|
+
}
|
|
98
177
|
}
|
|
99
178
|
// Does a market title reference the given watchlist coin? Matches on the PM coin
|
|
100
179
|
// NAME ("Bitcoin") or the ticker ("BTC"), case-insensitively — the discover `q`
|
|
@@ -120,6 +199,11 @@ function expandPmMarkets(discData, heldPmKeys) {
|
|
|
120
199
|
return (asArr(dd.data ?? dd.markets ?? dd.results)
|
|
121
200
|
.map(asObj)
|
|
122
201
|
.flatMap((ev) => {
|
|
202
|
+
// Explicit negative evidence removes only NEW-entry candidates. Unknown
|
|
203
|
+
// quality stays unknown; fresh quote + transactional guards remain final.
|
|
204
|
+
if (ev.eligible === false ||
|
|
205
|
+
asObj(ev.quality).decisionEligible === false)
|
|
206
|
+
return [];
|
|
123
207
|
const source = (asStr(ev.source) ?? "").toLowerCase();
|
|
124
208
|
const slug = (asStr(ev.slug) ?? "").toLowerCase();
|
|
125
209
|
// Keep titles SHORT: the model only needs to recognise the market.
|
|
@@ -127,6 +211,8 @@ function expandPmMarkets(discData, heldPmKeys) {
|
|
|
127
211
|
// prompt to ~69k tokens (413s on small-context free models).
|
|
128
212
|
const title = (asStr(ev.title) ?? asStr(ev.question) ?? "").slice(0, 80);
|
|
129
213
|
const freshness = freshnessOf(ev); // freshness is event-level
|
|
214
|
+
const quality = pmQualityOf(ev.quality);
|
|
215
|
+
const decisionSupport = pmDecisionSupportOf(ev.decisionSupport);
|
|
130
216
|
// Event-level 24h volume (the discover payload's `volume24h`, USD). Feeds
|
|
131
217
|
// the mechanical BENCHMARK agents' deterministic highest-volume pick rule.
|
|
132
218
|
// Same for every outcome of the event; undefined on an older backend.
|
|
@@ -137,13 +223,17 @@ function expandPmMarkets(discData, heldPmKeys) {
|
|
|
137
223
|
// model never bets a market that would fail the binary entry gate at
|
|
138
224
|
// quote. Back-compat: an older backend omits `eligible` (undefined) ->
|
|
139
225
|
// the outcome is kept (current behaviour).
|
|
140
|
-
const outcomes = asArr(ev.outcomes)
|
|
226
|
+
const outcomes = (Object.hasOwn(ev, "outcomes") ? asArr(ev.outcomes) : [ev])
|
|
141
227
|
.map(asObj)
|
|
142
228
|
.filter((o) => o.eligible !== false)
|
|
229
|
+
.filter((o) => {
|
|
230
|
+
const p = asNum(o.probability);
|
|
231
|
+
return p != null && p >= 0 && p <= 100;
|
|
232
|
+
})
|
|
143
233
|
.slice(0, 3);
|
|
144
|
-
//
|
|
145
|
-
|
|
146
|
-
return
|
|
234
|
+
// Only an absent legacy outcomes field permits the flat fallback. A
|
|
235
|
+
// present empty/malformed/all-rejected array must never resurrect ev.
|
|
236
|
+
return outcomes.map((o) => ({
|
|
147
237
|
source,
|
|
148
238
|
slug,
|
|
149
239
|
outcomeExternalMarketId: asStr(o.externalMarketId) ?? asStr(o.outcomeExternalMarketId) ?? "",
|
|
@@ -152,13 +242,22 @@ function expandPmMarkets(discData, heldPmKeys) {
|
|
|
152
242
|
outcomeName: asStr(o.name) ?? asStr(o.outcomeName) ?? undefined,
|
|
153
243
|
// Backend returns probability as 0..100 (percent) — normalise to 0..1
|
|
154
244
|
// to match the prompt's "0..1" framing (probed 2026-06-24).
|
|
155
|
-
probability: ((p) =>
|
|
245
|
+
probability: ((p) => p == null || p < 0 || p > 100 ? undefined : p / 100)(asNum(o.probability)),
|
|
156
246
|
title,
|
|
157
247
|
freshness,
|
|
248
|
+
quality,
|
|
249
|
+
decisionSupport,
|
|
158
250
|
volumeUsd,
|
|
251
|
+
// Event-level fundamentals from the same payload (slice 2): the
|
|
252
|
+
// resolution date and the venue-reported liquidity (USD).
|
|
253
|
+
endDate: asStr(ev.endDate) ?? undefined,
|
|
254
|
+
liquidityUsd: asNum(ev.liquidity) ?? undefined,
|
|
159
255
|
}));
|
|
160
256
|
})
|
|
161
|
-
.filter((m) => m.source &&
|
|
257
|
+
.filter((m) => m.source &&
|
|
258
|
+
m.slug &&
|
|
259
|
+
m.outcomeExternalMarketId &&
|
|
260
|
+
m.probability != null)
|
|
162
261
|
// Drop already-held markets so the model only sees markets it can actually
|
|
163
262
|
// open — done BEFORE any slice so held positions don't consume candidate slots.
|
|
164
263
|
.filter((m) => !heldPmKeys.has(`${m.source.toLowerCase()}|${m.slug.toLowerCase()}|${m.outcomeExternalMarketId}`)));
|
|
@@ -220,6 +319,7 @@ export async function observe(client, spec, state, trace) {
|
|
|
220
319
|
return {
|
|
221
320
|
venue: "futures",
|
|
222
321
|
id: Number(asNum(p.id) ?? p.id),
|
|
322
|
+
...(usesCapitalSizing(spec) ? { walletId: asNum(p.walletId) } : {}),
|
|
223
323
|
coinId: asStr(coin.ucid) ?? asStr(p.coinId),
|
|
224
324
|
symbol: asStr(coin.symbol) ?? asStr(p.symbol),
|
|
225
325
|
side: asStr(p.side),
|
|
@@ -232,6 +332,7 @@ export async function observe(client, spec, state, trace) {
|
|
|
232
332
|
liquidationPrice: asNum(p.liquidationPrice),
|
|
233
333
|
stopLossPrice: asNum(p.stopLossPrice),
|
|
234
334
|
takeProfitPrice: asNum(p.takeProfitPrice),
|
|
335
|
+
openedAt: asStr(p.openedAt),
|
|
235
336
|
};
|
|
236
337
|
});
|
|
237
338
|
// Sync poll: /trades since the persisted cursor.
|
|
@@ -286,7 +387,14 @@ export async function observe(client, spec, state, trace) {
|
|
|
286
387
|
sentimentBullishPct: asNum(asObj(m.sentiment).bullishPct) ?? undefined,
|
|
287
388
|
// Freshness lives under the response's `observation` block.
|
|
288
389
|
freshness: freshnessOf(asObj(m.observation)),
|
|
390
|
+
// Canonical slug (the news graph's key): from the resolve match, else
|
|
391
|
+
// the market context's observation.dataset.coinSlug.
|
|
392
|
+
slug: asStr(match.slug) ??
|
|
393
|
+
asStr(asObj(asObj(m.observation).dataset).coinSlug),
|
|
289
394
|
};
|
|
395
|
+
const fundamentals = coinFundamentalsOf(m);
|
|
396
|
+
if (fundamentals)
|
|
397
|
+
entry.fundamentals = fundamentals;
|
|
290
398
|
// Capture the market-wide Fear & Greed regime once (same across coins).
|
|
291
399
|
if (!marketMood) {
|
|
292
400
|
const fg = asObj(m.fearGreed);
|
|
@@ -297,11 +405,8 @@ export async function observe(client, spec, state, trace) {
|
|
|
297
405
|
// `indicators` capability: enrich the observation with computed TA so the
|
|
298
406
|
// model reasons over structure (trend/momentum/volatility/breakout) instead
|
|
299
407
|
// of price + %change alone. Backed by the candles endpoint's shared cache.
|
|
300
|
-
if (wantIndicators)
|
|
301
|
-
|
|
302
|
-
if (ind)
|
|
303
|
-
entry.indicators = ind;
|
|
304
|
-
}
|
|
408
|
+
if (wantIndicators)
|
|
409
|
+
await enrichFromCandles(client, entry, coinId, trace);
|
|
305
410
|
watch.push(entry);
|
|
306
411
|
}
|
|
307
412
|
// `universe_scan` capability (2026-08-18, direct user request): discover the
|
|
@@ -323,8 +428,12 @@ export async function observe(client, spec, state, trace) {
|
|
|
323
428
|
.map((r) => ({
|
|
324
429
|
symbol: (asStr(r.symbol) ?? "").toUpperCase(),
|
|
325
430
|
name: asStr(r.name),
|
|
326
|
-
|
|
327
|
-
|
|
431
|
+
// Both serialize as decimal STRINGS on the live feed (openapi
|
|
432
|
+
// PublicCryptoMover; probed 2026-09-02: "72.34"), so the strict
|
|
433
|
+
// asNum read left them undefined. Parse the numeric string.
|
|
434
|
+
change24hPct: asNumLoose(r.change24h),
|
|
435
|
+
priceUsd: asNumLoose(r.currentPrice),
|
|
436
|
+
slug: asStr(r.slug),
|
|
328
437
|
// The movers row already carries the ucid, which IS the coinId every
|
|
329
438
|
// downstream call takes. Kept so the resolve round-trip below can be
|
|
330
439
|
// skipped — see the comment there.
|
|
@@ -363,15 +472,23 @@ export async function observe(client, spec, state, trace) {
|
|
|
363
472
|
sentimentBullishPct: asNum(asObj(m.sentiment).bullishPct) ?? undefined,
|
|
364
473
|
freshness: freshnessOf(asObj(m.observation)),
|
|
365
474
|
discovered: true,
|
|
475
|
+
slug: row.slug ?? asStr(asObj(asObj(m.observation).dataset).coinSlug),
|
|
366
476
|
};
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
477
|
+
const fundamentals = coinFundamentalsOf(m);
|
|
478
|
+
if (fundamentals)
|
|
479
|
+
entry.fundamentals = fundamentals;
|
|
480
|
+
if (wantIndicators)
|
|
481
|
+
await enrichFromCandles(client, entry, coinId, trace);
|
|
372
482
|
watch.push(entry);
|
|
373
483
|
}
|
|
374
|
-
const context = rows
|
|
484
|
+
const context = rows
|
|
485
|
+
.slice(UNIVERSE_RESOLVE_TOP)
|
|
486
|
+
.map(({ symbol, name, change24hPct, priceUsd }) => ({
|
|
487
|
+
symbol,
|
|
488
|
+
name,
|
|
489
|
+
change24hPct,
|
|
490
|
+
priceUsd,
|
|
491
|
+
}));
|
|
375
492
|
if (context.length > 0)
|
|
376
493
|
universeMovers = context;
|
|
377
494
|
}
|
|
@@ -400,6 +517,7 @@ export async function observe(client, spec, state, trace) {
|
|
|
400
517
|
}
|
|
401
518
|
// PM open positions + discovered quote-ready candidates — only if pm enabled.
|
|
402
519
|
let pmPositions = [];
|
|
520
|
+
let capitalPmData;
|
|
403
521
|
let pmResolutions = [];
|
|
404
522
|
let pmMarkets = [];
|
|
405
523
|
if (wantPm) {
|
|
@@ -430,11 +548,13 @@ export async function observe(client, spec, state, trace) {
|
|
|
430
548
|
pmDiscR = fb;
|
|
431
549
|
}
|
|
432
550
|
if (pmPosR.ok) {
|
|
551
|
+
capitalPmData = pmPosR.data;
|
|
433
552
|
pmPositions = asArr(asObj(pmPosR.data).positions)
|
|
434
553
|
.map(asObj)
|
|
435
554
|
.filter((p) => (asStr(p.status) ?? "open") === "open")
|
|
436
555
|
.map((p) => ({
|
|
437
556
|
id: Number(asNum(p.id) ?? p.id),
|
|
557
|
+
...(usesCapitalSizing(spec) ? { walletId: asNum(p.walletId) } : {}),
|
|
438
558
|
// The /positions/pm API returns `eventSlug` and the outcome id NESTED at
|
|
439
559
|
// outcome.externalMarketId — NOT `slug` / `outcomeExternalMarketId`.
|
|
440
560
|
// Reading the wrong keys left both undefined, which silently broke the
|
|
@@ -451,6 +571,14 @@ export async function observe(client, spec, state, trace) {
|
|
|
451
571
|
// down trips the stop too — not just futures.
|
|
452
572
|
unrealizedPnlMusd: asNum(p.unrealizedPnl) ?? asNum(p.unrealizedPnlMusd),
|
|
453
573
|
status: asStr(p.status) ?? "open",
|
|
574
|
+
// Slice 2: what the bet IS (title, side) and its entry vs CURRENT
|
|
575
|
+
// outcome probability (0..100 points; current only while open), so
|
|
576
|
+
// the model and the thesis evaluator can re-judge a held bet.
|
|
577
|
+
title: (asStr(p.eventTitle) ?? asStr(p.title))?.slice(0, 80),
|
|
578
|
+
side: asStr(p.side),
|
|
579
|
+
entryProbability: asNum(p.entryProbability),
|
|
580
|
+
currentProbability: asNum(p.currentProbability),
|
|
581
|
+
openedAt: asStr(p.openedAt),
|
|
454
582
|
}));
|
|
455
583
|
// Settlement-feedback loop: the SAME /positions/pm response carries an
|
|
456
584
|
// additive `recentlyResolved` array — the agent's OWN bets that settled
|
|
@@ -567,9 +695,12 @@ export async function observe(client, spec, state, trace) {
|
|
|
567
695
|
...watch.map((w) => w.symbol.toUpperCase()),
|
|
568
696
|
]));
|
|
569
697
|
if (wantNews && newsCoins.length > 0) {
|
|
570
|
-
|
|
698
|
+
// limit 12 (was 8): the same single cached call now also feeds up to 3
|
|
699
|
+
// headlines per coin (slice 2 fundamentals); the prompt's news block is
|
|
700
|
+
// still capped at 6 below.
|
|
701
|
+
const nr = await client.agentNews({ coins: newsCoins.join(","), limit: 12, hours: 48 }, trace);
|
|
571
702
|
if (nr.ok) {
|
|
572
|
-
|
|
703
|
+
const fetched = asArr(asObj(nr.data).items)
|
|
573
704
|
.map(asObj)
|
|
574
705
|
.map((it) => ({
|
|
575
706
|
title: (asStr(it.title) ?? "").slice(0, 160),
|
|
@@ -577,12 +708,17 @@ export async function observe(client, spec, state, trace) {
|
|
|
577
708
|
sentiment: asStr(it.sentiment) ?? undefined,
|
|
578
709
|
importance: asNum(it.importance) ?? undefined,
|
|
579
710
|
ageHours: ((a) => a == null ? undefined : Math.round((a / 60) * 10) / 10)(asNum(it.ageMinutes)),
|
|
711
|
+
publishedAt: asStr(it.publishedAt) ?? undefined,
|
|
580
712
|
coins: asArr(it.coins)
|
|
581
713
|
.map((c) => asStr(c))
|
|
582
714
|
.filter((c) => !!c),
|
|
583
715
|
}))
|
|
584
|
-
.filter((n) => n.title.length > 0)
|
|
585
|
-
|
|
716
|
+
.filter((n) => n.title.length > 0);
|
|
717
|
+
news = fetched.slice(0, 6);
|
|
718
|
+
// Per-coin headlines (with timestamps) on the watch entries themselves,
|
|
719
|
+
// drawn from the full fetched list so a busy BTC tape cannot crowd a
|
|
720
|
+
// second coin's story out of the fundamentals.
|
|
721
|
+
attachHeadlines(watch, fetched);
|
|
586
722
|
}
|
|
587
723
|
}
|
|
588
724
|
const observation = {
|
|
@@ -590,6 +726,11 @@ export async function observe(client, spec, state, trace) {
|
|
|
590
726
|
scopes,
|
|
591
727
|
cashAvailableMusd,
|
|
592
728
|
equityMusd,
|
|
729
|
+
...(usesCapitalSizing(spec)
|
|
730
|
+
? {
|
|
731
|
+
capitalBook: deriveCapitalBook(portR.data, walletR.data, posR.data, capitalPmData),
|
|
732
|
+
}
|
|
733
|
+
: {}),
|
|
593
734
|
openPositions,
|
|
594
735
|
openOrders,
|
|
595
736
|
pmPositions,
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Freshness, PmDecisionSupport, PmQuality } from "./types.js";
|
|
2
|
+
export declare const PM_BLOCK_REASONS: readonly ["structurally_invalid", "stale_freshness", "freshness_unknown", "unpriced", "quote_dead", "dead_zero", "not_open", "source_degraded", "settlement_limbo"];
|
|
3
|
+
export declare const PM_WARNING_REASONS: readonly ["lagging_freshness", "unproven_no_activity", "untraded_default", "play_money", "anomaly_flagged", "source_time_unverified", "sum_atypical_independent"];
|
|
4
|
+
export declare const PM_FLAGS: readonly ["thinMarket", "inactiveMarket", "highAmbiguity", "nearResolution", "staleData"];
|
|
5
|
+
export declare const FRESHNESS_BASES: readonly ["latest_snapshot", "source_update", "processed", "event_update", "unknown"];
|
|
6
|
+
export declare const PM_TIERS: readonly ["high", "medium", "low", "unknown"];
|
|
7
|
+
export declare const PM_SPREAD_TIERS: readonly ["tight", "moderate", "wide", "unknown"];
|
|
8
|
+
export declare const PM_QUALITY_CAPS: readonly ["unassessable", "raw_book", "pinned_outcome"];
|
|
9
|
+
export declare const knownCode: (value: unknown, allowed: readonly string[]) => string | undefined;
|
|
10
|
+
export declare function sourceTimestamp(value: unknown): string | undefined;
|
|
11
|
+
export declare function freshnessOf(block: Record<string, unknown>): Freshness | undefined;
|
|
12
|
+
export declare function pmQualityOf(value: unknown): PmQuality | undefined;
|
|
13
|
+
export declare function pmDecisionSupportOf(value: unknown): PmDecisionSupport | undefined;
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { asArr, asNum, asObj } from "./extract.js";
|
|
2
|
+
export const PM_BLOCK_REASONS = [
|
|
3
|
+
"structurally_invalid",
|
|
4
|
+
"stale_freshness",
|
|
5
|
+
"freshness_unknown",
|
|
6
|
+
"unpriced",
|
|
7
|
+
"quote_dead",
|
|
8
|
+
"dead_zero",
|
|
9
|
+
"not_open",
|
|
10
|
+
"source_degraded",
|
|
11
|
+
"settlement_limbo",
|
|
12
|
+
];
|
|
13
|
+
export const PM_WARNING_REASONS = [
|
|
14
|
+
"lagging_freshness",
|
|
15
|
+
"unproven_no_activity",
|
|
16
|
+
"untraded_default",
|
|
17
|
+
"play_money",
|
|
18
|
+
"anomaly_flagged",
|
|
19
|
+
"source_time_unverified",
|
|
20
|
+
"sum_atypical_independent",
|
|
21
|
+
];
|
|
22
|
+
export const PM_FLAGS = [
|
|
23
|
+
"thinMarket",
|
|
24
|
+
"inactiveMarket",
|
|
25
|
+
"highAmbiguity",
|
|
26
|
+
"nearResolution",
|
|
27
|
+
"staleData",
|
|
28
|
+
];
|
|
29
|
+
export const FRESHNESS_BASES = [
|
|
30
|
+
"latest_snapshot",
|
|
31
|
+
"source_update",
|
|
32
|
+
"processed",
|
|
33
|
+
"event_update",
|
|
34
|
+
"unknown",
|
|
35
|
+
];
|
|
36
|
+
export const PM_TIERS = ["high", "medium", "low", "unknown"];
|
|
37
|
+
export const PM_SPREAD_TIERS = [
|
|
38
|
+
"tight",
|
|
39
|
+
"moderate",
|
|
40
|
+
"wide",
|
|
41
|
+
"unknown",
|
|
42
|
+
];
|
|
43
|
+
export const PM_QUALITY_CAPS = [
|
|
44
|
+
"unassessable",
|
|
45
|
+
"raw_book",
|
|
46
|
+
"pinned_outcome",
|
|
47
|
+
];
|
|
48
|
+
export const knownCode = (value, allowed) => typeof value === "string" && allowed.includes(value) ? value : undefined;
|
|
49
|
+
export function sourceTimestamp(value) {
|
|
50
|
+
if (typeof value !== "string" ||
|
|
51
|
+
value.length > 30 ||
|
|
52
|
+
!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/.test(value))
|
|
53
|
+
return undefined;
|
|
54
|
+
const ms = Date.parse(value);
|
|
55
|
+
if (!Number.isFinite(ms))
|
|
56
|
+
return undefined;
|
|
57
|
+
const iso = new Date(ms).toISOString();
|
|
58
|
+
const normalized = value.replace(/(?:\.(\d{1,3}))?Z$/, (_match, fraction) => `.${(fraction ?? "").padEnd(3, "0")}Z`);
|
|
59
|
+
// Date.parse otherwise silently rolls impossible calendar dates forward.
|
|
60
|
+
return iso === normalized ? iso : undefined;
|
|
61
|
+
}
|
|
62
|
+
export function freshnessOf(block) {
|
|
63
|
+
const fr = asObj(block.freshness);
|
|
64
|
+
const status = knownCode(fr.status, [
|
|
65
|
+
"fresh",
|
|
66
|
+
"stale",
|
|
67
|
+
"lagging",
|
|
68
|
+
"never_ingested",
|
|
69
|
+
"unknown",
|
|
70
|
+
]);
|
|
71
|
+
if (!status)
|
|
72
|
+
return undefined;
|
|
73
|
+
const seconds = asNum(fr.ageSeconds);
|
|
74
|
+
const minutes = asNum(fr.ageMinutes);
|
|
75
|
+
const age = seconds ?? (minutes == null ? undefined : minutes * 60);
|
|
76
|
+
return {
|
|
77
|
+
status,
|
|
78
|
+
...(age != null && Number.isFinite(age) && age >= 0
|
|
79
|
+
? { ageSeconds: age }
|
|
80
|
+
: {}),
|
|
81
|
+
...(sourceTimestamp(fr.asOf) ? { asOf: sourceTimestamp(fr.asOf) } : {}),
|
|
82
|
+
...(knownCode(fr.basis, FRESHNESS_BASES)
|
|
83
|
+
? { basis: knownCode(fr.basis, FRESHNESS_BASES) }
|
|
84
|
+
: {}),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
export function pmQualityOf(value) {
|
|
88
|
+
const raw = asObj(value);
|
|
89
|
+
if (Object.keys(raw).length === 0)
|
|
90
|
+
return undefined;
|
|
91
|
+
const reasons = (v, allowed) => [
|
|
92
|
+
...new Set(asArr(v)
|
|
93
|
+
.slice(0, 32)
|
|
94
|
+
.filter((r) => !!knownCode(r, allowed))),
|
|
95
|
+
];
|
|
96
|
+
const warningReasons = reasons(raw.warningReasons, PM_WARNING_REASONS);
|
|
97
|
+
const blockReasons = reasons(raw.blockReasons, PM_BLOCK_REASONS);
|
|
98
|
+
const omitted = (v, allowed) => !Array.isArray(v) || v.length > 32 || v.some((r) => !knownCode(r, allowed));
|
|
99
|
+
return {
|
|
100
|
+
...(typeof raw.decisionEligible === "boolean"
|
|
101
|
+
? { decisionEligible: raw.decisionEligible }
|
|
102
|
+
: {}),
|
|
103
|
+
warningReasons,
|
|
104
|
+
blockReasons,
|
|
105
|
+
...(typeof raw.policyVersion === "string" &&
|
|
106
|
+
/^pm-quality-\d{1,3}$/.test(raw.policyVersion)
|
|
107
|
+
? { policyVersion: raw.policyVersion }
|
|
108
|
+
: {}),
|
|
109
|
+
...(sourceTimestamp(raw.assessedAt)
|
|
110
|
+
? { assessedAt: sourceTimestamp(raw.assessedAt) }
|
|
111
|
+
: {}),
|
|
112
|
+
reasonsOmitted: raw.reasonsOmitted === true ||
|
|
113
|
+
omitted(raw.warningReasons, PM_WARNING_REASONS) ||
|
|
114
|
+
omitted(raw.blockReasons, PM_BLOCK_REASONS),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
export function pmDecisionSupportOf(value) {
|
|
118
|
+
const raw = asObj(value);
|
|
119
|
+
if (Object.keys(raw).length === 0)
|
|
120
|
+
return undefined;
|
|
121
|
+
const score = asNum(raw.qualityScore);
|
|
122
|
+
const rawFlags = asObj(raw.flags);
|
|
123
|
+
return {
|
|
124
|
+
...(score != null && score >= 0 && score <= 100
|
|
125
|
+
? { qualityScore: score }
|
|
126
|
+
: {}),
|
|
127
|
+
qualityTier: knownCode(raw.qualityTier, PM_TIERS),
|
|
128
|
+
qualityCapReason: raw.qualityCapReason === null
|
|
129
|
+
? null
|
|
130
|
+
: knownCode(raw.qualityCapReason, PM_QUALITY_CAPS),
|
|
131
|
+
spreadTier: knownCode(raw.spreadTier, PM_SPREAD_TIERS),
|
|
132
|
+
liquidityTier: knownCode(raw.liquidityTier, PM_TIERS),
|
|
133
|
+
volumeTier: knownCode(raw.volumeTier, PM_TIERS),
|
|
134
|
+
flags: Object.fromEntries(PM_FLAGS.filter((key) => typeof rawFlags[key] === "boolean").map((key) => [key, rawFlags[key]])),
|
|
135
|
+
};
|
|
136
|
+
}
|