@reefclaw/connect 0.1.18 → 0.1.20

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.
Files changed (35) hide show
  1. package/assets/bridge/gateway/event-parser.d.ts +42 -0
  2. package/assets/bridge/gateway/event-parser.js +88 -0
  3. package/assets/bridge/gateway/heartbeat-cron.d.ts +1 -0
  4. package/assets/bridge/gateway/heartbeat-cron.js +22 -1
  5. package/assets/bridge/providers/gateway.d.ts +49 -0
  6. package/assets/bridge/providers/gateway.js +167 -13
  7. package/assets/bridge/types.d.ts +34 -0
  8. package/assets/bridge/utils/identity-name.d.ts +24 -0
  9. package/assets/bridge/utils/identity-name.js +54 -0
  10. package/assets/plugin/ccxt/binance-public.d.ts +4 -2
  11. package/assets/plugin/ccxt/binance-public.js +39 -3
  12. package/assets/plugin/live/proposal-decision-listener.d.ts +20 -0
  13. package/assets/plugin/live/proposal-decision-listener.js +211 -48
  14. package/assets/plugin/openclaw.plugin.json +1 -1
  15. package/assets/plugin/tools/create-order.d.ts +11 -0
  16. package/assets/plugin/tools/create-order.js +23 -2
  17. package/assets/plugin/tools/get-risk-summary.d.ts +4 -0
  18. package/assets/plugin/tools/get-risk-summary.js +62 -23
  19. package/assets/plugin/venues/hyperliquid/hl-bracket-coordinator.d.ts +4 -0
  20. package/assets/plugin/venues/hyperliquid/hl-bracket-coordinator.js +2 -2
  21. package/assets/plugin/venues/hyperliquid/hl-live-adapter.d.ts +12 -0
  22. package/assets/plugin/venues/hyperliquid/hl-live-adapter.js +69 -6
  23. package/assets/plugin/venues/hyperliquid/hl-order.d.ts +35 -0
  24. package/assets/plugin/venues/hyperliquid/hl-order.js +123 -0
  25. package/assets/plugin/venues/hyperliquid/hl-position.d.ts +36 -0
  26. package/assets/plugin/venues/hyperliquid/hl-position.js +127 -0
  27. package/assets/plugin/venues/hyperliquid/hl-private.d.ts +20 -3
  28. package/assets/plugin/venues/hyperliquid/hl-private.js +37 -6
  29. package/dist/cli.js +31 -10
  30. package/dist/gateway-tuning.js +112 -0
  31. package/package.json +1 -1
  32. package/assets/shared/signals/indicators-extended.d.ts +0 -52
  33. package/assets/shared/signals/indicators-extended.js +0 -284
  34. package/assets/shared/signals/indicators.d.ts +0 -15
  35. package/assets/shared/signals/indicators.js +0 -107
