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.
- package/LICENSE +21 -0
- package/README.md +388 -0
- package/assets/lightweight-charts.standalone.js +7 -0
- package/bin/bitbank-lab-mcp.js +20 -0
- package/lib/cache.ts +70 -0
- package/lib/candle-utils.ts +48 -0
- package/lib/candle-validate.ts +434 -0
- package/lib/conversions.ts +25 -0
- package/lib/datetime.ts +157 -0
- package/lib/depth-analysis.ts +51 -0
- package/lib/error.ts +15 -0
- package/lib/formatter.ts +296 -0
- package/lib/get-depth.ts +111 -0
- package/lib/http.ts +132 -0
- package/lib/indicator-config.ts +39 -0
- package/lib/indicator_buffer.ts +41 -0
- package/lib/indicators.ts +579 -0
- package/lib/logger.ts +120 -0
- package/lib/ma-snapshot-utils.ts +277 -0
- package/lib/math.ts +89 -0
- package/lib/pattern-diagrams.ts +562 -0
- package/lib/result.ts +104 -0
- package/lib/validate.ts +154 -0
- package/lib/volatility.ts +132 -0
- package/package.json +79 -0
- package/src/env.ts +4 -0
- package/src/handlers/analyzeCandlePatternsHandler.ts +383 -0
- package/src/handlers/analyzeFibonacciHandler.ts +54 -0
- package/src/handlers/analyzeIndicatorsHandler.ts +682 -0
- package/src/handlers/analyzeMarketSignalHandler.ts +272 -0
- package/src/handlers/analyzeMyPortfolioHandler.ts +800 -0
- package/src/handlers/detectPatternsHandler.ts +77 -0
- package/src/handlers/detectPatternsViewsHandler.ts +518 -0
- package/src/handlers/getTickersJpyHandler.ts +145 -0
- package/src/handlers/getVolatilityMetricsHandler.ts +234 -0
- package/src/handlers/portfolio/calc.ts +549 -0
- package/src/handlers/portfolio/fetch.ts +318 -0
- package/src/handlers/portfolio/types.ts +170 -0
- package/src/handlers/renderChartSvgHandler.ts +69 -0
- package/src/handlers/runBacktestHandler.ts +70 -0
- package/src/http.ts +107 -0
- package/src/private/auth.ts +104 -0
- package/src/private/client.ts +298 -0
- package/src/private/config.ts +25 -0
- package/src/private/confirmation.ts +185 -0
- package/src/private/schemas.ts +866 -0
- package/src/prompts.ts +2296 -0
- package/src/resources/app-resources.ts +79 -0
- package/src/schema/analysis.ts +942 -0
- package/src/schema/backtest.ts +100 -0
- package/src/schema/base.ts +88 -0
- package/src/schema/candle-validate.ts +135 -0
- package/src/schema/chart.ts +399 -0
- package/src/schema/index.ts +11 -0
- package/src/schema/indicators.ts +125 -0
- package/src/schema/market-data.ts +298 -0
- package/src/schema/patterns.ts +382 -0
- package/src/schema/types.ts +97 -0
- package/src/schemas.d.ts +37 -0
- package/src/schemas.ts +7 -0
- package/src/server.ts +405 -0
- package/src/tool-definition.ts +44 -0
- package/src/tool-registry.ts +174 -0
- package/src/types/express-shim.d.ts +9 -0
- package/src/types/schemas.generated.d.ts +23 -0
- package/tools/analyze_bb_snapshot.ts +385 -0
- package/tools/analyze_candle_patterns.ts +810 -0
- package/tools/analyze_currency_strength.ts +273 -0
- package/tools/analyze_ema_snapshot.ts +183 -0
- package/tools/analyze_fibonacci.ts +530 -0
- package/tools/analyze_ichimoku_snapshot.ts +606 -0
- package/tools/analyze_indicators.ts +691 -0
- package/tools/analyze_market_signal.ts +665 -0
- package/tools/analyze_mtf_fibonacci.ts +273 -0
- package/tools/analyze_mtf_sma.ts +175 -0
- package/tools/analyze_sma_snapshot.ts +146 -0
- package/tools/analyze_stoch_snapshot.ts +276 -0
- package/tools/analyze_support_resistance.ts +817 -0
- package/tools/analyze_volume_profile.ts +546 -0
- package/tools/chart/ichimoku-cloud.ts +113 -0
- package/tools/chart/render-depth.ts +139 -0
- package/tools/chart/render-sub-panels.ts +208 -0
- package/tools/chart/svg-utils.ts +102 -0
- package/tools/detect_macd_cross.ts +691 -0
- package/tools/detect_patterns.ts +424 -0
- package/tools/detect_whale_events.ts +181 -0
- package/tools/get_candles.ts +487 -0
- package/tools/get_flow_metrics.ts +596 -0
- package/tools/get_orderbook.ts +540 -0
- package/tools/get_ticker.ts +132 -0
- package/tools/get_tickers_jpy.ts +240 -0
- package/tools/get_transactions.ts +209 -0
- package/tools/get_volatility_metrics.ts +302 -0
- package/tools/patterns/aftermath.ts +212 -0
- package/tools/patterns/config.ts +151 -0
- package/tools/patterns/detect_doubles.ts +650 -0
- package/tools/patterns/detect_hs.ts +635 -0
- package/tools/patterns/detect_pennants.ts +373 -0
- package/tools/patterns/detect_triangles.ts +820 -0
- package/tools/patterns/detect_triples.ts +633 -0
- package/tools/patterns/detect_wedges.ts +1072 -0
- package/tools/patterns/helpers.ts +517 -0
- package/tools/patterns/index.ts +40 -0
- package/tools/patterns/regression.ts +153 -0
- package/tools/patterns/smoothing.ts +168 -0
- package/tools/patterns/swing.ts +91 -0
- package/tools/patterns/types.ts +193 -0
- package/tools/prepare_chart_data.ts +294 -0
- package/tools/prepare_depth_data.ts +189 -0
- package/tools/private/analyze_my_portfolio.ts +21 -0
- package/tools/private/cancel_order.ts +127 -0
- package/tools/private/cancel_orders.ts +121 -0
- package/tools/private/create_order.ts +236 -0
- package/tools/private/get_margin_positions.ts +134 -0
- package/tools/private/get_margin_status.ts +155 -0
- package/tools/private/get_margin_trade_history.ts +156 -0
- package/tools/private/get_my_assets.ts +207 -0
- package/tools/private/get_my_deposit_withdrawal.ts +500 -0
- package/tools/private/get_my_orders.ts +157 -0
- package/tools/private/get_my_trade_history.ts +229 -0
- package/tools/private/get_order.ts +95 -0
- package/tools/private/get_orders_info.ts +90 -0
- package/tools/private/preview_cancel_order.ts +172 -0
- package/tools/private/preview_cancel_orders.ts +137 -0
- package/tools/private/preview_order.ts +292 -0
- package/tools/render_candle_pattern_diagram.ts +389 -0
- package/tools/render_chart_svg.ts +799 -0
- package/tools/render_depth_svg.ts +274 -0
- package/tools/trading_process/index.ts +7 -0
- package/tools/trading_process/lib/backtest_engine.ts +252 -0
- package/tools/trading_process/lib/equity.ts +131 -0
- package/tools/trading_process/lib/fetch_candles.ts +181 -0
- package/tools/trading_process/lib/sma.ts +62 -0
- package/tools/trading_process/lib/strategies/bb_breakout.ts +141 -0
- package/tools/trading_process/lib/strategies/index.ts +52 -0
- package/tools/trading_process/lib/strategies/macd_cross.ts +256 -0
- package/tools/trading_process/lib/strategies/rsi.ts +133 -0
- package/tools/trading_process/lib/strategies/sma_cross.ts +214 -0
- package/tools/trading_process/lib/strategies/types.ts +118 -0
- package/tools/trading_process/lib/svg_to_png.ts +64 -0
- package/tools/trading_process/render_backtest_chart_generic.ts +729 -0
- package/tools/trading_process/run_backtest.ts +243 -0
- package/tools/trading_process/types.ts +85 -0
- package/tools/validate_candle_data.ts +260 -0
- package/tsconfig.json +17 -0
- package/ui/cancel-confirm/dist/cancel-confirm.html +99 -0
- 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
|
+
}
|