feat: 详情页K线增加技术指标 MA/MACD/RSI/KDJ

- 新增 indicators.ts 指标计算库:SMA/EMA/MACD(12,26,9)/RSI(14)/KDJ(9,3,3)
- KLineChart 支持指标开关:MA(5/10/20/30) 主图叠加、MACD/RSI/KDJ 独立副图 pane
- 图表高度随指标 pane 数量动态增长(主图 300 + 每个副图 95),主图不被压缩
- 指标全部前端本地计算,无需后端
This commit is contained in:
Sakurasan
2026-08-28 12:05:37 +08:00
parent 25c179dc06
commit 0743014f1a
3 changed files with 344 additions and 24 deletions
+139
View File
@@ -0,0 +1,139 @@
/**
* 技术指标计算(纯函数,前端本地计算)
* 输入 K 线数组(时间升序),输出与输入等长、前段为 null 的指标序列。
*/
export interface KBar {
time: string;
open: number;
close: number;
high: number;
low: number;
volume: number;
}
export type Nums = (number | null)[];
/** 简单移动平均 */
export function sma(closes: number[], period: number): Nums {
const out: Nums = new Array(closes.length).fill(null);
let sum = 0;
for (let i = 0; i < closes.length; i++) {
sum += closes[i];
if (i >= period) sum -= closes[i - period];
if (i >= period - 1) out[i] = sum / period;
}
return out;
}
/** EMA(标准 MACD 用的指数平滑) */
export function ema(values: number[], period: number): Nums {
const out: Nums = new Array(values.length).fill(null);
const k = 2 / (period + 1);
let prev: number | null = null;
for (let i = 0; i < values.length; i++) {
if (i === period - 1) {
// 首值取前 period 个的 SMA
let s = 0;
for (let j = 0; j < period; j++) s += values[j];
prev = s / period;
out[i] = prev;
} else if (prev !== null) {
prev = values[i] * k + prev * (1 - k);
out[i] = prev;
}
}
return out;
}
/** MACD(12,26,9):返回 DIF、DEA、MACD 柱(柱 = (DIF-DEA)*2,国内口径) */
export function macd(
closes: number[],
fast = 12,
slow = 26,
signal = 9,
): { dif: Nums; dea: Nums; hist: Nums } {
const ef = ema(closes, fast);
const es = ema(closes, slow);
const dif: Nums = closes.map((_, i) =>
ef[i] != null && es[i] != null ? (ef[i] as number) - (es[i] as number) : null,
);
// DEA = DIF 的 9 日 EMA(跳过 null)
const dea: Nums = new Array(closes.length).fill(null);
const difVals: number[] = [];
const difIdx: number[] = [];
for (let i = 0; i < dif.length; i++) {
if (dif[i] != null) {
difVals.push(dif[i] as number);
difIdx.push(i);
}
}
const deaVals = ema(difVals, signal);
for (let j = 0; j < difIdx.length; j++) {
if (deaVals[j] != null) dea[difIdx[j]] = deaVals[j];
}
const hist: Nums = closes.map((_, i) =>
dif[i] != null && dea[i] != null ? ((dif[i] as number) - (dea[i] as number)) * 2 : null,
);
return { dif, dea, hist };
}
/** RSI(Wilder 平滑,国内软件常用 SMA(X,N,1) 等价 Wilder) */
export function rsi(closes: number[], period = 14): Nums {
const out: Nums = new Array(closes.length).fill(null);
if (closes.length <= period) return out;
let avgGain = 0;
let avgLoss = 0;
// 首段:前 period 个变动的简单均值
for (let i = 1; i <= period; i++) {
const ch = closes[i] - closes[i - 1];
if (ch > 0) avgGain += ch;
else avgLoss -= ch;
}
avgGain /= period;
avgLoss /= period;
out[period] = avgLoss === 0 ? 100 : 100 - 100 / (1 + avgGain / avgLoss);
// Wilder 平滑
for (let i = period + 1; i < closes.length; i++) {
const ch = closes[i] - closes[i - 1];
const gain = ch > 0 ? ch : 0;
const loss = ch < 0 ? -ch : 0;
avgGain = (avgGain * (period - 1) + gain) / period;
avgLoss = (avgLoss * (period - 1) + loss) / period;
out[i] = avgLoss === 0 ? 100 : 100 - 100 / (1 + avgGain / avgLoss);
}
return out;
}
/** KDJ(9,3,3) */
export function kdj(
bars: KBar[],
n = 9,
m1 = 3,
m2 = 3,
): { k: Nums; d: Nums; j: Nums } {
const len = bars.length;
const k: Nums = new Array(len).fill(null);
const d: Nums = new Array(len).fill(null);
const j: Nums = new Array(len).fill(null);
let prevK = 50;
let prevD = 50;
for (let i = 0; i < len; i++) {
const from = Math.max(0, i - n + 1);
let hh = -Infinity;
let ll = Infinity;
for (let x = from; x <= i; x++) {
hh = Math.max(hh, bars[x].high);
ll = Math.min(ll, bars[x].low);
}
const rsv = hh === ll ? 50 : ((bars[i].close - ll) / (hh - ll)) * 100;
const kv = (2 * prevK + rsv) / m1; // 国内口径 K = 2/3 前值 + 1/3 RSV
const dv = (2 * prevD + kv) / m2;
prevK = kv;
prevD = dv;
k[i] = kv;
d[i] = dv;
j[i] = 3 * kv - 2 * dv;
}
return { k, d, j };
}