855 lines
37 KiB
JavaScript
855 lines
37 KiB
JavaScript
const { db, upsertMarketArchive, getMarketArchive } = require('./db')
|
|
const { fetchSina, fetchYahoo, fetchTencent, fetchEastmoneyComexGold } = require('./marketsClient')
|
|
const { logJSON } = require('./logger')
|
|
const fs = require('fs')
|
|
const path = require('path')
|
|
const nodemailer = require('nodemailer')
|
|
const axios = require('axios')
|
|
const { getLatestData } = require('./market_skills/manager')
|
|
const securityCalendar = require('./security_calendar')
|
|
|
|
const insertEmailLog = db.prepare(
|
|
`INSERT OR IGNORE INTO alert_email_log (skill, alert_id, subject, status, error_message, created_at)
|
|
VALUES (@skill, @alert_id, @subject, @status, @error_message, datetime('now'))`
|
|
)
|
|
|
|
const cfgPath = path.join(process.cwd(), 'config', 'markets.json')
|
|
if (!fs.existsSync(cfgPath)) {
|
|
const defaultPort = process.env.PORT || 5050
|
|
const defaultBase = `http://localhost:${defaultPort}/api/weekly`
|
|
const defaultCalendar = `http://localhost:${defaultPort}/api/calendar/public`
|
|
fs.writeFileSync(cfgPath, JSON.stringify({
|
|
ui_mode_comment: '界面模式(debug=显示完整交互弹窗,normal=成功操作更快自动关闭)',
|
|
ui_mode: 'debug',
|
|
skills_only_mode: true,
|
|
yahoo_probe_comment: 'Yahoo 探针配置(海外行情与历史日线通过海外探针中转)',
|
|
yahoo_probe: {
|
|
enabled_comment: '是否启用 Yahoo 探针',
|
|
enabled: true,
|
|
base_url_comment: 'Yahoo 探针基础地址(不带结尾斜杠)',
|
|
base_url: 'http://47.252.30.127:19071',
|
|
token_comment: 'Yahoo 探针 token,仅后端使用,需在 config/markets.json 中配置',
|
|
token: '',
|
|
endpoint_comment: 'Yahoo 探针统一路由',
|
|
endpoint: '/probe/yahoo',
|
|
quote_endpoint_comment: '实时报价路由,默认与 endpoint 一致',
|
|
quote_endpoint: '/probe/yahoo',
|
|
chart_endpoint_comment: '历史K线路由,默认与 endpoint 一致',
|
|
chart_endpoint: '/probe/yahoo',
|
|
request_timeout_ms_comment: '请求超时毫秒数',
|
|
request_timeout_ms: 15000,
|
|
quote_ttl_seconds_comment: '实时报价缓存秒数,降低 Yahoo 访问频率',
|
|
quote_ttl_seconds: 120,
|
|
chart_ttl_seconds_comment: '历史K线缓存秒数,降低 Yahoo 访问频率',
|
|
chart_ttl_seconds: 21600,
|
|
quote_batch_size_comment: '单次报价批量 symbol 数,建议 3~5',
|
|
quote_batch_size: 4,
|
|
quote_min_interval_ms_comment: '报价请求最小间隔毫秒,避免探针触发限频',
|
|
quote_min_interval_ms: 2500,
|
|
chart_min_interval_ms_comment: 'K线请求最小间隔毫秒,避免探针触发限频',
|
|
chart_min_interval_ms: 2500
|
|
},
|
|
watchlist: [],
|
|
threshold_percent: -2,
|
|
comex_gold_min_price: 4800,
|
|
comex_gold_max_price: 5500,
|
|
email: { enabled: false, from: "", to: [], smtp: { host: "", port: 465, secure: true, auth: { user: "", pass: "" } } },
|
|
weekly_api_base: defaultBase,
|
|
weekly_key: "",
|
|
weekly_sync_enabled: true,
|
|
calendar_api_base: defaultCalendar,
|
|
calendar_sync_enabled: true
|
|
}, null, 2))
|
|
}
|
|
|
|
db.exec(
|
|
`CREATE TABLE IF NOT EXISTS market_quotes (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
label TEXT,
|
|
symbol TEXT,
|
|
source TEXT,
|
|
name TEXT,
|
|
price REAL,
|
|
prev_close REAL,
|
|
change REAL,
|
|
change_percent REAL,
|
|
captured_at TEXT DEFAULT (datetime('now'))
|
|
);`
|
|
)
|
|
|
|
const insertQuote = db.prepare(
|
|
`INSERT INTO market_quotes (label, symbol, source, name, price, prev_close, change, change_percent)
|
|
VALUES (@label, @symbol, @source, @name, @price, @prevClose, @change, @change_percent)`
|
|
)
|
|
|
|
const queryByDate = db.prepare(
|
|
`SELECT * FROM market_quotes WHERE date(captured_at)=date(?) ORDER BY label`
|
|
)
|
|
|
|
const getMarketState = db.prepare(
|
|
`SELECT value FROM market_state WHERE key = ?`
|
|
)
|
|
const upsertMarketState = db.prepare(
|
|
`INSERT INTO market_state (key, value, updated_at)
|
|
VALUES (@key, @value, datetime('now'))
|
|
ON CONFLICT(key) DO UPDATE SET
|
|
value = excluded.value,
|
|
updated_at = datetime('now')`
|
|
)
|
|
|
|
const getConfig = () => JSON.parse(fs.readFileSync(cfgPath, 'utf-8'))
|
|
const SKILLS_ONLY_WATCHLIST_ERROR = 'markets 配置错误:skills_only_mode=true 时禁止使用 watchlist,请在 src/server/market_skills 下新增或修改 skill'
|
|
const assertMarketsConfigInvariant = cfg => {
|
|
const skillsOnlyMode = cfg && cfg.skills_only_mode !== false
|
|
const watchlist = cfg && Array.isArray(cfg.watchlist) ? cfg.watchlist : []
|
|
if (skillsOnlyMode && watchlist.length > 0) throw new Error(SKILLS_ONLY_WATCHLIST_ERROR)
|
|
}
|
|
const setConfig = cfg => {
|
|
assertMarketsConfigInvariant(cfg)
|
|
fs.writeFileSync(cfgPath, JSON.stringify(cfg, null, 2))
|
|
}
|
|
|
|
const normCode = s => {
|
|
const x = String(s).toLowerCase()
|
|
if (x === '000001' || x === 'sh000001') return 'sh000001'
|
|
if (x === '399001' || x === 'sz399001') return 'sz399001'
|
|
if (x === '399006' || x === 'sz399006') return 'sz399006'
|
|
if (x === '000300' || x === 'sh000300') return 'sh000300'
|
|
if (x === '899050' || x.startsWith('bj899050')) return 'bj899050'
|
|
if (x === '000688' || x === 'sh000688') return 'sh000688'
|
|
if (x === '^ixic' || x === '.ixic' || x === 'ixic' || x === 'gb_ixic') return 'gb_ixic'
|
|
if (x === '^gspc' || x === '.inx' || x === 'inx' || x === 'gb_inx') return 'gb_inx'
|
|
return s
|
|
}
|
|
|
|
const normalizeWatchlist = list => (list || []).map(w => ({
|
|
label: w.label || w.symbol,
|
|
symbol: normCode(w.symbol || w.code || ''),
|
|
source: w.source || 'sina',
|
|
url: w.url,
|
|
displaySymbol: w.displaySymbol,
|
|
card_type: w.card_type,
|
|
card_order: Number.isFinite(Number(w.card_order)) ? Number(w.card_order) : undefined,
|
|
market_status: w.market_status
|
|
}))
|
|
|
|
const resolveEastmoneyUrl = (symbol, url) => {
|
|
if (url) return url
|
|
const s = String(symbol || '')
|
|
const lower = s.toLowerCase()
|
|
if (lower.startsWith('zs')) return `https://quote.eastmoney.com/gb/${s}.html`
|
|
if (lower.startsWith('us')) return `https://quote.eastmoney.com/us/${s.slice(2)}.html`
|
|
if (lower.startsWith('hk')) return `https://quote.eastmoney.com/hk/${s}.html`
|
|
return `https://quote.eastmoney.com/${s}.html`
|
|
}
|
|
|
|
const fetchEastmoneyQuote = async (url, symbol, nameFallback) => {
|
|
const secidMap = {
|
|
zsn225: '100.N225',
|
|
zsgdaxi: '100.GDAXI',
|
|
usnfty: '105.NFTY',
|
|
zskospi: '100.KS11',
|
|
zstwii: '100.TWII'
|
|
}
|
|
let secid = secidMap[String(symbol || '').toLowerCase()] || null
|
|
if (!secid && url) {
|
|
let html = ''
|
|
try {
|
|
const res = await axios.get(url, {
|
|
timeout: 10000,
|
|
validateStatus: () => true,
|
|
headers: { 'User-Agent': 'Mozilla/5.0', 'Referer': 'https://quote.eastmoney.com/' }
|
|
})
|
|
if (!res || res.status >= 400) return null
|
|
html = String(res.data || '')
|
|
} catch (e) {
|
|
return null
|
|
}
|
|
let m = html.match(/"secid"\s*:\s*"([0-9]+\.[^"]+)"/) || html.match(/secid\s*=\s*["']([0-9]+\.[^"']+)["']/)
|
|
if (!secid) {
|
|
const m2 = html.match(/quoteId\s*[:=]\s*["']([0-9]+\.[^"']+)["']/i) || html.match(/securityId\s*[:=]\s*["']([0-9]+\.[^"']+)["']/i)
|
|
if (m2) secid = m2[1]
|
|
}
|
|
if (!secid) {
|
|
const mMarket = html.match(/"market"\s*:\s*(\d+)/) || html.match(/market\s*=\s*(\d+)/)
|
|
const mCode = html.match(/"code"\s*:\s*"([^"]+)"/) || html.match(/code\s*=\s*["']([^"']+)["']/)
|
|
if (mMarket && mCode) secid = `${mMarket[1]}.${mCode[1]}`
|
|
}
|
|
}
|
|
if (!secid) return null
|
|
try {
|
|
const api = `https://push2his.eastmoney.com/api/qt/stock/kline/get?secid=${encodeURIComponent(secid)}&fields1=f1,f2,f3&fields2=f51,f52,f53,f54,f55,f56,f57,f58&klt=101&fqt=0&end=20500101&lmt=2`
|
|
const resp = await axios.get(api, {
|
|
timeout: 10000,
|
|
validateStatus: () => true,
|
|
headers: { 'User-Agent': 'Mozilla/5.0', 'Referer': 'https://quote.eastmoney.com/' }
|
|
})
|
|
const data = resp && resp.data && resp.data.data
|
|
const klines = data && Array.isArray(data.klines) ? data.klines : []
|
|
if (!klines.length) return null
|
|
const parseLine = (line) => String(line || '').split(',')
|
|
const last = parseLine(klines[klines.length - 1])
|
|
const prev = klines.length > 1 ? parseLine(klines[klines.length - 2]) : null
|
|
const last_trade_date = String(last[0] || '').trim()
|
|
const price = Number(last[2])
|
|
const prevClose = prev ? Number(prev[2]) : NaN
|
|
const pct = Number(last[7])
|
|
if (!Number.isFinite(price) || price <= 0) return null
|
|
const hasPrev = Number.isFinite(prevClose) && prevClose > 0
|
|
const change = hasPrev ? price - prevClose : (Number.isFinite(pct) ? (price * pct) / (100 + pct) : 0)
|
|
const change_percent = Number.isFinite(pct) ? pct : (hasPrev ? (change / prevClose) * 100 : 0)
|
|
const captured_at = new Date().toISOString().replace('T', ' ').slice(0, 19)
|
|
return {
|
|
symbol,
|
|
name: data.name || nameFallback || symbol,
|
|
price,
|
|
prevClose: hasPrev ? prevClose : (change_percent ? price / (1 + change_percent / 100) : 0),
|
|
change,
|
|
change_percent,
|
|
last_trade_date,
|
|
captured_at,
|
|
source: 'eastmoney'
|
|
}
|
|
} catch (e) {
|
|
return null
|
|
}
|
|
}
|
|
|
|
const fetchAll = async () => {
|
|
const trace = []
|
|
const pushTrace = (stage, detail = {}) => {
|
|
trace.push({ time: new Date().toISOString(), stage, ...detail })
|
|
}
|
|
const cfgRaw = getConfig()
|
|
assertMarketsConfigInvariant(cfgRaw)
|
|
const skillsOnlyMode = cfgRaw.skills_only_mode !== false
|
|
const rawWatchlist = Array.isArray(cfgRaw.watchlist) ? cfgRaw.watchlist : []
|
|
const watchlist = skillsOnlyMode ? [] : normalizeWatchlist(rawWatchlist)
|
|
pushTrace('markets.fetch.begin', { skills_only_mode: skillsOnlyMode, watchlist_count: watchlist.length })
|
|
const eastmoneyItems = watchlist.filter(w => String(w.source || '').toLowerCase() === 'eastmoney')
|
|
const tencentMap = {
|
|
'sh000001': 'sh000001',
|
|
'sz399001': 'sz399001',
|
|
'sz399006': 'sz399006',
|
|
'sh000300': 'sh000300',
|
|
'sh000688': 'sh000688',
|
|
'bj899050': 'bj899050',
|
|
'rt_hkHSI': 'hkHSI',
|
|
'rt_hk03153': 'hk03153',
|
|
'gb_ixic': 'usIXIC',
|
|
'gb_inx': 'usINX',
|
|
'gb_inda': 'usINDA',
|
|
'gb_ewg': 'usEWG',
|
|
'hf_GC': 'hf_GC',
|
|
}
|
|
let tencent = []
|
|
const symbolsTencent = watchlist.filter(w => tencentMap[w.symbol]).map(w => tencentMap[w.symbol])
|
|
if (symbolsTencent.length > 0) {
|
|
try {
|
|
const tq = await fetchTencent(symbolsTencent)
|
|
const rev = Object.entries(tencentMap).reduce((acc,[k,v]) => { acc[v]=k; return acc }, {})
|
|
tencent = tq.map(q => ({ symbol: rev[q.symbol] || q.symbol, name: q.name, price: q.price, prevClose: q.prevClose, change: q.change, change_percent: q.change_percent, last_trade_date: q.last_trade_date, source: 'tencent' }))
|
|
logJSON('markets.source.status', { tencent: tencent.length }, 'markets')
|
|
pushTrace('markets.fetch.tencent.ok', { requested: symbolsTencent.length, count: tencent.length })
|
|
} catch (e) { logJSON('markets.source.error', { source: 'tencent', error: String(e.message || e) }, 'markets') }
|
|
}
|
|
const sinaSymbols = watchlist.map(w => w.symbol)
|
|
let sina = []
|
|
try { sina = await fetchSina(sinaSymbols) } catch (e) { logJSON('markets.source.error', { source: 'sina', error: String(e.message || e) }, 'markets') }
|
|
logJSON('markets.source.status', { sina: sina.length }, 'markets')
|
|
pushTrace('markets.fetch.sina.done', { requested: sinaSymbols.length, count: sina.length })
|
|
// Fallback for US indices/ETF via Yahoo when Sina blocked
|
|
const yahooMap = {
|
|
'gb_inda': 'INDA',
|
|
'gb_ewg': 'EWG'
|
|
}
|
|
const needYahoo = watchlist
|
|
.filter(w => yahooMap[w.symbol])
|
|
.filter(w => !sina.find(r => r.symbol === w.symbol) && !tencent.find(r => r.symbol === w.symbol))
|
|
let yahoo = []
|
|
if (needYahoo.length > 0) {
|
|
try {
|
|
const symbolsYahoo = needYahoo.map(w => yahooMap[w.symbol])
|
|
const yq = await fetchYahoo(symbolsYahoo, { trace })
|
|
yahoo = yq.map(q => {
|
|
// reverse-map to our symbol keys
|
|
const pair = Object.entries(yahooMap).find(([k, v]) => v === q.symbol)
|
|
const ourSymbol = pair ? pair[0] : q.symbol
|
|
return { symbol: ourSymbol, name: q.name, price: q.price, prevClose: q.prevClose, change: q.change, change_percent: q.change_percent, last_trade_date: q.last_trade_date, source: q.source || 'yahoo_probe' }
|
|
})
|
|
logJSON('markets.source.status', { yahoo: yahoo.length }, 'markets')
|
|
pushTrace('markets.fetch.yahoo.done', { requested: symbolsYahoo.length, count: yahoo.length })
|
|
} catch (e) {
|
|
pushTrace('markets.fetch.yahoo.error', { error: String(e.message || e) })
|
|
logJSON('markets.source.error', { source: 'yahoo', error: String(e.message || e) }, 'markets')
|
|
}
|
|
}
|
|
let eastmoney = []
|
|
if (eastmoneyItems.length > 0) {
|
|
for (const w of eastmoneyItems) {
|
|
const url = resolveEastmoneyUrl(w.symbol, w.url)
|
|
const item = await fetchEastmoneyQuote(url, w.symbol, w.label || w.symbol)
|
|
if (item) eastmoney.push(item)
|
|
}
|
|
}
|
|
const merged = []
|
|
for (const w of watchlist) {
|
|
let q = null
|
|
if (String(w.source || '').toLowerCase() === 'eastmoney') {
|
|
q = eastmoney.find(r => r.symbol === w.symbol)
|
|
}
|
|
if (!q) q = tencent.find(r => r.symbol === w.symbol)
|
|
if (!q) q = sina.find(r => r.symbol === w.symbol)
|
|
if (!q) q = yahoo.find(r => r.symbol === w.symbol)
|
|
if (w.symbol === 'hf_GC' && (!q || !Number.isFinite(q.price) || q.price <= 0)) {
|
|
try {
|
|
const em = await fetchEastmoneyComexGold()
|
|
if (em) q = em
|
|
} catch (e) {
|
|
logJSON('markets.source.error', { source: 'eastmoney', error: String(e.message || e) }, 'markets')
|
|
}
|
|
}
|
|
if (q) merged.push({
|
|
label: w.label,
|
|
...q,
|
|
url: w.url || resolveEastmoneyUrl(w.symbol, w.url),
|
|
displaySymbol: w.displaySymbol || w.symbol,
|
|
card_type: w.card_type,
|
|
card_order: w.card_order,
|
|
market_status: w.market_status || q.market_status
|
|
})
|
|
}
|
|
for (const m of merged) insertQuote.run(m)
|
|
|
|
// Merge with Skill Manager data
|
|
const skillData = getLatestData()
|
|
const skillMap = new Map()
|
|
for (const s of skillData) {
|
|
if (s && s.symbol) skillMap.set(String(s.symbol).toLowerCase(), s)
|
|
}
|
|
|
|
const final = []
|
|
const seen = new Set()
|
|
|
|
// Process global items (replace with skill data if available)
|
|
for (const m of merged) {
|
|
const k = String(m.symbol).toLowerCase()
|
|
if (skillMap.has(k)) {
|
|
final.push(skillMap.get(k))
|
|
} else {
|
|
final.push(m)
|
|
}
|
|
seen.add(k)
|
|
}
|
|
|
|
// Append remaining skill items
|
|
for (const [k, s] of skillMap) {
|
|
if (!seen.has(k)) {
|
|
final.push(s)
|
|
seen.add(k)
|
|
}
|
|
}
|
|
|
|
const sourceCounts = final.reduce((acc, item) => {
|
|
const key = String(item && item.source || 'unknown')
|
|
acc[key] = (acc[key] || 0) + 1
|
|
return acc
|
|
}, {})
|
|
// #region debug-point E:fetchall-final
|
|
;(() => { try { const p = path.join(process.cwd(), '.dbg', 'markets-yahoo-429.env'); let u = 'http://127.0.0.1:7777/event', s = 'markets-yahoo-429'; try { const e = fs.readFileSync(p, 'utf8'); u = (e.match(/DEBUG_SERVER_URL=(.+)/) || [])[1] || u; s = (e.match(/DEBUG_SESSION_ID=(.+)/) || [])[1] || s } catch {} const overseas = final.filter(item => String(item && item.card_type || '').toLowerCase() === 'overseas'); fetch(u, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sessionId: s, runId: 'post-fix', hypothesisId: 'E', location: 'markets.js:fetchAll:final', msg: '[DEBUG] markets fetchAll final summary', data: { merged_count: merged.length, final_count: final.length, skill_count: skillData.length, overseas_count: overseas.length, overseas_symbols: overseas.map(item => item.symbol), source_counts: sourceCounts }, ts: Date.now() }) }).catch(() => {}) } catch {} })()
|
|
// #endregion
|
|
pushTrace('markets.fetch.end', {
|
|
merged_count: merged.length,
|
|
final_count: final.length,
|
|
source_counts: sourceCounts,
|
|
skill_count: skillData.length
|
|
})
|
|
|
|
return {
|
|
quotes: final,
|
|
trace: {
|
|
events: trace,
|
|
summary: {
|
|
skills_only_mode: skillsOnlyMode,
|
|
watchlist_count: watchlist.length,
|
|
skill_count: skillData.length,
|
|
merged_count: merged.length,
|
|
final_count: final.length,
|
|
source_counts: sourceCounts
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const brief = s => { try { return String(s||'').replace(/\s+/g,' ').slice(0,500) } catch { return '' } }
|
|
const getWeeklyCfg = () => {
|
|
const cfg = getConfig()
|
|
const port = process.env.PORT || 5050
|
|
const base = cfg.weekly_api_base || `http://localhost:${port}/api/weekly`
|
|
const key = String(cfg.weekly_key || process.env.SYSTEM_AUTH_PASS_93220 || '')
|
|
const enabled = cfg.weekly_sync_enabled !== false
|
|
return { base, key, enabled }
|
|
}
|
|
|
|
const getCalendarCfg = () => {
|
|
const cfg = getConfig()
|
|
const port = process.env.PORT || 5050
|
|
const base = cfg.calendar_api_base || `http://localhost:${port}/api/calendar/public`
|
|
const enabled = cfg.calendar_sync_enabled !== false
|
|
return { base, enabled }
|
|
}
|
|
|
|
const normalizeTradeDate = value => {
|
|
const text = String(value || '').trim()
|
|
const match = text.match(/^(\d{4}-\d{2}-\d{2})/)
|
|
return match ? match[1] : ''
|
|
}
|
|
|
|
const formatDateInTimeZone = (date, timeZone) => {
|
|
try {
|
|
const parts = new Intl.DateTimeFormat('sv-SE', {
|
|
timeZone,
|
|
year: 'numeric',
|
|
month: '2-digit',
|
|
day: '2-digit'
|
|
}).formatToParts(date)
|
|
const pick = type => parts.find(item => item.type === type)?.value || '00'
|
|
return `${pick('year')}-${pick('month')}-${pick('day')}`
|
|
} catch {
|
|
return ''
|
|
}
|
|
}
|
|
|
|
const addDaysToDateStr = (dateStr, offsetDays) => {
|
|
const m = String(dateStr || '').match(/^(\d{4})-(\d{2})-(\d{2})$/)
|
|
if (!m) return ''
|
|
const d = new Date(Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3])))
|
|
d.setUTCDate(d.getUTCDate() + offsetDays)
|
|
const pad = n => String(n).padStart(2, '0')
|
|
return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())}`
|
|
}
|
|
|
|
const getMarketLocalYesterday = q => {
|
|
const timezone = String((((q || {}).market_status) || {}).timezone || '').trim()
|
|
if (!timezone) return ''
|
|
const localToday = formatDateInTimeZone(new Date(), timezone)
|
|
if (!localToday) return ''
|
|
return addDaysToDateStr(localToday, -1)
|
|
}
|
|
|
|
const isChinaSecurityQuote = q => {
|
|
const sym = String((q && q.symbol) || '').toLowerCase()
|
|
const timezone = String((((q || {}).market_status) || {}).timezone || '').trim()
|
|
if (timezone !== 'Asia/Shanghai') return false
|
|
return /^(sh|sz|bj)\d{6}$/.test(sym) || /^sz399\d{3}$/.test(sym)
|
|
}
|
|
|
|
const getChinaCalendarDay = async (dateStr, calendarCache) => {
|
|
const safeDate = normalizeTradeDate(dateStr)
|
|
if (!safeDate) return null
|
|
const monthKey = safeDate.slice(0, 7)
|
|
if (!monthKey) return null
|
|
if (!calendarCache.has(monthKey)) {
|
|
try {
|
|
const payload = await securityCalendar.getMonthCalendar(monthKey, { force_refresh: false })
|
|
calendarCache.set(monthKey, payload)
|
|
} catch {
|
|
calendarCache.set(monthKey, null)
|
|
}
|
|
}
|
|
const payload = calendarCache.get(monthKey)
|
|
const days = payload && Array.isArray(payload.days) ? payload.days : []
|
|
return days.find(day => String(day && day.date || '') === safeDate) || null
|
|
}
|
|
|
|
const shouldNotifyByLastTradeDate = async (q, calendarCache) => {
|
|
const lastTradeDate = normalizeTradeDate(q && q.last_trade_date)
|
|
if (!lastTradeDate) return true
|
|
const marketYesterday = getMarketLocalYesterday(q)
|
|
if (!marketYesterday) return true
|
|
return lastTradeDate === marketYesterday
|
|
}
|
|
|
|
const filterNotifyCandidates = async quotes => {
|
|
const passed = []
|
|
const skipped = []
|
|
const calendarCache = new Map()
|
|
for (const q of quotes || []) {
|
|
const lastTradeDate = normalizeTradeDate(q && q.last_trade_date)
|
|
const marketYesterday = getMarketLocalYesterday(q)
|
|
let allow = true
|
|
let skipReason = ''
|
|
if (lastTradeDate) {
|
|
allow = !marketYesterday || lastTradeDate === marketYesterday
|
|
if (!allow) skipReason = 'last_trade_date_not_yesterday'
|
|
} else if (isChinaSecurityQuote(q) && marketYesterday) {
|
|
const calendarDay = await getChinaCalendarDay(marketYesterday, calendarCache)
|
|
if (calendarDay && calendarDay.is_trading_day === false) {
|
|
allow = false
|
|
skipReason = 'china_security_closed_yesterday'
|
|
}
|
|
}
|
|
if (allow) passed.push(q)
|
|
else skipped.push({
|
|
symbol: q && q.symbol,
|
|
label: q && q.label,
|
|
last_trade_date: lastTradeDate,
|
|
market_yesterday: marketYesterday,
|
|
reason: skipReason
|
|
})
|
|
}
|
|
return { passed, skipped }
|
|
}
|
|
|
|
const isComexGold = q => {
|
|
if (!q) return false
|
|
const sym = String(q.symbol || '').toLowerCase()
|
|
const label = String(q.label || '')
|
|
const name = String(q.name || '')
|
|
if (sym === 'hf_gc') return true
|
|
if (label.includes('COMEX黄金')) return true
|
|
if (/comex/i.test(name) && /gold|黄金/.test(name)) return true
|
|
return false
|
|
}
|
|
|
|
const maybeNotify = async quotes => {
|
|
const cfg = getConfig()
|
|
const threshold = typeof cfg.threshold_percent === 'number' ? cfg.threshold_percent : -2
|
|
const goldMin = typeof cfg.comex_gold_min_price === 'number' ? cfg.comex_gold_min_price : 4800
|
|
const goldMax = typeof cfg.comex_gold_max_price === 'number' ? cfg.comex_gold_max_price : 5500
|
|
const badCommon = quotes.filter(q => typeof q.change_percent === 'number' && q.change_percent <= threshold)
|
|
const badGoldPrice = quotes
|
|
.filter(isComexGold)
|
|
.filter(q => typeof q.price === 'number' && (q.price <= goldMin || q.price >= goldMax))
|
|
const badMap = new Map()
|
|
for (const q of badCommon) badMap.set(q.symbol, q)
|
|
for (const q of badGoldPrice) badMap.set(q.symbol, q)
|
|
const rawBad = Array.from(badMap.values())
|
|
const { passed: bad, skipped } = await filterNotifyCandidates(rawBad)
|
|
if (skipped.length > 0) {
|
|
logJSON('markets.notify.skip.closed_yesterday', { count: skipped.length, items: skipped }, 'markets')
|
|
}
|
|
if (bad.length === 0) return false
|
|
const subject = `市场下跌预警 ${bad.map(b => b.label).join(', ')}`
|
|
const lineTs = fmtCNDateTimeSrv(new Date())
|
|
const body = bad.map(b => `【${lineTs}】 ${b.label} ${b.symbol} src ${b.source} ${Number.isFinite(b.change_percent) ? b.change_percent.toFixed(2) : '0.00'}% price ${b.price}`).join('\n')
|
|
if (cfg.email && cfg.email.enabled) {
|
|
try {
|
|
const t = nodemailer.createTransport(cfg.email.smtp)
|
|
await t.sendMail({ from: cfg.email.from, to: cfg.email.to.join(','), subject, text: body })
|
|
try { insertEmailLog.run({ skill: 'markets_notify', alert_id: subject, subject, status: 'sent', error_message: '' }) } catch {}
|
|
} catch (e) {
|
|
const errMsg = String(e.message || e)
|
|
try { insertEmailLog.run({ skill: 'markets_notify', alert_id: subject, subject, status: 'failed', error_message: errMsg }) } catch {}
|
|
logJSON('markets.email.send.error', { code: e && e.code, message: errMsg }, 'markets')
|
|
}
|
|
}
|
|
try {
|
|
const api = getWeeklyCfg()
|
|
if (api.enabled) {
|
|
const now = fmtCNParts()
|
|
const date = now.dateStr
|
|
const idKey = `${date}-${String(now.hour).padStart(2,'0')}`
|
|
const payload = { key: api.key, source: 'markets', external_id: `markets-alert-${idKey}`, title: subject, content: body, status_code: 'new', status_label: '📅本周新增', date }
|
|
const url = `${api.base}/ext/event`
|
|
logJSON('markets.weekly.push.try', { url, date, count: bad.length }, 'markets')
|
|
const resp = await axios.post(url, payload, { timeout: 15000, headers: { 'Content-Type': 'application/json' } })
|
|
logJSON('markets.weekly.push.resp', { status: resp && resp.status, data_ok: !!(resp && resp.data && resp.data.ok), data: brief((resp && resp.data) ? JSON.stringify(resp.data) : '') }, 'markets')
|
|
}
|
|
} catch (e) {
|
|
const status = e && e.response ? e.response.status : null
|
|
const data = e && e.response ? e.response.data : null
|
|
logJSON('markets.weekly.push.error', { status, code: e && e.code, message: String(e.message||e), data: typeof data==='string' ? brief(data) : brief(data ? JSON.stringify(data) : '') }, 'markets')
|
|
}
|
|
try {
|
|
const cal = getCalendarCfg()
|
|
if (cal.enabled) {
|
|
const now = fmtCNParts()
|
|
const dateStr = now.dateStr
|
|
const hhmm = '15:00'
|
|
const tsLocal = `${dateStr} ${hhmm}`
|
|
const calendarUrl = `${cal.base}/events`
|
|
const ev = { title: subject, content: body, start: tsLocal, end: tsLocal, remindAt: tsLocal, allDay: 0 }
|
|
logJSON('markets.calendar.push.try', { url: calendarUrl, start: tsLocal }, 'markets')
|
|
const cresp = await axios.post(calendarUrl, ev, { timeout: 15000, headers: { 'Content-Type': 'application/json' } })
|
|
logJSON('markets.calendar.push.resp', { status: cresp && cresp.status, ok: !!(cresp && cresp.data && cresp.data.ok) }, 'markets')
|
|
}
|
|
} catch (e) {
|
|
const status = e && e.response ? e.response.status : null
|
|
const data = e && e.response ? e.response.data : null
|
|
logJSON('markets.calendar.push.error', { status, code: e && e.code, message: String(e.message || e), data: typeof data==='string' ? brief(data) : brief(data ? JSON.stringify(data) : '') }, 'markets')
|
|
}
|
|
return true
|
|
}
|
|
|
|
const fmtCNDateTimeSrv = (d) => {
|
|
const date = d instanceof Date ? d : new Date()
|
|
try {
|
|
const s = new Intl.DateTimeFormat('zh-CN', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }).format(date)
|
|
return s.replace(/\//g, '-').replace(/\u200E/g, '')
|
|
} catch {
|
|
const pad = n => String(n).padStart(2, '0')
|
|
const y = date.getFullYear(), m = pad(date.getMonth()+1), day = pad(date.getDate()), hh = pad(date.getHours()), mm = pad(date.getMinutes()), ss = pad(date.getSeconds())
|
|
return `${y}-${m}-${day} ${hh}:${mm}:${ss}`
|
|
}
|
|
}
|
|
|
|
const fmtCNParts = () => {
|
|
try {
|
|
const d = new Date()
|
|
const parts = new Intl.DateTimeFormat('zh-CN', { timeZone: 'Asia/Shanghai', year:'numeric', month:'2-digit', day:'2-digit', hour:'2-digit', minute:'2-digit', hour12:false }).formatToParts(d)
|
|
const get = t => parts.find(p => p.type === t)?.value || '00'
|
|
const y = get('year'), m = get('month'), day = get('day'), hour = get('hour'), minute = get('minute')
|
|
return { dateStr: `${y}-${m}-${day}`, hour: parseInt(hour,10), minute: parseInt(minute,10) }
|
|
} catch {
|
|
const d = new Date(); return { dateStr: d.toISOString().slice(0,10), hour: d.getHours(), minute: d.getMinutes() }
|
|
}
|
|
}
|
|
|
|
const getTZOffset = (timeZone) => {
|
|
try {
|
|
const now = new Date();
|
|
const iso = now.toLocaleString('en-US', { timeZone, timeZoneName: 'longOffset' });
|
|
const match = iso.match(/GMT([+-]\d{2}):?(\d{2})?/);
|
|
if (!match) return 0;
|
|
const sign = match[1][0] === '-' ? -1 : 1;
|
|
const h = parseInt(match[1].substring(1));
|
|
const m = match[2] ? parseInt(match[2]) : 0;
|
|
return sign * (h + m / 60);
|
|
} catch (e) { return 0; }
|
|
};
|
|
|
|
const getMarketStatus = (config) => {
|
|
if (!config || !config.timezone || !config.sessions) return null;
|
|
|
|
const now = new Date();
|
|
const targetOffset = getTZOffset(config.timezone);
|
|
const bjOffset = 8; // Beijing is UTC+8
|
|
const diffHours = bjOffset - targetOffset;
|
|
|
|
// Check if Open (using target local time)
|
|
const localStr = now.toLocaleString('en-US', { timeZone: config.timezone, hour12: false, hour: '2-digit', minute: '2-digit' });
|
|
const [h, m] = localStr.split(':').map(Number);
|
|
const currentMins = h * 60 + m;
|
|
|
|
let isOpen = false;
|
|
let rangesBJ = [];
|
|
|
|
for (const session of config.sessions) {
|
|
const [sh, sm] = session.start.split(':').map(Number);
|
|
const [eh, em] = session.end.split(':').map(Number);
|
|
|
|
let startMins = sh * 60 + sm;
|
|
let endMins = eh * 60 + em;
|
|
|
|
// Check Status
|
|
if (endMins < startMins) { // Cross midnight
|
|
if (currentMins >= startMins || currentMins < endMins) isOpen = true;
|
|
} else {
|
|
if (currentMins >= startMins && currentMins < endMins) isOpen = true;
|
|
}
|
|
|
|
// Convert to BJ Time
|
|
const toBJ = (hh, mm) => {
|
|
let totalMins = (hh * 60 + mm) + (diffHours * 60);
|
|
while (totalMins >= 1440) totalMins -= 1440;
|
|
while (totalMins < 0) totalMins += 1440;
|
|
|
|
const h = Math.floor(totalMins / 60);
|
|
const m = Math.floor(totalMins % 60);
|
|
return `${h}:${String(m).padStart(2, '0')}`;
|
|
};
|
|
|
|
rangesBJ.push(`${toBJ(sh, sm)}-${toBJ(eh, em)}`);
|
|
}
|
|
|
|
return {
|
|
isOpen,
|
|
label: rangesBJ.join(', '),
|
|
text: isOpen ? '开市' : '休市'
|
|
};
|
|
};
|
|
|
|
const getSymbolUrl = (symbol) => {
|
|
if (!symbol) return 'https://gu.qq.com/'
|
|
if (symbol.startsWith('us.')) return `https://gu.qq.com/${symbol.replace('.', '')}`
|
|
return `https://gu.qq.com/${symbol}`
|
|
}
|
|
|
|
const toFiniteNumber = v => {
|
|
const n = Number(v)
|
|
return Number.isFinite(n) ? n : null
|
|
}
|
|
|
|
const normalizeMove = (row) => {
|
|
const changeRaw = toFiniteNumber(row && row.change)
|
|
const prevClose = toFiniteNumber(row && row.prevClose)
|
|
const changePercentRaw = toFiniteNumber(row && row.change_percent)
|
|
const hasDerivedPercent = changeRaw !== null && prevClose !== null && prevClose !== 0
|
|
const changePercent = hasDerivedPercent ? (changeRaw / prevClose) * 100 : (changePercentRaw !== null ? changePercentRaw : 0)
|
|
const basis = changeRaw !== null ? changeRaw : changePercent
|
|
const sign = basis >= 0 ? 1 : -1
|
|
const change = changeRaw !== null ? changeRaw : 0
|
|
return { change, changePercent, sign }
|
|
}
|
|
|
|
const fmtSign = n => `${n >= 0 ? '+' : '-'}${Math.abs(n).toFixed(2)}`
|
|
|
|
const buildArchiveHtml = (date, quotes) => {
|
|
const css = `body{font-family:system-ui,Segoe UI,Arial,sans-serif;margin:0;padding:16px;box-sizing:border-box;width:100%;max-width:none} .grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:16px;width:100%;margin:0} .card{border:1px solid #e8e8e8;border-radius:12px;padding:12px;display:grid;gap:8px;background:var(--card-bg,#ffffff);box-shadow:0 1px 4px rgba(0,0,0,0.08)} .card-overseas{--card-bg:#FAE5E5} .card-domestic{--card-bg:#F8E0FA} .card-enhanced{--card-bg:#FDF9E2} .card-defensive{--card-bg:#DADAF8} .meta{font-size:12px;color:#8c8c8c} .up{color:#f5222d;font-weight:600} .down{color:#52c41a;font-weight:600} .card-alert{border-color:#ff4d4f} .alert-flag{display:inline-block;padding:2px 8px;font-size:12px;border-radius:12px;background:#ff4d4f;color:#fff;margin-left:8px} a{text-decoration:none}`;
|
|
|
|
const cfg = getConfig();
|
|
const threshold = typeof cfg.threshold_percent === 'number' ? cfg.threshold_percent : -2;
|
|
|
|
let cardsHTML = '';
|
|
|
|
const sorted = [...quotes].sort((a, b) => {
|
|
const ao = Number.isFinite(a.card_order) ? a.card_order : 9999
|
|
const bo = Number.isFinite(b.card_order) ? b.card_order : 9999
|
|
if (ao !== bo) return ao - bo
|
|
const aKey = `${a.label || ''}${a.name || ''}${a.symbol || ''}`
|
|
const bKey = `${b.label || ''}${b.name || ''}${b.symbol || ''}`
|
|
return aKey.localeCompare(bKey)
|
|
})
|
|
for (const r of sorted) {
|
|
let cardCls = 'card';
|
|
let isAlert = false;
|
|
if (r.card_type === 'overseas') cardCls += ' card-overseas';
|
|
if (r.card_type === 'domestic') cardCls += ' card-domestic';
|
|
if (r.card_type === 'enhanced') cardCls += ' card-enhanced';
|
|
if (r.card_type === 'defensive') cardCls += ' card-defensive';
|
|
|
|
const move = normalizeMove(r)
|
|
if (move.changePercent <= threshold) {
|
|
cardCls += ' card-alert';
|
|
isAlert = true;
|
|
}
|
|
|
|
const changePercent = move.changePercent;
|
|
const changeClass = move.sign >= 0 ? 'up' : 'down';
|
|
|
|
// Status Logic
|
|
let statusHtml = '';
|
|
const status = getMarketStatus(r.market_status);
|
|
if (status) {
|
|
const color = status.isOpen ? '#52c41a' : '#8c8c8c';
|
|
statusHtml = ` <span style="margin-left:8px;color:${color}">● ${status.text} (北时 ${status.label})</span>`;
|
|
}
|
|
|
|
// Link Logic
|
|
const linkUrl = r.url || getSymbolUrl(r.symbol);
|
|
const linkHtml = `<a href="${linkUrl}" target="_blank" title="查看详情" style="margin-left:8px">🏠</a>`;
|
|
|
|
// Alert Flag
|
|
const alertHtml = isAlert ? `<span class="alert-flag">⚠ 告警</span>` : '';
|
|
|
|
const price = Number.isFinite(r.price) ? r.price : '0.00';
|
|
const change = fmtSign(move.change);
|
|
|
|
cardsHTML += `
|
|
<div class="${cardCls}">
|
|
<div style="display:flex;justify-content:space-between;align-items:flex-start">
|
|
<div>${r.label} · ${r.name || ''} (${r.symbol}) ${alertHtml}</div>
|
|
${linkHtml}
|
|
</div>
|
|
<div class="price-display" style="font-size:16px;font-weight:600">
|
|
价格 <span style="font-weight:700">${price}</span>
|
|
涨跌 <span class="${changeClass}">${change}</span>
|
|
(<span class="${changeClass}" style="padding:2px 6px;border-radius:4px;background:${move.sign>=0?'#fff2f0':'#f6ffed'};border:1px solid ${move.sign>=0?'#ffccc7':'#b7eb8f'}">${fmtSign(changePercent)}%</span>)
|
|
</div>
|
|
<div class="meta">来源 ${r.source || ''} · 时间 ${r.captured_at || ''}${statusHtml}</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
const title = `市场封存 · ${date}`;
|
|
const time = fmtCNDateTimeSrv(new Date());
|
|
|
|
return `<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8" /><meta name="viewport" content="width=device-width, initial-scale=1" /><title>${title}</title><style>${css}</style></head><body><h3>${title}</h3><div class="grid">${cardsHTML}</div><div class="meta" style="margin-top:16px">生成时间 ${time}</div></body></html>`;
|
|
}
|
|
|
|
const isWeekday = d => { const day = d.getDay(); return day >= 1 && day <= 5 }
|
|
let schedulerStarted = false
|
|
let lastArchiveSavedDay = ''
|
|
let lastNotifySlotKey = ''
|
|
const notifyStateRow = getMarketState.get('notify_slot')
|
|
if (notifyStateRow && notifyStateRow.value) {
|
|
lastNotifySlotKey = String(notifyStateRow.value)
|
|
}
|
|
const startScheduler = () => {
|
|
if (schedulerStarted) return
|
|
schedulerStarted = true
|
|
setInterval(async () => {
|
|
try {
|
|
const nowParts = fmtCNParts()
|
|
const hour = nowParts.hour
|
|
const minute = nowParts.minute
|
|
const dateStr = nowParts.dateStr
|
|
const parts = String(dateStr || '').split('-').map(x => parseInt(x, 10))
|
|
const workdayDate = parts.length === 3 && parts.every(n => Number.isFinite(n)) ? new Date(parts[0], parts[1] - 1, parts[2]) : new Date()
|
|
const weekday = isWeekday(workdayDate)
|
|
let slot = ''
|
|
if (hour >= 10 && hour < 12) slot = 'am'
|
|
else if (hour >= 13 && hour <= 15) slot = 'pm'
|
|
if (weekday && slot) {
|
|
const notifySlotKey = `${dateStr}-${slot}`
|
|
if (lastNotifySlotKey !== notifySlotKey) {
|
|
const { quotes } = await fetchAll()
|
|
const notified = await maybeNotify(quotes)
|
|
if (notified) {
|
|
lastNotifySlotKey = notifySlotKey
|
|
upsertMarketState.run({ key: 'notify_slot', value: notifySlotKey })
|
|
logJSON('markets.scheduler.notify', { date: dateStr, slot }, 'markets')
|
|
}
|
|
}
|
|
}
|
|
if (hour === 15 && minute >= 0 && minute <= 30) {
|
|
if (lastArchiveSavedDay !== dateStr) {
|
|
const existed = getMarketArchive.get(dateStr)
|
|
if (!existed) {
|
|
const { quotes } = await fetchAll()
|
|
// #region debug-point C:auto-archive-before-save
|
|
;(() => { try { const p = path.join(process.cwd(), '.dbg', 'markets-yahoo-429.env'); let u = 'http://127.0.0.1:7777/event', s = 'markets-yahoo-429'; try { const e = fs.readFileSync(p, 'utf8'); u = (e.match(/DEBUG_SERVER_URL=(.+)/) || [])[1] || u; s = (e.match(/DEBUG_SESSION_ID=(.+)/) || [])[1] || s } catch {} const overseas = (quotes || []).filter(item => String(item && item.card_type || '').toLowerCase() === 'overseas'); fetch(u, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sessionId: s, runId: 'post-fix', hypothesisId: 'C', location: 'markets.js:startScheduler:auto-archive', msg: '[DEBUG] markets auto archive before save', data: { day: dateStr, quotes_count: Array.isArray(quotes) ? quotes.length : 0, overseas_count: overseas.length, overseas_symbols: overseas.map(item => item.symbol) }, ts: Date.now() }) }).catch(() => {}) } catch {} })()
|
|
// #endregion
|
|
const html = buildArchiveHtml(dateStr, quotes)
|
|
upsertMarketArchive.run({ day: dateStr, html })
|
|
lastArchiveSavedDay = dateStr
|
|
logJSON('markets.archive.auto.save', { day: dateStr, html_len: html.length }, 'markets')
|
|
} else {
|
|
lastArchiveSavedDay = dateStr
|
|
logJSON('markets.archive.auto.skip', { day: dateStr, reason: 'already_saved' }, 'markets')
|
|
}
|
|
}
|
|
}
|
|
} catch (e) {
|
|
logJSON('markets.scheduler.error', { error: String(e.message || e) }, 'markets')
|
|
}
|
|
}, 10 * 60 * 1000)
|
|
}
|
|
|
|
const sendTestEmail = async () => {
|
|
const cfg = getConfig()
|
|
assertMarketsConfigInvariant(cfg)
|
|
if (!cfg.email || !cfg.email.enabled) throw new Error('Email not enabled')
|
|
const date = fmtCNParts().dateStr
|
|
const rows = queryByDate.all(date)
|
|
const subject = `市场通知测试 ${date}`
|
|
const lines = rows.map(r => `${r.label} ${r.symbol} src ${r.source} ${isFinite(r.change_percent) ? r.change_percent.toFixed(2) : '0.00'}% price ${r.price}`)
|
|
const watchlist = Array.isArray(cfg.watchlist) ? cfg.watchlist : []
|
|
const monitorText = cfg.skills_only_mode !== false
|
|
? 'skill_mode (src/server/market_skills)'
|
|
: watchlist.map(w => `${w.label}:${w.symbol}`).join(', ')
|
|
const body = [`邮件通知测试 ${fmtCNDateTimeSrv(new Date())}`, `监控项: ${monitorText}`, `当日记录: ${rows.length}`, ...lines].join('\n')
|
|
try {
|
|
const t = nodemailer.createTransport(cfg.email.smtp)
|
|
await t.sendMail({ from: cfg.email.from, to: cfg.email.to.join(','), subject, text: body })
|
|
try { insertEmailLog.run({ skill: 'markets_test', alert_id: subject, subject, status: 'sent', error_message: '' }) } catch {}
|
|
} catch (e) {
|
|
const errMsg = String(e.message || e)
|
|
try { insertEmailLog.run({ skill: 'markets_test', alert_id: subject, subject, status: 'failed', error_message: errMsg }) } catch {}
|
|
throw e
|
|
}
|
|
}
|
|
|
|
module.exports = { fetchAll, queryByDate, getConfig, setConfig, startScheduler, sendTestEmail, maybeNotify }
|