openbroker 1.3.1 → 1.4.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/CHANGELOG.md +12 -0
- package/SKILL.md +7 -4
- package/dist/auto/audit.d.ts +57 -0
- package/dist/auto/audit.d.ts.map +1 -0
- package/dist/auto/audit.js +407 -0
- package/dist/auto/cli.d.ts +2 -0
- package/dist/auto/cli.d.ts.map +1 -0
- package/dist/auto/cli.js +423 -0
- package/dist/auto/events.d.ts +11 -0
- package/dist/auto/events.d.ts.map +1 -0
- package/dist/auto/events.js +36 -0
- package/dist/auto/examples/dca.d.ts +4 -0
- package/dist/auto/examples/dca.d.ts.map +1 -0
- package/dist/auto/examples/dca.js +60 -0
- package/dist/auto/examples/funding-arb.d.ts +4 -0
- package/dist/auto/examples/funding-arb.d.ts.map +1 -0
- package/dist/auto/examples/funding-arb.js +81 -0
- package/dist/auto/examples/grid.d.ts +4 -0
- package/dist/auto/examples/grid.d.ts.map +1 -0
- package/dist/auto/examples/grid.js +114 -0
- package/dist/auto/examples/mm-maker.d.ts +4 -0
- package/dist/auto/examples/mm-maker.d.ts.map +1 -0
- package/dist/auto/examples/mm-maker.js +131 -0
- package/dist/auto/examples/mm-spread.d.ts +4 -0
- package/dist/auto/examples/mm-spread.d.ts.map +1 -0
- package/dist/auto/examples/mm-spread.js +119 -0
- package/dist/auto/examples/price-alert.d.ts +4 -0
- package/dist/auto/examples/price-alert.d.ts.map +1 -0
- package/dist/auto/examples/price-alert.js +85 -0
- package/dist/auto/keep-awake.d.ts +11 -0
- package/dist/auto/keep-awake.d.ts.map +1 -0
- package/dist/auto/keep-awake.js +70 -0
- package/dist/auto/loader.d.ts +22 -0
- package/dist/auto/loader.d.ts.map +1 -0
- package/dist/auto/loader.js +127 -0
- package/dist/auto/prune.d.ts +40 -0
- package/dist/auto/prune.d.ts.map +1 -0
- package/dist/auto/prune.js +204 -0
- package/dist/auto/registry.d.ts +24 -0
- package/dist/auto/registry.d.ts.map +1 -0
- package/dist/auto/registry.js +93 -0
- package/dist/auto/report.d.ts +3 -0
- package/dist/auto/report.d.ts.map +1 -0
- package/dist/auto/report.js +385 -0
- package/dist/auto/runtime.d.ts +33 -0
- package/dist/auto/runtime.d.ts.map +1 -0
- package/dist/auto/runtime.js +844 -0
- package/dist/auto/types.d.ts +236 -0
- package/dist/auto/types.d.ts.map +1 -0
- package/dist/auto/types.js +3 -0
- package/dist/core/client.d.ts +684 -0
- package/dist/core/client.d.ts.map +1 -0
- package/dist/core/client.js +2040 -0
- package/dist/core/config.d.ts +22 -0
- package/dist/core/config.d.ts.map +1 -0
- package/dist/core/config.js +143 -0
- package/dist/core/types.d.ts +221 -0
- package/dist/core/types.d.ts.map +1 -0
- package/dist/core/types.js +2 -0
- package/dist/core/utils.d.ts +61 -0
- package/dist/core/utils.d.ts.map +1 -0
- package/dist/core/utils.js +142 -0
- package/dist/core/ws.d.ts +121 -0
- package/dist/core/ws.d.ts.map +1 -0
- package/dist/core/ws.js +222 -0
- package/dist/info/account.d.ts +3 -0
- package/dist/info/account.d.ts.map +1 -0
- package/dist/info/account.js +198 -0
- package/dist/info/all-markets.d.ts +3 -0
- package/dist/info/all-markets.d.ts.map +1 -0
- package/dist/info/all-markets.js +272 -0
- package/dist/info/candles.d.ts +3 -0
- package/dist/info/candles.d.ts.map +1 -0
- package/dist/info/candles.js +120 -0
- package/dist/info/fees.d.ts +3 -0
- package/dist/info/fees.d.ts.map +1 -0
- package/dist/info/fees.js +87 -0
- package/dist/info/fills.d.ts +3 -0
- package/dist/info/fills.d.ts.map +1 -0
- package/dist/info/fills.js +105 -0
- package/dist/info/funding-history.d.ts +3 -0
- package/dist/info/funding-history.d.ts.map +1 -0
- package/dist/info/funding-history.js +98 -0
- package/dist/info/funding-scan.d.ts +3 -0
- package/dist/info/funding-scan.d.ts.map +1 -0
- package/dist/info/funding-scan.js +178 -0
- package/dist/info/funding.d.ts +3 -0
- package/dist/info/funding.d.ts.map +1 -0
- package/dist/info/funding.js +158 -0
- package/dist/info/markets.d.ts +3 -0
- package/dist/info/markets.d.ts.map +1 -0
- package/dist/info/markets.js +178 -0
- package/dist/info/order-status.d.ts +3 -0
- package/dist/info/order-status.d.ts.map +1 -0
- package/dist/info/order-status.js +85 -0
- package/dist/info/orders.d.ts +3 -0
- package/dist/info/orders.d.ts.map +1 -0
- package/dist/info/orders.js +162 -0
- package/dist/info/outcomes.d.ts +3 -0
- package/dist/info/outcomes.d.ts.map +1 -0
- package/dist/info/outcomes.js +175 -0
- package/dist/info/positions.d.ts +3 -0
- package/dist/info/positions.d.ts.map +1 -0
- package/dist/info/positions.js +127 -0
- package/dist/info/rate-limit.d.ts +3 -0
- package/dist/info/rate-limit.d.ts.map +1 -0
- package/dist/info/rate-limit.js +58 -0
- package/dist/info/search-markets.d.ts +3 -0
- package/dist/info/search-markets.d.ts.map +1 -0
- package/dist/info/search-markets.js +296 -0
- package/dist/info/spot.d.ts +3 -0
- package/dist/info/spot.d.ts.map +1 -0
- package/dist/info/spot.js +192 -0
- package/dist/info/trades.d.ts +3 -0
- package/dist/info/trades.d.ts.map +1 -0
- package/dist/info/trades.js +97 -0
- package/dist/lib.d.ts +14 -0
- package/dist/lib.d.ts.map +1 -0
- package/dist/lib.js +17 -0
- package/dist/operations/bracket.d.ts +28 -0
- package/dist/operations/bracket.d.ts.map +1 -0
- package/dist/operations/bracket.js +266 -0
- package/dist/operations/cancel.d.ts +3 -0
- package/dist/operations/cancel.d.ts.map +1 -0
- package/dist/operations/cancel.js +107 -0
- package/dist/operations/chase.d.ts +25 -0
- package/dist/operations/chase.d.ts.map +1 -0
- package/dist/operations/chase.js +215 -0
- package/dist/operations/limit-order.d.ts +3 -0
- package/dist/operations/limit-order.d.ts.map +1 -0
- package/dist/operations/limit-order.js +144 -0
- package/dist/operations/market-order.d.ts +3 -0
- package/dist/operations/market-order.d.ts.map +1 -0
- package/dist/operations/market-order.js +153 -0
- package/dist/operations/outcome-order.d.ts +3 -0
- package/dist/operations/outcome-order.d.ts.map +1 -0
- package/dist/operations/outcome-order.js +171 -0
- package/dist/operations/scale.d.ts +3 -0
- package/dist/operations/scale.d.ts.map +1 -0
- package/dist/operations/scale.js +212 -0
- package/dist/operations/set-tpsl.d.ts +3 -0
- package/dist/operations/set-tpsl.d.ts.map +1 -0
- package/dist/operations/set-tpsl.js +277 -0
- package/dist/operations/spot-order.d.ts +3 -0
- package/dist/operations/spot-order.d.ts.map +1 -0
- package/dist/operations/spot-order.js +173 -0
- package/dist/operations/trigger-order.d.ts +3 -0
- package/dist/operations/trigger-order.d.ts.map +1 -0
- package/dist/operations/trigger-order.js +177 -0
- package/dist/operations/twap-cancel.d.ts +3 -0
- package/dist/operations/twap-cancel.d.ts.map +1 -0
- package/dist/operations/twap-cancel.js +57 -0
- package/dist/operations/twap-status.d.ts +3 -0
- package/dist/operations/twap-status.d.ts.map +1 -0
- package/dist/operations/twap-status.js +81 -0
- package/dist/operations/twap.d.ts +3 -0
- package/dist/operations/twap.d.ts.map +1 -0
- package/dist/operations/twap.js +124 -0
- package/dist/setup/approve-builder.d.ts +3 -0
- package/dist/setup/approve-builder.d.ts.map +1 -0
- package/dist/setup/approve-builder.js +155 -0
- package/dist/setup/env.d.ts +4 -0
- package/dist/setup/env.d.ts.map +1 -0
- package/dist/setup/env.js +8 -0
- package/dist/setup/onboard.d.ts +10 -0
- package/dist/setup/onboard.d.ts.map +1 -0
- package/dist/setup/onboard.js +462 -0
- package/package.json +10 -4
- package/scripts/core/client.ts +13 -3
- package/scripts/info/all-markets.ts +18 -2
- package/scripts/info/search-markets.ts +18 -2
|
@@ -0,0 +1,844 @@
|
|
|
1
|
+
// Automation Runtime — loads scripts, polls market data, dispatches events
|
|
2
|
+
// Supports real-time WebSocket feeds with REST polling as fallback heartbeat
|
|
3
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import os from 'os';
|
|
6
|
+
import { getClient } from '../core/client.js';
|
|
7
|
+
import { roundPrice, roundSize, sleep, normalizeCoin, formatUsd, formatPercent, annualizeFundingRate, } from '../core/utils.js';
|
|
8
|
+
import { WebSocketManager } from '../core/ws.js';
|
|
9
|
+
import { AutomationEventBus } from './events.js';
|
|
10
|
+
import { loadAutomation } from './loader.js';
|
|
11
|
+
import { registerAutomation, unregisterAutomation, getRegisteredAutomations as getRegisteredFromFile } from './registry.js';
|
|
12
|
+
import { createAutomationAudit, toSerializable } from './audit.js';
|
|
13
|
+
import { startKeepAwake } from './keep-awake.js';
|
|
14
|
+
// ── Observer fan-out ────────────────────────────────────────────────
|
|
15
|
+
//
|
|
16
|
+
// External monitoring packages (e.g. `openbroker-monitoring`) plug into
|
|
17
|
+
// the audit pipeline as observers. We auto-load them via convention
|
|
18
|
+
// dynamic-import: any package whose default export is a factory returning
|
|
19
|
+
// an `AutomationAuditObserver` (or null) and that resolves through Node's
|
|
20
|
+
// module resolver gets wired in at startup.
|
|
21
|
+
const CONVENTION_OBSERVER_PACKAGES = ['openbroker-monitoring'];
|
|
22
|
+
async function loadConventionObservers(log) {
|
|
23
|
+
const observers = [];
|
|
24
|
+
for (const name of CONVENTION_OBSERVER_PACKAGES) {
|
|
25
|
+
try {
|
|
26
|
+
const mod = await import(name);
|
|
27
|
+
const exported = mod.default ?? mod;
|
|
28
|
+
const observer = typeof exported === 'function' ? exported() : exported;
|
|
29
|
+
if (observer && typeof observer === 'object') {
|
|
30
|
+
observers.push(observer);
|
|
31
|
+
log.info(`[audit-observer] loaded: ${name}`);
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
// Package resolved but its factory returned null/undefined — typically
|
|
35
|
+
// because required env (e.g. OB_DASHBOARD_URL) is missing. Surface this
|
|
36
|
+
// so operators don't silently lose telemetry.
|
|
37
|
+
log.warn(`[audit-observer] ${name} resolved but factory returned no observer — check the package's required env vars`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
catch (err) {
|
|
41
|
+
const code = err?.code;
|
|
42
|
+
if (code === 'ERR_MODULE_NOT_FOUND' || code === 'MODULE_NOT_FOUND') {
|
|
43
|
+
log.info(`[audit-observer] ${name} not installed — skipping (install it alongside openbroker to forward telemetry)`);
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
log.warn(`[audit-observer] failed to load "${name}": ${err instanceof Error ? err.message : String(err)}`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return observers;
|
|
51
|
+
}
|
|
52
|
+
function fanOutNote(observers, kind, payload) {
|
|
53
|
+
for (const o of observers) {
|
|
54
|
+
try {
|
|
55
|
+
o.onNote?.(kind, payload);
|
|
56
|
+
}
|
|
57
|
+
catch { /* observer must not break runtime */ }
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function fanOutMetric(observers, name, value, tags) {
|
|
61
|
+
for (const o of observers) {
|
|
62
|
+
try {
|
|
63
|
+
o.onMetric?.(name, value, tags);
|
|
64
|
+
}
|
|
65
|
+
catch { /* observer must not break runtime */ }
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
function fanOutAgentAction(observers, action, status, details, txHash) {
|
|
69
|
+
for (const o of observers) {
|
|
70
|
+
try {
|
|
71
|
+
o.onAgentAction?.(action, status, details, txHash);
|
|
72
|
+
}
|
|
73
|
+
catch { /* observer must not break runtime */ }
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
const STATE_DIR = path.join(os.homedir(), '.openbroker', 'state');
|
|
77
|
+
const AUDITED_WRITE_METHODS = new Set([
|
|
78
|
+
'order', 'marketOrder', 'limitOrder', 'triggerOrder',
|
|
79
|
+
'takeProfit', 'stopLoss', 'cancel', 'cancelAll',
|
|
80
|
+
'spotOrder', 'spotMarketOrder', 'spotLimitOrder', 'spotCancel',
|
|
81
|
+
'updateLeverage', 'approveBuilderFee', 'twapOrder', 'twapCancel',
|
|
82
|
+
]);
|
|
83
|
+
function createState(id) {
|
|
84
|
+
mkdirSync(STATE_DIR, { recursive: true });
|
|
85
|
+
const stateFile = path.join(STATE_DIR, `${id}.json`);
|
|
86
|
+
let data = {};
|
|
87
|
+
let audit = null;
|
|
88
|
+
if (existsSync(stateFile)) {
|
|
89
|
+
try {
|
|
90
|
+
data = JSON.parse(readFileSync(stateFile, 'utf-8'));
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
data = {};
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
let flushTimer = null;
|
|
97
|
+
function scheduleFlush() {
|
|
98
|
+
if (flushTimer)
|
|
99
|
+
return;
|
|
100
|
+
flushTimer = setTimeout(() => {
|
|
101
|
+
flushTimer = null;
|
|
102
|
+
writeFileSync(stateFile, JSON.stringify(data, null, 2));
|
|
103
|
+
}, 500);
|
|
104
|
+
}
|
|
105
|
+
return {
|
|
106
|
+
state: {
|
|
107
|
+
get(key, defaultValue) {
|
|
108
|
+
return (key in data ? data[key] : defaultValue);
|
|
109
|
+
},
|
|
110
|
+
set(key, value) {
|
|
111
|
+
data[key] = value;
|
|
112
|
+
audit?.recordStateChange('set', key, value);
|
|
113
|
+
scheduleFlush();
|
|
114
|
+
},
|
|
115
|
+
delete(key) {
|
|
116
|
+
const previous = key in data ? data[key] : undefined;
|
|
117
|
+
delete data[key];
|
|
118
|
+
audit?.recordStateChange('delete', key, previous);
|
|
119
|
+
scheduleFlush();
|
|
120
|
+
},
|
|
121
|
+
clear() {
|
|
122
|
+
data = {};
|
|
123
|
+
audit?.recordStateChange('clear', null);
|
|
124
|
+
scheduleFlush();
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
snapshot() {
|
|
128
|
+
return toSerializable(data);
|
|
129
|
+
},
|
|
130
|
+
attachAudit(nextAudit) {
|
|
131
|
+
audit = nextAudit;
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
// ── Logger ──────────────────────────────────────────────────────────
|
|
136
|
+
function createLogger(id, verbose, audit) {
|
|
137
|
+
const prefix = `[auto:${id}]`;
|
|
138
|
+
return {
|
|
139
|
+
info: (msg) => {
|
|
140
|
+
audit?.recordLog('info', msg);
|
|
141
|
+
console.log(`${prefix} ${msg}`);
|
|
142
|
+
},
|
|
143
|
+
warn: (msg) => {
|
|
144
|
+
audit?.recordLog('warn', msg);
|
|
145
|
+
console.log(`${prefix} ⚠ ${msg}`);
|
|
146
|
+
},
|
|
147
|
+
error: (msg) => {
|
|
148
|
+
audit?.recordLog('error', msg);
|
|
149
|
+
console.error(`${prefix} ✗ ${msg}`);
|
|
150
|
+
},
|
|
151
|
+
debug: (msg) => {
|
|
152
|
+
if (verbose) {
|
|
153
|
+
audit?.recordLog('debug', msg);
|
|
154
|
+
console.log(`${prefix} … ${msg}`);
|
|
155
|
+
}
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
// ── Dry-run client proxy ────────────────────────────────────────────
|
|
160
|
+
const WRITE_METHODS = new Set([
|
|
161
|
+
'order', 'marketOrder', 'limitOrder', 'triggerOrder',
|
|
162
|
+
'takeProfit', 'stopLoss', 'cancel', 'cancelAll',
|
|
163
|
+
'updateLeverage', 'approveBuilderFee',
|
|
164
|
+
'spotOrder', 'spotMarketOrder', 'spotLimitOrder', 'spotCancel',
|
|
165
|
+
'twapOrder', 'twapCancel',
|
|
166
|
+
]);
|
|
167
|
+
function createDryClient(client, log) {
|
|
168
|
+
return new Proxy(client, {
|
|
169
|
+
get(target, prop, receiver) {
|
|
170
|
+
const value = Reflect.get(target, prop, receiver);
|
|
171
|
+
if (typeof prop === 'string' && WRITE_METHODS.has(prop) && typeof value === 'function') {
|
|
172
|
+
return (...args) => {
|
|
173
|
+
log.info(`[DRY] ${prop}(${args.map(a => JSON.stringify(a)).join(', ')})`);
|
|
174
|
+
return Promise.resolve({ status: 'ok', response: { type: 'dry_run' } });
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
return value;
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
function createAuditedClient(client, audit, dryRun, observers) {
|
|
182
|
+
return new Proxy(client, {
|
|
183
|
+
get(target, prop, receiver) {
|
|
184
|
+
const value = Reflect.get(target, prop, receiver);
|
|
185
|
+
if (typeof prop === 'string' && AUDITED_WRITE_METHODS.has(prop) && typeof value === 'function') {
|
|
186
|
+
return async (...args) => {
|
|
187
|
+
const actionId = `${prop}:${Date.now()}:${Math.random().toString(16).slice(2)}`;
|
|
188
|
+
audit.recordAction({
|
|
189
|
+
actionId,
|
|
190
|
+
phase: 'request',
|
|
191
|
+
method: prop,
|
|
192
|
+
payload: { args },
|
|
193
|
+
dryRun,
|
|
194
|
+
});
|
|
195
|
+
try {
|
|
196
|
+
const result = await value.apply(target, args);
|
|
197
|
+
audit.recordAction({
|
|
198
|
+
actionId,
|
|
199
|
+
phase: 'response',
|
|
200
|
+
method: prop,
|
|
201
|
+
result,
|
|
202
|
+
dryRun,
|
|
203
|
+
});
|
|
204
|
+
fanOutAgentAction(observers, prop, 'success', { args: toSerializable(args), result: toSerializable(result), dryRun });
|
|
205
|
+
return result;
|
|
206
|
+
}
|
|
207
|
+
catch (error) {
|
|
208
|
+
audit.recordAction({
|
|
209
|
+
actionId,
|
|
210
|
+
phase: 'error',
|
|
211
|
+
method: prop,
|
|
212
|
+
error,
|
|
213
|
+
dryRun,
|
|
214
|
+
});
|
|
215
|
+
fanOutAgentAction(observers, prop, 'error', { args: toSerializable(args), error: String(error), dryRun });
|
|
216
|
+
throw error;
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
return value;
|
|
221
|
+
},
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
// ── Snapshot building ───────────────────────────────────────────────
|
|
225
|
+
async function buildSnapshot(client) {
|
|
226
|
+
// `metaAndAssetCtxs` contains both mostly-static market metadata and live
|
|
227
|
+
// funding/premium values. The client caches it for market lookups, so clear
|
|
228
|
+
// it before each automation poll snapshot or funding_update events freeze at
|
|
229
|
+
// the first fetched value.
|
|
230
|
+
client.invalidateMetaCache();
|
|
231
|
+
const [state, mids, metaCtxs] = await Promise.all([
|
|
232
|
+
client.getUserStateAll(),
|
|
233
|
+
client.getAllMids(),
|
|
234
|
+
client.getMetaAndAssetCtxs(),
|
|
235
|
+
]);
|
|
236
|
+
const prices = new Map();
|
|
237
|
+
for (const [coin, mid] of Object.entries(mids)) {
|
|
238
|
+
prices.set(coin, parseFloat(mid));
|
|
239
|
+
}
|
|
240
|
+
const positions = new Map();
|
|
241
|
+
for (const ap of state.assetPositions) {
|
|
242
|
+
const p = ap.position;
|
|
243
|
+
const size = parseFloat(p.szi);
|
|
244
|
+
if (size === 0)
|
|
245
|
+
continue;
|
|
246
|
+
positions.set(p.coin, {
|
|
247
|
+
coin: p.coin,
|
|
248
|
+
size,
|
|
249
|
+
entryPrice: parseFloat(p.entryPx),
|
|
250
|
+
positionValue: parseFloat(p.positionValue),
|
|
251
|
+
unrealizedPnl: parseFloat(p.unrealizedPnl),
|
|
252
|
+
liquidationPx: p.liquidationPx ? parseFloat(p.liquidationPx) : null,
|
|
253
|
+
leverage: typeof p.leverage === 'object' ? p.leverage.value : parseFloat(String(p.leverage)),
|
|
254
|
+
marginUsed: parseFloat(p.marginUsed),
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
const equity = parseFloat(state.marginSummary.accountValue);
|
|
258
|
+
const marginUsed = parseFloat(state.marginSummary.totalMarginUsed);
|
|
259
|
+
// Build funding rates from asset contexts
|
|
260
|
+
const fundingRates = new Map();
|
|
261
|
+
const addFundingRates = (universe, assetCtxs) => {
|
|
262
|
+
if (!universe || !assetCtxs)
|
|
263
|
+
return;
|
|
264
|
+
for (let i = 0; i < universe.length; i++) {
|
|
265
|
+
const meta = universe[i];
|
|
266
|
+
const ctx = assetCtxs[i];
|
|
267
|
+
if (ctx && meta?.name) {
|
|
268
|
+
fundingRates.set(meta.name, {
|
|
269
|
+
rate: parseFloat(String(ctx.funding || '0')),
|
|
270
|
+
premium: parseFloat(String(ctx.premium || '0')),
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
};
|
|
275
|
+
if (metaCtxs && Array.isArray(metaCtxs)) {
|
|
276
|
+
for (const group of metaCtxs) {
|
|
277
|
+
addFundingRates(group.universe, group.assetCtxs);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
else if (metaCtxs) {
|
|
281
|
+
addFundingRates(metaCtxs.meta?.universe, metaCtxs.assetCtxs);
|
|
282
|
+
}
|
|
283
|
+
return {
|
|
284
|
+
prices,
|
|
285
|
+
positions,
|
|
286
|
+
openOrderIds: new Set(), // filled by separate call if needed
|
|
287
|
+
equity,
|
|
288
|
+
marginUsed,
|
|
289
|
+
marginUsedPct: equity > 0 ? (marginUsed / equity) * 100 : 0,
|
|
290
|
+
fundingRates,
|
|
291
|
+
timestamp: Date.now(),
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
// ── Publish (webhook) ───────────────────────────────────────────────
|
|
295
|
+
function createPublish(automationId, log, gatewayPort, hooksToken) {
|
|
296
|
+
return async (message, options) => {
|
|
297
|
+
const token = hooksToken;
|
|
298
|
+
const port = gatewayPort || 18789;
|
|
299
|
+
if (!token) {
|
|
300
|
+
log.debug('publish() skipped — no hooks token configured (pass --hooks-token, set OPENCLAW_HOOKS_TOKEN before invoking the CLI, or configure plugin.hooksToken)');
|
|
301
|
+
return false;
|
|
302
|
+
}
|
|
303
|
+
const body = {
|
|
304
|
+
message,
|
|
305
|
+
name: options?.name || `ob-auto-${automationId}`,
|
|
306
|
+
wakeMode: options?.wakeMode || 'now',
|
|
307
|
+
};
|
|
308
|
+
if (options?.deliver !== undefined)
|
|
309
|
+
body.deliver = options.deliver;
|
|
310
|
+
if (options?.channel)
|
|
311
|
+
body.channel = options.channel;
|
|
312
|
+
try {
|
|
313
|
+
const res = await fetch(`http://127.0.0.1:${port}/hooks/agent`, {
|
|
314
|
+
method: 'POST',
|
|
315
|
+
headers: {
|
|
316
|
+
'Content-Type': 'application/json',
|
|
317
|
+
'Authorization': `Bearer ${token}`,
|
|
318
|
+
},
|
|
319
|
+
body: JSON.stringify(body),
|
|
320
|
+
});
|
|
321
|
+
if (!res.ok) {
|
|
322
|
+
log.warn(`publish() failed: HTTP ${res.status} ${res.statusText}`);
|
|
323
|
+
return false;
|
|
324
|
+
}
|
|
325
|
+
log.debug(`publish() delivered to /hooks/agent (${message.length} chars)`);
|
|
326
|
+
return true;
|
|
327
|
+
}
|
|
328
|
+
catch (err) {
|
|
329
|
+
log.warn(`publish() error: ${err instanceof Error ? err.message : String(err)}`);
|
|
330
|
+
return false;
|
|
331
|
+
}
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
function createAuditedPublish(publish, audit) {
|
|
335
|
+
return async (message, options) => {
|
|
336
|
+
try {
|
|
337
|
+
const delivered = await publish(message, options);
|
|
338
|
+
audit.recordPublish(message, options, delivered);
|
|
339
|
+
return delivered;
|
|
340
|
+
}
|
|
341
|
+
catch (error) {
|
|
342
|
+
audit.recordError('publish', error);
|
|
343
|
+
throw error;
|
|
344
|
+
}
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
/** Registry of all running automations */
|
|
348
|
+
const registry = new Map();
|
|
349
|
+
export function getRunningAutomations() {
|
|
350
|
+
return [...registry.values()];
|
|
351
|
+
}
|
|
352
|
+
export function getAutomation(id) {
|
|
353
|
+
return registry.get(id);
|
|
354
|
+
}
|
|
355
|
+
/** Get all automations from file-based registry (cross-process visibility) */
|
|
356
|
+
export { getRegisteredFromFile as getRegisteredAutomations };
|
|
357
|
+
export async function startAutomation(options) {
|
|
358
|
+
const { scriptPath, dryRun = false, verbose = false, gatewayPort, hooksToken, initialState, useWebSocket = true, } = options;
|
|
359
|
+
// When WebSocket is enabled, REST poll becomes a heartbeat (30s default)
|
|
360
|
+
// When disabled, use the original 10s polling interval
|
|
361
|
+
const pollIntervalMs = options.pollIntervalMs ?? (useWebSocket ? 30_000 : 10_000);
|
|
362
|
+
const id = options.id || path.basename(scriptPath, '.ts');
|
|
363
|
+
if (registry.has(id)) {
|
|
364
|
+
throw new Error(`Automation "${id}" is already running`);
|
|
365
|
+
}
|
|
366
|
+
const stateController = createState(id);
|
|
367
|
+
// Apply --set flags before the factory function runs so CLI overrides win over persisted state.
|
|
368
|
+
if (initialState) {
|
|
369
|
+
for (const [key, value] of Object.entries(initialState)) {
|
|
370
|
+
stateController.state.set(key, value);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
const eventBus = new AutomationEventBus();
|
|
374
|
+
const rawClient = getClient();
|
|
375
|
+
const audit = createAutomationAudit({
|
|
376
|
+
automationId: id,
|
|
377
|
+
scriptPath,
|
|
378
|
+
dryRun,
|
|
379
|
+
verbose,
|
|
380
|
+
pollIntervalMs,
|
|
381
|
+
useWebSocket,
|
|
382
|
+
accountAddress: rawClient.address,
|
|
383
|
+
walletAddress: rawClient.walletAddress,
|
|
384
|
+
isApiWallet: rawClient.isApiWallet,
|
|
385
|
+
initialState,
|
|
386
|
+
persistedState: stateController.snapshot(),
|
|
387
|
+
});
|
|
388
|
+
stateController.attachAudit(audit);
|
|
389
|
+
const log = createLogger(id, verbose, audit);
|
|
390
|
+
const keepAwakeEnabled = options.keepAwake ?? !dryRun;
|
|
391
|
+
let keepAwake = null;
|
|
392
|
+
if (keepAwakeEnabled) {
|
|
393
|
+
keepAwake = startKeepAwake(`OpenBroker automation ${id} is running`, log);
|
|
394
|
+
if (keepAwake) {
|
|
395
|
+
log.info(`keep-awake enabled via ${keepAwake.backend}.`);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
const observers = await loadConventionObservers(log);
|
|
399
|
+
const baseClient = dryRun ? createDryClient(rawClient, log) : rawClient;
|
|
400
|
+
const client = createAuditedClient(baseClient, audit, dryRun, observers);
|
|
401
|
+
const startHooks = [];
|
|
402
|
+
const stopHooks = [];
|
|
403
|
+
const errorHooks = [];
|
|
404
|
+
const scheduledTasks = [];
|
|
405
|
+
// Build the API object
|
|
406
|
+
const publish = createAuditedPublish(createPublish(id, log, gatewayPort, hooksToken), audit);
|
|
407
|
+
const auditApi = {
|
|
408
|
+
record: (kind, payload) => {
|
|
409
|
+
audit.recordNote(kind, payload);
|
|
410
|
+
fanOutNote(observers, kind, payload);
|
|
411
|
+
},
|
|
412
|
+
metric: (name, value, tags) => {
|
|
413
|
+
audit.recordMetric(name, value, tags);
|
|
414
|
+
fanOutMetric(observers, name, value, tags);
|
|
415
|
+
},
|
|
416
|
+
};
|
|
417
|
+
const api = {
|
|
418
|
+
client,
|
|
419
|
+
utils: { roundPrice, roundSize, sleep, normalizeCoin, formatUsd, formatPercent, annualizeFundingRate },
|
|
420
|
+
on: (event, handler) => eventBus.on(event, handler),
|
|
421
|
+
every: (intervalMs, handler) => scheduledTasks.push({ intervalMs, handler, lastRun: 0 }),
|
|
422
|
+
onStart: (handler) => startHooks.push(handler),
|
|
423
|
+
onStop: (handler) => stopHooks.push(handler),
|
|
424
|
+
onError: (handler) => errorHooks.push(handler),
|
|
425
|
+
publish,
|
|
426
|
+
state: stateController.state,
|
|
427
|
+
log,
|
|
428
|
+
audit: auditApi,
|
|
429
|
+
id,
|
|
430
|
+
dryRun,
|
|
431
|
+
};
|
|
432
|
+
try {
|
|
433
|
+
// Load and execute the factory function (registers handlers)
|
|
434
|
+
log.info(`Loading automation: ${scriptPath}`);
|
|
435
|
+
const factory = await loadAutomation(scriptPath);
|
|
436
|
+
await factory(api);
|
|
437
|
+
// Call onStart hooks
|
|
438
|
+
for (const hook of startHooks) {
|
|
439
|
+
try {
|
|
440
|
+
await hook();
|
|
441
|
+
}
|
|
442
|
+
catch (err) {
|
|
443
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
444
|
+
audit.recordError('onStart', error);
|
|
445
|
+
log.error(`onStart hook error: ${error.message}`);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
catch (err) {
|
|
450
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
451
|
+
audit.recordError('startup', error);
|
|
452
|
+
await audit.stop({
|
|
453
|
+
status: 'error',
|
|
454
|
+
stopReason: 'startup_error',
|
|
455
|
+
pollCount: 0,
|
|
456
|
+
eventsEmitted: 0,
|
|
457
|
+
});
|
|
458
|
+
keepAwake?.stop();
|
|
459
|
+
throw error;
|
|
460
|
+
}
|
|
461
|
+
// Polling state (declared early so WebSocket handlers can reference)
|
|
462
|
+
let previousSnapshot = null;
|
|
463
|
+
let pollCount = 0;
|
|
464
|
+
let eventsEmitted = 0;
|
|
465
|
+
let isPolling = false;
|
|
466
|
+
let stopped = false;
|
|
467
|
+
async function handleErrors(errors) {
|
|
468
|
+
for (const err of errors) {
|
|
469
|
+
audit.recordError('handler', err);
|
|
470
|
+
log.error(`Handler error: ${err.message}`);
|
|
471
|
+
for (const hook of errorHooks) {
|
|
472
|
+
try {
|
|
473
|
+
await hook(err);
|
|
474
|
+
}
|
|
475
|
+
catch { /* swallow */ }
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
function shouldPersistEvent(event) {
|
|
480
|
+
return event !== 'tick' && event !== 'price_change';
|
|
481
|
+
}
|
|
482
|
+
async function emitAutomationEvent(event, payload, source) {
|
|
483
|
+
if (shouldPersistEvent(event)) {
|
|
484
|
+
audit.recordEvent(event, source, payload);
|
|
485
|
+
}
|
|
486
|
+
const errors = await eventBus.emit(event, payload);
|
|
487
|
+
if (errors.length)
|
|
488
|
+
await handleErrors(errors);
|
|
489
|
+
eventsEmitted++;
|
|
490
|
+
}
|
|
491
|
+
// ── WebSocket setup ─────────────────────────────────────────────
|
|
492
|
+
let ws = null;
|
|
493
|
+
let wsConnected = false;
|
|
494
|
+
// Track latest prices from WebSocket for real-time price_change events
|
|
495
|
+
let wsPrices = new Map();
|
|
496
|
+
if (useWebSocket) {
|
|
497
|
+
try {
|
|
498
|
+
ws = new WebSocketManager(verbose);
|
|
499
|
+
// Wire WebSocket events to the automation event bus
|
|
500
|
+
ws.on('allMids', ({ mids }) => {
|
|
501
|
+
const now = Date.now();
|
|
502
|
+
for (const [coin, mid] of Object.entries(mids)) {
|
|
503
|
+
const newPrice = parseFloat(mid);
|
|
504
|
+
if (isNaN(newPrice) || newPrice === 0)
|
|
505
|
+
continue;
|
|
506
|
+
const oldPrice = wsPrices.get(coin);
|
|
507
|
+
wsPrices.set(coin, newPrice);
|
|
508
|
+
if (oldPrice !== undefined && oldPrice !== 0 && eventBus.has('price_change')) {
|
|
509
|
+
const changePct = ((newPrice - oldPrice) / oldPrice) * 100;
|
|
510
|
+
if (Math.abs(changePct) >= 0.01) {
|
|
511
|
+
void emitAutomationEvent('price_change', { coin, oldPrice, newPrice, changePct }, 'ws');
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
});
|
|
516
|
+
ws.on('orderUpdate', (update) => {
|
|
517
|
+
audit.recordOrderUpdate({
|
|
518
|
+
coin: update.order.coin,
|
|
519
|
+
oid: update.order.oid,
|
|
520
|
+
side: update.order.side === 'B' ? 'buy' : 'sell',
|
|
521
|
+
size: parseFloat(update.order.sz),
|
|
522
|
+
price: parseFloat(update.order.limitPx),
|
|
523
|
+
origSize: parseFloat(update.order.origSz),
|
|
524
|
+
status: update.status,
|
|
525
|
+
statusTimestamp: update.statusTimestamp,
|
|
526
|
+
raw: update,
|
|
527
|
+
});
|
|
528
|
+
if (eventBus.has('order_update')) {
|
|
529
|
+
void emitAutomationEvent('order_update', {
|
|
530
|
+
coin: update.order.coin,
|
|
531
|
+
oid: update.order.oid,
|
|
532
|
+
side: update.order.side === 'B' ? 'buy' : 'sell',
|
|
533
|
+
size: parseFloat(update.order.sz),
|
|
534
|
+
price: parseFloat(update.order.limitPx),
|
|
535
|
+
origSize: parseFloat(update.order.origSz),
|
|
536
|
+
status: update.status,
|
|
537
|
+
statusTimestamp: update.statusTimestamp,
|
|
538
|
+
}, 'ws');
|
|
539
|
+
}
|
|
540
|
+
// NOTE: order_filled is emitted from the userFill handler below, not from
|
|
541
|
+
// here. The previous implementation fired it from orderUpdate.status
|
|
542
|
+
// === 'filled' using update.order.sz as the size, but that field is the
|
|
543
|
+
// REMAINING size (0 on a terminal fill), not the fill delta — so every
|
|
544
|
+
// consumer saw size=0. Additionally, Hyperliquid does not emit
|
|
545
|
+
// orderUpdate events for pure partial fills that don't transition
|
|
546
|
+
// status, so partial fills were silently dropped entirely. Sourcing
|
|
547
|
+
// order_filled from userFill fixes both issues: sz there IS the fill
|
|
548
|
+
// delta, and the userFills stream fires on every fill (partial and
|
|
549
|
+
// terminal).
|
|
550
|
+
});
|
|
551
|
+
ws.on('userFill', (fill) => {
|
|
552
|
+
audit.recordFill({
|
|
553
|
+
coin: fill.coin,
|
|
554
|
+
side: fill.side === 'B' ? 'buy' : 'sell',
|
|
555
|
+
size: fill.sz,
|
|
556
|
+
price: fill.px,
|
|
557
|
+
time: fill.time,
|
|
558
|
+
closedPnl: fill.closedPnl,
|
|
559
|
+
fee: fill.fee,
|
|
560
|
+
oid: fill.oid,
|
|
561
|
+
crossed: fill.crossed,
|
|
562
|
+
}, fill.time);
|
|
563
|
+
log.debug(`Fill: ${fill.side === 'B' ? 'BUY' : 'SELL'} ${fill.sz} ${fill.coin} @ ${fill.px} (PnL: ${fill.closedPnl})`);
|
|
564
|
+
// Emit order_filled with the authoritative fill delta + fee/pnl from
|
|
565
|
+
// the userFills WS stream. Covers both partial and terminal fills.
|
|
566
|
+
// Fee is converted to USD using feeToken: for non-USDC fees (spot
|
|
567
|
+
// buys pay in the received asset), fee × price yields USD since the
|
|
568
|
+
// fee token is the base of the traded pair and `price` is quote/base.
|
|
569
|
+
if (eventBus.has('order_filled')) {
|
|
570
|
+
const size = parseFloat(fill.sz);
|
|
571
|
+
const price = parseFloat(fill.px);
|
|
572
|
+
const rawFee = parseFloat(fill.fee);
|
|
573
|
+
const closedPnl = parseFloat(fill.closedPnl);
|
|
574
|
+
const feeToken = fill.feeToken;
|
|
575
|
+
let feeUsd;
|
|
576
|
+
if (Number.isFinite(rawFee)) {
|
|
577
|
+
if (feeToken === 'USDC' || !feeToken) {
|
|
578
|
+
feeUsd = rawFee;
|
|
579
|
+
}
|
|
580
|
+
else if (Number.isFinite(price) && price > 0) {
|
|
581
|
+
feeUsd = rawFee * price;
|
|
582
|
+
}
|
|
583
|
+
else {
|
|
584
|
+
feeUsd = undefined;
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
void emitAutomationEvent('order_filled', {
|
|
588
|
+
coin: fill.coin,
|
|
589
|
+
oid: fill.oid,
|
|
590
|
+
side: fill.side === 'B' ? 'buy' : 'sell',
|
|
591
|
+
size,
|
|
592
|
+
price,
|
|
593
|
+
fee: feeUsd,
|
|
594
|
+
feeToken,
|
|
595
|
+
closedPnl: Number.isFinite(closedPnl) ? closedPnl : undefined,
|
|
596
|
+
crossed: fill.crossed,
|
|
597
|
+
}, 'ws');
|
|
598
|
+
}
|
|
599
|
+
});
|
|
600
|
+
ws.on('userEvent', (event) => {
|
|
601
|
+
audit.recordUserEvent(event);
|
|
602
|
+
// Handle liquidation events — only available through WebSocket
|
|
603
|
+
if ('liquidation' in event && eventBus.has('liquidation')) {
|
|
604
|
+
const liq = event.liquidation;
|
|
605
|
+
void emitAutomationEvent('liquidation', {
|
|
606
|
+
lid: liq.lid,
|
|
607
|
+
liquidator: liq.liquidator,
|
|
608
|
+
liquidatedUser: liq.liquidated_user,
|
|
609
|
+
liquidatedNtlPos: parseFloat(liq.liquidated_ntl_pos),
|
|
610
|
+
liquidatedAccountValue: parseFloat(liq.liquidated_account_value),
|
|
611
|
+
}, 'ws');
|
|
612
|
+
}
|
|
613
|
+
});
|
|
614
|
+
ws.on('error', ({ error }) => {
|
|
615
|
+
audit.recordError('websocket', error);
|
|
616
|
+
log.warn(`WebSocket error: ${error.message}`);
|
|
617
|
+
});
|
|
618
|
+
ws.on('disconnected', () => {
|
|
619
|
+
wsConnected = false;
|
|
620
|
+
log.warn('WebSocket disconnected — falling back to REST polling');
|
|
621
|
+
});
|
|
622
|
+
ws.on('connected', () => {
|
|
623
|
+
wsConnected = true;
|
|
624
|
+
log.info('WebSocket connected — real-time events active');
|
|
625
|
+
});
|
|
626
|
+
// Connect and subscribe
|
|
627
|
+
const userAddress = rawClient.address;
|
|
628
|
+
await ws.subscribeAll(userAddress);
|
|
629
|
+
log.info('WebSocket subscriptions active (allMids, orderUpdates, userFills, userEvents)');
|
|
630
|
+
}
|
|
631
|
+
catch (err) {
|
|
632
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
633
|
+
audit.recordError('websocket_setup', error);
|
|
634
|
+
if (verbose && error.stack) {
|
|
635
|
+
log.debug(`WebSocket setup stack: ${error.stack}`);
|
|
636
|
+
}
|
|
637
|
+
log.warn(`WebSocket setup failed: ${error.message} — using REST polling only`);
|
|
638
|
+
ws = null;
|
|
639
|
+
wsConnected = false;
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
async function poll() {
|
|
643
|
+
if (isPolling || stopped)
|
|
644
|
+
return;
|
|
645
|
+
isPolling = true;
|
|
646
|
+
try {
|
|
647
|
+
const snapshot = await buildSnapshot(rawClient);
|
|
648
|
+
pollCount++;
|
|
649
|
+
const now = Date.now();
|
|
650
|
+
audit.recordSnapshot({
|
|
651
|
+
pollCount,
|
|
652
|
+
equity: snapshot.equity,
|
|
653
|
+
marginUsed: snapshot.marginUsed,
|
|
654
|
+
marginUsedPct: snapshot.marginUsedPct,
|
|
655
|
+
positions: [...snapshot.positions.values()],
|
|
656
|
+
timestamp: now,
|
|
657
|
+
});
|
|
658
|
+
// Always emit tick
|
|
659
|
+
await emitAutomationEvent('tick', { timestamp: now, pollCount }, 'poll');
|
|
660
|
+
if (previousSnapshot) {
|
|
661
|
+
// Price changes (skip when WebSocket is handling real-time prices)
|
|
662
|
+
if (eventBus.has('price_change') && !wsConnected) {
|
|
663
|
+
for (const [coin, newPrice] of snapshot.prices) {
|
|
664
|
+
const oldPrice = previousSnapshot.prices.get(coin);
|
|
665
|
+
if (oldPrice === undefined || oldPrice === 0)
|
|
666
|
+
continue;
|
|
667
|
+
const changePct = ((newPrice - oldPrice) / oldPrice) * 100;
|
|
668
|
+
if (Math.abs(changePct) >= 0.01) { // 0.01% minimum to fire (filters rounding noise)
|
|
669
|
+
await emitAutomationEvent('price_change', { coin, oldPrice, newPrice, changePct }, 'poll');
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
// Funding updates
|
|
674
|
+
if (eventBus.has('funding_update')) {
|
|
675
|
+
for (const [coin, data] of snapshot.fundingRates) {
|
|
676
|
+
await emitAutomationEvent('funding_update', {
|
|
677
|
+
coin,
|
|
678
|
+
fundingRate: data.rate,
|
|
679
|
+
annualized: annualizeFundingRate(data.rate),
|
|
680
|
+
premium: data.premium,
|
|
681
|
+
}, 'poll');
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
// Position opened
|
|
685
|
+
if (eventBus.has('position_opened')) {
|
|
686
|
+
for (const [coin, pos] of snapshot.positions) {
|
|
687
|
+
if (!previousSnapshot.positions.has(coin)) {
|
|
688
|
+
await emitAutomationEvent('position_opened', {
|
|
689
|
+
coin,
|
|
690
|
+
side: pos.size > 0 ? 'long' : 'short',
|
|
691
|
+
size: Math.abs(pos.size),
|
|
692
|
+
entryPrice: pos.entryPrice,
|
|
693
|
+
}, 'poll');
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
// Position closed
|
|
698
|
+
if (eventBus.has('position_closed')) {
|
|
699
|
+
for (const [coin, prevPos] of previousSnapshot.positions) {
|
|
700
|
+
if (!snapshot.positions.has(coin)) {
|
|
701
|
+
await emitAutomationEvent('position_closed', {
|
|
702
|
+
coin,
|
|
703
|
+
previousSize: prevPos.size,
|
|
704
|
+
entryPrice: prevPos.entryPrice,
|
|
705
|
+
}, 'poll');
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
// Position size changed
|
|
710
|
+
if (eventBus.has('position_changed')) {
|
|
711
|
+
for (const [coin, pos] of snapshot.positions) {
|
|
712
|
+
const prevPos = previousSnapshot.positions.get(coin);
|
|
713
|
+
if (prevPos && pos.size !== prevPos.size) {
|
|
714
|
+
await emitAutomationEvent('position_changed', {
|
|
715
|
+
coin,
|
|
716
|
+
oldSize: prevPos.size,
|
|
717
|
+
newSize: pos.size,
|
|
718
|
+
entryPrice: pos.entryPrice,
|
|
719
|
+
}, 'poll');
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
// PnL threshold (5% of position value)
|
|
724
|
+
if (eventBus.has('pnl_threshold')) {
|
|
725
|
+
for (const [coin, pos] of snapshot.positions) {
|
|
726
|
+
const prevPos = previousSnapshot.positions.get(coin);
|
|
727
|
+
if (!prevPos || pos.positionValue === 0)
|
|
728
|
+
continue;
|
|
729
|
+
const pnlChange = Math.abs(pos.unrealizedPnl - prevPos.unrealizedPnl);
|
|
730
|
+
const changePct = (pnlChange / pos.positionValue) * 100;
|
|
731
|
+
if (changePct >= 5) {
|
|
732
|
+
await emitAutomationEvent('pnl_threshold', {
|
|
733
|
+
coin,
|
|
734
|
+
unrealizedPnl: pos.unrealizedPnl,
|
|
735
|
+
changePct,
|
|
736
|
+
positionValue: pos.positionValue,
|
|
737
|
+
}, 'poll');
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
// Margin warning (80%)
|
|
742
|
+
if (eventBus.has('margin_warning') && snapshot.marginUsedPct >= 80) {
|
|
743
|
+
const prevPct = previousSnapshot.marginUsedPct;
|
|
744
|
+
if (prevPct < 80 || snapshot.marginUsedPct - prevPct >= 5) {
|
|
745
|
+
await emitAutomationEvent('margin_warning', {
|
|
746
|
+
marginUsedPct: snapshot.marginUsedPct,
|
|
747
|
+
equity: snapshot.equity,
|
|
748
|
+
marginUsed: snapshot.marginUsed,
|
|
749
|
+
}, 'poll');
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
// Order filled — compare open order IDs
|
|
753
|
+
// (Skipped for MVP — requires tracking open orders per poll, will add when needed)
|
|
754
|
+
}
|
|
755
|
+
// Run scheduled tasks
|
|
756
|
+
for (const task of scheduledTasks) {
|
|
757
|
+
if (now - task.lastRun >= task.intervalMs) {
|
|
758
|
+
try {
|
|
759
|
+
await task.handler();
|
|
760
|
+
}
|
|
761
|
+
catch (err) {
|
|
762
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
763
|
+
audit.recordError('scheduled_task', error);
|
|
764
|
+
log.error(`Scheduled task error: ${error.message}`);
|
|
765
|
+
await handleErrors([error]);
|
|
766
|
+
}
|
|
767
|
+
task.lastRun = now;
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
previousSnapshot = snapshot;
|
|
771
|
+
}
|
|
772
|
+
catch (err) {
|
|
773
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
774
|
+
audit.recordError('poll', error);
|
|
775
|
+
log.error(`Poll error: ${error.message}`);
|
|
776
|
+
}
|
|
777
|
+
finally {
|
|
778
|
+
isPolling = false;
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
// Start polling
|
|
782
|
+
const wsLabel = wsConnected ? ', ws=on' : (useWebSocket ? ', ws=failed' : '');
|
|
783
|
+
log.info(`Started (poll every ${pollIntervalMs / 1000}s, dry=${dryRun}${wsLabel})`);
|
|
784
|
+
const timer = setInterval(poll, pollIntervalMs);
|
|
785
|
+
// Initial poll to seed state
|
|
786
|
+
await poll();
|
|
787
|
+
// Stop function
|
|
788
|
+
async function stop(opts) {
|
|
789
|
+
if (stopped)
|
|
790
|
+
return;
|
|
791
|
+
stopped = true;
|
|
792
|
+
clearInterval(timer);
|
|
793
|
+
// Close WebSocket
|
|
794
|
+
if (ws) {
|
|
795
|
+
ws.removeAllListeners();
|
|
796
|
+
await ws.close();
|
|
797
|
+
ws = null;
|
|
798
|
+
}
|
|
799
|
+
for (const hook of stopHooks) {
|
|
800
|
+
try {
|
|
801
|
+
await hook();
|
|
802
|
+
}
|
|
803
|
+
catch (err) {
|
|
804
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
805
|
+
audit.recordError('onStop', error);
|
|
806
|
+
log.error(`onStop hook error: ${error.message}`);
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
keepAwake?.stop();
|
|
810
|
+
eventBus.removeAll();
|
|
811
|
+
registry.delete(id);
|
|
812
|
+
// persist defaults to true — fully remove from file registry.
|
|
813
|
+
// When false (gateway shutdown), keep the entry so it restarts next time.
|
|
814
|
+
if (opts?.persist !== false) {
|
|
815
|
+
unregisterAutomation(id);
|
|
816
|
+
}
|
|
817
|
+
log.info(`Stopped (${pollCount} polls, ${eventsEmitted} events)`);
|
|
818
|
+
await audit.stop({
|
|
819
|
+
status: 'stopped',
|
|
820
|
+
stopReason: opts?.persist === false ? 'shutdown_keep_registry' : 'manual_stop',
|
|
821
|
+
pollCount,
|
|
822
|
+
eventsEmitted,
|
|
823
|
+
});
|
|
824
|
+
}
|
|
825
|
+
const entry = {
|
|
826
|
+
id,
|
|
827
|
+
scriptPath,
|
|
828
|
+
startedAt: new Date(),
|
|
829
|
+
get pollCount() { return pollCount; },
|
|
830
|
+
get eventsEmitted() { return eventsEmitted; },
|
|
831
|
+
dryRun,
|
|
832
|
+
stop,
|
|
833
|
+
};
|
|
834
|
+
registry.set(id, entry);
|
|
835
|
+
// Persist to file-based registry so other processes (CLI, plugin) can see it
|
|
836
|
+
registerAutomation({
|
|
837
|
+
id,
|
|
838
|
+
scriptPath,
|
|
839
|
+
dryRun,
|
|
840
|
+
verbose,
|
|
841
|
+
pollIntervalMs,
|
|
842
|
+
});
|
|
843
|
+
return entry;
|
|
844
|
+
}
|