@reefclaw/openclaw-plugin 0.1.0
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/.gitignore +8 -0
- package/audit/mode-transition-audit.d.ts +11 -0
- package/audit/mode-transition-audit.js +29 -0
- package/balance-utils.d.ts +36 -0
- package/balance-utils.js +98 -0
- package/bridge/bridge.d.ts +109 -0
- package/bridge/bridge.js +1036 -0
- package/bridge/config.d.ts +43 -0
- package/bridge/config.js +176 -0
- package/bridge/connector.d.ts +52 -0
- package/bridge/connector.js +293 -0
- package/bridge/event-replay-buffer.d.ts +43 -0
- package/bridge/event-replay-buffer.js +109 -0
- package/bridge/gateway/event-parser.d.ts +209 -0
- package/bridge/gateway/event-parser.js +793 -0
- package/bridge/gateway/gateway-config.d.ts +39 -0
- package/bridge/gateway/gateway-config.js +86 -0
- package/bridge/gateway/gateway-http-client.d.ts +50 -0
- package/bridge/gateway/gateway-http-client.js +165 -0
- package/bridge/gateway/gateway-ws-client.d.ts +116 -0
- package/bridge/gateway/gateway-ws-client.js +417 -0
- package/bridge/gateway/poller.d.ts +146 -0
- package/bridge/gateway/poller.js +505 -0
- package/bridge/gateway/tool-discovery.d.ts +25 -0
- package/bridge/gateway/tool-discovery.js +198 -0
- package/bridge/index.d.ts +2 -0
- package/bridge/index.js +253 -0
- package/bridge/logger.d.ts +2 -0
- package/bridge/logger.js +2 -0
- package/bridge/provider.d.ts +156 -0
- package/bridge/provider.js +2 -0
- package/bridge/providers/emergency-commands.d.ts +54 -0
- package/bridge/providers/emergency-commands.js +235 -0
- package/bridge/providers/gateway.d.ts +322 -0
- package/bridge/providers/gateway.js +2302 -0
- package/bridge/providers/mock.d.ts +37 -0
- package/bridge/providers/mock.js +385 -0
- package/bridge/providers/onboarding-commands.d.ts +83 -0
- package/bridge/providers/onboarding-commands.js +213 -0
- package/bridge/providers/risk-calculator.d.ts +96 -0
- package/bridge/providers/risk-calculator.js +369 -0
- package/bridge/setup.d.ts +32 -0
- package/bridge/setup.js +226 -0
- package/bridge/types.d.ts +584 -0
- package/bridge/types.js +50 -0
- package/bridge/utils/reconnect.d.ts +6 -0
- package/bridge/utils/reconnect.js +6 -0
- package/bridge/utils/skill-signing.d.ts +51 -0
- package/bridge/utils/skill-signing.js +138 -0
- package/bridge/utils/skill-version.d.ts +17 -0
- package/bridge/utils/skill-version.js +74 -0
- package/ccxt/binance-ban-gate.d.ts +47 -0
- package/ccxt/binance-ban-gate.js +409 -0
- package/ccxt/binance-private.d.ts +325 -0
- package/ccxt/binance-private.js +1415 -0
- package/ccxt/binance-public.d.ts +18 -0
- package/ccxt/binance-public.js +147 -0
- package/config/agent-config-client.d.ts +55 -0
- package/config/agent-config-client.js +145 -0
- package/config/agent-config-poller.d.ts +25 -0
- package/config/agent-config-poller.js +100 -0
- package/config/brackets-config.d.ts +22 -0
- package/config/brackets-config.js +58 -0
- package/config/gate-store.d.ts +18 -0
- package/config/gate-store.js +61 -0
- package/config/plugin-config-io.d.ts +181 -0
- package/config/plugin-config-io.js +84 -0
- package/config/position-review-config.d.ts +35 -0
- package/config/position-review-config.js +105 -0
- package/config/tool-gate.d.ts +53 -0
- package/config/tool-gate.js +125 -0
- package/config/user-data-stream-config.d.ts +85 -0
- package/config/user-data-stream-config.js +224 -0
- package/connector-supervisor.d.ts +36 -0
- package/connector-supervisor.js +149 -0
- package/exchange-adapter.d.ts +49 -0
- package/exchange-adapter.js +4 -0
- package/index.d.ts +30 -0
- package/index.js +2030 -0
- package/ingest/pending-entry-metadata.d.ts +52 -0
- package/ingest/pending-entry-metadata.js +182 -0
- package/ingest/position-auto-capture.d.ts +98 -0
- package/ingest/position-auto-capture.js +394 -0
- package/ingest/position-decisions-client.d.ts +318 -0
- package/ingest/position-decisions-client.js +296 -0
- package/ingest/reconcile-db-vs-exchange.d.ts +13 -0
- package/ingest/reconcile-db-vs-exchange.js +114 -0
- package/ingest/reconciler-cleanup.d.ts +37 -0
- package/ingest/reconciler-cleanup.js +147 -0
- package/ingest/rest-gap-filler.d.ts +191 -0
- package/ingest/rest-gap-filler.js +565 -0
- package/ingest/touched-symbols-store.d.ts +25 -0
- package/ingest/touched-symbols-store.js +96 -0
- package/ingest/trade-store-client.d.ts +40 -0
- package/ingest/trade-store-client.js +125 -0
- package/ingest/ws-ingest.d.ts +43 -0
- package/ingest/ws-ingest.js +126 -0
- package/learning/setup-family.d.ts +21 -0
- package/learning/setup-family.js +103 -0
- package/lifecycle/install-signal-handlers.d.ts +33 -0
- package/lifecycle/install-signal-handlers.js +112 -0
- package/lifecycle/shutdown-coordinator.d.ts +43 -0
- package/lifecycle/shutdown-coordinator.js +131 -0
- package/live/bracket-id.d.ts +18 -0
- package/live/bracket-id.js +81 -0
- package/live/bracket-ledger.d.ts +54 -0
- package/live/bracket-ledger.js +267 -0
- package/live/bracket-manager.d.ts +82 -0
- package/live/bracket-manager.js +478 -0
- package/live/bracket-params.d.ts +22 -0
- package/live/bracket-params.js +124 -0
- package/live/bracket-reconciler.d.ts +95 -0
- package/live/bracket-reconciler.js +573 -0
- package/live/bracket-types.d.ts +102 -0
- package/live/bracket-types.js +8 -0
- package/live/deposit-tracker.d.ts +62 -0
- package/live/deposit-tracker.js +97 -0
- package/live/emergency-controls.d.ts +32 -0
- package/live/emergency-controls.js +226 -0
- package/live/exchange-errors.d.ts +12 -0
- package/live/exchange-errors.js +130 -0
- package/live/exchange-info-cache.d.ts +35 -0
- package/live/exchange-info-cache.js +119 -0
- package/live/fact-subscriber.d.ts +78 -0
- package/live/fact-subscriber.js +182 -0
- package/live/intent-journal.d.ts +42 -0
- package/live/intent-journal.js +122 -0
- package/live/listen-key-manager.d.ts +70 -0
- package/live/listen-key-manager.js +169 -0
- package/live/live-adapter.d.ts +264 -0
- package/live/live-adapter.js +1665 -0
- package/live/live-balance-enricher.d.ts +32 -0
- package/live/live-balance-enricher.js +104 -0
- package/live/live-bracket-api.d.ts +13 -0
- package/live/live-bracket-api.js +20 -0
- package/live/live-state-store.d.ts +194 -0
- package/live/live-state-store.js +450 -0
- package/live/local-signal-service.d.ts +57 -0
- package/live/local-signal-service.js +146 -0
- package/live/local-strategy-evaluator.d.ts +62 -0
- package/live/local-strategy-evaluator.js +127 -0
- package/live/microstructure-assembler.d.ts +54 -0
- package/live/microstructure-assembler.js +148 -0
- package/live/order-poller.d.ts +29 -0
- package/live/order-poller.js +125 -0
- package/live/position-state-store.d.ts +83 -0
- package/live/position-state-store.js +237 -0
- package/live/proposal-decision-listener.d.ts +64 -0
- package/live/proposal-decision-listener.js +288 -0
- package/live/proposal-manager.d.ts +76 -0
- package/live/proposal-manager.js +140 -0
- package/live/rate-limiter.d.ts +47 -0
- package/live/rate-limiter.js +159 -0
- package/live/reconciler.d.ts +39 -0
- package/live/reconciler.js +175 -0
- package/live/setup-buckets.d.ts +7 -0
- package/live/setup-buckets.js +33 -0
- package/live/slippage-tracker.d.ts +45 -0
- package/live/slippage-tracker.js +78 -0
- package/live/stop-watcher.d.ts +34 -0
- package/live/stop-watcher.js +158 -0
- package/live/user-data-active-probe.d.ts +54 -0
- package/live/user-data-active-probe.js +180 -0
- package/live/user-data-stream-controller.d.ts +200 -0
- package/live/user-data-stream-controller.js +579 -0
- package/live/user-data-stream-ws.d.ts +22 -0
- package/live/user-data-stream-ws.js +63 -0
- package/live/user-data-stream.d.ts +243 -0
- package/live/user-data-stream.js +704 -0
- package/logger.d.ts +2 -0
- package/logger.js +2 -0
- package/mfe.d.ts +21 -0
- package/mfe.js +68 -0
- package/onboarding/mode-ladder.d.ts +1 -0
- package/onboarding/mode-ladder.js +3 -0
- package/onboarding/runtime.d.ts +71 -0
- package/onboarding/runtime.js +153 -0
- package/openclaw.plugin.json +94 -0
- package/package.json +27 -0
- package/paper-adapter.d.ts +24 -0
- package/paper-adapter.js +91 -0
- package/persistence/state-manager.d.ts +42 -0
- package/persistence/state-manager.js +164 -0
- package/pinned-plan.d.ts +9 -0
- package/pinned-plan.js +23 -0
- package/risk/pre-trade-check.d.ts +38 -0
- package/risk/pre-trade-check.js +345 -0
- package/risk/pre-trade-types.d.ts +60 -0
- package/risk/pre-trade-types.js +3 -0
- package/scripts/assemble.mjs +114 -0
- package/shadow/shadow-tracker.d.ts +36 -0
- package/shadow/shadow-tracker.js +151 -0
- package/shadow/types.d.ts +42 -0
- package/shadow/types.js +20 -0
- package/shared/indicators-extended.d.ts +52 -0
- package/shared/indicators-extended.js +291 -0
- package/shared/indicators.d.ts +15 -0
- package/shared/indicators.js +114 -0
- package/signals/conditions/registry.d.ts +16 -0
- package/signals/conditions/registry.js +1274 -0
- package/signals/conditions/types.d.ts +1 -0
- package/signals/conditions/types.js +4 -0
- package/signals/direction-rules.d.ts +3 -0
- package/signals/direction-rules.js +24 -0
- package/signals/entry-rules.d.ts +6 -0
- package/signals/entry-rules.js +33 -0
- package/signals/serialize-context.d.ts +4 -0
- package/signals/serialize-context.js +39 -0
- package/signals/stop-rules.d.ts +3 -0
- package/signals/stop-rules.js +48 -0
- package/signals/strategy-adapter.d.ts +14 -0
- package/signals/strategy-adapter.js +122 -0
- package/signals/types.d.ts +1 -0
- package/signals/types.js +8 -0
- package/simulator/exchange-simulator.d.ts +93 -0
- package/simulator/exchange-simulator.js +684 -0
- package/simulator/fill-engine.d.ts +53 -0
- package/simulator/fill-engine.js +276 -0
- package/simulator/paper-market-feed.d.ts +26 -0
- package/simulator/paper-market-feed.js +104 -0
- package/simulator/realistic-fills.d.ts +59 -0
- package/simulator/realistic-fills.js +175 -0
- package/simulator/types.d.ts +219 -0
- package/simulator/types.js +43 -0
- package/skills/reefclaw/SKILL.md +97 -0
- package/strategy/builtin-strategies.d.ts +2 -0
- package/strategy/builtin-strategies.js +109 -0
- package/strategy/condition-registry.d.ts +3 -0
- package/strategy/condition-registry.js +153 -0
- package/strategy/evaluator.d.ts +67 -0
- package/strategy/evaluator.js +93 -0
- package/tools/assessment-validation.d.ts +118 -0
- package/tools/assessment-validation.js +415 -0
- package/tools/attach-brackets.d.ts +34 -0
- package/tools/attach-brackets.js +363 -0
- package/tools/audit-bracket-protection.d.ts +49 -0
- package/tools/audit-bracket-protection.js +527 -0
- package/tools/cancel-all-orders.d.ts +7 -0
- package/tools/cancel-all-orders.js +5 -0
- package/tools/cancel-order.d.ts +10 -0
- package/tools/cancel-order.js +14 -0
- package/tools/check-position-health.d.ts +46 -0
- package/tools/check-position-health.js +194 -0
- package/tools/clear-exchange-credentials.d.ts +24 -0
- package/tools/clear-exchange-credentials.js +70 -0
- package/tools/close-position.d.ts +22 -0
- package/tools/close-position.js +449 -0
- package/tools/create-order.d.ts +54 -0
- package/tools/create-order.js +338 -0
- package/tools/exit-gate.d.ts +58 -0
- package/tools/exit-gate.js +162 -0
- package/tools/fetch-balance.d.ts +5 -0
- package/tools/fetch-balance.js +4 -0
- package/tools/fetch-ohlcv.d.ts +11 -0
- package/tools/fetch-ohlcv.js +8 -0
- package/tools/fetch-open-orders.d.ts +7 -0
- package/tools/fetch-open-orders.js +4 -0
- package/tools/fetch-positions.d.ts +7 -0
- package/tools/fetch-positions.js +4 -0
- package/tools/fetch-ticker.d.ts +11 -0
- package/tools/fetch-ticker.js +5 -0
- package/tools/get-agent-profile.d.ts +4 -0
- package/tools/get-agent-profile.js +6 -0
- package/tools/get-analytics.d.ts +6 -0
- package/tools/get-analytics.js +7 -0
- package/tools/get-backtest.d.ts +12 -0
- package/tools/get-backtest.js +91 -0
- package/tools/get-basis.d.ts +7 -0
- package/tools/get-basis.js +7 -0
- package/tools/get-bracket-config.d.ts +11 -0
- package/tools/get-bracket-config.js +24 -0
- package/tools/get-cascade-risk.d.ts +7 -0
- package/tools/get-cascade-risk.js +8 -0
- package/tools/get-crypto-metrics.d.ts +18 -0
- package/tools/get-crypto-metrics.js +45 -0
- package/tools/get-cvd.d.ts +6 -0
- package/tools/get-cvd.js +6 -0
- package/tools/get-divergences.d.ts +6 -0
- package/tools/get-divergences.js +6 -0
- package/tools/get-funding-context.d.ts +6 -0
- package/tools/get-funding-context.js +16 -0
- package/tools/get-liquidation-levels.d.ts +7 -0
- package/tools/get-liquidation-levels.js +7 -0
- package/tools/get-liquidation-pulse.d.ts +9 -0
- package/tools/get-liquidation-pulse.js +22 -0
- package/tools/get-market-breadth.d.ts +6 -0
- package/tools/get-market-breadth.js +8 -0
- package/tools/get-market-intel.d.ts +19 -0
- package/tools/get-market-intel.js +116 -0
- package/tools/get-market-structure.d.ts +47 -0
- package/tools/get-market-structure.js +198 -0
- package/tools/get-my-mined-patterns.d.ts +20 -0
- package/tools/get-my-mined-patterns.js +61 -0
- package/tools/get-my-proposed-learnings.d.ts +20 -0
- package/tools/get-my-proposed-learnings.js +55 -0
- package/tools/get-my-recent-reviews.d.ts +22 -0
- package/tools/get-my-recent-reviews.js +66 -0
- package/tools/get-orderbook.d.ts +21 -0
- package/tools/get-orderbook.js +32 -0
- package/tools/get-pattern-scan.d.ts +7 -0
- package/tools/get-pattern-scan.js +8 -0
- package/tools/get-regime.d.ts +6 -0
- package/tools/get-regime.js +7 -0
- package/tools/get-relevant-learnings.d.ts +21 -0
- package/tools/get-relevant-learnings.js +65 -0
- package/tools/get-resting-liquidity.d.ts +6 -0
- package/tools/get-resting-liquidity.js +11 -0
- package/tools/get-risk-scenario.d.ts +29 -0
- package/tools/get-risk-scenario.js +47 -0
- package/tools/get-risk-summary.d.ts +51 -0
- package/tools/get-risk-summary.js +118 -0
- package/tools/get-sentiment.d.ts +4 -0
- package/tools/get-sentiment.js +6 -0
- package/tools/get-session-review.d.ts +7 -0
- package/tools/get-session-review.js +8 -0
- package/tools/get-setup-detail.d.ts +7 -0
- package/tools/get-setup-detail.js +303 -0
- package/tools/get-signals.d.ts +15 -0
- package/tools/get-signals.js +54 -0
- package/tools/get-sizing.d.ts +6 -0
- package/tools/get-sizing.js +6 -0
- package/tools/get-trade-feedback.d.ts +7 -0
- package/tools/get-trade-feedback.js +8 -0
- package/tools/get-trade-flow.d.ts +7 -0
- package/tools/get-trade-flow.js +7 -0
- package/tools/get-volume-analysis.d.ts +21 -0
- package/tools/get-volume-analysis.js +74 -0
- package/tools/get-volume-profile.d.ts +7 -0
- package/tools/get-volume-profile.js +7 -0
- package/tools/helpers.d.ts +25 -0
- package/tools/helpers.js +38 -0
- package/tools/intel-api.d.ts +14 -0
- package/tools/intel-api.js +52 -0
- package/tools/intel-cache.d.ts +25 -0
- package/tools/intel-cache.js +133 -0
- package/tools/list-strategies.d.ts +7 -0
- package/tools/list-strategies.js +6 -0
- package/tools/modify-stop.d.ts +17 -0
- package/tools/modify-stop.js +81 -0
- package/tools/modify-target.d.ts +17 -0
- package/tools/modify-target.js +71 -0
- package/tools/propose-learning.d.ts +22 -0
- package/tools/propose-learning.js +65 -0
- package/tools/query-review-outcomes.d.ts +30 -0
- package/tools/query-review-outcomes.js +64 -0
- package/tools/query-trades.d.ts +21 -0
- package/tools/query-trades.js +37 -0
- package/tools/record-position-reviews.d.ts +38 -0
- package/tools/record-position-reviews.js +147 -0
- package/tools/save-strategy.d.ts +16 -0
- package/tools/save-strategy.js +46 -0
- package/tools/scan-pairs.d.ts +18 -0
- package/tools/scan-pairs.js +220 -0
- package/tools/score-setup.d.ts +31 -0
- package/tools/score-setup.js +268 -0
- package/tools/set-bracket-requirement.d.ts +18 -0
- package/tools/set-bracket-requirement.js +81 -0
- package/tools/set-exchange-credentials.d.ts +25 -0
- package/tools/set-exchange-credentials.js +80 -0
- package/tools/set-trading-mode.d.ts +26 -0
- package/tools/set-trading-mode.js +135 -0
- package/tools/test-exchange-credentials.d.ts +16 -0
- package/tools/test-exchange-credentials.js +100 -0
- package/tools/toggle-strategy.d.ts +8 -0
- package/tools/toggle-strategy.js +8 -0
- package/trading-params-cache.d.ts +26 -0
- package/trading-params-cache.js +52 -0
- package/types.d.ts +110 -0
- package/types.js +7 -0
- package/util/plugin-paths.d.ts +3 -0
- package/util/plugin-paths.js +15 -0
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
// Position state store — restart-survival for the Position Decision Journal.
|
|
2
|
+
//
|
|
3
|
+
// Tracks the minimal per-open-position state the auto-capture pipeline needs
|
|
4
|
+
// to keep the (symbol, positionOpenAt) join key stable across plugin restarts:
|
|
5
|
+
//
|
|
6
|
+
// - openedAt: authoritative timestamp of the first opening fill, used as the
|
|
7
|
+
// webapp's natural-key column. Without persistence, restart would
|
|
8
|
+
// reset every position's openedAt to "now" and break the join.
|
|
9
|
+
// - webappPositionId: the UUID returned by POST /api/internal/positions; needed
|
|
10
|
+
// so subsequent /position-{entries,reviews,closes} POSTs can
|
|
11
|
+
// reference the correct parent row.
|
|
12
|
+
// - lastReviewAt + lastVerdict: drives the stale-review gate (per
|
|
13
|
+
// POSITION_REVIEW_PLAN.md §7.4). Cheap to recompute from the
|
|
14
|
+
// webapp on boot, but persisting locally avoids a round-trip
|
|
15
|
+
// during the gate-check hot path.
|
|
16
|
+
//
|
|
17
|
+
// Storage: full JSON rewrite + atomic rename, same pattern as bracket-ledger.
|
|
18
|
+
// Path: ~/.openclaw/plugins/<pluginId>/position-state.json
|
|
19
|
+
// Symbols stored in canonical (un-suffixed) form via normalizeBracketSymbol.
|
|
20
|
+
//
|
|
21
|
+
// Recovery on boot:
|
|
22
|
+
// - If the file exists and is valid, load it.
|
|
23
|
+
// - If not, start empty. On the next position observation (live: getPositions
|
|
24
|
+
// tick; paper: state.json load) the auto-capture pipeline seeds new entries.
|
|
25
|
+
//
|
|
26
|
+
// See docs/POSITION_DECISION_JOURNAL_PLAN.md §4.5.
|
|
27
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
28
|
+
import { join } from 'node:path';
|
|
29
|
+
import { logger, formatError } from '../logger.js';
|
|
30
|
+
import { normalizeBracketSymbol } from './bracket-ledger.js';
|
|
31
|
+
import { resolvePluginsBaseDir } from '../util/plugin-paths.js';
|
|
32
|
+
const TAG = 'position-state-store';
|
|
33
|
+
const DEFAULT_PLUGIN_ID = 'reefclaw-paper-trading';
|
|
34
|
+
const STATE_FILENAME = 'position-state.json';
|
|
35
|
+
const SCHEMA_VERSION = 1;
|
|
36
|
+
const defaultNow = () => Date.now();
|
|
37
|
+
export class PositionStateStore {
|
|
38
|
+
filePath;
|
|
39
|
+
dir;
|
|
40
|
+
now;
|
|
41
|
+
entries = new Map(); // key = canonical symbol
|
|
42
|
+
constructor(pluginId, opts) {
|
|
43
|
+
const base = resolvePluginsBaseDir(opts?.basePath);
|
|
44
|
+
this.dir = join(base, pluginId ?? DEFAULT_PLUGIN_ID);
|
|
45
|
+
this.filePath = join(this.dir, STATE_FILENAME);
|
|
46
|
+
this.now = opts?.now ?? defaultNow;
|
|
47
|
+
if (!existsSync(this.dir))
|
|
48
|
+
mkdirSync(this.dir, { recursive: true });
|
|
49
|
+
this.loadOrInit();
|
|
50
|
+
}
|
|
51
|
+
/** Insert or update a position. Caller is responsible for deciding when
|
|
52
|
+
* to call this — typically on first observation of a new position. */
|
|
53
|
+
upsert(entry) {
|
|
54
|
+
const ts = this.now();
|
|
55
|
+
const symbol = normalizeBracketSymbol(entry.symbol);
|
|
56
|
+
const existing = this.entries.get(symbol);
|
|
57
|
+
const full = {
|
|
58
|
+
...entry,
|
|
59
|
+
symbol,
|
|
60
|
+
createdAt: existing?.createdAt ?? entry.createdAt ?? ts,
|
|
61
|
+
updatedAt: ts,
|
|
62
|
+
};
|
|
63
|
+
this.entries.set(symbol, full);
|
|
64
|
+
this.persist();
|
|
65
|
+
return full;
|
|
66
|
+
}
|
|
67
|
+
/** Set the webapp positionId after the upsert returns. Idempotent. */
|
|
68
|
+
setWebappId(symbol, webappPositionId) {
|
|
69
|
+
const key = normalizeBracketSymbol(symbol);
|
|
70
|
+
const current = this.entries.get(key);
|
|
71
|
+
if (!current) {
|
|
72
|
+
logger.warn(TAG, `setWebappId: no entry for ${key} — dropping ${webappPositionId}`);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (current.webappPositionId === webappPositionId)
|
|
76
|
+
return;
|
|
77
|
+
this.entries.set(key, { ...current, webappPositionId, updatedAt: this.now() });
|
|
78
|
+
this.persist();
|
|
79
|
+
}
|
|
80
|
+
/** Update review tracking after a successful record_position_reviews call.
|
|
81
|
+
* thesisStatus is optional for backwards compatibility — older callers that
|
|
82
|
+
* predate the exit-gate work pass only verdict; the gate falls back to
|
|
83
|
+
* PASS('inputs_unavailable_thesis_status') when the field isn't cached. */
|
|
84
|
+
recordReview(symbol, verdict, reviewAt, thesisStatus) {
|
|
85
|
+
const key = normalizeBracketSymbol(symbol);
|
|
86
|
+
const current = this.entries.get(key);
|
|
87
|
+
if (!current)
|
|
88
|
+
return;
|
|
89
|
+
this.entries.set(key, {
|
|
90
|
+
...current,
|
|
91
|
+
lastReviewAt: reviewAt ?? this.now(),
|
|
92
|
+
lastVerdict: verdict,
|
|
93
|
+
lastThesisStatus: thesisStatus ?? current.lastThesisStatus,
|
|
94
|
+
updatedAt: this.now(),
|
|
95
|
+
});
|
|
96
|
+
this.persist();
|
|
97
|
+
}
|
|
98
|
+
/** Add opening contracts to the running size (first opening fill or a
|
|
99
|
+
* scale-in). Initialises the realised-PnL accumulator on first open. No-op
|
|
100
|
+
* if the entry doesn't exist yet (caller upserts the entry first). */
|
|
101
|
+
addOpenContracts(symbol, qty) {
|
|
102
|
+
const key = normalizeBracketSymbol(symbol);
|
|
103
|
+
const current = this.entries.get(key);
|
|
104
|
+
if (!current || !(qty > 0))
|
|
105
|
+
return;
|
|
106
|
+
this.entries.set(key, {
|
|
107
|
+
...current,
|
|
108
|
+
remainingContracts: (current.remainingContracts ?? 0) + qty,
|
|
109
|
+
realizedPnlAccum: current.realizedPnlAccum ?? 0,
|
|
110
|
+
updatedAt: this.now(),
|
|
111
|
+
});
|
|
112
|
+
this.persist();
|
|
113
|
+
}
|
|
114
|
+
/** Seed remaining contracts from exchange truth at startup. Only sets when
|
|
115
|
+
* the size is currently unknown — never clobbers live fill-tracked state. */
|
|
116
|
+
seedRemainingContracts(symbol, qty) {
|
|
117
|
+
const key = normalizeBracketSymbol(symbol);
|
|
118
|
+
const current = this.entries.get(key);
|
|
119
|
+
if (!current || current.remainingContracts !== undefined || !(qty > 0))
|
|
120
|
+
return;
|
|
121
|
+
this.entries.set(key, {
|
|
122
|
+
...current,
|
|
123
|
+
remainingContracts: qty,
|
|
124
|
+
realizedPnlAccum: current.realizedPnlAccum ?? 0,
|
|
125
|
+
updatedAt: this.now(),
|
|
126
|
+
});
|
|
127
|
+
this.persist();
|
|
128
|
+
}
|
|
129
|
+
/** Apply a reduce-only exit fill: decrement remaining contracts and sum the
|
|
130
|
+
* fill's realised PnL. Returns the updated entry so the caller can journal a
|
|
131
|
+
* close when the position is flat, or null when there's no tracked entry or
|
|
132
|
+
* the size is unknown (can't decide flatness — defer to the backstop). */
|
|
133
|
+
applyExitContracts(symbol, qty, realizedPnl) {
|
|
134
|
+
const key = normalizeBracketSymbol(symbol);
|
|
135
|
+
const current = this.entries.get(key);
|
|
136
|
+
if (!current || current.remainingContracts === undefined || !(qty > 0))
|
|
137
|
+
return null;
|
|
138
|
+
const updated = {
|
|
139
|
+
...current,
|
|
140
|
+
remainingContracts: current.remainingContracts - qty,
|
|
141
|
+
realizedPnlAccum: (current.realizedPnlAccum ?? 0) + (Number.isFinite(realizedPnl) ? realizedPnl : 0),
|
|
142
|
+
updatedAt: this.now(),
|
|
143
|
+
};
|
|
144
|
+
this.entries.set(key, updated);
|
|
145
|
+
this.persist();
|
|
146
|
+
return updated;
|
|
147
|
+
}
|
|
148
|
+
get(symbol) {
|
|
149
|
+
return this.entries.get(normalizeBracketSymbol(symbol));
|
|
150
|
+
}
|
|
151
|
+
/** Returns all entries — used by the stale-gate to enumerate open positions. */
|
|
152
|
+
getAll() {
|
|
153
|
+
return Array.from(this.entries.values());
|
|
154
|
+
}
|
|
155
|
+
/** Drop an entry — called when the auto-capture pipeline observes the
|
|
156
|
+
* position has closed (size dropped to zero on the exchange). */
|
|
157
|
+
remove(symbol) {
|
|
158
|
+
const existed = this.entries.delete(normalizeBracketSymbol(symbol));
|
|
159
|
+
if (existed)
|
|
160
|
+
this.persist();
|
|
161
|
+
return existed;
|
|
162
|
+
}
|
|
163
|
+
size() {
|
|
164
|
+
return this.entries.size;
|
|
165
|
+
}
|
|
166
|
+
getFilePath() {
|
|
167
|
+
return this.filePath;
|
|
168
|
+
}
|
|
169
|
+
// ---- Internals ----
|
|
170
|
+
loadOrInit() {
|
|
171
|
+
if (!existsSync(this.filePath)) {
|
|
172
|
+
logger.info(TAG, `No state file at ${this.filePath}; starting empty`);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
let raw;
|
|
176
|
+
try {
|
|
177
|
+
raw = readFileSync(this.filePath, 'utf8');
|
|
178
|
+
}
|
|
179
|
+
catch (err) {
|
|
180
|
+
throw new Error(`position-state-store: failed to read ${this.filePath}: ${formatError(err)}`);
|
|
181
|
+
}
|
|
182
|
+
let parsed;
|
|
183
|
+
try {
|
|
184
|
+
parsed = JSON.parse(raw);
|
|
185
|
+
}
|
|
186
|
+
catch (err) {
|
|
187
|
+
throw new Error(`position-state-store: ${this.filePath} is not valid JSON: ${formatError(err)}`);
|
|
188
|
+
}
|
|
189
|
+
const file = this.validateFile(parsed);
|
|
190
|
+
for (const entry of file.entries) {
|
|
191
|
+
this.entries.set(entry.symbol, entry);
|
|
192
|
+
}
|
|
193
|
+
logger.info(TAG, `Loaded ${this.entries.size} entries from ${this.filePath}`);
|
|
194
|
+
}
|
|
195
|
+
persist() {
|
|
196
|
+
const file = {
|
|
197
|
+
schemaVersion: SCHEMA_VERSION,
|
|
198
|
+
entries: Array.from(this.entries.values()),
|
|
199
|
+
};
|
|
200
|
+
const tempPath = `${this.filePath}.tmp`;
|
|
201
|
+
writeFileSync(tempPath, JSON.stringify(file, null, 2));
|
|
202
|
+
renameSync(tempPath, this.filePath);
|
|
203
|
+
}
|
|
204
|
+
validateFile(raw) {
|
|
205
|
+
if (!isRecord(raw))
|
|
206
|
+
throw new Error('position-state-store: file root is not an object');
|
|
207
|
+
if (raw.schemaVersion !== SCHEMA_VERSION) {
|
|
208
|
+
throw new Error(`position-state-store: unsupported schemaVersion ${String(raw.schemaVersion)} (expected ${SCHEMA_VERSION})`);
|
|
209
|
+
}
|
|
210
|
+
if (!Array.isArray(raw.entries))
|
|
211
|
+
throw new Error('position-state-store: entries is not an array');
|
|
212
|
+
const entries = [];
|
|
213
|
+
for (const e of raw.entries)
|
|
214
|
+
entries.push(this.validateEntry(e));
|
|
215
|
+
return { schemaVersion: SCHEMA_VERSION, entries };
|
|
216
|
+
}
|
|
217
|
+
validateEntry(raw) {
|
|
218
|
+
if (!isRecord(raw))
|
|
219
|
+
throw new Error('position-state-store: entry is not an object');
|
|
220
|
+
if (typeof raw.symbol !== 'string' || raw.symbol.length === 0) {
|
|
221
|
+
throw new Error('position-state-store: entry missing symbol');
|
|
222
|
+
}
|
|
223
|
+
if (typeof raw.openedAt !== 'number' || !Number.isFinite(raw.openedAt)) {
|
|
224
|
+
throw new Error('position-state-store: entry missing/invalid openedAt');
|
|
225
|
+
}
|
|
226
|
+
if (raw.side !== 'long' && raw.side !== 'short') {
|
|
227
|
+
throw new Error(`position-state-store: bad side ${String(raw.side)}`);
|
|
228
|
+
}
|
|
229
|
+
if (typeof raw.createdAt !== 'number' || typeof raw.updatedAt !== 'number') {
|
|
230
|
+
throw new Error('position-state-store: missing createdAt/updatedAt');
|
|
231
|
+
}
|
|
232
|
+
return raw;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
function isRecord(v) {
|
|
236
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
237
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { BinancePublicApi } from '../ccxt/binance-public.js';
|
|
2
|
+
import type { IExchangeAdapter } from '../exchange-adapter.js';
|
|
3
|
+
import type { AutoCaptureContext } from '../ingest/position-auto-capture.js';
|
|
4
|
+
export interface ProposalDecisionListenerOptions {
|
|
5
|
+
baseUrl: string;
|
|
6
|
+
ingestToken: string;
|
|
7
|
+
userId: string;
|
|
8
|
+
adapter: IExchangeAdapter;
|
|
9
|
+
binanceApi: BinancePublicApi;
|
|
10
|
+
autoCapture?: AutoCaptureContext;
|
|
11
|
+
/** Active poll interval — used while there are pending proposals (or the
|
|
12
|
+
* previous tick fetched any). Default 3 s. Keeps approval-to-fire latency
|
|
13
|
+
* low when the operator is actively working through proposals. */
|
|
14
|
+
pollIntervalMs?: number;
|
|
15
|
+
/** Idle poll interval — used after a tick returns zero pending proposals.
|
|
16
|
+
* Default 15 s. Cuts the constant-warm pressure on the webapp DB by ~5x
|
|
17
|
+
* during idle stretches (no approvals queued). Reset to active on the
|
|
18
|
+
* next non-empty tick. 15 s is well within the shortest hard-expiry
|
|
19
|
+
* bucket (90 s momentum) even allowing for operator decision time. */
|
|
20
|
+
pollIntervalIdleMs?: number;
|
|
21
|
+
/** Maximum |markPrice − proposedEntry| / |proposedEntry − stopPrice|.
|
|
22
|
+
* Default 0.3 per Locked Decision #6. */
|
|
23
|
+
driftAbandonR?: number;
|
|
24
|
+
/** Per-attempt request timeout for HTTP calls to the webapp. */
|
|
25
|
+
requestTimeoutMs?: number;
|
|
26
|
+
fetchImpl?: typeof fetch;
|
|
27
|
+
}
|
|
28
|
+
export interface ProposalDecisionListenerHealth {
|
|
29
|
+
running: boolean;
|
|
30
|
+
ticksTotal: number;
|
|
31
|
+
ticksWithFires: number;
|
|
32
|
+
firesAttempted: number;
|
|
33
|
+
firesSucceeded: number;
|
|
34
|
+
firesAbandonedDrift: number;
|
|
35
|
+
firesFailedTrading: number;
|
|
36
|
+
patchFailures: number;
|
|
37
|
+
pollFailures: number;
|
|
38
|
+
lastTickAt: string | null;
|
|
39
|
+
}
|
|
40
|
+
export declare class ProposalDecisionListener {
|
|
41
|
+
private readonly opts;
|
|
42
|
+
private timer;
|
|
43
|
+
private inFlight;
|
|
44
|
+
/** Current scheduling interval. Starts at active (so the first tick after
|
|
45
|
+
* start() fires promptly even if the queue is empty); switches to idle
|
|
46
|
+
* after any tick that finds zero pending; resets to active on any tick
|
|
47
|
+
* that finds ≥1 pending. */
|
|
48
|
+
private currentIntervalMs;
|
|
49
|
+
private health;
|
|
50
|
+
constructor(options: ProposalDecisionListenerOptions);
|
|
51
|
+
/** Begin the poll loop. Idempotent — subsequent calls are no-ops while
|
|
52
|
+
* the listener is already running. */
|
|
53
|
+
start(): void;
|
|
54
|
+
/** Stop the poll loop. If a tick is in flight, awaits its completion so
|
|
55
|
+
* the PATCH back to the webapp lands before the process exits. */
|
|
56
|
+
stop(): Promise<void>;
|
|
57
|
+
getHealth(): ProposalDecisionListenerHealth;
|
|
58
|
+
private scheduleNextTick;
|
|
59
|
+
private runTick;
|
|
60
|
+
private tick;
|
|
61
|
+
private fetchPending;
|
|
62
|
+
private firePending;
|
|
63
|
+
private patchResult;
|
|
64
|
+
}
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
// ProposalDecisionListener — background poller that fires operator-approved
|
|
2
|
+
// proposals via the existing create_order backend path.
|
|
3
|
+
//
|
|
4
|
+
// Phase B implementation: polls GET /api/internal/proposed_orders/pending-decisions
|
|
5
|
+
// at config.pollIntervalMs (default 3 sec). For each approved-but-unfired row:
|
|
6
|
+
// 1. Drift abandon — if mark price has moved more than ±0.3 R from the
|
|
7
|
+
// proposed entry since the proposal was created, fail with a transparent
|
|
8
|
+
// `fireError` rather than firing into a different setup.
|
|
9
|
+
// 2. Fire via createOrderTool — same backend function the toggle-off path
|
|
10
|
+
// calls directly. Reuses the pre-trade risk gate, bracket attach, and
|
|
11
|
+
// auto-capture entry-row write.
|
|
12
|
+
// 3. PATCH /fire-result with either firedOrderId or fireError. Fired-once
|
|
13
|
+
// idempotency lives in the DB constraint (status='approved' AND
|
|
14
|
+
// fired_order_id IS NULL filter).
|
|
15
|
+
//
|
|
16
|
+
// Started by index.ts only when config.approval.mode === 'per_trade' AND
|
|
17
|
+
// ingest credentials are present. Drained at shutdown so an in-flight fire
|
|
18
|
+
// completes its PATCH before the process exits.
|
|
19
|
+
//
|
|
20
|
+
// Why polling and not relay events: simpler — no skill <-> plugin event-bus
|
|
21
|
+
// changes needed. 3-sec poll is well under the latency a human approval flow
|
|
22
|
+
// tolerates. Refactor to SSE/relay-push only if measurement shows the poll
|
|
23
|
+
// load is meaningful (very unlikely — ~3 req/s per user is negligible).
|
|
24
|
+
//
|
|
25
|
+
// See docs/APPROVAL_MODE_DESIGN.md §2 (architecture) + §7.3 (re-validation) +
|
|
26
|
+
// §10 failure matrix.
|
|
27
|
+
import { logger, formatError } from '../logger.js';
|
|
28
|
+
import { createOrderTool } from '../tools/create-order.js';
|
|
29
|
+
const TAG = 'proposal-decision-listener';
|
|
30
|
+
export class ProposalDecisionListener {
|
|
31
|
+
opts;
|
|
32
|
+
timer = null;
|
|
33
|
+
inFlight = null;
|
|
34
|
+
/** Current scheduling interval. Starts at active (so the first tick after
|
|
35
|
+
* start() fires promptly even if the queue is empty); switches to idle
|
|
36
|
+
* after any tick that finds zero pending; resets to active on any tick
|
|
37
|
+
* that finds ≥1 pending. */
|
|
38
|
+
currentIntervalMs;
|
|
39
|
+
health = {
|
|
40
|
+
running: false,
|
|
41
|
+
ticksTotal: 0,
|
|
42
|
+
ticksWithFires: 0,
|
|
43
|
+
firesAttempted: 0,
|
|
44
|
+
firesSucceeded: 0,
|
|
45
|
+
firesAbandonedDrift: 0,
|
|
46
|
+
firesFailedTrading: 0,
|
|
47
|
+
patchFailures: 0,
|
|
48
|
+
pollFailures: 0,
|
|
49
|
+
lastTickAt: null,
|
|
50
|
+
};
|
|
51
|
+
constructor(options) {
|
|
52
|
+
this.opts = {
|
|
53
|
+
baseUrl: options.baseUrl.replace(/\/+$/, ''),
|
|
54
|
+
ingestToken: options.ingestToken,
|
|
55
|
+
userId: options.userId,
|
|
56
|
+
adapter: options.adapter,
|
|
57
|
+
binanceApi: options.binanceApi,
|
|
58
|
+
autoCapture: options.autoCapture,
|
|
59
|
+
pollIntervalMs: options.pollIntervalMs ?? 3_000,
|
|
60
|
+
pollIntervalIdleMs: options.pollIntervalIdleMs ?? 15_000,
|
|
61
|
+
driftAbandonR: options.driftAbandonR ?? 0.3,
|
|
62
|
+
requestTimeoutMs: options.requestTimeoutMs ?? 10_000,
|
|
63
|
+
fetchImpl: options.fetchImpl ?? fetch,
|
|
64
|
+
};
|
|
65
|
+
this.currentIntervalMs = this.opts.pollIntervalMs;
|
|
66
|
+
}
|
|
67
|
+
/** Begin the poll loop. Idempotent — subsequent calls are no-ops while
|
|
68
|
+
* the listener is already running. */
|
|
69
|
+
start() {
|
|
70
|
+
if (this.health.running)
|
|
71
|
+
return;
|
|
72
|
+
this.health.running = true;
|
|
73
|
+
logger.info(TAG, `Listener started — polling ${this.opts.baseUrl}/api/internal/proposed_orders/pending-decisions (active ${this.opts.pollIntervalMs} ms / idle ${this.opts.pollIntervalIdleMs} ms; userId=${this.opts.userId.slice(0, 8)}…)`);
|
|
74
|
+
this.scheduleNextTick();
|
|
75
|
+
}
|
|
76
|
+
/** Stop the poll loop. If a tick is in flight, awaits its completion so
|
|
77
|
+
* the PATCH back to the webapp lands before the process exits. */
|
|
78
|
+
async stop() {
|
|
79
|
+
this.health.running = false;
|
|
80
|
+
if (this.timer) {
|
|
81
|
+
clearTimeout(this.timer);
|
|
82
|
+
this.timer = null;
|
|
83
|
+
}
|
|
84
|
+
if (this.inFlight) {
|
|
85
|
+
try {
|
|
86
|
+
await this.inFlight;
|
|
87
|
+
}
|
|
88
|
+
catch { /* tick errors are logged inside tick() */ }
|
|
89
|
+
}
|
|
90
|
+
logger.info(TAG, 'Listener stopped');
|
|
91
|
+
}
|
|
92
|
+
getHealth() {
|
|
93
|
+
return { ...this.health };
|
|
94
|
+
}
|
|
95
|
+
// ---- Internals ----
|
|
96
|
+
scheduleNextTick() {
|
|
97
|
+
if (!this.health.running)
|
|
98
|
+
return;
|
|
99
|
+
this.timer = setTimeout(() => {
|
|
100
|
+
void this.runTick();
|
|
101
|
+
}, this.currentIntervalMs);
|
|
102
|
+
}
|
|
103
|
+
async runTick() {
|
|
104
|
+
if (!this.health.running)
|
|
105
|
+
return;
|
|
106
|
+
this.inFlight = this.tick().catch((err) => {
|
|
107
|
+
logger.warn(TAG, `Tick threw (caught): ${formatError(err)}`);
|
|
108
|
+
});
|
|
109
|
+
try {
|
|
110
|
+
await this.inFlight;
|
|
111
|
+
}
|
|
112
|
+
finally {
|
|
113
|
+
this.inFlight = null;
|
|
114
|
+
this.scheduleNextTick();
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
async tick() {
|
|
118
|
+
this.health.ticksTotal++;
|
|
119
|
+
this.health.lastTickAt = new Date().toISOString();
|
|
120
|
+
const pending = await this.fetchPending();
|
|
121
|
+
if (pending.length === 0) {
|
|
122
|
+
// Idle backoff — schedule next tick at the longer idle interval until
|
|
123
|
+
// the queue becomes non-empty. This is the dominant cost-reduction lever
|
|
124
|
+
// when Approval Mode is active but the operator is not currently
|
|
125
|
+
// working through proposals. The next non-empty tick resets to active.
|
|
126
|
+
this.currentIntervalMs = this.opts.pollIntervalIdleMs;
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
// Reset to fast polling so subsequent approvals fire with minimal latency.
|
|
130
|
+
this.currentIntervalMs = this.opts.pollIntervalMs;
|
|
131
|
+
this.health.ticksWithFires++;
|
|
132
|
+
logger.info(TAG, `${pending.length} approved proposal(s) pending fire`);
|
|
133
|
+
for (const p of pending) {
|
|
134
|
+
try {
|
|
135
|
+
await this.firePending(p);
|
|
136
|
+
}
|
|
137
|
+
catch (err) {
|
|
138
|
+
// Defensive — firePending should already write a fireError PATCH on
|
|
139
|
+
// any failure path. If something escapes, surface it loudly so the
|
|
140
|
+
// operator can diagnose; the proposal will remain in approved state
|
|
141
|
+
// and be retried on the next tick (idempotent because firedOrderId
|
|
142
|
+
// is still NULL until a successful PATCH writes it).
|
|
143
|
+
logger.error(TAG, `Unhandled exception firing proposal ${p.id} (will retry next tick): ${formatError(err)}`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
async fetchPending() {
|
|
148
|
+
const url = `${this.opts.baseUrl}/api/internal/proposed_orders/pending-decisions`;
|
|
149
|
+
try {
|
|
150
|
+
const ac = new AbortController();
|
|
151
|
+
const tid = setTimeout(() => ac.abort(), this.opts.requestTimeoutMs);
|
|
152
|
+
let res;
|
|
153
|
+
try {
|
|
154
|
+
res = await this.opts.fetchImpl(url, {
|
|
155
|
+
method: 'GET',
|
|
156
|
+
headers: {
|
|
157
|
+
authorization: `Bearer ${this.opts.ingestToken}`,
|
|
158
|
+
'x-user-id': this.opts.userId,
|
|
159
|
+
},
|
|
160
|
+
signal: ac.signal,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
finally {
|
|
164
|
+
clearTimeout(tid);
|
|
165
|
+
}
|
|
166
|
+
if (!res.ok) {
|
|
167
|
+
this.health.pollFailures++;
|
|
168
|
+
logger.warn(TAG, `GET ${url} → ${res.status}`);
|
|
169
|
+
return [];
|
|
170
|
+
}
|
|
171
|
+
const body = await res.json();
|
|
172
|
+
return Array.isArray(body.proposals) ? body.proposals : [];
|
|
173
|
+
}
|
|
174
|
+
catch (err) {
|
|
175
|
+
this.health.pollFailures++;
|
|
176
|
+
logger.warn(TAG, `GET ${url} threw: ${formatError(err)}`);
|
|
177
|
+
return [];
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
async firePending(p) {
|
|
181
|
+
this.health.firesAttempted++;
|
|
182
|
+
// ---- 1. Drift abandon (Locked Decision #6) ----
|
|
183
|
+
// Skip if we can't read a current price — better to let the trading path
|
|
184
|
+
// surface the error than to silently abandon on a transient ticker miss.
|
|
185
|
+
const lastPrice = await this.opts.adapter.getLastPrice(p.symbol).catch(() => null);
|
|
186
|
+
if (lastPrice != null && Number.isFinite(lastPrice) && lastPrice > 0) {
|
|
187
|
+
const rDistance = Math.abs(p.proposedEntry - p.stopPrice);
|
|
188
|
+
if (rDistance > 0) {
|
|
189
|
+
const driftR = Math.abs(lastPrice - p.proposedEntry) / rDistance;
|
|
190
|
+
if (driftR > this.opts.driftAbandonR) {
|
|
191
|
+
const msg = `price_drift: mark ${lastPrice} is ${driftR.toFixed(2)}R from proposed entry ${p.proposedEntry} (limit ${this.opts.driftAbandonR}R)`;
|
|
192
|
+
logger.warn(TAG, `Abandoning proposal ${p.id} — ${msg}`);
|
|
193
|
+
await this.patchResult(p.id, { fireError: msg });
|
|
194
|
+
this.health.firesAbandonedDrift++;
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
// ---- 2. Fire via createOrderTool (same path toggle-off mode uses) ----
|
|
200
|
+
// CRITICAL: do NOT pass proposalManager + userId here. The listener IS the
|
|
201
|
+
// consumer of approved proposals; passing proposalManager would recursively
|
|
202
|
+
// dual-write another proposal row for the same intent. The agent's path
|
|
203
|
+
// dual-writes; the listener's path fires the underlying order.
|
|
204
|
+
const result = await createOrderTool({
|
|
205
|
+
symbol: p.symbol,
|
|
206
|
+
side: p.side,
|
|
207
|
+
type: p.orderType,
|
|
208
|
+
amount: p.size,
|
|
209
|
+
// For limit orders we honour the original proposed entry. For market,
|
|
210
|
+
// the create_order tool reads the live ticker (per existing semantics).
|
|
211
|
+
price: p.orderType === 'limit' ? p.proposedEntry : undefined,
|
|
212
|
+
stopPrice: p.stopPrice,
|
|
213
|
+
target_price: p.targetPrice,
|
|
214
|
+
setup_type: p.setupType,
|
|
215
|
+
thesis: p.thesis,
|
|
216
|
+
regime: p.regime,
|
|
217
|
+
regime_confidence: p.regimeConfidence,
|
|
218
|
+
scorecard_verdict: p.scorecardVerdict,
|
|
219
|
+
confluence_score: p.confluenceScore,
|
|
220
|
+
}, {
|
|
221
|
+
binanceApi: this.opts.binanceApi,
|
|
222
|
+
adapter: this.opts.adapter,
|
|
223
|
+
autoCapture: this.opts.autoCapture,
|
|
224
|
+
// proposalManager + userId deliberately omitted — see above.
|
|
225
|
+
});
|
|
226
|
+
if ('error' in result) {
|
|
227
|
+
// Trading-path rejection (risk gate, bracket attach failure, exchange
|
|
228
|
+
// rejection, etc.). Surface to the operator via the proposal row.
|
|
229
|
+
logger.warn(TAG, `Proposal ${p.id} fire rejected: ${result.error}`);
|
|
230
|
+
await this.patchResult(p.id, { fireError: result.error });
|
|
231
|
+
this.health.firesFailedTrading++;
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
if ('status' in result && result.status === 'pending_approval') {
|
|
235
|
+
// Should be unreachable: the listener calls createOrderTool without
|
|
236
|
+
// proposalManager, so the per_trade branch can't trigger. If it does
|
|
237
|
+
// (config race or future refactor), surface as a fireError so we don't
|
|
238
|
+
// silently lose the proposal.
|
|
239
|
+
const msg = `unexpected pending_approval from listener-side fire (proposal_id=${result.proposal_id})`;
|
|
240
|
+
logger.error(TAG, msg);
|
|
241
|
+
await this.patchResult(p.id, { fireError: msg });
|
|
242
|
+
this.health.firesFailedTrading++;
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
const firedOrderId = typeof result.id === 'string' ? result.id : `unknown-${Date.now()}`;
|
|
246
|
+
logger.info(TAG, `Proposal ${p.id} fired → order ${firedOrderId}`);
|
|
247
|
+
await this.patchResult(p.id, { firedOrderId });
|
|
248
|
+
this.health.firesSucceeded++;
|
|
249
|
+
}
|
|
250
|
+
async patchResult(proposalId, body) {
|
|
251
|
+
const url = `${this.opts.baseUrl}/api/internal/proposed_orders/${proposalId}/fire-result`;
|
|
252
|
+
try {
|
|
253
|
+
const ac = new AbortController();
|
|
254
|
+
const tid = setTimeout(() => ac.abort(), this.opts.requestTimeoutMs);
|
|
255
|
+
let res;
|
|
256
|
+
try {
|
|
257
|
+
res = await this.opts.fetchImpl(url, {
|
|
258
|
+
method: 'PATCH',
|
|
259
|
+
headers: {
|
|
260
|
+
'content-type': 'application/json',
|
|
261
|
+
authorization: `Bearer ${this.opts.ingestToken}`,
|
|
262
|
+
'x-user-id': this.opts.userId,
|
|
263
|
+
},
|
|
264
|
+
body: JSON.stringify(body),
|
|
265
|
+
signal: ac.signal,
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
finally {
|
|
269
|
+
clearTimeout(tid);
|
|
270
|
+
}
|
|
271
|
+
if (res.status === 409) {
|
|
272
|
+
// Already-recorded race — listener double-fired but webapp's idempotency
|
|
273
|
+
// guard caught the duplicate. Not an error.
|
|
274
|
+
logger.info(TAG, `Proposal ${proposalId} fire-result already recorded (409, expected on retry)`);
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
if (!res.ok) {
|
|
278
|
+
this.health.patchFailures++;
|
|
279
|
+
logger.warn(TAG, `PATCH ${url} → ${res.status}`);
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
catch (err) {
|
|
284
|
+
this.health.patchFailures++;
|
|
285
|
+
logger.warn(TAG, `PATCH ${url} threw: ${formatError(err)}`);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { type SetupBucket } from './setup-buckets.js';
|
|
2
|
+
/** Trade params + agent metadata composing a single proposal. Mirrors the
|
|
3
|
+
* /api/internal/proposed_orders payload shape. */
|
|
4
|
+
export interface ProposalRequest {
|
|
5
|
+
symbol: string;
|
|
6
|
+
side: 'buy' | 'sell';
|
|
7
|
+
orderType: 'market' | 'limit';
|
|
8
|
+
size: number;
|
|
9
|
+
proposedEntry: number;
|
|
10
|
+
stopPrice: number;
|
|
11
|
+
targetPrice: number;
|
|
12
|
+
riskUsd: number;
|
|
13
|
+
thesis: string;
|
|
14
|
+
setupType: string;
|
|
15
|
+
regime: string;
|
|
16
|
+
regimeConfidence: number;
|
|
17
|
+
scorecardVerdict: string;
|
|
18
|
+
confluenceScore: number;
|
|
19
|
+
/** Phase C — when set, the named proposal is atomically marked `superseded`
|
|
20
|
+
* by the webapp and the new row links back via supersedes_id. Use this when
|
|
21
|
+
* the agent is modifying a still-pending proposal (e.g. after operator
|
|
22
|
+
* discussion). Must reference a pending/discussing proposal owned by the
|
|
23
|
+
* same user, or the supersede half is a no-op. */
|
|
24
|
+
supersedesId?: string;
|
|
25
|
+
/** 'shadow' = Phase A telemetry-only row (agent's create_order also fires
|
|
26
|
+
* the real order synchronously). The webapp filters these out of the
|
|
27
|
+
* operator UI so they don't appear as actionable cards. 'real' = Phase B/C
|
|
28
|
+
* actionable proposal the operator decides on. Defaults to 'real' on the
|
|
29
|
+
* webapp side if omitted. */
|
|
30
|
+
origin?: 'shadow' | 'real';
|
|
31
|
+
}
|
|
32
|
+
export interface ProposalManagerOptions {
|
|
33
|
+
baseUrl: string;
|
|
34
|
+
ingestToken: string;
|
|
35
|
+
fetchImpl?: typeof fetch;
|
|
36
|
+
requestTimeoutMs?: number;
|
|
37
|
+
maxAttempts?: number;
|
|
38
|
+
baseBackoffMs?: number;
|
|
39
|
+
backoffCapMs?: number;
|
|
40
|
+
/** Override clock for tests. */
|
|
41
|
+
now?: () => Date;
|
|
42
|
+
/** Override UUID generator for tests. */
|
|
43
|
+
uuid?: () => string;
|
|
44
|
+
}
|
|
45
|
+
export interface ProposalManagerHealth {
|
|
46
|
+
inFlight: number;
|
|
47
|
+
totalPosted: number;
|
|
48
|
+
totalSucceeded: number;
|
|
49
|
+
totalDroppedTerminal: number;
|
|
50
|
+
totalDroppedRetriesExhausted: number;
|
|
51
|
+
}
|
|
52
|
+
/** Result of a propose() call. The proposal_uuid is generated synchronously
|
|
53
|
+
* before any network I/O so the caller can correlate logs immediately. */
|
|
54
|
+
export interface ProposeResult {
|
|
55
|
+
proposalUuid: string;
|
|
56
|
+
setupBucket: SetupBucket;
|
|
57
|
+
hardExpiresAt: Date;
|
|
58
|
+
}
|
|
59
|
+
export declare class ProposalManager {
|
|
60
|
+
private readonly opts;
|
|
61
|
+
private inFlight;
|
|
62
|
+
private totalPosted;
|
|
63
|
+
private totalSucceeded;
|
|
64
|
+
private totalDroppedTerminal;
|
|
65
|
+
private totalDroppedRetriesExhausted;
|
|
66
|
+
constructor(options: ProposalManagerOptions);
|
|
67
|
+
/** Submit a proposal. Generates the proposal_uuid + computes the bucket
|
|
68
|
+
* + hard expiry synchronously; the actual POST runs in the background.
|
|
69
|
+
* Caller may correlate via the returned proposalUuid before the post lands. */
|
|
70
|
+
propose(userId: string, req: ProposalRequest): ProposeResult;
|
|
71
|
+
/** Await all in-flight POSTs. Used at shutdown so we don't lose proposals. */
|
|
72
|
+
drain(): Promise<void>;
|
|
73
|
+
getHealth(): ProposalManagerHealth;
|
|
74
|
+
private run;
|
|
75
|
+
private sleepBackoff;
|
|
76
|
+
}
|