@nexstone/rift-cli 0.1.1

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 (137) hide show
  1. package/LICENSE +201 -0
  2. package/bin/run.js +22 -0
  3. package/dist/commands/algo.d.ts +32 -0
  4. package/dist/commands/algo.js +719 -0
  5. package/dist/commands/audit.d.ts +13 -0
  6. package/dist/commands/audit.js +37 -0
  7. package/dist/commands/auth-status.d.ts +14 -0
  8. package/dist/commands/auth-status.js +118 -0
  9. package/dist/commands/auth.d.ts +14 -0
  10. package/dist/commands/auth.js +275 -0
  11. package/dist/commands/backtest.d.ts +26 -0
  12. package/dist/commands/backtest.js +283 -0
  13. package/dist/commands/collect/start.d.ts +11 -0
  14. package/dist/commands/collect/start.js +78 -0
  15. package/dist/commands/collect/status.d.ts +6 -0
  16. package/dist/commands/collect/status.js +60 -0
  17. package/dist/commands/compare.d.ts +16 -0
  18. package/dist/commands/compare.js +130 -0
  19. package/dist/commands/config.d.ts +16 -0
  20. package/dist/commands/config.js +143 -0
  21. package/dist/commands/cost.d.ts +20 -0
  22. package/dist/commands/cost.js +104 -0
  23. package/dist/commands/cross-asset.d.ts +14 -0
  24. package/dist/commands/cross-asset.js +39 -0
  25. package/dist/commands/data/fetch.d.ts +15 -0
  26. package/dist/commands/data/fetch.js +82 -0
  27. package/dist/commands/data/list.d.ts +6 -0
  28. package/dist/commands/data/list.js +28 -0
  29. package/dist/commands/data-inventory.d.ts +9 -0
  30. package/dist/commands/data-inventory.js +24 -0
  31. package/dist/commands/deposit.d.ts +10 -0
  32. package/dist/commands/deposit.js +222 -0
  33. package/dist/commands/doctor.d.ts +6 -0
  34. package/dist/commands/doctor.js +87 -0
  35. package/dist/commands/funding-browser.d.ts +12 -0
  36. package/dist/commands/funding-browser.js +33 -0
  37. package/dist/commands/guide.d.ts +6 -0
  38. package/dist/commands/guide.js +15 -0
  39. package/dist/commands/home.d.ts +23 -0
  40. package/dist/commands/home.js +210 -0
  41. package/dist/commands/init.d.ts +7 -0
  42. package/dist/commands/init.js +122 -0
  43. package/dist/commands/install.d.ts +9 -0
  44. package/dist/commands/install.js +89 -0
  45. package/dist/commands/interactive.d.ts +17 -0
  46. package/dist/commands/interactive.js +179 -0
  47. package/dist/commands/lessons.d.ts +12 -0
  48. package/dist/commands/lessons.js +33 -0
  49. package/dist/commands/montecarlo.d.ts +19 -0
  50. package/dist/commands/montecarlo.js +168 -0
  51. package/dist/commands/more.d.ts +11 -0
  52. package/dist/commands/more.js +227 -0
  53. package/dist/commands/new.d.ts +14 -0
  54. package/dist/commands/new.js +306 -0
  55. package/dist/commands/pairs.d.ts +22 -0
  56. package/dist/commands/pairs.js +147 -0
  57. package/dist/commands/perp/close.d.ts +12 -0
  58. package/dist/commands/perp/close.js +57 -0
  59. package/dist/commands/perp/long.d.ts +14 -0
  60. package/dist/commands/perp/long.js +38 -0
  61. package/dist/commands/perp/short.d.ts +14 -0
  62. package/dist/commands/perp/short.js +27 -0
  63. package/dist/commands/perp/status.d.ts +9 -0
  64. package/dist/commands/perp/status.js +26 -0
  65. package/dist/commands/portfolio/alerts.d.ts +6 -0
  66. package/dist/commands/portfolio/alerts.js +47 -0
  67. package/dist/commands/portfolio/backtest.d.ts +12 -0
  68. package/dist/commands/portfolio/backtest.js +178 -0
  69. package/dist/commands/portfolio/create.d.ts +7 -0
  70. package/dist/commands/portfolio/create.js +195 -0
  71. package/dist/commands/portfolio/start.d.ts +9 -0
  72. package/dist/commands/portfolio/start.js +64 -0
  73. package/dist/commands/portfolio/status.d.ts +6 -0
  74. package/dist/commands/portfolio/status.js +128 -0
  75. package/dist/commands/portfolio/stop.d.ts +6 -0
  76. package/dist/commands/portfolio/stop.js +81 -0
  77. package/dist/commands/portfolio-backtest.d.ts +13 -0
  78. package/dist/commands/portfolio-backtest.js +37 -0
  79. package/dist/commands/portfolio-matrix.d.ts +12 -0
  80. package/dist/commands/portfolio-matrix.js +30 -0
  81. package/dist/commands/quick-test.d.ts +17 -0
  82. package/dist/commands/quick-test.js +45 -0
  83. package/dist/commands/research.d.ts +57 -0
  84. package/dist/commands/research.js +1976 -0
  85. package/dist/commands/scout.d.ts +14 -0
  86. package/dist/commands/scout.js +184 -0
  87. package/dist/commands/serve.d.ts +9 -0
  88. package/dist/commands/serve.js +1176 -0
  89. package/dist/commands/setup/proxy.d.ts +10 -0
  90. package/dist/commands/setup/proxy.js +267 -0
  91. package/dist/commands/spot/buy.d.ts +14 -0
  92. package/dist/commands/spot/buy.js +38 -0
  93. package/dist/commands/spot/sell.d.ts +14 -0
  94. package/dist/commands/spot/sell.js +39 -0
  95. package/dist/commands/strategies/list.d.ts +6 -0
  96. package/dist/commands/strategies/list.js +34 -0
  97. package/dist/commands/sweep.d.ts +19 -0
  98. package/dist/commands/sweep.js +137 -0
  99. package/dist/commands/sync.d.ts +17 -0
  100. package/dist/commands/sync.js +54 -0
  101. package/dist/commands/test-trade.d.ts +6 -0
  102. package/dist/commands/test-trade.js +97 -0
  103. package/dist/commands/trade.d.ts +26 -0
  104. package/dist/commands/trade.js +274 -0
  105. package/dist/commands/transfer.d.ts +13 -0
  106. package/dist/commands/transfer.js +65 -0
  107. package/dist/commands/verify.d.ts +16 -0
  108. package/dist/commands/verify.js +38 -0
  109. package/dist/commands/walkforward.d.ts +20 -0
  110. package/dist/commands/walkforward.js +191 -0
  111. package/dist/commands/withdraw.d.ts +12 -0
  112. package/dist/commands/withdraw.js +55 -0
  113. package/dist/commands/workbench-create.d.ts +13 -0
  114. package/dist/commands/workbench-create.js +39 -0
  115. package/dist/lib/account-mode.d.ts +44 -0
  116. package/dist/lib/account-mode.js +96 -0
  117. package/dist/lib/analyzer.d.ts +4 -0
  118. package/dist/lib/analyzer.js +62 -0
  119. package/dist/lib/base-command.d.ts +35 -0
  120. package/dist/lib/base-command.js +49 -0
  121. package/dist/lib/credentials.d.ts +46 -0
  122. package/dist/lib/credentials.js +137 -0
  123. package/dist/lib/engine-passthrough.d.ts +28 -0
  124. package/dist/lib/engine-passthrough.js +60 -0
  125. package/dist/lib/fees.d.ts +52 -0
  126. package/dist/lib/fees.js +97 -0
  127. package/dist/lib/python-bridge.d.ts +24 -0
  128. package/dist/lib/python-bridge.js +182 -0
  129. package/dist/lib/setup-status.d.ts +32 -0
  130. package/dist/lib/setup-status.js +121 -0
  131. package/dist/lib/status-footer.d.ts +35 -0
  132. package/dist/lib/status-footer.js +101 -0
  133. package/dist/lib/tui.d.ts +130 -0
  134. package/dist/lib/tui.js +300 -0
  135. package/dist/lib/walletconnect.d.ts +70 -0
  136. package/dist/lib/walletconnect.js +407 -0
  137. package/package.json +49 -0
