@coinrithm/mcp-trading 0.7.2 → 0.7.3
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 +46 -1
- package/README.md +9 -7
- package/dist/agent/act.d.ts +2 -2
- package/dist/agent/act.js +24 -3
- package/dist/agent/cli.js +59 -18
- package/dist/agent/client.d.ts +33 -0
- package/dist/agent/client.js +34 -7
- package/dist/agent/decision.d.ts +3 -0
- package/dist/agent/decision.js +26 -3
- package/dist/agent/decisionValidator.js +2 -1
- package/dist/agent/deploymentOverlay.js +25 -5
- package/dist/agent/engine.d.ts +2 -1
- package/dist/agent/engine.js +4 -1
- package/dist/agent/extract.js +3 -1
- package/dist/agent/gate.js +25 -5
- package/dist/agent/index.js +0 -1
- package/dist/agent/indicators.js +4 -2
- package/dist/agent/manifest.js +1 -1
- package/dist/agent/mechanical.d.ts +36 -0
- package/dist/agent/mechanical.js +286 -0
- package/dist/agent/observe.js +120 -52
- package/dist/agent/prompt.d.ts +3 -1
- package/dist/agent/prompt.js +17 -6
- package/dist/agent/providers.js +39 -4
- package/dist/agent/resolve.js +23 -6
- package/dist/agent/resolvePm.js +14 -3
- package/dist/agent/runEvidence.js +6 -2
- package/dist/agent/runner.d.ts +8 -2
- package/dist/agent/runner.js +363 -59
- package/dist/agent/scorecard.js +12 -4
- package/dist/agent/setups.js +57 -9
- package/dist/agent/skill.js +1 -1
- package/dist/agent/state.js +9 -4
- package/dist/agent/types.d.ts +17 -2
- package/dist/agent/types.js +2 -1
- package/dist/agent/util.js +11 -4
- package/dist/agent/version.d.ts +1 -1
- package/dist/agent/version.js +1 -1
- package/dist/client.d.ts +33 -0
- package/dist/client.js +12 -3
- package/dist/executionPolicy.d.ts +2 -0
- package/dist/executionPolicy.js +21 -0
- package/dist/http.js +10 -2
- package/dist/tools.d.ts +1 -0
- package/dist/tools.js +214 -29
- package/package.json +9 -1
package/dist/agent/runner.js
CHANGED
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
// spot, futures, and prediction markets. Dry-run never writes. Live uses
|
|
3
3
|
// idempotency keys + agentTrace and exports run evidence. The client + provider
|
|
4
4
|
// are injected so the loop is fully unit-testable with no network/model calls.
|
|
5
|
+
import { COINRITHM_API } from "./version.js";
|
|
5
6
|
import { spotBuyCost, DEFAULT_TRIGGER_POLICY, } from "./types.js";
|
|
7
|
+
import { decideMechanical } from "./mechanical.js";
|
|
6
8
|
import { evaluateGate, noteLlmCall, estimateCostUsd } from "./gate.js";
|
|
7
9
|
import { baseSymbol } from "./setups.js";
|
|
8
10
|
import { observe } from "./observe.js";
|
|
@@ -15,6 +17,70 @@ import { makeDecisionId, makeTrace, exportRunEvidence } from "./runEvidence.js";
|
|
|
15
17
|
import { rollDay, checkKillSwitch, accrueRealized, saveState, } from "./state.js";
|
|
16
18
|
import { asObj, asNum, asStr } from "./extract.js";
|
|
17
19
|
import { parseCadenceMs, sleep } from "./util.js";
|
|
20
|
+
// Independent-forecast kill-switch. Default ON: the fleet elicits + submits its
|
|
21
|
+
// OWN forecastProbability on PM opens. Set HOUSE_AGENT_FORECAST_ENABLED to
|
|
22
|
+
// "false"/"0"/"no"/"off" to ship pm/open requests WITHOUT the field, byte-identical
|
|
23
|
+
// to pre-forecast behavior (prompt extension also drops out). Read at CALL TIME
|
|
24
|
+
// (not module-load) so a process env / a test can toggle it without re-import.
|
|
25
|
+
export function houseAgentForecastEnabled() {
|
|
26
|
+
const v = (process.env.HOUSE_AGENT_FORECAST_ENABLED ?? "")
|
|
27
|
+
.trim()
|
|
28
|
+
.toLowerCase();
|
|
29
|
+
return !["false", "0", "no", "off"].includes(v);
|
|
30
|
+
}
|
|
31
|
+
// Opportunity-capture kill-switch. Default ON: the runner reports the NON-opened
|
|
32
|
+
// opportunities the trade paths cannot see — the model ABSTAINING while PM markets
|
|
33
|
+
// were listed, forecasting WITHOUT trading (forecast_only), or a validated pm_open
|
|
34
|
+
// whose quote EXPIRED at act time (quote_expired) — so the public evaluation is not
|
|
35
|
+
// selection-biased toward opened trades. Mirrors the backend env name; set
|
|
36
|
+
// AGENT_OPPORTUNITY_CAPTURE_ENABLED to "false"/"0"/"no"/"off" to disable. Read at
|
|
37
|
+
// CALL TIME so a process env / a test can toggle it without re-import. Only ever
|
|
38
|
+
// posts on a LIVE cycle (dry-run never writes), at most ONCE per cycle.
|
|
39
|
+
export function agentOpportunityCaptureEnabled() {
|
|
40
|
+
const v = (process.env.AGENT_OPPORTUNITY_CAPTURE_ENABLED ?? "")
|
|
41
|
+
.trim()
|
|
42
|
+
.toLowerCase();
|
|
43
|
+
return !["false", "0", "no", "off"].includes(v);
|
|
44
|
+
}
|
|
45
|
+
// The runner's SELF-REPORTED runtime kind. This is the npm coinrithm-agent runner,
|
|
46
|
+
// so the honest default is "self_host_runner". When the house scheduler spawns the
|
|
47
|
+
// runner it may set COINRITHM_RUNTIME_KIND=hosted_scheduler — a purely DESCRIPTIVE
|
|
48
|
+
// label that carries NO trust (the backend forces providerVerified=false for every
|
|
49
|
+
// keyed caller regardless), so it can never be used to fake verification. Any
|
|
50
|
+
// unrecognized value falls back to self_host_runner.
|
|
51
|
+
export function runnerRuntimeKind() {
|
|
52
|
+
const v = (process.env.COINRITHM_RUNTIME_KIND ?? "").trim().toLowerCase();
|
|
53
|
+
return v === "hosted_scheduler" ? "hosted_scheduler" : "self_host_runner";
|
|
54
|
+
}
|
|
55
|
+
// Build the runner's REAL provenance from what it honestly knows: its runtime kind,
|
|
56
|
+
// the published package version (read from package.json via version.ts), and the
|
|
57
|
+
// self-reported model provider/name from the resolved agent spec. Everything the
|
|
58
|
+
// runner cannot truthfully attest (bundle/skill/prompt/config hashes, evidence refs)
|
|
59
|
+
// is OMITTED — absent > fabricated. Sending this block makes the artifact v2; the
|
|
60
|
+
// server still stamps the policy versions + providerVerified itself.
|
|
61
|
+
export function buildRunnerProvenance(spec) {
|
|
62
|
+
const prov = {
|
|
63
|
+
runtimeKind: runnerRuntimeKind(),
|
|
64
|
+
packageVersion: COINRITHM_API.mcpVersion,
|
|
65
|
+
};
|
|
66
|
+
if (spec.model?.provider)
|
|
67
|
+
prov.modelProvider = spec.model.provider;
|
|
68
|
+
if (spec.model?.name)
|
|
69
|
+
prov.modelName = spec.model.name;
|
|
70
|
+
return prov;
|
|
71
|
+
}
|
|
72
|
+
// Clamp a model-proposed forecast to the backend's exclusive (0,100) rail as a
|
|
73
|
+
// whole-or-one-decimal value in [1,99]. Returns undefined for missing / null /
|
|
74
|
+
// NaN / non-finite input (absent > fake — the trade proceeds WITHOUT the field;
|
|
75
|
+
// the value is NEVER defaulted to the market price / entryProbability). The
|
|
76
|
+
// decision parser already tolerates non-numeric model output down to undefined,
|
|
77
|
+
// so this mainly enforces the numeric range.
|
|
78
|
+
export function sanitizeForecastProbability(raw) {
|
|
79
|
+
if (typeof raw !== "number" || !Number.isFinite(raw))
|
|
80
|
+
return undefined;
|
|
81
|
+
const clamped = Math.min(99, Math.max(1, raw));
|
|
82
|
+
return Math.round(clamped * 10) / 10; // one-decimal precision
|
|
83
|
+
}
|
|
18
84
|
// A stable idempotency-key component per distinct intent (so a lost response
|
|
19
85
|
// replays rather than re-trades, but a genuinely new intent gets a new key).
|
|
20
86
|
function intentKeyOf(action) {
|
|
@@ -154,9 +220,98 @@ export function rationaleForAction(a, decisionRationale, perActionSummary, total
|
|
|
154
220
|
? decisionRationale
|
|
155
221
|
: summarizeAction(a);
|
|
156
222
|
}
|
|
223
|
+
// A market's current probability (stored 0..1) as whole percentage POINTS (0..100),
|
|
224
|
+
// the basis the opportunity/decision record uses. undefined when not reported.
|
|
225
|
+
function marketPct(prob) {
|
|
226
|
+
return typeof prob === "number" && Number.isFinite(prob)
|
|
227
|
+
? Math.round(prob * 100)
|
|
228
|
+
: undefined;
|
|
229
|
+
}
|
|
230
|
+
// Find the discovered market matching a resolved pm_open triple (case-insensitive
|
|
231
|
+
// source/slug, exact outcome id) — for the observed market probability.
|
|
232
|
+
function findPmMarket(pmMarkets, ref) {
|
|
233
|
+
return pmMarkets.find((m) => (m.source ?? "").toLowerCase() === (ref.source ?? "").toLowerCase() &&
|
|
234
|
+
(m.slug ?? "").toLowerCase() === (ref.slug ?? "").toLowerCase() &&
|
|
235
|
+
m.outcomeExternalMarketId === ref.outcomeExternalMarketId);
|
|
236
|
+
}
|
|
237
|
+
// Build the NON-opened opportunity for a MODEL-SKIP cycle, or null when there is no
|
|
238
|
+
// PM universe to report on. The top listed market is the subject and the universe
|
|
239
|
+
// breadth rides in universeSize so ONE post captures the whole cohort (never one
|
|
240
|
+
// per market). A pm_open the model listed WITH a usable forecast makes it
|
|
241
|
+
// forecast_only (it produced a probability but chose not to trade); otherwise it is
|
|
242
|
+
// a plain abstention. NEVER defaults the forecast to the market price.
|
|
243
|
+
//
|
|
244
|
+
// NOTE: parseDecision() clears a pure `skip` decision's actions to [] (an act with
|
|
245
|
+
// actions goes down the act path instead), so in the live runner this yields
|
|
246
|
+
// `abstained`. The forecast_only rule is exercised whenever a decision reaches here
|
|
247
|
+
// WITH surviving actions (kept faithful to the capture spec + future-proof; covered
|
|
248
|
+
// directly by the unit test).
|
|
249
|
+
export function buildSkipOpportunity(decision, pmMarkets, forecastEnabled) {
|
|
250
|
+
if (pmMarkets.length === 0)
|
|
251
|
+
return null;
|
|
252
|
+
const universeSize = pmMarkets.length;
|
|
253
|
+
const reasonCode = decision.reason?.trim() || undefined;
|
|
254
|
+
const pmAction = decision.actions.find((a) => a.type === "pm_open");
|
|
255
|
+
if (forecastEnabled && pmAction) {
|
|
256
|
+
const fc = sanitizeForecastProbability(pmAction.forecastProbability);
|
|
257
|
+
if (fc != null) {
|
|
258
|
+
// Resolve the forecasted market for an honest subject; fall back to the top
|
|
259
|
+
// listed market when the ref can't be resolved (still a valid forecast_only).
|
|
260
|
+
const resolved = resolvePmRef(pmAction, pmMarkets);
|
|
261
|
+
const subject = (resolved.ok ? findPmMarket(pmMarkets, resolved.action) : undefined) ??
|
|
262
|
+
pmMarkets[0];
|
|
263
|
+
return {
|
|
264
|
+
kind: "forecast_only",
|
|
265
|
+
source: subject.source,
|
|
266
|
+
slug: subject.slug,
|
|
267
|
+
outcomeExternalMarketId: subject.outcomeExternalMarketId,
|
|
268
|
+
universeSize,
|
|
269
|
+
forecastProbability: fc,
|
|
270
|
+
marketProbability: marketPct(subject.probability),
|
|
271
|
+
reasonCode,
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
const top = pmMarkets[0];
|
|
276
|
+
return {
|
|
277
|
+
kind: "abstained",
|
|
278
|
+
source: top.source,
|
|
279
|
+
slug: top.slug,
|
|
280
|
+
outcomeExternalMarketId: top.outcomeExternalMarketId,
|
|
281
|
+
universeSize,
|
|
282
|
+
marketProbability: marketPct(top.probability),
|
|
283
|
+
reasonCode,
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
// A validated pm_open that FAILED at act with quote-expiry semantics — the server
|
|
287
|
+
// rejected the open with a 422 mock_entry_blocked, i.e. the eligibility/quality/
|
|
288
|
+
// pricing state moved between the quote the runner validated and the act, so the
|
|
289
|
+
// quote it acted on had effectively expired. Distinct from a risk/balance 422
|
|
290
|
+
// (insufficient_balance), which is not a quote-expiry.
|
|
291
|
+
function isQuoteExpiredResult(status, data) {
|
|
292
|
+
if (status !== 422)
|
|
293
|
+
return false;
|
|
294
|
+
const d = asObj(data);
|
|
295
|
+
return asStr(d.error) === "mock_entry_blocked";
|
|
296
|
+
}
|
|
297
|
+
// Joined server block reasons (reasonCode) for a quote_expired opportunity.
|
|
298
|
+
function blockReasonsOf(data) {
|
|
299
|
+
const d = asObj(data);
|
|
300
|
+
const reasons = Array.isArray(d.blockReasons)
|
|
301
|
+
? d.blockReasons.map(String).filter((s) => s.length > 0)
|
|
302
|
+
: [];
|
|
303
|
+
return reasons.length > 0 ? reasons.join(",") : undefined;
|
|
304
|
+
}
|
|
157
305
|
export async function runCycle(deps) {
|
|
158
306
|
const { client, provider, spec, mergedProse, state, live, stateFile } = deps;
|
|
159
307
|
const log = deps.log ?? (() => { });
|
|
308
|
+
// One flag read per cycle governs BOTH the prompt extension and the submission,
|
|
309
|
+
// so they can never diverge (prompt asks for it iff we would submit it).
|
|
310
|
+
const forecastEnabled = houseAgentForecastEnabled();
|
|
311
|
+
// The runner's REAL provenance for this cycle (runtime kind + package version +
|
|
312
|
+
// self-reported model). Attached to every durable-artifact write (pm_open,
|
|
313
|
+
// opportunity) so the artifact records WHAT RAN. Constant per cycle.
|
|
314
|
+
const provenance = buildRunnerProvenance(spec);
|
|
160
315
|
state.cyclesRun += 1;
|
|
161
316
|
rollDay(state);
|
|
162
317
|
// Kill-switch pre-check: a disabled agent never observes, decides, or acts.
|
|
@@ -177,6 +332,42 @@ export async function runCycle(deps) {
|
|
|
177
332
|
const runId = state.runId;
|
|
178
333
|
const decisionId = makeDecisionId(state.cyclesRun);
|
|
179
334
|
const baseTrace = makeTrace(runId, decisionId, spec);
|
|
335
|
+
// Opportunity capture (kills evaluation selection bias). Post at most ONE
|
|
336
|
+
// non-opened opportunity per cycle, LIVE only (dry-run never writes), best-effort
|
|
337
|
+
// — a failed post never affects the cycle result. The latch is set BEFORE the
|
|
338
|
+
// await so a failure never retries within the cycle (respects the write budget);
|
|
339
|
+
// the cohort/universe field carries the breadth, so we never post per-market.
|
|
340
|
+
const captureOpportunity = agentOpportunityCaptureEnabled();
|
|
341
|
+
let opportunityPosted = false;
|
|
342
|
+
let postedOpportunity;
|
|
343
|
+
const postOpportunity = async (o) => {
|
|
344
|
+
if (!captureOpportunity || !live || opportunityPosted)
|
|
345
|
+
return;
|
|
346
|
+
opportunityPosted = true;
|
|
347
|
+
postedOpportunity = o;
|
|
348
|
+
try {
|
|
349
|
+
await client.reportPmOpportunity({
|
|
350
|
+
kind: o.kind,
|
|
351
|
+
source: o.source,
|
|
352
|
+
slug: o.slug,
|
|
353
|
+
outcomeExternalMarketId: o.outcomeExternalMarketId,
|
|
354
|
+
forecastProbability: o.forecastProbability,
|
|
355
|
+
marketProbability: o.marketProbability,
|
|
356
|
+
reasonCode: o.reasonCode,
|
|
357
|
+
cohort: {
|
|
358
|
+
universeSize: o.universeSize,
|
|
359
|
+
horizon: spec.objective?.horizon,
|
|
360
|
+
},
|
|
361
|
+
decisionId,
|
|
362
|
+
runId,
|
|
363
|
+
provenance,
|
|
364
|
+
}, baseTrace);
|
|
365
|
+
log(`reported ${o.kind} opportunity (universe ${o.universeSize ?? "?"})`);
|
|
366
|
+
}
|
|
367
|
+
catch (err) {
|
|
368
|
+
log(`opportunity post failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
369
|
+
}
|
|
370
|
+
};
|
|
180
371
|
// OBSERVE
|
|
181
372
|
const obs = await observe(client, spec, state, baseTrace);
|
|
182
373
|
const observation = obs.observation;
|
|
@@ -262,68 +453,97 @@ export async function runCycle(deps) {
|
|
|
262
453
|
writeAccepted: 0,
|
|
263
454
|
};
|
|
264
455
|
}
|
|
265
|
-
|
|
266
|
-
//
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
if (!res.ok) {
|
|
290
|
-
state.consecutiveModelFailures += 1;
|
|
291
|
-
saveState(stateFile, state);
|
|
292
|
-
log(`model error: ${res.error}`);
|
|
293
|
-
return {
|
|
294
|
-
decision: "skip",
|
|
295
|
-
skipReason: `model error: ${res.error}`,
|
|
296
|
-
planned: [],
|
|
297
|
-
modelFailed: true,
|
|
298
|
-
live,
|
|
299
|
-
...meter,
|
|
300
|
-
decisionType: "model_error",
|
|
301
|
-
writeAttempted: 0,
|
|
302
|
-
writeAccepted: 0,
|
|
456
|
+
// DECIDE. Two paths share the same downstream validate+act loop:
|
|
457
|
+
// • mechanical benchmark agents (provider "mechanical") compute the decision
|
|
458
|
+
// deterministically from the observation — no prompt, no model call, no
|
|
459
|
+
// inference cost. They never touch the LLM budget/debounce (noteLlmCall).
|
|
460
|
+
// • every other agent asks its BYO model.
|
|
461
|
+
let decision;
|
|
462
|
+
let meter;
|
|
463
|
+
if (providerName === "mechanical") {
|
|
464
|
+
const mech = decideMechanical({
|
|
465
|
+
strategy: spec.model?.name ?? "",
|
|
466
|
+
observation,
|
|
467
|
+
dateKey: state.dayKey,
|
|
468
|
+
});
|
|
469
|
+
for (const l of mech.log)
|
|
470
|
+
log(l);
|
|
471
|
+
decision = mech.decision;
|
|
472
|
+
state.consecutiveModelFailures = 0;
|
|
473
|
+
// Zero-cost cycle: a mechanical decision made no LLM call.
|
|
474
|
+
meter = {
|
|
475
|
+
triggerCodes: gate.codes,
|
|
476
|
+
llmCallMade: false,
|
|
477
|
+
tokensIn: 0,
|
|
478
|
+
tokensOut: 0,
|
|
479
|
+
estimatedCostUsd: 0,
|
|
303
480
|
};
|
|
304
481
|
}
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
482
|
+
else {
|
|
483
|
+
noteLlmCall(state, gate.codes, nowMs);
|
|
484
|
+
const system = buildSystemPrompt(spec, mergedProse, {
|
|
485
|
+
includeForecast: forecastEnabled,
|
|
486
|
+
});
|
|
487
|
+
const user = buildUserPrompt(observation, state.journal);
|
|
488
|
+
const tokensInEst = Math.round((system.length + user.length) / 4);
|
|
489
|
+
// Prompt-size + trigger visibility in the live terminal.
|
|
490
|
+
log(`prompt ~${tokensInEst} tok ` +
|
|
491
|
+
`(pm ${observation.pmMarkets.length}, trades ${observation.newClosedTrades.length}, watch ${observation.watch.length}, setups ${observation.setups.length}, triggers ${gate.codes.join("|") || "none"})`);
|
|
492
|
+
const res = await provider.decide({ system, user });
|
|
493
|
+
// Metering: prefer provider-reported usage; fall back to a chars/4 estimate.
|
|
494
|
+
const tokensIn = res.ok
|
|
495
|
+
? (res.usage?.promptTokens ?? tokensInEst)
|
|
496
|
+
: tokensInEst;
|
|
497
|
+
const tokensOut = res.ok
|
|
498
|
+
? (res.usage?.completionTokens ?? Math.round(res.text.length / 4))
|
|
499
|
+
: 0;
|
|
500
|
+
const estimatedCostUsd = estimateCostUsd(providerName, tokensIn, tokensOut);
|
|
501
|
+
meter = {
|
|
502
|
+
triggerCodes: gate.codes,
|
|
503
|
+
llmCallMade: true,
|
|
504
|
+
tokensIn,
|
|
505
|
+
tokensOut,
|
|
506
|
+
estimatedCostUsd,
|
|
323
507
|
};
|
|
508
|
+
if (!res.ok) {
|
|
509
|
+
state.consecutiveModelFailures += 1;
|
|
510
|
+
saveState(stateFile, state);
|
|
511
|
+
log(`model error: ${res.error}`);
|
|
512
|
+
return {
|
|
513
|
+
decision: "skip",
|
|
514
|
+
skipReason: `model error: ${res.error}`,
|
|
515
|
+
planned: [],
|
|
516
|
+
modelFailed: true,
|
|
517
|
+
live,
|
|
518
|
+
...meter,
|
|
519
|
+
decisionType: "model_error",
|
|
520
|
+
writeAttempted: 0,
|
|
521
|
+
writeAccepted: 0,
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
const parsed = parseDecision(res.text);
|
|
525
|
+
if (!parsed.ok) {
|
|
526
|
+
state.consecutiveModelFailures += 1;
|
|
527
|
+
saveState(stateFile, state);
|
|
528
|
+
log(`model output invalid: ${parsed.error}`);
|
|
529
|
+
return {
|
|
530
|
+
decision: "skip",
|
|
531
|
+
skipReason: `model output invalid: ${parsed.error}`,
|
|
532
|
+
// Never persist raw model text (no-CoT privacy policy) — the parse error
|
|
533
|
+
// in skipReason is the diagnostic; the malformed output is not stored.
|
|
534
|
+
rawModelOutput: undefined,
|
|
535
|
+
planned: [],
|
|
536
|
+
modelFailed: true,
|
|
537
|
+
live,
|
|
538
|
+
...meter,
|
|
539
|
+
decisionType: "model_error",
|
|
540
|
+
writeAttempted: 0,
|
|
541
|
+
writeAccepted: 0,
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
state.consecutiveModelFailures = 0;
|
|
545
|
+
decision = parsed.decision;
|
|
324
546
|
}
|
|
325
|
-
state.consecutiveModelFailures = 0;
|
|
326
|
-
const decision = parsed.decision;
|
|
327
547
|
// Reasoning captured for the Arena terminal (keystone transparency): the
|
|
328
548
|
// model's PARSED, sanitized short analysis + decision confidence. We do NOT
|
|
329
549
|
// persist the full raw model text — a response can carry prose reasoning
|
|
@@ -336,6 +556,13 @@ export async function runCycle(deps) {
|
|
|
336
556
|
const rawModelOutput = undefined;
|
|
337
557
|
if (decision.decision === "skip" || decision.actions.length === 0) {
|
|
338
558
|
state.consecutiveRejectCycles += 1;
|
|
559
|
+
// Capture the abstention / forecast-only: the model evaluated a non-empty PM
|
|
560
|
+
// universe and chose NOT to open, so the public evaluation must see it (else an
|
|
561
|
+
// agent looks skilled by exposure choice alone). One post carries the whole
|
|
562
|
+
// cohort via universeSize.
|
|
563
|
+
const skipOpp = buildSkipOpportunity(decision, observation.pmMarkets, forecastEnabled);
|
|
564
|
+
if (skipOpp)
|
|
565
|
+
await postOpportunity(skipOpp);
|
|
339
566
|
saveState(stateFile, state);
|
|
340
567
|
log(`model chose skip${decision.reason ? `: ${decision.reason}` : ""}`);
|
|
341
568
|
return {
|
|
@@ -350,6 +577,7 @@ export async function runCycle(deps) {
|
|
|
350
577
|
decisionType: "skip",
|
|
351
578
|
writeAttempted: decision.actions.length,
|
|
352
579
|
writeAccepted: 0,
|
|
580
|
+
...(postedOpportunity ? { opportunity: postedOpportunity } : {}),
|
|
353
581
|
};
|
|
354
582
|
}
|
|
355
583
|
// VALIDATE (+ ACT when live). Quote evidence is fetched by the runner.
|
|
@@ -404,6 +632,44 @@ export async function runCycle(deps) {
|
|
|
404
632
|
log(`reject pm_open: duplicate_intent (hold PM ${pm.slug})`);
|
|
405
633
|
continue;
|
|
406
634
|
}
|
|
635
|
+
// Independent forecast submission (HOUSE_AGENT_FORECAST_ENABLED, default ON).
|
|
636
|
+
// Attach the model's OWN probability the backed side wins — clamped to [1,99],
|
|
637
|
+
// OMITTED when absent/unparseable (a bad forecast never blocks the trade), and
|
|
638
|
+
// NEVER defaulted to the market price. Flag OFF strips any forecast so the open
|
|
639
|
+
// request is byte-identical to pre-forecast behavior.
|
|
640
|
+
if (forecastEnabled) {
|
|
641
|
+
const fc = sanitizeForecastProbability(pm.forecastProbability);
|
|
642
|
+
if (fc != null) {
|
|
643
|
+
const mkt = observation.pmMarkets.find((m) => (m.source ?? "").toLowerCase() ===
|
|
644
|
+
(pm.source ?? "").toLowerCase() &&
|
|
645
|
+
(m.slug ?? "").toLowerCase() === (pm.slug ?? "").toLowerCase() &&
|
|
646
|
+
m.outcomeExternalMarketId === pm.outcomeExternalMarketId);
|
|
647
|
+
const marketPct = typeof mkt?.probability === "number" &&
|
|
648
|
+
Number.isFinite(mkt.probability)
|
|
649
|
+
? Math.round(mkt.probability * 100)
|
|
650
|
+
: undefined;
|
|
651
|
+
// Anti-echo: an EXACT match on the market's integer probability is still
|
|
652
|
+
// submitted (a forecast can legitimately agree) — but we LOG it so echo
|
|
653
|
+
// rates stay observable; we never silently mutate the value. NOTE: the
|
|
654
|
+
// market-implied BENCHMARK agent (provider "mechanical", model.name
|
|
655
|
+
// "market-implied") echoes the market probability BY DESIGN — it IS the
|
|
656
|
+
// baseline definition — so a 100% echo rate there is expected, not a
|
|
657
|
+
// defect. Its agentModel/description say BENCHMARK so this log line is
|
|
658
|
+
// never mistaken for a mispriced skill agent.
|
|
659
|
+
if (marketPct != null && Math.round(fc) === marketPct) {
|
|
660
|
+
log(`pm_open forecast ${fc} == market prob ${marketPct}% (echo) — submitting as-is`);
|
|
661
|
+
}
|
|
662
|
+
action = { ...pm, forecastProbability: fc };
|
|
663
|
+
}
|
|
664
|
+
else {
|
|
665
|
+
action = { ...pm, forecastProbability: undefined };
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
else if (pm.forecastProbability != null) {
|
|
669
|
+
// Flag off but the model still emitted a forecast — strip it so the request
|
|
670
|
+
// carries NO forecastProbability field (byte-identical to pre-forecast).
|
|
671
|
+
action = { ...pm, forecastProbability: undefined };
|
|
672
|
+
}
|
|
407
673
|
}
|
|
408
674
|
// Anti-churn critic: block re-opening a futures position we ALREADY hold unless
|
|
409
675
|
// it's a confirmed WINNER with room (a legit scale-in). Stops the re-open-a-
|
|
@@ -437,6 +703,24 @@ export async function runCycle(deps) {
|
|
|
437
703
|
}
|
|
438
704
|
}
|
|
439
705
|
const quote = await fetchQuote(client, action, observation, baseTrace);
|
|
706
|
+
// Early PM skip: the quote's openBlocked preview tells us a pm/open right now
|
|
707
|
+
// would be rejected 422 by the open-time quality gate (distinct from the
|
|
708
|
+
// eligible/blockReasons SHAPE gate the validator checks). Bail here with a clear
|
|
709
|
+
// reason instead of burning the open attempt on a guaranteed 422.
|
|
710
|
+
if (action.type === "pm_open" && quote?.openBlocked === true) {
|
|
711
|
+
const reasons = Array.isArray(quote.openBlockReasons)
|
|
712
|
+
? quote.openBlockReasons.map(String)
|
|
713
|
+
: [];
|
|
714
|
+
planned.push({
|
|
715
|
+
action,
|
|
716
|
+
accepted: false,
|
|
717
|
+
code: "pm_open_blocked",
|
|
718
|
+
reason: `open-time quality gate would reject this (422): ${JSON.stringify(reasons)}`,
|
|
719
|
+
quote,
|
|
720
|
+
});
|
|
721
|
+
log(`skip pm_open early: openBlocked (${reasons.join(",") || "quality gate"})`);
|
|
722
|
+
continue;
|
|
723
|
+
}
|
|
440
724
|
// Auto-clamp a missing/wrong-side futures take-profit to a valid R:R target
|
|
441
725
|
// off the stop, so the open isn't silently rejected server-side (the runner
|
|
442
726
|
// owns trigger orientation; weak models routinely mis-sign it).
|
|
@@ -501,7 +785,7 @@ export async function runCycle(deps) {
|
|
|
501
785
|
// than a secondary trade — see rationaleForAction). Sanitized short reasoning
|
|
502
786
|
// only — never raw chain-of-thought.
|
|
503
787
|
rationaleForAction(action, decision.rationale, meta.rationaleSummary, decision.actions.length));
|
|
504
|
-
const r = await executeAction(client, action, observation, trace, idem);
|
|
788
|
+
const r = await executeAction(client, action, observation, trace, idem, provenance);
|
|
505
789
|
planned.push({
|
|
506
790
|
action,
|
|
507
791
|
accepted: true,
|
|
@@ -525,6 +809,25 @@ export async function runCycle(deps) {
|
|
|
525
809
|
}
|
|
526
810
|
else {
|
|
527
811
|
anyExecFailed = true;
|
|
812
|
+
// Quote-expiry capture: a validated pm_open the SERVER rejected at act time
|
|
813
|
+
// with a 422 mock_entry_blocked — the eligibility/quality/pricing state moved
|
|
814
|
+
// between the quote we validated and the act, so the quote expired. Report it
|
|
815
|
+
// (once-per-cycle; carries the universe breadth in the cohort field).
|
|
816
|
+
if (action.type === "pm_open" && isQuoteExpiredResult(r.status, r.data)) {
|
|
817
|
+
const pm = action;
|
|
818
|
+
await postOpportunity({
|
|
819
|
+
kind: "quote_expired",
|
|
820
|
+
source: pm.source,
|
|
821
|
+
slug: pm.slug,
|
|
822
|
+
outcomeExternalMarketId: pm.outcomeExternalMarketId,
|
|
823
|
+
universeSize: observation.pmMarkets.length,
|
|
824
|
+
forecastProbability: forecastEnabled
|
|
825
|
+
? sanitizeForecastProbability(pm.forecastProbability)
|
|
826
|
+
: undefined,
|
|
827
|
+
marketProbability: marketPct(findPmMarket(observation.pmMarkets, pm)?.probability),
|
|
828
|
+
reasonCode: blockReasonsOf(r.data),
|
|
829
|
+
});
|
|
830
|
+
}
|
|
528
831
|
}
|
|
529
832
|
log(`${r.ok ? "executed" : "FAILED"} ${action.type} (HTTP ${r.status})`);
|
|
530
833
|
}
|
|
@@ -564,6 +867,7 @@ export async function runCycle(deps) {
|
|
|
564
867
|
decisionType: "act",
|
|
565
868
|
writeAttempted: decision.actions.length,
|
|
566
869
|
writeAccepted: planned.filter((p) => p.accepted).length,
|
|
870
|
+
...(postedOpportunity ? { opportunity: postedOpportunity } : {}),
|
|
567
871
|
};
|
|
568
872
|
}
|
|
569
873
|
export async function runLoop(deps, opts = {}) {
|
package/dist/agent/scorecard.js
CHANGED
|
@@ -48,7 +48,9 @@ function normalCdf(z) {
|
|
|
48
48
|
const p = d *
|
|
49
49
|
t *
|
|
50
50
|
(0.31938153 +
|
|
51
|
-
t *
|
|
51
|
+
t *
|
|
52
|
+
(-0.356563782 +
|
|
53
|
+
t * (1.781477937 + t * (-1.821255978 + t * 1.330274429))));
|
|
52
54
|
return z >= 0 ? 1 - p : p;
|
|
53
55
|
}
|
|
54
56
|
// Max peak-to-trough drawdown (mUSD, >= 0) on a cumulative series.
|
|
@@ -107,7 +109,7 @@ function ece(preds) {
|
|
|
107
109
|
for (let b = 0; b < buckets; b += 1) {
|
|
108
110
|
const lo = b / buckets;
|
|
109
111
|
const hi = (b + 1) / buckets;
|
|
110
|
-
const inB = preds.filter((x) =>
|
|
112
|
+
const inB = preds.filter((x) => b === buckets - 1 ? x.p >= lo && x.p <= hi : x.p >= lo && x.p < hi);
|
|
111
113
|
if (inB.length === 0)
|
|
112
114
|
continue;
|
|
113
115
|
const avgP = mean(inB.map((x) => x.p));
|
|
@@ -127,7 +129,9 @@ export function computeScorecard(input) {
|
|
|
127
129
|
const avgWin = wins.length ? grossWin / wins.length : 0;
|
|
128
130
|
const avgLoss = losses.length ? grossLoss / losses.length : 0;
|
|
129
131
|
const pWin = decided ? wins.length / decided : null;
|
|
130
|
-
const rs = input.returns && input.returns.length
|
|
132
|
+
const rs = input.returns && input.returns.length
|
|
133
|
+
? input.returns.filter(Number.isFinite)
|
|
134
|
+
: pnls;
|
|
131
135
|
const returnsBasis = input.returns && input.returns.length ? "returns" : "realized_pnl";
|
|
132
136
|
const ann = input.annualizationFactor ?? 1;
|
|
133
137
|
const cumulative = input.cumulative && input.cumulative.length
|
|
@@ -156,7 +160,11 @@ export function computeScorecard(input) {
|
|
|
156
160
|
calibration_error: round0(ece(input.predictions ?? [])),
|
|
157
161
|
stop_coverage: input.gates?.stopCoverage ?? null,
|
|
158
162
|
evidence_coverage: input.gates?.evidenceCoverage ?? null,
|
|
159
|
-
leakage_clean: input.gates?.leakageClean == null
|
|
163
|
+
leakage_clean: input.gates?.leakageClean == null
|
|
164
|
+
? null
|
|
165
|
+
: input.gates.leakageClean
|
|
166
|
+
? 1
|
|
167
|
+
: 0,
|
|
160
168
|
};
|
|
161
169
|
// Canonicalize (sorted keys) and hash, so the report card carries a stable,
|
|
162
170
|
// verifiable fingerprint — a scorecard whose hash does not reproduce is not trusted.
|
package/dist/agent/setups.js
CHANGED
|
@@ -62,31 +62,77 @@ function classify(w, openPositions) {
|
|
|
62
62
|
const out = [];
|
|
63
63
|
// Primary trend-following / breakout read.
|
|
64
64
|
if (ind.brokeRecentHigh === true) {
|
|
65
|
-
out.push({
|
|
65
|
+
out.push({
|
|
66
|
+
symbol: w.symbol,
|
|
67
|
+
kind: "breakout",
|
|
68
|
+
bias: "long",
|
|
69
|
+
strength: 0.8,
|
|
70
|
+
note,
|
|
71
|
+
});
|
|
66
72
|
}
|
|
67
73
|
else if (ind.brokeRecentLow === true) {
|
|
68
|
-
out.push({
|
|
74
|
+
out.push({
|
|
75
|
+
symbol: w.symbol,
|
|
76
|
+
kind: "breakdown",
|
|
77
|
+
bias: "short",
|
|
78
|
+
strength: 0.8,
|
|
79
|
+
note,
|
|
80
|
+
});
|
|
69
81
|
}
|
|
70
82
|
else if (up && ch >= LEAN_MOVE_PCT) {
|
|
71
|
-
out.push({
|
|
83
|
+
out.push({
|
|
84
|
+
symbol: w.symbol,
|
|
85
|
+
kind: "uptrend",
|
|
86
|
+
bias: "long",
|
|
87
|
+
strength: ch >= STRONG_MOVE_PCT ? 0.75 : 0.6,
|
|
88
|
+
note,
|
|
89
|
+
});
|
|
72
90
|
}
|
|
73
91
|
else if (down && ch <= -LEAN_MOVE_PCT) {
|
|
74
|
-
out.push({
|
|
92
|
+
out.push({
|
|
93
|
+
symbol: w.symbol,
|
|
94
|
+
kind: "downtrend",
|
|
95
|
+
bias: "short",
|
|
96
|
+
strength: ch <= -STRONG_MOVE_PCT ? 0.75 : 0.6,
|
|
97
|
+
note,
|
|
98
|
+
});
|
|
75
99
|
}
|
|
76
100
|
else if (overbought) {
|
|
77
|
-
out.push({
|
|
101
|
+
out.push({
|
|
102
|
+
symbol: w.symbol,
|
|
103
|
+
kind: "stretched",
|
|
104
|
+
bias: "fade-short",
|
|
105
|
+
strength: 0.55,
|
|
106
|
+
note,
|
|
107
|
+
});
|
|
78
108
|
}
|
|
79
109
|
else if (oversold) {
|
|
80
|
-
out.push({
|
|
110
|
+
out.push({
|
|
111
|
+
symbol: w.symbol,
|
|
112
|
+
kind: "stretched",
|
|
113
|
+
bias: "fade-long",
|
|
114
|
+
strength: 0.55,
|
|
115
|
+
note,
|
|
116
|
+
});
|
|
81
117
|
}
|
|
82
118
|
else if (Math.abs(ch) >= STRONG_MOVE_PCT) {
|
|
83
119
|
// A strong move with no clean EMA stack — still tradeable momentum.
|
|
84
|
-
out.push({
|
|
120
|
+
out.push({
|
|
121
|
+
symbol: w.symbol,
|
|
122
|
+
kind: ch > 0 ? "uptrend" : "downtrend",
|
|
123
|
+
bias: ch > 0 ? "long" : "short",
|
|
124
|
+
strength: 0.55,
|
|
125
|
+
note,
|
|
126
|
+
});
|
|
85
127
|
}
|
|
86
128
|
// Secondary COUNTER-TREND fade: a standing trend that is ALSO RSI-extreme is a
|
|
87
129
|
// mean-reversion candidate. Only add it when the primary was the trend itself
|
|
88
130
|
// (so we don't double-list a pure stretched read).
|
|
89
|
-
const primaryIsTrend = out[0] &&
|
|
131
|
+
const primaryIsTrend = out[0] &&
|
|
132
|
+
(out[0].kind === "uptrend" ||
|
|
133
|
+
out[0].kind === "downtrend" ||
|
|
134
|
+
out[0].kind === "breakout" ||
|
|
135
|
+
out[0].kind === "breakdown");
|
|
90
136
|
if (primaryIsTrend && (oversold || overbought)) {
|
|
91
137
|
out.push({
|
|
92
138
|
symbol: w.symbol,
|
|
@@ -103,7 +149,9 @@ function classify(w, openPositions) {
|
|
|
103
149
|
// exceeds_cap churn). A winner with room is the one case a same-side "open" is OK
|
|
104
150
|
// (scaling in); otherwise it's manage-only.
|
|
105
151
|
const wb = baseSymbol(w.symbol);
|
|
106
|
-
const pos = wb
|
|
152
|
+
const pos = wb
|
|
153
|
+
? openPositions.find((p) => baseSymbol(p.symbol) === wb)
|
|
154
|
+
: undefined;
|
|
107
155
|
const held = pos && (pos.side === "long" || pos.side === "short") ? pos.side : undefined;
|
|
108
156
|
if (held) {
|
|
109
157
|
const u = pos?.unrealizedPnlMusd;
|
package/dist/agent/skill.js
CHANGED
|
@@ -12,7 +12,7 @@ import { checkCapabilityDrift } from "./capabilityGuard.js";
|
|
|
12
12
|
// guardrails. "Unlimited" is normalised to a large finite value so cap-merge arithmetic
|
|
13
13
|
// (most-restrictive-wins) and JSON serialisation stay simple.
|
|
14
14
|
export const UNLIMITED_TRADES_PER_DAY = 1_000_000;
|
|
15
|
-
const normalizeTradeCap = (v) =>
|
|
15
|
+
const normalizeTradeCap = (v) => v <= 0 ? UNLIMITED_TRADES_PER_DAY : v;
|
|
16
16
|
const DEFAULT_LIMITS = {
|
|
17
17
|
maxTradesPerDay: UNLIMITED_TRADES_PER_DAY,
|
|
18
18
|
maxWritesPerCycle: 2,
|