完善知行短线

This commit is contained in:
2026-08-17 00:47:13 +08:00
parent 5029a22de1
commit 6ef8c585d6
6 changed files with 668 additions and 0 deletions

View File

@@ -0,0 +1,132 @@
// 个股筹码峰Tushare cyq_chips / cyq_perf同花顺式右侧横向线状直方图
//
// 数据不在图表窗口上计算,而是一次性截面(某交易日各价位筹码占比 + 获利比例/平均成本),
// 经 extendData 注入、纯 draw 绘制:
// - 每个价位一根横线,从主图右缘向左延伸,宽度 ∝ 占比/最大占比(火焰山形态)
// - 分界价 = 选中K线的收盘价refClose下方为获利盘红线、上方为套牢盘蓝线
// - 平均成本画黄色虚线;右上角小字块:截面日期 / 获利比例 / 平均成本 / 90% 成本区间
// figures 为空、calc 返回空行:指标不产生序列值,也不影响主图价格刻度(筹码是覆盖层)。
import type { IndicatorTemplate } from 'klinecharts';
export const CHIPS_NAME = 'pv-chips';
/** 经 extendData 注入的筹码截面(父组件按 当前窗口末根/十字线 所在周期取数) */
export interface ChipsExt {
rows: { price: number; percent: number }[]; // percent 为 0~1 占比
date?: string; // YYYYMMDD 截面日
refClose?: number | null; // 获利/套牢分界价选中K线收盘缺省时 draw 退回图表末根收盘)
winner?: number | null; // 获利比例 %
avg?: number | null; // 平均成本
costLow?: number | null; // 90% 成本下沿cost_5pct
costHigh?: number | null; // 90% 成本上沿cost_95pct
error?: string | null; // 无数据/出错的提示文案(仍要画出来,用户才知道为何空)
}
// 同花顺筹码峰配色:获利盘红、套牢盘蓝
const UP = '#FE354B';
const DOWN = '#2F7BFF';
const AVG_COLOR = '#F5C518';
const LABEL_COLOR = '#9AA0AA';
const VALUE_COLOR = '#E8EAED';
/** 同花顺筹码峰同款横线最大占主图宽度的比例与上限px */
const MAX_WIDTH_RATIO = 0.22;
const MAX_WIDTH_PX = 150;
/** 横条高度上限px极度放大时单价位占比条不至于铺满半个主图 */
const BAR_HEIGHT_CAP = 24;
function fmtDate(d?: string): string {
return d && d.length === 8 ? `${d.slice(0, 4)}-${d.slice(4, 6)}-${d.slice(6, 8)}` : '—';
}
export const chipsIndicator: IndicatorTemplate<Record<string, never>, unknown, ChipsExt> = {
name: CHIPS_NAME,
shortName: '筹码',
figures: [], // 纯 draw 绘制,无序列图形
calc: (d) => d.map(() => ({})),
// 返回 trueisCover跳过框架 figure 渲染(本来也没有 figures全部自己画
draw: ({ ctx, chart, indicator, bounding, yAxis }) => {
const ext = indicator.extendData;
if (!ext) return true;
// 右上角统计小字块(画线工具栏是 HTML 层,占顶部 ~36px文字块从 44px 起)
const lines: { label: string; value: string; color: string }[] = [];
if (ext.error) {
lines.push({ label: '筹码', value: ext.error, color: LABEL_COLOR });
} else if (ext.rows.length) {
const w = ext.winner;
lines.push({ label: `筹码 ${fmtDate(ext.date)}`, value: '', color: LABEL_COLOR });
lines.push({
label: '获利比例',
value: w == null ? '—' : `${w.toFixed(2)}%`,
color: w == null ? VALUE_COLOR : w >= 50 ? UP : DOWN,
});
lines.push({ label: '平均成本', value: ext.avg == null ? '—' : ext.avg.toFixed(2), color: AVG_COLOR });
lines.push({
label: '90%成本',
value: ext.costLow == null || ext.costHigh == null ? '—' : `${ext.costLow.toFixed(2)}~${ext.costHigh.toFixed(2)}`,
color: VALUE_COLOR,
});
}
if (lines.length) {
ctx.font = '11px sans-serif';
ctx.textBaseline = 'top';
ctx.textAlign = 'right';
let y = bounding.top + 44;
for (const ln of lines) {
const text = ln.value ? `${ln.label} ${ln.value}` : ln.label;
ctx.fillStyle = ln.color;
ctx.fillText(text, bounding.right - 4, y);
y += 14;
}
}
if (!ext.rows.length) return true;
// 分界价选中K线的收盘价父组件随截面注入缺省退回图表末根收盘
// <=分界价为获利盘(红)、>分界价为套牢盘(蓝)——同花顺同款
const list = chart.getDataList();
const close = ext.refClose ?? (list.length ? list[list.length - 1].close : undefined);
// 价位升序,便于求相邻像素间距(直方图的“条高”)
const rows = [...ext.rows].sort((a, b) => a.price - b.price);
const maxP = rows.reduce((m, r) => Math.max(m, r.percent), 0);
if (maxP <= 0) return true;
const maxW = Math.min(bounding.width * MAX_WIDTH_RATIO, MAX_WIDTH_PX);
// 相邻价位的中位像素间距 → 条高(连续火焰形态;缩到极小时并成实心轮廓)
const pitches: number[] = [];
for (let i = 1; i < rows.length; i++) {
const p = Math.abs(yAxis.convertToPixel(rows[i].price) - yAxis.convertToPixel(rows[i - 1].price));
if (p > 0) pitches.push(p);
}
pitches.sort((a, b) => a - b);
const pitch = pitches.length ? pitches[pitches.length >> 1] : 4;
const barH = Math.min(BAR_HEIGHT_CAP, Math.max(1, pitch * 0.92));
for (const r of rows) {
const y = yAxis.convertToPixel(r.price);
if (y < bounding.top - barH || y > bounding.bottom + barH) continue; // 视口外跳过
const w = Math.max(1, (r.percent / maxP) * maxW);
ctx.fillStyle = close == null ? 'rgba(154,160,170,0.5)'
: r.price <= close ? 'rgba(254,53,75,0.55)' : 'rgba(47,123,255,0.55)';
ctx.fillRect(bounding.right - w, y - barH / 2, w, barH);
}
// 平均成本虚线(横贯直方图区)
if (ext.avg != null) {
const y = yAxis.convertToPixel(ext.avg);
if (y >= bounding.top && y <= bounding.bottom) {
ctx.save();
ctx.strokeStyle = AVG_COLOR;
ctx.lineWidth = 1;
ctx.setLineDash([4, 3]);
ctx.beginPath();
ctx.moveTo(bounding.right - maxW, y + 0.5);
ctx.lineTo(bounding.right, y + 0.5);
ctx.stroke();
ctx.restore();
}
}
return true;
},
};

