@stvy/fund-indicators 1.0.0 → 1.0.5

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 (52) hide show
  1. package/.github/workflows/publish.yml +73 -0
  2. package/AGENTS.md +322 -0
  3. package/dist/index.cjs +7014 -0
  4. package/dist/index.d.cts +779 -0
  5. package/dist/index.d.cts.map +1 -0
  6. package/dist/index.mjs +6917 -0
  7. package/package.json +15 -32
  8. package/pnpm-workspace.yaml +2 -0
  9. package/src/dca.ts +420 -0
  10. package/src/index.ts +133 -0
  11. package/src/jstat.d.ts +17 -0
  12. package/src/pattern.ts +447 -0
  13. package/src/risk.ts +516 -0
  14. package/src/statistics.ts +428 -0
  15. package/src/technical.ts +738 -0
  16. package/src/types.ts +369 -0
  17. package/test/index.test.ts +355 -0
  18. package/tsconfig.json +20 -0
  19. package/dist/browser/fund-indicators.esm.js +0 -7505
  20. package/dist/browser/fund-indicators.esm.min.js +0 -8
  21. package/dist/browser/fund-indicators.esm.min.js.map +0 -7
  22. package/dist/browser/fund-indicators.js +0 -7517
  23. package/dist/browser/fund-indicators.min.js +0 -8
  24. package/dist/browser/fund-indicators.min.js.map +0 -7
  25. package/dist/dca.d.ts +0 -91
  26. package/dist/dca.d.ts.map +0 -1
  27. package/dist/dca.js +0 -354
  28. package/dist/dca.js.map +0 -1
  29. package/dist/index.d.ts +0 -18
  30. package/dist/index.d.ts.map +0 -1
  31. package/dist/index.js +0 -141
  32. package/dist/index.js.map +0 -1
  33. package/dist/pattern.d.ts +0 -60
  34. package/dist/pattern.d.ts.map +0 -1
  35. package/dist/pattern.js +0 -386
  36. package/dist/pattern.js.map +0 -1
  37. package/dist/risk.d.ts +0 -115
  38. package/dist/risk.d.ts.map +0 -1
  39. package/dist/risk.js +0 -502
  40. package/dist/risk.js.map +0 -1
  41. package/dist/statistics.d.ts +0 -78
  42. package/dist/statistics.d.ts.map +0 -1
  43. package/dist/statistics.js +0 -402
  44. package/dist/statistics.js.map +0 -1
  45. package/dist/technical.d.ts +0 -105
  46. package/dist/technical.d.ts.map +0 -1
  47. package/dist/technical.js +0 -633
  48. package/dist/technical.js.map +0 -1
  49. package/dist/types.d.ts +0 -327
  50. package/dist/types.d.ts.map +0 -1
  51. package/dist/types.js +0 -7
  52. package/dist/types.js.map +0 -1
