Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions docs/run-identity.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,33 @@ A second round on the same day found the binding still too loose, and the patter
must be a safe repo-relative path — a pointer that can escape its repository is not a pointer to
that repository's decision.

## Refresh 2026-07-28 — квантизация ушла на границу артефакта (волна C)

Ожидания в `test/golden/expected-traces.json` переморожены один раз, осознанно, вместе с
`ENGINE_VERSION` `0.0.0` → `0.1.0`. `traceFormatVersion` остался `1`: изменилась семантика
исполнения, а не форма трейса — ровно тот случай, который выше описан как работа `engineVersion`,
а не формата.

**Что изменилось.** `core/` звал `quantize` после каждой арифметической операции — 6–12 раз на
бар, каждый раз полным кругом `new Decimal(n).toDecimalPlaces(8).toFixed()` → строка → `Number`.
Детерминизм покупался на гранулярности бара, а наблюдаем он только на границе артефакта:
`canonicalJson` квантует каждое число в любом случае. Пербарная подрезка не давала артефакту
ничего, чего не даёт сериализация, — она лишь меняла последующую арифметику. Теперь симуляция
считает в полной точности `Decimal`, а 8 знаков появляются один раз, при записи.

**Чем оправдана переморозка.** Не тем, что тесты позеленели после `--force`. Сдвиг измерен
differential-харнессом на замороженных лентах (`backtester#181`), сверявшимся с состоянием ДО
всей серии перф-волн, и ДО этого бампа версии — чтобы отделить численный эффект от смены
`engineVersion` в самом трейсе. Результат по трём сценариям: **ноль структурных расхождений**,
максимальный относительный сдвиг **4.49e-10**, последовательность решений, состав ордеров и
сделок, индексы баров и метки времени — совпали. Двинулись только величины, в десятом значащем
разряде.

**Что это значит для потребителей.** Форма трейса прежняя, читатели не мигрируют. Но `traceRef`
каждого прогона изменился, поэтому любой закреплённый у потребителя ref надо переморозить в том же
шаге, что и подъём пина, — иначе расхождение всплывёт как «поломка» там, где его причина уже
объяснена здесь.

## Bumping `traceFormatVersion`

