Files
openstock/src/commands/universe.rs
T

605 lines
21 KiB
Rust
Raw Normal View History

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()
}