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,1072 @@
1
+ /**
2
+ * Wedge 検出(Rising / Falling — 完成済み+形成中)
3
+ *
4
+ * v2: UAlgo + TradingPatternScanner ベースの改善
5
+ * - Savitzky-Golay フィルタによるノイズ除去(ピボット品質向上)
6
+ * - Apex(頂点)計算による収束バリデーション(UAlgo 方式)
7
+ * - 包含ルール(Containment)による偽パターン棄却
8
+ * - 収束閾値の緩和(0.30→0.70)とApexベース補完
9
+ * - プローブ用ハードコードの除去
10
+ *
11
+ * 4b) 回帰ベース(完成済みの主力検出)
12
+ * 4d) 形成中ウェッジ検出(緩い条件)
13
+ */
14
+ import { EPSILON } from '../../lib/math.js';
15
+ import { generatePatternDiagram, type PatternDiagramData } from '../../lib/pattern-diagrams.js';
16
+ import {
17
+ calcAlternationScoreEx,
18
+ calcApex,
19
+ calcATR,
20
+ calcDurationScoreEx,
21
+ calcInsideRatioEx,
22
+ calculatePatternScoreEx,
23
+ checkContainment,
24
+ checkConvergenceEx,
25
+ deduplicatePatterns,
26
+ detectWedgeBreak,
27
+ determineWedgeType,
28
+ evaluateTouchesEx,
29
+ generateWindows,
30
+ } from './helpers.js';
31
+ import { smoothCandleExtremes } from './smoothing.js';
32
+ import type {
33
+ CandDebugEntry,
34
+ CandleData,
35
+ DeduplicablePattern,
36
+ DetectContext,
37
+ DetectResult,
38
+ TouchResult,
39
+ } from './types.js';
40
+
41
+ // ── Configuration ──
42
+
43
+ // SG Filter
44
+ const SG_WINDOW_MIN = 5;
45
+ const SG_WINDOW_MAX = 11;
46
+ const SG_CANDLE_RATIO = 20;
47
+ const MIN_SG_PIVOTS = 6;
48
+
49
+ // Wedge Detection params
50
+ const WINDOW_SIZE_MIN = 25;
51
+ const WINDOW_SIZE_MAX = 90;
52
+ const WINDOW_STEP = 5;
53
+ const MIN_SLOPE = 0.00005;
54
+ const MAX_SLOPE = 0.08;
55
+ const SLOPE_RATIO_MIN = 1.15;
56
+ const SLOPE_RATIO_MIN_RISING = 1.2;
57
+ const MIN_WEAKER_SLOPE_RATIO = 0.3;
58
+ const MIN_TOUCHES_PER_LINE = 3;
59
+ const MIN_SCORE = 0.5;
60
+ const MIN_CONTAINMENT = 0.85;
61
+
62
+ // Touch Validation
63
+ const MAX_TOUCH_GAP_BARS = 25;
64
+ const MAX_START_GAP_BARS = 10;
65
+ const MIN_ALTERNATION = 0.25;
66
+ const MIN_TOUCH_BALANCE = 0.45;
67
+
68
+ // R2 threshold
69
+ const MIN_R2_THRESHOLD = 0.55;
70
+ const MIN_HIGHS_RATIO = 0.99;
71
+
72
+ // Scoring weights
73
+ const CONVERGENCE_WEIGHT = 0.4;
74
+ const SLOPE_WEIGHT = 0.3;
75
+ const DURATION_WEIGHT = 0.3;
76
+
77
+ // Confidence bounds
78
+ const CONFIDENCE_MIN = 0.65;
79
+ const CONFIDENCE_MAX = 0.95;
80
+ const CONFIDENCE_BOOST = 0.3;
81
+
82
+ // Forming wedge params
83
+ const FORMING_WINDOW_MIN = 20;
84
+ const FORMING_WINDOW_MAX = 120;
85
+ const FORMING_MIN_CONTAINMENT = 0.75;
86
+ const FORMING_MAX_CONV_RATIO = 0.8;
87
+ const FORMING_BREAKOUT_FACTOR = 0.015;
88
+
89
+ // ATR multiplier for break direction
90
+ const ATR_BREAK_THRESHOLD = 0.3;
91
+
92
+ // Downsample
93
+ const MAX_DIAGRAM_POINTS = 6;
94
+
95
+ // Forming duration
96
+ const FORMING_MIN_BARS_BEFORE_BREAK = 15;
97
+ const FORMING_PRICE_TOLERANCE_PCT = 0.01;
98
+
99
+ // ── Intermediate types ──
100
+
101
+ /** preparePivots の戻り値 */
102
+ interface PivotData {
103
+ smoothHigh: number[];
104
+ smoothLow: number[];
105
+ sgPeaks: Array<{ index: number; price: number }>;
106
+ sgValleys: Array<{ index: number; price: number }>;
107
+ }
108
+
109
+ /** lrWithR2 の戻り値互換(TrendLine + r2 必須) */
110
+ interface RegLine {
111
+ slope: number;
112
+ intercept: number;
113
+ r2: number;
114
+ valueAt: (x: number) => number;
115
+ }
116
+
117
+ /** validateRegressionCandidate の成功結果 */
118
+ interface RegressionValidation {
119
+ apex: { isValid: boolean; apexIdx: number; barsToApex: number };
120
+ conv: { isConverging: boolean; gapStart: number; gapEnd: number; ratio: number; score?: number };
121
+ containment: { closeInsideRatio: number; violations: number; total: number };
122
+ touches: TouchResult;
123
+ alternation: number;
124
+ insideRatio: number;
125
+ score: number;
126
+ }
127
+
128
+ // ── Phase 1: ピボット準備 ──
129
+
130
+ function preparePivots(ctx: DetectContext): PivotData {
131
+ const { candles, swingDepth } = ctx;
132
+
133
+ const sgWindowSize = Math.max(
134
+ SG_WINDOW_MIN,
135
+ Math.min(SG_WINDOW_MAX, Math.floor(candles.length / SG_CANDLE_RATIO) * 2 + 1),
136
+ );
137
+ const { smoothHigh, smoothLow } = smoothCandleExtremes(candles, sgWindowSize, 2);
138
+
139
+ const sgPeaks: Array<{ index: number; price: number }> = [];
140
+ const sgValleys: Array<{ index: number; price: number }> = [];
141
+ const sgDepth = Math.max(2, swingDepth);
142
+ for (let i = sgDepth; i < candles.length - sgDepth; i++) {
143
+ let isHigh = true;
144
+ let isLow = true;
145
+ for (let k = 1; k <= sgDepth; k++) {
146
+ if (!(smoothHigh[i] > smoothHigh[i - k] && smoothHigh[i] > smoothHigh[i + k])) isHigh = false;
147
+ if (!(smoothLow[i] < smoothLow[i - k] && smoothLow[i] < smoothLow[i + k])) isLow = false;
148
+ if (!isHigh && !isLow) break;
149
+ }
150
+ if (isHigh) sgPeaks.push({ index: i, price: candles[i].close });
151
+ else if (isLow) sgValleys.push({ index: i, price: candles[i].close });
152
+ }
153
+
154
+ return { smoothHigh, smoothLow, sgPeaks, sgValleys };
155
+ }
156
+
157
+ // ── Phase 2a: 回帰ベース候補のバリデーション ──
158
+
159
+ function calcMaxTouchGap(touchArr: Array<{ index: number; isBreak: boolean }>): number {
160
+ const validTouches = touchArr
161
+ .filter((t) => !t.isBreak)
162
+ .map((t) => t.index)
163
+ .sort((a, b) => a - b);
164
+ if (validTouches.length < 2) return Infinity;
165
+ let maxGap = 0;
166
+ for (let i = 1; i < validTouches.length; i++) {
167
+ maxGap = Math.max(maxGap, validTouches[i] - validTouches[i - 1]);
168
+ }
169
+ return maxGap;
170
+ }
171
+
172
+ function validateRegressionCandidate(
173
+ candles: CandleData[],
174
+ wedgeType: 'rising_wedge' | 'falling_wedge',
175
+ upper: RegLine,
176
+ lower: RegLine,
177
+ startIdx: number,
178
+ endIdx: number,
179
+ debugCandidates: CandDebugEntry[],
180
+ ): RegressionValidation | null {
181
+ // --- Apex バリデーション(UAlgo 方式) ---
182
+ const apex = calcApex(upper, lower, endIdx);
183
+ if (!apex.isValid) {
184
+ debugCandidates.push({
185
+ type: wedgeType,
186
+ accepted: false,
187
+ reason: 'apex_not_in_future',
188
+ indices: [startIdx, endIdx],
189
+ details: { apexIdx: apex.apexIdx, barsToApex: apex.barsToApex, endIdx },
190
+ });
191
+ return null;
192
+ }
193
+
194
+ // --- 収束チェック(Apexベース強化版) ---
195
+ const conv = checkConvergenceEx(upper, lower, startIdx, endIdx);
196
+ if (!conv.isConverging) {
197
+ debugCandidates.push({
198
+ type: wedgeType,
199
+ accepted: false,
200
+ reason: 'convergence_failed',
201
+ indices: [startIdx, endIdx],
202
+ details: { gapStart: conv.gapStart, gapEnd: conv.gapEnd, ratio: conv.ratio },
203
+ });
204
+ return null;
205
+ }
206
+
207
+ // --- 包含ルール(UAlgo 方式: 終値が境界内) ---
208
+ const containment = checkContainment(candles, upper, lower, startIdx, endIdx);
209
+ if (containment.closeInsideRatio < MIN_CONTAINMENT) {
210
+ debugCandidates.push({
211
+ type: wedgeType,
212
+ accepted: false,
213
+ reason: 'containment_violated',
214
+ indices: [startIdx, endIdx],
215
+ details: {
216
+ closeInsideRatio: Number(containment.closeInsideRatio.toFixed(3)),
217
+ violations: containment.violations,
218
+ total: containment.total,
219
+ minRequired: MIN_CONTAINMENT,
220
+ },
221
+ });
222
+ return null;
223
+ }
224
+
225
+ const touches = evaluateTouchesEx(candles, upper, lower, startIdx, endIdx);
226
+ if (touches.upperQuality < MIN_TOUCHES_PER_LINE || touches.lowerQuality < MIN_TOUCHES_PER_LINE) {
227
+ debugCandidates.push({
228
+ type: wedgeType,
229
+ accepted: false,
230
+ reason: 'insufficient_touches',
231
+ indices: [startIdx, endIdx],
232
+ details: {
233
+ upperTouches: touches.upperQuality,
234
+ lowerTouches: touches.lowerQuality,
235
+ minRequired: MIN_TOUCHES_PER_LINE,
236
+ },
237
+ });
238
+ return null;
239
+ }
240
+
241
+ // タッチ間隔チェック(日足で25本以上空いていたら除外)
242
+ const upperMaxGap = calcMaxTouchGap(touches.upperTouches);
243
+ const lowerMaxGap = calcMaxTouchGap(touches.lowerTouches);
244
+ const maxGap = Math.max(upperMaxGap, lowerMaxGap);
245
+ if (maxGap > MAX_TOUCH_GAP_BARS) {
246
+ debugCandidates.push({
247
+ type: wedgeType,
248
+ accepted: false,
249
+ reason: 'touch_gap_too_large',
250
+ indices: [startIdx, endIdx],
251
+ details: { upperMaxGap, lowerMaxGap, maxGap, maxAllowed: MAX_TOUCH_GAP_BARS },
252
+ });
253
+ return null;
254
+ }
255
+
256
+ // 開始日ギャップチェック
257
+ const firstUpperTouch = touches.upperTouches.find((t) => !t.isBreak);
258
+ const firstLowerTouch = touches.lowerTouches.find((t) => !t.isBreak);
259
+ if (firstUpperTouch && firstLowerTouch) {
260
+ const startGap = Math.abs(firstUpperTouch.index - firstLowerTouch.index);
261
+ if (startGap > MAX_START_GAP_BARS) {
262
+ debugCandidates.push({
263
+ type: wedgeType,
264
+ accepted: false,
265
+ reason: 'start_gap_too_large',
266
+ indices: [startIdx, endIdx],
267
+ details: {
268
+ firstUpperIdx: firstUpperTouch.index,
269
+ firstLowerIdx: firstLowerTouch.index,
270
+ startGap,
271
+ maxAllowed: MAX_START_GAP_BARS,
272
+ },
273
+ });
274
+ return null;
275
+ }
276
+ }
277
+
278
+ const alternation = calcAlternationScoreEx(touches);
279
+
280
+ // 上下タッチのバランスチェック
281
+ const upQ = Number(touches?.upperQuality ?? 0);
282
+ const loQ = Number(touches?.lowerQuality ?? 0);
283
+ const denom = Math.max(upQ, loQ, 1);
284
+ const touchBalance = Math.min(upQ, loQ) / denom;
285
+ if (touchBalance < MIN_TOUCH_BALANCE) {
286
+ debugCandidates.push({
287
+ type: wedgeType,
288
+ accepted: false,
289
+ reason: 'unbalanced_touches',
290
+ indices: [startIdx, endIdx],
291
+ details: {
292
+ upperTouches: upQ,
293
+ lowerTouches: loQ,
294
+ balance: Number(touchBalance.toFixed(3)),
295
+ minRequired: MIN_TOUCH_BALANCE,
296
+ },
297
+ });
298
+ return null;
299
+ }
300
+
301
+ const insideRatio = calcInsideRatioEx(candles, upper, lower, startIdx, endIdx);
302
+ const durationParams = { windowSizeMin: WINDOW_SIZE_MIN, windowSizeMax: WINDOW_SIZE_MAX };
303
+ const score = calculatePatternScoreEx({
304
+ fitScore: (upper.r2 + lower.r2) / 2,
305
+ convergeScore: conv.score ?? 0,
306
+ touchScore: touches.score,
307
+ alternationScore: alternation,
308
+ insideScore: insideRatio,
309
+ durationScore: calcDurationScoreEx(endIdx - startIdx, durationParams),
310
+ });
311
+
312
+ // 最低交互性チェック
313
+ if (Number(alternation ?? 0) < MIN_ALTERNATION) {
314
+ debugCandidates.push({
315
+ type: wedgeType,
316
+ accepted: false,
317
+ reason: 'insufficient_alternation',
318
+ indices: [startIdx, endIdx],
319
+ details: {
320
+ alternation: Number((alternation ?? 0).toFixed(3)),
321
+ minRequired: MIN_ALTERNATION,
322
+ upperTouches: Number(touches?.upperQuality ?? 0),
323
+ lowerTouches: Number(touches?.lowerQuality ?? 0),
324
+ },
325
+ });
326
+ return null;
327
+ }
328
+
329
+ if (score < MIN_SCORE) {
330
+ debugCandidates.push({
331
+ type: wedgeType,
332
+ accepted: false,
333
+ reason: 'score_below_threshold',
334
+ indices: [startIdx, endIdx],
335
+ details: {
336
+ score: Number(score.toFixed(3)),
337
+ minScore: MIN_SCORE,
338
+ components: {
339
+ fit: Number(((upper.r2 + lower.r2) / 2).toFixed(3)),
340
+ converge: Number((conv.score ?? 0).toFixed(3)),
341
+ touch: Number((touches.score ?? 0).toFixed(3)),
342
+ alternation: Number((alternation ?? 0).toFixed(3)),
343
+ inside: Number((insideRatio ?? 0).toFixed(3)),
344
+ duration: Number(calcDurationScoreEx(endIdx - startIdx, durationParams).toFixed(3)),
345
+ },
346
+ },
347
+ });
348
+ return null;
349
+ }
350
+
351
+ return { apex, conv, containment, touches, alternation, insideRatio, score };
352
+ }
353
+
354
+ // ── Phase 2b: 回帰ベース候補の結果構築 ──
355
+
356
+ function downsamplePoints(pts: Array<{ idx: number; kind: 'H' | 'L' }>, maxPoints: number) {
357
+ if (pts.length <= maxPoints) return pts;
358
+ const out: typeof pts = [];
359
+ const lastIdxPts = pts.length - 1;
360
+ for (let i = 0; i < maxPoints; i++) {
361
+ const pos = Math.round((i / Math.max(1, maxPoints - 1)) * lastIdxPts);
362
+ out.push(pts[pos]);
363
+ }
364
+ return out.filter((p, i, arr) => arr.findIndex((q) => q.idx === p.idx && q.kind === p.kind) === i);
365
+ }
366
+
367
+ function buildRegressionEntry(
368
+ candles: CandleData[],
369
+ wedgeType: 'rising_wedge' | 'falling_wedge',
370
+ upper: RegLine,
371
+ lower: RegLine,
372
+ startIdx: number,
373
+ endIdx: number,
374
+ v: RegressionValidation,
375
+ useSmoothed: boolean,
376
+ debugCandidates: CandDebugEntry[],
377
+ ): DeduplicablePattern | null {
378
+ const start = candles[startIdx]?.isoTime;
379
+ const theoreticalEnd = candles[endIdx]?.isoTime;
380
+ if (!start || !theoreticalEnd) return null;
381
+
382
+ // ブレイク検出
383
+ const lastIdx = candles.length - 1;
384
+ const atr = calcATR(candles, startIdx, endIdx, 14);
385
+ const breakInfo = detectWedgeBreak(candles, wedgeType, upper, lower, startIdx, endIdx, lastIdx, atr);
386
+
387
+ // 終点: ブレイクが検出された場合はブレイク日、そうでなければウィンドウ終端
388
+ const actualEndIdx = breakInfo.detected ? breakInfo.breakIdx : endIdx;
389
+ const end = candles[actualEndIdx]?.isoTime ?? theoreticalEnd;
390
+
391
+ // ブレイク方向の判定
392
+ let breakoutDirection: 'up' | 'down' | null = null;
393
+ if (breakInfo.detected && Number.isFinite(breakInfo.breakPrice)) {
394
+ const breakPrice = breakInfo.breakPrice as number;
395
+ const lLineAtBreak = lower.valueAt(breakInfo.breakIdx);
396
+ const uLineAtBreak = upper.valueAt(breakInfo.breakIdx);
397
+ if (breakPrice < lLineAtBreak - atr * ATR_BREAK_THRESHOLD) {
398
+ breakoutDirection = 'down';
399
+ } else if (breakPrice > uLineAtBreak + atr * ATR_BREAK_THRESHOLD) {
400
+ breakoutDirection = 'up';
401
+ }
402
+ }
403
+
404
+ const { apex, conv, containment, touches, alternation, insideRatio, score } = v;
405
+ const confidence = Math.max(0, Math.min(1, Number(score.toFixed(2))));
406
+
407
+ // --- ターゲット価格計算(pattern_height 方式) ---
408
+ const patternHeight = Math.abs(upper.valueAt(startIdx) - lower.valueAt(startIdx));
409
+ let breakoutTarget: number | undefined;
410
+ let targetReachedPct: number | undefined;
411
+ if (breakInfo.detected && breakoutDirection && Number.isFinite(breakInfo.breakPrice)) {
412
+ const bp = breakInfo.breakPrice as number;
413
+ breakoutTarget = breakoutDirection === 'up' ? bp + patternHeight : bp - patternHeight;
414
+ breakoutTarget = Math.round(breakoutTarget);
415
+ const currentPrice = Number(candles[candles.length - 1]?.close);
416
+ if (Number.isFinite(currentPrice) && Math.abs(breakoutTarget - bp) > EPSILON) {
417
+ targetReachedPct = Math.round(((currentPrice - bp) / (breakoutTarget - bp)) * 100);
418
+ }
419
+ }
420
+
421
+ // ダイアグラム用にタッチポイントから主要点を間引きして pivots を構成
422
+ const upTouchPts = (touches.upperTouches || [])
423
+ .filter((t) => !t.isBreak)
424
+ .map((t) => ({ idx: t.index, kind: 'H' as const }));
425
+ const loTouchPts = (touches.lowerTouches || [])
426
+ .filter((t) => !t.isBreak)
427
+ .map((t) => ({ idx: t.index, kind: 'L' as const }));
428
+ const allPts = [...upTouchPts, ...loTouchPts].sort((a, b) => a.idx - b.idx);
429
+ const sel = downsamplePoints(allPts, MAX_DIAGRAM_POINTS);
430
+ const pivForDiagram = sel.map((p) => ({
431
+ idx: p.idx,
432
+ price: Number(candles[p.idx]?.close ?? NaN),
433
+ kind: p.kind,
434
+ date: candles[p.idx]?.isoTime,
435
+ }));
436
+ let diagram: PatternDiagramData | undefined;
437
+ try {
438
+ diagram = generatePatternDiagram(wedgeType, pivForDiagram, { price: 0 }, { start, end });
439
+ } catch {
440
+ /* noop */
441
+ }
442
+
443
+ // aftermath情報
444
+ const isSuccessfulBreakout = breakInfo.detected
445
+ ? wedgeType === 'falling_wedge'
446
+ ? breakoutDirection === 'up'
447
+ : breakoutDirection === 'down'
448
+ : false;
449
+
450
+ const aftermath = breakInfo.detected
451
+ ? {
452
+ breakoutDate: breakInfo.breakIsoTime,
453
+ breakoutConfirmed: true,
454
+ targetReached: false,
455
+ outcome: isSuccessfulBreakout
456
+ ? wedgeType === 'falling_wedge'
457
+ ? 'bullish_breakout'
458
+ : 'bearish_breakout'
459
+ : wedgeType === 'falling_wedge'
460
+ ? 'bearish_breakdown'
461
+ : 'bullish_breakdown',
462
+ }
463
+ : undefined;
464
+
465
+ // status / outcome 判定(4b: 完成済み主力検出)
466
+ const status4b: 'completed' | 'invalid' | 'near_completion' = breakInfo.detected ? 'completed' : 'near_completion';
467
+ let outcome4b: 'success' | 'failure' | undefined;
468
+ if (breakInfo.detected && breakoutDirection) {
469
+ const expected = wedgeType === 'falling_wedge' ? 'up' : 'down';
470
+ outcome4b = breakoutDirection === expected ? 'success' : 'failure';
471
+ }
472
+
473
+ debugCandidates.push({
474
+ type: wedgeType,
475
+ accepted: true,
476
+ reason: 'revamped_ok',
477
+ indices: [startIdx, actualEndIdx],
478
+ details: {
479
+ slopeHigh: upper.slope,
480
+ slopeLow: lower.slope,
481
+ r2High: upper.r2,
482
+ r2Low: lower.r2,
483
+ apex: { idx: apex.apexIdx, barsToApex: apex.barsToApex },
484
+ containment: { ratio: containment.closeInsideRatio, violations: containment.violations },
485
+ converge: conv,
486
+ touches: { up: touches.upperQuality, lo: touches.lowerQuality },
487
+ alternation,
488
+ insideRatio,
489
+ score,
490
+ smoothed: useSmoothed,
491
+ breakInfo: breakInfo.detected ? { ...breakInfo, direction: breakoutDirection } : null,
492
+ },
493
+ });
494
+
495
+ return {
496
+ type: wedgeType,
497
+ confidence,
498
+ range: { start, end },
499
+ status: status4b,
500
+ daysToApex: apex.isValid ? apex.barsToApex : undefined,
501
+ breakoutDirection: breakoutDirection ?? undefined,
502
+ outcome: outcome4b,
503
+ breakoutDate: breakInfo.detected ? breakInfo.breakIsoTime : undefined,
504
+ breakoutBarIndex: breakInfo.detected ? breakInfo.breakIdx : undefined,
505
+ ...(breakoutTarget !== undefined ? { breakoutTarget, targetMethod: 'pattern_height' as const } : {}),
506
+ ...(targetReachedPct !== undefined ? { targetReachedPct } : {}),
507
+ ...(aftermath ? { aftermath } : {}),
508
+ ...(diagram ? { structureDiagram: diagram } : {}),
509
+ };
510
+ }
511
+
512
+ // ── Phase 2: 回帰ベース完成済みウェッジ検出 ──
513
+
514
+ function detectRegressionWedges(pivotData: PivotData, ctx: DetectContext): DeduplicablePattern[] {
515
+ const { candles, pivots, want, lrWithR2, debugCandidates } = ctx;
516
+ const patterns: DeduplicablePattern[] = [];
517
+
518
+ const params = {
519
+ swingDepth: ctx.swingDepth,
520
+ minBarsBetweenSwings: ctx.minDist,
521
+ tolerancePct: ctx.tolerancePct,
522
+ windowSizeMin: WINDOW_SIZE_MIN,
523
+ windowSizeMax: WINDOW_SIZE_MAX,
524
+ windowStep: WINDOW_STEP,
525
+ minSlope: MIN_SLOPE,
526
+ maxSlope: MAX_SLOPE,
527
+ slopeRatioMin: SLOPE_RATIO_MIN,
528
+ slopeRatioMinRising: SLOPE_RATIO_MIN_RISING,
529
+ minWeakerSlopeRatio: MIN_WEAKER_SLOPE_RATIO,
530
+ minTouchesPerLine: MIN_TOUCHES_PER_LINE,
531
+ minScore: MIN_SCORE,
532
+ minContainment: MIN_CONTAINMENT,
533
+ slopeRatioMinFalling: SLOPE_RATIO_MIN,
534
+ };
535
+
536
+ // SG ピボットと元ピボットをマージ(SG 優先、元で補完)
537
+ const origHighs = pivots.filter((p) => p.kind === 'H').map((p) => ({ index: p.idx, price: p.price }));
538
+ const origLows = pivots.filter((p) => p.kind === 'L').map((p) => ({ index: p.idx, price: p.price }));
539
+
540
+ const useSmoothed = pivotData.sgPeaks.length >= MIN_SG_PIVOTS && pivotData.sgValleys.length >= MIN_SG_PIVOTS;
541
+ const swings = {
542
+ highs: useSmoothed ? pivotData.sgPeaks : origHighs,
543
+ lows: useSmoothed ? pivotData.sgValleys : origLows,
544
+ };
545
+
546
+ const allowRising = want.size === 0 || want.has('rising_wedge');
547
+ const allowFalling = want.size === 0 || want.has('falling_wedge');
548
+ const windows = generateWindows(candles.length, params.windowSizeMin, params.windowSizeMax, params.windowStep);
549
+ for (const w of windows) {
550
+ const highsIn = swings.highs.filter((s) => s.index >= w.startIdx && s.index <= w.endIdx);
551
+ const lowsIn = swings.lows.filter((s) => s.index >= w.startIdx && s.index <= w.endIdx);
552
+ if (highsIn.length < 4 || lowsIn.length < 4) continue;
553
+ const upper = lrWithR2(highsIn.map((s) => ({ x: s.index, y: s.price })));
554
+ const lower = lrWithR2(lowsIn.map((s) => ({ x: s.index, y: s.price })));
555
+ if (upper.r2 < MIN_R2_THRESHOLD || lower.r2 < MIN_R2_THRESHOLD) {
556
+ const dbgType =
557
+ upper.slope < 0 && lower.slope < 0
558
+ ? 'falling_wedge'
559
+ : upper.slope > 0 && lower.slope > 0
560
+ ? 'rising_wedge'
561
+ : 'triangle_symmetrical';
562
+ debugCandidates.push({
563
+ type: dbgType,
564
+ accepted: false,
565
+ reason: 'r2_below_threshold',
566
+ indices: [w.startIdx, w.endIdx],
567
+ details: {
568
+ r2High: upper.r2,
569
+ r2Low: lower.r2,
570
+ slopeHigh: upper.slope,
571
+ slopeLow: lower.slope,
572
+ r2MinRequired: MIN_R2_THRESHOLD,
573
+ },
574
+ });
575
+ continue;
576
+ }
577
+ // Rising Wedge の「有意な上昇」チェック(動的なしきい値)
578
+ if (upper.slope > 0 && lower.slope > 0) {
579
+ let hiMax = -Infinity,
580
+ loMin = Infinity;
581
+ for (let i = w.startIdx; i <= w.endIdx; i++) {
582
+ const hi = Number(candles[i]?.high ?? NaN);
583
+ const lo = Number(candles[i]?.low ?? NaN);
584
+ if (Number.isFinite(hi)) hiMax = Math.max(hiMax, hi);
585
+ if (Number.isFinite(lo)) loMin = Math.min(loMin, lo);
586
+ }
587
+ const priceRange = Number.isFinite(hiMax) && Number.isFinite(loMin) ? hiMax - loMin : 0;
588
+ const barsSpan = Math.max(1, w.endIdx - w.startIdx);
589
+ const minMeaningfulSlope = (priceRange * 0.01) / barsSpan;
590
+ const absHi = Math.abs(upper.slope);
591
+ if (absHi < minMeaningfulSlope) {
592
+ debugCandidates.push({
593
+ type: 'rising_wedge',
594
+ accepted: false,
595
+ reason: 'upper_line_barely_rising',
596
+ indices: [w.startIdx, w.endIdx],
597
+ details: { slopeHigh: upper.slope, slopeLow: lower.slope, minMeaningfulSlope, priceRange, barsSpan },
598
+ });
599
+ continue;
600
+ }
601
+ if (highsIn.length >= 3) {
602
+ const mid = Math.floor(highsIn.length / 2);
603
+ const firstHalf = highsIn.slice(0, mid);
604
+ const secondHalf = highsIn.slice(mid);
605
+ const firstAvg = firstHalf.reduce((s, p) => s + Number(p.price), 0) / Math.max(1, firstHalf.length);
606
+ const secondAvg = secondHalf.reduce((s, p) => s + Number(p.price), 0) / Math.max(1, secondHalf.length);
607
+ const ratio = Number((secondAvg / Math.max(EPSILON, firstAvg)).toFixed(4));
608
+ if (Number.isFinite(firstAvg) && Number.isFinite(secondAvg) && ratio < MIN_HIGHS_RATIO) {
609
+ debugCandidates.push({
610
+ type: 'rising_wedge',
611
+ accepted: false,
612
+ reason: 'declining_highs',
613
+ indices: [w.startIdx, w.endIdx],
614
+ details: { firstAvg, secondAvg, ratio },
615
+ });
616
+ continue;
617
+ }
618
+ }
619
+ }
620
+ const wedgeType = determineWedgeType(upper.slope, lower.slope, params);
621
+ if (!wedgeType) {
622
+ const absHi = Math.abs(upper.slope);
623
+ const absLo = Math.abs(lower.slope);
624
+ const slopeRatioHL = absHi / Math.max(EPSILON, absLo);
625
+ const slopeRatioLH = absLo / Math.max(EPSILON, absHi);
626
+ let failureReason: 'slope_ratio_too_small' | 'slopes_too_flat' | 'wrong_side_steeper' = 'slope_ratio_too_small';
627
+ if (upper.slope > 0 && lower.slope > 0) {
628
+ if (absHi < (params.minSlope ?? 0.0001) || absLo < (params.minSlope ?? 0.0001)) {
629
+ failureReason = 'slopes_too_flat';
630
+ } else if (!(absLo > absHi)) {
631
+ failureReason = 'wrong_side_steeper';
632
+ } else if (!(slopeRatioLH > (params.slopeRatioMinRising ?? 1.2))) {
633
+ failureReason = 'slope_ratio_too_small';
634
+ }
635
+ } else if (upper.slope < 0 && lower.slope < 0) {
636
+ if (absHi < (params.minSlope ?? 0.0001) || absLo < (params.minSlope ?? 0.0001)) {
637
+ failureReason = 'slopes_too_flat';
638
+ } else if (!(absHi > absLo)) {
639
+ failureReason = 'wrong_side_steeper';
640
+ } else if (!(slopeRatioHL > (params.slopeRatioMinFalling ?? params.slopeRatioMin ?? 1.15))) {
641
+ failureReason = 'slope_ratio_too_small';
642
+ }
643
+ } else {
644
+ failureReason = 'slope_ratio_too_small';
645
+ }
646
+ const dbgType =
647
+ upper.slope < 0 && lower.slope < 0
648
+ ? 'falling_wedge'
649
+ : upper.slope > 0 && lower.slope > 0
650
+ ? 'rising_wedge'
651
+ : 'triangle_symmetrical';
652
+ debugCandidates.push({
653
+ type: dbgType,
654
+ accepted: false,
655
+ reason: 'type_classification_failed',
656
+ indices: [w.startIdx, w.endIdx],
657
+ details: {
658
+ slopeHigh: upper.slope,
659
+ slopeLow: lower.slope,
660
+ slopeRatio: Number((Math.abs(upper.slope) / Math.max(EPSILON, Math.abs(lower.slope))).toFixed(4)),
661
+ minSlope: params.minSlope ?? 0.0001,
662
+ maxSlope: params.maxSlope ?? 0.05,
663
+ slopeRatioMin:
664
+ dbgType === 'rising_wedge'
665
+ ? (params.slopeRatioMinRising ?? 1.2)
666
+ : (params.slopeRatioMinFalling ?? params.slopeRatioMin ?? 1.15),
667
+ failureReason,
668
+ },
669
+ });
670
+ continue;
671
+ }
672
+ if ((wedgeType === 'rising_wedge' && !allowRising) || (wedgeType === 'falling_wedge' && !allowFalling)) {
673
+ debugCandidates.push({
674
+ type: wedgeType,
675
+ accepted: false,
676
+ reason: 'type_not_requested',
677
+ indices: [w.startIdx, w.endIdx],
678
+ });
679
+ continue;
680
+ }
681
+
682
+ const v = validateRegressionCandidate(candles, wedgeType, upper, lower, w.startIdx, w.endIdx, debugCandidates);
683
+ if (!v) continue;
684
+ const entry = buildRegressionEntry(
685
+ candles,
686
+ wedgeType,
687
+ upper,
688
+ lower,
689
+ w.startIdx,
690
+ w.endIdx,
691
+ v,
692
+ useSmoothed,
693
+ debugCandidates,
694
+ );
695
+ if (entry) patterns.push(entry);
696
+ }
697
+
698
+ return patterns;
699
+ }
700
+
701
+ // ── Phase 3: 形成中ウェッジ検出ヘルパー ──
702
+
703
+ type FormingLine = { slope: number; intercept: number; valueAt: (idx: number) => number };
704
+
705
+ function makeLineF(p1: { idx: number; price: number }, p2: { idx: number; price: number }): FormingLine {
706
+ const slope = (p2.price - p1.price) / Math.max(1, p2.idx - p1.idx);
707
+ const intercept = p1.price - slope * p1.idx;
708
+ return { slope, intercept, valueAt: (idx: number) => slope * idx + intercept };
709
+ }
710
+
711
+ function findUpperTrendlineF(
712
+ highs: { idx: number; price: number }[],
713
+ startIdx: number,
714
+ endIdx: number,
715
+ tolerance: number,
716
+ ): FormingLine | null {
717
+ const inRange = highs.filter((h) => h.idx >= startIdx && h.idx <= endIdx);
718
+ if (inRange.length < 2) return null;
719
+
720
+ const midPoint = startIdx + (endIdx - startIdx) / 2;
721
+ const firstHalf = inRange.filter((h) => h.idx < midPoint);
722
+ const secondHalf = inRange.filter((h) => h.idx >= midPoint);
723
+ const cand1 = firstHalf.length > 0 ? firstHalf : inRange.slice(0, Math.ceil(inRange.length / 2));
724
+ const cand2 = secondHalf.length > 0 ? secondHalf : inRange.slice(Math.floor(inRange.length / 2));
725
+ if (cand1.length === 0 || cand2.length === 0) return null;
726
+
727
+ let bestLine: FormingLine | null = null;
728
+ let bestScore = -Infinity;
729
+
730
+ for (const p1 of cand1) {
731
+ for (const p2 of cand2) {
732
+ if (p1.idx >= p2.idx) continue;
733
+ const line = makeLineF(p1, p2);
734
+ let valid = true;
735
+ for (const h of inRange) {
736
+ if (h.price > line.valueAt(h.idx) + tolerance) {
737
+ valid = false;
738
+ break;
739
+ }
740
+ }
741
+ if (valid) {
742
+ const touches = inRange.filter((h) => Math.abs(h.price - line.valueAt(h.idx)) <= tolerance).length;
743
+ const lineScore = touches + (line.slope < 0 ? 1 : 0);
744
+ if (lineScore > bestScore) {
745
+ bestScore = lineScore;
746
+ bestLine = line;
747
+ }
748
+ }
749
+ }
750
+ }
751
+ return bestLine;
752
+ }
753
+
754
+ function findLowerTrendlineF(
755
+ lows: { idx: number; price: number }[],
756
+ startIdx: number,
757
+ endIdx: number,
758
+ tolerance: number,
759
+ ): FormingLine | null {
760
+ const inRange = lows.filter((l) => l.idx >= startIdx && l.idx <= endIdx);
761
+ if (inRange.length < 2) return null;
762
+
763
+ const midPoint = startIdx + (endIdx - startIdx) / 2;
764
+ const firstHalf = inRange.filter((l) => l.idx < midPoint);
765
+ const secondHalf = inRange.filter((l) => l.idx >= midPoint);
766
+ const cand1 = firstHalf.length > 0 ? firstHalf : inRange.slice(0, Math.ceil(inRange.length / 2));
767
+ const cand2 = secondHalf.length > 0 ? secondHalf : inRange.slice(Math.floor(inRange.length / 2));
768
+ if (cand1.length === 0 || cand2.length === 0) return null;
769
+
770
+ let bestLine: FormingLine | null = null;
771
+ let bestScore = -Infinity;
772
+
773
+ for (const p1 of cand1) {
774
+ for (const p2 of cand2) {
775
+ if (p1.idx >= p2.idx) continue;
776
+ const line = makeLineF(p1, p2);
777
+ let valid = true;
778
+ for (const l of inRange) {
779
+ if (l.price < line.valueAt(l.idx) - tolerance) {
780
+ valid = false;
781
+ break;
782
+ }
783
+ }
784
+ if (valid) {
785
+ const touches = inRange.filter((l) => Math.abs(l.price - line.valueAt(l.idx)) <= tolerance).length;
786
+ const lineScore = touches + (line.slope < 0 ? 1 : 0);
787
+ if (lineScore > bestScore) {
788
+ bestScore = lineScore;
789
+ bestLine = line;
790
+ }
791
+ }
792
+ }
793
+ }
794
+ return bestLine;
795
+ }
796
+
797
+ // ── Phase 3: 形成中ウェッジ検出 ──
798
+
799
+ function detectFormingWedges(
800
+ pivotData: PivotData,
801
+ existingPatterns: readonly DeduplicablePattern[],
802
+ ctx: DetectContext,
803
+ ): DeduplicablePattern[] {
804
+ const { candles, want, debugCandidates } = ctx;
805
+ const { smoothHigh, smoothLow } = pivotData;
806
+ const patterns: DeduplicablePattern[] = [];
807
+ const formingWedgeDebug: CandDebugEntry[] = [];
808
+
809
+ const fAllowFalling = want.size === 0 || want.has('falling_wedge');
810
+ const fAllowRising = want.size === 0 || want.has('rising_wedge');
811
+
812
+ // SG 平滑化データからリラックスピボットを検出(swingDepth=1 相当)
813
+ const relaxedPeaks: Array<{ idx: number; price: number }> = [];
814
+ const relaxedValleys: Array<{ idx: number; price: number }> = [];
815
+ for (let idx = 1; idx < candles.length - 1; idx++) {
816
+ const isPeak = smoothHigh[idx] > smoothHigh[idx - 1] && smoothHigh[idx] > smoothHigh[idx + 1];
817
+ const isValley = smoothLow[idx] < smoothLow[idx - 1] && smoothLow[idx] < smoothLow[idx + 1];
818
+ if (isPeak) relaxedPeaks.push({ idx, price: candles[idx].close });
819
+ if (isValley) relaxedValleys.push({ idx, price: candles[idx].close });
820
+ }
821
+
822
+ // ウィンドウスキャン
823
+ const fWindows: Array<{ startIdx: number; endIdx: number }> = [];
824
+ for (let size = FORMING_WINDOW_MIN; size <= FORMING_WINDOW_MAX; size += WINDOW_STEP) {
825
+ for (let startIdx = 0; startIdx + size < candles.length; startIdx += WINDOW_STEP) {
826
+ fWindows.push({ startIdx, endIdx: startIdx + size });
827
+ }
828
+ }
829
+ // 最新に揃えた特別ウィンドウ
830
+ const lastIdx = candles.length - 1;
831
+ for (let size = FORMING_WINDOW_MIN; size <= FORMING_WINDOW_MAX; size += WINDOW_STEP) {
832
+ const s = Math.max(0, lastIdx - size);
833
+ fWindows.push({ startIdx: s, endIdx: lastIdx });
834
+ }
835
+
836
+ // 重複チェック用: 既存パターン + この関数内で生成したパターン両方を参照
837
+ const allPatterns = [...existingPatterns];
838
+
839
+ for (const w of fWindows) {
840
+ const { startIdx, endIdx } = w;
841
+ const avgPrice = (Number(candles[startIdx]?.close) + Number(candles[endIdx]?.close)) / 2;
842
+ const tolerance = avgPrice * FORMING_PRICE_TOLERANCE_PCT;
843
+
844
+ const highsForWindow = relaxedPeaks
845
+ .filter((p) => p.idx >= startIdx && p.idx <= endIdx)
846
+ .map((p) => ({ idx: p.idx, price: Number(candles[p.idx]?.high) }));
847
+ const lowsForWindow = relaxedValleys
848
+ .filter((p) => p.idx >= startIdx && p.idx <= endIdx)
849
+ .map((p) => ({ idx: p.idx, price: Number(candles[p.idx]?.low) }));
850
+
851
+ if (highsForWindow.length < 2 || lowsForWindow.length < 2) continue;
852
+
853
+ const upperLine = findUpperTrendlineF(highsForWindow, startIdx, endIdx, tolerance);
854
+ const lowerLine = findLowerTrendlineF(lowsForWindow, startIdx, endIdx, tolerance);
855
+ if (!upperLine || !lowerLine) continue;
856
+
857
+ // 両方下向き = Falling Wedge、両方上向き = Rising Wedge
858
+ const bothDown = upperLine.slope < 0 && lowerLine.slope < 0;
859
+ const bothUp = upperLine.slope > 0 && lowerLine.slope > 0;
860
+ if (!bothDown && !bothUp) {
861
+ formingWedgeDebug.push({
862
+ type: upperLine.slope < 0 ? 'falling_wedge' : 'rising_wedge',
863
+ accepted: false,
864
+ reason: 'slopes_not_same_direction',
865
+ indices: [startIdx, endIdx],
866
+ details: { slopeU: upperLine.slope, slopeL: lowerLine.slope },
867
+ });
868
+ continue;
869
+ }
870
+
871
+ // minWeakerSlopeRatio チェック
872
+ const absU = Math.abs(upperLine.slope),
873
+ absL = Math.abs(lowerLine.slope);
874
+ const weakerRatio = Math.min(absU, absL) / Math.max(absU, absL);
875
+ if (weakerRatio < MIN_WEAKER_SLOPE_RATIO) {
876
+ formingWedgeDebug.push({
877
+ type: bothDown ? 'falling_wedge' : 'rising_wedge',
878
+ accepted: false,
879
+ reason: 'weaker_slope_ratio_low',
880
+ indices: [startIdx, endIdx],
881
+ details: { weakerRatio },
882
+ });
883
+ continue;
884
+ }
885
+
886
+ const wedgeType: 'falling_wedge' | 'rising_wedge' = bothDown ? 'falling_wedge' : 'rising_wedge';
887
+ if ((wedgeType === 'falling_wedge' && !fAllowFalling) || (wedgeType === 'rising_wedge' && !fAllowRising)) continue;
888
+
889
+ // 収束チェック
890
+ const gapStart = upperLine.valueAt(startIdx) - lowerLine.valueAt(startIdx);
891
+ const gapEnd = upperLine.valueAt(endIdx) - lowerLine.valueAt(endIdx);
892
+ if (gapStart <= 0 || gapEnd <= 0 || gapEnd >= gapStart) {
893
+ formingWedgeDebug.push({
894
+ type: wedgeType,
895
+ accepted: false,
896
+ reason: 'no_convergence',
897
+ indices: [startIdx, endIdx],
898
+ details: { gapStart, gapEnd },
899
+ });
900
+ continue;
901
+ }
902
+ const convRatio = gapEnd / gapStart;
903
+ if (convRatio >= FORMING_MAX_CONV_RATIO) {
904
+ formingWedgeDebug.push({
905
+ type: wedgeType,
906
+ accepted: false,
907
+ reason: 'conv_ratio_too_high',
908
+ indices: [startIdx, endIdx],
909
+ details: { convRatio },
910
+ });
911
+ continue;
912
+ }
913
+
914
+ // Apex バリデーション(形成中でも未来にあることを確認)
915
+ const fApex = calcApex(
916
+ { slope: upperLine.slope, intercept: upperLine.intercept, valueAt: upperLine.valueAt },
917
+ { slope: lowerLine.slope, intercept: lowerLine.intercept, valueAt: lowerLine.valueAt },
918
+ endIdx,
919
+ );
920
+ if (!fApex.isValid) {
921
+ formingWedgeDebug.push({
922
+ type: wedgeType,
923
+ accepted: false,
924
+ reason: 'apex_invalid',
925
+ indices: [startIdx, endIdx],
926
+ details: { apex: fApex },
927
+ });
928
+ continue;
929
+ }
930
+
931
+ // 包含チェック(形成中は緩めに 75%)
932
+ const fContainment = checkContainment(candles, upperLine, lowerLine, startIdx, endIdx, 0.005);
933
+ if (fContainment.closeInsideRatio < FORMING_MIN_CONTAINMENT) {
934
+ formingWedgeDebug.push({
935
+ type: wedgeType,
936
+ accepted: false,
937
+ reason: 'containment_low',
938
+ indices: [startIdx, endIdx],
939
+ details: { containment: fContainment.closeInsideRatio },
940
+ });
941
+ continue;
942
+ }
943
+
944
+ // ブレイク検出(終値ベース、トレンドライン乖離1.5%)
945
+ let breakoutIdx = -1;
946
+ let breakoutDirection: 'up' | 'down' | null = null;
947
+ for (
948
+ let i = startIdx + Math.max(FORMING_MIN_BARS_BEFORE_BREAK, Math.floor((endIdx - startIdx) * 0.3));
949
+ i <= lastIdx;
950
+ i++
951
+ ) {
952
+ const close = Number(candles[i]?.close);
953
+ const uVal = upperLine.valueAt(i);
954
+ const lVal = lowerLine.valueAt(i);
955
+
956
+ if (close > uVal * (1 + FORMING_BREAKOUT_FACTOR)) {
957
+ breakoutIdx = i;
958
+ breakoutDirection = 'up';
959
+ break;
960
+ }
961
+ if (close < lVal * (1 - FORMING_BREAKOUT_FACTOR)) {
962
+ breakoutIdx = i;
963
+ breakoutDirection = 'down';
964
+ break;
965
+ }
966
+ }
967
+
968
+ // ブレイクがない場合は形成中
969
+ const isForming = breakoutIdx === -1;
970
+ const actualEndIdx = isForming ? endIdx : breakoutIdx;
971
+ const start = candles[startIdx]?.isoTime;
972
+ const end = candles[actualEndIdx]?.isoTime;
973
+ if (!start || !end) continue;
974
+
975
+ // 重複チェック
976
+ const alreadyExists = allPatterns.some((p) => {
977
+ if (p.type !== wedgeType) return false;
978
+ const pStart = Date.parse(p.range?.start || '');
979
+ const pEnd = Date.parse(p.range?.end || '');
980
+ const thisStart = Date.parse(start);
981
+ const thisEnd = Date.parse(end);
982
+ if (!Number.isFinite(pStart) || !Number.isFinite(thisStart)) return false;
983
+ return Math.abs(pStart - thisStart) < 5 * 86400000 && Math.abs(pEnd - thisEnd) < 5 * 86400000;
984
+ });
985
+ if (alreadyExists) continue;
986
+
987
+ // スコア計算
988
+ const convergenceScore = 1 - convRatio;
989
+ const slopeScore = Math.min(absU, absL) / Math.max(absU, absL);
990
+ const durationDays = actualEndIdx - startIdx;
991
+ const durationScore = durationDays >= 20 && durationDays <= 60 ? 1.0 : 0.8;
992
+ const score = convergenceScore * CONVERGENCE_WEIGHT + slopeScore * SLOPE_WEIGHT + durationScore * DURATION_WEIGHT;
993
+ const confidence = Math.max(CONFIDENCE_MIN, Math.min(CONFIDENCE_MAX, score + CONFIDENCE_BOOST));
994
+
995
+ // ステータス判定
996
+ let status: 'forming' | 'near_completion' | 'completed' | 'invalid' = 'forming';
997
+ let outcome: 'success' | 'failure' | undefined;
998
+
999
+ if (breakoutDirection) {
1000
+ const expected = wedgeType === 'falling_wedge' ? 'up' : 'down';
1001
+ status = 'completed';
1002
+ outcome = breakoutDirection === expected ? 'success' : 'failure';
1003
+ } else if (fApex.barsToApex <= 10) {
1004
+ status = 'near_completion';
1005
+ }
1006
+
1007
+ // ブレイク日の取得
1008
+ const breakoutDate = breakoutIdx !== -1 ? candles[breakoutIdx]?.isoTime : undefined;
1009
+
1010
+ // --- ターゲット価格計算(pattern_height 方式) ---
1011
+ const fPatternHeight = Math.abs(upperLine.valueAt(startIdx) - lowerLine.valueAt(startIdx));
1012
+ let fBreakoutTarget: number | undefined;
1013
+ let fTargetReachedPct: number | undefined;
1014
+ if (breakoutDirection && breakoutIdx !== -1) {
1015
+ const bp = Number(candles[breakoutIdx]?.close);
1016
+ if (Number.isFinite(bp)) {
1017
+ fBreakoutTarget = breakoutDirection === 'up' ? bp + fPatternHeight : bp - fPatternHeight;
1018
+ fBreakoutTarget = Math.round(fBreakoutTarget);
1019
+ const curPrice = Number(candles[candles.length - 1]?.close);
1020
+ if (Number.isFinite(curPrice) && Math.abs(fBreakoutTarget - bp) > EPSILON) {
1021
+ fTargetReachedPct = Math.round(((curPrice - bp) / (fBreakoutTarget - bp)) * 100);
1022
+ }
1023
+ }
1024
+ }
1025
+
1026
+ const entry: DeduplicablePattern = {
1027
+ type: wedgeType,
1028
+ confidence,
1029
+ range: { start, end },
1030
+ status,
1031
+ daysToApex: fApex.isValid ? fApex.barsToApex : undefined,
1032
+ breakoutDirection: breakoutDirection ?? undefined,
1033
+ outcome,
1034
+ breakoutDate,
1035
+ breakoutBarIndex: breakoutIdx !== -1 ? breakoutIdx : undefined,
1036
+ ...(fBreakoutTarget !== undefined
1037
+ ? { breakoutTarget: fBreakoutTarget, targetMethod: 'pattern_height' as const }
1038
+ : {}),
1039
+ ...(fTargetReachedPct !== undefined ? { targetReachedPct: fTargetReachedPct } : {}),
1040
+ _method: 'forming_relaxed',
1041
+ };
1042
+ patterns.push(entry);
1043
+ allPatterns.push(entry);
1044
+
1045
+ formingWedgeDebug.push({
1046
+ type: wedgeType,
1047
+ accepted: true,
1048
+ indices: [startIdx, actualEndIdx],
1049
+ status,
1050
+ breakoutDirection,
1051
+ details: {
1052
+ apex: { idx: fApex.apexIdx, barsToApex: fApex.barsToApex },
1053
+ containment: fContainment.closeInsideRatio,
1054
+ },
1055
+ });
1056
+ }
1057
+
1058
+ for (const d of formingWedgeDebug) {
1059
+ debugCandidates.unshift(d);
1060
+ }
1061
+
1062
+ return patterns;
1063
+ }
1064
+
1065
+ // ── Orchestrator ──
1066
+
1067
+ export function detectWedges(ctx: DetectContext): DetectResult {
1068
+ const pivotData = preparePivots(ctx);
1069
+ const regressionPatterns = detectRegressionWedges(pivotData, ctx);
1070
+ const formingPatterns = detectFormingWedges(pivotData, regressionPatterns, ctx);
1071
+ return { patterns: deduplicatePatterns([...regressionPatterns, ...formingPatterns]) };
1072
+ }