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/migrate.js
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/*
|
|
3
|
+
* db/migrate.js —— 数据库结构同步(本地 db/local.db ←→ 线上 Cloudflare D1)
|
|
4
|
+
* ---------------------------------------------------------------------------
|
|
5
|
+
* 为什么需要它(2026-09-21 事故):改了 schema.sql 之后,**没有任何可靠路径**能把它
|
|
6
|
+
* 落到两个库上 —— 本地 sqlite 只在「缺 run_batch 表」时才建表,远程完全靠人工
|
|
7
|
+
* `wrangler d1 execute`。于是新增的 tail_run / tail_pick 线上从未被创建,
|
|
8
|
+
* /api/tail/* 全部 `no such table` → 四个页面 HTTP 500。详见 db/ddl.js 头部。
|
|
9
|
+
*
|
|
10
|
+
* 一条命令同步两边:
|
|
11
|
+
* npm run db:migrate # 同步线上 D1(默认;无凭据则提示并跳过)
|
|
12
|
+
* npm run db:migrate:local # 同步本地 db/local.db
|
|
13
|
+
* npm run db:migrate:both # 两边一起
|
|
14
|
+
* npm run db:migrate:dry # 只打印差异,不写库(安全预览)
|
|
15
|
+
*
|
|
16
|
+
* 执行策略:**差异驱动**,不是「整份重放」。
|
|
17
|
+
* schema.sql 是期望结构,先用 db/diff.js 与库中实际结构比对,只补真正缺的东西:
|
|
18
|
+
* 缺表 → CREATE TABLE ;缺列 → ALTER TABLE ADD COLUMN ;缺索引 → CREATE INDEX
|
|
19
|
+
* (整份重放对已存在的表是空操作,新增字段永远不会生效 —— 所以必须做差异识别。)
|
|
20
|
+
*
|
|
21
|
+
* 需要人工写迁移的情况(本脚本只识别、不擅自执行,避免静默丢数据):
|
|
22
|
+
* · 改列定义(类型/约束)→ SQLite 不支持直接改,需重建表
|
|
23
|
+
* · 删列 / 删表 → 涉及数据丢弃
|
|
24
|
+
* · 不满足 ALTER 限制的新列(主键 / UNIQUE / NOT NULL 无默认值)
|
|
25
|
+
* 这些写成 db/migrations/NNN-描述.sql,由本脚本按文件名顺序执行且只执行一次。
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
const crypto = require('crypto');
|
|
29
|
+
const { resolveTargets } = require('./clients');
|
|
30
|
+
const ddl = require('./ddl');
|
|
31
|
+
const diffLib = require('./diff');
|
|
32
|
+
const { dayjs } = require('../time');
|
|
33
|
+
|
|
34
|
+
const argv = process.argv;
|
|
35
|
+
const DRY = argv.includes('--dry-run') || argv.includes('--dry');
|
|
36
|
+
|
|
37
|
+
const META_TABLE = 'schema_meta';
|
|
38
|
+
// 与 schema.sql 中的定义保持一致(此处再写一遍是为了「先有表才能读 hash」的启动顺序)
|
|
39
|
+
const META_DDL = `CREATE TABLE IF NOT EXISTS ${META_TABLE} (
|
|
40
|
+
key TEXT PRIMARY KEY,
|
|
41
|
+
value TEXT,
|
|
42
|
+
updated_at TEXT)`;
|
|
43
|
+
const UPSERT_META = `INSERT INTO ${META_TABLE} (key, value, updated_at) VALUES (?,?,?)
|
|
44
|
+
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`;
|
|
45
|
+
|
|
46
|
+
const hash12 = (s) => crypto.createHash('sha1').update(s).digest('hex').slice(0, 12);
|
|
47
|
+
const nowBjt = () => dayjs.tz().format('YYYY-MM-DD HH:mm:ss');
|
|
48
|
+
const log = (m) => console.log(m);
|
|
49
|
+
const warn = (m) => console.warn(m);
|
|
50
|
+
|
|
51
|
+
/** 读出库中的实际结构(表 + 索引的 DDL 原文,SQLite 会把 CREATE 语句存在 sqlite_master 里)。 */
|
|
52
|
+
async function readActual(client) {
|
|
53
|
+
const tables = await client.query(
|
|
54
|
+
"SELECT name, sql FROM sqlite_master WHERE type = 'table' AND sql IS NOT NULL",
|
|
55
|
+
);
|
|
56
|
+
const indexes = await client.query(
|
|
57
|
+
"SELECT name, sql FROM sqlite_master WHERE type = 'index' AND sql IS NOT NULL",
|
|
58
|
+
);
|
|
59
|
+
const text = [...tables, ...indexes].map((r) => `${r.sql};`).join('\n');
|
|
60
|
+
return diffLib.parseObjects(text);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** 应用差异:只执行安全项,返回实际执行的语句条数。 */
|
|
64
|
+
async function applyDiff(client, d) {
|
|
65
|
+
const stmts = diffLib.applyStatements(d).map((sql) => ({ sql }));
|
|
66
|
+
if (stmts.length) await client.batch(stmts);
|
|
67
|
+
return stmts.length;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function reportDiff(tag, d) {
|
|
71
|
+
const bits = [];
|
|
72
|
+
if (d.addTables.length) bits.push(`新建表 ${d.addTables.map((t) => t.table).join(', ')}`);
|
|
73
|
+
if (d.addColumns.length) bits.push(`新增列 ${d.addColumns.map((c) => `${c.table}.${c.column}`).join(', ')}`);
|
|
74
|
+
if (d.addIndexes.length) bits.push(`新建索引 ${d.addIndexes.map((i) => i.name).join(', ')}`);
|
|
75
|
+
log(`[${tag}] ${bits.length ? bits.join(';') : '结构已是最新,无需变更'}`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function reportManual(tag, d) {
|
|
79
|
+
for (const c of d.unsafeColumns) {
|
|
80
|
+
warn(`[${tag}] ⚠ 无法自动加列 ${c.table}.${c.column}(${c.reason})→ 请写 db/migrations/ 迁移`);
|
|
81
|
+
}
|
|
82
|
+
for (const c of d.changedColumns) {
|
|
83
|
+
warn(`[${tag}] ⚠ 列定义已变 ${c.table}.${c.column}:库里「${c.actual}」≠ 期望「${c.desired}」→ 需手写迁移(SQLite 不支持直接改列)`);
|
|
84
|
+
}
|
|
85
|
+
for (const c of d.droppedColumns) {
|
|
86
|
+
warn(`[${tag}] ⚠ schema.sql 里已没有列 ${c.table}.${c.column},但库里仍存在:本脚本不会自动删列`);
|
|
87
|
+
}
|
|
88
|
+
for (const t of d.droppedTables) {
|
|
89
|
+
warn(`[${tag}] ⚠ schema.sql 里已没有表 ${t},但库里仍存在:本脚本不会自动删表`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function syncOne(target, statements, migrations) {
|
|
94
|
+
const { name, client } = target;
|
|
95
|
+
|
|
96
|
+
const desired = diffLib.parseObjects(ddl.readSchema());
|
|
97
|
+
const actual = await readActual(client);
|
|
98
|
+
const d = diffLib.diffSchema(desired, actual);
|
|
99
|
+
|
|
100
|
+
let oldHash = null;
|
|
101
|
+
try {
|
|
102
|
+
const rows = await client.query(`SELECT value FROM ${META_TABLE} WHERE key = ?`, ['schema_hash']);
|
|
103
|
+
oldHash = rows && rows.length ? rows[0].value : null;
|
|
104
|
+
} catch (_) {
|
|
105
|
+
oldHash = null; // schema_meta 尚不存在(首次运行)
|
|
106
|
+
}
|
|
107
|
+
const newHash = hash12(ddl.readSchema());
|
|
108
|
+
|
|
109
|
+
reportDiff(name, d);
|
|
110
|
+
reportManual(name, d);
|
|
111
|
+
|
|
112
|
+
if (DRY) {
|
|
113
|
+
const n = d.addTables.length + d.addColumns.length + d.addIndexes.length;
|
|
114
|
+
log(`[${name}] --dry-run:以上 ${n} 项结构变更未执行;schema hash ${newHash}`);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// 写入前先确保元信息表存在(否则无法记录 hash 与已应用迁移)
|
|
119
|
+
await client.batch([{ sql: META_DDL }]);
|
|
120
|
+
|
|
121
|
+
let applied = await applyDiff(client, d);
|
|
122
|
+
|
|
123
|
+
// 解析器安全网:万一 schema.sql 的写法超出解析能力(解析出 0 张表),退回整份重放。
|
|
124
|
+
// IF NOT EXISTS 保证重放无副作用,只是「新建整表」生效 —— 聊胜于无,不会更糟。
|
|
125
|
+
if (desired.tables.size === 0) {
|
|
126
|
+
warn(`[${name}] ⚠ 未能从 schema.sql 解析出任何表,退化为整份重放(请检查 db/diff.js 的解析规则)`);
|
|
127
|
+
await client.batch(statements.map((sql) => ({ sql })));
|
|
128
|
+
applied += statements.length;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// 增量迁移:按文件名顺序,只执行尚未记录过的
|
|
132
|
+
const doneRows = await client.query(`SELECT key FROM ${META_TABLE} WHERE key LIKE 'mig:%'`);
|
|
133
|
+
const done = new Set((doneRows || []).map((r) => r.key));
|
|
134
|
+
const migrated = [];
|
|
135
|
+
for (const m of migrations) {
|
|
136
|
+
const key = `mig:${m.name}`;
|
|
137
|
+
if (done.has(key)) continue;
|
|
138
|
+
await client.batch(m.statements.map((sql) => ({ sql })));
|
|
139
|
+
await client.batch([{ sql: UPSERT_META, params: [key, hash12(m.statements.join(';')), nowBjt()] }]);
|
|
140
|
+
migrated.push(m.name);
|
|
141
|
+
}
|
|
142
|
+
if (migrated.length) log(`[${name}] 已应用迁移 ${migrated.join(', ')}`);
|
|
143
|
+
|
|
144
|
+
await client.batch([{ sql: UPSERT_META, params: ['schema_hash', newHash, nowBjt()] }]);
|
|
145
|
+
log(
|
|
146
|
+
`[${name}] 完成,执行 ${applied} 条 DDL` +
|
|
147
|
+
(oldHash && oldHash !== newHash ? `;schema hash ${oldHash} → ${newHash}` : `;schema hash ${newHash}`),
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
(async () => {
|
|
152
|
+
const { targets, notes } = resolveTargets(argv);
|
|
153
|
+
for (const n of notes) log(`[提示] ${n}`);
|
|
154
|
+
|
|
155
|
+
const statements = ddl.loadStatements();
|
|
156
|
+
const migrations = ddl.loadMigrations();
|
|
157
|
+
log(`[schema] ${ddl.SCHEMA_FILE} → ${statements.length} 条语句;增量迁移文件 ${migrations.length} 个`);
|
|
158
|
+
|
|
159
|
+
if (!targets.length) {
|
|
160
|
+
log('[结束] 没有可同步的目标库(配好凭据后重跑,或加 --local 同步本地)');
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
let failed = false;
|
|
165
|
+
for (const t of targets) {
|
|
166
|
+
try {
|
|
167
|
+
await syncOne(t, statements, migrations);
|
|
168
|
+
} catch (e) {
|
|
169
|
+
failed = true;
|
|
170
|
+
console.error(`[${t.name}] 失败:${(e && e.message) || e}`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
if (failed) process.exit(1);
|
|
174
|
+
})().catch((e) => {
|
|
175
|
+
console.error('[致命]', (e && e.stack) || e);
|
|
176
|
+
process.exit(1);
|
|
177
|
+
});
|
package/db/normalize.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// 将一份初筛快照(data/snapshot-<锚定日>.json)规范化为入库结构。
|
|
3
|
+
// 关键:tier 由 r01/r07/r05 字段重新推导(快照里不含 tier,保证与流水线口径一致),
|
|
4
|
+
// 同票跨 run 自然成多行,满足「不同时期收录、全保留可区分」。
|
|
5
|
+
// 时间统一走 dayjs(北京时间 YYYY-MM-DD HH:mm:ss),见 ../time。
|
|
6
|
+
//
|
|
7
|
+
// 三个输出的**收录范围不同**,别混为一谈:
|
|
8
|
+
// picks → 只收「被分类的票」(重点/次级/条件 + R07/R05 触发),是入选记录;
|
|
9
|
+
// prices → **全观察池 × 本锚定日**,是 N 日复盘要的日线序列(与是否入选无关);
|
|
10
|
+
// base → 只收被分类的票的档案(候选列表/详情页用)。
|
|
11
|
+
// 三者原来共用一个 `continue`,导致日线只写了入选日 → N 日收益口径长期失真。
|
|
12
|
+
|
|
13
|
+
const { now: fmtNow } = require('../time');
|
|
14
|
+
|
|
15
|
+
function tierOf(s) {
|
|
16
|
+
const r = s.r01 || {};
|
|
17
|
+
if (r.ok) return 'high'; // 重点关注:R01 四门槛全过
|
|
18
|
+
if (r.capOk && r.highZoneOk && (r.core ?? 0) >= 2) {
|
|
19
|
+
return (r.core >= 3) ? 'secondary' : 'conditional'; // 次级 / 条件
|
|
20
|
+
}
|
|
21
|
+
return 'excluded';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// 「是否入选」:只入库被分类的票(重点/次级/条件),或触发 R07 补涨 / R05 尾盘异动的票。
|
|
25
|
+
// 与用户要求一致——只保存分类(R01/R07/R05/重点、次级、条件)的票,不保存 377 只全部。
|
|
26
|
+
// 注意:R07/R05 是叠加在个股上的信号,可能单独触发(未达 R01 梯队),这类票 tier 记为 excluded
|
|
27
|
+
// 但 r07_laggard / r05_partial 标记为 1,前端据此仍能识别其入选维度。
|
|
28
|
+
function isPicked(s) {
|
|
29
|
+
const r = s.r01 || {};
|
|
30
|
+
if (r.ok) return true; // 重点:R01 全过
|
|
31
|
+
if (r.capOk && r.highZoneOk && (r.core ?? 0) >= 2) return true; // 次级 / 条件
|
|
32
|
+
if (s.r07 && s.r07.laggard) return true; // R07 板块内补涨
|
|
33
|
+
if (s.r05 && s.r05.verdict === 'partial') return true; // R05 尾盘异动部分触发
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function reasonOf(s) {
|
|
38
|
+
const r = s.r01 || {};
|
|
39
|
+
const parts = [];
|
|
40
|
+
if (r.ok) parts.push('R01 量能验证突破(四门槛全过)');
|
|
41
|
+
if (s.r07 && s.r07.laggard) parts.push('R07 板块内补涨滞后');
|
|
42
|
+
if (s.r05 && s.r05.verdict === 'partial') parts.push('R05 尾盘量价达标(部分触发)');
|
|
43
|
+
if (!parts.length) {
|
|
44
|
+
if (r.capOk === false && r.mktCapYi != null) parts.push(`市值 ${Number(r.mktCapYi).toFixed(1)} 亿超出 20–500 亿区间`);
|
|
45
|
+
else if (r.capOk === false && r.mktCapYi == null) parts.push('市值数据缺失');
|
|
46
|
+
if (r.highZoneOk === false) parts.push('处于 52 周高位区');
|
|
47
|
+
if ((r.core ?? 0) < 2) parts.push(`量价突破仅达成 ${r.core ?? 0}/4`);
|
|
48
|
+
if (r.error) parts.push('行情数据缺失');
|
|
49
|
+
if (s.r05 && s.r05.verdict === 'gap') parts.push('R05 分时数据缺口');
|
|
50
|
+
}
|
|
51
|
+
return parts.join(';') || '未触发';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// 入参 snap:{ anchor, target, stocks:[...], sectors:[...], ... }
|
|
55
|
+
function normalizeSnap(snap) {
|
|
56
|
+
const { anchor, target, stocks = [] } = snap;
|
|
57
|
+
const ts = fmtNow(); // 北京时间 YYYY-MM-DD HH:mm:ss
|
|
58
|
+
const picks = [], prices = [], base = [];
|
|
59
|
+
let high = 0, secondary = 0, conditional = 0, excluded = 0;
|
|
60
|
+
let r01 = 0, r07 = 0, r05 = 0, err = 0;
|
|
61
|
+
|
|
62
|
+
for (const s of stocks) {
|
|
63
|
+
const r = s.r01 || {};
|
|
64
|
+
const tier = tierOf(s);
|
|
65
|
+
if (tier === 'high') high++;
|
|
66
|
+
else if (tier === 'secondary') secondary++;
|
|
67
|
+
else if (tier === 'conditional') conditional++;
|
|
68
|
+
else excluded++;
|
|
69
|
+
if (r.ok) r01++;
|
|
70
|
+
if (s.r07 && s.r07.laggard) r07++;
|
|
71
|
+
if (s.r05 && s.r05.verdict === 'partial') r05++;
|
|
72
|
+
if (r.error) err++;
|
|
73
|
+
|
|
74
|
+
// —— 行情:**全观察池 × 本锚定日**,与「是否入选」无关 ——
|
|
75
|
+
// N 日收益 = 「锚定日之后第 N 个交易日」的收盘 vs 入选价,前提是每只票每个交易日都有行。
|
|
76
|
+
// 原来只写入选日那一行,序列必然断档:下一行往往是「该票**下一次被入选**那天」,
|
|
77
|
+
// 实测平均隔 8.9 个自然日(最长 37 天)、只有 19% 真的隔 1 天 —— N1 报的其实是第 8~37 天。
|
|
78
|
+
// 停牌 / 行情缺失(close 为空)**不写行**:写一行 null 会占掉一个交易日的位置,
|
|
79
|
+
// 让「第 N 个交易日」整体前移,比缺一行更糟。
|
|
80
|
+
const close = (typeof r.close === 'number' && r.close > 0) ? r.close : null;
|
|
81
|
+
if (close !== null) {
|
|
82
|
+
prices.push({
|
|
83
|
+
code: s.code, date: anchor, open: null, high: null, low: null,
|
|
84
|
+
close, chg_pct: r.chg ?? null, turnover: r.turn ?? null,
|
|
85
|
+
circ_market_cap: r.circCapYi != null ? r.circCapYi * 1e8 : null,
|
|
86
|
+
total_market_cap: r.mktCapYi != null ? r.mktCapYi * 1e8 : null,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// —— 入选记录 / 基础档案:只存被分类的票(重点/次级/条件/R07/R05),不保存全部 377 只 ——
|
|
91
|
+
// 与上面的行情是两回事:行情要「全池、逐日、相邻」,入选记录只要「被分类的票」。
|
|
92
|
+
if (!isPicked(s)) continue;
|
|
93
|
+
|
|
94
|
+
picks.push({
|
|
95
|
+
anchor_date: anchor, code: s.code, name: s.name,
|
|
96
|
+
sector: s.sector || '未分类', tier,
|
|
97
|
+
r01_ok: r.ok ? 1 : 0,
|
|
98
|
+
r07_laggard: (s.r07 && s.r07.laggard) ? 1 : 0,
|
|
99
|
+
r05_partial: (s.r05 && s.r05.verdict === 'partial') ? 1 : 0,
|
|
100
|
+
core: r.core ?? null, r01_chg: r.chg ?? null, price: r.close ?? null,
|
|
101
|
+
turnover: r.turn ?? null,
|
|
102
|
+
vol_ratio: r.volRatio ?? null,
|
|
103
|
+
circ_market_cap: r.circCapYi != null ? r.circCapYi * 1e8 : null,
|
|
104
|
+
total_market_cap: r.mktCapYi != null ? r.mktCapYi * 1e8 : null,
|
|
105
|
+
sector_pct: (s.r07 && s.r07.sectorPct) ?? null,
|
|
106
|
+
sector_rank: (s.r07 && s.r07.sectorRank) ?? null,
|
|
107
|
+
reason: reasonOf(s), picked_at: ts,
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
base.push({ code: s.code, name: s.name, sector: s.sector || '未分类', updated_at: ts });
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const run = {
|
|
114
|
+
anchor_date: anchor, target_date: target, run_at: ts,
|
|
115
|
+
universe_count: stocks.length, r01_count: r01, r07_count: r07, r05_count: r05,
|
|
116
|
+
high_count: high, secondary_count: secondary, conditional_count: conditional,
|
|
117
|
+
excluded_count: excluded,
|
|
118
|
+
kline_fail_rate: stocks.length ? err / stocks.length : null,
|
|
119
|
+
source: 'tencent-live', note: '',
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const seen = new Set();
|
|
123
|
+
const baseUniq = base.filter(b => (seen.has(b.code) ? false : (seen.add(b.code), true)));
|
|
124
|
+
return { run, picks, prices, base: baseUniq };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
exports.normalizeSnap = normalizeSnap;
|
|
128
|
+
exports.tierOf = tierOf;
|
|
129
|
+
exports.reasonOf = reasonOf;
|
package/db/persist.js
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// 将规范化后的快照写入 Cloudflare D1(run_batch + pick_record + price_daily + stock_base)
|
|
3
|
+
// 幂等:同一锚定日重复跑不会重复插入(run_batch UNIQUE anchor_date;pick_record UNIQUE(run_id,code))
|
|
4
|
+
const { normalizeSnap } = require('./normalize');
|
|
5
|
+
|
|
6
|
+
const RUN_Q = `INSERT OR IGNORE INTO run_batch
|
|
7
|
+
(anchor_date,target_date,run_at,universe_count,r01_count,r07_count,r05_count,
|
|
8
|
+
high_count,secondary_count,conditional_count,excluded_count,kline_fail_rate,source,note)
|
|
9
|
+
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)`;
|
|
10
|
+
const RUN_SEL = 'SELECT id FROM run_batch WHERE anchor_date=?';
|
|
11
|
+
|
|
12
|
+
// 入选 / 行情写入采用「recency guard」而非 INSERT OR REPLACE:
|
|
13
|
+
// 同一锚定日若被本地与 GitHub 两端重复写入,仅当新快照的 run_at(数据生成时刻)≥ 已有值才覆盖,
|
|
14
|
+
// 避免「陈旧离线兜底数据」把「新鲜实时数据」顶掉(双写冲突的核心护栏之一)。
|
|
15
|
+
// 新行(无冲突)正常 INSERT;冲突且更新条件不满足时静默保留较新数据。
|
|
16
|
+
const PICK_Q = `INSERT INTO pick_record
|
|
17
|
+
(run_id,anchor_date,code,name,sector,tier,r01_ok,r07_laggard,r05_partial,core,r01_chg,price,
|
|
18
|
+
turnover,vol_ratio,circ_market_cap,total_market_cap,sector_pct,sector_rank,reason,picked_at,run_at)
|
|
19
|
+
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
|
20
|
+
ON CONFLICT(run_id, code) DO UPDATE SET
|
|
21
|
+
name=excluded.name, sector=excluded.sector, tier=excluded.tier, r01_ok=excluded.r01_ok,
|
|
22
|
+
r07_laggard=excluded.r07_laggard, r05_partial=excluded.r05_partial, core=excluded.core,
|
|
23
|
+
r01_chg=excluded.r01_chg, price=excluded.price, turnover=excluded.turnover,
|
|
24
|
+
vol_ratio=excluded.vol_ratio,
|
|
25
|
+
circ_market_cap=excluded.circ_market_cap, total_market_cap=excluded.total_market_cap,
|
|
26
|
+
sector_pct=excluded.sector_pct, sector_rank=excluded.sector_rank, reason=excluded.reason,
|
|
27
|
+
picked_at=excluded.picked_at, run_at=excluded.run_at
|
|
28
|
+
WHERE excluded.run_at >= pick_record.run_at OR pick_record.run_at IS NULL`;
|
|
29
|
+
const PRICE_Q = `INSERT INTO price_daily
|
|
30
|
+
(code,date,open,high,low,close,chg_pct,turnover,circ_market_cap,total_market_cap,run_at)
|
|
31
|
+
VALUES (?,?,?,?,?,?,?,?,?,?,?)
|
|
32
|
+
ON CONFLICT(code, date) DO UPDATE SET
|
|
33
|
+
open=excluded.open, high=excluded.high, low=excluded.low, close=excluded.close,
|
|
34
|
+
chg_pct=excluded.chg_pct, turnover=excluded.turnover,
|
|
35
|
+
circ_market_cap=excluded.circ_market_cap, total_market_cap=excluded.total_market_cap,
|
|
36
|
+
run_at=excluded.run_at
|
|
37
|
+
WHERE excluded.run_at >= price_daily.run_at OR price_daily.run_at IS NULL`;
|
|
38
|
+
const BASE_Q = `INSERT OR IGNORE INTO stock_base (code,name,sector,updated_at) VALUES (?,?,?,?)`;
|
|
39
|
+
|
|
40
|
+
// 惰性迁移:为既有 D1 / 本地 SQLite 补上 run_at 列(新库由 schema.sql 直接建好)。
|
|
41
|
+
// 两端都会跑,先到者成功、后到者捕获「列已存在」忽略即可。
|
|
42
|
+
let _migrated = false;
|
|
43
|
+
async function ensureColumns(client) {
|
|
44
|
+
if (_migrated) return;
|
|
45
|
+
_migrated = true;
|
|
46
|
+
for (const sql of [
|
|
47
|
+
'ALTER TABLE pick_record ADD COLUMN run_at TEXT',
|
|
48
|
+
'ALTER TABLE pick_record ADD COLUMN vol_ratio REAL',
|
|
49
|
+
'ALTER TABLE price_daily ADD COLUMN run_at TEXT',
|
|
50
|
+
]) {
|
|
51
|
+
try { await client.query(sql); } catch (_) { /* 列已存在则忽略 */ }
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function persistSnap(snap, d1) {
|
|
56
|
+
const { run, picks, prices, base } = normalizeSnap(snap);
|
|
57
|
+
await ensureColumns(d1);
|
|
58
|
+
|
|
59
|
+
await d1.query(RUN_Q, [
|
|
60
|
+
run.anchor_date, run.target_date, run.run_at, run.universe_count, run.r01_count,
|
|
61
|
+
run.r07_count, run.r05_count, run.high_count, run.secondary_count,
|
|
62
|
+
run.conditional_count, run.excluded_count, run.kline_fail_rate, run.source, run.note,
|
|
63
|
+
]);
|
|
64
|
+
const rows = await d1.query(RUN_SEL, [run.anchor_date]);
|
|
65
|
+
const runId = rows[0] && rows[0].id;
|
|
66
|
+
if (!runId) throw new Error('无法获取 run_id: ' + run.anchor_date);
|
|
67
|
+
|
|
68
|
+
const stmts = [];
|
|
69
|
+
for (const p of picks) {
|
|
70
|
+
stmts.push({ sql: PICK_Q, params: [
|
|
71
|
+
runId, p.anchor_date, p.code, p.name, p.sector, p.tier, p.r01_ok, p.r07_laggard,
|
|
72
|
+
p.r05_partial, p.core, p.r01_chg, p.price, p.turnover, p.vol_ratio, p.circ_market_cap,
|
|
73
|
+
p.total_market_cap, p.sector_pct, p.sector_rank, p.reason, p.picked_at, run.run_at,
|
|
74
|
+
] });
|
|
75
|
+
}
|
|
76
|
+
for (const pr of prices) {
|
|
77
|
+
stmts.push({ sql: PRICE_Q, params: [
|
|
78
|
+
pr.code, pr.date, pr.open, pr.high, pr.low, pr.close, pr.chg_pct,
|
|
79
|
+
pr.turnover, pr.circ_market_cap, pr.total_market_cap, run.run_at,
|
|
80
|
+
] });
|
|
81
|
+
}
|
|
82
|
+
for (const b of base) {
|
|
83
|
+
stmts.push({ sql: BASE_Q, params: [b.code, b.name, b.sector, b.updated_at] });
|
|
84
|
+
}
|
|
85
|
+
await d1.batch(stmts);
|
|
86
|
+
|
|
87
|
+
return { runId, picks: picks.length, prices: prices.length, base: base.length };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
module.exports = { persistSnap };
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// 本地 SQLite 查询助手:把传入的 SQL 跑在 db/local.db 上并以表格打印。
|
|
3
|
+
// 用法:
|
|
4
|
+
// node db/query_local.js # 打印内置概览(批次 / 分类分布)
|
|
5
|
+
// node db/query_local.js "SELECT * FROM run_batch ORDER BY anchor_date"
|
|
6
|
+
// node db/query_local.js "SELECT code,name,tier,reason FROM pick_record WHERE code='sz002594'"
|
|
7
|
+
// 仅允许读语句(SELECT/WITH),避免误改本地库。
|
|
8
|
+
const localSqlite = require('./sqlite_client');
|
|
9
|
+
|
|
10
|
+
const sql = process.argv.slice(2).join(' ').trim();
|
|
11
|
+
const client = localSqlite.createClient();
|
|
12
|
+
|
|
13
|
+
function table(rows) {
|
|
14
|
+
if (!rows.length) { console.log('(空结果)'); return; }
|
|
15
|
+
const cols = Object.keys(rows[0]);
|
|
16
|
+
const w = cols.map(c => Math.max(c.length, ...rows.map(r => String(r[c] ?? '').length)));
|
|
17
|
+
const sep = cols.map((c, i) => '-'.repeat(w[i])).join('-+-');
|
|
18
|
+
console.log(cols.map((c, i) => String(c).padEnd(w[i])).join(' | '));
|
|
19
|
+
console.log(sep);
|
|
20
|
+
for (const r of rows) console.log(cols.map((c, i) => String(r[c] ?? '').padEnd(w[i])).join(' | '));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
(async () => {
|
|
24
|
+
let rows;
|
|
25
|
+
if (!sql) {
|
|
26
|
+
console.log('=== 运行批次(run_batch)===');
|
|
27
|
+
table(await client.query('SELECT anchor_date, universe_count, high_count, secondary_count, conditional_count, excluded_count FROM run_batch ORDER BY anchor_date'));
|
|
28
|
+
console.log('\n=== 入选分类分布(pick_record)===');
|
|
29
|
+
table(await client.query("SELECT tier, COUNT(*) c FROM pick_record GROUP BY tier ORDER BY c DESC"));
|
|
30
|
+
const b = await client.query('SELECT COUNT(*) c FROM stock_base');
|
|
31
|
+
console.log('\nstock_base 档案数:', (b[0] && b[0].c) || 0);
|
|
32
|
+
// 多次入选(复盘重点)
|
|
33
|
+
console.log('\n=== 多次入选的票(同票跨 run)===');
|
|
34
|
+
table(await client.query("SELECT code, name, COUNT(DISTINCT run_id) runs, GROUP_CONCAT(DISTINCT anchor_date) dates FROM pick_record GROUP BY code HAVING runs > 1 ORDER BY runs DESC LIMIT 20"));
|
|
35
|
+
} else {
|
|
36
|
+
if (!/^\s*(SELECT|WITH|PRAGMA|EXPLAIN|VALUES)\b/i.test(sql)) {
|
|
37
|
+
console.error('只允许只读查询(SELECT / WITH / PRAGMA)。'); process.exit(1);
|
|
38
|
+
}
|
|
39
|
+
rows = await client.query(sql);
|
|
40
|
+
table(rows);
|
|
41
|
+
}
|
|
42
|
+
if (client.close) client.close();
|
|
43
|
+
})().catch(e => { console.error('查询失败:', e.message); process.exit(1); });
|