@@ -0,0 +1,738 @@
1
+ /**
2
+ * 技术指标模块 - 趋势、动量、震荡、通道类指标
3
+ * 基于 technicalindicators 库,所有函数接收净值序列作为输入
4
+ */
5
+
6
+ import {
7
+ SMA as TI_SMA,
8
+ EMA as TI_EMA,
9
+ WMA as TI_WMA,
10
+ MACD as TI_MACD,
11
+ RSI as TI_RSI,
12
+ Stochastic as TI_Stochastic,
13
+ BollingerBands as TI_BB,
14
+ ADX as TI_ADX,
15
+ CCI as TI_CCI,
16
+ ROC as TI_ROC,
17
+ WilliamsR as TI_WR,
18
+ StochasticRSI as TI_StochRSI,
19
+ ATR as TI_ATR,
20
+ PSAR as TI_SAR,
21
+ VWAP as TI_VWAP,
22
+ } from 'technicalindicators';
23
+
24
+ import {
25
+ NavSeries,
26
+ MAResult,
27
+ MACDResult,
28
+ BollingerResult,
29
+ ChannelResult,
30
+ RSIResult,
31
+ KDJResult,
32
+ ADXResult,
33
+ SARResult,
34
+ MACrossSignal,
35
+ MAAlignmentResult,
36
+ } from './types';
37
+
38
+ // ============================================================
39
+ // 辅助函数
40
+ // ============================================================
41
+
42
+ /** 将净值序列转为 high/low/close 格式(基金净值没有日内高低,三者相同) */
43
+ function navToHLC(nav: NavSeries) {
44
+ return nav.map((v) => ({ high: v, low: v, close: v }));
45
+ }
46
+
47
+ /** 对齐数组长度:前补 null 使其与输入等长 */
48
+ function padLeft(arr: (number | undefined)[], totalLen: number): (number | null)[] {
49
+ const padLen = totalLen - arr.length;
50
+ const result: (number | null)[] = new Array(padLen).fill(null);
51
+ for (const v of arr) {
52
+ result.push(v ?? null);
53
+ }
54
+ return result;
55
+ }
56
+
57
+ /** 安全取数组最后一个非 null 值 */
58
+ function lastNonNull(arr: (number | null | undefined)[]): number | null {
59
+ for (let i = arr.length - 1; i >= 0; i--) {
60
+ if (arr[i] != null) return arr[i]!;
61
+ }
62
+ return null;
63
+ }
64
+
65
+ // ============================================================
66
+ // 移动均线
67
+ // ============================================================
68
+
69
+ /** 简单移动平均线 (SMA) */
70
+ export function sma(nav: NavSeries, period: number): MAResult {
71
+ const raw = TI_SMA.calculate({ values: nav, period });
72
+ const values = padLeft(raw, nav.length);
73
+ return { values, current: lastNonNull(values), period, type: 'SMA' };
74
+ }
75
+
76
+ /** 指数移动平均线 (EMA) */
77
+ export function ema(nav: NavSeries, period: number): MAResult {
78
+ const raw = TI_EMA.calculate({ values: nav, period });
79
+ const values = padLeft(raw, nav.length);
80
+ return { values, current: lastNonNull(values), period, type: 'EMA' };
81
+ }
82
+
83
+ /** 加权移动平均线 (WMA) */
84
+ export function wma(nav: NavSeries, period: number): MAResult {
85
+ const raw = TI_WMA.calculate({ values: nav, period });
86
+ const values = padLeft(raw, nav.length);
87
+ return { values, current: lastNonNull(values), period, type: 'WMA' };
88
+ }
89
+
90
+ /** 双重指数移动平均线 (DEMA) */
91
+ export function dema(nav: NavSeries, period: number): MAResult {
92
+ // DEMA = 2*EMA - EMA(EMA)
93
+ const ema1 = TI_EMA.calculate({ values: nav, period });
94
+ const ema1Padded = padLeft(ema1, nav.length);
95
+
96
+ // 取非null部分做第二次EMA
97
+ const ema1Values = ema1.filter((v): v is number => v !== undefined);
98
+ const ema2 = TI_EMA.calculate({ values: ema1Values, period });
99
+
100
+ const values: (number | null)[] = new Array(nav.length).fill(null);
101
+ const offset = nav.length - ema1Values.length;
102
+ for (let i = 0; i < ema2.length; i++) {
103
+ const idx = offset + i;
104
+ if (idx < nav.length && ema2[i] != null && ema1Padded[idx] != null) {
105
+ values[idx] = 2 * ema1Padded[idx]! - ema2[i]!;
106
+ }
107
+ }
108
+ return { values, current: lastNonNull(values), period, type: 'DEMA' };
109
+ }
110
+
111
+ /** 三重指数移动平均线 (TEMA) */
112
+ export function tema(nav: NavSeries, period: number): MAResult {
113
+ const ema1Raw = TI_EMA.calculate({ values: nav, period });
114
+ const ema1Vals = ema1Raw.filter((v): v is number => v !== undefined);
115
+ const ema2Raw = TI_EMA.calculate({ values: ema1Vals, period });
116
+ const ema2Vals = ema2Raw.filter((v): v is number => v !== undefined);
117
+ const ema3Raw = TI_EMA.calculate({ values: ema2Vals, period });
118
+
119
+ // 将三层 EMA 全部填充到与 nav 等长
120
+ const ema1Padded = padLeft(ema1Raw, nav.length);
121
+ // ema2 的起始位置相对 ema1 又偏移了一截
122
+ const offset2 = nav.length - ema1Vals.length;
123
+ const ema2Full: (number | null)[] = new Array(nav.length).fill(null);
124
+ for (let i = 0; i < ema2Raw.length; i++) {
125
+ const idx = offset2 + i;
126
+ if (idx < nav.length) ema2Full[idx] = ema2Raw[i] ?? null;
127
+ }
128
+ // ema3 的起始位置相对 ema2 又偏移了一截
129
+ const offset3 = offset2 + (ema1Vals.length - ema2Vals.length);
130
+ const ema3Full: (number | null)[] = new Array(nav.length).fill(null);
131
+ for (let i = 0; i < ema3Raw.length; i++) {
132
+ const idx = offset3 + i;
133
+ if (idx < nav.length) ema3Full[idx] = ema3Raw[i] ?? null;
134
+ }
135
+
136
+ // TEMA = 3*EMA1 - 3*EMA2 + EMA3
137
+ const values: (number | null)[] = new Array(nav.length).fill(null);
138
+ for (let i = 0; i < nav.length; i++) {
139
+ const v1 = ema1Padded[i];
140
+ const v2 = ema2Full[i];
141
+ const v3 = ema3Full[i];
142
+ if (v1 != null && v2 != null && v3 != null) {
143
+ values[i] = 3 * v1 - 3 * v2 + v3;
144
+ }
145
+ }
146
+ return { values, current: lastNonNull(values), period, type: 'TEMA' };
147
+ }
148
+
149
+ /** 考夫曼自适应均线 (KAMA) - 手动实现 */
150
+ export function kama(nav: NavSeries, period: number = 10, fast: number = 2, slow: number = 30): MAResult {
151
+ const fastSC = 2 / (fast + 1);
152
+ const slowSC = 2 / (slow + 1);
153
+ const values: (number | null)[] = new Array(nav.length).fill(null);
154
+
155
+ if (nav.length <= period) return { values, current: null, period, type: 'KAMA' };
156
+
157
+ values[period] = nav[period];
158
+
159
+ for (let i = period + 1; i < nav.length; i++) {
160
+ // direction: 当前值与period前的值的绝对差
161
+ const direction = Math.abs(nav[i] - nav[i - period]);
162
+ // volatility: period内相邻差值绝对值之和
163
+ let volatility = 0;
164
+ for (let j = i - period + 1; j <= i; j++) {
165
+ volatility += Math.abs(nav[j] - nav[j - 1]);
166
+ }
167
+ // efficiency ratio
168
+ const er = volatility === 0 ? 0 : direction / volatility;
169
+ // smoothing constant
170
+ const sc = Math.pow(er * (fastSC - slowSC) + slowSC, 2);
171
+ // KAMA
172
+ const prevKama = values[i - 1]!;
173
+ values[i] = prevKama + sc * (nav[i] - prevKama);
174
+ }
175
+
176
+ return { values, current: lastNonNull(values), period, type: 'KAMA' };
177
+ }
178
+
179
+ // ============================================================
180
+ // MACD
181
+ // ============================================================
182
+
183
+ /**
184
+ * MACD 指标(DIF / DEA / 柱状线)
185
+ * @param nav 净值序列
186
+ * @param fastPeriod 快线周期(默认12)
187
+ * @param slowPeriod 慢线周期(默认26)
188
+ * @param signalPeriod 信号线周期(默认9)
189
+ */
190
+ export function macd(
191
+ nav: NavSeries,
192
+ fastPeriod: number = 12,
193
+ slowPeriod: number = 26,
194
+ signalPeriod: number = 9
195
+ ): MACDResult {
196
+ const raw = TI_MACD.calculate({
197
+ values: nav,
198
+ fastPeriod,
199
+ slowPeriod,
200
+ signalPeriod,
201
+ SimpleMAOscillator: false,
202
+ SimpleMASignal: false,
203
+ });
204
+
205
+ const dif = padLeft(raw.map((r) => r.MACD), nav.length);
206
+ const dea = padLeft(raw.map((r) => r.signal), nav.length);
207
+ const histogram = padLeft(raw.map((r) => r.histogram), nav.length);
208
+
209
+ return {
210
+ dif,
211
+ dea,
212
+ histogram,
213
+ currentDIF: lastNonNull(dif),
214
+ currentDEA: lastNonNull(dea),
215
+ currentHistogram: lastNonNull(histogram),
216
+ };
217
+ }
218
+
219
+ // ============================================================
220
+ // RSI
221
+ // ============================================================
222
+
223
+ /** RSI(相对强弱指标) */
224
+ export function rsi(nav: NavSeries, period: number = 14): RSIResult {
225
+ const raw = TI_RSI.calculate({ values: nav, period });
226
+ const values = padLeft(raw, nav.length);
227
+ return { values, current: lastNonNull(values), period };
228
+ }
229
+
230
+ // ============================================================
231
+ // KDJ(随机指标,含 J 值)
232
+ // ============================================================
233
+
234
+ /**
235
+ * KDJ 指标
236
+ * @param nav 净值序列
237
+ * @param kPeriod K 周期(默认9)
238
+ * @param kSmooth K 平滑因子(默认3)
239
+ * @param dPeriod D 周期(默认3)
240
+ */
241
+ export function kdj(nav: NavSeries, kPeriod: number = 9, kSmooth: number = 3, dPeriod: number = 3): KDJResult {
242
+ const hlc = navToHLC(nav);
243
+ const raw = TI_Stochastic.calculate({
244
+ high: hlc.map((h) => h.high),
245
+ low: hlc.map((h) => h.low),
246
+ close: hlc.map((h) => h.close),
247
+ period: kPeriod,
248
+ signalPeriod: dPeriod,
249
+ });
250
+
251
+ const kArr = padLeft(raw.map((r) => r.k), nav.length);
252
+ const dArr = padLeft(raw.map((r) => r.d), nav.length);
253
+
254
+ // J = 3K - 2D
255
+ const jArr: (number | null)[] = kArr.map((kVal, i) => {
256
+ const dVal = dArr[i];
257
+ if (kVal != null && dVal != null) return 3 * kVal - 2 * dVal;
258
+ return null;
259
+ });
260
+
261
+ return {
262
+ k: kArr,
263
+ d: dArr,
264
+ j: jArr,
265
+ currentK: lastNonNull(kArr),
266
+ currentD: lastNonNull(dArr),
267
+ currentJ: lastNonNull(jArr),
268
+ };
269
+ }
270
+
271
+ // ============================================================
272
+ // 布林带
273
+ // ============================================================
274
+
275
+ /**
276
+ * 布林带 (Bollinger Bands)
277
+ * @param nav 净值序列
278
+ * @param period 均线周期(默认20)
279
+ * @param stdDev 标准差倍数(默认2)
280
+ */
281
+ export function bollingerBands(nav: NavSeries, period: number = 20, stdDev: number = 2): BollingerResult {
282
+ const raw = TI_BB.calculate({ values: nav, period, stdDev });
283
+
284
+ const middle = padLeft(raw.map((r) => r.middle), nav.length);
285
+ const upper = padLeft(raw.map((r) => r.upper), nav.length);
286
+ const lower = padLeft(raw.map((r) => r.lower), nav.length);
287
+
288
+ // 计算带宽和 %B
289
+ const bandwidth: (number | null)[] = [];
290
+ const percentB: (number | null)[] = [];
291
+
292
+ for (let i = 0; i < nav.length; i++) {
293
+ const m = middle[i];
294
+ const u = upper[i];
295
+ const l = lower[i];
296
+ if (m != null && u != null && l != null) {
297
+ bandwidth.push((u - l) / m);
298
+ percentB.push(u === l ? 0.5 : (nav[i] - l) / (u - l));
299
+ } else {
300
+ bandwidth.push(null);
301
+ percentB.push(null);
302
+ }
303
+ }
304
+
305
+ return { middle, upper, lower, bandwidth, percentB };
306
+ }
307
+
308
+ // ============================================================
309
+ // 唐奇安通道 (Donchian Channel)
310
+ // ============================================================
311
+
312
+ /**
313
+ * 唐奇安通道
314
+ * @param nav 净值序列
315
+ * @param period 回看周期(默认20)
316
+ */
317
+ export function donchianChannel(nav: NavSeries, period: number = 20): ChannelResult {
318
+ const upper: (number | null)[] = [];
319
+ const lower: (number | null)[] = [];
320
+ const middle: (number | null)[] = [];
321
+
322
+ for (let i = 0; i < nav.length; i++) {
323
+ if (i < period - 1) {
324
+ upper.push(null);
325
+ lower.push(null);
326
+ middle.push(null);
327
+ continue;
328
+ }
329
+ const slice = nav.slice(i - period + 1, i + 1);
330
+ const high = Math.max(...slice);
331
+ const low = Math.min(...slice);
332
+ upper.push(high);
333
+ lower.push(low);
334
+ middle.push((high + low) / 2);
335
+ }
336
+
337
+ return { upper, lower, middle };
338
+ }
339
+
340
+ // ============================================================
341
+ // 肯特纳通道 (Keltner Channel)
342
+ // ============================================================
343
+
344
+ /**
345
+ * 肯特纳通道
346
+ * @param nav 净值序列
347
+ * @param emaPeriod EMA 周期(默认20)
348
+ * @param atrPeriod ATR 周期(默认10)
349
+ * @param multiplier ATR 倍数(默认2)
350
+ */
351
+ export function keltnerChannel(
352
+ nav: NavSeries,
353
+ emaPeriod: number = 20,
354
+ atrPeriod: number = 10,
355
+ multiplier: number = 2
356
+ ): ChannelResult {
357
+ const emaResult = ema(nav, emaPeriod);
358
+ const atrResult = atr(nav, atrPeriod);
359
+
360
+ const upper: (number | null)[] = [];
361
+ const lower: (number | null)[] = [];
362
+
363
+ for (let i = 0; i < nav.length; i++) {
364
+ const e = emaResult.values[i];
365
+ const a = atrResult.values[i];
366
+ if (e != null && a != null) {
367
+ upper.push(e + multiplier * a);
368
+ lower.push(e - multiplier * a);
369
+ } else {
370
+ upper.push(null);
371
+ lower.push(null);
372
+ }
373
+ }
374
+
375
+ return { upper, lower, middle: emaResult.values };
376
+ }
377
+
378
+ // ============================================================
379
+ // ADX(平均趋向指标)
380
+ // ============================================================
381
+
382
+ /** ADX + DI */
383
+ export function adx(nav: NavSeries, period: number = 14): ADXResult {
384
+ const hlc = navToHLC(nav);
385
+ const raw = TI_ADX.calculate({
386
+ high: hlc.map((h) => h.high),
387
+ low: hlc.map((h) => h.low),
388
+ close: hlc.map((h) => h.close),
389
+ period,
390
+ });
391
+
392
+ const adxArr = padLeft(raw.map((r) => r.adx), nav.length);
393
+ const plusDI = padLeft(raw.map((r) => r.pdi), nav.length);
394
+ const minusDI = padLeft(raw.map((r) => r.mdi), nav.length);
395
+
396
+ return {
397
+ adx: adxArr,
398
+ plusDI,
399
+ minusDI,
400
+ currentADX: lastNonNull(adxArr),
401
+ };
402
+ }
403
+
404
+ // ============================================================
405
+ // ATR(平均真实波幅 - 基于净值近似)
406
+ // ============================================================
407
+
408
+ /** ATR 近似值(基金净值无日内高低,用日涨跌幅绝对值近似) */
409
+ export function atr(nav: NavSeries, period: number = 14): MAResult {
410
+ const hlc = navToHLC(nav);
411
+ const raw = TI_ATR.calculate({
412
+ high: hlc.map((h) => h.high),
413
+ low: hlc.map((h) => h.low),
414
+ close: hlc.map((h) => h.close),
415
+ period,
416
+ });
417
+
418
+ // 由于净值无日内波动,ATR 退化为 EMA(|ΔP|)
419
+ // 这里直接用技术库输出
420
+ const values = padLeft(raw, nav.length);
421
+ return { values, current: lastNonNull(values), period, type: 'SMA' };
422
+ }
423
+
424
+ // ============================================================
425
+ // CCI(商品通道指数)
426
+ // ============================================================
427
+
428
+ /** CCI */
429
+ export function cci(nav: NavSeries, period: number = 20): MAResult {
430
+ const hlc = navToHLC(nav);
431
+ const raw = TI_CCI.calculate({
432
+ high: hlc.map((h) => h.high),
433
+ low: hlc.map((h) => h.low),
434
+ close: hlc.map((h) => h.close),
435
+ period,
436
+ });
437
+
438
+ const values = padLeft(raw, nav.length);
439
+ return { values, current: lastNonNull(values), period, type: 'SMA' };
440
+ }
441
+
442
+ // ============================================================
443
+ // ROC(变动率)
444
+ // ============================================================
445
+
446
+ /** ROC */
447
+ export function roc(nav: NavSeries, period: number = 12): MAResult {
448
+ const raw = TI_ROC.calculate({ values: nav, period });
449
+ const values = padLeft(raw, nav.length);
450
+ return { values, current: lastNonNull(values), period, type: 'SMA' };
451
+ }
452
+
453
+ // ============================================================
454
+ // 动量指标 (Momentum)
455
+ // ============================================================
456
+
457
+ /** 动量指标 = 当前净值 - N日前净值 */
458
+ export function momentum(nav: NavSeries, period: number = 10): MAResult {
459
+ const values: (number | null)[] = [];
460
+ for (let i = 0; i < nav.length; i++) {
461
+ if (i < period) {
462
+ values.push(null);
463
+ } else {
464
+ values.push(nav[i] - nav[i - period]);
465
+ }
466
+ }
467
+ return { values, current: lastNonNull(values), period, type: 'SMA' };
468
+ }
469
+
470
+ // ============================================================
471
+ // Williams %R(威廉指标)
472
+ // ============================================================
473
+
474
+ /** Williams %R */
475
+ export function williamsR(nav: NavSeries, period: number = 14): MAResult {
476
+ const hlc = navToHLC(nav);
477
+ const raw = TI_WR.calculate({
478
+ high: hlc.map((h) => h.high),
479
+ low: hlc.map((h) => h.low),
480
+ close: hlc.map((h) => h.close),
481
+ period,
482
+ });
483
+
484
+ const values = padLeft(raw, nav.length);
485
+ return { values, current: lastNonNull(values), period, type: 'SMA' };
486
+ }
487
+
488
+ // ============================================================
489
+ // Stochastic RSI
490
+ // ============================================================
491
+
492
+ /** 随机 RSI */
493
+ export function stochasticRSI(
494
+ nav: NavSeries,
495
+ rsiPeriod: number = 14,
496
+ stochPeriod: number = 14,
497
+ kPeriod: number = 3,
498
+ dPeriod: number = 3
499
+ ): KDJResult {
500
+ const raw = TI_StochRSI.calculate({
501
+ values: nav,
502
+ rsiPeriod,
503
+ stochasticPeriod: stochPeriod,
504
+ kPeriod,
505
+ dPeriod,
506
+ });
507
+
508
+ const k = padLeft(raw.map((r) => r.k), nav.length);
509
+ const d = padLeft(raw.map((r) => r.d), nav.length);
510
+ const j: (number | null)[] = k.map((kVal, i) => {
511
+ const dVal = d[i];
512
+ return kVal != null && dVal != null ? 3 * kVal - 2 * dVal : null;
513
+ });
514
+
515
+ return { k, d, j, currentK: lastNonNull(k), currentD: lastNonNull(d), currentJ: lastNonNull(j) };
516
+ }
517
+
518
+ // ============================================================
519
+ // SAR(抛物线转向)
520
+ // ============================================================
521
+
522
+ /**
523
+ * SAR 抛物线转向
524
+ * @param nav 净值序列
525
+ * @param step 加速因子步长(默认0.02)
526
+ * @param max 最大加速因子(默认0.2)
527
+ */
528
+ export function sar(nav: NavSeries, step: number = 0.02, max: number = 0.2): SARResult {
529
+ const hlc = navToHLC(nav);
530
+ const raw = TI_SAR.calculate({
531
+ high: hlc.map((h) => h.high),
532
+ low: hlc.map((h) => h.low),
533
+ step,
534
+ max,
535
+ });
536
+
537
+ const values = padLeft(raw, nav.length);
538
+ return { values, current: lastNonNull(values) };
539
+ }
540
+
541
+ // ============================================================
542
+ // TRIX(三重平滑指数)
543
+ // ============================================================
544
+
545
+ /** TRIX = 三重 EMA 的变化率 */
546
+ export function trix(nav: NavSeries, period: number = 12): MAResult {
547
+ const ema1 = TI_EMA.calculate({ values: nav, period });
548
+ const ema1Vals = ema1.filter((v): v is number => v !== undefined);
549
+ const ema2 = TI_EMA.calculate({ values: ema1Vals, period });
550
+ const ema2Vals = ema2.filter((v): v is number => v !== undefined);
551
+ const ema3 = TI_EMA.calculate({ values: ema2Vals, period });
552
+
553
+ const values: (number | null)[] = new Array(nav.length).fill(null);
554
+ const offset = nav.length - ema3.length;
555
+
556
+ for (let i = 1; i < ema3.length; i++) {
557
+ const prev = ema3[i - 1];
558
+ const curr = ema3[i];
559
+ if (prev != null && curr != null && prev !== 0) {
560
+ values[offset + i] = ((curr - prev) / prev) * 100;
561
+ }
562
+ }
563
+
564
+ return { values, current: lastNonNull(values), period, type: 'SMA' };
565
+ }
566
+
567
+ // ============================================================
568
+ // DPO(去趋势价格震荡器)
569
+ // ============================================================
570
+
571
+ /** DPO = 净值 - N/2+1 日前的 SMA */
572
+ export function dpo(nav: NavSeries, period: number = 20): MAResult {
573
+ const smaResult = sma(nav, period);
574
+ const shift = Math.floor(period / 2) + 1;
575
+ const values: (number | null)[] = [];
576
+
577
+ for (let i = 0; i < nav.length; i++) {
578
+ const smaIdx = i + shift;
579
+ if (smaIdx < nav.length && smaResult.values[smaIdx] != null) {
580
+ values.push(nav[i] - smaResult.values[smaIdx]!);
581
+ } else {
582
+ values.push(null);
583
+ }
584
+ }
585
+
586
+ return { values, current: lastNonNull(values), period, type: 'SMA' };
587
+ }
588
+
589
+ // ============================================================
590
+ // 均线偏离度 (BIAS / 乖离率)
591
+ // ============================================================
592
+
593
+ /** 乖离率 = (净值 - MA) / MA * 100 */
594
+ export function bias(nav: NavSeries, period: number = 20): MAResult {
595
+ const maResult = sma(nav, period);
596
+ const values: (number | null)[] = [];
597
+
598
+ for (let i = 0; i < nav.length; i++) {
599
+ const m = maResult.values[i];
600
+ if (m != null && m !== 0) {
601
+ values.push(((nav[i] - m) / m) * 100);
602
+ } else {
603
+ values.push(null);
604
+ }
605
+ }
606
+
607
+ return { values, current: lastNonNull(values), period, type: 'SMA' };
608
+ }
609
+
610
+ // ============================================================
611
+ // 净值百分位
612
+ // ============================================================
613
+
614
+ /**
615
+ * 净值百分位 - 当前净值在历史区间中所处的百分位
616
+ * @param nav 净值序列
617
+ * @param lookback 回看窗口(默认全部历史)
618
+ * @returns 0-100 的百分位值
619
+ */
620
+ export function navPercentile(nav: NavSeries, lookback?: number): number {
621
+ const window = lookback ? nav.slice(-lookback) : nav;
622
+ const current = nav[nav.length - 1];
623
+ const sorted = [...window].sort((a, b) => a - b);
624
+ let rank = 0;
625
+ for (const v of sorted) {
626
+ if (v < current) rank++;
627
+ else if (v === current) rank += 0.5;
628
+ }
629
+ return (rank / sorted.length) * 100;
630
+ }
631
+
632
+ // ============================================================
633
+ // 均线交叉信号检测
634
+ // ============================================================
635
+
636
+ /** 检测短期均线与长期均线的交叉信号 */
637
+ export function detectCrossSignal(
638
+ fastMA: MAResult,
639
+ slowMA: MAResult,
640
+ lookback: number = 5
641
+ ): MACrossSignal {
642
+ const fVals = fastMA.values;
643
+ const sVals = slowMA.values;
644
+ const len = Math.min(fVals.length, sVals.length);
645
+
646
+ for (let i = len - 1; i >= Math.max(1, len - lookback); i--) {
647
+ const fCurr = fVals[i];
648
+ const fPrev = fVals[i - 1];
649
+ const sCurr = sVals[i];
650
+ const sPrev = sVals[i - 1];
651
+ if (fCurr == null || fPrev == null || sCurr == null || sPrev == null) continue;
652
+
653
+ // 金叉:快线从下方穿越慢线
654
+ if (fPrev <= sPrev && fCurr > sCurr) {
655
+ return { type: 'golden_cross', index: i, fastValue: fCurr, slowValue: sCurr };
656
+ }
657
+ // 死叉:快线从上方穿越慢线
658
+ if (fPrev >= sPrev && fCurr < sCurr) {
659
+ return { type: 'death_cross', index: i, fastValue: fCurr, slowValue: sCurr };
660
+ }
661
+ }
662
+
663
+ return { type: 'none', index: -1, fastValue: 0, slowValue: 0 };
664
+ }
665
+
666
+ // ============================================================
667
+ // 均线排列状态
668
+ // ============================================================
669
+
670
+ /**
671
+ * 检测均线多空排列
672
+ * @param maList 从短期到长期排列的均线结果列表
673
+ */
674
+ export function detectMAAlignment(maList: MAResult[]): MAAlignmentResult {
675
+ const maValues = maList.map((m) => m.current ?? 0);
676
+ const allValid = maList.every((m) => m.current != null);
677
+
678
+ if (!allValid) {
679
+ return { alignment: 'neutral', maValues, divergence: 0 };
680
+ }
681
+
682
+ // 多头排列:短期 > 长期(递减)
683
+ let isBullish = true;
684
+ let isBearish = true;
685
+ for (let i = 1; i < maValues.length; i++) {
686
+ if (maValues[i] >= maValues[i - 1]) isBullish = false;
687
+ if (maValues[i] <= maValues[i - 1]) isBearish = false;
688
+ }
689
+
690
+ // 计算发散度(标准差)
691
+ const mean = maValues.reduce((a, b) => a + b, 0) / maValues.length;
692
+ const variance = maValues.reduce((sum, v) => sum + (v - mean) ** 2, 0) / maValues.length;
693
+ const divergence = Math.sqrt(variance);
694
+
695
+ const alignment = isBullish ? 'bullish' : isBearish ? 'bearish' : 'neutral';
696
+ return { alignment, maValues, divergence };
697
+ }
698
+
699
+ // ============================================================
700
+ // Mass Index(质量指数)
701
+ // ============================================================
702
+
703
+ /**
704
+ * Mass Index - 基于 EMA 的波动范围比率累积,用于识别变盘点
705
+ * @param nav 净值序列
706
+ * @param emaPeriod EMA 周期(默认9)
707
+ * @param sumPeriod 累积周期(默认25)
708
+ */
709
+ export function massIndex(nav: NavSeries, emaPeriod: number = 9, sumPeriod: number = 25): MAResult {
710
+ // 由于净值没有 high/low,使用 EMA 的变化幅度近似
711
+ const ema1 = TI_EMA.calculate({ values: nav, period: emaPeriod });
712
+ const ema1Vals = ema1.filter((v): v is number => v !== undefined);
713
+ const ema2 = TI_EMA.calculate({ values: ema1Vals, period: emaPeriod });
714
+
715
+ const ratios: number[] = [];
716
+ for (let i = 0; i < ema2.length; i++) {
717
+ const e1 = ema1Vals[i + (ema1Vals.length - ema2.length)];
718
+ const e2 = ema2[i];
719
+ if (e1 != null && e2 != null && e2 !== 0) {
720
+ ratios.push(e1 / e2);
721
+ } else {
722
+ ratios.push(1);
723
+ }
724
+ }
725
+
726
+ const values: (number | null)[] = new Array(nav.length).fill(null);
727
+ const offset = nav.length - ratios.length;
728
+
729
+ for (let i = sumPeriod - 1; i < ratios.length; i++) {
730
+ let sum = 0;
731
+ for (let j = i - sumPeriod + 1; j <= i; j++) {
732
+ sum += ratios[j];
733
+ }
734
+ values[offset + i] = sum;
735
+ }
736
+
737
+ return { values, current: lastNonNull(values), period: sumPeriod, type: 'SMA' };
738
+ }