tradelab 0.4.0 → 1.0.0
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/README.md +121 -52
- package/bin/tradelab.js +340 -49
- package/dist/cjs/data.cjs +210 -155
- package/dist/cjs/index.cjs +1782 -274
- package/dist/cjs/live.cjs +3350 -0
- package/docs/README.md +26 -9
- package/docs/api-reference.md +89 -26
- package/docs/backtest-engine.md +74 -60
- package/docs/data-reporting-cli.md +66 -36
- package/docs/examples.md +275 -0
- package/docs/live-trading.md +186 -0
- package/examples/yahooEmaCross.js +1 -6
- package/package.json +18 -3
- package/src/data/csv.js +24 -14
- package/src/data/index.js +1 -5
- package/src/data/yahoo.js +6 -19
- package/src/engine/backtest.js +137 -144
- package/src/engine/backtestTicks.js +481 -0
- package/src/engine/barSystemRunner.js +1027 -0
- package/src/engine/execution.js +11 -39
- package/src/engine/portfolio.js +237 -66
- package/src/engine/walkForward.js +132 -13
- package/src/index.js +3 -11
- package/src/live/broker/alpaca.js +254 -0
- package/src/live/broker/binance.js +351 -0
- package/src/live/broker/coinbase.js +339 -0
- package/src/live/broker/interactiveBrokers.js +123 -0
- package/src/live/broker/interface.js +74 -0
- package/src/live/clock.js +56 -0
- package/src/live/engine/candleAggregator.js +154 -0
- package/src/live/engine/liveEngine.js +694 -0
- package/src/live/engine/paperEngine.js +453 -0
- package/src/live/engine/riskManager.js +185 -0
- package/src/live/engine/stateManager.js +112 -0
- package/src/live/events.js +48 -0
- package/src/live/feed/brokerFeed.js +35 -0
- package/src/live/feed/interface.js +28 -0
- package/src/live/feed/pollingFeed.js +105 -0
- package/src/live/index.js +27 -0
- package/src/live/logger.js +82 -0
- package/src/live/orchestrator.js +133 -0
- package/src/live/storage/interface.js +36 -0
- package/src/live/storage/jsonFileStorage.js +112 -0
- package/src/metrics/buildMetrics.js +103 -100
- package/src/reporting/exportBacktestArtifacts.js +1 -4
- package/src/reporting/exportTradesCsv.js +2 -7
- package/src/reporting/renderHtmlReport.js +8 -13
- package/src/utils/indicators.js +1 -2
- package/src/utils/positionSizing.js +16 -2
- package/src/utils/time.js +4 -12
- package/templates/report.html +23 -9
- package/templates/report.js +83 -69
- package/types/index.d.ts +98 -4
- package/types/live.d.ts +382 -0
package/src/engine/execution.js
CHANGED
|
@@ -17,20 +17,10 @@ export function applyFill(
|
|
|
17
17
|
{ slippageBps = 0, feeBps = 0, kind = "market", qty = 0, costs = {} } = {}
|
|
18
18
|
) {
|
|
19
19
|
const model = costs || {};
|
|
20
|
-
const modelSlippageBps = Number.isFinite(model.slippageBps)
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
const
|
|
24
|
-
? model.commissionBps
|
|
25
|
-
: feeBps;
|
|
26
|
-
const effectiveSlippageBps = resolveSlippageBps(
|
|
27
|
-
kind,
|
|
28
|
-
modelSlippageBps,
|
|
29
|
-
model.slippageByKind
|
|
30
|
-
);
|
|
31
|
-
const halfSpreadBps = Number.isFinite(model.spreadBps)
|
|
32
|
-
? model.spreadBps / 2
|
|
33
|
-
: 0;
|
|
20
|
+
const modelSlippageBps = Number.isFinite(model.slippageBps) ? model.slippageBps : slippageBps;
|
|
21
|
+
const modelFeeBps = Number.isFinite(model.commissionBps) ? model.commissionBps : feeBps;
|
|
22
|
+
const effectiveSlippageBps = resolveSlippageBps(kind, modelSlippageBps, model.slippageByKind);
|
|
23
|
+
const halfSpreadBps = Number.isFinite(model.spreadBps) ? model.spreadBps / 2 : 0;
|
|
34
24
|
|
|
35
25
|
const slippage = ((effectiveSlippageBps + halfSpreadBps) / 10000) * price;
|
|
36
26
|
const filledPrice = side === "long" ? price + slippage : price - slippage;
|
|
@@ -38,16 +28,13 @@ export function applyFill(
|
|
|
38
28
|
((modelFeeBps || 0) / 10000) * Math.abs(filledPrice) +
|
|
39
29
|
(Number.isFinite(model.commissionPerUnit) ? model.commissionPerUnit : 0);
|
|
40
30
|
const variableFeeTotal = variableFeePerUnit * Math.max(0, qty);
|
|
41
|
-
const fixedFeeTotal = Number.isFinite(model.commissionPerOrder)
|
|
42
|
-
? model.commissionPerOrder
|
|
43
|
-
: 0;
|
|
31
|
+
const fixedFeeTotal = Number.isFinite(model.commissionPerOrder) ? model.commissionPerOrder : 0;
|
|
44
32
|
const grossFeeTotal = variableFeeTotal + fixedFeeTotal;
|
|
45
33
|
const feeTotal = Math.max(
|
|
46
34
|
Number.isFinite(model.minCommission) ? model.minCommission : 0,
|
|
47
35
|
grossFeeTotal
|
|
48
36
|
);
|
|
49
|
-
const feePerUnit =
|
|
50
|
-
qty > 0 ? feeTotal / qty : variableFeePerUnit;
|
|
37
|
+
const feePerUnit = qty > 0 ? feeTotal / qty : variableFeePerUnit;
|
|
51
38
|
|
|
52
39
|
return { price: filledPrice, fee: feePerUnit, feeTotal };
|
|
53
40
|
}
|
|
@@ -63,21 +50,12 @@ export function clampStop(marketPrice, proposedStop, side, oco) {
|
|
|
63
50
|
export function touchedLimit(side, limitPrice, bar, mode = "intrabar") {
|
|
64
51
|
if (!bar || limitPrice === undefined || limitPrice === null) return false;
|
|
65
52
|
if (mode === "close") {
|
|
66
|
-
return side === "long"
|
|
67
|
-
? bar.close <= limitPrice
|
|
68
|
-
: bar.close >= limitPrice;
|
|
53
|
+
return side === "long" ? bar.close <= limitPrice : bar.close >= limitPrice;
|
|
69
54
|
}
|
|
70
55
|
return side === "long" ? bar.low <= limitPrice : bar.high >= limitPrice;
|
|
71
56
|
}
|
|
72
57
|
|
|
73
|
-
export function ocoExitCheck({
|
|
74
|
-
side,
|
|
75
|
-
stop,
|
|
76
|
-
tp,
|
|
77
|
-
bar,
|
|
78
|
-
mode = "intrabar",
|
|
79
|
-
tieBreak = "pessimistic",
|
|
80
|
-
}) {
|
|
58
|
+
export function ocoExitCheck({ side, stop, tp, bar, mode = "intrabar", tieBreak = "pessimistic" }) {
|
|
81
59
|
if (mode === "close") {
|
|
82
60
|
const close = bar.close;
|
|
83
61
|
if (side === "long") {
|
|
@@ -94,9 +72,7 @@ export function ocoExitCheck({
|
|
|
94
72
|
const hitTarget = side === "long" ? bar.high >= tp : bar.low <= tp;
|
|
95
73
|
|
|
96
74
|
if (hitStop && hitTarget) {
|
|
97
|
-
return tieBreak === "optimistic"
|
|
98
|
-
? { hit: "TP", px: tp }
|
|
99
|
-
: { hit: "SL", px: stop };
|
|
75
|
+
return tieBreak === "optimistic" ? { hit: "TP", px: tp } : { hit: "SL", px: stop };
|
|
100
76
|
}
|
|
101
77
|
|
|
102
78
|
if (hitStop) return { hit: "SL", px: stop };
|
|
@@ -123,10 +99,7 @@ export function estimateBarMs(candles) {
|
|
|
123
99
|
if (deltas.length) {
|
|
124
100
|
deltas.sort((a, b) => a - b);
|
|
125
101
|
const middle = Math.floor(deltas.length / 2);
|
|
126
|
-
const median =
|
|
127
|
-
deltas.length % 2
|
|
128
|
-
? deltas[middle]
|
|
129
|
-
: (deltas[middle - 1] + deltas[middle]) / 2;
|
|
102
|
+
const median = deltas.length % 2 ? deltas[middle] : (deltas[middle - 1] + deltas[middle]) / 2;
|
|
130
103
|
return Math.max(60e3, Math.min(median, 60 * 60e3));
|
|
131
104
|
}
|
|
132
105
|
}
|
|
@@ -151,7 +124,6 @@ export function dayKeyET(timeMs) {
|
|
|
151
124
|
const anchor = new Date(
|
|
152
125
|
Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate(), 0, 0, 0)
|
|
153
126
|
);
|
|
154
|
-
const pseudoEtTime =
|
|
155
|
-
anchor.getTime() + hoursET * 60 * 60 * 1000 + minutesETDay * 60 * 1000;
|
|
127
|
+
const pseudoEtTime = anchor.getTime() + hoursET * 60 * 60 * 1000 + minutesETDay * 60 * 1000;
|
|
156
128
|
return dayKeyUTC(pseudoEtTime);
|
|
157
129
|
}
|
package/src/engine/portfolio.js
CHANGED
|
@@ -1,57 +1,62 @@
|
|
|
1
1
|
import { buildMetrics } from "../metrics/buildMetrics.js";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { estimateBarMs, dayKeyET } from "./execution.js";
|
|
3
|
+
import { BarSystemRunner, defaultSystemCap } from "./barSystemRunner.js";
|
|
4
4
|
|
|
5
5
|
function asWeight(value) {
|
|
6
6
|
return Number.isFinite(value) && value > 0 ? value : 0;
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
-
function
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
}
|
|
15
|
-
}
|
|
9
|
+
function describeValue(value) {
|
|
10
|
+
if (Array.isArray(value)) return `array(length=${value.length})`;
|
|
11
|
+
if (value === null) return "null";
|
|
12
|
+
return typeof value;
|
|
13
|
+
}
|
|
16
14
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
15
|
+
function buildPortfolioPoint(time, equity, lockedCapital, availableCapital) {
|
|
16
|
+
return {
|
|
17
|
+
time,
|
|
18
|
+
timestamp: time,
|
|
19
|
+
equity,
|
|
20
|
+
lockedCapital,
|
|
21
|
+
availableCapital,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
21
24
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
lastEquity: run.allocationEquity,
|
|
26
|
-
}));
|
|
25
|
+
function stableSystemOrder(left, right) {
|
|
26
|
+
return left.index - right.index;
|
|
27
|
+
}
|
|
27
28
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
29
|
+
function hashedOrderScore(index, time, seed) {
|
|
30
|
+
let value = (Number(time) ^ Math.imul(index + 1, 0x9e3779b1) ^ (seed | 0)) >>> 0;
|
|
31
|
+
value = Math.imul(value ^ (value >>> 16), 0x85ebca6b) >>> 0;
|
|
32
|
+
value = Math.imul(value ^ (value >>> 13), 0xc2b2ae35) >>> 0;
|
|
33
|
+
return (value ^ (value >>> 16)) >>> 0;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function orderActiveSystems(active, nextTime, processingOrder, shuffleSeed) {
|
|
37
|
+
if (processingOrder !== "shuffle") {
|
|
38
|
+
active.sort(stableSystemOrder);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
40
41
|
|
|
41
|
-
|
|
42
|
+
active.sort((left, right) => {
|
|
43
|
+
const leftScore = hashedOrderScore(left.index, nextTime, shuffleSeed);
|
|
44
|
+
const rightScore = hashedOrderScore(right.index, nextTime, shuffleSeed);
|
|
45
|
+
if (leftScore !== rightScore) return leftScore - rightScore;
|
|
46
|
+
return stableSystemOrder(left, right);
|
|
42
47
|
});
|
|
43
48
|
}
|
|
44
49
|
|
|
45
|
-
function combineReplay(
|
|
50
|
+
function combineReplay(systemResults, eqSeries, collectReplay) {
|
|
46
51
|
if (!collectReplay) {
|
|
47
52
|
return { frames: [], events: [] };
|
|
48
53
|
}
|
|
49
54
|
|
|
50
|
-
const events =
|
|
51
|
-
.flatMap((
|
|
52
|
-
(
|
|
55
|
+
const events = systemResults
|
|
56
|
+
.flatMap((entry) =>
|
|
57
|
+
(entry.result.replay?.events || []).map((event) => ({
|
|
53
58
|
...event,
|
|
54
|
-
symbol: event.symbol ||
|
|
59
|
+
symbol: event.symbol || entry.symbol,
|
|
55
60
|
}))
|
|
56
61
|
)
|
|
57
62
|
.sort((left, right) => new Date(left.t).getTime() - new Date(right.t).getTime());
|
|
@@ -62,17 +67,83 @@ function combineReplay(systemRuns, eqSeries, collectReplay) {
|
|
|
62
67
|
equity: point.equity,
|
|
63
68
|
posSide: null,
|
|
64
69
|
posSize: 0,
|
|
70
|
+
lockedCapital: point.lockedCapital,
|
|
71
|
+
availableCapital: point.availableCapital,
|
|
65
72
|
}));
|
|
66
73
|
|
|
67
74
|
return { frames, events };
|
|
68
75
|
}
|
|
69
76
|
|
|
77
|
+
function portfolioState(runners, initialEquity) {
|
|
78
|
+
let markedEquity = initialEquity;
|
|
79
|
+
let lockedCapital = 0;
|
|
80
|
+
|
|
81
|
+
for (const { runner, initialReferenceEquity } of runners) {
|
|
82
|
+
markedEquity += runner.getMarkedEquity() - initialReferenceEquity;
|
|
83
|
+
lockedCapital += runner.getLockedCapital();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
markedEquity,
|
|
88
|
+
lockedCapital,
|
|
89
|
+
availableCapital: markedEquity - lockedCapital,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function findNextTimeAndActive(runners) {
|
|
94
|
+
let nextTime = Infinity;
|
|
95
|
+
const active = [];
|
|
96
|
+
|
|
97
|
+
for (const entry of runners) {
|
|
98
|
+
const time = entry.runner.peekTime();
|
|
99
|
+
if (time < nextTime) {
|
|
100
|
+
nextTime = time;
|
|
101
|
+
active.length = 0;
|
|
102
|
+
active.push(entry);
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if (time === nextTime) {
|
|
106
|
+
active.push(entry);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return { nextTime, active };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function initialPortfolioTime(runners) {
|
|
114
|
+
let time = Infinity;
|
|
115
|
+
for (const { runner } of runners) {
|
|
116
|
+
const next = runner.candles[0]?.time ?? Infinity;
|
|
117
|
+
if (next < time) time = next;
|
|
118
|
+
}
|
|
119
|
+
return Number.isFinite(time) ? time : 0;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function resolveSystemCap(systemEntry, totalEquity) {
|
|
123
|
+
return defaultSystemCap(
|
|
124
|
+
Math.max(0, totalEquity),
|
|
125
|
+
systemEntry.defaultCapPct,
|
|
126
|
+
systemEntry.system.maxAllocation,
|
|
127
|
+
systemEntry.system.maxAllocationPct
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function forceExitAll(runners, time) {
|
|
132
|
+
for (const { runner } of runners) {
|
|
133
|
+
if (!runner.open) continue;
|
|
134
|
+
const price = runner.getMarkPrice();
|
|
135
|
+
if (!Number.isFinite(price)) continue;
|
|
136
|
+
runner.forceExit("PORTFOLIO_DAILY_LOSS", { time, close: price }, price);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
70
140
|
/**
|
|
71
|
-
* Run multiple
|
|
141
|
+
* Run multiple systems against a shared capital pool.
|
|
72
142
|
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
143
|
+
* Existing allocation weights are preserved as default per-system capital caps,
|
|
144
|
+
* but capital is only locked when a fill actually occurs. Systems therefore
|
|
145
|
+
* compete for the same remaining capital at fill time. `processingOrder` can be
|
|
146
|
+
* set to `"shuffle"` for fairness testing when multiple systems act on the same bar.
|
|
76
147
|
*/
|
|
77
148
|
export function backtestPortfolio({
|
|
78
149
|
systems = [],
|
|
@@ -80,39 +151,138 @@ export function backtestPortfolio({
|
|
|
80
151
|
allocation = "equal",
|
|
81
152
|
collectEqSeries = true,
|
|
82
153
|
collectReplay = false,
|
|
154
|
+
maxDailyLossPct = 0,
|
|
155
|
+
processingOrder = "sequential",
|
|
156
|
+
shuffleSeed = 0,
|
|
83
157
|
} = {}) {
|
|
84
158
|
if (!Array.isArray(systems) || systems.length === 0) {
|
|
85
|
-
throw new Error(
|
|
159
|
+
throw new Error(
|
|
160
|
+
`backtestPortfolio() requires a non-empty systems array, got ${describeValue(systems)}`
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
if (processingOrder !== "sequential" && processingOrder !== "shuffle") {
|
|
164
|
+
throw new Error(
|
|
165
|
+
`backtestPortfolio() processingOrder must be "sequential" or "shuffle", got ${processingOrder}`
|
|
166
|
+
);
|
|
86
167
|
}
|
|
87
168
|
|
|
88
169
|
const weights =
|
|
89
170
|
allocation === "equal"
|
|
90
171
|
? systems.map(() => 1)
|
|
91
172
|
: systems.map((system) => asWeight(system.weight || 0));
|
|
92
|
-
const totalWeight = weights.reduce((
|
|
173
|
+
const totalWeight = weights.reduce((sumValue, weight) => sumValue + weight, 0);
|
|
93
174
|
|
|
94
175
|
if (!(totalWeight > 0)) {
|
|
95
|
-
throw new Error(
|
|
176
|
+
throw new Error(
|
|
177
|
+
`backtestPortfolio() requires positive allocation weights, got allocation=${allocation}`
|
|
178
|
+
);
|
|
96
179
|
}
|
|
97
180
|
|
|
98
|
-
const
|
|
99
|
-
const
|
|
100
|
-
const
|
|
101
|
-
...system,
|
|
102
|
-
equity: allocationEquity,
|
|
103
|
-
collectEqSeries,
|
|
104
|
-
collectReplay,
|
|
105
|
-
});
|
|
106
|
-
|
|
181
|
+
const runners = systems.map((system, index) => {
|
|
182
|
+
const defaultCapPct = weights[index] / totalWeight;
|
|
183
|
+
const initialReferenceEquity = equity * defaultCapPct;
|
|
107
184
|
return {
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
185
|
+
index,
|
|
186
|
+
symbol: system.symbol ?? `system-${index + 1}`,
|
|
187
|
+
system,
|
|
188
|
+
defaultCapPct,
|
|
189
|
+
initialReferenceEquity,
|
|
190
|
+
runner: new BarSystemRunner({
|
|
191
|
+
...system,
|
|
192
|
+
symbol: system.symbol ?? `system-${index + 1}`,
|
|
193
|
+
equity: initialReferenceEquity,
|
|
194
|
+
collectEqSeries,
|
|
195
|
+
collectReplay,
|
|
196
|
+
}),
|
|
112
197
|
};
|
|
113
198
|
});
|
|
114
199
|
|
|
115
|
-
const
|
|
200
|
+
const eqSeries = collectEqSeries ? [] : [];
|
|
201
|
+
let state = portfolioState(runners, equity);
|
|
202
|
+
if (collectEqSeries) {
|
|
203
|
+
eqSeries.push(
|
|
204
|
+
buildPortfolioPoint(
|
|
205
|
+
initialPortfolioTime(runners),
|
|
206
|
+
state.markedEquity,
|
|
207
|
+
state.lockedCapital,
|
|
208
|
+
state.availableCapital
|
|
209
|
+
)
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
let currentDay = null;
|
|
214
|
+
let dayStartEquity = equity;
|
|
215
|
+
let portfolioHalted = false;
|
|
216
|
+
|
|
217
|
+
while (true) {
|
|
218
|
+
const { nextTime, active } = findNextTimeAndActive(runners);
|
|
219
|
+
if (!Number.isFinite(nextTime)) break;
|
|
220
|
+
orderActiveSystems(active, nextTime, processingOrder, shuffleSeed);
|
|
221
|
+
|
|
222
|
+
const dayKey = dayKeyET(nextTime);
|
|
223
|
+
if (currentDay === null || dayKey !== currentDay) {
|
|
224
|
+
currentDay = dayKey;
|
|
225
|
+
state = portfolioState(runners, equity);
|
|
226
|
+
dayStartEquity = state.markedEquity;
|
|
227
|
+
portfolioHalted = false;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
for (const systemEntry of active) {
|
|
231
|
+
state = portfolioState(runners, equity);
|
|
232
|
+
const totalEquity = state.markedEquity;
|
|
233
|
+
const availableCapital = Math.max(0, state.availableCapital);
|
|
234
|
+
const systemLocked = systemEntry.runner.getLockedCapital();
|
|
235
|
+
const systemCap = resolveSystemCap(systemEntry, totalEquity);
|
|
236
|
+
const systemRemainingCapital = Math.max(0, systemCap - systemLocked);
|
|
237
|
+
|
|
238
|
+
systemEntry.runner.step({
|
|
239
|
+
signalEquity: totalEquity,
|
|
240
|
+
canTrade: !portfolioHalted,
|
|
241
|
+
resolveEntrySize({ desiredSize, entryPrice }) {
|
|
242
|
+
const maxLeverage = Math.max(1, systemEntry.runner.options.maxLeverage || 1);
|
|
243
|
+
const byAvailable =
|
|
244
|
+
(availableCapital * maxLeverage) / Math.max(1e-12, Math.abs(entryPrice));
|
|
245
|
+
const bySystemCap =
|
|
246
|
+
(systemRemainingCapital * maxLeverage) / Math.max(1e-12, Math.abs(entryPrice));
|
|
247
|
+
return Math.min(desiredSize, byAvailable, bySystemCap);
|
|
248
|
+
},
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
state = portfolioState(runners, equity);
|
|
252
|
+
if (
|
|
253
|
+
!portfolioHalted &&
|
|
254
|
+
maxDailyLossPct > 0 &&
|
|
255
|
+
state.markedEquity <= dayStartEquity * (1 - Math.abs(maxDailyLossPct) / 100)
|
|
256
|
+
) {
|
|
257
|
+
portfolioHalted = true;
|
|
258
|
+
for (const { runner } of runners) runner.cancelPending();
|
|
259
|
+
forceExitAll(runners, nextTime);
|
|
260
|
+
state = portfolioState(runners, equity);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (collectEqSeries) {
|
|
265
|
+
eqSeries.push(
|
|
266
|
+
buildPortfolioPoint(
|
|
267
|
+
nextTime,
|
|
268
|
+
state.markedEquity,
|
|
269
|
+
state.lockedCapital,
|
|
270
|
+
state.availableCapital
|
|
271
|
+
)
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const systemResults = runners.map((entry) => ({
|
|
277
|
+
symbol: entry.symbol,
|
|
278
|
+
weight: entry.defaultCapPct,
|
|
279
|
+
equity: entry.initialReferenceEquity,
|
|
280
|
+
allocationCapPct: entry.defaultCapPct,
|
|
281
|
+
allocationCap: resolveSystemCap(entry, equity),
|
|
282
|
+
result: entry.runner.buildResult(),
|
|
283
|
+
}));
|
|
284
|
+
|
|
285
|
+
const trades = systemResults
|
|
116
286
|
.flatMap((run) =>
|
|
117
287
|
run.result.trades.map((trade) => ({
|
|
118
288
|
...trade,
|
|
@@ -120,7 +290,7 @@ export function backtestPortfolio({
|
|
|
120
290
|
}))
|
|
121
291
|
)
|
|
122
292
|
.sort((left, right) => left.exit.time - right.exit.time);
|
|
123
|
-
const positions =
|
|
293
|
+
const positions = systemResults
|
|
124
294
|
.flatMap((run) =>
|
|
125
295
|
run.result.positions.map((trade) => ({
|
|
126
296
|
...trade,
|
|
@@ -128,8 +298,13 @@ export function backtestPortfolio({
|
|
|
128
298
|
}))
|
|
129
299
|
)
|
|
130
300
|
.sort((left, right) => left.exit.time - right.exit.time);
|
|
131
|
-
const
|
|
132
|
-
|
|
301
|
+
const openPositions = systemResults.flatMap((run) =>
|
|
302
|
+
(run.result.openPositions || []).map((position) => ({
|
|
303
|
+
...position,
|
|
304
|
+
symbol: position.symbol || run.symbol,
|
|
305
|
+
}))
|
|
306
|
+
);
|
|
307
|
+
const replay = combineReplay(systemResults, eqSeries, collectReplay);
|
|
133
308
|
const allCandles = systems.flatMap((system) => system.candles || []);
|
|
134
309
|
const orderedCandles = [...allCandles].sort((left, right) => left.time - right.time);
|
|
135
310
|
const metrics = buildMetrics({
|
|
@@ -147,14 +322,10 @@ export function backtestPortfolio({
|
|
|
147
322
|
range: undefined,
|
|
148
323
|
trades,
|
|
149
324
|
positions,
|
|
325
|
+
openPositions,
|
|
150
326
|
metrics,
|
|
151
327
|
eqSeries,
|
|
152
328
|
replay,
|
|
153
|
-
systems:
|
|
154
|
-
symbol: run.symbol,
|
|
155
|
-
weight: run.weight / totalWeight,
|
|
156
|
-
equity: run.allocationEquity,
|
|
157
|
-
result: run.result,
|
|
158
|
-
})),
|
|
329
|
+
systems: systemResults,
|
|
159
330
|
};
|
|
160
331
|
}
|