Yahoo Finance에서 주식 정보 가져오기 (1)

모방은 창조의 어머니인가요? claude.ai의 도움을 받아 만든 “Yahoo Finace API를 이용해서 주식 데이터를 가져오는 프로그램”을 살펴보겠습니다. Rust는 먼저 Cargo.toml 파일에서 가져올 크레이트(라이브러리)를 정의하고, main.rs에서 실행 코드를 구현합니다.

1. Cargo.toml

[dependencies]
reqwest = { version = "0.11", features = ["json"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
tokio = { version = "1.0", features = ["full"] }

dependencies 섹션에 reqwest, serde, serde_json, tokio 크레이트(crate, library)를 버전과 features를 이용해 지정합니다.

가. reqwest

request가 맞는 단어인데, reqwest로 약간 다른 점 주의해야 합니다.
reqwest 크레이트는 편리하고 높은 수준의 HTTP 클라이언트를 제공합니다.

version은 크레이트의 버전을 지정하는 것은 알겠는데, features는 크레이트의 특정 기능(선택적 기능)을 활성화하거나 비활성화할 때 사용되며, 조건부 컴파일을 가능하게 하여, 필요한 기능만 컴파일하도록 하는 것입니다.

features = [“json”]은 “json” 기능을 켜서 JSON 직렬화/역직렬화 기능을 사용할 수 있게 하는 것입니다.

아래와 같이 버전 목록이 표시되는데, 맨 위에 0.12.23이 있으므로 클릭합니다.

그러면 버전이 자동으로 변경되고, X표시가 없어지고, 녹색 체크 표시로 바뀝니다.

나. serde

데이터 직렬화(Serialize) / 역직렬화(Deserialize) 라이브러리입니다.
JSON, TOML, YAML 등 다양한 포맷과 Rust 구조체를 변환할 때 사용하며,

features = [“derive”]는

[derive(Serialize, Deserialize)] 어트리뷰트를 쓸 수 있게 해주는 것입니다.

다. serde_json

serde의 JSON 전용 확장판으로서, Rust 데이터와 JSON 문자열간에 변환을 가능하게 해줍니다.

let user = User { name: "Kim".into(), age: 30 };
let json_str = serde_json::to_string(&user)?; // 구조체 → JSON 문자열
let parsed: User = serde_json::from_str(&json_str)?; // JSON → 구조체

라. tokio

Rust의 비동기 런타임 (async/await 동작을 실제로 수행하는 엔진)으로서,
features = [“full”]은 모든 기능(네트워킹, 파일 I/O, 타이머 등)을 한 번에 활성화하는 것이며,
#[tokio::main] 매크로로 main 함수를 비동기로 만들 수 있습니다.

2. main.rs

가. 코드 1

아래 코드를 src 폴더의 main.rs를 연 후 Ctrl + A해서 전체를 선택한 후 Ctrl + V를 하면 기존 내용에 덮어씌워집니다.

use reqwest;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use tokio;

#[derive(Debug, Deserialize, Serialize)]
struct YahooResponse {
    chart: Chart,
}

#[derive(Debug, Deserialize, Serialize)]
struct Chart {
    result: Vec<ChartResult>,
    error: Option<serde_json::Value>,
}

#[derive(Debug, Deserialize, Serialize)]
struct ChartResult {
    meta: Meta,
}

#[derive(Debug, Deserialize, Serialize)]
struct Meta {
    currency: String,
    symbol: String,
    #[serde(rename = "longName")]
    long_name: Option<String>,
    #[serde(rename = "regularMarketPrice")]
    regular_market_price: Option<f64>,
    #[serde(rename = "regularMarketTime")]
    regular_market_time: Option<i64>,
}

#[derive(Debug, Clone)]
struct StockData {
    symbol: String,
    long_name: String,
    regular_market_price: f64,
    currency: String,
    regular_market_time: i64,
}

async fn fetch_stock_data(symbol: &str) -> Result<StockData, Box<dyn std::error::Error + Send + Sync>> {
    let url = format!("https://query1.finance.yahoo.com/v8/finance/chart/{}", symbol);
    
    let client = reqwest::Client::new();
    let response = client
        .get(&url)
        .header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
        .send()
        .await?;

    let text = response.text().await?;
    let yahoo_response: YahooResponse = serde_json::from_str(&text)?;

    if let Some(result) = yahoo_response.chart.result.first() {
        let meta = &result.meta;
        
        Ok(StockData {
            symbol: symbol.to_string(),
            long_name: meta.long_name.clone().unwrap_or_else(|| "N/A".to_string()),
            regular_market_price: meta.regular_market_price.unwrap_or(0.0),
            currency: meta.currency.clone(),
            regular_market_time: meta.regular_market_time.unwrap_or(0),
        })
    } else {
        Err(format!("No data found for symbol: {}", symbol).into())
    }
}

async fn fetch_multiple_stocks(symbols: &[&str]) -> Vec<Result<StockData, Box<dyn std::error::Error + Send + Sync>>> {
    let mut handles = Vec::new();
    
    for &symbol in symbols {
        let symbol_owned = symbol.to_string();
        let handle = tokio::spawn(async move {
            fetch_stock_data(&symbol_owned).await
        });
        handles.push(handle);
    }
    
    let mut results = Vec::new();
    for handle in handles {
        match handle.await {
            Ok(result) => results.push(result),
            Err(e) => results.push(Err(e.into())),
        }
    }
    
    results
}

fn format_timestamp(timestamp: i64) -> String {
    use std::time::{UNIX_EPOCH, Duration};
    
    let datetime = UNIX_EPOCH + Duration::from_secs(timestamp as u64);
    
    // 간단한 포맷팅 (실제로는 chrono 크레이트 사용 권장)
    format!("Unix timestamp: {}", timestamp)
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
    let symbols = [
        "005930.KS", // 삼성전자
        "AAPL",      // Apple
        "TSLA",      // Tesla
        "LIT",       // Global X Lithium & Battery Tech ETF
        "BRK-B",     // Berkshire Hathaway Class B
        "AMZN",      // Amazon
        "O",         // Realty Income Corporation
        "TQQQ",      // ProShares UltraPro QQQ
        "XOM",       // Exxon Mobil
        "WMT",       // Walmart
    ];

    println!("Fetching stock data...\n");
    
    let results = fetch_multiple_stocks(&symbols).await;
    
    for (i, result) in results.iter().enumerate() {
        match result {
            Ok(stock) => {
                println!("Symbol: {}", stock.symbol);
                println!("Long Name: {}", stock.long_name);
                println!("Regular Market Price: {:.2} {}", stock.regular_market_price, stock.currency);
                println!("Currency: {}", stock.currency);
                println!("Regular Market Time: {}", format_timestamp(stock.regular_market_time));
                println!("---");
            }
            Err(e) => {
                println!("Error fetching data for {}: {}", symbols[i], e);
                println!("---");
            }
        }
    }

    Ok(())
}

cargo run을 하면 compile과 build를 한 후

Run을 하는데, 주식 정보 조회 결과를 주식별로 하나씩 보여주고, 시간이 Unix timestampt로 보여줘서 날짜와 시간을 알 수 없습니다.

나 코드 2

그래서 엑셀 처럼 표 형태로 보여주고, Unix time을 년월일시로 바꿔달라고 했더니 아래 코드가 되는데,

use reqwest;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use tokio;
use chrono::{DateTime, Utc, TimeZone};

#[derive(Debug, Deserialize, Serialize)]
struct YahooResponse {
    chart: Chart,
}

#[derive(Debug, Deserialize, Serialize)]
struct Chart {
    result: Vec<ChartResult>,
    error: Option<serde_json::Value>,
}

#[derive(Debug, Deserialize, Serialize)]
struct ChartResult {
    meta: Meta,
}

#[derive(Debug, Deserialize, Serialize)]
struct Meta {
    currency: String,
    symbol: String,
    #[serde(rename = "longName")]
    long_name: Option<String>,
    #[serde(rename = "regularMarketPrice")]
    regular_market_price: Option<f64>,
    #[serde(rename = "regularMarketTime")]
    regular_market_time: Option<i64>,
}

#[derive(Debug, Clone)]
struct StockData {
    symbol: String,
    long_name: String,
    regular_market_price: f64,
    currency: String,
    regular_market_time: i64,
}

async fn fetch_stock_data(symbol: &str) -> Result<StockData, Box<dyn std::error::Error + Send + Sync>> {
    let url = format!("https://query1.finance.yahoo.com/v8/finance/chart/{}", symbol);
    
    let client = reqwest::Client::new();
    let response = client
        .get(&url)
        .header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
        .send()
        .await?;

    let text = response.text().await?;
    let yahoo_response: YahooResponse = serde_json::from_str(&text)?;

    if let Some(result) = yahoo_response.chart.result.first() {
        let meta = &result.meta;
        
        Ok(StockData {
            symbol: symbol.to_string(),
            long_name: meta.long_name.clone().unwrap_or_else(|| "N/A".to_string()),
            regular_market_price: meta.regular_market_price.unwrap_or(0.0),
            currency: meta.currency.clone(),
            regular_market_time: meta.regular_market_time.unwrap_or(0),
        })
    } else {
        Err(format!("No data found for symbol: {}", symbol).into())
    }
}

async fn fetch_multiple_stocks(symbols: &[&str]) -> Vec<Result<StockData, Box<dyn std::error::Error + Send + Sync>>> {
    let mut handles = Vec::new();
    
    for &symbol in symbols {
        let symbol_owned = symbol.to_string();
        let handle = tokio::spawn(async move {
            fetch_stock_data(&symbol_owned).await
        });
        handles.push(handle);
    }
    
    let mut results = Vec::new();
    for handle in handles {
        match handle.await {
            Ok(result) => results.push(result),
            Err(e) => results.push(Err(e.into())),
        }
    }
    
    results
}

fn format_timestamp(timestamp: i64) -> String {
    match Utc.timestamp_opt(timestamp, 0) {
        chrono::LocalResult::Single(datetime) => datetime.format("%Y-%m-%d %H:%M:%S").to_string(),
        _ => "Invalid timestamp".to_string(),
    }
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
    let symbols = [
        "005930.KS", // 삼성전자
        "AAPL",      // Apple
        "TSLA",      // Tesla
        "LIT",       // Global X Lithium & Battery Tech ETF
        "BRK-B",     // Berkshire Hathaway Class B
        "AMZN",      // Amazon
        "O",         // Realty Income Corporation
        "TQQQ",      // ProShares UltraPro QQQ
        "XOM",       // Exxon Mobil
        "WMT",       // Walmart
    ];

    println!("Fetching stock data...\n");
    
    let results = fetch_multiple_stocks(&symbols).await;
    
    // 테이블 헤더 출력
    println!("{:<3} {:<10} {:<35} {:<15} {:<8} {:<20}", 
             "No.", "Symbol", "Long Name", "Regular Price", "Currency", "Regular Market Time");
    println!("{}", "-".repeat(95));
    
    for (i, result) in results.iter().enumerate() {
        let row_number = i + 1;
        match result {
            Ok(stock) => {
                println!("{:<3} {:<10} {:<35} {:<15.2} {:<8} {:<20}", 
                         row_number,
                         stock.symbol,
                         if stock.long_name.len() > 35 {
                             format!("{}...", &stock.long_name[..32])
                         } else {
                             stock.long_name.clone()
                         },
                         stock.regular_market_price,
                         stock.currency,
                         format_timestamp(stock.regular_market_time)
                );
            }
            Err(e) => {
                println!("{:<3} {:<10} {:<35} {:<15} {:<8} {:<20}", 
                         row_number,
                         symbols[i],
                         "Error fetching data",
                         "N/A",
                         "N/A",
                         "N/A"
                );
            }
        }
    }

    Ok(())
}

앞 부분에 use chrono::{DateTime, Utc, TimeZone};이 있어서

먼저 Cargo.toml에 chrono 크레이트를 추가해야 합니다.

chrono 버전에 커서를 갖다대보니 버전이 0.4.41로 표시되는데, 0.4 버전대이므로 그대로 둬도 문제 없습니다.

Cargo.toml과 main.rs를 저장하고, 실행하면

표 형태로 잘 표시되고, 날짜도 연월일 시분초로 잘 표시됩니다.

다음 편에서는 main.rs 코드를 하나씩 살펴보겠습니다.

Rust 생태계와 Hello, World! 출력하기

🔸 Rust 생태계

러스트의 생태계는 여러가지 도구들로 구성되어 있으며, 그 중 중요한 것은 rustc, cargo, rustup입니다. 그러나, Visual Studio Code가 있기 때문에 cargo new를 이용해 간단하게 패키지를 만들고, cargo run을 이용해 실행할 수 있습니다.

  • rustc : rust의 확장자는 .rs이며, 이 파일을 바이너리 또는 다른 중간 형식으로 변환해주는 역활을 하는 컴파일러입니다. 그러나 Visual Studio Code(이하 code)를 사용하기때문에 거의 사용하지 않습니다.
  • cargo : rust 실행에 필요한 라이브러리를 관리하고 또한 프로젝트를 관리, 빌드, 테스트하는 도구입니다.
  • rustup : 첫번째 개발환경 세팅에서 언급한 바와 같이 rust를 업데이트해주고, toolchain을 설치하는 역활을 합니다. 여러 개의 rust 버전이 설치되어 있을 때 필요한 것을 선택할 수 있도록 합니다.

🔸 폴더 만들고 Code 실행하기

명령 프롬프트 창을 열고 D드라이브 루트인 상태에서 md rust-practice 라고 입력해서 rust-practice 폴더를 만들고

루트에서 cd rust-practice 폴더로 이동한 다음 code . 이라고 입력하면 rust-practice 폴더에서 code가 실행됩니다.

폴더를 만들고 그 폴더로 이동한 다음 visual studio code를 실행함

code 화면에 terminal이 실행되어 있지 않다면 ctrl + `(키보드 1 왼쪽 키)를 눌러 terminal을 엽니다.

🔸 cargo new

그리고, cargo new day1 이라고 입력하고 엔터 키를 누르면

code 터미널 창에서 cargo new 명령을 이용해 새로운 패키지를 만듭니다.

그러면 왼쪽 탐색기 창을 보면 rust-practice 아래에 preview 폴더가 생기고 오른쪽 터미널 창을 보면 preview라는 바이너리 패키지를 생성하고 있다는 메시지와 Cargo.toml 키를 더 알아보고, 사이트를 참고하라고 합니다.

cargo enw day1이라고 입력해서 day1이라는 패키지를 생성한 화면

다시 왼쪽 탐색기 창의 preview를 열어보면 preview 폴더 아래에 src 폴더가 있고, 그 아래 main.rc 파일이 있고, src 폴더와 동급으로 .gitignore, Cargo.toml 파일이 있습니다.

day1 폴더 아래에 src 폴더가 있고, 그 아래 main.rc 파일이 있으며, src 폴더와 동급으로 .gitignore, Cargo.toml 파일이 있습니다.

Cargo new 패키지명을 입력하면 기본적으로 이런 형식으로 폴더와 파일이 만들어집니다.

🔸 main.rs의 내용

이제 main.rs를 클릭하면 오른쪽 에디터 창에 아래와 같이 표시됩니다.

기본적인 main.rs의 내용은 "Hello, world!를 화면에 출력하라"는 것입니다.

fn main() {
    println!("Hello, world!");
}

fn은 function이라는 의미이고, main은 함수명이며 괄호 안에 인수를 넣는데 괄호만 있으므로 인수가 없는 것입니다.

그리고, 중괄호안에 실행할 내용이 들어가는데,

println!(“Hello, world!”);란 화면에 “Hello, world!를 출력하라”는 의미입니다.

🔸 cargo run

cargo run은 컴파일하고, 패키지를 실행하는 명령어입니다.

터미널에서 day1 폴더로 이동해야 하므로 ‘cd day1’이라고 입력하고 엔터키를 입력한 다음 ‘cargo run’이라고 입력하고 엔터키를 누르면

Compiling과 Finished, Running이라는 글자가 보이고, 화면에 “Hello, world!가 출력됐습니다.

Finished 이전에는 Building이라고 표시되다가 Finished로 바뀌었습니다.

실행 파일은 target 폴더 아래 debug 폴더에 day1.exe란 이름으로 생겼습니다. 실행 파일의 사이즈가 궁금하면 탑색기에서 살펴보기 바랍니다. 그러나 실행은 명령 프롬프트 창에서 해야 합니다.

이렇게 rust 프로그램을 만들고 실행하는 법을 전체적으로 살펴봤습니다.