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.
@@ -0,0 +1,231 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ /**
4
+ * build_universe.js —— 生成/刷新候选观察池 candidates.json
5
+ *
6
+ * 背景:R01(量能验证突破)的市值门槛是 20~500 亿,属于中小盘短线打法;
7
+ * R07(板块内补涨)要求同行业有足够样本,中位涨幅才有代表性。
8
+ * 因此观察池必须「行业铺得开 + 中小盘为主 + 保留少量龙头做行业锚」。
9
+ *
10
+ * 做法:种子清单只维护【6 位代码 + 申万一级行业】,其余全部由接口回填并校验:
11
+ * 1. 批量拉 qt.gtimg.cn 报价(一次可查上百只,GBK 编码)
12
+ * 2. 丢弃:代码不存在 / 停牌无价 / 名称含 ST / 总市值低于下限
13
+ * 3. 股票名称一律以接口返回为准,避免人工维护的名称与代码错配
14
+ *
15
+ * 用法:
16
+ * node build_universe.js # 生成 candidates.json
17
+ * node build_universe.js --min-cap 15 # 自定义市值下限(亿)
18
+ * node build_universe.js --dry # 只打印结果不写文件
19
+ */
20
+
21
+ const fs = require('fs');
22
+ const path = require('path');
23
+ const https = require('https');
24
+ const { now } = require('./time');
25
+ const paths = require('./paths');
26
+
27
+ // 输出跟随工作目录(见 paths.js):默认 <仓库根>/candidates.json
28
+ const OUT = paths.candidatesFile();
29
+ const BATCH = 60; // 每批查询只数
30
+ const DEFAULT_MIN_CAP = 15; // 总市值下限(亿)
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // 种子清单:申万一级行业 -> 6 位代码
34
+ // 以中小盘为主,并保留各行业龙头作为行业涨幅锚点(R01 会用 20~500 亿自行卡市值)
35
+ // ---------------------------------------------------------------------------
36
+ const SEED = {
37
+ '食品饮料': ['600519', '000858', '600887', '603288', '002557', '002847', '002216', '600305',
38
+ '002481', '603719', '300973', '603345', '002732', '603517', '603866', '600882',
39
+ '603711', '603317', '002507', '002661', '603027', '603697'],
40
+ '医药生物': ['600276', '603259', '300760', '300122', '300003', '002007', '300244', '300482',
41
+ '603658', '300529', '688180', '300639', '603987', '002019', '600079', '000513',
42
+ '600976', '603229', '300888', '002020'],
43
+ '电力设备': ['300750', '601012', '300274', '002028', '601567', '300001', '002518', '603606',
44
+ '002335', '300724', '002851', '603063', '300827', '002531', '300443', '603985',
45
+ '002080', '600885', '002506'],
46
+ '汽车': ['002594', '601633', '600104', '002472', '603596', '002536', '603997', '300258',
47
+ '002101', '601965', '002906', '603178', '600699', '603348', '300825', '605005'],
48
+ '家用电器': ['000333', '000651', '600690', '002032', '002242', '603868', '002508', '002959',
49
+ '603579', '002614', '605365', '300894', '603515', '002035', '000921', '603355', '002403'],
50
+ '电子': ['002475', '002241', '603501', '688981', '002049', '300623', '300782', '688396',
51
+ '002156', '300661', '002185', '300223', '603160', '300458', '603662', '002138',
52
+ '300408', '300207', '300679', '603236', '300346'],
53
+ '计算机': ['002230', '688111', '603019', '300454', '002439', '300036', '300253', '300377',
54
+ '300271', '603039', '300579', '300085', '002195', '300229', '600845', '002261', '300525'],
55
+ '传媒': ['300413', '002027', '300251', '002739', '601801', '300058', '002400', '600088',
56
+ '300148', '002803', '601928', '300182', '600551', '300364', '002624', '300043'],
57
+ '银行': ['600036', '601398', '002142', '002966', '601528', '601860', '600926', '002807',
58
+ '603323', '601128', '600908', '601077', '601187', '600928', '002958'],
59
+ '非银金融': ['300059', '600030', '601377', '600909', '601198', '000728', '600155', '002926',
60
+ '601108', '000750', '601375', '002673', '601696', '300773'],
61
+ '房地产': ['600048', '000002', '000961', '600325', '002244', '600663', '600648', '000402',
62
+ '600383', '600895', '000031', '600639', '002285'],
63
+ '建筑材料': ['600585', '002271', '603737', '600801', '000401', '603916', '002088', '603601',
64
+ '300715', '002233', '600720', '603266'],
65
+ '基础化工': ['600309', '002324', '603650', '002326', '600409', '002250', '603067', '300848',
66
+ '002741', '603379', '002497', '600596', '600426'],
67
+ '机械设备': ['600031', '300124', '603338', '002158', '603611', '300415', '603757', '002559',
68
+ '002438', '300185', '603025', '002097', '300747', '603667'],
69
+ '有色金属': ['601899', '002460', '600549', '002203', '000807', '600711', '002378', '601168',
70
+ '000630', '600497', '002114', '000751', '600459', '002240'],
71
+ '煤炭': ['601088', '601225', '000983', '600971', '601001', '000933', '600985', '601699',
72
+ '600395', '601918', '000552', '600123', '601666'],
73
+ '石油石化': ['600028', '601857', '603353', '002554', '600583', '603727', '002207', '600871',
74
+ '000637', '603619', '300084', '002828'],
75
+ '钢铁': ['600019', '000709', '600507', '000825', '600282', '002110', '600581', '000778',
76
+ '600808', '002478', '600117', '002756', '603878'],
77
+ '交通运输': ['002352', '601816', '600233', '002468', '603056', '002120', '601008', '600717',
78
+ '000088', '600798', '601018', '600018', '603565', '603967'],
79
+ '农林牧渔': ['002714', '300498', '002567', '603477', '002385', '002124', '600975', '002100',
80
+ '300761', '600598', '000998', '002299', '603609', '002234', '300087', '002311'],
81
+ '公用事业': ['600900', '000883', '000539', '600863', '000027', '600995', '601016', '000531',
82
+ '600236', '000601', '600098', '300070', '000544', '601158'],
83
+ '通信': ['000063', '600941', '002396', '600498', '300394', '002281', '600487', '002115',
84
+ '603322', '002446', '600522', '300628'],
85
+ '国防军工': ['600760', '600893', '300696', '002389', '300719', '002465', '300775', '603267',
86
+ '600435', '002025', '600501', '300581', '002829', '688122'],
87
+ '商贸零售': ['601933', '002251', '600694', '000759', '600828', '002697', '603708', '002187',
88
+ '600682', '000715', '603214', '603123'],
89
+ '美容护理': ['603605', '603983', '300740', '605136', '002762', '603630', '300886', '605009'],
90
+ '轻工制造': ['603833', '002572', '603816', '300616', '603313', '002853', '603898', '600433',
91
+ '002078', '600966', '002511', '603116'],
92
+ };
93
+
94
+ // ---------------------------------------------------------------------------
95
+ function toFullCode(c) {
96
+ const s = String(c).trim();
97
+ if (/^6/.test(s)) return 'sh' + s;
98
+ if (/^[03]/.test(s)) return 'sz' + s;
99
+ if (/^[48]/.test(s)) return 'bj' + s;
100
+ return 'sh' + s;
101
+ }
102
+
103
+ function decodeGbk(buf) {
104
+ try { return new TextDecoder('gbk').decode(buf); }
105
+ catch (e) { return buf.toString('binary'); } // 兜底:中文会乱码,但代码/数字仍可用
106
+ }
107
+
108
+ function getRaw(url, retries = 3) {
109
+ return new Promise(async (resolve, reject) => {
110
+ let lastErr;
111
+ for (let i = 0; i < retries; i++) {
112
+ if (i > 0) await new Promise(r => setTimeout(r, 600 * i));
113
+ try {
114
+ return resolve(await new Promise((res, rej) => {
115
+ const req = https.get(url, {
116
+ timeout: 20000,
117
+ headers: { 'User-Agent': 'Mozilla/5.0', 'Referer': 'https://gu.qq.com/' },
118
+ }, r => {
119
+ const chunks = [];
120
+ r.on('data', c => chunks.push(c));
121
+ r.on('end', () => res(Buffer.concat(chunks)));
122
+ });
123
+ req.on('error', rej);
124
+ req.on('timeout', () => { req.destroy(); rej(new Error('timeout')); });
125
+ }));
126
+ } catch (e) { lastErr = e; }
127
+ }
128
+ reject(lastErr);
129
+ });
130
+ }
131
+
132
+ async function fetchBatch(codes) {
133
+ const buf = await getRaw('https://qt.gtimg.cn/q=' + codes.join(','));
134
+ const txt = decodeGbk(buf);
135
+ const out = {};
136
+ for (const seg of txt.split(';')) {
137
+ const m = seg.match(/v_([a-z]{2}\d{6})="([^"]*)"/);
138
+ if (!m) continue;
139
+ const f = m[2].split('~');
140
+ if (f.length < 50) continue;
141
+ const price = parseFloat(f[3]) || parseFloat(f[4]);
142
+ out[m[1]] = {
143
+ code: m[1],
144
+ name: (f[1] || '').replace(/\s+/g, ''), // 交易所原始简称含空格(如“五 粮 液”),统一去掉
145
+ price,
146
+ circCapYi: parseFloat(f[44]), // 流通市值(亿)
147
+ totalCapYi: parseFloat(f[45]), // 总市值(亿)
148
+ };
149
+ }
150
+ return out;
151
+ }
152
+
153
+ function parseArgs() {
154
+ const a = process.argv.slice(2);
155
+ const get = (k, d) => { const i = a.indexOf(k); return i >= 0 ? a[i + 1] : d; };
156
+ return { minCap: +get('--min-cap', DEFAULT_MIN_CAP), dry: a.includes('--dry') };
157
+ }
158
+
159
+ (async () => {
160
+ const { minCap, dry } = parseArgs();
161
+
162
+ // 展开种子清单并去重(同一代码若出现在多个行业,以首次出现为准)
163
+ const seen = new Set();
164
+ const wanted = [];
165
+ for (const [sector, list] of Object.entries(SEED)) {
166
+ for (const c of list) {
167
+ const full = toFullCode(c);
168
+ if (seen.has(full)) continue;
169
+ seen.add(full);
170
+ wanted.push({ code: full, sector });
171
+ }
172
+ }
173
+ console.log(`种子清单 ${wanted.length} 只,覆盖 ${Object.keys(SEED).length} 个申万一级行业`);
174
+
175
+ // 分批拉报价
176
+ const quotes = {};
177
+ for (let i = 0; i < wanted.length; i += BATCH) {
178
+ const chunk = wanted.slice(i, i + BATCH);
179
+ process.stdout.write(` 拉取报价 ${i + 1}~${Math.min(i + BATCH, wanted.length)} ...`);
180
+ try {
181
+ Object.assign(quotes, await fetchBatch(chunk.map(w => w.code)));
182
+ console.log(' ok');
183
+ } catch (e) { console.log(' 失败: ' + e.message); }
184
+ }
185
+
186
+ // 校验过滤
187
+ const kept = [], dropped = [];
188
+ for (const w of wanted) {
189
+ const q = quotes[w.code];
190
+ if (!q) { dropped.push([w.code, w.sector, '接口无返回(代码可能不存在)']); continue; }
191
+ if (!q.price || !isFinite(q.price)) { dropped.push([w.code, w.sector, '无有效价格(长期停牌/退市)']); continue; }
192
+ if (/ST|退/i.test(q.name)) { dropped.push([w.code, w.sector, `风险股 ${q.name}`]); continue; }
193
+ const cap = isFinite(q.totalCapYi) ? q.totalCapYi : q.circCapYi;
194
+ if (!isFinite(cap) || cap < minCap) { dropped.push([w.code, w.sector, `市值过小 ${cap}亿`]); continue; }
195
+ kept.push({ code: w.code, name: q.name || w.code, sector: w.sector, capYi: Math.round(cap) });
196
+ }
197
+
198
+ // 统计
199
+ const bySector = {};
200
+ for (const k of kept) (bySector[k.sector] = bySector[k.sector] || []).push(k);
201
+ const inR01Band = kept.filter(k => k.capYi >= 20 && k.capYi <= 500);
202
+
203
+ console.log(`\n保留 ${kept.length} 只 / 剔除 ${dropped.length} 只`);
204
+ if (dropped.length) {
205
+ console.log('剔除明细:');
206
+ dropped.forEach(d => console.log(` ${d[0]} [${d[1]}] ${d[2]}`));
207
+ }
208
+ console.log(`\n落在 R01 市值带(20~500亿)的标的: ${inR01Band.length} 只`);
209
+ console.log('各行业成分数:');
210
+ Object.entries(bySector).sort((a, b) => b[1].length - a[1].length)
211
+ .forEach(([s, v]) => {
212
+ const band = v.filter(x => x.capYi >= 20 && x.capYi <= 500).length;
213
+ console.log(` ${s.padEnd(6)} 共 ${String(v.length).padStart(2)} 只(其中 R01 市值带 ${band} 只)`);
214
+ });
215
+
216
+ const thin = Object.entries(bySector).filter(([, v]) => v.length < 5).map(([s]) => s);
217
+ if (thin.length) console.log(`\n⚠ 样本偏少(<5)的行业,R07 中位数代表性弱: ${thin.join('、')}`);
218
+
219
+ if (dry) { console.log('\n--dry 模式,未写入文件'); return; }
220
+
221
+ const payload = {
222
+ name: 'A股短线候选观察池',
223
+ note: '由 build_universe.js 生成;名称/市值来自腾讯行情接口校验,可手工增删 stocks 条目',
224
+ generatedAt: now(),
225
+ minCapYi: minCap,
226
+ count: kept.length,
227
+ stocks: kept.map(k => ({ code: k.code, name: k.name, sector: k.sector })),
228
+ };
229
+ fs.writeFileSync(OUT, JSON.stringify(payload, null, 2), 'utf8');
230
+ console.log(`\n已写出 ${OUT}(${kept.length} 只)`);
231
+ })().catch(e => { console.error('构建失败:', e.message); process.exit(1); });
@@ -0,0 +1,269 @@
1
+ {
2
+ "name": "A股短线候选观察池(示例模板)",
3
+ "note": "这是**示例模板**,不是可用的观察池。请按自己的口径替换 stocks —— 推荐用 `node build_universe.js` 按市值门槛重建(会校验名称并补全行业),或直接手工增删条目。",
4
+ "generatedAt": "2026-09-21T05:14:50.201Z",
5
+ "minCapYi": 15,
6
+ "count": 52,
7
+ "stocks": [
8
+ {
9
+ "code": "sz002352",
10
+ "name": "顺丰控股",
11
+ "sector": "交通运输"
12
+ },
13
+ {
14
+ "code": "sh601816",
15
+ "name": "京沪高铁",
16
+ "sector": "交通运输"
17
+ },
18
+ {
19
+ "code": "sz300413",
20
+ "name": "芒果超媒",
21
+ "sector": "传媒"
22
+ },
23
+ {
24
+ "code": "sz002027",
25
+ "name": "分众传媒",
26
+ "sector": "传媒"
27
+ },
28
+ {
29
+ "code": "sh600900",
30
+ "name": "长江电力",
31
+ "sector": "公用事业"
32
+ },
33
+ {
34
+ "code": "sz000883",
35
+ "name": "湖北能源",
36
+ "sector": "公用事业"
37
+ },
38
+ {
39
+ "code": "sz002714",
40
+ "name": "牧原股份",
41
+ "sector": "农林牧渔"
42
+ },
43
+ {
44
+ "code": "sz300498",
45
+ "name": "温氏股份",
46
+ "sector": "农林牧渔"
47
+ },
48
+ {
49
+ "code": "sh600276",
50
+ "name": "恒瑞医药",
51
+ "sector": "医药生物"
52
+ },
53
+ {
54
+ "code": "sh603259",
55
+ "name": "药明康德",
56
+ "sector": "医药生物"
57
+ },
58
+ {
59
+ "code": "sh601933",
60
+ "name": "永辉超市",
61
+ "sector": "商贸零售"
62
+ },
63
+ {
64
+ "code": "sz002251",
65
+ "name": "步步高",
66
+ "sector": "商贸零售"
67
+ },
68
+ {
69
+ "code": "sh600760",
70
+ "name": "中航沈飞",
71
+ "sector": "国防军工"
72
+ },
73
+ {
74
+ "code": "sh600893",
75
+ "name": "航发动力",
76
+ "sector": "国防军工"
77
+ },
78
+ {
79
+ "code": "sh600309",
80
+ "name": "万华化学",
81
+ "sector": "基础化工"
82
+ },
83
+ {
84
+ "code": "sz002324",
85
+ "name": "普利特",
86
+ "sector": "基础化工"
87
+ },
88
+ {
89
+ "code": "sz000333",
90
+ "name": "美的集团",
91
+ "sector": "家用电器"
92
+ },
93
+ {
94
+ "code": "sz000651",
95
+ "name": "格力电器",
96
+ "sector": "家用电器"
97
+ },
98
+ {
99
+ "code": "sh600585",
100
+ "name": "海螺水泥",
101
+ "sector": "建筑材料"
102
+ },
103
+ {
104
+ "code": "sz002271",
105
+ "name": "东方雨虹",
106
+ "sector": "建筑材料"
107
+ },
108
+ {
109
+ "code": "sh600048",
110
+ "name": "保利发展",
111
+ "sector": "房地产"
112
+ },
113
+ {
114
+ "code": "sz000002",
115
+ "name": "万科A",
116
+ "sector": "房地产"
117
+ },
118
+ {
119
+ "code": "sh601899",
120
+ "name": "紫金矿业",
121
+ "sector": "有色金属"
122
+ },
123
+ {
124
+ "code": "sz002460",
125
+ "name": "赣锋锂业",
126
+ "sector": "有色金属"
127
+ },
128
+ {
129
+ "code": "sh600031",
130
+ "name": "三一重工",
131
+ "sector": "机械设备"
132
+ },
133
+ {
134
+ "code": "sz300124",
135
+ "name": "汇川技术",
136
+ "sector": "机械设备"
137
+ },
138
+ {
139
+ "code": "sz002594",
140
+ "name": "比亚迪",
141
+ "sector": "汽车"
142
+ },
143
+ {
144
+ "code": "sh601633",
145
+ "name": "长城汽车",
146
+ "sector": "汽车"
147
+ },
148
+ {
149
+ "code": "sh601088",
150
+ "name": "中国神华",
151
+ "sector": "煤炭"
152
+ },
153
+ {
154
+ "code": "sh601225",
155
+ "name": "陕西煤业",
156
+ "sector": "煤炭"
157
+ },
158
+ {
159
+ "code": "sz300750",
160
+ "name": "宁德时代",
161
+ "sector": "电力设备"
162
+ },
163
+ {
164
+ "code": "sh601012",
165
+ "name": "隆基绿能",
166
+ "sector": "电力设备"
167
+ },
168
+ {
169
+ "code": "sz002475",
170
+ "name": "立讯精密",
171
+ "sector": "电子"
172
+ },
173
+ {
174
+ "code": "sz002241",
175
+ "name": "歌尔股份",
176
+ "sector": "电子"
177
+ },
178
+ {
179
+ "code": "sh600028",
180
+ "name": "中国石化",
181
+ "sector": "石油石化"
182
+ },
183
+ {
184
+ "code": "sh601857",
185
+ "name": "中国石油",
186
+ "sector": "石油石化"
187
+ },
188
+ {
189
+ "code": "sh603605",
190
+ "name": "珀莱雅",
191
+ "sector": "美容护理"
192
+ },
193
+ {
194
+ "code": "sh603983",
195
+ "name": "丸美生物",
196
+ "sector": "美容护理"
197
+ },
198
+ {
199
+ "code": "sz002230",
200
+ "name": "科大讯飞",
201
+ "sector": "计算机"
202
+ },
203
+ {
204
+ "code": "sh688111",
205
+ "name": "金山办公",
206
+ "sector": "计算机"
207
+ },
208
+ {
209
+ "code": "sh603833",
210
+ "name": "欧派家居",
211
+ "sector": "轻工制造"
212
+ },
213
+ {
214
+ "code": "sz002572",
215
+ "name": "索菲亚",
216
+ "sector": "轻工制造"
217
+ },
218
+ {
219
+ "code": "sz000063",
220
+ "name": "中兴通讯",
221
+ "sector": "通信"
222
+ },
223
+ {
224
+ "code": "sh600941",
225
+ "name": "中国移动",
226
+ "sector": "通信"
227
+ },
228
+ {
229
+ "code": "sh600019",
230
+ "name": "宝钢股份",
231
+ "sector": "钢铁"
232
+ },
233
+ {
234
+ "code": "sz000709",
235
+ "name": "河钢股份",
236
+ "sector": "钢铁"
237
+ },
238
+ {
239
+ "code": "sh600036",
240
+ "name": "招商银行",
241
+ "sector": "银行"
242
+ },
243
+ {
244
+ "code": "sh601398",
245
+ "name": "工商银行",
246
+ "sector": "银行"
247
+ },
248
+ {
249
+ "code": "sz300059",
250
+ "name": "东方财富",
251
+ "sector": "非银金融"
252
+ },
253
+ {
254
+ "code": "sh600030",
255
+ "name": "中信证券",
256
+ "sector": "非银金融"
257
+ },
258
+ {
259
+ "code": "sh600519",
260
+ "name": "贵州茅台",
261
+ "sector": "食品饮料"
262
+ },
263
+ {
264
+ "code": "sz000858",
265
+ "name": "五粮液",
266
+ "sector": "食品饮料"
267
+ }
268
+ ]
269
+ }
@@ -0,0 +1,13 @@
1
+ # Cloudflare D1 凭据(从 Cloudflare 控制台获取)
2
+ #
3
+ # 用法:复制本文件为 db/.env 并填入真实值即可生效(请勿提交真实值,已被 .gitignore 忽略)。
4
+ # 该文件由 db/d1client.js 通过 dotenv 加载,被 backfill.js / write_live.js / enrich_stock_base.js 等复用。
5
+ # 优先级:**已存在的系统环境变量 / CI Secrets 优先**(dotenv 默认不覆盖已存在的变量)。
6
+ CF_ACCOUNT_ID=your_account_id
7
+ CF_D1_DATABASE_ID=your_d1_database_id
8
+ CF_API_TOKEN=your_api_token_with_d1_edit_permission
9
+
10
+ # 获取位置:
11
+ # - CF_ACCOUNT_ID:右侧头像 → Account Home 里的 "Account ID"
12
+ # - CF_D1_DATABASE_ID:wrangler d1 create 后返回的 uuid,或控制台 Storage/D1 详情页
13
+ # - CF_API_TOKEN:API Tokens 页创建,权限勾选 "Account → D1 → Edit"
package/db/backfill.js ADDED
@@ -0,0 +1,71 @@
1
+ 'use strict';
2
+ // 历史回填:遍历 data/snapshot-*.json(覆盖全部历史运行),规范化后写入
3
+ // Cloudflare D1(默认,不写本地)/ 本地 SQLite(--local)/ 两边都写(--both)。
4
+ // 也支持 --dry 仅解析统计、不接触网络。
5
+ //
6
+ // 全量幂等重放:run_batch UNIQUE(anchor_date)、pick_record UNIQUE(run_id,code)、
7
+ // price_daily UNIQUE(code,date),重复运行不会重复插入 —— 因此「哪边落后就跑一次」即可追平。
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+ const { normalizeSnap } = require('./normalize');
11
+ const { persistSnap } = require('./persist');
12
+ const { resolveTargets } = require('./clients');
13
+ const paths = require('../paths');
14
+
15
+ // 快照目录跟随工作目录(见 paths.js):默认 <仓库根>/data
16
+ const DATA = paths.dataDir();
17
+ const isDry = process.argv.includes('--dry');
18
+
19
+ function listSnaps() {
20
+ if (!fs.existsSync(DATA)) return [];
21
+ return fs.readdirSync(DATA).filter(f => /^snapshot-\d{4}-\d{2}-\d{2}\.json$/.test(f)).sort();
22
+ }
23
+
24
+ (async () => {
25
+ const files = listSnaps();
26
+ if (!files.length) { console.log('[backfill] 无 snapshot 文件'); return; }
27
+
28
+ const { targets, notes } = isDry ? { targets: [], notes: [] } : resolveTargets();
29
+ for (const n of notes) console.log('[backfill] ' + n);
30
+ if (!isDry && !targets.length) {
31
+ console.log('[backfill] 未指定写入目标:--local 写本地 / --both 两边都写 / 配好 CF_* 凭据后默认写远程');
32
+ }
33
+ for (const t of targets) console.log(`[backfill] 写入目标 ${t.name}${t.file ? ' -> ' + t.file : ''}`);
34
+
35
+ console.log(`[backfill] 发现 ${files.length} 份快照${isDry ? '(dry-run,不写库)' : ''}`);
36
+
37
+ const totals = new Map();
38
+ for (const t of targets) totals.set(t.name, { picks: 0, prices: 0, base: 0, fail: 0 });
39
+
40
+ let totalPicks = 0, totalPrices = 0, totalBase = 0, parseFail = 0;
41
+ for (const f of files) {
42
+ const file = path.join(DATA, f);
43
+ let snap;
44
+ try { snap = JSON.parse(fs.readFileSync(file, 'utf8')); }
45
+ catch (e) { console.log(` ✗ ${f}: 解析失败 ${e.message}`); parseFail++; continue; }
46
+ const { run, picks, prices, base } = normalizeSnap(snap);
47
+ console.log(` · ${run.anchor_date} 全池 ${run.universe_count} 重点 ${run.high_count} / 次级 ${run.secondary_count} / 条件 ${run.conditional_count} / 排除 ${run.excluded_count} R01 ${run.r01_count} R07 ${run.r07_count} R05 ${run.r05_count}`);
48
+ totalPicks += picks.length; totalPrices += prices.length; totalBase += base.length;
49
+
50
+ for (const t of targets) {
51
+ try {
52
+ await persistSnap(snap, t.client);
53
+ const acc = totals.get(t.name);
54
+ acc.picks += picks.length; acc.prices += prices.length; acc.base += base.length;
55
+ } catch (e) {
56
+ console.log(` ✗ ${f} → ${t.name}: 入库失败 ${e.message}`);
57
+ totals.get(t.name).fail++;
58
+ }
59
+ }
60
+ }
61
+ for (const t of targets) if (t.client.close) t.client.close();
62
+
63
+ console.log(`[backfill] 解析合计:picks=${totalPicks} prices=${totalPrices} base=${totalBase}${parseFail ? ` 解析失败 ${parseFail}` : ''}`);
64
+ for (const [name, a] of totals) {
65
+ const runs = files.length - parseFail - a.fail;
66
+ console.log(`[backfill] ${name} 完成:runs=${runs} picks=${a.picks} prices=${a.prices} base=${a.base}${a.fail ? ` 失败 ${a.fail}` : ''}`);
67
+ }
68
+ if (!isDry && targets.length > 1) {
69
+ console.log('[backfill] 提示:两端都写完后可跑 `node db/verify_sync.js` 核对指纹是否一致');
70
+ }
71
+ })().catch(e => { console.error('[backfill] 中止:', e.message); process.exit(1); });
package/db/clients.js ADDED
@@ -0,0 +1,48 @@
1
+ 'use strict';
2
+ // 入库目标选择:把「写远程 D1 / 写本地 SQLite / 两边都写」收敛到一处,
3
+ // 供 backfill.js 与 write_live.js 共用(此前两边各写了一份互斥判断)。
4
+ //
5
+ // 为什么要有 --both:
6
+ // 两个库原本由「互斥分支」写入 —— 不加 --local 只写远程,加了只写本地,
7
+ // 一次运行只能写一边。而本机日常任务(run_today.*)与 GitHub 定时任务
8
+ // (daily-screen.yml)走的都是远程,于是 db/local.db 永远停在旧数据上。
9
+ // data/snapshot-*.json 是唯一真相来源,且 backfill 是「全量幂等重放」,
10
+ // 所以「谁落后就跑 --both 追平」就够了,不需要增量同步或差异比对。
11
+ const localSqlite = require('./sqlite_client');
12
+ const d1 = require('./d1client');
13
+
14
+ const has = (argv, flag) => argv.includes(flag);
15
+
16
+ /**
17
+ * 解析命令行参数得出写入目标。
18
+ *
19
+ * | 参数 | 远程 D1 | 本地 db/local.db |
20
+ * |-----------|----------------|------------------|
21
+ * | (无) | 有凭据则写 | 不写 |
22
+ * | --local | 不写 | 写 |
23
+ * | --both | 有凭据则写 | 写 |
24
+ *
25
+ * @param {string[]} argv 命令行参数,默认 process.argv
26
+ * @returns {{targets: Array<{name: 'remote'|'local', client: object, file?: string}>, notes: string[]}}
27
+ * notes 是需要调用方打印的提示(例如缺少远程凭据)。
28
+ */
29
+ function resolveTargets(argv = process.argv) {
30
+ const wantLocal = has(argv, '--local') || has(argv, '--both');
31
+ const wantRemote = has(argv, '--both') || !has(argv, '--local');
32
+ const targets = [];
33
+ const notes = [];
34
+
35
+ if (wantRemote) {
36
+ if (d1.cfg()) targets.push({ name: 'remote', client: d1 });
37
+ else notes.push('未配置 CF_ACCOUNT_ID / CF_D1_DATABASE_ID / CF_API_TOKEN,跳过远程 D1(设 --local 只写本地,或配好凭据后重跑)');
38
+ }
39
+
40
+ if (wantLocal) {
41
+ const client = localSqlite.createClient();
42
+ targets.push({ name: 'local', client, file: client.file });
43
+ }
44
+
45
+ return { targets, notes };
46
+ }
47
+
48
+ module.exports = { resolveTargets };