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
package/db/schema.sql
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
-- ===========================================================================
|
|
2
|
+
-- Cloudflare D1 建表脚本(SQLite 语法)
|
|
3
|
+
-- 用法:见 db/.env.example 与部署步骤;首次建库后执行本文件。
|
|
4
|
+
-- 设计要点:
|
|
5
|
+
-- 1) pick_record 为「一次运行 × 一只票」一行,同票跨 run 自动成多行,
|
|
6
|
+
-- 满足「同一只股票在不同时期被收录,全部保留、可区分」的需求。
|
|
7
|
+
-- 2) 所有时间字段(*_at / *_date / date)统一为「北京时间字符串」,
|
|
8
|
+
-- 格式 YYYY-MM-DD HH:mm:ss(无 Z),由 dayjs 生成,避免 +8h 时差。
|
|
9
|
+
-- 3) 列注释以 SQL 行内注释(--)书写,已写入 sqlite_master 的建表原文,
|
|
10
|
+
-- 在 DB Browser for SQLite / DBeaver 等工具的「DDL / SQL」视图中可直接查看。
|
|
11
|
+
-- ===========================================================================
|
|
12
|
+
|
|
13
|
+
-- ---------------------------------------------------------------------------
|
|
14
|
+
-- 股票基础档案:一票一档,长期复用。
|
|
15
|
+
-- 题材 / 地域 / 主营 / 最赚钱业务由 enrich_stock_base.js 低频从东财 F10 补全。
|
|
16
|
+
-- ---------------------------------------------------------------------------
|
|
17
|
+
CREATE TABLE IF NOT EXISTS stock_base (
|
|
18
|
+
code TEXT PRIMARY KEY, -- 股票代码(沪深京,如 sh600519 / sz000001 / bj830799)
|
|
19
|
+
name TEXT, -- 股票名称(以行情接口返回为准)
|
|
20
|
+
sector TEXT, -- 申万一级行业
|
|
21
|
+
concepts TEXT, -- 题材概念(顿号分隔,如「锂电池、光伏、军工」)
|
|
22
|
+
region TEXT, -- 地域(省份/城市)
|
|
23
|
+
main_business TEXT, -- 主营业务概述
|
|
24
|
+
top_business TEXT, -- 最赚钱业务(按毛利率挑选,含毛利率/营收占比)
|
|
25
|
+
updated_at TEXT -- 档案最近更新时间(北京时间 YYYY-MM-DD HH:mm:ss)
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
-- ---------------------------------------------------------------------------
|
|
29
|
+
-- 每日运行批次:一次初筛(一个 anchor_date)对应一行汇总。
|
|
30
|
+
-- anchor_date = 数据锚定日(收盘后的 T-1);target_date = 面向的交易日(T)。
|
|
31
|
+
-- ---------------------------------------------------------------------------
|
|
32
|
+
CREATE TABLE IF NOT EXISTS run_batch (
|
|
33
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
34
|
+
anchor_date TEXT UNIQUE, -- 锚定日(YYYY-MM-DD),唯一;初筛数据基于该日收盘
|
|
35
|
+
target_date TEXT, -- 面向交易日(YYYY-MM-DD,即 T)
|
|
36
|
+
run_at TEXT, -- 本批次运行时间(北京时间 YYYY-MM-DD HH:mm:ss)
|
|
37
|
+
universe_count INTEGER, -- 观察池标的总数
|
|
38
|
+
r01_count INTEGER, -- 触发 R01(量能验证突破)的标的数
|
|
39
|
+
r07_count INTEGER, -- 触发 R07(板块内补涨滞后)的标的数
|
|
40
|
+
r05_count INTEGER, -- 触发 R05(尾盘量价部分达标)的标的数
|
|
41
|
+
high_count INTEGER, -- 重点(R01 四门槛全过)数量
|
|
42
|
+
secondary_count INTEGER, -- 次级(R01 核心项≥3 且市值/高位区达标)数量
|
|
43
|
+
conditional_count INTEGER, -- 条件(R01 核心项≥2 且市值/高位区达标)数量
|
|
44
|
+
excluded_count INTEGER, -- 未达任何梯队的数量(仅统计,不入库)
|
|
45
|
+
kline_fail_rate REAL, -- K 线获取失败率(0~1,越高数据越不可靠)
|
|
46
|
+
source TEXT, -- 数据来源(如 tencent-live / tencent-offline)
|
|
47
|
+
note TEXT -- 备注(人工或脚本补充)
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
-- ---------------------------------------------------------------------------
|
|
51
|
+
-- 入选 / 评估记录:每次运行、每只入选票一行(同票跨 run 成多行)。
|
|
52
|
+
-- tier 仅表示 R01 梯队;即便 tier='excluded',只要触发 R07/R05 也会被写入
|
|
53
|
+
-- (r07_laggard / r05_partial 标记为 1),前端据此识别其入选维度。
|
|
54
|
+
-- ---------------------------------------------------------------------------
|
|
55
|
+
CREATE TABLE IF NOT EXISTS pick_record (
|
|
56
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
57
|
+
run_id INTEGER NOT NULL, -- 关联 run_batch.id
|
|
58
|
+
anchor_date TEXT NOT NULL, -- 锚定日(YYYY-MM-DD)
|
|
59
|
+
code TEXT NOT NULL, -- 股票代码
|
|
60
|
+
name TEXT, -- 股票名称(快照时刻)
|
|
61
|
+
sector TEXT, -- 申万一级行业
|
|
62
|
+
tier TEXT, -- 梯队:high / secondary / conditional / excluded
|
|
63
|
+
r01_ok INTEGER, -- 0/1:R01 四门槛是否全过(重点)
|
|
64
|
+
r07_laggard INTEGER, -- 0/1:是否触发 R07 板块内补涨滞后
|
|
65
|
+
r05_partial INTEGER, -- 0/1:是否触发 R05 尾盘量价部分达标
|
|
66
|
+
core INTEGER, -- R01 量价突破达成项数(0~4)
|
|
67
|
+
r01_chg REAL, -- 当日涨幅 %(R01 口径)
|
|
68
|
+
price REAL, -- 入选价(收盘价,元)
|
|
69
|
+
turnover REAL, -- 当日换手率 %
|
|
70
|
+
vol_ratio REAL, -- 量比(当日成交量 ÷ 近 5 日均量;R01 C1 口径)
|
|
71
|
+
circ_market_cap REAL, -- 流通市值(元)
|
|
72
|
+
total_market_cap REAL, -- 总市值(元)
|
|
73
|
+
sector_pct REAL, -- 所属行业当日涨幅中位数 %
|
|
74
|
+
sector_rank INTEGER, -- 行业内的涨幅排名(R07 口径)
|
|
75
|
+
reason TEXT, -- 入选 / 排除原因(中文说明)
|
|
76
|
+
picked_at TEXT, -- 入选记录写入时间(北京时间 YYYY-MM-DD HH:mm:ss)
|
|
77
|
+
run_at TEXT, -- 快照生成时刻(北京时间);双写冲突时用于 recency guard(仅当更新值更新才覆盖)
|
|
78
|
+
UNIQUE(run_id, code)
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
-- ---------------------------------------------------------------------------
|
|
82
|
+
-- 日线行情:全观察池每日一条,作为入选后 N 日表现复盘的收益底座。
|
|
83
|
+
-- ---------------------------------------------------------------------------
|
|
84
|
+
CREATE TABLE IF NOT EXISTS price_daily (
|
|
85
|
+
code TEXT NOT NULL, -- 股票代码
|
|
86
|
+
date TEXT NOT NULL, -- 行情日期(YYYY-MM-DD,锚定日)
|
|
87
|
+
open REAL, -- 开盘价(元)
|
|
88
|
+
high REAL, -- 最高价(元)
|
|
89
|
+
low REAL, -- 最低价(元)
|
|
90
|
+
close REAL, -- 收盘价(元)
|
|
91
|
+
chg_pct REAL, -- 当日涨跌幅 %
|
|
92
|
+
turnover REAL, -- 换手率 %
|
|
93
|
+
circ_market_cap REAL, -- 流通市值(元)
|
|
94
|
+
total_market_cap REAL, -- 总市值(元)
|
|
95
|
+
run_at TEXT, -- 快照生成时刻(北京时间);双写冲突时用于 recency guard
|
|
96
|
+
PRIMARY KEY (code, date)
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
-- ---------------------------------------------------------------------------
|
|
100
|
+
-- 自定义分组:用于把票按主题/策略分类观察(如「军工」「低位补涨」)。
|
|
101
|
+
-- ---------------------------------------------------------------------------
|
|
102
|
+
CREATE TABLE IF NOT EXISTS watch_group (
|
|
103
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
104
|
+
name TEXT UNIQUE NOT NULL, -- 分组名称(唯一)
|
|
105
|
+
color TEXT, -- 分组颜色(十六进制,如 #4F8DFD),前端展示用
|
|
106
|
+
description TEXT, -- 分组说明
|
|
107
|
+
created_at TEXT -- 创建时间(北京时间 YYYY-MM-DD HH:mm:ss)
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
-- ---------------------------------------------------------------------------
|
|
111
|
+
-- 票-组 关联:一只票可加入多个分组。
|
|
112
|
+
-- ---------------------------------------------------------------------------
|
|
113
|
+
CREATE TABLE IF NOT EXISTS pick_group_rel (
|
|
114
|
+
code TEXT NOT NULL, -- 股票代码
|
|
115
|
+
group_id INTEGER NOT NULL, -- 关联 watch_group.id
|
|
116
|
+
note TEXT, -- 入组备注(如入选该组的理由)
|
|
117
|
+
created_at TEXT, -- 关联创建时间(北京时间 YYYY-MM-DD HH:mm:ss)
|
|
118
|
+
PRIMARY KEY (code, group_id)
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
-- ---------------------------------------------------------------------------
|
|
122
|
+
-- 评论 / 备注:可按运行(anchor_date)或纯按票记录。
|
|
123
|
+
-- ---------------------------------------------------------------------------
|
|
124
|
+
CREATE TABLE IF NOT EXISTS stock_note (
|
|
125
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
126
|
+
code TEXT NOT NULL, -- 股票代码
|
|
127
|
+
anchor_date TEXT, -- 关联运行锚定日(可空:通用备注,不限某次运行)
|
|
128
|
+
type TEXT, -- 类型:comment(评论)/ memo(备忘)
|
|
129
|
+
content TEXT NOT NULL, -- 备注内容
|
|
130
|
+
created_at TEXT -- 创建时间(北京时间 YYYY-MM-DD HH:mm:ss)
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
-- ---------------------------------------------------------------------------
|
|
134
|
+
-- 尾盘选股:运行批次(一个交易日 × 一种口径一行)。
|
|
135
|
+
-- mode = 'formal'(14:50 固定口径)/ 'observe'(14:30~14:50 观察口径)。
|
|
136
|
+
-- runs_json / stats_json 分别存当日多次运行历史与初筛漏斗统计(JSON)。
|
|
137
|
+
-- ---------------------------------------------------------------------------
|
|
138
|
+
CREATE TABLE IF NOT EXISTS tail_run (
|
|
139
|
+
trade_date TEXT NOT NULL, -- 交易日(YYYY-MM-DD)
|
|
140
|
+
mode TEXT NOT NULL, -- formal | observe
|
|
141
|
+
cut_at TEXT, -- 截断时点 HHMM(如 1450)
|
|
142
|
+
updated_at TEXT, -- 最近落库时间(北京时间)
|
|
143
|
+
run_at TEXT, -- 最近一次运行时刻(runs[] 末条 at)
|
|
144
|
+
candidate_count INTEGER, -- 候选数
|
|
145
|
+
group_count INTEGER, -- 行业组数
|
|
146
|
+
pre_pass_count INTEGER, -- 初筛通过数
|
|
147
|
+
snapshot_count INTEGER, -- 全市场快照数
|
|
148
|
+
runner TEXT, -- local | github
|
|
149
|
+
runs_json TEXT, -- 当日多次运行历史(JSON 数组)
|
|
150
|
+
stats_json TEXT, -- 初筛/漏斗统计(JSON)
|
|
151
|
+
PRIMARY KEY (trade_date, mode)
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
-- ---------------------------------------------------------------------------
|
|
155
|
+
-- 尾盘选股:候选记录(一个交易日 × 一种口径 × 一只票一行)。
|
|
156
|
+
-- 字段对应 eod/lib/store.js 的 toRecord;六项打分存 score_json,尾盘段存 tail_*,
|
|
157
|
+
-- 收盘回填的 N1~N10 表现存 fill_json(P5 脚本写入,缺失时由 worker 从 price_daily 现算)。
|
|
158
|
+
-- ---------------------------------------------------------------------------
|
|
159
|
+
CREATE TABLE IF NOT EXISTS tail_pick (
|
|
160
|
+
trade_date TEXT NOT NULL, -- 交易日(YYYY-MM-DD)
|
|
161
|
+
mode TEXT NOT NULL, -- formal | observe
|
|
162
|
+
code TEXT NOT NULL, -- 股票代码(sh/sz/bj 前缀)
|
|
163
|
+
name TEXT, -- 名称
|
|
164
|
+
sector TEXT, -- 行业(东财/申万)
|
|
165
|
+
board TEXT, -- 板块代码(sh/sz/bj)
|
|
166
|
+
board_label TEXT, -- 板块中文名
|
|
167
|
+
price REAL, -- 入选价(14:50 截点价,元)
|
|
168
|
+
prev_close REAL, -- 昨收
|
|
169
|
+
high REAL, -- 当日最高
|
|
170
|
+
chg_pct REAL, -- 当日涨幅 %
|
|
171
|
+
turnover REAL, -- 换手率 %
|
|
172
|
+
vol_ratio REAL, -- 量比
|
|
173
|
+
vol_ratio_est REAL, -- 收盘预估量比(参考值)
|
|
174
|
+
float_cap_yi REAL, -- 流通市值(亿)
|
|
175
|
+
avg_price REAL, -- 均价
|
|
176
|
+
group_rank INTEGER, -- 组内排名
|
|
177
|
+
best_in_group INTEGER, -- 0/1:组内最佳
|
|
178
|
+
group_size INTEGER, -- 同组数量
|
|
179
|
+
total REAL, -- 总分(0~100)
|
|
180
|
+
sector_median_chg REAL, -- 行业当日涨幅中位数 %
|
|
181
|
+
sector_rank INTEGER, -- 行业内涨幅排名
|
|
182
|
+
sector_total INTEGER, -- 行业总数
|
|
183
|
+
tail_seg_pct REAL, -- 尾盘段(14:30→14:50)涨幅 %
|
|
184
|
+
tail_up_ratio REAL, -- 段内上涨分钟占比
|
|
185
|
+
tail_max_drawdown_pct REAL, -- 段内最大回撤 %
|
|
186
|
+
tail_price_vs_avg_pct REAL, -- 现价相对均价超出 %
|
|
187
|
+
tail_avg_at_cut REAL, -- 截点时刻均价
|
|
188
|
+
tail_p0 REAL, -- 段起点价
|
|
189
|
+
tail_p1 REAL, -- 段终点价
|
|
190
|
+
tail_bars TEXT, -- 尾盘段分钟序列(JSON 数组)
|
|
191
|
+
score_json TEXT, -- 六项子分(JSON)
|
|
192
|
+
fill_json TEXT, -- 收盘回填:{close,n1..n10,filled_at}(JSON)
|
|
193
|
+
PRIMARY KEY (trade_date, mode, code)
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
-- ---------------------------------------------------------------------------
|
|
197
|
+
-- 索引:加速按交易日 / 按口径 / 按票 的查询。
|
|
198
|
+
-- ---------------------------------------------------------------------------
|
|
199
|
+
CREATE INDEX IF NOT EXISTS idx_pick_code ON pick_record(code);
|
|
200
|
+
CREATE INDEX IF NOT EXISTS idx_pick_date ON pick_record(anchor_date);
|
|
201
|
+
CREATE INDEX IF NOT EXISTS idx_pick_tier ON pick_record(tier);
|
|
202
|
+
CREATE INDEX IF NOT EXISTS idx_pick_run ON pick_record(run_id);
|
|
203
|
+
CREATE INDEX IF NOT EXISTS idx_price_code ON price_daily(code);
|
|
204
|
+
CREATE INDEX IF NOT EXISTS idx_price_date ON price_daily(date);
|
|
205
|
+
CREATE INDEX IF NOT EXISTS idx_note_code ON stock_note(code);
|
|
206
|
+
CREATE INDEX IF NOT EXISTS idx_group_rel_code ON pick_group_rel(code);
|
|
207
|
+
CREATE INDEX IF NOT EXISTS idx_group_rel_gid ON pick_group_rel(group_id);
|
|
208
|
+
|
|
209
|
+
-- 尾盘选股索引
|
|
210
|
+
CREATE INDEX IF NOT EXISTS idx_tail_pick_date ON tail_pick(trade_date);
|
|
211
|
+
CREATE INDEX IF NOT EXISTS idx_tail_pick_mode ON tail_pick(mode);
|
|
212
|
+
CREATE INDEX IF NOT EXISTS idx_tail_pick_code ON tail_pick(code);
|
|
213
|
+
|
|
214
|
+
-- ---------------------------------------------------------------------------
|
|
215
|
+
-- 结构同步元信息(由 db/migrate.js 维护,业务代码不读它)。
|
|
216
|
+
-- key = 'schema_hash' → 已应用到本库的 schema.sql 指纹(用于核对两端是否同一版本)
|
|
217
|
+
-- key = 'mig:<文件名>' → 已执行过的增量迁移(db/migrations/*.sql,只跑一次)
|
|
218
|
+
-- 用途:跑 `npm run db:migrate` 后一眼看出「本地/线上各自同步到哪一版」,避免再出现
|
|
219
|
+
-- 「代码已引用新表、库里没有」这种只在运行时才暴露的问题。
|
|
220
|
+
-- ---------------------------------------------------------------------------
|
|
221
|
+
CREATE TABLE IF NOT EXISTS schema_meta (
|
|
222
|
+
key TEXT PRIMARY KEY,
|
|
223
|
+
value TEXT,
|
|
224
|
+
updated_at TEXT
|
|
225
|
+
);
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// 本地 SQLite 客户端:使用 Node 22 内置 node:sqlite,零依赖。
|
|
3
|
+
// 接口与 d1client.js 完全一致(exec / query / batch 均为 async),
|
|
4
|
+
// persist.js / enrich_stock_base.js 可以「本地开发」与「Cloudflare D1」之间无缝切换。
|
|
5
|
+
//
|
|
6
|
+
// 用法:
|
|
7
|
+
// const { createClient } = require('./sqlite_client');
|
|
8
|
+
// const db = createClient(); // 默认 db/local.db
|
|
9
|
+
// const db = createClient('x.db'); // 指定文件
|
|
10
|
+
// await db.query('SELECT ...', [p]); // 查询 -> 行数组
|
|
11
|
+
// await db.query('INSERT ...', [p]); // 写入 -> []
|
|
12
|
+
// await db.batch([{sql, params}, ...]);
|
|
13
|
+
// db.close();
|
|
14
|
+
|
|
15
|
+
const fs = require('fs');
|
|
16
|
+
const path = require('path');
|
|
17
|
+
|
|
18
|
+
// 必须在 require node:sqlite 之前抑制实验性告警(否则仍会在首次加载时打印)
|
|
19
|
+
const _origEmit = process.emit;
|
|
20
|
+
process.emit = function (type, ...args) {
|
|
21
|
+
if (type === 'warning' && args[0] && args[0].name === 'ExperimentalWarning' &&
|
|
22
|
+
/SQLite/i.test(args[0].message || '')) return false;
|
|
23
|
+
return _origEmit.apply(process, arguments);
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const { DatabaseSync } = require('node:sqlite');
|
|
27
|
+
|
|
28
|
+
const paths = require('../paths');
|
|
29
|
+
|
|
30
|
+
// 本地库文件跟随工作目录(见 paths.js):默认 <仓库根>/db/local.db。
|
|
31
|
+
// 注意 SCHEMA_FILE 是**代码资产**,永远跟包走,不随工作目录移动。
|
|
32
|
+
const DEFAULT_FILE = paths.localDb();
|
|
33
|
+
const SCHEMA_FILE = path.join(__dirname, 'schema.sql');
|
|
34
|
+
|
|
35
|
+
function applySchema(db) {
|
|
36
|
+
const sql = fs.readFileSync(SCHEMA_FILE, 'utf8');
|
|
37
|
+
db.exec(sql); // schema.sql 全部 IF NOT EXISTS,可重复执行
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function createClient(file) {
|
|
41
|
+
const f = file || DEFAULT_FILE;
|
|
42
|
+
const dir = path.dirname(f);
|
|
43
|
+
if (dir && !fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
44
|
+
|
|
45
|
+
const db = new DatabaseSync(f);
|
|
46
|
+
// 每次打开都应用 schema.sql:它是唯一真相源,且全部 IF NOT EXISTS,重复执行安全(毫秒级)。
|
|
47
|
+
// 反面教训(2026-09-21 线上四页 500):原实现是「仅当缺 run_batch 表时才建表」——
|
|
48
|
+
// 于是库早已存在的机器上,后续新增的 tail_run / tail_pick 永远不会被创建,
|
|
49
|
+
// 本地库结构永久停在旧版本,直到某个接口查询时才炸。不要再加任何「缺某表才建」的条件。
|
|
50
|
+
applySchema(db);
|
|
51
|
+
|
|
52
|
+
const isRead = (sql) => /^\s*(SELECT|WITH|PRAGMA|EXPLAIN|VALUES)\b/i.test(sql.trim());
|
|
53
|
+
|
|
54
|
+
async function exec(sql) { db.exec(sql); return []; }
|
|
55
|
+
|
|
56
|
+
async function query(sql, params = []) {
|
|
57
|
+
const ps = db.prepare(sql);
|
|
58
|
+
if (isRead(sql)) return ps.all(...params);
|
|
59
|
+
ps.run(...params);
|
|
60
|
+
return [];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function batch(statements) {
|
|
64
|
+
// 显式事务(不依赖 node:sqlite 的 db.transaction,跨版本更稳)
|
|
65
|
+
db.exec('BEGIN');
|
|
66
|
+
try {
|
|
67
|
+
for (const st of statements) db.prepare(st.sql).run(...(st.params || []));
|
|
68
|
+
db.exec('COMMIT');
|
|
69
|
+
} catch (e) {
|
|
70
|
+
try { db.exec('ROLLBACK'); } catch (_) {}
|
|
71
|
+
throw e;
|
|
72
|
+
}
|
|
73
|
+
return [];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function close() { try { db.close(); } catch (_) {} }
|
|
77
|
+
|
|
78
|
+
return { exec, query, batch, close, file: f };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
module.exports = { createClient, DEFAULT_FILE };
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// 一致性哨兵:比对「由快照派生的 4 张表」在远程 D1 与本地 db/local.db 上的指纹。
|
|
3
|
+
//
|
|
4
|
+
// 为什么可以直接比:
|
|
5
|
+
// 这 4 张表完全由 data/snapshot-*.json 派生,写入是「纯函数式规范化 + 幂等」
|
|
6
|
+
// (UNIQUE(anchor_date) / UNIQUE(run_id,code) / UNIQUE(code,date) + run_at recency guard)。
|
|
7
|
+
// 因此两端只要回放过同一批快照,指纹必然逐字段相等;不相等就说明有一端没跟上。
|
|
8
|
+
//
|
|
9
|
+
// 不参与比对的表:watch_group / pick_group_rel / stock_note
|
|
10
|
+
// —— 这三张由网页写接口产生,只存在于线上,本地库没有也不该有。
|
|
11
|
+
//
|
|
12
|
+
// 指纹一律用「整型化聚合」(金额 ×100、涨幅 ×1000 后 ROUND 求和),
|
|
13
|
+
// 避免两端浮点求和顺序不同导致末位差异误报。
|
|
14
|
+
//
|
|
15
|
+
// 退出码:0 = 全部一致;1 = 存在不一致或无法比对(可直接用于定时任务哨兵)。
|
|
16
|
+
const d1 = require('./d1client');
|
|
17
|
+
const localSqlite = require('./sqlite_client');
|
|
18
|
+
|
|
19
|
+
const CHECKS = [
|
|
20
|
+
{
|
|
21
|
+
table: 'run_batch',
|
|
22
|
+
sql: `SELECT COUNT(*) AS 运行批次,
|
|
23
|
+
IFNULL(SUM(universe_count), 0) AS 全池合计,
|
|
24
|
+
IFNULL(SUM(r01_count + r07_count + r05_count), 0) AS 规则命中合计,
|
|
25
|
+
MAX(anchor_date) AS 最新锚定日
|
|
26
|
+
FROM run_batch`,
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
table: 'pick_record',
|
|
30
|
+
sql: `SELECT COUNT(*) AS 入选记录,
|
|
31
|
+
COUNT(DISTINCT code) AS 入选个股,
|
|
32
|
+
COUNT(DISTINCT run_id) AS 覆盖批次,
|
|
33
|
+
IFNULL(SUM(CAST(ROUND(r01_chg * 1000) AS INTEGER)), 0) AS R01涨幅毫数和,
|
|
34
|
+
IFNULL(SUM(CAST(ROUND(price * 100) AS INTEGER)), 0) AS 入选价分和
|
|
35
|
+
FROM pick_record`,
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
table: 'price_daily',
|
|
39
|
+
sql: `SELECT COUNT(*) AS 日线行数,
|
|
40
|
+
COUNT(DISTINCT code) AS 覆盖个股,
|
|
41
|
+
COUNT(DISTINCT date) AS 交易日数,
|
|
42
|
+
MIN(date) AS 首日,
|
|
43
|
+
MAX(date) AS 末日,
|
|
44
|
+
IFNULL(SUM(CAST(ROUND(close * 100) AS INTEGER)), 0) AS 收盘分和
|
|
45
|
+
FROM price_daily`,
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
table: 'stock_base',
|
|
49
|
+
sql: `SELECT COUNT(*) AS 档案行数, COUNT(DISTINCT code) AS 档案个股 FROM stock_base`,
|
|
50
|
+
},
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
const fmt = (v) => (v === null || v === undefined ? 'null' : String(v));
|
|
54
|
+
|
|
55
|
+
(async () => {
|
|
56
|
+
if (!d1.cfg()) {
|
|
57
|
+
console.error('[verify] 缺少 CF_ACCOUNT_ID / CF_D1_DATABASE_ID / CF_API_TOKEN,无法读取远程 D1');
|
|
58
|
+
process.exit(1);
|
|
59
|
+
}
|
|
60
|
+
const local = localSqlite.createClient();
|
|
61
|
+
console.log('[verify] 远程 D1 ←→ 本地 ' + local.file);
|
|
62
|
+
console.log('');
|
|
63
|
+
|
|
64
|
+
let bad = 0;
|
|
65
|
+
const todo = [];
|
|
66
|
+
for (const chk of CHECKS) {
|
|
67
|
+
let remoteRow = null, localRow = null, err = '';
|
|
68
|
+
try { remoteRow = (await d1.query(chk.sql))[0] || null; } catch (e) { err = '远程读取失败: ' + e.message; }
|
|
69
|
+
try { localRow = (await local.query(chk.sql))[0] || null; } catch (e) { err = (err ? err + ';' : '') + '本地读取失败: ' + e.message; }
|
|
70
|
+
|
|
71
|
+
if (err) {
|
|
72
|
+
bad++;
|
|
73
|
+
console.log(` ${chk.table.padEnd(13)} FAIL ${err}`);
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const keys = Object.keys(remoteRow);
|
|
78
|
+
const diffs = [];
|
|
79
|
+
const pairs = [];
|
|
80
|
+
for (const k of keys) {
|
|
81
|
+
const a = remoteRow[k] ?? null;
|
|
82
|
+
const b = localRow ? (localRow[k] ?? null) : null;
|
|
83
|
+
pairs.push(`${k}=${fmt(a)}|${fmt(b)}`);
|
|
84
|
+
if (fmt(a) !== fmt(b)) diffs.push(`${k}(远程 ${fmt(a)} / 本地 ${fmt(b)})`);
|
|
85
|
+
}
|
|
86
|
+
if (diffs.length) {
|
|
87
|
+
bad++;
|
|
88
|
+
console.log(` ${chk.table.padEnd(13)} FAIL ${diffs.join(' ')}`);
|
|
89
|
+
todo.push(chk.table);
|
|
90
|
+
} else {
|
|
91
|
+
console.log(` ${chk.table.padEnd(13)} PASS ${pairs.join(' ')}`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
local.close();
|
|
96
|
+
console.log('');
|
|
97
|
+
if (bad) {
|
|
98
|
+
console.log(`[verify] ${CHECKS.length} 张表中 ${bad} 张不一致:${todo.join(' / ')}`);
|
|
99
|
+
console.log('[verify] 修复:node db/backfill.js --both (全量幂等重放,落后的那一端会被补齐)');
|
|
100
|
+
process.exit(1);
|
|
101
|
+
}
|
|
102
|
+
console.log(`[verify] 全部一致:${CHECKS.length}/${CHECKS.length} 张派生表逐字段相同`);
|
|
103
|
+
process.exit(0);
|
|
104
|
+
})().catch((e) => {
|
|
105
|
+
console.error('[verify] 中止:', e.message);
|
|
106
|
+
process.exit(1);
|
|
107
|
+
});
|
package/db/write_live.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// 实时入库:读取「最近一次」快照,写入
|
|
3
|
+
// Cloudflare D1(默认,不写本地)/ 本地 SQLite(--local)/ 两边都写(--both)。
|
|
4
|
+
// 由 run_today.cmd/sh 在 gen_candidates 之后链式调用 —— 本机任务用 --both,
|
|
5
|
+
// 这样一次运行就让本地库与线上库同时前进,不会出现「一边新一边旧」。
|
|
6
|
+
// 凭据缺失或入库失败时:打印告警并退出 0(不阻断报告生成本身)。
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
const path = require('path');
|
|
9
|
+
const { persistSnap } = require('./persist');
|
|
10
|
+
const { resolveTargets } = require('./clients');
|
|
11
|
+
const paths = require('../paths');
|
|
12
|
+
|
|
13
|
+
// 快照目录跟随工作目录(见 paths.js):默认 <仓库根>/data
|
|
14
|
+
const DATA = paths.dataDir();
|
|
15
|
+
|
|
16
|
+
function latestSnap() {
|
|
17
|
+
if (!fs.existsSync(DATA)) return null;
|
|
18
|
+
const files = fs.readdirSync(DATA).filter(f => /^snapshot-\d{4}-\d{2}-\d{2}\.json$/.test(f)).sort();
|
|
19
|
+
if (!files.length) return null;
|
|
20
|
+
return path.join(DATA, files[files.length - 1]);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
(async () => {
|
|
24
|
+
const { targets, notes } = resolveTargets();
|
|
25
|
+
for (const n of notes) console.log('[db] ' + n);
|
|
26
|
+
if (!targets.length) {
|
|
27
|
+
console.log('[db] 未指定写入目标:--local 写本地 / --both 两边都写 / 配好 CF_* 凭据后默认写远程');
|
|
28
|
+
process.exit(0);
|
|
29
|
+
}
|
|
30
|
+
for (const t of targets) console.log(`[db] 写入目标 ${t.name}${t.file ? ' -> ' + t.file : ''}`);
|
|
31
|
+
|
|
32
|
+
const f = latestSnap();
|
|
33
|
+
if (!f) { console.log('[db] 无 snapshot 可入库'); process.exit(0); }
|
|
34
|
+
const snap = JSON.parse(fs.readFileSync(f, 'utf8'));
|
|
35
|
+
console.log(`[db] 入库快照 ${path.basename(f)}(锚定日 ${snap.anchor})`);
|
|
36
|
+
|
|
37
|
+
let failed = 0;
|
|
38
|
+
for (const t of targets) {
|
|
39
|
+
try {
|
|
40
|
+
const r = await persistSnap(snap, t.client);
|
|
41
|
+
console.log(`[db] ${t.name} 完成 run_id=${r.runId} picks=${r.picks} prices=${r.prices} base=${r.base}`);
|
|
42
|
+
} catch (e) {
|
|
43
|
+
failed++;
|
|
44
|
+
console.error(`[db] ${t.name} 入库失败(不影响报告): ` + ((e && e.message) || e));
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
for (const t of targets) if (t.client.close) t.client.close();
|
|
48
|
+
|
|
49
|
+
if (failed) {
|
|
50
|
+
console.error('[db] 单边补写:远程 `node db/write_live.js` / 本地 `node db/write_live.js --local`');
|
|
51
|
+
console.error('[db] 若快照有缺漏(不止今天这一份),直接全量重放:`node db/backfill.js --both`');
|
|
52
|
+
}
|
|
53
|
+
process.exit(0);
|
|
54
|
+
})();
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
/*
|
|
4
|
+
* eod/build_industry_map.js —— 生成「代码 → 行业(+ 上市日期)」缓存
|
|
5
|
+
* ---------------------------------------------------------------------------
|
|
6
|
+
* 为什么单独做成低频脚本:
|
|
7
|
+
* 行业归属几乎不变(一年也就调几次成分),但获取全市场映射必须走东财 clist
|
|
8
|
+
* (一次 60 页、每页 100 只)。而**东财对突发高频请求会封 IP**(实测十余次请求后即
|
|
9
|
+
* socket hang up),所以绝不能放进 14:50 的关键路径 —— 那里只读本脚本产出的缓存文件。
|
|
10
|
+
*
|
|
11
|
+
* 源与降级:
|
|
12
|
+
* 1. 东财 clist(主源,含 f100 行业 + f26 上市日期)
|
|
13
|
+
* fs 覆盖 沪深A股 + 科创板 + 创业板 + 北交所;每页之间加节流,避免被封。
|
|
14
|
+
* 2. 失败时**保留既有缓存文件**并以非零码退出,绝不用半截数据覆盖好数据。
|
|
15
|
+
*
|
|
16
|
+
* 用法:
|
|
17
|
+
* node eod/build_industry_map.js # 刷新缓存
|
|
18
|
+
* node eod/build_industry_map.js --dry # 只统计不写文件
|
|
19
|
+
* ---------------------------------------------------------------------------
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const fs = require('fs');
|
|
23
|
+
const path = require('path');
|
|
24
|
+
const { httpGet } = require('./lib/http');
|
|
25
|
+
const { now } = require('../time');
|
|
26
|
+
const paths = require('../paths');
|
|
27
|
+
|
|
28
|
+
// 输出位置跟随工作目录(见 paths.js):默认 <仓库根>/eod/data/industry-map.json
|
|
29
|
+
const OUT = paths.industryMapFile();
|
|
30
|
+
|
|
31
|
+
// 沪深A股 + 科创板 + 创业板 + 北交所。与全市场快照的代码覆盖面对齐。
|
|
32
|
+
const FS_ALL = 'm:1+t:2,m:1+t:23,m:0+t:6,m:0+t:80,m:0+t:81+s:2048';
|
|
33
|
+
const FIELDS = 'f12,f13,f14,f100,f26';
|
|
34
|
+
const PAGE_SIZE = 100;
|
|
35
|
+
// 东财有多个编号镜像主机;主站被封时换一个往往仍可用(实测 push2 全挂时 push2delay 正常)。
|
|
36
|
+
const HOSTS = [
|
|
37
|
+
'https://push2.eastmoney.com',
|
|
38
|
+
'https://push2delay.eastmoney.com',
|
|
39
|
+
'https://82.push2.eastmoney.com',
|
|
40
|
+
];
|
|
41
|
+
const THROTTLE_MS = 220;
|
|
42
|
+
|
|
43
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* 东财返回的 6 位代码 -> 腾讯风格全码。
|
|
47
|
+
* 注意不能用 f13(市场)判断:东财把北交所也归到市场 0,
|
|
48
|
+
* 若按市场拼前缀会把 8xxxxx/920xxx 错拼成 sz。必须按代码首位判断。
|
|
49
|
+
*/
|
|
50
|
+
function toTsCode(code6) {
|
|
51
|
+
const c = String(code6);
|
|
52
|
+
if (/^6/.test(c)) return 'sh' + c;
|
|
53
|
+
if (/^[03]/.test(c)) return 'sz' + c;
|
|
54
|
+
if (/^[489]/.test(c)) return 'bj' + c;
|
|
55
|
+
return null; // 无法归类的(如 B 股)直接丢弃
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function fetchPage(host, pn) {
|
|
59
|
+
const url = `${host}/api/qt/clist/get?pn=${pn}&pz=${PAGE_SIZE}&po=1&np=1&fltt=2&invt=2`
|
|
60
|
+
+ `&fid=f12&fs=${FS_ALL}&fields=${FIELDS}`;
|
|
61
|
+
return httpGet(url, { json: true, retries: 3, timeout: 20000, referer: 'https://quote.eastmoney.com/' });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
(async () => {
|
|
65
|
+
const dry = process.argv.includes('--dry');
|
|
66
|
+
let host = null;
|
|
67
|
+
const stocks = {};
|
|
68
|
+
let page = 1;
|
|
69
|
+
let total = 0;
|
|
70
|
+
|
|
71
|
+
for (; page <= 200; page++) {
|
|
72
|
+
let obj = null;
|
|
73
|
+
// 逐主机降级:任一主机能出数就锁定它,后续页固定走同一台(避免来回换台被判定异常)
|
|
74
|
+
const order = host ? [host, ...HOSTS.filter((h) => h !== host)] : HOSTS;
|
|
75
|
+
let lastErr = null;
|
|
76
|
+
for (const h of order) {
|
|
77
|
+
try {
|
|
78
|
+
obj = await fetchPage(h, page);
|
|
79
|
+
if (obj && obj.data) {
|
|
80
|
+
if (host !== h) {
|
|
81
|
+
host = h;
|
|
82
|
+
console.log(` 数据源: ${h}`);
|
|
83
|
+
}
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
} catch (e) { lastErr = e; }
|
|
87
|
+
}
|
|
88
|
+
if (!obj || !obj.data) {
|
|
89
|
+
console.error(` 第 ${page} 页获取失败(${lastErr ? lastErr.message : '空响应'})`);
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
if (page === 1) {
|
|
93
|
+
total = obj.data.total || 0;
|
|
94
|
+
console.log(` 全市场 ${total} 只,约 ${Math.ceil(total / PAGE_SIZE)} 页`);
|
|
95
|
+
}
|
|
96
|
+
const rows = obj.data.diff || [];
|
|
97
|
+
if (!rows.length) break;
|
|
98
|
+
for (const r of rows) {
|
|
99
|
+
const code = toTsCode(r.f12);
|
|
100
|
+
if (!code) continue;
|
|
101
|
+
// 东财对部分个股(B 股壳、未分类次新等)回 '-':当作「无行业」处理,
|
|
102
|
+
// 让下游落到统一的「未分类」桶,而不是把 '-' 当成一个真实行业参与板块强度排序。
|
|
103
|
+
const sector = String(r.f100 || '').trim();
|
|
104
|
+
if (!sector || sector === '-') continue;
|
|
105
|
+
stocks[code] = { name: String(r.f14 || '').trim(), sector, listed: r.f26 ? String(r.f26) : null };
|
|
106
|
+
}
|
|
107
|
+
process.stdout.write(`\r 已抓 ${Object.keys(stocks).length} 条 / 共 ${total}`);
|
|
108
|
+
if (rows.length < PAGE_SIZE) break;
|
|
109
|
+
await sleep(THROTTLE_MS);
|
|
110
|
+
}
|
|
111
|
+
process.stdout.write('\n');
|
|
112
|
+
|
|
113
|
+
const n = Object.keys(stocks).length;
|
|
114
|
+
console.log(`\n共解析 ${n} 只(${(n / (total || 1) * 100).toFixed(1)}% 覆盖)`);
|
|
115
|
+
|
|
116
|
+
if (n < 3000) {
|
|
117
|
+
console.error(`\n✗ 解析条数过少(${n}),判定抓取不完整,**不覆盖既有缓存**。`);
|
|
118
|
+
console.error(' 若东财封禁本机 IP,可稍后重试;期间站点会沿用旧缓存(不影响运行)。');
|
|
119
|
+
process.exit(1);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// 行业分布概览
|
|
123
|
+
const bySector = {};
|
|
124
|
+
for (const v of Object.values(stocks)) bySector[v.sector] = (bySector[v.sector] || 0) + 1;
|
|
125
|
+
const sectors = Object.entries(bySector).sort((a, b) => b[1] - a[1]);
|
|
126
|
+
console.log(`行业数 ${sectors.length},前 12:`);
|
|
127
|
+
for (const [s, c] of sectors.slice(0, 12)) console.log(` ${s.padEnd(10)} ${c}`);
|
|
128
|
+
const noListed = Object.values(stocks).filter((v) => !v.listed).length;
|
|
129
|
+
console.log(`上市日期缺失 ${noListed} 只(缺失时新股判定退回名称 N/C 前缀)`);
|
|
130
|
+
|
|
131
|
+
if (dry) { console.log('\n--dry:未写入文件'); return; }
|
|
132
|
+
|
|
133
|
+
const payload = {
|
|
134
|
+
generatedAt: now(),
|
|
135
|
+
source: `eastmoney clist (${host})`,
|
|
136
|
+
count: n,
|
|
137
|
+
stocks,
|
|
138
|
+
};
|
|
139
|
+
fs.mkdirSync(path.dirname(OUT), { recursive: true });
|
|
140
|
+
fs.writeFileSync(OUT, JSON.stringify(payload), 'utf8');
|
|
141
|
+
const kb = (fs.statSync(OUT).size / 1024).toFixed(0);
|
|
142
|
+
console.log(`\n已写出 ${OUT}(${n} 条,${kb} KB)`);
|
|
143
|
+
})().catch((e) => {
|
|
144
|
+
console.error('构建失败:', (e && e.stack) || e);
|
|
145
|
+
process.exit(1);
|
|
146
|
+
});
|