akshare 0.1.9

100% pure Rust implementation of akshare — unified access to Chinese and global financial market data APIs
Documentation
use super::super::{CandlePoint, MarketDataClient, QuoteSnapshot};
use anyhow::{Context, bail};
use chrono::NaiveDate;
impl MarketDataClient {
    pub(crate) async fn fetch_hk_tencent_quote(
        &self,
        symbol: &str,
        compact_code: &str,
    ) -> anyhow::Result<QuoteSnapshot> {
        let market_symbol = format!("hk{compact_code}");
        let response = self
            .http
            .get("https://qt.gtimg.cn/q")
            .query(&[("q", market_symbol.as_str())])
            .header(reqwest::header::REFERER, "https://gu.qq.com/")
            .send()
            .await
            .context("failed to fetch HK quote from Tencent")?
            .error_for_status()
            .context("tencent HK quote request failed")?
            .text()
            .await
            .context("failed to read tencent HK quote response")?;
        Self::parse_hk_tencent_quote(symbol, &response)
    }

    fn parse_hk_tencent_quote(symbol: &str, raw: &str) -> anyhow::Result<QuoteSnapshot> {
        let fields = raw
            .split_once('"')
            .and_then(|(_, rest)| rest.rsplit_once('"').map(|(body, _)| body))
            .context("unexpected tencent HK quote response format")?
            .split('~')
            .collect::<Vec<_>>();
        if fields.len() < 40 {
            bail!("unexpected tencent HK quote field count");
        }
        let trade_date = normalize_hk_trade_date(fields.get(30).copied())?;
        QuoteResponseBuilder::new(symbol.to_uppercase(), trade_date)
            .open(fields.get(5).copied())
            .close(fields.get(3).copied())
            .high(fields.get(33).copied())
            .low(fields.get(34).copied())
            .volume(fields.get(6).copied())
            .build()
    }

    pub(crate) async fn fetch_hk_tencent_candles(
        &self,
        symbol: &str,
        limit: usize,
    ) -> anyhow::Result<Vec<CandlePoint>> {
        let market_symbol = format!("hk{}", self.hk_standard_code(symbol)?);
        let request_limit = limit.clamp(5, Self::HK_TENCENT_MAX_CANDLE_LIMIT);
        let response = self
            .http
            .get("https://web.ifzq.gtimg.cn/appstock/app/fqkline/get")
            .query(&[("param", format!("{market_symbol},day,,,{},", request_limit))])
            .send()
            .await
            .context("failed to fetch HK candles from Tencent")?
            .error_for_status()
            .context("tencent HK candle request failed")?
            .json::<serde_json::Value>()
            .await
            .context("failed to decode tencent HK candle response")?;

        let series = response
            .get("data")
            .and_then(|data| data.get(&market_symbol))
            .and_then(|item| item.get("day"))
            .and_then(|value| value.as_array())
            .context("tencent HK candle response missing day series")?;

        let mut items = series
            .iter()
            .map(Self::parse_hk_tencent_candle_row)
            .collect::<anyhow::Result<Vec<_>>>()?;
        if items.is_empty() {
            bail!("tencent HK candle response returned no rows");
        }
        items.sort_by(|left, right| left.trade_date.cmp(&right.trade_date));
        for index in 1..items.len() {
            let previous_close = items[index - 1].close;
            if previous_close > 0.0 {
                items[index].change_amount = items[index].close - previous_close;
                items[index].change_pct = (items[index].change_amount / previous_close) * 100.0;
            }
        }
        if items.len() > limit {
            let start_index = items.len() - limit;
            items = items[start_index..].to_vec();
        }
        Ok(items)
    }

    pub(crate) async fn fetch_hk_yahoo_quote(&self, symbol: &str) -> anyhow::Result<QuoteSnapshot> {
        let mut items = self.fetch_hk_yahoo_candles(symbol, 2).await?;
        let last = items
            .pop()
            .context("Yahoo Finance HK quote returned no rows")?;
        Ok(QuoteSnapshot {
            symbol: symbol.trim().to_uppercase(),
            date: last.trade_date,
            open: last.open,
            high: last.high,
            low: last.low,
            close: last.close,
            volume: last.volume,
        })
    }

    pub(crate) async fn fetch_hk_yahoo_candles(
        &self,
        symbol: &str,
        limit: usize,
    ) -> anyhow::Result<Vec<CandlePoint>> {
        let yahoo_symbol = self.hk_yahoo_symbol(symbol)?;
        let end = chrono::Utc::now().date_naive() + chrono::Days::new(1);
        let start = end - chrono::Days::new((limit.max(60) + 10) as u64);
        let mut items = self
            .fetch_us_chart_candles(&yahoo_symbol, start, end)
            .await?;
        if items.is_empty() {
            bail!("Yahoo Finance HK candles returned no rows");
        }
        if items.len() > limit {
            let start_index = items.len() - limit;
            items = items[start_index..].to_vec();
        }
        Ok(items)
    }

    pub(super) fn hk_yahoo_symbol(&self, symbol: &str) -> anyhow::Result<String> {
        let normalized = self
            .normalize_hk_symbol(symbol)
            .context("invalid HK symbol for candles")?;
        let code = normalized.trim_end_matches(".HK");
        let compact = code.trim_start_matches('0');
        let yahoo_code = if compact.is_empty() {
            "0000".to_string()
        } else {
            format!("{compact:0>4}")
        };
        Ok(format!("{yahoo_code}.HK"))
    }

