iT邦幫忙

2025 iThome 鐵人賽

DAY 21
0
Rust

Rust 實戰專案集:30 個漸進式專案從工具到服務系列 第 21

股價追蹤器 - 抓取股價資料並計算技術指標

  • 分享至 

  • xImage
  •  

前言

之前有參加 Elixir Taiwan 的社群,有一個專案 demo 是抓取股票相關資訊的專案,
想說這週都寫跟資料有關的,不如我也來搞一個股票追蹤器,這裡我們開始嘗試做一個
股票追蹤器抓取股票相關資料,以及針對一些指標進行分析

今日學習目標

  • 使用 reqwest 進行 HTTP 請求
  • 解析 JSON 資料與錯誤處理
  • 實作技術指標計算邏輯
  • 資料結構設計與處理時間序列資料
  • 使用 chrono 處理日期時間

簡單概述 : 從公開 API 抓取股價資料,並計算常見的技術指標如移動平均線(MA)、相對強弱指標(RSI)等

開始專案

cargo new stock_tracker
cd stock_tracker

依賴

cargo.toml

[dependencies]
reqwest = { version = "0.11", features = ["json", "blocking"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
chrono = { version = "0.4", features = ["serde"] }
tokio = { version = "1", features = ["full"] }
anyhow = "1.0"

實作

資料結構

use serde::{Deserialize, Serialize};
use chrono::NaiveDate;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StockPrice {
    pub date: NaiveDate,
    pub open: f64,
    pub high: f64,
    pub low: f64,
    pub close: f64,
    pub volume: u64,
}

#[derive(Debug, Clone)]
pub struct TechnicalIndicators {
    pub sma_20: Option<f64>,  // 20日簡單移動平均
    pub sma_50: Option<f64>,  // 50日簡單移動平均
    pub rsi_14: Option<f64>,  // 14日相對強弱指標
    pub ema_12: Option<f64>,  // 12日指數移動平均
}

#[derive(Debug)]
pub struct StockData {
    pub symbol: String,
    pub prices: Vec<StockPrice>,
}

實作資料抓取模組

  • 我們使用 Alpha Vantage 或 Yahoo Finance API 作為範例
use reqwest;
use anyhow::{Result, Context};

pub struct StockFetcher {
    client: reqwest::Client,
    api_key: String,
}

impl StockFetcher {
    pub fn new(api_key: String) -> Self {
        Self {
            client: reqwest::Client::new(),
            api_key,
        }
    }

    pub async fn fetch_stock_data(
        &self,
        symbol: &str,
        days: usize,
    ) -> Result<StockData> {
        // 這裡使用 Alpha Vantage API 作為範例
        let url = format!(
            "https://www.alphavantage.co/query?function=TIME_SERIES_DAILY&symbol={}&apikey={}&outputsize=compact",
            symbol, self.api_key
        );

        let response = self.client
            .get(&url)
            .send()
            .await
            .context("Failed to fetch stock data")?;

        let data: serde_json::Value = response
            .json()
            .await
            .context("Failed to parse JSON")?;

        self.parse_stock_data(symbol, data, days)
    }

    fn parse_stock_data(
        &self,
        symbol: &str,
        data: serde_json::Value,
        days: usize,
    ) -> Result<StockData> {
        let time_series = data["Time Series (Daily)"]
            .as_object()
            .context("Invalid response format")?;

        let mut prices: Vec<StockPrice> = time_series
            .iter()
            .take(days)
            .filter_map(|(date_str, values)| {
                let date = NaiveDate::parse_from_str(date_str, "%Y-%m-%d").ok()?;
                
                Some(StockPrice {
                    date,
                    open: values["1. open"].as_str()?.parse().ok()?,
                    high: values["2. high"].as_str()?.parse().ok()?,
                    low: values["3. low"].as_str()?.parse().ok()?,
                    close: values["4. close"].as_str()?.parse().ok()?,
                    volume: values["5. volume"].as_str()?.parse().ok()?,
                })
            })
            .collect();

        // 按日期排序(由舊到新)
        prices.sort_by(|a, b| a.date.cmp(&b.date));

        Ok(StockData {
            symbol: symbol.to_string(),
            prices,
        })
    }
}

建立指標計算模組

pub struct IndicatorCalculator;

impl IndicatorCalculator {
    /// 計算簡單移動平均線 (SMA)
    pub fn calculate_sma(prices: &[f64], period: usize) -> Vec<Option<f64>> {
        let mut result = Vec::with_capacity(prices.len());
        
        for i in 0..prices.len() {
            if i + 1 < period {
                result.push(None);
            } else {
                let sum: f64 = prices[i + 1 - period..=i].iter().sum();
                result.push(Some(sum / period as f64));
            }
        }
        
        result
    }

    /// 計算指數移動平均線 (EMA)
    pub fn calculate_ema(prices: &[f64], period: usize) -> Vec<Option<f64>> {
        if prices.is_empty() {
            return vec![];
        }

        let mut result = Vec::with_capacity(prices.len());
        let multiplier = 2.0 / (period as f64 + 1.0);

        // 第一個 EMA 使用 SMA
        let mut ema = prices.iter().take(period).sum::<f64>() / period as f64;
        
        for (i, &price) in prices.iter().enumerate() {
            if i < period - 1 {
                result.push(None);
            } else if i == period - 1 {
                result.push(Some(ema));
            } else {
                ema = (price - ema) * multiplier + ema;
                result.push(Some(ema));
            }
        }

        result
    }

    /// 計算相對強弱指標 (RSI)
    pub fn calculate_rsi(prices: &[f64], period: usize) -> Vec<Option<f64>> {
        if prices.len() < period + 1 {
            return vec![None; prices.len()];
        }

        let mut result = Vec::with_capacity(prices.len());
        let mut gains = Vec::new();
        let mut losses = Vec::new();

        // 計算價格變化
        for i in 1..prices.len() {
            let change = prices[i] - prices[i - 1];
            if change > 0.0 {
                gains.push(change);
                losses.push(0.0);
            } else {
                gains.push(0.0);
                losses.push(change.abs());
            }
        }

        result.push(None); // 第一天沒有 RSI

        for i in 0..gains.len() {
            if i < period - 1 {
                result.push(None);
            } else {
                let avg_gain: f64 = gains[i + 1 - period..=i].iter().sum::<f64>() / period as f64;
                let avg_loss: f64 = losses[i + 1 - period..=i].iter().sum::<f64>() / period as f64;

                let rsi = if avg_loss == 0.0 {
                    100.0
                } else {
                    let rs = avg_gain / avg_loss;
                    100.0 - (100.0 / (1.0 + rs))
                };

                result.push(Some(rsi));
            }
        }

        result
    }

    /// 計算所有技術指標
    pub fn calculate_all(stock_data: &StockData) -> Vec<TechnicalIndicators> {
        let closes: Vec<f64> = stock_data.prices.iter().map(|p| p.close).collect();

        let sma_20 = Self::calculate_sma(&closes, 20);
        let sma_50 = Self::calculate_sma(&closes, 50);
        let ema_12 = Self::calculate_ema(&closes, 12);
        let rsi_14 = Self::calculate_rsi(&closes, 14);

        (0..closes.len())
            .map(|i| TechnicalIndicators {
                sma_20: sma_20.get(i).and_then(|&v| v),
                sma_50: sma_50.get(i).and_then(|&v| v),
                ema_12: ema_12.get(i).and_then(|&v| v),
                rsi_14: rsi_14.get(i).and_then(|&v| v),
            })
            .collect()
    }
}

main.rs

use anyhow::Result;

mod stock;
mod indicators;

use stock::{StockFetcher, StockData};
use indicators::IndicatorCalculator;

#[tokio::main]
async fn main() -> Result<()> {
    // 從環境變數讀取 API Key
    let api_key = std::env::var("ALPHA_VANTAGE_API_KEY")
        .unwrap_or_else(|_| "demo".to_string());

    let fetcher = StockFetcher::new(api_key);

    // 抓取股價資料
    println!("正在抓取 AAPL 股價資料...");
    let stock_data = fetcher.fetch_stock_data("AAPL", 100).await?;

    println!("成功抓取 {} 筆資料\n", stock_data.prices.len());

    // 計算技術指標
    let indicators = IndicatorCalculator::calculate_all(&stock_data);

    // 顯示最近10天的資料
    println!("最近 10 天的股價與技術指標:");
    println!("{:-<100}", "");
    println!(
        "{:<12} {:>10} {:>10} {:>10} {:>10} {:>12} {:>12} {:>10}",
        "日期", "開盤", "最高", "最低", "收盤", "SMA(20)", "EMA(12)", "RSI(14)"
    );
    println!("{:-<100}", "");

    let start_idx = stock_data.prices.len().saturating_sub(10);
    for (price, indicator) in stock_data.prices[start_idx..]
        .iter()
        .zip(&indicators[start_idx..])
    {
        println!(
            "{:<12} {:>10.2} {:>10.2} {:>10.2} {:>10.2} {:>12} {:>12} {:>10}",
            price.date,
            price.open,
            price.high,
            price.low,
            price.close,
            indicator.sma_20.map_or("N/A".to_string(), |v| format!("{:.2}", v)),
            indicator.ema_12.map_or("N/A".to_string(), |v| format!("{:.2}", v)),
            indicator.rsi_14.map_or("N/A".to_string(), |v| format!("{:.2}", v)),
        );
    }

    // 分析最新指標
    if let Some(latest) = indicators.last() {
        println!("\n{:-<100}", "");
        println!("最新技術指標分析:");
        
        if let Some(rsi) = latest.rsi_14 {
            println!("RSI(14): {:.2}", rsi);
            if rsi > 70.0 {
                println!("  → 超買訊號,可能面臨回調");
            } else if rsi < 30.0 {
                println!("  → 超賣訊號,可能反彈");
            } else {
                println!("  → 中性區間");
            }
        }

        if let (Some(sma_20), Some(price)) = (
            latest.sma_20,
            stock_data.prices.last().map(|p| p.close),
        ) {
            let diff_pct = ((price - sma_20) / sma_20) * 100.0;
            println!("\n股價與 SMA(20) 差距: {:.2}%", diff_pct);
            if diff_pct > 5.0 {
                println!("  → 股價顯著高於均線,可能過熱");
            } else if diff_pct < -5.0 {
                println!("  → 股價顯著低於均線,可能超跌");
            }
        }
    }

    Ok(())
}

環境相關設定以及執行

# 設定 API Key (需要到 Alpha Vantage 註冊)
export ALPHA_VANTAGE_API_KEY=<你的 api key>

# 執行程式
cargo run

小小抱怨

寫到現在有點疲倦,好累!完成 21 天


上一篇
資料庫遷移工具 - SQL 資料庫 schema 版本管理
下一篇
文件轉換器 - Markdown 轉 HTML/PDF 工具
系列文
Rust 實戰專案集:30 個漸進式專案從工具到服務24
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言