openbroker 1.0.60 → 1.0.62
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 +244 -3
- package/bin/cli.ts +8 -0
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/scripts/auto/cli.ts +239 -0
- package/scripts/auto/events.ts +52 -0
- package/scripts/auto/loader.ts +71 -0
- package/scripts/auto/registry.ts +118 -0
- package/scripts/auto/runtime.ts +526 -0
- package/scripts/auto/types.ts +167 -0
- package/scripts/plugin/index.ts +80 -4
- package/scripts/plugin/tools.ts +119 -1
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.62", "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,243 @@ 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.publish(message, options?)` | Send a message to the OpenClaw agent via webhook. Triggers an agent turn — the agent receives the message and can notify the user, take action, etc. Returns `true` if delivered. Options: `{ name?, wakeMode?, deliver?, channel? }` |
|
|
550
|
+
| `api.log.info/warn/error/debug(msg)` | Structured logger |
|
|
551
|
+
| `api.utils` | `roundPrice`, `roundSize`, `sleep`, `normalizeCoin`, `formatUsd`, `annualizeFundingRate` |
|
|
552
|
+
| `api.id` | Automation ID (filename or `--id` flag) |
|
|
553
|
+
| `api.dryRun` | `true` if running with `--dry` (write methods are intercepted) |
|
|
554
|
+
|
|
555
|
+
### Events
|
|
556
|
+
|
|
557
|
+
| Event | Payload | When |
|
|
558
|
+
|-------|---------|------|
|
|
559
|
+
| `tick` | `{ timestamp, pollCount }` | Every poll cycle (default: 10s) |
|
|
560
|
+
| `price_change` | `{ coin, oldPrice, newPrice, changePct }` | Mid price moved > 0.1% |
|
|
561
|
+
| `funding_update` | `{ coin, fundingRate, annualized, premium }` | Every poll for all assets |
|
|
562
|
+
| `position_opened` | `{ coin, side, size, entryPrice }` | New position detected |
|
|
563
|
+
| `position_closed` | `{ coin, previousSize, entryPrice }` | Position no longer present |
|
|
564
|
+
| `position_changed` | `{ coin, oldSize, newSize, entryPrice }` | Position size changed |
|
|
565
|
+
| `pnl_threshold` | `{ coin, unrealizedPnl, changePct, positionValue }` | PnL moved > 5% of position value |
|
|
566
|
+
| `margin_warning` | `{ marginUsedPct, equity, marginUsed }` | Margin usage > 80% |
|
|
567
|
+
|
|
568
|
+
### Client Methods Available
|
|
569
|
+
|
|
570
|
+
The `api.client` object exposes the full Hyperliquid SDK:
|
|
571
|
+
|
|
572
|
+
**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?)`
|
|
573
|
+
|
|
574
|
+
**Market Data:** `getAllMids()`, `getMetaAndAssetCtxs()`, `getRecentTrades(coin)`, `getCandleSnapshot(coin, interval)`, `getFundingHistory(coin)`, `getPredictedFundings()`
|
|
575
|
+
|
|
576
|
+
**Account:** `getUserStateAll()`, `getOpenOrders()`, `getUserFills()`, `getUserFunding()`, `getHistoricalOrders()`, `getUserFees()`, `getUserRateLimit()`, `getSpotBalances()`
|
|
577
|
+
|
|
578
|
+
**Leverage:** `updateLeverage(coin, leverage, isIsolated?)`
|
|
579
|
+
|
|
580
|
+
### Example: Price Breakout
|
|
581
|
+
|
|
582
|
+
```typescript
|
|
583
|
+
// ~/.openbroker/automations/breakout.ts
|
|
584
|
+
export default function(api) {
|
|
585
|
+
const COIN = 'ETH';
|
|
586
|
+
const BREAKOUT_PCT = 2; // 2% move triggers entry
|
|
587
|
+
const SIZE = 0.5;
|
|
588
|
+
let basePrice = null;
|
|
589
|
+
|
|
590
|
+
api.onStart(async () => {
|
|
591
|
+
const mids = await api.client.getAllMids();
|
|
592
|
+
basePrice = parseFloat(mids[COIN]);
|
|
593
|
+
api.log.info(`Watching ${COIN} from $${basePrice} for ${BREAKOUT_PCT}% breakout`);
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
api.on('price_change', async ({ coin, newPrice }) => {
|
|
597
|
+
if (coin !== COIN || !basePrice) return;
|
|
598
|
+
const totalChange = ((newPrice - basePrice) / basePrice) * 100;
|
|
599
|
+
|
|
600
|
+
if (Math.abs(totalChange) >= BREAKOUT_PCT && !api.state.get('inPosition')) {
|
|
601
|
+
const side = totalChange > 0; // true = long, false = short
|
|
602
|
+
api.log.info(`Breakout! ${totalChange.toFixed(2)}% — entering ${side ? 'long' : 'short'}`);
|
|
603
|
+
await api.client.marketOrder(COIN, side, SIZE);
|
|
604
|
+
api.state.set('inPosition', true);
|
|
605
|
+
}
|
|
606
|
+
});
|
|
607
|
+
}
|
|
608
|
+
```
|
|
609
|
+
|
|
610
|
+
### Example: Scheduled DCA
|
|
611
|
+
|
|
612
|
+
```typescript
|
|
613
|
+
// ~/.openbroker/automations/hourly-dca.ts
|
|
614
|
+
export default function(api) {
|
|
615
|
+
const COIN = 'ETH';
|
|
616
|
+
const USD_PER_BUY = 100;
|
|
617
|
+
|
|
618
|
+
// Buy $100 of ETH every hour
|
|
619
|
+
api.every(60 * 60 * 1000, async () => {
|
|
620
|
+
const mids = await api.client.getAllMids();
|
|
621
|
+
const price = parseFloat(mids[COIN]);
|
|
622
|
+
const size = parseFloat(api.utils.roundSize(USD_PER_BUY / price, 4));
|
|
623
|
+
await api.client.marketOrder(COIN, true, size);
|
|
624
|
+
const count = (api.state.get('buyCount') || 0) + 1;
|
|
625
|
+
api.state.set('buyCount', count);
|
|
626
|
+
api.log.info(`DCA #${count}: bought ${size} ${COIN} at $${price}`);
|
|
627
|
+
});
|
|
628
|
+
}
|
|
629
|
+
```
|
|
630
|
+
|
|
631
|
+
### Example: Margin Guardian
|
|
632
|
+
|
|
633
|
+
```typescript
|
|
634
|
+
// ~/.openbroker/automations/margin-guard.ts
|
|
635
|
+
export default function(api) {
|
|
636
|
+
api.on('margin_warning', async ({ marginUsedPct, equity }) => {
|
|
637
|
+
api.log.warn(`Margin at ${marginUsedPct.toFixed(1)}% — reducing positions`);
|
|
638
|
+
|
|
639
|
+
// Close the smallest position to free margin
|
|
640
|
+
const state = await api.client.getUserStateAll();
|
|
641
|
+
const positions = state.assetPositions
|
|
642
|
+
.filter(p => parseFloat(p.position.szi) !== 0)
|
|
643
|
+
.sort((a, b) => Math.abs(parseFloat(a.position.positionValue)) - Math.abs(parseFloat(b.position.positionValue)));
|
|
644
|
+
|
|
645
|
+
if (positions.length > 0) {
|
|
646
|
+
const pos = positions[0].position;
|
|
647
|
+
const size = Math.abs(parseFloat(pos.szi));
|
|
648
|
+
const isBuy = parseFloat(pos.szi) < 0; // Close short = buy, close long = sell
|
|
649
|
+
api.log.info(`Closing smallest position: ${pos.coin} (${pos.szi})`);
|
|
650
|
+
await api.client.marketOrder(pos.coin, isBuy, size);
|
|
651
|
+
}
|
|
652
|
+
});
|
|
653
|
+
}
|
|
654
|
+
```
|
|
655
|
+
|
|
656
|
+
### Publishing to the Agent (Webhooks)
|
|
657
|
+
|
|
658
|
+
Use `api.publish()` to send messages back to the OpenClaw agent. This triggers an agent turn — the agent receives the message and can notify the user via their preferred channel, take trading actions, or log the event.
|
|
659
|
+
|
|
660
|
+
```typescript
|
|
661
|
+
// Simple notification
|
|
662
|
+
await api.publish(`ETH broke above $4000 — current price: $${price}`);
|
|
663
|
+
|
|
664
|
+
// With options
|
|
665
|
+
await api.publish(`Margin at ${pct}% — positions at risk`, {
|
|
666
|
+
name: 'margin-alert', // appears in logs
|
|
667
|
+
wakeMode: 'now', // 'now' (default) or 'next-heartbeat'
|
|
668
|
+
channel: 'slack', // target channel (optional)
|
|
669
|
+
});
|
|
670
|
+
```
|
|
671
|
+
|
|
672
|
+
`api.publish()` returns `true` if delivered, `false` if webhooks are not configured (no hooks token). It requires `OPENCLAW_HOOKS_TOKEN` to be set (automatically configured when running as an OpenClaw plugin).
|
|
673
|
+
|
|
674
|
+
**Example: Price alert automation with publish**
|
|
675
|
+
```typescript
|
|
676
|
+
// ~/.openbroker/automations/price-alert.ts
|
|
677
|
+
export default function(api) {
|
|
678
|
+
const COIN = 'ETH';
|
|
679
|
+
const THRESHOLD = 4000;
|
|
680
|
+
|
|
681
|
+
api.on('price_change', async ({ coin, newPrice, changePct }) => {
|
|
682
|
+
if (coin !== COIN) return;
|
|
683
|
+
|
|
684
|
+
const crossed = api.state.get<boolean>('crossed', false);
|
|
685
|
+
if (!crossed && newPrice >= THRESHOLD) {
|
|
686
|
+
api.state.set('crossed', true);
|
|
687
|
+
await api.publish(
|
|
688
|
+
`${COIN} crossed above $${THRESHOLD}! Price: $${newPrice.toFixed(2)} (+${changePct.toFixed(2)}%)`,
|
|
689
|
+
);
|
|
690
|
+
} else if (crossed && newPrice < THRESHOLD) {
|
|
691
|
+
api.state.set('crossed', false);
|
|
692
|
+
}
|
|
693
|
+
});
|
|
694
|
+
}
|
|
695
|
+
```
|
|
696
|
+
|
|
697
|
+
### Running Automations
|
|
698
|
+
|
|
699
|
+
**CLI:**
|
|
700
|
+
```bash
|
|
701
|
+
openbroker auto run my-strategy --dry # Test without trading
|
|
702
|
+
openbroker auto run ./funding-scalp.ts # Run from path
|
|
703
|
+
openbroker auto run my-strategy --poll 5000 # Poll every 5s
|
|
704
|
+
openbroker auto list # Show available scripts
|
|
705
|
+
openbroker auto status # Show running automations
|
|
706
|
+
```
|
|
707
|
+
|
|
708
|
+
**Plugin tools (for OpenClaw agents):**
|
|
709
|
+
- `ob_auto_run` — `{ "script": "funding-scalp", "dry": true }` — start an automation
|
|
710
|
+
- `ob_auto_stop` — `{ "id": "funding-scalp" }` — stop a running automation
|
|
711
|
+
- `ob_auto_list` — `{}` — list available and running automations
|
|
712
|
+
|
|
713
|
+
**Options:**
|
|
714
|
+
| Flag | Description | Default |
|
|
715
|
+
|------|-------------|---------|
|
|
716
|
+
| `--dry` | Intercept write methods — no real trades | false |
|
|
717
|
+
| `--verbose` | Show debug output | false |
|
|
718
|
+
| `--id <name>` | Custom automation ID | filename |
|
|
719
|
+
| `--poll <ms>` | Poll interval in milliseconds | 10000 |
|
|
720
|
+
|
|
721
|
+
**Important notes for agents writing automations:**
|
|
722
|
+
- Always test with `--dry` first before live trading
|
|
723
|
+
- Use `api.state` to track position state across restarts
|
|
724
|
+
- Use `api.onStop()` to clean up — close positions, cancel orders
|
|
725
|
+
- Use `api.publish()` to send alerts/events back to the OpenClaw agent — do NOT manually construct webhook requests
|
|
726
|
+
- The runtime catches errors per handler — one failing handler won't crash others
|
|
727
|
+
- Scripts are loaded from `~/.openbroker/automations/` by name, or from any absolute path
|
|
728
|
+
- All trading commands support HIP-3 assets (`api.client.marketOrder('xyz:CL', true, 1)`)
|
|
729
|
+
- Automations persist across gateway restarts — they are automatically restarted when the gateway comes back up
|
|
730
|
+
|
|
490
731
|
## Risk Warning
|
|
491
732
|
|
|
492
733
|
- 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,239 @@
|
|
|
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, getRegisteredAutomations } from './runtime.js';
|
|
6
|
+
import { unregisterAutomation, cleanRegistry } from './registry.js';
|
|
7
|
+
|
|
8
|
+
function printUsage() {
|
|
9
|
+
console.log(`
|
|
10
|
+
OpenBroker Automations — event-driven trading scripts
|
|
11
|
+
|
|
12
|
+
Usage:
|
|
13
|
+
openbroker auto run <script> [options] Run an automation script
|
|
14
|
+
openbroker auto stop <id> Unregister an automation (won't restart)
|
|
15
|
+
openbroker auto list List available automations
|
|
16
|
+
openbroker auto status Show running automations
|
|
17
|
+
openbroker auto clean Remove stale entries from registry
|
|
18
|
+
|
|
19
|
+
Options (for run):
|
|
20
|
+
--dry Intercept write methods (no real trades)
|
|
21
|
+
--verbose Show debug output
|
|
22
|
+
--id <name> Custom automation ID (default: filename)
|
|
23
|
+
--poll <ms> Poll interval in milliseconds (default: 10000)
|
|
24
|
+
|
|
25
|
+
Scripts are loaded from:
|
|
26
|
+
1. Absolute or relative path
|
|
27
|
+
2. ~/.openbroker/automations/<name>.ts
|
|
28
|
+
|
|
29
|
+
Writing an automation:
|
|
30
|
+
export default function(api) {
|
|
31
|
+
api.on('price_change', async ({ coin, changePct }) => {
|
|
32
|
+
api.log.info(\`\${coin} moved \${changePct.toFixed(2)}%\`);
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
Events: tick, price_change, funding_update, position_opened,
|
|
37
|
+
position_closed, position_changed, pnl_threshold, margin_warning
|
|
38
|
+
|
|
39
|
+
Examples:
|
|
40
|
+
openbroker auto run my-strategy --dry # Test without trading
|
|
41
|
+
openbroker auto run ./funding-scalp.ts # Run from path
|
|
42
|
+
openbroker auto list # Show available scripts
|
|
43
|
+
`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function runCommand(args: Record<string, string | boolean>, positional: string[]) {
|
|
47
|
+
const scriptName = positional[0];
|
|
48
|
+
if (!scriptName) {
|
|
49
|
+
console.error('Error: script name or path required');
|
|
50
|
+
console.log('Usage: openbroker auto run <script> [--dry] [--verbose]');
|
|
51
|
+
process.exit(1);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const scriptPath = resolveScriptPath(scriptName);
|
|
55
|
+
const dryRun = args.dry === true;
|
|
56
|
+
const verbose = args.verbose === true;
|
|
57
|
+
const pollIntervalMs = args.poll ? parseInt(String(args.poll), 10) : 10_000;
|
|
58
|
+
const id = args.id ? String(args.id) : undefined;
|
|
59
|
+
|
|
60
|
+
if (isNaN(pollIntervalMs) || pollIntervalMs < 1000) {
|
|
61
|
+
console.error('Error: --poll must be at least 1000ms');
|
|
62
|
+
process.exit(1);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const automation = await startAutomation({
|
|
66
|
+
scriptPath,
|
|
67
|
+
id,
|
|
68
|
+
dryRun,
|
|
69
|
+
verbose,
|
|
70
|
+
pollIntervalMs,
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// Graceful shutdown on SIGINT/SIGTERM
|
|
74
|
+
const shutdown = async () => {
|
|
75
|
+
console.log('\nShutting down...');
|
|
76
|
+
await automation.stop();
|
|
77
|
+
process.exit(0);
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
process.on('SIGINT', shutdown);
|
|
81
|
+
process.on('SIGTERM', shutdown);
|
|
82
|
+
|
|
83
|
+
// Keep process alive
|
|
84
|
+
await new Promise(() => {});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function listCommand() {
|
|
88
|
+
ensureAutomationsDir();
|
|
89
|
+
const automations = listAutomations();
|
|
90
|
+
|
|
91
|
+
if (automations.length === 0) {
|
|
92
|
+
console.log('No automations found in ~/.openbroker/automations/');
|
|
93
|
+
console.log('\nCreate a .ts file there with:');
|
|
94
|
+
console.log(' export default function(api) { ... }');
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
console.log('Available automations:\n');
|
|
99
|
+
for (const a of automations) {
|
|
100
|
+
console.log(` ${a.name.padEnd(30)} ${a.path}`);
|
|
101
|
+
}
|
|
102
|
+
console.log(`\nRun with: openbroker auto run <name>`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function statusCommand() {
|
|
106
|
+
// Show in-process automations (if any running in this process)
|
|
107
|
+
const inProcess = getRunningAutomations();
|
|
108
|
+
|
|
109
|
+
// Show all registered automations from file-based registry (cross-process)
|
|
110
|
+
const registered = getRegisteredAutomations();
|
|
111
|
+
|
|
112
|
+
if (inProcess.length === 0 && registered.length === 0) {
|
|
113
|
+
console.log('No automations running');
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Show in-process automations with live stats
|
|
118
|
+
if (inProcess.length > 0) {
|
|
119
|
+
console.log('Running in this process:\n');
|
|
120
|
+
for (const a of inProcess) {
|
|
121
|
+
const uptime = Math.round((Date.now() - a.startedAt.getTime()) / 1000);
|
|
122
|
+
console.log(` ${a.id}`);
|
|
123
|
+
console.log(` Script: ${a.scriptPath}`);
|
|
124
|
+
console.log(` Uptime: ${uptime}s`);
|
|
125
|
+
console.log(` Polls: ${a.pollCount}`);
|
|
126
|
+
console.log(` Events: ${a.eventsEmitted}`);
|
|
127
|
+
console.log(` Dry run: ${a.dryRun}`);
|
|
128
|
+
console.log('');
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Show all registered automations (may include ones from other processes)
|
|
133
|
+
const external = registered.filter(
|
|
134
|
+
r => !inProcess.some(ip => ip.id === r.id),
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
if (external.length > 0) {
|
|
138
|
+
if (inProcess.length > 0) console.log('Other processes:\n');
|
|
139
|
+
else console.log('Registered automations:\n');
|
|
140
|
+
|
|
141
|
+
for (const a of external) {
|
|
142
|
+
const uptime = a.status === 'running'
|
|
143
|
+
? `${Math.round((Date.now() - new Date(a.startedAt).getTime()) / 1000)}s`
|
|
144
|
+
: '-';
|
|
145
|
+
console.log(` ${a.id}`);
|
|
146
|
+
console.log(` Script: ${a.scriptPath}`);
|
|
147
|
+
console.log(` Status: ${a.status}${a.error ? ` (${a.error})` : ''}`);
|
|
148
|
+
console.log(` PID: ${a.pid}`);
|
|
149
|
+
console.log(` Uptime: ${uptime}`);
|
|
150
|
+
console.log(` Dry run: ${a.dryRun}`);
|
|
151
|
+
console.log('');
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function stopCommand(positional: string[]) {
|
|
157
|
+
const id = positional[0];
|
|
158
|
+
if (!id) {
|
|
159
|
+
console.error('Error: automation ID required');
|
|
160
|
+
console.log('Usage: openbroker auto stop <id>');
|
|
161
|
+
process.exit(1);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Check if running in this process
|
|
165
|
+
const inProcess = getRunningAutomations();
|
|
166
|
+
const running = inProcess.find(a => a.id === id);
|
|
167
|
+
if (running) {
|
|
168
|
+
running.stop().then(() => {
|
|
169
|
+
console.log(`Stopped and unregistered: ${id}`);
|
|
170
|
+
});
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Otherwise just remove from file registry (prevents restart)
|
|
175
|
+
unregisterAutomation(id);
|
|
176
|
+
console.log(`Unregistered: ${id} (will not restart on next gateway start)`);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function cleanCommand() {
|
|
180
|
+
cleanRegistry();
|
|
181
|
+
console.log('Cleaned stale entries from registry');
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function main() {
|
|
185
|
+
const rawArgs = process.argv.slice(2);
|
|
186
|
+
|
|
187
|
+
if (rawArgs.length === 0 || rawArgs[0] === '--help' || rawArgs[0] === '-h') {
|
|
188
|
+
printUsage();
|
|
189
|
+
process.exit(0);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const subcommand = rawArgs[0];
|
|
193
|
+
const restArgs = rawArgs.slice(1);
|
|
194
|
+
|
|
195
|
+
// Extract positional args (non-flag args)
|
|
196
|
+
const positional: string[] = [];
|
|
197
|
+
const flagArgs: string[] = [];
|
|
198
|
+
for (let i = 0; i < restArgs.length; i++) {
|
|
199
|
+
if (restArgs[i].startsWith('--')) {
|
|
200
|
+
flagArgs.push(restArgs[i]);
|
|
201
|
+
// If next arg doesn't start with --, it's a flag value
|
|
202
|
+
if (i + 1 < restArgs.length && !restArgs[i + 1].startsWith('--')) {
|
|
203
|
+
flagArgs.push(restArgs[i + 1]);
|
|
204
|
+
i++;
|
|
205
|
+
}
|
|
206
|
+
} else {
|
|
207
|
+
positional.push(restArgs[i]);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const args = parseArgs(flagArgs);
|
|
212
|
+
|
|
213
|
+
switch (subcommand) {
|
|
214
|
+
case 'run':
|
|
215
|
+
await runCommand(args, positional);
|
|
216
|
+
break;
|
|
217
|
+
case 'stop':
|
|
218
|
+
stopCommand(positional);
|
|
219
|
+
break;
|
|
220
|
+
case 'list':
|
|
221
|
+
listCommand();
|
|
222
|
+
break;
|
|
223
|
+
case 'status':
|
|
224
|
+
statusCommand();
|
|
225
|
+
break;
|
|
226
|
+
case 'clean':
|
|
227
|
+
cleanCommand();
|
|
228
|
+
break;
|
|
229
|
+
default:
|
|
230
|
+
console.error(`Unknown subcommand: ${subcommand}`);
|
|
231
|
+
console.log('Run "openbroker auto --help" for usage');
|
|
232
|
+
process.exit(1);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
main().catch(err => {
|
|
237
|
+
console.error(err.message || err);
|
|
238
|
+
process.exit(1);
|
|
239
|
+
});
|
|
@@ -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
|
+
}
|