Add market data universe and DART evidence workflows
This commit is contained in:
@@ -27,7 +27,8 @@ pub fn call(api: &KisApi, endpoint: &str, params: &[(&str, &str)]) -> Result<Str
|
||||
.collect::<Vec<_>>();
|
||||
let url = build_url(endpoint, &query_params);
|
||||
|
||||
let mut request = ureq::get(&url)
|
||||
let mut request = crate::core::http::agent()
|
||||
.get(&url)
|
||||
.header("Content-Type", "application/json; charset=UTF-8")
|
||||
.header("authorization", &format!("Bearer {}", token))
|
||||
.header("appkey", appkey)
|
||||
|
||||
@@ -53,7 +53,8 @@ pub fn login(args: &KisLoginArguments) -> Result<String, String> {
|
||||
"appsecret": args.appsecret,
|
||||
});
|
||||
|
||||
let response = ureq::post(&url)
|
||||
let response = crate::core::http::agent()
|
||||
.post(&url)
|
||||
.header("Content-Type", "application/json; charset=UTF-8")
|
||||
.send_json(&request_body)
|
||||
.map_err(|err| format!("[KIS] 접근토큰 발급 요청 실패: {}", err))?;
|
||||
|
||||
@@ -5,6 +5,7 @@ pub mod call;
|
||||
pub mod info;
|
||||
pub mod login;
|
||||
pub mod market;
|
||||
pub mod market_history;
|
||||
pub mod order_common;
|
||||
pub mod order_status;
|
||||
pub mod sell;
|
||||
@@ -88,6 +89,17 @@ impl TraderApi for KisApi {
|
||||
market::market(self, symbol)
|
||||
}
|
||||
|
||||
fn market_history(
|
||||
&self,
|
||||
symbol: &str,
|
||||
start_date: &str,
|
||||
end_date: &str,
|
||||
period: &str,
|
||||
adjusted: bool,
|
||||
) -> Result<String, String> {
|
||||
market_history::market_history(self, symbol, start_date, end_date, period, adjusted)
|
||||
}
|
||||
|
||||
fn info(&self) -> Vec<(&'static str, &'static str)> {
|
||||
info::info()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
use crate::apis::kis::KisApi;
|
||||
use crate::core::TraderApi;
|
||||
use serde_json::json;
|
||||
|
||||
const HISTORY_ENDPOINT: &str = "/uapi/domestic-stock/v1/quotations/inquire-daily-itemchartprice";
|
||||
|
||||
pub fn market_history(
|
||||
api: &KisApi,
|
||||
symbol: &str,
|
||||
start_date: &str,
|
||||
end_date: &str,
|
||||
period: &str,
|
||||
adjusted: bool,
|
||||
) -> Result<String, String> {
|
||||
let symbol = symbol.trim();
|
||||
let start_date = start_date.trim();
|
||||
let end_date = end_date.trim();
|
||||
let period = normalize_period(period)?;
|
||||
validate_symbol(symbol)?;
|
||||
validate_date(start_date, "--from")?;
|
||||
validate_date(end_date, "--to")?;
|
||||
if start_date > end_date {
|
||||
return Err("--from은 --to보다 늦을 수 없습니다.".to_string());
|
||||
}
|
||||
|
||||
let adj_price_code = if adjusted { "0" } else { "1" };
|
||||
let params = [
|
||||
("tr_id", "FHKST03010100"),
|
||||
("FID_COND_MRKT_DIV_CODE", "J"),
|
||||
("FID_INPUT_ISCD", symbol),
|
||||
("FID_INPUT_DATE_1", start_date),
|
||||
("FID_INPUT_DATE_2", end_date),
|
||||
("FID_PERIOD_DIV_CODE", period),
|
||||
("FID_ORG_ADJ_PRC", adj_price_code),
|
||||
];
|
||||
let response = api.call(HISTORY_ENDPOINT, ¶ms)?;
|
||||
let value = serde_json::from_str::<serde_json::Value>(&response)
|
||||
.map_err(|err| format!("[KIS] 기간별 시세 응답 파싱 실패: {}", err))?;
|
||||
ensure_success(&value)?;
|
||||
|
||||
let mut candles = value
|
||||
.get("output2")
|
||||
.and_then(|value| value.as_array())
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(normalize_candle)
|
||||
.collect::<Vec<_>>();
|
||||
candles.sort_by(|a, b| {
|
||||
a.get("date")
|
||||
.and_then(|value| value.as_str())
|
||||
.cmp(&b.get("date").and_then(|value| value.as_str()))
|
||||
});
|
||||
|
||||
Ok(json!({
|
||||
"broker": api.id(),
|
||||
"symbol": symbol,
|
||||
"period": period,
|
||||
"start_date": start_date,
|
||||
"end_date": end_date,
|
||||
"adjusted": adjusted,
|
||||
"count": candles.len(),
|
||||
"summary": value.get("output1").cloned().unwrap_or_else(|| json!({})),
|
||||
"candles": candles,
|
||||
"raw": value,
|
||||
})
|
||||
.to_string())
|
||||
}
|
||||
|
||||
fn normalize_candle(item: &serde_json::Value) -> serde_json::Value {
|
||||
json!({
|
||||
"date": text(item, "stck_bsop_date"),
|
||||
"open": text(item, "stck_oprc"),
|
||||
"high": text(item, "stck_hgpr"),
|
||||
"low": text(item, "stck_lwpr"),
|
||||
"close": text(item, "stck_clpr"),
|
||||
"volume": text(item, "acml_vol"),
|
||||
"trading_value": text(item, "acml_tr_pbmn"),
|
||||
"change": text(item, "prdy_vrss"),
|
||||
"change_sign": text(item, "prdy_vrss_sign"),
|
||||
"change_rate": text(item, "prdy_ctrt"),
|
||||
})
|
||||
}
|
||||
|
||||
fn text(item: &serde_json::Value, key: &str) -> String {
|
||||
item.get(key)
|
||||
.and_then(|value| value.as_str())
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn ensure_success(value: &serde_json::Value) -> Result<(), String> {
|
||||
if value
|
||||
.get("rt_cd")
|
||||
.and_then(|value| value.as_str())
|
||||
.unwrap_or("0")
|
||||
== "0"
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
let message = value
|
||||
.get("msg1")
|
||||
.or_else(|| value.get("msg"))
|
||||
.and_then(|value| value.as_str())
|
||||
.unwrap_or("KIS 기간별 시세 조회 실패");
|
||||
Err(format!("[KIS] 기간별 시세 조회 실패: {}", message))
|
||||
}
|
||||
|
||||
fn normalize_period(value: &str) -> Result<&'static str, String> {
|
||||
match value.trim().to_ascii_uppercase().as_str() {
|
||||
"D" | "DAY" | "DAILY" => Ok("D"),
|
||||
"W" | "WEEK" | "WEEKLY" => Ok("W"),
|
||||
"M" | "MONTH" | "MONTHLY" => Ok("M"),
|
||||
"Y" | "YEAR" | "YEARLY" => Ok("Y"),
|
||||
_ => Err("--period는 D, W, M, Y 중 하나여야 합니다.".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_symbol(value: &str) -> Result<(), String> {
|
||||
if value.len() == 6 && value.chars().all(|ch| ch.is_ascii_digit()) {
|
||||
return Ok(());
|
||||
}
|
||||
Err("종목코드는 6자리 숫자여야 합니다. 예: 005930".to_string())
|
||||
}
|
||||
|
||||
fn validate_date(value: &str, name: &str) -> Result<(), String> {
|
||||
if value.len() == 8 && value.chars().all(|ch| ch.is_ascii_digit()) {
|
||||
return Ok(());
|
||||
}
|
||||
Err(format!("{} 값은 YYYYMMDD 형식이어야 합니다.", name))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn normalizes_period_aliases() {
|
||||
assert_eq!(normalize_period("d").unwrap(), "D");
|
||||
assert_eq!(normalize_period("weekly").unwrap(), "W");
|
||||
assert_eq!(normalize_period("M").unwrap(), "M");
|
||||
assert!(normalize_period("hour").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalizes_candle_fields() {
|
||||
let item = json!({
|
||||
"stck_bsop_date": "20260609",
|
||||
"stck_oprc": "70000",
|
||||
"stck_hgpr": "71000",
|
||||
"stck_lwpr": "69000",
|
||||
"stck_clpr": "70500",
|
||||
"acml_vol": "123456",
|
||||
"acml_tr_pbmn": "8000000000",
|
||||
"prdy_vrss": "500",
|
||||
"prdy_vrss_sign": "2",
|
||||
"prdy_ctrt": "0.71"
|
||||
});
|
||||
|
||||
let candle = normalize_candle(&item);
|
||||
assert_eq!(candle["date"], "20260609");
|
||||
assert_eq!(candle["open"], "70000");
|
||||
assert_eq!(candle["close"], "70500");
|
||||
assert_eq!(candle["volume"], "123456");
|
||||
}
|
||||
}
|
||||
@@ -85,7 +85,8 @@ fn post_order(api: &KisApi, tr_id: &str, body: &serde_json::Value) -> Result<Str
|
||||
.ok_or("KIS_APPSECRET가 .env에 없습니다.")?;
|
||||
let url = format!("{}{}", KIS_BASE_URL, ORDER_CASH_ENDPOINT);
|
||||
|
||||
let response = ureq::post(&url)
|
||||
let response = crate::core::http::agent()
|
||||
.post(&url)
|
||||
.header("Content-Type", "application/json; charset=UTF-8")
|
||||
.header("authorization", &format!("Bearer {}", token))
|
||||
.header("appkey", appkey)
|
||||
|
||||
+16
-3
@@ -233,19 +233,19 @@ fn api_catalog(api: &dyn TraderApi) -> serde_json::Value {
|
||||
{
|
||||
"name": "KIS_APPKEY",
|
||||
"description": "KIS Open API application key used for authentication and every broker API request.",
|
||||
"required_for": ["api login", "api call", "account status", "market", "order buy", "order sell", "order status"],
|
||||
"required_for": ["api login", "api call", "account status", "market", "market history", "order buy", "order sell", "order status"],
|
||||
"source": ".env or api login --appkey"
|
||||
},
|
||||
{
|
||||
"name": "KIS_APPSECRET",
|
||||
"description": "KIS Open API application secret used for authentication and every broker API request.",
|
||||
"required_for": ["api login", "api call", "account status", "market", "order buy", "order sell", "order status"],
|
||||
"required_for": ["api login", "api call", "account status", "market", "market history", "order buy", "order sell", "order status"],
|
||||
"source": ".env or api login --appsecret"
|
||||
},
|
||||
{
|
||||
"name": "KIS_ACCESS_TOKEN",
|
||||
"description": "Bearer access token issued by api login. Read commands and order commands require it.",
|
||||
"required_for": ["api call", "account status", "market", "order buy", "order sell", "order status"],
|
||||
"required_for": ["api call", "account status", "market", "market history", "order buy", "order sell", "order status"],
|
||||
"source": ".env written by api login"
|
||||
},
|
||||
{
|
||||
@@ -291,6 +291,19 @@ fn api_catalog(api: &dyn TraderApi) -> serde_json::Value {
|
||||
"output_contract": "Explained JSON with broker, symbol, price, company, and raw KIS response.",
|
||||
"side_effect": "none"
|
||||
},
|
||||
{
|
||||
"command": "market history <symbol> --from YYYYMMDD --to YYYYMMDD --period D|W|M|Y [--raw-price]",
|
||||
"purpose": "Read OHLCV price history for a domestic stock code. The normalized candles are sorted ascending by date for strategy and backtest use.",
|
||||
"inputs": [
|
||||
{"name": "symbol", "required": true, "description": "Six digit domestic stock code such as 005930."},
|
||||
{"name": "--from", "required": true, "description": "Start date in YYYYMMDD."},
|
||||
{"name": "--to", "required": true, "description": "End date in YYYYMMDD."},
|
||||
{"name": "--period", "required": false, "description": "D daily, W weekly, M monthly, Y yearly. Defaults to D."},
|
||||
{"name": "--raw-price", "required": false, "description": "Use original prices. Without this flag the command requests adjusted prices."}
|
||||
],
|
||||
"output_contract": "Explained JSON with broker, symbol, period, date_range, adjusted, count, candles, summary, and raw KIS response.",
|
||||
"side_effect": "none"
|
||||
},
|
||||
{
|
||||
"command": "order buy <symbol> --qty <qty> (--price <price>|--market)",
|
||||
"purpose": "Submit a live domestic stock buy order.",
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
use crate::providers::{kind, opendart};
|
||||
use clap::{Args, Subcommand};
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum CacheCommands {
|
||||
/// 로컬 캐시 용량 상태 조회
|
||||
Status,
|
||||
|
||||
/// 보존 정책에 따라 오래된 캐시 스냅샷 정리
|
||||
Prune(CachePruneCommand),
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct CachePruneCommand {
|
||||
/// 실제 삭제하지 않고 삭제 예정 파일만 계산
|
||||
#[arg(long)]
|
||||
pub dry_run: bool,
|
||||
}
|
||||
|
||||
pub fn handle_cache(sub: &CacheCommands) {
|
||||
match sub {
|
||||
CacheCommands::Status => match crate::core::cache::status() {
|
||||
Ok(status) => println!(
|
||||
"{}",
|
||||
crate::core::output::explained_with_raw(
|
||||
"cache status",
|
||||
"로컬 캐시 용량 상태 조회 결과",
|
||||
vec![
|
||||
crate::core::output::field(
|
||||
"root",
|
||||
"캐시 루트 디렉터리",
|
||||
serde_json::json!(status.root),
|
||||
),
|
||||
crate::core::output::field(
|
||||
"exists",
|
||||
"캐시 루트 디렉터리 존재 여부",
|
||||
serde_json::json!(status.exists),
|
||||
),
|
||||
crate::core::output::field(
|
||||
"total_files",
|
||||
"캐시 파일 총 개수",
|
||||
serde_json::json!(status.total_files),
|
||||
),
|
||||
crate::core::output::field(
|
||||
"total_bytes",
|
||||
"캐시 총 용량(bytes)",
|
||||
serde_json::json!(status.total_bytes),
|
||||
),
|
||||
crate::core::output::field(
|
||||
"namespaces",
|
||||
"최상위 캐시 namespace별 파일 수와 용량",
|
||||
serde_json::json!(status.namespaces),
|
||||
),
|
||||
],
|
||||
serde_json::json!(status),
|
||||
)
|
||||
),
|
||||
Err(err) => eprintln!(
|
||||
"{}",
|
||||
crate::core::output::error("cache status", "캐시 상태 조회 실패", &err)
|
||||
),
|
||||
},
|
||||
CacheCommands::Prune(command) => {
|
||||
let reports = match prune_all(command.dry_run) {
|
||||
Ok(reports) => reports,
|
||||
Err(err) => {
|
||||
eprintln!(
|
||||
"{}",
|
||||
crate::core::output::error("cache prune", "캐시 정리 실패", &err)
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let deleted_files = reports
|
||||
.iter()
|
||||
.map(|report| report.deleted_files)
|
||||
.sum::<usize>();
|
||||
let deleted_bytes = reports
|
||||
.iter()
|
||||
.map(|report| report.deleted_bytes)
|
||||
.sum::<u64>();
|
||||
println!(
|
||||
"{}",
|
||||
crate::core::output::explained_with_raw(
|
||||
"cache prune",
|
||||
"로컬 캐시 보존 정책 적용 결과",
|
||||
vec![
|
||||
crate::core::output::field(
|
||||
"dry_run",
|
||||
"실제 삭제 없이 삭제 예정 항목만 계산했는지 여부",
|
||||
serde_json::json!(command.dry_run),
|
||||
),
|
||||
crate::core::output::field(
|
||||
"deleted_files",
|
||||
"삭제했거나 dry-run에서 삭제 예정인 파일 수",
|
||||
serde_json::json!(deleted_files),
|
||||
),
|
||||
crate::core::output::field(
|
||||
"deleted_bytes",
|
||||
"삭제했거나 dry-run에서 삭제 예정인 용량(bytes)",
|
||||
serde_json::json!(deleted_bytes),
|
||||
),
|
||||
crate::core::output::field(
|
||||
"reports",
|
||||
"namespace별 보존 정책과 정리 결과",
|
||||
serde_json::json!(reports),
|
||||
),
|
||||
],
|
||||
serde_json::json!(reports),
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn prune_all(dry_run: bool) -> Result<Vec<crate::core::cache::CachePruneReport>, String> {
|
||||
Ok(vec![
|
||||
kind::universe::prune_cache(dry_run)?,
|
||||
opendart::document::prune_cache(dry_run)?,
|
||||
])
|
||||
}
|
||||
@@ -0,0 +1,642 @@
|
||||
use crate::providers::opendart::{corp_codes, disclosures, document};
|
||||
use clap::{Args, Subcommand};
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum DartCommands {
|
||||
/// OpenDART 상장사 고유번호 매핑 캐시 갱신
|
||||
Sync(DartSyncCommand),
|
||||
|
||||
/// OpenDART 고유번호 매핑 캐시 상태 조회
|
||||
Status,
|
||||
|
||||
/// 종목코드로 OpenDART 고유번호 조회
|
||||
Corp(DartCorpCommand),
|
||||
|
||||
/// 전체 또는 특정 종목의 공시목록 조회
|
||||
Disclosures(DartDisclosuresCommand),
|
||||
|
||||
/// 접수번호로 공시서류 원본파일 조회
|
||||
Document(DartDocumentCommand),
|
||||
|
||||
/// 종목코드로 공시목록과 선택한 공시 원문 조회
|
||||
Show(DartShowCommand),
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct DartSyncCommand {
|
||||
/// 오늘 이미 갱신된 캐시가 있어도 다시 다운로드
|
||||
#[arg(long)]
|
||||
pub force: bool,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct DartCorpCommand {
|
||||
/// KRX 종목코드
|
||||
pub symbol: String,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct DartDisclosuresCommand {
|
||||
/// KRX 종목코드. 생략하면 전체 공시 검색
|
||||
pub symbol: Option<String>,
|
||||
|
||||
/// DART 고유번호. symbol보다 우선
|
||||
#[arg(long)]
|
||||
pub corp_code: Option<String>,
|
||||
|
||||
/// 검색 시작일 YYYYMMDD
|
||||
#[arg(long = "from")]
|
||||
pub from: Option<String>,
|
||||
|
||||
/// 검색 종료일 YYYYMMDD
|
||||
#[arg(long = "to")]
|
||||
pub to: Option<String>,
|
||||
|
||||
/// 법인구분: Y=유가, K=코스닥, N=코넥스, E=기타
|
||||
#[arg(long)]
|
||||
pub corp_cls: Option<String>,
|
||||
|
||||
/// 페이지 번호
|
||||
#[arg(long, default_value_t = 1)]
|
||||
pub page_no: u32,
|
||||
|
||||
/// 페이지당 건수 (1~100)
|
||||
#[arg(long, default_value_t = 20)]
|
||||
pub page_count: u32,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct DartDocumentCommand {
|
||||
/// DART 공시 접수번호
|
||||
pub rcept_no: String,
|
||||
|
||||
/// 캐시가 있어도 다시 다운로드
|
||||
#[arg(long)]
|
||||
pub force: bool,
|
||||
|
||||
/// 출력할 본문 텍스트 최대 글자 수
|
||||
#[arg(long, default_value_t = 20_000)]
|
||||
pub max_chars: usize,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct DartShowCommand {
|
||||
/// KRX 종목코드
|
||||
pub symbol: String,
|
||||
|
||||
/// 검색 시작일 YYYYMMDD
|
||||
#[arg(long = "from")]
|
||||
pub from: Option<String>,
|
||||
|
||||
/// 검색 종료일 YYYYMMDD
|
||||
#[arg(long = "to")]
|
||||
pub to: Option<String>,
|
||||
|
||||
/// 공시목록에서 열람할 항목 번호. 1이면 최신 첫 번째 항목
|
||||
#[arg(long, default_value_t = 1)]
|
||||
pub index: usize,
|
||||
|
||||
/// 공시목록 조회 건수 (1~100)
|
||||
#[arg(long, default_value_t = 20)]
|
||||
pub page_count: u32,
|
||||
|
||||
/// 캐시가 있어도 원문을 다시 다운로드
|
||||
#[arg(long)]
|
||||
pub force: bool,
|
||||
|
||||
/// 출력할 본문 텍스트 최대 글자 수
|
||||
#[arg(long, default_value_t = 20_000)]
|
||||
pub max_chars: usize,
|
||||
}
|
||||
|
||||
pub fn handle_dart(sub: &DartCommands) {
|
||||
match sub {
|
||||
DartCommands::Sync(command) => match corp_codes::load_or_refresh(command.force) {
|
||||
Ok(snapshot) => print_corp_snapshot(
|
||||
"dart sync",
|
||||
"OpenDART 상장사 고유번호 매핑 캐시 갱신 결과",
|
||||
&snapshot,
|
||||
),
|
||||
Err(err) => eprintln!(
|
||||
"{}",
|
||||
crate::core::output::error(
|
||||
"dart sync",
|
||||
"OpenDART 고유번호 매핑 캐시 갱신 실패",
|
||||
&err
|
||||
)
|
||||
),
|
||||
},
|
||||
DartCommands::Status => match corp_codes::load_cached() {
|
||||
Ok(Some(snapshot)) => {
|
||||
print_corp_snapshot("dart status", "OpenDART 고유번호 매핑 캐시 상태", &snapshot)
|
||||
}
|
||||
Ok(None) => eprintln!(
|
||||
"{}",
|
||||
crate::core::output::error(
|
||||
"dart status",
|
||||
"OpenDART 고유번호 매핑 캐시 상태 조회 실패",
|
||||
"로컬 OpenDART 캐시가 없습니다. `openstock dart sync`를 먼저 실행하세요.",
|
||||
)
|
||||
),
|
||||
Err(err) => eprintln!(
|
||||
"{}",
|
||||
crate::core::output::error(
|
||||
"dart status",
|
||||
"OpenDART 고유번호 매핑 캐시 상태 조회 실패",
|
||||
&err
|
||||
)
|
||||
),
|
||||
},
|
||||
DartCommands::Corp(command) => match corp_codes::load_or_refresh(false) {
|
||||
Ok(snapshot) => {
|
||||
let symbol = command.symbol.trim();
|
||||
match corp_codes::find_by_stock_code(&snapshot.corps, symbol) {
|
||||
Some(corp) => println!(
|
||||
"{}",
|
||||
crate::core::output::explained_with_raw(
|
||||
"dart corp",
|
||||
"KRX 종목코드에 대응하는 OpenDART 고유번호 조회 결과",
|
||||
vec![
|
||||
crate::core::output::field(
|
||||
"stock_code",
|
||||
"KRX 종목코드",
|
||||
serde_json::json!(corp.stock_code),
|
||||
),
|
||||
crate::core::output::field(
|
||||
"corp_code",
|
||||
"OpenDART 공시 API에서 사용하는 8자리 고유번호",
|
||||
serde_json::json!(corp.corp_code),
|
||||
),
|
||||
crate::core::output::field(
|
||||
"corp_name",
|
||||
"OpenDART에 등록된 회사명",
|
||||
serde_json::json!(corp.corp_name),
|
||||
),
|
||||
crate::core::output::field(
|
||||
"modify_date",
|
||||
"OpenDART 고유번호 정보의 최근 변경일자",
|
||||
serde_json::json!(corp.modify_date),
|
||||
),
|
||||
],
|
||||
serde_json::json!({
|
||||
"meta": snapshot.meta,
|
||||
"corp": corp,
|
||||
}),
|
||||
)
|
||||
),
|
||||
None => eprintln!(
|
||||
"{}",
|
||||
crate::core::output::error(
|
||||
"dart corp",
|
||||
"OpenDART 고유번호 조회 실패",
|
||||
&format!(
|
||||
"종목코드 {}에 대응하는 DART 고유번호를 찾지 못했습니다.",
|
||||
symbol
|
||||
),
|
||||
)
|
||||
),
|
||||
}
|
||||
}
|
||||
Err(err) => eprintln!(
|
||||
"{}",
|
||||
crate::core::output::error("dart corp", "OpenDART 고유번호 조회 실패", &err)
|
||||
),
|
||||
},
|
||||
DartCommands::Disclosures(command) => match disclosure_query(command) {
|
||||
Ok((query, resolved)) => match disclosures::list(&query) {
|
||||
Ok(value) => print_disclosures(&query, &resolved, value),
|
||||
Err(err) => eprintln!(
|
||||
"{}",
|
||||
crate::core::output::error(
|
||||
"dart disclosures",
|
||||
"OpenDART 공시목록 조회 실패",
|
||||
&err
|
||||
)
|
||||
),
|
||||
},
|
||||
Err(err) => eprintln!(
|
||||
"{}",
|
||||
crate::core::output::error("dart disclosures", "OpenDART 공시목록 조회 실패", &err)
|
||||
),
|
||||
},
|
||||
DartCommands::Document(command) => {
|
||||
let options = document::DartDocumentOptions {
|
||||
rcept_no: command.rcept_no.trim().to_string(),
|
||||
force: command.force,
|
||||
max_chars: command.max_chars,
|
||||
};
|
||||
match document::get(&options) {
|
||||
Ok(value) => print_document(value),
|
||||
Err(err) => eprintln!(
|
||||
"{}",
|
||||
crate::core::output::error(
|
||||
"dart document",
|
||||
"OpenDART 공시서류 원본파일 조회 실패",
|
||||
&err
|
||||
)
|
||||
),
|
||||
}
|
||||
}
|
||||
DartCommands::Show(command) => match show_query(command) {
|
||||
Ok((query, resolved)) => match disclosures::list(&query) {
|
||||
Ok(disclosures_value) => {
|
||||
match selected_disclosure(&disclosures_value, command.index) {
|
||||
Ok(selected) => {
|
||||
let Some(rcept_no) =
|
||||
selected.get("rcept_no").and_then(|value| value.as_str())
|
||||
else {
|
||||
eprintln!(
|
||||
"{}",
|
||||
crate::core::output::error(
|
||||
"dart show",
|
||||
"종목 공시 원문 조회 실패",
|
||||
"선택한 공시 항목에 rcept_no가 없습니다.",
|
||||
)
|
||||
);
|
||||
return;
|
||||
};
|
||||
let options = document::DartDocumentOptions {
|
||||
rcept_no: rcept_no.to_string(),
|
||||
force: command.force,
|
||||
max_chars: command.max_chars,
|
||||
};
|
||||
match document::get(&options) {
|
||||
Ok(document) => print_show(
|
||||
command,
|
||||
&query,
|
||||
&resolved,
|
||||
&disclosures_value,
|
||||
selected,
|
||||
document,
|
||||
),
|
||||
Err(err) => eprintln!(
|
||||
"{}",
|
||||
crate::core::output::error(
|
||||
"dart show",
|
||||
"종목 공시 원문 조회 실패",
|
||||
&err
|
||||
)
|
||||
),
|
||||
}
|
||||
}
|
||||
Err(err) => eprintln!(
|
||||
"{}",
|
||||
crate::core::output::error(
|
||||
"dart show",
|
||||
"종목 공시 원문 조회 실패",
|
||||
&err
|
||||
)
|
||||
),
|
||||
}
|
||||
}
|
||||
Err(err) => eprintln!(
|
||||
"{}",
|
||||
crate::core::output::error("dart show", "종목 공시목록 조회 실패", &err)
|
||||
),
|
||||
},
|
||||
Err(err) => eprintln!(
|
||||
"{}",
|
||||
crate::core::output::error("dart show", "종목 공시 원문 조회 실패", &err)
|
||||
),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn print_corp_snapshot(
|
||||
command: &str,
|
||||
description: &str,
|
||||
snapshot: &corp_codes::DartCorpCodeSnapshot,
|
||||
) {
|
||||
println!(
|
||||
"{}",
|
||||
crate::core::output::explained_with_raw(
|
||||
command,
|
||||
description,
|
||||
vec![
|
||||
crate::core::output::field(
|
||||
"source",
|
||||
"공시정보 원천 데이터",
|
||||
serde_json::json!(snapshot.meta.source),
|
||||
),
|
||||
crate::core::output::field(
|
||||
"cache_date",
|
||||
"캐시 기준일",
|
||||
serde_json::json!(snapshot.meta.date),
|
||||
),
|
||||
crate::core::output::field(
|
||||
"refreshed_at",
|
||||
"캐시 갱신 시각",
|
||||
serde_json::json!(snapshot.meta.refreshed_at),
|
||||
),
|
||||
crate::core::output::field(
|
||||
"refreshed",
|
||||
"이번 명령에서 새로 다운로드했는지 여부",
|
||||
serde_json::json!(snapshot.refreshed),
|
||||
),
|
||||
crate::core::output::field(
|
||||
"total_count",
|
||||
"OpenDART 고유번호 전체 항목 수",
|
||||
serde_json::json!(snapshot.meta.total_count),
|
||||
),
|
||||
crate::core::output::field(
|
||||
"listed_count",
|
||||
"stock_code가 존재하는 상장사 매핑 수",
|
||||
serde_json::json!(snapshot.meta.listed_count),
|
||||
),
|
||||
],
|
||||
serde_json::json!(snapshot.meta),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
fn disclosure_query(
|
||||
command: &DartDisclosuresCommand,
|
||||
) -> Result<(disclosures::DisclosureQuery, serde_json::Value), String> {
|
||||
let mut resolved = serde_json::json!({
|
||||
"input_symbol": command.symbol,
|
||||
"input_corp_code": command.corp_code,
|
||||
});
|
||||
let corp_code = if let Some(corp_code) = command
|
||||
.corp_code
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
Some(corp_code.to_string())
|
||||
} else if let Some(symbol) = command
|
||||
.symbol
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
let snapshot = corp_codes::load_or_refresh(false)?;
|
||||
let corp = corp_codes::find_by_stock_code(&snapshot.corps, symbol).ok_or_else(|| {
|
||||
format!(
|
||||
"종목코드 {}에 대응하는 DART 고유번호를 찾지 못했습니다.",
|
||||
symbol
|
||||
)
|
||||
})?;
|
||||
resolved["resolved_corp"] = serde_json::json!(corp);
|
||||
Some(corp.corp_code.clone())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Ok((
|
||||
disclosures::DisclosureQuery {
|
||||
corp_code,
|
||||
bgn_de: command.from.clone(),
|
||||
end_de: command.to.clone(),
|
||||
corp_cls: command.corp_cls.clone(),
|
||||
page_no: command.page_no,
|
||||
page_count: command.page_count,
|
||||
},
|
||||
resolved,
|
||||
))
|
||||
}
|
||||
|
||||
fn show_query(
|
||||
command: &DartShowCommand,
|
||||
) -> Result<(disclosures::DisclosureQuery, serde_json::Value), String> {
|
||||
if command.index == 0 {
|
||||
return Err("--index는 1 이상이어야 합니다.".to_string());
|
||||
}
|
||||
let symbol = command.symbol.trim();
|
||||
let snapshot = corp_codes::load_or_refresh(false)?;
|
||||
let corp = corp_codes::find_by_stock_code(&snapshot.corps, symbol).ok_or_else(|| {
|
||||
format!(
|
||||
"종목코드 {}에 대응하는 DART 고유번호를 찾지 못했습니다.",
|
||||
symbol
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok((
|
||||
disclosures::DisclosureQuery {
|
||||
corp_code: Some(corp.corp_code.clone()),
|
||||
bgn_de: command.from.clone(),
|
||||
end_de: command.to.clone(),
|
||||
corp_cls: None,
|
||||
page_no: 1,
|
||||
page_count: command.page_count,
|
||||
},
|
||||
serde_json::json!({
|
||||
"input_symbol": symbol,
|
||||
"resolved_corp": corp,
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
fn selected_disclosure(
|
||||
disclosures_value: &serde_json::Value,
|
||||
index: usize,
|
||||
) -> Result<&serde_json::Value, String> {
|
||||
let list = disclosures_value
|
||||
.get("list")
|
||||
.and_then(|value| value.as_array())
|
||||
.ok_or("공시목록 응답에 list가 없습니다.")?;
|
||||
if list.is_empty() {
|
||||
return Err("조회된 공시가 없습니다.".to_string());
|
||||
}
|
||||
list.get(index - 1).ok_or_else(|| {
|
||||
format!(
|
||||
"--index {}에 해당하는 공시가 없습니다. 현재 조회된 공시는 {}건입니다.",
|
||||
index,
|
||||
list.len()
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn print_disclosures(
|
||||
query: &disclosures::DisclosureQuery,
|
||||
resolved: &serde_json::Value,
|
||||
value: serde_json::Value,
|
||||
) {
|
||||
let list = value
|
||||
.get("list")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| serde_json::json!([]));
|
||||
println!(
|
||||
"{}",
|
||||
crate::core::output::explained_with_raw(
|
||||
"dart disclosures",
|
||||
"OpenDART 공시목록 조회 결과",
|
||||
vec![
|
||||
crate::core::output::field(
|
||||
"corp_code",
|
||||
"조회에 사용한 OpenDART 고유번호. 없으면 전체 공시 조회",
|
||||
query
|
||||
.corp_code
|
||||
.as_ref()
|
||||
.map(|value| serde_json::json!(value))
|
||||
.unwrap_or(serde_json::Value::Null),
|
||||
),
|
||||
crate::core::output::field(
|
||||
"from",
|
||||
"검색 시작일 YYYYMMDD",
|
||||
query
|
||||
.bgn_de
|
||||
.as_ref()
|
||||
.map(|value| serde_json::json!(value))
|
||||
.unwrap_or(serde_json::Value::Null),
|
||||
),
|
||||
crate::core::output::field(
|
||||
"to",
|
||||
"검색 종료일 YYYYMMDD",
|
||||
query
|
||||
.end_de
|
||||
.as_ref()
|
||||
.map(|value| serde_json::json!(value))
|
||||
.unwrap_or(serde_json::Value::Null),
|
||||
),
|
||||
crate::core::output::field(
|
||||
"page",
|
||||
"조회 페이지 정보",
|
||||
serde_json::json!({
|
||||
"page_no": query.page_no,
|
||||
"page_count": query.page_count,
|
||||
"total_count": value.get("total_count").cloned().unwrap_or(serde_json::Value::Null),
|
||||
"total_page": value.get("total_page").cloned().unwrap_or(serde_json::Value::Null),
|
||||
}),
|
||||
),
|
||||
crate::core::output::field(
|
||||
"resolved",
|
||||
"종목코드를 입력한 경우 OpenDART 고유번호로 변환한 정보",
|
||||
resolved.clone(),
|
||||
),
|
||||
crate::core::output::field("disclosures", "조회된 공시 목록", list),
|
||||
],
|
||||
value,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
fn print_document(value: document::DartDocument) {
|
||||
println!(
|
||||
"{}",
|
||||
crate::core::output::explained_with_raw(
|
||||
"dart document",
|
||||
"OpenDART 공시서류 원본파일 조회 및 텍스트 추출 결과",
|
||||
vec![
|
||||
crate::core::output::field(
|
||||
"rcept_no",
|
||||
"DART 공시 접수번호",
|
||||
serde_json::json!(value.rcept_no),
|
||||
),
|
||||
crate::core::output::field(
|
||||
"source",
|
||||
"공시서류 원본파일 제공 원천",
|
||||
serde_json::json!(value.source),
|
||||
),
|
||||
crate::core::output::field(
|
||||
"viewer_url",
|
||||
"브라우저에서 확인 가능한 DART 공시 뷰어 URL",
|
||||
serde_json::json!(value.viewer_url),
|
||||
),
|
||||
crate::core::output::field(
|
||||
"cached",
|
||||
"로컬 ZIP 캐시를 재사용했는지 여부",
|
||||
serde_json::json!(value.cached),
|
||||
),
|
||||
crate::core::output::field(
|
||||
"zip_path",
|
||||
"로컬에 저장된 공시서류 ZIP 캐시 경로",
|
||||
serde_json::json!(value.zip_path),
|
||||
),
|
||||
crate::core::output::field(
|
||||
"zip_bytes",
|
||||
"공시서류 ZIP 파일 크기(bytes)",
|
||||
serde_json::json!(value.zip_bytes),
|
||||
),
|
||||
crate::core::output::field(
|
||||
"files",
|
||||
"ZIP 내부 파일 목록과 각 파일에서 추출한 텍스트 길이",
|
||||
serde_json::json!(value.files),
|
||||
),
|
||||
crate::core::output::field(
|
||||
"text",
|
||||
"XML 태그를 제거한 공시 본문 텍스트. max_chars를 넘으면 앞부분만 제공",
|
||||
serde_json::json!(value.text),
|
||||
),
|
||||
crate::core::output::field(
|
||||
"text_chars",
|
||||
"출력된 본문 텍스트 글자 수",
|
||||
serde_json::json!(value.text_chars),
|
||||
),
|
||||
crate::core::output::field(
|
||||
"truncated",
|
||||
"본문이 max_chars 제한으로 잘렸는지 여부",
|
||||
serde_json::json!(value.truncated),
|
||||
),
|
||||
],
|
||||
serde_json::json!(value),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
fn print_show(
|
||||
command: &DartShowCommand,
|
||||
query: &disclosures::DisclosureQuery,
|
||||
resolved: &serde_json::Value,
|
||||
disclosures_value: &serde_json::Value,
|
||||
selected: &serde_json::Value,
|
||||
document: document::DartDocument,
|
||||
) {
|
||||
let list = disclosures_value
|
||||
.get("list")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| serde_json::json!([]));
|
||||
println!(
|
||||
"{}",
|
||||
crate::core::output::explained_with_raw(
|
||||
"dart show",
|
||||
"종목코드 기준 공시목록과 선택한 공시 원문 조회 결과",
|
||||
vec![
|
||||
crate::core::output::field(
|
||||
"symbol",
|
||||
"조회한 KRX 종목코드",
|
||||
serde_json::json!(command.symbol),
|
||||
),
|
||||
crate::core::output::field(
|
||||
"resolved",
|
||||
"종목코드를 OpenDART 고유번호로 변환한 정보",
|
||||
resolved.clone(),
|
||||
),
|
||||
crate::core::output::field(
|
||||
"date_range",
|
||||
"공시목록 검색 기간. null이면 OpenDART 기본 검색 범위",
|
||||
serde_json::json!({
|
||||
"from": query.bgn_de,
|
||||
"to": query.end_de,
|
||||
}),
|
||||
),
|
||||
crate::core::output::field(
|
||||
"selected_index",
|
||||
"원문을 조회한 공시목록 항목 번호. 1부터 시작",
|
||||
serde_json::json!(command.index),
|
||||
),
|
||||
crate::core::output::field(
|
||||
"selected_disclosure",
|
||||
"원문 조회 대상으로 선택된 공시 항목",
|
||||
selected.clone(),
|
||||
),
|
||||
crate::core::output::field(
|
||||
"disclosures",
|
||||
"조회된 공시목록. 다른 항목을 열려면 --index 값을 바꾼다.",
|
||||
list,
|
||||
),
|
||||
crate::core::output::field(
|
||||
"document",
|
||||
"선택된 공시의 원문 ZIP 캐시 정보와 추출 본문",
|
||||
serde_json::json!(document),
|
||||
),
|
||||
],
|
||||
serde_json::json!({
|
||||
"query": query,
|
||||
"resolved": resolved,
|
||||
"selected_disclosure": selected,
|
||||
"disclosures": disclosures_value,
|
||||
"document": document,
|
||||
}),
|
||||
)
|
||||
);
|
||||
}
|
||||
+150
-1
@@ -1,7 +1,62 @@
|
||||
use crate::apis::kis::KisApi;
|
||||
use crate::core::TraderApi;
|
||||
use clap::{Args, Subcommand};
|
||||
|
||||
pub fn handle_market(symbol: &str) {
|
||||
#[derive(Args)]
|
||||
pub struct MarketCommand {
|
||||
/// 종목코드. 하위 명령 없이 지정하면 현재가와 기업 기본 정보를 조회
|
||||
pub symbol: Option<String>,
|
||||
|
||||
#[command(subcommand)]
|
||||
pub sub: Option<MarketCommands>,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum MarketCommands {
|
||||
/// 기간별 OHLCV 가격 히스토리 조회
|
||||
History(MarketHistoryCommand),
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct MarketHistoryCommand {
|
||||
/// 종목코드
|
||||
pub symbol: String,
|
||||
|
||||
/// 조회 시작일 YYYYMMDD
|
||||
#[arg(long = "from")]
|
||||
pub from: String,
|
||||
|
||||
/// 조회 종료일 YYYYMMDD
|
||||
#[arg(long = "to")]
|
||||
pub to: String,
|
||||
|
||||
/// 기간 구분: D=일봉, W=주봉, M=월봉, Y=년봉
|
||||
#[arg(long, default_value = "D")]
|
||||
pub period: String,
|
||||
|
||||
/// 원주가를 조회. 기본값은 수정주가
|
||||
#[arg(long)]
|
||||
pub raw_price: bool,
|
||||
}
|
||||
|
||||
pub fn handle_market(command: &MarketCommand) {
|
||||
match &command.sub {
|
||||
Some(MarketCommands::History(history)) => handle_market_history(history),
|
||||
None => match command.symbol.as_deref() {
|
||||
Some(symbol) => handle_market_overview(symbol),
|
||||
None => eprintln!(
|
||||
"{}",
|
||||
crate::core::output::error(
|
||||
"market",
|
||||
"종목 정보 조회 실패",
|
||||
"종목코드를 지정하거나 `market history` 하위 명령을 사용하세요.",
|
||||
)
|
||||
),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_market_overview(symbol: &str) {
|
||||
let api = KisApi::new();
|
||||
match api.market(symbol) {
|
||||
Ok(json) => {
|
||||
@@ -55,3 +110,97 @@ pub fn handle_market(symbol: &str) {
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_market_history(command: &MarketHistoryCommand) {
|
||||
let api = KisApi::new();
|
||||
let adjusted = !command.raw_price;
|
||||
match api.market_history(
|
||||
&command.symbol,
|
||||
&command.from,
|
||||
&command.to,
|
||||
&command.period,
|
||||
adjusted,
|
||||
) {
|
||||
Ok(json) => {
|
||||
let value = crate::core::output::parse_json_or_text(&json);
|
||||
println!(
|
||||
"{}",
|
||||
crate::core::output::explained_with_raw(
|
||||
"market history",
|
||||
"종목 기간별 OHLCV 가격 히스토리 조회 결과",
|
||||
vec![
|
||||
crate::core::output::field(
|
||||
"broker",
|
||||
"조회에 사용한 증권사 API",
|
||||
value
|
||||
.get("broker")
|
||||
.cloned()
|
||||
.unwrap_or(serde_json::Value::Null),
|
||||
),
|
||||
crate::core::output::field(
|
||||
"symbol",
|
||||
"조회한 종목코드",
|
||||
value
|
||||
.get("symbol")
|
||||
.cloned()
|
||||
.unwrap_or(serde_json::Value::Null),
|
||||
),
|
||||
crate::core::output::field(
|
||||
"period",
|
||||
"가격 봉 단위. D=일봉, W=주봉, M=월봉, Y=년봉",
|
||||
value
|
||||
.get("period")
|
||||
.cloned()
|
||||
.unwrap_or(serde_json::Value::Null),
|
||||
),
|
||||
crate::core::output::field(
|
||||
"date_range",
|
||||
"조회 요청 기간",
|
||||
serde_json::json!({
|
||||
"from": value.get("start_date").cloned().unwrap_or(serde_json::Value::Null),
|
||||
"to": value.get("end_date").cloned().unwrap_or(serde_json::Value::Null),
|
||||
}),
|
||||
),
|
||||
crate::core::output::field(
|
||||
"adjusted",
|
||||
"수정주가 조회 여부. false이면 원주가 조회",
|
||||
value
|
||||
.get("adjusted")
|
||||
.cloned()
|
||||
.unwrap_or(serde_json::Value::Null),
|
||||
),
|
||||
crate::core::output::field(
|
||||
"count",
|
||||
"조회된 가격 봉 개수",
|
||||
value
|
||||
.get("count")
|
||||
.cloned()
|
||||
.unwrap_or(serde_json::Value::Null),
|
||||
),
|
||||
crate::core::output::field(
|
||||
"candles",
|
||||
"날짜 오름차순 OHLCV 배열. open/high/low/close/volume/trading_value는 문자열 숫자로 보존한다.",
|
||||
value
|
||||
.get("candles")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| serde_json::json!([])),
|
||||
),
|
||||
crate::core::output::field(
|
||||
"summary",
|
||||
"KIS가 반환한 종목 요약 정보",
|
||||
value
|
||||
.get("summary")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| serde_json::json!({})),
|
||||
),
|
||||
],
|
||||
value,
|
||||
)
|
||||
);
|
||||
}
|
||||
Err(err) => eprintln!(
|
||||
"{}",
|
||||
crate::core::output::error("market history", "기간별 시세 조회 실패", &err)
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
pub mod account;
|
||||
pub mod api;
|
||||
pub mod cache;
|
||||
pub mod dart;
|
||||
pub mod market;
|
||||
pub mod order;
|
||||
pub mod search;
|
||||
pub mod universe;
|
||||
mod version;
|
||||
|
||||
pub use account::handle_account;
|
||||
pub use api::handle_api;
|
||||
pub use cache::handle_cache;
|
||||
pub use dart::handle_dart;
|
||||
pub use market::handle_market;
|
||||
pub use order::handle_order;
|
||||
pub use search::handle_search;
|
||||
pub use universe::handle_universe;
|
||||
pub use version::handle_version;
|
||||
|
||||
@@ -0,0 +1,604 @@
|
||||
use crate::core::stock::{Stock, StockKind, StockMarket};
|
||||
use crate::providers::kind;
|
||||
use clap::{Args, Subcommand};
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum UniverseCommands {
|
||||
/// KIND 상장법인목록을 내려받아 로컬 universe 캐시를 갱신
|
||||
Sync(UniverseSyncCommand),
|
||||
|
||||
/// 로컬 universe 캐시 상태 조회
|
||||
Status,
|
||||
|
||||
/// 로컬 universe 종목 목록 조회
|
||||
List(UniverseListCommand),
|
||||
|
||||
/// 로컬 universe를 chunk 단위로 분할
|
||||
Chunks(UniverseChunksCommand),
|
||||
|
||||
/// 로컬 universe 캐시의 크기와 대표 값을 검증
|
||||
Validate(UniverseValidateCommand),
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct UniverseSyncCommand {
|
||||
/// 오늘 이미 갱신된 캐시가 있어도 다시 다운로드
|
||||
#[arg(long)]
|
||||
pub force: bool,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct UniverseListCommand {
|
||||
/// 시장 필터: KOSPI, KOSDAQ, KONEX
|
||||
#[arg(long)]
|
||||
pub market: Option<String>,
|
||||
|
||||
/// 종목 유형 필터: common_stock, preferred_stock, etf, etn, reit, spac
|
||||
#[arg(long)]
|
||||
pub kind: Option<String>,
|
||||
|
||||
/// 반환할 최대 종목 수
|
||||
#[arg(long, default_value_t = 100)]
|
||||
pub limit: usize,
|
||||
|
||||
/// 건너뛸 종목 수
|
||||
#[arg(long, default_value_t = 0)]
|
||||
pub offset: usize,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct UniverseChunksCommand {
|
||||
/// 시장 필터: KOSPI, KOSDAQ, KONEX
|
||||
#[arg(long)]
|
||||
pub market: Option<String>,
|
||||
|
||||
/// 종목 유형 필터: common_stock, preferred_stock, etf, etn, reit, spac
|
||||
#[arg(long)]
|
||||
pub kind: Option<String>,
|
||||
|
||||
/// chunk당 종목 수
|
||||
#[arg(long, default_value_t = 100)]
|
||||
pub size: usize,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct UniverseValidateCommand {
|
||||
/// 기대하는 최소 전체 종목 수
|
||||
#[arg(long, default_value_t = 2500)]
|
||||
pub min_count: usize,
|
||||
|
||||
/// 기대하는 최소 KOSPI 종목 수
|
||||
#[arg(long, default_value_t = 700)]
|
||||
pub min_kospi: usize,
|
||||
|
||||
/// 기대하는 최소 KOSDAQ 종목 수
|
||||
#[arg(long, default_value_t = 1500)]
|
||||
pub min_kosdaq: usize,
|
||||
|
||||
/// 기대하는 최소 KONEX 종목 수
|
||||
#[arg(long, default_value_t = 50)]
|
||||
pub min_konex: usize,
|
||||
|
||||
/// 반드시 존재해야 하는 종목. SYMBOL=NAME 형식
|
||||
#[arg(long = "expect")]
|
||||
pub expects: Vec<String>,
|
||||
}
|
||||
|
||||
pub fn handle_universe(sub: &UniverseCommands) {
|
||||
match sub {
|
||||
UniverseCommands::Sync(command) => match kind::universe::load_or_refresh(command.force) {
|
||||
Ok(snapshot) => {
|
||||
print_snapshot("universe sync", "종목 universe 캐시 갱신 결과", &snapshot)
|
||||
}
|
||||
Err(err) => eprintln!(
|
||||
"{}",
|
||||
crate::core::output::error("universe sync", "종목 universe 캐시 갱신 실패", &err)
|
||||
),
|
||||
},
|
||||
UniverseCommands::Status => match kind::universe::load_cached() {
|
||||
Ok(Some(snapshot)) => {
|
||||
print_snapshot("universe status", "종목 universe 캐시 상태", &snapshot)
|
||||
}
|
||||
Ok(None) => eprintln!(
|
||||
"{}",
|
||||
crate::core::output::error(
|
||||
"universe status",
|
||||
"종목 universe 캐시 상태 조회 실패",
|
||||
"로컬 universe 캐시가 없습니다. `openstock universe sync`를 먼저 실행하세요.",
|
||||
)
|
||||
),
|
||||
Err(err) => eprintln!(
|
||||
"{}",
|
||||
crate::core::output::error(
|
||||
"universe status",
|
||||
"종목 universe 캐시 상태 조회 실패",
|
||||
&err
|
||||
)
|
||||
),
|
||||
},
|
||||
UniverseCommands::List(command) => match kind::universe::load_or_refresh(false) {
|
||||
Ok(snapshot) => match filter_stocks(
|
||||
&snapshot.stocks,
|
||||
command.market.as_deref(),
|
||||
command.kind.as_deref(),
|
||||
) {
|
||||
Ok(filtered) => {
|
||||
let page = filtered
|
||||
.iter()
|
||||
.skip(command.offset)
|
||||
.take(command.limit)
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
println!(
|
||||
"{}",
|
||||
crate::core::output::explained_with_raw(
|
||||
"universe list",
|
||||
"로컬 universe 캐시에서 종목 목록을 조회한 결과",
|
||||
vec![
|
||||
crate::core::output::field(
|
||||
"source",
|
||||
"universe 원천 데이터",
|
||||
serde_json::json!(snapshot.meta.source),
|
||||
),
|
||||
crate::core::output::field(
|
||||
"cache_date",
|
||||
"캐시 기준일",
|
||||
serde_json::json!(snapshot.meta.date),
|
||||
),
|
||||
crate::core::output::field(
|
||||
"total_count",
|
||||
"필터 적용 전 전체 종목 수",
|
||||
serde_json::json!(snapshot.stocks.len()),
|
||||
),
|
||||
crate::core::output::field(
|
||||
"filtered_count",
|
||||
"필터 적용 후 종목 수",
|
||||
serde_json::json!(filtered.len()),
|
||||
),
|
||||
crate::core::output::field(
|
||||
"offset",
|
||||
"조회 시작 offset",
|
||||
serde_json::json!(command.offset),
|
||||
),
|
||||
crate::core::output::field(
|
||||
"limit",
|
||||
"최대 반환 종목 수",
|
||||
serde_json::json!(command.limit),
|
||||
),
|
||||
crate::core::output::field(
|
||||
"stocks",
|
||||
"조회된 종목 목록",
|
||||
serde_json::json!(page),
|
||||
),
|
||||
],
|
||||
serde_json::json!({
|
||||
"meta": snapshot.meta,
|
||||
"stocks": page,
|
||||
}),
|
||||
)
|
||||
);
|
||||
}
|
||||
Err(err) => eprintln!(
|
||||
"{}",
|
||||
crate::core::output::error(
|
||||
"universe list",
|
||||
"종목 universe 목록 조회 실패",
|
||||
&err
|
||||
)
|
||||
),
|
||||
},
|
||||
Err(err) => eprintln!(
|
||||
"{}",
|
||||
crate::core::output::error("universe list", "종목 universe 목록 조회 실패", &err)
|
||||
),
|
||||
},
|
||||
UniverseCommands::Chunks(command) => match kind::universe::load_or_refresh(false) {
|
||||
Ok(snapshot) => match filter_stocks(
|
||||
&snapshot.stocks,
|
||||
command.market.as_deref(),
|
||||
command.kind.as_deref(),
|
||||
) {
|
||||
Ok(filtered) => {
|
||||
let chunks = kind::universe::chunk_stocks(&filtered, command.size);
|
||||
println!(
|
||||
"{}",
|
||||
crate::core::output::explained_with_raw(
|
||||
"universe chunks",
|
||||
"로컬 universe 캐시를 scan 가능한 chunk로 분할한 결과",
|
||||
vec![
|
||||
crate::core::output::field(
|
||||
"source",
|
||||
"universe 원천 데이터",
|
||||
serde_json::json!(snapshot.meta.source),
|
||||
),
|
||||
crate::core::output::field(
|
||||
"cache_date",
|
||||
"캐시 기준일",
|
||||
serde_json::json!(snapshot.meta.date),
|
||||
),
|
||||
crate::core::output::field(
|
||||
"filtered_count",
|
||||
"chunk 생성 대상 종목 수",
|
||||
serde_json::json!(filtered.len()),
|
||||
),
|
||||
crate::core::output::field(
|
||||
"chunk_size",
|
||||
"chunk당 최대 종목 수",
|
||||
serde_json::json!(command.size.max(1)),
|
||||
),
|
||||
crate::core::output::field(
|
||||
"chunk_count",
|
||||
"생성된 chunk 수",
|
||||
serde_json::json!(chunks.len()),
|
||||
),
|
||||
crate::core::output::field(
|
||||
"chunks",
|
||||
"생성된 chunk 목록. start_symbol과 end_symbol은 문자열 정렬 기준 cursor 경계다.",
|
||||
serde_json::json!(chunks),
|
||||
),
|
||||
],
|
||||
serde_json::json!({
|
||||
"meta": snapshot.meta,
|
||||
"chunks": chunks,
|
||||
}),
|
||||
)
|
||||
);
|
||||
}
|
||||
Err(err) => eprintln!(
|
||||
"{}",
|
||||
crate::core::output::error(
|
||||
"universe chunks",
|
||||
"종목 universe chunk 생성 실패",
|
||||
&err
|
||||
)
|
||||
),
|
||||
},
|
||||
Err(err) => eprintln!(
|
||||
"{}",
|
||||
crate::core::output::error(
|
||||
"universe chunks",
|
||||
"종목 universe chunk 생성 실패",
|
||||
&err
|
||||
)
|
||||
),
|
||||
},
|
||||
UniverseCommands::Validate(command) => match kind::universe::load_or_refresh(false) {
|
||||
Ok(snapshot) => match validate_universe(&snapshot.stocks, command) {
|
||||
Ok(report) => println!(
|
||||
"{}",
|
||||
crate::core::output::explained_with_raw(
|
||||
"universe validate",
|
||||
"로컬 universe 캐시의 크기, 중복, 필수 필드, 대표 종목 값을 검증한 결과",
|
||||
vec![
|
||||
crate::core::output::field(
|
||||
"valid",
|
||||
"검증 통과 여부",
|
||||
serde_json::json!(report.valid),
|
||||
),
|
||||
crate::core::output::field(
|
||||
"stock_count",
|
||||
"검증 대상 전체 종목 수",
|
||||
serde_json::json!(report.stock_count),
|
||||
),
|
||||
crate::core::output::field(
|
||||
"counts_by_market",
|
||||
"시장별 종목 수",
|
||||
serde_json::json!(report.counts_by_market),
|
||||
),
|
||||
crate::core::output::field(
|
||||
"checks",
|
||||
"개별 검증 항목과 결과",
|
||||
serde_json::json!(report.checks),
|
||||
),
|
||||
crate::core::output::field(
|
||||
"errors",
|
||||
"실패한 검증 항목 설명",
|
||||
serde_json::json!(report.errors),
|
||||
),
|
||||
],
|
||||
serde_json::json!({
|
||||
"meta": snapshot.meta,
|
||||
"report": report,
|
||||
}),
|
||||
)
|
||||
),
|
||||
Err(err) => eprintln!(
|
||||
"{}",
|
||||
crate::core::output::error(
|
||||
"universe validate",
|
||||
"종목 universe 검증 실패",
|
||||
&err
|
||||
)
|
||||
),
|
||||
},
|
||||
Err(err) => eprintln!(
|
||||
"{}",
|
||||
crate::core::output::error("universe validate", "종목 universe 검증 실패", &err)
|
||||
),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn print_snapshot(command: &str, description: &str, snapshot: &kind::universe::UniverseSnapshot) {
|
||||
println!(
|
||||
"{}",
|
||||
crate::core::output::explained_with_raw(
|
||||
command,
|
||||
description,
|
||||
vec![
|
||||
crate::core::output::field(
|
||||
"source",
|
||||
"universe 원천 데이터",
|
||||
serde_json::json!(snapshot.meta.source),
|
||||
),
|
||||
crate::core::output::field(
|
||||
"source_url",
|
||||
"universe를 내려받은 원천 URL",
|
||||
serde_json::json!(snapshot.meta.source_url),
|
||||
),
|
||||
crate::core::output::field(
|
||||
"cache_date",
|
||||
"캐시 기준일",
|
||||
serde_json::json!(snapshot.meta.date),
|
||||
),
|
||||
crate::core::output::field(
|
||||
"refreshed_at",
|
||||
"캐시 갱신 시각",
|
||||
serde_json::json!(snapshot.meta.refreshed_at),
|
||||
),
|
||||
crate::core::output::field(
|
||||
"refreshed",
|
||||
"이번 명령에서 새로 다운로드했는지 여부",
|
||||
serde_json::json!(snapshot.refreshed),
|
||||
),
|
||||
crate::core::output::field(
|
||||
"stock_count",
|
||||
"캐시에 저장된 전체 종목 수",
|
||||
serde_json::json!(snapshot.meta.stock_count),
|
||||
),
|
||||
crate::core::output::field(
|
||||
"counts_by_market",
|
||||
"시장별 종목 수",
|
||||
serde_json::json!(snapshot.meta.counts_by_market),
|
||||
),
|
||||
],
|
||||
serde_json::json!(snapshot.meta),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
fn filter_stocks(
|
||||
stocks: &[Stock],
|
||||
market: Option<&str>,
|
||||
kind: Option<&str>,
|
||||
) -> Result<Vec<Stock>, String> {
|
||||
let market = market.map(parse_market).transpose()?;
|
||||
let kind = kind.map(parse_kind).transpose()?;
|
||||
let mut filtered = stocks
|
||||
.iter()
|
||||
.filter(|stock| market.map(|value| stock.market == value).unwrap_or(true))
|
||||
.filter(|stock| kind.map(|value| stock.kind == value).unwrap_or(true))
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
filtered.sort_by(|a, b| {
|
||||
a.market
|
||||
.cmp(&b.market)
|
||||
.then_with(|| a.kind.cmp(&b.kind))
|
||||
.then_with(|| a.id.symbol.cmp(&b.id.symbol))
|
||||
});
|
||||
Ok(filtered)
|
||||
}
|
||||
|
||||
fn parse_market(value: &str) -> Result<StockMarket, String> {
|
||||
match value.trim().to_ascii_uppercase().as_str() {
|
||||
"KOSPI" => Ok(StockMarket::Kospi),
|
||||
"KOSDAQ" => Ok(StockMarket::Kosdaq),
|
||||
"KONEX" => Ok(StockMarket::Konex),
|
||||
_ => Err(format!("지원하지 않는 시장 필터입니다: {}", value)),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_kind(value: &str) -> Result<StockKind, String> {
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"common_stock" | "common" => Ok(StockKind::CommonStock),
|
||||
"preferred_stock" | "preferred" => Ok(StockKind::PreferredStock),
|
||||
"etf" => Ok(StockKind::Etf),
|
||||
"etn" => Ok(StockKind::Etn),
|
||||
"reit" => Ok(StockKind::Reit),
|
||||
"spac" => Ok(StockKind::Spac),
|
||||
_ => Err(format!("지원하지 않는 종목 유형 필터입니다: {}", value)),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct UniverseValidationReport {
|
||||
valid: bool,
|
||||
stock_count: usize,
|
||||
counts_by_market: serde_json::Value,
|
||||
checks: Vec<ValidationCheck>,
|
||||
errors: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct ValidationCheck {
|
||||
name: &'static str,
|
||||
description: &'static str,
|
||||
valid: bool,
|
||||
expected: serde_json::Value,
|
||||
actual: serde_json::Value,
|
||||
}
|
||||
|
||||
fn validate_universe(
|
||||
stocks: &[Stock],
|
||||
command: &UniverseValidateCommand,
|
||||
) -> Result<UniverseValidationReport, String> {
|
||||
let mut checks = Vec::new();
|
||||
let mut errors = Vec::new();
|
||||
let counts = market_counts(stocks);
|
||||
|
||||
push_check(
|
||||
&mut checks,
|
||||
&mut errors,
|
||||
"min_count",
|
||||
"전체 종목 수가 기대 최소값 이상인지 검증",
|
||||
serde_json::json!({ "min": command.min_count }),
|
||||
serde_json::json!(stocks.len()),
|
||||
stocks.len() >= command.min_count,
|
||||
);
|
||||
push_check(
|
||||
&mut checks,
|
||||
&mut errors,
|
||||
"min_kospi",
|
||||
"KOSPI 종목 수가 기대 최소값 이상인지 검증",
|
||||
serde_json::json!({ "min": command.min_kospi }),
|
||||
serde_json::json!(counts.kospi),
|
||||
counts.kospi >= command.min_kospi,
|
||||
);
|
||||
push_check(
|
||||
&mut checks,
|
||||
&mut errors,
|
||||
"min_kosdaq",
|
||||
"KOSDAQ 종목 수가 기대 최소값 이상인지 검증",
|
||||
serde_json::json!({ "min": command.min_kosdaq }),
|
||||
serde_json::json!(counts.kosdaq),
|
||||
counts.kosdaq >= command.min_kosdaq,
|
||||
);
|
||||
push_check(
|
||||
&mut checks,
|
||||
&mut errors,
|
||||
"min_konex",
|
||||
"KONEX 종목 수가 기대 최소값 이상인지 검증",
|
||||
serde_json::json!({ "min": command.min_konex }),
|
||||
serde_json::json!(counts.konex),
|
||||
counts.konex >= command.min_konex,
|
||||
);
|
||||
|
||||
let duplicate_symbols = duplicate_symbols(stocks);
|
||||
push_check(
|
||||
&mut checks,
|
||||
&mut errors,
|
||||
"unique_symbols",
|
||||
"종목코드가 중복 없이 유일한지 검증",
|
||||
serde_json::json!([]),
|
||||
serde_json::json!(duplicate_symbols),
|
||||
duplicate_symbols.is_empty(),
|
||||
);
|
||||
|
||||
let invalid_required = stocks
|
||||
.iter()
|
||||
.filter(|stock| stock.id.symbol.trim().is_empty() || stock.name.trim().is_empty())
|
||||
.map(|stock| stock.id.symbol.clone())
|
||||
.collect::<Vec<_>>();
|
||||
push_check(
|
||||
&mut checks,
|
||||
&mut errors,
|
||||
"required_fields",
|
||||
"종목코드와 종목명이 비어 있지 않은지 검증",
|
||||
serde_json::json!([]),
|
||||
serde_json::json!(invalid_required),
|
||||
invalid_required.is_empty(),
|
||||
);
|
||||
|
||||
let default_expects = if command.expects.is_empty() {
|
||||
vec![
|
||||
"005930=삼성전자".to_string(),
|
||||
"000020=동화약품".to_string(),
|
||||
"035720=카카오".to_string(),
|
||||
]
|
||||
} else {
|
||||
command.expects.clone()
|
||||
};
|
||||
|
||||
for expect in default_expects {
|
||||
let (symbol, name) = expect
|
||||
.split_once('=')
|
||||
.ok_or_else(|| format!("--expect 값은 SYMBOL=NAME 형식이어야 합니다: {}", expect))?;
|
||||
let actual = stocks.iter().find(|stock| stock.id.symbol == symbol);
|
||||
let valid = actual.map(|stock| stock.name == name).unwrap_or(false);
|
||||
push_check(
|
||||
&mut checks,
|
||||
&mut errors,
|
||||
"expected_symbol",
|
||||
"대표 종목코드가 기대 종목명으로 존재하는지 검증",
|
||||
serde_json::json!({ "symbol": symbol, "name": name }),
|
||||
serde_json::json!(actual.map(|stock| &stock.name)),
|
||||
valid,
|
||||
);
|
||||
}
|
||||
|
||||
Ok(UniverseValidationReport {
|
||||
valid: errors.is_empty(),
|
||||
stock_count: stocks.len(),
|
||||
counts_by_market: serde_json::json!({
|
||||
"KOSPI": counts.kospi,
|
||||
"KOSDAQ": counts.kosdaq,
|
||||
"KONEX": counts.konex,
|
||||
"OTHER": counts.other,
|
||||
"UNKNOWN": counts.unknown,
|
||||
}),
|
||||
checks,
|
||||
errors,
|
||||
})
|
||||
}
|
||||
|
||||
fn push_check(
|
||||
checks: &mut Vec<ValidationCheck>,
|
||||
errors: &mut Vec<String>,
|
||||
name: &'static str,
|
||||
description: &'static str,
|
||||
expected: serde_json::Value,
|
||||
actual: serde_json::Value,
|
||||
valid: bool,
|
||||
) {
|
||||
if !valid {
|
||||
errors.push(format!("{} 검증 실패", name));
|
||||
}
|
||||
checks.push(ValidationCheck {
|
||||
name,
|
||||
description,
|
||||
valid,
|
||||
expected,
|
||||
actual,
|
||||
});
|
||||
}
|
||||
|
||||
struct MarketCounts {
|
||||
kospi: usize,
|
||||
kosdaq: usize,
|
||||
konex: usize,
|
||||
other: usize,
|
||||
unknown: usize,
|
||||
}
|
||||
|
||||
fn market_counts(stocks: &[Stock]) -> MarketCounts {
|
||||
let mut counts = MarketCounts {
|
||||
kospi: 0,
|
||||
kosdaq: 0,
|
||||
konex: 0,
|
||||
other: 0,
|
||||
unknown: 0,
|
||||
};
|
||||
|
||||
for stock in stocks {
|
||||
match stock.market {
|
||||
StockMarket::Kospi => counts.kospi += 1,
|
||||
StockMarket::Kosdaq => counts.kosdaq += 1,
|
||||
StockMarket::Konex => counts.konex += 1,
|
||||
StockMarket::Other => counts.other += 1,
|
||||
StockMarket::Unknown => counts.unknown += 1,
|
||||
}
|
||||
}
|
||||
|
||||
counts
|
||||
}
|
||||
|
||||
fn duplicate_symbols(stocks: &[Stock]) -> Vec<String> {
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
let mut duplicates = std::collections::BTreeSet::new();
|
||||
for stock in stocks {
|
||||
if !seen.insert(stock.id.symbol.as_str()) {
|
||||
duplicates.insert(stock.id.symbol.clone());
|
||||
}
|
||||
}
|
||||
duplicates.into_iter().collect()
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
use serde::Serialize;
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::SystemTime;
|
||||
|
||||
const CACHE_ROOT: &str = ".openstock";
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct CacheStatus {
|
||||
pub root: String,
|
||||
pub exists: bool,
|
||||
pub total_files: usize,
|
||||
pub total_bytes: u64,
|
||||
pub namespaces: Vec<CacheNamespaceStatus>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct CacheNamespaceStatus {
|
||||
pub namespace: String,
|
||||
pub files: usize,
|
||||
pub bytes: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct CachePrunePolicy {
|
||||
pub namespace: String,
|
||||
pub directory: String,
|
||||
pub max_snapshot_files: usize,
|
||||
pub max_snapshot_bytes: u64,
|
||||
pub protected_files: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct CachePruneReport {
|
||||
pub policy: CachePrunePolicy,
|
||||
pub dry_run: bool,
|
||||
pub deleted_files: usize,
|
||||
pub deleted_bytes: u64,
|
||||
pub retained_snapshot_files: usize,
|
||||
pub retained_snapshot_bytes: u64,
|
||||
pub deleted_paths: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct SnapshotFile {
|
||||
path: PathBuf,
|
||||
name: String,
|
||||
bytes: u64,
|
||||
modified: SystemTime,
|
||||
}
|
||||
|
||||
pub fn status() -> Result<CacheStatus, String> {
|
||||
let root = Path::new(CACHE_ROOT);
|
||||
if !root.exists() {
|
||||
return Ok(CacheStatus {
|
||||
root: CACHE_ROOT.to_string(),
|
||||
exists: false,
|
||||
total_files: 0,
|
||||
total_bytes: 0,
|
||||
namespaces: Vec::new(),
|
||||
});
|
||||
}
|
||||
|
||||
let mut namespaces = BTreeMap::<String, CacheNamespaceStatus>::new();
|
||||
let mut total_files = 0;
|
||||
let mut total_bytes = 0;
|
||||
collect_status(
|
||||
root,
|
||||
root,
|
||||
&mut namespaces,
|
||||
&mut total_files,
|
||||
&mut total_bytes,
|
||||
)?;
|
||||
|
||||
Ok(CacheStatus {
|
||||
root: CACHE_ROOT.to_string(),
|
||||
exists: true,
|
||||
total_files,
|
||||
total_bytes,
|
||||
namespaces: namespaces.into_values().collect(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn prune_json_snapshots(
|
||||
namespace: &str,
|
||||
dir: &Path,
|
||||
protected_files: &[&str],
|
||||
max_snapshot_files: usize,
|
||||
max_snapshot_bytes: u64,
|
||||
dry_run: bool,
|
||||
) -> Result<CachePruneReport, String> {
|
||||
prune_snapshots(
|
||||
namespace,
|
||||
dir,
|
||||
protected_files,
|
||||
&["json"],
|
||||
max_snapshot_files,
|
||||
max_snapshot_bytes,
|
||||
dry_run,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn prune_snapshots(
|
||||
namespace: &str,
|
||||
dir: &Path,
|
||||
protected_files: &[&str],
|
||||
extensions: &[&str],
|
||||
max_snapshot_files: usize,
|
||||
max_snapshot_bytes: u64,
|
||||
dry_run: bool,
|
||||
) -> Result<CachePruneReport, String> {
|
||||
let policy = CachePrunePolicy {
|
||||
namespace: namespace.to_string(),
|
||||
directory: dir.display().to_string(),
|
||||
max_snapshot_files,
|
||||
max_snapshot_bytes,
|
||||
protected_files: protected_files
|
||||
.iter()
|
||||
.map(|value| value.to_string())
|
||||
.collect(),
|
||||
};
|
||||
|
||||
if !dir.exists() {
|
||||
return Ok(CachePruneReport {
|
||||
policy,
|
||||
dry_run,
|
||||
deleted_files: 0,
|
||||
deleted_bytes: 0,
|
||||
retained_snapshot_files: 0,
|
||||
retained_snapshot_bytes: 0,
|
||||
deleted_paths: Vec::new(),
|
||||
});
|
||||
}
|
||||
|
||||
let mut snapshots = snapshot_files(dir, protected_files, extensions)?;
|
||||
snapshots.sort_by(|a, b| {
|
||||
b.modified
|
||||
.cmp(&a.modified)
|
||||
.then_with(|| b.name.cmp(&a.name))
|
||||
});
|
||||
|
||||
let mut retained_bytes = 0_u64;
|
||||
let mut delete = Vec::new();
|
||||
for (index, snapshot) in snapshots.iter().enumerate() {
|
||||
let keep_by_count = index < max_snapshot_files;
|
||||
let keep_by_bytes = retained_bytes.saturating_add(snapshot.bytes) <= max_snapshot_bytes;
|
||||
if keep_by_count && keep_by_bytes {
|
||||
retained_bytes = retained_bytes.saturating_add(snapshot.bytes);
|
||||
} else {
|
||||
delete.push(snapshot.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let deleted_files = delete.len();
|
||||
let deleted_bytes = delete.iter().map(|file| file.bytes).sum::<u64>();
|
||||
let deleted_paths = delete
|
||||
.iter()
|
||||
.map(|file| file.path.display().to_string())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if !dry_run {
|
||||
for file in &delete {
|
||||
fs::remove_file(&file.path).map_err(|err| {
|
||||
format!(
|
||||
"[cache] 캐시 파일 삭제 실패 ({}): {}",
|
||||
file.path.display(),
|
||||
err
|
||||
)
|
||||
})?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(CachePruneReport {
|
||||
policy,
|
||||
dry_run,
|
||||
deleted_files,
|
||||
deleted_bytes,
|
||||
retained_snapshot_files: snapshots.len().saturating_sub(deleted_files),
|
||||
retained_snapshot_bytes: retained_bytes,
|
||||
deleted_paths,
|
||||
})
|
||||
}
|
||||
|
||||
fn collect_status(
|
||||
root: &Path,
|
||||
dir: &Path,
|
||||
namespaces: &mut BTreeMap<String, CacheNamespaceStatus>,
|
||||
total_files: &mut usize,
|
||||
total_bytes: &mut u64,
|
||||
) -> Result<(), String> {
|
||||
for entry in
|
||||
fs::read_dir(dir).map_err(|err| format!("[cache] 캐시 디렉터리 읽기 실패: {}", err))?
|
||||
{
|
||||
let entry = entry.map_err(|err| format!("[cache] 캐시 항목 읽기 실패: {}", err))?;
|
||||
let path = entry.path();
|
||||
let metadata = entry
|
||||
.metadata()
|
||||
.map_err(|err| format!("[cache] 캐시 메타데이터 읽기 실패: {}", err))?;
|
||||
if metadata.is_dir() {
|
||||
collect_status(root, &path, namespaces, total_files, total_bytes)?;
|
||||
} else if metadata.is_file() {
|
||||
let bytes = metadata.len();
|
||||
*total_files += 1;
|
||||
*total_bytes = total_bytes.saturating_add(bytes);
|
||||
let namespace = namespace_for(root, &path);
|
||||
let entry = namespaces
|
||||
.entry(namespace.clone())
|
||||
.or_insert(CacheNamespaceStatus {
|
||||
namespace,
|
||||
files: 0,
|
||||
bytes: 0,
|
||||
});
|
||||
entry.files += 1;
|
||||
entry.bytes = entry.bytes.saturating_add(bytes);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn namespace_for(root: &Path, path: &Path) -> String {
|
||||
let Ok(relative) = path.strip_prefix(root) else {
|
||||
return "unknown".to_string();
|
||||
};
|
||||
relative
|
||||
.components()
|
||||
.next()
|
||||
.map(|component| component.as_os_str().to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| "root".to_string())
|
||||
}
|
||||
|
||||
fn snapshot_files(
|
||||
dir: &Path,
|
||||
protected_files: &[&str],
|
||||
extensions: &[&str],
|
||||
) -> Result<Vec<SnapshotFile>, String> {
|
||||
let mut files = Vec::new();
|
||||
for entry in
|
||||
fs::read_dir(dir).map_err(|err| format!("[cache] 캐시 디렉터리 읽기 실패: {}", err))?
|
||||
{
|
||||
let entry = entry.map_err(|err| format!("[cache] 캐시 항목 읽기 실패: {}", err))?;
|
||||
let path = entry.path();
|
||||
let metadata = entry
|
||||
.metadata()
|
||||
.map_err(|err| format!("[cache] 캐시 메타데이터 읽기 실패: {}", err))?;
|
||||
if !metadata.is_file() {
|
||||
continue;
|
||||
}
|
||||
let Some(name) = path
|
||||
.file_name()
|
||||
.and_then(|value| value.to_str())
|
||||
.map(str::to_string)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if protected_files.iter().any(|protected| protected == &name)
|
||||
|| !extensions.iter().any(|extension| {
|
||||
path.extension()
|
||||
.and_then(|value| value.to_str())
|
||||
.map(|value| value.eq_ignore_ascii_case(extension))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
{
|
||||
continue;
|
||||
}
|
||||
files.push(SnapshotFile {
|
||||
path,
|
||||
name,
|
||||
bytes: metadata.len(),
|
||||
modified: metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH),
|
||||
});
|
||||
}
|
||||
Ok(files)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
pub fn agent() -> ureq::Agent {
|
||||
let config = ureq::config::Config::builder()
|
||||
.tls_config(
|
||||
ureq::tls::TlsConfig::builder()
|
||||
.provider(ureq::tls::TlsProvider::NativeTls)
|
||||
.build(),
|
||||
)
|
||||
.build();
|
||||
ureq::Agent::new_with_config(config)
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
pub mod cache;
|
||||
pub mod dotenv;
|
||||
pub mod http;
|
||||
pub mod output;
|
||||
pub mod registry;
|
||||
pub mod stock;
|
||||
pub mod trader;
|
||||
|
||||
pub use registry::ApiRegistry;
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Stock {
|
||||
pub id: StockId,
|
||||
pub name: String,
|
||||
pub market: StockMarket,
|
||||
pub kind: StockKind,
|
||||
pub industry: Option<String>,
|
||||
pub sector: Option<String>,
|
||||
pub main_product: Option<String>,
|
||||
pub listed_at: Option<String>,
|
||||
pub fiscal_month: Option<String>,
|
||||
pub homepage: Option<String>,
|
||||
pub region: Option<String>,
|
||||
pub source: StockSource,
|
||||
pub source_url: Option<String>,
|
||||
pub updated_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct StockId {
|
||||
pub symbol: String,
|
||||
pub isin: Option<String>,
|
||||
pub reuters_code: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
||||
pub enum StockMarket {
|
||||
Kospi,
|
||||
Kosdaq,
|
||||
Konex,
|
||||
Other,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl StockMarket {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
StockMarket::Kospi => "KOSPI",
|
||||
StockMarket::Kosdaq => "KOSDAQ",
|
||||
StockMarket::Konex => "KONEX",
|
||||
StockMarket::Other => "OTHER",
|
||||
StockMarket::Unknown => "UNKNOWN",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum StockKind {
|
||||
CommonStock,
|
||||
PreferredStock,
|
||||
Etf,
|
||||
Etn,
|
||||
Reit,
|
||||
Spac,
|
||||
Other,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl StockKind {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
StockKind::CommonStock => "common_stock",
|
||||
StockKind::PreferredStock => "preferred_stock",
|
||||
StockKind::Etf => "etf",
|
||||
StockKind::Etn => "etn",
|
||||
StockKind::Reit => "reit",
|
||||
StockKind::Spac => "spac",
|
||||
StockKind::Other => "other",
|
||||
StockKind::Unknown => "unknown",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
||||
pub enum StockSource {
|
||||
Kind,
|
||||
Naver,
|
||||
Kis,
|
||||
Manual,
|
||||
}
|
||||
|
||||
impl Stock {
|
||||
#[allow(dead_code)]
|
||||
pub fn new(symbol: impl Into<String>, name: impl Into<String>) -> Self {
|
||||
Self {
|
||||
id: StockId {
|
||||
symbol: symbol.into(),
|
||||
isin: None,
|
||||
reuters_code: None,
|
||||
},
|
||||
name: name.into(),
|
||||
market: StockMarket::Unknown,
|
||||
kind: StockKind::Unknown,
|
||||
industry: None,
|
||||
sector: None,
|
||||
main_product: None,
|
||||
listed_at: None,
|
||||
fiscal_month: None,
|
||||
homepage: None,
|
||||
region: None,
|
||||
source: StockSource::Manual,
|
||||
source_url: None,
|
||||
updated_at: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
validate_symbol(&self.id.symbol)?;
|
||||
if self.name.trim().is_empty() {
|
||||
return Err("종목명은 비어 있을 수 없습니다.".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn validate_symbol(symbol: &str) -> Result<(), String> {
|
||||
let symbol = symbol.trim();
|
||||
if symbol.is_empty() {
|
||||
return Err("종목코드는 비어 있을 수 없습니다.".to_string());
|
||||
}
|
||||
if symbol.len() > 12 {
|
||||
return Err("종목코드는 12자를 넘을 수 없습니다.".to_string());
|
||||
}
|
||||
if !symbol.chars().all(|ch| ch.is_ascii_alphanumeric()) {
|
||||
return Err("종목코드는 영문 대문자/소문자와 숫자만 허용합니다.".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn normalize_kind_market(value: &str) -> StockMarket {
|
||||
match value.trim() {
|
||||
"유가" | "KOSPI" | "kospi" => StockMarket::Kospi,
|
||||
"코스닥" | "KOSDAQ" | "kosdaq" => StockMarket::Kosdaq,
|
||||
"코넥스" | "KONEX" | "konex" => StockMarket::Konex,
|
||||
"" => StockMarket::Unknown,
|
||||
_ => StockMarket::Other,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn infer_stock_kind(
|
||||
name: &str,
|
||||
industry: Option<&str>,
|
||||
main_product: Option<&str>,
|
||||
) -> StockKind {
|
||||
let text = format!(
|
||||
"{} {} {}",
|
||||
name,
|
||||
industry.unwrap_or(""),
|
||||
main_product.unwrap_or("")
|
||||
)
|
||||
.to_ascii_uppercase();
|
||||
|
||||
if text.contains(" ETF")
|
||||
|| text.starts_with("ETF ")
|
||||
|| text.contains("KODEX")
|
||||
|| text.contains("TIGER")
|
||||
{
|
||||
return StockKind::Etf;
|
||||
}
|
||||
if text.contains(" ETN") || text.starts_with("ETN ") {
|
||||
return StockKind::Etn;
|
||||
}
|
||||
if text.contains("리츠") || text.contains("REIT") {
|
||||
return StockKind::Reit;
|
||||
}
|
||||
if text.contains("스팩") || text.contains("SPAC") {
|
||||
return StockKind::Spac;
|
||||
}
|
||||
if name.ends_with('우') || name.contains("우B") || name.contains("우C") {
|
||||
return StockKind::PreferredStock;
|
||||
}
|
||||
|
||||
StockKind::CommonStock
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn serializes_stock_schema_with_stable_field_names() {
|
||||
let stock = Stock {
|
||||
id: StockId {
|
||||
symbol: "0126Z0".to_string(),
|
||||
isin: None,
|
||||
reuters_code: Some("0126Z0".to_string()),
|
||||
},
|
||||
name: "삼성에피스홀딩스".to_string(),
|
||||
market: StockMarket::Kospi,
|
||||
kind: StockKind::CommonStock,
|
||||
industry: Some("기타 금융업".to_string()),
|
||||
sector: None,
|
||||
main_product: Some("지주회사".to_string()),
|
||||
listed_at: Some("2025-11-24".to_string()),
|
||||
fiscal_month: Some("12월".to_string()),
|
||||
homepage: Some("http://www.samsungepisholdings.com".to_string()),
|
||||
region: Some("인천광역시".to_string()),
|
||||
source: StockSource::Kind,
|
||||
source_url: Some("https://kind.krx.co.kr/corpgeneral/corpList.do".to_string()),
|
||||
updated_at: Some("2026-06-09".to_string()),
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
serde_json::to_value(stock).unwrap(),
|
||||
json!({
|
||||
"id": {
|
||||
"symbol": "0126Z0",
|
||||
"isin": null,
|
||||
"reuters_code": "0126Z0"
|
||||
},
|
||||
"name": "삼성에피스홀딩스",
|
||||
"market": "KOSPI",
|
||||
"kind": "common_stock",
|
||||
"industry": "기타 금융업",
|
||||
"sector": null,
|
||||
"main_product": "지주회사",
|
||||
"listed_at": "2025-11-24",
|
||||
"fiscal_month": "12월",
|
||||
"homepage": "http://www.samsungepisholdings.com",
|
||||
"region": "인천광역시",
|
||||
"source": "KIND",
|
||||
"source_url": "https://kind.krx.co.kr/corpgeneral/corpList.do",
|
||||
"updated_at": "2026-06-09"
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_alphanumeric_symbols() {
|
||||
assert!(validate_symbol("005930").is_ok());
|
||||
assert!(validate_symbol("0126Z0").is_ok());
|
||||
assert!(validate_symbol("0126-0").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalizes_kind_market_names() {
|
||||
assert_eq!(normalize_kind_market("유가"), StockMarket::Kospi);
|
||||
assert_eq!(normalize_kind_market("코스닥"), StockMarket::Kosdaq);
|
||||
assert_eq!(normalize_kind_market("코넥스"), StockMarket::Konex);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn infers_common_stock_types() {
|
||||
assert_eq!(
|
||||
infer_stock_kind("삼성전자", Some("통신 및 방송 장비 제조업"), None),
|
||||
StockKind::CommonStock
|
||||
);
|
||||
assert_eq!(
|
||||
infer_stock_kind("삼성전자우", None, None),
|
||||
StockKind::PreferredStock
|
||||
);
|
||||
assert_eq!(infer_stock_kind("KODEX 반도체", None, None), StockKind::Etf);
|
||||
assert_eq!(
|
||||
infer_stock_kind("하나 레버리지 반도체 ETN", None, None),
|
||||
StockKind::Etn
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -82,6 +82,16 @@ pub trait TraderApi {
|
||||
/// 종목 시장 정보 조회
|
||||
fn market(&self, symbol: &str) -> Result<String, String>;
|
||||
|
||||
/// 종목 기간별 가격 히스토리 조회
|
||||
fn market_history(
|
||||
&self,
|
||||
symbol: &str,
|
||||
start_date: &str,
|
||||
end_date: &str,
|
||||
period: &str,
|
||||
adjusted: bool,
|
||||
) -> Result<String, String>;
|
||||
|
||||
/// 증권사 정보 (키-값 쌍)
|
||||
fn info(&self) -> Vec<(&'static str, &'static str)>;
|
||||
}
|
||||
|
||||
+27
-5
@@ -1,7 +1,11 @@
|
||||
use clap::{Parser, Subcommand};
|
||||
use commands::account::AccountCommands;
|
||||
use commands::api::ApiCommands;
|
||||
use commands::cache::CacheCommands;
|
||||
use commands::dart::DartCommands;
|
||||
use commands::market::MarketCommand;
|
||||
use commands::order::OrderCommands;
|
||||
use commands::universe::UniverseCommands;
|
||||
|
||||
mod apis;
|
||||
mod commands;
|
||||
@@ -31,18 +35,36 @@ enum Commands {
|
||||
sub: ApiCommands,
|
||||
},
|
||||
|
||||
/// OpenDART 공시정보 조회
|
||||
Dart {
|
||||
#[command(subcommand)]
|
||||
sub: DartCommands,
|
||||
},
|
||||
|
||||
/// 계좌 조회 및 관리
|
||||
Account {
|
||||
#[command(subcommand)]
|
||||
sub: AccountCommands,
|
||||
},
|
||||
|
||||
/// 로컬 캐시 상태 조회 및 용량 정리
|
||||
Cache {
|
||||
#[command(subcommand)]
|
||||
sub: CacheCommands,
|
||||
},
|
||||
|
||||
/// 주문 실행 및 조회
|
||||
Order {
|
||||
#[command(subcommand)]
|
||||
sub: OrderCommands,
|
||||
},
|
||||
|
||||
/// 종목 universe 캐시 구축 및 조회
|
||||
Universe {
|
||||
#[command(subcommand)]
|
||||
sub: UniverseCommands,
|
||||
},
|
||||
|
||||
/// 종목 검색
|
||||
Search {
|
||||
/// 종목명 또는 종목코드
|
||||
@@ -50,10 +72,7 @@ enum Commands {
|
||||
},
|
||||
|
||||
/// 종목 정보 및 기업정보 조회
|
||||
Market {
|
||||
/// 종목코드
|
||||
symbol: String,
|
||||
},
|
||||
Market(MarketCommand),
|
||||
}
|
||||
|
||||
fn main() {
|
||||
@@ -62,10 +81,13 @@ fn main() {
|
||||
match &cli.command {
|
||||
Some(Commands::Version) => commands::handle_version(),
|
||||
Some(Commands::Api { sub }) => commands::handle_api(sub),
|
||||
Some(Commands::Dart { sub }) => commands::handle_dart(sub),
|
||||
Some(Commands::Account { sub }) => commands::handle_account(sub),
|
||||
Some(Commands::Cache { sub }) => commands::handle_cache(sub),
|
||||
Some(Commands::Order { sub }) => commands::handle_order(sub),
|
||||
Some(Commands::Universe { sub }) => commands::handle_universe(sub),
|
||||
Some(Commands::Search { query }) => commands::handle_search(query),
|
||||
Some(Commands::Market { symbol }) => commands::handle_market(symbol),
|
||||
Some(Commands::Market(command)) => commands::handle_market(command),
|
||||
None => {
|
||||
println!(
|
||||
"{}",
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
pub mod universe;
|
||||
@@ -0,0 +1,445 @@
|
||||
use crate::core::stock::{
|
||||
infer_stock_kind, normalize_kind_market, validate_symbol, Stock, StockId, StockKind,
|
||||
StockMarket, StockSource,
|
||||
};
|
||||
use encoding_rs::EUC_KR;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
const KIND_UNIVERSE_URL: &str =
|
||||
"https://kind.krx.co.kr/corpgeneral/corpList.do?method=download&searchType=13";
|
||||
const CACHE_DIR: &str = ".openstock/universe/kind";
|
||||
const LATEST_FILE: &str = "latest.json";
|
||||
const META_FILE: &str = "meta.json";
|
||||
const SNAPSHOT_RETENTION_FILES: usize = 7;
|
||||
const SNAPSHOT_RETENTION_BYTES: u64 = 25 * 1024 * 1024;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct UniverseMeta {
|
||||
pub source: String,
|
||||
pub source_url: String,
|
||||
pub date: String,
|
||||
pub refreshed_at: String,
|
||||
pub stock_count: usize,
|
||||
pub counts_by_market: BTreeMap<String, usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UniverseSnapshot {
|
||||
pub stocks: Vec<Stock>,
|
||||
pub meta: UniverseMeta,
|
||||
pub refreshed: bool,
|
||||
}
|
||||
|
||||
pub fn load_or_refresh(force: bool) -> Result<UniverseSnapshot, String> {
|
||||
if !force {
|
||||
if let Some(snapshot) = load_today_cache()? {
|
||||
return Ok(snapshot);
|
||||
}
|
||||
}
|
||||
|
||||
match refresh() {
|
||||
Ok(snapshot) => Ok(snapshot),
|
||||
Err(err) => {
|
||||
if let Some(snapshot) = load_any_cache()? {
|
||||
return Ok(UniverseSnapshot {
|
||||
refreshed: false,
|
||||
..snapshot
|
||||
});
|
||||
}
|
||||
Err(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn refresh() -> Result<UniverseSnapshot, String> {
|
||||
let body = download()?;
|
||||
let html = decode_euc_kr(&body);
|
||||
let mut stocks = parse_kind_html(&html)?;
|
||||
stocks.sort_by(|a, b| {
|
||||
a.market
|
||||
.cmp(&b.market)
|
||||
.then_with(|| a.kind.cmp(&b.kind))
|
||||
.then_with(|| a.id.symbol.cmp(&b.id.symbol))
|
||||
});
|
||||
|
||||
let today = current_utc_date_string();
|
||||
for stock in &mut stocks {
|
||||
stock.updated_at = Some(today.clone());
|
||||
}
|
||||
|
||||
let meta = UniverseMeta {
|
||||
source: "KIND".to_string(),
|
||||
source_url: KIND_UNIVERSE_URL.to_string(),
|
||||
date: today.clone(),
|
||||
refreshed_at: current_utc_datetime_string(),
|
||||
stock_count: stocks.len(),
|
||||
counts_by_market: counts_by_market(&stocks),
|
||||
};
|
||||
|
||||
write_cache(&stocks, &meta)?;
|
||||
|
||||
Ok(UniverseSnapshot {
|
||||
stocks,
|
||||
meta,
|
||||
refreshed: true,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn load_cached() -> Result<Option<UniverseSnapshot>, String> {
|
||||
load_any_cache()
|
||||
}
|
||||
|
||||
pub fn prune_cache(dry_run: bool) -> Result<crate::core::cache::CachePruneReport, String> {
|
||||
crate::core::cache::prune_json_snapshots(
|
||||
"universe/kind",
|
||||
&cache_dir(),
|
||||
&[LATEST_FILE, META_FILE],
|
||||
SNAPSHOT_RETENTION_FILES,
|
||||
SNAPSHOT_RETENTION_BYTES,
|
||||
dry_run,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn chunk_stocks(stocks: &[Stock], size: usize) -> Vec<serde_json::Value> {
|
||||
let size = size.max(1);
|
||||
let mut groups = BTreeMap::<(StockMarket, StockKind), Vec<&Stock>>::new();
|
||||
for stock in stocks {
|
||||
groups
|
||||
.entry((stock.market, stock.kind))
|
||||
.or_default()
|
||||
.push(stock);
|
||||
}
|
||||
|
||||
let mut chunks = Vec::new();
|
||||
for ((market, kind), group) in groups {
|
||||
for (index, chunk) in group.chunks(size).enumerate() {
|
||||
let first = chunk.first().copied();
|
||||
let last = chunk.last().copied();
|
||||
chunks.push(serde_json::json!({
|
||||
"chunk_id": format!("{}:{}:{:04}", market.as_str(), kind.as_str(), index + 1),
|
||||
"index": index + 1,
|
||||
"size": size,
|
||||
"count": chunk.len(),
|
||||
"start_symbol": first.map(|stock| stock.id.symbol.as_str()),
|
||||
"end_symbol": last.map(|stock| stock.id.symbol.as_str()),
|
||||
"market": market.as_str(),
|
||||
"kind": kind.as_str(),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
chunks
|
||||
}
|
||||
|
||||
fn download() -> Result<Vec<u8>, String> {
|
||||
let response = crate::core::http::agent()
|
||||
.get(KIND_UNIVERSE_URL)
|
||||
.header("User-Agent", "openstock/0.1")
|
||||
.call()
|
||||
.map_err(|err| format!("[KIND] 상장법인목록 다운로드 실패: {}", err))?;
|
||||
let status = response.status();
|
||||
let body = response
|
||||
.into_body()
|
||||
.read_to_vec()
|
||||
.map_err(|err| format!("[KIND] 상장법인목록 응답 읽기 실패: {}", err))?;
|
||||
|
||||
if !status.is_success() {
|
||||
return Err(format!(
|
||||
"[KIND] 상장법인목록 다운로드 오류 ({}): {} bytes",
|
||||
status,
|
||||
body.len()
|
||||
));
|
||||
}
|
||||
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
fn decode_euc_kr(bytes: &[u8]) -> String {
|
||||
let (decoded, _, _) = EUC_KR.decode(bytes);
|
||||
decoded.into_owned()
|
||||
}
|
||||
|
||||
fn parse_kind_html(html: &str) -> Result<Vec<Stock>, String> {
|
||||
let mut rows = Vec::new();
|
||||
let mut rest = html;
|
||||
|
||||
while let Some(start) = find_ascii_case_insensitive(rest, "<tr") {
|
||||
let row_start = &rest[start..];
|
||||
let Some(open_end) = row_start.find('>') else {
|
||||
break;
|
||||
};
|
||||
let row_body_start = open_end + 1;
|
||||
let Some(close) = find_ascii_case_insensitive(&row_start[row_body_start..], "</tr>") else {
|
||||
break;
|
||||
};
|
||||
let row_body = &row_start[row_body_start..row_body_start + close];
|
||||
let cells = extract_cells(row_body);
|
||||
if cells.len() >= 10 && cells[0] != "회사명" {
|
||||
if let Some(stock) = row_to_stock(&cells) {
|
||||
rows.push(stock);
|
||||
}
|
||||
}
|
||||
rest = &row_start[row_body_start + close + "</tr>".len()..];
|
||||
}
|
||||
|
||||
if rows.is_empty() {
|
||||
return Err("[KIND] 상장법인목록에서 종목을 찾지 못했습니다.".to_string());
|
||||
}
|
||||
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
fn row_to_stock(cells: &[String]) -> Option<Stock> {
|
||||
let name = cells.first()?.trim();
|
||||
let market = cells.get(1)?.trim();
|
||||
let symbol = cells.get(2)?.trim();
|
||||
let industry = empty_to_none(cells.get(3).map(String::as_str).unwrap_or(""));
|
||||
let main_product = empty_to_none(cells.get(4).map(String::as_str).unwrap_or(""));
|
||||
|
||||
if name.is_empty() || validate_symbol(symbol).is_err() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(Stock {
|
||||
id: StockId {
|
||||
symbol: symbol.to_string(),
|
||||
isin: None,
|
||||
reuters_code: None,
|
||||
},
|
||||
name: name.to_string(),
|
||||
market: normalize_kind_market(market),
|
||||
kind: infer_stock_kind(name, industry.as_deref(), main_product.as_deref()),
|
||||
industry,
|
||||
sector: None,
|
||||
main_product,
|
||||
listed_at: empty_to_none(cells.get(5).map(String::as_str).unwrap_or("")),
|
||||
fiscal_month: empty_to_none(cells.get(6).map(String::as_str).unwrap_or("")),
|
||||
homepage: empty_to_none(cells.get(8).map(String::as_str).unwrap_or("")),
|
||||
region: empty_to_none(cells.get(9).map(String::as_str).unwrap_or("")),
|
||||
source: StockSource::Kind,
|
||||
source_url: Some(KIND_UNIVERSE_URL.to_string()),
|
||||
updated_at: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn extract_cells(row: &str) -> Vec<String> {
|
||||
let mut cells = Vec::new();
|
||||
let mut rest = row;
|
||||
|
||||
loop {
|
||||
let td = find_ascii_case_insensitive(rest, "<td");
|
||||
let th = find_ascii_case_insensitive(rest, "<th");
|
||||
let Some(start) = (match (td, th) {
|
||||
(Some(td), Some(th)) => Some(td.min(th)),
|
||||
(Some(td), None) => Some(td),
|
||||
(None, Some(th)) => Some(th),
|
||||
(None, None) => None,
|
||||
}) else {
|
||||
break;
|
||||
};
|
||||
|
||||
let cell_start = &rest[start..];
|
||||
let Some(open_end) = cell_start.find('>') else {
|
||||
break;
|
||||
};
|
||||
let close_tag = if cell_start.to_ascii_lowercase().starts_with("<th") {
|
||||
"</th>"
|
||||
} else {
|
||||
"</td>"
|
||||
};
|
||||
let body_start = open_end + 1;
|
||||
let Some(close) = find_ascii_case_insensitive(&cell_start[body_start..], close_tag) else {
|
||||
break;
|
||||
};
|
||||
let body = &cell_start[body_start..body_start + close];
|
||||
cells.push(clean_cell(body));
|
||||
rest = &cell_start[body_start + close + close_tag.len()..];
|
||||
}
|
||||
|
||||
cells
|
||||
}
|
||||
|
||||
fn clean_cell(value: &str) -> String {
|
||||
let mut text = String::new();
|
||||
let mut inside_tag = false;
|
||||
|
||||
for ch in value.chars() {
|
||||
match ch {
|
||||
'<' => inside_tag = true,
|
||||
'>' => {
|
||||
inside_tag = false;
|
||||
text.push(' ');
|
||||
}
|
||||
_ if !inside_tag => text.push(ch),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
decode_entities(&text)
|
||||
.split_whitespace()
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
fn decode_entities(value: &str) -> String {
|
||||
value
|
||||
.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace(""", "\"")
|
||||
.replace("'", "'")
|
||||
.replace(" ", " ")
|
||||
}
|
||||
|
||||
fn find_ascii_case_insensitive(haystack: &str, needle: &str) -> Option<usize> {
|
||||
haystack
|
||||
.as_bytes()
|
||||
.windows(needle.len())
|
||||
.position(|window| window.eq_ignore_ascii_case(needle.as_bytes()))
|
||||
}
|
||||
|
||||
fn empty_to_none(value: &str) -> Option<String> {
|
||||
let value = value.trim();
|
||||
if value.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(value.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn write_cache(stocks: &[Stock], meta: &UniverseMeta) -> Result<(), String> {
|
||||
let dir = cache_dir();
|
||||
fs::create_dir_all(&dir).map_err(|err| format!("[KIND] 캐시 디렉터리 생성 실패: {}", err))?;
|
||||
write_json_atomic(&dir.join(LATEST_FILE), stocks)?;
|
||||
write_json_atomic(&dir.join(META_FILE), meta)?;
|
||||
write_json_atomic(&dir.join(format!("{}.json", meta.date)), stocks)?;
|
||||
prune_cache(false)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_json_atomic<T: Serialize + ?Sized>(path: &Path, value: &T) -> Result<(), String> {
|
||||
let tmp = path.with_extension("tmp");
|
||||
let json = serde_json::to_string_pretty(value)
|
||||
.map_err(|err| format!("[KIND] 캐시 JSON 직렬화 실패: {}", err))?;
|
||||
fs::write(&tmp, json).map_err(|err| format!("[KIND] 캐시 임시 파일 쓰기 실패: {}", err))?;
|
||||
fs::rename(&tmp, path).map_err(|err| format!("[KIND] 캐시 파일 교체 실패: {}", err))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn load_today_cache() -> Result<Option<UniverseSnapshot>, String> {
|
||||
let Some(snapshot) = load_any_cache()? else {
|
||||
return Ok(None);
|
||||
};
|
||||
if snapshot.meta.date == current_utc_date_string() {
|
||||
return Ok(Some(snapshot));
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn load_any_cache() -> Result<Option<UniverseSnapshot>, String> {
|
||||
let dir = cache_dir();
|
||||
let latest_path = dir.join(LATEST_FILE);
|
||||
let meta_path = dir.join(META_FILE);
|
||||
if !latest_path.exists() || !meta_path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let stocks = read_json::<Vec<Stock>>(&latest_path)?;
|
||||
let meta = read_json::<UniverseMeta>(&meta_path)?;
|
||||
Ok(Some(UniverseSnapshot {
|
||||
stocks,
|
||||
meta,
|
||||
refreshed: false,
|
||||
}))
|
||||
}
|
||||
|
||||
fn read_json<T: for<'de> Deserialize<'de>>(path: &Path) -> Result<T, String> {
|
||||
let text = fs::read_to_string(path)
|
||||
.map_err(|err| format!("[KIND] 캐시 파일 읽기 실패 ({}): {}", path.display(), err))?;
|
||||
serde_json::from_str(&text)
|
||||
.map_err(|err| format!("[KIND] 캐시 JSON 파싱 실패 ({}): {}", path.display(), err))
|
||||
}
|
||||
|
||||
fn cache_dir() -> PathBuf {
|
||||
PathBuf::from(CACHE_DIR)
|
||||
}
|
||||
|
||||
fn counts_by_market(stocks: &[Stock]) -> BTreeMap<String, usize> {
|
||||
let mut counts = BTreeMap::new();
|
||||
for stock in stocks {
|
||||
*counts.entry(stock.market.as_str().to_string()).or_insert(0) += 1;
|
||||
}
|
||||
counts
|
||||
}
|
||||
|
||||
fn current_utc_date_string() -> String {
|
||||
let now_seconds = current_unix_seconds();
|
||||
let days = now_seconds.div_euclid(86_400);
|
||||
let (year, month, day) = civil_from_days(days);
|
||||
format!("{:04}-{:02}-{:02}", year, month, day)
|
||||
}
|
||||
|
||||
fn current_utc_datetime_string() -> String {
|
||||
let now_seconds = current_unix_seconds();
|
||||
let days = now_seconds.div_euclid(86_400);
|
||||
let seconds_of_day = now_seconds.rem_euclid(86_400);
|
||||
let (year, month, day) = civil_from_days(days);
|
||||
let hour = seconds_of_day / 3_600;
|
||||
let minute = (seconds_of_day % 3_600) / 60;
|
||||
let second = seconds_of_day % 60;
|
||||
format!(
|
||||
"{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
|
||||
year, month, day, hour, minute, second
|
||||
)
|
||||
}
|
||||
|
||||
fn current_unix_seconds() -> i64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|duration| duration.as_secs() as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn civil_from_days(days: i64) -> (i64, i64, i64) {
|
||||
let days = days + 719_468;
|
||||
let era = if days >= 0 { days } else { days - 146_096 } / 146_097;
|
||||
let day_of_era = days - era * 146_097;
|
||||
let year_of_era =
|
||||
(day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
|
||||
let mut year = year_of_era + era * 400;
|
||||
let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
|
||||
let month_prime = (5 * day_of_year + 2) / 153;
|
||||
let day = day_of_year - (153 * month_prime + 2) / 5 + 1;
|
||||
let month = month_prime + if month_prime < 10 { 3 } else { -9 };
|
||||
year += if month <= 2 { 1 } else { 0 };
|
||||
|
||||
(year, month, day)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::core::stock::{StockKind, StockMarket};
|
||||
|
||||
#[test]
|
||||
fn parses_kind_html_table() {
|
||||
let html = r#"
|
||||
<table>
|
||||
<tr><th>회사명</th><th>시장구분</th><th>종목코드</th><th>업종</th><th>주요제품</th><th>상장일</th><th>결산월</th><th>대표자명</th><th>홈페이지</th><th>지역</th></tr>
|
||||
<tr><td>삼성전자</td><td>유가</td><td>005930</td><td>통신 및 방송 장비 제조업</td><td>반도체</td><td>1975-06-11</td><td>12월</td><td>대표</td><td>https://www.samsung.com</td><td>경기도</td></tr>
|
||||
<tr><td>삼성전자우</td><td>유가</td><td>005935</td><td>통신 및 방송 장비 제조업</td><td>반도체</td><td>1989-01-01</td><td>12월</td><td>대표</td><td></td><td>경기도</td></tr>
|
||||
</table>
|
||||
"#;
|
||||
|
||||
let stocks = parse_kind_html(html).unwrap();
|
||||
assert_eq!(stocks.len(), 2);
|
||||
assert_eq!(stocks[0].id.symbol, "005930");
|
||||
assert_eq!(stocks[0].market, StockMarket::Kospi);
|
||||
assert_eq!(stocks[0].kind, StockKind::CommonStock);
|
||||
assert_eq!(stocks[1].kind, StockKind::PreferredStock);
|
||||
}
|
||||
}
|
||||
@@ -1 +1,3 @@
|
||||
pub mod kind;
|
||||
pub mod naver;
|
||||
pub mod opendart;
|
||||
|
||||
@@ -15,7 +15,8 @@ pub fn search(query: &str) -> Result<String, String> {
|
||||
percent_encode(query)
|
||||
);
|
||||
|
||||
let response = ureq::get(&url)
|
||||
let response = crate::core::http::agent()
|
||||
.get(&url)
|
||||
.header("User-Agent", "openstock/0.1")
|
||||
.call()
|
||||
.map_err(|err| format!("[Naver] 종목 검색 요청 실패: {}", err))?;
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
use crate::core::dotenv;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::io::{Cursor, Read};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
const OPENDART_CORP_CODE_URL: &str = "https://opendart.fss.or.kr/api/corpCode.xml";
|
||||
const CACHE_DIR: &str = ".openstock/opendart";
|
||||
const CORP_CODES_FILE: &str = "corp_codes.json";
|
||||
const META_FILE: &str = "corp_codes_meta.json";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DartCorpCode {
|
||||
pub corp_code: String,
|
||||
pub corp_name: String,
|
||||
pub stock_code: String,
|
||||
pub modify_date: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DartCorpCodeMeta {
|
||||
pub source: String,
|
||||
pub source_url: String,
|
||||
pub date: String,
|
||||
pub refreshed_at: String,
|
||||
pub total_count: usize,
|
||||
pub listed_count: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DartCorpCodeSnapshot {
|
||||
pub corps: Vec<DartCorpCode>,
|
||||
pub meta: DartCorpCodeMeta,
|
||||
pub refreshed: bool,
|
||||
}
|
||||
|
||||
pub fn load_or_refresh(force: bool) -> Result<DartCorpCodeSnapshot, String> {
|
||||
if !force {
|
||||
if let Some(snapshot) = load_today_cache()? {
|
||||
return Ok(snapshot);
|
||||
}
|
||||
}
|
||||
|
||||
match refresh() {
|
||||
Ok(snapshot) => Ok(snapshot),
|
||||
Err(err) => {
|
||||
if let Some(snapshot) = load_any_cache()? {
|
||||
return Ok(DartCorpCodeSnapshot {
|
||||
refreshed: false,
|
||||
..snapshot
|
||||
});
|
||||
}
|
||||
Err(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load_cached() -> Result<Option<DartCorpCodeSnapshot>, String> {
|
||||
load_any_cache()
|
||||
}
|
||||
|
||||
pub fn refresh() -> Result<DartCorpCodeSnapshot, String> {
|
||||
let key = api_key()?;
|
||||
let url = format!("{}?crtfc_key={}", OPENDART_CORP_CODE_URL, key);
|
||||
let response = crate::core::http::agent()
|
||||
.get(&url)
|
||||
.header("User-Agent", "openstock/0.1")
|
||||
.call()
|
||||
.map_err(|err| format!("[OpenDART] 고유번호 다운로드 실패: {}", err))?;
|
||||
let status = response.status();
|
||||
let body = response
|
||||
.into_body()
|
||||
.read_to_vec()
|
||||
.map_err(|err| format!("[OpenDART] 고유번호 응답 읽기 실패: {}", err))?;
|
||||
|
||||
if !status.is_success() {
|
||||
return Err(format!(
|
||||
"[OpenDART] 고유번호 다운로드 오류 ({}): {} bytes",
|
||||
status,
|
||||
body.len()
|
||||
));
|
||||
}
|
||||
|
||||
let all_corps = parse_corp_code_zip(&body)?;
|
||||
let total_count = all_corps.len();
|
||||
let mut listed = all_corps
|
||||
.into_iter()
|
||||
.filter(|corp| !corp.stock_code.trim().is_empty())
|
||||
.collect::<Vec<_>>();
|
||||
listed.sort_by(|a, b| {
|
||||
a.stock_code
|
||||
.cmp(&b.stock_code)
|
||||
.then_with(|| a.corp_code.cmp(&b.corp_code))
|
||||
});
|
||||
|
||||
let meta = DartCorpCodeMeta {
|
||||
source: "OpenDART".to_string(),
|
||||
source_url: OPENDART_CORP_CODE_URL.to_string(),
|
||||
date: current_utc_date_string(),
|
||||
refreshed_at: current_utc_datetime_string(),
|
||||
total_count,
|
||||
listed_count: listed.len(),
|
||||
};
|
||||
write_cache(&listed, &meta)?;
|
||||
|
||||
Ok(DartCorpCodeSnapshot {
|
||||
corps: listed,
|
||||
meta,
|
||||
refreshed: true,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn find_by_stock_code<'a>(corps: &'a [DartCorpCode], symbol: &str) -> Option<&'a DartCorpCode> {
|
||||
corps.iter().find(|corp| corp.stock_code == symbol)
|
||||
}
|
||||
|
||||
fn api_key() -> Result<String, String> {
|
||||
dotenv::read_env(Path::new(".env"))
|
||||
.get("OPENDART_API_KEY")
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or("OPENDART_API_KEY가 .env에 없습니다.".to_string())
|
||||
}
|
||||
|
||||
fn parse_corp_code_zip(bytes: &[u8]) -> Result<Vec<DartCorpCode>, String> {
|
||||
let cursor = Cursor::new(bytes);
|
||||
let mut archive = zip::ZipArchive::new(cursor).map_err(|err| {
|
||||
let text = String::from_utf8_lossy(bytes);
|
||||
format!(
|
||||
"[OpenDART] 고유번호 ZIP 파싱 실패: {} / 응답 앞부분: {}",
|
||||
err,
|
||||
text.chars().take(200).collect::<String>()
|
||||
)
|
||||
})?;
|
||||
let mut xml = String::new();
|
||||
archive
|
||||
.by_name("CORPCODE.xml")
|
||||
.map_err(|err| format!("[OpenDART] CORPCODE.xml 파일을 찾지 못했습니다: {}", err))?
|
||||
.read_to_string(&mut xml)
|
||||
.map_err(|err| format!("[OpenDART] CORPCODE.xml 읽기 실패: {}", err))?;
|
||||
parse_corps_xml(&xml)
|
||||
}
|
||||
|
||||
fn parse_corps_xml(xml: &str) -> Result<Vec<DartCorpCode>, String> {
|
||||
let mut corps = Vec::new();
|
||||
let mut rest = xml;
|
||||
|
||||
while let Some(start) = rest.find("<list>") {
|
||||
let item_start = start + "<list>".len();
|
||||
let Some(end) = rest[item_start..].find("</list>") else {
|
||||
break;
|
||||
};
|
||||
let item = &rest[item_start..item_start + end];
|
||||
let corp_code = extract_tag(item, "corp_code").unwrap_or_default();
|
||||
let corp_name = extract_tag(item, "corp_name").unwrap_or_default();
|
||||
if !corp_code.is_empty() && !corp_name.is_empty() {
|
||||
corps.push(DartCorpCode {
|
||||
corp_code,
|
||||
corp_name,
|
||||
stock_code: extract_tag(item, "stock_code").unwrap_or_default(),
|
||||
modify_date: extract_tag(item, "modify_date").unwrap_or_default(),
|
||||
});
|
||||
}
|
||||
rest = &rest[item_start + end + "</list>".len()..];
|
||||
}
|
||||
|
||||
if corps.is_empty() {
|
||||
return Err("[OpenDART] 고유번호 XML에서 회사를 찾지 못했습니다.".to_string());
|
||||
}
|
||||
Ok(corps)
|
||||
}
|
||||
|
||||
fn extract_tag(xml: &str, tag: &str) -> Option<String> {
|
||||
let open = format!("<{}>", tag);
|
||||
let close = format!("</{}>", tag);
|
||||
let start = xml.find(&open)? + open.len();
|
||||
let end = xml[start..].find(&close)?;
|
||||
Some(decode_xml_entities(xml[start..start + end].trim()))
|
||||
}
|
||||
|
||||
fn decode_xml_entities(value: &str) -> String {
|
||||
value
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace("&", "&")
|
||||
.replace(""", "\"")
|
||||
.replace("'", "'")
|
||||
}
|
||||
|
||||
fn write_cache(corps: &[DartCorpCode], meta: &DartCorpCodeMeta) -> Result<(), String> {
|
||||
let dir = cache_dir();
|
||||
fs::create_dir_all(&dir)
|
||||
.map_err(|err| format!("[OpenDART] 캐시 디렉터리 생성 실패: {}", err))?;
|
||||
write_json_atomic(&dir.join(CORP_CODES_FILE), corps)?;
|
||||
write_json_atomic(&dir.join(META_FILE), meta)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_json_atomic<T: Serialize + ?Sized>(path: &Path, value: &T) -> Result<(), String> {
|
||||
let tmp = path.with_extension("tmp");
|
||||
let json = serde_json::to_string_pretty(value)
|
||||
.map_err(|err| format!("[OpenDART] 캐시 JSON 직렬화 실패: {}", err))?;
|
||||
fs::write(&tmp, json).map_err(|err| format!("[OpenDART] 캐시 임시 파일 쓰기 실패: {}", err))?;
|
||||
fs::rename(&tmp, path).map_err(|err| format!("[OpenDART] 캐시 파일 교체 실패: {}", err))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn load_today_cache() -> Result<Option<DartCorpCodeSnapshot>, String> {
|
||||
let Some(snapshot) = load_any_cache()? else {
|
||||
return Ok(None);
|
||||
};
|
||||
if snapshot.meta.date == current_utc_date_string() {
|
||||
return Ok(Some(snapshot));
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn load_any_cache() -> Result<Option<DartCorpCodeSnapshot>, String> {
|
||||
let dir = cache_dir();
|
||||
let corps_path = dir.join(CORP_CODES_FILE);
|
||||
let meta_path = dir.join(META_FILE);
|
||||
if !corps_path.exists() || !meta_path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
Ok(Some(DartCorpCodeSnapshot {
|
||||
corps: read_json(&corps_path)?,
|
||||
meta: read_json(&meta_path)?,
|
||||
refreshed: false,
|
||||
}))
|
||||
}
|
||||
|
||||
fn read_json<T: for<'de> Deserialize<'de>>(path: &Path) -> Result<T, String> {
|
||||
let text = fs::read_to_string(path).map_err(|err| {
|
||||
format!(
|
||||
"[OpenDART] 캐시 파일 읽기 실패 ({}): {}",
|
||||
path.display(),
|
||||
err
|
||||
)
|
||||
})?;
|
||||
serde_json::from_str(&text).map_err(|err| {
|
||||
format!(
|
||||
"[OpenDART] 캐시 JSON 파싱 실패 ({}): {}",
|
||||
path.display(),
|
||||
err
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn cache_dir() -> PathBuf {
|
||||
PathBuf::from(CACHE_DIR)
|
||||
}
|
||||
|
||||
fn current_utc_date_string() -> String {
|
||||
let now_seconds = current_unix_seconds();
|
||||
let days = now_seconds.div_euclid(86_400);
|
||||
let (year, month, day) = civil_from_days(days);
|
||||
format!("{:04}-{:02}-{:02}", year, month, day)
|
||||
}
|
||||
|
||||
fn current_utc_datetime_string() -> String {
|
||||
let now_seconds = current_unix_seconds();
|
||||
let days = now_seconds.div_euclid(86_400);
|
||||
let seconds_of_day = now_seconds.rem_euclid(86_400);
|
||||
let (year, month, day) = civil_from_days(days);
|
||||
let hour = seconds_of_day / 3_600;
|
||||
let minute = (seconds_of_day % 3_600) / 60;
|
||||
let second = seconds_of_day % 60;
|
||||
format!(
|
||||
"{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
|
||||
year, month, day, hour, minute, second
|
||||
)
|
||||
}
|
||||
|
||||
fn current_unix_seconds() -> i64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|duration| duration.as_secs() as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn civil_from_days(days: i64) -> (i64, i64, i64) {
|
||||
let days = days + 719_468;
|
||||
let era = if days >= 0 { days } else { days - 146_096 } / 146_097;
|
||||
let day_of_era = days - era * 146_097;
|
||||
let year_of_era =
|
||||
(day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
|
||||
let mut year = year_of_era + era * 400;
|
||||
let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
|
||||
let month_prime = (5 * day_of_year + 2) / 153;
|
||||
let day = day_of_year - (153 * month_prime + 2) / 5 + 1;
|
||||
let month = month_prime + if month_prime < 10 { 3 } else { -9 };
|
||||
year += if month <= 2 { 1 } else { 0 };
|
||||
|
||||
(year, month, day)
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
use crate::core::dotenv;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
|
||||
const OPENDART_LIST_URL: &str = "https://opendart.fss.or.kr/api/list.json";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DisclosureQuery {
|
||||
pub corp_code: Option<String>,
|
||||
pub bgn_de: Option<String>,
|
||||
pub end_de: Option<String>,
|
||||
pub corp_cls: Option<String>,
|
||||
pub page_no: u32,
|
||||
pub page_count: u32,
|
||||
}
|
||||
|
||||
pub fn list(query: &DisclosureQuery) -> Result<serde_json::Value, String> {
|
||||
let key = api_key()?;
|
||||
validate_query(query)?;
|
||||
|
||||
let mut params = vec![
|
||||
("crtfc_key", key),
|
||||
("page_no", query.page_no.to_string()),
|
||||
("page_count", query.page_count.to_string()),
|
||||
];
|
||||
push_optional(&mut params, "corp_code", query.corp_code.as_deref());
|
||||
push_optional(&mut params, "bgn_de", query.bgn_de.as_deref());
|
||||
push_optional(&mut params, "end_de", query.end_de.as_deref());
|
||||
push_optional(&mut params, "corp_cls", query.corp_cls.as_deref());
|
||||
|
||||
let url = build_url(OPENDART_LIST_URL, ¶ms);
|
||||
let response = crate::core::http::agent()
|
||||
.get(&url)
|
||||
.header("User-Agent", "openstock/0.1")
|
||||
.call()
|
||||
.map_err(|err| format!("[OpenDART] 공시목록 요청 실패: {}", err))?;
|
||||
let status = response.status();
|
||||
let body = response
|
||||
.into_body()
|
||||
.read_to_string()
|
||||
.map_err(|err| format!("[OpenDART] 공시목록 응답 읽기 실패: {}", err))?;
|
||||
|
||||
if !status.is_success() {
|
||||
return Err(format!(
|
||||
"[OpenDART] 공시목록 요청 오류 ({}): {}",
|
||||
status, body
|
||||
));
|
||||
}
|
||||
|
||||
let payload = serde_json::from_str::<serde_json::Value>(&body)
|
||||
.map_err(|err| format!("[OpenDART] 공시목록 응답 파싱 실패: {}", err))?;
|
||||
ensure_success(&payload)?;
|
||||
Ok(payload)
|
||||
}
|
||||
|
||||
fn api_key() -> Result<String, String> {
|
||||
dotenv::read_env(Path::new(".env"))
|
||||
.get("OPENDART_API_KEY")
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or("OPENDART_API_KEY가 .env에 없습니다.".to_string())
|
||||
}
|
||||
|
||||
fn validate_query(query: &DisclosureQuery) -> Result<(), String> {
|
||||
if let Some(value) = &query.bgn_de {
|
||||
validate_date(value, "--from")?;
|
||||
}
|
||||
if let Some(value) = &query.end_de {
|
||||
validate_date(value, "--to")?;
|
||||
}
|
||||
if query.page_no == 0 {
|
||||
return Err("--page-no는 1 이상이어야 합니다.".to_string());
|
||||
}
|
||||
if query.page_count == 0 || query.page_count > 100 {
|
||||
return Err("--page-count는 1 이상 100 이하이어야 합니다.".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_date(value: &str, name: &str) -> Result<(), String> {
|
||||
if value.len() == 8 && value.chars().all(|ch| ch.is_ascii_digit()) {
|
||||
return Ok(());
|
||||
}
|
||||
Err(format!("{} 값은 YYYYMMDD 형식이어야 합니다.", name))
|
||||
}
|
||||
|
||||
fn push_optional(params: &mut Vec<(&str, String)>, key: &'static str, value: Option<&str>) {
|
||||
if let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) {
|
||||
params.push((key, value.to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
fn build_url(base: &str, params: &[(&str, String)]) -> String {
|
||||
let query = params
|
||||
.iter()
|
||||
.map(|(key, value)| format!("{}={}", percent_encode(key), percent_encode(value)))
|
||||
.collect::<Vec<_>>()
|
||||
.join("&");
|
||||
format!("{}?{}", base, query)
|
||||
}
|
||||
|
||||
fn percent_encode(value: &str) -> String {
|
||||
let mut encoded = String::new();
|
||||
for byte in value.bytes() {
|
||||
match byte {
|
||||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
|
||||
encoded.push(byte as char)
|
||||
}
|
||||
_ => encoded.push_str(&format!("%{:02X}", byte)),
|
||||
}
|
||||
}
|
||||
encoded
|
||||
}
|
||||
|
||||
fn ensure_success(payload: &serde_json::Value) -> Result<(), String> {
|
||||
let status = payload
|
||||
.get("status")
|
||||
.and_then(|value| value.as_str())
|
||||
.unwrap_or("000");
|
||||
if status == "000" || status == "013" {
|
||||
return Ok(());
|
||||
}
|
||||
let message = payload
|
||||
.get("message")
|
||||
.and_then(|value| value.as_str())
|
||||
.unwrap_or("OpenDART API 실패");
|
||||
Err(format!(
|
||||
"[OpenDART] 공시목록 조회 실패 ({}): {}",
|
||||
status, message
|
||||
))
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
use crate::core::dotenv;
|
||||
use encoding_rs::EUC_KR;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::io::{Cursor, Read};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
const OPENDART_DOCUMENT_URL: &str = "https://opendart.fss.or.kr/api/document.xml";
|
||||
const CACHE_DIR: &str = ".openstock/opendart/documents";
|
||||
const DOCUMENT_RETENTION_FILES: usize = 100;
|
||||
const DOCUMENT_RETENTION_BYTES: u64 = 200 * 1024 * 1024;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DartDocumentOptions {
|
||||
pub rcept_no: String,
|
||||
pub force: bool,
|
||||
pub max_chars: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DartDocument {
|
||||
pub rcept_no: String,
|
||||
pub source: String,
|
||||
pub source_url: String,
|
||||
pub viewer_url: String,
|
||||
pub zip_path: String,
|
||||
pub cached: bool,
|
||||
pub zip_bytes: u64,
|
||||
pub files: Vec<DartDocumentFile>,
|
||||
pub text: String,
|
||||
pub text_chars: usize,
|
||||
pub truncated: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DartDocumentFile {
|
||||
pub name: String,
|
||||
pub bytes: u64,
|
||||
pub text_chars: usize,
|
||||
}
|
||||
|
||||
pub fn get(options: &DartDocumentOptions) -> Result<DartDocument, String> {
|
||||
validate_rcept_no(&options.rcept_no)?;
|
||||
let max_chars = options.max_chars.max(1);
|
||||
let zip_path = cache_path(&options.rcept_no);
|
||||
|
||||
let (bytes, cached) = if !options.force && zip_path.exists() {
|
||||
(
|
||||
fs::read(&zip_path).map_err(|err| {
|
||||
format!(
|
||||
"[OpenDART] 공시서류 캐시 읽기 실패 ({}): {}",
|
||||
zip_path.display(),
|
||||
err
|
||||
)
|
||||
})?,
|
||||
true,
|
||||
)
|
||||
} else {
|
||||
let bytes = download(&options.rcept_no)?;
|
||||
write_cache(&zip_path, &bytes)?;
|
||||
prune_cache(false)?;
|
||||
(bytes, false)
|
||||
};
|
||||
|
||||
let parsed = parse_document_zip(&bytes, max_chars)?;
|
||||
let zip_bytes = bytes.len() as u64;
|
||||
|
||||
Ok(DartDocument {
|
||||
rcept_no: options.rcept_no.clone(),
|
||||
source: "OpenDART".to_string(),
|
||||
source_url: OPENDART_DOCUMENT_URL.to_string(),
|
||||
viewer_url: format!(
|
||||
"https://dart.fss.or.kr/dsaf001/main.do?rcpNo={}",
|
||||
options.rcept_no
|
||||
),
|
||||
zip_path: zip_path.display().to_string(),
|
||||
cached,
|
||||
zip_bytes,
|
||||
files: parsed.files,
|
||||
text: parsed.text,
|
||||
text_chars: parsed.text_chars,
|
||||
truncated: parsed.truncated,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn prune_cache(dry_run: bool) -> Result<crate::core::cache::CachePruneReport, String> {
|
||||
crate::core::cache::prune_snapshots(
|
||||
"opendart/documents",
|
||||
&cache_dir(),
|
||||
&[],
|
||||
&["zip"],
|
||||
DOCUMENT_RETENTION_FILES,
|
||||
DOCUMENT_RETENTION_BYTES,
|
||||
dry_run,
|
||||
)
|
||||
}
|
||||
|
||||
fn download(rcept_no: &str) -> Result<Vec<u8>, String> {
|
||||
let key = api_key()?;
|
||||
let url = format!(
|
||||
"{}?crtfc_key={}&rcept_no={}",
|
||||
OPENDART_DOCUMENT_URL,
|
||||
percent_encode(&key),
|
||||
percent_encode(rcept_no)
|
||||
);
|
||||
let response = crate::core::http::agent()
|
||||
.get(&url)
|
||||
.header("User-Agent", "openstock/0.1")
|
||||
.call()
|
||||
.map_err(|err| format!("[OpenDART] 공시서류 원본파일 요청 실패: {}", err))?;
|
||||
let status = response.status();
|
||||
let body = response
|
||||
.into_body()
|
||||
.read_to_vec()
|
||||
.map_err(|err| format!("[OpenDART] 공시서류 원본파일 응답 읽기 실패: {}", err))?;
|
||||
|
||||
if !status.is_success() {
|
||||
return Err(format!(
|
||||
"[OpenDART] 공시서류 원본파일 요청 오류 ({}): {} bytes",
|
||||
status,
|
||||
body.len()
|
||||
));
|
||||
}
|
||||
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
struct ParsedDocument {
|
||||
files: Vec<DartDocumentFile>,
|
||||
text: String,
|
||||
text_chars: usize,
|
||||
truncated: bool,
|
||||
}
|
||||
|
||||
fn parse_document_zip(bytes: &[u8], max_chars: usize) -> Result<ParsedDocument, String> {
|
||||
let cursor = Cursor::new(bytes);
|
||||
let mut archive = zip::ZipArchive::new(cursor).map_err(|err| {
|
||||
let text = decode_bytes(bytes);
|
||||
format!(
|
||||
"[OpenDART] 공시서류 ZIP 파싱 실패: {} / 응답 앞부분: {}",
|
||||
err,
|
||||
text.chars().take(200).collect::<String>()
|
||||
)
|
||||
})?;
|
||||
|
||||
let mut files = Vec::new();
|
||||
let mut collected = String::new();
|
||||
let mut truncated = false;
|
||||
|
||||
for index in 0..archive.len() {
|
||||
let mut file = archive
|
||||
.by_index(index)
|
||||
.map_err(|err| format!("[OpenDART] 공시서류 ZIP 항목 읽기 실패: {}", err))?;
|
||||
if file.is_dir() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let name = file.name().to_string();
|
||||
let bytes = file.size();
|
||||
let mut file_bytes = Vec::new();
|
||||
file.read_to_end(&mut file_bytes)
|
||||
.map_err(|err| format!("[OpenDART] 공시서류 파일 내용 읽기 실패: {}", err))?;
|
||||
let text = if looks_like_xml(&name, &file_bytes) {
|
||||
xml_to_text(&decode_bytes(&file_bytes))
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let text_chars = text.chars().count();
|
||||
if !text.is_empty() && !truncated {
|
||||
append_limited(&mut collected, &text, max_chars, &mut truncated);
|
||||
}
|
||||
files.push(DartDocumentFile {
|
||||
name,
|
||||
bytes,
|
||||
text_chars,
|
||||
});
|
||||
}
|
||||
|
||||
if files.is_empty() {
|
||||
return Err("[OpenDART] 공시서류 ZIP에 파일이 없습니다.".to_string());
|
||||
}
|
||||
|
||||
Ok(ParsedDocument {
|
||||
files,
|
||||
text_chars: collected.chars().count(),
|
||||
text: collected,
|
||||
truncated,
|
||||
})
|
||||
}
|
||||
|
||||
fn append_limited(target: &mut String, text: &str, max_chars: usize, truncated: &mut bool) {
|
||||
let remaining = max_chars.saturating_sub(target.chars().count());
|
||||
if remaining == 0 {
|
||||
*truncated = true;
|
||||
return;
|
||||
}
|
||||
let mut iter = text.chars();
|
||||
for ch in iter.by_ref().take(remaining) {
|
||||
target.push(ch);
|
||||
}
|
||||
if iter.next().is_some() {
|
||||
*truncated = true;
|
||||
}
|
||||
}
|
||||
|
||||
fn xml_to_text(xml: &str) -> String {
|
||||
let xml = remove_element_blocks(xml, "style");
|
||||
let xml = remove_element_blocks(&xml, "script");
|
||||
let mut text = String::new();
|
||||
let mut inside_tag = false;
|
||||
for ch in xml.chars() {
|
||||
match ch {
|
||||
'<' => {
|
||||
inside_tag = true;
|
||||
text.push(' ');
|
||||
}
|
||||
'>' => {
|
||||
inside_tag = false;
|
||||
text.push(' ');
|
||||
}
|
||||
_ if !inside_tag => text.push(ch),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
decode_xml_entities(&text)
|
||||
.split_whitespace()
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
fn remove_element_blocks(xml: &str, tag: &str) -> String {
|
||||
let mut output = String::new();
|
||||
let mut rest = xml;
|
||||
let open_pattern = format!("<{}", tag);
|
||||
let close_pattern = format!("</{}>", tag);
|
||||
|
||||
loop {
|
||||
let Some(open_start) = find_ascii_case_insensitive(rest, &open_pattern) else {
|
||||
output.push_str(rest);
|
||||
break;
|
||||
};
|
||||
output.push_str(&rest[..open_start]);
|
||||
let after_open = &rest[open_start..];
|
||||
let Some(close_start) = find_ascii_case_insensitive(after_open, &close_pattern) else {
|
||||
break;
|
||||
};
|
||||
let after_close = close_start + close_pattern.len();
|
||||
rest = &after_open[after_close..];
|
||||
}
|
||||
|
||||
output
|
||||
}
|
||||
|
||||
fn find_ascii_case_insensitive(haystack: &str, needle: &str) -> Option<usize> {
|
||||
haystack
|
||||
.as_bytes()
|
||||
.windows(needle.len())
|
||||
.position(|window| window.eq_ignore_ascii_case(needle.as_bytes()))
|
||||
}
|
||||
|
||||
fn decode_bytes(bytes: &[u8]) -> String {
|
||||
match std::str::from_utf8(bytes) {
|
||||
Ok(value) => value.to_string(),
|
||||
Err(_) => {
|
||||
let (decoded, _, _) = EUC_KR.decode(bytes);
|
||||
decoded.into_owned()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn looks_like_xml(name: &str, bytes: &[u8]) -> bool {
|
||||
name.to_ascii_lowercase().ends_with(".xml")
|
||||
|| decode_bytes(&bytes[..bytes.len().min(100)])
|
||||
.trim_start()
|
||||
.starts_with('<')
|
||||
}
|
||||
|
||||
fn decode_xml_entities(value: &str) -> String {
|
||||
value
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace("&", "&")
|
||||
.replace(""", "\"")
|
||||
.replace("'", "'")
|
||||
.replace("'", "'")
|
||||
.replace(" ", " ")
|
||||
}
|
||||
|
||||
fn write_cache(path: &Path, bytes: &[u8]) -> Result<(), String> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.map_err(|err| format!("[OpenDART] 공시서류 캐시 디렉터리 생성 실패: {}", err))?;
|
||||
}
|
||||
let tmp = path.with_extension("tmp");
|
||||
fs::write(&tmp, bytes).map_err(|err| format!("[OpenDART] 공시서류 캐시 쓰기 실패: {}", err))?;
|
||||
fs::rename(&tmp, path).map_err(|err| format!("[OpenDART] 공시서류 캐시 교체 실패: {}", err))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cache_path(rcept_no: &str) -> PathBuf {
|
||||
cache_dir().join(format!("{}.zip", rcept_no))
|
||||
}
|
||||
|
||||
fn cache_dir() -> PathBuf {
|
||||
PathBuf::from(CACHE_DIR)
|
||||
}
|
||||
|
||||
fn api_key() -> Result<String, String> {
|
||||
dotenv::read_env(Path::new(".env"))
|
||||
.get("OPENDART_API_KEY")
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or("OPENDART_API_KEY가 .env에 없습니다.".to_string())
|
||||
}
|
||||
|
||||
fn validate_rcept_no(value: &str) -> Result<(), String> {
|
||||
if value.len() == 14 && value.chars().all(|ch| ch.is_ascii_digit()) {
|
||||
return Ok(());
|
||||
}
|
||||
Err("rcept_no는 14자리 숫자 접수번호여야 합니다.".to_string())
|
||||
}
|
||||
|
||||
fn percent_encode(value: &str) -> String {
|
||||
let mut encoded = String::new();
|
||||
for byte in value.bytes() {
|
||||
match byte {
|
||||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
|
||||
encoded.push(byte as char)
|
||||
}
|
||||
_ => encoded.push_str(&format!("%{:02X}", byte)),
|
||||
}
|
||||
}
|
||||
encoded
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn validates_receipt_number() {
|
||||
assert!(validate_rcept_no("20260608800918").is_ok());
|
||||
assert!(validate_rcept_no("2026060880091").is_err());
|
||||
assert!(validate_rcept_no("2026060880091A").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_readable_text_from_xml() {
|
||||
let xml = r#"<DOCUMENT><STYLE>.xforms { color: red; }</STYLE><TITLE>사업보고서</TITLE><BODY>매출 & 이익 <TABLE><TR><TD>100</TD></TR></TABLE></BODY></DOCUMENT>"#;
|
||||
let text = xml_to_text(xml);
|
||||
assert!(text.contains("사업보고서"));
|
||||
assert!(text.contains("매출 & 이익"));
|
||||
assert!(text.contains("100"));
|
||||
assert!(!text.contains("xforms"));
|
||||
assert!(!text.contains("<TITLE>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn appends_text_with_character_limit() {
|
||||
let mut target = String::new();
|
||||
let mut truncated = false;
|
||||
append_limited(&mut target, "가나다라마", 3, &mut truncated);
|
||||
assert_eq!(target, "가나다");
|
||||
assert!(truncated);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod corp_codes;
|
||||
pub mod disclosures;
|
||||
pub mod document;
|
||||
Reference in New Issue
Block a user