bitbank-lab-mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +388 -0
  3. package/assets/lightweight-charts.standalone.js +7 -0
  4. package/bin/bitbank-lab-mcp.js +20 -0
  5. package/lib/cache.ts +70 -0
  6. package/lib/candle-utils.ts +48 -0
  7. package/lib/candle-validate.ts +434 -0
  8. package/lib/conversions.ts +25 -0
  9. package/lib/datetime.ts +157 -0
  10. package/lib/depth-analysis.ts +51 -0
  11. package/lib/error.ts +15 -0
  12. package/lib/formatter.ts +296 -0
  13. package/lib/get-depth.ts +111 -0
  14. package/lib/http.ts +132 -0
  15. package/lib/indicator-config.ts +39 -0
  16. package/lib/indicator_buffer.ts +41 -0
  17. package/lib/indicators.ts +579 -0
  18. package/lib/logger.ts +120 -0
  19. package/lib/ma-snapshot-utils.ts +277 -0
  20. package/lib/math.ts +89 -0
  21. package/lib/pattern-diagrams.ts +562 -0
  22. package/lib/result.ts +104 -0
  23. package/lib/validate.ts +154 -0
  24. package/lib/volatility.ts +132 -0
  25. package/package.json +79 -0
  26. package/src/env.ts +4 -0
  27. package/src/handlers/analyzeCandlePatternsHandler.ts +383 -0
  28. package/src/handlers/analyzeFibonacciHandler.ts +54 -0
  29. package/src/handlers/analyzeIndicatorsHandler.ts +682 -0
  30. package/src/handlers/analyzeMarketSignalHandler.ts +272 -0
  31. package/src/handlers/analyzeMyPortfolioHandler.ts +800 -0
  32. package/src/handlers/detectPatternsHandler.ts +77 -0
  33. package/src/handlers/detectPatternsViewsHandler.ts +518 -0
  34. package/src/handlers/getTickersJpyHandler.ts +145 -0
  35. package/src/handlers/getVolatilityMetricsHandler.ts +234 -0
  36. package/src/handlers/portfolio/calc.ts +549 -0
  37. package/src/handlers/portfolio/fetch.ts +318 -0
  38. package/src/handlers/portfolio/types.ts +170 -0
  39. package/src/handlers/renderChartSvgHandler.ts +69 -0
  40. package/src/handlers/runBacktestHandler.ts +70 -0
  41. package/src/http.ts +107 -0
  42. package/src/private/auth.ts +104 -0
  43. package/src/private/client.ts +298 -0
  44. package/src/private/config.ts +25 -0
  45. package/src/private/confirmation.ts +185 -0
  46. package/src/private/schemas.ts +866 -0
  47. package/src/prompts.ts +2296 -0
  48. package/src/resources/app-resources.ts +79 -0
  49. package/src/schema/analysis.ts +942 -0
  50. package/src/schema/backtest.ts +100 -0
  51. package/src/schema/base.ts +88 -0
  52. package/src/schema/candle-validate.ts +135 -0
  53. package/src/schema/chart.ts +399 -0
  54. package/src/schema/index.ts +11 -0
  55. package/src/schema/indicators.ts +125 -0
  56. package/src/schema/market-data.ts +298 -0
  57. package/src/schema/patterns.ts +382 -0
  58. package/src/schema/types.ts +97 -0
  59. package/src/schemas.d.ts +37 -0
  60. package/src/schemas.ts +7 -0
  61. package/src/server.ts +405 -0
  62. package/src/tool-definition.ts +44 -0
  63. package/src/tool-registry.ts +174 -0
  64. package/src/types/express-shim.d.ts +9 -0
  65. package/src/types/schemas.generated.d.ts +23 -0
  66. package/tools/analyze_bb_snapshot.ts +385 -0
  67. package/tools/analyze_candle_patterns.ts +810 -0
  68. package/tools/analyze_currency_strength.ts +273 -0
  69. package/tools/analyze_ema_snapshot.ts +183 -0
  70. package/tools/analyze_fibonacci.ts +530 -0
  71. package/tools/analyze_ichimoku_snapshot.ts +606 -0
  72. package/tools/analyze_indicators.ts +691 -0
  73. package/tools/analyze_market_signal.ts +665 -0
  74. package/tools/analyze_mtf_fibonacci.ts +273 -0
  75. package/tools/analyze_mtf_sma.ts +175 -0
  76. package/tools/analyze_sma_snapshot.ts +146 -0
  77. package/tools/analyze_stoch_snapshot.ts +276 -0
  78. package/tools/analyze_support_resistance.ts +817 -0
  79. package/tools/analyze_volume_profile.ts +546 -0
  80. package/tools/chart/ichimoku-cloud.ts +113 -0
  81. package/tools/chart/render-depth.ts +139 -0
  82. package/tools/chart/render-sub-panels.ts +208 -0
  83. package/tools/chart/svg-utils.ts +102 -0
  84. package/tools/detect_macd_cross.ts +691 -0
  85. package/tools/detect_patterns.ts +424 -0
  86. package/tools/detect_whale_events.ts +181 -0
  87. package/tools/get_candles.ts +487 -0
  88. package/tools/get_flow_metrics.ts +596 -0
  89. package/tools/get_orderbook.ts +540 -0
  90. package/tools/get_ticker.ts +132 -0
  91. package/tools/get_tickers_jpy.ts +240 -0
  92. package/tools/get_transactions.ts +209 -0
  93. package/tools/get_volatility_metrics.ts +302 -0
  94. package/tools/patterns/aftermath.ts +212 -0
  95. package/tools/patterns/config.ts +151 -0
  96. package/tools/patterns/detect_doubles.ts +650 -0
  97. package/tools/patterns/detect_hs.ts +635 -0
  98. package/tools/patterns/detect_pennants.ts +373 -0
  99. package/tools/patterns/detect_triangles.ts +820 -0
  100. package/tools/patterns/detect_triples.ts +633 -0
  101. package/tools/patterns/detect_wedges.ts +1072 -0
  102. package/tools/patterns/helpers.ts +517 -0
  103. package/tools/patterns/index.ts +40 -0
  104. package/tools/patterns/regression.ts +153 -0
  105. package/tools/patterns/smoothing.ts +168 -0
  106. package/tools/patterns/swing.ts +91 -0
  107. package/tools/patterns/types.ts +193 -0
  108. package/tools/prepare_chart_data.ts +294 -0
  109. package/tools/prepare_depth_data.ts +189 -0
  110. package/tools/private/analyze_my_portfolio.ts +21 -0
  111. package/tools/private/cancel_order.ts +127 -0
  112. package/tools/private/cancel_orders.ts +121 -0
  113. package/tools/private/create_order.ts +236 -0
  114. package/tools/private/get_margin_positions.ts +134 -0
  115. package/tools/private/get_margin_status.ts +155 -0
  116. package/tools/private/get_margin_trade_history.ts +156 -0
  117. package/tools/private/get_my_assets.ts +207 -0
  118. package/tools/private/get_my_deposit_withdrawal.ts +500 -0
  119. package/tools/private/get_my_orders.ts +157 -0
  120. package/tools/private/get_my_trade_history.ts +229 -0
  121. package/tools/private/get_order.ts +95 -0
  122. package/tools/private/get_orders_info.ts +90 -0
  123. package/tools/private/preview_cancel_order.ts +172 -0
  124. package/tools/private/preview_cancel_orders.ts +137 -0
  125. package/tools/private/preview_order.ts +292 -0
  126. package/tools/render_candle_pattern_diagram.ts +389 -0
  127. package/tools/render_chart_svg.ts +799 -0
  128. package/tools/render_depth_svg.ts +274 -0
  129. package/tools/trading_process/index.ts +7 -0
  130. package/tools/trading_process/lib/backtest_engine.ts +252 -0
  131. package/tools/trading_process/lib/equity.ts +131 -0
  132. package/tools/trading_process/lib/fetch_candles.ts +181 -0
  133. package/tools/trading_process/lib/sma.ts +62 -0
  134. package/tools/trading_process/lib/strategies/bb_breakout.ts +141 -0
  135. package/tools/trading_process/lib/strategies/index.ts +52 -0
  136. package/tools/trading_process/lib/strategies/macd_cross.ts +256 -0
  137. package/tools/trading_process/lib/strategies/rsi.ts +133 -0
  138. package/tools/trading_process/lib/strategies/sma_cross.ts +214 -0
  139. package/tools/trading_process/lib/strategies/types.ts +118 -0
  140. package/tools/trading_process/lib/svg_to_png.ts +64 -0
  141. package/tools/trading_process/render_backtest_chart_generic.ts +729 -0
  142. package/tools/trading_process/run_backtest.ts +243 -0
  143. package/tools/trading_process/types.ts +85 -0
  144. package/tools/validate_candle_data.ts +260 -0
  145. package/tsconfig.json +17 -0
  146. package/ui/cancel-confirm/dist/cancel-confirm.html +99 -0
  147. package/ui/order-confirm/dist/order-confirm.html +99 -0