    pub(super) async fn fetch_hk_tencent_snapshot(
        &self,
        symbol: &str,
    ) -> anyhow::Result<HkTencentSnapshot> {
        let compact = self.hk_standard_code(symbol)?;
        let market_symbol = format!("hk{compact}");
        let response = self
            .http
            .get("https://qt.gtimg.cn/q")
            .query(&[("q", market_symbol.as_str())])
            .header(reqwest::header::REFERER, "https://gu.qq.com/")
            .send()
            .await
            .context("failed to fetch HK quote snapshot from Tencent")?
            .error_for_status()
            .context("tencent HK snapshot request failed")?
            .text()
            .await
            .context("failed to read tencent HK snapshot response")?;
        Self::parse_hk_tencent_snapshot(&response)
    }

    pub(super) fn parse_hk_tencent_snapshot(raw: &str) -> anyhow::Result<HkTencentSnapshot> {
        let fields = raw
            .split_once('"')
            .and_then(|(_, rest)| rest.rsplit_once('"').map(|(body, _)| body))
            .context("unexpected tencent HK quote response format")?
            .split('~')
            .collect::<Vec<_>>();
        if fields.len() < 76 {
            bail!("unexpected tencent HK quote field count");
        }
        Ok(HkTencentSnapshot {
            name: fields[1].trim().to_string(),
            market_cap_hkd: fields
                .get(45)
                .and_then(|value| value.parse::<f64>().ok())
                .map(|value| value * 100_000_000.0),
            shares_outstanding: match (
                fields.get(3).and_then(|value| value.parse::<f64>().ok()),
                fields
                    .get(45)
                    .and_then(|value| value.parse::<f64>().ok())
                    .map(|value| value * 100_000_000.0),
            ) {
                (Some(close), Some(market_cap)) if close > 0.0 => {
                    Some((market_cap / close).round() as i64)
                }
                _ => None,
            },
            currency: fields
                .get(75)
                .map(|value| value.trim().to_string())
                .filter(|value| !value.is_empty()),
        })
    }

    fn parse_hk_tencent_candle_row(value: &serde_json::Value) -> anyhow::Result<CandlePoint> {
        let row = value
            .as_array()
            .context("unexpected tencent HK candle row format")?;
        if row.len() < 6 {
            bail!("unexpected tencent HK candle field count");
        }
        let trade_date = row[0]
            .as_str()
            .context("missing tencent HK candle trade_date")?
            .to_string();
        let open = row[1]
            .as_str()
            .context("missing tencent HK candle open")?
            .parse::<f64>()
            .context("invalid tencent HK candle open")?;
        let close = row[2]
            .as_str()
            .context("missing tencent HK candle close")?
            .parse::<f64>()
            .context("invalid tencent HK candle close")?;
        let high = row[3]
            .as_str()
            .context("missing tencent HK candle high")?
            .parse::<f64>()
            .context("invalid tencent HK candle high")?;
        let low = row[4]
            .as_str()
            .context("missing tencent HK candle low")?
            .parse::<f64>()
            .context("invalid tencent HK candle low")?;
        let volume = row[5]
            .as_str()
            .unwrap_or("0")
            .parse::<f64>()
            .unwrap_or_default()
            .round() as i64;
        Ok(CandlePoint {
            trade_date,
            open,
            close,
            high,
            low,
            volume,
            amount: 0.0,
            amplitude_pct: if low > 0.0 {
                ((high - low) / low) * 100.0
            } else {
                0.0
            },
            change_pct: 0.0,
            change_amount: 0.0,
            turnover_pct: 0.0,
        })
    }
}

#[derive(Debug, Clone)]
pub(super) struct HkTencentSnapshot {
    pub(super) name: String,
    pub(super) market_cap_hkd: Option<f64>,
    pub(super) shares_outstanding: Option<i64>,
    pub(super) currency: Option<String>,
}

struct QuoteResponseBuilder {
    symbol: String,
    date: String,
    open: Option<f64>,
    close: Option<f64>,
    high: Option<f64>,
    low: Option<f64>,
    volume: Option<i64>,
}

impl QuoteResponseBuilder {
    fn new(symbol: String, date: String) -> Self {
        Self {
            symbol,
            date,
            open: None,
            close: None,
            high: None,
            low: None,
            volume: None,
        }
    }

    fn open(mut self, raw: Option<&str>) -> Self {
        self.open = raw.and_then(|value| value.parse::<f64>().ok());
        self
    }

    fn close(mut self, raw: Option<&str>) -> Self {
        self.close = raw.and_then(|value| value.parse::<f64>().ok());
        self
    }

    fn high(mut self, raw: Option<&str>) -> Self {
        self.high = raw.and_then(|value| value.parse::<f64>().ok());
        self
    }

    fn low(mut self, raw: Option<&str>) -> Self {
        self.low = raw.and_then(|value| value.parse::<f64>().ok());
        self
    }

    fn volume(mut self, raw: Option<&str>) -> Self {
        self.volume = raw
            .and_then(|value| value.parse::<f64>().ok())
            .map(|value| value.round() as i64);
        self
    }

    fn build(self) -> anyhow::Result<QuoteSnapshot> {
        Ok(QuoteSnapshot {
            symbol: self.symbol,
            date: self.date,
            open: self.open.context("missing HK quote open")?,
            high: self.high.context("missing HK quote high")?,
            low: self.low.context("missing HK quote low")?,
            close: self.close.context("missing HK quote close")?,
            volume: self.volume.unwrap_or_default(),
        })
    }
}

pub(super) fn normalize_hk_trade_date(raw: Option<&str>) -> anyhow::Result<String> {
    let raw = raw
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .context("missing tencent HK trade date")?;
    let date = raw.get(0..10).unwrap_or(raw).replace('/', "-");
    NaiveDate::parse_from_str(&date, "%Y-%m-%d")
        .map(|value| value.format("%Y-%m-%d").to_string())
        .context("invalid tencent HK trade date")
}