@stvy/fund-indicators 1.0.0 → 1.0.2
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/.github/workflows/publish.yml +79 -0
- package/AGENTS.md +322 -0
- package/dist/index.cjs +1615 -0
- package/dist/index.d.cts +779 -0
- package/dist/index.d.cts.map +1 -0
- package/dist/index.mjs +1518 -0
- package/package.json +10 -29
- package/pnpm-workspace.yaml +2 -0
- package/src/dca.ts +420 -0
- package/src/index.ts +133 -0
- package/src/jstat.d.ts +17 -0
- package/src/pattern.ts +447 -0
- package/src/risk.ts +516 -0
- package/src/statistics.ts +428 -0
- package/src/technical.ts +738 -0
- package/src/types.ts +369 -0
- package/test/index.test.ts +355 -0
- package/tsconfig.json +20 -0
- package/dist/browser/fund-indicators.esm.js +0 -7505
- package/dist/browser/fund-indicators.esm.min.js +0 -8
- package/dist/browser/fund-indicators.esm.min.js.map +0 -7
- package/dist/browser/fund-indicators.js +0 -7517
- package/dist/browser/fund-indicators.min.js +0 -8
- package/dist/browser/fund-indicators.min.js.map +0 -7
- package/dist/dca.d.ts +0 -91
- package/dist/dca.d.ts.map +0 -1
- package/dist/dca.js +0 -354
- package/dist/dca.js.map +0 -1
- package/dist/index.d.ts +0 -18
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js +0 -141
- package/dist/index.js.map +0 -1
- package/dist/pattern.d.ts +0 -60
- package/dist/pattern.d.ts.map +0 -1
- package/dist/pattern.js +0 -386
- package/dist/pattern.js.map +0 -1
- package/dist/risk.d.ts +0 -115
- package/dist/risk.d.ts.map +0 -1
- package/dist/risk.js +0 -502
- package/dist/risk.js.map +0 -1
- package/dist/statistics.d.ts +0 -78
- package/dist/statistics.d.ts.map +0 -1
- package/dist/statistics.js +0 -402
- package/dist/statistics.js.map +0 -1
- package/dist/technical.d.ts +0 -105
- package/dist/technical.d.ts.map +0 -1
- package/dist/technical.js +0 -633
- package/dist/technical.js.map +0 -1
- package/dist/types.d.ts +0 -327
- package/dist/types.d.ts.map +0 -1
- package/dist/types.js +0 -7
- package/dist/types.js.map +0 -1
package/src/pattern.ts
ADDED
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 形态识别模块
|
|
3
|
+
* 包含:支撑/阻力位、双底/双顶、缺口识别、趋势强度等
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { NavSeries, SupportResistanceResult, DoubleBottomTopResult, GapResult } from './types';
|
|
7
|
+
|
|
8
|
+
// ============================================================
|
|
9
|
+
// 支撑位 / 阻力位
|
|
10
|
+
// ============================================================
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* 基于净值密集区识别支撑位和阻力位
|
|
14
|
+
* 使用核密度估计(KDE)思想,找到净值频繁出现的价位
|
|
15
|
+
*
|
|
16
|
+
* @param nav 净值序列
|
|
17
|
+
* @param tolerance 价格容差比例(如 0.02 = 2%以内的视为同一价位)
|
|
18
|
+
* @param minTouches 最少触及次数(默认3)
|
|
19
|
+
*/
|
|
20
|
+
export function supportResistance(
|
|
21
|
+
nav: NavSeries,
|
|
22
|
+
tolerance: number = 0.02,
|
|
23
|
+
minTouches: number = 3
|
|
24
|
+
): SupportResistanceResult {
|
|
25
|
+
if (nav.length < 10) {
|
|
26
|
+
return { supports: [], resistances: [] };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const current = nav[nav.length - 1];
|
|
30
|
+
|
|
31
|
+
// 统计每个价位附近的触及次数
|
|
32
|
+
const levelMap = new Map<number, number>();
|
|
33
|
+
|
|
34
|
+
for (let i = 0; i < nav.length; i++) {
|
|
35
|
+
// 寻找局部极值(局部最高点和最低点)
|
|
36
|
+
const isLocalHigh = (i > 0 && i < nav.length - 1) &&
|
|
37
|
+
nav[i] >= nav[i - 1] && nav[i] >= nav[i + 1];
|
|
38
|
+
const isLocalLow = (i > 0 && i < nav.length - 1) &&
|
|
39
|
+
nav[i] <= nav[i - 1] && nav[i] <= nav[i + 1];
|
|
40
|
+
|
|
41
|
+
if (isLocalHigh || isLocalLow) {
|
|
42
|
+
// 合并相近价位
|
|
43
|
+
let merged = false;
|
|
44
|
+
for (const [level, count] of levelMap.entries()) {
|
|
45
|
+
if (Math.abs(nav[i] - level) / level < tolerance) {
|
|
46
|
+
levelMap.set(level, count + 1);
|
|
47
|
+
merged = true;
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (!merged) {
|
|
52
|
+
levelMap.set(nav[i], 1);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// 也考虑净值序列中出现频率高的价位
|
|
58
|
+
for (const price of nav) {
|
|
59
|
+
let merged = false;
|
|
60
|
+
for (const [level, count] of levelMap.entries()) {
|
|
61
|
+
if (Math.abs(price - level) / level < tolerance) {
|
|
62
|
+
levelMap.set(level, count + 1);
|
|
63
|
+
merged = true;
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (!merged) {
|
|
68
|
+
levelMap.set(price, 1);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// 过滤并排序
|
|
73
|
+
const validLevels = Array.from(levelMap.entries())
|
|
74
|
+
.filter(([, touches]) => touches >= minTouches)
|
|
75
|
+
.map(([level, touches]) => ({
|
|
76
|
+
level,
|
|
77
|
+
touches,
|
|
78
|
+
strength: touches / nav.length,
|
|
79
|
+
}))
|
|
80
|
+
.sort((a, b) => b.strength - a.strength);
|
|
81
|
+
|
|
82
|
+
// 分为支撑位(低于当前价)和阻力位(高于当前价)
|
|
83
|
+
const supports = validLevels
|
|
84
|
+
.filter((l) => l.level < current)
|
|
85
|
+
.sort((a, b) => b.level - a.level); // 最近的支撑位排前面
|
|
86
|
+
|
|
87
|
+
const resistances = validLevels
|
|
88
|
+
.filter((l) => l.level > current)
|
|
89
|
+
.sort((a, b) => a.level - b.level); // 最近的阻力位排前面
|
|
90
|
+
|
|
91
|
+
return { supports, resistances };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ============================================================
|
|
95
|
+
// 双底 (W底) / 双顶 (M头) 识别
|
|
96
|
+
// ============================================================
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* 双底/双顶形态识别
|
|
100
|
+
* @param nav 净值序列
|
|
101
|
+
* @param lookback 回看天数(默认60)
|
|
102
|
+
* @param tolerance 两底/顶之间的价格容差比例(默认0.03 = 3%)
|
|
103
|
+
* @param minDistance 两个底/顶之间的最小间隔天数(默认10)
|
|
104
|
+
*/
|
|
105
|
+
export function doubleBottomTop(
|
|
106
|
+
nav: NavSeries,
|
|
107
|
+
lookback: number = 60,
|
|
108
|
+
tolerance: number = 0.03,
|
|
109
|
+
minDistance: number = 10
|
|
110
|
+
): DoubleBottomTopResult {
|
|
111
|
+
const noResult: DoubleBottomTopResult = {
|
|
112
|
+
type: 'none',
|
|
113
|
+
firstPointIndex: null,
|
|
114
|
+
secondPointIndex: null,
|
|
115
|
+
necklineIndex: null,
|
|
116
|
+
necklinePrice: null,
|
|
117
|
+
breakout: false,
|
|
118
|
+
confidence: 0,
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
if (nav.length < lookback || lookback < minDistance * 2) return noResult;
|
|
122
|
+
|
|
123
|
+
const window = nav.slice(-lookback);
|
|
124
|
+
const offset = nav.length - lookback;
|
|
125
|
+
|
|
126
|
+
// 寻找局部极值
|
|
127
|
+
const localMins: { index: number; value: number }[] = [];
|
|
128
|
+
const localMaxs: { index: number; value: number }[] = [];
|
|
129
|
+
|
|
130
|
+
for (let i = 2; i < window.length - 2; i++) {
|
|
131
|
+
if (window[i] <= window[i - 1] && window[i] <= window[i + 1] &&
|
|
132
|
+
window[i] <= window[i - 2] && window[i] <= window[i + 2]) {
|
|
133
|
+
localMins.push({ index: i, value: window[i] });
|
|
134
|
+
}
|
|
135
|
+
if (window[i] >= window[i - 1] && window[i] >= window[i + 1] &&
|
|
136
|
+
window[i] >= window[i - 2] && window[i] >= window[i + 2]) {
|
|
137
|
+
localMaxs.push({ index: i, value: window[i] });
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// 检测双底(W底)
|
|
142
|
+
for (let i = 0; i < localMins.length; i++) {
|
|
143
|
+
for (let j = i + 1; j < localMins.length; j++) {
|
|
144
|
+
const first = localMins[i];
|
|
145
|
+
const second = localMins[j];
|
|
146
|
+
const distance = second.index - first.index;
|
|
147
|
+
|
|
148
|
+
if (distance < minDistance) continue;
|
|
149
|
+
|
|
150
|
+
const priceDiff = Math.abs(first.value - second.value) / first.value;
|
|
151
|
+
if (priceDiff > tolerance) continue;
|
|
152
|
+
|
|
153
|
+
// 找两底之间的颈线(最高点)
|
|
154
|
+
const between = window.slice(first.index, second.index + 1);
|
|
155
|
+
const neckValue = Math.max(...between);
|
|
156
|
+
const neckIdx = first.index + between.indexOf(neckValue);
|
|
157
|
+
|
|
158
|
+
// 检查是否突破颈线
|
|
159
|
+
const currentPrice = window[window.length - 1];
|
|
160
|
+
const breakout = currentPrice > neckValue;
|
|
161
|
+
|
|
162
|
+
// 计算信号强度
|
|
163
|
+
const similarity = 1 - priceDiff / tolerance;
|
|
164
|
+
const distanceFactor = Math.min(1, distance / lookback * 2);
|
|
165
|
+
const confidence = (similarity * 0.6 + distanceFactor * 0.4) * (breakout ? 1 : 0.7);
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
type: 'double_bottom',
|
|
169
|
+
firstPointIndex: offset + first.index,
|
|
170
|
+
secondPointIndex: offset + second.index,
|
|
171
|
+
necklineIndex: offset + neckIdx,
|
|
172
|
+
necklinePrice: neckValue,
|
|
173
|
+
breakout,
|
|
174
|
+
confidence,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// 检测双顶(M头)
|
|
180
|
+
for (let i = 0; i < localMaxs.length; i++) {
|
|
181
|
+
for (let j = i + 1; j < localMaxs.length; j++) {
|
|
182
|
+
const first = localMaxs[i];
|
|
183
|
+
const second = localMaxs[j];
|
|
184
|
+
const distance = second.index - first.index;
|
|
185
|
+
|
|
186
|
+
if (distance < minDistance) continue;
|
|
187
|
+
|
|
188
|
+
const priceDiff = Math.abs(first.value - second.value) / first.value;
|
|
189
|
+
if (priceDiff > tolerance) continue;
|
|
190
|
+
|
|
191
|
+
// 找两顶之间的颈线(最低点)
|
|
192
|
+
const between = window.slice(first.index, second.index + 1);
|
|
193
|
+
const neckValue = Math.min(...between);
|
|
194
|
+
const neckIdx = first.index + between.indexOf(neckValue);
|
|
195
|
+
|
|
196
|
+
// 检查是否跌破颈线
|
|
197
|
+
const currentPrice = window[window.length - 1];
|
|
198
|
+
const breakout = currentPrice < neckValue;
|
|
199
|
+
|
|
200
|
+
const similarity = 1 - priceDiff / tolerance;
|
|
201
|
+
const distanceFactor = Math.min(1, distance / lookback * 2);
|
|
202
|
+
const confidence = (similarity * 0.6 + distanceFactor * 0.4) * (breakout ? 1 : 0.7);
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
type: 'double_top',
|
|
206
|
+
firstPointIndex: offset + first.index,
|
|
207
|
+
secondPointIndex: offset + second.index,
|
|
208
|
+
necklineIndex: offset + neckIdx,
|
|
209
|
+
necklinePrice: neckValue,
|
|
210
|
+
breakout,
|
|
211
|
+
confidence,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return noResult;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ============================================================
|
|
220
|
+
// 缺口识别
|
|
221
|
+
// ============================================================
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* 净值缺口识别(分红、估值调整等导致的净值跳变)
|
|
225
|
+
* @param nav 净值序列
|
|
226
|
+
* @param threshold 缺口阈值百分比(默认0.02 = 2%)
|
|
227
|
+
*/
|
|
228
|
+
export function detectGaps(nav: NavSeries, threshold: number = 0.02): GapResult[] {
|
|
229
|
+
const gaps: GapResult[] = [];
|
|
230
|
+
|
|
231
|
+
for (let i = 1; i < nav.length; i++) {
|
|
232
|
+
const change = (nav[i] - nav[i - 1]) / nav[i - 1];
|
|
233
|
+
const absChange = Math.abs(change);
|
|
234
|
+
|
|
235
|
+
if (absChange >= threshold) {
|
|
236
|
+
const isGapUp = change > 0;
|
|
237
|
+
const gapTop = isGapUp ? nav[i] : nav[i - 1];
|
|
238
|
+
const gapBottom = isGapUp ? nav[i - 1] : nav[i];
|
|
239
|
+
|
|
240
|
+
// 检查缺口是否已回补
|
|
241
|
+
let filled = false;
|
|
242
|
+
let filledIndex: number | null = null;
|
|
243
|
+
|
|
244
|
+
if (isGapUp) {
|
|
245
|
+
// 向上缺口:后续净值跌回缺口下沿即为回补
|
|
246
|
+
for (let j = i + 1; j < nav.length; j++) {
|
|
247
|
+
if (nav[j] <= gapBottom) {
|
|
248
|
+
filled = true;
|
|
249
|
+
filledIndex = j;
|
|
250
|
+
break;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
} else {
|
|
254
|
+
// 向下缺口:后续净值涨回缺口上沿即为回补
|
|
255
|
+
for (let j = i + 1; j < nav.length; j++) {
|
|
256
|
+
if (nav[j] >= gapTop) {
|
|
257
|
+
filled = true;
|
|
258
|
+
filledIndex = j;
|
|
259
|
+
break;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
gaps.push({
|
|
265
|
+
type: isGapUp ? 'gap_up' : 'gap_down',
|
|
266
|
+
startIndex: i - 1,
|
|
267
|
+
endIndex: i,
|
|
268
|
+
gapTop,
|
|
269
|
+
gapBottom,
|
|
270
|
+
gapSize: absChange,
|
|
271
|
+
filled,
|
|
272
|
+
filledIndex,
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return gaps;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// ============================================================
|
|
281
|
+
// 趋势强度评估
|
|
282
|
+
// ============================================================
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* 趋势强度评分(0-100)
|
|
286
|
+
* 综合多个维度评估当前趋势的强弱
|
|
287
|
+
*
|
|
288
|
+
* @param nav 净值序列
|
|
289
|
+
* @param period 评估周期(默认20天)
|
|
290
|
+
*/
|
|
291
|
+
export function trendStrength(nav: NavSeries, period: number = 20): number {
|
|
292
|
+
if (nav.length < period + 10) return 50;
|
|
293
|
+
|
|
294
|
+
const recent = nav.slice(-period);
|
|
295
|
+
const returns: number[] = [];
|
|
296
|
+
for (let i = 1; i < recent.length; i++) {
|
|
297
|
+
returns.push((recent[i] - recent[i - 1]) / recent[i - 1]);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// 1. 方向一致性(正收益天数占比)
|
|
301
|
+
const positiveDays = returns.filter((r) => r > 0).length;
|
|
302
|
+
const directionScore = (positiveDays / returns.length) * 100;
|
|
303
|
+
|
|
304
|
+
// 2. 线性回归拟合度 (R²)
|
|
305
|
+
const indices = returns.map((_, i) => i);
|
|
306
|
+
const meanY = returns.reduce((a, b) => a + b, 0) / returns.length;
|
|
307
|
+
const meanX = indices.reduce((a, b) => a + b, 0) / indices.length;
|
|
308
|
+
|
|
309
|
+
let ssXY = 0, ssXX = 0, ssYY = 0;
|
|
310
|
+
for (let i = 0; i < returns.length; i++) {
|
|
311
|
+
ssXY += (indices[i] - meanX) * (returns[i] - meanY);
|
|
312
|
+
ssXX += (indices[i] - meanX) ** 2;
|
|
313
|
+
ssYY += (returns[i] - meanY) ** 2;
|
|
314
|
+
}
|
|
315
|
+
const rSquared = ssXX > 0 && ssYY > 0 ? (ssXY * ssXY) / (ssXX * ssYY) : 0;
|
|
316
|
+
const fitScore = rSquared * 100;
|
|
317
|
+
|
|
318
|
+
// 3. 连续上涨/下跌天数
|
|
319
|
+
let maxStreak = 0;
|
|
320
|
+
let currentStreak = 0;
|
|
321
|
+
for (const r of returns) {
|
|
322
|
+
if (r > 0) { currentStreak++; maxStreak = Math.max(maxStreak, currentStreak); }
|
|
323
|
+
else currentStreak = 0;
|
|
324
|
+
}
|
|
325
|
+
const streakScore = Math.min(100, (maxStreak / returns.length) * 200);
|
|
326
|
+
|
|
327
|
+
// 综合评分(加权)
|
|
328
|
+
return directionScore * 0.4 + fitScore * 0.35 + streakScore * 0.25;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// ============================================================
|
|
332
|
+
// 头肩形态识别
|
|
333
|
+
// ============================================================
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* 头肩形态识别(简化版)
|
|
337
|
+
* @param nav 净值序列
|
|
338
|
+
* @param lookback 回看天数
|
|
339
|
+
* @returns 头肩顶/底形态信息
|
|
340
|
+
*/
|
|
341
|
+
export function headAndShoulders(
|
|
342
|
+
nav: NavSeries,
|
|
343
|
+
lookback: number = 90
|
|
344
|
+
): {
|
|
345
|
+
type: 'head_and_shoulders_top' | 'head_and_shoulders_bottom' | 'none';
|
|
346
|
+
leftShoulder: { index: number; value: number } | null;
|
|
347
|
+
head: { index: number; value: number } | null;
|
|
348
|
+
rightShoulder: { index: number; value: number } | null;
|
|
349
|
+
neckline: number | null;
|
|
350
|
+
confidence: number;
|
|
351
|
+
} {
|
|
352
|
+
const noResult = {
|
|
353
|
+
type: 'none' as const,
|
|
354
|
+
leftShoulder: null,
|
|
355
|
+
head: null,
|
|
356
|
+
rightShoulder: null,
|
|
357
|
+
neckline: null,
|
|
358
|
+
confidence: 0,
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
if (nav.length < lookback) return noResult;
|
|
362
|
+
const window = nav.slice(-lookback);
|
|
363
|
+
|
|
364
|
+
// 寻找局部极值
|
|
365
|
+
const peaks: { index: number; value: number }[] = [];
|
|
366
|
+
const troughs: { index: number; value: number }[] = [];
|
|
367
|
+
|
|
368
|
+
for (let i = 3; i < window.length - 3; i++) {
|
|
369
|
+
const isPeak = window[i] > window[i - 1] && window[i] > window[i + 1] &&
|
|
370
|
+
window[i] > window[i - 2] && window[i] > window[i + 2] &&
|
|
371
|
+
window[i] > window[i - 3] && window[i] > window[i + 3];
|
|
372
|
+
const isTrough = window[i] < window[i - 1] && window[i] < window[i + 1] &&
|
|
373
|
+
window[i] < window[i - 2] && window[i] < window[i + 2] &&
|
|
374
|
+
window[i] < window[i - 3] && window[i] < window[i + 3];
|
|
375
|
+
|
|
376
|
+
if (isPeak) peaks.push({ index: i, value: window[i] });
|
|
377
|
+
if (isTrough) troughs.push({ index: i, value: window[i] });
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// 检查头肩顶:三个峰,中间最高,两侧相近
|
|
381
|
+
if (peaks.length >= 3) {
|
|
382
|
+
for (let i = 0; i < peaks.length - 2; i++) {
|
|
383
|
+
const left = peaks[i];
|
|
384
|
+
const head = peaks[i + 1];
|
|
385
|
+
const right = peaks[i + 2];
|
|
386
|
+
|
|
387
|
+
// 头部必须高于两肩
|
|
388
|
+
if (head.value <= left.value || head.value <= right.value) continue;
|
|
389
|
+
|
|
390
|
+
// 两肩高度相近(容差5%)
|
|
391
|
+
const shoulderDiff = Math.abs(left.value - right.value) / left.value;
|
|
392
|
+
if (shoulderDiff > 0.05) continue;
|
|
393
|
+
|
|
394
|
+
// 头部高于肩部至少3%
|
|
395
|
+
const headPremium = (head.value - (left.value + right.value) / 2) / ((left.value + right.value) / 2);
|
|
396
|
+
if (headPremium < 0.03) continue;
|
|
397
|
+
|
|
398
|
+
// 颈线 = 两肩之间的最低点
|
|
399
|
+
const between = window.slice(left.index, right.index + 1);
|
|
400
|
+
const neckline = Math.min(...between);
|
|
401
|
+
|
|
402
|
+
const confidence = Math.min(1, (1 - shoulderDiff / 0.05) * 0.5 + Math.min(headPremium / 0.1, 1) * 0.5);
|
|
403
|
+
|
|
404
|
+
return {
|
|
405
|
+
type: 'head_and_shoulders_top',
|
|
406
|
+
leftShoulder: { index: nav.length - lookback + left.index, value: left.value },
|
|
407
|
+
head: { index: nav.length - lookback + head.index, value: head.value },
|
|
408
|
+
rightShoulder: { index: nav.length - lookback + right.index, value: right.value },
|
|
409
|
+
neckline,
|
|
410
|
+
confidence,
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// 检查头肩底(反转头肩)
|
|
416
|
+
if (troughs.length >= 3) {
|
|
417
|
+
for (let i = 0; i < troughs.length - 2; i++) {
|
|
418
|
+
const left = troughs[i];
|
|
419
|
+
const head = troughs[i + 1];
|
|
420
|
+
const right = troughs[i + 2];
|
|
421
|
+
|
|
422
|
+
if (head.value >= left.value || head.value >= right.value) continue;
|
|
423
|
+
|
|
424
|
+
const shoulderDiff = Math.abs(left.value - right.value) / left.value;
|
|
425
|
+
if (shoulderDiff > 0.05) continue;
|
|
426
|
+
|
|
427
|
+
const headDiscount = ((left.value + right.value) / 2 - head.value) / ((left.value + right.value) / 2);
|
|
428
|
+
if (headDiscount < 0.03) continue;
|
|
429
|
+
|
|
430
|
+
const between = window.slice(left.index, right.index + 1);
|
|
431
|
+
const neckline = Math.max(...between);
|
|
432
|
+
|
|
433
|
+
const confidence = Math.min(1, (1 - shoulderDiff / 0.05) * 0.5 + Math.min(headDiscount / 0.1, 1) * 0.5);
|
|
434
|
+
|
|
435
|
+
return {
|
|
436
|
+
type: 'head_and_shoulders_bottom',
|
|
437
|
+
leftShoulder: { index: nav.length - lookback + left.index, value: left.value },
|
|
438
|
+
head: { index: nav.length - lookback + head.index, value: head.value },
|
|
439
|
+
rightShoulder: { index: nav.length - lookback + right.index, value: right.value },
|
|
440
|
+
neckline,
|
|
441
|
+
confidence,
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
return noResult;
|
|
447
|
+
}
|