candidate-pool 2.0.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/CHANGELOG.md +245 -0
- package/README.md +397 -0
- package/bin/candidate-pool.js +23 -0
- package/bin/tail-screener.js +22 -0
- package/build_universe.js +231 -0
- package/candidates.example.json +269 -0
- package/db/.env.example +13 -0
- package/db/backfill.js +71 -0
- package/db/clients.js +48 -0
- package/db/d1client.js +69 -0
- package/db/ddl.js +82 -0
- package/db/diff.js +223 -0
- package/db/diff_selftest.js +124 -0
- package/db/enrich_stock_base.js +138 -0
- package/db/migrate.js +177 -0
- package/db/normalize.js +129 -0
- package/db/persist.js +90 -0
- package/db/query_local.js +43 -0
- package/db/schema.sql +225 -0
- package/db/sqlite_client.js +81 -0
- package/db/verify_sync.js +107 -0
- package/db/write_live.js +54 -0
- package/eod/build_industry_map.js +146 -0
- package/eod/data/industry-map.json +1 -0
- package/eod/lib/http.js +188 -0
- package/eod/lib/market.js +345 -0
- package/eod/lib/report.js +329 -0
- package/eod/lib/screen.js +355 -0
- package/eod/lib/sector.js +96 -0
- package/eod/lib/store.js +204 -0
- package/eod/lib/store_d1.js +293 -0
- package/eod/lib/trading.js +252 -0
- package/eod/lib/trading.selftest.js +283 -0
- package/eod/tail.config.js +212 -0
- package/eod/tail_screener.js +373 -0
- package/gen_candidates.js +994 -0
- package/notify.js +236 -0
- package/notify_config.example.json +14 -0
- package/package.json +96 -0
- package/paths.js +127 -0
- package/paths.selftest.js +142 -0
- package/scaffold.js +199 -0
- package/scaffold.selftest.js +153 -0
- package/time.js +17 -0
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/*
|
|
3
|
+
* eod/lib/screen.js —— 初筛过滤 → 分时校验 → 强度打分 → 分组排序
|
|
4
|
+
* ---------------------------------------------------------------------------
|
|
5
|
+
* 口径要点(尾盘口径 ≠ 现有 R01 隔夜口径,两套独立,不要互相套用):
|
|
6
|
+
*
|
|
7
|
+
* 1. 初筛(FR-03)只依赖快照字段,因此可以先在全市场 6000+ 只上跑一遍,
|
|
8
|
+
* 把「不可买 / 明显不合格」的先剔掉,再对少数幸存者抓分时(否则请求量爆炸)。
|
|
9
|
+
* 2. 「不可买」判定必须覆盖三件事,实测缺一不可:
|
|
10
|
+
* - 停牌/退市:成交量 0 或 行情时间戳停在 09:00:00
|
|
11
|
+
* (已退市个股仍会返回陈旧价格,只看价格会把它们放进来)
|
|
12
|
+
* - 一字板:开=高=低(全天一个价,实盘买不到)
|
|
13
|
+
* - 已封涨停:现价达到该板块涨停价(主板 10% / 双创 20% / 北交所 30%)
|
|
14
|
+
* 3. 打分(FR-06)六项全部线性映射到 0..1 再加权,**每一项都能在报告里解释**,
|
|
15
|
+
* 不做「黑箱总分」。任何一项缺数据记 0 分而不是猜值。
|
|
16
|
+
* 4. 分组(FR-07)按行业聚合,组内按总分降序,标记组内最优。
|
|
17
|
+
* ---------------------------------------------------------------------------
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const { dayjs } = require('../../time');
|
|
21
|
+
const { sectorOf, listedOf, computeSectorStats, UNKNOWN } = require('./sector');
|
|
22
|
+
|
|
23
|
+
/** 筛除原因(中文,直接进报告与日志) */
|
|
24
|
+
const REASON = {
|
|
25
|
+
ST: 'ST / 退市风险股',
|
|
26
|
+
SUSPENDED: '停牌或已退市(无成交)',
|
|
27
|
+
NO_DATA: '关键字段缺失或分时数据不可用',
|
|
28
|
+
CHG: '当日涨幅不在区间',
|
|
29
|
+
VOL_RATIO: '量比不足',
|
|
30
|
+
TURNOVER: '换手率不在区间',
|
|
31
|
+
CAP: '流通市值不在区间',
|
|
32
|
+
PRICE: '股价过低',
|
|
33
|
+
ONE_WORD: '一字板(实际上买不到)',
|
|
34
|
+
LIMIT_UP: '已封涨停(实际上买不到)',
|
|
35
|
+
NEW: '上市时间过短',
|
|
36
|
+
TAIL_SEG: '尾盘段涨幅不足',
|
|
37
|
+
BELOW_AVG: '现价低于当日均价',
|
|
38
|
+
FROM_HIGH: '距日内高点回落过多',
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/** 板块判定:涨跌幅制度与成交量单位都与板块相关 */
|
|
42
|
+
function boardOf(code) {
|
|
43
|
+
if (/^sh68[89]/.test(code)) return 'star'; // 科创板
|
|
44
|
+
if (/^sz30/.test(code)) return 'gem'; // 创业板
|
|
45
|
+
if (/^bj/.test(code)) return 'bj'; // 北交所
|
|
46
|
+
return 'main'; // 沪深主板
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** 该板块的涨停幅度(%)。ST 已在上游剔除,故不必再处理 5% 档 */
|
|
50
|
+
function limitUpPct(code) {
|
|
51
|
+
const b = boardOf(code);
|
|
52
|
+
if (b === 'star' || b === 'gem') return 20;
|
|
53
|
+
if (b === 'bj') return 30;
|
|
54
|
+
return 10;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const BOARD_LABEL = { main: '主板', star: '科创板', gem: '创业板', bj: '北交所' };
|
|
58
|
+
|
|
59
|
+
/** 两个 YYYYMMDD / YYYY-MM-DD 之间相差的自然日;任一无法解析返回 null */
|
|
60
|
+
function daysBetween(a, b) {
|
|
61
|
+
const da = dayjs.tz(String(a).replace(/^(\d{4})(\d{2})(\d{2})$/, '$1-$2-$3') + ' 00:00:00');
|
|
62
|
+
const db = dayjs.tz(String(b).slice(0, 10) + ' 00:00:00');
|
|
63
|
+
if (!da.isValid() || !db.isValid()) return null;
|
|
64
|
+
return Math.abs(db.diff(da, 'day'));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* 单只票的快照初筛。返回 null = 通过;返回字符串 = 淘汰原因。
|
|
69
|
+
* 顺序有意从「便宜且决定性」到「贵」,便于统计各原因淘汰量。
|
|
70
|
+
*/
|
|
71
|
+
function preFilterOne(q, cfg, map, tradeDate) {
|
|
72
|
+
const ex = cfg.exclude;
|
|
73
|
+
const f = cfg.filter;
|
|
74
|
+
|
|
75
|
+
if (ex.st && /ST|退/i.test(q.name)) return REASON.ST;
|
|
76
|
+
|
|
77
|
+
if (ex.suspended) {
|
|
78
|
+
const hhmmss = String(q.stamp || '').slice(8, 14);
|
|
79
|
+
if (!(q.vol > 0) || hhmmss === '090000') return REASON.SUSPENDED;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (q.chgPct == null || q.turnover == null || q.volRatio == null || q.floatCapYi == null || q.high == null) {
|
|
83
|
+
return REASON.NO_DATA;
|
|
84
|
+
}
|
|
85
|
+
if (q.chgPct < f.chgPctMin || q.chgPct > f.chgPctMax) return REASON.CHG;
|
|
86
|
+
if (q.volRatio < f.volRatioMin) return REASON.VOL_RATIO;
|
|
87
|
+
if (q.turnover < f.turnoverMin || q.turnover > f.turnoverMax) return REASON.TURNOVER;
|
|
88
|
+
if (q.floatCapYi < f.floatCapMinYi || q.floatCapYi > f.floatCapMaxYi) return REASON.CAP;
|
|
89
|
+
if (q.price < f.priceMin) return REASON.PRICE;
|
|
90
|
+
|
|
91
|
+
if (ex.oneWordBoard && q.open != null && q.high != null && q.low != null
|
|
92
|
+
&& q.open === q.high && q.high === q.low) {
|
|
93
|
+
return REASON.ONE_WORD;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (ex.limitUp && q.prevClose > 0) {
|
|
97
|
+
const limitPrice = Math.round(q.prevClose * (1 + limitUpPct(q.code) / 100) * 100) / 100;
|
|
98
|
+
if (q.price >= limitPrice - 0.005) return REASON.LIMIT_UP;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (ex.newListing) {
|
|
102
|
+
// 交易所对新股简称为「N 开头(首日)/ C 开头(上市后 5 日内)」
|
|
103
|
+
if (/^[NC]/.test(q.name)) return REASON.NEW;
|
|
104
|
+
const listed = listedOf(map, q.code);
|
|
105
|
+
if (listed && tradeDate) {
|
|
106
|
+
const days = daysBetween(listed, tradeDate);
|
|
107
|
+
if (days != null && days < ex.newListingDays) return REASON.NEW;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* 全市场初筛。
|
|
116
|
+
* @returns {{passed:Array, rejected:Array<{code,name,reason}>, byReason:object}}
|
|
117
|
+
*/
|
|
118
|
+
function preFilter(quotes, cfg, map, tradeDate) {
|
|
119
|
+
const passed = [];
|
|
120
|
+
const rejected = [];
|
|
121
|
+
const byReason = {};
|
|
122
|
+
for (const q of quotes) {
|
|
123
|
+
const reason = preFilterOne(q, cfg, map, tradeDate);
|
|
124
|
+
if (reason) {
|
|
125
|
+
byReason[reason] = (byReason[reason] || 0) + 1;
|
|
126
|
+
rejected.push({ code: q.code, name: q.name, reason });
|
|
127
|
+
} else {
|
|
128
|
+
passed.push(q);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return { passed, rejected, byReason };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** 分时校验(FR-04)。返回原因数组,空数组 = 通过 */
|
|
135
|
+
function postCheck(q, tm, cfg) {
|
|
136
|
+
const f = cfg.filter;
|
|
137
|
+
if (!tm) return [REASON.NO_DATA];
|
|
138
|
+
const out = [];
|
|
139
|
+
if (tm.segPct == null || tm.segPct < f.tailSegMinPct) out.push(REASON.TAIL_SEG);
|
|
140
|
+
if (f.aboveAvgMode === 'hard') {
|
|
141
|
+
if (tm.priceVsAvgPct == null) out.push(REASON.NO_DATA);
|
|
142
|
+
else if (tm.priceVsAvgPct < -Math.abs(f.aboveAvgTolerance || 0)) out.push(REASON.BELOW_AVG);
|
|
143
|
+
}
|
|
144
|
+
if (q.high > 0 && tm.p1 != null) {
|
|
145
|
+
const fromHigh = ((q.high - tm.p1) / q.high) * 100;
|
|
146
|
+
if (fromHigh > f.maxFromHighPct) out.push(REASON.FROM_HIGH);
|
|
147
|
+
}
|
|
148
|
+
return out;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function clamp01(v) { return Math.max(0, Math.min(1, v)); }
|
|
152
|
+
|
|
153
|
+
/** 线性映射到 0..1;v 为空返回 0(缺数据不猜值) */
|
|
154
|
+
function lin(v, lo, hi) {
|
|
155
|
+
if (v == null || !Number.isFinite(v)) return 0;
|
|
156
|
+
if (hi === lo) return v >= hi ? 1 : 0;
|
|
157
|
+
return clamp01((v - lo) / (hi - lo));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* 换手适中度:落在 [sweetLow, sweetHigh] 拿满分,
|
|
162
|
+
* 低于 sweetLow 按 [filterMin, sweetLow] 递减,高于 sweetHigh 按 [sweetHigh, filterMax] 递减。
|
|
163
|
+
* 这样「过低无人气」与「过高警惕出货」两端都平滑扣分,而不是一刀切。
|
|
164
|
+
*/
|
|
165
|
+
function turnoverFit(t, s, f) {
|
|
166
|
+
if (t == null || !Number.isFinite(t)) return 0;
|
|
167
|
+
if (t >= s.turnoverSweetLow && t <= s.turnoverSweetHigh) return 1;
|
|
168
|
+
if (t < s.turnoverSweetLow) return clamp01((t - f.turnoverMin) / (s.turnoverSweetLow - f.turnoverMin));
|
|
169
|
+
return clamp01((f.turnoverMax - t) / (f.turnoverMax - s.turnoverSweetHigh));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* 板块强度(实测后定的混合口径)。
|
|
174
|
+
*
|
|
175
|
+
* 只用「行业涨幅中位数的绝对阈值」会在普涨日整体饱和——实测 2026-09-18
|
|
176
|
+
* 全市场 68.7% 个股上涨,多数行业中位数都超过 2%,导致候选清一色拿满分、该项失去区分度。
|
|
177
|
+
* 只用「分位」则会在普跌日把「跌得最少的行业」也抬到满分,同样失真。
|
|
178
|
+
* 故取两者折中:
|
|
179
|
+
* 1. 行业中位数 ≤ sectorChgLoPct(默认 0%)→ 0 分:行业实际在跌,不给板块加分;
|
|
180
|
+
* 2. 行业中位数 ≥ sectorChgHiPct(默认 2%)→ 满分:绝对强度已足够;
|
|
181
|
+
* 3. 区间内 → 按该行业在全市场行业涨幅中的**分位**给分(相对强弱,自归一)。
|
|
182
|
+
*/
|
|
183
|
+
function sectorStrength(sec, s) {
|
|
184
|
+
if (!sec || sec.median == null) return 0;
|
|
185
|
+
if (sec.median <= s.sectorChgLoPct) return 0;
|
|
186
|
+
if (sec.median >= s.sectorChgHiPct) return 1;
|
|
187
|
+
if (!sec.total || sec.total <= 1) return 0;
|
|
188
|
+
const pct = (sec.total - sec.rank) / (sec.total - 1); // rank=1 → 1,最末 → 0
|
|
189
|
+
return clamp01(pct);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** 「距日内高点」百分比:越小越强 */
|
|
193
|
+
function fromHighPct(q, tm) {
|
|
194
|
+
if (!(q.high > 0) || tm == null || tm.p1 == null) return null;
|
|
195
|
+
return ((q.high - tm.p1) / q.high) * 100;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* 六项打分(FR-06)。总分 = Σ(权重 × 归一化值)。
|
|
200
|
+
* @returns {{total:number, items:object, ctx:object}} items 为各项得分(已乘权重)
|
|
201
|
+
*/
|
|
202
|
+
function scoreStock(q, tm, cfg, sectorStats, sector) {
|
|
203
|
+
const w = cfg.score.weights;
|
|
204
|
+
const s = cfg.score;
|
|
205
|
+
const f = cfg.filter;
|
|
206
|
+
|
|
207
|
+
const fh = fromHighPct(q, tm);
|
|
208
|
+
const sec = sectorStats.get(sector || UNKNOWN);
|
|
209
|
+
|
|
210
|
+
const items = {
|
|
211
|
+
// 尾盘动能:段涨幅 0 → 0 分,达到 tailMomentumFullPct → 满分
|
|
212
|
+
tailMomentum: w.tailMomentum * lin(tm.segPct, 0, s.tailMomentumFullPct),
|
|
213
|
+
// 量能:量比从门槛起到 volRatioFull 线性给分
|
|
214
|
+
volume: w.volume * lin(q.volRatio, f.volRatioMin, s.volRatioFull),
|
|
215
|
+
// 位置:距日内高点 0% → 满分,达到 maxFromHighPct → 0 分
|
|
216
|
+
position: w.position * (1 - lin(fh, 0, f.maxFromHighPct)),
|
|
217
|
+
// 均价线强度:高出均价 0% → 0 分,avgLineFullPct → 满分
|
|
218
|
+
avgLine: w.avgLine * lin(tm.priceVsAvgPct, 0, s.avgLineFullPct),
|
|
219
|
+
// 板块强度:见 sectorStrength() 的口径说明
|
|
220
|
+
sector: w.sector * sectorStrength(sec, s),
|
|
221
|
+
// 换手适中度
|
|
222
|
+
turnoverFit: w.turnoverFit * turnoverFit(q.turnover, s, f),
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
const total = Object.values(items).reduce((a, b) => a + b, 0);
|
|
226
|
+
return { items, total, context: { fromHighPct: fh, sectorMedian: sec ? sec.median : null } };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* 分组 + 组内排序(FR-07)。
|
|
231
|
+
* 组间按「组内最高总分」降序,组内按总分降序,组内第一名标 bestInGroup。
|
|
232
|
+
*/
|
|
233
|
+
function groupAndRank(candidates, cfg) {
|
|
234
|
+
const bySector = new Map();
|
|
235
|
+
for (const c of candidates) {
|
|
236
|
+
const k = c.sector || UNKNOWN;
|
|
237
|
+
if (!bySector.has(k)) bySector.set(k, []);
|
|
238
|
+
bySector.get(k).push(c);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const groups = [];
|
|
242
|
+
for (const [sector, list] of bySector) {
|
|
243
|
+
list.sort((a, b) => (b.total - a.total) || ((b.tail?.segPct ?? -99) - (a.tail?.segPct ?? -99)));
|
|
244
|
+
list.forEach((c, i) => {
|
|
245
|
+
c.groupRank = i + 1;
|
|
246
|
+
c.bestInGroup = i === 0;
|
|
247
|
+
c.groupSize = list.length;
|
|
248
|
+
});
|
|
249
|
+
groups.push({
|
|
250
|
+
sector,
|
|
251
|
+
size: list.length,
|
|
252
|
+
// 展示用:同组内只留前 N 只,避免推一堆同质票(Q12)
|
|
253
|
+
shown: list.slice(0, Math.max(1, cfg.group.maxPerGroup)),
|
|
254
|
+
best: list[0],
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
groups.sort((a, b) => b.best.total - a.best.total);
|
|
258
|
+
return groups;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* 主入口:快照 + 分时 -> 候选、分组、TOP。
|
|
263
|
+
*
|
|
264
|
+
* @param {Array} quotes 全市场快照
|
|
265
|
+
* @param {Map} minutes code -> { rows, date } | { error }
|
|
266
|
+
* @param {object} cfg 配置
|
|
267
|
+
* @param {object} map 行业映射
|
|
268
|
+
* @param {string} tradeDate 'YYYY-MM-DD'
|
|
269
|
+
* @param {string} cutHHMM '1450'(或观察模式下的当前时点)
|
|
270
|
+
* @param {string} segFrom '1430'
|
|
271
|
+
* @param {(code:string)=>object|null} makeTail 由 rows 计算 tailMetrics 的函数(注入以便复用/测试)
|
|
272
|
+
*/
|
|
273
|
+
function buildCandidates({ quotes, minutes, cfg, map, tradeDate, cutHHMM, segFrom, makeTail }) {
|
|
274
|
+
const pre = preFilter(quotes, cfg, map, tradeDate);
|
|
275
|
+
const sectorStats = computeSectorStats(pre.passed, map);
|
|
276
|
+
|
|
277
|
+
const candidates = [];
|
|
278
|
+
const dropped = [];
|
|
279
|
+
const tailReasons = {};
|
|
280
|
+
|
|
281
|
+
for (const q of pre.passed) {
|
|
282
|
+
const m = minutes.get(q.code);
|
|
283
|
+
if (!m || m.error) {
|
|
284
|
+
tailReasons[REASON.NO_DATA] = (tailReasons[REASON.NO_DATA] || 0) + 1;
|
|
285
|
+
dropped.push({ code: q.code, name: q.name, reason: REASON.NO_DATA });
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
const tm = makeTail(m.rows, q.code, cutHHMM, segFrom);
|
|
289
|
+
const reasons = postCheck(q, tm, cfg);
|
|
290
|
+
if (reasons.length) {
|
|
291
|
+
for (const r of reasons) tailReasons[r] = (tailReasons[r] || 0) + 1;
|
|
292
|
+
dropped.push({ code: q.code, name: q.name, reason: reasons.join('、') });
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const sector = sectorOf(map, q.code) || UNKNOWN;
|
|
297
|
+
const sc = scoreStock(q, tm, cfg, sectorStats, sector);
|
|
298
|
+
const sec = sectorStats.get(sector);
|
|
299
|
+
|
|
300
|
+
candidates.push({
|
|
301
|
+
code: q.code,
|
|
302
|
+
name: q.name,
|
|
303
|
+
sector,
|
|
304
|
+
board: boardOf(q.code),
|
|
305
|
+
boardLabel: BOARD_LABEL[boardOf(q.code)],
|
|
306
|
+
price: q.price,
|
|
307
|
+
prevClose: q.prevClose,
|
|
308
|
+
open: q.open,
|
|
309
|
+
high: q.high,
|
|
310
|
+
low: q.low,
|
|
311
|
+
chgPct: q.chgPct,
|
|
312
|
+
turnover: q.turnover,
|
|
313
|
+
volRatio: q.volRatio,
|
|
314
|
+
// 收盘预估量比:即时量比按已交易分钟外推,量能非匀速,仅作参考
|
|
315
|
+
volRatioEst: null,
|
|
316
|
+
floatCapYi: q.floatCapYi,
|
|
317
|
+
totalCapYi: q.totalCapYi,
|
|
318
|
+
avgPrice: tm.avgAtCut,
|
|
319
|
+
stamp: q.stamp,
|
|
320
|
+
tail: tm,
|
|
321
|
+
score: sc.items,
|
|
322
|
+
total: sc.total,
|
|
323
|
+
scoreContext: sc.context,
|
|
324
|
+
sectorMedianChg: sec ? sec.median : null,
|
|
325
|
+
sectorRank: sec ? sec.rank : null,
|
|
326
|
+
sectorTotal: sec ? sec.total : null,
|
|
327
|
+
sectorMemberCount: sec ? sec.count : null,
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
candidates.sort((a, b) => b.total - a.total);
|
|
332
|
+
const groups = groupAndRank(candidates, cfg);
|
|
333
|
+
|
|
334
|
+
return {
|
|
335
|
+
candidates,
|
|
336
|
+
groups,
|
|
337
|
+
top: candidates.slice(0, Math.max(1, cfg.group.topN)),
|
|
338
|
+
stats: {
|
|
339
|
+
snapshotCount: quotes.length,
|
|
340
|
+
prePassCount: pre.passed.length,
|
|
341
|
+
preByReason: pre.byReason,
|
|
342
|
+
minuteDropped: dropped.length,
|
|
343
|
+
tailByReason: tailReasons,
|
|
344
|
+
candidateCount: candidates.length,
|
|
345
|
+
groupCount: groups.length,
|
|
346
|
+
},
|
|
347
|
+
dropped: [...pre.rejected, ...dropped],
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
module.exports = {
|
|
352
|
+
REASON, BOARD_LABEL, boardOf, limitUpPct, daysBetween,
|
|
353
|
+
preFilterOne, preFilter, postCheck, scoreStock, turnoverFit, fromHighPct, lin,
|
|
354
|
+
sectorStrength, groupAndRank, buildCandidates,
|
|
355
|
+
};
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/*
|
|
3
|
+
* eod/lib/sector.js —— 行业归属(代码 -> 行业)缓存读取
|
|
4
|
+
* ---------------------------------------------------------------------------
|
|
5
|
+
* 为什么需要缓存:
|
|
6
|
+
* 全市场「代码 → 行业」的映射有两个来源,都很慢或不稳:
|
|
7
|
+
* - 东财 clist 的 f100 字段:一次能拿全市场,但**东财对突发高频请求会封 IP**
|
|
8
|
+
* (实测十余次请求后即 socket hang up),不能放进 14:50 的关键路径;
|
|
9
|
+
* - 新浪行业分类:只给「行业 → 领涨股」,成员清单要按行业分页拉,请求数上百。
|
|
10
|
+
* 而行业归属**几乎不变**(一年也就调几次成分),所以做成低频刷新的本地缓存:
|
|
11
|
+
* 平时读文件(零网络、零延迟),偶尔用 build_industry_map.js 刷一次。
|
|
12
|
+
*
|
|
13
|
+
* 缓存结构(eod/data/industry-map.json):
|
|
14
|
+
* { generatedAt, source, count, stocks: { "sh600519": { name, sector, listed } } }
|
|
15
|
+
* ---------------------------------------------------------------------------
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const fs = require('fs');
|
|
19
|
+
const path = require('path');
|
|
20
|
+
const paths = require('../../paths');
|
|
21
|
+
|
|
22
|
+
// 缓存位置跟随工作目录(见 paths.js):默认 <仓库根>/eod/data/industry-map.json
|
|
23
|
+
const UNKNOWN = '未分类';
|
|
24
|
+
|
|
25
|
+
/** 读取单个映射文件;缺失 / 损坏一律返回空表(不抛错) */
|
|
26
|
+
function readMap(file) {
|
|
27
|
+
try {
|
|
28
|
+
const j = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
29
|
+
const stocks = j && j.stocks && typeof j.stocks === 'object' ? j.stocks : {};
|
|
30
|
+
return { generatedAt: j.generatedAt || null, source: j.source || null, count: Object.keys(stocks).length, stocks };
|
|
31
|
+
} catch (e) {
|
|
32
|
+
return { generatedAt: null, source: null, count: 0, stocks: {}, error: String((e && e.message) || e) };
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 读取行业映射;文件缺失 / 损坏一律降级(不抛错,让主流程记为「未分类」)。
|
|
38
|
+
*
|
|
39
|
+
* 兜底顺序:工作目录 → **包内自带的那份**。
|
|
40
|
+
* 为什么需要第二级:行业映射虽是「数据」,却是由 refresh-industry-map 工作流生成的
|
|
41
|
+
* **派生缓存**(一年只变几次),不是用户资产;npm 包把它一起发出去当种子。
|
|
42
|
+
* 否则 `npx candidate-pool` 在一个空目录里跑,映射读不到 → 全市场塌成单一「未分类」,
|
|
43
|
+
* 板块内补涨(R07)、板块统计、板块分组会**静默失效**(不报错,只是结果变差)。
|
|
44
|
+
* 用户若要刷新,`node eod/build_industry_map.js` 会写到工作目录并从此优先。
|
|
45
|
+
*/
|
|
46
|
+
function loadIndustryMap(file = paths.industryMapFile()) {
|
|
47
|
+
const primary = readMap(file);
|
|
48
|
+
if (primary.count > 0) return primary;
|
|
49
|
+
|
|
50
|
+
const seed = paths.codeAsset('eod', 'data', 'industry-map.json');
|
|
51
|
+
if (path.resolve(file) === path.resolve(seed)) return primary;
|
|
52
|
+
|
|
53
|
+
const fromSeed = readMap(seed);
|
|
54
|
+
return fromSeed.count > 0 ? { ...fromSeed, seedFallback: true } : primary;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** 取某只票的行业;查不到返回 null(由调用方决定记为「未分类」还是 data_gap) */
|
|
58
|
+
function sectorOf(map, code) {
|
|
59
|
+
const s = map && map.stocks ? map.stocks[code] : null;
|
|
60
|
+
return (s && s.sector) || null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** 取某只票的上市日期(YYYYMMDD,可能为 null) */
|
|
64
|
+
function listedOf(map, code) {
|
|
65
|
+
const s = map && map.stocks ? map.stocks[code] : null;
|
|
66
|
+
return (s && s.listed) || null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* 按行业统计当日涨幅中位数(板块强度,FR-05)。
|
|
71
|
+
* 只用快照里**已通过基础有效性**的票参与统计,避免退市/停牌票把中位数带偏。
|
|
72
|
+
* @returns {Map<string, {sector, median, count, rank, total}>}
|
|
73
|
+
*/
|
|
74
|
+
function computeSectorStats(stocks, map) {
|
|
75
|
+
const buckets = new Map();
|
|
76
|
+
for (const q of stocks) {
|
|
77
|
+
const sec = sectorOf(map, q.code);
|
|
78
|
+
if (!sec) continue;
|
|
79
|
+
if (q.chgPct == null) continue;
|
|
80
|
+
if (!buckets.has(sec)) buckets.set(sec, []);
|
|
81
|
+
buckets.get(sec).push(q.chgPct);
|
|
82
|
+
}
|
|
83
|
+
const rows = [];
|
|
84
|
+
for (const [sector, arr] of buckets) {
|
|
85
|
+
const sorted = arr.slice().sort((a, b) => a - b);
|
|
86
|
+
const mid = sorted.length % 2
|
|
87
|
+
? sorted[(sorted.length - 1) / 2]
|
|
88
|
+
: (sorted[sorted.length / 2 - 1] + sorted[sorted.length / 2]) / 2;
|
|
89
|
+
rows.push({ sector, median: mid, count: sorted.length });
|
|
90
|
+
}
|
|
91
|
+
rows.sort((a, b) => b.median - a.median);
|
|
92
|
+
rows.forEach((r, i) => { r.rank = i + 1; r.total = rows.length; });
|
|
93
|
+
return new Map(rows.map((r) => [r.sector, r]));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
module.exports = { loadIndustryMap, sectorOf, listedOf, computeSectorStats, mapPath: paths.industryMapFile, UNKNOWN };
|
package/eod/lib/store.js
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/*
|
|
3
|
+
* eod/lib/store.js —— 幂等落库(FR-10 / FR-11 / Q3)
|
|
4
|
+
* ---------------------------------------------------------------------------
|
|
5
|
+
* 约定(与 tail.config.js store 段一致):
|
|
6
|
+
* 1. 一个交易日一份正式文件 `eod-<交易日>.json`,**整文件覆盖**:
|
|
7
|
+
* 文件内 records 以 code 为键,天然不会出现重复记录;
|
|
8
|
+
* 同日多次运行的历史(几次、各跑出几只候选)记在 runs[] 里留痕,
|
|
9
|
+
* 最多保留 keepRunHistory 条。
|
|
10
|
+
* 2. 观察模式(14:30~14:50)另存 `eod-<交易日>-obs.json`,
|
|
11
|
+
* 不污染正式口径;同一天多次观察互相覆盖(同口径幂等),
|
|
12
|
+
* 「跑了几次 / 各自什么时点」看 runs[](文件名带时点的 `-obs<HHMM>` 仅在 --stamp 时用)。
|
|
13
|
+
* 3. 收盘后回填(P5)写进每条 record 的 `fill` 字段(当日收盘价 / 次日表现),
|
|
14
|
+
* 重新跑筛选会覆盖 records 但**保留旧 fill**——只要 code 还在候选里。
|
|
15
|
+
* 4. 全部零依赖:Node 内置 fs/path + 项目 time.js。
|
|
16
|
+
* ---------------------------------------------------------------------------
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const fs = require('fs');
|
|
20
|
+
const path = require('path');
|
|
21
|
+
const { now: nowBJ } = require('../../time');
|
|
22
|
+
const paths = require('../../paths');
|
|
23
|
+
|
|
24
|
+
/** 候选对象 -> 存档记录(压缩字段:分时明细不落盘,只留尾盘段汇总) */
|
|
25
|
+
function toRecord(c) {
|
|
26
|
+
return {
|
|
27
|
+
code: c.code,
|
|
28
|
+
name: c.name,
|
|
29
|
+
sector: c.sector,
|
|
30
|
+
board: c.board,
|
|
31
|
+
boardLabel: c.boardLabel,
|
|
32
|
+
price: c.price,
|
|
33
|
+
prevClose: c.prevClose,
|
|
34
|
+
high: c.high,
|
|
35
|
+
chgPct: c.chgPct,
|
|
36
|
+
turnover: c.turnover,
|
|
37
|
+
volRatio: c.volRatio,
|
|
38
|
+
volRatioEst: c.volRatioEst ?? null,
|
|
39
|
+
floatCapYi: c.floatCapYi,
|
|
40
|
+
avgPrice: c.avgPrice,
|
|
41
|
+
groupRank: c.groupRank,
|
|
42
|
+
bestInGroup: c.bestInGroup,
|
|
43
|
+
groupSize: c.groupSize,
|
|
44
|
+
tail: c.tail ? {
|
|
45
|
+
segPct: c.tail.segPct,
|
|
46
|
+
upRatio: c.tail.upRatio,
|
|
47
|
+
maxDrawdownPct: c.tail.maxDrawdownPct,
|
|
48
|
+
priceVsAvgPct: c.tail.priceVsAvgPct,
|
|
49
|
+
avgAtCut: c.tail.avgAtCut,
|
|
50
|
+
p0: c.tail.p0,
|
|
51
|
+
p1: c.tail.p1,
|
|
52
|
+
bars: c.tail.bars,
|
|
53
|
+
// 实际取数窗口(盘中模式的滚动窗口靠它才可回溯;正式口径固定 1430→1450)
|
|
54
|
+
segFrom: c.tail.segFromUsed ?? c.tail.segFrom ?? null,
|
|
55
|
+
cut: c.tail.cut ?? null,
|
|
56
|
+
} : null,
|
|
57
|
+
score: c.score,
|
|
58
|
+
total: c.total,
|
|
59
|
+
sectorMedianChg: c.sectorMedianChg,
|
|
60
|
+
sectorRank: c.sectorRank,
|
|
61
|
+
sectorTotal: c.sectorTotal,
|
|
62
|
+
// P5 回填位:{ close, next1, next2, next3, filledAt }(收盘后由回填脚本写入)
|
|
63
|
+
fill: {},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* 文件名后缀(不含扩展名)。
|
|
69
|
+
* 正式 (cut) → ''
|
|
70
|
+
* 观察 (observe) → '-obs' ← **一天一个文件**,多次观察靠 runs[] 留痕
|
|
71
|
+
* '-obs<HHMM>'(stamp=true)
|
|
72
|
+
* 盘中 (intraday) → '-intraday' ← **一天一个文件**,多次运行靠 runs[] 留痕
|
|
73
|
+
* '-intraday<HHMM>-w<分钟>'(stamp=true)
|
|
74
|
+
*
|
|
75
|
+
* 观察 / 盘中都默认**一天一个文件、不按时点命名**,理由相同:
|
|
76
|
+
* 两者的「尾盘段」都是**相对窗口**(观察 = 14:30→当前时点,盘中 = 滚动 segMinutes 分钟),
|
|
77
|
+
* 不同时刻的结果**本来就不可横向比较**,逐个留档没有复盘价值;而盘中一天可能跑十几次,
|
|
78
|
+
* 按时点命名会让目录失控(十几个 JSON + 十几个 HTML)。
|
|
79
|
+
* 真正需要留痕的是「跑了几次、每次什么口径、多少候选」—— 那正是 runs[] 的职责;
|
|
80
|
+
* 时点也没丢,存档里有 doc.cutAt,D1 侧有 tail_run.cut_at。
|
|
81
|
+
* 还有一条:D1 对 (trade_date, mode) 是 UPSERT,一个交易日只有一行观察,
|
|
82
|
+
* 文件名若不跟着「一天一个」,就会出现「磁盘 N 份 vs 库里 1 行」这种两源粒度不一致
|
|
83
|
+
* (曾按 `-obs<HHMM>` 命名,副作用是文件名取决于启动分钟,文档 / --replay 无法写死)。
|
|
84
|
+
* 需要完整保留某一次时显式加 --stamp;stamp 模式下连窗口长度一起写进文件名,
|
|
85
|
+
* 因为「对比 20 分钟 vs 30 分钟窗口」正是 stamp 的主要用途(同名会互相覆盖)。
|
|
86
|
+
*/
|
|
87
|
+
function daySuffix(mode, cutHHMM, { stamp = false, segMinutes = null } = {}) {
|
|
88
|
+
if (mode === 'observe') return stamp ? `-obs${cutHHMM}` : '-obs';
|
|
89
|
+
if (mode === 'intraday') {
|
|
90
|
+
if (!stamp) return '-intraday';
|
|
91
|
+
return `-intraday${cutHHMM}${segMinutes ? `-w${segMinutes}` : ''}`;
|
|
92
|
+
}
|
|
93
|
+
return '';
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* 存档文件名(数据 JSON)。
|
|
98
|
+
* 正式 (cut) → eod-<日>.json ← 唯一被 D1 同步 / 复盘引用的口径
|
|
99
|
+
* 观察 (observe) → eod-<日>-obs.json (--stamp 时带时点)
|
|
100
|
+
* 盘中 (intraday) → eod-<日>-intraday.json (--stamp 时带时点与窗口)
|
|
101
|
+
* 观察 / 盘中必须另存:它们的尾盘段不是 14:30→14:50 的固定口径
|
|
102
|
+
* (观察是 14:30→当前时点,盘中是滚动窗口),若写进正式文件会把当天的正式结果
|
|
103
|
+
* 覆盖成假的 14:50 口径。
|
|
104
|
+
*/
|
|
105
|
+
function dayFile(cfg, tradeDate, mode, cutHHMM, opts) {
|
|
106
|
+
return path.join(paths.eodDir(), cfg.store.dataDir, `eod-${tradeDate}${daySuffix(mode, cutHHMM, opts)}.json`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** 读取某交易日的正式存档(无则 null) */
|
|
110
|
+
function loadDay(cfg, tradeDate) {
|
|
111
|
+
const f = dayFile(cfg, tradeDate, 'cut');
|
|
112
|
+
if (!fs.existsSync(f)) return null;
|
|
113
|
+
try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch (e) { return null; }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* 保存一次运行结果(幂等)。
|
|
118
|
+
* @param {object} p
|
|
119
|
+
* - segFrom 尾盘段起点 'HHMM'(盘中模式会随之滚动,存进 runs[] 才能回溯口径)
|
|
120
|
+
* - segMinutes 盘中窗口长度(分钟);正式/观察为 null
|
|
121
|
+
* - stamp 盘中模式下是否在文件名里带上时点(默认 false = 一天一个文件)
|
|
122
|
+
* @returns {{file:string, recordCount:number, runCount:number, replacedFill:number}}
|
|
123
|
+
*/
|
|
124
|
+
function saveRun({ cfg, tradeDate, mode, cutHHMM, result, runner, segFrom = null, segMinutes = null, stamp = false }) {
|
|
125
|
+
const dir = path.join(paths.eodDir(), cfg.store.dataDir);
|
|
126
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
127
|
+
const file = dayFile(cfg, tradeDate, mode, cutHHMM, { stamp, segMinutes });
|
|
128
|
+
|
|
129
|
+
const old = fs.existsSync(file) ? (() => {
|
|
130
|
+
try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch (e) { return null; }
|
|
131
|
+
})() : null;
|
|
132
|
+
|
|
133
|
+
const run = {
|
|
134
|
+
at: nowBJ(),
|
|
135
|
+
mode,
|
|
136
|
+
cutAt: cutHHMM,
|
|
137
|
+
// 尾盘段起点与窗口长度:正式/观察恒为 1430;盘中是滚动窗口,必须记下来,
|
|
138
|
+
// 否则事后看 runs[] 只知道「11:20 跑过一次」,不知道那次用的是哪一段。
|
|
139
|
+
segFrom,
|
|
140
|
+
segMinutes,
|
|
141
|
+
candidateCount: result.candidates.length,
|
|
142
|
+
groupCount: result.groups.length,
|
|
143
|
+
prePassCount: result.stats.prePassCount,
|
|
144
|
+
snapshotCount: result.stats.snapshotCount,
|
|
145
|
+
runner: runner || 'local',
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
// 合并旧 fill:同 code 重选时保留收盘后回填的表现数据
|
|
149
|
+
const records = {};
|
|
150
|
+
let replacedFill = 0;
|
|
151
|
+
const oldRecs = (old && old.records) || {};
|
|
152
|
+
for (const c of result.candidates) {
|
|
153
|
+
const rec = toRecord(c);
|
|
154
|
+
const prev = oldRecs[c.code];
|
|
155
|
+
if (prev && prev.fill && Object.keys(prev.fill).length) {
|
|
156
|
+
rec.fill = prev.fill;
|
|
157
|
+
replacedFill++;
|
|
158
|
+
}
|
|
159
|
+
records[c.code] = rec;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const runs = [...((old && old.runs) || []), run].slice(-cfg.store.keepRunHistory);
|
|
163
|
+
|
|
164
|
+
const doc = {
|
|
165
|
+
version: cfg.version,
|
|
166
|
+
tradeDate,
|
|
167
|
+
mode,
|
|
168
|
+
cutAt: cutHHMM,
|
|
169
|
+
updatedAt: nowBJ(),
|
|
170
|
+
runs,
|
|
171
|
+
stats: result.stats,
|
|
172
|
+
records,
|
|
173
|
+
};
|
|
174
|
+
fs.writeFileSync(file, JSON.stringify(doc, null, 1), 'utf8');
|
|
175
|
+
return { file, doc, recordCount: Object.keys(records).length, runCount: runs.length, replacedFill };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* 回填(P5):给指定交易日的正式文件写 fill 字段。
|
|
180
|
+
* @param {object} patch code -> fill 对象(整体替换该 code 的 fill)
|
|
181
|
+
* @returns {{updated:number, missing:string[]}} missing = 文件里没有的 code
|
|
182
|
+
*/
|
|
183
|
+
function applyFill(cfg, tradeDate, patch) {
|
|
184
|
+
const file = dayFile(cfg, tradeDate, 'cut');
|
|
185
|
+
if (!fs.existsSync(file)) return { updated: 0, missing: Object.keys(patch) };
|
|
186
|
+
const doc = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
187
|
+
const missing = [];
|
|
188
|
+
let updated = 0;
|
|
189
|
+
for (const [code, fill] of Object.entries(patch)) {
|
|
190
|
+
if (doc.records[code]) {
|
|
191
|
+
doc.records[code].fill = { ...fill, filledAt: nowBJ() };
|
|
192
|
+
updated++;
|
|
193
|
+
} else {
|
|
194
|
+
missing.push(code);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
if (updated) {
|
|
198
|
+
doc.updatedAt = nowBJ();
|
|
199
|
+
fs.writeFileSync(file, JSON.stringify(doc, null, 1), 'utf8');
|
|
200
|
+
}
|
|
201
|
+
return { updated, missing };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
module.exports = { saveRun, loadDay, applyFill, toRecord, dayFile, daySuffix };
|