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/notify.js ADDED
@@ -0,0 +1,236 @@
1
+ 'use strict';
2
+ /*
3
+ * notify.js
4
+ * ---------------------------------------------------------------------------
5
+ * 实时结果推送(零外部依赖,仅用 Node 内置 https / tls):
6
+ * - 个人微信:Server酱 (sctapi.ftqq.com) 或 PushPlus (pushplus.plus)
7
+ * - 163 邮箱:SMTP_SSL (smtp.163.com:465),HTML 报告作为附件发送
8
+ *
9
+ * 配置来源(凭据不要写进脚本 / 不要提交):
10
+ * notify_config.json 或 环境变量(NOTIFY_WX_KEY / NOTIFY_WX_TOKEN /
11
+ * NOTIFY_WX_PROVIDER / NOTIFY_MAIL_SENDER / NOTIFY_MAIL_AUTH /
12
+ * NOTIFY_MAIL_RECEIVER / NOTIFY_MAIL_HOST / NOTIFY_MAIL_PORT)
13
+ *
14
+ * 用法(在 gen_candidates.js 中 require 后调用):
15
+ * const { notify } = require('./notify');
16
+ * await notify({ title, content, htmlPath });
17
+ * ---------------------------------------------------------------------------
18
+ */
19
+ const fs = require('fs');
20
+ const path = require('path');
21
+ const https = require('https');
22
+ const tls = require('tls');
23
+ const { URL } = require('url');
24
+ const paths = require('./paths');
25
+
26
+ // 配置位置跟随工作目录(见 paths.js):默认 <仓库根>/notify_config.json,
27
+ // 重定位后为 <HOME>/notify_config.json。
28
+ function cfgPath() { return paths.notifyConfigFile(); }
29
+
30
+ function loadCfg() {
31
+ const cfg = {};
32
+ const CFG_PATH = cfgPath();
33
+ if (fs.existsSync(CFG_PATH)) {
34
+ try { Object.assign(cfg, JSON.parse(fs.readFileSync(CFG_PATH, 'utf8'))); } catch (e) { /* 损坏则忽略 */ }
35
+ }
36
+ const wx = cfg.wechat || (cfg.wechat = {});
37
+ if (process.env.NOTIFY_WX_KEY) wx.key = process.env.NOTIFY_WX_KEY;
38
+ if (process.env.NOTIFY_WX_TOKEN) wx.token = process.env.NOTIFY_WX_TOKEN;
39
+ if (process.env.NOTIFY_WX_PROVIDER) wx.provider = process.env.NOTIFY_WX_PROVIDER;
40
+ const em = cfg.email || (cfg.email = {});
41
+ const map = {
42
+ sender: 'NOTIFY_MAIL_SENDER', auth_code: 'NOTIFY_MAIL_AUTH',
43
+ receiver: 'NOTIFY_MAIL_RECEIVER', smtp_host: 'NOTIFY_MAIL_HOST', smtp_port: 'NOTIFY_MAIL_PORT',
44
+ };
45
+ for (const k in map) if (process.env[map[k]]) em[k] = process.env[map[k]];
46
+ return cfg;
47
+ }
48
+
49
+ // ---------- HTTP POST ----------
50
+ function postForm(urlStr, form, json) {
51
+ return new Promise((resolve, reject) => {
52
+ const u = new URL(urlStr);
53
+ const body = json
54
+ ? JSON.stringify(form)
55
+ : Object.keys(form).map(k => encodeURIComponent(k) + '=' + encodeURIComponent(form[k])).join('&');
56
+ const req = https.request({
57
+ hostname: u.hostname,
58
+ path: u.pathname + (u.search || ''),
59
+ method: 'POST',
60
+ headers: {
61
+ 'Content-Type': json ? 'application/json' : 'application/x-www-form-urlencoded',
62
+ 'Content-Length': Buffer.byteLength(body),
63
+ },
64
+ }, res => {
65
+ let data = '';
66
+ res.on('data', c => (data += c));
67
+ res.on('end', () => resolve({ status: res.statusCode, body: data }));
68
+ });
69
+ req.on('error', reject);
70
+ req.write(body);
71
+ req.end();
72
+ });
73
+ }
74
+
75
+ // ---------- 微信推送 ----------
76
+ async function pushWechat(title, content, cfg) {
77
+ const w = cfg.wechat || {};
78
+ const provider = (w.provider || 'serverchan').toLowerCase();
79
+ try {
80
+ if (provider === 'serverchan') {
81
+ const key = w.key || process.env.NOTIFY_WX_KEY;
82
+ if (!key) return [false, '缺少 serverchan key'];
83
+ const r = await postForm(`https://sctapi.ftqq.com/${key}.send`, { title, desp: content });
84
+ let d; try { d = JSON.parse(r.body); } catch (e) { d = {}; }
85
+ return [(d.code === 0 || d.errno === 0), d];
86
+ } else if (provider === 'pushplus') {
87
+ const token = w.token || process.env.NOTIFY_WX_TOKEN;
88
+ if (!token) return [false, '缺少 pushplus token'];
89
+ const r = await postForm('https://www.pushplus.plus/send',
90
+ { token, title, content, template: 'markdown' }, true);
91
+ let d; try { d = JSON.parse(r.body); } catch (e) { d = {}; }
92
+ return [d.code === 200, d];
93
+ }
94
+ return [false, '未知 provider: ' + provider];
95
+ } catch (e) {
96
+ return [false, String(e && e.message || e)];
97
+ }
98
+ }
99
+
100
+ // ---------- 163 邮箱(最小 SMTP_SSL 客户端,无 nodemailer 依赖)----------
101
+ function smtpSend({ host, port, user, pass, from, to, subject, text, htmlPath }) {
102
+ return new Promise((resolve, reject) => {
103
+ const socket = tls.connect({ host, port: Number(port), rejectUnauthorized: true });
104
+ let leftover = '';
105
+ let current = null;
106
+ let ready = false;
107
+
108
+ function send(cmd) { socket.write(cmd + '\r\n'); }
109
+ function next() {
110
+ current = queue.shift();
111
+ if (!current) { socket.end(); return resolve(true); }
112
+ send(current.cmd);
113
+ }
114
+ function onReady() { if (ready) return; ready = true; next(); }
115
+
116
+ socket.on('data', chunk => {
117
+ leftover += chunk.toString('binary');
118
+ let idx;
119
+ while ((idx = leftover.indexOf('\r\n')) >= 0) {
120
+ const line = leftover.slice(0, idx);
121
+ leftover = leftover.slice(idx + 2);
122
+ const code = parseInt(line.slice(0, 3), 10);
123
+ if (current === null) {
124
+ if (code === 220) onReady(); // 服务器招呼语
125
+ continue;
126
+ }
127
+ const final = line.length >= 4 && line[3] === ' ';
128
+ if (isNaN(code) || !final) continue; // 多行续行 / 非响应,忽略
129
+ const ok = current.codes.includes(code) ||
130
+ current.codes.some(c => Math.floor(code / 100) === Math.floor(c / 100));
131
+ if (!ok) { socket.destroy(); return reject(new Error(`SMTP ${code}: ${line}`)); }
132
+ setTimeout(next, 30);
133
+ }
134
+ });
135
+ socket.on('error', reject);
136
+
137
+ // 组装邮件(multipart/mixed,HTML 报告作附件)
138
+ //
139
+ // 【重要】所有正文/附件一律 base64 + 76 字符折行。
140
+ // 原因:RFC 5321 规定 SMTP 单行不得超过 998 字节,而生成的报告 HTML 里
141
+ // 含有超长单行(双语数据块可达 10 万+ 字符),直接以 8bit 原文投递会被
142
+ // 服务端拒收或静默截断。base64 折行后天然满足行长限制,且不需要点填充。
143
+ const b64 = (buf) => Buffer.from(buf).toString('base64').replace(/.{76}/g, '$&\r\n');
144
+ const boundary = '----=_Part_' + Date.now();
145
+ let msg = '';
146
+ msg += `From: ${from}\r\n`;
147
+ msg += `To: ${to}\r\n`;
148
+ msg += `Subject: =?UTF-8?B?${Buffer.from(subject).toString('base64')}?=\r\n`;
149
+ msg += `Date: ${new Date().toUTCString().replace('GMT', '+0000')}\r\n`;
150
+ msg += `Message-ID: <${Date.now()}.${process.pid}@${String(from).split('@')[1] || 'localhost'}>\r\n`;
151
+ msg += `MIME-Version: 1.0\r\n`;
152
+ msg += `Content-Type: multipart/mixed; boundary="${boundary}"\r\n\r\n`;
153
+ msg += `--${boundary}\r\n`;
154
+ msg += `Content-Type: text/plain; charset=UTF-8\r\n`;
155
+ msg += `Content-Transfer-Encoding: base64\r\n\r\n`;
156
+ msg += b64(text || 'A股次日候选池报告,详见附件 HTML。') + `\r\n\r\n`;
157
+ if (htmlPath && fs.existsSync(htmlPath)) {
158
+ const fname = path.basename(htmlPath);
159
+ msg += `--${boundary}\r\n`;
160
+ msg += `Content-Type: text/html; charset=UTF-8; name="${fname}"\r\n`;
161
+ msg += `Content-Transfer-Encoding: base64\r\n`;
162
+ msg += `Content-Disposition: attachment; filename="${fname}"\r\n\r\n`;
163
+ msg += b64(fs.readFileSync(htmlPath)) + `\r\n\r\n`;
164
+ }
165
+ msg += `--${boundary}--\r\n`;
166
+
167
+ // SMTP 透明化:行首的 "." 改为 ".."(base64 不会以 "." 开头,此处仅作兜底)
168
+ msg = msg.replace(/(\r\n)\./g, '$1..');
169
+
170
+ const queue = [
171
+ { cmd: 'EHLO localhost', codes: [250] },
172
+ { cmd: 'AUTH LOGIN', codes: [334] },
173
+ { cmd: Buffer.from(user).toString('base64'), codes: [334] },
174
+ { cmd: Buffer.from(pass).toString('base64'), codes: [235] },
175
+ { cmd: `MAIL FROM:<${from}>`, codes: [250] },
176
+ { cmd: `RCPT TO:<${to}>`, codes: [250, 251] },
177
+ { cmd: 'DATA', codes: [354] },
178
+ { cmd: msg + '\r\n.', codes: [250] },
179
+ { cmd: 'QUIT', codes: [221, 250] },
180
+ ];
181
+ if (!ready) socket.on('secureConnect', () => {}); // 确保握手完成后再等 220
182
+ });
183
+ }
184
+
185
+ async function pushEmail(subject, htmlPath, cfg) {
186
+ const e = cfg.email || {};
187
+ const sender = e.sender, auth = e.auth_code;
188
+ if (!sender || !auth) return [false, '缺少 email sender/auth_code'];
189
+ const host = e.smtp_host || 'smtp.163.com';
190
+ const port = Number(e.smtp_port || 465);
191
+ const receiver = e.receiver || sender;
192
+ try {
193
+ await smtpSend({ host, port, user: sender, pass: auth, from: sender, to: receiver, subject, text: 'A股次日候选池报告,详见附件 HTML。', htmlPath });
194
+ return [true, `已发送至 ${receiver}`];
195
+ } catch (ex) {
196
+ return [false, String(ex && ex.message || ex)];
197
+ }
198
+ }
199
+
200
+ // ---------- 统一入口 ----------
201
+ async function notify({ title, content, htmlPath, cfg }) {
202
+ cfg = cfg || loadCfg();
203
+ const res = [];
204
+ if (cfg.wechat && (cfg.wechat.key || cfg.wechat.token)) {
205
+ res.push(['wechat', await pushWechat(title, content, cfg)]);
206
+ }
207
+ if (cfg.email && cfg.email.sender && cfg.email.auth_code) {
208
+ res.push(['email', await pushEmail(title, htmlPath, cfg)]);
209
+ }
210
+ if (!res.length) res.push(['none', [false, '未配置任何推送渠道(notify_config.json 为空)']]);
211
+ return res;
212
+ }
213
+
214
+ module.exports = { loadCfg, notify, pushWechat, pushEmail };
215
+
216
+ // CLI:
217
+ // node notify.js -> 自检,打印配置状态
218
+ // node notify.js --send "标题" "正文" -> 直接发一条(供 CI 在任务失败时告警用)
219
+ if (require.main === module) {
220
+ const argv = process.argv.slice(2);
221
+ if (argv[0] === '--send') {
222
+ const title = argv[1] || 'A股初筛告警';
223
+ const content = argv[2] || '(无正文)';
224
+ notify({ title, content }).then(res => {
225
+ for (const [ch, [ok, info]] of res) {
226
+ console.log(`[notify] ${ch}: ${ok ? 'OK' : '失败 ' + JSON.stringify(info)}`);
227
+ }
228
+ // 告警本身发不出去不应让 CI 再挂一次,恒定 0 退出
229
+ }).catch(e => console.log('[notify] 异常:', e && e.message || e));
230
+ } else {
231
+ const c = loadCfg();
232
+ console.log('wechat 配置:', !!(c.wechat && (c.wechat.key || c.wechat.token)));
233
+ console.log('email 配置:', !!(c.email && c.email.sender && c.email.auth_code));
234
+ console.log('notify_config.json 路径:', cfgPath());
235
+ }
236
+ }
@@ -0,0 +1,14 @@
1
+ {
2
+ "wechat": {
3
+ "provider": "serverchan",
4
+ "key": "SCTxxxxxxxx此处替换为你的SendKey",
5
+ "token": ""
6
+ },
7
+ "email": {
8
+ "smtp_host": "smtp.163.com",
9
+ "smtp_port": 465,
10
+ "sender": "you@163.com",
11
+ "auth_code": "你的163邮箱授权码(非登录密码)",
12
+ "receiver": "you@163.com"
13
+ }
14
+ }
package/package.json ADDED
@@ -0,0 +1,96 @@
1
+ {
2
+ "name": "candidate-pool",
3
+ "version": "2.0.0",
4
+ "description": "A股短线选股工具包:次日候选池初筛(R01 量能验证突破 + R07 板块内补涨)与尾盘选股(14:50 口径),数据取自腾讯公开行情接口,输出单文件双语 HTML 报告",
5
+ "bin": {
6
+ "candidate-pool": "bin/candidate-pool.js",
7
+ "tail-screener": "bin/tail-screener.js"
8
+ },
9
+ "files": [
10
+ "bin/",
11
+ "paths.js",
12
+ "paths.selftest.js",
13
+ "scaffold.js",
14
+ "scaffold.selftest.js",
15
+ "time.js",
16
+ "gen_candidates.js",
17
+ "notify.js",
18
+ "build_universe.js",
19
+ "candidates.example.json",
20
+ "notify_config.example.json",
21
+ "eod/",
22
+ "db/",
23
+ "README.md",
24
+ "CHANGELOG.md",
25
+ "!eod/reports/",
26
+ "!eod/data/eod-*.json",
27
+ "!db/.env",
28
+ "!db/local.db*",
29
+ "!**/*.log"
30
+ ],
31
+ "repository": {
32
+ "type": "git",
33
+ "url": "git+https://github.com/GuoSirius/candidate-pool.git"
34
+ },
35
+ "keywords": [
36
+ "a-share",
37
+ "stock-screener",
38
+ "quant",
39
+ "选股",
40
+ "尾盘选股",
41
+ "量化"
42
+ ],
43
+ "publishConfig": {
44
+ "access": "public",
45
+ "registry": "https://registry.npmjs.org/"
46
+ },
47
+ "scripts": {
48
+ "start": "node gen_candidates.js",
49
+ "run": "node gen_candidates.js",
50
+ "live": "node gen_candidates.js",
51
+ "offline": "node gen_candidates.js --offline",
52
+ "tail": "node eod/tail_screener.js",
53
+ "tail:intraday": "node eod/tail_screener.js --intraday",
54
+ "tail:replay": "node eod/tail_screener.js --replay",
55
+ "tail:selftest": "node eod/lib/trading.selftest.js",
56
+ "paths": "node gen_candidates.js --paths",
57
+ "paths:selftest": "node paths.selftest.js",
58
+ "init": "node gen_candidates.js --init",
59
+ "scaffold:selftest": "node scaffold.selftest.js",
60
+ "selftest": "npm run paths:selftest && npm run scaffold:selftest && npm run tail:selftest && npm run db:selftest",
61
+ "universe": "node build_universe.js",
62
+ "universe:dry": "node build_universe.js --dry",
63
+ "notify": "node notify.js",
64
+ "db:migrate": "node db/migrate.js",
65
+ "db:migrate:local": "node db/migrate.js --local",
66
+ "db:migrate:both": "node db/migrate.js --both",
67
+ "db:migrate:dry": "node db/migrate.js --dry-run",
68
+ "db:selftest": "node db/diff_selftest.js",
69
+ "db:sync": "node db/backfill.js --both",
70
+ "db:verify": "node db/verify_sync.js",
71
+ "db:local": "node db/backfill.js --local",
72
+ "deploy": "npm run deploy --prefix ./web",
73
+ "setup": "npm install && npm install --prefix ./worker && npm install --prefix ./web",
74
+ "typecheck": "npm run typecheck --prefix ./worker && npm run typecheck --prefix ./web",
75
+ "changelog": "changelogen",
76
+ "release": "node scripts/release.mjs",
77
+ "release:notes": "node scripts/release-notes.mjs",
78
+ "pack:dry": "npm pack --dry-run",
79
+ "verify:pack": "node scripts/verify-pack.mjs",
80
+ "commitlint": "commitlint --edit",
81
+ "prepare": "husky"
82
+ },
83
+ "devDependencies": {
84
+ "@commitlint/cli": "^21.2.3",
85
+ "@commitlint/config-conventional": "^21.2.3",
86
+ "changelogen": "^0.6.2",
87
+ "husky": "^9.1.7"
88
+ },
89
+ "engines": {
90
+ "node": ">=18"
91
+ },
92
+ "dependencies": {
93
+ "dayjs": "^1.11.23",
94
+ "dotenv": "^18.0.1"
95
+ }
96
+ }
package/paths.js ADDED
@@ -0,0 +1,127 @@
1
+ 'use strict';
2
+ /*
3
+ * paths.js —— 工作目录(HOME)解析:把「代码」与「运行产物 / 配置」分开
4
+ * ---------------------------------------------------------------------------
5
+ * 为什么要有这一层:
6
+ * 本项目历史上所有路径都锚在 `__dirname`(= 仓库结构)。这在「拉代码运行」下完全
7
+ * 正确,但一旦装进 `node_modules/`(或 `npx` 的临时缓存目录),产物就会被写进**包目录
8
+ * 本身**——重装即丢、npx 场景下更是跑完就消失;配置也无处可放。故引入一个可覆盖的 HOME。
9
+ *
10
+ * 解析优先级(一次解析后缓存):
11
+ * 1) setHome(dir) —— 程序内显式指定(自测用)
12
+ * 2) CANDIDATE_POOL_HOME —— 环境变量(推荐,所有脚本都认)
13
+ * 3) 命令行 --cwd <dir> —— 入口脚本可直接传
14
+ * 4) 包根(= 本文件所在目录) —— **默认,与历史行为逐字节一致**
15
+ *
16
+ * HOME 之下保持与仓库**同样的相对布局**,于是「拉代码」与「装包」两种用法指向同一套
17
+ * 相对路径,行为不会分叉:
18
+ * <HOME>/candidates.json 观察池(可编辑)
19
+ * <HOME>/data/snapshot-*.json 候选池快照
20
+ * <HOME>/reports/*.html 候选池报告
21
+ * <HOME>/notify_config.json 推送凭据
22
+ * <HOME>/eod/data/*.json 尾盘归档 + industry-map.json 缓存
23
+ * <HOME>/eod/reports/*.html 尾盘报告
24
+ * <HOME>/db/local.db 本地 SQLite
25
+ * <HOME>/db/.env D1 凭据
26
+ * <HOME>/.env (预留:通用环境变量,当前未使用)
27
+ *
28
+ * **不随 HOME 移动的(代码资产,永远跟包走,见 codeAsset())**:
29
+ * db/schema.sql、db/migrations/、以及全部 .js —— 它们是程序的一部分,不是用户数据。
30
+ * ---------------------------------------------------------------------------
31
+ */
32
+
33
+ const fs = require('fs');
34
+ const path = require('path');
35
+
36
+ /** 包 / 仓库根(= 本文件所在目录)。代码资产的锚点,**永不**随 HOME 移动。 */
37
+ const PKG_ROOT = __dirname;
38
+
39
+ /** 从 argv 里取 `--cwd <dir>`(不改动各脚本已有的参数解析;两个入口都零改动就能认) */
40
+ function cwdFromArgv(argv = process.argv.slice(2)) {
41
+ const i = argv.indexOf('--cwd');
42
+ if (i < 0) return null;
43
+ const v = argv[i + 1];
44
+ return v && !v.startsWith('--') ? v : null;
45
+ }
46
+
47
+ let _home = null;
48
+ let _source = null;
49
+
50
+ /** 当前工作目录(绝对路径)。解析一次后缓存;setHome(null) 可重置。 */
51
+ function home() {
52
+ if (_home) return _home;
53
+ const fromEnv = process.env.CANDIDATE_POOL_HOME;
54
+ const fromArg = cwdFromArgv();
55
+ _home = path.resolve(fromEnv || fromArg || PKG_ROOT);
56
+ _source = fromEnv ? 'env:CANDIDATE_POOL_HOME' : (fromArg ? 'argv:--cwd' : 'pkg-root');
57
+ return _home;
58
+ }
59
+
60
+ /** 显式指定 HOME;传 null / '' 清除覆盖并回到默认解析。返回生效值。 */
61
+ function setHome(dir) {
62
+ _home = dir ? path.resolve(dir) : null;
63
+ _source = dir ? 'setHome()' : null;
64
+ return home();
65
+ }
66
+
67
+ /** HOME 的来源(诊断用):pkg-root | env:CANDIDATE_POOL_HOME | argv:--cwd | setHome() */
68
+ function homeSource() { home(); return _source; }
69
+
70
+ /** 是否处于「重定位」状态(HOME 不是包根) */
71
+ function isRelocated() { return home() !== PKG_ROOT; }
72
+
73
+ const at = (...s) => path.join(home(), ...s);
74
+
75
+ // ---------- 工作区(随 HOME 移动)----------
76
+ const Home = {
77
+ candidatesFile: () => at('candidates.json'),
78
+ notifyConfigFile: () => at('notify_config.json'),
79
+ dataDir: () => at('data'),
80
+ reportsDir: () => at('reports'),
81
+ eodDir: () => at('eod'),
82
+ eodDataDir: () => at('eod', 'data'),
83
+ eodReportsDir: () => at('eod', 'reports'),
84
+ industryMapFile: () => at('eod', 'data', 'industry-map.json'),
85
+ localDb: () => at('db', 'local.db'),
86
+ dbEnvFile: () => at('db', '.env'),
87
+ };
88
+
89
+ // ---------- 代码资产(永远跟包走)----------
90
+ /** 包内的程序文件路径(schema.sql / migrations / 任意 .js) */
91
+ function codeAsset(...s) { return path.join(PKG_ROOT, ...s); }
92
+
93
+ /** 建目录(递归),返回该目录路径,便于链式使用 */
94
+ function ensureDir(dir) {
95
+ if (dir && !fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
96
+ return dir;
97
+ }
98
+
99
+ /** 全部关键路径一览(`--paths` 与自测用) */
100
+ function describe() {
101
+ return {
102
+ home: home(),
103
+ source: homeSource(),
104
+ relocated: isRelocated(),
105
+ pkgRoot: PKG_ROOT,
106
+ workspace: {
107
+ candidates: Home.candidatesFile(),
108
+ notifyConfig: Home.notifyConfigFile(),
109
+ data: Home.dataDir(),
110
+ reports: Home.reportsDir(),
111
+ eodData: Home.eodDataDir(),
112
+ eodReports: Home.eodReportsDir(),
113
+ industryMap: Home.industryMapFile(),
114
+ localDb: Home.localDb(),
115
+ dbEnv: Home.dbEnvFile(),
116
+ },
117
+ codeAssets: {
118
+ schemaSql: codeAsset('db', 'schema.sql'),
119
+ migrations: codeAsset('db', 'migrations'),
120
+ },
121
+ };
122
+ }
123
+
124
+ module.exports = {
125
+ PKG_ROOT, home, setHome, homeSource, isRelocated, cwdFromArgv,
126
+ ...Home, codeAsset, ensureDir, describe,
127
+ };
@@ -0,0 +1,142 @@
1
+ 'use strict';
2
+ /*
3
+ * paths.selftest.js —— 工作目录解析的回归自测
4
+ * ---------------------------------------------------------------------------
5
+ * 为什么必须有:`paths.js` 的默认分支直接决定**全部数据文件读写的落点**。
6
+ * 一旦默认值被改错(例如有人「顺手」把默认从包根改成 process.cwd()),
7
+ * 本机定时任务与 CI 就会把快照/报告写到别处,表现为「跑了但 report 里找不到」——
8
+ * 这种故障不会报错,只会静默换目录。故用可重跑断言把默认行为钉死。
9
+ *
10
+ * 运行:node paths.selftest.js (退出码非 0 = 失败)
11
+ * 纯函数 + 临时目录,不联网、不写真实工作区。
12
+ */
13
+
14
+ const fs = require('fs');
15
+ const os = require('os');
16
+ const path = require('path');
17
+ const P = require('./paths');
18
+
19
+ let failed = 0;
20
+ function check(name, cond, detail) {
21
+ console.log(`${cond ? ' OK ' : ' '}${name}${cond || detail === undefined ? '' : ` → ${detail}`}`);
22
+ if (!cond) failed++;
23
+ }
24
+
25
+ const PKG = P.PKG_ROOT;
26
+ const TMP_A = fs.mkdtempSync(path.join(os.tmpdir(), 'cp-home-a-'));
27
+ const TMP_B = fs.mkdtempSync(path.join(os.tmpdir(), 'cp-home-b-'));
28
+
29
+ /** 每个用例前清掉解析缓存,保证互不污染 */
30
+ function reset() { P.setHome(null); }
31
+ function withEnv(val, fn) {
32
+ const bak = process.env.CANDIDATE_POOL_HOME;
33
+ if (val === undefined) delete process.env.CANDIDATE_POOL_HOME; else process.env.CANDIDATE_POOL_HOME = val;
34
+ reset();
35
+ try { return fn(); } finally {
36
+ if (bak === undefined) delete process.env.CANDIDATE_POOL_HOME; else process.env.CANDIDATE_POOL_HOME = bak;
37
+ reset();
38
+ }
39
+ }
40
+ function withArgv(extra, fn) {
41
+ const bak = process.argv;
42
+ process.argv = ['node', 'x.js', ...extra];
43
+ reset();
44
+ try { return fn(); } finally { process.argv = bak; reset(); }
45
+ }
46
+
47
+ console.log('\n[1] 默认行为必须与历史 __dirname 完全一致(改了就是静默换目录)');
48
+ withEnv(undefined, () => withArgv([], () => {
49
+ check('home() === 包根', P.home() === PKG, P.home());
50
+ check('来源标记 pkg-root', P.homeSource() === 'pkg-root', P.homeSource());
51
+ check('未处于重定位状态', P.isRelocated() === false);
52
+ // 逐项对齐「老写法」的结果
53
+ const legacy = {
54
+ candidatesFile: path.join(PKG, 'candidates.json'),
55
+ notifyConfigFile: path.join(PKG, 'notify_config.json'),
56
+ dataDir: path.join(PKG, 'data'),
57
+ reportsDir: path.join(PKG, 'reports'),
58
+ eodDataDir: path.join(PKG, 'eod', 'data'),
59
+ eodReportsDir: path.join(PKG, 'eod', 'reports'),
60
+ industryMapFile: path.join(PKG, 'eod', 'data', 'industry-map.json'),
61
+ localDb: path.join(PKG, 'db', 'local.db'),
62
+ dbEnvFile: path.join(PKG, 'db', '.env'),
63
+ };
64
+ for (const [k, v] of Object.entries(legacy)) check(`默认 ${k} 与老写法一致`, P[k]() === v, `${P[k]()} vs ${v}`);
65
+ // 尾盘子目录的两个用法(store.js 用 eodDir + cfg.store.dataDir)
66
+ check('eodDir() + dataDir 名 = eod/data', path.join(P.eodDir(), 'data') === path.join(PKG, 'eod', 'data'));
67
+ }));
68
+
69
+ console.log('\n[2] CANDIDATE_POOL_HOME 必须整棵重定向');
70
+ withEnv(TMP_A, () => withArgv([], () => {
71
+ check('home() === 环境变量值', P.home() === path.resolve(TMP_A), P.home());
72
+ check('来源标记 env', P.homeSource() === 'env:CANDIDATE_POOL_HOME', P.homeSource());
73
+ check('isRelocated() === true', P.isRelocated() === true);
74
+ check('dataDir 落在 HOME 下', P.dataDir() === path.join(TMP_A, 'data'), P.dataDir());
75
+ check('eodDataDir 落在 HOME 下', P.eodDataDir() === path.join(TMP_A, 'eod', 'data'), P.eodDataDir());
76
+ check('eodReportsDir 落在 HOME 下', P.eodReportsDir() === path.join(TMP_A, 'eod', 'reports'), P.eodReportsDir());
77
+ check('reportsDir 落在 HOME 下', P.reportsDir() === path.join(TMP_A, 'reports'), P.reportsDir());
78
+ check('localDb 落在 HOME 下', P.localDb() === path.join(TMP_A, 'db', 'local.db'), P.localDb());
79
+ check('dbEnvFile 落在 HOME 下', P.dbEnvFile() === path.join(TMP_A, 'db', '.env'), P.dbEnvFile());
80
+ check('candidatesFile 落在 HOME 下', P.candidatesFile() === path.join(TMP_A, 'candidates.json'), P.candidatesFile());
81
+ check('industryMapFile 落在 HOME 下', P.industryMapFile() === path.join(TMP_A, 'eod', 'data', 'industry-map.json'), P.industryMapFile());
82
+ // 关键不变式:代码资产**不**跟着走
83
+ check('schema.sql 仍在包内', P.codeAsset('db', 'schema.sql') === path.join(PKG, 'db', 'schema.sql'), P.codeAsset('db', 'schema.sql'));
84
+ check('schema.sql 不落在 HOME 下', !P.codeAsset('db', 'schema.sql').startsWith(TMP_A));
85
+ }));
86
+
87
+ console.log('\n[3] --cwd <dir> 必须生效');
88
+ withEnv(undefined, () => withArgv(['--cwd', TMP_B], () => {
89
+ check('home() === --cwd 值', P.home() === path.resolve(TMP_B), P.home());
90
+ check('来源标记 argv', P.homeSource() === 'argv:--cwd', P.homeSource());
91
+ check('eodDataDir 落在 --cwd 下', P.eodDataDir() === path.join(TMP_B, 'eod', 'data'), P.eodDataDir());
92
+ }));
93
+
94
+ console.log('\n[4] 优先级:env > --cwd > 包根');
95
+ {
96
+ const bakEnv = process.env.CANDIDATE_POOL_HOME;
97
+ const bakArgv = process.argv;
98
+ process.env.CANDIDATE_POOL_HOME = TMP_A;
99
+ process.argv = ['node', 'x.js', '--cwd', TMP_B];
100
+ reset();
101
+ check('同时给 env 与 --cwd → env 胜', P.home() === path.resolve(TMP_A) && P.homeSource() === 'env:CANDIDATE_POOL_HOME', P.homeSource());
102
+ if (bakEnv === undefined) delete process.env.CANDIDATE_POOL_HOME; else process.env.CANDIDATE_POOL_HOME = bakEnv;
103
+ process.argv = bakArgv; reset();
104
+ }
105
+
106
+ console.log('\n[5] cwdFromArgv 解析健壮性(不误吃下一个参数)');
107
+ check('无 --cwd → null', P.cwdFromArgv(['--offline']) === null);
108
+ check('--cwd 在末尾无值 → null', P.cwdFromArgv(['--cwd']) === null);
109
+ check('--cwd 后跟另一个 flag → null', P.cwdFromArgv(['--cwd', '--offline']) === null);
110
+ check('--cwd X → X', P.cwdFromArgv(['--cwd', 'X']) === 'X');
111
+ check('--cwd X 混在其他参数里 → X', P.cwdFromArgv(['--intraday', '--cwd', 'X', '--json']) === 'X');
112
+ check('--now 的值不会被误当 --cwd', P.cwdFromArgv(['--now', '2026-09-21 11:30']) === null);
113
+
114
+ console.log('\n[6] setHome 显式覆盖与重置');
115
+ P.setHome(TMP_A);
116
+ check('setHome 生效', P.home() === path.resolve(TMP_A) && P.homeSource() === 'setHome()', P.homeSource());
117
+ P.setHome(null);
118
+ check('setHome(null) 回到包根', P.home() === PKG && P.homeSource() === 'pkg-root', `${P.home()} / ${P.homeSource()}`);
119
+
120
+ console.log('\n[7] describe() 自述完整');
121
+ withEnv(TMP_A, () => {
122
+ const d = P.describe();
123
+ check('describe 含 home/source/relocated', d.home === path.resolve(TMP_A) && d.relocated === true && !!d.source);
124
+ check('describe 列出 9 个工作区路径', Object.keys(d.workspace).length === 9, String(Object.keys(d.workspace).length));
125
+ check('describe 列出代码资产', d.codeAssets.schemaSql === path.join(PKG, 'db', 'schema.sql'));
126
+ });
127
+
128
+ console.log('\n[8] 自测自身不泄漏临时目录');
129
+ {
130
+ const probe = fs.mkdtempSync(path.join(os.tmpdir(), 'cp-home-probe-'));
131
+ const ok = fs.existsSync(probe);
132
+ fs.rmSync(probe, { recursive: true, force: true });
133
+ check('临时目录可创建且可清理', ok && !fs.existsSync(probe));
134
+ }
135
+
136
+ // 收尾:把自己造的两个临时目录删掉(否则每跑一次泄漏两个)
137
+ for (const d of [TMP_A, TMP_B]) {
138
+ try { fs.rmSync(d, { recursive: true, force: true }); } catch (e) { /* 已被占用则忽略 */ }
139
+ }
140
+
141
+ console.log(failed ? `\n自我检查失败:${failed} 项` : '\n全部通过');
142
+ process.exit(failed ? 1 : 0);