openbroker 1.0.67 → 1.0.68
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/SKILL.md +25 -51
- package/bin/cli.ts +2 -14
- package/openclaw.plugin.json +1 -1
- package/package.json +4 -9
- package/scripts/auto/cli.ts +114 -20
- package/scripts/auto/examples/dca.ts +72 -0
- package/scripts/auto/examples/funding-arb.ts +98 -0
- package/scripts/auto/examples/grid.ts +135 -0
- package/scripts/auto/examples/mm-maker.ts +125 -0
- package/scripts/auto/examples/mm-spread.ts +115 -0
- package/scripts/auto/loader.ts +47 -1
- package/scripts/auto/runtime.ts +13 -0
- package/scripts/auto/types.ts +14 -0
- package/scripts/plugin/tools.ts +20 -7
- package/scripts/strategies/dca.ts +0 -292
- package/scripts/strategies/funding-arb.ts +0 -352
- package/scripts/strategies/grid.ts +0 -397
- package/scripts/strategies/mm-maker.ts +0 -411
- package/scripts/strategies/mm-spread.ts +0 -402
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// Grid Trading — Place buy/sell orders at evenly spaced price levels
|
|
2
|
+
|
|
3
|
+
import type { AutomationAPI, AutomationConfig } from '../types.js';
|
|
4
|
+
|
|
5
|
+
export const config: AutomationConfig = {
|
|
6
|
+
description: 'Grid trading — buy/sell orders at evenly spaced price levels',
|
|
7
|
+
fields: {
|
|
8
|
+
coin: { type: 'string', description: 'Asset to trade', default: 'HYPE' },
|
|
9
|
+
lower: { type: 'number', description: 'Lower price bound (default: auto -5% from mid)', default: 0 },
|
|
10
|
+
upper: { type: 'number', description: 'Upper price bound (default: auto +5% from mid)', default: 0 },
|
|
11
|
+
grids: { type: 'number', description: 'Number of grid levels', default: 10 },
|
|
12
|
+
size: { type: 'number', description: 'Size per level in base asset', default: 0.1 },
|
|
13
|
+
mode: { type: 'string', description: 'Grid mode: neutral, long, or short', default: 'neutral' },
|
|
14
|
+
},
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
interface GridLevel {
|
|
18
|
+
price: number;
|
|
19
|
+
side: 'buy' | 'sell';
|
|
20
|
+
size: number;
|
|
21
|
+
oid?: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export default function grid(api: AutomationAPI) {
|
|
25
|
+
const COIN = api.state.get<string>('coin', 'HYPE')!;
|
|
26
|
+
const GRIDS = api.state.get<number>('grids', 10)!;
|
|
27
|
+
const SIZE = api.state.get<number>('size', 0.1)!;
|
|
28
|
+
const MODE = api.state.get<string>('mode', 'neutral')!;
|
|
29
|
+
|
|
30
|
+
let levels: GridLevel[] = [];
|
|
31
|
+
let realizedPnl = api.state.get<number>('realizedPnl', 0)!;
|
|
32
|
+
let initialized = false;
|
|
33
|
+
|
|
34
|
+
api.onStart(async () => {
|
|
35
|
+
const mids = await api.client.getAllMids();
|
|
36
|
+
const mid = parseFloat(mids[COIN]);
|
|
37
|
+
if (!mid) {
|
|
38
|
+
api.log.error(`No price for ${COIN}`);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const lower = api.state.get<number>('lower', mid * 0.95)!;
|
|
43
|
+
const upper = api.state.get<number>('upper', mid * 1.05)!;
|
|
44
|
+
const spacing = (upper - lower) / (GRIDS - 1);
|
|
45
|
+
|
|
46
|
+
api.log.info(`Grid: ${COIN} ${api.utils.formatUsd(lower)}-${api.utils.formatUsd(upper)} | ${GRIDS} levels | ${SIZE}/level | ${MODE}`);
|
|
47
|
+
|
|
48
|
+
// Build and place grid
|
|
49
|
+
for (let i = 0; i < GRIDS; i++) {
|
|
50
|
+
const price = lower + spacing * i;
|
|
51
|
+
let side: 'buy' | 'sell';
|
|
52
|
+
if (MODE === 'long') side = 'buy';
|
|
53
|
+
else if (MODE === 'short') side = 'sell';
|
|
54
|
+
else side = price < mid ? 'buy' : 'sell';
|
|
55
|
+
|
|
56
|
+
// Skip levels too close to mid
|
|
57
|
+
if (Math.abs(price - mid) / mid < 0.001) continue;
|
|
58
|
+
|
|
59
|
+
const level: GridLevel = { price, side, size: SIZE };
|
|
60
|
+
|
|
61
|
+
const response = await api.client.limitOrder(COIN, side === 'buy', SIZE, price, 'Gtc', false);
|
|
62
|
+
if (response.status === 'ok' && response.response && typeof response.response === 'object') {
|
|
63
|
+
const status = response.response.data.statuses[0];
|
|
64
|
+
if (status?.resting) {
|
|
65
|
+
level.oid = status.resting.oid;
|
|
66
|
+
api.log.info(`${side.toUpperCase()} @ ${api.utils.formatUsd(price)} — OID: ${level.oid}`);
|
|
67
|
+
} else if (status?.filled) {
|
|
68
|
+
api.log.info(`${side.toUpperCase()} @ ${api.utils.formatUsd(price)} — filled immediately`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
levels.push(level);
|
|
73
|
+
await api.utils.sleep(100);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
initialized = true;
|
|
77
|
+
api.log.info(`Grid initialized: ${levels.filter(l => l.oid).length} open orders`);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// Monitor fills and replace with opposite orders
|
|
81
|
+
api.on('tick', async () => {
|
|
82
|
+
if (!initialized || levels.length === 0) return;
|
|
83
|
+
|
|
84
|
+
const openOrders = await api.client.getOpenOrders();
|
|
85
|
+
const openOids = new Set(openOrders.filter(o => o.coin === COIN).map(o => o.oid));
|
|
86
|
+
|
|
87
|
+
const mids = await api.client.getAllMids();
|
|
88
|
+
const mid = parseFloat(mids[COIN]);
|
|
89
|
+
const lower = api.state.get<number>('lower', mid * 0.95)!;
|
|
90
|
+
const upper = api.state.get<number>('upper', mid * 1.05)!;
|
|
91
|
+
const spacing = (upper - lower) / (GRIDS - 1);
|
|
92
|
+
|
|
93
|
+
for (const level of levels) {
|
|
94
|
+
if (!level.oid || openOids.has(level.oid)) continue;
|
|
95
|
+
|
|
96
|
+
// Order was filled
|
|
97
|
+
api.log.info(`${level.side.toUpperCase()} FILLED @ ${api.utils.formatUsd(level.price)}`);
|
|
98
|
+
level.oid = undefined;
|
|
99
|
+
|
|
100
|
+
if (MODE !== 'neutral') continue;
|
|
101
|
+
|
|
102
|
+
// Place opposite order
|
|
103
|
+
const oppositeSide = level.side === 'buy' ? 'sell' : 'buy';
|
|
104
|
+
const oppositePrice = level.side === 'buy' ? level.price + spacing : level.price - spacing;
|
|
105
|
+
|
|
106
|
+
if (oppositePrice < lower || oppositePrice > upper) continue;
|
|
107
|
+
|
|
108
|
+
const response = await api.client.limitOrder(COIN, oppositeSide === 'buy', SIZE, oppositePrice, 'Gtc', false);
|
|
109
|
+
if (response.status === 'ok' && response.response && typeof response.response === 'object') {
|
|
110
|
+
const status = response.response.data.statuses[0];
|
|
111
|
+
if (status?.resting) {
|
|
112
|
+
const newLevel: GridLevel = { price: oppositePrice, side: oppositeSide, size: SIZE, oid: status.resting.oid };
|
|
113
|
+
levels.push(newLevel);
|
|
114
|
+
|
|
115
|
+
if (level.side === 'buy') {
|
|
116
|
+
realizedPnl += (oppositePrice - level.price) * SIZE;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
api.state.set('realizedPnl', realizedPnl);
|
|
120
|
+
api.log.info(`Placed ${oppositeSide.toUpperCase()} @ ${api.utils.formatUsd(oppositePrice)} | PnL: ${api.utils.formatUsd(realizedPnl)}`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
api.onStop(async () => {
|
|
127
|
+
api.log.info('Cancelling grid orders...');
|
|
128
|
+
for (const level of levels) {
|
|
129
|
+
if (level.oid) {
|
|
130
|
+
try { await api.client.cancel(COIN, level.oid); } catch { /* may be filled */ }
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
api.log.info(`Grid stopped. Realized PnL: ${api.utils.formatUsd(realizedPnl)}`);
|
|
134
|
+
});
|
|
135
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// Maker-Only Market Making — ALO orders that guarantee maker rebates
|
|
2
|
+
|
|
3
|
+
import type { AutomationAPI, AutomationConfig } from '../types.js';
|
|
4
|
+
|
|
5
|
+
export const config: AutomationConfig = {
|
|
6
|
+
description: 'Maker-only market making — ALO orders for guaranteed maker rebates',
|
|
7
|
+
fields: {
|
|
8
|
+
coin: { type: 'string', description: 'Asset to market make', default: 'HYPE' },
|
|
9
|
+
size: { type: 'number', description: 'Order size on each side (base asset)', default: 0.1 },
|
|
10
|
+
offsetBps: { type: 'number', description: 'Offset from best bid/ask in bps', default: 1 },
|
|
11
|
+
maxPosition: { type: 'number', description: 'Max net position (default: 3x size)', default: 0.3 },
|
|
12
|
+
skewFactor: { type: 'number', description: 'Inventory skew aggressiveness', default: 2.0 },
|
|
13
|
+
},
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export default function mmMaker(api: AutomationAPI) {
|
|
17
|
+
const COIN = api.state.get<string>('coin', 'HYPE')!;
|
|
18
|
+
const SIZE = api.state.get<number>('size', 0.1)!;
|
|
19
|
+
const OFFSET_BPS = api.state.get<number>('offsetBps', 1)!;
|
|
20
|
+
const MAX_POS = api.state.get<number>('maxPosition', SIZE * 3)!;
|
|
21
|
+
const SKEW = api.state.get<number>('skewFactor', 2.0)!;
|
|
22
|
+
|
|
23
|
+
let bidOid: number | undefined;
|
|
24
|
+
let askOid: number | undefined;
|
|
25
|
+
let bidPrice = 0;
|
|
26
|
+
let askPrice = 0;
|
|
27
|
+
let totalBought = 0;
|
|
28
|
+
let totalSold = 0;
|
|
29
|
+
let totalBuyCost = 0;
|
|
30
|
+
let totalSellRevenue = 0;
|
|
31
|
+
let rejections = 0;
|
|
32
|
+
|
|
33
|
+
const offsetFraction = OFFSET_BPS / 10000;
|
|
34
|
+
|
|
35
|
+
api.onStart(() => {
|
|
36
|
+
api.log.info(`Maker MM: ${COIN} | ${SIZE}/side | ${OFFSET_BPS}bps offset | ALO only`);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
api.on('tick', async () => {
|
|
40
|
+
const book = await api.client.getL2Book(COIN);
|
|
41
|
+
if (book.bestBid === 0 || book.bestAsk === 0) return;
|
|
42
|
+
|
|
43
|
+
// Get position
|
|
44
|
+
const userState = await api.client.getUserState();
|
|
45
|
+
const pos = userState.assetPositions.find(p => p.position.coin === COIN);
|
|
46
|
+
const position = pos ? parseFloat(pos.position.szi) : 0;
|
|
47
|
+
|
|
48
|
+
// Check fills
|
|
49
|
+
const openOrders = await api.client.getOpenOrders();
|
|
50
|
+
const openOids = new Set(openOrders.filter(o => o.coin === COIN).map(o => o.oid));
|
|
51
|
+
|
|
52
|
+
if (bidOid && !openOids.has(bidOid)) {
|
|
53
|
+
totalBought += SIZE;
|
|
54
|
+
totalBuyCost += bidPrice * SIZE;
|
|
55
|
+
api.log.info(`BID FILLED @ ${api.utils.formatUsd(bidPrice)} | Pos: ${position.toFixed(4)} | +rebate`);
|
|
56
|
+
bidOid = undefined;
|
|
57
|
+
}
|
|
58
|
+
if (askOid && !openOids.has(askOid)) {
|
|
59
|
+
totalSold += SIZE;
|
|
60
|
+
totalSellRevenue += askPrice * SIZE;
|
|
61
|
+
api.log.info(`ASK FILLED @ ${api.utils.formatUsd(askPrice)} | Pos: ${position.toFixed(4)} | +rebate`);
|
|
62
|
+
askOid = undefined;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Inventory skew
|
|
66
|
+
const ratio = Math.max(-1, Math.min(1, position / MAX_POS));
|
|
67
|
+
const bidSkewMult = 1 + ratio * SKEW;
|
|
68
|
+
const askSkewMult = 1 - ratio * SKEW;
|
|
69
|
+
|
|
70
|
+
const targetBid = book.bestBid * (1 - offsetFraction * Math.max(0.1, bidSkewMult));
|
|
71
|
+
const targetAsk = book.bestAsk * (1 + offsetFraction * Math.max(0.1, askSkewMult));
|
|
72
|
+
|
|
73
|
+
// Ensure no crossing
|
|
74
|
+
const safeBid = Math.min(targetBid, book.bestAsk * 0.9999);
|
|
75
|
+
const safeAsk = Math.max(targetAsk, book.bestBid * 1.0001);
|
|
76
|
+
|
|
77
|
+
const shouldBid = position < MAX_POS;
|
|
78
|
+
const shouldAsk = position > -MAX_POS;
|
|
79
|
+
|
|
80
|
+
// Cancel stale quotes
|
|
81
|
+
if (bidOid) {
|
|
82
|
+
const drift = Math.abs(bidPrice - safeBid) / book.midPrice;
|
|
83
|
+
if (drift > 0.0005 || !shouldBid) {
|
|
84
|
+
try { await api.client.cancel(COIN, bidOid); } catch { /* */ }
|
|
85
|
+
bidOid = undefined;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (askOid) {
|
|
89
|
+
const drift = Math.abs(askPrice - safeAsk) / book.midPrice;
|
|
90
|
+
if (drift > 0.0005 || !shouldAsk) {
|
|
91
|
+
try { await api.client.cancel(COIN, askOid); } catch { /* */ }
|
|
92
|
+
askOid = undefined;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Place ALO bid
|
|
97
|
+
if (shouldBid && !bidOid && safeBid < book.bestAsk) {
|
|
98
|
+
const resp = await api.client.limitOrder(COIN, true, SIZE, safeBid, 'Alo', false);
|
|
99
|
+
if (resp.status === 'ok' && resp.response && typeof resp.response === 'object') {
|
|
100
|
+
const s = resp.response.data.statuses[0];
|
|
101
|
+
if (s?.resting) { bidOid = s.resting.oid; bidPrice = safeBid; }
|
|
102
|
+
else if (s?.error) { rejections++; }
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Place ALO ask
|
|
107
|
+
if (shouldAsk && !askOid && safeAsk > book.bestBid) {
|
|
108
|
+
const resp = await api.client.limitOrder(COIN, false, SIZE, safeAsk, 'Alo', false);
|
|
109
|
+
if (resp.status === 'ok' && resp.response && typeof resp.response === 'object') {
|
|
110
|
+
const s = resp.response.data.statuses[0];
|
|
111
|
+
if (s?.resting) { askOid = s.resting.oid; askPrice = safeAsk; }
|
|
112
|
+
else if (s?.error) { rejections++; }
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
api.onStop(async () => {
|
|
118
|
+
if (bidOid) try { await api.client.cancel(COIN, bidOid); } catch { /* */ }
|
|
119
|
+
if (askOid) try { await api.client.cancel(COIN, askOid); } catch { /* */ }
|
|
120
|
+
const pnl = totalSellRevenue - totalBuyCost;
|
|
121
|
+
const volume = totalBuyCost + totalSellRevenue;
|
|
122
|
+
const rebates = volume * 0.00003;
|
|
123
|
+
api.log.info(`Maker MM stopped. PnL: ${api.utils.formatUsd(pnl)} | Rebates: ~${api.utils.formatUsd(rebates)} | Rejections: ${rejections}`);
|
|
124
|
+
});
|
|
125
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// Market Making (Spread) — Quote bid/ask around mid with inventory skewing
|
|
2
|
+
|
|
3
|
+
import type { AutomationAPI, AutomationConfig } from '../types.js';
|
|
4
|
+
|
|
5
|
+
export const config: AutomationConfig = {
|
|
6
|
+
description: 'Market making — quote bid/ask around mid price with inventory skewing',
|
|
7
|
+
fields: {
|
|
8
|
+
coin: { type: 'string', description: 'Asset to market make', default: 'HYPE' },
|
|
9
|
+
size: { type: 'number', description: 'Order size on each side (base asset)', default: 0.1 },
|
|
10
|
+
spreadBps: { type: 'number', description: 'Spread in bps from mid price', default: 10 },
|
|
11
|
+
maxPosition: { type: 'number', description: 'Max net position before pausing side (default: 3x size)', default: 0.3 },
|
|
12
|
+
skewFactor: { type: 'number', description: 'Inventory skew aggressiveness', default: 2.0 },
|
|
13
|
+
},
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export default function mmSpread(api: AutomationAPI) {
|
|
17
|
+
const COIN = api.state.get<string>('coin', 'HYPE')!;
|
|
18
|
+
const SIZE = api.state.get<number>('size', 0.1)!;
|
|
19
|
+
const SPREAD_BPS = api.state.get<number>('spreadBps', 10)!;
|
|
20
|
+
const MAX_POS = api.state.get<number>('maxPosition', SIZE * 3)!;
|
|
21
|
+
const SKEW = api.state.get<number>('skewFactor', 2.0)!;
|
|
22
|
+
|
|
23
|
+
let bidOid: number | undefined;
|
|
24
|
+
let askOid: number | undefined;
|
|
25
|
+
let bidPrice = 0;
|
|
26
|
+
let askPrice = 0;
|
|
27
|
+
let totalBought = 0;
|
|
28
|
+
let totalSold = 0;
|
|
29
|
+
let totalBuyCost = 0;
|
|
30
|
+
let totalSellRevenue = 0;
|
|
31
|
+
|
|
32
|
+
const halfSpread = SPREAD_BPS / 10000 / 2;
|
|
33
|
+
|
|
34
|
+
api.onStart(() => {
|
|
35
|
+
api.log.info(`MM Spread: ${COIN} | ${SIZE}/side | ${SPREAD_BPS}bps | Max: ±${MAX_POS}`);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
api.on('tick', async () => {
|
|
39
|
+
const mids = await api.client.getAllMids();
|
|
40
|
+
const mid = parseFloat(mids[COIN]);
|
|
41
|
+
if (!mid) return;
|
|
42
|
+
|
|
43
|
+
// Get position
|
|
44
|
+
const userState = await api.client.getUserState();
|
|
45
|
+
const pos = userState.assetPositions.find(p => p.position.coin === COIN);
|
|
46
|
+
const position = pos ? parseFloat(pos.position.szi) : 0;
|
|
47
|
+
|
|
48
|
+
// Check fills
|
|
49
|
+
const openOrders = await api.client.getOpenOrders();
|
|
50
|
+
const openOids = new Set(openOrders.filter(o => o.coin === COIN).map(o => o.oid));
|
|
51
|
+
|
|
52
|
+
if (bidOid && !openOids.has(bidOid)) {
|
|
53
|
+
totalBought += SIZE;
|
|
54
|
+
totalBuyCost += bidPrice * SIZE;
|
|
55
|
+
api.log.info(`BID FILLED @ ${api.utils.formatUsd(bidPrice)} | Pos: ${position.toFixed(4)}`);
|
|
56
|
+
bidOid = undefined;
|
|
57
|
+
}
|
|
58
|
+
if (askOid && !openOids.has(askOid)) {
|
|
59
|
+
totalSold += SIZE;
|
|
60
|
+
totalSellRevenue += askPrice * SIZE;
|
|
61
|
+
api.log.info(`ASK FILLED @ ${api.utils.formatUsd(askPrice)} | Pos: ${position.toFixed(4)}`);
|
|
62
|
+
askOid = undefined;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Inventory skew
|
|
66
|
+
const ratio = Math.max(-1, Math.min(1, position / MAX_POS));
|
|
67
|
+
const bidSkew = halfSpread * (1 + ratio * SKEW);
|
|
68
|
+
const askSkew = halfSpread * (1 - ratio * SKEW);
|
|
69
|
+
|
|
70
|
+
const targetBid = mid * (1 - Math.max(0.0001, bidSkew));
|
|
71
|
+
const targetAsk = mid * (1 + Math.max(0.0001, askSkew));
|
|
72
|
+
|
|
73
|
+
const shouldBid = position < MAX_POS;
|
|
74
|
+
const shouldAsk = position > -MAX_POS;
|
|
75
|
+
|
|
76
|
+
// Cancel stale quotes
|
|
77
|
+
if (bidOid) {
|
|
78
|
+
const drift = Math.abs(bidPrice - targetBid) / mid;
|
|
79
|
+
if (drift > 0.001 || !shouldBid) {
|
|
80
|
+
try { await api.client.cancel(COIN, bidOid); } catch { /* */ }
|
|
81
|
+
bidOid = undefined;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (askOid) {
|
|
85
|
+
const drift = Math.abs(askPrice - targetAsk) / mid;
|
|
86
|
+
if (drift > 0.001 || !shouldAsk) {
|
|
87
|
+
try { await api.client.cancel(COIN, askOid); } catch { /* */ }
|
|
88
|
+
askOid = undefined;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Place new quotes
|
|
93
|
+
if (shouldBid && !bidOid) {
|
|
94
|
+
const resp = await api.client.limitOrder(COIN, true, SIZE, targetBid, 'Gtc', false);
|
|
95
|
+
if (resp.status === 'ok' && resp.response && typeof resp.response === 'object') {
|
|
96
|
+
const s = resp.response.data.statuses[0];
|
|
97
|
+
if (s?.resting) { bidOid = s.resting.oid; bidPrice = targetBid; }
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (shouldAsk && !askOid) {
|
|
101
|
+
const resp = await api.client.limitOrder(COIN, false, SIZE, targetAsk, 'Gtc', false);
|
|
102
|
+
if (resp.status === 'ok' && resp.response && typeof resp.response === 'object') {
|
|
103
|
+
const s = resp.response.data.statuses[0];
|
|
104
|
+
if (s?.resting) { askOid = s.resting.oid; askPrice = targetAsk; }
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
api.onStop(async () => {
|
|
110
|
+
if (bidOid) try { await api.client.cancel(COIN, bidOid); } catch { /* */ }
|
|
111
|
+
if (askOid) try { await api.client.cancel(COIN, askOid); } catch { /* */ }
|
|
112
|
+
const pnl = totalSellRevenue - totalBuyCost;
|
|
113
|
+
api.log.info(`MM stopped. Bought: ${totalBought.toFixed(6)} | Sold: ${totalSold.toFixed(6)} | PnL: ${api.utils.formatUsd(pnl)}`);
|
|
114
|
+
});
|
|
115
|
+
}
|
package/scripts/auto/loader.ts
CHANGED
|
@@ -3,9 +3,14 @@
|
|
|
3
3
|
import { existsSync, readdirSync, mkdirSync } from 'fs';
|
|
4
4
|
import path from 'path';
|
|
5
5
|
import os from 'os';
|
|
6
|
-
import
|
|
6
|
+
import { fileURLToPath } from 'url';
|
|
7
|
+
import type { AutomationFactory, AutomationConfig } from './types.js';
|
|
8
|
+
|
|
9
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
10
|
+
const __dirname = path.dirname(__filename);
|
|
7
11
|
|
|
8
12
|
const AUTOMATIONS_DIR = path.join(os.homedir(), '.openbroker', 'automations');
|
|
13
|
+
const EXAMPLES_DIR = path.join(__dirname, 'examples');
|
|
9
14
|
|
|
10
15
|
/** Resolve a script path from a name or path */
|
|
11
16
|
export function resolveScriptPath(nameOrPath: string): string {
|
|
@@ -35,6 +40,47 @@ export function resolveScriptPath(nameOrPath: string): string {
|
|
|
35
40
|
);
|
|
36
41
|
}
|
|
37
42
|
|
|
43
|
+
/** Resolve a bundled example by name */
|
|
44
|
+
export function resolveExamplePath(name: string): string {
|
|
45
|
+
const examplePath = path.join(EXAMPLES_DIR, `${name}.ts`);
|
|
46
|
+
if (!existsSync(examplePath)) {
|
|
47
|
+
const available = listExamples().map(e => e.name).join(', ');
|
|
48
|
+
throw new Error(`Unknown example: ${name}\nAvailable: ${available}`);
|
|
49
|
+
}
|
|
50
|
+
return examplePath;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** List bundled example automations */
|
|
54
|
+
export function listExamples(): Array<{ name: string; path: string }> {
|
|
55
|
+
if (!existsSync(EXAMPLES_DIR)) return [];
|
|
56
|
+
|
|
57
|
+
return readdirSync(EXAMPLES_DIR)
|
|
58
|
+
.filter(f => f.endsWith('.ts') && !f.startsWith('.'))
|
|
59
|
+
.map(f => ({
|
|
60
|
+
name: f.replace(/\.ts$/, ''),
|
|
61
|
+
path: path.join(EXAMPLES_DIR, f),
|
|
62
|
+
}));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Load config metadata from all bundled examples */
|
|
66
|
+
export async function loadExampleConfigs(): Promise<Record<string, AutomationConfig>> {
|
|
67
|
+
const examples = listExamples();
|
|
68
|
+
const configs: Record<string, AutomationConfig> = {};
|
|
69
|
+
|
|
70
|
+
for (const example of examples) {
|
|
71
|
+
try {
|
|
72
|
+
const mod = await import(example.path);
|
|
73
|
+
if (mod.config && typeof mod.config === 'object' && mod.config.description) {
|
|
74
|
+
configs[example.name] = mod.config as AutomationConfig;
|
|
75
|
+
}
|
|
76
|
+
} catch {
|
|
77
|
+
// Skip examples that fail to load
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return configs;
|
|
82
|
+
}
|
|
83
|
+
|
|
38
84
|
/** Load an automation module and validate the default export */
|
|
39
85
|
export async function loadAutomation(scriptPath: string): Promise<AutomationFactory> {
|
|
40
86
|
const absolutePath = path.resolve(scriptPath);
|
package/scripts/auto/runtime.ts
CHANGED
|
@@ -231,6 +231,8 @@ export interface RuntimeOptions {
|
|
|
231
231
|
gatewayPort?: number;
|
|
232
232
|
/** Hooks token for webhook auth. Falls back to OPENCLAW_HOOKS_TOKEN */
|
|
233
233
|
hooksToken?: string;
|
|
234
|
+
/** Pre-seed state before the factory function runs (e.g. from --set key=value) */
|
|
235
|
+
initialState?: Record<string, unknown>;
|
|
234
236
|
}
|
|
235
237
|
|
|
236
238
|
/** Registry of all running automations */
|
|
@@ -255,6 +257,7 @@ export async function startAutomation(options: RuntimeOptions): Promise<RunningA
|
|
|
255
257
|
pollIntervalMs = 10_000,
|
|
256
258
|
gatewayPort,
|
|
257
259
|
hooksToken,
|
|
260
|
+
initialState,
|
|
258
261
|
} = options;
|
|
259
262
|
|
|
260
263
|
const id = options.id || path.basename(scriptPath, '.ts');
|
|
@@ -265,6 +268,16 @@ export async function startAutomation(options: RuntimeOptions): Promise<RunningA
|
|
|
265
268
|
|
|
266
269
|
const log = createLogger(id, verbose);
|
|
267
270
|
const state = createState(id);
|
|
271
|
+
|
|
272
|
+
// Pre-seed state from --set flags (doesn't overwrite already-persisted keys)
|
|
273
|
+
if (initialState) {
|
|
274
|
+
for (const [key, value] of Object.entries(initialState)) {
|
|
275
|
+
if (state.get(key) === undefined) {
|
|
276
|
+
state.set(key, value);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
268
281
|
const eventBus = new AutomationEventBus();
|
|
269
282
|
|
|
270
283
|
const rawClient = getClient();
|
package/scripts/auto/types.ts
CHANGED
|
@@ -8,6 +8,20 @@ import type { HyperliquidClient } from '../core/client.js';
|
|
|
8
8
|
/** What an automation .ts file exports */
|
|
9
9
|
export type AutomationFactory = (api: AutomationAPI) => void | Promise<void>;
|
|
10
10
|
|
|
11
|
+
/** Config field descriptor for example automations */
|
|
12
|
+
export interface AutomationConfigField {
|
|
13
|
+
type: 'string' | 'number' | 'boolean';
|
|
14
|
+
description: string;
|
|
15
|
+
default: unknown;
|
|
16
|
+
required?: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Config metadata exported by example automations as `export const config` */
|
|
20
|
+
export interface AutomationConfig {
|
|
21
|
+
description: string;
|
|
22
|
+
fields: Record<string, AutomationConfigField>;
|
|
23
|
+
}
|
|
24
|
+
|
|
11
25
|
// ── Event system ────────────────────────────────────────────────────
|
|
12
26
|
|
|
13
27
|
export type AutomationEventType =
|
package/scripts/plugin/tools.ts
CHANGED
|
@@ -1344,23 +1344,34 @@ export function createTools(watcherOrCtx: PositionWatcher | null | ToolsContext)
|
|
|
1344
1344
|
|
|
1345
1345
|
{
|
|
1346
1346
|
name: 'ob_auto_run',
|
|
1347
|
-
description: 'Start a trading automation script. Scripts are TypeScript files that export a default factory function with event handlers (price_change, funding_update, position_opened, etc.). Scripts are loaded from ~/.openbroker/automations
|
|
1347
|
+
description: 'Start a trading automation script. Scripts are TypeScript files that export a default factory function with event handlers (price_change, funding_update, position_opened, etc.). Scripts are loaded from ~/.openbroker/automations/, an absolute path, or bundled examples (dca, grid, funding-arb, mm-spread, mm-maker).',
|
|
1348
1348
|
parameters: {
|
|
1349
1349
|
type: 'object',
|
|
1350
1350
|
properties: {
|
|
1351
1351
|
script: { type: 'string', description: 'Script name (from ~/.openbroker/automations/) or absolute path' },
|
|
1352
|
+
example: { type: 'string', description: 'Bundled example name: dca, grid, funding-arb, mm-spread, mm-maker' },
|
|
1353
|
+
config: { type: 'object', description: 'Key-value config to pre-seed automation state (e.g. { coin: "BTC", amount: 50 })' },
|
|
1352
1354
|
id: { type: 'string', description: 'Custom automation ID (default: filename)' },
|
|
1353
1355
|
dry: { type: 'boolean', description: 'Intercept write methods — no real trades' },
|
|
1354
1356
|
poll: { type: 'number', description: 'Poll interval in milliseconds (default: 10000)' },
|
|
1355
1357
|
},
|
|
1356
|
-
required: ['script'],
|
|
1357
1358
|
},
|
|
1358
1359
|
async execute(_id, params) {
|
|
1359
1360
|
try {
|
|
1360
|
-
const { resolveScriptPath } = await import('../auto/loader.js');
|
|
1361
|
+
const { resolveScriptPath, resolveExamplePath } = await import('../auto/loader.js');
|
|
1361
1362
|
const { startAutomation } = await import('../auto/runtime.js');
|
|
1362
1363
|
|
|
1363
|
-
|
|
1364
|
+
if (!params.script && !params.example) {
|
|
1365
|
+
return error('Either "script" or "example" parameter is required');
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
const scriptPath = params.example
|
|
1369
|
+
? resolveExamplePath(String(params.example))
|
|
1370
|
+
: resolveScriptPath(String(params.script));
|
|
1371
|
+
const initialState = params.config && typeof params.config === 'object'
|
|
1372
|
+
? params.config as Record<string, unknown>
|
|
1373
|
+
: undefined;
|
|
1374
|
+
|
|
1364
1375
|
const automation = await startAutomation({
|
|
1365
1376
|
scriptPath,
|
|
1366
1377
|
id: params.id ? String(params.id) : undefined,
|
|
@@ -1368,6 +1379,7 @@ export function createTools(watcherOrCtx: PositionWatcher | null | ToolsContext)
|
|
|
1368
1379
|
pollIntervalMs: params.poll ? Number(params.poll) : 10_000,
|
|
1369
1380
|
gatewayPort,
|
|
1370
1381
|
hooksToken,
|
|
1382
|
+
initialState,
|
|
1371
1383
|
});
|
|
1372
1384
|
|
|
1373
1385
|
return json({
|
|
@@ -1409,13 +1421,14 @@ export function createTools(watcherOrCtx: PositionWatcher | null | ToolsContext)
|
|
|
1409
1421
|
|
|
1410
1422
|
{
|
|
1411
1423
|
name: 'ob_auto_list',
|
|
1412
|
-
description: 'List available automation scripts
|
|
1424
|
+
description: 'List available automation scripts, bundled examples (dca, grid, funding-arb, mm-spread, mm-maker), and running automations',
|
|
1413
1425
|
parameters: { type: 'object', properties: {} },
|
|
1414
1426
|
async execute() {
|
|
1415
|
-
const { listAutomations } = await import('../auto/loader.js');
|
|
1427
|
+
const { listAutomations, loadExampleConfigs } = await import('../auto/loader.js');
|
|
1416
1428
|
const { getRunningAutomations, getRegisteredAutomations } = await import('../auto/runtime.js');
|
|
1417
1429
|
|
|
1418
1430
|
const available = listAutomations();
|
|
1431
|
+
const examples = await loadExampleConfigs();
|
|
1419
1432
|
|
|
1420
1433
|
// In-process automations with live stats
|
|
1421
1434
|
const inProcess = getRunningAutomations().map(a => ({
|
|
@@ -1443,7 +1456,7 @@ export function createTools(watcherOrCtx: PositionWatcher | null | ToolsContext)
|
|
|
1443
1456
|
source: 'other_process',
|
|
1444
1457
|
}));
|
|
1445
1458
|
|
|
1446
|
-
return json({ available, running: [...inProcess, ...external] });
|
|
1459
|
+
return json({ available, examples, running: [...inProcess, ...external] });
|
|
1447
1460
|
},
|
|
1448
1461
|
},
|
|
1449
1462
|
];
|