use serde::Deserialize;
use crate::client::AkShareClient;
use crate::error::{Error, Result};
use crate::types::{CniHistPoint, CniIndexItem};
#[derive(Debug, Deserialize)]
struct CniListEnvelope {
data: Option<CniListData>,
}
#[derive(Debug, Deserialize)]
struct CniListData {
rows: Option<Vec<Vec<serde_json::Value>>>,
}
#[derive(Debug, Deserialize)]
struct CniHistEnvelope {
data: Option<CniHistData>,
}
#[derive(Debug, Deserialize)]
struct CniHistData {
data: Option<Vec<Vec<serde_json::Value>>>,
}
impl AkShareClient {
pub async fn index_all_cni(&self) -> Result<Vec<CniIndexItem>> {
let response = self
.get("https://www.cnindex.com.cn/index/indexList")
.query(&[("channelCode", "-1"), ("rows", "2000"), ("pageNum", "1")])
.send()
.await
.map_err(Error::from)?
.error_for_status()
.map_err(Error::from)?;
let payload: CniListEnvelope = response.json().await.map_err(Error::from)?;
let rows = payload.data.and_then(|d| d.rows).unwrap_or_default();
let items: Vec<CniIndexItem> = rows
.into_iter()
.filter_map(|row| {
if row.len() < 25 {
return None;
}
let code = row[2].as_str()?.to_string();
let name = row[8].as_str().unwrap_or("").to_string();
let sample_count = row[12].as_f64().unwrap_or(0.0);
let close = row[13].as_f64().unwrap_or(0.0);
let change_pct = row[14].as_f64().unwrap_or(0.0);
let pe_ttm = row[16].as_f64().unwrap_or(0.0);
let volume = row[18].as_f64().unwrap_or(0.0) / 100_000.0;
let amount = row[19].as_f64().unwrap_or(0.0) / 100_000_000.0;
let total_mv = row[20].as_f64().unwrap_or(0.0) / 100_000_000.0;
let free_circ_mv = row[21].as_f64().unwrap_or(0.0) / 100_000_000.0;
Some(CniIndexItem {
code,
name,
sample_count,
close,
change_pct,
pe_ttm,
volume,
amount,
total_mv,
free_circ_mv,
})
})
.collect();
if items.is_empty() {
return Err(Error::not_found("cnindex returned no index items"));
}
Ok(items)
}
pub async fn index_hist_cni(
&self,
symbol: &str,
start_date: &str,
end_date: &str,
) -> Result<Vec<CniHistPoint>> {
let start = format_date_hyphen(start_date);
let end = format_date_hyphen(end_date);
let response = self
.get("http://hq.cnindex.com.cn/market/market/getIndexDailyDataWithDataFormat")
.query(&[
("indexCode", symbol),
("startDate", &start),
("endDate", &end),
("frequency", "day"),
])
.send()
.await
.map_err(Error::from)?
.error_for_status()
.map_err(Error::from)?;
let payload: CniHistEnvelope = response.json().await.map_err(Error::from)?;
let data = payload.data.and_then(|d| d.data).unwrap_or_default();
let mut points: Vec<CniHistPoint> = data
.into_iter()
.filter_map(|row| {
if row.len() < 11 {
return None;
}
let date = row[0].as_str()?.to_string();
let high = row[2].as_f64().unwrap_or(0.0);
let open = row[3].as_f64().unwrap_or(0.0);
let low = row[4].as_f64().unwrap_or(0.0);
let close = row[5].as_f64().unwrap_or(0.0);
let change_pct_raw = row[7].as_str().unwrap_or("0");
let change_pct = change_pct_raw
.trim_end_matches('%')
.parse::<f64>()
.unwrap_or(0.0)
/ 100.0;
let amount = row[8].as_f64().unwrap_or(0.0);
let volume = row[9].as_f64().unwrap_or(0.0);
Some(CniHistPoint {
date,
open,
high,
low,
close,
change_pct,
volume,
amount,
})
})
.collect();
points.sort_by(|a, b| a.date.cmp(&b.date));
if points.is_empty() {
return Err(Error::not_found("cnindex returned no history data"));
}
Ok(points)
}
pub async fn index_detail_cni(&self, symbol: &str) -> Result<Vec<serde_json::Value>> {
let response = self
.get("https://www.cnindex.com.cn/sample-detail/download-history")
.query(&[("indexcode", symbol)])
.send()
.await
.map_err(Error::from)?
.error_for_status()
.map_err(Error::from)?;
let content_type = response
.headers()
.get("content-type")
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_string();
if content_type.contains("json") {
let data: Vec<serde_json::Value> = response.json().await.map_err(Error::from)?;
Ok(data)
} else {
Err(Error::upstream(
"cnindex returns XLS format — not supported in pure Rust; \
use the JSON API or a third-party XLS parser",
))
}
}
pub async fn index_detail_hist_cni(
&self,
symbol: &str,
start_date: &str,
end_date: &str,
) -> Result<Vec<CniHistPoint>> {
self.index_hist_cni(symbol, start_date, end_date).await
}
pub async fn index_detail_hist_adjust_cni(
&self,
symbol: &str,
) -> Result<Vec<serde_json::Value>> {
let response = self
.get("http://www.cnindex.com.cn/sample-detail/download-adjustment")
.query(&[("indexcode", symbol)])
.send()
.await
.map_err(Error::from)?
.error_for_status()
.map_err(Error::from)?;
let content_type = response
.headers()
.get("content-type")
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_string();
if content_type.contains("json") {
let data: Vec<serde_json::Value> = response.json().await.map_err(Error::from)?;
Ok(data)
} else {
Err(Error::upstream(
"cnindex adjustment returns XLS format — not supported in pure Rust",
))
}
}
}
fn format_date_hyphen(d: &str) -> String {
let d = d.trim();
if d.len() == 8 && d.chars().all(|c| c.is_ascii_digit()) {
format!("{}-{}-{}", &d[..4], &d[4..6], &d[6..8])
} else {
d.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_format_date_hyphen() {
assert_eq!(format_date_hyphen("20230114"), "2023-01-14");
assert_eq!(format_date_hyphen("2023-01-14"), "2023-01-14");
}
}