use crate::client::AkShareClient;
use crate::error::{Error, Result};
use serde::{Deserialize, Serialize};
#[derive(Debug, Deserialize)]
struct KlineEnvelope {
data: Option<KlineData>,
}
#[derive(Debug, Deserialize)]
struct KlineData {
klines: Option<Vec<String>>,
}
#[derive(Debug, Deserialize)]
struct StockGetEnvelope {
data: Option<serde_json::Value>,
}
#[derive(Debug, Deserialize)]
struct TrendsEnvelope {
data: Option<TrendsData>,
}
#[derive(Debug, Deserialize)]
struct TrendsData {
trends: Option<Vec<String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BoardHistPoint {
pub date: String,
pub open: f64,
pub close: f64,
pub high: f64,
pub low: f64,
pub volume: f64,
pub amount: f64,
pub amplitude_pct: f64,
pub change_pct: f64,
pub change_amount: f64,
pub turnover_rate: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BoardMinutePoint {
pub datetime: String,
pub open: f64,
pub close: f64,
pub high: f64,
pub low: f64,
pub volume: f64,
pub amount: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BoardSpotItem {
pub item: String,
pub value: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BoardChangeRow {
pub code: String,
pub name: String,
#[serde(default)]
pub latest_price: Option<f64>,
#[serde(default)]
pub change_pct: Option<f64>,
#[serde(default)]
pub change_amount: Option<f64>,
#[serde(default)]
pub volume: Option<f64>,
#[serde(default)]
pub amount: Option<f64>,
#[serde(default)]
pub turnover_rate: Option<f64>,
#[serde(default)]
pub main_net_inflow: Option<f64>,
#[serde(default)]
pub main_net_inflow_ratio: Option<f64>,
}
impl AkShareClient {
pub async fn stock_board_concept_hist_em(
&self,
symbol: &str,
period: &str,
start_date: &str,
end_date: &str,
adjust: &str,
) -> Result<Vec<BoardHistPoint>> {
let secid = self.resolve_board_secid(symbol, "concept").await?;
let klt = match period {
"daily" => "101",
"weekly" => "102",
"monthly" => "103",
_ => return Err(Error::invalid_input(format!("invalid period: {period}"))),
};
let fqt = match adjust {
"" => "0",
"qfq" => "1",
"hfq" => "2",
_ => return Err(Error::invalid_input(format!("invalid adjust: {adjust}"))),
};
self.fetch_board_kline(&secid, klt, fqt, start_date, end_date)
.await
}
pub async fn stock_board_concept_hist_min_em(
&self,
symbol: &str,
period: &str,
) -> Result<Vec<BoardMinutePoint>> {
let secid = self.resolve_board_secid(symbol, "concept").await?;
self.fetch_board_minute_kline(&secid, period).await
}
pub async fn stock_board_concept_spot_em(&self, symbol: &str) -> Result<Vec<BoardSpotItem>> {
let secid = self.resolve_board_secid(symbol, "concept").await?;
self.fetch_board_spot(&secid).await
}
pub async fn stock_board_industry_hist_em(
&self,
symbol: &str,
period: &str,
start_date: &str,
end_date: &str,
adjust: &str,
) -> Result<Vec<BoardHistPoint>> {
let secid = self.resolve_board_secid(symbol, "industry").await?;
let klt = match period {
"daily" => "101",
"weekly" => "102",
"monthly" => "103",
_ => return Err(Error::invalid_input(format!("invalid period: {period}"))),
};
let fqt = match adjust {
"" => "0",
"qfq" => "1",
"hfq" => "2",
_ => return Err(Error::invalid_input(format!("invalid adjust: {adjust}"))),
};
self.fetch_board_kline(&secid, klt, fqt, start_date, end_date)
.await
}
pub async fn stock_board_industry_hist_min_em(
&self,
symbol: &str,
period: &str,
) -> Result<Vec<BoardMinutePoint>> {
let secid = self.resolve_board_secid(symbol, "industry").await?;
self.fetch_board_minute_kline(&secid, period).await
}
pub async fn stock_board_industry_spot_em(&self, symbol: &str) -> Result<Vec<BoardSpotItem>> {
let secid = self.resolve_board_secid(symbol, "industry").await?;
self.fetch_board_spot(&secid).await
}
pub async fn stock_board_change_em(&self, symbol: &str) -> Result<Vec<BoardChangeRow>> {
#[derive(Deserialize)]
struct Env {
data: Option<EnvData>,
}
#[derive(Deserialize)]
struct EnvData {
diff: Option<Vec<serde_json::Value>>,
}
let fs = match symbol {
"行业板块" => "m:90 t:2 f:!50",
"概念板块" => "m:90 t:3 f:!50",
_ => symbol,
};
let response = self
.get("https://push2.eastmoney.com/api/qt/clist/get")
.query(&[
("pn", "1"),
("pz", "100"),
("po", "1"),
("np", "1"),
("ut", "bd1d9ddb04089700cf9c27f6f7426281"),
("fltt", "2"),
("invt", "2"),
("fid", "f62"),
("fs", fs),
("fields", "f2,f3,f4,f5,f6,f8,f12,f14,f62,f184"),
])
.send()
.await
.map_err(Error::from)?
.error_for_status()
.map_err(Error::from)?;
let payload: Env = response.json().await.map_err(Error::from)?;
let diff = payload
.data
.and_then(|d| d.diff)
.ok_or_else(|| Error::upstream("eastmoney board change missing data"))?;
let items: Vec<BoardChangeRow> = diff
.iter()
.filter_map(|item| {
let code = item.get("f12")?.as_str()?.to_string();
let name = item.get("f14")?.as_str()?.to_string();
Some(BoardChangeRow {
code,
name,
latest_price: item.get("f2").and_then(serde_json::Value::as_f64),
change_pct: item.get("f3").and_then(serde_json::Value::as_f64),
change_amount: item.get("f4").and_then(serde_json::Value::as_f64),
volume: item.get("f5").and_then(serde_json::Value::as_f64),
amount: item.get("f6").and_then(serde_json::Value::as_f64),
turnover_rate: item.get("f8").and_then(serde_json::Value::as_f64),
main_net_inflow: item.get("f62").and_then(serde_json::Value::as_f64),
main_net_inflow_ratio: item.get("f184").and_then(serde_json::Value::as_f64),
})
})
.collect();
if items.is_empty() {
return Err(Error::not_found("eastmoney returned no board change items"));
}
Ok(items)
}
async fn resolve_board_secid(&self, symbol: &str, board_type: &str) -> Result<String> {
#[derive(Deserialize)]
struct Env {
data: Option<EnvData>,
}
#[derive(Deserialize)]
struct EnvData {
diff: Option<Vec<serde_json::Value>>,
}
if symbol.starts_with("BK") {
return Ok(format!("90.{symbol}"));
}
let fs = match board_type {
"concept" => "m:90 t:3 f:!50",
"industry" => "m:90 t:2 f:!50",
_ => {
return Err(Error::invalid_input(format!(
"invalid board type: {board_type}"
)));
}
};
let response = self
.get("https://push2.eastmoney.com/api/qt/clist/get")
.query(&[
("pn", "1"),
("pz", "5000"),
("po", "1"),
("np", "1"),
("ut", "bd1d9ddb04089700cf9c27f6f7426281"),
("fltt", "2"),
("invt", "2"),
("fid", "f12"),
("fs", fs),
("fields", "f12,f14"),
])
.send()
.await
.map_err(Error::from)?
.error_for_status()
.map_err(Error::from)?;
let payload: Env = response.json().await.map_err(Error::from)?;
let diff = payload
.data
.and_then(|d| d.diff)
.ok_or_else(|| Error::upstream("eastmoney board list missing data"))?;
for item in &diff {
let name = item.get("f14").and_then(|v| v.as_str()).unwrap_or("");
if name == symbol {
let code = item.get("f12").and_then(|v| v.as_str()).ok_or_else(|| {
Error::not_found(format!("board code not found for: {symbol}"))
})?;
return Ok(format!("90.{code}"));
}
}
Err(Error::not_found(format!("board not found: {symbol}")))
}
async fn fetch_board_kline(
&self,
secid: &str,
klt: &str,
fqt: &str,
beg: &str,
end: &str,
) -> Result<Vec<BoardHistPoint>> {
let response = self
.get("https://push2his.eastmoney.com/api/qt/stock/kline/get")
.query(&[
("secid", secid),
("fields1", "f1,f2,f3,f4,f5,f6"),
("fields2", "f51,f52,f53,f54,f55,f56,f57,f58,f59,f60,f61"),
("klt", klt),
("fqt", fqt),
("beg", beg),
("end", end),
("smplmt", "10000"),
("lmt", "1000000"),
])
.send()
.await
.map_err(Error::from)?
.error_for_status()
.map_err(Error::from)?;
let payload: KlineEnvelope = response.json().await.map_err(Error::from)?;
let klines = payload
.data
.and_then(|d| d.klines)
.ok_or_else(|| Error::upstream("board kline missing data"))?;
let items: Vec<BoardHistPoint> = klines
.iter()
.filter_map(|line| {
let parts: Vec<&str> = line.split(',').collect();
if parts.len() < 11 {
return None;
}
Some(BoardHistPoint {
date: parts[0].to_string(),
open: parts[1].parse().unwrap_or(0.0),
close: parts[2].parse().unwrap_or(0.0),
high: parts[3].parse().unwrap_or(0.0),
low: parts[4].parse().unwrap_or(0.0),
volume: parts[5].parse().unwrap_or(0.0),
amount: parts[6].parse().unwrap_or(0.0),
amplitude_pct: parts[7].parse().unwrap_or(0.0),
change_pct: parts[8].parse().unwrap_or(0.0),
change_amount: parts[9].parse().unwrap_or(0.0),
turnover_rate: parts[10].parse().unwrap_or(0.0),
})
})
.collect();
if items.is_empty() {
return Err(Error::not_found("board kline returned no data"));
}
Ok(items)
}
async fn fetch_board_minute_kline(
&self,
secid: &str,
period: &str,
) -> Result<Vec<BoardMinutePoint>> {
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"),
("iscr", "0"),
("ndays", "1"),
("secid", secid),
])
.send()
.await
.map_err(Error::from)?
.error_for_status()
.map_err(Error::from)?;
let payload: TrendsEnvelope = response.json().await.map_err(Error::from)?;
let trends = payload
.data
.and_then(|d| d.trends)
.ok_or_else(|| Error::upstream("board trends missing data"))?;
let items: Vec<BoardMinutePoint> = trends
.iter()
.filter_map(|line| {
let parts: Vec<&str> = line.split(',').collect();
if parts.len() < 8 {
return None;
}
Some(BoardMinutePoint {
datetime: parts[0].to_string(),
open: parts[1].parse().unwrap_or(0.0),
close: parts[2].parse().unwrap_or(0.0),
high: parts[3].parse().unwrap_or(0.0),
low: parts[4].parse().unwrap_or(0.0),
volume: parts[5].parse().unwrap_or(0.0),
amount: parts[6].parse().unwrap_or(0.0),
})
})
.collect();
if items.is_empty() {
return Err(Error::not_found("board 1-min trends returned no data"));
}
Ok(items)
} else {
let response = self
.get("https://push2his.eastmoney.com/api/qt/stock/kline/get")
.query(&[
("secid", secid),
("fields1", "f1,f2,f3,f4,f5,f6"),
("fields2", "f51,f52,f53,f54,f55,f56,f57,f58,f59,f60,f61"),
("klt", period),
("fqt", "1"),
("end", "20500101"),
("lmt", "1000000"),
])
.send()
.await
.map_err(Error::from)?
.error_for_status()
.map_err(Error::from)?;
let payload: KlineEnvelope = response.json().await.map_err(Error::from)?;
let klines = payload
.data
.and_then(|d| d.klines)
.ok_or_else(|| Error::upstream("board minute kline missing data"))?;
let items: Vec<BoardMinutePoint> = klines
.iter()
.filter_map(|line| {
let parts: Vec<&str> = line.split(',').collect();
if parts.len() < 11 {
return None;
}
Some(BoardMinutePoint {
datetime: parts[0].to_string(),
open: parts[1].parse().unwrap_or(0.0),
close: parts[2].parse().unwrap_or(0.0),
high: parts[3].parse().unwrap_or(0.0),
low: parts[4].parse().unwrap_or(0.0),
volume: parts[5].parse().unwrap_or(0.0),
amount: parts[6].parse().unwrap_or(0.0),
})
})
.collect();
if items.is_empty() {
return Err(Error::not_found("board minute kline returned no data"));
}
Ok(items)
}
}
async fn fetch_board_spot(&self, secid: &str) -> Result<Vec<BoardSpotItem>> {
let field_map = [
("f43", "最新"),
("f44", "最高"),
("f45", "最低"),
("f46", "开盘"),
("f47", "成交量"),
("f48", "成交额"),
("f170", "涨跌幅"),
("f171", "振幅"),
("f168", "换手率"),
("f169", "涨跌额"),
];
let fields: String = field_map
.iter()
.map(|(f, _)| *f)
.collect::<Vec<_>>()
.join(",");
let response = self
.get("https://push2.eastmoney.com/api/qt/stock/get")
.query(&[
("fields", fields.as_str()),
("mpi", "1000"),
("invt", "2"),
("fltt", "1"),
("secid", secid),
])
.send()
.await
.map_err(Error::from)?
.error_for_status()
.map_err(Error::from)?;
let payload: StockGetEnvelope = response.json().await.map_err(Error::from)?;
let data = payload
.data
.ok_or_else(|| Error::upstream("board spot missing data"))?;
let obj = data
.as_object()
.ok_or_else(|| Error::decode("board spot data is not an object"))?;
let mut items = Vec::new();
for (field_key, label) in &field_map {
if let Some(val) = obj.get(*field_key).and_then(serde_json::Value::as_f64) {
let converted = if *field_key == "f47" || *field_key == "f48" {
val
} else {
val * 0.01
};
items.push(BoardSpotItem {
item: label.to_string(),
value: converted,
});
}
}
if items.is_empty() {
return Err(Error::not_found("board spot returned no data"));
}
Ok(items)
}
}