@@ -49,16 +49,33 @@ export declare class HyperliquidPrivateApi {
49
49
  getExchange(): any;
50
50
  loadMarkets(): Promise<boolean>;
51
51
  /** Positions for the MASTER account. `null` = fetch failed (state unknown);
52
- * `[]` = the exchange confirmed flat. */
52
+ * `[]` = the exchange confirmed flat.
53
+ *
54
+ * ★ Rows are normalized through `normalizeHlPosition` — ccxt leaves
55
+ * `markPrice`/`contractSize`/`timestamp`/`datetime` UNDEFINED on this venue
56
+ * (clearinghouseState carries no markPx) while `CcxtPosition` declares them
57
+ * required, which crashed `get_risk_summary` and degraded every other
58
+ * mark-reading consumer. See hl-position.ts for the doc-exact derivation.
59
+ * A row that cannot be priced makes the WHOLE snapshot null (unknown) — never
60
+ * a partial list, never a fabricated zero. */
53
61
  fetchPositions(symbol?: string): Promise<CcxtPosition[] | null>;
54
62
  /** Open orders INCLUDING trigger/TPSL legs. CCXT's HL `fetchOpenOrders`
55
63
  * defaults to `frontendOpenOrders`, which is the only endpoint that returns
56
- * trigger orders (plan §3.6) — the analog of Binance's merged algo endpoints. */
64
+ * trigger orders (plan §3.6) — the analog of Binance's merged algo endpoints.
65
+ *
66
+ * ★ Rows are normalized through `normalizeHlOrder` (ccxt leaves cost/fee/
67
+ * average/timeInForce undefined and leaks 'take profit market' past the
68
+ * type union — see hl-order.ts); `info` + `clientOrderId` survive verbatim
69
+ * (protective classification + bracket reconciliation read them). */
57
70
  fetchOpenOrders(symbol?: string): Promise<CcxtOrder[] | null>;
58
71
  fetchBalance(): Promise<CcxtBalance | null>;
59
72
  /** Per-order status — the liveness resolver's REST tier (Tier 2 of the
60
73
  * 3-tier rule). Weight 2. `null` = lookup FAILED (unknown), which callers
61
- * must treat as "do not act", NOT as "gone". */
74
+ * must treat as "do not act", NOT as "gone".
75
+ *
76
+ * Normalized like fetchOpenOrders. Unknown statuses collapse to 'open'
77
+ * (= not confirmed terminal — the conservative direction for both the
78
+ * liveness resolver and the entry poller); raw status stays in `info`. */
62
79
  fetchOrder(orderId: string, symbol?: string): Promise<CcxtOrder | null>;
63
80
  /** Own fills. WS is the authoritative ingress (plan + the audit-trail rule);
64
81
  * this is the gap-fill/truth-check path. NOTE: only the 10,000 most recent
@@ -29,6 +29,8 @@ import { logger } from '../../logger.js';
29
29
  import { toCcxtSymbol } from '../symbols.js';
30
30
  import { assertNotLimited, noteError, noteSuccess, exchangeIpWeight, updateAddressBudget, } from './hl-rate-gate.js';
31
31
  import { isValidHlCloid } from './hl-cloid.js';
32
+ import { normalizeHlPosition } from './hl-position.js';
33
+ import { normalizeHlOrder } from './hl-order.js';
32
34
  // ccxt via CJS require — OpenClaw's ESM loader yields the wrong module shape
33
35
  // (same rationale as binance-private.ts / hl-public.ts).
34
36
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -124,7 +126,15 @@ export class HyperliquidPrivateApi {
124
126
  }
125
127
  // ---- Reads (null on failure — NEVER []) ----
126
128
  /** Positions for the MASTER account. `null` = fetch failed (state unknown);
127
- * `[]` = the exchange confirmed flat. */
129
+ * `[]` = the exchange confirmed flat.
130
+ *
131
+ * ★ Rows are normalized through `normalizeHlPosition` — ccxt leaves
132
+ * `markPrice`/`contractSize`/`timestamp`/`datetime` UNDEFINED on this venue
133
+ * (clearinghouseState carries no markPx) while `CcxtPosition` declares them
134
+ * required, which crashed `get_risk_summary` and degraded every other
135
+ * mark-reading consumer. See hl-position.ts for the doc-exact derivation.
136
+ * A row that cannot be priced makes the WHOLE snapshot null (unknown) — never
137
+ * a partial list, never a fabricated zero. */
128
138
  async fetchPositions(symbol) {
129
139
  try {
130
140
  assertNotLimited('clearinghouseState');
@@ -133,7 +143,19 @@ export class HyperliquidPrivateApi {
133
143
  noteSuccess('clearinghouseState');
134
144
  if (!Array.isArray(raw))
135
145
  return null;
136
- return raw.filter((p) => Math.abs(Number(p.contracts ?? 0)) > 0);
146
+ const out = [];
147
+ for (const p of raw) {
148
+ if (Math.abs(Number(p?.contracts ?? 0)) <= 0)
149
+ continue; // flat row — information, not failure
150
+ const normalized = normalizeHlPosition(p);
151
+ if (!normalized) {
152
+ logger.error(TAG, `fetchPositions: unparseable position row for ${String(p?.symbol ?? 'unknown symbol')} ` +
153
+ '— returning null (snapshot UNTRUSTED; a dropped row would read as "flat")');
154
+ return null;
155
+ }
156
+ out.push(normalized);
157
+ }
158
+ return out;
137
159
  }
138
160
  catch (err) {
139
161
  noteError(err, 'fetchPositions');
@@ -143,14 +165,19 @@ export class HyperliquidPrivateApi {
143
165
  }
144
166
  /** Open orders INCLUDING trigger/TPSL legs. CCXT's HL `fetchOpenOrders`
145
167
  * defaults to `frontendOpenOrders`, which is the only endpoint that returns
146
- * trigger orders (plan §3.6) — the analog of Binance's merged algo endpoints. */
168
+ * trigger orders (plan §3.6) — the analog of Binance's merged algo endpoints.
169
+ *
170
+ * ★ Rows are normalized through `normalizeHlOrder` (ccxt leaves cost/fee/
171
+ * average/timeInForce undefined and leaks 'take profit market' past the
172
+ * type union — see hl-order.ts); `info` + `clientOrderId` survive verbatim
173
+ * (protective classification + bracket reconciliation read them). */
147
174
  async fetchOpenOrders(symbol) {
148
175
  try {
149
176
  assertNotLimited('frontendOpenOrders');
150
177
  const s = symbol ? toCcxtSymbol('hyperliquid', symbol) : undefined;
151
178
  const raw = await this.exchange.fetchOpenOrders(s);
152
179
  noteSuccess('frontendOpenOrders');
153
- return Array.isArray(raw) ? raw : null;
180
+ return Array.isArray(raw) ? raw.map((o) => normalizeHlOrder(o)) : null;
154
181
  }
155
182
  catch (err) {
156
183
  noteError(err, 'fetchOpenOrders');
@@ -173,14 +200,18 @@ export class HyperliquidPrivateApi {
173
200
  }
174
201
  /** Per-order status — the liveness resolver's REST tier (Tier 2 of the
175
202
  * 3-tier rule). Weight 2. `null` = lookup FAILED (unknown), which callers
176
- * must treat as "do not act", NOT as "gone". */
203
+ * must treat as "do not act", NOT as "gone".
204
+ *
205
+ * Normalized like fetchOpenOrders. Unknown statuses collapse to 'open'
206
+ * (= not confirmed terminal — the conservative direction for both the
207
+ * liveness resolver and the entry poller); raw status stays in `info`. */
177
208
  async fetchOrder(orderId, symbol) {
178
209
  try {
179
210
  assertNotLimited('orderStatus');
180
211
  const s = symbol ? toCcxtSymbol('hyperliquid', symbol) : undefined;
181
212
  const raw = await this.exchange.fetchOrder(orderId, s);
182
213
  noteSuccess('orderStatus');
183
- return raw ?? null;
214
+ return raw ? normalizeHlOrder(raw) : null;
184
215
  }
185
216
  catch (err) {
186
217
  noteError(err, 'fetchOrder');
package/dist/cli.js CHANGED
@@ -7,10 +7,14 @@
7
7
  // message from reefclaw.com/onboarding to their agent (paste-to-agent stays the
8
8
  // primary connect path). The bridge reads that config from openclaw.json and
9
9
  // connects within seconds.
10
+ import { existsSync } from 'node:fs';
11
+ import { homedir } from 'node:os';
12
+ import { join } from 'node:path';
10
13
  import { installPlugin } from './plugin.js';
11
14
  import { installBridge } from './bridge.js';
12
15
  import { installSkill } from './skill.js';
13
16
  import { enableConnectorSupervisor } from './supervisor-config.js';
17
+ import { tuneGatewayMemory } from './gateway-tuning.js';
14
18
  import { checkGateway, checkBinanceRegion } from './validate.js';
15
19
  import { readConfig, writeConfig, mergeReefClawConfig, gatewayAuthDowngradeNeeded, openClawInstalled, openClawConfigPath, readGatewayPort, } from './openclaw.js';
16
20
  import { run, which } from './exec.js';
@@ -92,21 +96,35 @@ function wireConfig(pre) {
92
96
  /** The gateway only loads plugins at startup — a plugin registered while it
93
97
  * runs stays invisible until restart (the bridge would log "No trading tools
94
98
  * found" forever). Best-effort: a user without a managed gateway just gets
95
- * the manual hint. */
99
+ * the manual hint.
100
+ *
101
+ * Fallback: on systemd-user installs `openclaw gateway restart` can fail
102
+ * while `systemctl --user restart` works (observed live 2026-07-24 — the
103
+ * installer printed "could not restart automatically" and the new plugin
104
+ * never loaded until a manual systemctl restart). Try the unit directly
105
+ * before giving up. */
96
106
  function restartGateway() {
97
107
  step('Restarting the OpenClaw gateway to load the plugin');
98
- if (!which('openclaw')) {
99
- info('openclaw CLI not on PATH — restart your gateway manually to load the trading tools.');
100
- return;
101
- }
102
- const r = run('openclaw', ['gateway', 'restart'], { timeoutMs: 120_000 });
103
- if (r.ok) {
104
- ok('gateway restarted');
108
+ if (which('openclaw')) {
109
+ const r = run('openclaw', ['gateway', 'restart'], { timeoutMs: 120_000 });
110
+ if (r.ok) {
111
+ ok('gateway restarted');
112
+ return;
113
+ }
105
114
  }
106
115
  else {
107
- warn('could not restart the gateway automatically.');
108
- info('Restart it yourself (e.g. `openclaw gateway restart`) so the trading tools load.');
116
+ info('openclaw CLI not on PATH — trying the systemd user service directly.');
117
+ }
118
+ if (process.platform === 'linux' &&
119
+ existsSync(join(homedir(), '.config', 'systemd', 'user', 'openclaw-gateway.service'))) {
120
+ const s = run('systemctl', ['--user', 'restart', 'openclaw-gateway'], { timeoutMs: 120_000 });
121
+ if (s.ok) {
122
+ ok('gateway restarted (systemd user service)');
123
+ return;
124
+ }
109
125
  }
126
+ warn('could not restart the gateway automatically.');
127
+ info('Restart it yourself (e.g. `openclaw gateway restart`) so the trading tools load.');
110
128
  }
111
129
  function nextSteps() {
112
130
  banner(green('✓ ReefClaw is installed.'));
@@ -135,6 +153,9 @@ async function main() {
135
153
  // launchd/Task-Scheduler, works in containers. The gateway restart below is
136
154
  // therefore also what STARTS the connector.
137
155
  const supervised = enableConnectorSupervisor();
156
+ // Size the gateway heap cap to this box BEFORE the restart below picks it up.
157
+ // Re-runs after a VPS resize; leaves operator-authored overrides untouched.
158
+ tuneGatewayMemory();
138
159
  if (plugin.registered)
139
160
  restartGateway();
140
161
  await checkGateway(readGatewayPort(merged));
@@ -0,0 +1,112 @@
1
+ // Box-size-aware gateway memory tuning (systemd-user installs only).
2
+ //
3
+ // Writes a NODE_OPTIONS heap cap + cgroup MemoryHigh override for the
4
+ // openclaw-gateway user service, sized to the box's total RAM. Two field-proven
5
+ // operating points (2026-07-22 / 2026-07-24 incidents):
6
+ // - ~2 GB box: uncapped node children balloon into the kernel OOM killer
7
+ // (heartbeats die at ~10 min) → cap 320 MB, MemoryHigh 1500M.
8
+ // - ~4 GB box: a STALE 320 MB cap left over from before a resize makes the
9
+ // gateway V8-OOM mid-heartbeat every ~15 min ("FATAL ERROR: Reached heap
10
+ // limit") with 2.5 GB free → cap 1536 MB, MemoryHigh 3000M.
11
+ // The second incident is why this module re-runs on every install/update and
12
+ // OVERWRITES a stale ReefClaw-authored override: resizing the droplet does not
13
+ // raise the cap by itself. Files we did not author are never touched.
14
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
15
+ import { homedir, totalmem } from 'node:os';
16
+ import { join } from 'node:path';
17
+ import { run } from './exec.js';
18
+ import { step, ok, info, warn } from './ui.js';
19
+ const GiB = 1024 * 1024 * 1024;
20
+ /** First line of every override we write — the ownership marker that makes the
21
+ * file safe to overwrite on the next run. Operator-authored files without it
22
+ * are left alone. */
23
+ export const REEFCLAW_TUNING_MARKER = '# ReefClaw gateway memory tuning';
24
+ /** Pick the tuning for a box, or null when the box is large enough that node's
25
+ * defaults are fine and no override should be written. Pure — unit-testable. */
26
+ export function computeHeapTuning(totalBytes) {
27
+ if (totalBytes <= 2.75 * GiB) {
28
+ // 1-2 GB class: survival mode. Node children must GC hard instead of
29
+ // ballooning into the kernel OOM killer.
30
+ return { maxOldSpaceMb: 320, memoryHigh: '1500M' };
31
+ }
32
+ if (totalBytes <= 6 * GiB) {
33
+ // 4 GB class (the recommended floor): room to breathe, still backstopped
34
+ // below the kernel OOM threshold.
35
+ return { maxOldSpaceMb: 1536, memoryHigh: '3000M' };
36
+ }
37
+ // 8 GB+: node's own defaults are appropriate; do not constrain.
38
+ return null;
39
+ }
40
+ /** Render the override file content for a tuning. */
41
+ export function renderTuningConf(tuning, totalBytes) {
42
+ const totalGb = (totalBytes / GiB).toFixed(1);
43
+ return (`${REEFCLAW_TUNING_MARKER} — written by @reefclaw/connect for a ${totalGb} GB box.\n` +
44
+ `# Re-running the installer after a VPS resize regenerates this file with the\n` +
45
+ `# right cap (a stale small-box cap V8-OOMs the gateway mid-heartbeat).\n` +
46
+ `# Delete this file and its marker line to manage these values yourself.\n` +
47
+ `[Service]\n` +
48
+ `Environment=NODE_OPTIONS=--max-old-space-size=${tuning.maxOldSpaceMb}\n` +
49
+ `MemoryHigh=${tuning.memoryHigh}\n`);
50
+ }
51
+ /** True when the existing override file is ours to overwrite (absent, or
52
+ * carries a ReefClaw marker — including the pre-installer hand-written
53
+ * variants whose first line starts with "# ReefClaw"). Pure. */
54
+ export function canReplaceOverride(existingContent) {
55
+ if (existingContent === null)
56
+ return true;
57
+ return existingContent.trimStart().startsWith('# ReefClaw');
58
+ }
59
+ /** Best-effort: returns true when the override is in place (written now or
60
+ * already correct), false when skipped. Never throws — resource tuning must
61
+ * not fail an install. */
62
+ export function tuneGatewayMemory() {
63
+ if (process.platform !== 'linux')
64
+ return false;
65
+ const unitPath = join(homedir(), '.config', 'systemd', 'user', 'openclaw-gateway.service');
66
+ if (!existsSync(unitPath)) {
67
+ // Container / non-systemd install (e.g. the connector-supervisor path) —
68
+ // there is no unit to override.
69
+ return false;
70
+ }
71
+ step('Tuning gateway memory for this box');
72
+ try {
73
+ const tuning = computeHeapTuning(totalmem());
74
+ const dropInDir = `${unitPath}.d`;
75
+ // Historical name kept for continuity with the hand-written mitigation
76
+ // files already in the field (same file = systemd merge rules stay simple).
77
+ const confPath = join(dropInDir, 'small-box.conf');
78
+ const existing = existsSync(confPath) ? readFileSync(confPath, 'utf-8') : null;
79
+ if (!canReplaceOverride(existing)) {
80
+ info(`found an operator-managed override at ${confPath} — leaving it untouched.`);
81
+ return false;
82
+ }
83
+ if (tuning === null) {
84
+ if (existing !== null) {
85
+ info('box has ample RAM — existing ReefClaw override left as-is (delete it to use node defaults).');
86
+ }
87
+ else {
88
+ ok('box has ample RAM — no memory override needed');
89
+ }
90
+ return false;
91
+ }
92
+ const content = renderTuningConf(tuning, totalmem());
93
+ if (existing === content) {
94
+ ok(`gateway memory override already current (heap ${tuning.maxOldSpaceMb} MB, MemoryHigh ${tuning.memoryHigh})`);
95
+ return true;
96
+ }
97
+ mkdirSync(dropInDir, { recursive: true });
98
+ writeFileSync(confPath, content, 'utf-8');
99
+ // Reload so the values apply on the next service (re)start — the gateway
100
+ // restart later in the install flow picks them up.
101
+ const reload = run('systemctl', ['--user', 'daemon-reload'], { timeoutMs: 30_000 });
102
+ if (!reload.ok) {
103
+ warn('wrote the memory override but `systemctl --user daemon-reload` failed — it applies after the next reload/restart.');
104
+ }
105
+ ok(`gateway heap cap set to ${tuning.maxOldSpaceMb} MB (MemoryHigh ${tuning.memoryHigh}) for this box size`);
106
+ return true;
107
+ }
108
+ catch (err) {
109
+ warn(`could not tune gateway memory: ${err instanceof Error ? err.message : String(err)}`);
110
+ return false;
111
+ }
112
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reefclaw/connect",
3
- "version": "0.1.18",
3
+ "version": "0.1.20",
4
4
  "description": "One-command installer that connects your OpenClaw agent to ReefClaw (paper trading, no exchange keys).",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,52 +0,0 @@
1
- export interface OhlcvInput {
2
- open: number;
3
- high: number;
4
- low: number;
5
- close: number;
6
- volume: number;
7
- }
8
- export interface MACDResult {
9
- line: number;
10
- signal: number;
11
- histogram: number;
12
- crossover: 'bullish' | 'bearish' | 'none';
13
- }
14
- export interface BollingerResult {
15
- upper: number;
16
- middle: number;
17
- lower: number;
18
- bandwidth: number;
19
- percentB: number;
20
- }
21
- export interface StochRSIResult {
22
- k: number;
23
- d: number;
24
- }
25
- export interface IchimokuResult {
26
- tenkan: number;
27
- kijun: number;
28
- senkouA: number;
29
- senkouB: number;
30
- chikou: number;
31
- cloudPosition: 'above' | 'below' | 'inside';
32
- }
33
- export interface SupertrendResult {
34
- value: number;
35
- direction: 'bullish' | 'bearish';
36
- }
37
- export declare function computeMACD(closes: number[], fast?: number, slow?: number, signal?: number): MACDResult;
38
- export declare function computeBollingerBands(closes: number[], period?: number, stdDev?: number): BollingerResult;
39
- export declare function computeVWAP(bars: OhlcvInput[]): number;
40
- export declare function computeStochRSI(closes: number[], rsiPeriod?: number, stochPeriod?: number, kSmooth?: number, dSmooth?: number): StochRSIResult;
41
- export declare function computeADX(highs: number[], lows: number[], closes: number[], period?: number): {
42
- adx: number;
43
- plusDI: number;
44
- minusDI: number;
45
- };
46
- export declare function computeIchimoku(highs: number[], lows: number[], closes: number[], tenkanPeriod?: number, kijunPeriod?: number, senkouBPeriod?: number): IchimokuResult;
47
- export declare function computeOBV(closes: number[], volumes: number[]): {
48
- obv: number;
49
- slope: 'rising' | 'falling' | 'flat';
50
- };
51
- export declare function computeSupertrend(highs: number[], lows: number[], closes: number[], period?: number, multiplier?: number): SupertrendResult;
52
- export declare function computeWilliamsR(highs: number[], lows: number[], closes: number[], period?: number): number;
@@ -1,284 +0,0 @@
1
- // Extended indicator computations for Phase 13 — Expanded Indicators.
2
- // Wraps technicalindicators library + custom implementations.
3
- // All functions take OHLCV arrays (oldest first) and return latest values.
4
- import { computeATRSeries, computeRSI, mean, computeStd } from './indicators.js';
5
- // ─── MACD (12, 26, 9) ──────────────────────────────────────────────────
6
- export function computeMACD(closes, fast = 12, slow = 26, signal = 9) {
7
- if (closes.length < slow + signal) {
8
- return { line: 0, signal: 0, histogram: 0, crossover: 'none' };
9
- }
10
- // Compute full EMA series (both aligned to start at index `slow - 1`)
11
- const emaFastSeries = emaSeries(closes, fast);
12
- const emaSlowSeries = emaSeries(closes, slow);
13
- // Align: fast series starts earlier, so take the tail matching slow series length
14
- const offset = emaFastSeries.length - emaSlowSeries.length;
15
- const macdLine = [];
16
- for (let i = 0; i < emaSlowSeries.length; i++) {
17
- macdLine.push(emaFastSeries[i + offset] - emaSlowSeries[i]);
18
- }
19
- // Signal line = EMA of MACD line
20
- const signalSeries = emaSeries(macdLine, signal);
21
- const sigOffset = macdLine.length - signalSeries.length;
22
- const currentLine = macdLine[macdLine.length - 1];
23
- const currentSignal = signalSeries[signalSeries.length - 1];
24
- const prevLine = macdLine.length >= 2 ? macdLine[macdLine.length - 2] : currentLine;
25
- const prevSignalIdx = signalSeries.length >= 2 ? signalSeries.length - 2 : signalSeries.length - 1;
26
- const prevSignal = signalSeries[prevSignalIdx];
27
- let crossover = 'none';
28
- if (prevLine <= prevSignal && currentLine > currentSignal)
29
- crossover = 'bullish';
30
- else if (prevLine >= prevSignal && currentLine < currentSignal)
31
- crossover = 'bearish';
32
- return {
33
- line: currentLine,
34
- signal: currentSignal,
35
- histogram: currentLine - currentSignal,
36
- crossover,
37
- };
38
- }
39
- // ─── Bollinger Bands (20, 2σ) ───────────────────────────────────────────
40
- export function computeBollingerBands(closes, period = 20, stdDev = 2) {
41
- if (closes.length < period) {
42
- const p = closes[closes.length - 1] ?? 0;
43
- return { upper: p, middle: p, lower: p, bandwidth: 0, percentB: 0.5 };
44
- }
45
- const slice = closes.slice(-period);
46
- const middle = mean(slice);
47
- const std = computeStd(slice);
48
- const upper = middle + stdDev * std;
49
- const lower = middle - stdDev * std;
50
- const bandwidth = middle > 0 ? ((upper - lower) / middle) * 100 : 0;
51
- const price = closes[closes.length - 1];
52
- const percentB = upper !== lower ? (price - lower) / (upper - lower) : 0.5;
53
- return { upper, middle, lower, bandwidth, percentB };
54
- }
55
- // ─── VWAP ───────────────────────────────────────────────────────────────
56
- export function computeVWAP(bars) {
57
- if (bars.length === 0)
58
- return 0;
59
- let cumVolume = 0;
60
- let cumTPxVol = 0;
61
- for (const bar of bars) {
62
- const tp = (bar.high + bar.low + bar.close) / 3;
63
- cumVolume += bar.volume;
64
- cumTPxVol += tp * bar.volume;
65
- }
66
- return cumVolume > 0 ? cumTPxVol / cumVolume : bars[bars.length - 1].close;
67
- }
68
- // ─── Stochastic RSI (14, 14, 3, 3) ─────────────────────────────────────
69
- export function computeStochRSI(closes, rsiPeriod = 14, stochPeriod = 14, kSmooth = 3, dSmooth = 3) {
70
- if (closes.length < rsiPeriod + stochPeriod + dSmooth) {
71
- return { k: 50, d: 50 };
72
- }
73
- // Compute RSI series
74
- const rsiValues = [];
75
- for (let i = rsiPeriod + 1; i <= closes.length; i++) {
76
- rsiValues.push(computeRSI(closes.slice(0, i), rsiPeriod));
77
- }
78
- if (rsiValues.length < stochPeriod)
79
- return { k: 50, d: 50 };
80
- // Stochastic of RSI
81
- const rawK = [];
82
- for (let i = stochPeriod - 1; i < rsiValues.length; i++) {
83
- const window = rsiValues.slice(i - stochPeriod + 1, i + 1);
84
- const min = Math.min(...window);
85
- const max = Math.max(...window);
86
- rawK.push(max !== min ? ((rsiValues[i] - min) / (max - min)) * 100 : 50);
87
- }
88
- // %K = SMA of raw stochastic
89
- const kValues = sma(rawK, kSmooth);
90
- // %D = SMA of %K
91
- const dValues = sma(kValues, dSmooth);
92
- return {
93
- k: Math.round(kValues[kValues.length - 1] ?? 50),
94
- d: Math.round(dValues[dValues.length - 1] ?? 50),
95
- };
96
- }
97
- // ─── ADX (14) ───────────────────────────────────────────────────────────
98
- export function computeADX(highs, lows, closes, period = 14) {
99
- if (highs.length < period * 2 + 1) {
100
- return { adx: 0, plusDI: 0, minusDI: 0 };
101
- }
102
- const plusDM = [];
103
- const minusDM = [];
104
- const tr = [];
105
- for (let i = 1; i < highs.length; i++) {
106
- const upMove = highs[i] - highs[i - 1];
107
- const downMove = lows[i - 1] - lows[i];
108
- plusDM.push(upMove > downMove && upMove > 0 ? upMove : 0);
109
- minusDM.push(downMove > upMove && downMove > 0 ? downMove : 0);
110
- tr.push(Math.max(highs[i] - lows[i], Math.abs(highs[i] - closes[i - 1]), Math.abs(lows[i] - closes[i - 1])));
111
- }
112
- // Smooth with Wilder's smoothing (equivalent to EMA with alpha=1/period)
113
- const smoothPlusDM = wilderSmooth(plusDM, period);
114
- const smoothMinusDM = wilderSmooth(minusDM, period);
115
- const smoothTR = wilderSmooth(tr, period);
116
- // +DI and -DI series
117
- const plusDISeries = [];
118
- const minusDISeries = [];
119
- for (let i = 0; i < smoothTR.length; i++) {
120
- plusDISeries.push(smoothTR[i] > 0 ? (smoothPlusDM[i] / smoothTR[i]) * 100 : 0);
121
- minusDISeries.push(smoothTR[i] > 0 ? (smoothMinusDM[i] / smoothTR[i]) * 100 : 0);
122
- }
123
- // DX series
124
- const dxSeries = [];
125
- for (let i = 0; i < plusDISeries.length; i++) {
126
- const sum = plusDISeries[i] + minusDISeries[i];
127
- dxSeries.push(sum > 0 ? (Math.abs(plusDISeries[i] - minusDISeries[i]) / sum) * 100 : 0);
128
- }
129
- // ADX = Wilder smooth of DX
130
- const adxSeries = wilderSmooth(dxSeries, period);
131
- return {
132
- adx: Math.round(adxSeries[adxSeries.length - 1] ?? 0),
133
- plusDI: Math.round(plusDISeries[plusDISeries.length - 1] ?? 0),
134
- minusDI: Math.round(minusDISeries[minusDISeries.length - 1] ?? 0),
135
- };
136
- }
137
- // ─── Ichimoku Cloud ─────────────────────────────────────────────────────
138
- export function computeIchimoku(highs, lows, closes, tenkanPeriod = 9, kijunPeriod = 26, senkouBPeriod = 52) {
139
- const n = highs.length;
140
- if (n < senkouBPeriod) {
141
- const p = closes[n - 1] ?? 0;
142
- return { tenkan: p, kijun: p, senkouA: p, senkouB: p, chikou: p, cloudPosition: 'inside' };
143
- }
144
- const midpoint = (arr, period, end) => {
145
- const slice = arr.slice(Math.max(0, end - period + 1), end + 1);
146
- return (Math.max(...slice) + Math.min(...slice)) / 2;
147
- };
148
- const tenkan = midpoint(highs.concat().map((h, i) => Math.max(h, lows[i])), tenkanPeriod, n - 1);
149
- const kijun = midpoint(highs.concat().map((h, i) => Math.max(h, lows[i])), kijunPeriod, n - 1);
150
- // Recalculate properly using highs and lows separately
151
- const tenkanH = Math.max(...highs.slice(-tenkanPeriod));
152
- const tenkanL = Math.min(...lows.slice(-tenkanPeriod));
153
- const tenkanVal = (tenkanH + tenkanL) / 2;
154
- const kijunH = Math.max(...highs.slice(-kijunPeriod));
155
- const kijunL = Math.min(...lows.slice(-kijunPeriod));
156
- const kijunVal = (kijunH + kijunL) / 2;
157
- const senkouA = (tenkanVal + kijunVal) / 2;
158
- const senkouBH = Math.max(...highs.slice(-senkouBPeriod));
159
- const senkouBL = Math.min(...lows.slice(-senkouBPeriod));
160
- const senkouB = (senkouBH + senkouBL) / 2;
161
- const chikou = closes[n - 1]; // Current close (projected back 26 periods)
162
- const price = closes[n - 1];
163
- const cloudTop = Math.max(senkouA, senkouB);
164
- const cloudBottom = Math.min(senkouA, senkouB);
165
- const cloudPosition = price > cloudTop ? 'above' : price < cloudBottom ? 'below' : 'inside';
166
- return { tenkan: tenkanVal, kijun: kijunVal, senkouA, senkouB, chikou, cloudPosition };
167
- }
168
- // ─── OBV (On-Balance Volume) ────────────────────────────────────────────
169
- export function computeOBV(closes, volumes) {
170
- if (closes.length < 2)
171
- return { obv: 0, slope: 'flat' };
172
- let obv = 0;
173
- const obvSeries = [0];
174
- for (let i = 1; i < closes.length; i++) {
175
- if (closes[i] > closes[i - 1])
176
- obv += volumes[i];
177
- else if (closes[i] < closes[i - 1])
178
- obv -= volumes[i];
179
- obvSeries.push(obv);
180
- }
181
- // Slope over last 10 bars
182
- const lookback = Math.min(10, obvSeries.length);
183
- const recent = obvSeries.slice(-lookback);
184
- const first = recent[0];
185
- const last = recent[recent.length - 1];
186
- const threshold = Math.abs(first) * 0.01; // 1% threshold
187
- const slope = last - first > threshold ? 'rising' : last - first < -threshold ? 'falling' : 'flat';
188
- return { obv, slope };
189
- }
190
- // ─── Supertrend (10, 3) ─────────────────────────────────────────────────
191
- export function computeSupertrend(highs, lows, closes, period = 10, multiplier = 3) {
192
- const atrSeries = computeATRSeries(highs, lows, closes, period);
193
- if (atrSeries.length === 0) {
194
- return { value: closes[closes.length - 1] ?? 0, direction: 'bullish' };
195
- }
196
- // ATR series starts at index 1 (needs previous close for TR)
197
- // Align: atrSeries[i] corresponds to bar index i+1
198
- let upperBand = 0;
199
- let lowerBand = 0;
200
- let supertrend = 0;
201
- let direction = 'bullish';
202
- for (let i = 0; i < atrSeries.length; i++) {
203
- const barIdx = i + 1; // offset for TR calculation
204
- const hl2 = (highs[barIdx] + lows[barIdx]) / 2;
205
- const atr = atrSeries[i];
206
- const basicUpper = hl2 + multiplier * atr;
207
- const basicLower = hl2 - multiplier * atr;
208
- upperBand = i > 0 && basicUpper < upperBand && closes[barIdx - 1] > upperBand ? upperBand : basicUpper;
209
- lowerBand = i > 0 && basicLower > lowerBand && closes[barIdx - 1] < lowerBand ? lowerBand : basicLower;
210
- if (i === 0) {
211
- supertrend = closes[barIdx] > upperBand ? lowerBand : upperBand;
212
- direction = closes[barIdx] > upperBand ? 'bullish' : 'bearish';
213
- }
214
- else {
215
- if (direction === 'bullish') {
216
- if (closes[barIdx] < lowerBand) {
217
- direction = 'bearish';
218
- supertrend = upperBand;
219
- }
220
- else {
221
- supertrend = lowerBand;
222
- }
223
- }
224
- else {
225
- if (closes[barIdx] > upperBand) {
226
- direction = 'bullish';
227
- supertrend = lowerBand;
228
- }
229
- else {
230
- supertrend = upperBand;
231
- }
232
- }
233
- }
234
- }
235
- return { value: supertrend, direction };
236
- }
237
- // ─── Williams %R (14) ───────────────────────────────────────────────────
238
- export function computeWilliamsR(highs, lows, closes, period = 14) {
239
- if (highs.length < period)
240
- return -50;
241
- const recentHighs = highs.slice(-period);
242
- const recentLows = lows.slice(-period);
243
- const hh = Math.max(...recentHighs);
244
- const ll = Math.min(...recentLows);
245
- const close = closes[closes.length - 1];
246
- return hh !== ll ? ((hh - close) / (hh - ll)) * -100 : -50;
247
- }
248
- // ─── Helper: EMA series ─────────────────────────────────────────────────
249
- function emaSeries(data, period) {
250
- if (data.length === 0)
251
- return [];
252
- if (data.length < period)
253
- return [data[data.length - 1]];
254
- const k = 2 / (period + 1);
255
- const result = [];
256
- let ema = mean(data.slice(0, period));
257
- result.push(ema);
258
- for (let i = period; i < data.length; i++) {
259
- ema = data[i] * k + ema * (1 - k);
260
- result.push(ema);
261
- }
262
- return result;
263
- }
264
- // ─── Helper: SMA series ─────────────────────────────────────────────────
265
- function sma(data, period) {
266
- if (data.length < period)
267
- return data.length > 0 ? [mean(data)] : [];
268
- const result = [];
269
- for (let i = period - 1; i < data.length; i++) {
270
- result.push(mean(data.slice(i - period + 1, i + 1)));
271
- }
272
- return result;
273
- }
274
- // ─── Helper: Wilder's smoothing ─────────────────────────────────────────
275
- function wilderSmooth(data, period) {
276
- if (data.length < period)
277
- return [];
278
- const result = [];
279
- result.push(mean(data.slice(0, period)));
280
- for (let i = period; i < data.length; i++) {
281
- result.push((result[result.length - 1] * (period - 1) + data[i]) / period);
282
- }
283
- return result;
284
- }