use serde::Deserialize;
use crate::client::AkShareClient;
use crate::error::{Error, Result};
use crate::types::{CandlePoint, ReitSnapshot};
use crate::util::{parse_csv_line, parse_f64_safe, today_iso};
#[derive(Debug, Deserialize)]
struct ClistEnvelope {
data: Option<ClistData>,
}
#[derive(Debug, Deserialize)]
struct ClistData {
diff: Option<Vec<ClistItem>>,
}
#[derive(Debug, Deserialize)]
struct ClistItem {
#[serde(rename = "f12")]
code: Option<String>,
#[serde(rename = "f14")]
name: Option<String>,
#[serde(rename = "f2")]
price: Option<f64>,
#[serde(rename = "f3")]
change_pct: Option<f64>,
#[serde(rename = "f5")]
volume: Option<f64>,
}
#[derive(Debug, Deserialize)]
struct KlineEnvelope {
data: Option<KlineData>,
}
#[derive(Debug, Deserialize)]
struct KlineData {
klines: Option<Vec<String>>,
}
impl AkShareClient {
pub async fn reits_list(&self, limit: usize) -> Result<Vec<ReitSnapshot>> {
let pz = limit.clamp(1, 200).to_string();
let today = today_iso();
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"),
("fid", "f3"),
("fs", "b:MK0970"),
("fields", "f12,f14,f2,f3,f5"),
])
.send()
.await
.map_err(Error::from)?
.error_for_status()
.map_err(Error::from)?;
let payload: ClistEnvelope = response.json().await.map_err(Error::from)?;
let items = payload
.data
.and_then(|d| d.diff)
.unwrap_or_default()
.into_iter()
.filter_map(|item| {
let code = item.code?;
if code.is_empty() {
return None;
}
Some(ReitSnapshot {
symbol: code,
name: item.name.unwrap_or_else(|| "未知REIT".to_string()),
date: today.clone(),
close: item.price.unwrap_or(0.0),
change_pct: item.change_pct.unwrap_or(0.0),
volume: item.volume.unwrap_or(0.0),
nav: None,
})
})
.collect::<Vec<_>>();
if items.is_empty() {
return Err(Error::not_found("eastmoney returned no REITs"));
}
Ok(items)
}
pub async fn reits_hist_em(
&self,
symbol: &str,
period: &str,
_start_date: &str,
_end_date: &str,
_adjust: &str,
) -> Result<Vec<CandlePoint>> {
let klt = match period {
"weekly" => "102",
"monthly" => "103",
_ => "101",
};
let secid = reits_eastmoney_secid(symbol)?;
let response = self
.get("https://push2his.eastmoney.com/api/qt/stock/kline/get")
.query(&[
("secid", secid.as_str()),
("ut", "fa5fd1943c7b386f172d6893dbfba10b"),
("klt", klt),
("fqt", "1"),
("lmt", "500"),
("end", "20500000"),
("fields1", "f1,f2,f3,f4,f5,f6"),
("fields2", "f51,f52,f53,f54,f55,f56,f57,f58,f59,f60,f61"),
])
.send()
.await
.map_err(Error::from)?
.error_for_status()
.map_err(Error::from)?;
let payload: KlineEnvelope = response.json().await.map_err(Error::from)?;
let data = payload
.data
.ok_or_else(|| Error::upstream("eastmoney REIT kline response missing data"))?;
let klines = data
.klines
.ok_or_else(|| Error::upstream("eastmoney REIT kline response missing klines"))?;
let items: Vec<CandlePoint> = klines
.iter()
.map(|line| parse_reit_candle_line(line))
.collect::<Result<Vec<_>>>()?;
if items.is_empty() {
return Err(Error::not_found("eastmoney returned no REIT kline items"));
}
Ok(items)
}
pub async fn reits_hist_min_em(&self, symbol: &str, period: &str) -> Result<Vec<CandlePoint>> {
let secid = reits_eastmoney_secid(symbol)?;
let response = self
.get("https://push2his.eastmoney.com/api/qt/stock/kline/get")
.query(&[
("secid", secid.as_str()),
("ut", "fa5fd1943c7b386f172d6893dbfba10b"),
("klt", period),
("fqt", "1"),
("lmt", "500"),
("end", "20500000"),
("fields1", "f1,f2,f3,f4,f5,f6"),
("fields2", "f51,f52,f53,f54,f55,f56,f57,f58,f59,f60,f61"),
])
.send()
.await
.map_err(Error::from)?
.error_for_status()
.map_err(Error::from)?;
let payload: KlineEnvelope = response.json().await.map_err(Error::from)?;
let data = payload
.data
.ok_or_else(|| Error::upstream("eastmoney REIT min kline missing data"))?;
let klines = data
.klines
.ok_or_else(|| Error::upstream("eastmoney REIT min kline missing klines"))?;
let items: Vec<CandlePoint> = klines
.iter()
.map(|line| parse_reit_candle_line(line))
.collect::<Result<Vec<_>>>()?;
if items.is_empty() {
return Err(Error::not_found(
"eastmoney returned no REIT min kline items",
));
}
Ok(items)
}
pub async fn reits_realtime_em(&self) -> Result<Vec<ReitSnapshot>> {
self.reits_list(200).await
}
pub async fn reits_hist(&self, symbol: &str, limit: usize) -> Result<Vec<CandlePoint>> {
let secid = reits_eastmoney_secid(symbol)?;
let lmt = limit.max(5).to_string();
let response = self
.get("https://push2his.eastmoney.com/api/qt/stock/kline/get")
.query(&[
("secid", secid.as_str()),
("ut", "fa5fd1943c7b386f172d6893dbfba10b"),
("klt", "101"),
("fqt", "1"),
("lmt", lmt.as_str()),
("end", "20500000"),
("fields1", "f1,f2,f3,f4,f5,f6"),
("fields2", "f51,f52,f53,f54,f55,f56,f57,f58,f59,f60,f61"),
])
.send()
.await
.map_err(Error::from)?
.error_for_status()
.map_err(Error::from)?;
let payload: KlineEnvelope = response.json().await.map_err(Error::from)?;
let data = payload
.data
.ok_or_else(|| Error::upstream("eastmoney REIT kline response missing data"))?;
let klines = data
.klines
.ok_or_else(|| Error::upstream("eastmoney REIT kline response missing klines"))?;
let mut items: Vec<CandlePoint> = klines
.iter()
.map(|line| parse_reit_candle_line(line))
.collect::<Result<Vec<_>>>()?;
if items.is_empty() {
return Err(Error::not_found("eastmoney returned no REIT kline items"));
}
items.sort_by(|a, b| a.trade_date.cmp(&b.trade_date));
if items.len() > limit {
let start = items.len() - limit;
items = items[start..].to_vec();
}
Ok(items)
}
}
fn reits_eastmoney_secid(symbol: &str) -> Result<String> {
let trimmed = symbol.trim();
if let Some((code, suffix)) = trimmed.split_once('.') {
let suffix_upper = suffix.to_uppercase();
let market = match suffix_upper.as_str() {
"SH" => "1",
"SZ" => "0",
_ => {
return Err(Error::invalid_input(format!(
"unsupported REIT exchange: {suffix}"
)));
}
};
return Ok(format!("{market}.{code}"));
}
if trimmed.is_empty() {
return Err(Error::invalid_input("REIT symbol is empty"));
}
let market = if trimmed.starts_with('5') || trimmed.starts_with('6') {
"1" } else {
"0" };
Ok(format!("{market}.{trimmed}"))
}
fn parse_reit_candle_line(line: &str) -> Result<CandlePoint> {
let f = parse_csv_line(line);
if f.len() < 11 {
return Err(Error::decode(format!(
"unexpected eastmoney REIT kline format: {line}"
)));
}
Ok(CandlePoint {
trade_date: f[0].to_string(),
open: parse_f64_safe(f[1]),
close: parse_f64_safe(f[2]),
high: parse_f64_safe(f[3]),
low: parse_f64_safe(f[4]),
volume: parse_f64_safe(f[5]).round() as i64,
amount: parse_f64_safe(f[6]),
amplitude_pct: parse_f64_safe(f[7]),
change_pct: parse_f64_safe(f[8]),
change_amount: parse_f64_safe(f[9]),
turnover_pct: parse_f64_safe(f[10]),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_reits_eastmoney_secid_sh() {
assert_eq!(reits_eastmoney_secid("508000").unwrap(), "1.508000");
assert_eq!(reits_eastmoney_secid("508000.SH").unwrap(), "1.508000");
}
#[test]
fn test_reits_eastmoney_secid_sz() {
assert_eq!(reits_eastmoney_secid("180201").unwrap(), "0.180201");
assert_eq!(reits_eastmoney_secid("180201.SZ").unwrap(), "0.180201");
}
#[test]
fn test_reits_eastmoney_secid_empty() {
assert!(reits_eastmoney_secid("").is_err());
}
#[test]
fn test_parse_reit_candle_line() {
let line = "2025-01-02,3.500,3.550,3.600,3.480,50000,177500.00,3.43,1.43,0.050,0.12";
let point = parse_reit_candle_line(line).unwrap();
assert_eq!(point.trade_date, "2025-01-02");
assert!((point.open - 3.50).abs() < 0.001);
assert!((point.close - 3.55).abs() < 0.001);
assert!((point.high - 3.60).abs() < 0.001);
assert!((point.low - 3.48).abs() < 0.001);
assert_eq!(point.volume, 50000);
}
#[test]
fn test_parse_reit_candle_line_insufficient_fields() {
let line = "2025-01-02,3.500,3.550";
assert!(parse_reit_candle_line(line).is_err());
}
}