@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,1274 @@
|
|
|
1
|
+
// ⚠️ GENERATED FILE — DO NOT EDIT.
|
|
2
|
+
// Canonical source of truth: shared/src/signals/conditions/registry.ts
|
|
3
|
+
// Regenerate: node scripts/sync-shared-code.mjs (enforced by shared-code-sync.test.ts)
|
|
4
|
+
//
|
|
5
|
+
// This copy exists because this package builds with tsc and deploys as a
|
|
6
|
+
// self-contained tree that strips workspace deps, so it cannot import
|
|
7
|
+
// @reefclaw/shared runtime code across the deploy boundary.
|
|
8
|
+
// Condition registry — all reusable condition functions for declarative strategies.
|
|
9
|
+
// Each condition is a pure function: (ctx, params, direction, condCtx) => ConditionResult.
|
|
10
|
+
import { computeEMA, computeRSI, linearSlope, findSwingPoints, mean } from '../../shared/indicators.js';
|
|
11
|
+
import { computeMACD, computeBollingerBands, computeVWAP, computeStochRSI, computeADX, computeIchimoku, computeSupertrend, } from '../../shared/indicators-extended.js';
|
|
12
|
+
// ─── Condition Registry ────────────────────────────────────────────────
|
|
13
|
+
const REGISTRY = new Map();
|
|
14
|
+
function register(type, fn) {
|
|
15
|
+
REGISTRY.set(type, fn);
|
|
16
|
+
}
|
|
17
|
+
export function getConditionFn(type) {
|
|
18
|
+
return REGISTRY.get(type);
|
|
19
|
+
}
|
|
20
|
+
export function hasCondition(type) {
|
|
21
|
+
return REGISTRY.has(type);
|
|
22
|
+
}
|
|
23
|
+
export function listConditionTypes() {
|
|
24
|
+
return Array.from(REGISTRY.keys());
|
|
25
|
+
}
|
|
26
|
+
/** Evaluate an array of condition configs. Returns SignalCondition[] + shared context. */
|
|
27
|
+
export function evaluateConditions(configs, ctx, direction) {
|
|
28
|
+
const condCtx = {};
|
|
29
|
+
const conditions = configs.map(cfg => {
|
|
30
|
+
const fn = REGISTRY.get(cfg.type);
|
|
31
|
+
if (!fn) {
|
|
32
|
+
return { name: cfg.type, met: false, currentValue: 0, threshold: 0, description: `Unknown condition: ${cfg.type}` };
|
|
33
|
+
}
|
|
34
|
+
const result = fn(ctx, cfg.params, direction, condCtx);
|
|
35
|
+
return { name: cfg.type, met: result.met, currentValue: result.value, threshold: result.threshold, description: result.description };
|
|
36
|
+
});
|
|
37
|
+
return { conditions, condCtx };
|
|
38
|
+
}
|
|
39
|
+
// ─── 1. ema_proximity ──────────────────────────────────────────────────
|
|
40
|
+
// Price within X% of EMA(N)
|
|
41
|
+
register('ema_proximity', (ctx, params, _dir, condCtx) => {
|
|
42
|
+
const period = params.period ?? 21;
|
|
43
|
+
const maxDistPct = params.maxDistPct ?? 0.003;
|
|
44
|
+
const closes = ctx.ohlcv1h.map(b => b.close);
|
|
45
|
+
const ema = computeEMA(closes, period);
|
|
46
|
+
const dist = Math.abs(ctx.currentPrice - ema) / ema;
|
|
47
|
+
const met = dist <= maxDistPct;
|
|
48
|
+
// Store EMA for entry rule
|
|
49
|
+
condCtx.ema21 = period === 21 ? ema : condCtx.ema21;
|
|
50
|
+
return {
|
|
51
|
+
met,
|
|
52
|
+
value: Math.round(dist * 10000) / 100,
|
|
53
|
+
threshold: maxDistPct * 100,
|
|
54
|
+
description: met
|
|
55
|
+
? `Price within ${(dist * 100).toFixed(2)}% of EMA(${period})`
|
|
56
|
+
: `Price ${(dist * 100).toFixed(2)}% from EMA(${period}) (need ≤${maxDistPct * 100}%)`,
|
|
57
|
+
};
|
|
58
|
+
});
|
|
59
|
+
// ─── 2. orderbook_imbalance ────────────────────────────────────────────
|
|
60
|
+
// OB imbalance favors direction; auto-passes in backtest mode
|
|
61
|
+
register('orderbook_imbalance', (ctx, params, direction) => {
|
|
62
|
+
const threshold = params.threshold ?? 0.6;
|
|
63
|
+
if (ctx.backtestMode) {
|
|
64
|
+
return { met: true, value: 50, threshold: threshold * 100, description: 'Skipped (no historical orderbook)' };
|
|
65
|
+
}
|
|
66
|
+
const met = direction === 'LONG'
|
|
67
|
+
? ctx.obImbalance > threshold
|
|
68
|
+
: direction === 'SHORT'
|
|
69
|
+
? ctx.obImbalance < 1 - threshold
|
|
70
|
+
: Math.abs(ctx.obImbalance - 0.5) > (threshold - 0.5);
|
|
71
|
+
return {
|
|
72
|
+
met,
|
|
73
|
+
value: Math.round(ctx.obImbalance * 100),
|
|
74
|
+
threshold: threshold * 100,
|
|
75
|
+
description: met
|
|
76
|
+
? `Orderbook ${direction === 'LONG' ? 'bid' : 'ask'} imbalance ${(ctx.obImbalance * 100).toFixed(0)}%`
|
|
77
|
+
: `Orderbook imbalance ${(ctx.obImbalance * 100).toFixed(0)}% (need ${direction === 'LONG' ? '>' : '<'}${threshold * 100}%)`,
|
|
78
|
+
};
|
|
79
|
+
});
|
|
80
|
+
// ─── 3. funding_not_crowded ────────────────────────────────────────────
|
|
81
|
+
// Funding z-score within bounds (not overcrowded)
|
|
82
|
+
register('funding_not_crowded', (ctx, params) => {
|
|
83
|
+
const maxZScore = params.maxZScore ?? 2;
|
|
84
|
+
const zScore = ctx.fundingStd > 0
|
|
85
|
+
? Math.abs((ctx.fundingRates[0] ?? 0) - ctx.fundingMean) / ctx.fundingStd
|
|
86
|
+
: 0;
|
|
87
|
+
const met = zScore < maxZScore;
|
|
88
|
+
return {
|
|
89
|
+
met,
|
|
90
|
+
value: Math.round(zScore * 100) / 100,
|
|
91
|
+
threshold: maxZScore,
|
|
92
|
+
description: met
|
|
93
|
+
? `Funding z-score ${zScore.toFixed(2)} (not crowded)`
|
|
94
|
+
: `Funding z-score ${zScore.toFixed(2)} (crowded, need <${maxZScore}σ)`,
|
|
95
|
+
};
|
|
96
|
+
});
|
|
97
|
+
// ─── 4. oi_slope ───────────────────────────────────────────────────────
|
|
98
|
+
// OI slope over N slots is >= minSlope (steady or rising)
|
|
99
|
+
register('oi_slope', (ctx, params) => {
|
|
100
|
+
const lookbackSlots = params.lookbackSlots ?? 16;
|
|
101
|
+
const minSlope = params.minSlope ?? 0;
|
|
102
|
+
const oiRecent = ctx.oiValues.slice(0, lookbackSlots).reverse();
|
|
103
|
+
const slope = oiRecent.length >= 4 ? linearSlope(oiRecent) : 0;
|
|
104
|
+
const met = slope >= minSlope;
|
|
105
|
+
return {
|
|
106
|
+
met,
|
|
107
|
+
value: Math.round(slope * 100) / 100,
|
|
108
|
+
threshold: minSlope,
|
|
109
|
+
description: met
|
|
110
|
+
? `OI slope ${slope >= 0 ? '+' : ''}${slope.toFixed(2)} (steady/rising)`
|
|
111
|
+
: `OI slope ${slope.toFixed(2)} (declining)`,
|
|
112
|
+
};
|
|
113
|
+
});
|
|
114
|
+
// ─── 5. no_liquidation_cluster ─────────────────────────────────────────
|
|
115
|
+
// No major liq cluster near entry; auto-passes in backtest mode
|
|
116
|
+
register('no_liquidation_cluster', (ctx, params) => {
|
|
117
|
+
const maxUsd = params.maxUsd ?? 500_000;
|
|
118
|
+
if (ctx.backtestMode) {
|
|
119
|
+
return { met: true, value: 0, threshold: maxUsd, description: 'Skipped (no historical liquidation data)' };
|
|
120
|
+
}
|
|
121
|
+
const met = ctx.liqNearEntry < maxUsd;
|
|
122
|
+
return {
|
|
123
|
+
met,
|
|
124
|
+
value: Math.round(ctx.liqNearEntry),
|
|
125
|
+
threshold: maxUsd,
|
|
126
|
+
description: met
|
|
127
|
+
? `Liquidations near entry $${(ctx.liqNearEntry / 1000).toFixed(0)}k (below threshold)`
|
|
128
|
+
: `Liquidation cluster $${(ctx.liqNearEntry / 1000).toFixed(0)}k near entry (threshold $${(maxUsd / 1000).toFixed(0)}k)`,
|
|
129
|
+
};
|
|
130
|
+
});
|
|
131
|
+
// ─── 6. price_sweep ────────────────────────────────────────────────────
|
|
132
|
+
// Price swept a swing high/low on 5m, then reversed
|
|
133
|
+
register('price_sweep', (ctx, params, _dir, condCtx) => {
|
|
134
|
+
const swingLookback = params.swingLookback ?? 5;
|
|
135
|
+
const recentBarCount = params.recentBars ?? 10;
|
|
136
|
+
const bars5m = ctx.ohlcv5m;
|
|
137
|
+
if (bars5m.length < 50) {
|
|
138
|
+
return { met: false, value: 0, threshold: 0, description: 'Insufficient 5m data' };
|
|
139
|
+
}
|
|
140
|
+
const lookbackBars = bars5m.slice(0, -recentBarCount);
|
|
141
|
+
const recentBars = bars5m.slice(-recentBarCount);
|
|
142
|
+
const swings = findSwingPoints(lookbackBars, swingLookback);
|
|
143
|
+
const latestClose = recentBars[recentBars.length - 1].close;
|
|
144
|
+
const recentLow = Math.min(...recentBars.map(b => b.low));
|
|
145
|
+
const recentHigh = Math.max(...recentBars.map(b => b.high));
|
|
146
|
+
let sweptLow = false;
|
|
147
|
+
let sweptHigh = false;
|
|
148
|
+
let sweepLevel = 0;
|
|
149
|
+
if (swings.lows.length > 0) {
|
|
150
|
+
const nearestLow = Math.min(...swings.lows.slice(-5));
|
|
151
|
+
if (recentLow < nearestLow && latestClose > nearestLow) {
|
|
152
|
+
sweptLow = true;
|
|
153
|
+
sweepLevel = nearestLow;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
if (!sweptLow && swings.highs.length > 0) {
|
|
157
|
+
const nearestHigh = Math.max(...swings.highs.slice(-5));
|
|
158
|
+
if (recentHigh > nearestHigh && latestClose < nearestHigh) {
|
|
159
|
+
sweptHigh = true;
|
|
160
|
+
sweepLevel = nearestHigh;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
// Store in shared context for entry/stop rules and direction
|
|
164
|
+
condCtx.sweepLevel = sweepLevel;
|
|
165
|
+
condCtx.recentLow = recentLow;
|
|
166
|
+
condCtx.recentHigh = recentHigh;
|
|
167
|
+
condCtx.sweptDirection = sweptLow ? 'LONG' : sweptHigh ? 'SHORT' : null;
|
|
168
|
+
const met = sweptLow || sweptHigh;
|
|
169
|
+
return {
|
|
170
|
+
met,
|
|
171
|
+
value: sweepLevel,
|
|
172
|
+
threshold: 0,
|
|
173
|
+
description: sweptLow
|
|
174
|
+
? `Swept swing low at ${sweepLevel.toFixed(2)}, reversed above`
|
|
175
|
+
: sweptHigh
|
|
176
|
+
? `Swept swing high at ${sweepLevel.toFixed(2)}, reversed below`
|
|
177
|
+
: 'No sweep detected',
|
|
178
|
+
};
|
|
179
|
+
});
|
|
180
|
+
// ─── 7. liquidations_at_sweep ──────────────────────────────────────────
|
|
181
|
+
// Liquidation volume at sweep level above minimum; auto-passes in backtest
|
|
182
|
+
register('liquidations_at_sweep', (ctx, params) => {
|
|
183
|
+
const minUsd = params.minUsd ?? 200_000;
|
|
184
|
+
if (ctx.backtestMode) {
|
|
185
|
+
return { met: true, value: 0, threshold: minUsd, description: 'Skipped (no historical liquidation data)' };
|
|
186
|
+
}
|
|
187
|
+
const liq = ctx.liqNearEntry;
|
|
188
|
+
const met = liq > minUsd;
|
|
189
|
+
return {
|
|
190
|
+
met,
|
|
191
|
+
value: Math.round(liq),
|
|
192
|
+
threshold: minUsd,
|
|
193
|
+
description: met
|
|
194
|
+
? `$${(liq / 1000).toFixed(0)}k liquidations at sweep level`
|
|
195
|
+
: `Only $${(liq / 1000).toFixed(0)}k liquidations (need >${(minUsd / 1000).toFixed(0)}k)`,
|
|
196
|
+
};
|
|
197
|
+
});
|
|
198
|
+
// ─── 8. order_flow_absorption ──────────────────────────────────────────
|
|
199
|
+
// High volume but small net delta = absorption
|
|
200
|
+
register('order_flow_absorption', (ctx, params) => {
|
|
201
|
+
const maxRatio = params.maxRatio ?? 0.3;
|
|
202
|
+
const totalBuy = ctx.tradeFlow.reduce((s, b) => s + b.buyVolume, 0);
|
|
203
|
+
const totalSell = ctx.tradeFlow.reduce((s, b) => s + b.sellVolume, 0);
|
|
204
|
+
const totalVolume = totalBuy + totalSell;
|
|
205
|
+
const netDelta = Math.abs(totalBuy - totalSell);
|
|
206
|
+
const ratio = totalVolume > 0 ? netDelta / totalVolume : 1;
|
|
207
|
+
const met = ratio < maxRatio;
|
|
208
|
+
return {
|
|
209
|
+
met,
|
|
210
|
+
value: Math.round(ratio * 100),
|
|
211
|
+
threshold: maxRatio * 100,
|
|
212
|
+
description: met
|
|
213
|
+
? `Absorption detected: delta/volume ratio ${(ratio * 100).toFixed(0)}%`
|
|
214
|
+
: `No absorption: delta/volume ratio ${(ratio * 100).toFixed(0)}% (need <${(maxRatio * 100).toFixed(0)}%)`,
|
|
215
|
+
};
|
|
216
|
+
});
|
|
217
|
+
// ─── 9. funding_contrarian ─────────────────────────────────────────────
|
|
218
|
+
// Funding not extreme in the direction of trade (direction-aware)
|
|
219
|
+
register('funding_contrarian', (ctx, params, direction) => {
|
|
220
|
+
const maxZScore = params.maxZScore ?? 1.5;
|
|
221
|
+
const currentFunding = ctx.fundingRates[0] ?? 0;
|
|
222
|
+
const zScore = ctx.fundingStd > 0 ? (currentFunding - ctx.fundingMean) / ctx.fundingStd : 0;
|
|
223
|
+
const met = direction === 'LONG'
|
|
224
|
+
? zScore > -maxZScore
|
|
225
|
+
: direction === 'SHORT'
|
|
226
|
+
? zScore < maxZScore
|
|
227
|
+
: Math.abs(zScore) < maxZScore;
|
|
228
|
+
return {
|
|
229
|
+
met,
|
|
230
|
+
value: Math.round(zScore * 100) / 100,
|
|
231
|
+
threshold: maxZScore,
|
|
232
|
+
description: met
|
|
233
|
+
? `Funding z-score ${zScore.toFixed(2)} (neutral/contrarian)`
|
|
234
|
+
: `Funding z-score ${zScore.toFixed(2)} (aligned with crowd)`,
|
|
235
|
+
};
|
|
236
|
+
});
|
|
237
|
+
// ─── 10. funding_extreme ───────────────────────────────────────────────
|
|
238
|
+
// Funding > Nσ or < -Nσ from mean (for mean reversion setups)
|
|
239
|
+
// Also determines direction: extreme positive → SHORT, extreme negative → LONG
|
|
240
|
+
register('funding_extreme', (ctx, params, _dir, condCtx) => {
|
|
241
|
+
const minZScore = params.minZScore ?? 3;
|
|
242
|
+
const currentFunding = ctx.fundingRates[0] ?? 0;
|
|
243
|
+
const zScore = ctx.fundingStd > 0 ? (currentFunding - ctx.fundingMean) / ctx.fundingStd : 0;
|
|
244
|
+
const isExtremePositive = zScore > minZScore;
|
|
245
|
+
const isExtremeNegative = zScore < -minZScore;
|
|
246
|
+
const met = isExtremePositive || isExtremeNegative;
|
|
247
|
+
// Store direction for from_funding rule
|
|
248
|
+
if (isExtremePositive)
|
|
249
|
+
condCtx.sweptDirection = 'SHORT';
|
|
250
|
+
else if (isExtremeNegative)
|
|
251
|
+
condCtx.sweptDirection = 'LONG';
|
|
252
|
+
return {
|
|
253
|
+
met,
|
|
254
|
+
value: Math.round(zScore * 100) / 100,
|
|
255
|
+
threshold: minZScore,
|
|
256
|
+
description: met
|
|
257
|
+
? `Funding z-score ${zScore.toFixed(2)} (extreme ${isExtremePositive ? 'positive' : 'negative'})`
|
|
258
|
+
: `Funding z-score ${zScore.toFixed(2)} (need >${minZScore}σ or <-${minZScore}σ)`,
|
|
259
|
+
};
|
|
260
|
+
});
|
|
261
|
+
// ─── 11. oi_elevated ───────────────────────────────────────────────────
|
|
262
|
+
// OI above N× its average (elevated positioning)
|
|
263
|
+
register('oi_elevated', (ctx, params) => {
|
|
264
|
+
const elevationRatio = params.elevationRatio ?? 1.1;
|
|
265
|
+
const oiCurrent = ctx.oiValues[0] ?? 0;
|
|
266
|
+
const oiAvg = ctx.oiValues.length > 10 ? mean(ctx.oiValues) : oiCurrent;
|
|
267
|
+
const ratio = oiAvg > 0 ? oiCurrent / oiAvg : 1;
|
|
268
|
+
const met = ratio > elevationRatio;
|
|
269
|
+
return {
|
|
270
|
+
met,
|
|
271
|
+
value: Math.round(ratio * 100) / 100,
|
|
272
|
+
threshold: elevationRatio,
|
|
273
|
+
description: met
|
|
274
|
+
? `OI ${((ratio - 1) * 100).toFixed(0)}% above average (crowded positioning)`
|
|
275
|
+
: `OI only ${((ratio - 1) * 100).toFixed(0)}% vs average (need >${((elevationRatio - 1) * 100).toFixed(0)}%)`,
|
|
276
|
+
};
|
|
277
|
+
});
|
|
278
|
+
// ─── 12. price_at_level ────────────────────────────────────────────────
|
|
279
|
+
// Price near a technically significant level (EMA50, EMA200, or swing)
|
|
280
|
+
register('price_at_level', (ctx, params) => {
|
|
281
|
+
const maxDistPct = params.maxDistPct ?? 0.005;
|
|
282
|
+
const closes = ctx.ohlcv1h.map(b => b.close);
|
|
283
|
+
const ema50 = computeEMA(closes, params.emaPeriod1 ?? 50);
|
|
284
|
+
const ema200 = computeEMA(closes, params.emaPeriod2 ?? 200);
|
|
285
|
+
const swingLookbackBars = params.swingLookbackBars ?? 48;
|
|
286
|
+
const swingLookback = params.swingLookback ?? 3;
|
|
287
|
+
const swings = findSwingPoints(ctx.ohlcv1h.slice(-swingLookbackBars), swingLookback);
|
|
288
|
+
const distToEma50 = Math.abs(ctx.currentPrice - ema50) / ema50;
|
|
289
|
+
const distToEma200 = Math.abs(ctx.currentPrice - ema200) / ema200;
|
|
290
|
+
const distToSwing = Math.min(...swings.highs.slice(-5).map(h => Math.abs(ctx.currentPrice - h) / ctx.currentPrice), ...swings.lows.slice(-5).map(l => Math.abs(ctx.currentPrice - l) / ctx.currentPrice), 1);
|
|
291
|
+
const closestDist = Math.min(distToEma50, distToEma200, distToSwing);
|
|
292
|
+
const met = closestDist < maxDistPct;
|
|
293
|
+
return {
|
|
294
|
+
met,
|
|
295
|
+
value: Math.round(closestDist * 10000) / 100,
|
|
296
|
+
threshold: maxDistPct * 10000 / 100,
|
|
297
|
+
description: met
|
|
298
|
+
? `Price within ${(closestDist * 100).toFixed(2)}% of significant level`
|
|
299
|
+
: `Price ${(closestDist * 100).toFixed(2)}% from nearest level (need ≤${(maxDistPct * 100).toFixed(1)}%)`,
|
|
300
|
+
};
|
|
301
|
+
});
|
|
302
|
+
// ─── 13. macd_crossover ──────────────────────────────────────────────────
|
|
303
|
+
// MACD line crossed signal in trade direction
|
|
304
|
+
register('macd_crossover', (ctx, params, direction) => {
|
|
305
|
+
const closes = ctx.ohlcv1h.map(b => b.close);
|
|
306
|
+
const macd = computeMACD(closes, params.fast ?? 12, params.slow ?? 26, params.signal ?? 9);
|
|
307
|
+
const met = direction === 'LONG'
|
|
308
|
+
? macd.crossover === 'bullish'
|
|
309
|
+
: direction === 'SHORT'
|
|
310
|
+
? macd.crossover === 'bearish'
|
|
311
|
+
: macd.crossover !== 'none';
|
|
312
|
+
return {
|
|
313
|
+
met,
|
|
314
|
+
value: Math.round(macd.histogram * 100) / 100,
|
|
315
|
+
threshold: 0,
|
|
316
|
+
description: met
|
|
317
|
+
? `MACD ${macd.crossover} crossover (histogram ${macd.histogram.toFixed(2)})`
|
|
318
|
+
: `No MACD crossover in trade direction (histogram ${macd.histogram.toFixed(2)})`,
|
|
319
|
+
};
|
|
320
|
+
});
|
|
321
|
+
// ─── 14. macd_divergence ─────────────────────────────────────────────────
|
|
322
|
+
// Price vs MACD divergence (trend weakening)
|
|
323
|
+
register('macd_divergence', (ctx) => {
|
|
324
|
+
const closes = ctx.ohlcv1h.map(b => b.close);
|
|
325
|
+
const macd = computeMACD(closes, 12, 26, 9);
|
|
326
|
+
if (closes.length < 20) {
|
|
327
|
+
return { met: false, value: 0, threshold: 0, description: 'Insufficient data for divergence' };
|
|
328
|
+
}
|
|
329
|
+
const recent = closes.slice(-20);
|
|
330
|
+
const priceUp = recent[recent.length - 1] > recent[0];
|
|
331
|
+
const histDown = macd.histogram < 0;
|
|
332
|
+
const priceDown = recent[recent.length - 1] < recent[0];
|
|
333
|
+
const histUp = macd.histogram > 0;
|
|
334
|
+
const met = (priceUp && histDown) || (priceDown && histUp);
|
|
335
|
+
return {
|
|
336
|
+
met,
|
|
337
|
+
value: Math.round(macd.histogram * 100) / 100,
|
|
338
|
+
threshold: 0,
|
|
339
|
+
description: met
|
|
340
|
+
? `MACD divergence: price ${priceUp ? 'rising' : 'falling'} vs histogram ${histUp ? 'positive' : 'negative'}`
|
|
341
|
+
: 'No MACD divergence detected',
|
|
342
|
+
};
|
|
343
|
+
});
|
|
344
|
+
// ─── 15. bollinger_squeeze ───────────────────────────────────────────────
|
|
345
|
+
// Bollinger bandwidth below threshold (compression)
|
|
346
|
+
register('bollinger_squeeze', (ctx, params) => {
|
|
347
|
+
const closes = ctx.ohlcv1h.map(b => b.close);
|
|
348
|
+
const bb = computeBollingerBands(closes, params.period ?? 20, params.stdDev ?? 2);
|
|
349
|
+
const maxBandwidth = params.maxBandwidth ?? 3.0;
|
|
350
|
+
const met = bb.bandwidth < maxBandwidth;
|
|
351
|
+
return {
|
|
352
|
+
met,
|
|
353
|
+
value: Math.round(bb.bandwidth * 100) / 100,
|
|
354
|
+
threshold: maxBandwidth,
|
|
355
|
+
description: met
|
|
356
|
+
? `Bollinger squeeze: bandwidth ${bb.bandwidth.toFixed(2)}% (compressed)`
|
|
357
|
+
: `Bollinger bandwidth ${bb.bandwidth.toFixed(2)}% (need <${maxBandwidth}%)`,
|
|
358
|
+
};
|
|
359
|
+
});
|
|
360
|
+
// ─── 16. bollinger_breakout ──────────────────────────────────────────────
|
|
361
|
+
// Price closes outside Bollinger band
|
|
362
|
+
register('bollinger_breakout', (ctx, params, direction) => {
|
|
363
|
+
const closes = ctx.ohlcv1h.map(b => b.close);
|
|
364
|
+
const bb = computeBollingerBands(closes, params.period ?? 20, params.stdDev ?? 2);
|
|
365
|
+
const met = direction === 'LONG'
|
|
366
|
+
? bb.percentB > 1
|
|
367
|
+
: direction === 'SHORT'
|
|
368
|
+
? bb.percentB < 0
|
|
369
|
+
: bb.percentB > 1 || bb.percentB < 0;
|
|
370
|
+
return {
|
|
371
|
+
met,
|
|
372
|
+
value: Math.round(bb.percentB * 100) / 100,
|
|
373
|
+
threshold: direction === 'SHORT' ? 0 : 1,
|
|
374
|
+
description: met
|
|
375
|
+
? `Bollinger breakout: %B=${bb.percentB.toFixed(2)} (outside band)`
|
|
376
|
+
: `Price inside Bollinger bands: %B=${bb.percentB.toFixed(2)}`,
|
|
377
|
+
};
|
|
378
|
+
});
|
|
379
|
+
// ─── 17. vwap_position ───────────────────────────────────────────────────
|
|
380
|
+
// Price above/below VWAP aligns with direction
|
|
381
|
+
register('vwap_position', (ctx, params, direction) => {
|
|
382
|
+
const bars = ctx.ohlcv1h.slice(-24).map(b => ({
|
|
383
|
+
open: b.open, high: b.high, low: b.low, close: b.close, volume: b.volume,
|
|
384
|
+
}));
|
|
385
|
+
const vwap = computeVWAP(bars);
|
|
386
|
+
const above = ctx.currentPrice > vwap;
|
|
387
|
+
const met = direction === 'LONG' ? above : direction === 'SHORT' ? !above : true;
|
|
388
|
+
const distPct = vwap > 0 ? ((ctx.currentPrice - vwap) / vwap) * 100 : 0;
|
|
389
|
+
return {
|
|
390
|
+
met,
|
|
391
|
+
value: Math.round(distPct * 100) / 100,
|
|
392
|
+
threshold: 0,
|
|
393
|
+
description: met
|
|
394
|
+
? `Price ${above ? 'above' : 'below'} VWAP (${distPct.toFixed(2)}%)`
|
|
395
|
+
: `Price on wrong side of VWAP for ${direction} (${distPct.toFixed(2)}%)`,
|
|
396
|
+
};
|
|
397
|
+
});
|
|
398
|
+
// ─── 18. stoch_rsi_extreme ───────────────────────────────────────────────
|
|
399
|
+
// StochRSI in extreme zone
|
|
400
|
+
register('stoch_rsi_extreme', (ctx, params, direction) => {
|
|
401
|
+
const closes = ctx.ohlcv1h.map(b => b.close);
|
|
402
|
+
const sr = computeStochRSI(closes, 14, 14, 3, 3);
|
|
403
|
+
const oversoldThreshold = params.oversold ?? 20;
|
|
404
|
+
const overboughtThreshold = params.overbought ?? 80;
|
|
405
|
+
const met = direction === 'LONG'
|
|
406
|
+
? sr.k < oversoldThreshold
|
|
407
|
+
: direction === 'SHORT'
|
|
408
|
+
? sr.k > overboughtThreshold
|
|
409
|
+
: sr.k < oversoldThreshold || sr.k > overboughtThreshold;
|
|
410
|
+
return {
|
|
411
|
+
met,
|
|
412
|
+
value: sr.k,
|
|
413
|
+
threshold: direction === 'SHORT' ? overboughtThreshold : oversoldThreshold,
|
|
414
|
+
description: met
|
|
415
|
+
? `StochRSI extreme: K=${sr.k} (${sr.k < 50 ? 'oversold' : 'overbought'})`
|
|
416
|
+
: `StochRSI K=${sr.k} (not extreme)`,
|
|
417
|
+
};
|
|
418
|
+
});
|
|
419
|
+
// ─── 19. adx_trending ────────────────────────────────────────────────────
|
|
420
|
+
// ADX above threshold (trending market)
|
|
421
|
+
register('adx_trending', (ctx, params) => {
|
|
422
|
+
const closes = ctx.ohlcv1h.map(b => b.close);
|
|
423
|
+
const highs = ctx.ohlcv1h.map(b => b.high);
|
|
424
|
+
const lows = ctx.ohlcv1h.map(b => b.low);
|
|
425
|
+
const adx = computeADX(highs, lows, closes, params.period ?? 14);
|
|
426
|
+
const minAdx = params.minAdx ?? 25;
|
|
427
|
+
const met = adx.adx > minAdx;
|
|
428
|
+
return {
|
|
429
|
+
met,
|
|
430
|
+
value: adx.adx,
|
|
431
|
+
threshold: minAdx,
|
|
432
|
+
description: met
|
|
433
|
+
? `ADX ${adx.adx} (trending, +DI=${adx.plusDI} -DI=${adx.minusDI})`
|
|
434
|
+
: `ADX ${adx.adx} (not trending, need >${minAdx})`,
|
|
435
|
+
};
|
|
436
|
+
});
|
|
437
|
+
// ─── 20. adx_ranging ─────────────────────────────────────────────────────
|
|
438
|
+
// ADX below threshold (ranging market)
|
|
439
|
+
register('adx_ranging', (ctx, params) => {
|
|
440
|
+
const closes = ctx.ohlcv1h.map(b => b.close);
|
|
441
|
+
const highs = ctx.ohlcv1h.map(b => b.high);
|
|
442
|
+
const lows = ctx.ohlcv1h.map(b => b.low);
|
|
443
|
+
const adx = computeADX(highs, lows, closes, params.period ?? 14);
|
|
444
|
+
const maxAdx = params.maxAdx ?? 20;
|
|
445
|
+
const met = adx.adx < maxAdx;
|
|
446
|
+
return {
|
|
447
|
+
met,
|
|
448
|
+
value: adx.adx,
|
|
449
|
+
threshold: maxAdx,
|
|
450
|
+
description: met
|
|
451
|
+
? `ADX ${adx.adx} (ranging, choppy market)`
|
|
452
|
+
: `ADX ${adx.adx} (too strong for range, need <${maxAdx})`,
|
|
453
|
+
};
|
|
454
|
+
});
|
|
455
|
+
// ─── 21. ichimoku_cloud ──────────────────────────────────────────────────
|
|
456
|
+
// Price position relative to Ichimoku cloud + TK cross
|
|
457
|
+
register('ichimoku_cloud', (ctx, params, direction) => {
|
|
458
|
+
const closes = ctx.ohlcv1h.map(b => b.close);
|
|
459
|
+
const highs = ctx.ohlcv1h.map(b => b.high);
|
|
460
|
+
const lows = ctx.ohlcv1h.map(b => b.low);
|
|
461
|
+
const ichi = computeIchimoku(highs, lows, closes, 9, 26, 52);
|
|
462
|
+
const bullish = ichi.cloudPosition === 'above' && ichi.tenkan > ichi.kijun;
|
|
463
|
+
const bearish = ichi.cloudPosition === 'below' && ichi.tenkan < ichi.kijun;
|
|
464
|
+
const met = direction === 'LONG' ? bullish : direction === 'SHORT' ? bearish : bullish || bearish;
|
|
465
|
+
return {
|
|
466
|
+
met,
|
|
467
|
+
value: ichi.cloudPosition === 'above' ? 1 : ichi.cloudPosition === 'below' ? -1 : 0,
|
|
468
|
+
threshold: 0,
|
|
469
|
+
description: met
|
|
470
|
+
? `Ichimoku ${bullish ? 'bullish' : 'bearish'}: ${ichi.cloudPosition} cloud, TK ${ichi.tenkan > ichi.kijun ? 'bull' : 'bear'} cross`
|
|
471
|
+
: `Ichimoku neutral: ${ichi.cloudPosition} cloud`,
|
|
472
|
+
};
|
|
473
|
+
});
|
|
474
|
+
// ─── 22. obv_divergence ──────────────────────────────────────────────────
|
|
475
|
+
// OBV diverges from price
|
|
476
|
+
register('obv_divergence', (ctx, params, direction) => {
|
|
477
|
+
const closes = ctx.ohlcv1h.map(b => b.close);
|
|
478
|
+
const volumes = ctx.ohlcv1h.map(b => b.volume);
|
|
479
|
+
if (closes.length < 20) {
|
|
480
|
+
return { met: false, value: 0, threshold: 0, description: 'Insufficient data' };
|
|
481
|
+
}
|
|
482
|
+
const recentCloses = closes.slice(-20);
|
|
483
|
+
const recentVolumes = volumes.slice(-20);
|
|
484
|
+
let obv = 0;
|
|
485
|
+
const obvSeries = [0];
|
|
486
|
+
for (let i = 1; i < recentCloses.length; i++) {
|
|
487
|
+
if (recentCloses[i] > recentCloses[i - 1])
|
|
488
|
+
obv += recentVolumes[i];
|
|
489
|
+
else if (recentCloses[i] < recentCloses[i - 1])
|
|
490
|
+
obv -= recentVolumes[i];
|
|
491
|
+
obvSeries.push(obv);
|
|
492
|
+
}
|
|
493
|
+
const priceSlope = linearSlope(recentCloses);
|
|
494
|
+
const obvSlope = linearSlope(obvSeries);
|
|
495
|
+
const bullishDiv = priceSlope < 0 && obvSlope > 0;
|
|
496
|
+
const bearishDiv = priceSlope > 0 && obvSlope < 0;
|
|
497
|
+
const met = direction === 'LONG' ? bullishDiv : direction === 'SHORT' ? bearishDiv : bullishDiv || bearishDiv;
|
|
498
|
+
return {
|
|
499
|
+
met,
|
|
500
|
+
value: Math.round(obvSlope * 100) / 100,
|
|
501
|
+
threshold: 0,
|
|
502
|
+
description: met
|
|
503
|
+
? `OBV ${bullishDiv ? 'bullish' : 'bearish'} divergence (accumulation/distribution)`
|
|
504
|
+
: 'No OBV divergence detected',
|
|
505
|
+
};
|
|
506
|
+
});
|
|
507
|
+
// ─── 24. cvd_divergence_bullish ──────────────────────────────────────────
|
|
508
|
+
// CVD bullish divergence detected (price lower low + CVD higher low)
|
|
509
|
+
register('cvd_divergence_bullish', (ctx, _params, _dir, condCtx) => {
|
|
510
|
+
if (!ctx.cvd) {
|
|
511
|
+
if (ctx.backtestMode)
|
|
512
|
+
return { met: true, value: 0, threshold: 0, description: 'Skipped (no CVD data in backtest)' };
|
|
513
|
+
return { met: false, value: 0, threshold: 0, description: 'No CVD data available' };
|
|
514
|
+
}
|
|
515
|
+
const met = ctx.cvd.cvdDivergence === 'bullish';
|
|
516
|
+
if (met)
|
|
517
|
+
condCtx.sweptDirection = condCtx.sweptDirection ?? 'LONG';
|
|
518
|
+
return {
|
|
519
|
+
met,
|
|
520
|
+
value: Math.round(ctx.cvd.cvdDivergenceStrength * 100),
|
|
521
|
+
threshold: 0,
|
|
522
|
+
description: met
|
|
523
|
+
? `CVD bullish divergence (strength ${(ctx.cvd.cvdDivergenceStrength * 100).toFixed(0)}%)`
|
|
524
|
+
: 'No CVD bullish divergence',
|
|
525
|
+
};
|
|
526
|
+
});
|
|
527
|
+
// ─── 25. cvd_divergence_bearish ─────────────────────────────────────────
|
|
528
|
+
// CVD bearish divergence detected (price higher high + CVD lower high)
|
|
529
|
+
register('cvd_divergence_bearish', (ctx, _params, _dir, condCtx) => {
|
|
530
|
+
if (!ctx.cvd) {
|
|
531
|
+
if (ctx.backtestMode)
|
|
532
|
+
return { met: true, value: 0, threshold: 0, description: 'Skipped (no CVD data in backtest)' };
|
|
533
|
+
return { met: false, value: 0, threshold: 0, description: 'No CVD data available' };
|
|
534
|
+
}
|
|
535
|
+
const met = ctx.cvd.cvdDivergence === 'bearish';
|
|
536
|
+
if (met)
|
|
537
|
+
condCtx.sweptDirection = condCtx.sweptDirection ?? 'SHORT';
|
|
538
|
+
return {
|
|
539
|
+
met,
|
|
540
|
+
value: Math.round(ctx.cvd.cvdDivergenceStrength * 100),
|
|
541
|
+
threshold: 0,
|
|
542
|
+
description: met
|
|
543
|
+
? `CVD bearish divergence (strength ${(ctx.cvd.cvdDivergenceStrength * 100).toFixed(0)}%)`
|
|
544
|
+
: 'No CVD bearish divergence',
|
|
545
|
+
};
|
|
546
|
+
});
|
|
547
|
+
// ─── 26. cvd_positive_slope ─────────────────────────────────────────────
|
|
548
|
+
// CVD slope is positive (buying pressure increasing)
|
|
549
|
+
register('cvd_positive_slope', (ctx, params) => {
|
|
550
|
+
if (!ctx.cvd) {
|
|
551
|
+
if (ctx.backtestMode)
|
|
552
|
+
return { met: true, value: 0, threshold: 0, description: 'Skipped (no CVD data in backtest)' };
|
|
553
|
+
return { met: false, value: 0, threshold: 0, description: 'No CVD data available' };
|
|
554
|
+
}
|
|
555
|
+
const minSlope = params.minSlope ?? 0;
|
|
556
|
+
const met = ctx.cvd.cvdSlope1h > minSlope;
|
|
557
|
+
return {
|
|
558
|
+
met,
|
|
559
|
+
value: Math.round(ctx.cvd.cvdSlope1h * 100) / 100,
|
|
560
|
+
threshold: minSlope,
|
|
561
|
+
description: met
|
|
562
|
+
? `CVD slope positive: ${ctx.cvd.cvdSlope1h.toFixed(2)} (buying pressure increasing)`
|
|
563
|
+
: `CVD slope ${ctx.cvd.cvdSlope1h.toFixed(2)} (buying pressure weak)`,
|
|
564
|
+
};
|
|
565
|
+
});
|
|
566
|
+
// ─── 27. cvd_negative_slope ─────────────────────────────────────────────
|
|
567
|
+
// CVD slope is negative (selling pressure increasing)
|
|
568
|
+
register('cvd_negative_slope', (ctx, params) => {
|
|
569
|
+
if (!ctx.cvd) {
|
|
570
|
+
if (ctx.backtestMode)
|
|
571
|
+
return { met: true, value: 0, threshold: 0, description: 'Skipped (no CVD data in backtest)' };
|
|
572
|
+
return { met: false, value: 0, threshold: 0, description: 'No CVD data available' };
|
|
573
|
+
}
|
|
574
|
+
const maxSlope = params.maxSlope ?? 0;
|
|
575
|
+
const met = ctx.cvd.cvdSlope1h < maxSlope;
|
|
576
|
+
return {
|
|
577
|
+
met,
|
|
578
|
+
value: Math.round(ctx.cvd.cvdSlope1h * 100) / 100,
|
|
579
|
+
threshold: maxSlope,
|
|
580
|
+
description: met
|
|
581
|
+
? `CVD slope negative: ${ctx.cvd.cvdSlope1h.toFixed(2)} (selling pressure increasing)`
|
|
582
|
+
: `CVD slope ${ctx.cvd.cvdSlope1h.toFixed(2)} (selling pressure weak)`,
|
|
583
|
+
};
|
|
584
|
+
});
|
|
585
|
+
// ─── 28. cvd_extreme ────────────────────────────────────────────────────
|
|
586
|
+
// CVD z-score at extreme (>2σ = exhaustion signal)
|
|
587
|
+
register('cvd_extreme', (ctx, params) => {
|
|
588
|
+
if (!ctx.cvd) {
|
|
589
|
+
if (ctx.backtestMode)
|
|
590
|
+
return { met: true, value: 0, threshold: 0, description: 'Skipped (no CVD data in backtest)' };
|
|
591
|
+
return { met: false, value: 0, threshold: 0, description: 'No CVD data available' };
|
|
592
|
+
}
|
|
593
|
+
const minZScore = params.minZScore ?? 2;
|
|
594
|
+
const absZ = Math.abs(ctx.cvd.cvdZScore);
|
|
595
|
+
const met = absZ > minZScore;
|
|
596
|
+
return {
|
|
597
|
+
met,
|
|
598
|
+
value: Math.round(ctx.cvd.cvdZScore * 100) / 100,
|
|
599
|
+
threshold: minZScore,
|
|
600
|
+
description: met
|
|
601
|
+
? `CVD extreme: z-score ${ctx.cvd.cvdZScore.toFixed(2)} (${ctx.cvd.cvdZScore > 0 ? 'buying' : 'selling'} exhaustion)`
|
|
602
|
+
: `CVD z-score ${ctx.cvd.cvdZScore.toFixed(2)} (not extreme, need >${minZScore}σ)`,
|
|
603
|
+
};
|
|
604
|
+
});
|
|
605
|
+
// ─── 23. supertrend_direction ────────────────────────────────────────────
|
|
606
|
+
// Supertrend indicator direction matches trade direction
|
|
607
|
+
register('supertrend_direction', (ctx, params, direction) => {
|
|
608
|
+
const closes = ctx.ohlcv1h.map(b => b.close);
|
|
609
|
+
const highs = ctx.ohlcv1h.map(b => b.high);
|
|
610
|
+
const lows = ctx.ohlcv1h.map(b => b.low);
|
|
611
|
+
const st = computeSupertrend(highs, lows, closes, params.period ?? 10, params.multiplier ?? 3);
|
|
612
|
+
const met = direction === 'LONG'
|
|
613
|
+
? st.direction === 'bullish'
|
|
614
|
+
: direction === 'SHORT'
|
|
615
|
+
? st.direction === 'bearish'
|
|
616
|
+
: true;
|
|
617
|
+
return {
|
|
618
|
+
met,
|
|
619
|
+
value: st.direction === 'bullish' ? 1 : -1,
|
|
620
|
+
threshold: 0,
|
|
621
|
+
description: met
|
|
622
|
+
? `Supertrend ${st.direction} (value ${st.value.toFixed(2)})`
|
|
623
|
+
: `Supertrend ${st.direction} conflicts with ${direction} direction`,
|
|
624
|
+
};
|
|
625
|
+
});
|
|
626
|
+
// ─── Helpers for higher-timeframe pattern conditions (Path B) ─────────
|
|
627
|
+
// All conditions below honour params.tfHours: 1 (default, ohlcv1h),
|
|
628
|
+
// 4 (ohlcv4h), or 24 (ohlcv1d). When the requested higher timeframe is
|
|
629
|
+
// unavailable on this MarketContext, they fall back to ohlcv1h so unit
|
|
630
|
+
// tests and legacy contexts still work.
|
|
631
|
+
function pickBars(ctx, tfHours) {
|
|
632
|
+
if (tfHours >= 24)
|
|
633
|
+
return ctx.ohlcv1d ?? ctx.ohlcv1h;
|
|
634
|
+
if (tfHours >= 4)
|
|
635
|
+
return ctx.ohlcv4h ?? ctx.ohlcv1h;
|
|
636
|
+
return ctx.ohlcv1h;
|
|
637
|
+
}
|
|
638
|
+
/** Pivot finder that returns indices alongside prices. We can't reuse
|
|
639
|
+
* `findSwingPoints` from shared/indicators because it discards indices,
|
|
640
|
+
* and trendline-fit + multi-pivot divergence both need them. */
|
|
641
|
+
function findPivotsIdx(bars, lookback) {
|
|
642
|
+
const highs = [];
|
|
643
|
+
const lows = [];
|
|
644
|
+
for (let i = lookback; i < bars.length - lookback; i++) {
|
|
645
|
+
let isHigh = true;
|
|
646
|
+
let isLow = true;
|
|
647
|
+
for (let j = i - lookback; j <= i + lookback; j++) {
|
|
648
|
+
if (j === i)
|
|
649
|
+
continue;
|
|
650
|
+
if (bars[j].high >= bars[i].high)
|
|
651
|
+
isHigh = false;
|
|
652
|
+
if (bars[j].low <= bars[i].low)
|
|
653
|
+
isLow = false;
|
|
654
|
+
}
|
|
655
|
+
if (isHigh)
|
|
656
|
+
highs.push({ idx: i, price: bars[i].high });
|
|
657
|
+
if (isLow)
|
|
658
|
+
lows.push({ idx: i, price: bars[i].low });
|
|
659
|
+
}
|
|
660
|
+
return { highs, lows };
|
|
661
|
+
}
|
|
662
|
+
// ─── 30. ema_slope ───────────────────────────────────────────────────
|
|
663
|
+
// EMA(N) is sloping in the trade direction at >= minSlopePct per bar
|
|
664
|
+
// (slope normalised by current EMA value, so it's a percent change rate).
|
|
665
|
+
// params: period, lookback, minSlopePct, dirSign (1=up,-1=down,0=use trade direction), tfHours
|
|
666
|
+
register('ema_slope', (ctx, params, direction) => {
|
|
667
|
+
const period = Math.floor(params.period ?? 21);
|
|
668
|
+
const lookback = Math.floor(params.lookback ?? 5);
|
|
669
|
+
const minSlopePct = params.minSlopePct ?? 0;
|
|
670
|
+
const dirSign = params.dirSign ?? 0;
|
|
671
|
+
const tfHours = params.tfHours ?? 1;
|
|
672
|
+
const bars = pickBars(ctx, tfHours);
|
|
673
|
+
if (bars.length < period + lookback + 5) {
|
|
674
|
+
return { met: false, value: 0, threshold: minSlopePct * 100, description: `Insufficient ${tfHours}h bars for ema_slope` };
|
|
675
|
+
}
|
|
676
|
+
const closes = bars.map((b) => b.close);
|
|
677
|
+
const emaSeries = [];
|
|
678
|
+
for (let i = closes.length - lookback - 1; i < closes.length; i++) {
|
|
679
|
+
emaSeries.push(computeEMA(closes.slice(0, i + 1), period));
|
|
680
|
+
}
|
|
681
|
+
const slope = linearSlope(emaSeries);
|
|
682
|
+
const lastEma = emaSeries[emaSeries.length - 1];
|
|
683
|
+
const slopePct = lastEma > 0 ? slope / lastEma : 0;
|
|
684
|
+
const requiredSign = dirSign !== 0
|
|
685
|
+
? Math.sign(dirSign)
|
|
686
|
+
: direction === 'LONG' ? 1 : direction === 'SHORT' ? -1 : 0;
|
|
687
|
+
const met = requiredSign === 0
|
|
688
|
+
? Math.abs(slopePct) >= minSlopePct
|
|
689
|
+
: (slopePct * requiredSign) >= minSlopePct;
|
|
690
|
+
return {
|
|
691
|
+
met,
|
|
692
|
+
value: Math.round(slopePct * 1_000_000) / 10_000,
|
|
693
|
+
threshold: minSlopePct * 100,
|
|
694
|
+
description: met
|
|
695
|
+
? `EMA(${period}) slope ${(slopePct * 100).toFixed(3)}%/bar over ${lookback} ${tfHours}h bars (${slopePct > 0 ? 'rising' : slopePct < 0 ? 'falling' : 'flat'})`
|
|
696
|
+
: `EMA(${period}) slope ${(slopePct * 100).toFixed(3)}%/bar fails ${requiredSign > 0 ? '≥+' : requiredSign < 0 ? '≤−' : '|·|≥'}${(minSlopePct * 100).toFixed(3)}%`,
|
|
697
|
+
};
|
|
698
|
+
});
|
|
699
|
+
// ─── 31. volume_vs_sma ───────────────────────────────────────────────
|
|
700
|
+
// Latest bar volume >= multiplier × N-bar SMA of prior volumes.
|
|
701
|
+
// params: period, minMultiplier, tfHours
|
|
702
|
+
register('volume_vs_sma', (ctx, params) => {
|
|
703
|
+
const period = Math.floor(params.period ?? 20);
|
|
704
|
+
const minMultiplier = params.minMultiplier ?? 1.5;
|
|
705
|
+
const tfHours = params.tfHours ?? 1;
|
|
706
|
+
const bars = pickBars(ctx, tfHours);
|
|
707
|
+
if (bars.length < period + 1) {
|
|
708
|
+
return { met: false, value: 0, threshold: minMultiplier, description: `Insufficient ${tfHours}h bars for volume_vs_sma` };
|
|
709
|
+
}
|
|
710
|
+
const currentVol = bars[bars.length - 1].volume;
|
|
711
|
+
const prevVols = bars.slice(-period - 1, -1).map((b) => b.volume);
|
|
712
|
+
const sma = mean(prevVols);
|
|
713
|
+
const ratio = sma > 0 ? currentVol / sma : 0;
|
|
714
|
+
const met = ratio >= minMultiplier;
|
|
715
|
+
return {
|
|
716
|
+
met,
|
|
717
|
+
value: Math.round(ratio * 100) / 100,
|
|
718
|
+
threshold: minMultiplier,
|
|
719
|
+
description: met
|
|
720
|
+
? `Volume ${ratio.toFixed(2)}× ${period}-bar SMA on ${tfHours}h`
|
|
721
|
+
: `Volume only ${ratio.toFixed(2)}× ${period}-bar SMA (need ≥${minMultiplier}×)`,
|
|
722
|
+
};
|
|
723
|
+
});
|
|
724
|
+
// Direction-aware return momentum over a fixed lookback.
|
|
725
|
+
//
|
|
726
|
+
// params:
|
|
727
|
+
// lookback: bars back from the latest close
|
|
728
|
+
// minReturnPct: decimal return threshold, e.g. 0.02 = 2%
|
|
729
|
+
// maxAbsReturnPct: optional decimal cap to avoid chasing blow-off moves
|
|
730
|
+
// mode: 1 = momentum with direction, -1 = counter-move into direction,
|
|
731
|
+
// 0 = absolute move either way
|
|
732
|
+
// dirSign: 1 long/up, -1 short/down, 0 use resolved trade direction
|
|
733
|
+
// tfHours: 1, 4, or 24
|
|
734
|
+
register('return_momentum', (ctx, params, direction) => {
|
|
735
|
+
const lookback = Math.max(1, Math.floor(params.lookback ?? 12));
|
|
736
|
+
const minReturnPct = params.minReturnPct ?? 0.01;
|
|
737
|
+
const maxAbsReturnPct = params.maxAbsReturnPct ?? 0;
|
|
738
|
+
const mode = params.mode === 0 ? 0 : params.mode && params.mode < 0 ? -1 : 1;
|
|
739
|
+
const dirSign = params.dirSign ?? 0;
|
|
740
|
+
const tfHours = params.tfHours ?? 1;
|
|
741
|
+
const bars = pickBars(ctx, tfHours);
|
|
742
|
+
if (bars.length <= lookback) {
|
|
743
|
+
return {
|
|
744
|
+
met: false,
|
|
745
|
+
value: 0,
|
|
746
|
+
threshold: minReturnPct * 100,
|
|
747
|
+
description: `Insufficient ${tfHours}h bars for return_momentum`,
|
|
748
|
+
};
|
|
749
|
+
}
|
|
750
|
+
const currentClose = bars[bars.length - 1].close;
|
|
751
|
+
const priorClose = bars[bars.length - 1 - lookback].close;
|
|
752
|
+
const ret = priorClose > 0 ? (currentClose - priorClose) / priorClose : 0;
|
|
753
|
+
const absRet = Math.abs(ret);
|
|
754
|
+
const baseSign = dirSign !== 0
|
|
755
|
+
? Math.sign(dirSign)
|
|
756
|
+
: direction === 'LONG' ? 1 : direction === 'SHORT' ? -1 : 0;
|
|
757
|
+
const targetSign = mode === 0 || baseSign === 0 ? 0 : baseSign * mode;
|
|
758
|
+
const directionOk = targetSign > 0
|
|
759
|
+
? ret >= minReturnPct
|
|
760
|
+
: targetSign < 0
|
|
761
|
+
? ret <= -minReturnPct
|
|
762
|
+
: absRet >= minReturnPct;
|
|
763
|
+
const extensionOk = maxAbsReturnPct <= 0 || absRet <= maxAbsReturnPct;
|
|
764
|
+
const met = directionOk && extensionOk;
|
|
765
|
+
const label = targetSign > 0
|
|
766
|
+
? 'upside momentum'
|
|
767
|
+
: targetSign < 0
|
|
768
|
+
? 'downside momentum'
|
|
769
|
+
: 'absolute momentum';
|
|
770
|
+
const capText = maxAbsReturnPct > 0 ? `, cap ${(maxAbsReturnPct * 100).toFixed(1)}%` : '';
|
|
771
|
+
return {
|
|
772
|
+
met,
|
|
773
|
+
value: Math.round(ret * 10000) / 100,
|
|
774
|
+
threshold: minReturnPct * 100,
|
|
775
|
+
description: met
|
|
776
|
+
? `${lookback}-bar ${tfHours}h return ${(ret * 100).toFixed(2)}% confirms ${label}`
|
|
777
|
+
: `${lookback}-bar ${tfHours}h return ${(ret * 100).toFixed(2)}% fails ${label} threshold ${(minReturnPct * 100).toFixed(1)}%${capText}`,
|
|
778
|
+
};
|
|
779
|
+
});
|
|
780
|
+
// ─── 32. rsi_multi_pivot_divergence ──────────────────────────────────
|
|
781
|
+
// Strict N-pivot RSI divergence: price makes successively lower lows (or
|
|
782
|
+
// higher highs) while RSI makes the opposite, with optional volume dry-up
|
|
783
|
+
// at the final pivot.
|
|
784
|
+
// params: pivots, swingLookback, rsiPeriod, volumeDryUp (0=disabled,
|
|
785
|
+
// 0..1=ratio cap of last/first vol), direction (1=bullish, -1=bearish), tfHours
|
|
786
|
+
register('rsi_multi_pivot_divergence', (ctx, params) => {
|
|
787
|
+
const pivotsN = Math.max(2, Math.floor(params.pivots ?? 3));
|
|
788
|
+
const swingLookback = Math.floor(params.swingLookback ?? 5);
|
|
789
|
+
const rsiPeriod = Math.floor(params.rsiPeriod ?? 14);
|
|
790
|
+
const volumeDryUp = params.volumeDryUp ?? 0;
|
|
791
|
+
const dirParam = params.direction ?? 1;
|
|
792
|
+
const isBullish = dirParam >= 0;
|
|
793
|
+
const tfHours = params.tfHours ?? 1;
|
|
794
|
+
const bars = pickBars(ctx, tfHours);
|
|
795
|
+
if (bars.length < rsiPeriod + swingLookback * 2 + 10) {
|
|
796
|
+
return { met: false, value: 0, threshold: pivotsN, description: `Insufficient ${tfHours}h bars for rsi_multi_pivot_divergence` };
|
|
797
|
+
}
|
|
798
|
+
const found = findPivotsIdx(bars, swingLookback);
|
|
799
|
+
const piv = isBullish ? found.lows : found.highs;
|
|
800
|
+
if (piv.length < pivotsN) {
|
|
801
|
+
return {
|
|
802
|
+
met: false,
|
|
803
|
+
value: piv.length,
|
|
804
|
+
threshold: pivotsN,
|
|
805
|
+
description: `Only ${piv.length} pivot ${isBullish ? 'lows' : 'highs'} on ${tfHours}h (need ${pivotsN})`,
|
|
806
|
+
};
|
|
807
|
+
}
|
|
808
|
+
const last = piv.slice(-pivotsN);
|
|
809
|
+
// Price sequence check
|
|
810
|
+
let priceSeqOk = true;
|
|
811
|
+
for (let i = 1; i < last.length; i++) {
|
|
812
|
+
if (isBullish && !(last[i].price < last[i - 1].price))
|
|
813
|
+
priceSeqOk = false;
|
|
814
|
+
if (!isBullish && !(last[i].price > last[i - 1].price))
|
|
815
|
+
priceSeqOk = false;
|
|
816
|
+
}
|
|
817
|
+
if (!priceSeqOk) {
|
|
818
|
+
return {
|
|
819
|
+
met: false,
|
|
820
|
+
value: 0,
|
|
821
|
+
threshold: pivotsN,
|
|
822
|
+
description: `${isBullish ? 'Price lows' : 'Price highs'} not in ${isBullish ? 'descending' : 'ascending'} sequence: ${last.map((p) => p.price.toFixed(2)).join('→')}`,
|
|
823
|
+
};
|
|
824
|
+
}
|
|
825
|
+
// RSI at each pivot index
|
|
826
|
+
const closes = bars.map((b) => b.close);
|
|
827
|
+
const rsiAt = last.map((p) => computeRSI(closes.slice(0, p.idx + 1), rsiPeriod));
|
|
828
|
+
let rsiSeqOk = true;
|
|
829
|
+
for (let i = 1; i < rsiAt.length; i++) {
|
|
830
|
+
if (isBullish && !(rsiAt[i] > rsiAt[i - 1]))
|
|
831
|
+
rsiSeqOk = false;
|
|
832
|
+
if (!isBullish && !(rsiAt[i] < rsiAt[i - 1]))
|
|
833
|
+
rsiSeqOk = false;
|
|
834
|
+
}
|
|
835
|
+
if (!rsiSeqOk) {
|
|
836
|
+
return {
|
|
837
|
+
met: false,
|
|
838
|
+
value: rsiAt[rsiAt.length - 1] - rsiAt[0],
|
|
839
|
+
threshold: 0,
|
|
840
|
+
description: `RSI not diverging: ${rsiAt.map((r) => r.toFixed(0)).join('→')}`,
|
|
841
|
+
};
|
|
842
|
+
}
|
|
843
|
+
if (volumeDryUp > 0) {
|
|
844
|
+
const v0 = bars[last[0].idx].volume;
|
|
845
|
+
const vN = bars[last[last.length - 1].idx].volume;
|
|
846
|
+
const ratio = v0 > 0 ? vN / v0 : 1;
|
|
847
|
+
if (ratio > volumeDryUp) {
|
|
848
|
+
return {
|
|
849
|
+
met: false,
|
|
850
|
+
value: Math.round(ratio * 100) / 100,
|
|
851
|
+
threshold: volumeDryUp,
|
|
852
|
+
description: `No volume dry-up: last pivot vol ${(ratio * 100).toFixed(0)}% of first (need ≤${(volumeDryUp * 100).toFixed(0)}%)`,
|
|
853
|
+
};
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
return {
|
|
857
|
+
met: true,
|
|
858
|
+
value: Math.round((rsiAt[rsiAt.length - 1] - rsiAt[0]) * 100) / 100,
|
|
859
|
+
threshold: 0,
|
|
860
|
+
description: `${pivotsN}-pivot ${isBullish ? 'bullish' : 'bearish'} divergence on ${tfHours}h: price ${last.map((p) => p.price.toFixed(2)).join('→')}, RSI ${rsiAt.map((r) => r.toFixed(0)).join('→')}`,
|
|
861
|
+
};
|
|
862
|
+
});
|
|
863
|
+
// ─── 33. trendline_break ─────────────────────────────────────────────
|
|
864
|
+
// Linear-fit a trendline through the last N pivot lows (or highs) and
|
|
865
|
+
// check whether the latest close has broken through it in the requested
|
|
866
|
+
// direction. Slope sign is also constrained to match the expected pattern
|
|
867
|
+
// (descending trendline for bullish break above, ascending for bearish
|
|
868
|
+
// break below).
|
|
869
|
+
// params: pivots, swingLookback, useHighs (0=lows, 1=highs), breakDir
|
|
870
|
+
// (1=above, -1=below), requireSlopeSign (1=ascending, -1=descending,
|
|
871
|
+
// default flips with useHighs), tfHours
|
|
872
|
+
register('trendline_break', (ctx, params) => {
|
|
873
|
+
const pivotsN = Math.max(2, Math.floor(params.pivots ?? 3));
|
|
874
|
+
const swingLookback = Math.floor(params.swingLookback ?? 5);
|
|
875
|
+
const useHighs = (params.useHighs ?? 0) > 0;
|
|
876
|
+
const breakDir = (params.breakDir ?? 1) >= 0 ? 1 : -1;
|
|
877
|
+
const defaultSlope = useHighs ? 1 : -1;
|
|
878
|
+
const requireSlope = (params.requireSlopeSign ?? defaultSlope) >= 0 ? 1 : -1;
|
|
879
|
+
const tfHours = params.tfHours ?? 1;
|
|
880
|
+
const bars = pickBars(ctx, tfHours);
|
|
881
|
+
if (bars.length < swingLookback * 2 + pivotsN + 5) {
|
|
882
|
+
return { met: false, value: 0, threshold: 0, description: `Insufficient ${tfHours}h bars for trendline_break` };
|
|
883
|
+
}
|
|
884
|
+
const found = findPivotsIdx(bars, swingLookback);
|
|
885
|
+
const piv = useHighs ? found.highs : found.lows;
|
|
886
|
+
if (piv.length < pivotsN) {
|
|
887
|
+
return {
|
|
888
|
+
met: false,
|
|
889
|
+
value: piv.length,
|
|
890
|
+
threshold: pivotsN,
|
|
891
|
+
description: `Only ${piv.length} pivot ${useHighs ? 'highs' : 'lows'} on ${tfHours}h (need ${pivotsN})`,
|
|
892
|
+
};
|
|
893
|
+
}
|
|
894
|
+
const lastPivots = piv.slice(-pivotsN);
|
|
895
|
+
const xs = lastPivots.map((p) => p.idx);
|
|
896
|
+
const ys = lastPivots.map((p) => p.price);
|
|
897
|
+
const slope = linearSlope(ys, xs);
|
|
898
|
+
const intercept = mean(ys) - slope * mean(xs);
|
|
899
|
+
if ((requireSlope > 0 && slope <= 0) || (requireSlope < 0 && slope >= 0)) {
|
|
900
|
+
return {
|
|
901
|
+
met: false,
|
|
902
|
+
value: Math.round(slope * 10000) / 10000,
|
|
903
|
+
threshold: 0,
|
|
904
|
+
description: `Trendline slope ${slope.toExponential(2)} wrong sign (need ${requireSlope > 0 ? '>0' : '<0'})`,
|
|
905
|
+
};
|
|
906
|
+
}
|
|
907
|
+
const lastIdx = bars.length - 1;
|
|
908
|
+
const lastClose = bars[lastIdx].close;
|
|
909
|
+
const lineAtLast = slope * lastIdx + intercept;
|
|
910
|
+
const broken = breakDir > 0 ? lastClose > lineAtLast : lastClose < lineAtLast;
|
|
911
|
+
return {
|
|
912
|
+
met: broken,
|
|
913
|
+
value: Math.round((lastClose - lineAtLast) * 100) / 100,
|
|
914
|
+
threshold: 0,
|
|
915
|
+
description: broken
|
|
916
|
+
? `${pivotsN}-pivot ${useHighs ? 'highs' : 'lows'} trendline broken ${breakDir > 0 ? 'above' : 'below'}: close ${lastClose.toFixed(2)} vs line ${lineAtLast.toFixed(2)}`
|
|
917
|
+
: `Close ${lastClose.toFixed(2)} ${breakDir > 0 ? 'still below' : 'still above'} trendline ${lineAtLast.toFixed(2)}`,
|
|
918
|
+
};
|
|
919
|
+
});
|
|
920
|
+
// ─── 34. wedge_breakout ──────────────────────────────────────────────
|
|
921
|
+
// Detect a falling wedge in the last N bars and confirm a breakout above
|
|
922
|
+
// the upper trendline with volume confirmation. Falling wedge = both
|
|
923
|
+
// trendlines sloping down with the upper line falling faster (converging).
|
|
924
|
+
// params: minBars, swingLookback, minVolMultiplier, tfHours
|
|
925
|
+
// (Currently implements falling wedge only — the bullish-break variant
|
|
926
|
+
// the operator's strategies need. Rising wedge can be added later.)
|
|
927
|
+
register('wedge_breakout', (ctx, params) => {
|
|
928
|
+
const minBars = Math.floor(params.minBars ?? 40);
|
|
929
|
+
const swingLookback = Math.floor(params.swingLookback ?? 5);
|
|
930
|
+
const minVolMultiplier = params.minVolMultiplier ?? 1.5;
|
|
931
|
+
const tfHours = params.tfHours ?? 1;
|
|
932
|
+
const bars = pickBars(ctx, tfHours);
|
|
933
|
+
if (bars.length < minBars + 5) {
|
|
934
|
+
return { met: false, value: 0, threshold: 0, description: `Insufficient ${tfHours}h bars for wedge_breakout` };
|
|
935
|
+
}
|
|
936
|
+
const window = bars.slice(-minBars);
|
|
937
|
+
const found = findPivotsIdx(window, swingLookback);
|
|
938
|
+
if (found.highs.length < 3 || found.lows.length < 3) {
|
|
939
|
+
return {
|
|
940
|
+
met: false,
|
|
941
|
+
value: 0,
|
|
942
|
+
threshold: 0,
|
|
943
|
+
description: `Need ≥3 highs+lows in ${minBars}-bar ${tfHours}h window (have ${found.highs.length}/${found.lows.length})`,
|
|
944
|
+
};
|
|
945
|
+
}
|
|
946
|
+
const xsH = found.highs.map((h) => h.idx);
|
|
947
|
+
const ysH = found.highs.map((h) => h.price);
|
|
948
|
+
const xsL = found.lows.map((l) => l.idx);
|
|
949
|
+
const ysL = found.lows.map((l) => l.price);
|
|
950
|
+
const slopeH = linearSlope(ysH, xsH);
|
|
951
|
+
const slopeL = linearSlope(ysL, xsL);
|
|
952
|
+
const interceptH = mean(ysH) - slopeH * mean(xsH);
|
|
953
|
+
const isFallingWedge = slopeH < 0 && slopeL < 0 && Math.abs(slopeH) > Math.abs(slopeL);
|
|
954
|
+
if (!isFallingWedge) {
|
|
955
|
+
return {
|
|
956
|
+
met: false,
|
|
957
|
+
value: 0,
|
|
958
|
+
threshold: 0,
|
|
959
|
+
description: `Not a falling wedge: upper slope ${slopeH.toExponential(2)}, lower ${slopeL.toExponential(2)}`,
|
|
960
|
+
};
|
|
961
|
+
}
|
|
962
|
+
const lastIdx = window.length - 1;
|
|
963
|
+
const upperAtLast = slopeH * lastIdx + interceptH;
|
|
964
|
+
const lastClose = window[lastIdx].close;
|
|
965
|
+
if (lastClose <= upperAtLast) {
|
|
966
|
+
return {
|
|
967
|
+
met: false,
|
|
968
|
+
value: Math.round((lastClose - upperAtLast) * 100) / 100,
|
|
969
|
+
threshold: 0,
|
|
970
|
+
description: `Wedge intact: close ${lastClose.toFixed(2)} ≤ upper line ${upperAtLast.toFixed(2)}`,
|
|
971
|
+
};
|
|
972
|
+
}
|
|
973
|
+
const lastVol = window[lastIdx].volume;
|
|
974
|
+
const volSma = mean(window.slice(-21, -1).map((b) => b.volume));
|
|
975
|
+
const volRatio = volSma > 0 ? lastVol / volSma : 0;
|
|
976
|
+
if (volRatio < minVolMultiplier) {
|
|
977
|
+
return {
|
|
978
|
+
met: false,
|
|
979
|
+
value: Math.round(volRatio * 100) / 100,
|
|
980
|
+
threshold: minVolMultiplier,
|
|
981
|
+
description: `Wedge break but volume ${volRatio.toFixed(2)}× SMA (need ≥${minVolMultiplier}×)`,
|
|
982
|
+
};
|
|
983
|
+
}
|
|
984
|
+
return {
|
|
985
|
+
met: true,
|
|
986
|
+
value: Math.round((lastClose - upperAtLast) * 100) / 100,
|
|
987
|
+
threshold: 0,
|
|
988
|
+
description: `Falling wedge break on ${tfHours}h: close ${lastClose.toFixed(2)} > upper ${upperAtLast.toFixed(2)}, vol ${volRatio.toFixed(1)}× SMA`,
|
|
989
|
+
};
|
|
990
|
+
});
|
|
991
|
+
// ─── 35. distance_to_resistance_R ────────────────────────────────────
|
|
992
|
+
// Measures the distance from current price to the nearest swing high
|
|
993
|
+
// (LONG) / swing low (SHORT) in units of ATR(14). Used to ensure a setup
|
|
994
|
+
// has at least N R of headroom before hitting structural resistance.
|
|
995
|
+
// params: minR, swingLookback, lookbackBars, tfHours
|
|
996
|
+
register('distance_to_resistance_R', (ctx, params, direction) => {
|
|
997
|
+
const minR = params.minR ?? 1.5;
|
|
998
|
+
const swingLookback = Math.floor(params.swingLookback ?? 5);
|
|
999
|
+
const lookbackBars = Math.floor(params.lookbackBars ?? 100);
|
|
1000
|
+
const tfHours = params.tfHours ?? 1;
|
|
1001
|
+
const bars = pickBars(ctx, tfHours);
|
|
1002
|
+
if (bars.length < lookbackBars) {
|
|
1003
|
+
return { met: false, value: 0, threshold: minR, description: `Insufficient ${tfHours}h bars for distance_to_resistance_R` };
|
|
1004
|
+
}
|
|
1005
|
+
if (ctx.atr14 <= 0) {
|
|
1006
|
+
return { met: false, value: 0, threshold: minR, description: 'ATR(14) is zero — cannot compute R distance' };
|
|
1007
|
+
}
|
|
1008
|
+
const window = bars.slice(-lookbackBars);
|
|
1009
|
+
const found = findPivotsIdx(window, swingLookback);
|
|
1010
|
+
const isLong = direction === 'LONG' || direction === null;
|
|
1011
|
+
const candidates = isLong
|
|
1012
|
+
? found.highs.map((h) => h.price).filter((p) => p > ctx.currentPrice)
|
|
1013
|
+
: found.lows.map((l) => l.price).filter((p) => p < ctx.currentPrice);
|
|
1014
|
+
if (candidates.length === 0) {
|
|
1015
|
+
return {
|
|
1016
|
+
met: true,
|
|
1017
|
+
value: 999,
|
|
1018
|
+
threshold: minR,
|
|
1019
|
+
description: `No ${isLong ? 'overhead resistance' : 'support below'} in last ${lookbackBars} ${tfHours}h bars`,
|
|
1020
|
+
};
|
|
1021
|
+
}
|
|
1022
|
+
const nearest = isLong ? Math.min(...candidates) : Math.max(...candidates);
|
|
1023
|
+
const distance = Math.abs(nearest - ctx.currentPrice);
|
|
1024
|
+
const distanceR = distance / ctx.atr14;
|
|
1025
|
+
const met = distanceR >= minR;
|
|
1026
|
+
return {
|
|
1027
|
+
met,
|
|
1028
|
+
value: Math.round(distanceR * 100) / 100,
|
|
1029
|
+
threshold: minR,
|
|
1030
|
+
description: met
|
|
1031
|
+
? `${distanceR.toFixed(2)}R clear to nearest ${isLong ? 'resistance' : 'support'} at ${nearest.toFixed(2)}`
|
|
1032
|
+
: `Only ${distanceR.toFixed(2)}R to nearest ${isLong ? 'resistance' : 'support'} at ${nearest.toFixed(2)} (need ≥${minR}R)`,
|
|
1033
|
+
};
|
|
1034
|
+
});
|
|
1035
|
+
// ─── 36. btc_rsi_overbought (skipIf helper) ──────────────────────────
|
|
1036
|
+
// Reads the global market context (BTC daily RSI) and returns met=true
|
|
1037
|
+
// when BTC RSI exceeds the threshold. Designed to be used in StrategyConfig.skipIf
|
|
1038
|
+
// to block longs when BTC daily RSI is overbought. Fail-open: if global
|
|
1039
|
+
// context is missing the condition returns met=false (do not skip).
|
|
1040
|
+
// params: maxRsi (default 70)
|
|
1041
|
+
register('btc_rsi_overbought', (ctx, params) => {
|
|
1042
|
+
const maxRsi = params.maxRsi ?? 70;
|
|
1043
|
+
const btcRsi = ctx.globalContext?.btcRsi14_1d;
|
|
1044
|
+
if (btcRsi === undefined) {
|
|
1045
|
+
return {
|
|
1046
|
+
met: false,
|
|
1047
|
+
value: 0,
|
|
1048
|
+
threshold: maxRsi,
|
|
1049
|
+
description: 'BTC 1d RSI unavailable in global context (fail-open)',
|
|
1050
|
+
};
|
|
1051
|
+
}
|
|
1052
|
+
const met = btcRsi > maxRsi;
|
|
1053
|
+
return {
|
|
1054
|
+
met,
|
|
1055
|
+
value: Math.round(btcRsi * 100) / 100,
|
|
1056
|
+
threshold: maxRsi,
|
|
1057
|
+
description: met
|
|
1058
|
+
? `BTC 1d RSI ${btcRsi.toFixed(1)} > ${maxRsi} (overbought — skip longs)`
|
|
1059
|
+
: `BTC 1d RSI ${btcRsi.toFixed(1)} ≤ ${maxRsi}`,
|
|
1060
|
+
};
|
|
1061
|
+
});
|
|
1062
|
+
// ─── cascade_pulse_safety ──────────────────────────────────────────────
|
|
1063
|
+
// `met=true` when an `active_cascade` is in progress on the majors group
|
|
1064
|
+
// AND the dominant side opposes the trade direction — i.e. liquidations
|
|
1065
|
+
// are running INTO our entry. Use as skipIf to keep the agent out of
|
|
1066
|
+
// liquidation cascades that typically continue for 60-180 seconds.
|
|
1067
|
+
//
|
|
1068
|
+
// Params (ConditionConfig schema is `Record<string, number>` so flags are
|
|
1069
|
+
// numeric):
|
|
1070
|
+
// scope: 0 = majors (default), 1 = symbol
|
|
1071
|
+
// minSeverity: 1 = elevated, 2 = active_cascade (default)
|
|
1072
|
+
//
|
|
1073
|
+
// Fail-open semantics: returns met=false when the microstructure flag is
|
|
1074
|
+
// off (fields undefined) or events=0 in window (dominantSide=balanced).
|
|
1075
|
+
// This keeps the condition safe to add to a strategy before Phase 1 is on.
|
|
1076
|
+
register('cascade_pulse_safety', (ctx, params, direction) => {
|
|
1077
|
+
const useSymbolScope = params.scope === 1;
|
|
1078
|
+
const minSeverity = params.minSeverity === 1 ? 1 : 2; // default: active_cascade
|
|
1079
|
+
const classification = useSymbolScope ? ctx.cascadePulse60s : ctx.majorsCascadePulse60s;
|
|
1080
|
+
const dominant = ctx.cascadeDominantSide;
|
|
1081
|
+
const scopeLabel = useSymbolScope ? 'symbol' : 'majors';
|
|
1082
|
+
if (classification === undefined || dominant === undefined) {
|
|
1083
|
+
return {
|
|
1084
|
+
met: false,
|
|
1085
|
+
value: 0,
|
|
1086
|
+
threshold: 0,
|
|
1087
|
+
description: 'Cascade pulse unavailable (feature flag off — fail-open)',
|
|
1088
|
+
};
|
|
1089
|
+
}
|
|
1090
|
+
const severityRank = classification === 'active_cascade' ? 2 : classification === 'elevated' ? 1 : 0;
|
|
1091
|
+
const meetsSeverity = severityRank >= minSeverity;
|
|
1092
|
+
// Direction-aware: a long entry is endangered when LONGS are being
|
|
1093
|
+
// liquidated (cascading SELL pressure). A short entry is endangered when
|
|
1094
|
+
// SHORTS are being liquidated (cascading BUY pressure).
|
|
1095
|
+
const opposing = direction === 'LONG' ? dominant === 'long' :
|
|
1096
|
+
direction === 'SHORT' ? dominant === 'short' :
|
|
1097
|
+
false;
|
|
1098
|
+
const met = meetsSeverity && opposing;
|
|
1099
|
+
return {
|
|
1100
|
+
met,
|
|
1101
|
+
value: severityRank,
|
|
1102
|
+
threshold: minSeverity,
|
|
1103
|
+
description: met
|
|
1104
|
+
? `${scopeLabel} cascade ${classification} dominant=${dominant} opposing ${direction} — skip`
|
|
1105
|
+
: `${scopeLabel} pulse=${classification}, dominant=${dominant}, dir=${direction} (no skip)`,
|
|
1106
|
+
};
|
|
1107
|
+
});
|
|
1108
|
+
// ─── resting_band_unfavourable ─────────────────────────────────────────
|
|
1109
|
+
// `met=true` when banded resting liquidity in the 0.5%/1% band shows the
|
|
1110
|
+
// opposite side has substantially more notional resting against the
|
|
1111
|
+
// trade direction — i.e. there's a wall of overhead supply against a
|
|
1112
|
+
// long, or a wall of underlying support against a short.
|
|
1113
|
+
//
|
|
1114
|
+
// Direction-aware:
|
|
1115
|
+
// LONG : skip if ask-side share of (mid, mid+0.5%] > minMargin (default 0.55)
|
|
1116
|
+
// SHORT : skip if bid-side share of (mid-0.5%, mid] > minMargin
|
|
1117
|
+
//
|
|
1118
|
+
// Fail-open: returns met=false (no skip) when bandedLiquidity flag is
|
|
1119
|
+
// off OR coverage_pct < minCoverage (data unreliable on tight books like
|
|
1120
|
+
// BTCUSDT where depth-20 reaches < 0.01%). Strategies on those symbols
|
|
1121
|
+
// effectively bypass this filter.
|
|
1122
|
+
//
|
|
1123
|
+
// Numeric params (ConditionConfig schema is Record<string, number>):
|
|
1124
|
+
// band: 5 = 0.5% (default), 10 = 1.0%
|
|
1125
|
+
// minMargin: 0.55 default — minimum share against direction to trigger
|
|
1126
|
+
// minCoverage: 0.5 default (% units) — coverage_pct floor for reliability
|
|
1127
|
+
register('resting_band_unfavourable', (ctx, params, direction) => {
|
|
1128
|
+
const useBand10 = params.band === 10;
|
|
1129
|
+
const minMargin = params.minMargin ?? 0.55;
|
|
1130
|
+
const minCoverage = params.minCoverage ?? 0.5;
|
|
1131
|
+
const imbalance = useBand10 ? ctx.restingBandImbalance10 : ctx.restingBandImbalance05;
|
|
1132
|
+
const coverage = ctx.restingCoveragePct;
|
|
1133
|
+
const bandLabel = useBand10 ? '1.0%' : '0.5%';
|
|
1134
|
+
if (imbalance === undefined || coverage === undefined) {
|
|
1135
|
+
return {
|
|
1136
|
+
met: false,
|
|
1137
|
+
value: 0,
|
|
1138
|
+
threshold: minMargin,
|
|
1139
|
+
description: 'Resting bands unavailable (feature flag off — fail-open)',
|
|
1140
|
+
};
|
|
1141
|
+
}
|
|
1142
|
+
if (coverage < minCoverage) {
|
|
1143
|
+
return {
|
|
1144
|
+
met: false,
|
|
1145
|
+
value: imbalance,
|
|
1146
|
+
threshold: minMargin,
|
|
1147
|
+
description: `Coverage ${coverage.toFixed(3)}% < ${minCoverage}% — depth-20 too tight, fail-open`,
|
|
1148
|
+
};
|
|
1149
|
+
}
|
|
1150
|
+
// imbalance is bid-share. ask-share = 1 - imbalance.
|
|
1151
|
+
// For LONG, "unfavourable" = ask-share dominant = imbalance < 1 - minMargin.
|
|
1152
|
+
// For SHORT, "unfavourable" = bid-share dominant = imbalance > minMargin.
|
|
1153
|
+
let unfavourable = false;
|
|
1154
|
+
let actualShare = 0;
|
|
1155
|
+
if (direction === 'LONG') {
|
|
1156
|
+
actualShare = 1 - imbalance; // ask side
|
|
1157
|
+
unfavourable = actualShare > minMargin;
|
|
1158
|
+
}
|
|
1159
|
+
else if (direction === 'SHORT') {
|
|
1160
|
+
actualShare = imbalance; // bid side
|
|
1161
|
+
unfavourable = actualShare > minMargin;
|
|
1162
|
+
}
|
|
1163
|
+
return {
|
|
1164
|
+
met: unfavourable,
|
|
1165
|
+
value: Math.round(actualShare * 1000) / 1000,
|
|
1166
|
+
threshold: minMargin,
|
|
1167
|
+
description: unfavourable
|
|
1168
|
+
? `${bandLabel} band ${direction === 'LONG' ? 'ask' : 'bid'}-share ${(actualShare * 100).toFixed(1)}% > ${(minMargin * 100).toFixed(0)}% — wall against ${direction} entry, skip`
|
|
1169
|
+
: `${bandLabel} band ${direction === 'LONG' ? 'ask' : 'bid'}-share ${(actualShare * 100).toFixed(1)}%, coverage ${coverage.toFixed(2)}% (no skip)`,
|
|
1170
|
+
};
|
|
1171
|
+
});
|
|
1172
|
+
// ─── funding_extreme_skip ──────────────────────────────────────────────
|
|
1173
|
+
// `met=true` (skip) when current funding sits in the extreme tail of its
|
|
1174
|
+
// 30-day distribution AND the trade direction would be entering INTO that
|
|
1175
|
+
// crowded positioning:
|
|
1176
|
+
// - LONG skipped when percentileRank > upperRank (default 0.90)
|
|
1177
|
+
// — funding is in the top decile = crowded longs, top-tick risk
|
|
1178
|
+
// - SHORT skipped when percentileRank < lowerRank (default 0.10)
|
|
1179
|
+
// — funding is in the bottom decile = crowded shorts, squeeze risk
|
|
1180
|
+
//
|
|
1181
|
+
// Per-symbol adaptive by construction: the rank is computed against each
|
|
1182
|
+
// symbol's own 30d distribution server-side (see funding-percentile-api.ts),
|
|
1183
|
+
// so DOT/WIF heavy tails and BTC/BNB tight ranges both get sensible cutoffs.
|
|
1184
|
+
//
|
|
1185
|
+
// Numeric params:
|
|
1186
|
+
// upperRank: default 0.90 — skip LONG if rank > this
|
|
1187
|
+
// lowerRank: default 0.10 — skip SHORT if rank < this
|
|
1188
|
+
//
|
|
1189
|
+
// Fail-open: returns met=false when fundingPercentile is undefined
|
|
1190
|
+
// (FUNDING_OVERLAY flag off, insufficient sample, or symbol missing data).
|
|
1191
|
+
// Direction-aware via pass2 evaluation; in skipIf (direction=null) it
|
|
1192
|
+
// effectively no-ops since neither branch matches.
|
|
1193
|
+
register('funding_extreme_skip', (ctx, params, direction) => {
|
|
1194
|
+
const upperRank = params.upperRank ?? 0.90;
|
|
1195
|
+
const lowerRank = params.lowerRank ?? 0.10;
|
|
1196
|
+
const fp = ctx.fundingPercentile;
|
|
1197
|
+
if (!fp) {
|
|
1198
|
+
return {
|
|
1199
|
+
met: false,
|
|
1200
|
+
value: 0,
|
|
1201
|
+
threshold: 0,
|
|
1202
|
+
description: 'Funding percentile unavailable (FUNDING_OVERLAY off or no data — fail-open)',
|
|
1203
|
+
};
|
|
1204
|
+
}
|
|
1205
|
+
const rate_bps = fp.current * 10000;
|
|
1206
|
+
let met = false;
|
|
1207
|
+
let why = '';
|
|
1208
|
+
if (direction === 'LONG' && fp.percentileRank > upperRank) {
|
|
1209
|
+
met = true;
|
|
1210
|
+
why = `rank ${(fp.percentileRank * 100).toFixed(0)}th > ${(upperRank * 100).toFixed(0)}th — crowded longs (rate ${rate_bps.toFixed(2)} bps), skip LONG`;
|
|
1211
|
+
}
|
|
1212
|
+
else if (direction === 'SHORT' && fp.percentileRank < lowerRank) {
|
|
1213
|
+
met = true;
|
|
1214
|
+
why = `rank ${(fp.percentileRank * 100).toFixed(0)}th < ${(lowerRank * 100).toFixed(0)}th — crowded shorts (rate ${rate_bps.toFixed(2)} bps), skip SHORT`;
|
|
1215
|
+
}
|
|
1216
|
+
else {
|
|
1217
|
+
why = `rank ${(fp.percentileRank * 100).toFixed(0)}th, dir=${direction ?? 'null'} (no skip)`;
|
|
1218
|
+
}
|
|
1219
|
+
return {
|
|
1220
|
+
met,
|
|
1221
|
+
value: Math.round(fp.percentileRank * 100) / 100,
|
|
1222
|
+
threshold: direction === 'LONG' ? upperRank : direction === 'SHORT' ? lowerRank : 0,
|
|
1223
|
+
description: why,
|
|
1224
|
+
};
|
|
1225
|
+
});
|
|
1226
|
+
// ─── funding_position_ok ───────────────────────────────────────────────
|
|
1227
|
+
// Inverse of `funding_extreme_skip` for use in a strategy's `conditions`
|
|
1228
|
+
// list (gate semantics: met=true means "funding is OK to trade in this
|
|
1229
|
+
// direction"). Where `funding_extreme_skip` is intended for skipIf in
|
|
1230
|
+
// fixed-direction strategies, this variant is what regular conditions
|
|
1231
|
+
// pipelines need: pass-2 evaluation re-runs after directionRule resolves,
|
|
1232
|
+
// giving the agent direction-aware behaviour without changing the adapter.
|
|
1233
|
+
//
|
|
1234
|
+
// met=true when the trade is NOT entering INTO crowded positioning:
|
|
1235
|
+
// LONG acceptable if percentileRank ≤ upperRank (default 0.90)
|
|
1236
|
+
// SHORT acceptable if percentileRank ≥ lowerRank (default 0.10)
|
|
1237
|
+
//
|
|
1238
|
+
// Same numeric params as funding_extreme_skip. Same fail-open contract:
|
|
1239
|
+
// returns met=true when fundingPercentile is undefined (FUNDING_OVERLAY
|
|
1240
|
+
// off, no data, or insufficient sample) so the strategy is not blocked
|
|
1241
|
+
// by missing data.
|
|
1242
|
+
register('funding_position_ok', (ctx, params, direction) => {
|
|
1243
|
+
const upperRank = params.upperRank ?? 0.90;
|
|
1244
|
+
const lowerRank = params.lowerRank ?? 0.10;
|
|
1245
|
+
const fp = ctx.fundingPercentile;
|
|
1246
|
+
if (!fp) {
|
|
1247
|
+
return {
|
|
1248
|
+
met: true,
|
|
1249
|
+
value: 0,
|
|
1250
|
+
threshold: 0,
|
|
1251
|
+
description: 'Funding percentile unavailable (FUNDING_OVERLAY off or no data — fail-open)',
|
|
1252
|
+
};
|
|
1253
|
+
}
|
|
1254
|
+
const rate_bps = fp.current * 10000;
|
|
1255
|
+
let met = true;
|
|
1256
|
+
let why = '';
|
|
1257
|
+
if (direction === 'LONG' && fp.percentileRank > upperRank) {
|
|
1258
|
+
met = false;
|
|
1259
|
+
why = `rank ${(fp.percentileRank * 100).toFixed(0)}th > ${(upperRank * 100).toFixed(0)}th — crowded longs (rate ${rate_bps.toFixed(2)} bps), block LONG`;
|
|
1260
|
+
}
|
|
1261
|
+
else if (direction === 'SHORT' && fp.percentileRank < lowerRank) {
|
|
1262
|
+
met = false;
|
|
1263
|
+
why = `rank ${(fp.percentileRank * 100).toFixed(0)}th < ${(lowerRank * 100).toFixed(0)}th — crowded shorts (rate ${rate_bps.toFixed(2)} bps), block SHORT`;
|
|
1264
|
+
}
|
|
1265
|
+
else {
|
|
1266
|
+
why = `rank ${(fp.percentileRank * 100).toFixed(0)}th, dir=${direction ?? 'null'} (funding OK for this direction)`;
|
|
1267
|
+
}
|
|
1268
|
+
return {
|
|
1269
|
+
met,
|
|
1270
|
+
value: Math.round(fp.percentileRank * 100) / 100,
|
|
1271
|
+
threshold: direction === 'LONG' ? upperRank : direction === 'SHORT' ? lowerRank : 0,
|
|
1272
|
+
description: why,
|
|
1273
|
+
};
|
|
1274
|
+
});
|