View File

@@ -0,0 +1,23 @@
// 自定义指标注册表:通达信导入的主图指标都在这里追加一项。
// 依赖预计算序列的指标(知行)不在此处闭包数据,而是导出模板工厂、由 DetailKLine 注入
// 按时间戳取值的访问器g(),与 pv-boll 等后端序列同款管道)。注意 g 及其闭包的
// PV/allData 是组件实例级的script setup 每次挂载重建),知行模板必须随挂载重注册
// 换绑到当前实例——否则永远读首挂实例的数据,切股后画的是第一只股票的线;
// 筹码峰等“截面数据 + 纯 draw”指标不闭包实例数据经 extendData 注入),注册一次即可。
import { registerIndicator } from 'klinecharts';
import { chipsIndicator } from './chips';
import { makeZhixingIndicator, ZHIXING_NAME, type ZhixingGetter } from './zhixing';
let staticRegistered = false;
export function registerCustomIndicators(get: ZhixingGetter) {
// 知行:同名重注册即替换,把 calc 换绑到本次挂载实例的 g与 DetailKLine 内
// pv-boll/pv-macd 每挂必注册同款;幂等闸曾导致知行只认第一只股票)
registerIndicator(makeZhixingIndicator(get));
if (staticRegistered) return;
staticRegistered = true;
registerIndicator(chipsIndicator);
}
export { CHIPS_NAME, type ChipsExt } from './chips';
export { ZHIXING_NAME, ZHIXING_TREND_COLOR, DKX_PERIODS, computeZhixingSeries } from './zhixing';

