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
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
function qtyCloseEnough(a, b, tolerancePct = 0.05) {
|
|
2
|
+
const left = Math.abs(Number(a) || 0);
|
|
3
|
+
const right = Math.abs(Number(b) || 0);
|
|
4
|
+
if (left === 0 && right === 0) return true;
|
|
5
|
+
const baseline = Math.max(left, right, 1e-12);
|
|
6
|
+
return Math.abs(left - right) / baseline <= tolerancePct;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function sideMatches(openPosition, brokerPosition) {
|
|
10
|
+
if (!openPosition || !brokerPosition) return false;
|
|
11
|
+
const openSide = openPosition.side;
|
|
12
|
+
const brokerSide = brokerPosition.side;
|
|
13
|
+
return openSide === brokerSide;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Coordinates state persistence and restart reconciliation.
|
|
18
|
+
*/
|
|
19
|
+
export class StateManager {
|
|
20
|
+
constructor({ storage }) {
|
|
21
|
+
this.storage = storage;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async load(namespace) {
|
|
25
|
+
return this.storage.load(namespace);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async save(namespace, state) {
|
|
29
|
+
await this.storage.save(namespace, {
|
|
30
|
+
...state,
|
|
31
|
+
savedAt: Date.now(),
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async appendTrade(namespace, trade) {
|
|
36
|
+
await this.storage.appendTrade(namespace, trade);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async appendEquityPoint(namespace, point) {
|
|
40
|
+
await this.storage.appendEquityPoint(namespace, point);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async loadTrades(namespace) {
|
|
44
|
+
return this.storage.loadTrades(namespace);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async loadEquityCurve(namespace) {
|
|
48
|
+
return this.storage.loadEquityCurve(namespace);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async clear(namespace) {
|
|
52
|
+
await this.storage.clear(namespace);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
reconcile({ persistedState, brokerPositions = [], symbol }) {
|
|
56
|
+
const report = {
|
|
57
|
+
status: "ok",
|
|
58
|
+
action: "none",
|
|
59
|
+
message: "no reconciliation needed",
|
|
60
|
+
adoptedPosition: null,
|
|
61
|
+
mismatch: null,
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const persistedOpen = persistedState?.openPosition || null;
|
|
65
|
+
const brokerForSymbol = brokerPositions.find((position) => position.symbol === symbol) || null;
|
|
66
|
+
|
|
67
|
+
if (persistedOpen && brokerForSymbol) {
|
|
68
|
+
const sameSide = sideMatches(persistedOpen, brokerForSymbol);
|
|
69
|
+
const similarQty = qtyCloseEnough(
|
|
70
|
+
persistedOpen.size ?? persistedOpen.qty,
|
|
71
|
+
brokerForSymbol.qty
|
|
72
|
+
);
|
|
73
|
+
if (sameSide && similarQty) {
|
|
74
|
+
report.action = "adopt-broker";
|
|
75
|
+
report.message = "persisted and broker positions matched";
|
|
76
|
+
report.adoptedPosition = {
|
|
77
|
+
...persistedOpen,
|
|
78
|
+
size: brokerForSymbol.qty,
|
|
79
|
+
entryFill: brokerForSymbol.avgEntry ?? persistedOpen.entryFill ?? persistedOpen.entry,
|
|
80
|
+
};
|
|
81
|
+
return report;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
report.status = "error";
|
|
85
|
+
report.action = "mismatch";
|
|
86
|
+
report.message = "persisted and broker positions mismatch";
|
|
87
|
+
report.mismatch = { persisted: persistedOpen, broker: brokerForSymbol };
|
|
88
|
+
return report;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (persistedOpen && !brokerForSymbol) {
|
|
92
|
+
report.status = "warn";
|
|
93
|
+
report.action = "closed-externally";
|
|
94
|
+
report.message = "persisted open position missing at broker";
|
|
95
|
+
return report;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (!persistedOpen && brokerForSymbol) {
|
|
99
|
+
report.status = "warn";
|
|
100
|
+
report.action = "external-position";
|
|
101
|
+
report.message = "broker has external position not present in persisted state";
|
|
102
|
+
report.adoptedPosition = null;
|
|
103
|
+
return report;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return report;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function createStateManager(options) {
|
|
111
|
+
return new StateManager(options);
|
|
112
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { EventEmitter } from "node:events";
|
|
2
|
+
|
|
3
|
+
export const LIVE_EVENTS = [
|
|
4
|
+
"signal",
|
|
5
|
+
"order:submitted",
|
|
6
|
+
"order:filled",
|
|
7
|
+
"order:canceled",
|
|
8
|
+
"order:rejected",
|
|
9
|
+
"order:modified",
|
|
10
|
+
"position:opened",
|
|
11
|
+
"position:updated",
|
|
12
|
+
"position:closed",
|
|
13
|
+
"equity:update",
|
|
14
|
+
"risk:warning",
|
|
15
|
+
"risk:halt",
|
|
16
|
+
"bar",
|
|
17
|
+
"tick",
|
|
18
|
+
"error",
|
|
19
|
+
"connected",
|
|
20
|
+
"disconnected",
|
|
21
|
+
"reconnecting",
|
|
22
|
+
"shutdown",
|
|
23
|
+
"stateRestored",
|
|
24
|
+
"reconciled",
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Lightweight event bus used by live trading components.
|
|
29
|
+
*
|
|
30
|
+
* Events are emitted on their native channel plus the wildcard `*` channel for
|
|
31
|
+
* consumers that want to capture all traffic (for logging/monitoring).
|
|
32
|
+
*/
|
|
33
|
+
export class EventBus extends EventEmitter {
|
|
34
|
+
emitEvent(event, payload = {}) {
|
|
35
|
+
this.emit(event, payload);
|
|
36
|
+
this.emit("*", { event, payload });
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
onAny(handler) {
|
|
41
|
+
this.on("*", handler);
|
|
42
|
+
return () => this.off("*", handler);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function createEventBus() {
|
|
47
|
+
return new EventBus();
|
|
48
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { FeedProvider } from "./interface.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Feed provider that delegates to a broker adapter.
|
|
5
|
+
*/
|
|
6
|
+
export class BrokerFeed extends FeedProvider {
|
|
7
|
+
constructor({ broker }) {
|
|
8
|
+
super();
|
|
9
|
+
this.broker = broker;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async connect() {
|
|
13
|
+
return undefined;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async disconnect() {
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
subscribeBars(symbol, interval, handler) {
|
|
21
|
+
return this.broker.subscribeBars(symbol, interval, handler);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
subscribeTicks(symbol, handler) {
|
|
25
|
+
return this.broker.subscribeTrades(symbol, handler);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async getHistoricalBars(symbol, interval, count) {
|
|
29
|
+
return this.broker.getHistoricalBars(symbol, interval, count);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function createBrokerFeed(options) {
|
|
34
|
+
return new BrokerFeed(options);
|
|
35
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
function notImplemented(method) {
|
|
2
|
+
throw new Error(`FeedProvider.${method}() not implemented`);
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Base class for feed providers.
|
|
7
|
+
*/
|
|
8
|
+
export class FeedProvider {
|
|
9
|
+
async connect() {
|
|
10
|
+
notImplemented("connect");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async disconnect() {
|
|
14
|
+
notImplemented("disconnect");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
subscribeBars(_symbol, _interval, _handler) {
|
|
18
|
+
notImplemented("subscribeBars");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
subscribeTicks(_symbol, _handler) {
|
|
22
|
+
notImplemented("subscribeTicks");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async getHistoricalBars(_symbol, _interval, _count) {
|
|
26
|
+
notImplemented("getHistoricalBars");
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { FeedProvider } from "./interface.js";
|
|
2
|
+
|
|
3
|
+
function keyFor(symbol, interval) {
|
|
4
|
+
return `${symbol}::${interval}`;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* REST polling feed suitable for serverless/cron mode.
|
|
9
|
+
*/
|
|
10
|
+
export class PollingFeed extends FeedProvider {
|
|
11
|
+
constructor({ broker, pollIntervalMs = 60_000, defaultBarsPerPoll = 2 } = {}) {
|
|
12
|
+
super();
|
|
13
|
+
this.broker = broker;
|
|
14
|
+
this.pollIntervalMs = Math.max(500, Number(pollIntervalMs) || 60_000);
|
|
15
|
+
this.defaultBarsPerPoll = Math.max(1, Number(defaultBarsPerPoll) || 2);
|
|
16
|
+
this.barSubscriptions = new Map();
|
|
17
|
+
this.tickSubscriptions = new Map();
|
|
18
|
+
this.lastEmittedByStream = new Map();
|
|
19
|
+
this.timer = null;
|
|
20
|
+
this.connected = false;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async connect() {
|
|
24
|
+
this.connected = true;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async disconnect() {
|
|
28
|
+
this.connected = false;
|
|
29
|
+
if (this.timer) {
|
|
30
|
+
clearInterval(this.timer);
|
|
31
|
+
this.timer = null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
subscribeBars(symbol, interval, handler) {
|
|
36
|
+
const streamKey = keyFor(symbol, interval);
|
|
37
|
+
const list = this.barSubscriptions.get(streamKey) || [];
|
|
38
|
+
list.push(handler);
|
|
39
|
+
this.barSubscriptions.set(streamKey, list);
|
|
40
|
+
return {
|
|
41
|
+
unsubscribe: () => {
|
|
42
|
+
const current = this.barSubscriptions.get(streamKey) || [];
|
|
43
|
+
this.barSubscriptions.set(
|
|
44
|
+
streamKey,
|
|
45
|
+
current.filter((candidate) => candidate !== handler)
|
|
46
|
+
);
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
subscribeTicks(symbol, handler) {
|
|
52
|
+
const list = this.tickSubscriptions.get(symbol) || [];
|
|
53
|
+
list.push(handler);
|
|
54
|
+
this.tickSubscriptions.set(symbol, list);
|
|
55
|
+
return {
|
|
56
|
+
unsubscribe: () => {
|
|
57
|
+
const current = this.tickSubscriptions.get(symbol) || [];
|
|
58
|
+
this.tickSubscriptions.set(
|
|
59
|
+
symbol,
|
|
60
|
+
current.filter((candidate) => candidate !== handler)
|
|
61
|
+
);
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async getHistoricalBars(symbol, interval, count) {
|
|
67
|
+
return this.broker.getHistoricalBars(symbol, interval, count);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async pollOnce() {
|
|
71
|
+
const streams = [...this.barSubscriptions.keys()];
|
|
72
|
+
for (const stream of streams) {
|
|
73
|
+
const [symbol, interval] = stream.split("::");
|
|
74
|
+
const bars = await this.broker.getHistoricalBars(symbol, interval, this.defaultBarsPerPoll);
|
|
75
|
+
const ordered = [...bars].sort((left, right) => left.time - right.time);
|
|
76
|
+
const lastSeen = this.lastEmittedByStream.get(stream) ?? -Infinity;
|
|
77
|
+
const next = ordered.filter((bar) => bar.time > lastSeen);
|
|
78
|
+
if (!next.length) continue;
|
|
79
|
+
const handlers = this.barSubscriptions.get(stream) || [];
|
|
80
|
+
for (const bar of next) {
|
|
81
|
+
for (const handler of handlers) {
|
|
82
|
+
await handler(bar);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
this.lastEmittedByStream.set(stream, next[next.length - 1].time);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
startPolling() {
|
|
90
|
+
if (this.timer) return;
|
|
91
|
+
this.timer = setInterval(() => {
|
|
92
|
+
this.pollOnce().catch(() => {});
|
|
93
|
+
}, this.pollIntervalMs);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
stopPolling() {
|
|
97
|
+
if (!this.timer) return;
|
|
98
|
+
clearInterval(this.timer);
|
|
99
|
+
this.timer = null;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function createPollingFeed(options) {
|
|
104
|
+
return new PollingFeed(options);
|
|
105
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export { EventBus, LIVE_EVENTS, createEventBus } from "./events.js";
|
|
2
|
+
export { LiveLogger, createLogger } from "./logger.js";
|
|
3
|
+
export { BrokerClock, createClock } from "./clock.js";
|
|
4
|
+
|
|
5
|
+
export { BrokerAdapter } from "./broker/interface.js";
|
|
6
|
+
export { AlpacaBroker, createAlpacaBroker } from "./broker/alpaca.js";
|
|
7
|
+
export { BinanceBroker, createBinanceBroker } from "./broker/binance.js";
|
|
8
|
+
export { CoinbaseBroker, createCoinbaseBroker } from "./broker/coinbase.js";
|
|
9
|
+
export {
|
|
10
|
+
InteractiveBrokersBroker,
|
|
11
|
+
createInteractiveBrokersBroker,
|
|
12
|
+
} from "./broker/interactiveBrokers.js";
|
|
13
|
+
|
|
14
|
+
export { FeedProvider } from "./feed/interface.js";
|
|
15
|
+
export { BrokerFeed, createBrokerFeed } from "./feed/brokerFeed.js";
|
|
16
|
+
export { PollingFeed, createPollingFeed } from "./feed/pollingFeed.js";
|
|
17
|
+
|
|
18
|
+
export { StorageProvider } from "./storage/interface.js";
|
|
19
|
+
export { JsonFileStorage, createJsonFileStorage } from "./storage/jsonFileStorage.js";
|
|
20
|
+
|
|
21
|
+
export { CandleAggregator, createCandleAggregator } from "./engine/candleAggregator.js";
|
|
22
|
+
export { RiskManager, createRiskManager } from "./engine/riskManager.js";
|
|
23
|
+
export { StateManager, createStateManager } from "./engine/stateManager.js";
|
|
24
|
+
export { PaperEngine, createPaperEngine } from "./engine/paperEngine.js";
|
|
25
|
+
export { LiveEngine, createLiveEngine } from "./engine/liveEngine.js";
|
|
26
|
+
|
|
27
|
+
export { LiveOrchestrator, createLiveOrchestrator } from "./orchestrator.js";
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
const LOG_PRIORITIES = {
|
|
2
|
+
debug: 10,
|
|
3
|
+
info: 20,
|
|
4
|
+
warn: 30,
|
|
5
|
+
error: 40,
|
|
6
|
+
silent: 100,
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
function normalizeLevel(level) {
|
|
10
|
+
return Object.prototype.hasOwnProperty.call(LOG_PRIORITIES, level) ? level : "info";
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Structured JSON logger for live components.
|
|
15
|
+
*/
|
|
16
|
+
export class LiveLogger {
|
|
17
|
+
constructor({ level = "info", stream = process.stdout } = {}) {
|
|
18
|
+
this.level = normalizeLevel(level);
|
|
19
|
+
this.stream = stream;
|
|
20
|
+
this._unsub = null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
shouldLog(level) {
|
|
24
|
+
return LOG_PRIORITIES[level] >= LOG_PRIORITIES[this.level];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
write(level, message, fields = {}) {
|
|
28
|
+
const normalizedLevel = normalizeLevel(level);
|
|
29
|
+
if (!this.shouldLog(normalizedLevel)) return;
|
|
30
|
+
const record = {
|
|
31
|
+
t: new Date().toISOString(),
|
|
32
|
+
level: normalizedLevel,
|
|
33
|
+
msg: message,
|
|
34
|
+
...fields,
|
|
35
|
+
};
|
|
36
|
+
this.stream.write(`${JSON.stringify(record)}\n`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
debug(message, fields) {
|
|
40
|
+
this.write("debug", message, fields);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
info(message, fields) {
|
|
44
|
+
this.write("info", message, fields);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
warn(message, fields) {
|
|
48
|
+
this.write("warn", message, fields);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
error(message, fields) {
|
|
52
|
+
this.write("error", message, fields);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
attach(eventBus) {
|
|
56
|
+
if (!eventBus || typeof eventBus.onAny !== "function") return () => {};
|
|
57
|
+
this.detach();
|
|
58
|
+
this._unsub = eventBus.onAny(({ event, payload }) => {
|
|
59
|
+
const level =
|
|
60
|
+
event === "error"
|
|
61
|
+
? "error"
|
|
62
|
+
: event.startsWith("risk:")
|
|
63
|
+
? "warn"
|
|
64
|
+
: event === "reconnecting" || event === "disconnected"
|
|
65
|
+
? "warn"
|
|
66
|
+
: "info";
|
|
67
|
+
this.write(level, event, { event, payload });
|
|
68
|
+
});
|
|
69
|
+
return () => this.detach();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
detach() {
|
|
73
|
+
if (typeof this._unsub === "function") {
|
|
74
|
+
this._unsub();
|
|
75
|
+
this._unsub = null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function createLogger(options) {
|
|
81
|
+
return new LiveLogger(options);
|
|
82
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { dayKeyET } from "../engine/execution.js";
|
|
2
|
+
import { EventBus } from "./events.js";
|
|
3
|
+
import { LiveEngine } from "./engine/liveEngine.js";
|
|
4
|
+
|
|
5
|
+
function asWeight(value) {
|
|
6
|
+
return Number.isFinite(value) && value > 0 ? value : 0;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function defaultSystemId(system, index) {
|
|
10
|
+
return system.id || `${system.symbol}-${system.interval || "1m"}-${index + 1}`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Multi-strategy live orchestrator with portfolio-level guardrails.
|
|
15
|
+
*/
|
|
16
|
+
export class LiveOrchestrator {
|
|
17
|
+
constructor(options = {}) {
|
|
18
|
+
if (!Array.isArray(options.systems) || options.systems.length === 0) {
|
|
19
|
+
throw new Error("orchestrator requires a non-empty systems array");
|
|
20
|
+
}
|
|
21
|
+
if (!options.broker) {
|
|
22
|
+
throw new Error("orchestrator requires a broker adapter");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
this.options = {
|
|
26
|
+
allocation: "equal",
|
|
27
|
+
equity: 10_000,
|
|
28
|
+
maxDailyLossPct: 0,
|
|
29
|
+
...options,
|
|
30
|
+
};
|
|
31
|
+
this.eventBus = this.options.eventBus || new EventBus();
|
|
32
|
+
this.engines = [];
|
|
33
|
+
this.running = false;
|
|
34
|
+
this.dayStartEquity = this.options.equity;
|
|
35
|
+
this.currentDay = null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
_emit(event, payload = {}) {
|
|
39
|
+
this.eventBus.emitEvent(event, payload);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
_allocationWeights() {
|
|
43
|
+
const systems = this.options.systems;
|
|
44
|
+
if (this.options.allocation === "equal") {
|
|
45
|
+
return systems.map(() => 1);
|
|
46
|
+
}
|
|
47
|
+
return systems.map((system) => asWeight(system.weight || 0));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
_allocatedEquities(totalEquity) {
|
|
51
|
+
const weights = this._allocationWeights();
|
|
52
|
+
const totalWeight = weights.reduce((sum, value) => sum + value, 0) || 1;
|
|
53
|
+
return weights.map((weight) => (totalEquity * weight) / totalWeight);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async start() {
|
|
57
|
+
if (this.running) return;
|
|
58
|
+
const account = await this.options.broker.getAccount().catch(() => null);
|
|
59
|
+
const totalEquity = Number.isFinite(account?.equity) ? account.equity : this.options.equity;
|
|
60
|
+
const perSystemEquity = this._allocatedEquities(totalEquity);
|
|
61
|
+
|
|
62
|
+
this.engines = this.options.systems.map((system, index) => {
|
|
63
|
+
const engineBus = new EventBus();
|
|
64
|
+
engineBus.onAny(({ event, payload }) => {
|
|
65
|
+
this._emit(event, {
|
|
66
|
+
systemId: defaultSystemId(system, index),
|
|
67
|
+
...payload,
|
|
68
|
+
});
|
|
69
|
+
if (event === "equity:update") this._checkPortfolioLimits();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
return new LiveEngine({
|
|
73
|
+
...system,
|
|
74
|
+
id: defaultSystemId(system, index),
|
|
75
|
+
broker: this.options.broker,
|
|
76
|
+
feed: this.options.feed,
|
|
77
|
+
storage: this.options.storage,
|
|
78
|
+
eventBus: engineBus,
|
|
79
|
+
brokerConfig: this.options.brokerConfig,
|
|
80
|
+
equity: perSystemEquity[index],
|
|
81
|
+
useBrokerAccountEquity: false,
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
await Promise.all(this.engines.map((engine) => engine.start()));
|
|
86
|
+
this.running = true;
|
|
87
|
+
this.dayStartEquity = this.getStatus().aggregateEquity;
|
|
88
|
+
this.currentDay = dayKeyET(Date.now());
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
_checkPortfolioLimits() {
|
|
92
|
+
if (!this.options.maxDailyLossPct || this.options.maxDailyLossPct <= 0) return;
|
|
93
|
+
const nowDay = dayKeyET(Date.now());
|
|
94
|
+
if (this.currentDay !== nowDay) {
|
|
95
|
+
this.currentDay = nowDay;
|
|
96
|
+
this.dayStartEquity = this.getStatus().aggregateEquity;
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
const equity = this.getStatus().aggregateEquity;
|
|
100
|
+
const maxLossFraction = Math.abs(this.options.maxDailyLossPct) / 100;
|
|
101
|
+
if (equity <= this.dayStartEquity * (1 - maxLossFraction)) {
|
|
102
|
+
for (const engine of this.engines) {
|
|
103
|
+
engine.riskManager.halt("portfolio daily loss limit reached");
|
|
104
|
+
}
|
|
105
|
+
this._emit("risk:halt", {
|
|
106
|
+
reason: "portfolio daily loss limit reached",
|
|
107
|
+
aggregateEquity: equity,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async stop() {
|
|
113
|
+
await Promise.all(this.engines.map((engine) => engine.stop()));
|
|
114
|
+
this.running = false;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
getStatus() {
|
|
118
|
+
const systems = this.engines.map((engine) => engine.getStatus());
|
|
119
|
+
const aggregateEquity = systems.reduce((sum, status) => sum + (status.equity || 0), 0);
|
|
120
|
+
const openPositions = systems.filter((status) => status.openPosition).length;
|
|
121
|
+
return {
|
|
122
|
+
running: this.running,
|
|
123
|
+
systems,
|
|
124
|
+
aggregateEquity,
|
|
125
|
+
openPositions,
|
|
126
|
+
dayStartEquity: this.dayStartEquity,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function createLiveOrchestrator(options) {
|
|
132
|
+
return new LiveOrchestrator(options);
|
|
133
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
function notImplemented(method) {
|
|
2
|
+
throw new Error(`StorageProvider.${method}() not implemented`);
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Base class for state persistence providers.
|
|
7
|
+
*/
|
|
8
|
+
export class StorageProvider {
|
|
9
|
+
async load(_namespace) {
|
|
10
|
+
notImplemented("load");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async save(_namespace, _state) {
|
|
14
|
+
notImplemented("save");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async appendTrade(_namespace, _trade) {
|
|
18
|
+
notImplemented("appendTrade");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async appendEquityPoint(_namespace, _point) {
|
|
22
|
+
notImplemented("appendEquityPoint");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async loadTrades(_namespace) {
|
|
26
|
+
notImplemented("loadTrades");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async loadEquityCurve(_namespace) {
|
|
30
|
+
notImplemented("loadEquityCurve");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async clear(_namespace) {
|
|
34
|
+
notImplemented("clear");
|
|
35
|
+
}
|
|
36
|
+
}
|