@reefclaw/openclaw-plugin 0.1.6 → 0.1.7
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/bridge/gateway/event-parser.d.ts +6 -1
- package/bridge/gateway/event-parser.js +19 -2
- package/bridge/gateway/poller.d.ts +1 -0
- package/bridge/gateway/poller.js +14 -2
- package/bridge/providers/gateway.d.ts +22 -2
- package/bridge/providers/gateway.js +67 -9
- package/ccxt/public-market-data-api.d.ts +14 -0
- package/ccxt/public-market-data-api.js +15 -1
- package/config/plugin-config-io.d.ts +7 -0
- package/config/plugin-config-io.js +15 -0
- package/index.js +107 -29
- package/ingest/position-auto-capture.d.ts +68 -0
- package/ingest/position-auto-capture.js +321 -23
- package/ingest/position-decisions-client.d.ts +7 -2
- package/ingest/position-decisions-client.js +13 -3
- package/ingest/reconcile-db-vs-exchange.d.ts +39 -1
- package/ingest/reconcile-db-vs-exchange.js +66 -10
- package/live/fill-price.d.ts +13 -0
- package/live/fill-price.js +37 -0
- package/live/live-adapter.d.ts +33 -1
- package/live/live-adapter.js +176 -47
- package/live/position-state-store.d.ts +4 -0
- package/live/stop-watcher.d.ts +8 -1
- package/live/stop-watcher.js +5 -2
- package/onboarding/runtime.d.ts +6 -0
- package/onboarding/runtime.js +13 -2
- package/package.json +2 -2
- package/portfolio/reentry-tracker.d.ts +36 -0
- package/portfolio/reentry-tracker.js +127 -0
- package/signals/conditions/registry.js +11 -2
- package/signals/strategy-adapter.js +17 -7
- package/simulator/exchange-simulator.d.ts +12 -0
- package/simulator/exchange-simulator.js +73 -3
- package/simulator/types.d.ts +4 -0
- package/skills/reefclaw/SKILL.md +2 -0
- package/tools/assessment-validation.d.ts +21 -0
- package/tools/assessment-validation.js +58 -0
- package/tools/attach-brackets.js +165 -0
- package/tools/audit-bracket-protection.js +157 -1
- package/tools/bracket-control.d.ts +12 -0
- package/tools/bracket-control.js +35 -0
- package/tools/create-order.js +30 -2
- package/tools/get-setup-detail.js +12 -1
- package/tools/modify-stop.js +5 -5
- package/tools/modify-target.js +5 -5
- package/tools/scan-pairs.d.ts +4 -0
- package/tools/scan-pairs.js +4 -1
- package/venues/hyperliquid/hl-bracket-coordinator.d.ts +123 -0
- package/venues/hyperliquid/hl-bracket-coordinator.js +533 -0
- package/venues/hyperliquid/hl-live-adapter.d.ts +61 -3
- package/venues/hyperliquid/hl-live-adapter.js +380 -5
- package/venues/hyperliquid/hl-public.js +8 -1
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events';
|
|
2
|
+
import type { CcxtOrder, CcxtPosition } from '../../types.js';
|
|
3
|
+
import type { CloseReason } from '../../simulator/types.js';
|
|
4
|
+
import type { BracketId, BracketRequest, BracketState } from '../../live/bracket-types.js';
|
|
5
|
+
import type { BracketLedger } from '../../live/bracket-ledger.js';
|
|
6
|
+
import type { HlOrderUpdateEvent } from './hl-user-stream.js';
|
|
7
|
+
/** Narrow execution surface the coordinator needs — the HyperliquidLiveAdapter
|
|
8
|
+
* implements it; tests mock it without a network. */
|
|
9
|
+
export interface HlBracketExecutor {
|
|
10
|
+
attachBrackets(args: {
|
|
11
|
+
bracketId: BracketId;
|
|
12
|
+
symbol: string;
|
|
13
|
+
positionSide: 'long' | 'short';
|
|
14
|
+
positionSize: number;
|
|
15
|
+
stopPrice?: number;
|
|
16
|
+
targetPrice?: number;
|
|
17
|
+
}): Promise<{
|
|
18
|
+
orders: CcxtOrder[];
|
|
19
|
+
slCid?: string;
|
|
20
|
+
tpCid?: string;
|
|
21
|
+
}>;
|
|
22
|
+
resizeBrackets(args: {
|
|
23
|
+
bracketId: BracketId;
|
|
24
|
+
symbol: string;
|
|
25
|
+
positionSide: 'long' | 'short';
|
|
26
|
+
positionSize: number;
|
|
27
|
+
}): Promise<{
|
|
28
|
+
resized: boolean;
|
|
29
|
+
slCid?: string;
|
|
30
|
+
tpCid?: string;
|
|
31
|
+
}>;
|
|
32
|
+
/** Cancel one leg by cloid. Idempotent — "already gone" must not throw. */
|
|
33
|
+
cancelBracketLeg(cloid: string, symbol: string): Promise<void>;
|
|
34
|
+
/** Coverage audit — THROWS when order state is unknown (null ≠ empty). */
|
|
35
|
+
auditBracketCoverage(symbol: string, positionSize: number): Promise<{
|
|
36
|
+
covered: boolean;
|
|
37
|
+
shortfall: {
|
|
38
|
+
stop: number;
|
|
39
|
+
target: number;
|
|
40
|
+
};
|
|
41
|
+
missing: ('stop' | 'target')[];
|
|
42
|
+
}>;
|
|
43
|
+
getPositionsOrNull(symbol?: string): Promise<CcxtPosition[] | null>;
|
|
44
|
+
closePosition(symbol: string, closeReason?: CloseReason): Promise<CcxtOrder>;
|
|
45
|
+
}
|
|
46
|
+
export interface HlAttachResult {
|
|
47
|
+
ok: boolean;
|
|
48
|
+
latencyMs: number;
|
|
49
|
+
slCid?: string;
|
|
50
|
+
tpCid?: string;
|
|
51
|
+
error?: string;
|
|
52
|
+
attempts: number;
|
|
53
|
+
}
|
|
54
|
+
export interface HlCoordinatorOpts {
|
|
55
|
+
maxAttempts?: number;
|
|
56
|
+
retryBackoffMs?: (attempt: number) => number;
|
|
57
|
+
now?: () => number;
|
|
58
|
+
sleep?: (ms: number) => Promise<void>;
|
|
59
|
+
}
|
|
60
|
+
export declare function isTerminalBracketState(state: BracketState): boolean;
|
|
61
|
+
export declare class HlBracketCoordinator extends EventEmitter {
|
|
62
|
+
private readonly executor;
|
|
63
|
+
private readonly ledger;
|
|
64
|
+
private readonly maxAttempts;
|
|
65
|
+
private readonly backoff;
|
|
66
|
+
private readonly now;
|
|
67
|
+
private readonly sleep;
|
|
68
|
+
/** Per-symbol in-flight guard so a WS fill + the createOrder return path
|
|
69
|
+
* can't both run attachOnFill concurrently (double-submit). */
|
|
70
|
+
private readonly inFlight;
|
|
71
|
+
/** Cloids WE deliberately cancelled (resize/modify supersede, retry cleanup,
|
|
72
|
+
* cancelBrackets). Their WS 'canceled' events are OUR OWN and must never
|
|
73
|
+
* trip the stripped-protection canary — the T-8 testnet run proved the WS
|
|
74
|
+
* event can arrive BEFORE the ledger's fresh-cid update lands, so a
|
|
75
|
+
* generation check alone loses the race. Entries pruned after 5 min. */
|
|
76
|
+
private readonly ownCancels;
|
|
77
|
+
constructor(executor: HlBracketExecutor, ledger: BracketLedger, opts?: HlCoordinatorOpts);
|
|
78
|
+
getLedger(): BracketLedger;
|
|
79
|
+
/** Record a leg cancel WE initiated (see `ownCancels`). The adapter calls
|
|
80
|
+
* this for every deliberate leg cancel, including the resize path's direct
|
|
81
|
+
* api cancels, BEFORE the cancel request goes out. */
|
|
82
|
+
noteOwnLegCancel(cloid: string): void;
|
|
83
|
+
/** Same anti-clobber contract as BracketManager.registerEntry: a duplicate
|
|
84
|
+
* fill signal against a non-terminal row is a warned NO-OP, never a fresh
|
|
85
|
+
* bracketId that orphans the live legs' ledger identity. */
|
|
86
|
+
registerEntry(req: BracketRequest, bracketId: BracketId, entryCid: string): void;
|
|
87
|
+
/** Attach both legs (ONE batched signed action) with retries. Idempotent on
|
|
88
|
+
* a non-pending row. On exhaustion: ledger 'failed' + attach_failed event —
|
|
89
|
+
* the ADAPTER escalates to auto-flatten (it owns closePosition). */
|
|
90
|
+
attachOnFill(symbol: string, filledSize: number): Promise<HlAttachResult>;
|
|
91
|
+
/** ★ T-2: bring the legs to the CURRENT position size (scale-in / partial
|
|
92
|
+
* fill growth). Submit-first-then-cancel inside the adapter. Updates the
|
|
93
|
+
* ledger's qty + fresh cids on success. */
|
|
94
|
+
resizeToPosition(symbol: string, newPositionSize: number): Promise<{
|
|
95
|
+
resized: boolean;
|
|
96
|
+
}>;
|
|
97
|
+
/** Move the stop. HL ordering: submit the NEW leg first, then cancel the old
|
|
98
|
+
* one — over-protection for a moment beats a naked window (see header). */
|
|
99
|
+
modifyStop(symbol: string, newStopPrice: number): Promise<void>;
|
|
100
|
+
modifyTarget(symbol: string, newTargetPrice: number): Promise<void>;
|
|
101
|
+
private modifyLeg;
|
|
102
|
+
/** Cancel both legs, mark cancelled. Risk-reducing; best-effort per leg. */
|
|
103
|
+
cancelBrackets(symbol: string, reason: string): Promise<void>;
|
|
104
|
+
/**
|
|
105
|
+
* The HL analog of the Binance ALGO_UPDATE handler — `orderUpdates` is
|
|
106
|
+
* authoritative for bracket-leg lifecycle. Returns the transition applied
|
|
107
|
+
* (for the adapter to emit drift/close-bypass signals on triggers).
|
|
108
|
+
*/
|
|
109
|
+
handleOrderUpdate(update: HlOrderUpdateEvent): 'triggered_sl' | 'triggered_tp' | 'forced_close' | null;
|
|
110
|
+
/**
|
|
111
|
+
* ★ T-5 mandate: REST truth-check. Runs after every WS (re)connect AND on the
|
|
112
|
+
* periodic sweep. TRUSTED reads only — a null positions fetch skips the pass
|
|
113
|
+
* (never act on unknown). Self-heals: pending rows whose entry filled while
|
|
114
|
+
* we were blind get their legs attached; undersized legs get resized; flat
|
|
115
|
+
* positions get their rows closed out (T-1 already cancelled the legs).
|
|
116
|
+
* Returns symbols whose rows were closed out (close-bypass signals for the
|
|
117
|
+
* adapter's drift pipeline).
|
|
118
|
+
*/
|
|
119
|
+
resyncAgainstExchange(reasonTag: string): Promise<{
|
|
120
|
+
closedSymbols: string[];
|
|
121
|
+
}>;
|
|
122
|
+
private emitEvent;
|
|
123
|
+
}
|
|
@@ -0,0 +1,533 @@
|
|
|
1
|
+
// HlBracketCoordinator — orchestrates the lifecycle of Hyperliquid bracket legs
|
|
2
|
+
// (issue #209: the wiring that Phase 3 shipped the machinery for).
|
|
3
|
+
//
|
|
4
|
+
// A SIBLING of live/bracket-manager.ts, not a reuse of it: BracketManager's leg
|
|
5
|
+
// layer is Binance-shaped (sequential STOP_MARKET/TAKE_PROFIT_MARKET submits,
|
|
6
|
+
// closePosition:true whole-position semantics, `bkt…` cids), while HL brackets
|
|
7
|
+
// are fixed-size reduce-only trigger legs submitted as ONE batched signed action
|
|
8
|
+
// (nonce mutex — see hl-brackets.ts). The ORCHESTRATION is deliberately the
|
|
9
|
+
// same shape: registerEntry → attach-on-fill with retries → auto-flatten
|
|
10
|
+
// escalation on failure, persisted in the SAME venue-agnostic BracketLedger
|
|
11
|
+
// (its own file — `new BracketLedger('<pluginId>-hl')` — so a venue switch can
|
|
12
|
+
// never cross-contaminate rows), emitting the SAME BracketEvent shapes.
|
|
13
|
+
//
|
|
14
|
+
// THE MEASURED FACTS THIS FILE TURNS ON (testnet 2026-07-12, do NOT re-derive):
|
|
15
|
+
// ★ T-1: reduce-only trigger legs auto-cancel on flat and behave OCO — no
|
|
16
|
+
// sibling-canceller needed; a defensive cancel is harmless.
|
|
17
|
+
// ★ T-2: legs are FIXED SIZE and do NOT follow a scale-in — every position
|
|
18
|
+
// size increase MUST resize the legs or the added contracts are NAKED.
|
|
19
|
+
// ★ T-5: the WS replays NOTHING on reconnect — `onResyncNeeded` drives a REST
|
|
20
|
+
// truth-check here (`resyncAgainstExchange`), and a periodic sweep runs the
|
|
21
|
+
// same check even while connected (a dropped frame is invisible).
|
|
22
|
+
//
|
|
23
|
+
// Modify semantics differ from Binance ON PURPOSE: Binance modifyStop cancels
|
|
24
|
+
// the old leg then submits the new one (with rollback). On HL we SUBMIT FIRST,
|
|
25
|
+
// then cancel the stale leg — two live reduce-only stops for a few hundred ms
|
|
26
|
+
// are over-protection (reduce-only cannot flip a position, and T-1 cleans up
|
|
27
|
+
// stragglers on flat), whereas cancel-first opens a naked window. Same ordering
|
|
28
|
+
// rule as planResize.
|
|
29
|
+
import { EventEmitter } from 'node:events';
|
|
30
|
+
import { logger, formatError } from '../../logger.js';
|
|
31
|
+
import { parseHlBracketCloid } from './hl-cloid.js';
|
|
32
|
+
const TAG = 'hl-bracket-coordinator';
|
|
33
|
+
const DEFAULT_MAX_ATTEMPTS = 3;
|
|
34
|
+
const DEFAULT_BACKOFF = (attempt) => (attempt === 1 ? 1_000 : 2_000);
|
|
35
|
+
const TERMINAL_STATES = new Set([
|
|
36
|
+
'triggered_sl',
|
|
37
|
+
'triggered_tp',
|
|
38
|
+
'cancelled',
|
|
39
|
+
'failed',
|
|
40
|
+
]);
|
|
41
|
+
export function isTerminalBracketState(state) {
|
|
42
|
+
return TERMINAL_STATES.has(state);
|
|
43
|
+
}
|
|
44
|
+
/** Order-update statuses that mean the leg EXECUTED (position closed by it). */
|
|
45
|
+
const TRIGGERED_STATUSES = new Set(['filled', 'triggered']);
|
|
46
|
+
/** A batch item HL rejected. ccxt types status as open|closed|canceled, but a
|
|
47
|
+
* per-item rejection surfaces as an `error` entry in the raw batch response
|
|
48
|
+
* (`info.error`) — check both shapes rather than trust the narrow type. */
|
|
49
|
+
function isRejectedOrder(o) {
|
|
50
|
+
if (!o)
|
|
51
|
+
return true;
|
|
52
|
+
if (String(o.status ?? '') === 'rejected')
|
|
53
|
+
return true;
|
|
54
|
+
const info = o.info;
|
|
55
|
+
return Boolean(info && info.error);
|
|
56
|
+
}
|
|
57
|
+
/** Expected T-1 cleanup — the sibling of a fired leg self-cancels. */
|
|
58
|
+
const SIBLING_CLEANUP_STATUSES = new Set(['siblingFilledCanceled']);
|
|
59
|
+
/** The position was force-closed out from under the bracket. */
|
|
60
|
+
const FORCED_CLOSE_STATUSES = new Set(['liquidatedCanceled', 'marginCanceled', 'delistedCanceled']);
|
|
61
|
+
export class HlBracketCoordinator extends EventEmitter {
|
|
62
|
+
executor;
|
|
63
|
+
ledger;
|
|
64
|
+
maxAttempts;
|
|
65
|
+
backoff;
|
|
66
|
+
now;
|
|
67
|
+
sleep;
|
|
68
|
+
/** Per-symbol in-flight guard so a WS fill + the createOrder return path
|
|
69
|
+
* can't both run attachOnFill concurrently (double-submit). */
|
|
70
|
+
inFlight = new Set();
|
|
71
|
+
/** Cloids WE deliberately cancelled (resize/modify supersede, retry cleanup,
|
|
72
|
+
* cancelBrackets). Their WS 'canceled' events are OUR OWN and must never
|
|
73
|
+
* trip the stripped-protection canary — the T-8 testnet run proved the WS
|
|
74
|
+
* event can arrive BEFORE the ledger's fresh-cid update lands, so a
|
|
75
|
+
* generation check alone loses the race. Entries pruned after 5 min. */
|
|
76
|
+
ownCancels = new Map();
|
|
77
|
+
constructor(executor, ledger, opts = {}) {
|
|
78
|
+
super();
|
|
79
|
+
this.executor = executor;
|
|
80
|
+
this.ledger = ledger;
|
|
81
|
+
this.maxAttempts = opts.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
|
|
82
|
+
this.backoff = opts.retryBackoffMs ?? DEFAULT_BACKOFF;
|
|
83
|
+
this.now = opts.now ?? Date.now;
|
|
84
|
+
this.sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
85
|
+
}
|
|
86
|
+
getLedger() {
|
|
87
|
+
return this.ledger;
|
|
88
|
+
}
|
|
89
|
+
/** Record a leg cancel WE initiated (see `ownCancels`). The adapter calls
|
|
90
|
+
* this for every deliberate leg cancel, including the resize path's direct
|
|
91
|
+
* api cancels, BEFORE the cancel request goes out. */
|
|
92
|
+
noteOwnLegCancel(cloid) {
|
|
93
|
+
const now = this.now();
|
|
94
|
+
for (const [k, ts] of this.ownCancels) {
|
|
95
|
+
if (now - ts > 300_000)
|
|
96
|
+
this.ownCancels.delete(k);
|
|
97
|
+
}
|
|
98
|
+
this.ownCancels.set(cloid, now);
|
|
99
|
+
}
|
|
100
|
+
/** Same anti-clobber contract as BracketManager.registerEntry: a duplicate
|
|
101
|
+
* fill signal against a non-terminal row is a warned NO-OP, never a fresh
|
|
102
|
+
* bracketId that orphans the live legs' ledger identity. */
|
|
103
|
+
registerEntry(req, bracketId, entryCid) {
|
|
104
|
+
if (req.stopPrice === undefined && req.targetPrice === undefined) {
|
|
105
|
+
throw new Error('registerEntry: at least one of stopPrice or targetPrice is required');
|
|
106
|
+
}
|
|
107
|
+
const existing = this.ledger.getBySymbol(req.symbol);
|
|
108
|
+
if (existing && !isTerminalBracketState(existing.state)) {
|
|
109
|
+
logger.warn(TAG, `registerEntry(${req.symbol}): non-terminal row exists (state=${existing.state}, ` +
|
|
110
|
+
`bracketId=${existing.bracketId}) — duplicate fill signal, NOT clobbering ` +
|
|
111
|
+
`(anti-clobber contract; scale-ins go through resizeToPosition)`);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (existing)
|
|
115
|
+
this.ledger.remove(req.symbol);
|
|
116
|
+
this.ledger.upsert({
|
|
117
|
+
bracketId,
|
|
118
|
+
symbol: req.symbol,
|
|
119
|
+
entrySide: req.side,
|
|
120
|
+
entryCid,
|
|
121
|
+
stopPrice: req.stopPrice,
|
|
122
|
+
targetPrice: req.targetPrice,
|
|
123
|
+
state: 'pending_entry',
|
|
124
|
+
});
|
|
125
|
+
this.emitEvent({
|
|
126
|
+
type: 'bracket.entry_submitted',
|
|
127
|
+
bracketId,
|
|
128
|
+
symbol: req.symbol,
|
|
129
|
+
ts: new Date(this.now()).toISOString(),
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
/** Attach both legs (ONE batched signed action) with retries. Idempotent on
|
|
133
|
+
* a non-pending row. On exhaustion: ledger 'failed' + attach_failed event —
|
|
134
|
+
* the ADAPTER escalates to auto-flatten (it owns closePosition). */
|
|
135
|
+
async attachOnFill(symbol, filledSize) {
|
|
136
|
+
const entry = this.ledger.getBySymbol(symbol);
|
|
137
|
+
if (!entry)
|
|
138
|
+
throw new Error(`attachOnFill: no ledger entry for ${symbol}`);
|
|
139
|
+
if (entry.state !== 'pending_entry') {
|
|
140
|
+
return {
|
|
141
|
+
ok: entry.state === 'active' || entry.state === 'partial',
|
|
142
|
+
latencyMs: entry.attachLatencyMs ?? 0,
|
|
143
|
+
slCid: entry.slCid,
|
|
144
|
+
tpCid: entry.tpCid,
|
|
145
|
+
attempts: 0,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
if (this.inFlight.has(entry.symbol)) {
|
|
149
|
+
logger.warn(TAG, `attachOnFill(${symbol}): attach already in flight — skipping duplicate`);
|
|
150
|
+
return { ok: false, latencyMs: 0, attempts: 0, error: 'attach_in_flight' };
|
|
151
|
+
}
|
|
152
|
+
this.inFlight.add(entry.symbol);
|
|
153
|
+
try {
|
|
154
|
+
const startTs = this.now();
|
|
155
|
+
this.ledger.markState(symbol, 'attaching', {
|
|
156
|
+
attachStartedAt: new Date(startTs).toISOString(),
|
|
157
|
+
});
|
|
158
|
+
this.emitEvent({
|
|
159
|
+
type: 'bracket.entry_filled',
|
|
160
|
+
bracketId: entry.bracketId,
|
|
161
|
+
symbol: entry.symbol,
|
|
162
|
+
ts: new Date(startTs).toISOString(),
|
|
163
|
+
});
|
|
164
|
+
const positionSide = entry.entrySide === 'buy' ? 'long' : 'short';
|
|
165
|
+
let lastError;
|
|
166
|
+
for (let attempt = 1; attempt <= this.maxAttempts; attempt++) {
|
|
167
|
+
let slCid;
|
|
168
|
+
let tpCid;
|
|
169
|
+
try {
|
|
170
|
+
const res = await this.executor.attachBrackets({
|
|
171
|
+
bracketId: entry.bracketId,
|
|
172
|
+
symbol: entry.symbol,
|
|
173
|
+
positionSide,
|
|
174
|
+
positionSize: filledSize,
|
|
175
|
+
stopPrice: entry.stopPrice,
|
|
176
|
+
targetPrice: entry.targetPrice,
|
|
177
|
+
});
|
|
178
|
+
slCid = res.slCid;
|
|
179
|
+
tpCid = res.tpCid;
|
|
180
|
+
const wanted = (entry.stopPrice !== undefined ? 1 : 0) + (entry.targetPrice !== undefined ? 1 : 0);
|
|
181
|
+
const rejected = res.orders.filter((o) => isRejectedOrder(o));
|
|
182
|
+
if (res.orders.length < wanted || rejected.length > 0) {
|
|
183
|
+
throw new Error(`batch returned ${res.orders.length}/${wanted} legs` +
|
|
184
|
+
(rejected.length ? ` (${rejected.length} rejected)` : ''));
|
|
185
|
+
}
|
|
186
|
+
const latencyMs = this.now() - startTs;
|
|
187
|
+
const targetState = slCid && tpCid ? 'active' : 'partial';
|
|
188
|
+
this.ledger.markState(symbol, targetState, {
|
|
189
|
+
attachCompletedAt: new Date(this.now()).toISOString(),
|
|
190
|
+
attachLatencyMs: latencyMs,
|
|
191
|
+
slCid,
|
|
192
|
+
tpCid,
|
|
193
|
+
// Freeze the size the legs were sized to — modify/resize MUST reuse
|
|
194
|
+
// it (the Binance 1-contract under-protect lesson carries over).
|
|
195
|
+
qty: filledSize,
|
|
196
|
+
});
|
|
197
|
+
this.emitEvent({
|
|
198
|
+
type: 'bracket.attached',
|
|
199
|
+
bracketId: entry.bracketId,
|
|
200
|
+
symbol: entry.symbol,
|
|
201
|
+
latencyMs,
|
|
202
|
+
ts: new Date(this.now()).toISOString(),
|
|
203
|
+
});
|
|
204
|
+
return { ok: true, latencyMs, slCid, tpCid, attempts: attempt };
|
|
205
|
+
}
|
|
206
|
+
catch (err) {
|
|
207
|
+
lastError = formatError(err);
|
|
208
|
+
this.emitEvent({
|
|
209
|
+
type: 'bracket.attach_failed',
|
|
210
|
+
bracketId: entry.bracketId,
|
|
211
|
+
symbol: entry.symbol,
|
|
212
|
+
error: lastError,
|
|
213
|
+
retryCount: attempt,
|
|
214
|
+
ts: new Date(this.now()).toISOString(),
|
|
215
|
+
});
|
|
216
|
+
// A failed/partial batch may have left a leg on the book. Cancel
|
|
217
|
+
// best-effort before retrying with FRESH cloids (a cloid is
|
|
218
|
+
// single-use) so the exchange never runs ahead of the ledger.
|
|
219
|
+
// T-1 makes a missed cancel harmless (auto-cleanup on flat).
|
|
220
|
+
for (const cloid of [slCid, tpCid]) {
|
|
221
|
+
if (!cloid)
|
|
222
|
+
continue;
|
|
223
|
+
try {
|
|
224
|
+
await this.executor.cancelBracketLeg(cloid, entry.symbol);
|
|
225
|
+
}
|
|
226
|
+
catch {
|
|
227
|
+
/* idempotent best-effort */
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
if (attempt < this.maxAttempts)
|
|
231
|
+
await this.sleep(this.backoff(attempt));
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
const latencyMs = this.now() - startTs;
|
|
235
|
+
this.ledger.markState(symbol, 'failed', {
|
|
236
|
+
closeReason: 'attach_failed',
|
|
237
|
+
lastError,
|
|
238
|
+
});
|
|
239
|
+
return { ok: false, latencyMs, attempts: this.maxAttempts, error: lastError };
|
|
240
|
+
}
|
|
241
|
+
finally {
|
|
242
|
+
this.inFlight.delete(entry.symbol);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
/** ★ T-2: bring the legs to the CURRENT position size (scale-in / partial
|
|
246
|
+
* fill growth). Submit-first-then-cancel inside the adapter. Updates the
|
|
247
|
+
* ledger's qty + fresh cids on success. */
|
|
248
|
+
async resizeToPosition(symbol, newPositionSize) {
|
|
249
|
+
const entry = this.ledger.getBySymbol(symbol);
|
|
250
|
+
if (!entry)
|
|
251
|
+
throw new Error(`resizeToPosition: no ledger entry for ${symbol}`);
|
|
252
|
+
if (entry.state !== 'active' && entry.state !== 'partial') {
|
|
253
|
+
throw new Error(`resizeToPosition: cannot resize in state ${entry.state}`);
|
|
254
|
+
}
|
|
255
|
+
if (!Number.isFinite(newPositionSize) || newPositionSize <= 0) {
|
|
256
|
+
throw new Error(`resizeToPosition: invalid size ${newPositionSize}`);
|
|
257
|
+
}
|
|
258
|
+
const positionSide = entry.entrySide === 'buy' ? 'long' : 'short';
|
|
259
|
+
const res = await this.executor.resizeBrackets({
|
|
260
|
+
bracketId: entry.bracketId,
|
|
261
|
+
symbol: entry.symbol,
|
|
262
|
+
positionSide,
|
|
263
|
+
positionSize: newPositionSize,
|
|
264
|
+
});
|
|
265
|
+
if (res.resized) {
|
|
266
|
+
this.ledger.markState(symbol, entry.state, {
|
|
267
|
+
qty: newPositionSize,
|
|
268
|
+
...(res.slCid ? { slCid: res.slCid } : {}),
|
|
269
|
+
...(res.tpCid ? { tpCid: res.tpCid } : {}),
|
|
270
|
+
});
|
|
271
|
+
logger.info(TAG, `resized ${entry.symbol} legs to ${newPositionSize} (T-2: HL legs do not auto-resize)`);
|
|
272
|
+
}
|
|
273
|
+
return { resized: res.resized };
|
|
274
|
+
}
|
|
275
|
+
/** Move the stop. HL ordering: submit the NEW leg first, then cancel the old
|
|
276
|
+
* one — over-protection for a moment beats a naked window (see header). */
|
|
277
|
+
async modifyStop(symbol, newStopPrice) {
|
|
278
|
+
await this.modifyLeg(symbol, 'stop', newStopPrice);
|
|
279
|
+
}
|
|
280
|
+
async modifyTarget(symbol, newTargetPrice) {
|
|
281
|
+
await this.modifyLeg(symbol, 'target', newTargetPrice);
|
|
282
|
+
}
|
|
283
|
+
async modifyLeg(symbol, role, newPrice) {
|
|
284
|
+
if (!Number.isFinite(newPrice) || newPrice <= 0) {
|
|
285
|
+
throw new Error(`modify ${role}: price must be positive finite, got ${newPrice}`);
|
|
286
|
+
}
|
|
287
|
+
const entry = this.ledger.getBySymbol(symbol);
|
|
288
|
+
if (!entry)
|
|
289
|
+
throw new Error(`modify ${role}: no ledger entry for ${symbol}`);
|
|
290
|
+
if (entry.state !== 'active' && entry.state !== 'partial') {
|
|
291
|
+
throw new Error(`modify ${role}: cannot modify in state ${entry.state}`);
|
|
292
|
+
}
|
|
293
|
+
const qty = entry.qty;
|
|
294
|
+
if (qty === undefined || !Number.isFinite(qty) || qty <= 0) {
|
|
295
|
+
throw new Error(`modify ${role}: ledger has no valid bracket qty for ${symbol} (qty=${qty}); ` +
|
|
296
|
+
`refusing to rebuild the leg at a placeholder size — re-attach the bracket first`);
|
|
297
|
+
}
|
|
298
|
+
const oldCid = role === 'stop' ? entry.slCid : entry.tpCid;
|
|
299
|
+
const positionSide = entry.entrySide === 'buy' ? 'long' : 'short';
|
|
300
|
+
// SUBMIT the replacement first. On failure the old leg is still live —
|
|
301
|
+
// the position stays protected and we just report the error.
|
|
302
|
+
const res = await this.executor.attachBrackets({
|
|
303
|
+
bracketId: entry.bracketId,
|
|
304
|
+
symbol: entry.symbol,
|
|
305
|
+
positionSide,
|
|
306
|
+
positionSize: qty,
|
|
307
|
+
...(role === 'stop' ? { stopPrice: newPrice } : { targetPrice: newPrice }),
|
|
308
|
+
});
|
|
309
|
+
const newCid = role === 'stop' ? res.slCid : res.tpCid;
|
|
310
|
+
const submitted = res.orders.filter((o) => !isRejectedOrder(o));
|
|
311
|
+
if (!newCid || submitted.length === 0) {
|
|
312
|
+
throw new Error(`modify ${role}: replacement leg submission failed — old leg left in place (still protected)`);
|
|
313
|
+
}
|
|
314
|
+
// Then cancel the stale leg. A failed cancel = brief over-protection (two
|
|
315
|
+
// reduce-only legs), which T-1 cleans up on flat — warn, don't throw.
|
|
316
|
+
if (oldCid) {
|
|
317
|
+
try {
|
|
318
|
+
await this.executor.cancelBracketLeg(oldCid, entry.symbol);
|
|
319
|
+
}
|
|
320
|
+
catch (err) {
|
|
321
|
+
logger.warn(TAG, `modify ${role}(${symbol}): stale leg ${oldCid} cancel failed (over-protection, ` +
|
|
322
|
+
`not risk — T-1 cleans up on flat): ${formatError(err)}`);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
const oldPrice = role === 'stop' ? entry.stopPrice : entry.targetPrice;
|
|
326
|
+
this.ledger.upsert({
|
|
327
|
+
...entry,
|
|
328
|
+
...(role === 'stop'
|
|
329
|
+
? { stopPrice: newPrice, slCid: newCid }
|
|
330
|
+
: { targetPrice: newPrice, tpCid: newCid }),
|
|
331
|
+
});
|
|
332
|
+
this.emitEvent({
|
|
333
|
+
type: 'bracket.modified',
|
|
334
|
+
bracketId: entry.bracketId,
|
|
335
|
+
symbol: entry.symbol,
|
|
336
|
+
field: role,
|
|
337
|
+
oldValue: oldPrice ?? NaN,
|
|
338
|
+
newValue: newPrice,
|
|
339
|
+
ts: new Date(this.now()).toISOString(),
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
/** Cancel both legs, mark cancelled. Risk-reducing; best-effort per leg. */
|
|
343
|
+
async cancelBrackets(symbol, reason) {
|
|
344
|
+
const entry = this.ledger.getBySymbol(symbol);
|
|
345
|
+
if (!entry)
|
|
346
|
+
return;
|
|
347
|
+
for (const cloid of [entry.slCid, entry.tpCid]) {
|
|
348
|
+
if (!cloid)
|
|
349
|
+
continue;
|
|
350
|
+
try {
|
|
351
|
+
await this.executor.cancelBracketLeg(cloid, entry.symbol);
|
|
352
|
+
}
|
|
353
|
+
catch (err) {
|
|
354
|
+
logger.warn(TAG, `cancelBrackets ${entry.symbol} leg ${cloid}: ${formatError(err)}`);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
this.ledger.markState(symbol, 'cancelled', { closeReason: 'cancelled_manual' });
|
|
358
|
+
this.emitEvent({
|
|
359
|
+
type: 'bracket.cancelled',
|
|
360
|
+
bracketId: entry.bracketId,
|
|
361
|
+
symbol: entry.symbol,
|
|
362
|
+
reason,
|
|
363
|
+
ts: new Date(this.now()).toISOString(),
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* The HL analog of the Binance ALGO_UPDATE handler — `orderUpdates` is
|
|
368
|
+
* authoritative for bracket-leg lifecycle. Returns the transition applied
|
|
369
|
+
* (for the adapter to emit drift/close-bypass signals on triggers).
|
|
370
|
+
*/
|
|
371
|
+
handleOrderUpdate(update) {
|
|
372
|
+
const cloid = update.order?.cloid;
|
|
373
|
+
if (!cloid)
|
|
374
|
+
return null;
|
|
375
|
+
const parsed = parseHlBracketCloid(cloid);
|
|
376
|
+
if (!parsed || parsed.role === 'entry')
|
|
377
|
+
return null;
|
|
378
|
+
const status = update.status;
|
|
379
|
+
const coin = update.order.coin;
|
|
380
|
+
const entry = this.ledger.getByBracketId(parsed.bracketId);
|
|
381
|
+
if (!entry)
|
|
382
|
+
return null;
|
|
383
|
+
if (TRIGGERED_STATUSES.has(status)) {
|
|
384
|
+
if (isTerminalBracketState(entry.state))
|
|
385
|
+
return null; // duplicate signal
|
|
386
|
+
const next = parsed.role === 'stop' ? 'triggered_sl' : 'triggered_tp';
|
|
387
|
+
this.ledger.markState(entry.symbol, next, {
|
|
388
|
+
closeReason: parsed.role === 'stop' ? 'triggered_sl' : 'triggered_tp',
|
|
389
|
+
});
|
|
390
|
+
this.emitEvent({
|
|
391
|
+
type: 'bracket.triggered',
|
|
392
|
+
bracketId: entry.bracketId,
|
|
393
|
+
symbol: entry.symbol,
|
|
394
|
+
side: parsed.role === 'stop' ? 'sl' : 'tp',
|
|
395
|
+
ts: new Date(this.now()).toISOString(),
|
|
396
|
+
});
|
|
397
|
+
logger.info(TAG, `${entry.symbol} ${parsed.role} leg fired (coin=${coin}, status=${status})`);
|
|
398
|
+
return next;
|
|
399
|
+
}
|
|
400
|
+
if (SIBLING_CLEANUP_STATUSES.has(status)) {
|
|
401
|
+
// Expected T-1 OCO cleanup after the sibling fired — benign.
|
|
402
|
+
logger.info(TAG, `${entry.symbol} ${parsed.role} leg sibling-cancelled (T-1 cleanup)`);
|
|
403
|
+
return null;
|
|
404
|
+
}
|
|
405
|
+
if (FORCED_CLOSE_STATUSES.has(status)) {
|
|
406
|
+
if (!isTerminalBracketState(entry.state)) {
|
|
407
|
+
this.ledger.markState(entry.symbol, 'cancelled', {
|
|
408
|
+
closeReason: 'cancelled_auto',
|
|
409
|
+
lastError: `leg ${status}`,
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
logger.warn(TAG, `${entry.symbol} ${parsed.role} leg ${status} — position force-closed by the exchange`);
|
|
413
|
+
return 'forced_close';
|
|
414
|
+
}
|
|
415
|
+
if (status === 'canceled' && (entry.state === 'active' || entry.state === 'partial')) {
|
|
416
|
+
// CANCELED-while-active without a trigger/sibling reason = something
|
|
417
|
+
// stripped protection. Same WARN canary as the Binance ALGO_UPDATE rule.
|
|
418
|
+
// ONLY for cancels we did NOT initiate: resize/modify supersede legs
|
|
419
|
+
// with fresh cloids and then cancel the stale ones, and the WS 'canceled'
|
|
420
|
+
// event can arrive BEFORE the ledger's fresh-cid update (race observed
|
|
421
|
+
// live on the T-8 testnet run) — so the check is the explicit own-cancel
|
|
422
|
+
// registry, backstopped by the generation check.
|
|
423
|
+
const isCurrentLeg = cloid === entry.slCid || cloid === entry.tpCid;
|
|
424
|
+
if (isCurrentLeg && !this.ownCancels.has(cloid)) {
|
|
425
|
+
logger.warn(TAG, `CANARY: ${entry.symbol} ${parsed.role} leg CANCELED while bracket ${entry.state} — ` +
|
|
426
|
+
`protection may have been stripped out-of-band; coverage audit will re-attach`);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
return null;
|
|
430
|
+
}
|
|
431
|
+
/**
|
|
432
|
+
* ★ T-5 mandate: REST truth-check. Runs after every WS (re)connect AND on the
|
|
433
|
+
* periodic sweep. TRUSTED reads only — a null positions fetch skips the pass
|
|
434
|
+
* (never act on unknown). Self-heals: pending rows whose entry filled while
|
|
435
|
+
* we were blind get their legs attached; undersized legs get resized; flat
|
|
436
|
+
* positions get their rows closed out (T-1 already cancelled the legs).
|
|
437
|
+
* Returns symbols whose rows were closed out (close-bypass signals for the
|
|
438
|
+
* adapter's drift pipeline).
|
|
439
|
+
*/
|
|
440
|
+
async resyncAgainstExchange(reasonTag) {
|
|
441
|
+
const closedSymbols = [];
|
|
442
|
+
const rows = this.ledger.getAll().filter((r) => !isTerminalBracketState(r.state));
|
|
443
|
+
if (rows.length === 0)
|
|
444
|
+
return { closedSymbols };
|
|
445
|
+
const positions = await this.executor.getPositionsOrNull();
|
|
446
|
+
if (positions === null) {
|
|
447
|
+
logger.warn(TAG, `resync(${reasonTag}): positions fetch FAILED — state UNKNOWN, skipping this pass ` +
|
|
448
|
+
`(null ≠ empty; acting would risk closing rows for live positions)`);
|
|
449
|
+
return { closedSymbols };
|
|
450
|
+
}
|
|
451
|
+
const sizeBySymbol = new Map();
|
|
452
|
+
for (const p of positions) {
|
|
453
|
+
const contracts = Math.abs(Number(p.contracts ?? 0));
|
|
454
|
+
if (contracts > 0 && p.symbol)
|
|
455
|
+
sizeBySymbol.set(baseOf(p.symbol), contracts);
|
|
456
|
+
}
|
|
457
|
+
for (const row of rows) {
|
|
458
|
+
const liveSize = sizeBySymbol.get(baseOf(row.symbol)) ?? 0;
|
|
459
|
+
if (liveSize === 0) {
|
|
460
|
+
// Exchange is flat. T-1 already auto-cancelled any legs. If the row
|
|
461
|
+
// was active, the trigger event was missed (T-5) — close the row out
|
|
462
|
+
// and let the adapter surface a close-bypass drift.
|
|
463
|
+
if (row.state === 'active' || row.state === 'partial') {
|
|
464
|
+
logger.warn(TAG, `resync(${reasonTag}): ${row.symbol} flat on exchange while bracket ${row.state} — ` +
|
|
465
|
+
`missed trigger/close during a WS gap; closing the row (legs auto-cancel on flat, T-1)`);
|
|
466
|
+
this.ledger.markState(row.symbol, 'cancelled', {
|
|
467
|
+
closeReason: 'cancelled_auto',
|
|
468
|
+
lastError: `resync_position_flat:${reasonTag}`,
|
|
469
|
+
});
|
|
470
|
+
closedSymbols.push(row.symbol);
|
|
471
|
+
}
|
|
472
|
+
else {
|
|
473
|
+
// pending_entry/attaching with no position: entry never filled (or
|
|
474
|
+
// filled and closed inside the gap — the journal reconcile owns
|
|
475
|
+
// that). Remove the stale row so the next entry starts clean.
|
|
476
|
+
logger.info(TAG, `resync(${reasonTag}): ${row.symbol} row ${row.state} with no position — clearing stale row`);
|
|
477
|
+
this.ledger.markState(row.symbol, 'cancelled', {
|
|
478
|
+
closeReason: 'cancelled_auto',
|
|
479
|
+
lastError: `resync_no_position:${reasonTag}`,
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
continue;
|
|
483
|
+
}
|
|
484
|
+
// Position is live.
|
|
485
|
+
if (row.state === 'pending_entry') {
|
|
486
|
+
// Entry filled while we were blind — attach now, sized to the position.
|
|
487
|
+
logger.warn(TAG, `resync(${reasonTag}): ${row.symbol} position live (${liveSize}) with bracket still ` +
|
|
488
|
+
`pending_entry — the fill event was missed; attaching legs now`);
|
|
489
|
+
const res = await this.attachOnFill(row.symbol, liveSize).catch((err) => {
|
|
490
|
+
logger.error(TAG, `resync attach failed for ${row.symbol}: ${formatError(err)}`);
|
|
491
|
+
return null;
|
|
492
|
+
});
|
|
493
|
+
if (res && !res.ok) {
|
|
494
|
+
this.emitEvent({
|
|
495
|
+
type: 'bracket.attach_failed',
|
|
496
|
+
bracketId: row.bracketId,
|
|
497
|
+
symbol: row.symbol,
|
|
498
|
+
error: res.error ?? 'resync attach failed',
|
|
499
|
+
retryCount: res.attempts,
|
|
500
|
+
ts: new Date(this.now()).toISOString(),
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
continue;
|
|
504
|
+
}
|
|
505
|
+
if (row.state === 'active' || row.state === 'partial') {
|
|
506
|
+
// ★ T-2 invariant: do the live legs cover the live size?
|
|
507
|
+
try {
|
|
508
|
+
const audit = await this.executor.auditBracketCoverage(row.symbol, liveSize);
|
|
509
|
+
if (!audit.covered) {
|
|
510
|
+
logger.warn(TAG, `resync(${reasonTag}): ${row.symbol} legs do NOT cover position ` +
|
|
511
|
+
`(size=${liveSize}, shortfall=${JSON.stringify(audit.shortfall)}, ` +
|
|
512
|
+
`missing=${audit.missing.join(',') || 'none'}) — resizing (naked size is a ` +
|
|
513
|
+
`safety-floor breach)`);
|
|
514
|
+
await this.resizeToPosition(row.symbol, liveSize);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
catch (err) {
|
|
518
|
+
// getOpenOrders threw (unknown state) — honest skip, next sweep retries.
|
|
519
|
+
logger.warn(TAG, `resync(${reasonTag}): ${row.symbol} coverage unverifiable (${formatError(err)}) — ` +
|
|
520
|
+
`not acting on unknown state; next sweep retries`);
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
return { closedSymbols };
|
|
525
|
+
}
|
|
526
|
+
emitEvent(event) {
|
|
527
|
+
this.emit('event', event);
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
/** Compare symbols by base asset (`BTC/USDC` vs `BTC/USDC:USDC`). */
|
|
531
|
+
function baseOf(symbol) {
|
|
532
|
+
return symbol.split(':')[0];
|
|
533
|
+
}
|