@reefclaw/openclaw-plugin 0.1.1 → 0.1.3
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/bridge.d.ts +28 -1
- package/bridge/bridge.js +145 -7
- package/bridge/utils/skill-version.d.ts +9 -0
- package/bridge/utils/skill-version.js +45 -1
- package/ccxt/binance-ban-gate.js +12 -1
- package/ccxt/binance-public.d.ts +15 -0
- package/ccxt/binance-public.js +34 -1
- package/config/agent-config-client.d.ts +22 -0
- package/config/agent-config-client.js +44 -1
- package/config/agent-config-poller.d.ts +8 -1
- package/config/agent-config-poller.js +1 -0
- package/config/entitlement-gate.d.ts +51 -0
- package/config/entitlement-gate.js +137 -0
- package/index.js +54 -23
- package/ingest/pending-entry-metadata.d.ts +31 -9
- package/ingest/pending-entry-metadata.js +70 -16
- package/ingest/position-auto-capture.js +14 -3
- package/ingest/readiness-reporter.d.ts +19 -0
- package/ingest/readiness-reporter.js +142 -0
- package/live/exchange-info-cache.d.ts +3 -1
- package/live/exchange-info-cache.js +17 -2
- package/package.json +1 -1
- package/signals/strategy-adapter.d.ts +35 -2
- package/signals/strategy-adapter.js +87 -10
- package/skills/reefclaw/SKILL.md +6 -0
- package/tools/create-order.js +26 -20
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// EntitlementGate — the plugin-side enforcement point for account-level
|
|
2
|
+
// (billing) restrictions: when the server-resolved entitlement says the user
|
|
3
|
+
// has no paid access (trial expired past its end, subscription lapsed past
|
|
4
|
+
// the 7-day grace), NEW ENTRIES pause. Everything else keeps working.
|
|
5
|
+
//
|
|
6
|
+
// ★ SAFETY ANALYSIS (why this can never add risk to an existing book):
|
|
7
|
+
// the gate blocks ONLY the names in ENTITLEMENT_GATED_TOOLS — today exactly
|
|
8
|
+
// `create_order`, the one tool that ADDS risk and the one thing our servers
|
|
9
|
+
// cannot 403 (it goes plugin → Binance directly). Every risk-REDUCING and
|
|
10
|
+
// safety path (close_position, modify_stop, attach_brackets,
|
|
11
|
+
// audit_bracket_protection, record_position_reviews, the operator-control
|
|
12
|
+
// tools, kill/flatten/pause via the skill) is untouched by construction: it
|
|
13
|
+
// is not in the gated set, and a pin test asserts the set can never grow into
|
|
14
|
+
// the safety floor. This mirrors what an auto-pause on loss limits already
|
|
15
|
+
// does (no new entries, existing positions fully manageable) — an operation
|
|
16
|
+
// the product already considers safe.
|
|
17
|
+
//
|
|
18
|
+
// ★ THREAT MODEL HONESTY: the plugin runs on the trader's own hardware, so
|
|
19
|
+
// this gate is the COURTESY layer — it makes the agent stop cleanly with an
|
|
20
|
+
// actionable message instead of burning failed calls. The HARD enforcement is
|
|
21
|
+
// server-side: webapp Pro routes and the intelligence API both 403 on
|
|
22
|
+
// paidAccess=false, which removes the data the agent trades on.
|
|
23
|
+
//
|
|
24
|
+
// Fail direction: no entitlement data (older webapp, outage, garbage payload,
|
|
25
|
+
// no token) → ALLOW. Billing enforcement must never brick a paying customer
|
|
26
|
+
// because our config channel hiccuped; last-known-good semantics come from
|
|
27
|
+
// the agent-config cache like every other centrally-delivered value.
|
|
28
|
+
//
|
|
29
|
+
// Kill-switch: RC_ENTITLEMENT_GATE=off (never blocks) | shadow (log-only)
|
|
30
|
+
// | default enforce — same convention as RC_TOOL_GATE / RC_CENTRAL_GATES.
|
|
31
|
+
import { logger } from '../logger.js';
|
|
32
|
+
const TAG = 'entitlement-gate';
|
|
33
|
+
/** The ONLY tools an expired entitlement may pause. Deliberately a denylist
|
|
34
|
+
* (not "everything except safety"): a tool added tomorrow is un-gated by
|
|
35
|
+
* default, which is the conservative failure mode in a trading product.
|
|
36
|
+
* Intel/data tools are NOT here — the intelligence service and webapp 403
|
|
37
|
+
* those server-side with the same actionable message. */
|
|
38
|
+
export const ENTITLEMENT_GATED_TOOLS = new Set(['create_order']);
|
|
39
|
+
export function resolveEntitlementGateMode() {
|
|
40
|
+
const raw = (process.env.RC_ENTITLEMENT_GATE ?? '').toLowerCase();
|
|
41
|
+
if (raw === 'off')
|
|
42
|
+
return 'off';
|
|
43
|
+
if (raw === 'shadow')
|
|
44
|
+
return 'shadow';
|
|
45
|
+
return 'enforce';
|
|
46
|
+
}
|
|
47
|
+
/** Structured, agent-visible result for a blocked call. Worded so the agent
|
|
48
|
+
* stops cleanly (no retry loop), keeps managing the existing book, and tells
|
|
49
|
+
* the operator exactly how to restore service. */
|
|
50
|
+
export function entitlementBlockedResult(name) {
|
|
51
|
+
return {
|
|
52
|
+
ok: false,
|
|
53
|
+
error: 'entitlement_expired',
|
|
54
|
+
tool: name,
|
|
55
|
+
message: `New entries are paused: the ReefClaw subscription for this connection has expired ` +
|
|
56
|
+
`(trial ended or payment lapsed). Do not retry this call. Existing positions remain fully ` +
|
|
57
|
+
`manageable — close_position, modify_stop, attach_brackets, and all emergency/operator ` +
|
|
58
|
+
`controls work normally, and protective brackets stay enforced by the exchange. ` +
|
|
59
|
+
`Tell your operator to renew at https://reefclaw.com (Settings → Billing) to resume trading.`,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
export class EntitlementGate {
|
|
63
|
+
mode;
|
|
64
|
+
entitlement;
|
|
65
|
+
/** Avoids log spam: one line per state transition, not per blocked call. */
|
|
66
|
+
lastLoggedKey = '';
|
|
67
|
+
constructor(mode = resolveEntitlementGateMode()) {
|
|
68
|
+
this.mode = mode;
|
|
69
|
+
if (mode !== 'enforce')
|
|
70
|
+
logger.info(TAG, `mode=${mode}`);
|
|
71
|
+
}
|
|
72
|
+
getMode() {
|
|
73
|
+
return this.mode;
|
|
74
|
+
}
|
|
75
|
+
getEntitlement() {
|
|
76
|
+
return this.entitlement;
|
|
77
|
+
}
|
|
78
|
+
/** Apply the entitlement slice of a validated config (poller path).
|
|
79
|
+
* `undefined` (server omitted it / validation dropped it) CLEARS the held
|
|
80
|
+
* verdict — the gate then fails open rather than enforcing a stale one
|
|
81
|
+
* the server no longer asserts. */
|
|
82
|
+
apply(entitlement) {
|
|
83
|
+
const key = entitlement ? `${entitlement.state}:${entitlement.paidAccess}` : '(none)';
|
|
84
|
+
if (key !== this.lastLoggedKey) {
|
|
85
|
+
this.lastLoggedKey = key;
|
|
86
|
+
logger.info(TAG, entitlement
|
|
87
|
+
? `entitlement: state=${entitlement.state} paidAccess=${entitlement.paidAccess}` +
|
|
88
|
+
(entitlement.graceUntil ? ` graceUntil=${entitlement.graceUntil}` : '')
|
|
89
|
+
: 'entitlement: no server verdict — gate inactive (fail-open)');
|
|
90
|
+
}
|
|
91
|
+
this.entitlement = entitlement;
|
|
92
|
+
}
|
|
93
|
+
/** Should this call be blocked? Non-gated names short-circuit FIRST (the
|
|
94
|
+
* safety set can never be touched, in any mode, with any payload); then
|
|
95
|
+
* only an explicit server verdict of paidAccess=false blocks. */
|
|
96
|
+
isBlocked(name) {
|
|
97
|
+
if (this.mode === 'off')
|
|
98
|
+
return false;
|
|
99
|
+
if (!ENTITLEMENT_GATED_TOOLS.has(name))
|
|
100
|
+
return false;
|
|
101
|
+
if (!this.entitlement)
|
|
102
|
+
return false; // unknown → allow
|
|
103
|
+
if (this.entitlement.paidAccess !== false)
|
|
104
|
+
return false;
|
|
105
|
+
if (this.mode === 'shadow') {
|
|
106
|
+
logger.info(TAG, `SHADOW: would block ${name} (state=${this.entitlement.state})`);
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
/** Wrap a tool's execute with the gate check — same hot-apply pattern as
|
|
112
|
+
* ToolGate.wrapTool (state is consulted per call, not at registration). */
|
|
113
|
+
wrapTool(tool, jsonResult) {
|
|
114
|
+
if (!ENTITLEMENT_GATED_TOOLS.has(tool.name))
|
|
115
|
+
return tool;
|
|
116
|
+
const originalExecute = tool.execute.bind(tool);
|
|
117
|
+
return {
|
|
118
|
+
...tool,
|
|
119
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
120
|
+
execute: (...args) => {
|
|
121
|
+
if (this.isBlocked(tool.name)) {
|
|
122
|
+
logger.info(TAG, `blocked ${tool.name} (entitlement state=${this.entitlement?.state ?? 'unknown'})`);
|
|
123
|
+
return Promise.resolve(jsonResult(entitlementBlockedResult(tool.name)));
|
|
124
|
+
}
|
|
125
|
+
return originalExecute(...args);
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
/** Test-only. */
|
|
130
|
+
__reset() {
|
|
131
|
+
this.entitlement = undefined;
|
|
132
|
+
this.lastLoggedKey = '';
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
/** Module singleton — one gate per plugin process, shared by the poller
|
|
136
|
+
* (writer) and the tool-wrap in index.ts (consumer), mirroring gateStore. */
|
|
137
|
+
export const entitlementGate = new EntitlementGate();
|
package/index.js
CHANGED
|
@@ -29,6 +29,7 @@ import { PositionStateStore } from './live/position-state-store.js';
|
|
|
29
29
|
import { PendingEntryStore } from './ingest/pending-entry-metadata.js';
|
|
30
30
|
import { onReconcilerObservedClose, reconcileStateStoreOnStartup } from './ingest/reconciler-cleanup.js';
|
|
31
31
|
import { reconcileDbOpenVsExchange } from './ingest/reconcile-db-vs-exchange.js';
|
|
32
|
+
import { startReadinessReporter } from './ingest/readiness-reporter.js';
|
|
32
33
|
import { IntelMicrostructureAssembler } from './live/microstructure-assembler.js';
|
|
33
34
|
import { recordPositionReviewsTool } from './tools/record-position-reviews.js';
|
|
34
35
|
import { getMyRecentReviewsTool } from './tools/get-my-recent-reviews.js';
|
|
@@ -43,6 +44,7 @@ import { readPluginConfig, readOpenClawConnection } from './config/plugin-config
|
|
|
43
44
|
import { startConnectorSupervisor, hasBundledBridge } from './connector-supervisor.js';
|
|
44
45
|
import { ToolGate } from './config/tool-gate.js';
|
|
45
46
|
import { gateStore } from './config/gate-store.js';
|
|
47
|
+
import { entitlementGate } from './config/entitlement-gate.js';
|
|
46
48
|
import { startAgentConfigPoller } from './config/agent-config-poller.js';
|
|
47
49
|
import { PositionWatcher } from './live/stop-watcher.js';
|
|
48
50
|
import { PaperMarketFeed } from './simulator/paper-market-feed.js';
|
|
@@ -1273,31 +1275,39 @@ const paperTradingPlugin = {
|
|
|
1273
1275
|
// weight). getPositionsOrNull() returns null on a failed/weight-paced
|
|
1274
1276
|
// fetch; getPositions() would collapse that to [] (null≠empty trap).
|
|
1275
1277
|
const trusted = await liveAdapter.getPositionsOrNull();
|
|
1276
|
-
const positions = trusted ?? [];
|
|
1277
|
-
await reconcileStateStoreOnStartup({
|
|
1278
|
-
decisionsClient: positionDecisionsClient,
|
|
1279
|
-
stateStore: positionStateStore,
|
|
1280
|
-
userId: positionDecisionsUserId,
|
|
1281
|
-
lastPriceFn: (sym) => liveAdapter.getLastPrice(sym),
|
|
1282
|
-
}, positions.map((p) => p.symbol));
|
|
1283
|
-
// Seed remaining-contracts for positions still open on the exchange
|
|
1284
|
-
// so the reduce-only-fill close handler can detect flat for entries
|
|
1285
|
-
// that opened before this plugin instance started (or before the
|
|
1286
|
-
// size-tracking field existed). seedRemainingContracts never clobbers
|
|
1287
|
-
// live fill-tracked state.
|
|
1288
|
-
for (const p of positions) {
|
|
1289
|
-
const contracts = Math.abs(Number(p.contracts));
|
|
1290
|
-
if (Number.isFinite(contracts) && contracts > 0) {
|
|
1291
|
-
positionStateStore.seedRemainingContracts(p.symbol, contracts);
|
|
1292
|
-
}
|
|
1293
|
-
}
|
|
1294
|
-
// DB-authoritative orphan close: close webapp `positions` rows that
|
|
1295
|
-
// are status='open' but absent from the exchange (closed on-exchange
|
|
1296
|
-
// while down / state-store wiped). ONLY on a TRUSTED snapshot — a
|
|
1297
|
-
// null (failed) fetch must never be read as "flat" and close the book.
|
|
1298
1278
|
if (trusted !== null) {
|
|
1279
|
+
// State-store sweep ONLY on a TRUSTED snapshot: this call treats
|
|
1280
|
+
// every state-store symbol absent from the list as closed and
|
|
1281
|
+
// removes it (incl. its webappPositionId journal linkage), so
|
|
1282
|
+
// collapsing a null (failed) fetch to [] would wipe the store
|
|
1283
|
+
// for every open position on a transient boot-time 429/paced
|
|
1284
|
+
// read — the null≠empty trap, with no self-healing retry.
|
|
1285
|
+
await reconcileStateStoreOnStartup({
|
|
1286
|
+
decisionsClient: positionDecisionsClient,
|
|
1287
|
+
stateStore: positionStateStore,
|
|
1288
|
+
userId: positionDecisionsUserId,
|
|
1289
|
+
lastPriceFn: (sym) => liveAdapter.getLastPrice(sym),
|
|
1290
|
+
}, trusted.map((p) => p.symbol));
|
|
1291
|
+
// Seed remaining-contracts for positions still open on the exchange
|
|
1292
|
+
// so the reduce-only-fill close handler can detect flat for entries
|
|
1293
|
+
// that opened before this plugin instance started (or before the
|
|
1294
|
+
// size-tracking field existed). seedRemainingContracts never clobbers
|
|
1295
|
+
// live fill-tracked state.
|
|
1296
|
+
for (const p of trusted) {
|
|
1297
|
+
const contracts = Math.abs(Number(p.contracts));
|
|
1298
|
+
if (Number.isFinite(contracts) && contracts > 0) {
|
|
1299
|
+
positionStateStore.seedRemainingContracts(p.symbol, contracts);
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1302
|
+
// DB-authoritative orphan close: close webapp `positions` rows that
|
|
1303
|
+
// are status='open' but absent from the exchange (closed on-exchange
|
|
1304
|
+
// while down / state-store wiped). Same trusted-snapshot-only rule —
|
|
1305
|
+
// a null fetch must never be read as "flat" and close the book.
|
|
1299
1306
|
await reconcileDbOpenVsExchange({ decisionsClient: positionDecisionsClient, userId: positionDecisionsUserId }, trusted.map((p) => p.symbol));
|
|
1300
1307
|
}
|
|
1308
|
+
else {
|
|
1309
|
+
logger.warn(TAG, 'Startup reconciliation skipped — positions fetch untrusted (null); state-store left untouched. Runtime drift_detected + periodic reconcilers cover the gap.');
|
|
1310
|
+
}
|
|
1301
1311
|
}
|
|
1302
1312
|
catch (err) {
|
|
1303
1313
|
logger.warn(TAG, `Startup state-store reconciliation failed: ${formatError(err)}`);
|
|
@@ -1958,7 +1968,13 @@ const paperTradingPlugin = {
|
|
|
1958
1968
|
// config blob can never strip close_position/attach_brackets/etc.
|
|
1959
1969
|
// Kill-switch: RC_TOOL_GATE=off. See docs/TOOL_DISTRIBUTION_ARCHITECTURE.md §5f.
|
|
1960
1970
|
const toolGate = new ToolGate();
|
|
1961
|
-
|
|
1971
|
+
// Entitlement gate (account-level enforcement): wraps OUTSIDE the
|
|
1972
|
+
// ToolGate so an expired subscription's message wins over an operator
|
|
1973
|
+
// OFF-list message. It touches ONLY ENTITLEMENT_GATED_TOOLS (exactly
|
|
1974
|
+
// create_order — new risk); closes/stops/brackets/emergency are not in
|
|
1975
|
+
// the set by construction. No server verdict → fail-open. Kill-switch:
|
|
1976
|
+
// RC_ENTITLEMENT_GATE=off. See config/entitlement-gate.ts.
|
|
1977
|
+
const gatedTools = tools.map((t) => entitlementGate.wrapTool(toolGate.wrapTool(t, jsonResult), jsonResult));
|
|
1962
1978
|
// Poll the central config (boot + every ~60s), keyed by the same rc_
|
|
1963
1979
|
// token the ingest paths use (plugin-config connectionToken →
|
|
1964
1980
|
// WEBAPP_INGEST_TOKEN env fallback). No token → poller no-ops → gate
|
|
@@ -1966,9 +1982,11 @@ const paperTradingPlugin = {
|
|
|
1966
1982
|
// gateStore carries centrally-delivered operational gates (slice 2:
|
|
1967
1983
|
// exitGate) — loadExitGateMode() consults it before the local file;
|
|
1968
1984
|
// kill-switch RC_CENTRAL_GATES=off. See config/gate-store.ts.
|
|
1985
|
+
// entitlementGate receives the server-resolved billing verdict.
|
|
1969
1986
|
startAgentConfigPoller({
|
|
1970
1987
|
gate: toolGate,
|
|
1971
1988
|
gateStore,
|
|
1989
|
+
entitlementGate,
|
|
1972
1990
|
apiBaseUrl,
|
|
1973
1991
|
token: resolveIngestToken({ connectionToken }),
|
|
1974
1992
|
});
|
|
@@ -2003,6 +2021,19 @@ const paperTradingPlugin = {
|
|
|
2003
2021
|
pluginToolNames = toolNames;
|
|
2004
2022
|
pluginInitialised = true;
|
|
2005
2023
|
logger.info(TAG, `Registered ${gatedTools.length} tools (gate mode=${toolGate.getMode()}): ${toolNames.join(', ')}. Plugin v3.8.0 (${runtime.mode} mode)`);
|
|
2024
|
+
// Agent-readiness reporter (docs/AGENT_READINESS_GATE_PLAN.md Phase 1):
|
|
2025
|
+
// probe host→Binance reachability (HTTP 451 geo-block) + clock drift on the
|
|
2026
|
+
// host and POST a plain-English report to the webapp, so a silently-broken
|
|
2027
|
+
// agent shows an actionable dashboard alert instead of a false green.
|
|
2028
|
+
// Advisory + fire-and-forget; no token → no-op. Runs here once (guarded by
|
|
2029
|
+
// the pluginInitialised early-return → once per process) + on an unref'd
|
|
2030
|
+
// interval inside the reporter.
|
|
2031
|
+
startReadinessReporter({
|
|
2032
|
+
apiBaseUrl,
|
|
2033
|
+
token: resolveIngestToken({ connectionToken }),
|
|
2034
|
+
binanceApi,
|
|
2035
|
+
toolCount: toolNames.length,
|
|
2036
|
+
});
|
|
2006
2037
|
maybeStartConnectorSupervisor();
|
|
2007
2038
|
},
|
|
2008
2039
|
};
|
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
import type { PositionMetadata } from '../simulator/types.js';
|
|
2
2
|
export interface PendingEntry {
|
|
3
|
-
/**
|
|
3
|
+
/** Primary lookup key. Initially the client order id (the stash happens
|
|
4
|
+
* BEFORE submission, when no exchange orderId exists yet); promote()
|
|
5
|
+
* re-keys it to the exchange-issued orderId once the REST ack returns. */
|
|
4
6
|
orderId: string;
|
|
7
|
+
/** Client order id (idempotency key) — secondary lookup key. The WS
|
|
8
|
+
* ORDER_TRADE_UPDATE carries it (`o.c`), so a fill that arrives BEFORE
|
|
9
|
+
* the REST response (routine for market orders) still finds the stash. */
|
|
10
|
+
clientOrderId?: string;
|
|
5
11
|
/** Canonical (un-suffixed) symbol. */
|
|
6
12
|
symbol: string;
|
|
7
13
|
/** Side the agent intended at create_order time. */
|
|
@@ -24,25 +30,41 @@ export declare class PendingEntryStore {
|
|
|
24
30
|
private readonly now;
|
|
25
31
|
private readonly ttlMs;
|
|
26
32
|
private entries;
|
|
33
|
+
/** clientOrderId → primary key in `entries`. Lets WS fills that beat the
|
|
34
|
+
* REST ack (no exchange orderId known yet) find the stash by cid. */
|
|
35
|
+
private cidIndex;
|
|
27
36
|
constructor(opts?: PendingEntryStoreOptions);
|
|
28
|
-
/** Stash metadata until the WS fill for this
|
|
37
|
+
/** Stash metadata until the WS fill for this order arrives. Keyed by
|
|
38
|
+
* `orderId`; when `clientOrderId` is provided the entry is also findable
|
|
39
|
+
* by it (pre-submission stashes pass the cid as BOTH — see create_order). */
|
|
29
40
|
put(input: {
|
|
30
41
|
orderId: string;
|
|
42
|
+
clientOrderId?: string;
|
|
31
43
|
symbol: string;
|
|
32
44
|
side: 'buy' | 'sell';
|
|
33
45
|
metadata: PositionMetadata;
|
|
34
46
|
}): void;
|
|
35
|
-
/**
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
|
|
47
|
+
/** Re-key a pre-submission stash (keyed by clientOrderId) to the
|
|
48
|
+
* exchange-issued orderId once the REST ack returns. The cid alias keeps
|
|
49
|
+
* working. No-op when the entry is gone (already consumed by a WS fill
|
|
50
|
+
* that beat the REST response, or expired). */
|
|
51
|
+
promote(fromKey: string, exchangeOrderId: string): void;
|
|
52
|
+
/** Retrieve metadata stashed for an orderId (or clientOrderId alias).
|
|
53
|
+
* Does NOT remove the entry — callers should call `consume(orderId)` to
|
|
54
|
+
* remove on first match, since multiple partial fills on the same limit
|
|
55
|
+
* order all share one orderId and the FIRST fill is the entry; subsequent
|
|
56
|
+
* are scale-ins of an already-registered position (state-store branch
|
|
57
|
+
* handles those). */
|
|
40
58
|
get(orderId: string): PendingEntry | undefined;
|
|
41
|
-
/** Atomic get + remove. Use this when
|
|
42
|
-
* to post an entry row and don't
|
|
59
|
+
/** Atomic get + remove (accepts orderId or clientOrderId). Use this when
|
|
60
|
+
* you've already consumed the metadata to post an entry row and don't
|
|
61
|
+
* want it picked up again. */
|
|
43
62
|
consume(orderId: string): PendingEntry | undefined;
|
|
44
63
|
/** Remove an entry without retrieval (e.g. on cancel). */
|
|
45
64
|
drop(orderId: string): boolean;
|
|
65
|
+
/** Resolve a lookup key that may be an orderId or a clientOrderId alias. */
|
|
66
|
+
private resolveKey;
|
|
67
|
+
private deleteEntry;
|
|
46
68
|
size(): number;
|
|
47
69
|
getFilePath(): string;
|
|
48
70
|
private pruneExpired;
|
|
@@ -33,6 +33,9 @@ export class PendingEntryStore {
|
|
|
33
33
|
now;
|
|
34
34
|
ttlMs;
|
|
35
35
|
entries = new Map();
|
|
36
|
+
/** clientOrderId → primary key in `entries`. Lets WS fills that beat the
|
|
37
|
+
* REST ack (no exchange orderId known yet) find the stash by cid. */
|
|
38
|
+
cidIndex = new Map();
|
|
36
39
|
constructor(opts) {
|
|
37
40
|
const base = resolvePluginsBaseDir(opts?.basePath);
|
|
38
41
|
this.dir = join(base, opts?.pluginId ?? DEFAULT_PLUGIN_ID);
|
|
@@ -43,45 +46,91 @@ export class PendingEntryStore {
|
|
|
43
46
|
mkdirSync(this.dir, { recursive: true });
|
|
44
47
|
this.loadOrInit();
|
|
45
48
|
}
|
|
46
|
-
/** Stash metadata until the WS fill for this
|
|
49
|
+
/** Stash metadata until the WS fill for this order arrives. Keyed by
|
|
50
|
+
* `orderId`; when `clientOrderId` is provided the entry is also findable
|
|
51
|
+
* by it (pre-submission stashes pass the cid as BOTH — see create_order). */
|
|
47
52
|
put(input) {
|
|
48
53
|
const ts = this.now();
|
|
49
54
|
this.entries.set(input.orderId, {
|
|
50
55
|
orderId: input.orderId,
|
|
56
|
+
...(input.clientOrderId ? { clientOrderId: input.clientOrderId } : {}),
|
|
51
57
|
symbol: input.symbol,
|
|
52
58
|
side: input.side,
|
|
53
59
|
metadata: input.metadata,
|
|
54
60
|
createdAt: ts,
|
|
55
61
|
expiresAt: ts + this.ttlMs,
|
|
56
62
|
});
|
|
63
|
+
if (input.clientOrderId)
|
|
64
|
+
this.cidIndex.set(input.clientOrderId, input.orderId);
|
|
57
65
|
this.persist();
|
|
58
66
|
}
|
|
59
|
-
/**
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
|
|
67
|
+
/** Re-key a pre-submission stash (keyed by clientOrderId) to the
|
|
68
|
+
* exchange-issued orderId once the REST ack returns. The cid alias keeps
|
|
69
|
+
* working. No-op when the entry is gone (already consumed by a WS fill
|
|
70
|
+
* that beat the REST response, or expired). */
|
|
71
|
+
promote(fromKey, exchangeOrderId) {
|
|
72
|
+
const key = this.resolveKey(fromKey);
|
|
73
|
+
if (key === undefined || key === exchangeOrderId)
|
|
74
|
+
return;
|
|
75
|
+
const entry = this.entries.get(key);
|
|
76
|
+
if (!entry)
|
|
77
|
+
return;
|
|
78
|
+
this.entries.delete(key);
|
|
79
|
+
entry.orderId = exchangeOrderId;
|
|
80
|
+
this.entries.set(exchangeOrderId, entry);
|
|
81
|
+
if (entry.clientOrderId)
|
|
82
|
+
this.cidIndex.set(entry.clientOrderId, exchangeOrderId);
|
|
83
|
+
this.persist();
|
|
84
|
+
}
|
|
85
|
+
/** Retrieve metadata stashed for an orderId (or clientOrderId alias).
|
|
86
|
+
* Does NOT remove the entry — callers should call `consume(orderId)` to
|
|
87
|
+
* remove on first match, since multiple partial fills on the same limit
|
|
88
|
+
* order all share one orderId and the FIRST fill is the entry; subsequent
|
|
89
|
+
* are scale-ins of an already-registered position (state-store branch
|
|
90
|
+
* handles those). */
|
|
64
91
|
get(orderId) {
|
|
65
92
|
this.pruneExpired();
|
|
66
|
-
|
|
93
|
+
const key = this.resolveKey(orderId);
|
|
94
|
+
return key === undefined ? undefined : this.entries.get(key);
|
|
67
95
|
}
|
|
68
|
-
/** Atomic get + remove. Use this when
|
|
69
|
-
* to post an entry row and don't
|
|
96
|
+
/** Atomic get + remove (accepts orderId or clientOrderId). Use this when
|
|
97
|
+
* you've already consumed the metadata to post an entry row and don't
|
|
98
|
+
* want it picked up again. */
|
|
70
99
|
consume(orderId) {
|
|
71
100
|
this.pruneExpired();
|
|
72
|
-
const
|
|
101
|
+
const key = this.resolveKey(orderId);
|
|
102
|
+
if (key === undefined)
|
|
103
|
+
return undefined;
|
|
104
|
+
const entry = this.entries.get(key);
|
|
73
105
|
if (!entry)
|
|
74
106
|
return undefined;
|
|
75
|
-
this.
|
|
107
|
+
this.deleteEntry(key, entry);
|
|
76
108
|
this.persist();
|
|
77
109
|
return entry;
|
|
78
110
|
}
|
|
79
111
|
/** Remove an entry without retrieval (e.g. on cancel). */
|
|
80
112
|
drop(orderId) {
|
|
81
|
-
const
|
|
82
|
-
if (
|
|
83
|
-
|
|
84
|
-
|
|
113
|
+
const key = this.resolveKey(orderId);
|
|
114
|
+
if (key === undefined)
|
|
115
|
+
return false;
|
|
116
|
+
const entry = this.entries.get(key);
|
|
117
|
+
if (!entry)
|
|
118
|
+
return false;
|
|
119
|
+
this.deleteEntry(key, entry);
|
|
120
|
+
this.persist();
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
/** Resolve a lookup key that may be an orderId or a clientOrderId alias. */
|
|
124
|
+
resolveKey(k) {
|
|
125
|
+
if (this.entries.has(k))
|
|
126
|
+
return k;
|
|
127
|
+
const viaCid = this.cidIndex.get(k);
|
|
128
|
+
return viaCid !== undefined && this.entries.has(viaCid) ? viaCid : undefined;
|
|
129
|
+
}
|
|
130
|
+
deleteEntry(key, entry) {
|
|
131
|
+
this.entries.delete(key);
|
|
132
|
+
if (entry.clientOrderId)
|
|
133
|
+
this.cidIndex.delete(entry.clientOrderId);
|
|
85
134
|
}
|
|
86
135
|
size() {
|
|
87
136
|
return this.entries.size;
|
|
@@ -95,7 +144,7 @@ export class PendingEntryStore {
|
|
|
95
144
|
let pruned = false;
|
|
96
145
|
for (const [orderId, entry] of this.entries) {
|
|
97
146
|
if (entry.expiresAt <= ts) {
|
|
98
|
-
this.
|
|
147
|
+
this.deleteEntry(orderId, entry);
|
|
99
148
|
pruned = true;
|
|
100
149
|
}
|
|
101
150
|
}
|
|
@@ -128,6 +177,8 @@ export class PendingEntryStore {
|
|
|
128
177
|
for (const entry of file.entries) {
|
|
129
178
|
if (entry.expiresAt > ts) {
|
|
130
179
|
this.entries.set(entry.orderId, entry);
|
|
180
|
+
if (entry.clientOrderId)
|
|
181
|
+
this.cidIndex.set(entry.clientOrderId, entry.orderId);
|
|
131
182
|
kept++;
|
|
132
183
|
}
|
|
133
184
|
else {
|
|
@@ -163,6 +214,9 @@ export class PendingEntryStore {
|
|
|
163
214
|
if (typeof e.orderId !== 'string' || e.orderId.length === 0) {
|
|
164
215
|
throw new Error('pending-entry-metadata: entry missing orderId');
|
|
165
216
|
}
|
|
217
|
+
if (e.clientOrderId !== undefined && (typeof e.clientOrderId !== 'string' || e.clientOrderId.length === 0)) {
|
|
218
|
+
throw new Error('pending-entry-metadata: invalid clientOrderId');
|
|
219
|
+
}
|
|
166
220
|
if (typeof e.symbol !== 'string' || e.symbol.length === 0) {
|
|
167
221
|
throw new Error('pending-entry-metadata: entry missing symbol');
|
|
168
222
|
}
|
|
@@ -173,6 +173,17 @@ export async function onClosePositionFilled(ctx, inputs, order) {
|
|
|
173
173
|
ctx.stateStore.remove(inputs.symbol);
|
|
174
174
|
logger.info(TAG, `close captured ${inputs.symbol} reason=${inputs.closeReason} (positionId=${stateEntry.webappPositionId.slice(0, 8)}…)`);
|
|
175
175
|
}
|
|
176
|
+
/** Look up the metadata create_order stashed for this fill. Primary key is
|
|
177
|
+
* the exchange orderId (post-REST-ack `promote()`); the clientOrderId
|
|
178
|
+
* fallback covers the routine market-order race where the WS fill arrives
|
|
179
|
+
* BEFORE the REST response, i.e. before the promote — the pre-submission
|
|
180
|
+
* stash is keyed by the cid, which the WS event carries (`o.c`). */
|
|
181
|
+
function consumePendingEntry(ctx, fill) {
|
|
182
|
+
if (!ctx.pendingEntries)
|
|
183
|
+
return undefined;
|
|
184
|
+
return (ctx.pendingEntries.consume(fill.exchangeOrderId) ??
|
|
185
|
+
(fill.clientOrderId ? ctx.pendingEntries.consume(fill.clientOrderId) : undefined));
|
|
186
|
+
}
|
|
176
187
|
export async function onWsFillObserved(ctx, fill) {
|
|
177
188
|
if (!ctx.decisionsClient || !ctx.userId || !ctx.stateStore)
|
|
178
189
|
return;
|
|
@@ -208,7 +219,7 @@ export async function onWsFillObserved(ctx, fill) {
|
|
|
208
219
|
logger.warn(TAG, `onWsFillObserved ${fill.symbol}: scale-in observed but no webappPositionId yet — skipping (parent upsert may have failed; will retry next fill)`);
|
|
209
220
|
return;
|
|
210
221
|
}
|
|
211
|
-
const pending = ctx
|
|
222
|
+
const pending = consumePendingEntry(ctx, fill);
|
|
212
223
|
const entry = buildEntryPayload({
|
|
213
224
|
positionId: stateEntry.webappPositionId,
|
|
214
225
|
isScaleIn: true,
|
|
@@ -227,8 +238,8 @@ export async function onWsFillObserved(ctx, fill) {
|
|
|
227
238
|
}
|
|
228
239
|
// No state-store entry → NEW position via WS-driven fill. Replicates the
|
|
229
240
|
// synchronous-market-order path but pulls metadata from the pending cache
|
|
230
|
-
// (which create_order populated
|
|
231
|
-
const pending = ctx
|
|
241
|
+
// (which create_order populated BEFORE submitting the order).
|
|
242
|
+
const pending = consumePendingEntry(ctx, fill);
|
|
232
243
|
ctx.stateStore.upsert({
|
|
233
244
|
symbol: fill.symbol,
|
|
234
245
|
openedAt: ts,
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type ReadinessReport } from '@reefclaw/shared';
|
|
2
|
+
import type { BinancePublicApi } from '../ccxt/binance-public.js';
|
|
3
|
+
export interface ReadinessReporterOptions {
|
|
4
|
+
apiBaseUrl: string;
|
|
5
|
+
token: string;
|
|
6
|
+
binanceApi: Pick<BinancePublicApi, 'probeReachability'>;
|
|
7
|
+
/** Number of trading tools registered (a health signal). */
|
|
8
|
+
toolCount: number;
|
|
9
|
+
fetchImpl?: typeof fetch;
|
|
10
|
+
intervalMs?: number;
|
|
11
|
+
requestTimeoutMs?: number;
|
|
12
|
+
}
|
|
13
|
+
/** Run every connect-phase check once and assemble the report. Exported for
|
|
14
|
+
* unit tests. */
|
|
15
|
+
export declare function collectReadiness(opts: Pick<ReadinessReporterOptions, 'binanceApi' | 'toolCount'>): Promise<ReadinessReport>;
|
|
16
|
+
/** Test-only — reset the singleton guard between unit tests. */
|
|
17
|
+
export declare function __resetReadinessReporterForTests(): void;
|
|
18
|
+
/** Fire the readiness report once at boot then on an unref'd interval. */
|
|
19
|
+
export declare function startReadinessReporter(opts: ReadinessReporterOptions): void;
|