@coinrithm/mcp-trading 0.7.6 → 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 +134 -0
- package/README.md +37 -8
- 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/client.d.ts +2 -0
- package/dist/agent/client.js +4 -0
- package/dist/agent/decision.d.ts +392 -0
- package/dist/agent/decision.js +177 -0
- package/dist/agent/decisionProbe.d.ts +17 -0
- package/dist/agent/decisionProbe.js +70 -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 +97 -3
- package/dist/agent/engine.d.ts +5 -1
- package/dist/agent/engine.js +8 -1
- package/dist/agent/observe.js +194 -35
- package/dist/agent/pmContext.d.ts +13 -0
- package/dist/agent/pmContext.js +136 -0
- package/dist/agent/prompt.d.ts +14 -2
- package/dist/agent/prompt.js +232 -35
- package/dist/agent/providerCapabilities.d.ts +23 -0
- package/dist/agent/providerCapabilities.js +105 -0
- package/dist/agent/providers.d.ts +29 -1
- package/dist/agent/providers.js +159 -88
- package/dist/agent/resolve.d.ts +1 -1
- package/dist/agent/resolve.js +21 -1
- package/dist/agent/runner.d.ts +4 -1
- package/dist/agent/runner.js +418 -47
- package/dist/agent/scorecard.js +7 -1
- package/dist/agent/skill.js +23 -0
- package/dist/agent/skillValidator.d.ts +1 -0
- package/dist/agent/skillValidator.js +63 -0
- package/dist/agent/state.js +7 -1
- package/dist/agent/strictLint.js +20 -0
- package/dist/agent/thesis.d.ts +40 -0
- package/dist/agent/thesis.js +319 -0
- package/dist/agent/types.d.ts +151 -0
- package/dist/http.js +21 -0
- package/dist/tools.js +10 -10
- 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
|
|
@@ -36,6 +75,29 @@ export function validateAction(action, ctx) {
|
|
|
36
75
|
}
|
|
37
76
|
}
|
|
38
77
|
}
|
|
78
|
+
// Direction constraint (2026-08-24): a strategy's side restriction is a HARD
|
|
79
|
+
// cap, not prose-obedience. Live incident: a short-only fade agent opened
|
|
80
|
+
// two momentum LONGS when the flagged-setups act-pressure outweighed its
|
|
81
|
+
// prose. Closes/SL-TP/cancels are never direction-gated — reducing or
|
|
82
|
+
// protecting an existing position is not a directional bet.
|
|
83
|
+
const direction = spec.risk.direction;
|
|
84
|
+
if (direction) {
|
|
85
|
+
if (action.type === "futures_open") {
|
|
86
|
+
if (direction === "short_only" && action.side !== "short") {
|
|
87
|
+
return fail("direction_constraint", `direction ${direction}: futures_open side must be "short", got "${action.side}"`);
|
|
88
|
+
}
|
|
89
|
+
if (direction === "long_only" && action.side !== "long") {
|
|
90
|
+
return fail("direction_constraint", `direction ${direction}: futures_open side must be "long", got "${action.side}"`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
// Spot buys are long exposure; a short_only agent must not accumulate
|
|
94
|
+
// them. Spot sells reduce a holding and stay allowed.
|
|
95
|
+
if (action.type === "spot_order" &&
|
|
96
|
+
direction === "short_only" &&
|
|
97
|
+
action.side === "buy") {
|
|
98
|
+
return fail("direction_constraint", `direction ${direction}: spot buys are long exposure`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
39
101
|
if (action.type === "futures_open") {
|
|
40
102
|
// Daily realized-loss stop: once today's loss hits the cap, open no new risk.
|
|
41
103
|
if (spec.limits.maxDailyLossMusd > 0 &&
|
|
@@ -250,6 +312,38 @@ export function validateAction(action, ctx) {
|
|
|
250
312
|
if (!ctx.quote.freshness || ctx.quote.freshness.status !== "fresh") {
|
|
251
313
|
return fail("stale_quote", `quote freshness ${ctx.quote.freshness?.status ?? "missing"} (need fresh)`);
|
|
252
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
|
+
}
|
|
253
347
|
return ok();
|
|
254
348
|
}
|
|
255
349
|
return fail("unknown_action", "unsupported action type");
|
package/dist/agent/engine.d.ts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
export { runCycle, type RunnerDeps } from "./runner.js";
|
|
2
|
-
export {
|
|
2
|
+
export { DECISION_INPUT_MAX_BYTES, sanitizeDecisionInputRecord, type DecisionInputRecord, } from "./decisionReceipt.js";
|
|
3
|
+
export { selectProvider, providerForRoute, type ProviderEnv, type Provider, type DecideInput, type DecideResult, type DecideRouteAttempt, type DecideRouteMeta, } from "./providers.js";
|
|
4
|
+
export { parseDecision } from "./decision.js";
|
|
5
|
+
export { chatShapeFor, buildChatBody, type ChatShape, } from "./providerCapabilities.js";
|
|
6
|
+
export { probeDecisionContract, type ProbeRoute, type ProbeDecisionResult, } from "./decisionProbe.js";
|
|
3
7
|
export { CoinRithmClient } from "./client.js";
|
|
4
8
|
export { loadAgent, buildSpec, type LoadedAgent } from "./skill.js";
|
|
5
9
|
export { resolveAgent } from "./resolve.js";
|
package/dist/agent/engine.js
CHANGED
|
@@ -6,7 +6,14 @@
|
|
|
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 {
|
|
9
|
+
export { DECISION_INPUT_MAX_BYTES, sanitizeDecisionInputRecord, } from "./decisionReceipt.js";
|
|
10
|
+
export { selectProvider, providerForRoute, } from "./providers.js";
|
|
11
|
+
export { parseDecision } from "./decision.js";
|
|
12
|
+
// Reliability slice A: the declarative request-capability table and the
|
|
13
|
+
// representative decision probe (route eligibility = a REAL parsed decision,
|
|
14
|
+
// never a bare HTTP 200 — the 62f3a12 lesson).
|
|
15
|
+
export { chatShapeFor, buildChatBody, } from "./providerCapabilities.js";
|
|
16
|
+
export { probeDecisionContract, } from "./decisionProbe.js";
|
|
10
17
|
export { CoinRithmClient } from "./client.js";
|
|
11
18
|
export { loadAgent, buildSpec } from "./skill.js";
|
|
12
19
|
export { resolveAgent } from "./resolve.js";
|
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.
|
|
@@ -11,8 +13,36 @@ const INDICATOR_RANGE = "1D";
|
|
|
11
13
|
// `universe_scan` bounds: how many top movers to pull, and how many of those
|
|
12
14
|
// to fully resolve into tradable watch entries (each resolved row costs a
|
|
13
15
|
// resolve + market [+ candles] call).
|
|
16
|
+
//
|
|
17
|
+
// RESOLVE_TOP 3 -> 6 on 2026-08-21. Only a resolved row carries indicators, and
|
|
18
|
+
// therefore a `setups` flag; the unresolved remainder is bare symbol + 24h
|
|
19
|
+
// change + price. At 3, a discovery-driven strategy could reason properly about
|
|
20
|
+
// exactly three coins per cycle out of fifteen surfaced.
|
|
21
|
+
//
|
|
22
|
+
// That bit a real user case. A pump-fade agent identifies a candidate from a
|
|
23
|
+
// `stretched`/`fade-short` setup (RSI14 >= 68) and then waits for exhaustion,
|
|
24
|
+
// which by definition means RSI is NO LONGER extreme. Nothing persists between
|
|
25
|
+
// cycles, so the candidate has to still be a resolved row at the moment the
|
|
26
|
+
// exhaustion evidence appears. Discovery is gainers-ranked, so a retracing coin
|
|
27
|
+
// slides down the list — at 3 it fell out almost immediately and went blind
|
|
28
|
+
// exactly when the strategy needed to look at it.
|
|
29
|
+
//
|
|
30
|
+
// 6 roughly doubles how far a coin can slide before losing its indicators. Cost
|
|
31
|
+
// is 3 extra market+candles calls per cycle against CoinRithm's own API (never
|
|
32
|
+
// the model quota) and ~3 more watch entries in the prompt, and only for agents
|
|
33
|
+
// that declare universe_scan.
|
|
14
34
|
const UNIVERSE_SCAN_LIMIT = 15;
|
|
15
|
-
const UNIVERSE_RESOLVE_TOP =
|
|
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
|
+
};
|
|
16
46
|
// Watchlist symbols -> the coin NAMES prediction-market titles use, so an agent
|
|
17
47
|
// discovers PM markets about the coins it actually has a price view on.
|
|
18
48
|
const PM_COIN_NAMES = {
|
|
@@ -43,10 +73,11 @@ const PM_CALIBRATION_CHURN_RE = /(updown|up-or-down|-5-?min|-5m-|-15m|15m(?:-|$)
|
|
|
43
73
|
export function isCalibrationChurnMarket(market) {
|
|
44
74
|
return PM_CALIBRATION_CHURN_RE.test(`${market.slug ?? ""} ${market.title ?? ""}`);
|
|
45
75
|
}
|
|
46
|
-
// Fetch candles for one coin and reduce them to a compact indicator bundle
|
|
47
|
-
// Tolerant by design: any failure (HTTP error, malformed/sparse
|
|
48
|
-
// null so the cycle proceeds with price-only context
|
|
49
|
-
|
|
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) {
|
|
50
81
|
// The try honors the documented tolerance for SYNCHRONOUS throws too (an
|
|
51
82
|
// unexpected client error must degrade to price-only context, never kill
|
|
52
83
|
// the cycle).
|
|
@@ -55,10 +86,10 @@ async function fetchIndicators(client, coinId, trace) {
|
|
|
55
86
|
cr = await client.candles(coinId, INDICATOR_RANGE, trace);
|
|
56
87
|
}
|
|
57
88
|
catch {
|
|
58
|
-
return null;
|
|
89
|
+
return { indicators: null };
|
|
59
90
|
}
|
|
60
91
|
if (!cr.ok)
|
|
61
|
-
return null;
|
|
92
|
+
return { indicators: null };
|
|
62
93
|
// Endpoint shape: { candles: [{ t, o, h, l, c, v }] } ascending (oldest first).
|
|
63
94
|
const candles = [];
|
|
64
95
|
for (const raw of asArr(asObj(cr.data).candles)) {
|
|
@@ -71,12 +102,78 @@ async function fetchIndicators(client, coinId, trace) {
|
|
|
71
102
|
continue;
|
|
72
103
|
candles.push({ open, high, low, close, volume: asNum(c.v) ?? undefined });
|
|
73
104
|
}
|
|
74
|
-
|
|
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
|
+
};
|
|
75
110
|
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
+
}
|
|
80
177
|
}
|
|
81
178
|
// Does a market title reference the given watchlist coin? Matches on the PM coin
|
|
82
179
|
// NAME ("Bitcoin") or the ticker ("BTC"), case-insensitively — the discover `q`
|
|
@@ -102,6 +199,11 @@ function expandPmMarkets(discData, heldPmKeys) {
|
|
|
102
199
|
return (asArr(dd.data ?? dd.markets ?? dd.results)
|
|
103
200
|
.map(asObj)
|
|
104
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 [];
|
|
105
207
|
const source = (asStr(ev.source) ?? "").toLowerCase();
|
|
106
208
|
const slug = (asStr(ev.slug) ?? "").toLowerCase();
|
|
107
209
|
// Keep titles SHORT: the model only needs to recognise the market.
|
|
@@ -109,6 +211,8 @@ function expandPmMarkets(discData, heldPmKeys) {
|
|
|
109
211
|
// prompt to ~69k tokens (413s on small-context free models).
|
|
110
212
|
const title = (asStr(ev.title) ?? asStr(ev.question) ?? "").slice(0, 80);
|
|
111
213
|
const freshness = freshnessOf(ev); // freshness is event-level
|
|
214
|
+
const quality = pmQualityOf(ev.quality);
|
|
215
|
+
const decisionSupport = pmDecisionSupportOf(ev.decisionSupport);
|
|
112
216
|
// Event-level 24h volume (the discover payload's `volume24h`, USD). Feeds
|
|
113
217
|
// the mechanical BENCHMARK agents' deterministic highest-volume pick rule.
|
|
114
218
|
// Same for every outcome of the event; undefined on an older backend.
|
|
@@ -119,13 +223,17 @@ function expandPmMarkets(discData, heldPmKeys) {
|
|
|
119
223
|
// model never bets a market that would fail the binary entry gate at
|
|
120
224
|
// quote. Back-compat: an older backend omits `eligible` (undefined) ->
|
|
121
225
|
// the outcome is kept (current behaviour).
|
|
122
|
-
const outcomes = asArr(ev.outcomes)
|
|
226
|
+
const outcomes = (Object.hasOwn(ev, "outcomes") ? asArr(ev.outcomes) : [ev])
|
|
123
227
|
.map(asObj)
|
|
124
228
|
.filter((o) => o.eligible !== false)
|
|
229
|
+
.filter((o) => {
|
|
230
|
+
const p = asNum(o.probability);
|
|
231
|
+
return p != null && p >= 0 && p <= 100;
|
|
232
|
+
})
|
|
125
233
|
.slice(0, 3);
|
|
126
|
-
//
|
|
127
|
-
|
|
128
|
-
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) => ({
|
|
129
237
|
source,
|
|
130
238
|
slug,
|
|
131
239
|
outcomeExternalMarketId: asStr(o.externalMarketId) ?? asStr(o.outcomeExternalMarketId) ?? "",
|
|
@@ -134,13 +242,22 @@ function expandPmMarkets(discData, heldPmKeys) {
|
|
|
134
242
|
outcomeName: asStr(o.name) ?? asStr(o.outcomeName) ?? undefined,
|
|
135
243
|
// Backend returns probability as 0..100 (percent) — normalise to 0..1
|
|
136
244
|
// to match the prompt's "0..1" framing (probed 2026-06-24).
|
|
137
|
-
probability: ((p) =>
|
|
245
|
+
probability: ((p) => p == null || p < 0 || p > 100 ? undefined : p / 100)(asNum(o.probability)),
|
|
138
246
|
title,
|
|
139
247
|
freshness,
|
|
248
|
+
quality,
|
|
249
|
+
decisionSupport,
|
|
140
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,
|
|
141
255
|
}));
|
|
142
256
|
})
|
|
143
|
-
.filter((m) => m.source &&
|
|
257
|
+
.filter((m) => m.source &&
|
|
258
|
+
m.slug &&
|
|
259
|
+
m.outcomeExternalMarketId &&
|
|
260
|
+
m.probability != null)
|
|
144
261
|
// Drop already-held markets so the model only sees markets it can actually
|
|
145
262
|
// open — done BEFORE any slice so held positions don't consume candidate slots.
|
|
146
263
|
.filter((m) => !heldPmKeys.has(`${m.source.toLowerCase()}|${m.slug.toLowerCase()}|${m.outcomeExternalMarketId}`)));
|
|
@@ -202,6 +319,7 @@ export async function observe(client, spec, state, trace) {
|
|
|
202
319
|
return {
|
|
203
320
|
venue: "futures",
|
|
204
321
|
id: Number(asNum(p.id) ?? p.id),
|
|
322
|
+
...(usesCapitalSizing(spec) ? { walletId: asNum(p.walletId) } : {}),
|
|
205
323
|
coinId: asStr(coin.ucid) ?? asStr(p.coinId),
|
|
206
324
|
symbol: asStr(coin.symbol) ?? asStr(p.symbol),
|
|
207
325
|
side: asStr(p.side),
|
|
@@ -214,6 +332,7 @@ export async function observe(client, spec, state, trace) {
|
|
|
214
332
|
liquidationPrice: asNum(p.liquidationPrice),
|
|
215
333
|
stopLossPrice: asNum(p.stopLossPrice),
|
|
216
334
|
takeProfitPrice: asNum(p.takeProfitPrice),
|
|
335
|
+
openedAt: asStr(p.openedAt),
|
|
217
336
|
};
|
|
218
337
|
});
|
|
219
338
|
// Sync poll: /trades since the persisted cursor.
|
|
@@ -268,7 +387,14 @@ export async function observe(client, spec, state, trace) {
|
|
|
268
387
|
sentimentBullishPct: asNum(asObj(m.sentiment).bullishPct) ?? undefined,
|
|
269
388
|
// Freshness lives under the response's `observation` block.
|
|
270
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),
|
|
271
394
|
};
|
|
395
|
+
const fundamentals = coinFundamentalsOf(m);
|
|
396
|
+
if (fundamentals)
|
|
397
|
+
entry.fundamentals = fundamentals;
|
|
272
398
|
// Capture the market-wide Fear & Greed regime once (same across coins).
|
|
273
399
|
if (!marketMood) {
|
|
274
400
|
const fg = asObj(m.fearGreed);
|
|
@@ -279,11 +405,8 @@ export async function observe(client, spec, state, trace) {
|
|
|
279
405
|
// `indicators` capability: enrich the observation with computed TA so the
|
|
280
406
|
// model reasons over structure (trend/momentum/volatility/breakout) instead
|
|
281
407
|
// of price + %change alone. Backed by the candles endpoint's shared cache.
|
|
282
|
-
if (wantIndicators)
|
|
283
|
-
|
|
284
|
-
if (ind)
|
|
285
|
-
entry.indicators = ind;
|
|
286
|
-
}
|
|
408
|
+
if (wantIndicators)
|
|
409
|
+
await enrichFromCandles(client, entry, coinId, trace);
|
|
287
410
|
watch.push(entry);
|
|
288
411
|
}
|
|
289
412
|
// `universe_scan` capability (2026-08-18, direct user request): discover the
|
|
@@ -305,8 +428,12 @@ export async function observe(client, spec, state, trace) {
|
|
|
305
428
|
.map((r) => ({
|
|
306
429
|
symbol: (asStr(r.symbol) ?? "").toUpperCase(),
|
|
307
430
|
name: asStr(r.name),
|
|
308
|
-
|
|
309
|
-
|
|
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),
|
|
310
437
|
// The movers row already carries the ucid, which IS the coinId every
|
|
311
438
|
// downstream call takes. Kept so the resolve round-trip below can be
|
|
312
439
|
// skipped — see the comment there.
|
|
@@ -345,15 +472,23 @@ export async function observe(client, spec, state, trace) {
|
|
|
345
472
|
sentimentBullishPct: asNum(asObj(m.sentiment).bullishPct) ?? undefined,
|
|
346
473
|
freshness: freshnessOf(asObj(m.observation)),
|
|
347
474
|
discovered: true,
|
|
475
|
+
slug: row.slug ?? asStr(asObj(asObj(m.observation).dataset).coinSlug),
|
|
348
476
|
};
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
477
|
+
const fundamentals = coinFundamentalsOf(m);
|
|
478
|
+
if (fundamentals)
|
|
479
|
+
entry.fundamentals = fundamentals;
|
|
480
|
+
if (wantIndicators)
|
|
481
|
+
await enrichFromCandles(client, entry, coinId, trace);
|
|
354
482
|
watch.push(entry);
|
|
355
483
|
}
|
|
356
|
-
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
|
+
}));
|
|
357
492
|
if (context.length > 0)
|
|
358
493
|
universeMovers = context;
|
|
359
494
|
}
|
|
@@ -382,6 +517,7 @@ export async function observe(client, spec, state, trace) {
|
|
|
382
517
|
}
|
|
383
518
|
// PM open positions + discovered quote-ready candidates — only if pm enabled.
|
|
384
519
|
let pmPositions = [];
|
|
520
|
+
let capitalPmData;
|
|
385
521
|
let pmResolutions = [];
|
|
386
522
|
let pmMarkets = [];
|
|
387
523
|
if (wantPm) {
|
|
@@ -412,11 +548,13 @@ export async function observe(client, spec, state, trace) {
|
|
|
412
548
|
pmDiscR = fb;
|
|
413
549
|
}
|
|
414
550
|
if (pmPosR.ok) {
|
|
551
|
+
capitalPmData = pmPosR.data;
|
|
415
552
|
pmPositions = asArr(asObj(pmPosR.data).positions)
|
|
416
553
|
.map(asObj)
|
|
417
554
|
.filter((p) => (asStr(p.status) ?? "open") === "open")
|
|
418
555
|
.map((p) => ({
|
|
419
556
|
id: Number(asNum(p.id) ?? p.id),
|
|
557
|
+
...(usesCapitalSizing(spec) ? { walletId: asNum(p.walletId) } : {}),
|
|
420
558
|
// The /positions/pm API returns `eventSlug` and the outcome id NESTED at
|
|
421
559
|
// outcome.externalMarketId — NOT `slug` / `outcomeExternalMarketId`.
|
|
422
560
|
// Reading the wrong keys left both undefined, which silently broke the
|
|
@@ -433,6 +571,14 @@ export async function observe(client, spec, state, trace) {
|
|
|
433
571
|
// down trips the stop too — not just futures.
|
|
434
572
|
unrealizedPnlMusd: asNum(p.unrealizedPnl) ?? asNum(p.unrealizedPnlMusd),
|
|
435
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),
|
|
436
582
|
}));
|
|
437
583
|
// Settlement-feedback loop: the SAME /positions/pm response carries an
|
|
438
584
|
// additive `recentlyResolved` array — the agent's OWN bets that settled
|
|
@@ -549,9 +695,12 @@ export async function observe(client, spec, state, trace) {
|
|
|
549
695
|
...watch.map((w) => w.symbol.toUpperCase()),
|
|
550
696
|
]));
|
|
551
697
|
if (wantNews && newsCoins.length > 0) {
|
|
552
|
-
|
|
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);
|
|
553
702
|
if (nr.ok) {
|
|
554
|
-
|
|
703
|
+
const fetched = asArr(asObj(nr.data).items)
|
|
555
704
|
.map(asObj)
|
|
556
705
|
.map((it) => ({
|
|
557
706
|
title: (asStr(it.title) ?? "").slice(0, 160),
|
|
@@ -559,12 +708,17 @@ export async function observe(client, spec, state, trace) {
|
|
|
559
708
|
sentiment: asStr(it.sentiment) ?? undefined,
|
|
560
709
|
importance: asNum(it.importance) ?? undefined,
|
|
561
710
|
ageHours: ((a) => a == null ? undefined : Math.round((a / 60) * 10) / 10)(asNum(it.ageMinutes)),
|
|
711
|
+
publishedAt: asStr(it.publishedAt) ?? undefined,
|
|
562
712
|
coins: asArr(it.coins)
|
|
563
713
|
.map((c) => asStr(c))
|
|
564
714
|
.filter((c) => !!c),
|
|
565
715
|
}))
|
|
566
|
-
.filter((n) => n.title.length > 0)
|
|
567
|
-
|
|
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);
|
|
568
722
|
}
|
|
569
723
|
}
|
|
570
724
|
const observation = {
|
|
@@ -572,6 +726,11 @@ export async function observe(client, spec, state, trace) {
|
|
|
572
726
|
scopes,
|
|
573
727
|
cashAvailableMusd,
|
|
574
728
|
equityMusd,
|
|
729
|
+
...(usesCapitalSizing(spec)
|
|
730
|
+
? {
|
|
731
|
+
capitalBook: deriveCapitalBook(portR.data, walletR.data, posR.data, capitalPmData),
|
|
732
|
+
}
|
|
733
|
+
: {}),
|
|
575
734
|
openPositions,
|
|
576
735
|
openOrders,
|
|
577
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;
|