Bump it when, and only when, the trace shape changes. A bump is a migration event, not a
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@trdlabs/engine",
"version": "0.1.1",
"description": "One deterministic execution core (decision risk pending order fill portfolio canonical trace) shared by backtester and platform.",
"version": "0.2.0",
"description": "One deterministic execution core (decision \u2192 risk \u2192 pending order \u2192 fill \u2192 portfolio \u2192 canonical trace) shared by backtester and platform.",
"license": "Apache-2.0",
"repository": {
"type": "git",
Expand Down
15 changes: 7 additions & 8 deletions src/core/execution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ import type {
OpenFillCalc,
RealityModel,
} from '../contract/index.js';
import { quantize } from '../determinism/canonical-json.js';
import { assertRealityModelSupported } from '../reality/catalog.js';

const BPS_DENOM = 10_000;
Expand Down Expand Up @@ -83,11 +82,11 @@ export class ExecutionSimulator implements ExecutionPort {
const fp = this.fillPrice(isBuy, base);
const n = new Decimal(notional);
return {
fillPrice: quantize(fp.toNumber()),
baseOpen: quantize(base),
fillPrice: fp.toNumber(),
baseOpen: base,
slippageBps: this.slippageBps,
fee: quantize(this.fee(n).toNumber()),
size: quantize(n.div(fp).toNumber()),
fee: this.fee(n).toNumber(),
size: n.div(fp).toNumber(),
};
}

Expand All @@ -96,10 +95,10 @@ export class ExecutionSimulator implements ExecutionPort {
const isBuy = side === 'short';
const fp = this.fillPrice(isBuy, base);
return {
fillPrice: quantize(fp.toNumber()),
baseOpen: quantize(base),
fillPrice: fp.toNumber(),
baseOpen: base,
slippageBps: this.slippageBps,
fee: quantize(this.fee(fp.times(size)).toNumber()),
fee: this.fee(fp.times(size)).toNumber(),
};
}

Expand Down
57 changes: 27 additions & 30 deletions src/core/portfolio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@

import { Decimal } from 'decimal.js';

import { quantize } from '../determinism/canonical-json.js';
import type { CloseReason, Trade } from '../trace/artifacts.js';

/**
Expand Down Expand Up @@ -82,7 +81,7 @@ export class Portfolio {
private _closeSeq = 0;

constructor(initialEquity: number) {
this._cash = quantize(initialEquity);
this._cash = initialEquity;
}

get cash(): number {
Expand Down Expand Up @@ -119,7 +118,7 @@ export class Portfolio {

/** Mark-to-market equity: `cash + unrealized(mark)`. The base for `equity_pct` sizing. */
equityAt(mark: number): number {
return quantize(new Decimal(this._cash).plus(this.grossUnrealized(mark)).toNumber());
return new Decimal(this._cash).plus(this.grossUnrealized(mark)).toNumber();
}

/**
Expand All @@ -131,10 +130,10 @@ export class Portfolio {
settleFunding(cost: number): void {
const pos = this._position;
if (pos === null) throw new Error('Portfolio.settleFunding: no open position');
this._cash = quantize(new Decimal(this._cash).minus(cost).toNumber());
this._cash = new Decimal(this._cash).minus(cost).toNumber();
this._position = {
...pos,
fundingAccrued: quantize(new Decimal(pos.fundingAccrued).plus(cost).toNumber()),
fundingAccrued: new Decimal(pos.fundingAccrued).plus(cost).toNumber(),
};
}

Expand Down Expand Up @@ -166,7 +165,7 @@ export class Portfolio {
if (order === null || order.intent !== 'open') {
throw new Error('Portfolio.settleOpen: no open pending');
}
this._cash = quantize(new Decimal(this._cash).minus(fill.fee).toNumber());
this._cash = new Decimal(this._cash).minus(fill.fee).toNumber();
this._position = {
symbol: order.symbol,
side: order.side,
Expand Down Expand Up @@ -206,20 +205,18 @@ export class Portfolio {
settleAdd(fill: OpenFill): void {
const pos = this._position;
if (pos === null) throw new Error('Portfolio.settleAdd: no open position');
const newSize = quantize(new Decimal(pos.size).plus(fill.size).toNumber());
const newEntry = quantize(
new Decimal(pos.entryPrice)
.times(pos.size)
.plus(new Decimal(fill.fillPrice).times(fill.size))
.div(newSize)
.toNumber(),
);
this._cash = quantize(new Decimal(this._cash).minus(fill.fee).toNumber());
const newSize = new Decimal(pos.size).plus(fill.size).toNumber();
const newEntry = new Decimal(pos.entryPrice)
.times(pos.size)
.plus(new Decimal(fill.fillPrice).times(fill.size))
.div(newSize)
.toNumber();
this._cash = new Decimal(this._cash).minus(fill.fee).toNumber();
this._position = {
...pos,
size: newSize,
entryPrice: newEntry,
entryFee: quantize(new Decimal(pos.entryFee).plus(fill.fee).toNumber()),
entryFee: new Decimal(pos.entryFee).plus(fill.fee).toNumber(),
addCount: (pos.addCount ?? 0) + 1,
};
this._pending = null;
Expand All @@ -244,7 +241,7 @@ export class Portfolio {
closedSizeAt(fraction: number): number {
const pos = this._position;
if (pos === null) throw new Error('Portfolio.closedSizeAt: no open position');
return fraction < 1 ? quantize(new Decimal(pos.size).times(fraction).toNumber()) : pos.size;
return fraction < 1 ? new Decimal(pos.size).times(fraction).toNumber() : pos.size;
}

/**
Expand All @@ -262,21 +259,23 @@ export class Portfolio {
const isPartial = fraction < 1;
const closedSize = this.closedSizeAt(fraction);
const entryFeeClosed = isPartial
? quantize(new Decimal(pos.entryFee).times(fraction).toNumber())
? new Decimal(pos.entryFee).times(fraction).toNumber()
: pos.entryFee;
const fundingClosed = isPartial
? quantize(new Decimal(pos.fundingAccrued).times(fraction).toNumber())
? new Decimal(pos.fundingAccrued).times(fraction).toNumber()
: pos.fundingAccrued;
const gross = this.grossAtSize(pos.side, pos.entryPrice, fill.fillPrice, closedSize);
this._cash = quantize(new Decimal(this._cash).plus(gross).minus(fill.fee).toNumber());
this._cash = new Decimal(this._cash).plus(gross).minus(fill.fee).toNumber();

const closeSeq = this._closeSeq;
this._closeSeq = closeSeq + 1;

const feePaid = quantize(new Decimal(entryFeeClosed).plus(fill.fee).toNumber());
const realizedPnl = quantize(
new Decimal(gross).minus(entryFeeClosed).minus(fill.fee).minus(fundingClosed).toNumber(),
);
const feePaid = new Decimal(entryFeeClosed).plus(fill.fee).toNumber();
const realizedPnl = new Decimal(gross)
.minus(entryFeeClosed)
.minus(fill.fee)
.minus(fundingClosed)
.toNumber();
const isProtection = closeReason === 'stop_hit' || closeReason === 'take_hit';
const isRich = isPartial || isProtection || closeSeq > 0;
const baseId = `trade-${pos.symbol}-${pos.entryBarIndex}-${fill.barIndex}`;
Expand Down Expand Up @@ -305,11 +304,9 @@ export class Portfolio {
if (isPartial) {
this._position = {
...pos,
size: quantize(new Decimal(pos.size).minus(closedSize).toNumber()),
entryFee: quantize(new Decimal(pos.entryFee).minus(entryFeeClosed).toNumber()),
fundingAccrued: quantize(
new Decimal(pos.fundingAccrued).minus(fundingClosed).toNumber(),
),
size: new Decimal(pos.size).minus(closedSize).toNumber(),
entryFee: new Decimal(pos.entryFee).minus(entryFeeClosed).toNumber(),
fundingAccrued: new Decimal(pos.fundingAccrued).minus(fundingClosed).toNumber(),
};
} else {
this._position = null;
Expand All @@ -336,6 +333,6 @@ export class Portfolio {
side === 'long'
? new Decimal(exitPrice).minus(entryPrice)
: new Decimal(entryPrice).minus(exitPrice);
return quantize(d.times(size).toNumber());
return d.times(size).toNumber();
}
}
21 changes: 8 additions & 13 deletions src/core/protection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@
import { Decimal } from 'decimal.js';

import type { Bar } from '../contract/index.js';
import { quantize } from '../determinism/canonical-json.js';

/** Quantized trigger prices recomputed from the average entry price. */
export interface ProtectionLevels {
Expand Down Expand Up @@ -40,21 +39,17 @@ export function protectionLevels(
const stopLevel =
stop === undefined
? undefined
: quantize(
(side === 'long'
? e.times(new Decimal(1).minus(stop))
: e.times(new Decimal(1).plus(stop))
).toNumber(),
);
: (side === 'long'
? e.times(new Decimal(1).minus(stop))
: e.times(new Decimal(1).plus(stop))
).toNumber();
const takeLevel =
take === undefined
? undefined
: quantize(
(side === 'long'
? e.times(new Decimal(1).plus(take))
: e.times(new Decimal(1).minus(take))
).toNumber(),
);
: (side === 'long'
? e.times(new Decimal(1).plus(take))
: e.times(new Decimal(1).minus(take))
).toNumber();
return {
...(stopLevel !== undefined ? { stopLevel } : {}),
...(takeLevel !== undefined ? { takeLevel } : {}),
Expand Down
5 changes: 2 additions & 3 deletions src/core/risk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ import type {
RiskProfile,
StrategyDecision,
} from '../contract/index.js';
import { quantize } from '../determinism/canonical-json.js';
import type { RiskClamp, RiskDecision } from '../trace/artifacts.js';

/** Outcome of a risk evaluation. */
Expand Down Expand Up @@ -71,7 +70,7 @@ export class RiskEngine {
? new Decimal(sizing.usd)
: new Decimal(equity).times(sizing.pct);
const cap = new Decimal(equity).times(this.profile.exposureLimits.maxPositionNotionalPct);
return quantize(Decimal.min(raw, cap).toNumber());
return Decimal.min(raw, cap).toNumber();
}

private normHint(value: number | undefined, bounds?: Bounds): number | undefined {
Expand Down Expand Up @@ -300,7 +299,7 @@ export class RiskEngine {
const allowedPct = Math.min(requestedPct, limits.maxAddNotionalPct, totalRemainingPct);
if (allowedPct <= 0) return reject(limitExceeded);

const notional = quantize(new Decimal(ctx.equity).times(allowedPct).toNumber());
const notional = new Decimal(ctx.equity).times(allowedPct).toNumber();
if (!(notional > 0)) return reject(limitExceeded);

if (allowedPct < requestedPct) {
Expand Down
38 changes: 21 additions & 17 deletions src/core/simulate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import type {
StrategyModule,
Tape,
} from '../contract/index.js';
import { canonicalJson, quantize } from '../determinism/canonical-json.js';
import { canonicalJson } from '../determinism/canonical-json.js';
import { contentRef } from '../determinism/hash.js';
import { createSeededRng } from '../determinism/rng.js';
import type {
Expand Down Expand Up @@ -83,10 +83,16 @@ export const TRACE_FORMAT_VERSION = '1';
*
* So: bump this when, and ONLY when, execution semantics change. `refresh-expectations` enforces
* the converse — moving the anchor without moving this constant is rejected — so the two cannot
* drift apart in either direction. The value stays `0.0.0` because the semantics frozen on
* 2026-07-25 have not changed since; a release does not touch it.
* drift apart in either direction. A release does not touch it.
*
* `0.1.0` (волна C, 2026-07-28): квантизация ушла из горячего цикла на границу артефакта.
* Симуляция считает в полной точности `Decimal`, 8 знаков появляются один раз — при сериализации.
* Форма трейса НЕ изменилась, поэтому `traceFormatVersion` остался `1`; изменились значения в
* последнем разряде. Сдвиг измерен differential-харнессом на замороженных лентах ДО этого бампа
* (чтобы отделить численный эффект от смены версии в самом трейсе): ноль структурных расхождений,
* максимальный относительный сдвиг 4.49e-10, последовательность решений и состав сделок совпали.
*/
export const ENGINE_VERSION = '0.0.0';
export const ENGINE_VERSION = '0.1.0';

/** Everything a run binds. */
export interface RunRequest {
Expand Down Expand Up @@ -464,17 +470,15 @@ export function simulate(request: RunRequest): CanonicalTrace {
const pos = portfolio.position;
const rate8h = tape.market?.funding8h?.[t];
const covered = rate8h !== undefined;
const cost = quantize(
computeBarFunding({
side: pos.side,
size: pos.size,
mark: bar.close,
rate8h: covered ? rate8h : 0,
covered,
barMinutes: cadenceMinutes,
intervalHours: exec.fundingIntervalHours(),
}).toNumber(),
);
const cost = computeBarFunding({
side: pos.side,
size: pos.size,
mark: bar.close,
rate8h: covered ? rate8h : 0,
covered,
barMinutes: cadenceMinutes,
intervalHours: exec.fundingIntervalHours(),
}).toNumber();
portfolio.settleFunding(cost);
acc.fundingLedger.push({ barIndex: t, ts: bar.ts, rate: covered ? rate8h : 0, covered, cost });
}
Expand All @@ -498,7 +502,7 @@ export function simulate(request: RunRequest): CanonicalTrace {
}

const finalEquity =
bars.length > 0 ? portfolio.equityAt(bars[bars.length - 1].close) : quantize(initialEquity);
bars.length > 0 ? portfolio.equityAt(bars[bars.length - 1].close) : initialEquity;

const trace: CanonicalTrace = {
traceFormatVersion: TRACE_FORMAT_VERSION,
Expand All @@ -513,7 +517,7 @@ export function simulate(request: RunRequest): CanonicalTrace {
strategyRef: { id: strategy.id, version: strategy.version },
riskProfileRef: { id: riskProfile.id, version: riskProfile.version },
realityModelRef: { id: realityModel.id, version: realityModel.version },
initialEquity: quantize(initialEquity),
initialEquity,
},
orders: acc.orders,
fills: acc.fills,
Expand Down
19 changes: 18 additions & 1 deletion src/determinism/canonical-json.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,24 @@ function quantizeToString(n: number): string {
return d.toFixed(); // fixed notation, no trailing zeros, no exponent
}

/** Quantize a number to the canonical scale (8 places, ROUND_HALF_EVEN) as a `number`. */
/**
* Quantize a number to the canonical scale (8 places, ROUND_HALF_EVEN) as a `number`.
*
* Волна C: КВАНТИЗАЦИЯ ЖИВЁТ ТОЛЬКО НА ГРАНИЦЕ АРТЕФАКТА.
*
* Раньше `core/` звал `quantize` после каждой арифметической операции — 6–12 раз на бар, и каждый
* раз это был полный круг `new Decimal(n).toDecimalPlaces(8).toFixed()` → строка → `Number`.
* Детерминизм при этом покупался на гранулярности бара, а наблюдаем он только здесь: `serialize`
* ниже квантует КАЖДОЕ число артефакта в любом случае. То есть пербарная подрезка не давала
* артефакту ничего, чего не даёт сама сериализация, — она лишь меняла последующую арифметику.
*
* Теперь симуляция считает в полной точности `Decimal`, а 8 знаков появляются один раз, при записи
* артефакта. Это сдвигает значения в последнем разряде — ровно тот сдвиг, ради которого волна
* затевалась, и он проверяется differential-харнессом, а не принимается на веру.
*
* `quantize` остаётся экспортом: он часть контракта пакета и нужен потребителям, которым надо
* привести число к канонической шкале ВНЕ сериализации.
*/
export function quantize(n: number): number {
return Number(quantizeToString(n));
}
Expand Down
Loading
Loading