Files
Toolbox/src/server/markets.js
T

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 }