@cablate/banini-tracker 2.0.6 → 2.0.8
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/dist/db.d.ts +3 -0
- package/dist/db.js +63 -0
- package/dist/index.js +24 -1
- package/dist/stock-map.d.ts +14 -0
- package/dist/stock-map.js +51 -0
- package/dist/stock-price.d.ts +31 -0
- package/dist/stock-price.js +121 -0
- package/dist/tracker.d.ts +26 -0
- package/dist/tracker.js +209 -0
- package/package.json +3 -1
package/dist/db.d.ts
ADDED
package/dist/db.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import Database from 'better-sqlite3';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { mkdirSync } from 'fs';
|
|
4
|
+
import { homedir } from 'os';
|
|
5
|
+
const DATA_DIR = process.env.DATA_DIR || join(homedir(), '.banini-tracker');
|
|
6
|
+
let db = null;
|
|
7
|
+
export function getDb() {
|
|
8
|
+
if (db)
|
|
9
|
+
return db;
|
|
10
|
+
mkdirSync(DATA_DIR, { recursive: true });
|
|
11
|
+
const dbPath = join(DATA_DIR, 'banini.db');
|
|
12
|
+
const instance = new Database(dbPath);
|
|
13
|
+
instance.pragma('journal_mode = WAL');
|
|
14
|
+
instance.pragma('foreign_keys = ON');
|
|
15
|
+
migrate(instance);
|
|
16
|
+
// 只在 migrate 成功後才設值
|
|
17
|
+
db = instance;
|
|
18
|
+
return db;
|
|
19
|
+
}
|
|
20
|
+
function migrate(db) {
|
|
21
|
+
db.exec(`
|
|
22
|
+
CREATE TABLE IF NOT EXISTS predictions (
|
|
23
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
24
|
+
post_id TEXT NOT NULL,
|
|
25
|
+
post_url TEXT,
|
|
26
|
+
symbol_name TEXT NOT NULL,
|
|
27
|
+
symbol_code TEXT,
|
|
28
|
+
symbol_type TEXT NOT NULL,
|
|
29
|
+
her_action TEXT NOT NULL,
|
|
30
|
+
reverse_view TEXT NOT NULL,
|
|
31
|
+
confidence TEXT NOT NULL,
|
|
32
|
+
reasoning TEXT,
|
|
33
|
+
base_price REAL,
|
|
34
|
+
created_at TEXT NOT NULL,
|
|
35
|
+
recorded_at TEXT NOT NULL,
|
|
36
|
+
status TEXT NOT NULL DEFAULT 'tracking',
|
|
37
|
+
completed_at TEXT,
|
|
38
|
+
next_prediction_id INTEGER REFERENCES predictions(id),
|
|
39
|
+
UNIQUE(post_id, symbol_name)
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
CREATE TABLE IF NOT EXISTS price_snapshots (
|
|
43
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
44
|
+
prediction_id INTEGER NOT NULL REFERENCES predictions(id),
|
|
45
|
+
day_number INTEGER NOT NULL,
|
|
46
|
+
date TEXT NOT NULL,
|
|
47
|
+
open_price REAL NOT NULL,
|
|
48
|
+
high_price REAL NOT NULL,
|
|
49
|
+
low_price REAL NOT NULL,
|
|
50
|
+
close_price REAL NOT NULL,
|
|
51
|
+
change_pct_close REAL NOT NULL,
|
|
52
|
+
change_pct_high REAL NOT NULL,
|
|
53
|
+
change_pct_low REAL NOT NULL,
|
|
54
|
+
UNIQUE(prediction_id, date)
|
|
55
|
+
);
|
|
56
|
+
`);
|
|
57
|
+
}
|
|
58
|
+
export function closeDb() {
|
|
59
|
+
if (db) {
|
|
60
|
+
db.close();
|
|
61
|
+
db = null;
|
|
62
|
+
}
|
|
63
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -17,6 +17,7 @@ import { sendTelegramMessageWithConfig, formatReport, formatFallbackReport } fro
|
|
|
17
17
|
import { filterNewPosts as filterNew, markPostsSeen } from './seen.js';
|
|
18
18
|
import { withRetry } from './retry.js';
|
|
19
19
|
import { createTranscriber, transcribeVideoPosts } from './transcribe.js';
|
|
20
|
+
import { recordPredictions, updateTracking } from './tracker.js';
|
|
20
21
|
// ── Config ──────────────────────────────────────────────────
|
|
21
22
|
const FB_PAGE_URL = 'https://www.facebook.com/DieWithoutBang/';
|
|
22
23
|
const DATA_DIR = process.env.DATA_DIR || join(process.cwd(), 'data');
|
|
@@ -203,7 +204,23 @@ async function runInner(opts) {
|
|
|
203
204
|
else {
|
|
204
205
|
console.log('[Telegram] 未設定 TG_BOT_TOKEN / TG_CHANNEL_ID,跳過通知');
|
|
205
206
|
}
|
|
206
|
-
// 8.
|
|
207
|
+
// 8. 預測追蹤記錄
|
|
208
|
+
if (analysis.hasInvestmentContent && !llmFailed) {
|
|
209
|
+
try {
|
|
210
|
+
const postInfos = newPosts.map((p) => ({
|
|
211
|
+
id: p.id,
|
|
212
|
+
url: p.url,
|
|
213
|
+
timestamp: p.timestamp,
|
|
214
|
+
}));
|
|
215
|
+
const count = await recordPredictions(analysis, postInfos);
|
|
216
|
+
if (count > 0)
|
|
217
|
+
console.log(`[tracker] 已記錄 ${count} 筆預測`);
|
|
218
|
+
}
|
|
219
|
+
catch (err) {
|
|
220
|
+
console.error(`[tracker] 記錄預測失敗: ${err instanceof Error ? err.message : err}`);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
// 9. 存檔
|
|
207
224
|
mkdirSync(DATA_DIR, { recursive: true });
|
|
208
225
|
const outFile = join(DATA_DIR, `report-${new Date().toISOString().slice(0, 19).replace(/:/g, '')}.json`);
|
|
209
226
|
writeFileSync(outFile, JSON.stringify({ timestamp: new Date().toISOString(), posts: newPosts, analysis }, null, 2), 'utf-8');
|
|
@@ -221,6 +238,11 @@ if (isCronMode) {
|
|
|
221
238
|
run({ maxPosts: 1, isDryRun: false, label: '盤中' })
|
|
222
239
|
.catch((err) => console.error('[盤中] 執行失敗:', err));
|
|
223
240
|
}, { timezone: 'Asia/Taipei' });
|
|
241
|
+
// 追蹤更新:週一到五 15:00(收盤後更新預測追蹤)
|
|
242
|
+
cron.schedule('0 15 * * 1-5', () => {
|
|
243
|
+
updateTracking()
|
|
244
|
+
.catch((err) => console.error('[追蹤更新] 執行失敗:', err));
|
|
245
|
+
}, { timezone: 'Asia/Taipei' });
|
|
224
246
|
// 盤後:每天晚上 23:00,FB 3 篇
|
|
225
247
|
cron.schedule('3 23 * * *', () => {
|
|
226
248
|
run({ maxPosts: 3, isDryRun: false, label: '盤後' })
|
|
@@ -228,6 +250,7 @@ if (isCronMode) {
|
|
|
228
250
|
}, { timezone: 'Asia/Taipei' });
|
|
229
251
|
console.log('=== 巴逆逆排程已啟動 ===');
|
|
230
252
|
console.log(' 盤中:週一~五 09:07/09:37/10:07/.../13:07(FB, 1 篇)');
|
|
253
|
+
console.log(' 追蹤更新:週一~五 15:00(預測追蹤判定)');
|
|
231
254
|
console.log(' 盤後:每天 23:03(FB, 3 篇)');
|
|
232
255
|
console.log(' 按 Ctrl+C 停止\n');
|
|
233
256
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export interface StockInfo {
|
|
2
|
+
code: string;
|
|
3
|
+
name: string;
|
|
4
|
+
market: 'tse' | 'otc';
|
|
5
|
+
}
|
|
6
|
+
export interface ResolvedStock {
|
|
7
|
+
code: string;
|
|
8
|
+
market: 'tse' | 'otc';
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* 名稱→代碼映射
|
|
12
|
+
* 匹配順序:完全匹配 → 包含匹配 → 反向包含
|
|
13
|
+
*/
|
|
14
|
+
export declare function resolveStock(name: string): ResolvedStock | null;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { readFileSync } from 'fs';
|
|
2
|
+
import { join, dirname } from 'path';
|
|
3
|
+
import { fileURLToPath } from 'url';
|
|
4
|
+
let stockList = null;
|
|
5
|
+
function loadStockList() {
|
|
6
|
+
if (stockList)
|
|
7
|
+
return stockList;
|
|
8
|
+
// 嘗試從專案 data/ 目錄載入
|
|
9
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
const candidates = [
|
|
11
|
+
join(__dirname, '..', 'data', 'tw-stock-list.json'),
|
|
12
|
+
join(__dirname, 'data', 'tw-stock-list.json'),
|
|
13
|
+
];
|
|
14
|
+
for (const path of candidates) {
|
|
15
|
+
try {
|
|
16
|
+
const raw = readFileSync(path, 'utf-8');
|
|
17
|
+
stockList = JSON.parse(raw);
|
|
18
|
+
console.log(`[stock-map] 載入 ${stockList.length} 檔股票映射(${path})`);
|
|
19
|
+
return stockList;
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
// 繼續嘗試下一個路徑
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
console.warn('[stock-map] 找不到 tw-stock-list.json,名稱映射將無法使用');
|
|
26
|
+
stockList = [];
|
|
27
|
+
return stockList;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* 名稱→代碼映射
|
|
31
|
+
* 匹配順序:完全匹配 → 包含匹配 → 反向包含
|
|
32
|
+
*/
|
|
33
|
+
export function resolveStock(name) {
|
|
34
|
+
const list = loadStockList();
|
|
35
|
+
if (list.length === 0)
|
|
36
|
+
return null;
|
|
37
|
+
const trimmed = name.trim();
|
|
38
|
+
// 1. 完全匹配
|
|
39
|
+
const exact = list.find((s) => s.name === trimmed);
|
|
40
|
+
if (exact)
|
|
41
|
+
return { code: exact.code, market: exact.market };
|
|
42
|
+
// 2. 包含匹配:輸入名稱包含在股票名稱中(如「台光電」→「台光電」)
|
|
43
|
+
const contains = list.find((s) => s.name.includes(trimmed));
|
|
44
|
+
if (contains)
|
|
45
|
+
return { code: contains.code, market: contains.market };
|
|
46
|
+
// 3. 反向包含:股票名稱包含在輸入中(如「台積電ADR」→「台積電」)
|
|
47
|
+
const reverse = list.find((s) => trimmed.includes(s.name));
|
|
48
|
+
if (reverse)
|
|
49
|
+
return { code: reverse.code, market: reverse.market };
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 股價查詢模組
|
|
3
|
+
* - TWSE 即時報價(盤中)
|
|
4
|
+
* - FinMind OHLC(盤後歷史資料)
|
|
5
|
+
*/
|
|
6
|
+
export interface OHLCData {
|
|
7
|
+
date: string;
|
|
8
|
+
open: number;
|
|
9
|
+
high: number;
|
|
10
|
+
low: number;
|
|
11
|
+
close: number;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* 判斷台股是否在交易時間(週一到五 09:00-13:30 台北時間)
|
|
15
|
+
*/
|
|
16
|
+
export declare function isMarketOpen(now?: Date): boolean;
|
|
17
|
+
/**
|
|
18
|
+
* TWSE 即時報價(盤中使用)
|
|
19
|
+
* 回傳最新成交價
|
|
20
|
+
*/
|
|
21
|
+
export declare function getRealtimePrice(code: string, market: 'tse' | 'otc'): Promise<number | null>;
|
|
22
|
+
/**
|
|
23
|
+
* FinMind API 取得歷史 OHLC 資料
|
|
24
|
+
*/
|
|
25
|
+
export declare function getDailyOHLC(code: string, startDate: string, endDate?: string): Promise<OHLCData[]>;
|
|
26
|
+
/**
|
|
27
|
+
* 取得基準價格(以貼文時間為準)
|
|
28
|
+
* 貼文當天 + 盤中 → TWSE 即時報價
|
|
29
|
+
* 其他情況 → FinMind 貼文當日收盤價
|
|
30
|
+
*/
|
|
31
|
+
export declare function getBasePrice(code: string, market: 'tse' | 'otc', postTimestamp?: string): Promise<number | null>;
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 股價查詢模組
|
|
3
|
+
* - TWSE 即時報價(盤中)
|
|
4
|
+
* - FinMind OHLC(盤後歷史資料)
|
|
5
|
+
*/
|
|
6
|
+
const FINMIND_TOKEN = process.env.FINMIND_TOKEN || '';
|
|
7
|
+
const FINMIND_BASE = 'https://api.finmindtrade.com/api/v4/data';
|
|
8
|
+
const TWSE_REALTIME = 'https://mis.twse.com.tw/stock/api/getStockInfo.jsp';
|
|
9
|
+
/**
|
|
10
|
+
* 判斷台股是否在交易時間(週一到五 09:00-13:30 台北時間)
|
|
11
|
+
*/
|
|
12
|
+
export function isMarketOpen(now) {
|
|
13
|
+
const taipei = new Date((now ?? new Date()).toLocaleString('en-US', { timeZone: 'Asia/Taipei' }));
|
|
14
|
+
const day = taipei.getDay();
|
|
15
|
+
if (day === 0 || day === 6)
|
|
16
|
+
return false;
|
|
17
|
+
const hours = taipei.getHours();
|
|
18
|
+
const minutes = taipei.getMinutes();
|
|
19
|
+
const timeMinutes = hours * 60 + minutes;
|
|
20
|
+
return timeMinutes >= 540 && timeMinutes <= 810; // 09:00 ~ 13:30
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* TWSE 即時報價(盤中使用)
|
|
24
|
+
* 回傳最新成交價
|
|
25
|
+
*/
|
|
26
|
+
export async function getRealtimePrice(code, market) {
|
|
27
|
+
try {
|
|
28
|
+
const prefix = market === 'tse' ? 'tse' : 'otc';
|
|
29
|
+
const url = `${TWSE_REALTIME}?ex_ch=${prefix}_${code}.tw&json=1&delay=0`;
|
|
30
|
+
const res = await fetch(url, {
|
|
31
|
+
headers: { 'User-Agent': 'Mozilla/5.0' },
|
|
32
|
+
signal: AbortSignal.timeout(10000),
|
|
33
|
+
});
|
|
34
|
+
if (!res.ok)
|
|
35
|
+
return null;
|
|
36
|
+
const data = await res.json();
|
|
37
|
+
const info = data?.msgArray?.[0];
|
|
38
|
+
if (!info)
|
|
39
|
+
return null;
|
|
40
|
+
// z = 最新成交價,y = 昨收
|
|
41
|
+
const price = parseFloat(info.z);
|
|
42
|
+
if (!isNaN(price) && price > 0)
|
|
43
|
+
return price;
|
|
44
|
+
// 尚未成交,用昨收
|
|
45
|
+
const yesterday = parseFloat(info.y);
|
|
46
|
+
if (!isNaN(yesterday) && yesterday > 0)
|
|
47
|
+
return yesterday;
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
catch (err) {
|
|
51
|
+
console.warn(`[stock-price] TWSE 即時報價失敗 (${code}): ${err instanceof Error ? err.message : err}`);
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* FinMind API 取得歷史 OHLC 資料
|
|
57
|
+
*/
|
|
58
|
+
export async function getDailyOHLC(code, startDate, endDate) {
|
|
59
|
+
const end = endDate ?? startDate;
|
|
60
|
+
const params = new URLSearchParams({
|
|
61
|
+
dataset: 'TaiwanStockPrice',
|
|
62
|
+
data_id: code,
|
|
63
|
+
start_date: startDate,
|
|
64
|
+
end_date: end,
|
|
65
|
+
});
|
|
66
|
+
if (FINMIND_TOKEN)
|
|
67
|
+
params.set('token', FINMIND_TOKEN);
|
|
68
|
+
const url = `${FINMIND_BASE}?${params}`;
|
|
69
|
+
try {
|
|
70
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(15000) });
|
|
71
|
+
if (!res.ok) {
|
|
72
|
+
console.warn(`[stock-price] FinMind API 回應 ${res.status}`);
|
|
73
|
+
return [];
|
|
74
|
+
}
|
|
75
|
+
const json = await res.json();
|
|
76
|
+
if (json.status !== 200 || !Array.isArray(json.data)) {
|
|
77
|
+
console.warn(`[stock-price] FinMind API 錯誤: ${json.msg ?? 'unknown'}`);
|
|
78
|
+
return [];
|
|
79
|
+
}
|
|
80
|
+
return json.data.map((d) => ({
|
|
81
|
+
date: d.date,
|
|
82
|
+
open: d.open,
|
|
83
|
+
high: d.max,
|
|
84
|
+
low: d.min,
|
|
85
|
+
close: d.close,
|
|
86
|
+
}));
|
|
87
|
+
}
|
|
88
|
+
catch (err) {
|
|
89
|
+
console.warn(`[stock-price] FinMind API 失敗 (${code}): ${err instanceof Error ? err.message : err}`);
|
|
90
|
+
return [];
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* 取得基準價格(以貼文時間為準)
|
|
95
|
+
* 貼文當天 + 盤中 → TWSE 即時報價
|
|
96
|
+
* 其他情況 → FinMind 貼文當日收盤價
|
|
97
|
+
*/
|
|
98
|
+
export async function getBasePrice(code, market, postTimestamp) {
|
|
99
|
+
const now = new Date();
|
|
100
|
+
const postDate = postTimestamp ? new Date(postTimestamp) : now;
|
|
101
|
+
const postDateStr = postDate.toLocaleDateString('en-CA', { timeZone: 'Asia/Taipei' });
|
|
102
|
+
const todayStr = now.toLocaleDateString('en-CA', { timeZone: 'Asia/Taipei' });
|
|
103
|
+
// 貼文是今天 + 現在盤中 → 即時報價(最接近發文時的價格)
|
|
104
|
+
if (postDateStr === todayStr && isMarketOpen()) {
|
|
105
|
+
const price = await getRealtimePrice(code, market);
|
|
106
|
+
if (price)
|
|
107
|
+
return price;
|
|
108
|
+
}
|
|
109
|
+
// 查貼文當日收盤價
|
|
110
|
+
const ohlc = await getDailyOHLC(code, postDateStr);
|
|
111
|
+
if (ohlc.length > 0)
|
|
112
|
+
return ohlc[0].close;
|
|
113
|
+
// fallback:貼文日期往前查 5 天(處理週末/假日)
|
|
114
|
+
const fallbackStart = new Date(postDate);
|
|
115
|
+
fallbackStart.setDate(fallbackStart.getDate() - 5);
|
|
116
|
+
const startStr = fallbackStart.toLocaleDateString('en-CA', { timeZone: 'Asia/Taipei' });
|
|
117
|
+
const recent = await getDailyOHLC(code, startStr, postDateStr);
|
|
118
|
+
if (recent.length > 0)
|
|
119
|
+
return recent[recent.length - 1].close;
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { BaniniAnalysis } from './analyze.js';
|
|
2
|
+
interface PostInfo {
|
|
3
|
+
id: string;
|
|
4
|
+
url: string;
|
|
5
|
+
timestamp: string;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* 記錄 LLM 分析出的預測
|
|
9
|
+
* 同股票有 tracking 中的舊預測 → supersede 舊的
|
|
10
|
+
*/
|
|
11
|
+
export declare function recordPredictions(analysis: BaniniAnalysis, posts: PostInfo[]): Promise<number>;
|
|
12
|
+
/**
|
|
13
|
+
* 每日更新追蹤中的預測(建議 15:00 後執行)
|
|
14
|
+
* 一律追蹤 5 個交易日,不提前終止。
|
|
15
|
+
*/
|
|
16
|
+
export declare function updateTracking(): Promise<void>;
|
|
17
|
+
/**
|
|
18
|
+
* 取得追蹤統計(基本概覽)
|
|
19
|
+
*/
|
|
20
|
+
export declare function getStats(): {
|
|
21
|
+
total: number;
|
|
22
|
+
completed: number;
|
|
23
|
+
tracking: number;
|
|
24
|
+
superseded: number;
|
|
25
|
+
};
|
|
26
|
+
export {};
|
package/dist/tracker.js
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 預測追蹤主邏輯
|
|
3
|
+
* - recordPredictions:LLM 分析後記錄預測
|
|
4
|
+
* - updateTracking:每日更新追蹤中的預測(15:00 排程)
|
|
5
|
+
*
|
|
6
|
+
* 設計原則:資料記錄與勝敗判定分離
|
|
7
|
+
* 系統只負責忠實記錄 5 個交易日的 OHLC,勝敗在查詢時決定。
|
|
8
|
+
*/
|
|
9
|
+
import { getDb } from './db.js';
|
|
10
|
+
import { resolveStock } from './stock-map.js';
|
|
11
|
+
import { getBasePrice, getDailyOHLC } from './stock-price.js';
|
|
12
|
+
/**
|
|
13
|
+
* 記錄 LLM 分析出的預測
|
|
14
|
+
* 同股票有 tracking 中的舊預測 → supersede 舊的
|
|
15
|
+
*/
|
|
16
|
+
export async function recordPredictions(analysis, posts) {
|
|
17
|
+
if (!analysis.hasInvestmentContent || !analysis.mentionedTargets?.length) {
|
|
18
|
+
return 0;
|
|
19
|
+
}
|
|
20
|
+
const db = getDb();
|
|
21
|
+
const insert = db.prepare(`
|
|
22
|
+
INSERT OR IGNORE INTO predictions
|
|
23
|
+
(post_id, post_url, symbol_name, symbol_code, symbol_type,
|
|
24
|
+
her_action, reverse_view, confidence, reasoning,
|
|
25
|
+
base_price, created_at, recorded_at, status)
|
|
26
|
+
VALUES
|
|
27
|
+
(@post_id, @post_url, @symbol_name, @symbol_code, @symbol_type,
|
|
28
|
+
@her_action, @reverse_view, @confidence, @reasoning,
|
|
29
|
+
@base_price, @created_at, @recorded_at, @status)
|
|
30
|
+
`);
|
|
31
|
+
const findTracking = db.prepare(`
|
|
32
|
+
SELECT id FROM predictions
|
|
33
|
+
WHERE symbol_code = ? AND status = 'tracking'
|
|
34
|
+
ORDER BY id DESC LIMIT 1
|
|
35
|
+
`);
|
|
36
|
+
const supersede = db.prepare(`
|
|
37
|
+
UPDATE predictions
|
|
38
|
+
SET status = 'superseded', next_prediction_id = ?
|
|
39
|
+
WHERE id = ?
|
|
40
|
+
`);
|
|
41
|
+
const latestPost = posts[0];
|
|
42
|
+
const now = new Date().toISOString();
|
|
43
|
+
let recorded = 0;
|
|
44
|
+
for (const target of analysis.mentionedTargets) {
|
|
45
|
+
if (target.type !== '個股' && target.type !== 'ETF') {
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
const stock = resolveStock(target.name);
|
|
49
|
+
let basePrice = null;
|
|
50
|
+
let status = 'tracking';
|
|
51
|
+
if (stock) {
|
|
52
|
+
basePrice = await getBasePrice(stock.code, stock.market, latestPost.timestamp);
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
status = 'unmappable';
|
|
56
|
+
console.warn(`[tracker] 無法映射股票名稱: ${target.name}`);
|
|
57
|
+
}
|
|
58
|
+
const result = insert.run({
|
|
59
|
+
post_id: latestPost.id,
|
|
60
|
+
post_url: latestPost.url,
|
|
61
|
+
symbol_name: target.name,
|
|
62
|
+
symbol_code: stock?.code ?? null,
|
|
63
|
+
symbol_type: target.type,
|
|
64
|
+
her_action: target.herAction,
|
|
65
|
+
reverse_view: target.reverseView,
|
|
66
|
+
confidence: target.confidence,
|
|
67
|
+
reasoning: target.reasoning ?? null,
|
|
68
|
+
base_price: basePrice,
|
|
69
|
+
created_at: latestPost.timestamp,
|
|
70
|
+
recorded_at: now,
|
|
71
|
+
status,
|
|
72
|
+
});
|
|
73
|
+
if (result.changes > 0) {
|
|
74
|
+
const newId = result.lastInsertRowid;
|
|
75
|
+
// 同股票有追蹤中的舊預測 → supersede
|
|
76
|
+
if (stock && status === 'tracking') {
|
|
77
|
+
const existing = findTracking.get(stock.code);
|
|
78
|
+
if (existing && existing.id !== newId) {
|
|
79
|
+
supersede.run(newId, existing.id);
|
|
80
|
+
console.log(`[tracker] 覆蓋舊預測 #${existing.id} → #${newId}(${target.name})`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
recorded++;
|
|
84
|
+
const priceStr = basePrice ? `$${basePrice}` : '無報價';
|
|
85
|
+
console.log(`[tracker] 記錄預測: ${target.name}(${stock?.code ?? '?'})${target.reverseView} [${priceStr}]`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return recorded;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* 每日更新追蹤中的預測(建議 15:00 後執行)
|
|
92
|
+
* 一律追蹤 5 個交易日,不提前終止。
|
|
93
|
+
*/
|
|
94
|
+
export async function updateTracking() {
|
|
95
|
+
let db;
|
|
96
|
+
try {
|
|
97
|
+
db = getDb();
|
|
98
|
+
}
|
|
99
|
+
catch (err) {
|
|
100
|
+
console.error(`[tracker] 無法開啟資料庫: ${err instanceof Error ? err.message : err}`);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
const predictions = db.prepare(`
|
|
104
|
+
SELECT id, symbol_code, symbol_name, base_price
|
|
105
|
+
FROM predictions
|
|
106
|
+
WHERE status = 'tracking' AND symbol_code IS NOT NULL AND base_price IS NOT NULL
|
|
107
|
+
`).all();
|
|
108
|
+
if (predictions.length === 0) {
|
|
109
|
+
console.log('[tracker] 沒有追蹤中的預測');
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
console.log(`[tracker] 更新 ${predictions.length} 筆追蹤中的預測...`);
|
|
113
|
+
// 去重 symbol_code,避免重複查詢
|
|
114
|
+
const uniqueCodes = [...new Set(predictions.map((p) => p.symbol_code))];
|
|
115
|
+
const today = new Date().toLocaleDateString('en-CA', { timeZone: 'Asia/Taipei' });
|
|
116
|
+
// 批次查詢今日 OHLC
|
|
117
|
+
const ohlcMap = new Map();
|
|
118
|
+
for (const code of uniqueCodes) {
|
|
119
|
+
try {
|
|
120
|
+
const data = await getDailyOHLC(code, today);
|
|
121
|
+
if (data.length > 0) {
|
|
122
|
+
ohlcMap.set(code, data[0]);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
catch (err) {
|
|
126
|
+
console.warn(`[tracker] 查詢 ${code} OHLC 失敗,跳過: ${err instanceof Error ? err.message : err}`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if (ohlcMap.size === 0) {
|
|
130
|
+
console.warn('[tracker] 今日無任何 OHLC 資料,跳過更新');
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
const countSnapshots = db.prepare(`
|
|
134
|
+
SELECT COUNT(*) as cnt FROM price_snapshots WHERE prediction_id = ?
|
|
135
|
+
`);
|
|
136
|
+
const insertSnapshot = db.prepare(`
|
|
137
|
+
INSERT OR IGNORE INTO price_snapshots
|
|
138
|
+
(prediction_id, day_number, date, open_price, high_price, low_price, close_price,
|
|
139
|
+
change_pct_close, change_pct_high, change_pct_low)
|
|
140
|
+
VALUES (@prediction_id, @day_number, @date, @open_price, @high_price, @low_price, @close_price,
|
|
141
|
+
@change_pct_close, @change_pct_high, @change_pct_low)
|
|
142
|
+
`);
|
|
143
|
+
const markCompleted = db.prepare(`
|
|
144
|
+
UPDATE predictions SET status = 'completed', completed_at = ? WHERE id = ?
|
|
145
|
+
`);
|
|
146
|
+
const updateInTransaction = db.transaction(() => {
|
|
147
|
+
for (const pred of predictions) {
|
|
148
|
+
const ohlc = ohlcMap.get(pred.symbol_code);
|
|
149
|
+
if (!ohlc)
|
|
150
|
+
continue;
|
|
151
|
+
// day_number = 已有的 snapshot 數 + 1
|
|
152
|
+
const currentCount = countSnapshots.get(pred.id).cnt;
|
|
153
|
+
const dayNumber = currentCount + 1;
|
|
154
|
+
// 計算漲跌幅(兩個方向都記錄)
|
|
155
|
+
const changePctClose = ((ohlc.close - pred.base_price) / pred.base_price) * 100;
|
|
156
|
+
const changePctHigh = ((ohlc.high - pred.base_price) / pred.base_price) * 100;
|
|
157
|
+
const changePctLow = ((ohlc.low - pred.base_price) / pred.base_price) * 100;
|
|
158
|
+
insertSnapshot.run({
|
|
159
|
+
prediction_id: pred.id,
|
|
160
|
+
day_number: dayNumber,
|
|
161
|
+
date: today,
|
|
162
|
+
open_price: ohlc.open,
|
|
163
|
+
high_price: ohlc.high,
|
|
164
|
+
low_price: ohlc.low,
|
|
165
|
+
close_price: ohlc.close,
|
|
166
|
+
change_pct_close: Math.round(changePctClose * 100) / 100,
|
|
167
|
+
change_pct_high: Math.round(changePctHigh * 100) / 100,
|
|
168
|
+
change_pct_low: Math.round(changePctLow * 100) / 100,
|
|
169
|
+
});
|
|
170
|
+
// 5 個交易日 → completed
|
|
171
|
+
if (dayNumber >= 5) {
|
|
172
|
+
markCompleted.run(today, pred.id);
|
|
173
|
+
console.log(`[tracker] 完成追蹤: ${pred.symbol_name}(${pred.symbol_code})5 天結束`);
|
|
174
|
+
}
|
|
175
|
+
else {
|
|
176
|
+
const pctStr = changePctClose >= 0 ? `+${changePctClose.toFixed(2)}` : changePctClose.toFixed(2);
|
|
177
|
+
console.log(`[tracker] ${pred.symbol_name}(${pred.symbol_code})day ${dayNumber}: ${pctStr}%`);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
try {
|
|
182
|
+
updateInTransaction();
|
|
183
|
+
console.log('[tracker] 追蹤更新完成');
|
|
184
|
+
}
|
|
185
|
+
catch (err) {
|
|
186
|
+
console.error(`[tracker] 寫入快照失敗: ${err instanceof Error ? err.message : err}`);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* 取得追蹤統計(基本概覽)
|
|
191
|
+
*/
|
|
192
|
+
export function getStats() {
|
|
193
|
+
const db = getDb();
|
|
194
|
+
const stats = db.prepare(`
|
|
195
|
+
SELECT
|
|
196
|
+
COUNT(*) as total,
|
|
197
|
+
SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed,
|
|
198
|
+
SUM(CASE WHEN status = 'tracking' THEN 1 ELSE 0 END) as tracking,
|
|
199
|
+
SUM(CASE WHEN status = 'superseded' THEN 1 ELSE 0 END) as superseded
|
|
200
|
+
FROM predictions
|
|
201
|
+
WHERE status != 'unmappable'
|
|
202
|
+
`).get();
|
|
203
|
+
return {
|
|
204
|
+
total: stats.total,
|
|
205
|
+
completed: stats.completed,
|
|
206
|
+
tracking: stats.tracking,
|
|
207
|
+
superseded: stats.superseded,
|
|
208
|
+
};
|
|
209
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cablate/banini-tracker",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.8",
|
|
4
4
|
"description": "巴逆逆反指標追蹤器 — 常駐排程 + CLI 雙模式",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
"author": "cablate",
|
|
29
29
|
"license": "MIT",
|
|
30
30
|
"dependencies": {
|
|
31
|
+
"better-sqlite3": "^12.8.0",
|
|
31
32
|
"commander": "^13.0.0",
|
|
32
33
|
"dotenv": "^16.4.0",
|
|
33
34
|
"groq-sdk": "^1.1.2",
|
|
@@ -35,6 +36,7 @@
|
|
|
35
36
|
"openai": "^4.0.0"
|
|
36
37
|
},
|
|
37
38
|
"devDependencies": {
|
|
39
|
+
"@types/better-sqlite3": "^7.6.13",
|
|
38
40
|
"@types/node": "^20.0.0",
|
|
39
41
|
"@types/node-cron": "^3.0.11",
|
|
40
42
|
"tsx": "^4.0.0",
|