openbroker 1.0.66 → 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/README.md +4 -1
- package/SKILL.md +55 -62
- 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/info/orders.ts +70 -0
- package/scripts/plugin/tools.ts +51 -9
- 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,98 @@
|
|
|
1
|
+
// Funding Arbitrage — Collect funding by positioning opposite to the crowd
|
|
2
|
+
|
|
3
|
+
import type { AutomationAPI, AutomationConfig } from '../types.js';
|
|
4
|
+
|
|
5
|
+
export const config: AutomationConfig = {
|
|
6
|
+
description: 'Funding arbitrage — collect funding by positioning opposite to the crowd',
|
|
7
|
+
fields: {
|
|
8
|
+
coin: { type: 'string', description: 'Asset to trade', default: 'HYPE' },
|
|
9
|
+
sizeUsd: { type: 'number', description: 'Position size in USD notional', default: 5000 },
|
|
10
|
+
minFunding: { type: 'number', description: 'Min annualized % to enter', default: 20 },
|
|
11
|
+
maxFunding: { type: 'number', description: 'Max annualized % — avoid squeezes', default: 200 },
|
|
12
|
+
closeAt: { type: 'number', description: 'Close when funding drops below this %', default: 5 },
|
|
13
|
+
},
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export default function fundingArb(api: AutomationAPI) {
|
|
17
|
+
const COIN = api.state.get<string>('coin', 'HYPE')!;
|
|
18
|
+
const SIZE_USD = api.state.get<number>('sizeUsd', 5000)!;
|
|
19
|
+
const MIN_FUNDING = api.state.get<number>('minFunding', 20)!;
|
|
20
|
+
const MAX_FUNDING = api.state.get<number>('maxFunding', 200)!;
|
|
21
|
+
const CLOSE_AT = api.state.get<number>('closeAt', 5)!;
|
|
22
|
+
|
|
23
|
+
let inPosition = api.state.get<boolean>('inPosition', false)!;
|
|
24
|
+
let positionSide = api.state.get<string>('positionSide', '')!;
|
|
25
|
+
let entryPrice = api.state.get<number>('entryPrice', 0)!;
|
|
26
|
+
let positionSize = api.state.get<number>('positionSize', 0)!;
|
|
27
|
+
let totalFunding = api.state.get<number>('totalFunding', 0)!;
|
|
28
|
+
|
|
29
|
+
api.onStart(() => {
|
|
30
|
+
api.log.info(`Funding arb: ${COIN} | $${SIZE_USD} | Enter >${MIN_FUNDING}% | Close <${CLOSE_AT}%`);
|
|
31
|
+
if (inPosition) {
|
|
32
|
+
api.log.info(`Resuming ${positionSide} position: ${positionSize.toFixed(6)} @ $${entryPrice.toFixed(2)}`);
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
api.on('funding_update', async ({ coin, annualized }) => {
|
|
37
|
+
if (coin !== COIN) return;
|
|
38
|
+
|
|
39
|
+
const annualizedPct = annualized * 100;
|
|
40
|
+
const absAnnualized = Math.abs(annualizedPct);
|
|
41
|
+
|
|
42
|
+
if (inPosition) {
|
|
43
|
+
// Check if we should close
|
|
44
|
+
const shouldClose =
|
|
45
|
+
(positionSide === 'short' && annualizedPct < CLOSE_AT) ||
|
|
46
|
+
(positionSide === 'long' && annualizedPct > -CLOSE_AT);
|
|
47
|
+
|
|
48
|
+
if (shouldClose) {
|
|
49
|
+
api.log.info(`Funding dropped to ${annualizedPct.toFixed(2)}% (below ${CLOSE_AT}%), closing ${positionSide}`);
|
|
50
|
+
const closeIsBuy = positionSide === 'short';
|
|
51
|
+
await api.client.marketOrder(coin, closeIsBuy, positionSize);
|
|
52
|
+
|
|
53
|
+
inPosition = false;
|
|
54
|
+
api.state.set('inPosition', false);
|
|
55
|
+
api.log.info(`Position closed. Funding collected: ~$${totalFunding.toFixed(2)}`);
|
|
56
|
+
} else {
|
|
57
|
+
api.log.debug(`${coin} funding: ${annualizedPct.toFixed(2)}% — holding ${positionSide}`);
|
|
58
|
+
}
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Not in position — check if we should enter
|
|
63
|
+
if (absAnnualized >= MIN_FUNDING && absAnnualized <= MAX_FUNDING) {
|
|
64
|
+
const shouldShort = annualizedPct > 0; // Positive = longs pay shorts
|
|
65
|
+
const side = shouldShort ? 'short' : 'long';
|
|
66
|
+
|
|
67
|
+
const mids = await api.client.getAllMids();
|
|
68
|
+
const price = parseFloat(mids[coin]);
|
|
69
|
+
const size = SIZE_USD / price;
|
|
70
|
+
|
|
71
|
+
api.log.info(`Funding at ${annualizedPct.toFixed(2)}% — opening ${side} ${size.toFixed(6)} ${coin}`);
|
|
72
|
+
const response = await api.client.marketOrder(coin, !shouldShort, size);
|
|
73
|
+
|
|
74
|
+
if (response.status === 'ok' && response.response && typeof response.response === 'object') {
|
|
75
|
+
const status = response.response.data.statuses[0];
|
|
76
|
+
if (status?.filled) {
|
|
77
|
+
positionSize = parseFloat(status.filled.totalSz);
|
|
78
|
+
entryPrice = parseFloat(status.filled.avgPx);
|
|
79
|
+
positionSide = side;
|
|
80
|
+
inPosition = true;
|
|
81
|
+
|
|
82
|
+
api.state.set('inPosition', true);
|
|
83
|
+
api.state.set('positionSide', side);
|
|
84
|
+
api.state.set('entryPrice', entryPrice);
|
|
85
|
+
api.state.set('positionSize', positionSize);
|
|
86
|
+
|
|
87
|
+
api.log.info(`Entered ${side} ${positionSize.toFixed(6)} @ $${entryPrice.toFixed(2)}`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
api.onStop(() => {
|
|
94
|
+
if (inPosition) {
|
|
95
|
+
api.log.warn(`Stopping with open ${positionSide} position of ${positionSize.toFixed(6)} ${COIN} — close manually if desired`);
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
}
|
|
@@ -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/info/orders.ts
CHANGED
|
@@ -11,11 +11,14 @@ Usage: openbroker orders [options]
|
|
|
11
11
|
Options:
|
|
12
12
|
--coin <symbol> Filter by coin (e.g. ETH, BTC)
|
|
13
13
|
--status <status> Filter by status (filled, canceled, open, triggered, rejected, etc.)
|
|
14
|
+
--open Show only currently open orders
|
|
14
15
|
--top <n> Show last N orders (default: 20)
|
|
15
16
|
--help, -h Show this help
|
|
16
17
|
|
|
17
18
|
Examples:
|
|
18
19
|
openbroker orders
|
|
20
|
+
openbroker orders --open
|
|
21
|
+
openbroker orders --open --coin ETH
|
|
19
22
|
openbroker orders --coin ETH --status filled
|
|
20
23
|
openbroker orders --top 50
|
|
21
24
|
`);
|
|
@@ -31,11 +34,78 @@ async function main() {
|
|
|
31
34
|
|
|
32
35
|
const filterCoin = args.coin as string | undefined;
|
|
33
36
|
const filterStatus = args.status as string | undefined;
|
|
37
|
+
const openOnly = args.open as boolean;
|
|
34
38
|
const top = parseInt(args.top as string) || 20;
|
|
35
39
|
const jsonOutput = args.json as boolean;
|
|
36
40
|
const client = getClient();
|
|
37
41
|
|
|
38
42
|
try {
|
|
43
|
+
if (openOnly) {
|
|
44
|
+
// Use the dedicated open orders endpoint
|
|
45
|
+
let openOrders = await client.getOpenOrders();
|
|
46
|
+
|
|
47
|
+
if (filterCoin) {
|
|
48
|
+
openOrders = openOrders.filter(o => o.coin === normalizeCoin(filterCoin));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
openOrders.sort((a, b) => b.timestamp - a.timestamp);
|
|
52
|
+
openOrders = openOrders.slice(0, top);
|
|
53
|
+
|
|
54
|
+
if (jsonOutput) {
|
|
55
|
+
console.log(JSON.stringify(openOrders.map(o => ({
|
|
56
|
+
time: new Date(o.timestamp).toISOString(),
|
|
57
|
+
coin: o.coin,
|
|
58
|
+
side: o.side === 'B' ? 'buy' : 'sell',
|
|
59
|
+
orderType: o.orderType,
|
|
60
|
+
size: o.sz,
|
|
61
|
+
origSize: o.origSz,
|
|
62
|
+
price: o.limitPx,
|
|
63
|
+
status: 'open',
|
|
64
|
+
oid: o.oid,
|
|
65
|
+
})), null, 2));
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
console.log('Open Broker - Open Orders');
|
|
70
|
+
console.log('=========================\n');
|
|
71
|
+
|
|
72
|
+
if (openOrders.length === 0) {
|
|
73
|
+
console.log('No open orders found');
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Table header
|
|
78
|
+
console.log(
|
|
79
|
+
'Time'.padEnd(20) +
|
|
80
|
+
'Coin'.padEnd(10) +
|
|
81
|
+
'Side'.padEnd(6) +
|
|
82
|
+
'Type'.padEnd(14) +
|
|
83
|
+
'Size'.padEnd(12) +
|
|
84
|
+
'Price'.padEnd(14) +
|
|
85
|
+
'OID'
|
|
86
|
+
);
|
|
87
|
+
console.log('─'.repeat(90));
|
|
88
|
+
|
|
89
|
+
for (const o of openOrders) {
|
|
90
|
+
const time = new Date(o.timestamp).toLocaleString();
|
|
91
|
+
const side = o.side === 'B' ? 'BUY' : 'SELL';
|
|
92
|
+
|
|
93
|
+
console.log(
|
|
94
|
+
time.padEnd(20) +
|
|
95
|
+
o.coin.padEnd(10) +
|
|
96
|
+
side.padEnd(6) +
|
|
97
|
+
o.orderType.padEnd(14) +
|
|
98
|
+
o.sz.padEnd(12) +
|
|
99
|
+
formatUsd(parseFloat(o.limitPx)).padEnd(14) +
|
|
100
|
+
String(o.oid)
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
console.log('─'.repeat(90));
|
|
105
|
+
console.log(`Showing ${openOrders.length} open orders`);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
39
109
|
let orders = await client.getHistoricalOrders();
|
|
40
110
|
|
|
41
111
|
if (filterCoin) {
|