From 1b9555f8262e881019e3e48b5f85456622cf8ea2 Mon Sep 17 00:00:00 2001 From: NklausMikealson Date: Wed, 30 Sep 2026 11:10:41 +0800 Subject: [PATCH 1/2] feat: support exchange-traded assets and market data --- apps/chrome/src/ports/chromeDataPort.ts | 4 +- apps/tauri/src-tauri/src/calc.rs | 19 +- apps/tauri/src-tauri/src/calendar.rs | 30 ++- apps/tauri/src-tauri/src/commands.rs | 3 + apps/tauri/src-tauri/src/history.rs | 17 ++ apps/tauri/src-tauri/src/market.rs | 227 +++++++++++++++++- apps/tauri/src-tauri/src/model.rs | 8 + apps/tauri/src-tauri/src/portfolio.rs | 5 + apps/tauri/src-tauri/src/providers/mod.rs | 1 + apps/tauri/src-tauri/src/refresh.rs | 13 +- apps/tauri/src/ports/tauriDataPort.ts | 7 +- docs/import-prompt.md | 20 +- packages/core/src/holdingsCalc.ts | 19 +- packages/core/src/port.ts | 4 +- packages/core/src/portfolioLogic.ts | 4 + packages/core/src/tradingCalendar.ts | 34 ++- packages/core/src/types.ts | 6 + packages/services/src/fund.ts | 42 +++- packages/services/src/market.ts | 52 ++++ packages/ui/src/App.tsx | 8 +- packages/ui/src/OptionsApp.tsx | 38 ++- packages/ui/src/components/FundFormDialog.tsx | 27 ++- packages/ui/src/components/FundTrendChart.tsx | 35 ++- packages/ui/src/components/HoldingsNav.tsx | 2 +- packages/ui/src/components/fundBits.tsx | 37 +-- packages/ui/src/components/popup/FundList.tsx | 10 - packages/ui/src/lib/batchEdit.ts | 12 +- packages/ui/src/lib/fundOps.ts | 2 + packages/ui/src/lib/importHoldings.ts | 23 +- 29 files changed, 591 insertions(+), 118 deletions(-) diff --git a/apps/chrome/src/ports/chromeDataPort.ts b/apps/chrome/src/ports/chromeDataPort.ts index 3113781..42a9d78 100644 --- a/apps/chrome/src/ports/chromeDataPort.ts +++ b/apps/chrome/src/ports/chromeDataPort.ts @@ -89,10 +89,10 @@ export class ChromeDataPort implements DataPort { return sendMessage({ type: 'FETCH_INDEX_HISTORY', code, range }) } - async fetchFundIntraday(fundKey: string): Promise { + async fetchFundIntraday(code: string, fundKey?: string): Promise { const data = await sendMessage({ type: 'FETCH_FUND_INTRADAY', - code: fundKey, + code, fundKey, }) return data.points diff --git a/apps/tauri/src-tauri/src/calc.rs b/apps/tauri/src-tauri/src/calc.rs index 8ae7a21..542b2b3 100644 --- a/apps/tauri/src-tauri/src/calc.rs +++ b/apps/tauri/src-tauri/src/calc.rs @@ -149,9 +149,10 @@ pub fn calc_holdings( } let overview_has_cost = overview_cost_row > 0.0; - let using_estimate = q.percent_source.as_deref() == Some("estimate") - || (q.percent_source.as_deref() != Some("confirmed") - && latest_estimate_nav(q).is_some()); + let using_estimate = !q.is_market_quote.unwrap_or(false) + && (q.percent_source.as_deref() == Some("estimate") + || (q.percent_source.as_deref() != Some("confirmed") + && latest_estimate_nav(q).is_some())); let mut pnl: Option = None; if shares > 0.0 && prev_nav.is_some() && curr_nav.is_some() { @@ -218,7 +219,13 @@ pub fn calc_holdings( persist_patches.push(patch); } - let confirmed_updated = should_show_confirmed_updated_badge( + // 「今日已更新」只描述场外基金的确认净值披露;场内 ETF/股票的 + // 成交价本来就是实时行情,不应显示这个基金净值状态。 + let is_off_exchange_fund = crate::market::infer_asset_kind( + &raw.code, + raw.asset_kind.as_deref(), + ) == "fund"; + let confirmed_updated = is_off_exchange_fund && should_show_confirmed_updated_badge( q.percent_source.as_deref(), Some(if nav_day.is_empty() { q.net_value_date.as_str() @@ -239,6 +246,9 @@ pub fn calc_holdings( } row_fund.sectors = sectors.clone(); row_fund.shares = Some(shares); + if q.is_market_quote.unwrap_or(false) && row_fund.asset_kind.is_none() { + row_fund.asset_kind = Some(crate::market::infer_asset_kind(&row_fund.code, None).to_string()); + } rows.push(FundQuoteRow { fund: row_fund, @@ -350,4 +360,5 @@ static EMPTY_QUOTE: FundQuote = FundQuote { error: None, use_calc: None, is_qdii: None, + is_market_quote: None, }; diff --git a/apps/tauri/src-tauri/src/calendar.rs b/apps/tauri/src-tauri/src/calendar.rs index fbc7267..d0f5841 100644 --- a/apps/tauri/src-tauri/src/calendar.rs +++ b/apps/tauri/src-tauri/src/calendar.rs @@ -1,12 +1,25 @@ //! 交易日历 —— 对应 `packages/core/src/tradingCalendar.ts` 1:1 迁移。 -//! A 股简易交易日:仅跳周末(不含法定节假日,与 TS 版一致)。 +//! A 股交易日历。除周末外,内置交易所公布的 2026 年休市日,避免将调休工作日误判为可交易。 -use chrono::{DateTime, Datelike, Local, NaiveDate, Timelike, Weekday}; +use chrono::{DateTime, Datelike, Local, NaiveDate, TimeZone, Timelike, Weekday}; fn now_local() -> DateTime { Local::now() } +/// 上交所 2026 年休市安排(不含天然周末)。年度数据更新前,未知年份仅按周末判断。 +const A_SHARE_CLOSED_2026: &[&str] = &[ + "2026-01-01", "2026-01-02", "2026-02-16", "2026-02-17", "2026-02-18", "2026-02-19", "2026-02-20", "2026-02-23", + "2026-04-06", "2026-05-01", "2026-05-04", "2026-05-05", "2026-06-19", + "2026-09-25", "2026-10-01", "2026-10-02", "2026-10-05", "2026-10-06", "2026-10-07", +]; + +/// A 股是否开市。调休工作日不交易;法定休市日优先于星期判断。 +pub fn is_a_share_trading_day(now: &DateTime) -> bool { + if matches!(now.weekday(), Weekday::Sat | Weekday::Sun) { return false; } + !A_SHARE_CLOSED_2026.contains(&today_date_str(now).as_str()) +} + pub fn today_date_str(d: &DateTime) -> String { format!("{:04}-{:02}-{:02}", d.year(), d.month(), d.day()) } @@ -61,8 +74,8 @@ pub fn next_trading_day(date_str: &str, now: &DateTime) -> String { }; loop { d = d.succ_opt().unwrap_or(d); - let wd = d.weekday(); - if wd != Weekday::Sat && wd != Weekday::Sun { + let next = d.and_hms_opt(12, 0, 0).and_then(|v| Local.from_local_datetime(&v).single()); + if next.is_some_and(|v| is_a_share_trading_day(&v)) { break; } } @@ -123,8 +136,7 @@ fn hms_to_minutes(d: &DateTime) -> u32 { /// A 股交易日(周一到周五)盘中时段:09:15 - 15:30 pub fn is_a_share_trading_time(now: &DateTime) -> bool { - let day = now.weekday(); - if day == Weekday::Sat || day == Weekday::Sun { + if !is_a_share_trading_day(now) { return false; } let m = hms_to_minutes(now); @@ -135,8 +147,7 @@ pub fn is_a_share_trading_time(now: &DateTime) -> bool { /// 避免 09:00-09:15 定时器空转——开盘前拉到的是静态数据,无副作用; /// 盘中估值 + 空窗自算 + 晚间确认) pub fn should_refresh_fund(now: &DateTime) -> bool { - let day = now.weekday(); - if day == Weekday::Sat || day == Weekday::Sun { + if !is_a_share_trading_day(now) { return false; } let m = hms_to_minutes(now); @@ -146,8 +157,7 @@ pub fn should_refresh_fund(now: &DateTime) -> bool { /// A 股指数 / 大盘:交易日 09:00-15:30(09:00 起覆盖黄金 AU9999 日盘 09:00 开盘, /// 避免 09:00-09:15 空窗) pub fn should_refresh_a_share_market(now: &DateTime) -> bool { - let day = now.weekday(); - if day == Weekday::Sat || day == Weekday::Sun { + if !is_a_share_trading_day(now) { return false; } let m = hms_to_minutes(now); diff --git a/apps/tauri/src-tauri/src/commands.rs b/apps/tauri/src-tauri/src/commands.rs index 1a05c8a..f589627 100644 --- a/apps/tauri/src-tauri/src/commands.rs +++ b/apps/tauri/src-tauri/src/commands.rs @@ -131,6 +131,9 @@ pub async fn fetch_fund_intraday( req: FundIntradayRequest, ) -> Result { let _ = state; + if crate::market::infer_asset_kind(&req.code, None) != "fund" { + return crate::market::fetch_market_intraday(&req.code).await; + } crate::providers::fund123::fetch_intraday_for_dialog( &req.code, req.fund_key.as_deref(), diff --git a/apps/tauri/src-tauri/src/history.rs b/apps/tauri/src-tauri/src/history.rs index e6e05e2..7219188 100644 --- a/apps/tauri/src-tauri/src/history.rs +++ b/apps/tauri/src-tauri/src/history.rs @@ -309,6 +309,22 @@ struct VerifyResult { pub async fn resolve_fund( payload: &crate::model::ResolveFundRequest, ) -> Result { + let asset_kind = crate::market::infer_asset_kind(&payload.code, payload.asset_kind.as_deref()); + if asset_kind != "fund" { + let input = crate::providers::FundQuoteInput { + code: payload.code.clone(), asset_kind: Some(asset_kind.to_string()), fund_key: None, + name: payload.name.clone(), sectors: payload.sectors.clone().unwrap_or_default(), + }; + let quote = crate::market::fetch_market_quotes(&[input]).await.into_iter().next() + .ok_or_else(|| "未获取到场内证券行情,请核对代码".to_string())?; + let price = quote.net_value.ok_or_else(|| format!("未找到证券代码 {} 的场内行情,请核对代码", payload.code))?; + return Ok(ResolveFundPayload { + code: quote.code, name: quote.name.clone(), fund_key: String::new(), sectors: vec![], + asset_kind: Some(asset_kind.to_string()), net_value: Some(price), prev_net_value: quote.prev_net_value, + prev_net_value_date: None, net_value_date: Some(chrono::Local::now().format("%Y-%m-%d").to_string()), + confirmed_session: Some(true), official_name: Some(quote.name), name_mismatch: None, code_corrected: None, + }); + } let mut code = payload.code.trim().to_string(); let mut meta: Option = None; match search_fund(&code).await { @@ -431,6 +447,7 @@ pub async fn resolve_fund( .map(|m| m.fund_key.clone()) .unwrap_or_default(), sectors, + asset_kind: Some("fund".to_string()), net_value, prev_net_value, prev_net_value_date: if prev_net_value_date.is_empty() { diff --git a/apps/tauri/src-tauri/src/market.rs b/apps/tauri/src-tauri/src/market.rs index 4c0a1bf..51df100 100644 --- a/apps/tauri/src-tauri/src/market.rs +++ b/apps/tauri/src-tauri/src/market.rs @@ -6,7 +6,9 @@ use std::time::Duration; use serde_json::Value; use crate::http::{self, DESKTOP_UA}; -use crate::model::{IndexHistoryPayload, IndexHistoryPoint, IndexItem}; +use crate::model::{FundIntradayPayload, IndexHistoryPayload, IndexHistoryPoint, IndexItem, TrendPoint}; +use crate::model::FundQuote; +use crate::providers::FundQuoteInput; /// 东财行情 host 兜底链(push2delay 延迟行情最稳,放第一;push2 主域名对无 cookie 请求常风控秒断)。 /// getIndices 与 fundmnfinfo::fetch_stock_pct_changes 共用,保证行情类请求有三级 host 保险。 @@ -16,6 +18,166 @@ pub(crate) const PUSH_HOSTS: &[&str] = &[ "https://82.push2.eastmoney.com", ]; +/// 只识别明确的场内代码,旧版配置未带 assetKind 时仍能平滑迁移。 +pub fn infer_asset_kind(code: &str, explicit: Option<&str>) -> &'static str { + match explicit { + Some("fund") => return "fund", + Some("etf") => return "etf", + Some("stock") => return "stock", + _ => {} + } + let c = format!("{:0>6}", code.trim()); + if c.len() == 6 + && ((c.starts_with("15") || c.starts_with("16")) + || (c.starts_with('5') && matches!(c.as_bytes().get(1), Some(b'1'..=b'9')))) + { + return "etf"; + } + if ["000", "001", "002", "003", "300", "301", "600", "601", "603", "605", "688", "689", "920"] + .iter().any(|p| c.starts_with(p)) + || c.starts_with('4') || c.starts_with('8') + { "stock" } else { "fund" } +} + +fn security_secid(code: &str) -> Option { + let c = format!("{:0>6}", code.trim()); + if c.len() != 6 || !c.bytes().all(|b| b.is_ascii_digit()) { return None; } + let market = if c.starts_with('5') || c.starts_with('6') || c.starts_with('9') { 1 } else { 0 }; + Some(format!("{market}.{c}")) +} + +/// 腾讯行情兜底:东财 push2 被网络/风控中断时,场内 ETF 与 A 股仍可正常更新。 +/// 名称字段可能是 GBK,故保留本地名称;价格、昨收和涨跌幅均为纯数字字段。 +async fn fetch_tencent_market_quotes(inputs: &[&FundQuoteInput]) -> Vec { + if inputs.is_empty() { return Vec::new(); } + let symbols = inputs.iter().map(|input| { + let code = format!("{:0>6}", input.code.trim()); + let market = if code.starts_with('5') || code.starts_with('6') || code.starts_with('9') { "sh" } else if code.starts_with('4') || code.starts_with('8') { "bj" } else { "sz" }; + format!("{market}{code}") + }).collect::>().join(","); + let url = format!("https://qt.gtimg.cn/q={symbols}"); + let text = match http::http_get_text(&url, &HashMap::new(), DESKTOP_UA, Some("https://gu.qq.com/"), Duration::from_secs(12)).await { + Ok(text) => text, + Err(e) => return inputs.iter().map(|input| FundQuote { + code: format!("{:0>6}", input.code.trim()), name: input.name.clone().unwrap_or_else(|| input.code.clone()), + sectors: input.sectors.clone(), error: Some(e.clone()), ..Default::default() + }).collect(), + }; + let mut rows: HashMap> = HashMap::new(); + for line in text.split(';') { + let Some(start) = line.find('"') else { continue; }; + let Some(end) = line.rfind('"') else { continue; }; + let fields: Vec<&str> = line[start + 1..end].split('~').collect(); + if fields.len() > 32 && fields[2].len() == 6 { rows.insert(fields[2].to_string(), fields); } + } + inputs.iter().map(|input| { + let code = format!("{:0>6}", input.code.trim()); + let row = rows.get(&code); + let number = |idx: usize| row.and_then(|v| v.get(idx)).and_then(|v| v.parse::().ok()).filter(|v| *v > 0.0); + let price = number(3); + let previous_close = number(4); + let percent = row.and_then(|v| v.get(32)).and_then(|v| v.parse::().ok()); + let time = row.and_then(|v| v.get(30)).filter(|v| v.len() >= 8).map(|v| format!("{}-{}-{}", &v[0..4], &v[4..6], &v[6..8])); + FundQuote { + code: code.clone(), name: input.name.clone().unwrap_or(code), fund_key: String::new(), + day_growth: percent, estimate_growth: percent, percent, percent_source: Some("confirmed".to_string()), + net_value: price, estimate_net_value: price, prev_net_value: previous_close, + net_value_date: time.unwrap_or_else(|| chrono::Local::now().format("%Y-%m-%d").to_string()), + time: None, trend: vec![], sectors: input.sectors.clone(), is_market_quote: Some(true), + error: if price.is_none() { Some("腾讯行情未返回该代码".to_string()) } else { None }, ..Default::default() + } + }).collect() +} + +/// 东财延迟行情:场内 ETF 与 A 股股票共用,返回的 netValue 即最新成交价。 +pub async fn fetch_market_quotes(inputs: &[FundQuoteInput]) -> Vec { + let valid: Vec<(&FundQuoteInput, String)> = inputs.iter() + .filter_map(|input| security_secid(&input.code).map(|secid| (input, secid))) + .collect(); + if valid.is_empty() { return Vec::new(); } + let secids = valid.iter().map(|(_, secid)| secid.as_str()).collect::>().join(","); + let query = http::params(&[ + ("fltt", "2"), ("invt", "2"), + ("fields", "f2,f3,f12,f14,f18,f124"), ("secids", &secids), + ]); + let data = match http::eastmoney_get("/api/qt/ulist.np/get", &query, PUSH_HOSTS).await { + Ok(data) => data, + Err(e) => { + crate::err_log!("东财场内行情不可用,切换腾讯兜底: {e}"); + let refs = valid.iter().map(|(input, _)| *input).collect::>(); + return fetch_tencent_market_quotes(&refs).await; + } + }; + let rows: HashMap = data.pointer("/data/diff").and_then(|v| v.as_array()) + .map(|items| items.iter().filter_map(|row| row.get("f12").and_then(|v| v.as_str()).map(|code| (code.to_string(), row))).collect()) + .unwrap_or_default(); + valid.into_iter().map(|(input, _)| { + let code = format!("{:0>6}", input.code.trim()); + let row = rows.get(&code).copied(); + let price = row.and_then(|r| r.get("f2")).and_then(|v| v.as_f64()).filter(|v| *v > 0.0); + let previous_close = row.and_then(|r| r.get("f18")).and_then(|v| v.as_f64()).filter(|v| *v > 0.0); + let percent = row.and_then(|r| r.get("f3")).and_then(|v| v.as_f64()); + let name = row.and_then(|r| r.get("f14")).and_then(|v| v.as_str()).unwrap_or_else(|| input.name.as_deref().unwrap_or(&code)).to_string(); + FundQuote { + code, name, fund_key: String::new(), day_growth: percent, estimate_growth: percent, percent, + percent_source: Some("confirmed".to_string()), net_value: price, estimate_net_value: price, + prev_net_value: previous_close, net_value_date: chrono::Local::now().format("%Y-%m-%d").to_string(), + time: row.and_then(|r| r.get("f124")).and_then(|v| v.as_i64()).map(|ts| chrono::DateTime::from_timestamp(ts, 0).map(|d| d.to_rfc3339()).unwrap_or_default()), + trend: vec![], sectors: input.sectors.clone(), is_market_quote: Some(true), + error: if price.is_none() { Some("未获取到行情".to_string()) } else { None }, ..Default::default() + } + }).collect() +} + +/// 场内 ETF / A 股的当日分时:东财 trends2 返回每分钟成交价,以昨收换算涨跌幅。 +/// 这条路径与场外基金的 fund123 估值分时完全不同,不能混用。 +pub async fn fetch_market_intraday(code: &str) -> Result { + let secid = security_secid(code).ok_or_else(|| format!("无效场内代码 {code}"))?; + let query = http::params(&[ + ("fields1", "f1,f2,f3,f4,f5,f6,f7,f8"), + ("fields2", "f51,f52,f53,f54,f55,f56,f57,f58"), + ("ndays", "1"), + ("iscr", "0"), + ("secid", &secid), + ]); + let mut last_error = None; + for host in ["https://push2his.eastmoney.com", "https://push2delay.eastmoney.com", "https://push2.eastmoney.com"] { + match http::http_get_json( + &format!("{host}/api/qt/stock/trends2/get"), + &query, + DESKTOP_UA, + Some("https://quote.eastmoney.com/"), + Duration::from_secs(12), + ).await { + Ok(data) => { + let payload = data.get("data").unwrap_or(&Value::Null); + let pre_close = payload.get("preClose").and_then(Value::as_f64).filter(|v| *v > 0.0); + let Some(pre_close) = pre_close else { last_error = Some("场内分时缺少昨收价".to_string()); continue }; + let points: Vec = payload.get("trends").and_then(Value::as_array).into_iter().flatten().filter_map(|item| { + let row = item.as_str()?; + let mut fields = row.split(','); + let timestamp = fields.next()?; + let price = fields.next()?.parse::().ok().filter(|v| *v > 0.0)?; + let time = timestamp.rsplit_once(' ').map(|(_, time)| time).unwrap_or(timestamp).to_string(); + Some(TrendPoint { time, growth: Some(((price / pre_close) - 1.0) * 100.0), net_value: Some(price) }) + }).collect(); + if !points.is_empty() { + let latest = points.last().cloned(); + return Ok(FundIntradayPayload { + points, + latest, + fund_key: format!("market:{secid}"), + name: payload.get("name").and_then(Value::as_str).unwrap_or(code).to_string(), + }); + } + last_error = Some("场内分时暂无数据(休市或停牌)".to_string()); + } + Err(error) => last_error = Some(error), + } + } + Err(last_error.unwrap_or_else(|| "场内分时加载失败".to_string())) +} + const KLINE_HOSTS: &[&str] = &[ "https://push2his.eastmoney.com", "https://push2delay.eastmoney.com", @@ -209,6 +371,56 @@ pub async fn get_all_indices() -> Vec { fetch_indices(&list).await } +/// 顶栏仅在东财三级地址都不可用时使用腾讯实时行情兜底。 +/// 这里沿用 IndexMeta 的 tx 标识;名称固定使用本地配置,避免 GBK 编码影响显示。 +async fn fetch_tencent_indices(list: &[&IndexMeta], eastmoney_error: &str) -> Vec { + let supported: Vec<&IndexMeta> = list.iter().copied().filter(|item| item.tx.is_some()).collect(); + if supported.is_empty() { + let code = error_code_of(eastmoney_error).to_string(); + return list.iter().map(|item| IndexItem { + code: item.code.to_string(), name: item.name.to_string(), percent: None, + price: None, change: None, error: Some(code.clone()), + }).collect(); + } + let symbols = supported.iter().filter_map(|item| item.tx).collect::>().join(","); + let url = format!("https://qt.gtimg.cn/q={symbols}"); + let text = match http::http_get_text( + &url, &HashMap::new(), DESKTOP_UA, Some("https://gu.qq.com/"), Duration::from_secs(12), + ).await { + Ok(text) => text, + Err(e) => { + crate::err_log!("顶栏腾讯实时行情也失败: {e}"); + let code = error_code_of(eastmoney_error).to_string(); + return list.iter().map(|item| IndexItem { + code: item.code.to_string(), name: item.name.to_string(), percent: None, + price: None, change: None, error: Some(code.clone()), + }).collect(); + } + }; + let mut rows: HashMap> = HashMap::new(); + for line in text.split(';') { + let Some(start) = line.find('"') else { continue; }; + let Some(end) = line.rfind('"') else { continue; }; + let fields: Vec<&str> = line[start + 1..end].split('~').collect(); + let Some(code) = fields.get(2) else { continue; }; + // 美股指数代码由腾讯以 .NDX/.INX 形式返回;界面内部使用 NDX/SPX。 + rows.insert(code.trim_start_matches('.').to_string(), fields); + } + list.iter().map(|item| { + let lookup_code = if item.code == "SPX" { "INX" } else { item.code }; + let fields = rows.get(lookup_code); + let number = |index: usize| fields.and_then(|row| row.get(index)).and_then(|value| value.parse::().ok()); + let price = number(3).filter(|value| *value > 0.0); + let previous_close = number(4).filter(|value| *value > 0.0); + let change = number(31).or_else(|| price.zip(previous_close).map(|(now, prev)| now - prev)); + let percent = number(32).or_else(|| price.zip(previous_close).map(|(now, prev)| (now - prev) / prev * 100.0)); + IndexItem { + code: item.code.to_string(), name: item.name.to_string(), price, change, percent, + error: if price.is_some() { None } else { Some("NET".to_string()) }, + } + }).collect() +} + /// 拉取指数实时行情(条目级容错:接口整体失败/条目缺失不抛错,对应条目带 error 码, /// 保证看板卡片始终能显示,数值处与底部展示错误状态) async fn fetch_indices(list: &[&IndexMeta]) -> Vec { @@ -223,18 +435,7 @@ async fn fetch_indices(list: &[&IndexMeta]) -> Vec { Ok(d) => d, Err(e) => { crate::err_log!("getIndices 失败: {e}"); - let code = error_code_of(&e).to_string(); - return list - .iter() - .map(|i| IndexItem { - code: i.code.to_string(), - name: i.name.to_string(), - percent: None, - price: None, - change: None, - error: Some(code.clone()), - }) - .collect(); + return fetch_tencent_indices(list, &e).await; } }; let diff = data diff --git a/apps/tauri/src-tauri/src/model.rs b/apps/tauri/src-tauri/src/model.rs index c08035f..af36c8d 100644 --- a/apps/tauri/src-tauri/src/model.rs +++ b/apps/tauri/src-tauri/src/model.rs @@ -14,6 +14,9 @@ use serde::{Deserialize, Serialize}; pub struct FundRecord { pub code: String, pub name: String, + /// fund / etf / stock;旧配置缺失时行情层按代码识别。 + #[serde(skip_serializing_if = "Option::is_none")] + pub asset_kind: Option, #[serde(skip_serializing_if = "Option::is_none")] pub fund_key: Option, /// 'hold' | 'watch' @@ -283,6 +286,8 @@ pub struct FundQuote { pub use_calc: Option, #[serde(skip_serializing_if = "Option::is_none")] pub is_qdii: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub is_market_quote: Option, } #[derive(Debug, Clone, Default, Serialize)] @@ -432,6 +437,8 @@ pub struct ResolveFundPayload { #[serde(default)] pub sectors: Vec, #[serde(skip_serializing_if = "Option::is_none")] + pub asset_kind: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub net_value: Option, #[serde(skip_serializing_if = "Option::is_none")] pub prev_net_value: Option, @@ -483,6 +490,7 @@ pub struct ResolveFundRequest { pub fund_type: Option, pub name: Option, pub sectors: Option>, + pub asset_kind: Option, } /// 前端「分时走势」命令入参 diff --git a/apps/tauri/src-tauri/src/portfolio.rs b/apps/tauri/src-tauri/src/portfolio.rs index 8cac238..3b2d962 100644 --- a/apps/tauri/src-tauri/src/portfolio.rs +++ b/apps/tauri/src-tauri/src/portfolio.rs @@ -224,6 +224,11 @@ pub fn normalize_fund( Some(FundRecord { code, name, + asset_kind: raw + .get("assetKind") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .or_else(|| prev.and_then(|p| p.asset_kind.clone())), fund_key: raw .get("fundKey") .and_then(|v| v.as_str()) diff --git a/apps/tauri/src-tauri/src/providers/mod.rs b/apps/tauri/src-tauri/src/providers/mod.rs index 4f26f8e..615c27f 100644 --- a/apps/tauri/src-tauri/src/providers/mod.rs +++ b/apps/tauri/src-tauri/src/providers/mod.rs @@ -17,6 +17,7 @@ use crate::model::FundQuote; #[derive(Debug, Clone, Default)] pub struct FundQuoteInput { pub code: String, + pub asset_kind: Option, pub fund_key: Option, pub name: Option, pub sectors: Vec, diff --git a/apps/tauri/src-tauri/src/refresh.rs b/apps/tauri/src-tauri/src/refresh.rs index f2dfc02..057d21f 100644 --- a/apps/tauri/src-tauri/src/refresh.rs +++ b/apps/tauri/src-tauri/src/refresh.rs @@ -221,6 +221,7 @@ pub async fn trigger_refresh(app: AppHandle, reset_timer: bool) { fn to_input(f: &FundRecord) -> FundQuoteInput { FundQuoteInput { code: f.code.clone(), + asset_kind: f.asset_kind.clone(), fund_key: f.fund_key.clone(), name: Some(f.name.clone()), sectors: f.sectors.clone(), @@ -448,8 +449,16 @@ async fn refresh_day(app: &AppHandle, force: bool) { for f in &holdings_funds { inputs.push(to_input(f)); } + let input_count = inputs.len(); + let (fund_inputs, market_inputs): (Vec<_>, Vec<_>) = inputs + .into_iter() + .partition(|input| crate::market::infer_asset_kind(&input.code, input.asset_kind.as_deref()) == "fund"); let provider = get_quote_provider(source); - let mut quotes = provider.fetch_quotes(&inputs).await; + let (mut quotes, mut market_quotes) = tokio::join!( + provider.fetch_quotes(&fund_inputs), + crate::market::fetch_market_quotes(&market_inputs), + ); + quotes.append(&mut market_quotes); // 本轮 provider **原始**产出里真正可用的基金集合:必须在 merge_stale_estimate // 之前统计 —— 合并会把上一份快照的估算值写回空 quote,若在其后统计,整体断网 // (全部为空)也会被误判成「拿到了新数据」,导致「数据更新于」时间戳前移。 @@ -470,7 +479,7 @@ async fn refresh_day(app: &AppHandle, force: bool) { .quote_source .as_deref() .unwrap_or("fundmnfinfo"), - inputs.len(), + input_count, quotes.len(), with_percent, with_nav diff --git a/apps/tauri/src/ports/tauriDataPort.ts b/apps/tauri/src/ports/tauriDataPort.ts index e57bfa4..8bab553 100644 --- a/apps/tauri/src/ports/tauriDataPort.ts +++ b/apps/tauri/src/ports/tauriDataPort.ts @@ -51,9 +51,9 @@ export class TauriDataPort implements DataPort { return invoke('fetch_index_history', { code, range }) } - async fetchFundIntraday(fundKey: string): Promise { + async fetchFundIntraday(code: string, fundKey?: string): Promise { const data = await invoke<{ points: IntradayPoint[] }>('fetch_fund_intraday', { - req: { code: fundKey, fundKey }, + req: {code, fundKey: fundKey ?? null}, }) return data.points } @@ -63,6 +63,7 @@ export class TauriDataPort implements DataPort { type?: 'hold' | 'watch' name?: string sectors?: string[] + assetKind?: 'fund' | 'etf' | 'stock' }): Promise { return invoke('resolve_fund', { req: { @@ -70,7 +71,9 @@ export class TauriDataPort implements DataPort { type: payload.type ?? null, name: payload.name ?? null, sectors: payload.sectors ?? null, + assetKind: payload.assetKind ?? null, }, }) } + } diff --git a/docs/import-prompt.md b/docs/import-prompt.md index 3233075..719d335 100644 --- a/docs/import-prompt.md +++ b/docs/import-prompt.md @@ -7,7 +7,7 @@ ① 主输出是一个 JSON 数组;只有代码未识别的基金,才允许在数组之后另起一行用「待确认:<名称>」说明(解析器会自动忽略这段说明)。除此之外不要输出任何解释文字。 ② `amount` 是「当前市值/资产」(已含收益),不是本金/成本;`amountBasis` / `navDate` 必须按截图实际状态判断(见规则 2、3),填错会让份额整体偏掉一整天的涨跌幅,且导入后表面看不出异常。 -③ 每只基金**都必须给 `name`**,且优先用 OCR 直读截图上的 6 位代码——`name` 是校验代码是否正确的唯一可靠依据(见规则 10)。 +③ 每条持仓**都必须给 `name` 和 `assetKind`**:`assetKind` 只能是 `fund`(场外基金)、`etf`(场内 ETF)或 `stock`(A 股股票);不要只凭名称里的“ETF”猜测(见规则 10)。 # 输出格式 @@ -23,6 +23,7 @@ | 字段 | 类型 | 必填 | 从截图哪里取 | | -------------- | ------------- | -- | ----------------------------------------- | | code | string | 是 | 基金代码(6 位)。优先 OCR 直读截图上的代码,不要靠名称反推 | +| assetKind | string | 是 | `fund`(场外基金)/ `etf`(场内 ETF)/ `stock`(A 股股票) | | name | string | 是 | 基金全称(须与 code 指向同一只基金,含 A/C 份额字母) | | amount | number | 是 | 「资产」/「持仓金额」——当前市值,已含收益 | | amountBasis | string | 是 | "today" 或 "prev",按下面规则 2 判断 | @@ -47,9 +48,10 @@ "items": { "type": "object", "additionalProperties": false, - "required": ["code", "amount", "amountBasis", "name"], + "required": ["code", "assetKind", "amount", "amountBasis", "name"], "properties": { "code": {"type": "string", "pattern": "^\\d{6}$"}, + "assetKind": {"enum": ["fund", "etf", "stock"]}, "name": {"type": "string", "minLength": 1}, "amount": {"type": "number", "minimum": 0}, "amountBasis": {"enum": ["prev", "today"]}, @@ -73,6 +75,7 @@ Schema 里几个容易踩的点: - `code` 是 **string**,`"025687"` ✅ / `25687` ❌(数字会丢前导 0)。 +- `assetKind` 必填:普通申购赎回基金写 `fund`;证券账户交易的 ETF 写 `etf`;沪深北 A 股写 `stock`。 - `amount` 是 **number**,`27538.49` ✅ / `"27,538.49"` ❌(不要引号、不要千分位)。 - `holdProfitRate` 写字符串时**必须带 `%`**:`"9.95%"` ✅ / `"9.95"` ❌。 写纯数字时有歧义(|v| ≥ 1 按百分数、|v| < 1 按小数,`0.5` 会被当成 50%),所以**优先用带 % 的字符串**。 @@ -111,14 +114,15 @@ Schema 里几个容易踩的点: 只有在你能确认「该交易日净值确实零涨跌」时才填 0(几乎不会发生)。 7. 不要输出这些字段,扩展不读取:`constantProfit`、`constantProfitRate`、 `dailyProfitRate`、`nav`、`percent`、`marketValue` 等。 -8. 关于基金代码: +8. **必须正确标注 `assetKind`**:代码以 15/16/51–59 开头且在证券账户交易的,一般是 `etf`;600/601/603/605/688、000/001/002/003/300/301 等 A 股写 `stock`;其余普通开放式基金写 `fund`。不确定时保留截图信息并标注待确认,不要把股票或 ETF 写成 `fund`。 +9. 关于基金代码: - 截图里有代码就用截图上的,**优先用 OCR 直读那 6 位数字**,不要靠名称反推。 - 截图只有名称、没有代码时,**先使用你的联网/网页搜索能力按名称查代码**;查到就填真实 6 位代码。 - 确实搜不到(或你无法联网)时,才把 `code` 填为 `"000000"`,`name` 照常填真实名称; 并在 JSON 数组之后另起一行,用「待确认:<基金名称> 未识别到代码,请补全」逐条列出。 - 注意区分同一只基金的 A/C 份额(名称结尾的 A、C),代码不同,不能混用。 -9. 负数照实填负号。收益率负的就写 `"-1.91%"`. -10. **`name` 必填,且是校验代码是否正确的关键。** 基金代码极易被识图错认一两位数字, +10. 负数照实填负号。收益率负的就写 `"-1.91%"`. +11. **`name` 必填,且是校验代码是否正确的关键。** 基金代码极易被识图错认一两位数字, 但基金名称通常识别正确。典型:截图是「嘉实创新先锋混合C」(009995),代码却被误识成 009895 (那是另一只完全不同的基金)。因此: - **务必同时输出 `name`(基金全称)**——它是校验代码是否正确的唯一可靠依据。 @@ -140,6 +144,7 @@ Schema 里几个容易踩的点: [ { "code": "025687", + "assetKind": "fund", "name": "国泰半导体制造精选混合发起C", "amount": 24754.60, "amountBasis": "today", @@ -164,6 +169,7 @@ Schema 里几个容易踩的点: [ { "code": "025687", + "assetKind": "fund", "name": "国泰半导体制造精选混合发起C", "amount": 27538.49, "amountBasis": "prev", @@ -186,6 +192,7 @@ Schema 里几个容易踩的点: [ { "code": "012697", + "assetKind": "fund", "name": "广发中证白酒指数C", "amount": 12890.12, "amountBasis": "prev", @@ -203,6 +210,7 @@ Schema 里几个容易踩的点: [ { "code": "025687", + "assetKind": "fund", "name": "国泰半导体制造精选混合发起C", "amount": 16523.09, "amountBasis": "prev", @@ -213,6 +221,7 @@ Schema 里几个容易踩的点: }, { "code": "025687", + "assetKind": "fund", "name": "国泰半导体制造精选混合发起C", "amount": 11015.40, "amountBasis": "prev", @@ -236,6 +245,7 @@ Schema 里几个容易踩的点: [ { "code": "005827", + "assetKind": "fund", "name": "易方达蓝筹精选混合C", "amount": 50000.00, "amountBasis": "prev", diff --git a/packages/core/src/holdingsCalc.ts b/packages/core/src/holdingsCalc.ts index be03dac..9b27fce 100644 --- a/packages/core/src/holdingsCalc.ts +++ b/packages/core/src/holdingsCalc.ts @@ -8,6 +8,15 @@ function round2(n: number) { return Math.round(Number(n) * 100) / 100 } +/** 与桌面端 market::infer_asset_kind 同口径:旧配置缺失类别时识别场内代码。 */ +function isOffExchangeFund(raw: FundRecord): boolean { + if (raw.assetKind) return raw.assetKind === 'fund' + const code = String(raw.code || '').trim().padStart(6, '0') + if (/^(15|16|5[1-9])\d{4}$/.test(code)) return false + if (/^(000|001|002|003|300|301|600|601|603|605|688|689|920)\d{3}$/.test(code) || /^[48]\d{5}$/.test(code)) return false + return true +} + /** 收益取分:与支付宝一致,舍去厘(向 0 截断),不用四舍五入 */ export function truncPnl2(n: number) { const x = Number(n) @@ -33,6 +42,8 @@ export type QuoteLike = { /** 关联板块行情(仅 xiaobei 源填充)——板块指数涨跌幅,**不是本基金收益** */ sectorQuotes?: SectorQuote[] isQdii?: boolean + /** 场内标的的最新成交价,不使用基金盘中估值的“昨收市值”展示口径。 */ + isMarketQuote?: boolean } /** 从估值分时末点取净值(比涨幅更精确) */ @@ -161,8 +172,9 @@ export function calcHoldings( const overviewHasCost = overviewCostRow > 0 const usingEstimate = - q.percentSource === 'estimate' || - (q.percentSource !== 'confirmed' && latestEstimateNav(q) != null) + !q.isMarketQuote && + (q.percentSource === 'estimate' || + (q.percentSource !== 'confirmed' && latestEstimateNav(q) != null)) let pnl: number | null = null if (shares > 0 && prevNav != null && currNav != null) { @@ -251,7 +263,7 @@ export function calcHoldings( costs: hasCost ? costs : undefined, type: raw.type, code: raw.code, - confirmedUpdated: shouldShowConfirmedUpdatedBadge({ + confirmedUpdated: isOffExchangeFund(raw) && shouldShowConfirmedUpdatedBadge({ percentSource: q.percentSource || null, netValueDate: navDay || q.netValueDate || '', isQdii: q.isQdii, @@ -289,4 +301,3 @@ export function calcHoldings( persistPatches, } } - diff --git a/packages/core/src/port.ts b/packages/core/src/port.ts index b683dbd..d60edfb 100644 --- a/packages/core/src/port.ts +++ b/packages/core/src/port.ts @@ -49,12 +49,14 @@ export interface DataPort { fetchRefreshSchedule?(): Promise fetchFundHistory(code: string, range?: FundHistoryRange): Promise fetchIndexHistory(code: string, range: string): Promise - fetchFundIntraday(fundKey: string): Promise + /** 场外基金传 code + 可选 fundKey;场内 ETF/股票传证券 code 以走实时分时行情。 */ + fetchFundIntraday(code: string, fundKey?: string): Promise resolveFund(payload: { code: string type?: 'hold' name?: string sectors?: string[] + assetKind?: 'fund' | 'etf' | 'stock' }): Promise } diff --git a/packages/core/src/portfolioLogic.ts b/packages/core/src/portfolioLogic.ts index 03434ad..c6d40c6 100644 --- a/packages/core/src/portfolioLogic.ts +++ b/packages/core/src/portfolioLogic.ts @@ -184,6 +184,10 @@ export function normalizeFund( return { code, name: raw.name ?? prev?.name ?? code, + assetKind: + raw.assetKind === 'fund' || raw.assetKind === 'etf' || raw.assetKind === 'stock' + ? raw.assetKind + : prev?.assetKind, fundKey: raw.fundKey ?? prev?.fundKey ?? '', type: raw.type === 'hold' || raw.type === 'watch' diff --git a/packages/core/src/tradingCalendar.ts b/packages/core/src/tradingCalendar.ts index a22f1ed..280a633 100644 --- a/packages/core/src/tradingCalendar.ts +++ b/packages/core/src/tradingCalendar.ts @@ -1,9 +1,25 @@ -/** A 股简易交易日:仅跳过周末(不含法定节假日) */ +/** + * A 股交易日历。除周末外,内置交易所公布的 2026 年休市日;调休上班的周末 + * 仍不是交易日。年度数据尚未内置时保守地仅按周末判断。 + */ +const A_SHARE_CLOSED_2026 = new Set([ + '2026-01-01', '2026-01-02', + '2026-02-16', '2026-02-17', '2026-02-18', '2026-02-19', '2026-02-20', '2026-02-23', + '2026-04-06', '2026-05-01', '2026-05-04', '2026-05-05', '2026-06-19', + '2026-09-25', '2026-10-01', '2026-10-02', '2026-10-05', '2026-10-06', '2026-10-07', +]) export function todayDateStr(d = new Date()) { return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` } +/** A 股是否开市;调休工作日不交易。 */ +export function isAShareTradingDay(now = new Date()): boolean { + const day = now.getDay() + if (day === 0 || day === 6) return false + return !A_SHARE_CLOSED_2026.has(todayDateStr(now)) +} + /** 统一成 YYYY-MM-DD(兼容 MM-DD) */ export function normalizeNetValueDate(raw: string | null | undefined, now = new Date()) { const s = String(raw || '').trim() @@ -25,13 +41,13 @@ function parseDateStr(s: string): Date { return new Date(y, m - 1, day) } -/** 给定交易日之后的下一个交易日(周末顺延) */ +/** 给定交易日之后的下一个交易日(周末和交易所休市日顺延) */ export function nextTradingDay(dateStr: string): string { const normalized = normalizeNetValueDate(dateStr) || dateStr const d = parseDateStr(normalized) do { d.setDate(d.getDate() + 1) - } while (d.getDay() === 0 || d.getDay() === 6) + } while (!isAShareTradingDay(d)) return todayDateStr(d) } @@ -72,8 +88,7 @@ export function shouldShowConfirmedUpdatedBadge( /* ============================================================ * * 市场时段判断 —— 用于 SW 定时刷新时跳过非交易时段的数据源 - * 仅按"周一到周五"判断,不识别法定节假日(节假日时刷新到的是 - * 静态数据,不会出错,只是浪费少量请求)。 + * 使用交易所休市日历,调休工作日不发起盘中请求。 * ============================================================ */ function hmsToMinutes(now: Date): number { @@ -82,8 +97,7 @@ function hmsToMinutes(now: Date): number { /** A 股交易日(周一到周五)的盘中时段:09:15 - 15:30 */ export function isAShareTradingTime(now = new Date()): boolean { - const day = now.getDay() - if (day === 0 || day === 6) return false + if (!isAShareTradingDay(now)) return false const m = hmsToMinutes(now) return m >= 9 * 60 + 15 && m <= 15 * 60 + 30 } @@ -97,16 +111,14 @@ export function isAShareTradingTime(now = new Date()): boolean { * - A 股交易日 20:00-23:00(晚间官方确认涨跌更新) */ export function shouldRefreshFund(now = new Date()): boolean { - const day = now.getDay() - if (day === 0 || day === 6) return false + if (!isAShareTradingDay(now)) return false const m = hmsToMinutes(now) return m >= 9 * 60 && m <= 23 * 60 } /** A 股指数 / 大盘(涨跌家数、板块排行):交易日 09:00-15:30(09:00 起覆盖黄金 AU9999 日盘 09:00 开盘,避免 09:00-09:15 空窗) */ export function shouldRefreshAShareMarket(now = new Date()): boolean { - const day = now.getDay() - if (day === 0 || day === 6) return false + if (!isAShareTradingDay(now)) return false const m = hmsToMinutes(now) return m >= 9 * 60 && m <= 15 * 60 + 30 } diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index eb524ed..012480d 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -1,8 +1,13 @@ // 共享类型定义 +/** 资产交易形态。缺省值为 fund,兼容旧版配置;ETF/股票会按交易价格刷新。 */ +export type AssetKind = 'fund' | 'etf' | 'stock' + export type FundRecord = { code: string name: string + /** 场外基金 / 场内 ETF / A 股股票。旧配置缺失时由行情层按代码识别。 */ + assetKind?: AssetKind fundKey?: string type: 'hold' | 'watch' /** @@ -338,6 +343,7 @@ export type ResolveFundResult = { name: string fundKey: string sectors: string[] + assetKind?: AssetKind netValue?: number | null prevNetValue?: number | null prevNetValueDate?: string diff --git a/packages/services/src/fund.ts b/packages/services/src/fund.ts index bdd816c..a45ccd2 100644 --- a/packages/services/src/fund.ts +++ b/packages/services/src/fund.ts @@ -1,5 +1,5 @@ import {httpGet, httpPost, MOBILE_UA, fmtDate} from './http' -import {PUSH_HOSTS} from './market' +import {PUSH_HOSTS, getMarketSecurityQuotes, inferAssetKind, inferSecurityExchange} from './market' import { isAShareTradingTime, isConfirmedSessionActive, @@ -826,6 +826,8 @@ export async function getFundQuote(fund: { */ export type FundQuoteInput = { code: string + assetKind?: 'fund' | 'etf' | 'stock' + exchange?: string fundKey?: string name?: string sectors?: string[] @@ -854,6 +856,7 @@ export type FundQuote = { useCalc?: boolean /** 是否为 QDII/海外延迟披露基金(UI 区分:盘中「-」、基金列次行净值日期) */ isQdii?: boolean + isMarketQuote?: boolean } export interface FundQuoteProvider { @@ -1768,14 +1771,35 @@ export async function getFundsQuotes( funds: any[], source: QuoteSource = 'fundmnfinfo', ): Promise { - const provider = getQuoteProvider(source) const inputs: FundQuoteInput[] = funds.map((f) => ({ code: f.code, + assetKind: inferAssetKind(f.code, f.assetKind), + exchange: f.exchange, fundKey: f.fundKey, name: f.name, sectors: f.sectors, })) - return provider.fetchQuotes(inputs) + const fundInputs = inputs.filter((input) => input.assetKind === 'fund') + const marketInputs = inputs.filter((input) => input.assetKind !== 'fund') + const provider = getQuoteProvider(source) + const [fundQuotes, marketQuotes] = await Promise.all([ + fundInputs.length ? provider.fetchQuotes(fundInputs) : Promise.resolve([]), + marketInputs.length ? getMarketSecurityQuotes(marketInputs) : Promise.resolve([]), + ]) + const fundByCode = new Map(fundQuotes.map((quote) => [quote.code, quote])) + const marketByCode = new Map(marketQuotes.map((quote) => [quote.code, quote])) + return inputs.map((input) => { + const code = String(input.code).padStart(6, '0') + const market = marketByCode.get(code) + if (!market) return fundByCode.get(code) || emptyQuote(input, '未获取到行情') + return { + ...emptyQuote(input), code, name: market.name || input.name || code, + netValue: market.price, estimateNetValue: market.price, prevNetValue: market.previousClose, + percent: market.percent, percentSource: 'confirmed' as const, + dayGrowth: market.percent, estimateGrowth: market.percent, + netValueDate: market.time.slice(0, 10), time: market.time || null, isMarketQuote: true, + } + }) } export async function resolveFund(payload: { @@ -1783,8 +1807,20 @@ export async function resolveFund(payload: { type?: 'hold' | 'watch' name?: string sectors?: string[] + assetKind?: 'fund' | 'etf' | 'stock' }) { let code = String(payload.code || '').trim() + const assetKind = inferAssetKind(code, payload.assetKind) + if (assetKind !== 'fund') { + const [quote] = await getMarketSecurityQuotes([{code, exchange: inferSecurityExchange(code) || undefined}]) + if (!quote?.price) throw new Error(`未找到证券代码 ${code} 的场内行情,请核对代码`) + return { + code: quote.code, name: quote.name || payload.name || quote.code, fundKey: '', sectors: [], + netValue: quote.price, prevNetValue: quote.previousClose, prevNetValueDate: '', + netValueDate: quote.time.slice(0, 10), confirmedSession: true, + officialName: quote.name, assetKind, + } + } let meta: any = {} try { meta = await searchFund(code) diff --git a/packages/services/src/market.ts b/packages/services/src/market.ts index b704c5e..c6cd634 100644 --- a/packages/services/src/market.ts +++ b/packages/services/src/market.ts @@ -58,6 +58,58 @@ export const PUSH_HOSTS = [ 'https://82.push2.eastmoney.com', ] +export type MarketSecurityInput = {code: string; exchange?: string} +export type MarketSecurityQuote = { + code: string + name: string + exchange: string + price: number | null + previousClose: number | null + percent: number | null + time: string +} + +/** 仅识别常见场内代码,避免将普通场外基金误当作股票。 */ +export function inferAssetKind(code: string, explicit?: string): 'fund' | 'etf' | 'stock' { + if (explicit === 'fund' || explicit === 'etf' || explicit === 'stock') return explicit + const c = String(code || '').padStart(6, '0') + if (/^(15|16|5[1-9])\d{4}$/.test(c)) return 'etf' + if (/^(000|001|002|003|300|301|600|601|603|605|688|689|4|8|92)\d{3}$/.test(c)) return 'stock' + return 'fund' +} + +export function inferSecurityExchange(code: string, exchange?: string): string | null { + if (exchange === 'SH' || exchange === 'SZ' || exchange === 'BJ') return exchange + const c = String(code || '').padStart(6, '0') + if (/^(5|6|9)/.test(c)) return 'SH' + if (/^(0|1|2|3)/.test(c)) return 'SZ' + if (/^(4|8)/.test(c)) return 'BJ' + return null +} + +/** 批量获取沪深北股票和场内 ETF 的最新成交价、昨收及涨跌幅。 */ +export async function getMarketSecurityQuotes(inputs: MarketSecurityInput[]): Promise { + const valid = inputs + .map((input) => ({code: String(input.code || '').padStart(6, '0'), exchange: inferSecurityExchange(input.code, input.exchange)})) + .filter((input): input is {code: string; exchange: string} => /^\d{6}$/.test(input.code) && !!input.exchange) + if (!valid.length) return [] + const secids = valid.map(({code, exchange}) => `${exchange === 'SH' ? 1 : 0}.${code}`).join(',') + const data = await eastmoneyGet('/api/qt/ulist.np/get', { + fltt: 2, invt: 2, fields: 'f2,f3,f12,f13,f14,f18,f124', secids, + }, PUSH_HOSTS) + const rows = new Map((data?.data?.diff || []).map((row: any) => [String(row.f12), row])) + return valid.map(({code, exchange}) => { + const row = rows.get(code) + return { + code, exchange, name: typeof row?.f14 === 'string' ? row.f14 : code, + price: typeof row?.f2 === 'number' && row.f2 > 0 ? row.f2 : null, + previousClose: typeof row?.f18 === 'number' && row.f18 > 0 ? row.f18 : null, + percent: typeof row?.f3 === 'number' ? row.f3 : null, + time: typeof row?.f124 === 'number' ? new Date(row.f124 * 1000).toISOString() : '', + } + }) +} + async function eastmoneyGet( url: string, params: Record, diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 85f7e3b..03d2b0c 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -143,8 +143,12 @@ export function App() { ? settings.selectedIndices : DEFAULT_SELECTED_INDICES + // 全局行情快照时间只在顶部展示,避免逐行时间戳与持仓的涨跌/净值信息抢空间。 const updatedAt = lastUpdate - ? new Date(lastUpdate).toLocaleTimeString('zh-CN', {hour12: false}) + ? (() => { + const d = new Date(lastUpdate) + return `${d.getMonth() + 1}月${d.getDate()}日 ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}:${String(d.getSeconds()).padStart(2, '0')}` + })() : '' // 行情快照未就绪(后端首轮刷新进行中 / 切源清空待刷)时,用本地配置合成持仓骨架 @@ -194,7 +198,7 @@ export function App() { {quoteSourceBadge} - {updatedAt ? `更新 ${updatedAt}` : ''} + {updatedAt ? `更新:${updatedAt}` : ''}
diff --git a/packages/ui/src/OptionsApp.tsx b/packages/ui/src/OptionsApp.tsx index a452cf7..3f52e3c 100644 --- a/packages/ui/src/OptionsApp.tsx +++ b/packages/ui/src/OptionsApp.tsx @@ -63,6 +63,7 @@ import { MENUBAR_OVERVIEW_KEY, MIN_REFRESH_INTERVAL, normalizeHexColor, + todayDateStr, } from '@fund01/core' import {cn} from '@fund01/core' import {buildInfo} from './buildInfo' @@ -92,6 +93,7 @@ import { } from './lib/fundOps' import { deriveRowReadonly, + inferAssetKind, loadEditRows, type EditRow, } from './lib/batchEdit' @@ -1505,7 +1507,7 @@ function AddFundSection({ return (

- 录入基金代码与持有金额即可添加;持有收益可选填(与基金列表/截图一致)。同一基金可在多个分组各持有独立份额;添加后表单自动清空,方便连续录入。 + 支持场外基金、场内 ETF 和 A 股股票;录入证券代码与持有金额即可添加。持有收益可选填,同一标的可在多个分组各持有独立份额。

cur.map((r, i) => (i === index ? {...r, ...patch, touched: true} : r))) } + /** 同一证券可分属多个分组,但类别只能有一个;修改时同步所有分组行。 */ + function updateAssetKind(index: number, assetKind: EditRow['assetKind']) { + const code = rows[index]?.code + if (!code) return + setRows((cur) => cur.map((r) => r.code === code ? {...r, assetKind, touched: true} : r)) + } + function removeRow(index: number) { setRows((cur) => cur.filter((_, i) => i !== index)) } @@ -1845,6 +1855,9 @@ function EditHoldingsSection({ } continue } + // assetKind 是证券级字段(不是分组字段);编辑页已同步同代码的各分组行。 + funds[key].assetKind = list[0].assetKind + funds[key].updatedAt = new Date().toISOString() const rowGroups = new Set(list.map((r) => r.group)) for (const r of list) { // 统一录入口径:金额 + 收益 → 份额 = 金额 ÷ 口径基准净值;成本单价 = (金额−收益) ÷ 份额(派生) @@ -2077,6 +2090,7 @@ function EditHoldingsSection({ + @@ -2088,6 +2102,7 @@ function EditHoldingsSection({ + @@ -2121,6 +2136,21 @@ function EditHoldingsSection({ ) : null} + + @@ -2692,6 +2725,9 @@ function ImportSection({ + diff --git a/packages/ui/src/components/FundFormDialog.tsx b/packages/ui/src/components/FundFormDialog.tsx index 3a2112f..87f30a3 100644 --- a/packages/ui/src/components/FundFormDialog.tsx +++ b/packages/ui/src/components/FundFormDialog.tsx @@ -1,7 +1,7 @@ import {useEffect, useState} from 'react' import {X} from 'lucide-react' import {Button, Dialog, TextField} from '@radix-ui/themes' -import type {FundQuoteRow} from '@fund01/core' +import type {AssetKind, FundQuoteRow} from '@fund01/core' import {usePorts} from '../context' type Payload = { @@ -12,6 +12,14 @@ type Payload = { group?: string /** 该分组的持有收益(元,可选;用于反推成本单价) */ holdProfit?: number + assetKind?: AssetKind +} + +function inferAssetKind(code: string): AssetKind { + const normalized = code.trim().padStart(6, '0') + if (/^(15|16|5[1-9])\d{4}$/.test(normalized)) return 'etf' + if (/^(000|001|002|003|300|301|600|601|603|605|688|689|920)\d{3}$/.test(normalized) || /^[48]\d{5}$/.test(normalized)) return 'stock' + return 'fund' } /** 表单内容(不含 Dialog 包装),可在弹窗里用,也可在设置页里内联常驻。 @@ -60,6 +68,7 @@ export function FundFormBody({ const [addingGroup, setAddingGroup] = useState(false) const [saving, setSaving] = useState(false) const [error, setError] = useState('') + const [assetKind, setAssetKind] = useState('fund') // 内联常驻场景:始终以最新 props 同步表单(settings 页里打开即空白/预填) useEffect(() => { @@ -70,6 +79,7 @@ export function FundFormBody({ setSelectedGroup(editingGroup ?? '') setAddingGroup(false) setError('') + setAssetKind(initial?.assetKind || inferAssetKind(initial?.code || '')) }, [initial, editingGroup, initialAmount, initialHoldProfit]) return ( @@ -83,6 +93,7 @@ export function FundFormBody({ const payload: Payload = { code: code.trim(), type: mode, + assetKind, } if (mode === 'hold') { payload.amount = Number(amount) || 0 @@ -114,19 +125,29 @@ export function FundFormBody({ >
setCode(e.target.value.replace(/\D/g, '').slice(0, 6))} - placeholder="如 001618" + placeholder="如 001618、159655 或 601288" inputMode="numeric" required />
+
+ +
+ {([['fund', '场外基金'], ['etf', '场内 ETF'], ['stock', 'A 股股票']] as const).map(([kind, label]) => ( + + ))} +
+ {initial ?

修改类型后,保存时会按新类型使用对应行情源刷新。

: null} +
+ {mode === 'hold' ? ( <> {/* 分组(单选) */} diff --git a/packages/ui/src/components/FundTrendChart.tsx b/packages/ui/src/components/FundTrendChart.tsx index 08c71d8..a122201 100644 --- a/packages/ui/src/components/FundTrendChart.tsx +++ b/packages/ui/src/components/FundTrendChart.tsx @@ -18,6 +18,19 @@ const TABS: {key: TabKey; label: string}[] = [ const HISTORY_RANGES: FundHistoryRange[] = ['3m', '1y', '3y', 'since'] +/** A 股连续竞价分钟:午间休市不生成点,横轴固定到 15:00。 */ +function aShareIntradayLabels(): string[] { + const labels: string[] = [] + const append = (start: number, end: number) => { + for (let minute = start; minute <= end; minute += 1) { + labels.push(`${String(Math.floor(minute / 60)).padStart(2, '0')}:${String(minute % 60).padStart(2, '0')}`) + } + } + append(9 * 60 + 30, 11 * 60 + 30) + append(13 * 60, 15 * 60) + return labels +} + /** * 基金走势图(不含 Dialog 外壳),可独立嵌在任意位置(详情弹窗底部 / 独立走势弹窗内)。 * - active:是否处于可视/激活状态;仅当为 true 时才拉取与渲染曲线,避免弹窗未打开时浪费请求。 @@ -77,7 +90,7 @@ export function FundTrendChart({ let cancelled = false setLoadingIntraday(true) setIntradayError('') - ports.data.fetchFundIntraday(fundKey || code) + ports.data.fetchFundIntraday(code, fundKey) .then((points) => { if (cancelled) return setLazyIntraday( @@ -181,15 +194,20 @@ export function FundTrendChart({ if (range === 'intraday') { const points = effectiveIntraday if (!points.length) return null - const values = points.map((p) => p.value) - const lastPct = values[values.length - 1] ?? 0 + // 场内分时固定显示完整交易日。尚未到达的分钟置空,曲线自然停在最新成交时刻, + // 不会把 11:00 的数据拉伸占满到右边界。 + const labels = aShareIntradayLabels() + const valueByTime = new Map(points.map((p) => [p.time.slice(-5), p.value])) + const values = labels.map((time) => valueByTime.get(time) ?? null) + const numericValues = values.filter((value): value is number => value != null) + const lastPct = numericValues[numericValues.length - 1] ?? 0 const up = lastPct >= 0 const color = up ? '#d7263d' : '#0f8a5f' const lastLabel = `${lastPct > 0 ? '+' : ''}${lastPct.toFixed(2)}%` return buildChartOption({ - labels: points.map((p) => p.time), + labels, values, - fullDates: points.map((p) => p.time), + fullDates: labels, extras: points.map(() => null as number | null), extraLabel: '', color, @@ -313,7 +331,7 @@ function buildChartOption({ lastLabel, }: { labels: string[] - values: number[] + values: (number | null)[] fullDates: string[] extras: (number | null)[] extraLabel: string @@ -322,8 +340,9 @@ function buildChartOption({ line: string lastLabel: string }) { - const min = Math.min(...values) - const max = Math.max(...values) + const numericValues = values.filter((value): value is number => value != null) + const min = Math.min(...numericValues) + const max = Math.max(...numericValues) const pad = Math.max((max - min) * 0.12, 0.05) return { diff --git a/packages/ui/src/components/HoldingsNav.tsx b/packages/ui/src/components/HoldingsNav.tsx index c3e275a..e83f513 100644 --- a/packages/ui/src/components/HoldingsNav.tsx +++ b/packages/ui/src/components/HoldingsNav.tsx @@ -91,4 +91,4 @@ export function HoldingsNav() { ) -} \ No newline at end of file +} diff --git a/packages/ui/src/components/fundBits.tsx b/packages/ui/src/components/fundBits.tsx index 21f0907..236b521 100644 --- a/packages/ui/src/components/fundBits.tsx +++ b/packages/ui/src/components/fundBits.tsx @@ -17,54 +17,23 @@ export const HOLD_PROFIT_TERMS_NOTE = ( ) -/** 已确认涨跌的徽标:展示「已更新 + 当日涨跌% + 最新净值」,仅 confirmedUpdated 时渲染 */ +/** 已确认涨跌的徽标:只表达确认状态,涨跌和净值由表格右侧专栏展示,避免重复。 */ export function ConfirmedUpdatedBadge({ show, - percent, - netValue, compact, }: { show?: boolean - percent?: number | null - netValue?: number | null compact?: boolean }) { if (!show) return null - if (compact) { - return ( - - 已更新 - - ) - } - const pctText = formatPct(percent) - const navText = - netValue != null && Number.isFinite(netValue) ? netValue.toFixed(4) : '--' return ( = 0 - ? 'border-rise/35 bg-rise/10 text-rise' - : 'border-fall/35 bg-fall/10 text-fall' - }`} + className="inline-flex shrink-0 items-center rounded border border-emerald-400/40 bg-emerald-400/10 px-1.5 py-0.5 text-[10px] font-medium leading-none text-emerald-500" title={ '已拉到官方确认涨跌,持仓金额与收益已按确认值更新;下一交易日开盘后自动清除' } > - 已更新 - {pctText !== '--' ? ( - <> - | - {pctText} - - ) : null} - | - {navText} + {compact ? '已更新' : '今日已更新'} ) } diff --git a/packages/ui/src/components/popup/FundList.tsx b/packages/ui/src/components/popup/FundList.tsx index 3847090..90d012a 100644 --- a/packages/ui/src/components/popup/FundList.tsx +++ b/packages/ui/src/components/popup/FundList.tsx @@ -383,18 +383,8 @@ export function FundList({ 净值{row.netValueDate.slice(5)} ) : null} - {row.time ? ( - - {row.time} - - ) : null} diff --git a/packages/ui/src/lib/batchEdit.ts b/packages/ui/src/lib/batchEdit.ts index fbe4f02..44f7ed4 100644 --- a/packages/ui/src/lib/batchEdit.ts +++ b/packages/ui/src/lib/batchEdit.ts @@ -6,13 +6,15 @@ * 口径(spec 持仓录入与展示统一 §4.3):编辑表格可编辑列 = 持有金额 + 持有收益; * 持有份额 / 成本单价 / 持有成本 均为只读派生展示。落库仍写 allocations/costs。 */ -import type {FundRecord, Ports} from '@fund01/core' +import type {AssetKind, FundRecord, Ports} from '@fund01/core' import {getHoldingGroupOrder, listFunds, listHoldingGroups} from './fundOps' /** 一行可编辑项:某基金在某分组的持有金额与持有收益(编辑用),份额/成本只读派生 */ export type EditRow = { code: string name: string + /** 标的类别;旧配置缺失时由代码推断,保存编辑后会显式写入。 */ + assetKind: AssetKind group: string /** 持有份额(只读展示;loadEditRows 时为原值,UI 按 amount÷净值 实时派生) */ shares: string @@ -28,6 +30,13 @@ export type EditRow = { touched?: boolean } +/** 旧配置缺失类别时的兼容推断;新增功能应与编辑持仓使用同一口径。 */ +export function inferAssetKind(code: string): AssetKind { + if (/^(15|16|5[1-9])\d{4}$/.test(code)) return 'etf' + if (/^(000|001|002|003|300|301|600|601|603|605|688|689|920)\d{3}$/.test(code) || /^[48]\d{5}$/.test(code)) return 'stock' + return 'fund' +} + /** * 实时派生一组只读展示值(持有份额 / 成本单价 / 持有成本)。 * nav 缺失(无确认净值)时份额派不出 → 三项均为 null,UI 显示 --。 @@ -100,6 +109,7 @@ export async function loadEditRows( rows.push({ code: f.code, name: f.name || f.code, + assetKind: f.assetKind || inferAssetKind(f.code), group: g, // 0 份额也显示为「0」(0 金额关注基金),供只读列回退与预填判断用 shares: shares > 0 ? String(shares) : shares === 0 ? '0' : '', diff --git a/packages/ui/src/lib/fundOps.ts b/packages/ui/src/lib/fundOps.ts index ef40626..f9991fc 100644 --- a/packages/ui/src/lib/fundOps.ts +++ b/packages/ui/src/lib/fundOps.ts @@ -295,6 +295,7 @@ export async function createFund( type: 'hold', name: payload.name, sectors: payload.sectors, + assetKind: payload.assetKind, }) // 代码 ↔ 名称核对结果(对持仓生效)。 @@ -407,6 +408,7 @@ export async function createFund( // 官方名优先:传入名可能来自 AI 识图,与代码不符时以数据源为准, // 否则会出现「显示的名字是对的、数据却是另一只基金」的隐形错配。 name: meta.name || payload.name, + assetKind: meta.assetKind || payload.assetKind, fundKey: meta.fundKey, type: 'hold', allocations, diff --git a/packages/ui/src/lib/importHoldings.ts b/packages/ui/src/lib/importHoldings.ts index 29aa91c..321a708 100644 --- a/packages/ui/src/lib/importHoldings.ts +++ b/packages/ui/src/lib/importHoldings.ts @@ -1,3 +1,5 @@ +import type {AssetKind} from '@fund01/core' + /** * 导入持仓的纯解析逻辑(与具体 UI 解耦)。 * 原 ImportHoldingsDialog 依赖 Dialog,这里把解析单独抽出来, @@ -7,6 +9,8 @@ /** 一条导入记录:一个 (基金, 分组, 金额[, 成本]) 元组 */ export type ImportEntry = { code: string + /** 场外基金 / 场内 ETF / A 股股票。导入后明确写入配置,不依赖代码推断。 */ + assetKind: AssetKind amount: number /** * amount 对应哪一版确认净值的市值 —— 直接对应持仓截图上的「今日收益已更新」字样: @@ -53,6 +57,21 @@ export type ImportEntry = { holdProfitRate?: number } +function inferAssetKind(code: string): AssetKind { + if (/^(15|16|5[1-9])\d{4}$/.test(code)) return 'etf' + if (/^(000|001|002|003|300|301|600|601|603|605|688|689|920)\d{3}$/.test(code) || /^[48]\d{5}$/.test(code)) return 'stock' + return 'fund' +} + +function parseAssetKind(raw: unknown, code: string, index: number): AssetKind { + if (raw == null || String(raw).trim() === '') return inferAssetKind(code) + const value = String(raw).trim().toLowerCase().replace(/\s+/g, '') + if (value === 'fund' || value === '场外基金') return 'fund' + if (value === 'etf' || value === '场内etf' || value === '场内基金') return 'etf' + if (value === 'stock' || value === 'a股股票' || value === '股票') return 'stock' + throw new Error(`第 ${index + 1} 项 assetKind 无效,应为 fund、etf 或 stock`) +} + /** * 归一化收益率输入为小数。 * 支持 "9.95%" / "-1.91%" 字符串,以及数字 9.95(视为百分数)/ 0.0995(视为小数)。 @@ -126,6 +145,7 @@ export function parseImport(input: string): ImportEntry[] { if (!/^\d{6}$/.test(code)) { throw new Error(`第 ${i + 1} 项基金代码无效`) } + const assetKind = parseAssetKind(obj.assetKind ?? obj.assetType ?? obj.标的类型, code, i) const rawAmount = Number(obj.amount ?? obj.money ?? 0) if (!Number.isFinite(rawAmount) || rawAmount < 0) { throw new Error(`第 ${i + 1} 项金额无效`) @@ -182,6 +202,7 @@ export function parseImport(input: string): ImportEntry[] { ) return { code, + assetKind, amount: rawAmount, amountBasis, navDate, @@ -197,4 +218,4 @@ export function parseImport(input: string): ImportEntry[] { } /** 导入 JSON 格式示例(用于占位与说明) */ -export const IMPORT_SAMPLE = `请将 AI 生成的 JSON 粘贴到此处,格式如下:\n[\n { "code": "012697", "name": "广发中证白酒指数C", "amount": 12890.12, "amountBasis": "prev", "shares": 12345.67, "cost": 1.0234 },\n { "code": "025687", "name": "国泰半导体制造精选混合发起C", "amount": 27538.49, "amountBasis": "today", "group": "人工智能", "holdProfit": 2401.57, "holdProfitRate": "9.95%", "dailyProfit": 768.29 },\n { "code": "025687", "name": "国泰半导体制造精选混合发起C", "amount": 3000, "amountBasis": "prev", "navDate": "2026-08-01", "group": "核心" }\n]` +export const IMPORT_SAMPLE = `请将 AI 生成的 JSON 粘贴到此处,格式如下:\n[\n { "code": "012697", "assetKind": "fund", "name": "广发中证白酒指数C", "amount": 12890.12, "amountBasis": "prev", "shares": 12345.67, "cost": 1.0234 },\n { "code": "159655", "assetKind": "etf", "name": "华夏标普500ETF(QDII)", "amount": 100951.20, "amountBasis": "today", "holdProfit": 9777.60 },\n { "code": "601288", "assetKind": "stock", "name": "农业银行", "amount": 6246.00, "amountBasis": "today", "holdProfit": 548.10 }\n]` From 5a456782350d9c3e0fc2fc567b32295a87b7eeea Mon Sep 17 00:00:00 2001 From: NklausMikealson Date: Wed, 30 Sep 2026 14:02:23 +0800 Subject: [PATCH 2/2] fix: add Tencent fallback for exchange intraday charts --- apps/tauri/src-tauri/src/market.rs | 82 +++++++++++++++++++++++++++++- packages/ui/src/OptionsApp.tsx | 4 +- 2 files changed, 83 insertions(+), 3 deletions(-) diff --git a/apps/tauri/src-tauri/src/market.rs b/apps/tauri/src-tauri/src/market.rs index 51df100..a852f7d 100644 --- a/apps/tauri/src-tauri/src/market.rs +++ b/apps/tauri/src-tauri/src/market.rs @@ -175,7 +175,87 @@ pub async fn fetch_market_intraday(code: &str) -> Result last_error = Some(error), } } - Err(last_error.unwrap_or_else(|| "场内分时加载失败".to_string())) + // `push2his` 在部分网络(尤其是启用了企业代理时)会直接断开连接。 + // 报价已使用腾讯兜底,分时也必须同样兜底;不能再退回 fund123, + // 因为场内证券代码不是场外基金的 productId。 + fetch_tencent_market_intraday(code).await.map_err(|fallback_error| { + let primary_error = last_error.unwrap_or_else(|| "东财场内分时加载失败".to_string()); + format!("{primary_error};腾讯分时兜底也失败:{fallback_error}") + }) +} + +/// 腾讯分钟行情兜底。接口返回 `0930 价格 …`,昨收位于 qt 数组第 5 项。 +/// 与东财同样按昨收换算涨跌幅,保证图表口径一致。 +async fn fetch_tencent_market_intraday(code: &str) -> Result { + let normalized = format!("{:0>6}", code.trim()); + if normalized.len() != 6 || !normalized.bytes().all(|b| b.is_ascii_digit()) { + return Err(format!("无效场内代码 {code}")); + } + let market = if normalized.starts_with('5') || normalized.starts_with('6') || normalized.starts_with('9') { + "sh" + } else if normalized.starts_with('4') || normalized.starts_with('8') { + "bj" + } else { + "sz" + }; + let symbol = format!("{market}{normalized}"); + let url = format!("https://web.ifzq.gtimg.cn/appstock/app/minute/query?code={symbol}"); + let data = http::http_get_json( + &url, + &HashMap::new(), + DESKTOP_UA, + Some("https://gu.qq.com/"), + Duration::from_secs(12), + ).await?; + let item = data.get("data").and_then(|value| value.get(&symbol)).unwrap_or(&Value::Null); + let pre_close = item + .get("qt") + .and_then(|value| value.get(&symbol)) + .and_then(Value::as_array) + .and_then(|quote| quote.get(4)) + .and_then(|value| value.as_str().and_then(|text| text.parse::().ok())) + .filter(|value| *value > 0.0) + .ok_or_else(|| "腾讯分时缺少昨收价".to_string())?; + let points: Vec = item + .get("data") + .and_then(|value| value.get("data")) + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(|row| { + let text = row.as_str()?; + let mut fields = text.split_whitespace(); + let raw_time = fields.next()?; + let price = fields.next()?.parse::().ok().filter(|value| *value > 0.0)?; + let time = if raw_time.len() == 4 { + format!("{}:{}", &raw_time[..2], &raw_time[2..]) + } else { + raw_time.to_string() + }; + Some(TrendPoint { + time, + growth: Some(((price / pre_close) - 1.0) * 100.0), + net_value: Some(price), + }) + }) + .collect(); + if points.is_empty() { + return Err("腾讯分时暂无数据(休市或停牌)".to_string()); + } + let latest = points.last().cloned(); + Ok(FundIntradayPayload { + points, + latest, + fund_key: format!("tencent:{symbol}"), + name: item + .get("qt") + .and_then(|value| value.get(&symbol)) + .and_then(Value::as_array) + .and_then(|quote| quote.get(1)) + .and_then(Value::as_str) + .unwrap_or(code) + .to_string(), + }) } const KLINE_HOSTS: &[&str] = &[ diff --git a/packages/ui/src/OptionsApp.tsx b/packages/ui/src/OptionsApp.tsx index 3f52e3c..f51271d 100644 --- a/packages/ui/src/OptionsApp.tsx +++ b/packages/ui/src/OptionsApp.tsx @@ -952,7 +952,7 @@ function GeneralSection({
数据源

- 基金当日净值/估值/涨跌幅的来源。盘中分时走势均走 fund123。 + 基金当日净值/估值/涨跌幅的来源。场外基金分时走 fund123;场内 ETF 与 A 股分时优先走东方财富,失败时自动切换腾讯行情。

基金净值、估值、涨跌幅来自两个数据源:FundMNFInfo(天天基金)与 fund123(蚂蚁基金)。

-

两种数据源的盘中分时走势均走 fund123。

+

场外基金的盘中分时走势走 fund123;场内 ETF、A 股则使用证券实时行情,东方财富失败时自动切换腾讯行情。

「预估收益」与「实际收益」计算方式不同,同一基金在两个源下可能不同;实际当日收益以官方披露净值为准(一般当日 20:00 后开始更新)。

基金类别 持有金额 持有收益 持有份额 + updateAssetKind(i, value as EditRow['assetKind'])} + disabled={saving || refreshing} + size="1" + > + + + 场外基金 + 场内 ETF + A 股股票 + + +
代码 名称类别 金额 成本/累计 口径
{e.code} {e.name || '--'} + {e.assetKind === 'fund' ? '场外基金' : e.assetKind === 'etf' ? '场内 ETF' : 'A 股股票'} + {e.amount.toFixed(2)}