openbroker 1.0.60 → 1.0.61
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 +200 -3
- package/bin/cli.ts +8 -0
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/scripts/auto/cli.ts +172 -0
- package/scripts/auto/events.ts +52 -0
- package/scripts/auto/loader.ts +71 -0
- package/scripts/auto/runtime.ts +447 -0
- package/scripts/auto/types.ts +138 -0
- package/scripts/plugin/tools.ts +87 -0
package/SKILL.md
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: openbroker
|
|
3
|
-
description: Hyperliquid trading plugin with background position monitoring. Execute market orders, limit orders, manage positions, view funding rates,
|
|
3
|
+
description: Hyperliquid trading plugin with background position monitoring and custom automations. Execute market orders, limit orders, manage positions, view funding rates, run trading strategies, and write event-driven automation scripts with automatic alerts for PnL changes and liquidation risk.
|
|
4
4
|
license: MIT
|
|
5
5
|
compatibility: Requires Node.js 22+, network access to api.hyperliquid.xyz
|
|
6
6
|
homepage: https://www.npmjs.com/package/openbroker
|
|
7
|
-
metadata: {"author": "monemetrics", "version": "1.0.
|
|
8
|
-
allowed-tools: ob_account ob_positions ob_funding ob_markets ob_search ob_spot ob_fills ob_orders ob_order_status ob_fees ob_candles ob_funding_history ob_trades ob_rate_limit ob_funding_scan ob_buy ob_sell ob_limit ob_trigger ob_tpsl ob_cancel ob_twap ob_bracket ob_chase ob_watcher_status Bash(openbroker:*)
|
|
7
|
+
metadata: {"author": "monemetrics", "version": "1.0.61", "openclaw": {"requires": {"bins": ["openbroker"], "env": ["HYPERLIQUID_PRIVATE_KEY"]}, "primaryEnv": "HYPERLIQUID_PRIVATE_KEY", "install": [{"id": "node", "kind": "node", "package": "openbroker", "bins": ["openbroker"], "label": "Install openbroker (npm)"}]}}
|
|
8
|
+
allowed-tools: ob_account ob_positions ob_funding ob_markets ob_search ob_spot ob_fills ob_orders ob_order_status ob_fees ob_candles ob_funding_history ob_trades ob_rate_limit ob_funding_scan ob_buy ob_sell ob_limit ob_trigger ob_tpsl ob_cancel ob_twap ob_bracket ob_chase ob_watcher_status ob_auto_run ob_auto_stop ob_auto_list Bash(openbroker:*)
|
|
9
9
|
---
|
|
10
10
|
|
|
11
11
|
# Open Broker - Hyperliquid Trading CLI
|
|
@@ -66,6 +66,9 @@ If an `ob_*` plugin tool returns unexpected errors, empty results, or crashes, *
|
|
|
66
66
|
| `ob_orders` | `openbroker orders --json` |
|
|
67
67
|
| `ob_funding_scan` | `openbroker funding-scan --json` |
|
|
68
68
|
| `ob_candles` | `openbroker candles --coin <COIN> --json` |
|
|
69
|
+
| `ob_auto_run` | `openbroker auto run <script> [--dry]` |
|
|
70
|
+
| `ob_auto_stop` | (stop via SIGINT when using CLI) |
|
|
71
|
+
| `ob_auto_list` | `openbroker auto list` |
|
|
69
72
|
|
|
70
73
|
**When to use CLI fallback:**
|
|
71
74
|
- Plugin tool returns `null`, empty data, or throws an error
|
|
@@ -448,6 +451,7 @@ This skill works standalone via Bash — every command above runs through the `o
|
|
|
448
451
|
|
|
449
452
|
- **Structured agent tools** (`ob_account`, `ob_buy`, `ob_limit`, etc.) — typed tool calls with proper input schemas instead of Bash strings. The agent gets structured JSON responses.
|
|
450
453
|
- **Background position watcher** — polls your Hyperliquid account every 30s and sends webhook alerts when positions open/close, PnL moves significantly, or margin usage gets dangerous.
|
|
454
|
+
- **Automation tools** (`ob_auto_run`, `ob_auto_stop`, `ob_auto_list`) — start, stop, and manage custom trading automations from within the agent.
|
|
451
455
|
- **CLI commands** — `openclaw ob status` and `openclaw ob watch` for inspecting the watcher.
|
|
452
456
|
|
|
453
457
|
### Enable the plugin
|
|
@@ -487,6 +491,199 @@ Without hooks, the watcher still runs and tracks state (accessible via `ob_watch
|
|
|
487
491
|
- **Skill only (no plugin):** Use Bash commands (`openbroker buy --coin ETH --size 0.1`). No background monitoring.
|
|
488
492
|
- **Skill + plugin:** The agent prefers the `ob_*` tools when available (structured data), falls back to Bash for commands not covered by tools (strategies, scale). Background watcher sends alerts automatically.
|
|
489
493
|
|
|
494
|
+
## Trading Automations
|
|
495
|
+
|
|
496
|
+
Automations let you write custom event-driven trading logic as TypeScript scripts. Instead of using the rigid built-in strategies, write exactly the logic you need and OpenBroker handles the polling, event detection, and SDK access.
|
|
497
|
+
|
|
498
|
+
### How Automations Work
|
|
499
|
+
|
|
500
|
+
An automation is a `.ts` file that exports a default function. The function receives an `AutomationAPI` with the full Hyperliquid client, typed event subscriptions, persistent state, and a logger. The runtime polls Hyperliquid every 10s (configurable) and dispatches events when changes are detected.
|
|
501
|
+
|
|
502
|
+
### Writing an Automation
|
|
503
|
+
|
|
504
|
+
Create a `.ts` file in `~/.openbroker/automations/` (or any path):
|
|
505
|
+
|
|
506
|
+
```typescript
|
|
507
|
+
// ~/.openbroker/automations/funding-scalp.ts
|
|
508
|
+
export default function(api) {
|
|
509
|
+
const COIN = 'ETH';
|
|
510
|
+
|
|
511
|
+
api.on('funding_update', async ({ coin, annualized }) => {
|
|
512
|
+
if (coin !== COIN) return;
|
|
513
|
+
|
|
514
|
+
if (annualized > 0.5 && !api.state.get('isShort')) {
|
|
515
|
+
api.log.info('High positive funding — going short');
|
|
516
|
+
await api.client.marketOrder(COIN, false, 0.1);
|
|
517
|
+
api.state.set('isShort', true);
|
|
518
|
+
} else if (annualized < -0.1 && api.state.get('isShort')) {
|
|
519
|
+
api.log.info('Funding normalized — closing short');
|
|
520
|
+
await api.client.marketOrder(COIN, true, 0.1);
|
|
521
|
+
api.state.set('isShort', false);
|
|
522
|
+
}
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
api.onStop(async () => {
|
|
526
|
+
if (api.state.get('isShort')) {
|
|
527
|
+
api.log.warn('Closing short on shutdown');
|
|
528
|
+
await api.client.marketOrder('ETH', true, 0.1);
|
|
529
|
+
api.state.set('isShort', false);
|
|
530
|
+
}
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
```
|
|
534
|
+
|
|
535
|
+
### AutomationAPI Reference
|
|
536
|
+
|
|
537
|
+
| Property / Method | Description |
|
|
538
|
+
|-------------------|-------------|
|
|
539
|
+
| `api.client` | Full HyperliquidClient — `marketOrder()`, `limitOrder()`, `triggerOrder()`, `cancelAll()`, `getUserStateAll()`, `getAllMids()`, `updateLeverage()`, and 35+ more methods |
|
|
540
|
+
| `api.on(event, handler)` | Subscribe to a market/account event (see Events below) |
|
|
541
|
+
| `api.every(ms, handler)` | Run a handler on a recurring interval (aligned to poll loop) |
|
|
542
|
+
| `api.onStart(handler)` | Called after all handlers are registered, before first poll |
|
|
543
|
+
| `api.onStop(handler)` | Called on shutdown (SIGINT). Use for cleanup — close positions, cancel orders |
|
|
544
|
+
| `api.onError(handler)` | Called when a handler throws. Error is already logged — use for recovery logic |
|
|
545
|
+
| `api.state.get(key)` | Get a persisted value (survives restarts, stored in `~/.openbroker/state/`) |
|
|
546
|
+
| `api.state.set(key, value)` | Set a persisted value |
|
|
547
|
+
| `api.state.delete(key)` | Delete a persisted value |
|
|
548
|
+
| `api.state.clear()` | Clear all state |
|
|
549
|
+
| `api.log.info/warn/error/debug(msg)` | Structured logger |
|
|
550
|
+
| `api.utils` | `roundPrice`, `roundSize`, `sleep`, `normalizeCoin`, `formatUsd`, `annualizeFundingRate` |
|
|
551
|
+
| `api.id` | Automation ID (filename or `--id` flag) |
|
|
552
|
+
| `api.dryRun` | `true` if running with `--dry` (write methods are intercepted) |
|
|
553
|
+
|
|
554
|
+
### Events
|
|
555
|
+
|
|
556
|
+
| Event | Payload | When |
|
|
557
|
+
|-------|---------|------|
|
|
558
|
+
| `tick` | `{ timestamp, pollCount }` | Every poll cycle (default: 10s) |
|
|
559
|
+
| `price_change` | `{ coin, oldPrice, newPrice, changePct }` | Mid price moved > 0.1% |
|
|
560
|
+
| `funding_update` | `{ coin, fundingRate, annualized, premium }` | Every poll for all assets |
|
|
561
|
+
| `position_opened` | `{ coin, side, size, entryPrice }` | New position detected |
|
|
562
|
+
| `position_closed` | `{ coin, previousSize, entryPrice }` | Position no longer present |
|
|
563
|
+
| `position_changed` | `{ coin, oldSize, newSize, entryPrice }` | Position size changed |
|
|
564
|
+
| `pnl_threshold` | `{ coin, unrealizedPnl, changePct, positionValue }` | PnL moved > 5% of position value |
|
|
565
|
+
| `margin_warning` | `{ marginUsedPct, equity, marginUsed }` | Margin usage > 80% |
|
|
566
|
+
|
|
567
|
+
### Client Methods Available
|
|
568
|
+
|
|
569
|
+
The `api.client` object exposes the full Hyperliquid SDK:
|
|
570
|
+
|
|
571
|
+
**Trading:** `marketOrder(coin, isBuy, size)`, `limitOrder(coin, isBuy, size, price)`, `triggerOrder(coin, isBuy, size, triggerPx, isMarket)`, `takeProfit(coin, isBuy, size, triggerPx)`, `stopLoss(coin, isBuy, size, triggerPx)`, `cancel(coin, oid)`, `cancelAll(coin?)`
|
|
572
|
+
|
|
573
|
+
**Market Data:** `getAllMids()`, `getMetaAndAssetCtxs()`, `getRecentTrades(coin)`, `getCandleSnapshot(coin, interval)`, `getFundingHistory(coin)`, `getPredictedFundings()`
|
|
574
|
+
|
|
575
|
+
**Account:** `getUserStateAll()`, `getOpenOrders()`, `getUserFills()`, `getUserFunding()`, `getHistoricalOrders()`, `getUserFees()`, `getUserRateLimit()`, `getSpotBalances()`
|
|
576
|
+
|
|
577
|
+
**Leverage:** `updateLeverage(coin, leverage, isIsolated?)`
|
|
578
|
+
|
|
579
|
+
### Example: Price Breakout
|
|
580
|
+
|
|
581
|
+
```typescript
|
|
582
|
+
// ~/.openbroker/automations/breakout.ts
|
|
583
|
+
export default function(api) {
|
|
584
|
+
const COIN = 'ETH';
|
|
585
|
+
const BREAKOUT_PCT = 2; // 2% move triggers entry
|
|
586
|
+
const SIZE = 0.5;
|
|
587
|
+
let basePrice = null;
|
|
588
|
+
|
|
589
|
+
api.onStart(async () => {
|
|
590
|
+
const mids = await api.client.getAllMids();
|
|
591
|
+
basePrice = parseFloat(mids[COIN]);
|
|
592
|
+
api.log.info(`Watching ${COIN} from $${basePrice} for ${BREAKOUT_PCT}% breakout`);
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
api.on('price_change', async ({ coin, newPrice }) => {
|
|
596
|
+
if (coin !== COIN || !basePrice) return;
|
|
597
|
+
const totalChange = ((newPrice - basePrice) / basePrice) * 100;
|
|
598
|
+
|
|
599
|
+
if (Math.abs(totalChange) >= BREAKOUT_PCT && !api.state.get('inPosition')) {
|
|
600
|
+
const side = totalChange > 0; // true = long, false = short
|
|
601
|
+
api.log.info(`Breakout! ${totalChange.toFixed(2)}% — entering ${side ? 'long' : 'short'}`);
|
|
602
|
+
await api.client.marketOrder(COIN, side, SIZE);
|
|
603
|
+
api.state.set('inPosition', true);
|
|
604
|
+
}
|
|
605
|
+
});
|
|
606
|
+
}
|
|
607
|
+
```
|
|
608
|
+
|
|
609
|
+
### Example: Scheduled DCA
|
|
610
|
+
|
|
611
|
+
```typescript
|
|
612
|
+
// ~/.openbroker/automations/hourly-dca.ts
|
|
613
|
+
export default function(api) {
|
|
614
|
+
const COIN = 'ETH';
|
|
615
|
+
const USD_PER_BUY = 100;
|
|
616
|
+
|
|
617
|
+
// Buy $100 of ETH every hour
|
|
618
|
+
api.every(60 * 60 * 1000, async () => {
|
|
619
|
+
const mids = await api.client.getAllMids();
|
|
620
|
+
const price = parseFloat(mids[COIN]);
|
|
621
|
+
const size = parseFloat(api.utils.roundSize(USD_PER_BUY / price, 4));
|
|
622
|
+
await api.client.marketOrder(COIN, true, size);
|
|
623
|
+
const count = (api.state.get('buyCount') || 0) + 1;
|
|
624
|
+
api.state.set('buyCount', count);
|
|
625
|
+
api.log.info(`DCA #${count}: bought ${size} ${COIN} at $${price}`);
|
|
626
|
+
});
|
|
627
|
+
}
|
|
628
|
+
```
|
|
629
|
+
|
|
630
|
+
### Example: Margin Guardian
|
|
631
|
+
|
|
632
|
+
```typescript
|
|
633
|
+
// ~/.openbroker/automations/margin-guard.ts
|
|
634
|
+
export default function(api) {
|
|
635
|
+
api.on('margin_warning', async ({ marginUsedPct, equity }) => {
|
|
636
|
+
api.log.warn(`Margin at ${marginUsedPct.toFixed(1)}% — reducing positions`);
|
|
637
|
+
|
|
638
|
+
// Close the smallest position to free margin
|
|
639
|
+
const state = await api.client.getUserStateAll();
|
|
640
|
+
const positions = state.assetPositions
|
|
641
|
+
.filter(p => parseFloat(p.position.szi) !== 0)
|
|
642
|
+
.sort((a, b) => Math.abs(parseFloat(a.position.positionValue)) - Math.abs(parseFloat(b.position.positionValue)));
|
|
643
|
+
|
|
644
|
+
if (positions.length > 0) {
|
|
645
|
+
const pos = positions[0].position;
|
|
646
|
+
const size = Math.abs(parseFloat(pos.szi));
|
|
647
|
+
const isBuy = parseFloat(pos.szi) < 0; // Close short = buy, close long = sell
|
|
648
|
+
api.log.info(`Closing smallest position: ${pos.coin} (${pos.szi})`);
|
|
649
|
+
await api.client.marketOrder(pos.coin, isBuy, size);
|
|
650
|
+
}
|
|
651
|
+
});
|
|
652
|
+
}
|
|
653
|
+
```
|
|
654
|
+
|
|
655
|
+
### Running Automations
|
|
656
|
+
|
|
657
|
+
**CLI:**
|
|
658
|
+
```bash
|
|
659
|
+
openbroker auto run my-strategy --dry # Test without trading
|
|
660
|
+
openbroker auto run ./funding-scalp.ts # Run from path
|
|
661
|
+
openbroker auto run my-strategy --poll 5000 # Poll every 5s
|
|
662
|
+
openbroker auto list # Show available scripts
|
|
663
|
+
openbroker auto status # Show running automations
|
|
664
|
+
```
|
|
665
|
+
|
|
666
|
+
**Plugin tools (for OpenClaw agents):**
|
|
667
|
+
- `ob_auto_run` — `{ "script": "funding-scalp", "dry": true }` — start an automation
|
|
668
|
+
- `ob_auto_stop` — `{ "id": "funding-scalp" }` — stop a running automation
|
|
669
|
+
- `ob_auto_list` — `{}` — list available and running automations
|
|
670
|
+
|
|
671
|
+
**Options:**
|
|
672
|
+
| Flag | Description | Default |
|
|
673
|
+
|------|-------------|---------|
|
|
674
|
+
| `--dry` | Intercept write methods — no real trades | false |
|
|
675
|
+
| `--verbose` | Show debug output | false |
|
|
676
|
+
| `--id <name>` | Custom automation ID | filename |
|
|
677
|
+
| `--poll <ms>` | Poll interval in milliseconds | 10000 |
|
|
678
|
+
|
|
679
|
+
**Important notes for agents writing automations:**
|
|
680
|
+
- Always test with `--dry` first before live trading
|
|
681
|
+
- Use `api.state` to track position state across restarts
|
|
682
|
+
- Use `api.onStop()` to clean up — close positions, cancel orders
|
|
683
|
+
- The runtime catches errors per handler — one failing handler won't crash others
|
|
684
|
+
- Scripts are loaded from `~/.openbroker/automations/` by name, or from any absolute path
|
|
685
|
+
- All trading commands support HIP-3 assets (`api.client.marketOrder('xyz:CL', true, 1)`)
|
|
686
|
+
|
|
490
687
|
## Risk Warning
|
|
491
688
|
|
|
492
689
|
- Always use `--dry` first to preview orders
|
package/bin/cli.ts
CHANGED
|
@@ -52,6 +52,9 @@ const commands: Record<string, { script: string; description: string }> = {
|
|
|
52
52
|
'dca': { script: 'strategies/dca.ts', description: 'DCA strategy' },
|
|
53
53
|
'mm-spread': { script: 'strategies/mm-spread.ts', description: 'Market making (spread)' },
|
|
54
54
|
'mm-maker': { script: 'strategies/mm-maker.ts', description: 'Market making (ALO)' },
|
|
55
|
+
|
|
56
|
+
// Automations
|
|
57
|
+
'auto': { script: 'auto/cli.ts', description: 'Run/manage trading automations' },
|
|
55
58
|
};
|
|
56
59
|
|
|
57
60
|
function printHelp() {
|
|
@@ -103,6 +106,11 @@ Strategies:
|
|
|
103
106
|
mm-spread Market making (spread-based)
|
|
104
107
|
mm-maker Market making (ALO orders)
|
|
105
108
|
|
|
109
|
+
Automations:
|
|
110
|
+
auto run <script> Run a custom trading automation
|
|
111
|
+
auto list List available automations
|
|
112
|
+
auto status Show running automations
|
|
113
|
+
|
|
106
114
|
Options:
|
|
107
115
|
--help, -h Show help for a command
|
|
108
116
|
--dry Preview without executing
|
package/openclaw.plugin.json
CHANGED
package/package.json
CHANGED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// CLI entry point for `openbroker auto` commands
|
|
2
|
+
|
|
3
|
+
import { parseArgs } from '../core/utils.js';
|
|
4
|
+
import { resolveScriptPath, listAutomations, ensureAutomationsDir } from './loader.js';
|
|
5
|
+
import { startAutomation, getRunningAutomations } from './runtime.js';
|
|
6
|
+
|
|
7
|
+
function printUsage() {
|
|
8
|
+
console.log(`
|
|
9
|
+
OpenBroker Automations — event-driven trading scripts
|
|
10
|
+
|
|
11
|
+
Usage:
|
|
12
|
+
openbroker auto run <script> [options] Run an automation script
|
|
13
|
+
openbroker auto list List available automations
|
|
14
|
+
openbroker auto status Show running automations
|
|
15
|
+
|
|
16
|
+
Options (for run):
|
|
17
|
+
--dry Intercept write methods (no real trades)
|
|
18
|
+
--verbose Show debug output
|
|
19
|
+
--id <name> Custom automation ID (default: filename)
|
|
20
|
+
--poll <ms> Poll interval in milliseconds (default: 10000)
|
|
21
|
+
|
|
22
|
+
Scripts are loaded from:
|
|
23
|
+
1. Absolute or relative path
|
|
24
|
+
2. ~/.openbroker/automations/<name>.ts
|
|
25
|
+
|
|
26
|
+
Writing an automation:
|
|
27
|
+
export default function(api) {
|
|
28
|
+
api.on('price_change', async ({ coin, changePct }) => {
|
|
29
|
+
api.log.info(\`\${coin} moved \${changePct.toFixed(2)}%\`);
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
Events: tick, price_change, funding_update, position_opened,
|
|
34
|
+
position_closed, position_changed, pnl_threshold, margin_warning
|
|
35
|
+
|
|
36
|
+
Examples:
|
|
37
|
+
openbroker auto run my-strategy --dry # Test without trading
|
|
38
|
+
openbroker auto run ./funding-scalp.ts # Run from path
|
|
39
|
+
openbroker auto list # Show available scripts
|
|
40
|
+
`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function runCommand(args: Record<string, string | boolean>, positional: string[]) {
|
|
44
|
+
const scriptName = positional[0];
|
|
45
|
+
if (!scriptName) {
|
|
46
|
+
console.error('Error: script name or path required');
|
|
47
|
+
console.log('Usage: openbroker auto run <script> [--dry] [--verbose]');
|
|
48
|
+
process.exit(1);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const scriptPath = resolveScriptPath(scriptName);
|
|
52
|
+
const dryRun = args.dry === true;
|
|
53
|
+
const verbose = args.verbose === true;
|
|
54
|
+
const pollIntervalMs = args.poll ? parseInt(String(args.poll), 10) : 10_000;
|
|
55
|
+
const id = args.id ? String(args.id) : undefined;
|
|
56
|
+
|
|
57
|
+
if (isNaN(pollIntervalMs) || pollIntervalMs < 1000) {
|
|
58
|
+
console.error('Error: --poll must be at least 1000ms');
|
|
59
|
+
process.exit(1);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const automation = await startAutomation({
|
|
63
|
+
scriptPath,
|
|
64
|
+
id,
|
|
65
|
+
dryRun,
|
|
66
|
+
verbose,
|
|
67
|
+
pollIntervalMs,
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// Graceful shutdown on SIGINT/SIGTERM
|
|
71
|
+
const shutdown = async () => {
|
|
72
|
+
console.log('\nShutting down...');
|
|
73
|
+
await automation.stop();
|
|
74
|
+
process.exit(0);
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
process.on('SIGINT', shutdown);
|
|
78
|
+
process.on('SIGTERM', shutdown);
|
|
79
|
+
|
|
80
|
+
// Keep process alive
|
|
81
|
+
await new Promise(() => {});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function listCommand() {
|
|
85
|
+
ensureAutomationsDir();
|
|
86
|
+
const automations = listAutomations();
|
|
87
|
+
|
|
88
|
+
if (automations.length === 0) {
|
|
89
|
+
console.log('No automations found in ~/.openbroker/automations/');
|
|
90
|
+
console.log('\nCreate a .ts file there with:');
|
|
91
|
+
console.log(' export default function(api) { ... }');
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
console.log('Available automations:\n');
|
|
96
|
+
for (const a of automations) {
|
|
97
|
+
console.log(` ${a.name.padEnd(30)} ${a.path}`);
|
|
98
|
+
}
|
|
99
|
+
console.log(`\nRun with: openbroker auto run <name>`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function statusCommand() {
|
|
103
|
+
const running = getRunningAutomations();
|
|
104
|
+
|
|
105
|
+
if (running.length === 0) {
|
|
106
|
+
console.log('No automations running');
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
console.log('Running automations:\n');
|
|
111
|
+
for (const a of running) {
|
|
112
|
+
const uptime = Math.round((Date.now() - a.startedAt.getTime()) / 1000);
|
|
113
|
+
console.log(` ${a.id}`);
|
|
114
|
+
console.log(` Script: ${a.scriptPath}`);
|
|
115
|
+
console.log(` Uptime: ${uptime}s`);
|
|
116
|
+
console.log(` Polls: ${a.pollCount}`);
|
|
117
|
+
console.log(` Events: ${a.eventsEmitted}`);
|
|
118
|
+
console.log(` Dry run: ${a.dryRun}`);
|
|
119
|
+
console.log('');
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function main() {
|
|
124
|
+
const rawArgs = process.argv.slice(2);
|
|
125
|
+
|
|
126
|
+
if (rawArgs.length === 0 || rawArgs[0] === '--help' || rawArgs[0] === '-h') {
|
|
127
|
+
printUsage();
|
|
128
|
+
process.exit(0);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const subcommand = rawArgs[0];
|
|
132
|
+
const restArgs = rawArgs.slice(1);
|
|
133
|
+
|
|
134
|
+
// Extract positional args (non-flag args)
|
|
135
|
+
const positional: string[] = [];
|
|
136
|
+
const flagArgs: string[] = [];
|
|
137
|
+
for (let i = 0; i < restArgs.length; i++) {
|
|
138
|
+
if (restArgs[i].startsWith('--')) {
|
|
139
|
+
flagArgs.push(restArgs[i]);
|
|
140
|
+
// If next arg doesn't start with --, it's a flag value
|
|
141
|
+
if (i + 1 < restArgs.length && !restArgs[i + 1].startsWith('--')) {
|
|
142
|
+
flagArgs.push(restArgs[i + 1]);
|
|
143
|
+
i++;
|
|
144
|
+
}
|
|
145
|
+
} else {
|
|
146
|
+
positional.push(restArgs[i]);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const args = parseArgs(flagArgs);
|
|
151
|
+
|
|
152
|
+
switch (subcommand) {
|
|
153
|
+
case 'run':
|
|
154
|
+
await runCommand(args, positional);
|
|
155
|
+
break;
|
|
156
|
+
case 'list':
|
|
157
|
+
listCommand();
|
|
158
|
+
break;
|
|
159
|
+
case 'status':
|
|
160
|
+
statusCommand();
|
|
161
|
+
break;
|
|
162
|
+
default:
|
|
163
|
+
console.error(`Unknown subcommand: ${subcommand}`);
|
|
164
|
+
console.log('Run "openbroker auto --help" for usage');
|
|
165
|
+
process.exit(1);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
main().catch(err => {
|
|
170
|
+
console.error(err.message || err);
|
|
171
|
+
process.exit(1);
|
|
172
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// Lightweight typed event bus for the automation runtime
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
AutomationEventType,
|
|
5
|
+
AutomationEventPayloads,
|
|
6
|
+
AutomationEventHandler,
|
|
7
|
+
} from './types.js';
|
|
8
|
+
|
|
9
|
+
export class AutomationEventBus {
|
|
10
|
+
private handlers = new Map<AutomationEventType, Set<Function>>();
|
|
11
|
+
|
|
12
|
+
on<E extends AutomationEventType>(
|
|
13
|
+
event: E,
|
|
14
|
+
handler: AutomationEventHandler<E>,
|
|
15
|
+
): void {
|
|
16
|
+
let set = this.handlers.get(event);
|
|
17
|
+
if (!set) {
|
|
18
|
+
set = new Set();
|
|
19
|
+
this.handlers.set(event, set);
|
|
20
|
+
}
|
|
21
|
+
set.add(handler);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Emit an event — handlers run sequentially, errors are returned (not thrown) */
|
|
25
|
+
async emit<E extends AutomationEventType>(
|
|
26
|
+
event: E,
|
|
27
|
+
payload: AutomationEventPayloads[E],
|
|
28
|
+
): Promise<Error[]> {
|
|
29
|
+
const set = this.handlers.get(event);
|
|
30
|
+
if (!set || set.size === 0) return [];
|
|
31
|
+
|
|
32
|
+
const errors: Error[] = [];
|
|
33
|
+
for (const handler of set) {
|
|
34
|
+
try {
|
|
35
|
+
await handler(payload);
|
|
36
|
+
} catch (err) {
|
|
37
|
+
errors.push(err instanceof Error ? err : new Error(String(err)));
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return errors;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Check if any handlers are registered for an event */
|
|
44
|
+
has(event: AutomationEventType): boolean {
|
|
45
|
+
const set = this.handlers.get(event);
|
|
46
|
+
return set !== undefined && set.size > 0;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
removeAll(): void {
|
|
50
|
+
this.handlers.clear();
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Automation script loader — discovers and loads .ts automation files
|
|
2
|
+
|
|
3
|
+
import { existsSync, readdirSync, mkdirSync } from 'fs';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import os from 'os';
|
|
6
|
+
import type { AutomationFactory } from './types.js';
|
|
7
|
+
|
|
8
|
+
const AUTOMATIONS_DIR = path.join(os.homedir(), '.openbroker', 'automations');
|
|
9
|
+
|
|
10
|
+
/** Resolve a script path from a name or path */
|
|
11
|
+
export function resolveScriptPath(nameOrPath: string): string {
|
|
12
|
+
// Absolute path
|
|
13
|
+
if (path.isAbsolute(nameOrPath)) {
|
|
14
|
+
if (!existsSync(nameOrPath)) {
|
|
15
|
+
throw new Error(`Automation script not found: ${nameOrPath}`);
|
|
16
|
+
}
|
|
17
|
+
return nameOrPath;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Relative to cwd
|
|
21
|
+
const cwdPath = path.resolve(process.cwd(), nameOrPath);
|
|
22
|
+
if (existsSync(cwdPath)) return cwdPath;
|
|
23
|
+
|
|
24
|
+
// Relative to ~/.openbroker/automations/
|
|
25
|
+
const globalPath = path.join(AUTOMATIONS_DIR, nameOrPath);
|
|
26
|
+
if (existsSync(globalPath)) return globalPath;
|
|
27
|
+
|
|
28
|
+
// Try appending .ts
|
|
29
|
+
const withExt = path.join(AUTOMATIONS_DIR, `${nameOrPath}.ts`);
|
|
30
|
+
if (existsSync(withExt)) return withExt;
|
|
31
|
+
|
|
32
|
+
throw new Error(
|
|
33
|
+
`Automation script not found: ${nameOrPath}\n` +
|
|
34
|
+
`Searched:\n ${cwdPath}\n ${globalPath}\n ${withExt}`,
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Load an automation module and validate the default export */
|
|
39
|
+
export async function loadAutomation(scriptPath: string): Promise<AutomationFactory> {
|
|
40
|
+
const absolutePath = path.resolve(scriptPath);
|
|
41
|
+
|
|
42
|
+
// Dynamic import — tsx handles TypeScript transpilation
|
|
43
|
+
const mod = await import(absolutePath);
|
|
44
|
+
|
|
45
|
+
const factory = mod.default;
|
|
46
|
+
if (typeof factory !== 'function') {
|
|
47
|
+
throw new Error(
|
|
48
|
+
`Automation script must export a default function.\n` +
|
|
49
|
+
`Got: ${typeof factory} from ${scriptPath}`,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return factory as AutomationFactory;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** List available automation scripts in ~/.openbroker/automations/ */
|
|
57
|
+
export function listAutomations(): Array<{ name: string; path: string }> {
|
|
58
|
+
if (!existsSync(AUTOMATIONS_DIR)) return [];
|
|
59
|
+
|
|
60
|
+
return readdirSync(AUTOMATIONS_DIR)
|
|
61
|
+
.filter(f => f.endsWith('.ts') && !f.startsWith('.'))
|
|
62
|
+
.map(f => ({
|
|
63
|
+
name: f.replace(/\.ts$/, ''),
|
|
64
|
+
path: path.join(AUTOMATIONS_DIR, f),
|
|
65
|
+
}));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Ensure the automations directory exists */
|
|
69
|
+
export function ensureAutomationsDir(): void {
|
|
70
|
+
mkdirSync(AUTOMATIONS_DIR, { recursive: true });
|
|
71
|
+
}
|
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
// Automation Runtime — loads scripts, polls market data, dispatches events
|
|
2
|
+
|
|
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 type { HyperliquidClient } from '../core/client.js';
|
|
8
|
+
import {
|
|
9
|
+
roundPrice, roundSize, sleep, normalizeCoin,
|
|
10
|
+
formatUsd, formatPercent, annualizeFundingRate,
|
|
11
|
+
} from '../core/utils.js';
|
|
12
|
+
import { AutomationEventBus } from './events.js';
|
|
13
|
+
import { loadAutomation } from './loader.js';
|
|
14
|
+
import type {
|
|
15
|
+
AutomationAPI,
|
|
16
|
+
AutomationLogger,
|
|
17
|
+
AutomationState,
|
|
18
|
+
AutomationSnapshot,
|
|
19
|
+
PositionSnapshot,
|
|
20
|
+
ScheduledTask,
|
|
21
|
+
RunningAutomation,
|
|
22
|
+
} from './types.js';
|
|
23
|
+
|
|
24
|
+
const STATE_DIR = path.join(os.homedir(), '.openbroker', 'state');
|
|
25
|
+
|
|
26
|
+
// ── State persistence ───────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
function createState(id: string): AutomationState {
|
|
29
|
+
mkdirSync(STATE_DIR, { recursive: true });
|
|
30
|
+
const stateFile = path.join(STATE_DIR, `${id}.json`);
|
|
31
|
+
|
|
32
|
+
let data: Record<string, unknown> = {};
|
|
33
|
+
if (existsSync(stateFile)) {
|
|
34
|
+
try {
|
|
35
|
+
data = JSON.parse(readFileSync(stateFile, 'utf-8'));
|
|
36
|
+
} catch {
|
|
37
|
+
data = {};
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
let flushTimer: ReturnType<typeof setTimeout> | null = null;
|
|
42
|
+
function scheduleFlush() {
|
|
43
|
+
if (flushTimer) return;
|
|
44
|
+
flushTimer = setTimeout(() => {
|
|
45
|
+
flushTimer = null;
|
|
46
|
+
writeFileSync(stateFile, JSON.stringify(data, null, 2));
|
|
47
|
+
}, 500);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
get<T = unknown>(key: string, defaultValue?: T): T | undefined {
|
|
52
|
+
return (key in data ? data[key] : defaultValue) as T | undefined;
|
|
53
|
+
},
|
|
54
|
+
set<T = unknown>(key: string, value: T): void {
|
|
55
|
+
data[key] = value;
|
|
56
|
+
scheduleFlush();
|
|
57
|
+
},
|
|
58
|
+
delete(key: string): void {
|
|
59
|
+
delete data[key];
|
|
60
|
+
scheduleFlush();
|
|
61
|
+
},
|
|
62
|
+
clear(): void {
|
|
63
|
+
data = {};
|
|
64
|
+
scheduleFlush();
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ── Logger ──────────────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
function createLogger(id: string, verbose: boolean): AutomationLogger {
|
|
72
|
+
const prefix = `[auto:${id}]`;
|
|
73
|
+
return {
|
|
74
|
+
info: (msg: string) => console.log(`${prefix} ${msg}`),
|
|
75
|
+
warn: (msg: string) => console.log(`${prefix} ⚠ ${msg}`),
|
|
76
|
+
error: (msg: string) => console.error(`${prefix} ✗ ${msg}`),
|
|
77
|
+
debug: (msg: string) => { if (verbose) console.log(`${prefix} … ${msg}`); },
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ── Dry-run client proxy ────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
const WRITE_METHODS = new Set([
|
|
84
|
+
'order', 'marketOrder', 'limitOrder', 'triggerOrder',
|
|
85
|
+
'takeProfit', 'stopLoss', 'cancel', 'cancelAll',
|
|
86
|
+
'updateLeverage', 'approveBuilderFee',
|
|
87
|
+
]);
|
|
88
|
+
|
|
89
|
+
function createDryClient(client: HyperliquidClient, log: AutomationLogger): HyperliquidClient {
|
|
90
|
+
return new Proxy(client, {
|
|
91
|
+
get(target, prop, receiver) {
|
|
92
|
+
const value = Reflect.get(target, prop, receiver);
|
|
93
|
+
if (typeof prop === 'string' && WRITE_METHODS.has(prop) && typeof value === 'function') {
|
|
94
|
+
return (...args: unknown[]) => {
|
|
95
|
+
log.info(`[DRY] ${prop}(${args.map(a => JSON.stringify(a)).join(', ')})`);
|
|
96
|
+
return Promise.resolve({ status: 'ok', response: { type: 'dry_run' } });
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
return value;
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ── Snapshot building ───────────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
async function buildSnapshot(
|
|
107
|
+
client: HyperliquidClient,
|
|
108
|
+
): Promise<AutomationSnapshot> {
|
|
109
|
+
const [state, mids, metaCtxs] = await Promise.all([
|
|
110
|
+
client.getUserStateAll(),
|
|
111
|
+
client.getAllMids(),
|
|
112
|
+
client.getMetaAndAssetCtxs(),
|
|
113
|
+
]);
|
|
114
|
+
|
|
115
|
+
const prices = new Map<string, number>();
|
|
116
|
+
for (const [coin, mid] of Object.entries(mids)) {
|
|
117
|
+
prices.set(coin, parseFloat(mid as string));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const positions = new Map<string, PositionSnapshot>();
|
|
121
|
+
for (const ap of state.assetPositions) {
|
|
122
|
+
const p = ap.position;
|
|
123
|
+
const size = parseFloat(p.szi);
|
|
124
|
+
if (size === 0) continue;
|
|
125
|
+
positions.set(p.coin, {
|
|
126
|
+
coin: p.coin,
|
|
127
|
+
size,
|
|
128
|
+
entryPrice: parseFloat(p.entryPx),
|
|
129
|
+
positionValue: parseFloat(p.positionValue),
|
|
130
|
+
unrealizedPnl: parseFloat(p.unrealizedPnl),
|
|
131
|
+
liquidationPx: p.liquidationPx ? parseFloat(p.liquidationPx) : null,
|
|
132
|
+
leverage: typeof p.leverage === 'object' ? p.leverage.value : parseFloat(String(p.leverage)),
|
|
133
|
+
marginUsed: parseFloat(p.marginUsed),
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const equity = parseFloat(state.marginSummary.accountValue);
|
|
138
|
+
const marginUsed = parseFloat(state.marginSummary.totalMarginUsed);
|
|
139
|
+
|
|
140
|
+
// Build funding rates from asset contexts
|
|
141
|
+
const fundingRates = new Map<string, { rate: number; premium: number }>();
|
|
142
|
+
if (metaCtxs && Array.isArray(metaCtxs)) {
|
|
143
|
+
for (const group of metaCtxs) {
|
|
144
|
+
if (!group.universe || !group.assetCtxs) continue;
|
|
145
|
+
for (let i = 0; i < group.universe.length; i++) {
|
|
146
|
+
const meta = group.universe[i];
|
|
147
|
+
const ctx = group.assetCtxs[i];
|
|
148
|
+
if (ctx && meta) {
|
|
149
|
+
fundingRates.set(meta.name, {
|
|
150
|
+
rate: parseFloat(ctx.funding || '0'),
|
|
151
|
+
premium: parseFloat(ctx.premium || '0'),
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
prices,
|
|
160
|
+
positions,
|
|
161
|
+
openOrderIds: new Set(), // filled by separate call if needed
|
|
162
|
+
equity,
|
|
163
|
+
marginUsed,
|
|
164
|
+
marginUsedPct: equity > 0 ? (marginUsed / equity) * 100 : 0,
|
|
165
|
+
fundingRates,
|
|
166
|
+
timestamp: Date.now(),
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ── Runtime ─────────────────────────────────────────────────────────
|
|
171
|
+
|
|
172
|
+
export interface RuntimeOptions {
|
|
173
|
+
scriptPath: string;
|
|
174
|
+
id?: string;
|
|
175
|
+
dryRun?: boolean;
|
|
176
|
+
verbose?: boolean;
|
|
177
|
+
pollIntervalMs?: number;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Registry of all running automations */
|
|
181
|
+
const registry = new Map<string, RunningAutomation>();
|
|
182
|
+
|
|
183
|
+
export function getRunningAutomations(): RunningAutomation[] {
|
|
184
|
+
return [...registry.values()];
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function getAutomation(id: string): RunningAutomation | undefined {
|
|
188
|
+
return registry.get(id);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export async function startAutomation(options: RuntimeOptions): Promise<RunningAutomation> {
|
|
192
|
+
const {
|
|
193
|
+
scriptPath,
|
|
194
|
+
dryRun = false,
|
|
195
|
+
verbose = false,
|
|
196
|
+
pollIntervalMs = 10_000,
|
|
197
|
+
} = options;
|
|
198
|
+
|
|
199
|
+
const id = options.id || path.basename(scriptPath, '.ts');
|
|
200
|
+
|
|
201
|
+
if (registry.has(id)) {
|
|
202
|
+
throw new Error(`Automation "${id}" is already running`);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const log = createLogger(id, verbose);
|
|
206
|
+
const state = createState(id);
|
|
207
|
+
const eventBus = new AutomationEventBus();
|
|
208
|
+
|
|
209
|
+
const rawClient = getClient();
|
|
210
|
+
const client = dryRun ? createDryClient(rawClient, log) : rawClient;
|
|
211
|
+
|
|
212
|
+
const startHooks: Array<() => void | Promise<void>> = [];
|
|
213
|
+
const stopHooks: Array<() => void | Promise<void>> = [];
|
|
214
|
+
const errorHooks: Array<(err: Error) => void | Promise<void>> = [];
|
|
215
|
+
const scheduledTasks: ScheduledTask[] = [];
|
|
216
|
+
|
|
217
|
+
// Build the API object
|
|
218
|
+
const api: AutomationAPI = {
|
|
219
|
+
client,
|
|
220
|
+
utils: { roundPrice, roundSize, sleep, normalizeCoin, formatUsd, formatPercent, annualizeFundingRate },
|
|
221
|
+
on: (event, handler) => eventBus.on(event, handler),
|
|
222
|
+
every: (intervalMs, handler) => scheduledTasks.push({ intervalMs, handler, lastRun: 0 }),
|
|
223
|
+
onStart: (handler) => startHooks.push(handler),
|
|
224
|
+
onStop: (handler) => stopHooks.push(handler),
|
|
225
|
+
onError: (handler) => errorHooks.push(handler),
|
|
226
|
+
state,
|
|
227
|
+
log,
|
|
228
|
+
id,
|
|
229
|
+
dryRun,
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
// Load and execute the factory function (registers handlers)
|
|
233
|
+
log.info(`Loading automation: ${scriptPath}`);
|
|
234
|
+
const factory = await loadAutomation(scriptPath);
|
|
235
|
+
await factory(api);
|
|
236
|
+
|
|
237
|
+
// Call onStart hooks
|
|
238
|
+
for (const hook of startHooks) {
|
|
239
|
+
try { await hook(); } catch (err) {
|
|
240
|
+
log.error(`onStart hook error: ${err instanceof Error ? err.message : String(err)}`);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Polling state
|
|
245
|
+
let previousSnapshot: AutomationSnapshot | null = null;
|
|
246
|
+
let pollCount = 0;
|
|
247
|
+
let eventsEmitted = 0;
|
|
248
|
+
let isPolling = false;
|
|
249
|
+
let stopped = false;
|
|
250
|
+
|
|
251
|
+
async function handleErrors(errors: Error[]) {
|
|
252
|
+
for (const err of errors) {
|
|
253
|
+
log.error(`Handler error: ${err.message}`);
|
|
254
|
+
for (const hook of errorHooks) {
|
|
255
|
+
try { await hook(err); } catch { /* swallow */ }
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async function poll() {
|
|
261
|
+
if (isPolling || stopped) return;
|
|
262
|
+
isPolling = true;
|
|
263
|
+
|
|
264
|
+
try {
|
|
265
|
+
const snapshot = await buildSnapshot(rawClient);
|
|
266
|
+
pollCount++;
|
|
267
|
+
const now = Date.now();
|
|
268
|
+
|
|
269
|
+
// Always emit tick
|
|
270
|
+
const tickErrors = await eventBus.emit('tick', { timestamp: now, pollCount });
|
|
271
|
+
if (tickErrors.length) await handleErrors(tickErrors);
|
|
272
|
+
eventsEmitted++;
|
|
273
|
+
|
|
274
|
+
if (previousSnapshot) {
|
|
275
|
+
// Price changes
|
|
276
|
+
if (eventBus.has('price_change')) {
|
|
277
|
+
for (const [coin, newPrice] of snapshot.prices) {
|
|
278
|
+
const oldPrice = previousSnapshot.prices.get(coin);
|
|
279
|
+
if (oldPrice === undefined || oldPrice === 0) continue;
|
|
280
|
+
const changePct = ((newPrice - oldPrice) / oldPrice) * 100;
|
|
281
|
+
if (Math.abs(changePct) >= 0.1) { // 0.1% minimum to fire
|
|
282
|
+
const errors = await eventBus.emit('price_change', { coin, oldPrice, newPrice, changePct });
|
|
283
|
+
if (errors.length) await handleErrors(errors);
|
|
284
|
+
eventsEmitted++;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Funding updates
|
|
290
|
+
if (eventBus.has('funding_update')) {
|
|
291
|
+
for (const [coin, data] of snapshot.fundingRates) {
|
|
292
|
+
const errors = await eventBus.emit('funding_update', {
|
|
293
|
+
coin,
|
|
294
|
+
fundingRate: data.rate,
|
|
295
|
+
annualized: annualizeFundingRate(data.rate),
|
|
296
|
+
premium: data.premium,
|
|
297
|
+
});
|
|
298
|
+
if (errors.length) await handleErrors(errors);
|
|
299
|
+
eventsEmitted++;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Position opened
|
|
304
|
+
if (eventBus.has('position_opened')) {
|
|
305
|
+
for (const [coin, pos] of snapshot.positions) {
|
|
306
|
+
if (!previousSnapshot.positions.has(coin)) {
|
|
307
|
+
const errors = await eventBus.emit('position_opened', {
|
|
308
|
+
coin,
|
|
309
|
+
side: pos.size > 0 ? 'long' : 'short',
|
|
310
|
+
size: Math.abs(pos.size),
|
|
311
|
+
entryPrice: pos.entryPrice,
|
|
312
|
+
});
|
|
313
|
+
if (errors.length) await handleErrors(errors);
|
|
314
|
+
eventsEmitted++;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Position closed
|
|
320
|
+
if (eventBus.has('position_closed')) {
|
|
321
|
+
for (const [coin, prevPos] of previousSnapshot.positions) {
|
|
322
|
+
if (!snapshot.positions.has(coin)) {
|
|
323
|
+
const errors = await eventBus.emit('position_closed', {
|
|
324
|
+
coin,
|
|
325
|
+
previousSize: prevPos.size,
|
|
326
|
+
entryPrice: prevPos.entryPrice,
|
|
327
|
+
});
|
|
328
|
+
if (errors.length) await handleErrors(errors);
|
|
329
|
+
eventsEmitted++;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Position size changed
|
|
335
|
+
if (eventBus.has('position_changed')) {
|
|
336
|
+
for (const [coin, pos] of snapshot.positions) {
|
|
337
|
+
const prevPos = previousSnapshot.positions.get(coin);
|
|
338
|
+
if (prevPos && pos.size !== prevPos.size) {
|
|
339
|
+
const errors = await eventBus.emit('position_changed', {
|
|
340
|
+
coin,
|
|
341
|
+
oldSize: prevPos.size,
|
|
342
|
+
newSize: pos.size,
|
|
343
|
+
entryPrice: pos.entryPrice,
|
|
344
|
+
});
|
|
345
|
+
if (errors.length) await handleErrors(errors);
|
|
346
|
+
eventsEmitted++;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// PnL threshold (5% of position value)
|
|
352
|
+
if (eventBus.has('pnl_threshold')) {
|
|
353
|
+
for (const [coin, pos] of snapshot.positions) {
|
|
354
|
+
const prevPos = previousSnapshot.positions.get(coin);
|
|
355
|
+
if (!prevPos || pos.positionValue === 0) continue;
|
|
356
|
+
const pnlChange = Math.abs(pos.unrealizedPnl - prevPos.unrealizedPnl);
|
|
357
|
+
const changePct = (pnlChange / pos.positionValue) * 100;
|
|
358
|
+
if (changePct >= 5) {
|
|
359
|
+
const errors = await eventBus.emit('pnl_threshold', {
|
|
360
|
+
coin,
|
|
361
|
+
unrealizedPnl: pos.unrealizedPnl,
|
|
362
|
+
changePct,
|
|
363
|
+
positionValue: pos.positionValue,
|
|
364
|
+
});
|
|
365
|
+
if (errors.length) await handleErrors(errors);
|
|
366
|
+
eventsEmitted++;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Margin warning (80%)
|
|
372
|
+
if (eventBus.has('margin_warning') && snapshot.marginUsedPct >= 80) {
|
|
373
|
+
const prevPct = previousSnapshot.marginUsedPct;
|
|
374
|
+
if (prevPct < 80 || snapshot.marginUsedPct - prevPct >= 5) {
|
|
375
|
+
const errors = await eventBus.emit('margin_warning', {
|
|
376
|
+
marginUsedPct: snapshot.marginUsedPct,
|
|
377
|
+
equity: snapshot.equity,
|
|
378
|
+
marginUsed: snapshot.marginUsed,
|
|
379
|
+
});
|
|
380
|
+
if (errors.length) await handleErrors(errors);
|
|
381
|
+
eventsEmitted++;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// Order filled — compare open order IDs
|
|
386
|
+
// (Skipped for MVP — requires tracking open orders per poll, will add when needed)
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Run scheduled tasks
|
|
390
|
+
for (const task of scheduledTasks) {
|
|
391
|
+
if (now - task.lastRun >= task.intervalMs) {
|
|
392
|
+
try {
|
|
393
|
+
await task.handler();
|
|
394
|
+
} catch (err) {
|
|
395
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
396
|
+
log.error(`Scheduled task error: ${error.message}`);
|
|
397
|
+
await handleErrors([error]);
|
|
398
|
+
}
|
|
399
|
+
task.lastRun = now;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
previousSnapshot = snapshot;
|
|
404
|
+
} catch (err) {
|
|
405
|
+
log.error(`Poll error: ${err instanceof Error ? err.message : String(err)}`);
|
|
406
|
+
} finally {
|
|
407
|
+
isPolling = false;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Start polling
|
|
412
|
+
log.info(`Started (poll every ${pollIntervalMs / 1000}s, dry=${dryRun})`);
|
|
413
|
+
const timer = setInterval(poll, pollIntervalMs);
|
|
414
|
+
|
|
415
|
+
// Initial poll to seed state
|
|
416
|
+
await poll();
|
|
417
|
+
|
|
418
|
+
// Stop function
|
|
419
|
+
async function stop() {
|
|
420
|
+
if (stopped) return;
|
|
421
|
+
stopped = true;
|
|
422
|
+
clearInterval(timer);
|
|
423
|
+
|
|
424
|
+
for (const hook of stopHooks) {
|
|
425
|
+
try { await hook(); } catch (err) {
|
|
426
|
+
log.error(`onStop hook error: ${err instanceof Error ? err.message : String(err)}`);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
eventBus.removeAll();
|
|
431
|
+
registry.delete(id);
|
|
432
|
+
log.info(`Stopped (${pollCount} polls, ${eventsEmitted} events)`);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const entry: RunningAutomation = {
|
|
436
|
+
id,
|
|
437
|
+
scriptPath,
|
|
438
|
+
startedAt: new Date(),
|
|
439
|
+
get pollCount() { return pollCount; },
|
|
440
|
+
get eventsEmitted() { return eventsEmitted; },
|
|
441
|
+
dryRun,
|
|
442
|
+
stop,
|
|
443
|
+
};
|
|
444
|
+
|
|
445
|
+
registry.set(id, entry);
|
|
446
|
+
return entry;
|
|
447
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// Trading Automation Harness — Type definitions
|
|
2
|
+
// The API contract that automation scripts code against
|
|
3
|
+
|
|
4
|
+
import type { HyperliquidClient } from '../core/client.js';
|
|
5
|
+
|
|
6
|
+
// ── Factory function ────────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
/** What an automation .ts file exports */
|
|
9
|
+
export type AutomationFactory = (api: AutomationAPI) => void | Promise<void>;
|
|
10
|
+
|
|
11
|
+
// ── Event system ────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
export type AutomationEventType =
|
|
14
|
+
| 'tick'
|
|
15
|
+
| 'price_change'
|
|
16
|
+
| 'funding_update'
|
|
17
|
+
| 'position_opened'
|
|
18
|
+
| 'position_closed'
|
|
19
|
+
| 'position_changed'
|
|
20
|
+
| 'pnl_threshold'
|
|
21
|
+
| 'margin_warning'
|
|
22
|
+
| 'order_filled';
|
|
23
|
+
|
|
24
|
+
export interface AutomationEventPayloads {
|
|
25
|
+
tick: { timestamp: number; pollCount: number };
|
|
26
|
+
price_change: { coin: string; oldPrice: number; newPrice: number; changePct: number };
|
|
27
|
+
funding_update: { coin: string; fundingRate: number; annualized: number; premium: number };
|
|
28
|
+
position_opened: { coin: string; side: 'long' | 'short'; size: number; entryPrice: number };
|
|
29
|
+
position_closed: { coin: string; previousSize: number; entryPrice: number };
|
|
30
|
+
position_changed: { coin: string; oldSize: number; newSize: number; entryPrice: number };
|
|
31
|
+
pnl_threshold: { coin: string; unrealizedPnl: number; changePct: number; positionValue: number };
|
|
32
|
+
margin_warning: { marginUsedPct: number; equity: number; marginUsed: number };
|
|
33
|
+
order_filled: { coin: string; oid: number; side: 'buy' | 'sell'; size: number; price: number };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type AutomationEventHandler<E extends AutomationEventType> =
|
|
37
|
+
(payload: AutomationEventPayloads[E]) => void | Promise<void>;
|
|
38
|
+
|
|
39
|
+
// ── State & logging ─────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
export interface AutomationState {
|
|
42
|
+
get<T = unknown>(key: string, defaultValue?: T): T | undefined;
|
|
43
|
+
set<T = unknown>(key: string, value: T): void;
|
|
44
|
+
delete(key: string): void;
|
|
45
|
+
clear(): void;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface AutomationLogger {
|
|
49
|
+
info(message: string): void;
|
|
50
|
+
warn(message: string): void;
|
|
51
|
+
error(message: string): void;
|
|
52
|
+
debug(message: string): void;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ── Core API ────────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
export interface AutomationAPI {
|
|
58
|
+
/** Full Hyperliquid client (42+ methods) */
|
|
59
|
+
client: HyperliquidClient;
|
|
60
|
+
|
|
61
|
+
/** Convenience utilities from core */
|
|
62
|
+
utils: {
|
|
63
|
+
roundPrice: (price: number, szDecimals: number, isSpot?: boolean) => string;
|
|
64
|
+
roundSize: (size: number, szDecimals: number) => string;
|
|
65
|
+
sleep: (ms: number) => Promise<void>;
|
|
66
|
+
normalizeCoin: (coin: string) => string;
|
|
67
|
+
formatUsd: (amount: number | string) => string;
|
|
68
|
+
formatPercent: (value: number | string, decimals?: number) => string;
|
|
69
|
+
annualizeFundingRate: (hourlyRate: number | string) => number;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/** Subscribe to a market/account event */
|
|
73
|
+
on<E extends AutomationEventType>(event: E, handler: AutomationEventHandler<E>): void;
|
|
74
|
+
|
|
75
|
+
/** Run a handler on a recurring interval (ms). Aligned to the poll loop. */
|
|
76
|
+
every(intervalMs: number, handler: () => void | Promise<void>): void;
|
|
77
|
+
|
|
78
|
+
/** Called after all handlers are registered and polling begins */
|
|
79
|
+
onStart(handler: () => void | Promise<void>): void;
|
|
80
|
+
|
|
81
|
+
/** Called when automation is stopping (SIGINT, manual stop). Use for cleanup. */
|
|
82
|
+
onStop(handler: () => void | Promise<void>): void;
|
|
83
|
+
|
|
84
|
+
/** Called when a handler throws. The error is already logged — use this for recovery logic. */
|
|
85
|
+
onError(handler: (error: Error) => void | Promise<void>): void;
|
|
86
|
+
|
|
87
|
+
/** Persisted key-value state (~/.openbroker/state/<id>.json) */
|
|
88
|
+
state: AutomationState;
|
|
89
|
+
|
|
90
|
+
/** Structured logger */
|
|
91
|
+
log: AutomationLogger;
|
|
92
|
+
|
|
93
|
+
/** Unique automation ID (derived from filename or --id flag) */
|
|
94
|
+
id: string;
|
|
95
|
+
|
|
96
|
+
/** True if running in --dry mode (write methods are intercepted) */
|
|
97
|
+
dryRun: boolean;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ── Runtime internals ───────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
export interface AutomationSnapshot {
|
|
103
|
+
prices: Map<string, number>;
|
|
104
|
+
positions: Map<string, PositionSnapshot>;
|
|
105
|
+
openOrderIds: Set<number>;
|
|
106
|
+
equity: number;
|
|
107
|
+
marginUsed: number;
|
|
108
|
+
marginUsedPct: number;
|
|
109
|
+
fundingRates: Map<string, { rate: number; premium: number }>;
|
|
110
|
+
timestamp: number;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export interface PositionSnapshot {
|
|
114
|
+
coin: string;
|
|
115
|
+
size: number;
|
|
116
|
+
entryPrice: number;
|
|
117
|
+
positionValue: number;
|
|
118
|
+
unrealizedPnl: number;
|
|
119
|
+
liquidationPx: number | null;
|
|
120
|
+
leverage: number;
|
|
121
|
+
marginUsed: number;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export interface ScheduledTask {
|
|
125
|
+
intervalMs: number;
|
|
126
|
+
handler: () => void | Promise<void>;
|
|
127
|
+
lastRun: number;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export interface RunningAutomation {
|
|
131
|
+
id: string;
|
|
132
|
+
scriptPath: string;
|
|
133
|
+
startedAt: Date;
|
|
134
|
+
pollCount: number;
|
|
135
|
+
eventsEmitted: number;
|
|
136
|
+
dryRun: boolean;
|
|
137
|
+
stop: () => Promise<void>;
|
|
138
|
+
}
|
package/scripts/plugin/tools.ts
CHANGED
|
@@ -1299,5 +1299,92 @@ export function createTools(watcher: PositionWatcher | null): PluginTool[] {
|
|
|
1299
1299
|
return json(watcher.getStatus());
|
|
1300
1300
|
},
|
|
1301
1301
|
},
|
|
1302
|
+
|
|
1303
|
+
// ── Automation Tools ──────────────────────────────────────────
|
|
1304
|
+
|
|
1305
|
+
{
|
|
1306
|
+
name: 'ob_auto_run',
|
|
1307
|
+
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/ or an absolute path.',
|
|
1308
|
+
parameters: {
|
|
1309
|
+
type: 'object',
|
|
1310
|
+
properties: {
|
|
1311
|
+
script: { type: 'string', description: 'Script name (from ~/.openbroker/automations/) or absolute path' },
|
|
1312
|
+
id: { type: 'string', description: 'Custom automation ID (default: filename)' },
|
|
1313
|
+
dry: { type: 'boolean', description: 'Intercept write methods — no real trades' },
|
|
1314
|
+
poll: { type: 'number', description: 'Poll interval in milliseconds (default: 10000)' },
|
|
1315
|
+
},
|
|
1316
|
+
required: ['script'],
|
|
1317
|
+
},
|
|
1318
|
+
async execute(_id, params) {
|
|
1319
|
+
try {
|
|
1320
|
+
const { resolveScriptPath } = await import('../auto/loader.js');
|
|
1321
|
+
const { startAutomation } = await import('../auto/runtime.js');
|
|
1322
|
+
|
|
1323
|
+
const scriptPath = resolveScriptPath(String(params.script));
|
|
1324
|
+
const automation = await startAutomation({
|
|
1325
|
+
scriptPath,
|
|
1326
|
+
id: params.id ? String(params.id) : undefined,
|
|
1327
|
+
dryRun: params.dry === true,
|
|
1328
|
+
pollIntervalMs: params.poll ? Number(params.poll) : 10_000,
|
|
1329
|
+
});
|
|
1330
|
+
|
|
1331
|
+
return json({
|
|
1332
|
+
status: 'started',
|
|
1333
|
+
id: automation.id,
|
|
1334
|
+
scriptPath: automation.scriptPath,
|
|
1335
|
+
dryRun: automation.dryRun,
|
|
1336
|
+
});
|
|
1337
|
+
} catch (err) {
|
|
1338
|
+
return error(err instanceof Error ? err.message : String(err));
|
|
1339
|
+
}
|
|
1340
|
+
},
|
|
1341
|
+
},
|
|
1342
|
+
|
|
1343
|
+
{
|
|
1344
|
+
name: 'ob_auto_stop',
|
|
1345
|
+
description: 'Stop a running trading automation by ID',
|
|
1346
|
+
parameters: {
|
|
1347
|
+
type: 'object',
|
|
1348
|
+
properties: {
|
|
1349
|
+
id: { type: 'string', description: 'Automation ID to stop' },
|
|
1350
|
+
},
|
|
1351
|
+
required: ['id'],
|
|
1352
|
+
},
|
|
1353
|
+
async execute(_id, params) {
|
|
1354
|
+
try {
|
|
1355
|
+
const { getAutomation } = await import('../auto/runtime.js');
|
|
1356
|
+
const automation = getAutomation(String(params.id));
|
|
1357
|
+
if (!automation) {
|
|
1358
|
+
return error(`No running automation with ID "${params.id}"`);
|
|
1359
|
+
}
|
|
1360
|
+
await automation.stop();
|
|
1361
|
+
return json({ status: 'stopped', id: params.id });
|
|
1362
|
+
} catch (err) {
|
|
1363
|
+
return error(err instanceof Error ? err.message : String(err));
|
|
1364
|
+
}
|
|
1365
|
+
},
|
|
1366
|
+
},
|
|
1367
|
+
|
|
1368
|
+
{
|
|
1369
|
+
name: 'ob_auto_list',
|
|
1370
|
+
description: 'List available automation scripts and running automations',
|
|
1371
|
+
parameters: { type: 'object', properties: {} },
|
|
1372
|
+
async execute() {
|
|
1373
|
+
const { listAutomations } = await import('../auto/loader.js');
|
|
1374
|
+
const { getRunningAutomations } = await import('../auto/runtime.js');
|
|
1375
|
+
|
|
1376
|
+
const available = listAutomations();
|
|
1377
|
+
const running = getRunningAutomations().map(a => ({
|
|
1378
|
+
id: a.id,
|
|
1379
|
+
scriptPath: a.scriptPath,
|
|
1380
|
+
uptime: Math.round((Date.now() - a.startedAt.getTime()) / 1000),
|
|
1381
|
+
pollCount: a.pollCount,
|
|
1382
|
+
eventsEmitted: a.eventsEmitted,
|
|
1383
|
+
dryRun: a.dryRun,
|
|
1384
|
+
}));
|
|
1385
|
+
|
|
1386
|
+
return json({ available, running });
|
|
1387
|
+
},
|
|
1388
|
+
},
|
|
1302
1389
|
];
|
|
1303
1390
|
}
|