dexterm-cli 0.2.1 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/dist/packages/autotrade/src/chat/connector.js +5 -4
  2. package/dist/packages/autotrade/src/chat/control.js +3 -0
  3. package/dist/packages/autotrade/src/chat/hub.js +37 -2
  4. package/dist/packages/autotrade/src/chat/index.js +3 -2
  5. package/dist/packages/autotrade/src/chat/readonly.js +530 -0
  6. package/dist/packages/autotrade/src/chat/service.js +12 -1
  7. package/dist/packages/autotrade/src/engine.js +75 -29
  8. package/dist/packages/autotrade/src/index.js +2 -0
  9. package/dist/packages/autotrade/src/runner.js +72 -14
  10. package/dist/packages/autotrade/src/store.js +1 -1
  11. package/dist/packages/autotrade/src/strategy.js +17 -0
  12. package/dist/packages/autotrade/src/tiers.js +194 -0
  13. package/dist/packages/classifier/src/config.js +90 -12
  14. package/dist/packages/classifier/src/index.js +1 -0
  15. package/dist/packages/classifier/src/win-acl.js +71 -0
  16. package/dist/packages/cli/src/app-help-gen.js +1 -1
  17. package/dist/packages/cli/src/autotrade-cmd/fleet.js +4 -2
  18. package/dist/packages/cli/src/autotrade-cmd/run.js +4 -2
  19. package/dist/packages/cli/src/autotrade-cmd/service.js +3 -1
  20. package/dist/packages/cli/src/autotrade-cmd/strategies.js +1 -1
  21. package/dist/packages/cli/src/autotrade-cmd/telegram-host.js +2 -0
  22. package/dist/packages/cli/src/classifier-cmd.js +16 -8
  23. package/dist/packages/cli/src/skill-eval-build.js +7 -2
  24. package/dist/packages/cli/src/version.js +1 -1
  25. package/dist/packages/mcp/assets/kb/app-help/autotrade-telegram.md +1 -1
  26. package/dist/packages/mcp/assets/kb/docs/docs-autotrade-confidence-tiers-experimental-unproven-paper-only.md +56 -0
  27. package/dist/packages/mcp/assets/kb/docs/docs-classifier-hosted-jev-with-your-own-key.md +3 -1
  28. package/dist/packages/mcp/assets/kb/docs/docs-getting-started-install.md +3 -3
  29. package/dist/packages/mcp/assets/kb/docs/docs-getting-started-requirements.md +1 -1
  30. package/dist/packages/mcp/assets/kb/docs/docs-narratives-where-to-find-it.md +1 -1
  31. package/dist/packages/mcp/assets/kb/docs/docs-telegram-read-only-data-check-top-and-signals-experimental.md +48 -0
  32. package/dist/packages/mcp/assets/kb/docs/docs-telegram-what-it-is.md +2 -0
  33. package/dist/packages/mcp/assets/kb/docs/docs-telegram-what-it-will-not-do.md +1 -1
  34. package/dist/packages/mcp/src/server.js +2 -1
  35. package/dist/packages/packs/src/skill-care.js +10 -2
  36. package/package.json +1 -1
@@ -12,6 +12,7 @@
12
12
  // three independent fences, never the only one.
13
13
  import { parseFilter } from "../../cli/src/screener/filter.js";
14
14
  import { tickRow } from "./replay.js";
15
+ import { TIERS_HARD_CAP_PCT, selectConfidenceTier, tierTable } from "./tiers.js";
15
16
  export const DAY_MS = 86_400_000;
16
17
  const r2 = (x) => Math.round(x * 100) / 100;
17
18
  const r6 = (x) => Number(x.toPrecision(6));
@@ -297,7 +298,7 @@ export const dayOf = (t) => Math.floor(t / DAY_MS);
297
298
  * cooldowns, counts), then the size the strategy itself picks, then every ceiling, each reduction recorded.
298
299
  * An agent request can only LOWER the size below the strategy's own pick, never raise it above a ceiling.
299
300
  */
