@reefclaw/openclaw-plugin 0.1.6 → 0.1.8
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 +116 -31
- 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 +18 -0
- package/onboarding/runtime.js +45 -3
- package/openclaw.plugin.json +1 -1
- package/package.json +2 -2
- package/portfolio/reentry-tracker.d.ts +36 -0
- package/portfolio/reentry-tracker.js +127 -0
- package/scripts/assemble.mjs +18 -2
- 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.d.ts +7 -0
- package/tools/create-order.js +42 -3
- 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/tools/set-trading-mode.js +23 -6
- 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
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import type { PositionDecisionsClient } from './position-decisions-client.js';
|
|
2
2
|
import type { PositionStateStore } from '../live/position-state-store.js';
|
|
3
3
|
import type { CcxtOrder } from '../types.js';
|
|
4
|
+
import type { IExchangeAdapter } from '../exchange-adapter.js';
|
|
4
5
|
import type { PositionMetadata } from '../simulator/types.js';
|
|
5
6
|
import type { PendingEntryStore } from './pending-entry-metadata.js';
|
|
7
|
+
import type { ReentryTracker } from '../portfolio/reentry-tracker.js';
|
|
6
8
|
export interface AutoCaptureContext {
|
|
7
9
|
decisionsClient?: PositionDecisionsClient;
|
|
8
10
|
stateStore?: PositionStateStore;
|
|
@@ -26,7 +28,29 @@ export interface AutoCaptureContext {
|
|
|
26
28
|
* `resolveMode` this is a plain value, not a resolver. Tags journal rows
|
|
27
29
|
* (positions.exchange, migration 0058). */
|
|
28
30
|
venue?: 'binance' | 'hyperliquid';
|
|
31
|
+
/** Resolve the ACTIVE adapter at capture time (follows a runtime reconnect,
|
|
32
|
+
* same deferred-closure pattern as resolveMode). Used by the stale-state
|
|
33
|
+
* defense in onCreateOrderFilled: when the state-store claims an open
|
|
34
|
+
* position but the exchange position is exactly the just-filled quantity,
|
|
35
|
+
* the prior exposure was closed outside the journal (watcher/manual) and
|
|
36
|
+
* the mapping is STALE — stitching onto it fabricates P&L (issue #199). */
|
|
37
|
+
resolveAdapter?: () => IExchangeAdapter | undefined;
|
|
38
|
+
/** Re-entry tracker (issue #204) — every close path records the exit so
|
|
39
|
+
* scan_pairs can flag setups already traded within the current signal bar. */
|
|
40
|
+
reentryTracker?: ReentryTracker;
|
|
29
41
|
}
|
|
42
|
+
/** Engine-exact paper fill economics attached by the simulator to close-order
|
|
43
|
+
* results (`order.info.paperTrade`, issue #201). All numbers are quote-ccy. */
|
|
44
|
+
export interface PaperTradeInfo {
|
|
45
|
+
grossRealizedPnl: number;
|
|
46
|
+
netRealizedPnl: number;
|
|
47
|
+
openFee: number;
|
|
48
|
+
closeFee: number;
|
|
49
|
+
setupType?: string;
|
|
50
|
+
}
|
|
51
|
+
/** Extract the simulator's engine-exact trade economics from a close order,
|
|
52
|
+
* with strict numeric guards (absent on live orders → undefined). */
|
|
53
|
+
export declare function extractPaperTrade(order: CcxtOrder | undefined): PaperTradeInfo | undefined;
|
|
30
54
|
export interface CreateOrderInputs {
|
|
31
55
|
symbol: string;
|
|
32
56
|
side: 'buy' | 'sell';
|
|
@@ -56,6 +80,50 @@ export interface ClosePositionInputs {
|
|
|
56
80
|
* assessment from close_position.ts. Looks up the cached position UUID, posts
|
|
57
81
|
* the close decision row, and drops the local state entry. */
|
|
58
82
|
export declare function onClosePositionFilled(ctx: AutoCaptureContext, inputs: ClosePositionInputs, order: CcxtOrder): Promise<void>;
|
|
83
|
+
export interface AutoFlattenCloseInputs {
|
|
84
|
+
symbol: string;
|
|
85
|
+
/** Actual average fill price of the flatten close (resolved, never a limit). */
|
|
86
|
+
fillPrice: number;
|
|
87
|
+
fillSize: number;
|
|
88
|
+
exchangeTradeId?: string;
|
|
89
|
+
observedAtMs?: number;
|
|
90
|
+
}
|
|
91
|
+
/** Journal the close produced by an adapter-internal auto-flatten
|
|
92
|
+
* (`bracket_attach_failed`). Those flattens call `adapter.closePosition`
|
|
93
|
+
* directly, bypassing the close_position tool, so `onClosePositionFilled`
|
|
94
|
+
* never runs and the position row orphaned as status='open' (issue #196:
|
|
95
|
+
* LTC 2026-07-14, TAO 2026-07-15 — both showed 1 exchange position vs 2 in the
|
|
96
|
+
* journal). Polls briefly for the entry journal's webappPositionId (the
|
|
97
|
+
* entry's WS fill can arrive just after the flatten), then posts an idempotent
|
|
98
|
+
* close row (UNIQUE on position_id) and drops the state-store entry. Fail-open;
|
|
99
|
+
* the boot DB reconcile sweep is the backstop if the entry never journals. */
|
|
100
|
+
export declare function onAutoFlattenClose(ctx: AutoCaptureContext, inputs: AutoFlattenCloseInputs, lookup?: {
|
|
101
|
+
attempts: number;
|
|
102
|
+
intervalMs: number;
|
|
103
|
+
}): Promise<void>;
|
|
104
|
+
export interface StopWatcherCloseInputs {
|
|
105
|
+
symbol: string;
|
|
106
|
+
stopPrice: number;
|
|
107
|
+
markPrice: number;
|
|
108
|
+
/** The executed close order from adapter.closePosition (may be absent if the
|
|
109
|
+
* close resolved through a path that didn't surface it). */
|
|
110
|
+
order?: CcxtOrder;
|
|
111
|
+
}
|
|
112
|
+
/** Journal a stop-watcher auto-close (issue #199).
|
|
113
|
+
*
|
|
114
|
+
* The watcher calls `adapter.closePosition(symbol, 'stop_watcher')` directly —
|
|
115
|
+
* it never goes through the close_position tool, and paper has no WS fill
|
|
116
|
+
* stream, so before this hook every watcher close left the journal position
|
|
117
|
+
* (and the state-store mapping) alive: the next trade on the symbol was then
|
|
118
|
+
* silently dropped or stitched onto the stale position, fabricating P&L
|
|
119
|
+
* (measured 14/69 corrupted closes on the 2026-07 HL soak; worst single
|
|
120
|
+
* fabrication −$117.86 vs a real −$10.63).
|
|
121
|
+
*
|
|
122
|
+
* Contract: the engine exposure is DEFINITIVELY gone when this runs (the
|
|
123
|
+
* watcher's close resolved), so the state-store entry is always dropped —
|
|
124
|
+
* even when we can't post a close row (the periodic DB sweep then heals the
|
|
125
|
+
* orphaned DB row instead of a later close being mis-stitched). Fail-open. */
|
|
126
|
+
export declare function onStopWatcherClose(ctx: AutoCaptureContext, inputs: StopWatcherCloseInputs): Promise<void>;
|
|
59
127
|
/** WS-driven fill observed. Resolves the PR1 deferral that limit-order fills
|
|
60
128
|
* + scale-ins were silently dropped. Branches on three cases:
|
|
61
129
|
*
|
|
@@ -20,11 +20,57 @@
|
|
|
20
20
|
// §5.1 for the longer-term design).
|
|
21
21
|
import { logger } from '../logger.js';
|
|
22
22
|
import { isBracketCid } from '../live/bracket-id.js';
|
|
23
|
+
import { normalizeBracketSymbol } from '../live/bracket-ledger.js';
|
|
24
|
+
import { fillPriceFromOrder } from '../live/fill-price.js';
|
|
23
25
|
const TAG = 'position-auto-capture';
|
|
24
26
|
/** Flatness tolerance for remaining-contracts tracking. Reduce-only fills sum
|
|
25
27
|
* exactly to the position size on Binance, so any residual below this is noise
|
|
26
28
|
* / float rounding and means the position is flat. */
|
|
27
29
|
const FLAT_EPSILON = 1e-6;
|
|
30
|
+
/** Extract the simulator's engine-exact trade economics from a close order,
|
|
31
|
+
* with strict numeric guards (absent on live orders → undefined). */
|
|
32
|
+
export function extractPaperTrade(order) {
|
|
33
|
+
const raw = order?.info?.['paperTrade'];
|
|
34
|
+
if (!raw || typeof raw !== 'object')
|
|
35
|
+
return undefined;
|
|
36
|
+
const num = (v) => typeof v === 'number' && Number.isFinite(v) ? v : undefined;
|
|
37
|
+
const gross = num(raw['grossRealizedPnl']);
|
|
38
|
+
const net = num(raw['netRealizedPnl']);
|
|
39
|
+
if (gross === undefined || net === undefined)
|
|
40
|
+
return undefined;
|
|
41
|
+
return {
|
|
42
|
+
grossRealizedPnl: gross,
|
|
43
|
+
netRealizedPnl: net,
|
|
44
|
+
openFee: num(raw['openFee']) ?? 0,
|
|
45
|
+
closeFee: num(raw['closeFee']) ?? 0,
|
|
46
|
+
setupType: typeof raw['setupType'] === 'string' ? raw['setupType'] : undefined,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
/** Stale-state probe: does the exchange position for `symbol` consist entirely
|
|
50
|
+
* of the just-filled quantity? 'stale' → the tracked prior exposure no longer
|
|
51
|
+
* exists (closed outside the journal); 'active' → genuine scale-in;
|
|
52
|
+
* 'unknown' → cannot tell (null fetch etc.) — callers keep legacy behaviour. */
|
|
53
|
+
async function probeStaleStateEntry(ctx, symbol, filledQty) {
|
|
54
|
+
const adapter = ctx.resolveAdapter?.();
|
|
55
|
+
if (!adapter)
|
|
56
|
+
return 'unknown';
|
|
57
|
+
let positions;
|
|
58
|
+
try {
|
|
59
|
+
positions = await adapter.getPositionsOrNull(symbol);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return 'unknown';
|
|
63
|
+
}
|
|
64
|
+
if (positions === null)
|
|
65
|
+
return 'unknown';
|
|
66
|
+
const key = normalizeBracketSymbol(symbol);
|
|
67
|
+
const pos = positions.find((p) => normalizeBracketSymbol(p.symbol) === key);
|
|
68
|
+
const contracts = Math.abs(Number(pos?.contracts));
|
|
69
|
+
if (!pos || !Number.isFinite(contracts) || contracts <= 0)
|
|
70
|
+
return 'unknown';
|
|
71
|
+
const tolerance = Math.max(1e-9, contracts * 0.001);
|
|
72
|
+
return Math.abs(contracts - filledQty) <= tolerance ? 'stale' : 'active';
|
|
73
|
+
}
|
|
28
74
|
/** Called after a successful adapter.createOrder().
|
|
29
75
|
*
|
|
30
76
|
* For PR 1, we treat any non-zero `filled` quantity as an entry event and
|
|
@@ -44,13 +90,16 @@ export async function onCreateOrderFilled(ctx, inputs, order) {
|
|
|
44
90
|
// Limit order not yet filled — nothing to capture yet.
|
|
45
91
|
return;
|
|
46
92
|
}
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
93
|
+
// Resolve the ACTUAL average fill — never order.price (the limit). A
|
|
94
|
+
// marketable limit can fill percent-scale away from its limit, and the
|
|
95
|
+
// synchronous RESULT response sometimes omits avgPrice on a filled limit
|
|
96
|
+
// (issue #196: TAO journaled 202 vs a real 196.23 fill). The adapter
|
|
97
|
+
// re-queries to enrich order.average before we get here; if it's still
|
|
98
|
+
// unresolvable we SKIP rather than journal the wrong (limit) price — the
|
|
99
|
+
// WS-driven onWsFillObserved path re-captures the entry with the real fill.
|
|
100
|
+
const fillPrice = fillPriceFromOrder(order);
|
|
52
101
|
if (fillPrice === null) {
|
|
53
|
-
logger.warn(TAG, `onCreateOrderFilled ${inputs.symbol}:
|
|
102
|
+
logger.warn(TAG, `onCreateOrderFilled ${inputs.symbol}: fill price unresolvable (avg=${order.average ?? 'null'} cost=${order.cost} filled=${order.filled}); skipping capture (WS fill path will re-capture with the real price)`);
|
|
54
103
|
return;
|
|
55
104
|
}
|
|
56
105
|
// Need v2.10.0 metadata to build a meaningful entry row. In paper mode the
|
|
@@ -62,13 +111,57 @@ export async function onCreateOrderFilled(ctx, inputs, order) {
|
|
|
62
111
|
}
|
|
63
112
|
const positionSide = inputs.side === 'buy' ? 'long' : 'short';
|
|
64
113
|
const openedAtMs = Date.now();
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
//
|
|
114
|
+
// State-store already knows this symbol. Three cases (issue #199):
|
|
115
|
+
// STALE — the exchange position is exactly this fill, so the tracked
|
|
116
|
+
// prior exposure was closed outside the journal (stop-watcher /
|
|
117
|
+
// manual). Drop the stale mapping and journal a NEW position;
|
|
118
|
+
// stitching onto it fabricates P&L against the old entry.
|
|
119
|
+
// SCALE-IN (paper) — genuine add. Paper has no WS fill stream, so the old
|
|
120
|
+
// "deferred to v2" early-return silently dropped every paper
|
|
121
|
+
// scale-in; journal an is_scale_in entry row here instead.
|
|
122
|
+
// SCALE-IN (live) — keep deferring to onWsFillObserved (it captures
|
|
123
|
+
// scale-ins with WS-exact data; capturing here would double-post).
|
|
68
124
|
const existing = ctx.stateStore.get(inputs.symbol);
|
|
69
125
|
if (existing) {
|
|
70
|
-
|
|
71
|
-
|
|
126
|
+
const probe = await probeStaleStateEntry(ctx, inputs.symbol, filledQty);
|
|
127
|
+
if (probe === 'stale') {
|
|
128
|
+
logger.warn(TAG, `onCreateOrderFilled ${inputs.symbol}: state-store entry (openedAt=${existing.openedAt}) is STALE — ` +
|
|
129
|
+
`exchange position equals this fill (${filledQty}); prior exposure closed outside the journal. ` +
|
|
130
|
+
`Dropping stale mapping and journaling a NEW position (issue #199).`);
|
|
131
|
+
ctx.stateStore.remove(inputs.symbol);
|
|
132
|
+
// fall through to the new-position path below
|
|
133
|
+
}
|
|
134
|
+
else if (ctx.resolveMode?.() !== 'paper') {
|
|
135
|
+
logger.info(TAG, `onCreateOrderFilled ${inputs.symbol}: existing position in state-store (openedAt=${existing.openedAt}); scale-in captured by the WS fill path`);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
else if (existing.side !== positionSide) {
|
|
139
|
+
// Opposite-side paper fill on a tracked position = partial/soft flatten
|
|
140
|
+
// that bypassed close_position. Don't guess — the close paths + periodic
|
|
141
|
+
// sweep own this.
|
|
142
|
+
logger.warn(TAG, `onCreateOrderFilled ${inputs.symbol}: opposite-side fill on tracked ${existing.side} position — skipping (use close_position)`);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
else {
|
|
146
|
+
if (!existing.webappPositionId) {
|
|
147
|
+
logger.warn(TAG, `onCreateOrderFilled ${inputs.symbol}: paper scale-in but no webappPositionId yet — skipping entry row`);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
const entry = buildEntryPayload({
|
|
151
|
+
positionId: existing.webappPositionId,
|
|
152
|
+
isScaleIn: true,
|
|
153
|
+
ts: openedAtMs,
|
|
154
|
+
fillPrice,
|
|
155
|
+
fillSize: filledQty,
|
|
156
|
+
exchangeTradeId: typeof order.id === 'string' ? order.id : '',
|
|
157
|
+
metadata: md,
|
|
158
|
+
});
|
|
159
|
+
ctx.decisionsClient.postEntry(ctx.userId, entry);
|
|
160
|
+
ctx.stateStore.addOpenContracts(inputs.symbol, filledQty);
|
|
161
|
+
logger.info(TAG, `scale-in captured (paper) ${inputs.symbol} ${positionSide} ${filledQty} @ ${fillPrice} ` +
|
|
162
|
+
`(positionId=${existing.webappPositionId.slice(0, 8)}…)`);
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
72
165
|
}
|
|
73
166
|
// Local state-store first — ensures restart-survival even if the webapp POST
|
|
74
167
|
// fails on the first attempt.
|
|
@@ -77,6 +170,7 @@ export async function onCreateOrderFilled(ctx, inputs, order) {
|
|
|
77
170
|
openedAt: openedAtMs,
|
|
78
171
|
side: positionSide,
|
|
79
172
|
openedFromExchangeTradeId: typeof order.id === 'string' ? order.id : undefined,
|
|
173
|
+
setupType: md.setupType,
|
|
80
174
|
});
|
|
81
175
|
const upsert = {
|
|
82
176
|
symbol: inputs.symbol,
|
|
@@ -134,46 +228,242 @@ export async function onClosePositionFilled(ctx, inputs, order) {
|
|
|
134
228
|
const filledQty = typeof order.filled === 'number' && Number.isFinite(order.filled)
|
|
135
229
|
? order.filled
|
|
136
230
|
: 0;
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
: typeof order.price === 'number' && Number.isFinite(order.price) && order.price > 0
|
|
140
|
-
? order.price
|
|
141
|
-
: null;
|
|
231
|
+
// Actual average fill — never order.price (the limit); see fillPriceFromOrder (#196).
|
|
232
|
+
const fillPrice = fillPriceFromOrder(order);
|
|
142
233
|
if (filledQty <= 0 || fillPrice === null) {
|
|
143
234
|
logger.warn(TAG, `onClosePositionFilled ${inputs.symbol}: missing fill quantity or price; skipping capture`);
|
|
144
235
|
return;
|
|
145
236
|
}
|
|
146
237
|
const closeAtMs = Date.now();
|
|
147
|
-
// Realized PnL + R-multiple require entry-side state we don't have here for
|
|
148
|
-
// PR 1. The skill-side computation already produces these for the webapp;
|
|
149
|
-
// this auto-capture just records what the close tool input told us. Default
|
|
150
|
-
// numeric fields to 0 — webapp accepts and the operator will see the close
|
|
151
|
-
// card with zeros until the v2 ws-ingest hook fills them properly.
|
|
152
238
|
const regimeConfNormalized = typeof inputs.regimeConfidence === 'number'
|
|
153
239
|
? Math.max(0, Math.min(1, inputs.regimeConfidence / 100))
|
|
154
240
|
: 0.5;
|
|
241
|
+
// Engine-exact economics on paper (issue #201): the simulator attaches the
|
|
242
|
+
// trade's net P&L + both fee legs to the close order. Posting a non-zero
|
|
243
|
+
// realizedPnl means the webapp keeps it verbatim (its recompute only fires
|
|
244
|
+
// on the legacy all-zero path) — the journal stops being gross-of-fees.
|
|
245
|
+
const paperTrade = extractPaperTrade(order);
|
|
246
|
+
// Size-mismatch defense (issue #199): if the close fill is materially larger
|
|
247
|
+
// or smaller than the contracts tracked for THIS journal position, the close
|
|
248
|
+
// likely belongs to different exposure (state-store staleness). Flag it so
|
|
249
|
+
// the webapp close route refuses to fabricate price-based P&L/R from it.
|
|
250
|
+
const tracked = stateEntry.remainingContracts;
|
|
251
|
+
const sizeMismatch = typeof tracked === 'number' && Number.isFinite(tracked) && tracked > 0 &&
|
|
252
|
+
Math.abs(tracked - filledQty) / Math.max(tracked, filledQty) > 0.05
|
|
253
|
+
? { tracked_contracts: tracked, close_fill_size: filledQty }
|
|
254
|
+
: undefined;
|
|
255
|
+
if (sizeMismatch) {
|
|
256
|
+
logger.warn(TAG, `onClosePositionFilled ${inputs.symbol}: close fill ${filledQty} vs tracked ${tracked} contracts — ` +
|
|
257
|
+
`flagging size_mismatch (webapp will not derive price-based metrics from this close)`);
|
|
258
|
+
}
|
|
259
|
+
const closeAssessment = {
|
|
260
|
+
...inputs.closeAssessment,
|
|
261
|
+
...(paperTrade
|
|
262
|
+
? {
|
|
263
|
+
pnl_source: 'paper_engine_net',
|
|
264
|
+
pnl_gross: paperTrade.grossRealizedPnl,
|
|
265
|
+
fees: { open: paperTrade.openFee, close: paperTrade.closeFee },
|
|
266
|
+
}
|
|
267
|
+
: {}),
|
|
268
|
+
...(sizeMismatch ? { size_mismatch: sizeMismatch } : {}),
|
|
269
|
+
};
|
|
155
270
|
const close = {
|
|
156
271
|
positionId: stateEntry.webappPositionId,
|
|
157
272
|
closeAt: closeAtMs,
|
|
158
273
|
closeReason: inputs.closeReason,
|
|
159
|
-
closeAssessment
|
|
274
|
+
closeAssessment,
|
|
160
275
|
scorecardVerdict: inputs.scorecardVerdict ?? 'NO_GO',
|
|
161
276
|
confluenceScore: typeof inputs.confluenceScore === 'number' ? inputs.confluenceScore : 0,
|
|
162
277
|
regime: inputs.regime ?? 'unknown',
|
|
163
278
|
regimeConfidence: regimeConfNormalized,
|
|
164
279
|
fillPrice,
|
|
165
280
|
fillSize: filledQty,
|
|
166
|
-
realizedPnl: 0, //
|
|
281
|
+
realizedPnl: paperTrade?.netRealizedPnl ?? 0, // live: filled by ws-ingest / server
|
|
167
282
|
realizedR: 0,
|
|
168
283
|
mfeRAtClose: 0,
|
|
169
284
|
giveBackPctAtClose: 0,
|
|
170
285
|
exchangeTradeId: typeof order.id === 'string' ? order.id : undefined,
|
|
171
286
|
};
|
|
172
287
|
ctx.decisionsClient.postClose(ctx.userId, close);
|
|
288
|
+
// Re-entry indication (issue #204) — record the exit so scan_pairs can flag
|
|
289
|
+
// same-bar re-entries on this (symbol, setup).
|
|
290
|
+
ctx.reentryTracker?.recordExit({
|
|
291
|
+
symbol: inputs.symbol,
|
|
292
|
+
setupType: stateEntry.setupType ?? paperTrade?.setupType,
|
|
293
|
+
side: stateEntry.side,
|
|
294
|
+
wasLoss: paperTrade ? paperTrade.netRealizedPnl < 0 : undefined,
|
|
295
|
+
closedAtMs: closeAtMs,
|
|
296
|
+
});
|
|
173
297
|
// Drop local state — symbol can re-enter as a new position.
|
|
174
298
|
ctx.stateStore.remove(inputs.symbol);
|
|
175
299
|
logger.info(TAG, `close captured ${inputs.symbol} reason=${inputs.closeReason} (positionId=${stateEntry.webappPositionId.slice(0, 8)}…)`);
|
|
176
300
|
}
|
|
301
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
302
|
+
/** How long onAutoFlattenClose polls for the entry journal's webappPositionId
|
|
303
|
+
* before giving up (the entry's WS fill can land a beat after the flatten).
|
|
304
|
+
* 6 × 750ms ≈ 4.5s upper bound; off the trading hot path (event-handler). */
|
|
305
|
+
const AUTO_FLATTEN_LOOKUP_ATTEMPTS = 6;
|
|
306
|
+
const AUTO_FLATTEN_LOOKUP_INTERVAL_MS = 750;
|
|
307
|
+
/** Journal the close produced by an adapter-internal auto-flatten
|
|
308
|
+
* (`bracket_attach_failed`). Those flattens call `adapter.closePosition`
|
|
309
|
+
* directly, bypassing the close_position tool, so `onClosePositionFilled`
|
|
310
|
+
* never runs and the position row orphaned as status='open' (issue #196:
|
|
311
|
+
* LTC 2026-07-14, TAO 2026-07-15 — both showed 1 exchange position vs 2 in the
|
|
312
|
+
* journal). Polls briefly for the entry journal's webappPositionId (the
|
|
313
|
+
* entry's WS fill can arrive just after the flatten), then posts an idempotent
|
|
314
|
+
* close row (UNIQUE on position_id) and drops the state-store entry. Fail-open;
|
|
315
|
+
* the boot DB reconcile sweep is the backstop if the entry never journals. */
|
|
316
|
+
export async function onAutoFlattenClose(ctx, inputs, lookup = {
|
|
317
|
+
attempts: AUTO_FLATTEN_LOOKUP_ATTEMPTS,
|
|
318
|
+
intervalMs: AUTO_FLATTEN_LOOKUP_INTERVAL_MS,
|
|
319
|
+
}) {
|
|
320
|
+
if (!ctx.decisionsClient || !ctx.userId || !ctx.stateStore)
|
|
321
|
+
return;
|
|
322
|
+
if (!(inputs.fillPrice > 0) || !(inputs.fillSize > 0))
|
|
323
|
+
return;
|
|
324
|
+
// The entry may not be journaled yet: the auto-flatten fires the moment the
|
|
325
|
+
// bracket attach exhausts its retries, which can beat the entry's WS-fill
|
|
326
|
+
// capture. Poll a few times for the webappPositionId before giving up.
|
|
327
|
+
let webappPositionId;
|
|
328
|
+
for (let attempt = 0; attempt < lookup.attempts; attempt++) {
|
|
329
|
+
webappPositionId = ctx.stateStore.get(inputs.symbol)?.webappPositionId;
|
|
330
|
+
if (webappPositionId)
|
|
331
|
+
break;
|
|
332
|
+
if (attempt < lookup.attempts - 1)
|
|
333
|
+
await sleep(lookup.intervalMs);
|
|
334
|
+
}
|
|
335
|
+
if (!webappPositionId) {
|
|
336
|
+
logger.warn(TAG, `onAutoFlattenClose ${inputs.symbol}: no webappPositionId after ${lookup.attempts} attempts — ` +
|
|
337
|
+
`DB row may stay status='open' until the boot reconcile sweep (entry likely not journaled)`);
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
const close = {
|
|
341
|
+
positionId: webappPositionId,
|
|
342
|
+
closeAt: inputs.observedAtMs ?? Date.now(),
|
|
343
|
+
closeReason: 'bracket_attach_failed',
|
|
344
|
+
closeAssessment: {
|
|
345
|
+
note: 'Position auto-flattened because exchange-native bracket protection could not be ' +
|
|
346
|
+
'attached (bracket_attach_failed). Journaled from the flatten fill — no close_position ' +
|
|
347
|
+
'call (auto-flatten bypass, issue #196).',
|
|
348
|
+
observedFrom: 'auto_flatten',
|
|
349
|
+
},
|
|
350
|
+
scorecardVerdict: 'NO_GO',
|
|
351
|
+
confluenceScore: 0,
|
|
352
|
+
regime: 'unknown',
|
|
353
|
+
regimeConfidence: 0.5,
|
|
354
|
+
fillPrice: inputs.fillPrice,
|
|
355
|
+
fillSize: Math.abs(inputs.fillSize),
|
|
356
|
+
// Real close (we have the flatten fill) — not synthetic. Zeros are filled
|
|
357
|
+
// in per-metric by the webapp close route (price-based R from the pinned
|
|
358
|
+
// invalidation_price); a scratch resolves to ≈0R, honestly.
|
|
359
|
+
realizedPnl: 0,
|
|
360
|
+
realizedR: 0,
|
|
361
|
+
mfeRAtClose: 0,
|
|
362
|
+
giveBackPctAtClose: 0,
|
|
363
|
+
...(inputs.exchangeTradeId ? { exchangeTradeId: inputs.exchangeTradeId } : {}),
|
|
364
|
+
};
|
|
365
|
+
ctx.decisionsClient.postClose(ctx.userId, close);
|
|
366
|
+
const flattenState = ctx.stateStore.get(inputs.symbol);
|
|
367
|
+
ctx.reentryTracker?.recordExit({
|
|
368
|
+
symbol: inputs.symbol,
|
|
369
|
+
setupType: flattenState?.setupType,
|
|
370
|
+
side: flattenState?.side ?? 'long',
|
|
371
|
+
closedAtMs: inputs.observedAtMs ?? Date.now(),
|
|
372
|
+
});
|
|
373
|
+
ctx.stateStore.remove(inputs.symbol);
|
|
374
|
+
logger.info(TAG, `auto-flatten close captured ${inputs.symbol} @ ${inputs.fillPrice} ` +
|
|
375
|
+
`(positionId=${webappPositionId.slice(0, 8)}…)`);
|
|
376
|
+
}
|
|
377
|
+
/** Journal a stop-watcher auto-close (issue #199).
|
|
378
|
+
*
|
|
379
|
+
* The watcher calls `adapter.closePosition(symbol, 'stop_watcher')` directly —
|
|
380
|
+
* it never goes through the close_position tool, and paper has no WS fill
|
|
381
|
+
* stream, so before this hook every watcher close left the journal position
|
|
382
|
+
* (and the state-store mapping) alive: the next trade on the symbol was then
|
|
383
|
+
* silently dropped or stitched onto the stale position, fabricating P&L
|
|
384
|
+
* (measured 14/69 corrupted closes on the 2026-07 HL soak; worst single
|
|
385
|
+
* fabrication −$117.86 vs a real −$10.63).
|
|
386
|
+
*
|
|
387
|
+
* Contract: the engine exposure is DEFINITIVELY gone when this runs (the
|
|
388
|
+
* watcher's close resolved), so the state-store entry is always dropped —
|
|
389
|
+
* even when we can't post a close row (the periodic DB sweep then heals the
|
|
390
|
+
* orphaned DB row instead of a later close being mis-stitched). Fail-open. */
|
|
391
|
+
export async function onStopWatcherClose(ctx, inputs) {
|
|
392
|
+
if (!ctx.stateStore)
|
|
393
|
+
return;
|
|
394
|
+
const stateEntry = ctx.stateStore.get(inputs.symbol);
|
|
395
|
+
const paperTrade = extractPaperTrade(inputs.order);
|
|
396
|
+
const closeAtMs = Date.now();
|
|
397
|
+
// Record the exit for re-entry indication regardless of journal linkage —
|
|
398
|
+
// the engine trade happened even if the journal never knew the position.
|
|
399
|
+
ctx.reentryTracker?.recordExit({
|
|
400
|
+
symbol: inputs.symbol,
|
|
401
|
+
setupType: stateEntry?.setupType ?? paperTrade?.setupType,
|
|
402
|
+
side: stateEntry?.side ?? 'long',
|
|
403
|
+
wasLoss: paperTrade ? paperTrade.netRealizedPnl < 0 : undefined,
|
|
404
|
+
closedAtMs: closeAtMs,
|
|
405
|
+
});
|
|
406
|
+
const dropState = () => { ctx.stateStore?.remove(inputs.symbol); };
|
|
407
|
+
if (!ctx.decisionsClient || !ctx.userId) {
|
|
408
|
+
dropState();
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
if (!stateEntry?.webappPositionId) {
|
|
412
|
+
logger.warn(TAG, `onStopWatcherClose ${inputs.symbol}: no cached webappPositionId — dropping state; ` +
|
|
413
|
+
`periodic DB sweep will close any orphaned journal row`);
|
|
414
|
+
dropState();
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
const order = inputs.order;
|
|
418
|
+
const fillPrice = (order ? fillPriceFromOrder(order) : null)
|
|
419
|
+
?? (Number.isFinite(inputs.markPrice) && inputs.markPrice > 0 ? inputs.markPrice : null);
|
|
420
|
+
const filledQty = typeof order?.filled === 'number' && Number.isFinite(order.filled) && order.filled > 0
|
|
421
|
+
? order.filled
|
|
422
|
+
: (typeof stateEntry.remainingContracts === 'number' && stateEntry.remainingContracts > 0
|
|
423
|
+
? stateEntry.remainingContracts
|
|
424
|
+
: null);
|
|
425
|
+
if (fillPrice === null || filledQty === null) {
|
|
426
|
+
logger.warn(TAG, `onStopWatcherClose ${inputs.symbol}: fill data unresolvable — dropping state; ` +
|
|
427
|
+
`periodic DB sweep will close the journal row`);
|
|
428
|
+
dropState();
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
const close = {
|
|
432
|
+
positionId: stateEntry.webappPositionId,
|
|
433
|
+
closeAt: closeAtMs,
|
|
434
|
+
closeReason: 'stop_watcher',
|
|
435
|
+
closeAssessment: {
|
|
436
|
+
note: 'Position auto-closed by the stop-watcher: mark crossed the pinned stopPrice. ' +
|
|
437
|
+
'Journaled from the watcher close fill — no close_position call (issue #199).',
|
|
438
|
+
observedFrom: 'stop_watcher',
|
|
439
|
+
stop_price: inputs.stopPrice,
|
|
440
|
+
mark_price: inputs.markPrice,
|
|
441
|
+
...(paperTrade
|
|
442
|
+
? {
|
|
443
|
+
pnl_source: 'paper_engine_net',
|
|
444
|
+
pnl_gross: paperTrade.grossRealizedPnl,
|
|
445
|
+
fees: { open: paperTrade.openFee, close: paperTrade.closeFee },
|
|
446
|
+
}
|
|
447
|
+
: {}),
|
|
448
|
+
},
|
|
449
|
+
scorecardVerdict: 'NO_GO',
|
|
450
|
+
confluenceScore: 0,
|
|
451
|
+
regime: 'unknown',
|
|
452
|
+
regimeConfidence: 0.5,
|
|
453
|
+
fillPrice,
|
|
454
|
+
fillSize: Math.abs(filledQty),
|
|
455
|
+
realizedPnl: paperTrade?.netRealizedPnl ?? 0,
|
|
456
|
+
realizedR: 0,
|
|
457
|
+
mfeRAtClose: 0,
|
|
458
|
+
giveBackPctAtClose: 0,
|
|
459
|
+
...(typeof order?.id === 'string' && order.id.length > 0 ? { exchangeTradeId: order.id } : {}),
|
|
460
|
+
};
|
|
461
|
+
ctx.decisionsClient.postClose(ctx.userId, close);
|
|
462
|
+
dropState();
|
|
463
|
+
logger.info(TAG, `stop-watcher close captured ${inputs.symbol} @ ${fillPrice} ` +
|
|
464
|
+
`(positionId=${stateEntry.webappPositionId.slice(0, 8)}…, ` +
|
|
465
|
+
`pnl=${paperTrade ? paperTrade.netRealizedPnl.toFixed(4) : 'server-side'})`);
|
|
466
|
+
}
|
|
177
467
|
/** Look up the metadata create_order stashed for this fill. Primary key is
|
|
178
468
|
* the exchange orderId (post-REST-ack `promote()`); the clientOrderId
|
|
179
469
|
* fallback covers the routine market-order race where the WS fill arrives
|
|
@@ -246,6 +536,7 @@ export async function onWsFillObserved(ctx, fill) {
|
|
|
246
536
|
openedAt: ts,
|
|
247
537
|
side: fillSide,
|
|
248
538
|
openedFromExchangeTradeId: fill.exchangeOrderId,
|
|
539
|
+
setupType: pending?.metadata?.setupType,
|
|
249
540
|
});
|
|
250
541
|
const upsert = {
|
|
251
542
|
symbol: fill.symbol,
|
|
@@ -347,6 +638,13 @@ async function handleReduceOnlyExit(ctx, fill) {
|
|
|
347
638
|
exchangeTradeId: fill.exchangeTradeId,
|
|
348
639
|
};
|
|
349
640
|
ctx.decisionsClient.postClose(ctx.userId, close);
|
|
641
|
+
ctx.reentryTracker?.recordExit({
|
|
642
|
+
symbol: fill.symbol,
|
|
643
|
+
setupType: stateEntry.setupType,
|
|
644
|
+
side: stateEntry.side,
|
|
645
|
+
wasLoss: realizedPnl < 0,
|
|
646
|
+
closedAtMs: fill.exchangeTimeMs ?? Date.now(),
|
|
647
|
+
});
|
|
350
648
|
ctx.stateStore.remove(fill.symbol);
|
|
351
649
|
logger.info(TAG, `close captured (ws bracket fill) ${fill.symbol} pnl=${realizedPnl.toFixed(4)} ` +
|
|
352
650
|
`(positionId=${stateEntry.webappPositionId.slice(0, 8)}…)`);
|
|
@@ -290,8 +290,13 @@ export declare class PositionDecisionsClient {
|
|
|
290
290
|
/** DB-vs-exchange reconcile read: this tenant's open positions. Awaited.
|
|
291
291
|
* Returns null on terminal/retry-exhausted failure — the caller MUST treat
|
|
292
292
|
* null as UNKNOWN (never as "no open rows"), else a failed fetch would
|
|
293
|
-
* false-close the whole book.
|
|
294
|
-
|
|
293
|
+
* false-close the whole book. Pass `mode` to scope the read to the ACTIVE
|
|
294
|
+
* book (paper strict / live incl. legacy NULL rows) and `exchange` to scope
|
|
295
|
+
* it to the ACTIVE venue (hyperliquid strict / binance incl. legacy NULL
|
|
296
|
+
* rows) — the sweep diffs against one adapter's snapshot, so an unscoped
|
|
297
|
+
* read misreads the inactive book's/venue's rows as orphans (issue #209
|
|
298
|
+
* item 5: the same corruption class on the venue axis). */
|
|
299
|
+
getOpenPositions(userId: string, mode?: 'paper' | 'live', exchange?: 'binance' | 'hyperliquid'): Promise<OpenPositionsResponse | null>;
|
|
295
300
|
/** Read endpoint for the Phase 1 self-reflection feature. Awaited.
|
|
296
301
|
* Returns null on terminal/retry-exhausted failure (caller logs + degrades). */
|
|
297
302
|
getRecentReviews(userId: string, positionIds: string[], reviewLimit?: number): Promise<RecentReviewsResponse | null>;
|
|
@@ -53,9 +53,19 @@ export class PositionDecisionsClient {
|
|
|
53
53
|
/** DB-vs-exchange reconcile read: this tenant's open positions. Awaited.
|
|
54
54
|
* Returns null on terminal/retry-exhausted failure — the caller MUST treat
|
|
55
55
|
* null as UNKNOWN (never as "no open rows"), else a failed fetch would
|
|
56
|
-
* false-close the whole book.
|
|
57
|
-
|
|
58
|
-
|
|
56
|
+
* false-close the whole book. Pass `mode` to scope the read to the ACTIVE
|
|
57
|
+
* book (paper strict / live incl. legacy NULL rows) and `exchange` to scope
|
|
58
|
+
* it to the ACTIVE venue (hyperliquid strict / binance incl. legacy NULL
|
|
59
|
+
* rows) — the sweep diffs against one adapter's snapshot, so an unscoped
|
|
60
|
+
* read misreads the inactive book's/venue's rows as orphans (issue #209
|
|
61
|
+
* item 5: the same corruption class on the venue axis). */
|
|
62
|
+
async getOpenPositions(userId, mode, exchange) {
|
|
63
|
+
const qs = new URLSearchParams({ status: 'open' });
|
|
64
|
+
if (mode)
|
|
65
|
+
qs.set('mode', mode);
|
|
66
|
+
if (exchange)
|
|
67
|
+
qs.set('exchange', exchange);
|
|
68
|
+
return this.runGetReturning(userId, `/api/internal/positions?${qs.toString()}`);
|
|
59
69
|
}
|
|
60
70
|
/** Read endpoint for the Phase 1 self-reflection feature. Awaited.
|
|
61
71
|
* Returns null on terminal/retry-exhausted failure (caller logs + degrades). */
|
|
@@ -2,6 +2,16 @@ import type { PositionDecisionsClient } from './position-decisions-client.js';
|
|
|
2
2
|
export interface DbVsExchangeContext {
|
|
3
3
|
decisionsClient?: PositionDecisionsClient;
|
|
4
4
|
userId?: string;
|
|
5
|
+
/** Active trading book. Scopes the DB read so open rows from the INACTIVE
|
|
6
|
+
* book — which can never appear in this adapter's snapshot — are not
|
|
7
|
+
* misread as orphans and synthetically closed (cross-book corruption after
|
|
8
|
+
* a set_trading_mode switch with positions open). Absent → unscoped
|
|
9
|
+
* (legacy callers). */
|
|
10
|
+
resolveMode?: () => 'paper' | 'live';
|
|
11
|
+
/** Active venue (issue #209 item 5) — the same orphan-misread class on the
|
|
12
|
+
* exchange axis: a tenant running two venues has open rows the OTHER
|
|
13
|
+
* venue's snapshot can never contain. Absent → unscoped (legacy). */
|
|
14
|
+
resolveExchange?: () => 'binance' | 'hyperliquid';
|
|
5
15
|
}
|
|
6
16
|
/**
|
|
7
17
|
* Close webapp `positions` rows that are status='open' but absent from the
|
|
@@ -10,4 +20,32 @@ export interface DbVsExchangeContext {
|
|
|
10
20
|
* @param exchangeSymbols symbols from a TRUSTED snapshot (getPositionsOrNull()
|
|
11
21
|
* !== null). Pass only when the fetch genuinely succeeded.
|
|
12
22
|
*/
|
|
13
|
-
export
|
|
23
|
+
export interface DbReconcileOptions {
|
|
24
|
+
/** Provenance tag written into closeAssessment.source (default boot sweep). */
|
|
25
|
+
source?: string;
|
|
26
|
+
/** Attribution hook (issue #203): given an orphaned symbol, return a short
|
|
27
|
+
* human-readable description of what the execution engine last knew about
|
|
28
|
+
* it (e.g. the simulator's last trade). Logged with the synthetic close so
|
|
29
|
+
* chronic capture misses can be classified instead of perpetually healed. */
|
|
30
|
+
describeLastExit?: (symbol: string) => string | undefined;
|
|
31
|
+
}
|
|
32
|
+
export declare function reconcileDbOpenVsExchange(ctx: DbVsExchangeContext, exchangeSymbols: Iterable<string>, nowMs?: number, opts?: DbReconcileOptions): Promise<number>;
|
|
33
|
+
export declare const DEFAULT_DB_RECONCILE_INTERVAL_MS = 300000;
|
|
34
|
+
export interface PeriodicDbReconcileDeps extends DbVsExchangeContext {
|
|
35
|
+
/** Resolve the ACTIVE adapter each tick (follows runtime reconnects). */
|
|
36
|
+
resolveAdapter: () => {
|
|
37
|
+
getPositionsOrNull(symbol?: string): Promise<Array<{
|
|
38
|
+
symbol: string;
|
|
39
|
+
}> | null>;
|
|
40
|
+
} | undefined;
|
|
41
|
+
describeLastExit?: (symbol: string) => string | undefined;
|
|
42
|
+
}
|
|
43
|
+
export interface PeriodicDbReconcileHandle {
|
|
44
|
+
stop(): void;
|
|
45
|
+
/** One sweep cycle — exposed for tests (no setInterval). */
|
|
46
|
+
runOnce(): Promise<number>;
|
|
47
|
+
}
|
|
48
|
+
/** Resolve the sweep interval from RC_DB_RECONCILE_INTERVAL_MS.
|
|
49
|
+
* 'off' or '0' disables (returns null); values are clamped to ≥60s. */
|
|
50
|
+
export declare function resolveDbReconcileIntervalMs(raw?: string | undefined): number | null;
|
|
51
|
+
export declare function startPeriodicDbReconcile(deps: PeriodicDbReconcileDeps, intervalMs?: number | null): PeriodicDbReconcileHandle | null;
|