use crate::client::AkShareClient;
use crate::error::{Error, Result};
use crate::types::{CandlePoint, EtfScaleItem, EtfSpotItem, FundNavHistory, FundSnapshot};
use crate::util::parse_f64_safe;
fn etf_market_id(symbol: &str) -> &str {
if symbol.starts_with('5') || symbol.starts_with('6') {
"1"
} else {
"0"
}
}
impl AkShareClient {
pub async fn fund_etf_fund_daily_em(&self, limit: usize) -> Result<Vec<FundSnapshot>> {
let pz = limit.max(1).to_string();
let response = self
.get("https://push2.eastmoney.com/api/qt/clist/get")
.query(&[
("pn", "1"),
("pz", pz.as_str()),
("po", "1"),
("np", "1"),
("fltt", "2"),
("invt", "2"),
("fs", "b:MK0021,b:MK0022,b:MK0023,b:MK0024,b:MK0025"),
("fields", "f2,f3,f12,f14"),
])
.send()
.await
.map_err(Error::from)?
.error_for_status()
.map_err(Error::from)?;
let payload: serde_json::Value = response.json().await.map_err(Error::from)?;
let items = payload
.get("data")
.and_then(|d| d.get("diff"))
.and_then(|d| d.as_array())
.cloned()
.unwrap_or_default();
let today = crate::util::today_iso();
let snapshots: Vec<FundSnapshot> = items
.into_iter()
.take(limit)
.filter_map(|v| {
let code = v.get("f12")?.as_str()?.to_string();
let name = v
.get("f14")
.and_then(|x| x.as_str())
.unwrap_or("")
.to_string();
let price = v.get("f2").and_then(|x| x.as_f64()).unwrap_or(0.0);
let change_pct = v.get("f3").and_then(|x| x.as_f64()).unwrap_or(0.0);
Some(FundSnapshot {
symbol: code,
name,
date: today.clone(),
nav: price,
acc_nav: price,
change_pct,
fund_type: Some("etf".to_string()),
})
})
.collect();
if snapshots.is_empty() {
return Err(Error::not_found("no ETF fund daily data"));
}
Ok(snapshots)
}
pub async fn fund_etf_hist_em(
&self,
symbol: &str,
period: &str,
start_date: &str,
end_date: &str,
adjust: &str,
) -> Result<Vec<CandlePoint>> {
let period_map = match period {
"daily" => "101",
"weekly" => "102",
"monthly" => "103",
_ => {
return Err(Error::invalid_input(format!(
"unsupported period: {period}"
)));
}
};
let adjust_map = match adjust {
"qfq" => "1",
"hfq" => "2",
"" | "none" => "0",
_ => {
return Err(Error::invalid_input(format!(
"unsupported adjust: {adjust}"
)));
}
};
let market_id = etf_market_id(symbol);
let response = self
.get("https://push2his.eastmoney.com/api/qt/stock/kline/get")
.query(&[
("fields1", "f1,f2,f3,f4,f5,f6"),
(
"fields2",
"f51,f52,f53,f54,f55,f56,f57,f58,f59,f60,f61,f116",
),
("ut", "7eea3edcaed734bea9cbfc24409ed989"),
("klt", period_map),
("fqt", adjust_map),
("beg", start_date),
("end", end_date),
("secid", &format!("{market_id}.{symbol}")),
])
.send()
.await
.map_err(Error::from)?
.error_for_status()
.map_err(Error::from)?;
let payload: serde_json::Value = response.json().await.map_err(Error::from)?;
let klines = payload
.get("data")
.and_then(|d| d.get("klines"))
.and_then(|k| k.as_array())
.ok_or_else(|| Error::not_found(format!("no kline data for ETF {symbol}")))?;
let mut candles = Vec::new();
for kline in klines {
let s = kline.as_str().unwrap_or("");
let fields: Vec<&str> = s.split(',').collect();
if fields.len() < 11 {
continue;
}
candles.push(CandlePoint {
trade_date: fields[0].to_string(),
open: parse_f64_safe(fields[1]),
close: parse_f64_safe(fields[2]),
high: parse_f64_safe(fields[3]),
low: parse_f64_safe(fields[4]),
volume: fields[5].parse().unwrap_or(0),
amount: parse_f64_safe(fields[6]),
amplitude_pct: parse_f64_safe(fields[7]),
change_pct: parse_f64_safe(fields[8]),
change_amount: parse_f64_safe(fields[9]),
turnover_pct: parse_f64_safe(fields[10]),
});
}
Ok(candles)
}
pub async fn fund_etf_hist_min_em(
&self,
symbol: &str,
period: &str,
start_date: &str,
end_date: &str,
adjust: &str,
) -> Result<Vec<CandlePoint>> {
let adjust_map = match adjust {
"qfq" => "1",
"hfq" => "2",
"" | "none" => "0",
_ => {
return Err(Error::invalid_input(format!(
"unsupported adjust: {adjust}"
)));
}
};
let market_id = etf_market_id(symbol);
if period == "1" {
let response = self
.get("https://push2his.eastmoney.com/api/qt/stock/trends2/get")
.query(&[
("fields1", "f1,f2,f3,f4,f5,f6,f7,f8,f9,f10,f11,f12,f13"),
("fields2", "f51,f52,f53,f54,f55,f56,f57,f58"),
("ut", "7eea3edcaed734bea9cbfc24409ed989"),
("ndays", "5"),
("iscr", "0"),
("secid", &format!("{market_id}.{symbol}")),
])
.send()
.await
.map_err(Error::from)?
.error_for_status()
.map_err(Error::from)?;
let payload: serde_json::Value = response.json().await.map_err(Error::from)?;
let trends = payload
.get("data")
.and_then(|d| d.get("trends"))
.and_then(|t| t.as_array())
.ok_or_else(|| Error::not_found(format!("no trend data for ETF {symbol}")))?;
let mut candles = Vec::new();
for trend in trends {
let s = trend.as_str().unwrap_or("");
let fields: Vec<&str> = s.split(',').collect();
if fields.len() < 8 {
continue;
}
let dt = fields[0];
if dt < start_date || dt > end_date {
continue;
}
candles.push(CandlePoint {
trade_date: dt.to_string(),
open: parse_f64_safe(fields[1]),
close: parse_f64_safe(fields[2]),
high: parse_f64_safe(fields[3]),
low: parse_f64_safe(fields[4]),
volume: fields[5].parse().unwrap_or(0),
amount: parse_f64_safe(fields[6]),
amplitude_pct: 0.0,
change_pct: 0.0,
change_amount: 0.0,
turnover_pct: 0.0,
});
}
Ok(candles)
} else {
let response = self
.get("https://push2his.eastmoney.com/api/qt/stock/kline/get")
.query(&[
("fields1", "f1,f2,f3,f4,f5,f6"),
("fields2", "f51,f52,f53,f54,f55,f56,f57,f58,f59,f60,f61"),
("ut", "7eea3edcaed734bea9cbfc24409ed989"),
("klt", period),
("fqt", adjust_map),
("secid", &format!("{market_id}.{symbol}")),
("beg", "0"),
("end", "20500000"),
])
.send()
.await
.map_err(Error::from)?
.error_for_status()
.map_err(Error::from)?;
let payload: serde_json::Value = response.json().await.map_err(Error::from)?;
let klines = payload
.get("data")
.and_then(|d| d.get("klines"))
.and_then(|k| k.as_array())
.ok_or_else(|| {
Error::not_found(format!("no minute kline data for ETF {symbol}"))
})?;
let mut candles = Vec::new();
for kline in klines {
let s = kline.as_str().unwrap_or("");
let fields: Vec<&str> = s.split(',').collect();
if fields.len() < 11 {
continue;
}
let dt = fields[0];
if dt < start_date || dt > end_date {
continue;
}
candles.push(CandlePoint {
trade_date: dt.to_string(),
open: parse_f64_safe(fields[1]),
close: parse_f64_safe(fields[2]),
high: parse_f64_safe(fields[3]),
low: parse_f64_safe(fields[4]),
volume: fields[5].parse().unwrap_or(0),
amount: parse_f64_safe(fields[6]),
amplitude_pct: parse_f64_safe(fields[7]),
change_pct: parse_f64_safe(fields[8]),
change_amount: parse_f64_safe(fields[9]),
turnover_pct: parse_f64_safe(fields[10]),
});
}
Ok(candles)
}
}
pub async fn fund_etf_spot_em(&self) -> Result<Vec<EtfSpotItem>> {
let response = self
.get("https://push2delay.eastmoney.com/api/qt/clist/get")
.query(&[
("pn", "1"),
("pz", "10000"),
("po", "1"),
("np", "1"),
("ut", "bd1d9ddb04089700cf9c27f6f7426281"),
("fltt", "2"),
("invt", "2"),
("fid", "f12"),
("fs", "b:MK0021,b:MK0022,b:MK0023,b:MK0024,b:MK0827"),
(
"fields",
"f2,f3,f4,f5,f6,f7,f12,f14,f15,f16,f17,f18,f20,f21,f38,f62,f184,f402,f441,f297",
),
])
.send()
.await
.map_err(Error::from)?
.error_for_status()
.map_err(Error::from)?;
let payload: serde_json::Value = response.json().await.map_err(Error::from)?;
let items = payload
.get("data")
.and_then(|d| d.get("diff"))
.and_then(|d| d.as_array())
.cloned()
.unwrap_or_default();
let result: Vec<EtfSpotItem> = items
.into_iter()
.filter_map(|v| {
Some(EtfSpotItem {
code: v.get("f12")?.as_str()?.to_string(),
name: v
.get("f14")
.and_then(|x| x.as_str())
.unwrap_or("")
.to_string(),
latest_price: v.get("f2").and_then(|x| x.as_f64()).unwrap_or(0.0),
change_pct: v.get("f3").and_then(|x| x.as_f64()).unwrap_or(0.0),
change_amount: v.get("f4").and_then(|x| x.as_f64()).unwrap_or(0.0),
volume: v.get("f5").and_then(|x| x.as_f64()).unwrap_or(0.0),
amount: v.get("f6").and_then(|x| x.as_f64()).unwrap_or(0.0),
open: v.get("f17").and_then(|x| x.as_f64()).unwrap_or(0.0),
high: v.get("f15").and_then(|x| x.as_f64()).unwrap_or(0.0),
low: v.get("f16").and_then(|x| x.as_f64()).unwrap_or(0.0),
prev_close: v.get("f18").and_then(|x| x.as_f64()).unwrap_or(0.0),
amplitude: v.get("f7").and_then(|x| x.as_f64()).unwrap_or(0.0),
turnover_rate: v.get("f38").and_then(|x| x.as_f64()).unwrap_or(0.0),
iopv: v.get("f441").and_then(|x| x.as_f64()).unwrap_or(0.0),
discount_rate: v.get("f402").and_then(|x| x.as_f64()).unwrap_or(0.0),
shares: v.get("f38").and_then(|x| x.as_f64()).unwrap_or(0.0),
circ_mv: v.get("f21").and_then(|x| x.as_f64()).unwrap_or(0.0),
total_mv: v.get("f20").and_then(|x| x.as_f64()).unwrap_or(0.0),
data_date: v
.get("f297")
.and_then(|x| x.as_str())
.unwrap_or("")
.to_string(),
})
})
.collect();
if result.is_empty() {
return Err(Error::not_found("no ETF spot data"));
}
Ok(result)
}
pub async fn fund_etf_fund_info_em(
&self,
fund: &str,
start_date: &str,
end_date: &str,
) -> Result<Vec<FundNavHistory>> {
let sd = format!(
"{}-{}-{}",
&start_date[0..4],
&start_date[4..6],
&start_date[6..8]
);
let ed = format!(
"{}-{}-{}",
&end_date[0..4],
&end_date[4..6],
&end_date[6..8]
);
let response = self
.get("https://api.fund.eastmoney.com/f10/lsjz")
.header(
"Referer",
format!("https://fundf10.eastmoney.com/jjjz_{fund}.html"),
)
.query(&[
("fundCode", fund),
("pageIndex", "1"),
("pageSize", "10000"),
("startDate", sd.as_str()),
("endDate", ed.as_str()),
])
.send()
.await
.map_err(Error::from)?
.error_for_status()
.map_err(Error::from)?;
let payload: serde_json::Value = response.json().await.map_err(Error::from)?;
let list = payload
.get("Data")
.and_then(|d| d.get("LSJZList"))
.and_then(|l| l.as_array())
.ok_or_else(|| Error::not_found(format!("no NAV data for fund {fund}")))?;
let mut result = Vec::new();
for item in list {
let arr = item.as_array();
if let Some(arr) = arr {
if arr.len() < 10 {
continue;
}
result.push(FundNavHistory {
date: arr[0].as_str().unwrap_or("").to_string(),
nav: arr[1].as_str().unwrap_or("0").parse().unwrap_or(0.0),
acc_nav: arr[2].as_str().unwrap_or("0").parse().unwrap_or(0.0),
change_pct: arr[6].as_str().unwrap_or("0").parse().unwrap_or(0.0),
subscribe_status: arr[7].as_str().unwrap_or("").to_string(),
redeem_status: arr[8].as_str().unwrap_or("").to_string(),
});
}
}
if result.is_empty() {
return Err(Error::not_found(format!("no NAV data for fund {fund}")));
}
result.sort_by(|a, b| a.date.cmp(&b.date));
Ok(result)
}
pub async fn fund_etf_scale_sse(&self, date: &str) -> Result<Vec<EtfScaleItem>> {
let data_str = format!("{}-{}-{}", &date[0..4], &date[4..6], &date[6..8]);
let response = self
.get("https://query.sse.com.cn/commonQuery.do")
.header("Referer", "https://www.sse.com.cn/")
.query(&[
("isPagination", "true"),
("pageHelp.pageSize", "10000"),
("pageHelp.pageNo", "1"),
("pageHelp.beginPage", "1"),
("pageHelp.cacheSize", "1"),
("pageHelp.endPage", "1"),
("sqlId", "COMMON_SSE_ZQPZ_ETFZL_XXPL_ETFGM_SEARCH_L"),
("STAT_DATE", data_str.as_str()),
])
.send()
.await
.map_err(Error::from)?
.error_for_status()
.map_err(Error::from)?;
let payload: serde_json::Value = response.json().await.map_err(Error::from)?;
let items = payload
.get("result")
.and_then(|r| r.as_array())
.ok_or_else(|| Error::not_found("no SSE ETF scale data"))?;
let mut result = Vec::new();
for (i, item) in items.iter().enumerate() {
result.push(EtfScaleItem {
rank: (i + 1) as i32,
fund_code: item
.get("SEC_CODE")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
fund_name: item
.get("SEC_NAME")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
etf_type: item
.get("ETF_TYPE")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
stat_date: item
.get("STAT_DATE")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
shares: item.get("TOT_VOL").and_then(|v| v.as_f64()).unwrap_or(0.0) * 10000.0,
});
}
if result.is_empty() {
return Err(Error::not_found("no SSE ETF scale data"));
}
Ok(result)
}
pub async fn fund_etf_scale_szse(&self) -> Result<Vec<serde_json::Value>> {
Err(Error::decode(
"SZSE ETF scale data requires xlsx parsing (not supported in pure Rust)",
))
}
pub async fn fund_etf_category_ths(
&self,
symbol: &str,
date: &str,
) -> Result<Vec<serde_json::Value>> {
let symbol_map: &[(&str, &str)] = &[
("股票型", "gpx"),
("债券型", "zqx"),
("混合型", "hhx"),
("ETF", "ETF"),
("LOF", "LOF"),
("QDII", "QDII"),
("保本型", "bbx"),
("指数型", "zsx"),
("", "all"),
];
let inner_symbol = symbol_map
.iter()
.find(|(n, _)| *n == symbol)
.map(|(_, c)| *c)
.unwrap_or("ETF");
let inner_date = if !date.is_empty() && date.len() >= 8 {
format!("{}-{}-{}", &date[0..4], &date[4..6], &date[6..8])
} else {
"0".to_string()
};
let url = format!(
"https://fund.10jqka.com.cn/data/Net/info/{}_rate_desc_{}_0_1_9999_0_0_0_jsonp_g.html",
inner_symbol, inner_date
);
let response = self
.get(&url)
.send()
.await
.map_err(Error::from)?
.error_for_status()
.map_err(Error::from)?;
let text = response.text().await.map_err(Error::from)?;
let json_str = text
.strip_prefix("g(")
.and_then(|s| s.strip_suffix(')'))
.unwrap_or(&text);
let root: serde_json::Value = serde_json::from_str(json_str)
.map_err(|e| Error::decode(format!("THS JSON parse: {e}")))?;
let data = root
.get("data")
.and_then(|d| d.get("data"))
.ok_or_else(|| Error::not_found("THS fund category data missing"))?;
let items: Vec<serde_json::Value> = if let Some(arr) = data.as_array() {
arr.clone()
} else if let Some(obj) = data.as_object() {
obj.values().cloned().collect()
} else {
return Err(Error::decode("unexpected THS data format"));
};
if items.is_empty() {
return Err(Error::not_found("no THS fund category data"));
}
Ok(items)
}
pub async fn fund_etf_spot_ths(&self, date: &str) -> Result<Vec<serde_json::Value>> {
self.fund_etf_category_ths("ETF", date).await
}
pub async fn fund_etf_dividend_sina(&self, symbol: &str) -> Result<Vec<serde_json::Value>> {
let factor_url = format!(
"https://finance.sina.com.cn/realstock/company/{}/hfq.js",
symbol
);
let response = self
.get(&factor_url)
.send()
.await
.map_err(Error::from)?
.error_for_status()
.map_err(Error::from)?;
let text = response.text().await.map_err(Error::from)?;
if !text.starts_with("var") {
return Err(Error::not_found(format!("no dividend data for {symbol}")));
}
let json_start = text.find('{').unwrap_or(0);
let json_end = text.rfind('}').map(|i| i + 1).unwrap_or(text.len());
let json_str = &text[json_start..json_end];
let root: serde_json::Value = serde_json::from_str(json_str)
.map_err(|e| Error::decode(format!("Sina dividend JSON parse: {e}")))?;
let data = root
.get("data")
.and_then(|d| d.as_array())
.ok_or_else(|| Error::not_found(format!("no dividend data for {symbol}")))?;
let mut result = Vec::new();
for item in data {
if let Some(arr) = item.as_array() {
if arr.len() >= 4 {
let date = arr[0].as_str().unwrap_or("");
if date == "1900-01-01" {
continue;
}
result.push(serde_json::json!({
"date": date,
"cumulative_dividend": arr[3].as_f64().unwrap_or(0.0),
}));
}
}
}
if result.is_empty() {
return Err(Error::not_found(format!("no dividend data for {symbol}")));
}
Ok(result)
}
}