300
- export function clampEntry(book, k, req, killSwitch = false) {
301
+ export function clampEntry(book, k, req, killSwitch = false, nowMs = k.t, inFlight) {
301
302
  const s = book.strategy;
302
303
  const st = book.state;
303
304
  const clamps = [];
@@ -306,6 +307,11 @@ export function clampEntry(book, k, req, killSwitch = false) {
306
307
  // fills become trade-fire signals for the user's own executor (the runner admits it only through the real-mode hook).
307
308
  if (s.mode !== 'paper' && s.mode !== 'real')
308
309
  return no('mode-not-paper');
310
+ // 0.2.2: EXPERIMENTAL confidence tiers are PAPER ONLY. validateStrategy and the runner refuse them too; this is the
311
+ // engine's own fence for any document that reached it another way.
312
+ const tiersOn = s.sizing.tiers ?? null;
313
+ if (tiersOn && s.mode !== 'paper')
314
+ return no('tiers-paper-only');
309
315
  if (killSwitch)
310
316
  return no('kill-switch');
311
317
  if (st.halted)
@@ -339,34 +345,57 @@ export function clampEntry(book, k, req, killSwitch = false) {
339
345
  }
340
346
  if ((k.liq ?? 0) < profile.fit.minLiquidityUsd)
341
347
  return no('profile-min-liquidity');
348
+ // 0.2.2 confidence tiers: score the tick (fresh at nowMs), pick the tier; T0 is a skip; one open position unless the
349
+ // block says not. With tiers, an entry decided but not filled yet (the latency window, an open approval) counts as
350
+ // open for one-open and maxConcurrent, and its dollars come off the exposure and cash room (task-2156).
351
+ let tier;
352
+ const fly = tiersOn ? inFlightOf(inFlight) : { opens: 0, usd: 0 };
353
+ if (tiersOn) {
354
+ if (tiersOn.oneOpen !== false && book.open.size + fly.opens >= 1)
355
+ return no('tier-one-open');
356
+ if (book.open.size - bags + fly.opens >= s.risk.maxConcurrent)
357
+ return no('max-concurrent');
358
+ tier = selectConfidenceTier(k, tierTable(tiersOn.table), nowMs);
359
+ if (tier.tier === 'T0')
360
+ return { ok: false, refused: 'tier-T0', clamps, tier };
361
+ }
342
362
  const bank = book.bookEquity();
343
363
  const z = s.sizing;
344
364
  let usd;
345
365
  let basis;
346
- switch (z.mode) {
347
- case 'fixed':
348
- usd = z.fixedUsd ?? 0;
349
- basis = `fixed $${z.fixedUsd}`;
350
- break;
351
- case 'pct':
352
- usd = (bank * (z.pct ?? 0)) / 100;
353
- basis = `${z.pct}% of $${r2(bank)}`;
354
- break;
355
- case 'risk': {
356
- const stop = profile.hardStop.enabled && profile.hardStop.atPct !== null ? Math.abs(profile.hardStop.atPct) : s.risk.perTrade.assumedLossPctNoStop;
357
- usd = (bank * (z.riskPct ?? 0)) / stop;
358
- basis = `${z.riskPct}% risk / ${stop}% stop of $${r2(bank)}`;
359
- break;
360
- }
361
- case 'confidence': {
362
- const c = z.confidence ?? { minPct: 1, maxPct: 1, default: 0.5 };
363
- const conf = clamp01(req.confidence ?? c.default);
364
- const pct = c.minPct + (c.maxPct - c.minPct) * conf;
365
- usd = (bank * pct) / 100;
366
- basis = `confidence ${r2(conf)} → ${r2(pct)}% of $${r2(bank)}`;
367
- break;
368
- }
366
+ // With tiers on, the tier's runs-at % is the strategy's pick (an unproven tier above the cap is recorded as clamped
367
+ // from its configured size); every ceiling below still applies.
368
+ if (tier) {
369
+ usd = (bank * tier.effectivePct) / 100;
370
+ basis = `tier ${tier.tier} (score ${tier.score}/${tier.of}) ${tier.effectivePct}% of $${r2(bank)}`;
371
+ if (tier.capped)
372
+ clamps.push({ rule: 'tier-unproven-cap', fromUsd: r2((bank * tier.configuredPct) / 100), toUsd: r2(usd) });
369
373
  }
374
+ else
375
+ switch (z.mode) {
376
+ case 'fixed':
377
+ usd = z.fixedUsd ?? 0;
378
+ basis = `fixed $${z.fixedUsd}`;
379
+ break;
380
+ case 'pct':
381
+ usd = (bank * (z.pct ?? 0)) / 100;
382
+ basis = `${z.pct}% of $${r2(bank)}`;
383
+ break;
384
+ case 'risk': {
385
+ const stop = profile.hardStop.enabled && profile.hardStop.atPct !== null ? Math.abs(profile.hardStop.atPct) : s.risk.perTrade.assumedLossPctNoStop;
386
+ usd = (bank * (z.riskPct ?? 0)) / stop;
387
+ basis = `${z.riskPct}% risk / ${stop}% stop of $${r2(bank)}`;
388
+ break;
389
+ }
390
+ case 'confidence': {
391
+ const c = z.confidence ?? { minPct: 1, maxPct: 1, default: 0.5 };
392
+ const conf = clamp01(req.confidence ?? c.default);
393
+ const pct = c.minPct + (c.maxPct - c.minPct) * conf;
394
+ usd = (bank * pct) / 100;
395
+ basis = `confidence ${r2(conf)} → ${r2(pct)}% of $${r2(bank)}`;
396
+ break;
397
+ }
398
+ }
370
399
  // The agent may ask for LESS than the strategy's pick (never more).
371
400
  const asked = req.sizeUsd ?? (req.sizePct !== undefined ? (bank * req.sizePct) / 100 : undefined);
372
401
  if (asked !== undefined && Number.isFinite(asked)) {
@@ -381,6 +410,12 @@ export function clampEntry(book, k, req, killSwitch = false) {
381
410
  clamps.push({ rule, fromUsd: r2(usd), toUsd: r2(Math.max(0, limit)) });
382
411
  usd = Math.max(0, limit);
383
412
  } };
413
+ // The engine's own ceiling for a tiered entry, whatever the tier pick said: code, not the table or the strategy file.
414
+ if (tier) {
415
+ if (!Number.isFinite(usd))
416
+ usd = 0;
417
+ cap('tier-hard-cap', tierHardCapUsd(bank));
418
+ }
384
419
  if (st.sizeDown)
385
420
  cap('drawdown-size-down', usd * s.risk.drawdown.sizeDownMult);
386
421
  cap('strategy-max-pct', (bank * s.sizing.maxPctOfBankroll) / 100);
@@ -388,14 +423,25 @@ export function clampEntry(book, k, req, killSwitch = false) {
388
423
  cap('strategy-max-usd', s.sizing.maxUsd);
389
424
  const lossPct = profile.hardStop.enabled && profile.hardStop.atPct !== null ? Math.abs(profile.hardStop.atPct) : s.risk.perTrade.assumedLossPctNoStop;
390
425
  cap('per-trade-max-loss', (bank * s.risk.perTrade.maxLossPctOfBankroll) / lossPct);
391
- cap('max-exposure', (bank * s.risk.maxExposurePct) / 100 - book.exposureUsd());
392
- cap('cash', st.cashUsd);
426
+ cap('max-exposure', (bank * s.risk.maxExposurePct) / 100 - book.exposureUsd() - fly.usd);
427
+ cap('cash', st.cashUsd - fly.usd);
393
428
  const R = (k.liq ?? 0) / 2;
394
429
  const imp = s.execution.maxPriceImpactPct / 100;
395
430
  cap('price-impact', (R * imp) / (1 - s.execution.feeBps / 1e4) + s.execution.networkFeeUsd);
396
- if (usd < s.sizing.minUsd)
397
- return no(clamps.length ? `below-min-after-${clamps[clamps.length - 1].rule}` : 'below-min-size');
431
+ if (usd < s.sizing.minUsd) {
432
+ const r = no(clamps.length ? `below-min-after-${clamps[clamps.length - 1].rule}` : 'below-min-size');
433
+ return tier ? { ...r, tier } : r;
434
+ }
398
435
  const first = z.ladder ? usd * z.ladder[0].frac : usd;
399
- return { ok: true, usd: r2(usd), firstTrancheUsd: r2(first), profile, clamps, basis };
436
+ return { ok: true, usd: r2(usd), firstTrancheUsd: r2(first), profile, clamps, basis, ...(tier ? { tier } : {}) };
437
+ }
438
+ /** In-flight entries, failing closed: a negative or non-finite count is one open entry, such dollars are the whole room. */
439
+ const inFlightOf = (f) => ({
440
+ opens: f === undefined ? 0 : Number.isFinite(f.opens) && f.opens >= 0 ? f.opens : 1,
441
+ usd: f === undefined ? 0 : Number.isFinite(f.usd) && f.usd >= 0 ? f.usd : Infinity,
442
+ });
443
+ /** The most a tiered position may cost in total (every tranche and add): TIERS_HARD_CAP_PCT of the book. */
444
+ export function tierHardCapUsd(bookUsd) {
445
+ return Number.isFinite(bookUsd) && bookUsd > 0 ? (bookUsd * TIERS_HARD_CAP_PCT) / 100 : 0;
400
446
  }
401
447
  const clamp01 = (x) => (Number.isFinite(x) ? Math.min(1, Math.max(0, x)) : 0.5);
@@ -22,6 +22,7 @@
22
22
  // execution-port where an approved entry goes: the paper port (local single-use proofs) and the live stub (task-1527)
23
23
  // chat Telegram notify, control and approve-to-trade through the user's own bot; chat/ (task-1527)
24
24
  // yolo task-1532: the full-port replay (rugs, loss spread, how often it zeroed the wallet); paper only
25
+ // tiers 0.2.2: EXPERIMENTAL, UNPROVEN confidence-tiered sizing (sizing.tiers), PAPER ONLY, default off
25
26
  // signal / outbox / signal-channels / receipts / real-mode task-1645: mode real = the paper engine + a LIVE TRADE FIRE
26
27
  // signal per fill to the user's own executor (webhook, command hook, JSONL, MCP feed, Telegram copy); no key
27
28
  // limits / executor-health / report task-1654: every real-mode limit user-set per scope (stricter wins), executor
@@ -57,6 +58,7 @@ export * from "./chat/index.js";
57
58
  export * from "./yolo.js";
58
59
  export * from "./rugproof.js";
59
60
  export * from "./rugproof-probe.js";
61
+ export * from "./tiers.js";
60
62
  export * from "./signal.js";
61
63
  export * from "./outbox.js";
62
64
  export * from "./signal-channels.js";
@@ -21,12 +21,13 @@ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, write
21
21
  import { uptime } from 'node:os';
22
22
  import { join } from 'node:path';
23
23
  import { Ledger, writeJsonAtomic } from "./ledger.js";
24
- import { EntryRules, SimBook, clampEntry, simBuy, simSell } from "./engine.js";
24
+ import { EntryRules, SimBook, clampEntry, simBuy, tierHardCapUsd, simSell } from "./engine.js";
25
25
  import { monitorTick } from "./monitor.js";
26
26
  import { buildDelta, renderDelta, renderEvent, urgent, view } from "./session.js";
27
27
  import { SessionGate, renderDossierRecord, renderPositionDossier, tapeDossierRecord } from "./sessions.js";
28
28
  import { DEFAULT_RUNNER_CONFIG, effectiveCaps } from "./runner-config.js";
29
29
  import { DEFAULT_PRICE_BASIS, fillMcaps, levelText } from "./basis-text.js";
30
+ import { confidenceTierLine, tierRecord } from "./tiers.js";
30
31
  export { Ledger } from "./ledger.js";
31
32
  export function simExecutor(x) {
32
33
  return { name: 'sim', buy: (k, usd) => simBuy(k, usd, x), sell: (k, tokens, reason) => simSell(k, tokens, x, reason) };
@@ -40,6 +41,10 @@ async function* fromArray(ticks) { for (const k of ticks)
40
41
  yield k; }
41
42
  export async function runAutotrade(o) {
42
43
  const s = o.strategy;
44
+ // 0.2.2: EXPERIMENTAL confidence tiers run on paper only: never in a real-signal run, never in any mode but paper
45
+ // (validateStrategy refuses the block outside paper and the engine refuses it too; this is the runner's own gate).
46
+ if (s.sizing.tiers && !(!o.real && s.mode === 'paper'))
47
+ throw new Error(`strategy ${s.id}: confidence tiers (sizing.tiers) are EXPERIMENTAL and PAPER ONLY: refused ${o.real ? 'in a real-signal run' : `in mode "${s.mode}"`} (remove sizing.tiers to run it there)`);
43
48
  // task-1571: autotrade runs on paper only. dexterm holds no key and places no live order; live trading happens in the
44
49
  // Telegram trading bot of the user's choice.
45
50
  // task-1645: mode "real" = the same paper engine, plus a LIVE TRADE FIRE signal per fill for the user's own executor;
@@ -78,6 +83,8 @@ export async function runAutotrade(o) {
78
83
  const skippedUntil = new Map();
79
84
  /** Coins the vet passed this run (a refused one goes to skippedUntil for good). */
80
85
  const vetted = new Set();
86
+ /** 0.2.2 confidence tiers: coins whose T0 (skip) line was already written at the pre-check. */
87
+ const tierSkipSaid = new Set();
81
88
  const orders = [];
82
89
  const refusals = {};
83
90
  const clampCount = {};
@@ -97,6 +104,24 @@ export async function runAutotrade(o) {
97
104
  const pumps = new Set();
98
105
  /** Proposals waiting for an answer, by mint (task-1527). */
99
106
  const approvalsOpen = new Map();
107
+ /** task-2156: the first-tranche dollars of each open approval (an in-flight entry for the tier clamp). */
108
+ const approvalUsd = new Map();
109
+ /** task-2156: entries decided but not filled (pending open orders + open approvals), other than `except`'s own. */
110
+ const inFlight = (except) => {
111
+ let opens = 0;
112
+ let usd = 0;
113
+ for (const q of orders)
114
+ if (q.kind === 'open' && q.mint !== except) {
115
+ opens++;
116
+ usd += q.usd;
117
+ }
118
+ for (const m of approvalsOpen.keys())
119
+ if (m !== except) {
120
+ opens++;
121
+ usd += approvalUsd.get(m) ?? Infinity;
122
+ }
123
+ return { opens, usd };
124
+ };
100
125
  ledger.add(t0, 'run_start', { strategy: s.id, version: s.version, strategyHash: s.hash, mode: s.mode, ...(o.real ? { tag: 'real-signal', accountId: o.real.accountId } : {}), ...(s.highRisk ? { highRisk: true } : {}), tape: o.tape?.source ?? o.source.name, ticks: tapeTicks?.length ?? null, decide: decideAuto ? 'auto' : s.entry.decide, ...(o.approvals ? { approvals: o.approvals.required(s) ? 'required' : 'when-asked' } : {}), pacing: pacing.mode, executor: exec.name, caps: capsView(gate), ...(o.account ? { account: o.account.id } : {}) });
101
126
  // task-1645 r2: a real run takes over the real positions an earlier run of this strategy left open (held.json),
102
127
  // so their exits keep firing. A profile the strategy no longer has falls back to its default profile.
@@ -312,8 +337,10 @@ export async function runAutotrade(o) {
312
337
  return;
313
338
  }
314
339
  const req = { profile: d.profile, sizePct: d.sizePct, sizeUsd: d.sizeUsd, confidence: d.confidence };
315
- const r = capYoungPool(clampEntry(book, k, req, killed), c);
316
- ledger.add(now, 'clamp', { mint: c.mint, sessionId: c.session?.id ?? null, request: req, result: r.ok ? { ok: true, usd: r.usd, firstTrancheUsd: r.firstTrancheUsd, profile: r.profile.id, basis: r.basis, clamps: r.clamps } : r });
340
+ const r = capYoungPool(clampEntry(book, k, req, killed, now, inFlight(c.mint)), c);
341
+ ledger.add(now, 'clamp', { mint: c.mint, sessionId: c.session?.id ?? null, request: req, result: r.ok ? { ok: true, usd: r.usd, firstTrancheUsd: r.firstTrancheUsd, profile: r.profile.id, basis: r.basis, clamps: r.clamps } : r.tier ? { ok: false, refused: r.refused, clamps: r.clamps } : r, ...(r.tier ? { tier: tierRecord(r.tier) } : {}) });
342
+ if (r.tier)
343
+ say(confidenceTierLine(r.tier, k, r.ok ? r.usd : undefined));
317
344
  for (const cl of r.clamps)
318
345
  clampCount[cl.rule] = (clampCount[cl.rule] ?? 0) + 1;
319
346
  if (!r.ok) {
@@ -398,7 +425,7 @@ export async function runAutotrade(o) {
398
425
  const t = latest.get(c.mint);
399
426
  if (!t)
400
427
  return { ok: false, refused: 'no-tick' };
401
- const rc = clampEntry(book, t, { profile: prof.id, sizeUsd: usd }, killed);
428
+ const rc = clampEntry(book, t, { profile: prof.id, sizeUsd: usd }, killed, now, inFlight(c.mint));
402
429
  return rc.ok ? { ok: true, usd: rc.usd, clamps: rc.clamps.map((q) => q.rule) } : { ok: false, refused: rc.refused };
403
430
  },
404
431
  blocked: entryBlocked,
@@ -419,19 +446,32 @@ export async function runAutotrade(o) {
419
446
  await closeCandidate(c, 'refused:approval-dropped');
420
447
  return;
421
448
  }
422
- const usd = Math.min(out.usd, r.usd);
423
- const scale = r.usd > 0 ? usd / r.usd : 0;
424
449
  const t = latest.get(c.mint) ?? k;
450
+ let usd = Math.min(out.usd, r.usd);
451
+ // task-2156: a tiered entry re-clamps at the approval (one open, concurrency, room, a fresh tick), since other
452
+ // entries may have been decided or filled while it waited.
453
+ if (s.sizing.tiers) {
454
+ const rc = clampEntry(book, t, { profile: prof.id, sizeUsd: usd }, killed, now, inFlight(c.mint));
455
+ if (!rc.ok) {
456
+ refuse('approval-dropped', { mint: c.mint, proposalId: out.proposalId, why: rc.refused });
457
+ if (!c.closing)
458
+ await closeCandidate(c, 'refused:approval-dropped');
459
+ return;
460
+ }
461
+ usd = Math.min(usd, rc.usd);
462
+ }
463
+ const scale = r.usd > 0 ? usd / r.usd : 0;
425
464
  ledger.add(now, 'approval_granted', { mint: c.mint, proposalId: out.proposalId, via: out.via, usd, proofId: out.proofId ? `${out.proofId.slice(0, 6)}…` : null });
426
465
  say(`APPROVED ${k.symbol}: $${round2(usd)} via ${out.via}`);
427
466
  const ladder = (s.sizing.ladder ?? []).slice(1).map((tr) => ({ frac: tr.frac, atPct: tr.atPct, deadline: now + tr.withinMin * 60e3, usd: usd * tr.frac }));
428
467
  const first = round2(r.firstTrancheUsd * scale);
429
468
  orders.push({ mint: c.mint, fillAt: now + x.latencyMs, decidedPrice: t.price ?? 0, usd: first, firstUsd: first, profile: prof.id, sessionId: c.session?.id ?? null, trigger: c.matched, ladder, kind: 'open', ctx: { proofId: out.proofId, proposalId: out.proposalId, orderHash: out.orderHash } });
430
469
  };
470
+ approvalUsd.set(c.mint, r.firstTrancheUsd);
431
471
  const pending = o.approvals.request(prop, live)
432
472
  .catch((e) => ({ approved: false, state: 'ERROR', why: e instanceof Error ? e.message.slice(0, 120) : String(e) }))
433
473
  .then(settle)
434
- .finally(() => { approvalsOpen.delete(c.mint); });
474
+ .finally(() => { approvalsOpen.delete(c.mint); approvalUsd.delete(c.mint); });
435
475
  approvalsOpen.set(c.mint, pending);
436
476
  // Lockstep (backtests, tests) waits for the answer; every other pacing lets it land when it lands.
437
477
  if (pacing.mode === 'lockstep')
@@ -531,11 +571,16 @@ export async function runAutotrade(o) {
531
571
  // Re-check at the fill, like a swap's slippage tolerance: a pool that drained or a price that ran
532
572
  // away between the decision and the fill refuses the order instead of buying into it.
533
573
  const prof0 = s.exits.profiles[q.profile];
534
- const why = (k.liq ?? 0) < prof0.fit.minLiquidityUsd ? 'fill-liquidity-gone'
535
- : k.price && q.decidedPrice && k.price > q.decidedPrice * (1 + x.slippageBps / 1e4) ? 'fill-slippage'
536
- // task-1538: on a shared paper account another strategy may have spent the cash since this order was decided;
537
- // the cash is held before the (async) fill so two strategies can never spend the same dollars.
538
- : book.account && !book.account.hold(q.usd) ? 'fill-account-cash' : null;
574
+ // task-2156: a tiered open re-checks one-open and concurrency at the fill (another entry may have filled first).
575
+ const tierBusy = s.sizing.tiers && q.kind === 'open'
576
+ ? (s.sizing.tiers.oneOpen !== false && book.open.size >= 1 ? 'fill-tier-one-open' : book.open.size >= s.risk.maxConcurrent ? 'fill-max-concurrent' : null)
577
+ : null;
578
+ const why = tierBusy ? tierBusy
579
+ : (k.liq ?? 0) < prof0.fit.minLiquidityUsd ? 'fill-liquidity-gone'
580
+ : k.price && q.decidedPrice && k.price > q.decidedPrice * (1 + x.slippageBps / 1e4) ? 'fill-slippage'
581
+ // task-1538: on a shared paper account another strategy may have spent the cash since this order was decided;
582
+ // the cash is held before the (async) fill so two strategies can never spend the same dollars.
583
+ : book.account && !book.account.hold(q.usd) ? 'fill-account-cash' : null;
539
584
  let f;
540
585
  try {
541
586
  f = why ? { refused: why } : await exec.buy(k, q.usd, q.ctx);
@@ -763,7 +808,16 @@ export async function runAutotrade(o) {
763
808
  else if (a.kind === 'add') {
764
809
  if (killed || book.state.halted)
765
810
  continue;
766
- const room = Math.min(a.usd, book.state.cashUsd, (book.bookEquity() * s.risk.maxExposurePct) / 100 - book.exposureUsd());
811
+ let room = Math.min(a.usd, book.state.cashUsd, (book.bookEquity() * s.risk.maxExposurePct) / 100 - book.exposureUsd());
812
+ // 0.2.2 confidence tiers: the whole position (every tranche) stays within the engine's tier ceiling (fails closed).
813
+ if (s.sizing.tiers) {
814
+ const left = tierHardCapUsd(book.bookEquity()) - pos.costUsd;
815
+ if (!(left >= s.sizing.minUsd)) {
816
+ refuse('ladder-add-tier-hard-cap', { positionId: pos.id });
817
+ continue;
818
+ }
819
+ room = Number.isFinite(room) ? Math.min(room, left) : 0;
820
+ }
767
821
  if (room < s.sizing.minUsd) {
768
822
  refuse('ladder-add-no-room', { positionId: pos.id });
769
823
  continue;
@@ -807,11 +861,15 @@ export async function runAutotrade(o) {
807
861
  const sr = rules.screen(k);
808
862
  if (sr.pass) {
809
863
  // Cheap pre-check so a full book does not spawn sessions it could never act on.
810
- const pre = clampEntry(book, k, {}, killed);
864
+ const pre = clampEntry(book, k, {}, killed, now, inFlight(k.mint));
811
865
  const v = pre.ok && o.vet && !vetted.has(k.mint) ? await vetCoin(k, vetCtx(k, pre.usd, h)) : null;
812
866
  if (!pre.ok) {
813
867
  if (!['already-open'].includes(pre.refused))
814
868
  count(`precheck:${pre.refused}`);
869
+ if (pre.tier?.tier === 'T0' && !tierSkipSaid.has(k.mint)) {
870
+ tierSkipSaid.add(k.mint);
871
+ say(confidenceTierLine(pre.tier, k));
872
+ }
815
873
  }
816
874
  else if (v && !v.ok) {
817
875
  // task-1748: a time gate (pool age, the second liquidity read) looks again later; any other refusal is for good.
@@ -81,7 +81,7 @@ export class StrategyStore {
81
81
  return { id, source, ok: false, errors: r.errors, warnings: r.warnings };
82
82
  const s = r.strategy;
83
83
  return {
84
- id, source, ok: true, name: s.name, version: s.version, hash: s.hash, mode: s.mode, ...(s.highRisk ? { highRisk: true } : {}), profiles: Object.keys(s.exits.profiles), warnings: r.warnings,
84
+ id, source, ok: true, name: s.name, version: s.version, hash: s.hash, mode: s.mode, ...(s.highRisk ? { highRisk: true } : {}), ...(s.sizing.tiers ? { experimentalTiers: true } : {}), profiles: Object.keys(s.exits.profiles), warnings: r.warnings,
85
85
  ...(last[s.hash] ? { lastBacktest: last[s.hash] } : {}),
86
86
  };
87
87
  };
@@ -13,6 +13,7 @@ import { readFileSync, readdirSync } from 'node:fs';
13
13
  import { createHash } from 'node:crypto';
14
14
  import { validate } from '../../custom/src/index.js';
15
15
  import { parseFilter } from "../../cli/src/screener/filter.js";
16
+ import { TIER_TABLE_IDS, TIERS_VERDICT, tierTable, tierTableLines } from "./tiers.js";
16
17
  export const STRATEGY_SCHEMA = 'dexterm-strategy/1';
17
18
  export const EXIT_PROFILE_VERSION = '1.2.0';
18
19
  // ───────────────────────────── schema ─────────────────────────────
@@ -92,6 +93,7 @@ export const SCHEMAS = {
92
93
  confidence: obj({ minPct: num(0.1, 25), maxPct: num(0.1, 25), default: num(0, 1) }),
93
94
  ladder: { type: 'array', max: 4, items: obj({ frac: num(0.05, 1), atPct: num(-90, 1000), withinMin: num(1, 1440) }) },
94
95
  maxPctOfBankroll: num(0.1, 25), maxUsd: num(1, 1e6), minUsd: num(0.01, 1e6),
96
+ tiers: nullable(obj({ table: { type: 'string', enum: TIER_TABLE_IDS }, experimental: { type: 'string', enum: ['paper-only'] }, oneOpen: { type: 'boolean' } }, ['table', 'experimental'])),
95
97
  }, ['mode', 'maxPctOfBankroll', 'maxUsd', 'minUsd']),
96
98
  risk: obj({
97
99
  perTrade: obj({ maxLossPctOfBankroll: num(0.1, 25), assumedLossPctNoStop: num(10, 100) }),
@@ -209,6 +211,21 @@ export function validateStrategy(doc, library = builtinProfiles()) {
209
211
  errors.push({ path: '/risk/drawdown/recoverBelowPct', msg: 'recoverBelowPct must be below sizeDownAtPct' });
210
212
  if (s.execution.stuckWriteOffMin != null && s.mode !== 'paper')
211
213
  errors.push({ path: '/execution/stuckWriteOffMin', msg: `the stuck write-off is paper only: in mode ${s.mode} the bag is still in your wallet and keeps its exit signals (remove it or set it to null)` });
214
+ if (z.tiers) {
215
+ // 0.2.2: EXPERIMENTAL confidence tiers are PAPER ONLY (the runner and the engine refuse them outside paper too).
216
+ if (s.mode !== 'paper')
217
+ errors.push({ path: '/sizing/tiers', msg: `confidence tiers are EXPERIMENTAL and PAPER ONLY: refused in mode ${s.mode} (remove sizing.tiers or set it to null)` });
218
+ const tt = tierTable(z.tiers.table);
219
+ const rp = s.entry.safety.rugproof;
220
+ if (!rp || rp.minPoolAgeMin < tt.gates.minPoolAgeMin)
221
+ errors.push({ path: '/entry/safety/rugproof', msg: `confidence tiers need the rug-proof block with minPoolAgeMin ${tt.gates.minPoolAgeMin} or more (the pool age gate of table ${tt.id}; the tick alone cannot tell a pool's age)` });
222
+ warnings.push({ path: '/sizing/tiers', msg: TIERS_VERDICT });
223
+ for (const line of tierTableLines(tt).slice(1))
224
+ warnings.push({ path: '/sizing/tiers', msg: line });
225
+ warnings.push({ path: '/sizing/tiers', msg: `the tier's size replaces the size of sizing mode ${z.mode}; every limit still applies and the stricter wins (sizing.maxPctOfBankroll ${z.maxPctOfBankroll}%, maxUsd $${z.maxUsd}, the profile's max %, per-trade loss, exposure, cash, price impact, drawdown size-down)${z.tiers.oneOpen === false ? '' : '; one open position at a time'}; tier gates: liquidity >= $${tt.gates.minLiqUsd}, pool age >= ${tt.gates.minPoolAgeMin} min, not young and vertical` });
226
+ if (z.ladder)
227
+ warnings.push({ path: '/sizing/ladder', msg: 'with confidence tiers the ladder splits the tier size into tranches (the first fills at entry)' });
228
+ }
212
229
  if (s.agent.budgetUsdPerSession > s.agent.budgetUsdPerDay)
213
230
  errors.push({ path: '/agent/budgetUsdPerSession', msg: 'a session budget above the daily budget' });
214
231
  if (s.entry.ageHours.max !== null && s.entry.ageHours.max < s.entry.ageHours.min)
@@ -0,0 +1,194 @@
1
+ // dexterm autotrade — confidence-tiered sizing, EXPERIMENTAL, UNPROVEN and PAPER ONLY (0.2.2, from the rules v4 research).
2
+ //
3
+ // A strategy opts in with `sizing.tiers` (strategy.ts). The ENGINE (clampEntry) then scores every candidate tick on
4
+ // two measured points, maps the score to a tier and the tier to a size in % of the book. Nothing here reads the
5
+ // agent's own confidence: the research found no held-out signal in it. The rules, all enforced in the engine:
6
+ // - T0 (score 0, or a failed tier gate) is a skip: no entry.
7
+ // - No tier runs above 25% of the book (TIERS_HARD_CAP_PCT). The cap is code: the table's evidence or cap fields,
8
+ // the strategy file and the agent cannot lift it, and the engine applies it again on the dollar size and on every
9
+ // ladder add. No tier is proven, so a tier configured above 25% is recorded as capped.
10
+ // - The tick must be fresh and listed: a sweep row, a tick time that is not a number, or a tick older than 5 min at
11
+ // decision time (the bs5m window) is T0.
12
+ // - The tier's size is the strategy's pick; every existing ceiling (strategy and profile max %, max $, per-trade loss,
13
+ // exposure, cash, price impact, drawdown size-down, a young-pool cap) still applies, and the stricter one wins.
14
+ // - One open position at a time unless the block says otherwise.
15
+ // Refused outside paper: validateStrategy (mode real / live), the runner (a real-signal run) and the engine itself.
16
+ //
17
+ // The verdict, shown on every TIER line and in validation: unproven; on the held-out data no tier made money, and no
18
+ // dexterm strategy has shown an edge (strategy synthesis, 2026-11).
19
+ //
20
+ // Pure: no I/O.
21
+ export const TIER_TABLE_IDS = ['v4-1785'];
22
+ /** The one verdict line. Printed by validation and on every TIER line. */
23
+ export const TIERS_VERDICT = 'EXPERIMENTAL, UNPROVEN, PAPER ONLY: on our held-out data no tier made money and no dexterm strategy has shown an edge; every tier runs at 25% of the book at most.';
24
+ /** The engine's ceiling for any tier, in % of the book. Code, not data: nothing a strategy file or table says lifts it. */
25
+ export const TIERS_HARD_CAP_PCT = 25;
26
+ /** A tick older than this at decision time is T0: bs5m describes a 5 min window that has moved on. */
27
+ export const TIER_TICK_MAX_AGE_MS = 5 * 60_000;
28
+ const freeze = (x) => {
29
+ if (x && typeof x === 'object') {
30
+ for (const v of Object.values(x))
31
+ freeze(v);
32
+ Object.freeze(x);
33
+ }
34
+ return x;
35
+ };
36
+ /**
37
+ * The frozen v4 table (schema dexterm-challenge-tiers/1, version 4.0.0), as data. The points were fitted on TRAIN only;
38
+ * the held-out set was scored once. Changing a number here is a new table id, never an edit.
39
+ */
40
+ export const CONFIDENCE_TIERS_V4 = freeze({
41
+ id: 'v4-1785',
42
+ provenance: 'task-1785 rules v4 research (family strict): frozen 2026-10-28T03:17:49Z, held-out scored once 2026-10-28T03:18:28Z',
43
+ unprovenCapPct: 25,
44
+ reservePct: 2,
45
+ points: [
46
+ { id: 'turnover1h-max', signal: 'turnover1h', op: '<=', value: 9.878, what: '1 h volume / liquidity' },
47
+ { id: 'bs5m-min', signal: 'bs5m', op: '>=', value: 3.714, what: 'buys / sells, 5 min' },
48
+ ],
49
+ tiers: [
50
+ { id: 'T0', minScore: 0, sizePct: 0, evidence: 'skip', heldOut: { n: 19, meanPct: -17.0, loPct: -35.7, hiPct: 3.4, wins: 5, rugs: 2 } },
51
+ { id: 'T1', minScore: 1, sizePct: 15, evidence: 'n/a', heldOut: { n: 0, meanPct: null, loPct: null, hiPct: null, wins: null, rugs: null } },
52
+ { id: 'T2', minScore: 1, sizePct: 50, evidence: 'unproven', heldOut: { n: 0, meanPct: null, loPct: null, hiPct: null, wins: null, rugs: null } },
53
+ { id: 'T3', minScore: 1, sizePct: 75, evidence: 'unproven', heldOut: { n: 17, meanPct: -7.6, loPct: -31.8, hiPct: 16.8, wins: 5, rugs: 1 } },
54
+ { id: 'T4', minScore: 2, sizePct: 100, evidence: 'unproven', heldOut: { n: 0, meanPct: null, loPct: null, hiPct: null, wins: null, rugs: null } },
55
+ ],
56
+ gates: { minPoolAgeMin: 10, minLiqUsd: 30_000, youngVertical: { maxAgeHours: 2, maxH1Pct: 150 } },
57
+ });
58
+ export function tierTable(id) {
59
+ if (id === 'v4-1785')
60
+ return CONFIDENCE_TIERS_V4;
61
+ throw new Error(`unknown tier table ${String(id)}`);
62
+ }
63
+ const fin = (x) => (typeof x === 'number' && Number.isFinite(x) ? x : null);
64
+ /** Liquidity, volume and trade counts are never negative: a negative or non-finite figure is unknown. */
65
+ const nonneg = (x) => { const v = fin(x); return v !== null && v >= 0 ? v : null; };
66
+ /**
67
+ * The research's feature definitions, on one tick:
68
+ * turnover1h = vol1h / liquidity (unknown when liquidity is not > 0 or vol1h is unknown)
69
+ * bs5m = buys5m / sells5m; no sells with buys = 99 (all buys); no buys and no sells = 0
70
+ * One difference, on the safe side: the research tape counted a missing sells figure as 0 (so buys alone gave 99);
71
+ * here a missing buys or sells figure makes bs5m unknown, and an unknown signal never scores its point. A negative or
72
+ * non-finite figure (NaN, Infinity) is unknown too.
73
+ */
74
+ export function tierSignals(k) {
75
+ const liq = nonneg(k.liq);
76
+ const vol1h = nonneg(k.vol1h);
77
+ const b = nonneg(k.buys5m);
78
+ const s = nonneg(k.sells5m);
79
+ const turnover1h = liq !== null && liq > 0 && vol1h !== null ? vol1h / liq : null;
80
+ const bs5m = b === null || s === null ? null : s > 0 ? b / s : b > 0 ? 99 : 0;
81
+ return { liq, vol1h, turnover1h, buys5m: b, sells5m: s, bs5m };
82
+ }
83
+ /**
84
+ * The tier gates on the tick. Pool age: the tick carries the token's creation time only, and a pool is never older
85
+ * than its token, so a token younger than minPoolAgeMin (or of unknown age) fails here; the pair's own age is the
86
+ * rug-proof pool-age gate's job (validateStrategy requires it at minPoolAgeMin or more with tiers on).
87
+ * The tick itself: a sweep row (the coin had left the screen), a tick time that is not a number, a tick older than
88
+ * TIER_TICK_MAX_AGE_MS at `nowMs` or stamped after it fails. `nowMs` defaults to the tick's own time (a replay row).
89
+ */
90
+ export function tierGateFailures(k, g, nowMs = k.t) {
91
+ const out = [];
92
+ const t = fin(k.t);
93
+ const now = fin(nowMs);
94
+ if (k.kind !== 'live')
95
+ out.push(`tick not live (${String(k.kind)})`);
96
+ if (t === null || now === null)
97
+ out.push('tick time unknown');
98
+ else if (now - t > TIER_TICK_MAX_AGE_MS)
99
+ out.push(`tick stale (${Math.ceil((now - t) / 1000)} s old > ${TIER_TICK_MAX_AGE_MS / 1000} s)`);
100
+ else if (t > now)
101
+ out.push('tick time after the decision time');
102
+ const created = fin(k.createdAt) !== null && k.createdAt > 0 ? k.createdAt : null;
103
+ const ageMin = t !== null && created !== null ? (t - created) / 60e3 : null;
104
+ if (ageMin === null)
105
+ out.push(`pool age unknown (no token creation time) < ${g.minPoolAgeMin} min`);
106
+ else if (ageMin < g.minPoolAgeMin)
107
+ out.push(`pool age < ${g.minPoolAgeMin} min (token ${ageMin.toFixed(0)} min old)`);
108
+ const liq = fin(k.liq);
109
+ if (liq === null || liq < g.minLiqUsd)
110
+ out.push(`liquidity ${liq === null ? 'unknown' : `$${Math.round(liq)}`} < $${g.minLiqUsd}`);
111
+ // A young coin with no 1 h change figure cannot be cleared of "vertical": it fails (the research tape had the figure).
112
+ const h1 = fin(k.chg1h);
113
+ if (ageMin !== null && ageMin / 60 < g.youngVertical.maxAgeHours) {
114
+ if (h1 === null)
115
+ out.push(`young and h1 unknown (at ${(ageMin / 60).toFixed(1)} h)`);
116
+ else if (h1 >= g.youngVertical.maxH1Pct)
117
+ out.push(`young and vertical (h1 ${h1}% at ${(ageMin / 60).toFixed(1)} h)`);
118
+ }
119
+ return out;
120
+ }
121
+ /**
122
+ * The size a tier runs at: never above TIERS_HARD_CAP_PCT (or the table's own cap when lower), whatever its evidence
123
+ * says (0.2.2 has no proven tier and no path that lifts the cap), never above 100 - reservePct. A non-finite or
124
+ * negative configured size runs at 0.
125
+ */
126
+ export function tierRunsAtPct(t, table) {
127
+ const cfg = Number.isFinite(t.sizePct) && t.sizePct > 0 ? t.sizePct : 0;
128
+ const tableCap = Number.isFinite(table.unprovenCapPct) && table.unprovenCapPct >= 0 ? table.unprovenCapPct : 0;
129
+ const capPct = Math.min(TIERS_HARD_CAP_PCT, tableCap);
130
+ let pct = cfg;
131
+ let capped = false;
132
+ if (pct > capPct) {
133
+ pct = capPct;
134
+ capped = true;
135
+ }
136
+ if (pct > 100 - table.reservePct)
137
+ pct = 100 - table.reservePct;
138
+ return { pct, capped };
139
+ }
140
+ /** Score the tick (one point per rule that holds; an unknown signal never passes), then the highest tier met wins. */
141
+ export function selectConfidenceTier(k, table = CONFIDENCE_TIERS_V4, nowMs = k.t) {
142
+ const sig = tierSignals(k);
143
+ const points = table.points.map((p) => {
144
+ const actual = sig[p.signal];
145
+ const pass = actual !== null && (p.op === '>=' ? actual >= p.value : actual <= p.value);
146
+ return { id: p.id, signal: p.signal, op: p.op, value: p.value, actual: actual === null ? null : Math.round(actual * 1000) / 1000, pass };
147
+ });
148
+ const score = points.filter((p) => p.pass).length;
149
+ const gates = tierGateFailures(k, table.gates, nowMs);
150
+ let row = table.tiers[0];
151
+ if (!gates.length)
152
+ for (const t of table.tiers)
153
+ if (score >= t.minScore)
154
+ row = t;
155
+ const run = row.id === 'T0' ? { pct: 0, capped: false } : tierRunsAtPct(row, table);
156
+ return { table: table.id, tier: row.id, score, of: points.length, points, gates, configuredPct: row.sizePct, effectivePct: run.pct, evidence: row.evidence, capped: run.capped, heldOut: row.heldOut };
157
+ }
158
+ // ───────────────────────────── text ─────────────────────────────
159
+ const sgn = (x) => `${x > 0 ? '+' : ''}${x.toFixed(1)}`;
160
+ /**
161
+ * "unproven: held-out n 17, mean -7.6% per entry, lower bound -31.8% (upper +16.8%), rug 1/17". The text carries the
162
+ * mean and its interval, never a win count or rate (a small-sample win rate reads as a claim the data does not make).
163
+ */
164
+ export function tierEvidenceText(evidence, h) {
165
+ const head = evidence === 'n/a' ? 'n/a (at or under the 25% cap)' : evidence;
166
+ if (h.n === 0 || h.meanPct === null)
167
+ return `${head}: held-out n 0 (too small to say)`;
168
+ return `${head}: held-out n ${h.n}, mean ${h.meanPct.toFixed(1)}% per entry, lower bound ${h.loPct === null ? '?' : `${h.loPct.toFixed(1)}%`}${h.hiPct === null ? '' : ` (upper ${sgn(h.hiPct)}%)`}${h.rugs === null ? '' : `, rug ${h.rugs}/${h.n}`}`;
169
+ }
170
+ const short = (m) => (m.length > 10 ? `${m.slice(0, 4)}…${m.slice(-4)}` : m);
171
+ /** The run log's TIER line: tier, score, size, evidence status and the verdict. `afterUsd` = the size after every limit. */
172
+ export function confidenceTierLine(p, k, afterUsd) {
173
+ const head = `TIER ${p.tier} for ${k.symbol} (${short(k.mint)}): score ${p.score}/${p.of}`;
174
+ const ev = tierEvidenceText(p.evidence, p.heldOut);
175
+ if (p.tier === 'T0')
176
+ return `${head} · SKIP (size 0)${p.gates.length ? ` · gate: ${p.gates.join('; ')}` : ''} · ${ev} · ${TIERS_VERDICT}`;
177
+ const pts = p.points.filter((x) => x.pass).map((x) => x.id).join(', ') || 'none';
178
+ const size = p.capped ? `configured ${p.configuredPct}%, runs at ${p.effectivePct}% of the book (unproven: capped)` : `runs at ${p.effectivePct}% of the book`;
179
+ return `${head} (${pts}) · ${size}${afterUsd !== undefined ? ` · $${afterUsd} after the strategy's limits` : ''} · ${ev} · ${TIERS_VERDICT}`;
180
+ }
181
+ /** Validation / docs text: one line per tier, the verdict first. */
182
+ export function tierTableLines(table = CONFIDENCE_TIERS_V4) {
183
+ const rows = table.tiers.map((t, i) => {
184
+ const run = t.id === 'T0' ? { pct: 0, capped: false } : tierRunsAtPct(t, table);
185
+ const unreachable = i > 0 && table.tiers.slice(i + 1).some((u) => u.minScore === t.minScore);
186
+ const size = t.id === 'T0' ? 'skip (size 0)' : run.capped ? `configured ${t.sizePct}%, runs at ${run.pct}% (unproven: capped)` : `runs at ${run.pct}%`;
187
+ return `${t.id} score >= ${t.minScore}: ${size} · ${tierEvidenceText(t.evidence, t.heldOut)}${unreachable ? ` · never selected (a higher tier has the same score ${t.minScore})` : ''}`;
188
+ });
189
+ return [TIERS_VERDICT, ...rows, `table ${table.id}: ${table.provenance}`];
190
+ }
191
+ /** A compact record of the pick for the ledger. */
192
+ export function tierRecord(p) {
193
+ return { table: p.table, tier: p.tier, score: p.score, of: p.of, points: p.points.map((x) => ({ id: x.id, actual: x.actual, pass: x.pass })), gates: p.gates, configuredPct: p.configuredPct, runsAtPct: p.effectivePct, capped: p.capped, evidence: tierEvidenceText(p.evidence, p.heldOut) };
194
+ }