@@ -0,0 +1,1976 @@
1
+ import { Args, Flags } from '@oclif/core';
2
+ import { GatedCommand } from '../lib/base-command.js';
3
+ import { runEngine } from '../lib/python-bridge.js';
4
+ import { green, red, yellow, cyan, bold, dim, colorNum, gradeColor, boxRow, boxTop, boxBottom, boxDivider, boldBoxRow, boldBoxTop, boldBoxBottom, boldBoxDivider, resultRow, padEndVis, ask, } from '../lib/tui.js';
5
+ // Market type color coding
6
+ const marketColor = (m) => {
7
+ if (m === 'All Conditions')
8
+ return cyan(m);
9
+ if (m === 'Adaptive')
10
+ return cyan(m);
11
+ if (m.startsWith('Sideways'))
12
+ return dim(m);
13
+ if (m.includes('Trend'))
14
+ return yellow(m);
15
+ if (m === 'Mean-Reverting')
16
+ return yellow(m);
17
+ if (m === 'Breakout')
18
+ return yellow(m);
19
+ return dim(m);
20
+ };
21
+ // Strategy catalog with descriptions
22
+ // Strategies discovered dynamically from engine — users create their own
23
+ const STRATEGIES = [];
24
+ const TEMPLATES = [
25
+ { key: '1', label: 'Funding template', desc: 'funding rate capture with EMA trend filter', template: 'funding' },
26
+ { key: '2', label: 'VWAP reversion', desc: 'VWAP deviation mean reversion', template: 'vwap_reversion' },
27
+ { key: '3', label: 'Trend following', desc: 'EMA crossover with ADX filter', template: 'trend_follow' },
28
+ { key: '4', label: 'Blank', desc: 'empty strategy, build from scratch', template: 'blank' },
29
+ ];
30
+ export default class Research extends GatedCommand {
31
+ static description = 'Research Lab — discover, test, build, optimize, and compare strategies';
32
+ static examples = [
33
+ '$ rift research',
34
+ '$ rift research my_strategy --pair SUI',
35
+ ];
36
+ static args = {
37
+ strategy: Args.string({ description: 'Strategy name (interactive if omitted)', required: false }),
38
+ };
39
+ static flags = {
40
+ pair: Flags.string({ description: 'Ticker symbol (e.g. BTC, ETH, SOL)', default: 'BTC' }),
41
+ tf: Flags.string({ description: 'Timeframe (auto-detected if omitted)' }),
42
+ equity: Flags.integer({ description: 'Starting equity', default: 10000 }),
43
+ };
44
+ async run() {
45
+ const { args, flags } = await this.parse(Research);
46
+ if (args.strategy) {
47
+ return this.runPipeline(args.strategy, flags.pair, flags.tf, flags.equity);
48
+ }
49
+ return this.mainMenu();
50
+ }
51
+ // ═══════════════════════════════════════════
52
+ // MAIN MENU
53
+ // ═══════════════════════════════════════════
54
+ async mainMenu() {
55
+ // Gather live stats for the dashboard header
56
+ let customCount = 0;
57
+ let testCount = 0;
58
+ try {
59
+ await runEngine('workbench-list', [], (msg) => {
60
+ if (msg.type === 'result') {
61
+ customCount = (msg.strategies || []).length;
62
+ }
63
+ });
64
+ }
65
+ catch { /* empty */ }
66
+ // Find best grade and return from validated strategies (if any).
67
+ // STRATEGIES is currently empty by design (users discover strategies
68
+ // dynamically); guard against an empty-array reduce TypeError.
69
+ const bestStrategy = STRATEGIES.length > 0
70
+ ? STRATEGIES.reduce((a, b) => a.ret > b.ret ? a : b)
71
+ : null;
72
+ const iw = 51; // inner width
73
+ this.log('');
74
+ this.log(boldBoxTop(iw));
75
+ this.log(boldBoxRow(iw)(` ${bold('RESEARCH LAB')}`));
76
+ this.log(boldBoxDivider(iw));
77
+ // Stat boxes — 3 columns
78
+ const col = 16; // column width
79
+ const bestVal = bestStrategy
80
+ ? `${gradeColor(bestStrategy.grade)} ${green('+' + bestStrategy.ret + '%')}`
81
+ : dim('—');
82
+ const valCount = bold(String(STRATEGIES.length));
83
+ const custCount = bold(String(customCount));
84
+ // Labels
85
+ this.log(boldBoxRow(iw)(` ${padEndVis(dim('BEST'), col)}${padEndVis(dim('VALIDATED'), col)}${dim('CUSTOM')}`));
86
+ // Values
87
+ this.log(boldBoxRow(iw)(` ${padEndVis(bestVal, col)}${padEndVis(valCount, col)}${custCount}`));
88
+ this.log(boldBoxDivider(iw));
89
+ // Menu options — use boldBoxRow for proper alignment
90
+ const brow = boldBoxRow(iw);
91
+ this.log(brow(''));
92
+ this.log(brow(` ${cyan('1')} Test ${dim('validate a strategy')}`));
93
+ this.log(brow(` ${cyan('2')} Explore ${dim('browse what works')}`));
94
+ this.log(brow(` ${cyan('3')} Build ${dim('create in the workbench')}`));
95
+ this.log(brow(` ${cyan('4')} Optimize ${dim('find best parameters')}`));
96
+ this.log(brow(` ${cyan('5')} Compare ${dim('head-to-head showdown')}`));
97
+ this.log(brow(''));
98
+ this.log(brow(` ${cyan('0')} ${dim('Exit')}`));
99
+ this.log(brow(''));
100
+ this.log(boldBoxBottom(iw));
101
+ this.log('');
102
+ this.log(` ${dim('Tip:')} ${cyan('rift more')} ${dim('shows every engine command (102 total)')}`);
103
+ this.log('');
104
+ const choice = await ask(` ${cyan('>')} `);
105
+ switch (choice) {
106
+ case '0':
107
+ case 'q':
108
+ case 'b':
109
+ case 'B':
110
+ return;
111
+ case '1': return this.testMenu();
112
+ case '2': return this.exploreMenu();
113
+ case '3': return this.buildMenu();
114
+ case '4': return this.optimizeMenu();
115
+ case '5': return this.compareMenu();
116
+ default:
117
+ if (choice)
118
+ this.log(dim(' Invalid selection.'));
119
+ return this.mainMenu();
120
+ }
121
+ }
122
+ // ═══════════════════════════════════════════
123
+ // SHARED STRATEGY PICKER
124
+ // ═══════════════════════════════════════════
125
+ /**
126
+ * Show a numbered list of all strategies (validated + custom).
127
+ * Returns the selected strategy name, or null if cancelled.
128
+ * If multi=true, allows comma-separated selection and returns names joined by comma.
129
+ */
130
+ async pickStrategy(prompt = 'Select a strategy', multi = false) {
131
+ // Fetch custom workbench strategies
132
+ let customStrategies = [];
133
+ try {
134
+ await runEngine('workbench-list', [], (msg) => {
135
+ if (msg.type === 'result') {
136
+ customStrategies = (msg.strategies || []);
137
+ }
138
+ });
139
+ }
140
+ catch { /* empty */ }
141
+ this.log('');
142
+ this.log(` ${bold(prompt + ':')}`);
143
+ this.log('');
144
+ // Validated strategies
145
+ const allStrategies = [];
146
+ for (const s of STRATEGIES) {
147
+ allStrategies.push({
148
+ name: s.name,
149
+ desc: s.desc,
150
+ tag: `${gradeColor(s.grade)} ${padEndVis(colorNum(s.ret, '%'), 10)}`,
151
+ market: s.market,
152
+ });
153
+ }
154
+ // Custom strategies
155
+ for (const s of customStrategies) {
156
+ allStrategies.push({
157
+ name: String(s.name),
158
+ desc: String(s.description || '').slice(0, 40),
159
+ tag: dim(`v${s.version} custom`),
160
+ market: '',
161
+ });
162
+ }
163
+ // Display
164
+ for (let i = 0; i < allStrategies.length; i++) {
165
+ const s = allStrategies[i];
166
+ const mkt = s.market ? `${padEndVis(marketColor(s.market), 24)} ` : '';
167
+ this.log(` ${cyan(String(i + 1))} ${bold(s.name.padEnd(22))} ${s.tag} ${mkt} ${dim(s.desc)}`);
168
+ }
169
+ this.log(` ${cyan(String(allStrategies.length + 1))} ${dim('Enter custom name')}`);
170
+ this.log('');
171
+ if (multi) {
172
+ this.log(dim(' Enter numbers separated by commas (e.g., 1,2,4)'));
173
+ }
174
+ const choice = await ask(` ${cyan('>')} `);
175
+ if (multi) {
176
+ // Parse comma-separated numbers
177
+ const parts = choice.split(',').map(s => s.trim());
178
+ const names = [];
179
+ for (const part of parts) {
180
+ const idx = parseInt(part) - 1;
181
+ if (idx >= 0 && idx < allStrategies.length) {
182
+ names.push(allStrategies[idx].name);
183
+ }
184
+ }
185
+ if (names.length > 0)
186
+ return names.join(',');
187
+ // Fallback to raw input (they may have typed names)
188
+ return choice || null;
189
+ }
190
+ const idx = parseInt(choice) - 1;
191
+ if (idx >= 0 && idx < allStrategies.length) {
192
+ return allStrategies[idx].name;
193
+ }
194
+ if (parseInt(choice) === allStrategies.length + 1) {
195
+ return await ask(` ${cyan('Strategy name')}: `) || null;
196
+ }
197
+ // If they typed a name directly, use it
198
+ if (choice && isNaN(parseInt(choice)))
199
+ return choice;
200
+ return null;
201
+ }
202
+ // ═══════════════════════════════════════════
203
+ // 1. TEST — Full validation pipeline
204
+ // ═══════════════════════════════════════════
205
+ async testMenu() {
206
+ const strategy = await this.pickStrategy('Test a strategy');
207
+ if (!strategy)
208
+ return this.mainMenu();
209
+ const pair = (await ask(` ${cyan('Ticker')} ${dim('(BTC)')}: `) || 'BTC').replace('-PERP', '').replace('-perp', '').toUpperCase();
210
+ this.log('');
211
+ await this.runPipeline(strategy, pair);
212
+ return this.mainMenu();
213
+ }
214
+ // ═══════════════════════════════════════════
215
+ // 2. EXPLORE — Discovery hub
216
+ // ═══════════════════════════════════════════
217
+ async exploreMenu() {
218
+ const iw = 60;
219
+ const brow = boldBoxRow(iw);
220
+ this.log('');
221
+ this.log(boldBoxTop(iw));
222
+ this.log(brow(` ${bold('EXPLORE')} ${dim('— discover what exists before building')}`));
223
+ this.log(boldBoxDivider(iw));
224
+ this.log(brow(''));
225
+ this.log(brow(` ${cyan('1')} Indicator catalog ${dim('50+ indicators, filterable')}`));
226
+ this.log(brow(` ${cyan('2')} Strategy showcase ${dim('validated + custom')}`));
227
+ this.log(brow(` ${cyan('3')} Market scanner ${dim('rift scout — live opportunities')}`));
228
+ this.log(brow(` ${cyan('4')} Signal forensics ${dim('stats / decay / backfill')}`));
229
+ this.log(brow(` ${cyan('5')} Funding rate browser ${dim('current + 7d + extremes')}`));
230
+ this.log(brow(` ${cyan('6')} Order flow browser ${dim('taker ratio / imbalance / flow')}`));
231
+ this.log(brow(` ${cyan('7')} Cross-asset matrix ${dim('correlation / lead-lag / beta')}`));
232
+ this.log(brow(` ${cyan('8')} Regime browser ${dim('vol + trend regime now & history')}`));
233
+ this.log(brow(''));
234
+ this.log(brow(` ${cyan('b')} Back to Research Lab`));
235
+ this.log(boldBoxBottom(iw));
236
+ this.log('');
237
+ const choice = await ask(` ${cyan('>')} `);
238
+ switch (choice) {
239
+ case '1': return this.indicatorCatalogMenu();
240
+ case '2': return this.strategyShowcaseMenu();
241
+ case '3':
242
+ await this.config.runCommand('scout');
243
+ return this.exploreMenu();
244
+ case '4': return this.signalForensicsMenu();
245
+ case '5': return this.fundingBrowserMenu();
246
+ case '6': return this.orderFlowBrowserMenu();
247
+ case '7': return this.crossAssetMenu();
248
+ case '8': return this.regimeBrowserMenu();
249
+ case 'b':
250
+ case 'B': return this.mainMenu();
251
+ default:
252
+ this.log(dim(' Invalid selection.'));
253
+ return this.exploreMenu();
254
+ }
255
+ }
256
+ // ─── Strategy showcase (formerly exploreMenu) ─────────────
257
+ async strategyShowcaseMenu() {
258
+ const iw = 56; // inner width for strategy cards
259
+ const row = boxRow(iw);
260
+ // Dynamic discovery — pull whatever's actually registered (shipped OSS
261
+ // + private + workbench customs). No hardcoded list of RIFT-team-only
262
+ // strategies that an OSS user wouldn't have on disk.
263
+ let registered = [];
264
+ try {
265
+ await runEngine('strategies', [], (msg) => {
266
+ if (msg.type === 'result') {
267
+ registered = (msg.strategies || []).map(s => ({
268
+ name: String(s.name),
269
+ doc: s.doc ? String(s.doc) : undefined,
270
+ class: s.class ? String(s.class) : undefined,
271
+ }));
272
+ }
273
+ });
274
+ }
275
+ catch { /* empty registry handled below */ }
276
+ let customs = [];
277
+ try {
278
+ await runEngine('workbench-list', [], (msg) => {
279
+ if (msg.type === 'result') {
280
+ customs = msg.strategies || [];
281
+ }
282
+ });
283
+ }
284
+ catch { /* empty */ }
285
+ this.log('');
286
+ this.log(boldBoxTop(iw + 2));
287
+ this.log(` ${bold('║')} ${'Strategy Explorer'.padStart(Math.floor((iw) / 2) + 9).padEnd(iw + 1)}${bold('║')}`);
288
+ this.log(boldBoxBottom(iw + 2));
289
+ this.log('');
290
+ if (registered.length === 0 && customs.length === 0) {
291
+ this.log(` ${yellow('!')} No strategies registered yet.`);
292
+ this.log('');
293
+ this.log(` ${dim('Get started:')}`);
294
+ this.log(` ${cyan('rift new my-strategy')} ${dim('— scaffold from template')}`);
295
+ this.log(` ${cyan('rift backtest trend_follow --pair BTC --tf 4h')} ${dim('— try the shipped example')}`);
296
+ this.log('');
297
+ }
298
+ else {
299
+ if (registered.length > 0) {
300
+ this.log(dim(' Registered (shipped + custom):'));
301
+ this.log('');
302
+ for (const s of registered) {
303
+ const doc = (s.doc || s.class || '').toString().split('\n')[0].slice(0, iw - 4);
304
+ this.log(boxTop(iw));
305
+ this.log(row(`${green('★')} ${bold(s.name)}`));
306
+ if (doc)
307
+ this.log(row(` ${dim(doc)}`));
308
+ this.log(boxBottom(iw));
309
+ }
310
+ this.log('');
311
+ }
312
+ if (customs.length > 0) {
313
+ this.log(dim(' Your workbench strategies:'));
314
+ this.log('');
315
+ for (const s of customs) {
316
+ this.log(` ${cyan(String(s.name).padEnd(22))} v${s.version} ${dim(String(s.description || '').slice(0, 40))}`);
317
+ }
318
+ this.log('');
319
+ }
320
+ }
321
+ this.log(` ${bold('What next?')}`);
322
+ this.log(` ${cyan('1')} Test a strategy on a specific pair`);
323
+ this.log(` ${cyan('2')} See which pairs work best for a strategy`);
324
+ this.log(` ${cyan('3')} Back to Explore`);
325
+ this.log('');
326
+ const choice = await ask(` ${cyan('>')} `);
327
+ switch (choice) {
328
+ case '1': return this.testMenu();
329
+ case '2': {
330
+ const strat = await this.pickStrategy('Which strategy to test across pairs');
331
+ if (!strat)
332
+ return this.exploreMenu();
333
+ this.log('');
334
+ await this.config.runCommand('backtest', [strat, '--all-pairs', '--top', '10']);
335
+ return this.exploreMenu();
336
+ }
337
+ case '3': return this.exploreMenu();
338
+ default: return this.exploreMenu();
339
+ }
340
+ }
341
+ // ═══════════════════════════════════════════
342
+ // 3. BUILD — Strategy Workbench
343
+ // ═══════════════════════════════════════════
344
+ async buildMenu() {
345
+ this.log('');
346
+ this.log(` ${bold('╔═══════════════════════════════════════════╗')}`);
347
+ this.log(` ${bold('║ Strategy Workbench ║')}`);
348
+ this.log(` ${bold('╚═══════════════════════════════════════════╝')}`);
349
+ this.log('');
350
+ // Check for existing custom strategies
351
+ let customStrategies = [];
352
+ try {
353
+ await runEngine('workbench-list', [], (msg) => {
354
+ if (msg.type === 'result') {
355
+ customStrategies = msg.strategies || [];
356
+ }
357
+ });
358
+ }
359
+ catch { /* empty */ }
360
+ if (customStrategies.length > 0) {
361
+ this.log(dim(' Your strategies:'));
362
+ this.log('');
363
+ for (let i = 0; i < customStrategies.length; i++) {
364
+ const s = customStrategies[i];
365
+ const filters = (s.filters || []).join(', ');
366
+ this.log(` ${cyan(String(i + 1))} ${bold(String(s.name).padEnd(22))} v${s.version} ${dim(String(s.description || '').slice(0, 35))}`);
367
+ if (filters)
368
+ this.log(` ${dim('filters: ' + filters)}`);
369
+ }
370
+ this.log('');
371
+ this.log(` ${cyan(String(customStrategies.length + 1))} ${green('+')} Create new strategy`);
372
+ this.log('');
373
+ const choice = await ask(` ${cyan('>')} `);
374
+ const idx = parseInt(choice) - 1;
375
+ if (idx >= 0 && idx < customStrategies.length) {
376
+ return this.workbench(String(customStrategies[idx].name));
377
+ }
378
+ }
379
+ // Create new strategy flow
380
+ this.log(dim(' Start from a template:'));
381
+ this.log('');
382
+ for (const t of TEMPLATES) {
383
+ this.log(` ${cyan(t.key)} ${bold(t.label.padEnd(22))} ${dim('— ' + t.desc)}`);
384
+ }
385
+ this.log('');
386
+ const templateChoice = await ask(` ${cyan('>')} `);
387
+ const template = TEMPLATES.find(t => t.key === templateChoice);
388
+ if (!template)
389
+ return this.mainMenu();
390
+ const name = await ask(`\n ${cyan('Strategy name')} ${dim('(snake_case)')}: `);
391
+ if (!name)
392
+ return this.mainMenu();
393
+ // Create via engine
394
+ let created = false;
395
+ try {
396
+ await runEngine('workbench-create', [name, '--template', template.template], (msg) => {
397
+ if (msg.type === 'result') {
398
+ created = true;
399
+ this.log('');
400
+ this.log(` ${green('✔')} Created ${bold(name)} from ${dim(template.label)} template`);
401
+ }
402
+ else if (msg.type === 'error') {
403
+ this.log(` ${red('✘')} ${msg.msg}`);
404
+ }
405
+ });
406
+ }
407
+ catch (e) {
408
+ this.log(` ${red('✘')} ${e.message}`);
409
+ return this.mainMenu();
410
+ }
411
+ if (created) {
412
+ this.log('');
413
+ return this.workbench(name);
414
+ }
415
+ return this.mainMenu();
416
+ }
417
+ // ═══════════════════════════════════════════
418
+ // WORKBENCH — The persistent editing view
419
+ // ═══════════════════════════════════════════
420
+ async workbench(strategyName, pair = 'BTC') {
421
+ // Load config
422
+ let config = {};
423
+ try {
424
+ await runEngine('workbench-show', [strategyName], (msg) => {
425
+ if (msg.type === 'result') {
426
+ config = msg.config;
427
+ }
428
+ else if (msg.type === 'error') {
429
+ this.log(` ${red('✘')} ${msg.msg}`);
430
+ }
431
+ });
432
+ }
433
+ catch (e) {
434
+ this.log(` ${red('✘')} ${e.message}`);
435
+ return;
436
+ }
437
+ if (!config.name)
438
+ return;
439
+ const entry = config.entry || {};
440
+ const exit_ = config.exit || {};
441
+ const risk = config.risk || {};
442
+ const filters = config.filters || {};
443
+ const entryConds = entry.conditions || [];
444
+ const exitConds = exit_.conditions || [];
445
+ // Get last test result
446
+ const lastData = { result: null };
447
+ try {
448
+ await runEngine('experiments', [strategyName, '--limit', '1'], (msg) => {
449
+ if (msg.type === 'result') {
450
+ const exps = msg.experiments || [];
451
+ if (exps.length > 0)
452
+ lastData.result = exps[0];
453
+ }
454
+ });
455
+ }
456
+ catch { /* empty */ }
457
+ const lastResult = lastData.result;
458
+ // Render workbench
459
+ const iw = 54;
460
+ const wr = boldBoxRow(iw);
461
+ const stratMeta = STRATEGIES.find(s => s.name === strategyName);
462
+ const mktLabel = stratMeta ? ` · ${marketColor(stratMeta.market)}` : '';
463
+ this.log('');
464
+ this.log(boldBoxTop(iw));
465
+ this.log(wr(` WORKBENCH: ${bold(strategyName)} on ${pair} PERP ${config.timeframe || '1h'}${mktLabel}`));
466
+ this.log(boldBoxDivider(iw));
467
+ if (lastResult) {
468
+ const ret = Number(lastResult.return_pct ?? 0).toFixed(2);
469
+ const sharpe = Number(lastResult.sharpe ?? 0).toFixed(2);
470
+ const trades = lastResult.num_trades ?? 0;
471
+ const win = Number(lastResult.win_rate ?? 0).toFixed(0);
472
+ this.log(wr(` LAST TEST: ${colorNum(Number(ret), '%')} | Sharpe ${colorNum(Number(sharpe))} | ${trades} trades | ${win}% win`));
473
+ }
474
+ else {
475
+ this.log(wr(` ${dim('No tests yet — press [t] to quick test')}`));
476
+ }
477
+ this.log(boldBoxDivider(iw));
478
+ // Entry zone
479
+ this.log(wr(''));
480
+ this.log(wr(` ${cyan('[1]')} Entry ${this.formatConditions(entryConds, 'entry')}`));
481
+ // Exit zone
482
+ this.log(wr(''));
483
+ const exitDesc = this.formatConditions(exitConds, 'exit');
484
+ const maxHold = exit_.max_hold || 48;
485
+ this.log(wr(` ${cyan('[2]')} Exit ${exitDesc}`));
486
+ this.log(wr(` ${dim(`max hold: ${maxHold} candles`)}`));
487
+ // Risk zone
488
+ this.log(wr(''));
489
+ const sl = risk.stop_loss ? `${(risk.stop_loss * 100).toFixed(1)}%` : '2.0%';
490
+ const lev = risk.leverage || 2.0;
491
+ const rpt = risk.risk_per_trade ? `${(risk.risk_per_trade * 100).toFixed(1)}%` : '2.0%';
492
+ this.log(wr(` ${cyan('[3]')} Risk SL: ${sl} Size: ${rpt} Lev: ${lev}x`));
493
+ // Filters zone
494
+ this.log(wr(''));
495
+ const filterList = Object.entries(filters)
496
+ .map(([k, v]) => `${v ? green('☑') : dim('☐')} ${k.replace(/_/g, ' ')}`)
497
+ .join(' ');
498
+ this.log(wr(` ${cyan('[4]')} Filters ${filterList || dim('none')}`));
499
+ this.log(wr(''));
500
+ this.log(boldBoxDivider(iw));
501
+ this.log(wr(` ${cyan('[t]')} Quick test ${cyan('[T]')} Full validate ${cyan('[h]')} History`));
502
+ this.log(wr(` ${cyan('[p]')} Change pair ${cyan('[m]')} Mixer ${cyan('[q]')} Back`));
503
+ this.log(boldBoxBottom(iw));
504
+ this.log('');
505
+ const choice = await ask(` ${cyan('>')} `);
506
+ switch (choice) {
507
+ case '1': return this.editEntry(strategyName, pair);
508
+ case '2': return this.editExit(strategyName, pair);
509
+ case '3': return this.editRisk(strategyName, pair);
510
+ case '4': return this.editFilters(strategyName, pair);
511
+ case 't': return this.runQuickTest(strategyName, pair);
512
+ case 'T': return this.runFullValidate(strategyName, pair);
513
+ case 'h': return this.showHistory(strategyName, pair);
514
+ case 'p':
515
+ const newPair = (await ask(` ${cyan('Ticker')}: `) || pair).replace('-PERP', '').replace('-perp', '').toUpperCase();
516
+ return this.workbench(strategyName, newPair);
517
+ case 'm': return this.mixerMenu(strategyName, pair);
518
+ case 'q': return this.buildMenu();
519
+ default: return this.workbench(strategyName, pair);
520
+ }
521
+ }
522
+ formatConditions(conds, _type) {
523
+ const validConds = conds.filter(c => !c.indicator?.startsWith('_'));
524
+ if (validConds.length === 0)
525
+ return dim('(none configured)');
526
+ // Show first condition inline, rest are visible in the edit menu
527
+ const c = validConds[0];
528
+ const ind = c.indicator;
529
+ const op = c.op;
530
+ const val = c.value !== undefined ? c.value : c.ref;
531
+ const side = c.side ? dim(` [${c.side}]`) : '';
532
+ let result = `${ind} ${op} ${val}${side}`;
533
+ if (validConds.length > 1)
534
+ result += dim(` +${validConds.length - 1} more`);
535
+ return result;
536
+ }
537
+ // ═══════════════════════════════════════════
538
+ // WORKBENCH — Zone editors
539
+ // ═══════════════════════════════════════════
540
+ async editEntry(strategyName, pair) {
541
+ let config = {};
542
+ await runEngine('workbench-show', [strategyName], (msg) => {
543
+ if (msg.type === 'result')
544
+ config = msg.config;
545
+ });
546
+ const entry = config.entry || {};
547
+ const conds = entry.conditions || [];
548
+ this.log('');
549
+ this.log(` ${bold('ENTRY CONDITIONS')} for ${bold(strategyName)}`);
550
+ this.log('');
551
+ this.log(` Current:`);
552
+ if (conds.length === 0) {
553
+ this.log(` ${dim('(no conditions set)')}`);
554
+ }
555
+ else {
556
+ for (let i = 0; i < conds.length; i++) {
557
+ const c = conds[i];
558
+ this.log(` ${dim(String(i + 1) + '.')} ${c.indicator} ${c.op} ${c.value ?? c.ref}${c.side ? dim(` [${c.side}]`) : ''}`);
559
+ }
560
+ }
561
+ this.log('');
562
+ this.log(` ${cyan('a')} Add a condition`);
563
+ this.log(` ${cyan('r')} Remove a condition`);
564
+ this.log(` ${cyan('d')} Change direction ${dim(`(current: ${entry.direction || 'both'})`)}`);
565
+ this.log(` ${cyan('b')} Back to workbench`);
566
+ this.log('');
567
+ const choice = await ask(` ${cyan('>')} `);
568
+ if (choice === 'a') {
569
+ const newCond = await this.guidedConditionPicker(pair);
570
+ if (newCond) {
571
+ conds.push(newCond);
572
+ entry.conditions = conds;
573
+ config.entry = entry;
574
+ const desc = `added entry: ${newCond.indicator} ${newCond.op} ${newCond.value ?? newCond.ref}`;
575
+ await this.saveAndRegenerate(strategyName, config, pair, desc);
576
+ }
577
+ }
578
+ else if (choice === 'r' && conds.length > 0) {
579
+ const idx = parseInt(await ask(` ${cyan('Remove #')}: `)) - 1;
580
+ if (idx >= 0 && idx < conds.length) {
581
+ const removed = conds.splice(idx, 1)[0];
582
+ entry.conditions = conds;
583
+ config.entry = entry;
584
+ await this.saveAndRegenerate(strategyName, config, pair, `removed entry: ${removed.indicator}`);
585
+ }
586
+ }
587
+ else if (choice === 'd') {
588
+ const dir = await ask(` ${cyan('Direction')} ${dim('(both/long_only/short_only)')}: `);
589
+ if (dir) {
590
+ entry.direction = dir;
591
+ config.entry = entry;
592
+ await this.saveAndRegenerate(strategyName, config, pair, `direction → ${dir}`);
593
+ }
594
+ }
595
+ return this.workbench(strategyName, pair);
596
+ }
597
+ async editExit(strategyName, pair) {
598
+ let config = {};
599
+ await runEngine('workbench-show', [strategyName], (msg) => {
600
+ if (msg.type === 'result')
601
+ config = msg.config;
602
+ });
603
+ const exit_ = config.exit || {};
604
+ const conds = exit_.conditions || [];
605
+ this.log('');
606
+ this.log(` ${bold('EXIT CONDITIONS')} for ${bold(strategyName)}`);
607
+ this.log('');
608
+ this.log(` Current:`);
609
+ this.log(` ${dim('•')} Max hold: ${exit_.max_hold || 48} candles`);
610
+ for (let i = 0; i < conds.length; i++) {
611
+ const c = conds[i];
612
+ this.log(` ${dim(String(i + 1) + '.')} ${c.indicator} ${c.op} ${c.value ?? c.ref}${c.side ? dim(` [${c.side}]`) : ''}`);
613
+ }
614
+ this.log('');
615
+ this.log(` ${cyan('a')} Add exit condition`);
616
+ this.log(` ${cyan('r')} Remove exit condition`);
617
+ this.log(` ${cyan('h')} Change max hold`);
618
+ this.log(` ${cyan('b')} Back`);
619
+ this.log('');
620
+ const choice = await ask(` ${cyan('>')} `);
621
+ if (choice === 'a') {
622
+ const newCond = await this.guidedConditionPicker(pair, true);
623
+ if (newCond) {
624
+ if (newCond.indicator === '_max_hold') {
625
+ // Special case: max hold is a config property, not a condition
626
+ exit_.max_hold = newCond.value;
627
+ config.exit = exit_;
628
+ await this.saveAndRegenerate(strategyName, config, pair, `max hold → ${newCond.value}`);
629
+ }
630
+ else {
631
+ conds.push(newCond);
632
+ exit_.conditions = conds;
633
+ config.exit = exit_;
634
+ await this.saveAndRegenerate(strategyName, config, pair, `added exit: ${newCond.indicator} ${newCond.op} ${newCond.value}`);
635
+ }
636
+ }
637
+ }
638
+ else if (choice === 'r' && conds.length > 0) {
639
+ const idx = parseInt(await ask(` ${cyan('Remove #')}: `)) - 1;
640
+ if (idx >= 0 && idx < conds.length) {
641
+ conds.splice(idx, 1);
642
+ exit_.conditions = conds;
643
+ config.exit = exit_;
644
+ await this.saveAndRegenerate(strategyName, config, pair, `removed exit condition`);
645
+ }
646
+ }
647
+ else if (choice === 'h') {
648
+ const hold = await ask(` ${cyan('Max hold (candles)')}: `);
649
+ if (hold) {
650
+ exit_.max_hold = parseInt(hold);
651
+ config.exit = exit_;
652
+ await this.saveAndRegenerate(strategyName, config, pair, `max hold → ${hold}`);
653
+ }
654
+ }
655
+ return this.workbench(strategyName, pair);
656
+ }
657
+ async editRisk(strategyName, pair) {
658
+ let config = {};
659
+ await runEngine('workbench-show', [strategyName], (msg) => {
660
+ if (msg.type === 'result')
661
+ config = msg.config;
662
+ });
663
+ const risk = config.risk || {};
664
+ this.log('');
665
+ this.log(` ${bold('RISK SETTINGS')} for ${bold(strategyName)}`);
666
+ this.log('');
667
+ this.log(` ${cyan('1')} Stop loss: ${bold(String((risk.stop_loss * 100).toFixed(1) + '%'))}`);
668
+ this.log(` ${cyan('2')} Risk per trade: ${bold(String((risk.risk_per_trade * 100).toFixed(1) + '%'))}`);
669
+ this.log(` ${cyan('3')} Leverage: ${bold(String(risk.leverage + 'x'))}`);
670
+ this.log(` ${cyan('b')} Back`);
671
+ this.log('');
672
+ const choice = await ask(` ${cyan('>')} `);
673
+ if (choice === '1') {
674
+ const val = await ask(` ${cyan('Stop loss %')}: `);
675
+ if (val) {
676
+ const oldSl = (risk.stop_loss * 100).toFixed(1);
677
+ risk.stop_loss = parseFloat(val) / 100;
678
+ config.risk = risk;
679
+ await this.saveAndRegenerate(strategyName, config, pair, `stop loss ${oldSl}% → ${val}%`);
680
+ }
681
+ }
682
+ else if (choice === '2') {
683
+ const val = await ask(` ${cyan('Risk per trade %')}: `);
684
+ if (val) {
685
+ risk.risk_per_trade = parseFloat(val) / 100;
686
+ config.risk = risk;
687
+ await this.saveAndRegenerate(strategyName, config, pair, `risk per trade → ${val}%`);
688
+ }
689
+ }
690
+ else if (choice === '3') {
691
+ const val = await ask(` ${cyan('Leverage')}: `);
692
+ if (val) {
693
+ risk.leverage = parseFloat(val);
694
+ config.risk = risk;
695
+ await this.saveAndRegenerate(strategyName, config, pair, `leverage → ${val}x`);
696
+ }
697
+ }
698
+ return this.workbench(strategyName, pair);
699
+ }
700
+ async editFilters(strategyName, pair) {
701
+ let config = {};
702
+ await runEngine('workbench-show', [strategyName], (msg) => {
703
+ if (msg.type === 'result')
704
+ config = msg.config;
705
+ });
706
+ const filters = config.filters || {};
707
+ const available = [
708
+ { key: 'hmm_filter', desc: 'HMM regime filter — skip crisis markets (self-contained, no dependency)' },
709
+ { key: 'rsi_confirmation', desc: 'RSI confirmation — oversold/overbought gates' },
710
+ { key: 'volume_filter', desc: 'Volume filter — require 1.5x avg volume' },
711
+ { key: 'adx_trend', desc: 'ADX trend filter — only trade when trending (>25)' },
712
+ ];
713
+ this.log('');
714
+ this.log(` ${bold('FILTERS')} for ${bold(strategyName)}`);
715
+ this.log('');
716
+ for (let i = 0; i < available.length; i++) {
717
+ const f = available[i];
718
+ const on = filters[f.key] === true;
719
+ this.log(` ${cyan(String(i + 1))} ${on ? green('☑') : dim('☐')} ${bold(f.key.padEnd(22))} ${dim(f.desc)}`);
720
+ }
721
+ this.log(` ${cyan('b')} Back`);
722
+ this.log('');
723
+ const choice = await ask(` ${cyan('Toggle #')} `);
724
+ const idx = parseInt(choice) - 1;
725
+ if (idx >= 0 && idx < available.length) {
726
+ const key = available[idx].key;
727
+ filters[key] = !filters[key];
728
+ config.filters = filters;
729
+ const action = filters[key] ? 'enabled' : 'disabled';
730
+ await this.saveAndRegenerate(strategyName, config, pair, `${action} ${key}`);
731
+ }
732
+ return this.workbench(strategyName, pair);
733
+ }
734
+ // ═══════════════════════════════════════════
735
+ // GUIDED CONDITION PICKER — with live stats
736
+ // ═══════════════════════════════════════════
737
+ async guidedConditionPicker(pair, isExit = false) {
738
+ // Step 1: Category
739
+ this.log('');
740
+ this.log(` ${bold(isExit ? 'ADD EXIT CONDITION' : 'ADD ENTRY CONDITION')}`);
741
+ this.log('');
742
+ this.log(` ${dim('Pick a category:')}`);
743
+ this.log('');
744
+ this.log(` ${cyan('1')} ${bold('Funding')} ${dim('funding rate thresholds')}`);
745
+ this.log(` ${cyan('2')} ${bold('Price')} ${dim('vs EMA, vs VWAP')}`);
746
+ this.log(` ${cyan('3')} ${bold('Momentum')} ${dim('RSI, ADX')}`);
747
+ this.log(` ${cyan('4')} ${bold('Volume')} ${dim('volume spike detection')}`);
748
+ if (isExit) {
749
+ this.log(` ${cyan('5')} ${bold('Time')} ${dim('max hold candles')}`);
750
+ }
751
+ this.log('');
752
+ const cat = await ask(` ${cyan('>')} `);
753
+ // Fetch live stats for this pair
754
+ let stats = {};
755
+ this.log(dim(' Loading market stats...'));
756
+ try {
757
+ await runEngine('indicator-stats', ['--pair', pair], (msg) => {
758
+ if (msg.type === 'result')
759
+ stats = msg;
760
+ });
761
+ }
762
+ catch { /* stats are optional enhancement */ }
763
+ switch (cat) {
764
+ case '1': return this.pickFundingCondition(stats, isExit);
765
+ case '2': return this.pickPriceCondition(stats, isExit);
766
+ case '3': return this.pickMomentumCondition(stats, isExit);
767
+ case '4': return this.pickVolumeCondition(stats);
768
+ case '5': if (isExit)
769
+ return this.pickTimeCondition();
770
+ default: return null;
771
+ }
772
+ }
773
+ async pickFundingCondition(stats, isExit) {
774
+ const fs = stats.funding_rate;
775
+ this.log('');
776
+ if (isExit) {
777
+ this.log(` ${bold('FUNDING EXIT')}`);
778
+ this.log('');
779
+ this.log(` ${cyan('1')} Funding normalizes ${dim('exit when rate drops below threshold')}`);
780
+ this.log('');
781
+ }
782
+ else {
783
+ this.log(` ${bold('FUNDING ENTRY')}`);
784
+ this.log('');
785
+ this.log(` ${cyan('1')} Funding rate extreme ${dim('enter when |rate| exceeds threshold')}`);
786
+ this.log(` ${cyan('2')} Funding rate positive ${dim('enter when shorts earn (rate > 0)')}`);
787
+ this.log(` ${cyan('3')} Funding rate negative ${dim('enter when longs earn (rate < 0)')}`);
788
+ this.log('');
789
+ }
790
+ const choice = await ask(` ${cyan('>')} `);
791
+ // Show live stats
792
+ if (fs) {
793
+ this.log('');
794
+ this.log(` ${dim(`${stats.pair} funding — last ${stats.candles} candles:`)}`);
795
+ // Visual gauge
796
+ const min = fs.min;
797
+ const max = fs.max;
798
+ const p75 = fs.p75;
799
+ const p90 = fs.p90;
800
+ const rec = fs.recommended;
801
+ this.log(` ${dim('Min')} ${dim(String(min))}`);
802
+ this.log(` ${dim('Median')} ${dim(String(fs.median))}`);
803
+ this.log(` ${dim('75th')} ${cyan(String(p75))}`);
804
+ this.log(` ${dim('90th')} ${yellow(String(p90))}`);
805
+ this.log(` ${dim('Max')} ${dim(String(max))}`);
806
+ this.log(` ${dim(`${fs.positive_pct}% of the time, longs pay shorts`)}`);
807
+ this.log('');
808
+ this.log(` ${green('★')} Recommended: ${bold(String(rec))} ${dim('(75th percentile of |rate|)')}`);
809
+ }
810
+ if (isExit) {
811
+ const defaultVal = fs ? fs.median : 0.000003;
812
+ const val = await ask(`\n ${cyan('Exit below')} ${dim(`(${defaultVal})`)}: `);
813
+ const threshold = val ? parseFloat(val) : defaultVal;
814
+ return { indicator: 'funding_rate', op: 'abs_below', value: threshold };
815
+ }
816
+ if (choice === '1') {
817
+ const defaultVal = fs ? fs.recommended : 0.000015;
818
+ const val = await ask(`\n ${cyan('Threshold')} ${dim(`(${defaultVal})`)}: `);
819
+ const threshold = val ? parseFloat(val) : defaultVal;
820
+ this.log('');
821
+ this.log(` ${dim('When funding exceeds this threshold:')}`);
822
+ this.log(` ${cyan('1')} ${bold('SHORT')} when positive ${dim('— shorts earn (recommended)')}`);
823
+ this.log(` ${cyan('2')} ${bold('LONG')} when negative ${dim('— longs earn (recommended)')}`);
824
+ this.log(` ${cyan('3')} ${bold('Both')} directions`);
825
+ this.log('');
826
+ const sideChoice = await ask(` ${cyan('>')} `);
827
+ if (sideChoice === '1') {
828
+ return { indicator: 'funding_rate', op: '>', value: threshold, side: 'short' };
829
+ }
830
+ else if (sideChoice === '2') {
831
+ return { indicator: 'funding_rate', op: '<', value: -threshold, side: 'long' };
832
+ }
833
+ else {
834
+ // Both — add as two conditions? Just return the positive side, user can add more
835
+ return { indicator: 'funding_rate', op: '>', value: threshold, side: 'short' };
836
+ }
837
+ }
838
+ else if (choice === '2') {
839
+ const defaultVal = fs ? fs.recommended : 0.000015;
840
+ const val = await ask(`\n ${cyan('Min positive rate')} ${dim(`(${defaultVal})`)}: `);
841
+ return { indicator: 'funding_rate', op: '>', value: val ? parseFloat(val) : defaultVal, side: 'short' };
842
+ }
843
+ else if (choice === '3') {
844
+ const defaultVal = fs ? -fs.recommended : -0.000015;
845
+ const val = await ask(`\n ${cyan('Max negative rate')} ${dim(`(${defaultVal})`)}: `);
846
+ return { indicator: 'funding_rate', op: '<', value: val ? parseFloat(val) : defaultVal, side: 'long' };
847
+ }
848
+ return null;
849
+ }
850
+ async pickPriceCondition(stats, isExit) {
851
+ this.log('');
852
+ this.log(` ${bold('PRICE CONDITIONS')}`);
853
+ this.log('');
854
+ this.log(` ${cyan('1')} Price above EMA ${dim('trend filter — only trade with trend')}`);
855
+ this.log(` ${cyan('2')} Price below EMA ${dim('mean reversion — buy the dip')}`);
856
+ this.log(` ${cyan('3')} VWAP deviation ${dim('extreme deviation from fair value')}`);
857
+ this.log('');
858
+ const choice = await ask(` ${cyan('>')} `);
859
+ if (choice === '1' || choice === '2') {
860
+ // EMA period selection
861
+ const emaStats = stats.ema_100;
862
+ this.log('');
863
+ this.log(` ${dim('EMA period:')}`);
864
+ this.log(` ${cyan('1')} EMA 50 ${dim('— faster, more responsive')}`);
865
+ this.log(` ${cyan('2')} EMA 100 ${dim('— balanced (default)')}`);
866
+ this.log(` ${cyan('3')} EMA 200 ${dim('— slower, stronger filter')}`);
867
+ this.log('');
868
+ if (emaStats) {
869
+ this.log(` ${dim(`${stats.pair}: price above EMA 100 ${emaStats.pct_above}% of the time`)}`);
870
+ this.log('');
871
+ }
872
+ const periodChoice = await ask(` ${cyan('>')} ${dim('(2)')}: `) || '2';
873
+ const period = periodChoice === '1' ? 50 : periodChoice === '3' ? 200 : 100;
874
+ // Price above EMA = bullish = use as filter for SHORT funding trades (price overextended)
875
+ // Price below EMA = bearish = use as filter for LONG funding trades (price oversold)
876
+ const op = choice === '1' ? '>' : '<';
877
+ const side = choice === '1' ? 'short' : 'long';
878
+ return { indicator: 'price', op, ref: `ema_${period}`, side };
879
+ }
880
+ else if (choice === '3') {
881
+ const vs = stats.vwap_zscore;
882
+ if (vs) {
883
+ this.log('');
884
+ this.log(` ${dim(`${stats.pair} VWAP z-score distribution:`)}`);
885
+ this.log(` ${dim('5th')} ${dim(String(vs.p5))}σ`);
886
+ this.log(` ${dim('95th')} ${dim(String(vs.p95))}σ`);
887
+ this.log(` ${dim(`Beyond ±2σ: ${vs.pct_beyond_2}% of candles`)}`);
888
+ this.log(` ${dim(`Beyond ±3σ: ${vs.pct_beyond_3}% of candles`)}`);
889
+ this.log('');
890
+ this.log(` ${green('★')} Recommended entry: ${bold(`±${vs.recommended_entry}σ`)} ${dim('(95th percentile)')}`);
891
+ }
892
+ const defaultDev = vs ? vs.recommended_entry : 2.5;
893
+ const val = await ask(`\n ${cyan('Entry deviation (σ)')} ${dim(`(${defaultDev})`)}: `);
894
+ const dev = val ? parseFloat(val) : defaultDev;
895
+ return { indicator: 'vwap_zscore', op: isExit ? 'abs_below' : '<', value: isExit ? dev : -dev, side: 'long' };
896
+ }
897
+ return null;
898
+ }
899
+ async pickMomentumCondition(stats, isExit) {
900
+ this.log('');
901
+ this.log(` ${bold('MOMENTUM CONDITIONS')}`);
902
+ this.log('');
903
+ this.log(` ${cyan('1')} RSI oversold/overbought ${dim('momentum extremes')}`);
904
+ this.log(` ${cyan('2')} ADX trend strength ${dim('only trade when trending')}`);
905
+ this.log('');
906
+ const choice = await ask(` ${cyan('>')} `);
907
+ if (choice === '1') {
908
+ const rs = stats.rsi;
909
+ if (rs) {
910
+ this.log('');
911
+ this.log(` ${dim(`${stats.pair} RSI (14) distribution:`)}`);
912
+ this.log(` ${dim('10th')} ${dim(String(rs.p10))}`);
913
+ this.log(` ${dim('25th')} ${dim(String(rs.p25))}`);
914
+ this.log(` ${dim('Mean')} ${dim(String(rs.mean))}`);
915
+ this.log(` ${dim('75th')} ${dim(String(rs.p75))}`);
916
+ this.log(` ${dim('90th')} ${dim(String(rs.p90))}`);
917
+ this.log(` ${dim(`Below 30: ${rs.pct_below_30}% · Above 70: ${rs.pct_above_70}%`)}`);
918
+ this.log('');
919
+ this.log(` ${green('★')} Recommended: oversold < ${bold(String(rs.recommended_oversold))} · overbought > ${bold(String(rs.recommended_overbought))}`);
920
+ }
921
+ this.log('');
922
+ this.log(` ${cyan('1')} RSI oversold ${dim('— enter LONG when RSI < threshold')}`);
923
+ this.log(` ${cyan('2')} RSI overbought ${dim('— enter SHORT when RSI > threshold')}`);
924
+ this.log('');
925
+ const rsiChoice = await ask(` ${cyan('>')} `);
926
+ if (rsiChoice === '1') {
927
+ const def = rs ? rs.recommended_oversold : 40;
928
+ const val = await ask(` ${cyan('RSI below')} ${dim(`(${def})`)}: `);
929
+ return { indicator: 'rsi', op: '<', value: val ? parseFloat(val) : def, side: 'long' };
930
+ }
931
+ else if (rsiChoice === '2') {
932
+ const def = rs ? rs.recommended_overbought : 60;
933
+ const val = await ask(` ${cyan('RSI above')} ${dim(`(${def})`)}: `);
934
+ return { indicator: 'rsi', op: '>', value: val ? parseFloat(val) : def, side: 'short' };
935
+ }
936
+ }
937
+ else if (choice === '2') {
938
+ const as = stats.adx;
939
+ if (as) {
940
+ this.log('');
941
+ this.log(` ${dim(`${stats.pair} ADX (14):`)}`);
942
+ this.log(` ${dim(`Mean: ${as.mean} · Median: ${as.median}`)}`);
943
+ this.log(` ${dim(`Above 25 (trending): ${as.pct_above_25}%`)}`);
944
+ this.log(` ${dim(`Above 40 (strong trend): ${as.pct_above_40}%`)}`);
945
+ this.log('');
946
+ this.log(` ${green('★')} Recommended: ${bold('> 25')} ${dim('(standard trend threshold)')}`);
947
+ }
948
+ const def = 25;
949
+ const val = await ask(`\n ${cyan('ADX above')} ${dim(`(${def})`)}: `);
950
+ return { indicator: 'adx', op: '>', value: val ? parseFloat(val) : def };
951
+ }
952
+ return null;
953
+ }
954
+ async pickVolumeCondition(stats) {
955
+ const vs = stats.volume;
956
+ this.log('');
957
+ this.log(` ${bold('VOLUME CONDITIONS')}`);
958
+ if (vs) {
959
+ this.log('');
960
+ this.log(` ${dim(`${stats.pair} volume ratio (vs 20-period avg):`)}`);
961
+ this.log(` ${dim(`Median: ${vs.median}x · 75th: ${vs.p75}x · 90th: ${vs.p90}x · 95th: ${vs.p95}x`)}`);
962
+ this.log('');
963
+ this.log(` ${green('★')} Recommended: ${bold(`> ${vs.recommended}x`)} ${dim('(above average volume)')}`);
964
+ }
965
+ const def = vs ? vs.recommended : 1.5;
966
+ const val = await ask(`\n ${cyan('Volume above')} ${dim(`(${def}x avg)`)}: `);
967
+ return { indicator: 'vol_ratio', op: '>', value: val ? parseFloat(val) : def };
968
+ }
969
+ async pickTimeCondition() {
970
+ this.log('');
971
+ this.log(` ${bold('TIME EXIT')}`);
972
+ this.log(` ${dim('Close position after N candles regardless of conditions.')}`);
973
+ this.log('');
974
+ this.log(` ${dim('Common values: 6 (quick), 24 (1 day), 48 (2 days), 72 (3 days)')}`);
975
+ const val = await ask(`\n ${cyan('Max hold candles')} ${dim('(48)')}: `);
976
+ // This is handled via max_hold in exit config, not as a condition
977
+ // Return a special marker that editExit handles
978
+ return { indicator: '_max_hold', op: '=', value: val ? parseInt(val, 10) : 48 };
979
+ }
980
+ async saveAndRegenerate(strategyName, config, pair, changeDesc) {
981
+ try {
982
+ await runEngine('workbench-update', [strategyName, JSON.stringify(config)], (msg) => {
983
+ if (msg.type === 'result') {
984
+ this.log(` ${green('✔')} Updated — ${dim(changeDesc)}`);
985
+ }
986
+ else if (msg.type === 'error') {
987
+ this.log(` ${red('✘')} ${msg.msg}`);
988
+ }
989
+ });
990
+ }
991
+ catch (e) {
992
+ this.log(` ${red('✘')} ${e.message}`);
993
+ }
994
+ // Auto quick-test after every change
995
+ const doTest = await ask(` ${dim('Quick test? (y/n)')}: `);
996
+ if (doTest.toLowerCase() === 'y' || doTest === '') {
997
+ await this.runQuickTest(strategyName, pair, changeDesc);
998
+ }
999
+ }
1000
+ // ═══════════════════════════════════════════
1001
+ // WORKBENCH — Quick test & history
1002
+ // ═══════════════════════════════════════════
1003
+ async runQuickTest(strategyName, pair, changeDesc = '') {
1004
+ this.log('');
1005
+ this.log(dim(` Testing ${strategyName} on ${pair}...`));
1006
+ const args = [strategyName, '--pair', pair];
1007
+ if (changeDesc)
1008
+ args.push('--change', changeDesc);
1009
+ try {
1010
+ await runEngine('quick-test', args, (msg) => {
1011
+ if (msg.type === 'progress') {
1012
+ this.log(` ${dim(String(msg.msg))}`);
1013
+ }
1014
+ else if (msg.type === 'result') {
1015
+ const ret = msg.return_pct;
1016
+ const sharpe = msg.sharpe;
1017
+ const trades = msg.num_trades;
1018
+ const win = msg.win_rate;
1019
+ const dd = msg.max_drawdown;
1020
+ const pf = msg.profit_factor;
1021
+ this.log('');
1022
+ this.log(` ${bold('Quick test:')} ${colorNum(ret, '%')}, ${trades} trades, ${win}% win, ${colorNum(dd, '%')} DD, Sharpe ${colorNum(sharpe)}`);
1023
+ // Show delta if available
1024
+ if (msg.has_previous) {
1025
+ const delta = msg.delta;
1026
+ const parts = [];
1027
+ if (delta.return_pct)
1028
+ parts.push(`return ${delta.return_pct > 0 ? '↑' : '↓'}${Math.abs(delta.return_pct).toFixed(1)}%`);
1029
+ if (delta.sharpe)
1030
+ parts.push(`Sharpe ${delta.sharpe > 0 ? '↑' : '↓'}${Math.abs(delta.sharpe).toFixed(2)}`);
1031
+ if (delta.num_trades)
1032
+ parts.push(`trades ${delta.num_trades > 0 ? '↑' : '↓'}${Math.abs(delta.num_trades)}`);
1033
+ if (delta.max_drawdown)
1034
+ parts.push(`DD ${delta.max_drawdown > 0 ? '↑' : '↓'}${Math.abs(delta.max_drawdown).toFixed(1)}%`);
1035
+ if (parts.length > 0) {
1036
+ this.log(` ${dim('vs last:')} ${parts.join(', ')}`);
1037
+ }
1038
+ }
1039
+ this.log('');
1040
+ }
1041
+ else if (msg.type === 'error') {
1042
+ this.log(` ${red('✘')} ${msg.msg}`);
1043
+ }
1044
+ });
1045
+ }
1046
+ catch (e) {
1047
+ this.log(` ${red('✘')} ${e.message}`);
1048
+ }
1049
+ return this.workbench(strategyName, pair);
1050
+ }
1051
+ async runFullValidate(strategyName, pair) {
1052
+ this.log('');
1053
+ await this.runPipeline(strategyName, pair);
1054
+ return this.workbench(strategyName, pair);
1055
+ }
1056
+ async showHistory(strategyName, pair) {
1057
+ this.log('');
1058
+ this.log(` ${bold('EXPERIMENT LOG:')} ${strategyName}`);
1059
+ this.log('');
1060
+ try {
1061
+ await runEngine('experiments', [strategyName, '--limit', '15'], (msg) => {
1062
+ if (msg.type === 'result') {
1063
+ const exps = msg.experiments || [];
1064
+ if (exps.length === 0) {
1065
+ this.log(dim(' No experiments yet. Press [t] in the workbench to start.'));
1066
+ }
1067
+ else {
1068
+ this.log(` ${dim('#'.padEnd(4))} ${dim('v'.padEnd(4))} ${dim('Pair'.padEnd(6))} ${dim('Return'.padEnd(10))} ${dim('Sharpe'.padEnd(9))} ${dim('Trades'.padEnd(8))} ${dim('Change')}`);
1069
+ this.log(` ${dim('─'.repeat(65))}`);
1070
+ for (const e of exps) {
1071
+ const ret = colorNum(e.return_pct, '%');
1072
+ const cleanRet = String(e.return_pct).slice(0, 6);
1073
+ this.log(` ${String(e.id).padEnd(4)} v${String(e.version).padEnd(3)} ${String(e.pair).padEnd(6)} ${ret.padEnd(18)} ${String(e.sharpe?.toFixed(2) || '—').padEnd(9)} ${String(e.num_trades).padEnd(8)} ${dim(String(e.change_description || ''))}`);
1074
+ }
1075
+ }
1076
+ }
1077
+ });
1078
+ }
1079
+ catch (e) {
1080
+ this.log(` ${red('✘')} ${e.message}`);
1081
+ }
1082
+ this.log('');
1083
+ this.log(` ${cyan('r')} Revert to a version`);
1084
+ this.log(` ${cyan('b')} Back to workbench`);
1085
+ this.log('');
1086
+ const choice = await ask(` ${cyan('>')} `);
1087
+ if (choice === 'r') {
1088
+ const expId = await ask(` ${cyan('Experiment #')}: `);
1089
+ if (expId) {
1090
+ try {
1091
+ await runEngine('experiment-revert', [expId], (msg) => {
1092
+ if (msg.type === 'result') {
1093
+ this.log(` ${green('✔')} Reverted to experiment #${expId} (now v${msg.version})`);
1094
+ }
1095
+ else if (msg.type === 'error') {
1096
+ this.log(` ${red('✘')} ${msg.msg}`);
1097
+ }
1098
+ });
1099
+ }
1100
+ catch (e) {
1101
+ this.log(` ${red('✘')} ${e.message}`);
1102
+ }
1103
+ }
1104
+ }
1105
+ return this.workbench(strategyName, pair);
1106
+ }
1107
+ // ═══════════════════════════════════════════
1108
+ // WORKBENCH — Strategy Mixer
1109
+ // ═══════════════════════════════════════════
1110
+ async mixerMenu(strategyName, pair) {
1111
+ this.log('');
1112
+ this.log(` ${bold('STRATEGY MIXER')} — combine components from validated strategies`);
1113
+ this.log('');
1114
+ // Get current config
1115
+ let config = {};
1116
+ try {
1117
+ await runEngine('workbench-show', [strategyName], (msg) => {
1118
+ if (msg.type === 'result')
1119
+ config = msg.config;
1120
+ });
1121
+ }
1122
+ catch { /* empty */ }
1123
+ const filters = config.filters || {};
1124
+ this.log(` Your base: ${bold(strategyName)}`);
1125
+ this.log('');
1126
+ this.log(` ${dim('FILTERS (toggle to add/remove):')}`);
1127
+ this.log('');
1128
+ const filterOptions = [
1129
+ { key: 'hmm_filter', label: 'HMM crisis filter', source: 'workbench-builtin', desc: 'skip trades during crisis regimes (HMM, self-contained)' },
1130
+ { key: 'rsi_confirmation', label: 'RSI confirmation', source: 'workbench-builtin', desc: 'require RSI < 40 for longs, > 60 for shorts' },
1131
+ { key: 'volume_filter', label: 'Volume spike', source: 'custom', desc: 'require 1.5x average volume' },
1132
+ { key: 'adx_trend', label: 'ADX trend', source: 'custom', desc: 'only trade when ADX > 25' },
1133
+ ];
1134
+ for (let i = 0; i < filterOptions.length; i++) {
1135
+ const f = filterOptions[i];
1136
+ const on = filters[f.key] === true;
1137
+ this.log(` ${cyan(String(i + 1))} ${on ? green('☑') : dim('☐')} From ${bold(f.source)}: ${f.desc}`);
1138
+ }
1139
+ this.log('');
1140
+ this.log(` ${dim('Benchmarks — your strategy vs validated:')}`);
1141
+ this.log('');
1142
+ for (const s of STRATEGIES) {
1143
+ this.log(` ${green('★')} ${s.name.padEnd(22)} ${colorNum(s.ret, '%').padEnd(18)} Sharpe ${s.sharpe}`);
1144
+ }
1145
+ this.log('');
1146
+ this.log(` ${cyan('#')} Toggle a filter`);
1147
+ this.log(` ${cyan('t')} Quick test this combination`);
1148
+ this.log(` ${cyan('b')} Back to workbench`);
1149
+ this.log('');
1150
+ const choice = await ask(` ${cyan('>')} `);
1151
+ const idx = parseInt(choice) - 1;
1152
+ if (idx >= 0 && idx < filterOptions.length) {
1153
+ const key = filterOptions[idx].key;
1154
+ filters[key] = !filters[key];
1155
+ config.filters = filters;
1156
+ const action = filters[key] ? 'enabled' : 'disabled';
1157
+ await this.saveAndRegenerate(strategyName, config, pair, `mixer: ${action} ${key}`);
1158
+ }
1159
+ else if (choice === 't') {
1160
+ await this.runQuickTest(strategyName, pair, 'mixer test');
1161
+ }
1162
+ if (choice === 'b') {
1163
+ return this.workbench(strategyName, pair);
1164
+ }
1165
+ return this.mixerMenu(strategyName, pair);
1166
+ }
1167
+ // ═══════════════════════════════════════════
1168
+ // 4. OPTIMIZE — Parameter sweep + apply
1169
+ // ═══════════════════════════════════════════
1170
+ async optimizeMenu() {
1171
+ this.log('');
1172
+ this.log(` ${bold('╔═══════════════════════════════════════════╗')}`);
1173
+ this.log(` ${bold('║ Strategy Optimizer ║')}`);
1174
+ this.log(` ${bold('╚═══════════════════════════════════════════╝')}`);
1175
+ const strategy = await this.pickStrategy('Strategy to optimize');
1176
+ if (!strategy)
1177
+ return this.mainMenu();
1178
+ const pair = (await ask(` ${cyan('Ticker')} ${dim('(BTC)')}: `) || 'BTC').replace('-PERP', '').replace('-perp', '').toUpperCase();
1179
+ this.log('');
1180
+ this.log(dim(` Running parameter sweep on ${strategy}...`));
1181
+ this.log('');
1182
+ // Run sweep via engine directly so we can capture the best params
1183
+ let bestParams = null;
1184
+ let bestMetrics = null;
1185
+ const isValidated = STRATEGIES.some(s => s.name === strategy);
1186
+ try {
1187
+ await runEngine('sweep', [strategy, '--pair', pair, '--top', '5'], (msg) => {
1188
+ if (msg.type === 'progress' && msg.msg) {
1189
+ // Compact progress: just combo count + ETA
1190
+ const msgStr = String(msg.msg);
1191
+ const match = msgStr.match(/Combo (\d+)\/(\d+)(.*?ETA \S+)?/);
1192
+ if (match) {
1193
+ const eta = match[3] ? match[3].trim() : '';
1194
+ process.stdout.write(`\x1b[2K\r ${dim(`Combo ${match[1]}/${match[2]}${eta ? ' — ' + eta : ''}`)}`);
1195
+ }
1196
+ else {
1197
+ process.stdout.write(`\x1b[2K\r ${dim(msgStr)}`);
1198
+ }
1199
+ }
1200
+ else if (msg.type === 'result') {
1201
+ process.stdout.write('\x1b[2K\r');
1202
+ const topEntries = msg.top;
1203
+ const total = msg.total_combos;
1204
+ const completed = msg.completed;
1205
+ this.log(dim(` Tested ${completed}/${total} combinations`));
1206
+ this.log('');
1207
+ if (topEntries && topEntries.length > 0) {
1208
+ bestParams = topEntries[0].params;
1209
+ bestMetrics = topEntries[0].metrics;
1210
+ this.log(dim(' ── Top Results ──'));
1211
+ this.log('');
1212
+ for (let i = 0; i < Math.min(topEntries.length, 5); i++) {
1213
+ const e = topEntries[i];
1214
+ const m = e.metrics;
1215
+ const ret = m.total_return_pct;
1216
+ const sharpe = m.sharpe_ratio;
1217
+ const marker = i === 0 ? green('★') : dim(String(i + 1));
1218
+ this.log(` ${marker} ${colorNum(ret, '%').padEnd(22)} Sharpe ${colorNum(sharpe)}`);
1219
+ const paramStr = Object.entries(e.params).map(([k, v]) => `${k}=${v}`).join(', ');
1220
+ this.log(` ${dim(paramStr)}`);
1221
+ this.log('');
1222
+ }
1223
+ }
1224
+ }
1225
+ else if (msg.type === 'error') {
1226
+ process.stdout.write('\x1b[2K\r');
1227
+ this.log(` ${red('✘')} ${msg.msg}`);
1228
+ }
1229
+ });
1230
+ }
1231
+ catch (e) {
1232
+ this.log(` ${red('✘')} ${e.message}`);
1233
+ return this.mainMenu();
1234
+ }
1235
+ if (!bestParams) {
1236
+ this.log(dim(' No results from sweep.'));
1237
+ return this.mainMenu();
1238
+ }
1239
+ this.log(` ${bold('What next?')}`);
1240
+ this.log(` ${cyan('1')} Validate the top config with full research pipeline`);
1241
+ this.log(` ${cyan('2')} Run another sweep with different settings`);
1242
+ this.log(` ${cyan('3')} Back to main menu`);
1243
+ this.log('');
1244
+ const next = await ask(` ${cyan('>')} `);
1245
+ if (next === '1') {
1246
+ // Run the research pipeline with config overrides — no new strategy file needed
1247
+ this.log('');
1248
+ this.log(dim(` Validating ${strategy} with optimized parameters...`));
1249
+ this.log('');
1250
+ const overridesJson = JSON.stringify(bestParams);
1251
+ let finalGrade = '';
1252
+ const engineArgs = [strategy, '--pair', pair, '--config-overrides', overridesJson];
1253
+ await runEngine('research', engineArgs, (msg) => {
1254
+ if (msg.type === 'step') {
1255
+ this.log(` ${dim(`Step ${msg.step}/5`)} ${msg.msg}`);
1256
+ }
1257
+ else if (msg.type === 'step_done') {
1258
+ this.log(` ${green('✔')} ${msg.msg}`);
1259
+ }
1260
+ else if (msg.type === 'progress' && msg.msg) {
1261
+ this.log(` ${dim(String(msg.msg))}`);
1262
+ }
1263
+ else if (msg.type === 'result') {
1264
+ finalGrade = msg.grade;
1265
+ this.renderGradedResult(msg);
1266
+ }
1267
+ else if (msg.type === 'error') {
1268
+ this.log(` ${red('✘')} ${msg.msg}`);
1269
+ }
1270
+ });
1271
+ // After validation, offer to save
1272
+ if (finalGrade === 'A' || finalGrade === 'B') {
1273
+ this.log(` ${green('★')} Optimization validated! Grade ${gradeColor(finalGrade)}`);
1274
+ this.log('');
1275
+ this.log(` ${cyan('1')} Save as a new strategy`);
1276
+ this.log(` ${cyan('2')} Back to main menu`);
1277
+ this.log('');
1278
+ const saveChoice = await ask(` ${cyan('>')} `);
1279
+ if (saveChoice === '1') {
1280
+ const saveName = await ask(` ${cyan('Strategy name')} ${dim('(snake_case)')}: `);
1281
+ if (saveName) {
1282
+ await this.saveOptimizedStrategy(strategy, saveName, bestParams);
1283
+ }
1284
+ }
1285
+ }
1286
+ else {
1287
+ this.log(` ${dim('Optimization did not pass validation. Try different parameters.')}`);
1288
+ }
1289
+ return this.mainMenu();
1290
+ }
1291
+ else if (next === '2') {
1292
+ return this.optimizeMenu();
1293
+ }
1294
+ return this.mainMenu();
1295
+ }
1296
+ /**
1297
+ * Save an optimized strategy by copying the original .py file and replacing config defaults.
1298
+ */
1299
+ async saveOptimizedStrategy(baseStrategy, newName, params) {
1300
+ // Use the Python engine to create the strategy file
1301
+ const paramsJson = JSON.stringify(params);
1302
+ try {
1303
+ await runEngine('save-optimized', [baseStrategy, newName, paramsJson], (msg) => {
1304
+ if (msg.type === 'result') {
1305
+ this.log(` ${green('✔')} Saved ${bold(newName)} with optimized parameters`);
1306
+ this.log(` ${dim(`File: ${msg.path}`)}`);
1307
+ }
1308
+ else if (msg.type === 'error') {
1309
+ this.log(` ${red('✘')} ${msg.msg}`);
1310
+ }
1311
+ });
1312
+ }
1313
+ catch (e) {
1314
+ this.log(` ${red('✘')} ${e.message}`);
1315
+ }
1316
+ }
1317
+ // ═══════════════════════════════════════════
1318
+ // 5. COMPARE — Head-to-head
1319
+ // ═══════════════════════════════════════════
1320
+ async compareMenu() {
1321
+ this.log('');
1322
+ this.log(` ${bold('╔═══════════════════════════════════════════╗')}`);
1323
+ this.log(` ${bold('║ Strategy Comparison ║')}`);
1324
+ this.log(` ${bold('╚═══════════════════════════════════════════╝')}`);
1325
+ this.log('');
1326
+ // Picker — dynamic from registry + workbench
1327
+ this.log(` ${cyan('1')} Compare all registered strategies`);
1328
+ this.log(` ${cyan('2')} Pick specific strategies`);
1329
+ this.log('');
1330
+ const choice = await ask(` ${cyan('>')} `);
1331
+ let strategies;
1332
+ if (choice === '1') {
1333
+ // Fetch full registry, comma-join
1334
+ let allNames = [];
1335
+ try {
1336
+ await runEngine('strategies', [], (msg) => {
1337
+ if (msg.type === 'result') {
1338
+ const strats = msg.strategies || [];
1339
+ allNames = strats.map((s) => String(s.name));
1340
+ }
1341
+ });
1342
+ }
1343
+ catch { /* empty */ }
1344
+ if (allNames.length < 2) {
1345
+ this.log(` ${dim('Need at least 2 registered strategies. Create one: rift new my-strategy')}`);
1346
+ return this.mainMenu();
1347
+ }
1348
+ strategies = allNames.join(',');
1349
+ }
1350
+ else {
1351
+ const picked = await this.pickStrategy('Pick strategies to compare', true);
1352
+ strategies = picked || '';
1353
+ }
1354
+ if (!strategies)
1355
+ return this.mainMenu();
1356
+ const pair = (await ask(` ${cyan('Ticker')} ${dim('(BTC)')}: `) || 'BTC').replace('-PERP', '').replace('-perp', '').toUpperCase();
1357
+ this.log('');
1358
+ await this.config.runCommand('compare', [strategies, '--pair', pair]);
1359
+ // Visual summary
1360
+ this.log(` ${bold('Recommendation:')}`);
1361
+ this.log(` Run ${cyan('rift research <strategy> --pair <COIN>')} to validate any strategy`);
1362
+ this.log(` Run ${cyan('rift scan --pair <COIN>')} to discover predictive features`);
1363
+ this.log('');
1364
+ this.log(` ${bold('What next?')}`);
1365
+ this.log(` ${cyan('1')} Run full research on one of these`);
1366
+ this.log(` ${cyan('2')} Build a portfolio with these strategies`);
1367
+ this.log(` ${cyan('3')} Back to main menu`);
1368
+ this.log('');
1369
+ const next = await ask(` ${cyan('>')} `);
1370
+ if (next === '1')
1371
+ return this.testMenu();
1372
+ if (next === '2') {
1373
+ this.log(`\n ${dim('Run:')} ${cyan('rift portfolio backtest strategies/configs/portfolio_btc.yaml')}\n`);
1374
+ }
1375
+ return this.mainMenu();
1376
+ }
1377
+ // ═══════════════════════════════════════════
1378
+ // PIPELINE — Full validation
1379
+ // ═══════════════════════════════════════════
1380
+ async runPipeline(strategy, pair, tf, equity = 10000) {
1381
+ this.log(` ${bold('RIFT Research Pipeline')}`);
1382
+ this.log(` ${dim('─'.repeat(50))}`);
1383
+ this.log(` Strategy: ${bold(strategy)}`);
1384
+ this.log(` Pair: ${pair}`);
1385
+ if (tf)
1386
+ this.log(` Timeframe: ${tf}`);
1387
+ this.log(` ${dim('─'.repeat(50))}`);
1388
+ this.log('');
1389
+ const engineArgs = [strategy, '--pair', pair, '--equity', String(equity)];
1390
+ if (tf)
1391
+ engineArgs.push('--tf', tf);
1392
+ let finalGrade = '';
1393
+ await runEngine('research', engineArgs, (msg) => {
1394
+ if (msg.type === 'step') {
1395
+ this.log(` ${dim(`Step ${msg.step}/5`)} ${msg.msg}`);
1396
+ }
1397
+ else if (msg.type === 'step_done') {
1398
+ this.log(` ${green('✔')} ${msg.msg}`);
1399
+ }
1400
+ else if (msg.type === 'progress' && msg.msg) {
1401
+ this.log(` ${dim(String(msg.msg))}`);
1402
+ }
1403
+ else if (msg.type === 'result') {
1404
+ finalGrade = msg.grade;
1405
+ this.renderGradedResult(msg);
1406
+ }
1407
+ else if (msg.type === 'error') {
1408
+ this.log(` ${red('✘')} ${msg.msg}`);
1409
+ }
1410
+ });
1411
+ // Post-pipeline guidance. When stdin isn't a TTY (README quickstart,
1412
+ // piped/scripted use, CI), printing an interactive menu and awaiting
1413
+ // input either hangs forever or — with </dev/null — drops the user
1414
+ // at an unanswerable prompt. Detect and print actionable copyable
1415
+ // commands instead, then exit cleanly.
1416
+ if (!process.stdin.isTTY) {
1417
+ this.log(` ${bold('Next steps:')}`);
1418
+ if (finalGrade === 'A' || finalGrade === 'B') {
1419
+ this.log(` ${cyan(`rift algo ${strategy} --pair ${pair}`)} ${dim('— go live with this strategy')}`);
1420
+ this.log(` ${cyan(`rift sweep ${strategy} --pair ${pair}`)} ${dim('— optimize parameters')}`);
1421
+ this.log(` ${cyan(`rift backtest ${strategy} --all-pairs --top 10`)} ${dim('— test other pairs')}`);
1422
+ }
1423
+ else {
1424
+ this.log(` ${cyan(`rift sweep ${strategy} --pair ${pair}`)} ${dim('— optimize parameters (may improve the grade)')}`);
1425
+ this.log(` ${cyan('rift strategies list')} ${dim('— see all available strategies')}`);
1426
+ this.log(` ${cyan(`rift research ${strategy} --pair <OTHER-COIN>`)} ${dim('— try a different pair')}`);
1427
+ }
1428
+ this.log('');
1429
+ return;
1430
+ }
1431
+ this.log(` ${bold('What next?')}`);
1432
+ if (finalGrade === 'A' || finalGrade === 'B') {
1433
+ this.log(` ${cyan('1')} Go live ${dim(`→ rift algo ${strategy} --pair ${pair}`)}`);
1434
+ this.log(` ${cyan('2')} Optimize parameters`);
1435
+ this.log(` ${cyan('3')} Test on different pairs`);
1436
+ this.log(` ${cyan('4')} Back to Research Lab`);
1437
+ }
1438
+ else {
1439
+ this.log(` ${cyan('1')} Optimize parameters ${dim('— might improve the grade')}`);
1440
+ this.log(` ${cyan('2')} Try a different strategy`);
1441
+ this.log(` ${cyan('3')} Try a different pair`);
1442
+ this.log(` ${cyan('4')} Back to Research Lab`);
1443
+ }
1444
+ this.log('');
1445
+ const next = await ask(` ${cyan('>')} `);
1446
+ if (finalGrade === 'A' || finalGrade === 'B') {
1447
+ if (next === '1') {
1448
+ this.log(`\n ${dim('Starting algo trading...')}\n`);
1449
+ await this.config.runCommand('algo', [strategy, '--pair', pair]);
1450
+ return this.mainMenu();
1451
+ }
1452
+ else if (next === '2')
1453
+ return this.optimizeMenu();
1454
+ else if (next === '3') {
1455
+ this.log('');
1456
+ await this.config.runCommand('backtest', [strategy, '--all-pairs', '--top', '10']);
1457
+ return this.mainMenu();
1458
+ }
1459
+ else if (next === '4')
1460
+ return this.mainMenu();
1461
+ else
1462
+ return this.mainMenu();
1463
+ }
1464
+ else {
1465
+ if (next === '1')
1466
+ return this.optimizeMenu();
1467
+ else if (next === '2')
1468
+ return this.testMenu();
1469
+ else if (next === '3') {
1470
+ const newPair = await ask(` ${cyan('New ticker')}: `);
1471
+ if (newPair) {
1472
+ return this.runPipeline(strategy, newPair.replace('-PERP', '').replace('-perp', '').toUpperCase(), tf, equity);
1473
+ }
1474
+ return this.mainMenu();
1475
+ }
1476
+ else if (next === '4')
1477
+ return this.mainMenu();
1478
+ else
1479
+ return this.mainMenu();
1480
+ }
1481
+ }
1482
+ // ═══════════════════════════════════════════
1483
+ // GRADE RENDER
1484
+ // ═══════════════════════════════════════════
1485
+ renderGradedResult(msg) {
1486
+ const grade = msg.grade;
1487
+ const verdict = msg.verdict;
1488
+ const bt = msg.backtest;
1489
+ const wf = msg.walkforward;
1490
+ const mc = msg.montecarlo;
1491
+ const multi = msg.multi_pair;
1492
+ const iw = 53;
1493
+ const row = boxRow(iw);
1494
+ const rr = resultRow(iw);
1495
+ this.log('');
1496
+ // Grade banner
1497
+ const gradeText = `GRADE: ${gradeColor(grade)}`;
1498
+ this.log(boldBoxTop(iw + 2));
1499
+ this.log(` ${bold('║')}${padEndVis(' '.repeat(Math.floor((iw - 7) / 2)) + gradeText, iw + 1)}${bold('║')}`);
1500
+ this.log(boldBoxBottom(iw + 2));
1501
+ this.log('');
1502
+ if (grade === 'A')
1503
+ this.log(` ${green(verdict)}`);
1504
+ else if (grade === 'B')
1505
+ this.log(` ${cyan(verdict)}`);
1506
+ else if (grade === 'C')
1507
+ this.log(` ${yellow(verdict)}`);
1508
+ else
1509
+ this.log(` ${red(verdict)}`);
1510
+ this.log('');
1511
+ this.log(boxTop(iw));
1512
+ // Backtest
1513
+ if (bt) {
1514
+ this.log(row(`${bold('BACKTEST')}`));
1515
+ this.log(boxDivider(iw));
1516
+ this.log(rr('Return', colorNum(bt.return_pct, '%')));
1517
+ this.log(rr('Sharpe', colorNum(bt.sharpe)));
1518
+ this.log(rr('Profit Factor', String(bt.profit_factor)));
1519
+ this.log(rr('Max Drawdown', colorNum(bt.max_drawdown_pct, '%')));
1520
+ this.log(rr('Win Rate', `${bt.win_rate}%`));
1521
+ this.log(rr('Trades', String(bt.num_trades)));
1522
+ }
1523
+ // Walk-Forward
1524
+ if (wf && !wf.error) {
1525
+ this.log(boxDivider(iw));
1526
+ this.log(row(`${bold('WALK-FORWARD')}`));
1527
+ this.log(boxDivider(iw));
1528
+ const deg = wf.degradation_ratio;
1529
+ const degLabel = deg >= 0.7 ? green('ROBUST') : deg >= 0.4 ? yellow('MODERATE') : deg > 0 ? red('WEAK') : red('OVERFIT');
1530
+ this.log(rr('Degradation', `${deg} — ${degLabel}`));
1531
+ this.log(rr('Profitable Windows', `${wf.profitable_windows}%`));
1532
+ this.log(rr('Combined OOS Return', colorNum(wf.combined_oos_return, '%')));
1533
+ }
1534
+ // Monte Carlo
1535
+ if (mc && !mc.error) {
1536
+ this.log(boxDivider(iw));
1537
+ this.log(row(`${bold('MONTE CARLO')}`));
1538
+ this.log(boxDivider(iw));
1539
+ const probColor = mc.prob_profit >= 85 ? green : mc.prob_profit >= 60 ? yellow : red;
1540
+ this.log(rr('Profit Probability', probColor(`${mc.prob_profit}%`)));
1541
+ this.log(rr('Ruin Probability', mc.prob_ruin === 0 ? green(`${mc.prob_ruin}%`) : red(`${mc.prob_ruin}%`)));
1542
+ this.log(rr('Worst Case (5th)', colorNum(mc.p5, '%')));
1543
+ this.log(rr('Median', colorNum(mc.p50, '%')));
1544
+ }
1545
+ // Multi-pair
1546
+ if (multi && multi.length > 0) {
1547
+ this.log(boxDivider(iw));
1548
+ this.log(row(`${bold('MULTI-PAIR TEST')}`));
1549
+ this.log(boxDivider(iw));
1550
+ for (const r of multi) {
1551
+ const marker = r.return_pct > 0 ? green('✔') : red('✘');
1552
+ this.log(row(` ${marker} ${r.pair.padEnd(10)} ${padEndVis(colorNum(r.return_pct, '%'), 14)} Sharpe ${colorNum(r.sharpe)}`));
1553
+ }
1554
+ const profitable = multi.filter(r => r.return_pct > 0).length;
1555
+ this.log(row(` ${dim(`Profitable on ${profitable}/${multi.length} additional pairs`)}`));
1556
+ }
1557
+ this.log(boxBottom(iw));
1558
+ this.log('');
1559
+ }
1560
+ // ═══════════════════════════════════════════
1561
+ // EXPLORE SUBMENUS
1562
+ // ═══════════════════════════════════════════
1563
+ // ─── 1. Indicator catalog ────────────────────
1564
+ async indicatorCatalogMenu(category = '') {
1565
+ let data = null;
1566
+ try {
1567
+ const args = ['indicators'];
1568
+ if (category)
1569
+ args.push('--category', category);
1570
+ await runEngine(args[0], args.slice(1), (msg) => {
1571
+ if (msg.type === 'result')
1572
+ data = msg;
1573
+ });
1574
+ }
1575
+ catch (e) {
1576
+ this.log(` ${red('✘')} ${e.message}`);
1577
+ return this.exploreMenu();
1578
+ }
1579
+ if (!data)
1580
+ return this.exploreMenu();
1581
+ const d = data;
1582
+ this.log('');
1583
+ const title = category ? `INDICATOR CATALOG — ${category}` : 'INDICATOR CATALOG';
1584
+ this.log(` ${bold(title)} ${dim(`(${d.total} indicators)`)}`);
1585
+ this.log(` ${dim('─'.repeat(60))}`);
1586
+ const renderItems = (items) => {
1587
+ for (const it of items) {
1588
+ const params = it.params.map(p => p.default !== null && p.default !== undefined ? `${p.name}=${p.default}` : p.name).join(', ');
1589
+ const paramStr = params ? `(${params})` : '';
1590
+ this.log(` ${cyan(it.name.padEnd(22))} ${dim(paramStr.padEnd(30))} ${dim(it.description)}`);
1591
+ }
1592
+ };
1593
+ for (const [cat, items] of Object.entries(d.categories)) {
1594
+ this.log('');
1595
+ this.log(` ${bold(cat.toUpperCase())} ${dim(`(${items.length})`)}`);
1596
+ renderItems(items);
1597
+ }
1598
+ if (d.uncategorized.length > 0) {
1599
+ this.log('');
1600
+ this.log(` ${yellow('UNCATEGORIZED')} ${dim(`(${d.uncategorized.length} — update _INDICATOR_CATEGORIES)`)}`);
1601
+ renderItems(d.uncategorized);
1602
+ }
1603
+ this.log('');
1604
+ if (!category) {
1605
+ this.log(` ${dim('Filter:')} ${cyan('1')} trend ${cyan('2')} momentum ${cyan('3')} volatility ${cyan('4')} volume`);
1606
+ this.log(` ${dim(' ')} ${cyan('5')} structure ${cyan('6')} adaptive ${cyan('7')} cross_asset ${cyan('8')} order_flow`);
1607
+ this.log(` ${dim(' ')} ${cyan('s')} search by name/description ${cyan('b')} back`);
1608
+ }
1609
+ else {
1610
+ this.log(` ${cyan('a')} show all categories ${cyan('b')} back`);
1611
+ }
1612
+ this.log('');
1613
+ const choice = await ask(` ${cyan('>')} `);
1614
+ const catMap = {
1615
+ '1': 'trend', '2': 'momentum', '3': 'volatility', '4': 'volume',
1616
+ '5': 'structure', '6': 'adaptive', '7': 'cross_asset', '8': 'order_flow',
1617
+ };
1618
+ if (catMap[choice])
1619
+ return this.indicatorCatalogMenu(catMap[choice]);
1620
+ if (choice === 'a' || choice === 'A')
1621
+ return this.indicatorCatalogMenu('');
1622
+ if (choice === 's' || choice === 'S')
1623
+ return this.indicatorSearchMenu();
1624
+ return this.exploreMenu();
1625
+ }
1626
+ async indicatorSearchMenu() {
1627
+ const q = await ask(` ${cyan('Search term')}: `);
1628
+ if (!q)
1629
+ return this.exploreMenu();
1630
+ try {
1631
+ await runEngine('indicators', ['--search', q], (msg) => {
1632
+ if (msg.type !== 'result')
1633
+ return;
1634
+ const d = msg;
1635
+ this.log('');
1636
+ this.log(` ${bold('SEARCH:')} "${q}" ${dim(`(${d.total} matches)`)}`);
1637
+ this.log(` ${dim('─'.repeat(60))}`);
1638
+ for (const [cat, items] of Object.entries(d.categories)) {
1639
+ for (const it of items) {
1640
+ const params = it.params.map((p) => p.default !== null && p.default !== undefined ? `${p.name}=${p.default}` : p.name).join(', ');
1641
+ const paramStr = params ? `(${params})` : '';
1642
+ this.log(` ${cyan(it.name.padEnd(22))} ${dim(cat.padEnd(14))} ${dim(paramStr.padEnd(28))} ${dim(it.description)}`);
1643
+ }
1644
+ }
1645
+ this.log('');
1646
+ });
1647
+ }
1648
+ catch (e) {
1649
+ this.log(` ${red('✘')} ${e.message}`);
1650
+ }
1651
+ await ask(` ${dim('Press Enter to continue')} `);
1652
+ return this.exploreMenu();
1653
+ }
1654
+ // ─── 4. Signal forensics ─────────────────────
1655
+ async signalForensicsMenu() {
1656
+ this.log('');
1657
+ this.log(` ${bold('SIGNAL FORENSICS')}`);
1658
+ this.log(` ${dim('─'.repeat(60))}`);
1659
+ this.log('');
1660
+ this.log(` ${cyan('1')} Signal stats ${dim('hit rate + edge per signal on a coin')}`);
1661
+ this.log(` ${cyan('2')} Signal decay ${dim('does the signal lose edge over time?')}`);
1662
+ this.log(` ${cyan('3')} Signal backfill ${dim('compute missing signal series from cache')}`);
1663
+ this.log(` ${cyan('b')} Back`);
1664
+ this.log('');
1665
+ const choice = await ask(` ${cyan('>')} `);
1666
+ if (choice === 'b' || choice === 'B')
1667
+ return this.exploreMenu();
1668
+ const coin = (await ask(` ${cyan('Coin')} ${dim('(BTC)')}: `) || 'BTC').toUpperCase();
1669
+ const tf = await ask(` ${cyan('Timeframe')} ${dim('(1h)')}: `) || '1h';
1670
+ let cmd = '';
1671
+ let args = [];
1672
+ if (choice === '1') {
1673
+ cmd = 'signal-stats';
1674
+ args = ['--pair', coin, '--tf', tf];
1675
+ }
1676
+ else if (choice === '2') {
1677
+ cmd = 'signal-decay';
1678
+ args = ['--pair', coin, '--tf', tf];
1679
+ }
1680
+ else if (choice === '3') {
1681
+ cmd = 'signal-backfill';
1682
+ args = ['--pair', coin, '--tf', tf];
1683
+ }
1684
+ else
1685
+ return this.signalForensicsMenu();
1686
+ this.log('');
1687
+ this.log(dim(` Running ${cmd}...`));
1688
+ try {
1689
+ await runEngine(cmd, args, (msg) => {
1690
+ if (msg.type === 'result') {
1691
+ // Print structured JSON for now; per-command rendering can come later.
1692
+ this.log(JSON.stringify(msg, null, 2));
1693
+ }
1694
+ else if (msg.type === 'error') {
1695
+ this.log(` ${red('✘')} ${msg.msg}`);
1696
+ }
1697
+ });
1698
+ }
1699
+ catch (e) {
1700
+ this.log(` ${red('✘')} ${e.message}`);
1701
+ }
1702
+ this.log('');
1703
+ await ask(` ${dim('Press Enter to continue')} `);
1704
+ return this.signalForensicsMenu();
1705
+ }
1706
+ // ─── 5. Funding rate browser ─────────────────
1707
+ async fundingBrowserMenu() {
1708
+ let data = null;
1709
+ try {
1710
+ await runEngine('funding-browser', ['--top', '20'], (msg) => {
1711
+ if (msg.type === 'result')
1712
+ data = msg;
1713
+ });
1714
+ }
1715
+ catch (e) {
1716
+ this.log(` ${red('✘')} ${e.message}`);
1717
+ return this.exploreMenu();
1718
+ }
1719
+ if (!data)
1720
+ return this.exploreMenu();
1721
+ const rows = (data.coins || []);
1722
+ this.log('');
1723
+ this.log(` ${bold('FUNDING RATE BROWSER')} ${dim(`(${data.lookback_days}d stats, sorted by |current rate|)`)}`);
1724
+ this.log(` ${dim('─'.repeat(76))}`);
1725
+ if (rows.length === 0) {
1726
+ this.log('');
1727
+ this.log(` ${yellow('!')} No funding data cached yet.`);
1728
+ this.log('');
1729
+ this.log(` ${dim('To populate:')}`);
1730
+ this.log(` ${cyan('rift fetch BTC --tf 1h')} ${dim('— fetches candles + funding (free, HL info)')}`);
1731
+ this.log(` ${cyan('rift sync --include-funding')} ${dim('— full historical (requires AWS for HL S3)')}`);
1732
+ this.log('');
1733
+ await ask(` ${dim('Press Enter to continue')} `);
1734
+ return this.exploreMenu();
1735
+ }
1736
+ this.log(` ${dim('coin'.padEnd(10))} ${dim('current/hr'.padStart(12))} ${dim('mean/hr'.padStart(12))} ${dim('min'.padStart(12))} ${dim('max'.padStart(12))} ${dim('zscore'.padStart(8))}`);
1737
+ for (const r of rows) {
1738
+ const cur = (r.current_pct_per_hour * 1).toFixed(4) + '%';
1739
+ const mean = (r.mean_rate * 100).toFixed(4) + '%';
1740
+ const min = (r.min_rate * 100).toFixed(4) + '%';
1741
+ const max = (r.max_rate * 100).toFixed(4) + '%';
1742
+ const z = r.zscore.toFixed(2);
1743
+ const curColor = r.current_rate > 0 ? green : r.current_rate < 0 ? red : dim;
1744
+ const zColor = Math.abs(r.zscore) >= 2 ? yellow : dim;
1745
+ this.log(` ${cyan(r.coin.padEnd(10))} ${curColor(cur.padStart(12))} ${dim(mean.padStart(12))} ${dim(min.padStart(12))} ${dim(max.padStart(12))} ${zColor(z.padStart(8))}`);
1746
+ }
1747
+ this.log('');
1748
+ this.log(dim(' positive = longs pay shorts (longs overcrowded)'));
1749
+ this.log(dim(' |z|≥2 = currently extreme vs the trailing window'));
1750
+ this.log('');
1751
+ await ask(` ${dim('Press Enter to continue')} `);
1752
+ return this.exploreMenu();
1753
+ }
1754
+ // ─── 6. Order flow browser ───────────────────
1755
+ async orderFlowBrowserMenu() {
1756
+ let data = null;
1757
+ try {
1758
+ await runEngine('order-flow', ['--top', '20'], (msg) => {
1759
+ if (msg.type === 'result')
1760
+ data = msg;
1761
+ });
1762
+ }
1763
+ catch (e) {
1764
+ this.log(` ${red('✘')} ${e.message}`);
1765
+ return this.exploreMenu();
1766
+ }
1767
+ if (!data)
1768
+ return this.exploreMenu();
1769
+ const rows = (data.coins || []);
1770
+ this.log('');
1771
+ this.log(` ${bold('ORDER FLOW BROWSER')} ${dim(`(${data.lookback_hours}h, sorted by |imbalance|)`)}`);
1772
+ this.log(` ${dim('─'.repeat(80))}`);
1773
+ if (rows.length === 0) {
1774
+ this.log('');
1775
+ this.log(` ${yellow('!')} No fill data cached yet.`);
1776
+ this.log('');
1777
+ this.log(` ${dim('Fill data only comes from HL\'s S3 archive (requester-pays).')}`);
1778
+ this.log(` ${dim('To populate:')}`);
1779
+ this.log(` ${cyan('rift sync --coins BTC --include-fills')} ${dim('— requires AWS credentials')}`);
1780
+ this.log('');
1781
+ this.log(` ${dim('No free path exists for order-flow data (HL info endpoint')}`);
1782
+ this.log(` ${dim('doesn\'t expose per-fill data, only aggregated candles).')}`);
1783
+ this.log('');
1784
+ await ask(` ${dim('Press Enter to continue')} `);
1785
+ return this.exploreMenu();
1786
+ }
1787
+ this.log(` ${dim('coin'.padEnd(8))} ${dim('fills'.padStart(8))} ${dim('imbalance'.padStart(12))} ${dim('taker'.padStart(8))} ${dim('opens'.padStart(12))} ${dim('closes'.padStart(12))} ${dim('net flow'.padStart(12))}`);
1788
+ for (const r of rows) {
1789
+ const imb = (r.imbalance_pct).toFixed(2) + '%';
1790
+ const taker = isNaN(r.taker_ratio) ? '—' : (r.taker_ratio * 100).toFixed(1) + '%';
1791
+ const opens = isNaN(r.opens) ? '—' : r.opens.toFixed(0);
1792
+ const closes = isNaN(r.closes) ? '—' : r.closes.toFixed(0);
1793
+ const netflow = isNaN(r.net_flow) ? '—' : r.net_flow.toFixed(0);
1794
+ const imbColor = r.imbalance > 0.02 ? green : r.imbalance < -0.02 ? red : dim;
1795
+ this.log(` ${cyan(r.coin.padEnd(8))} ${dim(String(r.fills).padStart(8))} ${imbColor(imb.padStart(12))} ${dim(taker.padStart(8))} ${dim(opens.padStart(12))} ${dim(closes.padStart(12))} ${dim(netflow.padStart(12))}`);
1796
+ }
1797
+ this.log('');
1798
+ this.log(dim(' imbalance > 0 = more buy-aggressor volume; < 0 = more sell-aggressor'));
1799
+ this.log(dim(' taker = % of fills that crossed the spread (aggressive)'));
1800
+ this.log(dim(' net flow > 0 = positions being opened; < 0 = being closed'));
1801
+ this.log('');
1802
+ await ask(` ${dim('Press Enter to continue')} `);
1803
+ return this.exploreMenu();
1804
+ }
1805
+ // ─── 7. Cross-asset relationships ────────────
1806
+ async crossAssetMenu() {
1807
+ // Default coin list = whatever's actually cached at 1h (no hardcoded
1808
+ // RIFT-team assumptions). OSS users with only BTC cached will see
1809
+ // BTC and a helpful hint to fetch more.
1810
+ let defaultCoins = 'BTC';
1811
+ try {
1812
+ const fs = await import('node:fs');
1813
+ const path = await import('node:path');
1814
+ const dataDir = path.join(process.env.HOME || '~', '.rift', 'data');
1815
+ if (fs.existsSync(dataDir)) {
1816
+ const coins = [];
1817
+ for (const entry of fs.readdirSync(dataDir)) {
1818
+ if (entry.startsWith('_'))
1819
+ continue;
1820
+ const candleFile = path.join(dataDir, entry, '1h', 'candles.parquet');
1821
+ if (fs.existsSync(candleFile))
1822
+ coins.push(entry);
1823
+ }
1824
+ if (coins.length > 0)
1825
+ defaultCoins = coins.slice(0, 8).join(',');
1826
+ }
1827
+ }
1828
+ catch { /* fall back to BTC */ }
1829
+ if (defaultCoins === 'BTC') {
1830
+ this.log('');
1831
+ this.log(` ${dim('Only BTC cached at 1h. For a meaningful cross-asset matrix, fetch a few coins first:')}`);
1832
+ this.log(` ${cyan('rift fetch ETH --tf 1h')}`);
1833
+ this.log(` ${cyan('rift fetch SOL --tf 1h')}`);
1834
+ this.log(` ${cyan('rift fetch HYPE --tf 1h')}`);
1835
+ }
1836
+ const coinsInput = await ask(` ${cyan('Coins')} ${dim(`(${defaultCoins})`)}: `);
1837
+ const coins = coinsInput || defaultCoins;
1838
+ const lookbackInput = await ask(` ${cyan('Lookback candles')} ${dim('(720 = 30d of 1h)')}: `);
1839
+ const lookback = lookbackInput || '720';
1840
+ this.log('');
1841
+ this.log(dim(' Computing correlation + lead-lag + beta...'));
1842
+ let data = null;
1843
+ let errMsg = '';
1844
+ try {
1845
+ await runEngine('cross-asset', ['--coins', coins, '--lookback', lookback], (msg) => {
1846
+ if (msg.type === 'result')
1847
+ data = msg;
1848
+ else if (msg.type === 'error')
1849
+ errMsg = String(msg.msg);
1850
+ });
1851
+ }
1852
+ catch (e) {
1853
+ errMsg = e.message;
1854
+ }
1855
+ if (!data) {
1856
+ this.log('');
1857
+ this.log(` ${yellow('!')} ${errMsg || 'No data returned.'}`);
1858
+ this.log('');
1859
+ this.log(` ${dim('Fetch candle data first:')}`);
1860
+ this.log(` ${cyan('rift fetch <COIN> --tf 1h')} ${dim('— for each coin you want in the matrix')}`);
1861
+ this.log('');
1862
+ await ask(` ${dim('Press Enter to continue')} `);
1863
+ return this.exploreMenu();
1864
+ }
1865
+ this.log('');
1866
+ this.log(` ${bold('CROSS-ASSET MATRIX')} ${dim(`(${data.lookback_candles} ${data.tf} candles, benchmark=${data.benchmark})`)}`);
1867
+ this.log(` ${dim('─'.repeat(70))}`);
1868
+ const available = data.available_coins;
1869
+ const skipped = data.skipped;
1870
+ // Correlation matrix
1871
+ this.log('');
1872
+ this.log(` ${bold('Correlation matrix')} ${dim('(log returns)')}`);
1873
+ const header = ' ' + available.map(c => c.padStart(8)).join('');
1874
+ this.log(` ${dim(header)}`);
1875
+ for (const ci of available) {
1876
+ const row = available.map(cj => {
1877
+ const v = data.corr[ci][cj];
1878
+ const s = v.toFixed(2).padStart(8);
1879
+ if (ci === cj)
1880
+ return dim(s);
1881
+ if (Math.abs(v) >= 0.7)
1882
+ return green(s);
1883
+ if (Math.abs(v) >= 0.4)
1884
+ return cyan(s);
1885
+ if (Math.abs(v) >= 0.2)
1886
+ return dim(s);
1887
+ return dim(s);
1888
+ }).join('');
1889
+ this.log(` ${cyan(ci.padEnd(7))}${row}`);
1890
+ }
1891
+ // Lead-lag
1892
+ this.log('');
1893
+ this.log(` ${bold('Lead-lag vs')} ${cyan(data.benchmark)} ${dim('(positive lag = benchmark leads)')}`);
1894
+ for (const ll of data.lead_lag) {
1895
+ const lagStr = ll.best_lag > 0 ? `+${ll.best_lag}` : String(ll.best_lag);
1896
+ const corrColor = Math.abs(ll.best_corr) >= 0.5 ? green : Math.abs(ll.best_corr) >= 0.3 ? cyan : dim;
1897
+ this.log(` ${cyan(ll.coin.padEnd(8))} best lag=${lagStr.padStart(3)} corr=${corrColor(ll.best_corr.toFixed(3))}`);
1898
+ }
1899
+ // Beta
1900
+ this.log('');
1901
+ this.log(` ${bold('Beta vs')} ${cyan(data.benchmark)} ${dim('(>1 = more volatile, <1 = less)')}`);
1902
+ for (const b of data.beta) {
1903
+ const betaColor = b.beta > 1.5 ? red : b.beta > 1 ? yellow : b.beta < 0.5 ? cyan : dim;
1904
+ this.log(` ${cyan(b.coin.padEnd(8))} β = ${betaColor(b.beta.toFixed(3))}`);
1905
+ }
1906
+ if (skipped.length > 0) {
1907
+ this.log('');
1908
+ this.log(dim(' Skipped:'));
1909
+ for (const s of skipped) {
1910
+ this.log(` ${dim(s.coin)}: ${dim(s.reason)}`);
1911
+ }
1912
+ }
1913
+ this.log('');
1914
+ await ask(` ${dim('Press Enter to continue')} `);
1915
+ return this.exploreMenu();
1916
+ }
1917
+ // ─── 8. Regime browser ───────────────────────
1918
+ async regimeBrowserMenu() {
1919
+ const coin = (await ask(` ${cyan('Coin')} ${dim('(BTC)')}: `) || 'BTC').toUpperCase();
1920
+ const tf = await ask(` ${cyan('Timeframe')} ${dim('(1h)')}: `) || '1h';
1921
+ this.log('');
1922
+ this.log(dim(` Classifying regime for ${coin} ${tf}...`));
1923
+ let data = null;
1924
+ let errMsg = '';
1925
+ try {
1926
+ await runEngine('regime', ['--coin', coin, '--tf', tf], (msg) => {
1927
+ if (msg.type === 'result')
1928
+ data = msg;
1929
+ else if (msg.type === 'error')
1930
+ errMsg = String(msg.msg);
1931
+ });
1932
+ }
1933
+ catch (e) {
1934
+ errMsg = e.message;
1935
+ }
1936
+ if (!data) {
1937
+ this.log('');
1938
+ this.log(` ${yellow('!')} ${errMsg || `No ${coin} ${tf} data cached.`}`);
1939
+ this.log('');
1940
+ this.log(` ${dim('Fetch it:')} ${cyan(`rift fetch ${coin} --tf ${tf}`)}`);
1941
+ this.log('');
1942
+ await ask(` ${dim('Press Enter to continue')} `);
1943
+ return this.exploreMenu();
1944
+ }
1945
+ const cur = data.current;
1946
+ const volColor = cur.vol_regime === 'high' ? red : cur.vol_regime === 'low' ? cyan : dim;
1947
+ const trendColor = cur.trend_regime === 'bull' ? green : cur.trend_regime === 'bear' ? red : dim;
1948
+ this.log('');
1949
+ this.log(` ${bold('REGIME BROWSER')} ${dim(`(${data.coin} ${data.tf}, ${data.candles_analyzed} candles)`)}`);
1950
+ this.log(` ${dim('─'.repeat(60))}`);
1951
+ this.log('');
1952
+ this.log(` ${bold('Right now:')}`);
1953
+ this.log(` Vol regime: ${volColor(cur.vol_regime.toUpperCase())} ${dim(`ATR ${cur.atr.toFixed(2)}`)}`);
1954
+ this.log(` Trend regime: ${trendColor(cur.trend_regime.toUpperCase())} ${dim(`ADX ${cur.adx.toFixed(1)} +DI ${cur.plus_di.toFixed(1)} -DI ${cur.minus_di.toFixed(1)}`)}`);
1955
+ this.log(` Last close: ${bold('$' + cur.close.toLocaleString())}`);
1956
+ this.log('');
1957
+ this.log(` ${bold('Historical breakdown')} ${dim(`(% of analyzed candles)`)}`);
1958
+ this.log(` Vol:`);
1959
+ const vb = data.vol_breakdown_pct;
1960
+ for (const [k, v] of Object.entries(vb)) {
1961
+ const c = k === 'high' ? red : k === 'low' ? cyan : dim;
1962
+ const barLen = Math.round(v / 2);
1963
+ this.log(` ${c(k.padEnd(8))} ${c('█'.repeat(barLen))} ${dim(v.toFixed(1) + '%')}`);
1964
+ }
1965
+ this.log(` Trend:`);
1966
+ const tb = data.trend_breakdown_pct;
1967
+ for (const [k, v] of Object.entries(tb)) {
1968
+ const c = k === 'bull' ? green : k === 'bear' ? red : dim;
1969
+ const barLen = Math.round(v / 2);
1970
+ this.log(` ${c(k.padEnd(8))} ${c('█'.repeat(barLen))} ${dim(v.toFixed(1) + '%')}`);
1971
+ }
1972
+ this.log('');
1973
+ await ask(` ${dim('Press Enter to continue')} `);
1974
+ return this.exploreMenu();
1975
+ }
1976
+ }