@@ -0,0 +1,820 @@
1
+ /**
2
+ * Triangle detection — swing-point + R²-regression, multi-scale.
3
+ * + Trendoscope-style pennant reclassification (2-stage).
4
+ *
5
+ * Architecture:
6
+ * 1. Relaxed swing detection (swingDepth=1) for peaks/valleys
7
+ * 2. Multi-scale sliding window scan (geometric progression ×1.5)
8
+ * 3. R²-based regression on peaks and valleys within each window
9
+ * 4. Classify: ascending (upper ≈ flat, lower rising),
10
+ * descending (upper falling, lower ≈ flat),
11
+ * symmetrical (upper falling, lower rising)
12
+ * 5. Convergence check (gap narrows ≥ 10%)
13
+ * 6. Breakout detection with ATR × 0.3 buffer
14
+ * 7. Pole detection: if impulsive move precedes the triangle → reclassify as pennant
15
+ * 8. deduplicatePatterns() before returning
16
+ */
17
+
18
+ import { calcATR, deduplicatePatterns, finalizeConf } from './helpers.js';
19
+ import { clamp01 } from './regression.js';
20
+ import type { CandDebugEntry, DetectContext, DetectResult, PatternEntry } from './types.js';
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // bars-per-day helper
24
+ // ---------------------------------------------------------------------------
25
+ function barsPerDay(tf: string): number {
26
+ switch (tf) {
27
+ case '1min':
28
+ return 1440;
29
+ case '5min':
30
+ return 288;
31
+ case '15min':
32
+ return 96;
33
+ case '30min':
34
+ return 48;
35
+ case '1hour':
36
+ return 24;
37
+ case '4hour':
38
+ return 6;
39
+ case '8hour':
40
+ return 3;
41
+ case '12hour':
42
+ return 2;
43
+ case '1day':
44
+ return 1;
45
+ case '1week':
46
+ return 1 / 7;
47
+ case '1month':
48
+ return 1 / 30;
49
+ default:
50
+ return 1;
51
+ }
52
+ }
53
+
54
+ // ---------------------------------------------------------------------------
55
+ // Time-frame dependent parameters
56
+ // ---------------------------------------------------------------------------
57
+ function getTriangleParams(tf: string) {
58
+ const bpd = barsPerDay(tf);
59
+ const maxDurationDays = 90; // triangles > 90 days → different pattern
60
+ const minWindowBars = 15; // absolute minimum bars
61
+ const maxWindowBars = Math.max(minWindowBars, Math.round(maxDurationDays * bpd));
62
+ const minR2 = 0.6; // 収束形状なので多少の揺れは許容 — 0.25 では偽陽性が多すぎた
63
+ const flatThreshold = 0.03; // |relSlope| < 3% over window → "flat"
64
+ const moveThreshold = 0.015; // |relSlope| > 1.5% over window → "rising/falling"
65
+ const minConvergence = 0.9; // gap must narrow by ≥ 10%
66
+
67
+ return { minWindowBars, maxWindowBars, minR2, flatThreshold, moveThreshold, minConvergence };
68
+ }
69
+
70
+ // ---------------------------------------------------------------------------
71
+ // Pole detection parameters for pennant reclassification (Trendoscope 2-stage)
72
+ // ---------------------------------------------------------------------------
73
+ function getPoleParams(tf: string) {
74
+ const bpd = barsPerDay(tf);
75
+ const poleMinBars = Math.max(2, Math.round(1 * bpd));
76
+ const poleMaxBars = Math.max(5, Math.round(15 * bpd));
77
+
78
+ const t = String(tf);
79
+ let minPoleATRMult = 1.5;
80
+ let minPolePct = 0.03;
81
+ if (t === '1day') {
82
+ minPoleATRMult = 2.0;
83
+ minPolePct = 0.05;
84
+ }
85
+ if (t === '1week') {
86
+ minPoleATRMult = 2.0;
87
+ minPolePct = 0.06;
88
+ }
89
+ if (t === '1month') {
90
+ minPoleATRMult = 2.5;
91
+ minPolePct = 0.08;
92
+ }
93
+ if (t === '1min' || t === '5min') {
94
+ minPolePct = 0.01;
95
+ }
96
+ if (t === '15min' || t === '30min') {
97
+ minPolePct = 0.015;
98
+ }
99
+ if (t === '1hour') {
100
+ minPolePct = 0.02;
101
+ }
102
+
103
+ return { poleMinBars, poleMaxBars, minPoleATRMult, minPolePct };
104
+ }
105
+
106
+ /**
107
+ * Detect if there is an impulsive move (flagpole) immediately before winStart.
108
+ * Returns pole info if found, null otherwise.
109
+ */
110
+ function detectPole(
111
+ candles: readonly { open: number; close: number; high: number; low: number; isoTime?: string }[],
112
+ winStart: number,
113
+ tf: string,
114
+ ): { poleStart: number; poleEnd: number; poleDirection: 'up' | 'down'; atrMult: number; poleHeight: number } | null {
115
+ const pp = getPoleParams(tf);
116
+
117
+ let bestPoleStart = -1;
118
+ let bestPoleMag = 0;
119
+ let bestPoleATRMult = 0;
120
+
121
+ // Pole ends just before the triangle window starts
122
+ const poleEnd = winStart - 1;
123
+ if (poleEnd < pp.poleMinBars) return null;
124
+
125
+ for (let poleLen = pp.poleMinBars; poleLen <= Math.min(pp.poleMaxBars, poleEnd); poleLen++) {
126
+ const ps = poleEnd - poleLen;
127
+ if (ps < 0) continue;
128
+ const startPrice = candles[ps].close;
129
+ const endPrice = candles[poleEnd].close;
130
+ const magnitude = endPrice - startPrice;
131
+ const changePct = Math.abs(magnitude) / Math.max(1e-12, startPrice);
132
+
133
+ const localATR = calcATR(candles, Math.max(1, ps), poleEnd, 14);
134
+ if (localATR <= 0) continue;
135
+
136
+ const atrMult = Math.abs(magnitude) / localATR;
137
+ if (atrMult < pp.minPoleATRMult || changePct < pp.minPolePct) continue;
138
+
139
+ if (atrMult > bestPoleATRMult) {
140
+ bestPoleStart = ps;
141
+ bestPoleMag = magnitude;
142
+ bestPoleATRMult = atrMult;
143
+ }
144
+ }
145
+
146
+ if (bestPoleStart < 0) return null;
147
+
148
+ // Consolidation (triangle) range should be contained within pole range
149
+ // Skip if triangle is too wide relative to pole
150
+ const poleRange = Math.abs(bestPoleMag);
151
+ const triangleHigh = Math.max(...candles.slice(winStart, winStart + 10).map((c) => c.high));
152
+ const triangleLow = Math.min(...candles.slice(winStart, winStart + 10).map((c) => c.low));
153
+ const triRange = triangleHigh - triangleLow;
154
+ if (triRange > poleRange * 0.9) return null;
155
+
156
+ return {
157
+ poleStart: bestPoleStart,
158
+ poleEnd,
159
+ poleDirection: bestPoleMag > 0 ? 'up' : 'down',
160
+ atrMult: bestPoleATRMult,
161
+ poleHeight: poleRange,
162
+ };
163
+ }
164
+
165
+ // ---------------------------------------------------------------------------
166
+ // Shared type aliases
167
+ // ---------------------------------------------------------------------------
168
+ type RegLine = { slope: number; intercept: number; r2: number; valueAt: (x: number) => number };
169
+ type SwingPoint = { idx: number; price: number };
170
+ type LrWithR2Fn = (pts: Array<{ x: number; y: number }>) => RegLine;
171
+
172
+ // ---------------------------------------------------------------------------
173
+ // Robust regression helpers (extracted from detectTriangles inner function)
174
+ // ---------------------------------------------------------------------------
175
+
176
+ /** Find the index of the point with the largest absolute residual. */
177
+ function findWorstResidualIdx(current: readonly SwingPoint[], line: Pick<RegLine, 'valueAt'>): number {
178
+ let worstIdx = 0;
179
+ let worstResidual = 0;
180
+ for (let j = 0; j < current.length; j++) {
181
+ const residual = Math.abs(current[j].price - line.valueAt(current[j].idx));
182
+ if (residual > worstResidual) {
183
+ worstResidual = residual;
184
+ worstIdx = j;
185
+ }
186
+ }
187
+ return worstIdx;
188
+ }
189
+
190
+ /**
191
+ * R²-based regression with robust outlier removal fallback.
192
+ * When initial R² is below threshold, iteratively remove the point
193
+ * with the largest residual and re-fit, keeping at least minPoints.
194
+ */
195
+ function robustFit(
196
+ pts: SwingPoint[],
197
+ minPoints: number,
198
+ lrWithR2: LrWithR2Fn,
199
+ minR2: number,
200
+ ): { line: RegLine; filtered: SwingPoint[] } {
201
+ let current = [...pts];
202
+ let line = lrWithR2(current.map((p) => ({ x: p.idx, y: p.price })));
203
+ const maxRemovals = Math.max(0, pts.length - minPoints);
204
+ for (let r = 0; r < maxRemovals && line.r2 < minR2; r++) {
205
+ const worstIdx = findWorstResidualIdx(current, line);
206
+ current = current.filter((_, j) => j !== worstIdx);
207
+ if (current.length < minPoints) break;
208
+ line = lrWithR2(current.map((p) => ({ x: p.idx, y: p.price })));
209
+ }
210
+ return { line, filtered: current };
211
+ }
212
+
213
+ /**
214
+ * Flat-line fallback: when R² is low but points cluster around the same
215
+ * price level (low relative std deviation), use a horizontal line instead.
216
+ * Critical for descending triangles (flat support) and ascending triangles
217
+ * (flat resistance) where non-monotonic oscillation produces low R².
218
+ */
219
+ function tryFlatFallback(line: RegLine, pts: readonly SwingPoint[], minR2: number, flatThreshold: number): RegLine {
220
+ if (line.r2 >= minR2) return line;
221
+ if (pts.length < 3) return line;
222
+ const mean = pts.reduce((s, p) => s + p.price, 0) / pts.length;
223
+ const variance = pts.reduce((s, p) => s + (p.price - mean) ** 2, 0) / pts.length;
224
+ const relStd = Math.sqrt(variance) / mean;
225
+ if (relStd >= flatThreshold) return line;
226
+ return {
227
+ slope: 0,
228
+ intercept: mean,
229
+ r2: clamp01(1 - relStd / flatThreshold),
230
+ valueAt: (_x: number) => mean,
231
+ };
232
+ }
233
+
234
+ // ---------------------------------------------------------------------------
235
+ // Breakout detection
236
+ // ---------------------------------------------------------------------------
237
+ interface BreakoutResult {
238
+ breakoutIdx: number;
239
+ breakoutDirection: 'up' | 'down' | null;
240
+ }
241
+
242
+ /** Scan for triangle breakout (close exceeding trendline + ATR buffer). */
243
+ function findTriangleBreakout(
244
+ candles: readonly { close: number }[],
245
+ upperLine: Pick<RegLine, 'valueAt'>,
246
+ lowerLine: Pick<RegLine, 'valueAt'>,
247
+ localATR: number,
248
+ scanStart: number,
249
+ lastIdx: number,
250
+ ): BreakoutResult {
251
+ for (let i = scanStart; i <= lastIdx; i++) {
252
+ const close = candles[i].close;
253
+ const uVal = upperLine.valueAt(i);
254
+ const lVal = lowerLine.valueAt(i);
255
+ if (close > uVal + localATR * 0.3) {
256
+ return { breakoutIdx: i, breakoutDirection: 'up' };
257
+ }
258
+ if (close < lVal - localATR * 0.3) {
259
+ return { breakoutIdx: i, breakoutDirection: 'down' };
260
+ }
261
+ }
262
+ return { breakoutIdx: -1, breakoutDirection: null };
263
+ }
264
+
265
+ // ---------------------------------------------------------------------------
266
+ // Status determination (whipsaw + forming + apex proximity)
267
+ // ---------------------------------------------------------------------------
268
+ interface StatusResult {
269
+ status: 'completed' | 'invalid' | 'forming' | 'near_completion';
270
+ hasBreakout: boolean;
271
+ breakoutIdx: number;
272
+ breakoutDirection: 'up' | 'down' | null;
273
+ isExpectedBreakout: boolean;
274
+ resultEndIdx: number;
275
+ skip: boolean;
276
+ }
277
+
278
+ function determineTriangleStatus(
279
+ breakout: BreakoutResult,
280
+ candles: readonly { close: number }[],
281
+ upperLine: RegLine,
282
+ lowerLine: RegLine,
283
+ triangleType: 'triangle_ascending' | 'triangle_descending' | 'triangle_symmetrical',
284
+ patternEndIdx: number,
285
+ lastIdx: number,
286
+ winEnd: number,
287
+ windowSize: number,
288
+ includeForming: boolean,
289
+ ): StatusResult {
290
+ let { breakoutIdx, breakoutDirection } = breakout;
291
+ let hasBreakout = breakoutIdx !== -1;
292
+
293
+ // Whipsaw / false-breakout detection: if the breakout occurred but the
294
+ // latest candle's close is back inside the triangle boundaries, treat
295
+ // the breakout as a whipsaw and consider the pattern still forming.
296
+ if (hasBreakout && lastIdx > breakoutIdx) {
297
+ const latestClose = candles[lastIdx].close;
298
+ const uLatest = upperLine.valueAt(lastIdx);
299
+ const lLatest = lowerLine.valueAt(lastIdx);
300
+ if (latestClose > lLatest && latestClose < uLatest) {
301
+ hasBreakout = false;
302
+ breakoutIdx = -1;
303
+ breakoutDirection = null;
304
+ }
305
+ }
306
+
307
+ const resultEndIdx = hasBreakout ? breakoutIdx : patternEndIdx;
308
+ const expectedDirection: 'up' | 'down' | null =
309
+ triangleType === 'triangle_ascending' ? 'up' : triangleType === 'triangle_descending' ? 'down' : null;
310
+ const isExpectedBreakout = hasBreakout && (expectedDirection === null || breakoutDirection === expectedDirection);
311
+ const base = { hasBreakout, breakoutIdx, breakoutDirection, isExpectedBreakout, resultEndIdx };
312
+
313
+ if (hasBreakout) {
314
+ return { ...base, status: isExpectedBreakout ? 'completed' : 'invalid', skip: false };
315
+ }
316
+
317
+ // No breakout — skip old historical patterns that never broke out
318
+ if (lastIdx - winEnd > windowSize * 0.5) {
319
+ return { ...base, status: 'forming', skip: true };
320
+ }
321
+
322
+ // Check apex proximity for forming status
323
+ const slopeDiff = upperLine.slope - lowerLine.slope;
324
+ let status: StatusResult['status'] = 'forming';
325
+ if (Math.abs(slopeDiff) > 1e-12) {
326
+ const apexIdx = Math.round((lowerLine.intercept - upperLine.intercept) / slopeDiff);
327
+ const barsToApex = Math.max(0, apexIdx - lastIdx);
328
+ status = barsToApex <= 5 ? 'near_completion' : 'forming';
329
+ }
330
+
331
+ const skip = (status === 'forming' || status === 'near_completion') && !includeForming;
332
+ return { ...base, status, skip };
333
+ }
334
+
335
+ // ---------------------------------------------------------------------------
336
+ // Pennant reclassification (Trendoscope 2-stage)
337
+ // ---------------------------------------------------------------------------
338
+ interface PennantInfo {
339
+ poleDirection: 'up' | 'down';
340
+ poleATRMult: number;
341
+ flagpoleHeight: number;
342
+ reclassifiedStartIso: string;
343
+ retracementRatio: number | undefined;
344
+ isTrendContinuation: boolean | undefined;
345
+ }
346
+
347
+ function buildPennantInfo(
348
+ candles: readonly { open: number; close: number; high: number; low: number; isoTime?: string }[],
349
+ peaks: readonly SwingPoint[],
350
+ valleys: readonly SwingPoint[],
351
+ winStart: number,
352
+ startIso: string,
353
+ tf: string,
354
+ hasBreakout: boolean,
355
+ breakoutDirection: 'up' | 'down' | null,
356
+ ): PennantInfo | null {
357
+ const pole = detectPole(candles, winStart, tf);
358
+ if (!pole) return null;
359
+
360
+ let reclassifiedStartIso = startIso;
361
+ const poleStartIso = candles[pole.poleStart]?.isoTime;
362
+ if (poleStartIso) reclassifiedStartIso = poleStartIso;
363
+
364
+ // Calculate retracement ratio: how much of the pole move has been retraced
365
+ const poleEndPrice = candles[pole.poleEnd].close;
366
+ const triHigh = Math.max(...peaks.map((p) => p.price));
367
+ const triLow = Math.min(...valleys.map((p) => p.price));
368
+
369
+ let retracementRatio: number | undefined;
370
+ if (pole.poleHeight > 0) {
371
+ retracementRatio =
372
+ pole.poleDirection === 'up'
373
+ ? (poleEndPrice - triLow) / pole.poleHeight
374
+ : (triHigh - poleEndPrice) / pole.poleHeight;
375
+ retracementRatio = Math.max(0, Math.min(1, retracementRatio));
376
+ }
377
+
378
+ // ペナント失敗(ダマシ)は構造的には有効なパターンなので status は 'completed' のまま維持
379
+ // outcome で success/failure を区別する('invalid' にすると includeInvalid フィルタで除外されてしまう)
380
+ let isTrendContinuation: boolean | undefined;
381
+ if (hasBreakout) {
382
+ isTrendContinuation = pole.poleDirection === breakoutDirection;
383
+ }
384
+
385
+ return {
386
+ poleDirection: pole.poleDirection,
387
+ poleATRMult: pole.atrMult,
388
+ flagpoleHeight: pole.poleHeight,
389
+ reclassifiedStartIso,
390
+ retracementRatio,
391
+ isTrendContinuation,
392
+ };
393
+ }
394
+
395
+ // ---------------------------------------------------------------------------
396
+ // Result construction (scoring, pennant, target, entry, debug)
397
+ // ---------------------------------------------------------------------------
398
+ interface TriangleCandidateCtx {
399
+ candles: readonly { open: number; close: number; high: number; low: number; isoTime?: string }[];
400
+ triangleType: 'triangle_ascending' | 'triangle_descending' | 'triangle_symmetrical';
401
+ upperLine: RegLine;
402
+ lowerLine: RegLine;
403
+ upperRelSlope: number;
404
+ lowerRelSlope: number;
405
+ convergenceRatio: number;
406
+ gapStart: number;
407
+ peaks: SwingPoint[];
408
+ valleys: SwingPoint[];
409
+ filteredPeaks: SwingPoint[];
410
+ filteredValleys: SwingPoint[];
411
+ winStart: number;
412
+ winEnd: number;
413
+ startIso: string;
414
+ endIso: string;
415
+ status: StatusResult['status'];
416
+ hasBreakout: boolean;
417
+ breakoutIdx: number;
418
+ breakoutDirection: 'up' | 'down' | null;
419
+ isExpectedBreakout: boolean;
420
+ resultEndIdx: number;
421
+ lastIdx: number;
422
+ wantPennant: boolean;
423
+ tf: string;
424
+ }
425
+
426
+ function buildTriangleResult(c: TriangleCandidateCtx): { pattern: PatternEntry; debug: CandDebugEntry } {
427
+ const {
428
+ candles,
429
+ triangleType,
430
+ upperLine,
431
+ lowerLine,
432
+ upperRelSlope,
433
+ lowerRelSlope,
434
+ convergenceRatio,
435
+ gapStart,
436
+ peaks,
437
+ valleys,
438
+ filteredPeaks,
439
+ filteredValleys,
440
+ winStart,
441
+ winEnd,
442
+ startIso,
443
+ endIso,
444
+ status,
445
+ hasBreakout,
446
+ breakoutIdx,
447
+ breakoutDirection,
448
+ resultEndIdx,
449
+ lastIdx,
450
+ wantPennant,
451
+ tf,
452
+ } = c;
453
+
454
+ // --- Neckline for aftermath ---
455
+ const necklineLine =
456
+ triangleType === 'triangle_ascending'
457
+ ? upperLine
458
+ : triangleType === 'triangle_descending'
459
+ ? lowerLine
460
+ : breakoutDirection === 'down'
461
+ ? lowerLine
462
+ : upperLine;
463
+ const neckline = [
464
+ { x: winStart, y: Number(necklineLine.valueAt(winStart).toFixed(2)) },
465
+ { x: winEnd, y: Number(necklineLine.valueAt(winEnd).toFixed(2)) },
466
+ ];
467
+
468
+ // --- Scoring ---
469
+ const fitScore = (upperLine.r2 + lowerLine.r2) / 2;
470
+ const convScore = clamp01((1 - convergenceRatio) / 0.5);
471
+ const touchScore = clamp01((filteredPeaks.length + filteredValleys.length) / 8);
472
+ const symScore =
473
+ triangleType === 'triangle_symmetrical'
474
+ ? clamp01(
475
+ 1 -
476
+ Math.abs(Math.abs(upperRelSlope) - Math.abs(lowerRelSlope)) /
477
+ Math.max(1e-12, Math.abs(upperRelSlope) + Math.abs(lowerRelSlope)),
478
+ )
479
+ : 0.5;
480
+ const baseScore = fitScore * 0.25 + convScore * 0.25 + touchScore * 0.3 + symScore * 0.2;
481
+ const confidence = finalizeConf(baseScore, triangleType);
482
+
483
+ // Pivot points
484
+ const allPivots = [
485
+ ...peaks.map((p) => ({ idx: p.idx, price: p.price, kind: 'H' as const })),
486
+ ...valleys.map((p) => ({ idx: p.idx, price: p.price, kind: 'L' as const })),
487
+ ].sort((a, b) => a.idx - b.idx);
488
+
489
+ // --- Pennant reclassification ---
490
+ const pennant = wantPennant
491
+ ? buildPennantInfo(candles, peaks, valleys, winStart, startIso, tf, hasBreakout, breakoutDirection)
492
+ : null;
493
+ const finalType: string = pennant ? 'pennant' : triangleType;
494
+ const reclassifiedStartIso = pennant?.reclassifiedStartIso ?? startIso;
495
+ const poleDirection = pennant?.poleDirection;
496
+ const poleATRMult = pennant?.poleATRMult;
497
+ const flagpoleHeight = pennant?.flagpoleHeight;
498
+ const retracementRatio = pennant?.retracementRatio;
499
+ const isTrendContinuation = pennant?.isTrendContinuation;
500
+
501
+ // Confidence adjustment for pennants
502
+ let finalConfidence: number;
503
+ if (finalType === 'pennant') {
504
+ let pennantScore = baseScore * 0.9 + clamp01((poleATRMult ?? 0) / 6) * 0.05;
505
+ if (retracementRatio !== undefined && retracementRatio > 0.38) {
506
+ const penalty = Math.min(0.15, (retracementRatio - 0.38) * 0.25);
507
+ pennantScore -= penalty;
508
+ }
509
+ finalConfidence = finalizeConf(Math.max(0, pennantScore), 'pennant');
510
+ } else {
511
+ finalConfidence = confidence;
512
+ }
513
+
514
+ // --- ターゲット価格計算 ---
515
+ const patternHeight = gapStart;
516
+ let breakoutTarget: number | undefined;
517
+ let targetReachedPct: number | undefined;
518
+ let targetMethod: 'flagpole_projection' | 'pattern_height' | undefined;
519
+ if (hasBreakout && breakoutDirection) {
520
+ const bp = candles[breakoutIdx].close;
521
+ if (finalType === 'pennant' && flagpoleHeight !== undefined) {
522
+ breakoutTarget = breakoutDirection === 'up' ? bp + flagpoleHeight : bp - flagpoleHeight;
523
+ targetMethod = 'flagpole_projection';
524
+ } else {
525
+ breakoutTarget = breakoutDirection === 'up' ? bp + patternHeight : bp - patternHeight;
526
+ targetMethod = 'pattern_height';
527
+ }
528
+ breakoutTarget = Math.round(breakoutTarget);
529
+ const curPrice = Number(candles[lastIdx]?.close);
530
+ if (Number.isFinite(curPrice) && Math.abs(breakoutTarget - bp) > 1e-12) {
531
+ targetReachedPct = Math.round(((curPrice - bp) / (breakoutTarget - bp)) * 100);
532
+ }
533
+ }
534
+
535
+ // --- 用語正規化ラベル ---
536
+ let trendlineLabel: string | undefined;
537
+ if (finalType === 'pennant') {
538
+ trendlineLabel = 'コンソリデーション境界線';
539
+ } else if (triangleType === 'triangle_ascending') {
540
+ trendlineLabel = '上限トレンドライン(レジスタンス)';
541
+ } else if (triangleType === 'triangle_descending') {
542
+ trendlineLabel = '下限トレンドライン(サポート)';
543
+ } else {
544
+ trendlineLabel = 'トレンドライン(ブレイク側)';
545
+ }
546
+
547
+ const pattern: PatternEntry = {
548
+ type: finalType,
549
+ confidence: finalConfidence,
550
+ range: { start: reclassifiedStartIso, end: endIso },
551
+ status,
552
+ pivots: allPivots,
553
+ neckline,
554
+ trendlineLabel,
555
+ breakoutDirection: breakoutDirection ?? undefined,
556
+ outcome: hasBreakout
557
+ ? finalType === 'pennant'
558
+ ? isTrendContinuation
559
+ ? 'success'
560
+ : 'failure'
561
+ : status === 'completed'
562
+ ? 'success'
563
+ : 'failure'
564
+ : undefined,
565
+ breakoutBarIndex: hasBreakout ? breakoutIdx : undefined,
566
+ ...(breakoutTarget !== undefined ? { breakoutTarget, targetMethod } : {}),
567
+ ...(targetReachedPct !== undefined ? { targetReachedPct } : {}),
568
+ ...(poleDirection
569
+ ? {
570
+ poleDirection,
571
+ priorTrendDirection: poleDirection === 'up' ? 'bullish' : 'bearish',
572
+ ...(flagpoleHeight !== undefined ? { flagpoleHeight: Math.round(flagpoleHeight) } : {}),
573
+ ...(retracementRatio !== undefined ? { retracementRatio: Number(retracementRatio.toFixed(2)) } : {}),
574
+ ...(isTrendContinuation !== undefined ? { isTrendContinuation } : {}),
575
+ }
576
+ : {}),
577
+ };
578
+
579
+ const debug: CandDebugEntry = {
580
+ type: finalType,
581
+ accepted: true,
582
+ reason: finalType === 'pennant' ? 'reclassified_from_triangle' : 'detected',
583
+ indices: [winStart, resultEndIdx],
584
+ details: {
585
+ convergenceRatio: Number(convergenceRatio.toFixed(3)),
586
+ r2Upper: Number(upperLine.r2.toFixed(3)),
587
+ r2Lower: Number(lowerLine.r2.toFixed(3)),
588
+ upperRelSlope: Number(upperRelSlope.toFixed(4)),
589
+ lowerRelSlope: Number(lowerRelSlope.toFixed(4)),
590
+ touchCount: filteredPeaks.length + filteredValleys.length,
591
+ outlierPeaksRemoved: peaks.length - filteredPeaks.length,
592
+ outlierValleysRemoved: valleys.length - filteredValleys.length,
593
+ breakout: hasBreakout ? { idx: breakoutIdx, direction: breakoutDirection } : null,
594
+ status,
595
+ confidence: finalConfidence,
596
+ ...(poleDirection
597
+ ? {
598
+ poleDirection,
599
+ poleATRMult: Number((poleATRMult ?? 0).toFixed(2)),
600
+ ...(flagpoleHeight !== undefined ? { flagpoleHeight: Math.round(flagpoleHeight) } : {}),
601
+ ...(retracementRatio !== undefined ? { retracementRatio: Number(retracementRatio.toFixed(2)) } : {}),
602
+ ...(isTrendContinuation !== undefined ? { isTrendContinuation } : {}),
603
+ }
604
+ : {}),
605
+ },
606
+ };
607
+
608
+ return { pattern, debug };
609
+ }
610
+
611
+ export function detectTriangles(ctx: DetectContext): DetectResult {
612
+ const { candles, want, includeForming, debugCandidates, lrWithR2 } = ctx;
613
+ const type = ctx.type;
614
+ let patterns: PatternEntry[] = [];
615
+
616
+ const wantPennant = want.size === 0 || want.has('pennant');
617
+ const wantAsc = want.size === 0 || want.has('triangle') || want.has('triangle_ascending') || wantPennant;
618
+ const wantDesc = want.size === 0 || want.has('triangle') || want.has('triangle_descending') || wantPennant;
619
+ const wantSym = want.size === 0 || want.has('triangle') || want.has('triangle_symmetrical') || wantPennant;
620
+ if (!wantAsc && !wantDesc && !wantSym) return { patterns: [] };
621
+
622
+ const lastIdx = candles.length - 1;
623
+ if (lastIdx < 15) return { patterns: [] };
624
+
625
+ const params = getTriangleParams(type);
626
+
627
+ // --- Relaxed swing detection (swingDepth=1) ---
628
+ const relaxedPeaks: Array<{ idx: number; price: number }> = [];
629
+ const relaxedValleys: Array<{ idx: number; price: number }> = [];
630
+ for (let i = 1; i < candles.length - 1; i++) {
631
+ const c = candles[i],
632
+ prev = candles[i - 1],
633
+ next = candles[i + 1];
634
+ if (c.high > prev.high && c.high > next.high) {
635
+ relaxedPeaks.push({ idx: i, price: c.high });
636
+ }
637
+ if (c.low < prev.low && c.low < next.low) {
638
+ relaxedValleys.push({ idx: i, price: c.low });
639
+ }
640
+ }
641
+
642
+ // --- Generate multi-scale window sizes (geometric ×1.5) ---
643
+ const effectiveMax = Math.min(lastIdx - 5, params.maxWindowBars);
644
+ const windowSizes: number[] = [];
645
+ {
646
+ let w = params.minWindowBars;
647
+ while (w <= effectiveMax) {
648
+ windowSizes.push(Math.round(w));
649
+ w = Math.round(w * 1.5);
650
+ }
651
+ }
652
+ if (!windowSizes.length) return { patterns: [] };
653
+
654
+ // --- Sliding window scan ---
655
+ for (const windowSize of windowSizes) {
656
+ const posStep = Math.max(1, Math.floor(windowSize / 6));
657
+
658
+ for (let winEnd = windowSize; winEnd <= lastIdx; winEnd += posStep) {
659
+ const winStart = winEnd - windowSize;
660
+
661
+ // Collect peaks/valleys in window
662
+ const peaks = relaxedPeaks.filter((p) => p.idx >= winStart && p.idx <= winEnd);
663
+ const valleys = relaxedValleys.filter((p) => p.idx >= winStart && p.idx <= winEnd);
664
+
665
+ if (peaks.length < 2 || valleys.length < 2) continue;
666
+
667
+ const minPtsForFit = 3;
668
+ let { line: upperLine, filtered: filteredPeaks } = robustFit(peaks, minPtsForFit, lrWithR2, params.minR2);
669
+ let { line: lowerLine, filtered: filteredValleys } = robustFit(valleys, minPtsForFit, lrWithR2, params.minR2);
670
+
671
+ upperLine = tryFlatFallback(upperLine, filteredPeaks, params.minR2, params.flatThreshold);
672
+ lowerLine = tryFlatFallback(lowerLine, filteredValleys, params.minR2, params.flatThreshold);
673
+
674
+ if (upperLine.r2 < params.minR2 || lowerLine.r2 < params.minR2) {
675
+ debugCandidates.push({
676
+ type: 'triangle_symmetrical',
677
+ accepted: false,
678
+ reason: 'poor_trendline_fit',
679
+ indices: [winStart, winEnd],
680
+ details: {
681
+ r2Upper: Number(upperLine.r2.toFixed(3)),
682
+ r2Lower: Number(lowerLine.r2.toFixed(3)),
683
+ peaksUsed: filteredPeaks.length,
684
+ valleysUsed: filteredValleys.length,
685
+ peaksTotal: peaks.length,
686
+ valleysTotal: valleys.length,
687
+ },
688
+ });
689
+ continue;
690
+ }
691
+
692
+ // Convergence check
693
+ const gapStart = upperLine.valueAt(winStart) - lowerLine.valueAt(winStart);
694
+ const gapEnd = upperLine.valueAt(winEnd) - lowerLine.valueAt(winEnd);
695
+ if (gapStart <= 0 || gapEnd <= 0) continue; // lines cross → invalid
696
+
697
+ const convergenceRatio = gapEnd / gapStart;
698
+ if (convergenceRatio >= params.minConvergence) continue; // not converging enough
699
+
700
+ // Slope classification (relative slope over window)
701
+ // Use filtered points (post outlier-removal) for slope analysis
702
+ const barsSpan = Math.max(1, winEnd - winStart);
703
+ const avgHigh = filteredPeaks.reduce((s, p) => s + p.price, 0) / filteredPeaks.length;
704
+ const avgLow = filteredValleys.reduce((s, p) => s + p.price, 0) / filteredValleys.length;
705
+ const upperRelSlope = (upperLine.slope * barsSpan) / Math.max(1e-12, avgHigh);
706
+ const lowerRelSlope = (lowerLine.slope * barsSpan) / Math.max(1e-12, avgLow);
707
+
708
+ // Both meaningfully same direction → likely wedge, skip
709
+ if (upperRelSlope > params.moveThreshold && lowerRelSlope > params.moveThreshold) continue;
710
+ if (upperRelSlope < -params.moveThreshold && lowerRelSlope < -params.moveThreshold) continue;
711
+
712
+ const upperFlat = Math.abs(upperRelSlope) < params.flatThreshold;
713
+ const upperFalling = upperRelSlope < -params.moveThreshold;
714
+ const lowerFlat = Math.abs(lowerRelSlope) < params.flatThreshold;
715
+ const lowerRising = lowerRelSlope > params.moveThreshold;
716
+
717
+ // Classify
718
+ let triangleType: 'triangle_ascending' | 'triangle_descending' | 'triangle_symmetrical' | null = null;
719
+
720
+ if (wantAsc && upperFlat && lowerRising) {
721
+ triangleType = 'triangle_ascending';
722
+ } else if (wantDesc && upperFalling && lowerFlat) {
723
+ triangleType = 'triangle_descending';
724
+ } else if (wantSym && upperFalling && lowerRising) {
725
+ triangleType = 'triangle_symmetrical';
726
+ }
727
+
728
+ if (!triangleType) {
729
+ debugCandidates.push({
730
+ type: 'triangle_symmetrical',
731
+ accepted: false,
732
+ reason: 'classification_failed',
733
+ indices: [winStart, winEnd],
734
+ details: {
735
+ upperRelSlope: Number(upperRelSlope.toFixed(4)),
736
+ lowerRelSlope: Number(lowerRelSlope.toFixed(4)),
737
+ convergenceRatio: Number(convergenceRatio.toFixed(3)),
738
+ upperFlat,
739
+ upperFalling,
740
+ lowerFlat,
741
+ lowerRising,
742
+ },
743
+ });
744
+ continue;
745
+ }
746
+
747
+ // --- Breakout detection (ATR × 0.3 buffer) ---
748
+ const localATR = calcATR(candles, Math.max(1, winStart), winEnd, 14);
749
+
750
+ const patternEndIdx = Math.max(
751
+ filteredPeaks[filteredPeaks.length - 1].idx,
752
+ filteredValleys[filteredValleys.length - 1].idx,
753
+ );
754
+
755
+ const scanStart = winStart + Math.max(3, Math.floor(barsSpan * 0.5));
756
+ let { breakoutIdx, breakoutDirection } = findTriangleBreakout(
757
+ candles,
758
+ upperLine,
759
+ lowerLine,
760
+ localATR,
761
+ scanStart,
762
+ lastIdx,
763
+ );
764
+
765
+ const statusResult = determineTriangleStatus(
766
+ { breakoutIdx, breakoutDirection },
767
+ candles,
768
+ upperLine,
769
+ lowerLine,
770
+ triangleType,
771
+ patternEndIdx,
772
+ lastIdx,
773
+ winEnd,
774
+ windowSize,
775
+ includeForming,
776
+ );
777
+ if (statusResult.skip) continue;
778
+ const { status, hasBreakout, isExpectedBreakout, resultEndIdx } = statusResult;
779
+ ({ breakoutIdx, breakoutDirection } = statusResult);
780
+
781
+ const startIso = candles[winStart]?.isoTime;
782
+ const endIso = candles[resultEndIdx]?.isoTime;
783
+ if (!startIso || !endIso) continue;
784
+
785
+ const { pattern, debug } = buildTriangleResult({
786
+ candles,
787
+ triangleType,
788
+ upperLine,
789
+ lowerLine,
790
+ upperRelSlope,
791
+ lowerRelSlope,
792
+ convergenceRatio,
793
+ gapStart,
794
+ peaks,
795
+ valleys,
796
+ filteredPeaks,
797
+ filteredValleys,
798
+ winStart,
799
+ winEnd,
800
+ startIso,
801
+ endIso,
802
+ status,
803
+ hasBreakout,
804
+ breakoutIdx,
805
+ breakoutDirection,
806
+ isExpectedBreakout,
807
+ resultEndIdx,
808
+ lastIdx,
809
+ wantPennant,
810
+ tf: type,
811
+ });
812
+ patterns.push(pattern);
813
+ debugCandidates.push(debug);
814
+ }
815
+ }
816
+
817
+ patterns = deduplicatePatterns(patterns);
818
+
819
+ return { patterns };
820
+ }