@reefclaw/connect 0.1.20 → 0.1.22
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/assets/bridge/bridge.d.ts +20 -5
- package/assets/bridge/bridge.js +29 -14
- package/assets/bridge/config.js +6 -0
- package/assets/bridge/gateway/gateway-config.d.ts +16 -5
- package/assets/bridge/gateway/gateway-config.js +68 -12
- package/assets/bridge/gateway/gateway-ws-client.d.ts +4 -1
- package/assets/bridge/gateway/gateway-ws-client.js +41 -11
- package/assets/bridge/gateway/poller.js +18 -8
- package/assets/bridge/providers/emergency-commands.d.ts +9 -1
- package/assets/bridge/providers/emergency-commands.js +38 -1
- package/assets/bridge/providers/gateway.d.ts +51 -1
- package/assets/bridge/providers/gateway.js +209 -22
- package/assets/bridge/providers/onboarding-commands.d.ts +11 -0
- package/assets/bridge/providers/onboarding-commands.js +5 -5
- package/assets/bridge/providers/risk-calculator.d.ts +61 -2
- package/assets/bridge/providers/risk-calculator.js +92 -20
- package/assets/bridge/utils/skill-signing.js +8 -3
- package/assets/plugin/ccxt/binance-public.d.ts +17 -5
- package/assets/plugin/ccxt/binance-public.js +31 -3
- package/assets/plugin/config/operator-provenance.d.ts +6 -0
- package/assets/plugin/config/operator-provenance.js +50 -0
- package/assets/plugin/config/plugin-config-io.d.ts +15 -1
- package/assets/plugin/config/plugin-config-io.js +29 -0
- package/assets/plugin/exchange-adapter.d.ts +13 -0
- package/assets/plugin/index.js +230 -176
- package/assets/plugin/ingest/event-loop-monitor.d.ts +22 -0
- package/assets/plugin/ingest/event-loop-monitor.js +190 -0
- package/assets/plugin/ingest/position-auto-capture.d.ts +5 -0
- package/assets/plugin/ingest/position-auto-capture.js +14 -5
- package/assets/plugin/ingest/readiness-reporter.d.ts +26 -6
- package/assets/plugin/ingest/readiness-reporter.js +137 -9
- package/assets/plugin/ingest/skill-version-reader.d.ts +16 -0
- package/assets/plugin/ingest/skill-version-reader.js +64 -0
- package/assets/plugin/live/approval-lifecycle.d.ts +30 -0
- package/assets/plugin/live/approval-lifecycle.js +80 -0
- package/assets/plugin/live/bracket-types.d.ts +9 -0
- package/assets/plugin/live/live-adapter.d.ts +0 -1
- package/assets/plugin/live/user-data-stream.js +10 -2
- package/assets/plugin/onboarding/runtime.d.ts +34 -1
- package/assets/plugin/onboarding/runtime.js +56 -5
- package/assets/plugin/openclaw.plugin.json +1 -1
- package/assets/plugin/risk/pre-trade-check.js +18 -5
- package/assets/plugin/simulator/exchange-simulator.d.ts +45 -2
- package/assets/plugin/simulator/exchange-simulator.js +96 -4
- package/assets/plugin/simulator/types.d.ts +17 -0
- package/assets/plugin/strategy/condition-registry.js +9 -2
- package/assets/plugin/strategy/evaluator.d.ts +5 -0
- package/assets/plugin/tools/attach-brackets.js +50 -1
- package/assets/plugin/tools/cancel-all-orders.js +9 -1
- package/assets/plugin/tools/create-order.js +18 -1
- package/assets/plugin/tools/get-bracket-config.d.ts +21 -2
- package/assets/plugin/tools/get-bracket-config.js +18 -2
- package/assets/plugin/tools/set-trading-mode.js +6 -3
- package/assets/plugin/venues/hyperliquid/hl-bracket-coordinator.d.ts +25 -1
- package/assets/plugin/venues/hyperliquid/hl-bracket-coordinator.js +57 -0
- package/assets/plugin/venues/hyperliquid/hl-brackets.d.ts +10 -0
- package/assets/plugin/venues/hyperliquid/hl-brackets.js +45 -13
- package/assets/plugin/venues/hyperliquid/hl-fill-ingest.d.ts +18 -0
- package/assets/plugin/venues/hyperliquid/hl-fill-ingest.js +88 -0
- package/assets/plugin/venues/hyperliquid/hl-live-adapter.d.ts +36 -0
- package/assets/plugin/venues/hyperliquid/hl-live-adapter.js +116 -7
- package/assets/plugin/venues/hyperliquid/hl-public.d.ts +12 -5
- package/assets/plugin/venues/hyperliquid/hl-public.js +24 -3
- package/assets/plugin/venues/hyperliquid/hl-user-stream.d.ts +13 -1
- package/assets/plugin/venues/hyperliquid/hl-user-stream.js +4 -1
- package/assets/plugin/venues/registry.js +8 -7
- package/assets/plugin/wave9/paper-admission-guard.d.ts +12 -1
- package/assets/plugin/wave9/paper-admission-guard.js +12 -1
- package/assets/shared/index.d.ts +3 -3
- package/assets/shared/index.js +6 -2
- package/assets/shared/readiness.d.ts +45 -2
- package/assets/shared/readiness.js +60 -8
- package/assets/skill/SKILL.md +6 -11
- package/dist/cli.js +3 -0
- package/package.json +1 -1
|
@@ -36,20 +36,27 @@ register('oi_slope', (fact) => ({
|
|
|
36
36
|
name: 'oi_slope',
|
|
37
37
|
met: fact.conditions.oi_slope_up ?? false,
|
|
38
38
|
}));
|
|
39
|
-
// 5. no_liquidation_cluster —
|
|
39
|
+
// 5. no_liquidation_cluster — FAIL-OPEN PLACEHOLDER in scan context: the
|
|
40
|
+
// fact-computer ships no liquidation data, so this preview cannot evaluate
|
|
41
|
+
// it. `note` marks the gap explicitly (never a silent pass); the
|
|
42
|
+
// authoritative evaluation runs in the central signal engine, which reads the
|
|
43
|
+
// live liquidation-levels feed.
|
|
40
44
|
register('no_liquidation_cluster', () => ({
|
|
41
45
|
name: 'no_liquidation_cluster',
|
|
42
46
|
met: true,
|
|
47
|
+
note: 'not evaluated in scan preview (no liquidation data in facts) — authoritative check runs in the signal engine',
|
|
43
48
|
}));
|
|
44
49
|
// 6. price_sweep
|
|
45
50
|
register('price_sweep', (fact) => ({
|
|
46
51
|
name: 'price_sweep',
|
|
47
52
|
met: (fact.conditions.price_sweep_high ?? false) || (fact.conditions.price_sweep_low ?? false),
|
|
48
53
|
}));
|
|
49
|
-
// 7. liquidations_at_sweep —
|
|
54
|
+
// 7. liquidations_at_sweep — FAIL-OPEN PLACEHOLDER in scan context, same gap
|
|
55
|
+
// and same note contract as no_liquidation_cluster above.
|
|
50
56
|
register('liquidations_at_sweep', () => ({
|
|
51
57
|
name: 'liquidations_at_sweep',
|
|
52
58
|
met: true,
|
|
59
|
+
note: 'not evaluated in scan preview (no liquidation data in facts) — authoritative check runs in the signal engine',
|
|
53
60
|
}));
|
|
54
61
|
// 8. order_flow_absorption
|
|
55
62
|
register('order_flow_absorption', (fact) => ({
|
|
@@ -45,6 +45,11 @@ export interface ConditionEvalResult {
|
|
|
45
45
|
name: string;
|
|
46
46
|
met: boolean;
|
|
47
47
|
value?: number;
|
|
48
|
+
/** Set when `met` is a fail-open placeholder rather than a real evaluation
|
|
49
|
+
* (e.g. the scan facts carry no data for this condition). Surfaces the gap
|
|
50
|
+
* to any consumer instead of letting a pass silently impersonate a check —
|
|
51
|
+
* the authoritative evaluation runs in the central signal engine. */
|
|
52
|
+
note?: string;
|
|
48
53
|
}
|
|
49
54
|
export interface StrategyEvalResult {
|
|
50
55
|
strategy: string;
|
|
@@ -323,13 +323,28 @@ async function attachBracketsHl(args, adapter) {
|
|
|
323
323
|
let clearedStaleLedgerRow = false;
|
|
324
324
|
if (existing && !isTerminalBracketState(existing.state)) {
|
|
325
325
|
let cls = 'unknown';
|
|
326
|
+
// Per-LEG liveness (audit 2026-07-26 F4): "either cid survives ⇒ live"
|
|
327
|
+
// let a TP-only position no-op as "already protected" indefinitely while
|
|
328
|
+
// its stop was gone. A registered leg positively absent from a NON-EMPTY
|
|
329
|
+
// order set is a protection gap this tool must repair, not paper over.
|
|
330
|
+
const missingLegs = [];
|
|
326
331
|
const hasCids = Boolean(existing.slCid || existing.tpCid);
|
|
327
332
|
if (hasCids) {
|
|
328
333
|
try {
|
|
329
334
|
const open = await adapter.getOpenOrders(position.symbol);
|
|
330
335
|
const liveCids = new Set(open.map(o => o.clientOrderId).filter(Boolean));
|
|
331
|
-
|
|
336
|
+
const stopLive = Boolean(existing.slCid && liveCids.has(existing.slCid));
|
|
337
|
+
const tpLive = Boolean(existing.tpCid && liveCids.has(existing.tpCid));
|
|
338
|
+
if (stopLive || tpLive) {
|
|
332
339
|
cls = 'live';
|
|
340
|
+
if (open.length > 0) {
|
|
341
|
+
// Non-empty set = positive evidence for absence (d59e51b) — a
|
|
342
|
+
// registered-but-absent sibling is a repairable gap.
|
|
343
|
+
if (existing.slCid && !stopLive)
|
|
344
|
+
missingLegs.push('stop');
|
|
345
|
+
if (existing.tpCid && !tpLive)
|
|
346
|
+
missingLegs.push('target');
|
|
347
|
+
}
|
|
333
348
|
}
|
|
334
349
|
else if (open.length > 0) {
|
|
335
350
|
cls = 'stale'; // non-empty set positively lacking our cids
|
|
@@ -351,6 +366,40 @@ async function attachBracketsHl(args, adapter) {
|
|
|
351
366
|
if (cls === 'live') {
|
|
352
367
|
if (pricesMatch(args.stop_price, existing.stopPrice)
|
|
353
368
|
&& pricesMatch(args.target_price, existing.targetPrice)) {
|
|
369
|
+
if (missingLegs.length > 0) {
|
|
370
|
+
// One registered leg is positively gone (a stop-less position is a
|
|
371
|
+
// safety-floor breach). Rebuild it at the registered price via the
|
|
372
|
+
// resize path — planResize submits ONLY the missing leg and leaves
|
|
373
|
+
// the healthy sibling untouched (F4).
|
|
374
|
+
try {
|
|
375
|
+
const coordinator = adapter.getHlBracketCoordinator();
|
|
376
|
+
await coordinator.resizeToPosition(position.symbol, contracts);
|
|
377
|
+
const healed = ledger.getBySymbol(position.symbol);
|
|
378
|
+
return {
|
|
379
|
+
ok: true,
|
|
380
|
+
symbol: position.symbol,
|
|
381
|
+
bracket_id: existing.bracketId,
|
|
382
|
+
entry_side: entrySide,
|
|
383
|
+
stop_price: existing.stopPrice,
|
|
384
|
+
target_price: existing.targetPrice,
|
|
385
|
+
sl_cid: healed?.slCid ?? existing.slCid,
|
|
386
|
+
tp_cid: healed?.tpCid ?? existing.tpCid,
|
|
387
|
+
attach_latency_ms: 0,
|
|
388
|
+
cancelled_stale_bracket_orders: 0,
|
|
389
|
+
cleared_stale_ledger_row: false,
|
|
390
|
+
attempts: 1,
|
|
391
|
+
idempotent_no_op: false,
|
|
392
|
+
note: `Missing ${missingLegs.join('+')} leg re-attached at the registered price(s); sibling leg untouched.`,
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
catch (err) {
|
|
396
|
+
return {
|
|
397
|
+
error: `Registered ${missingLegs.join('+')} leg is GONE from the exchange and the rebuild ` +
|
|
398
|
+
`failed (${formatError(err)}). The position is under-protected — retry attach_brackets ` +
|
|
399
|
+
`next heartbeat; the 60s truth sweep also retries. Do NOT record a protected review.`,
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
}
|
|
354
403
|
return {
|
|
355
404
|
ok: true,
|
|
356
405
|
symbol: position.symbol,
|
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
// Tool: cancel_all_orders — cancel all open orders (paper or live)
|
|
2
|
-
//
|
|
2
|
+
//
|
|
3
|
+
// NO readiness gate and NO confirmation prompt — BY DESIGN, not an oversight:
|
|
4
|
+
// this is the safety floor's kill-switch primitive ("Hard controls NEVER
|
|
5
|
+
// disabled or hidden"). Any gate added here becomes a failure mode of the
|
|
6
|
+
// emergency path itself — a wedged confirmation would strand live orders
|
|
7
|
+
// during the exact incident the kill switch exists for. Risk-reducing only:
|
|
8
|
+
// it cancels WORKING orders; the adapter layer preserves protective bracket
|
|
9
|
+
// legs (parseBracketCid skip in cancelAllOrders) so positions are never left
|
|
10
|
+
// naked, and it never opens or increases exposure.
|
|
3
11
|
export async function cancelAllOrdersTool(args, deps) {
|
|
4
12
|
const cancel = () => deps.adapter.cancelAllOrders(args.symbol);
|
|
5
13
|
return deps.adapter.isLive && deps.operationLock
|
|
@@ -1008,11 +1008,28 @@ export async function createOrderTool(args, deps) {
|
|
|
1008
1008
|
}
|
|
1009
1009
|
// Bracket enforcement only applies in live mode when the feature is enabled.
|
|
1010
1010
|
// Paper mode uses the stop-watcher and doesn't care about these flags.
|
|
1011
|
+
//
|
|
1012
|
+
// ★ `brackets.mode` is a BINANCE-only knob — it is only ever passed to
|
|
1013
|
+
// LiveAdapter, and it defaults to 'off'. Gating solely on it meant an HL
|
|
1014
|
+
// live rig skipped the mandatory-stop check entirely: a stopless entry
|
|
1015
|
+
// passed the gate, and HL only attaches legs when stop/target metadata is
|
|
1016
|
+
// present (`wireBracketsAfterSubmit`), so the position went on the book
|
|
1017
|
+
// NAKED. Venues that always enforce brackets declare it on the adapter.
|
|
1011
1018
|
let bracketEnforcement;
|
|
1012
|
-
if (deps.adapter.isLive && bracketsEnabled(loadBracketMode())) {
|
|
1019
|
+
if (deps.adapter.isLive && (deps.adapter.bracketsAlwaysEnforced || bracketsEnabled(loadBracketMode()))) {
|
|
1013
1020
|
bracketEnforcement = wave9Claimed
|
|
1014
1021
|
? { requireStopLoss: true, requireTakeProfit: false }
|
|
1015
1022
|
: loadBracketRequirements();
|
|
1023
|
+
// On a venue-enforced adapter the stop requirement is NOT operator-
|
|
1024
|
+
// waivable: `requireStopLoss: false` in plugin-config is a Binance-era
|
|
1025
|
+
// toggle whose documented risk assumed a watcher fallback existed. HL has
|
|
1026
|
+
// none — a waved-through stopless entry would sit naked. This also keeps
|
|
1027
|
+
// the gate consistent with get_bracket_config, which reports
|
|
1028
|
+
// requireStopLoss=true for venue-enforced adapters. The TP flag stays
|
|
1029
|
+
// operator-controlled (stop-only "let winners run" is legitimate).
|
|
1030
|
+
if (deps.adapter.bracketsAlwaysEnforced) {
|
|
1031
|
+
bracketEnforcement = { ...bracketEnforcement, requireStopLoss: true };
|
|
1032
|
+
}
|
|
1016
1033
|
}
|
|
1017
1034
|
const riskCheck = preTradeRiskCheck(proposed, portfolio, getDefaultPreTradeLimits(), {
|
|
1018
1035
|
volFactor: !deps.adapter.isLive ? deps.adapter.getSimulator().getVolFactor() : 1.0,
|
|
@@ -3,9 +3,28 @@ export interface GetBracketConfigResult {
|
|
|
3
3
|
mode: BracketMode;
|
|
4
4
|
requireStopLoss: boolean;
|
|
5
5
|
requireTakeProfit: boolean;
|
|
6
|
+
/** True when the mode reported above is the VENUE's unconditional
|
|
7
|
+
* enforcement rather than the `brackets.mode` config value. Lets the
|
|
8
|
+
* dashboard explain why the toggles are inert. */
|
|
9
|
+
venueEnforced?: boolean;
|
|
6
10
|
}
|
|
7
|
-
export
|
|
11
|
+
export interface GetBracketConfigDeps {
|
|
8
12
|
configPath?: string;
|
|
9
|
-
|
|
13
|
+
/** Active adapter. Its `bracketsAlwaysEnforced` capability overrides the
|
|
14
|
+
* config-file mode — see below. */
|
|
15
|
+
adapter?: {
|
|
16
|
+
readonly bracketsAlwaysEnforced?: boolean;
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* ★ `brackets.mode` in plugin-config.json is a BINANCE-only lifecycle knob: it
|
|
21
|
+
* is passed to `LiveAdapter` and nowhere else. Reporting it verbatim made a
|
|
22
|
+
* Hyperliquid rig — where `HlBracketCoordinator` attaches legs unconditionally
|
|
23
|
+
* and there is no watcher fallback — render "Brackets off" on a live dashboard
|
|
24
|
+
* whose every position was in fact bracketed. A protection indicator that
|
|
25
|
+
* under-reports is exactly as dangerous as one that over-reports, so the
|
|
26
|
+
* effective venue behaviour wins over the stale config value.
|
|
27
|
+
*/
|
|
28
|
+
export declare function getBracketConfigTool(_args: Record<string, never>, deps?: GetBracketConfigDeps): GetBracketConfigResult | {
|
|
10
29
|
error: string;
|
|
11
30
|
};
|
|
@@ -6,13 +6,29 @@
|
|
|
6
6
|
import { readPluginConfig } from '../config/plugin-config-io.js';
|
|
7
7
|
import { getBracketMode, getBracketRequirements } from '../config/brackets-config.js';
|
|
8
8
|
import { formatError } from '../logger.js';
|
|
9
|
+
/**
|
|
10
|
+
* ★ `brackets.mode` in plugin-config.json is a BINANCE-only lifecycle knob: it
|
|
11
|
+
* is passed to `LiveAdapter` and nowhere else. Reporting it verbatim made a
|
|
12
|
+
* Hyperliquid rig — where `HlBracketCoordinator` attaches legs unconditionally
|
|
13
|
+
* and there is no watcher fallback — render "Brackets off" on a live dashboard
|
|
14
|
+
* whose every position was in fact bracketed. A protection indicator that
|
|
15
|
+
* under-reports is exactly as dangerous as one that over-reports, so the
|
|
16
|
+
* effective venue behaviour wins over the stale config value.
|
|
17
|
+
*/
|
|
9
18
|
export function getBracketConfigTool(_args, deps) {
|
|
10
19
|
try {
|
|
11
20
|
const cfg = readPluginConfig(deps?.configPath);
|
|
12
|
-
const mode = getBracketMode(cfg);
|
|
13
21
|
const req = getBracketRequirements(cfg);
|
|
22
|
+
if (deps?.adapter?.bracketsAlwaysEnforced) {
|
|
23
|
+
return {
|
|
24
|
+
mode: 'enforce',
|
|
25
|
+
requireStopLoss: true,
|
|
26
|
+
requireTakeProfit: req.requireTakeProfit,
|
|
27
|
+
venueEnforced: true,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
14
30
|
return {
|
|
15
|
-
mode,
|
|
31
|
+
mode: getBracketMode(cfg),
|
|
16
32
|
requireStopLoss: req.requireStopLoss,
|
|
17
33
|
requireTakeProfit: req.requireTakeProfit,
|
|
18
34
|
};
|
|
@@ -4,9 +4,12 @@
|
|
|
4
4
|
// the one-rung-at-a-time ladder. Requires valid exchange credentials for
|
|
5
5
|
// any non-PAPER target. Rebuilds the adapter via runtime.reconnect().
|
|
6
6
|
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
7
|
+
// GUARDED at the dispatch site (plugin/src/index.ts registration): every call
|
|
8
|
+
// must carry the `operator_token` provenance proof and is refused by
|
|
9
|
+
// verifyOperatorProvenance without it (audit 2026-07-26 F12). The agent cannot
|
|
10
|
+
// supply that token — chat redaction strips rc_* tokens — so only the
|
|
11
|
+
// dashboard operator path reaches this handler. This module stays guard-free
|
|
12
|
+
// by design: the check lives once, at registration, for all operator tools.
|
|
10
13
|
import { readPluginConfig } from '../config/plugin-config-io.js';
|
|
11
14
|
import { validateModeTransition, modeRequiresCredentials } from '../onboarding/mode-ladder.js';
|
|
12
15
|
import { parseVenue } from '../venues/registry.js';
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { EventEmitter } from 'node:events';
|
|
2
2
|
import type { CcxtOrder, CcxtPosition } from '../../types.js';
|
|
3
3
|
import type { CloseReason } from '../../simulator/types.js';
|
|
4
|
-
import type { BracketId, BracketRequest, BracketState } from '../../live/bracket-types.js';
|
|
4
|
+
import type { BracketId, BracketLedgerEntry, BracketRequest, BracketState } from '../../live/bracket-types.js';
|
|
5
5
|
import type { BracketLedger } from '../../live/bracket-ledger.js';
|
|
6
6
|
import type { HlOrderUpdateEvent } from './hl-user-stream.js';
|
|
7
7
|
/** Narrow execution surface the coordinator needs — the HyperliquidLiveAdapter
|
|
@@ -24,6 +24,11 @@ export interface HlBracketExecutor {
|
|
|
24
24
|
symbol: string;
|
|
25
25
|
positionSide: 'long' | 'short';
|
|
26
26
|
positionSize: number;
|
|
27
|
+
/** Ledger-registered prices — lets resize REBUILD a vanished leg (F4). */
|
|
28
|
+
registeredPrices?: {
|
|
29
|
+
stop?: number;
|
|
30
|
+
target?: number;
|
|
31
|
+
};
|
|
27
32
|
}): Promise<{
|
|
28
33
|
resized: boolean;
|
|
29
34
|
slCid?: string;
|
|
@@ -88,6 +93,25 @@ export declare class HlBracketCoordinator extends EventEmitter {
|
|
|
88
93
|
* fill signal against a non-terminal row is a warned NO-OP, never a fresh
|
|
89
94
|
* bracketId that orphans the live legs' ledger identity. */
|
|
90
95
|
registerEntry(req: BracketRequest, bracketId: BracketId, entryCid: string): void;
|
|
96
|
+
/** ★ Audit 2026-07-26 F5 — track a SECOND entry order placed against a live
|
|
97
|
+
* bracket row (a scale-in, or another entry while the first still rests).
|
|
98
|
+
*
|
|
99
|
+
* Each submission gets a fresh cloid, but the row keeps the ORIGINAL
|
|
100
|
+
* `entryCid`. The user-stream fill handler matches fills to rows by cid, so
|
|
101
|
+
* an unrecorded cid meant the later fill matched NOTHING: no attach, no
|
|
102
|
+
* resize. On HL that is naked exposure, not cosmetic drift — legs are FIXED
|
|
103
|
+
* SIZE (T-2), so the added contracts stayed unprotected until the 60s
|
|
104
|
+
* truth-check sweep happened to catch them.
|
|
105
|
+
*
|
|
106
|
+
* Idempotent; a terminal/absent row or a repeat of the primary cid is a
|
|
107
|
+
* no-op. Recording is deliberately CHEAP and local — the sweep remains the
|
|
108
|
+
* backstop, this just stops it being the only line of defence. */
|
|
109
|
+
registerAdditionalEntryCid(symbol: string, entryCid: string): void;
|
|
110
|
+
/** The bracket row a user-stream fill belongs to: the primary `entryCid` OR
|
|
111
|
+
* any cid recorded by `registerAdditionalEntryCid`. Terminal rows never
|
|
112
|
+
* match. Single home for the matching rule so the adapter's fill handler
|
|
113
|
+
* and the ledger can never disagree about what "our entry" means. */
|
|
114
|
+
findRowByEntryCid(cloid: string | undefined | null): BracketLedgerEntry | undefined;
|
|
91
115
|
/** Attach both legs (ONE batched signed action) with retries. Idempotent on
|
|
92
116
|
* a non-pending row. On exhaustion: ledger 'failed' + attach_failed event —
|
|
93
117
|
* the ADAPTER escalates to auto-flatten (it owns closePosition). */
|
|
@@ -32,6 +32,10 @@ import { parseHlBracketCloid } from './hl-cloid.js';
|
|
|
32
32
|
const TAG = 'hl-bracket-coordinator';
|
|
33
33
|
const DEFAULT_MAX_ATTEMPTS = 3;
|
|
34
34
|
const DEFAULT_BACKOFF = (attempt) => (attempt === 1 ? 1_000 : 2_000);
|
|
35
|
+
/** Cap on `extraEntryCids` so a position scaled many times cannot grow its
|
|
36
|
+
* ledger row without bound. Oldest dropped — a cid that old has either filled
|
|
37
|
+
* (row already resized) or been cancelled. */
|
|
38
|
+
const MAX_EXTRA_ENTRY_CIDS = 20;
|
|
35
39
|
const TERMINAL_STATES = new Set([
|
|
36
40
|
'triggered_sl',
|
|
37
41
|
'triggered_tp',
|
|
@@ -109,6 +113,12 @@ export class HlBracketCoordinator extends EventEmitter {
|
|
|
109
113
|
logger.warn(TAG, `registerEntry(${req.symbol}): non-terminal row exists (state=${existing.state}, ` +
|
|
110
114
|
`bracketId=${existing.bracketId}) — duplicate fill signal, NOT clobbering ` +
|
|
111
115
|
`(anti-clobber contract; scale-ins go through resizeToPosition)`);
|
|
116
|
+
// ★ F5: not clobbering the row is right — DROPPING the new order's cloid
|
|
117
|
+
// was not. A duplicate signal carrying a DIFFERENT cid is a second order
|
|
118
|
+
// against the same position (a resting scale-in, or a second entry while
|
|
119
|
+
// the first is still pending); its fill has to find this row or nothing
|
|
120
|
+
// attaches/resizes for it.
|
|
121
|
+
this.registerAdditionalEntryCid(req.symbol, entryCid);
|
|
112
122
|
return;
|
|
113
123
|
}
|
|
114
124
|
if (existing)
|
|
@@ -129,6 +139,49 @@ export class HlBracketCoordinator extends EventEmitter {
|
|
|
129
139
|
ts: new Date(this.now()).toISOString(),
|
|
130
140
|
});
|
|
131
141
|
}
|
|
142
|
+
/** ★ Audit 2026-07-26 F5 — track a SECOND entry order placed against a live
|
|
143
|
+
* bracket row (a scale-in, or another entry while the first still rests).
|
|
144
|
+
*
|
|
145
|
+
* Each submission gets a fresh cloid, but the row keeps the ORIGINAL
|
|
146
|
+
* `entryCid`. The user-stream fill handler matches fills to rows by cid, so
|
|
147
|
+
* an unrecorded cid meant the later fill matched NOTHING: no attach, no
|
|
148
|
+
* resize. On HL that is naked exposure, not cosmetic drift — legs are FIXED
|
|
149
|
+
* SIZE (T-2), so the added contracts stayed unprotected until the 60s
|
|
150
|
+
* truth-check sweep happened to catch them.
|
|
151
|
+
*
|
|
152
|
+
* Idempotent; a terminal/absent row or a repeat of the primary cid is a
|
|
153
|
+
* no-op. Recording is deliberately CHEAP and local — the sweep remains the
|
|
154
|
+
* backstop, this just stops it being the only line of defence. */
|
|
155
|
+
registerAdditionalEntryCid(symbol, entryCid) {
|
|
156
|
+
if (!entryCid)
|
|
157
|
+
return;
|
|
158
|
+
const row = this.ledger.getBySymbol(symbol);
|
|
159
|
+
if (!row || isTerminalBracketState(row.state))
|
|
160
|
+
return;
|
|
161
|
+
if (row.entryCid === entryCid)
|
|
162
|
+
return;
|
|
163
|
+
const current = row.extraEntryCids ?? [];
|
|
164
|
+
if (current.includes(entryCid))
|
|
165
|
+
return;
|
|
166
|
+
this.ledger.upsert({
|
|
167
|
+
...row,
|
|
168
|
+
extraEntryCids: [...current, entryCid].slice(-MAX_EXTRA_ENTRY_CIDS),
|
|
169
|
+
});
|
|
170
|
+
logger.info(TAG, `${row.symbol}: tracking additional entry cid ${entryCid} against bracket ` +
|
|
171
|
+
`${row.bracketId} (state=${row.state}) — its fill must drive attach/resize (T-2)`);
|
|
172
|
+
}
|
|
173
|
+
/** The bracket row a user-stream fill belongs to: the primary `entryCid` OR
|
|
174
|
+
* any cid recorded by `registerAdditionalEntryCid`. Terminal rows never
|
|
175
|
+
* match. Single home for the matching rule so the adapter's fill handler
|
|
176
|
+
* and the ledger can never disagree about what "our entry" means. */
|
|
177
|
+
findRowByEntryCid(cloid) {
|
|
178
|
+
if (!cloid)
|
|
179
|
+
return undefined;
|
|
180
|
+
return this.ledger
|
|
181
|
+
.getAll()
|
|
182
|
+
.find((r) => !isTerminalBracketState(r.state) &&
|
|
183
|
+
(r.entryCid === cloid || r.extraEntryCids?.includes(cloid) === true));
|
|
184
|
+
}
|
|
132
185
|
/** Attach both legs (ONE batched signed action) with retries. Idempotent on
|
|
133
186
|
* a non-pending row. On exhaustion: ledger 'failed' + attach_failed event —
|
|
134
187
|
* the ADAPTER escalates to auto-flatten (it owns closePosition). */
|
|
@@ -261,6 +314,10 @@ export class HlBracketCoordinator extends EventEmitter {
|
|
|
261
314
|
symbol: entry.symbol,
|
|
262
315
|
positionSide,
|
|
263
316
|
positionSize: newPositionSize,
|
|
317
|
+
// F4: with the registered prices in hand, resize can REBUILD a leg that
|
|
318
|
+
// vanished entirely (e.g. a stripped stop) instead of no-oping while
|
|
319
|
+
// the coverage sweep warns forever.
|
|
320
|
+
registeredPrices: { stop: entry.stopPrice, target: entry.targetPrice },
|
|
264
321
|
});
|
|
265
322
|
if (res.resized) {
|
|
266
323
|
this.ledger.markState(symbol, entry.state, {
|
|
@@ -87,6 +87,16 @@ export declare function planResize(args: {
|
|
|
87
87
|
positionSide: 'long' | 'short';
|
|
88
88
|
positionSize: number;
|
|
89
89
|
liveLegs: LiveLeg[];
|
|
90
|
+
/** The prices REGISTERED at attach (the ledger row's stopPrice/targetPrice).
|
|
91
|
+
* ★ Audit 2026-07-26 F4: a role whose leg vanished entirely used to be
|
|
92
|
+
* "the attach path's job" — but no path owned it, so a stop-less position
|
|
93
|
+
* never self-healed. With the registered price in hand, resize REBUILDS
|
|
94
|
+
* the missing leg at the price the agent pinned; without one it cannot
|
|
95
|
+
* invent a level and leaves the role to the coverage alarms. */
|
|
96
|
+
registeredPrices?: {
|
|
97
|
+
stop?: number;
|
|
98
|
+
target?: number;
|
|
99
|
+
};
|
|
90
100
|
}): ResizePlan;
|
|
91
101
|
/** Is this exchange order one of OUR protective legs?
|
|
92
102
|
*
|
|
@@ -84,6 +84,11 @@ export function bracketCoversPosition(args) {
|
|
|
84
84
|
const legs = args.liveLegs.filter((l) => l.role === role);
|
|
85
85
|
if (legs.length === 0) {
|
|
86
86
|
missing.push(role);
|
|
87
|
+
// A role with no leg at all leaves the WHOLE position uncovered on that
|
|
88
|
+
// side — report the honest naked size. Leaving this at 0 was audit
|
|
89
|
+
// 2026-07-26 F4: the resize planner keyed on shortfall, saw zero, and
|
|
90
|
+
// no-oped forever while a stop-less position stayed naked.
|
|
91
|
+
shortfall[role] = args.positionSize;
|
|
87
92
|
continue;
|
|
88
93
|
}
|
|
89
94
|
const coveredSize = legs.reduce((sum, l) => sum + l.size, 0);
|
|
@@ -91,12 +96,15 @@ export function bracketCoversPosition(args) {
|
|
|
91
96
|
if (gap > COVERAGE_EPSILON)
|
|
92
97
|
shortfall[role] = gap;
|
|
93
98
|
}
|
|
94
|
-
const covered =
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
99
|
+
const covered =
|
|
100
|
+
// A missing STOP is never "covered" — that is the naked-position case
|
|
101
|
+
// (its shortfall is the full position size, so this arm also fails).
|
|
102
|
+
!missing.includes('stop') &&
|
|
103
|
+
shortfall.stop <= COVERAGE_EPSILON &&
|
|
104
|
+
// A missing TARGET is a policy choice (requireTakeProfit can be off): it
|
|
105
|
+
// is reported via `missing` + `shortfall` but does not by itself fail
|
|
106
|
+
// coverage. A PRESENT-but-undersized target still does.
|
|
107
|
+
(missing.includes('target') || shortfall.target <= COVERAGE_EPSILON);
|
|
100
108
|
return { covered, shortfall, missing };
|
|
101
109
|
}
|
|
102
110
|
/** ★ Resize protective legs after a scale-in (or any position-size change).
|
|
@@ -120,20 +128,44 @@ export function planResize(args) {
|
|
|
120
128
|
positionSize: args.positionSize,
|
|
121
129
|
liveLegs: args.liveLegs,
|
|
122
130
|
});
|
|
123
|
-
|
|
131
|
+
const healable = ['stop', 'target'].filter((role) => {
|
|
132
|
+
const price = args.registeredPrices?.[role];
|
|
133
|
+
return (coverage.missing.includes(role) &&
|
|
134
|
+
typeof price === 'number' && Number.isFinite(price) && price > 0);
|
|
135
|
+
});
|
|
136
|
+
// Nothing to do when every present leg already covers the position exactly
|
|
137
|
+
// and no vanished-but-registered leg needs rebuilding. (A missing role's
|
|
138
|
+
// shortfall is the full position size, so present-leg sizing is checked
|
|
139
|
+
// against the PRESENT legs only.)
|
|
140
|
+
const presentShortfall = ['stop', 'target'].some((role) => !coverage.missing.includes(role) && coverage.shortfall[role] > COVERAGE_EPSILON);
|
|
124
141
|
const oversized = args.liveLegs.some((l) => l.size - args.positionSize > COVERAGE_EPSILON);
|
|
125
|
-
if (
|
|
126
|
-
coverage.shortfall.target <= COVERAGE_EPSILON &&
|
|
127
|
-
!oversized) {
|
|
142
|
+
if (!presentShortfall && !oversized && healable.length === 0) {
|
|
128
143
|
return { cancelCloids: [], submit: [], noop: true };
|
|
129
144
|
}
|
|
130
|
-
// Rebuild every PRESENT role at the correct size
|
|
145
|
+
// Rebuild every PRESENT role at the correct size (preserving its trigger
|
|
146
|
+
// price), plus every HEALABLE missing role at its registered price.
|
|
131
147
|
const legs = [];
|
|
132
148
|
const cancelCloids = [];
|
|
133
149
|
for (const role of ['stop', 'target']) {
|
|
134
150
|
const existing = args.liveLegs.filter((l) => l.role === role);
|
|
135
|
-
if (existing.length === 0)
|
|
136
|
-
|
|
151
|
+
if (existing.length === 0) {
|
|
152
|
+
if (!healable.includes(role))
|
|
153
|
+
continue; // no leg, no registered price — coverage alarms own it
|
|
154
|
+
legs.push({
|
|
155
|
+
role,
|
|
156
|
+
triggerPrice: args.registeredPrices[role],
|
|
157
|
+
size: args.positionSize,
|
|
158
|
+
cloid: buildHlBracketCloid(args.bracketId, role),
|
|
159
|
+
});
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
// A present role that already covers exactly (and isn't oversized) is left
|
|
163
|
+
// untouched — a heal of the OTHER role must not churn a healthy leg.
|
|
164
|
+
const coveredSize = existing.reduce((sum, l) => sum + l.size, 0);
|
|
165
|
+
const roleHealthy = args.positionSize - coveredSize <= COVERAGE_EPSILON &&
|
|
166
|
+
!existing.some((l) => l.size - args.positionSize > COVERAGE_EPSILON);
|
|
167
|
+
if (roleHealthy)
|
|
168
|
+
continue;
|
|
137
169
|
// Trigger price is preserved from the live leg (the agent's pinned plan) —
|
|
138
170
|
// resizing must NEVER silently move a stop.
|
|
139
171
|
const triggerPrice = existing[0].triggerPrice;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { FillEvent } from '@reefclaw/shared';
|
|
2
|
+
import type { HlFillEvent } from './hl-user-stream.js';
|
|
3
|
+
/** Inverse of shared `toHyperliquidCoin`: every HL perp is USDC-quoted, so the
|
|
4
|
+
* canonical symbol is `<coin>/USDC` with the coin's case preserved (kPEPE). */
|
|
5
|
+
export declare function hlCoinToCanonical(coin: string): string;
|
|
6
|
+
/**
|
|
7
|
+
* Map one HL fill to the shared FillEvent contract.
|
|
8
|
+
*
|
|
9
|
+
* Returns null (with a warn) when the identity fields the audit trail's
|
|
10
|
+
* idempotency key needs are absent or garbled — a row we cannot key must be
|
|
11
|
+
* dropped, never fabricated. `exchange` MUST come from
|
|
12
|
+
* `fillExchangeId('hyperliquid')` (FILL_EXCHANGE_ID strings are frozen once
|
|
13
|
+
* rows exist) — this mapper trusts the wiring, it does not mint literals.
|
|
14
|
+
*/
|
|
15
|
+
export declare function hlFillToFillEvent(fill: Partial<HlFillEvent>, wiring: {
|
|
16
|
+
userId: string;
|
|
17
|
+
exchange: string;
|
|
18
|
+
}, source: 'ws' | 'rest_reconcile'): FillEvent | null;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// Per-fill audit-trail ingest for the Hyperliquid venue (audit 2026-07-26 F26).
|
|
2
|
+
//
|
|
3
|
+
// Binance fills reach the `trades` table via ws-ingest (ORDER_TRADE_UPDATE →
|
|
4
|
+
// FillEvent → TradeStoreClient). HL's user stream had no equivalent: fills
|
|
5
|
+
// drove bracket wiring and were then DROPPED — a live book and P&L with no
|
|
6
|
+
// independent per-trade record, nothing to reconcile against when a number
|
|
7
|
+
// looks wrong, and fills during a WS gap lost forever. This module is the HL
|
|
8
|
+
// analog: a pure mapper from the WS fill shape (also satisfied by REST
|
|
9
|
+
// `userFillsByTime` rows) to the shared FillEvent contract. The adapter calls
|
|
10
|
+
// it from the WS hot path and from the reconnect gap backfill; idempotency is
|
|
11
|
+
// the server-side (exchange, exchange_trade_id) upsert, so double-delivery
|
|
12
|
+
// between the two paths is harmless by design.
|
|
13
|
+
import { logger } from '../../logger.js';
|
|
14
|
+
const TAG = 'hl-fill-ingest';
|
|
15
|
+
/** Inverse of shared `toHyperliquidCoin`: every HL perp is USDC-quoted, so the
|
|
16
|
+
* canonical symbol is `<coin>/USDC` with the coin's case preserved (kPEPE). */
|
|
17
|
+
export function hlCoinToCanonical(coin) {
|
|
18
|
+
return `${coin}/USDC`;
|
|
19
|
+
}
|
|
20
|
+
const num = (v) => {
|
|
21
|
+
const n = typeof v === 'string' ? Number(v) : typeof v === 'number' ? v : Number.NaN;
|
|
22
|
+
return Number.isFinite(n) ? n : undefined;
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* Map one HL fill to the shared FillEvent contract.
|
|
26
|
+
*
|
|
27
|
+
* Returns null (with a warn) when the identity fields the audit trail's
|
|
28
|
+
* idempotency key needs are absent or garbled — a row we cannot key must be
|
|
29
|
+
* dropped, never fabricated. `exchange` MUST come from
|
|
30
|
+
* `fillExchangeId('hyperliquid')` (FILL_EXCHANGE_ID strings are frozen once
|
|
31
|
+
* rows exist) — this mapper trusts the wiring, it does not mint literals.
|
|
32
|
+
*/
|
|
33
|
+
export function hlFillToFillEvent(fill, wiring, source) {
|
|
34
|
+
const price = num(fill.px);
|
|
35
|
+
const quantity = num(fill.sz);
|
|
36
|
+
const time = num(fill.time);
|
|
37
|
+
if (typeof fill.tid !== 'number' || !Number.isFinite(fill.tid) ||
|
|
38
|
+
typeof fill.oid !== 'number' || !Number.isFinite(fill.oid) ||
|
|
39
|
+
typeof fill.coin !== 'string' || fill.coin.length === 0 ||
|
|
40
|
+
price === undefined || quantity === undefined || quantity <= 0 ||
|
|
41
|
+
time === undefined ||
|
|
42
|
+
(fill.side !== 'A' && fill.side !== 'B')) {
|
|
43
|
+
// Log the DIAGNOSIS (which identity fields failed + what shape arrived),
|
|
44
|
+
// never the raw payload — fills carry account trading data that doesn't
|
|
45
|
+
// belong in journals.
|
|
46
|
+
const bad = [];
|
|
47
|
+
if (typeof fill.tid !== 'number' || !Number.isFinite(fill.tid))
|
|
48
|
+
bad.push('tid');
|
|
49
|
+
if (typeof fill.oid !== 'number' || !Number.isFinite(fill.oid))
|
|
50
|
+
bad.push('oid');
|
|
51
|
+
if (typeof fill.coin !== 'string' || fill.coin.length === 0)
|
|
52
|
+
bad.push('coin');
|
|
53
|
+
if (price === undefined)
|
|
54
|
+
bad.push('px');
|
|
55
|
+
if (quantity === undefined || quantity <= 0)
|
|
56
|
+
bad.push('sz');
|
|
57
|
+
if (time === undefined)
|
|
58
|
+
bad.push('time');
|
|
59
|
+
if (fill.side !== 'A' && fill.side !== 'B')
|
|
60
|
+
bad.push('side');
|
|
61
|
+
logger.warn(TAG, `Dropping unmappable HL fill (source=${source}): invalid=[${bad.join(',')}] ` +
|
|
62
|
+
`keys=[${Object.keys(fill).join(',')}]`);
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
exchange: wiring.exchange,
|
|
67
|
+
exchangeTradeId: String(fill.tid),
|
|
68
|
+
exchangeOrderId: String(fill.oid),
|
|
69
|
+
clientOrderId: typeof fill.cloid === 'string' && fill.cloid.length > 0 ? fill.cloid : undefined,
|
|
70
|
+
source,
|
|
71
|
+
userId: wiring.userId,
|
|
72
|
+
symbol: hlCoinToCanonical(fill.coin),
|
|
73
|
+
side: fill.side === 'B' ? 'BUY' : 'SELL',
|
|
74
|
+
quantity,
|
|
75
|
+
price,
|
|
76
|
+
// HL `crossed` is the taker flag; maker is its inverse. Absent → unknown.
|
|
77
|
+
maker: typeof fill.crossed === 'boolean' ? !fill.crossed : undefined,
|
|
78
|
+
// `fee` is inclusive of builderFee, and we hard-disable the builder fee
|
|
79
|
+
// (options.builderFee:false, pinned by hl-private.test.ts) so this is the
|
|
80
|
+
// whole commission either way. HL fees settle in USDC unless feeToken says
|
|
81
|
+
// otherwise.
|
|
82
|
+
commission: num(fill.fee),
|
|
83
|
+
commissionAsset: typeof fill.feeToken === 'string' ? fill.feeToken : 'USDC',
|
|
84
|
+
realizedPnl: num(fill.closedPnl),
|
|
85
|
+
exchangeTime: time,
|
|
86
|
+
rawPayload: fill,
|
|
87
|
+
};
|
|
88
|
+
}
|