View File

@@ -0,0 +1,173 @@
# 知行指标移植文档(通达信 → 任意平台)
> 供后续导入到其他项目/平台使用的完整规格:公式原文、参数、数值口径、参考实现、
> 校验向量、显示规范。框架无关部分可直接拷贝klinecharts v10 集成见第八节。
> 本仓库实现:`zhixing.ts`(算法+模板)/ `index.ts`(注册表)/ `DetailKLine.vue`(数据管道)。
## 一、原始通达信公式
```
知行短期趋势线:EMA(EMA(C,10),10),COLORE61851,LINETHICK1;
MA1:=MA(CLOSE,60);
MA2:=EMA(CLOSE,13);
Z1:=STRCAT(HYBLOCK,' ');
Z2:=STRCAT(Z1,DYBLOCK);
Z3:=STRCAT(Z2,' ');
DRAWTEXT_FIX(ISLASTBAR,0,0,0,STRCAT(Z3,GNBLOCK)),COLOR00C0C0;
知行多空线:(MA(CLOSE,M1)+MA(CLOSE,M2)+MA(CLOSE,M3)+MA(CLOSE,M4))/4;
```
**参数表(通达信公式管理器里配置,公式文本不带——移植时最容易丢的就是这个):**
| 参数 | 缺省值 |
|------|--------|
| M1 | 14 |
| M2 | 28 |
| M3 | 57 |
| M4 | 114 |
## 二、逐行语义
| 行 | 作用 | 移植处理 |
|----|------|----------|
| 知行短期趋势线 | EMA 嵌套两层,主图线 | 画线,#E61851 |
| MA1 / MA2 | `:=` 隐藏赋值,且未被后续引用 → 死代码 | 不画 |
| Z1~Z3 + DRAWTEXT_FIX | 左上角固定文本:行业板块+地域板块+概念板块(通达信本地板块数据) | 无本地板块数据源,用「行业+地域」近似做成图例行;概念板块缺失 |
| 知行多空线 | 4 条 MA 的等权平均,主图线 | 画线,周期 14/28/57/114 |
## 三、数值口径(与通达信逐位一致)
**EMA通达信式**`E[i] = (2·X[i] + (N1)·E[i1]) / (N+1)`,首根以自身为种子
`E[0] = X[0]`)。等价于系数 `k = 2/(N+1)``E[i] = k·X[i] + (1k)·E[i1]`
无限记忆,但衰减因子 `(N1)/(N+1)`N=10 时 ≈0.82/根),截断起点的影响几十根后可忽略。
**MA通达信式严格窗口**窗口不满输出空值null/NaN**不是** min_periods=1 的渐进均值——
很多图表库内置 MA 是渐进口径,直接用会整条线偏掉。
**多空线**`dkx[i] = (MA14[i] + MA28[i] + MA57[i] + MA114[i]) / 4`,四个 MA 任一为空则为空。
即前 113 根没有多空线(通达信从上市日起算,同样如此)。
**复权口径**:序列必须与图表展示用同一复权基准(前复权/不复权)的收盘价计算,否则数值对不上。
## 四、参考实现(框架无关 TypeScript可直接拷贝
```ts
export const DKX_PERIODS = [14, 28, 57, 114];
function ema(src: number[], n: number): number[] {
const out = new Array<number>(src.length);
const k = 2 / (n + 1);
let prev = NaN;
for (let i = 0; i < src.length; i++) {
prev = Number.isNaN(prev) ? src[i] : src[i] * k + prev * (1 - k);
out[i] = prev;
}
return out;
}
function sma(src: number[], n: number): (number | null)[] {
const out = new Array<number | null>(src.length).fill(null);
let sum = 0;
for (let i = 0; i < src.length; i++) {
sum += src[i];
if (i >= n) sum -= src[i - n];
if (i >= n - 1) out[i] = sum / n;
}
return out;
}
/** closes 为整段历史收盘价(建议从上市日或至少 ≥ 需求长度+114 根起) */
export function computeZhixingSeries(closes: number[]) {
const trend = ema(ema(closes, 10), 10);
const mas = DKX_PERIODS.map((p) => sma(closes, p));
const dkx = new Array<number | null>(closes.length).fill(null);
for (let i = 0; i < closes.length; i++) {
const m = mas.map((a) => a[i]);
if (m.every((v) => v != null)) dkx[i] = m.reduce((s, v) => s + (v as number), 0) / m.length;
}
return { trend, dkx };
}
```
## 五、参考实现Python / numpy
```python
import numpy as np
DKX_PERIODS = (14, 28, 57, 114)
def ema_tdx(x: np.ndarray, n: int) -> np.ndarray:
out = np.empty(len(x))
k = 2.0 / (n + 1)
prev = np.nan
for i, v in enumerate(x):
prev = v if np.isnan(prev) else v * k + prev * (1 - k)
out[i] = prev
return out
# pandas 等价写法adjust=False 且首根种子=自身,与通达信一致):
# s.ewm(span=n, adjust=False).mean()
def sma_tdx(x: np.ndarray, n: int) -> np.ndarray:
c = np.concatenate(([0.0], np.cumsum(x)))
out = np.full(len(x), np.nan)
out[n - 1:] = (c[n:] - c[:-n]) / n # 窗口不满保持 NaN
return out
def zhixing(close: np.ndarray):
trend = ema_tdx(ema_tdx(close.astype(float), 10), 10)
stack = np.vstack([sma_tdx(close.astype(float), p) for p in DKX_PERIODS])
dkx = np.full(len(close), np.nan)
ok = ~np.isnan(stack).any(axis=0)
dkx[ok] = stack[:, ok].mean(axis=0)
return trend, dkx
```
## 六、校验向量(移植后跑一遍,全对即口径正确)
**趋势线** `closes = [10,11,12,13,14,15]`
```
EMA(C,10) = 10.000000, 10.181818, 10.512397, 10.964688, 11.516563, 12.149915
EMA(EMA(C,10),10) = 10.000000, 10.033058, 10.120210, 10.273752, 10.499717, 10.799753
```
**MA 严格窗口** `sma([1,2,3,4,5,6], 3)`
```
null, null, 2.000000, 3.000000, 4.000000, 5.000000
```
**多空线预热** `closes[i] = i+1`线性爬坡116 根):索引 0~112 全为 null
索引 113 起有值,闭式解 `dkx[i] = (i+1) (13+27+56+113)/8 = i+1 26.125`
```
dkx[113] = 87.875000 dkx[114] = 88.875000 dkx[115] = 89.875000
```
## 七、数据与预热要求
- MA114 需预热 113 根:**喂给算法的历史长度 = 可视区最左端再往左 ≥113 根**。
分页加载的图表不要只算当前窗口(首屏窗口常见 240 根,左边会缺一大段),
应在「全部已加载历史」上整段计算,左滑前插后整体重算。
- EMA 理论上要上市日起算;实际截断到几百根即可(衰减 0.82/根60 根后残差 < 1e5
- 建议 closes 传入前先按 timestamp 升序、剔除停牌空K。
## 八、klinecharts v10 集成要点(本仓库方案)
- 注册:`registerIndicator(makeZhixingIndicator(get))`,模板工厂注入「(key, timestamp) → 预计算值」
访问器;序列由 DetailKLine 在 allData 上预计算写入 `PV['zx_trend']/PV['zx_dkx']`
`calc` 里按 bar.timestamp 取值(**不要用图表相对索引**,翻页后会错位)。
- 回调参数的 `Indicator<D>` 不带 E 泛型,`extendData` 是 unknown取值需 `as` 收窄。
- `createTooltipDataSource``legends` 会整体替换默认图例行,线值图例须自己拼回;
`calcParams: DKX_PERIODS` 让图例显示「知行(14,28,57,114)」(通达信式)。
- 长周期指标 + 分页窗口的完整坑位说明见
`memory/custom-indicator-framework.md`
## 九、移植检查清单
1. M1~M4 = **14/28/57/114**(不是 BBI 的 3/6/12/24
2. EMA 首根种子=自身MA 窗口不满=null两条都与常见库默认不同
3. 计算输入与显示用同一复权基准的收盘价
4. 预热 ≥113 根覆盖到可视区最左端之外
5. 跑第六节校验向量,逐位一致
6. 颜色:趋势线 #E61851、多空线 #F5C518、板块文本 #00C0C0

View File

@@ -0,0 +1,109 @@
// 通达信《知行》主图指标前端移植
//
// 知行短期趋势线:EMA(EMA(C,10),10),COLORE61851,LINETHICK1;
// 知行多空线:(MA(CLOSE,M1)+MA(CLOSE,M2)+MA(CLOSE,M3)+MA(CLOSE,M4))/4;
//
// - M1~M4 = 14/28/57/114用户通达信公式参数表里的缺省值公式文本不带
// - 原文 MA1:=MA(CLOSE,60) / MA2:=EMA(CLOSE,13) 为隐藏赋值(:=)且未被引用,不绘制
// - DRAWTEXT_FIX 的 HYBLOCK/DYBLOCK/GNBLOCK 是通达信本地板块数据web 端用后端
// info 的 行业+地域 近似前两者(经 extendData 注入图例行),概念板块无数据源暂缺
//
// 序列不是在图表窗口上现算,而是由 DetailKLine 在 allData全部已加载历史上预计算、
// 经 PV 按时间戳取值(与 pv-boll 同款管道MA114 预热 113 根,图表首吐窗口 240 根,
// 窗口现算左边会缺一大段allData 首屏 500 根(左滑翻页还会前插)足够覆盖。
import type { IndicatorTemplate, IndicatorTooltipData } from 'klinecharts';
export const ZHIXING_NAME = 'pv-zhixing';
/** 多空线 4 个 MA 周期M1~M4通达信参数表缺省值 */
export const DKX_PERIODS = [14, 28, 57, 114];
/** 板块文本颜色(通达信原文 COLOR00C0C0 */
export const ZHIXING_BLOCK_COLOR = '#00C0C0';
/** 趋势线颜色(通达信原文 COLORE61851同时用作开关按钮主色 */
export const ZHIXING_TREND_COLOR = '#E61851';
export const ZHIXING_DKX_COLOR = '#F5C518';
export interface ZhixingRow { trend?: number; dkx?: number }
export interface ZhixingExt { blocks?: string }
/** 序列取值函数:(指标 key, 时间戳) → 值DetailKLine 的 g() 按时间戳定位 allData 索引) */
export type ZhixingGetter = (key: 'zx_trend' | 'zx_dkx', ts: number) => number | undefined;
/** 通达信 EMAE=(2*X+(N-1)*prev)/(N+1),首根以自身为种子。无限记忆,但衰减因子
* (N-1)/(N+1)≈0.82/根allData 截断的影响几十根后即可忽略,左滑前插会整体重算自愈 */
function ema(src: number[], n: number): number[] {
const out = new Array<number>(src.length);
const k = 2 / (n + 1);
let prev = NaN;
for (let i = 0; i < src.length; i++) {
prev = Number.isNaN(prev) ? src[i] : src[i] * k + prev * (1 - k);
out[i] = prev;
}
return out;
}
/** 通达信 MA窗口不满为 null忠实原公式主图 MA 指标是 min_periods=1口径不同 */
function sma(src: number[], n: number): (number | null)[] {
const out = new Array<number | null>(src.length).fill(null);
let sum = 0;
for (let i = 0; i < src.length; i++) {
sum += src[i];
if (i >= n) sum -= src[i - n];
if (i >= n - 1) out[i] = sum / n;
}
return out;
}
/** 在整段已加载历史上计算知行序列(与 allData 按索引对齐build / 左滑前插后各跑一次 */
export function computeZhixingSeries(closes: number[]): { trend: (number | null)[]; dkx: (number | null)[] } {
const trend = ema(ema(closes, 10), 10);
const mas = DKX_PERIODS.map((p) => sma(closes, p));
const dkx = new Array<number | null>(closes.length).fill(null);
for (let i = 0; i < closes.length; i++) {
const m = mas.map((a) => a[i]);
if (m.every((v) => v != null)) dkx[i] = m.reduce((s, v) => s + (v as number), 0) / m.length;
}
return { trend, dkx };
}
/** 指标模板工厂:注册时注入按时间戳取预计算序列的访问器(实例级 g 闭包,随每次
* DetailKLine 挂载重注册换绑,与 pv-boll 同款;幂等注册会绑死首挂实例导致切股错线) */
export function makeZhixingIndicator(get: ZhixingGetter): IndicatorTemplate<ZhixingRow, unknown, ZhixingExt> {
return {
name: ZHIXING_NAME,
shortName: '知行',
figures: [
{ key: 'trend', title: '趋势线', type: 'line', styles: () => ({ color: ZHIXING_TREND_COLOR }) },
{ key: 'dkx', title: '多空线', type: 'line', styles: () => ({ color: ZHIXING_DKX_COLOR }) },
],
calc: (d) => d.map((bar) => {
const row: ZhixingRow = {};
const t = get('zx_trend', bar.timestamp);
const k = get('zx_dkx', bar.timestamp);
if (t != null) row.trend = t;
if (k != null) row.dkx = k;
return row;
}),
// 通达信 DRAWTEXT_FIX(ISLASTBAR,0,0,0,STRCAT(HYBLOCK,DYBLOCK,GNBLOCK)) 的近似:
// 自定义 legends 会整体替换图例行,须把两条线的值一并补回(颜色随各自线色)。
// calcParamsText 从 calcParams 拼,保留「知行(14,28,57,114)」的通达信式图例
createTooltipDataSource: ({ indicator, crosshair }): IndicatorTooltipData => {
const di = crosshair.dataIndex;
const row = (typeof di === 'number' && di >= 0 ? indicator.result[di] : undefined) ?? {};
const fmt = (v?: number) => (v == null ? '—' : v.toFixed(2));
const legends: IndicatorTooltipData['legends'] = [];
// 回调参数的 Indicator<D> 未带 E 泛型extendData 为 unknown需收窄
const blocks = (indicator.extendData as ZhixingExt | undefined)?.blocks;
if (blocks) {
legends.push({ title: { text: '板块', color: '#9AA0AA' }, value: { text: blocks, color: ZHIXING_BLOCK_COLOR } });
}
legends.push({ title: { text: '趋势线', color: ZHIXING_TREND_COLOR }, value: { text: fmt(row.trend), color: ZHIXING_TREND_COLOR } });
legends.push({ title: { text: '多空线', color: ZHIXING_DKX_COLOR }, value: { text: fmt(row.dkx), color: ZHIXING_DKX_COLOR } });
return {
name: '知行',
calcParamsText: `(${(indicator.calcParams as number[]).join(',')})`,
legends,
features: [],
};
},
};
}