Implement KIS CLI trading workflow

This commit is contained in:
2026-06-08 11:22:46 +00:00
commit b86c34760d
37 changed files with 3261 additions and 0 deletions
+46
View File
@@ -0,0 +1,46 @@
use crate::core::dotenv;
use std::path::Path;
pub(crate) struct AccountConfig {
pub(crate) number: String,
pub(crate) product_code: String,
}
impl AccountConfig {
pub(crate) fn full_name(&self) -> String {
format!("{}-{}", self.number, self.product_code)
}
}
pub(crate) fn read_account_config() -> Result<AccountConfig, String> {
let env = dotenv::read_env(Path::new(".env"));
if let Some(account) = env
.get("KIS_ACCOUNT")
.filter(|value| !value.trim().is_empty())
{
let (number, product_code) = split_account(account.trim());
return Ok(AccountConfig {
number: number.to_string(),
product_code: product_code.to_string(),
});
}
if let Some(number) = env.get("KIS_CANO").filter(|value| !value.trim().is_empty()) {
return Ok(AccountConfig {
number: number.trim().to_string(),
product_code: env
.get("KIS_ACNT_PRDT_CD")
.map(|value| value.trim())
.filter(|value| !value.is_empty())
.unwrap_or("01")
.to_string(),
});
}
Err("계좌 설정이 없습니다. .env에 KIS_ACCOUNT=12345678-01 또는 KIS_CANO/KIS_ACNT_PRDT_CD를 설정하세요.".to_string())
}
fn split_account(account: &str) -> (&str, &str) {
account.split_once('-').unwrap_or((account, "01"))
}
+33
View File
@@ -0,0 +1,33 @@
use crate::apis::kis::KisApi;
use crate::core::TraderApi;
use serde_json::json;
pub fn account_status(api: &KisApi) -> Result<String, String> {
let account = crate::apis::kis::account_config::read_account_config()?;
let params = [
("tr_id", "TTTC8434R"),
("CANO", account.number.as_str()),
("ACNT_PRDT_CD", account.product_code.as_str()),
("AFHR_FLPR_YN", "N"),
("OFL_YN", ""),
("INQR_DVSN", "02"),
("UNPR_DVSN", "01"),
("FUND_STTL_ICLD_YN", "N"),
("FNCG_AMT_AUTO_RDPT_YN", "N"),
("PRCS_DVSN", "00"),
("CTX_AREA_FK100", ""),
("CTX_AREA_NK100", ""),
];
let response = api.call("/uapi/domestic-stock/v1/trading/inquire-balance", &params)?;
let json: serde_json::Value = serde_json::from_str(&response)
.map_err(|err| format!("[KIS] 계좌 상태 응답 파싱 실패: {}", err))?;
Ok(json!({
"broker": api.id(),
"account": account.full_name(),
"holdings": json.get("output1").cloned().unwrap_or_else(|| json!([])),
"balance": json.get("output2").cloned().unwrap_or_else(|| json!([])),
"raw": json,
})
.to_string())
}
+12
View File
@@ -0,0 +1,12 @@
use crate::apis::kis::order_common::{self, OrderSide};
use crate::apis::kis::KisApi;
pub fn buy(
api: &KisApi,
symbol: &str,
qty: u64,
price: Option<u64>,
market: bool,
) -> Result<String, String> {
order_common::order_cash(api, OrderSide::Buy, symbol.trim(), qty, price, market)
}
+92
View File
@@ -0,0 +1,92 @@
use crate::apis::kis::KisApi;
use crate::core::dotenv;
use std::path::Path;
const KIS_BASE_URL: &str = "https://openapi.koreainvestment.com:9443";
pub fn call(api: &KisApi, endpoint: &str, params: &[(&str, &str)]) -> Result<String, String> {
let env = dotenv::read_env(Path::new(".env"));
let token = api
.token()
.or_else(|| env.get("KIS_ACCESS_TOKEN").map(String::as_str))
.ok_or("로그인이 필요합니다. `openstock api login`을 먼저 실행하세요.")?;
let appkey = env
.get("KIS_APPKEY")
.ok_or("KIS_APPKEY가 .env에 없습니다.")?;
let appsecret = env
.get("KIS_APPSECRET")
.ok_or("KIS_APPSECRET가 .env에 없습니다.")?;
let tr_id = params
.iter()
.find(|(key, _)| key.eq_ignore_ascii_case("tr_id"))
.map(|(_, value)| *value);
let query_params = params
.iter()
.copied()
.filter(|(key, _)| !key.eq_ignore_ascii_case("tr_id"))
.collect::<Vec<_>>();
let url = build_url(endpoint, &query_params);
let mut request = ureq::get(&url)
.header("Content-Type", "application/json; charset=UTF-8")
.header("authorization", &format!("Bearer {}", token))
.header("appkey", appkey)
.header("appsecret", appsecret)
.header("custtype", "P");
if let Some(tr_id) = tr_id {
request = request.header("tr_id", tr_id);
}
let response = request
.call()
.map_err(|err| format!("[KIS] API 호출 실패: {}", err))?;
let status = response.status();
let body = response
.into_body()
.read_to_string()
.map_err(|err| format!("[KIS] API 응답 읽기 실패: {}", err))?;
if !status.is_success() {
return Err(format!("[KIS] API 호출 오류 ({}): {}", status, body));
}
Ok(body)
}
fn build_url(endpoint: &str, params: &[(&str, &str)]) -> String {
let base = if endpoint.starts_with("http://") || endpoint.starts_with("https://") {
endpoint.to_string()
} else if endpoint.starts_with('/') {
format!("{}{}", KIS_BASE_URL, endpoint)
} else {
format!("{}/{}", KIS_BASE_URL, endpoint)
};
if params.is_empty() {
return base;
}
let query = params
.iter()
.map(|(key, value)| format!("{}={}", percent_encode(key), percent_encode(value)))
.collect::<Vec<_>>()
.join("&");
let separator = if base.contains('?') { "&" } else { "?" };
format!("{}{}{}", base, separator, 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
}
+10
View File
@@ -0,0 +1,10 @@
/// KisApi 표준 정보 반환
pub fn info() -> Vec<(&'static str, &'static str)> {
vec![
("id", "KIS"),
("name", "한국투자증권"),
("description", "Korea Investment & Securities"),
("version", "1.0"),
("status", "available"),
]
}
+224
View File
@@ -0,0 +1,224 @@
use crate::core::{dotenv, LoginArguments};
use std::collections::HashMap;
use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};
const KIS_BASE_URL: &str = "https://openapi.koreainvestment.com:9443";
const KIS_TOKEN_PATH: &str = "/oauth2/tokenP";
const KIS_ACCESS_TOKEN_EXPIRED_AT_KEY: &str = "KIS_ACCESS_TOKEN_EXPIRED_AT";
/// 한국투자증권 실전 API 로그인에 필요한 인자
pub struct KisLoginArguments {
pub appkey: String,
pub appsecret: String,
pub force: bool,
}
impl KisLoginArguments {
pub fn new(appkey: String, appsecret: String, force: bool) -> Self {
Self {
appkey,
appsecret,
force,
}
}
}
impl LoginArguments for KisLoginArguments {
fn token_env_key(&self) -> &'static str {
"KIS_ACCESS_TOKEN"
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
}
/// KIS 실전 REST 접근토큰을 발급하고 .env에 저장한다.
pub fn login(args: &KisLoginArguments) -> Result<String, String> {
let env_path = Path::new(".env");
let env = dotenv::read_env(env_path);
if !args.force {
if let Some(access_token) = valid_access_token(&env) {
return Ok(access_token.to_string());
}
}
validate_credentials(args)?;
let url = format!("{}{}", KIS_BASE_URL, KIS_TOKEN_PATH);
let request_body = serde_json::json!({
"grant_type": "client_credentials",
"appkey": args.appkey,
"appsecret": args.appsecret,
});
let response = ureq::post(&url)
.header("Content-Type", "application/json; charset=UTF-8")
.send_json(&request_body)
.map_err(|err| format!("[KIS] 접근토큰 발급 요청 실패: {}", err))?;
let status = response.status();
let response_body = response
.into_body()
.read_to_string()
.map_err(|err| format!("[KIS] 접근토큰 응답 읽기 실패: {}", err))?;
let json = serde_json::from_str::<serde_json::Value>(&response_body).map_err(|err| {
format!(
"[KIS] 접근토큰 응답 파싱 실패: {} / 원문: {}",
err, response_body
)
})?;
if !status.is_success() {
return Err(format!(
"[KIS] 접근토큰 발급 오류 ({}): {}",
status,
kis_error_message(&json, &response_body)
));
}
let access_token = json
.get("access_token")
.and_then(|value| value.as_str())
.ok_or_else(|| format!("[KIS] 응답에 access_token이 없습니다: {}", response_body))?
.to_string();
let token_type = json
.get("token_type")
.and_then(|value| value.as_str())
.unwrap_or("Bearer");
let expires_at = json
.get("access_token_token_expired")
.and_then(|value| value.as_str())
.unwrap_or("");
let expires_in = json
.get("expires_in")
.and_then(|value| value.as_u64())
.unwrap_or(0);
dotenv::write_env(env_path, "KIS_APPKEY", &args.appkey)?;
dotenv::write_env(env_path, "KIS_APPSECRET", &args.appsecret)?;
dotenv::write_env(env_path, args.token_env_key(), &access_token)?;
dotenv::write_env(env_path, "KIS_TOKEN_TYPE", token_type)?;
if !expires_at.is_empty() {
dotenv::write_env(env_path, KIS_ACCESS_TOKEN_EXPIRED_AT_KEY, expires_at)?;
}
if expires_in > 0 {
dotenv::write_env(
env_path,
"KIS_ACCESS_TOKEN_EXPIRES_IN",
&expires_in.to_string(),
)?;
}
Ok(access_token)
}
fn validate_credentials(args: &KisLoginArguments) -> Result<(), String> {
if args.appkey.trim().is_empty() {
return Err("[KIS] KIS_APPKEY가 비어 있습니다".to_string());
}
if args.appsecret.trim().is_empty() {
return Err("[KIS] KIS_APPSECRET가 비어 있습니다".to_string());
}
Ok(())
}
fn valid_access_token(env: &HashMap<String, String>) -> Option<&str> {
let access_token = env.get("KIS_ACCESS_TOKEN")?;
if access_token.trim().is_empty() {
return None;
}
let expires_at = env.get(KIS_ACCESS_TOKEN_EXPIRED_AT_KEY)?;
if expires_at.trim().is_empty() || !is_future_kst_datetime(expires_at) {
return None;
}
Some(access_token.as_str())
}
fn is_future_kst_datetime(datetime: &str) -> bool {
let now_kst = current_kst_datetime_string();
datetime.trim() > now_kst.as_str()
}
fn current_kst_datetime_string() -> String {
let now_seconds = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_secs() as i64)
.unwrap_or(0);
let kst_seconds = now_seconds + 9 * 60 * 60;
format_unix_datetime(kst_seconds)
}
fn format_unix_datetime(seconds: i64) -> String {
let days = seconds.div_euclid(86_400);
let seconds_of_day = 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} {:02}:{:02}:{:02}",
year, month, day, hour, minute, second
)
}
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)
}
fn kis_error_message(json: &serde_json::Value, fallback: &str) -> String {
json.get("msg1")
.or_else(|| json.get("msg"))
.or_else(|| json.get("error_description"))
.or_else(|| json.get("error"))
.and_then(|value| value.as_str())
.unwrap_or(fallback)
.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn formats_unix_datetime() {
assert_eq!(format_unix_datetime(0), "1970-01-01 00:00:00");
assert_eq!(format_unix_datetime(1_704_067_200), "2024-01-01 00:00:00");
}
#[test]
fn returns_token_only_when_expiration_exists() {
let mut env = HashMap::new();
env.insert("KIS_ACCESS_TOKEN".to_string(), "token".to_string());
assert_eq!(valid_access_token(&env), None);
}
#[test]
fn returns_token_when_expiration_is_future() {
let mut env = HashMap::new();
env.insert("KIS_ACCESS_TOKEN".to_string(), "token".to_string());
env.insert(
KIS_ACCESS_TOKEN_EXPIRED_AT_KEY.to_string(),
"9999-12-31 23:59:59".to_string(),
);
assert_eq!(valid_access_token(&env), Some("token"));
}
}
+94
View File
@@ -0,0 +1,94 @@
pub mod account_config;
pub mod account_status;
pub mod buy;
pub mod call;
pub mod info;
pub mod login;
pub mod market;
pub mod order_common;
pub mod order_status;
pub mod sell;
pub mod stock_info;
use crate::core::{LoginArguments, TraderApi, TraderBase};
/// 한국투자증권(KIS) API 구현체
pub struct KisApi {
base: TraderBase,
token: Option<String>,
}
impl KisApi {
pub fn new() -> Self {
KisApi {
base: TraderBase::new("KIS", "한국투자증권", "Korea Investment & Securities"),
token: None,
}
}
pub(crate) fn token(&self) -> Option<&str> {
self.token.as_deref()
}
}
impl TraderApi for KisApi {
fn base(&self) -> &TraderBase {
&self.base
}
fn login(&mut self, args: &dyn LoginArguments) -> Result<(), String> {
let kis_args = args
.as_any()
.downcast_ref::<login::KisLoginArguments>()
.ok_or("[KIS] login: 잘못된 로그인 인자 타입입니다")?;
let token = login::login(kis_args)?;
self.token = Some(token);
Ok(())
}
fn call(&self, endpoint: &str, params: &[(&str, &str)]) -> Result<String, String> {
call::call(self, endpoint, params)
}
fn account_status(&self) -> Result<String, String> {
account_status::account_status(self)
}
fn buy(
&self,
symbol: &str,
qty: u64,
price: Option<u64>,
market: bool,
) -> Result<String, String> {
buy::buy(self, symbol, qty, price, market)
}
fn sell(
&self,
symbol: &str,
qty: u64,
price: Option<u64>,
market: bool,
) -> Result<String, String> {
sell::sell(self, symbol, qty, price, market)
}
fn order_status(
&self,
order_no: Option<&str>,
start_date: Option<&str>,
end_date: Option<&str>,
) -> Result<String, String> {
order_status::order_status(self, order_no, start_date, end_date)
}
fn market(&self, symbol: &str) -> Result<String, String> {
market::market(self, symbol)
}
fn info(&self) -> Vec<(&'static str, &'static str)> {
info::info()
}
}
+40
View File
@@ -0,0 +1,40 @@
use crate::apis::kis::stock_info;
use crate::apis::kis::KisApi;
use crate::core::TraderApi;
use serde_json::json;
pub fn market(api: &KisApi, symbol: &str) -> Result<String, String> {
let symbol = symbol.trim();
if !is_stock_code(symbol) {
return Err("종목코드는 6자리 숫자여야 합니다. 예: 005930".to_string());
}
let price = inquire_price(api, symbol)?;
let company = stock_info::stock_info(api, symbol)?;
Ok(json!({
"broker": api.id(),
"symbol": symbol,
"price": price.get("output").cloned().unwrap_or_else(|| json!({})),
"company": company.get("output").cloned().unwrap_or_else(|| json!({})),
"raw": {
"price": price,
"company": company,
},
})
.to_string())
}
fn inquire_price(api: &KisApi, symbol: &str) -> Result<serde_json::Value, String> {
let params = [
("tr_id", "FHKST01010100"),
("FID_COND_MRKT_DIV_CODE", "J"),
("FID_INPUT_ISCD", symbol),
];
let response = api.call("/uapi/domestic-stock/v1/quotations/inquire-price", &params)?;
serde_json::from_str(&response).map_err(|err| format!("[KIS] 현재가 응답 파싱 실패: {}", err))
}
fn is_stock_code(value: &str) -> bool {
value.len() == 6 && value.chars().all(|ch| ch.is_ascii_digit())
}
+144
View File
@@ -0,0 +1,144 @@
use crate::apis::kis::KisApi;
use crate::core::dotenv;
use crate::core::TraderApi;
use serde_json::json;
use std::path::Path;
const KIS_BASE_URL: &str = "https://openapi.koreainvestment.com:9443";
const ORDER_CASH_ENDPOINT: &str = "/uapi/domestic-stock/v1/trading/order-cash";
pub(crate) enum OrderSide {
Buy,
Sell,
}
impl OrderSide {
fn tr_id(&self) -> &'static str {
match self {
OrderSide::Buy => "TTTC0802U",
OrderSide::Sell => "TTTC0801U",
}
}
fn name(&self) -> &'static str {
match self {
OrderSide::Buy => "buy",
OrderSide::Sell => "sell",
}
}
}
pub(crate) fn order_cash(
api: &KisApi,
side: OrderSide,
symbol: &str,
qty: u64,
price: Option<u64>,
market: bool,
) -> Result<String, String> {
validate_order(symbol, qty, price, market)?;
let account = crate::apis::kis::account_config::read_account_config()?;
let order_type = if market { "01" } else { "00" };
let order_price = if market {
"0".to_string()
} else {
price.unwrap_or(0).to_string()
};
let body = json!({
"CANO": account.number,
"ACNT_PRDT_CD": account.product_code,
"PDNO": symbol,
"ORD_DVSN": order_type,
"ORD_QTY": qty.to_string(),
"ORD_UNPR": order_price,
});
let raw = post_order(api, side.tr_id(), &body)?;
let value = serde_json::from_str::<serde_json::Value>(&raw)
.map_err(|err| format!("[KIS] 주문 응답 파싱 실패: {}", err))?;
ensure_success(&value)?;
Ok(json!({
"broker": api.id(),
"side": side.name(),
"symbol": symbol,
"qty": qty,
"order_type": if market { "market" } else { "limit" },
"price": if market { serde_json::Value::Null } else { json!(price.unwrap_or(0)) },
"order": value.get("output").cloned().unwrap_or_else(|| json!({})),
"raw": value,
})
.to_string())
}
fn post_order(api: &KisApi, tr_id: &str, body: &serde_json::Value) -> Result<String, String> {
let env = dotenv::read_env(Path::new(".env"));
let token = api
.token()
.or_else(|| env.get("KIS_ACCESS_TOKEN").map(String::as_str))
.ok_or("로그인이 필요합니다. `openstock api login`을 먼저 실행하세요.")?;
let appkey = env
.get("KIS_APPKEY")
.ok_or("KIS_APPKEY가 .env에 없습니다.")?;
let appsecret = env
.get("KIS_APPSECRET")
.ok_or("KIS_APPSECRET가 .env에 없습니다.")?;
let url = format!("{}{}", KIS_BASE_URL, ORDER_CASH_ENDPOINT);
let response = ureq::post(&url)
.header("Content-Type", "application/json; charset=UTF-8")
.header("authorization", &format!("Bearer {}", token))
.header("appkey", appkey)
.header("appsecret", appsecret)
.header("tr_id", tr_id)
.header("custtype", "P")
.send_json(body)
.map_err(|err| format!("[KIS] 주문 요청 실패: {}", err))?;
let status = response.status();
let response_body = response
.into_body()
.read_to_string()
.map_err(|err| format!("[KIS] 주문 응답 읽기 실패: {}", err))?;
if !status.is_success() {
return Err(format!("[KIS] 주문 오류 ({}): {}", status, response_body));
}
Ok(response_body)
}
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 validate_order(symbol: &str, qty: u64, price: Option<u64>, market: bool) -> Result<(), String> {
if symbol.len() != 6 || !symbol.chars().all(|ch| ch.is_ascii_digit()) {
return Err("종목코드는 6자리 숫자여야 합니다. 예: 005930".to_string());
}
if qty == 0 {
return Err("주문 수량은 1 이상이어야 합니다.".to_string());
}
if market {
return Ok(());
}
if price.unwrap_or(0) == 0 {
return Err(
"지정가 주문은 --price 값이 필요합니다. 시장가는 --market을 사용하세요.".to_string(),
);
}
Ok(())
}
+110
View File
@@ -0,0 +1,110 @@
use crate::apis::kis::account_config;
use crate::apis::kis::KisApi;
use crate::core::TraderApi;
use serde_json::json;
use std::time::{SystemTime, UNIX_EPOCH};
pub fn order_status(
api: &KisApi,
order_no: Option<&str>,
start_date: Option<&str>,
end_date: Option<&str>,
) -> Result<String, String> {
let account = account_config::read_account_config()?;
let today = current_kst_date_string();
let start_date = start_date.unwrap_or(today.as_str());
let end_date = end_date.unwrap_or(today.as_str());
validate_date(start_date, "--from")?;
validate_date(end_date, "--to")?;
let order_no = order_no.unwrap_or("").trim();
let params = [
("tr_id", "TTTC8001R"),
("CANO", account.number.as_str()),
("ACNT_PRDT_CD", account.product_code.as_str()),
("INQR_STRT_DT", start_date),
("INQR_END_DT", end_date),
("SLL_BUY_DVSN_CD", "00"),
("INQR_DVSN", "00"),
("PDNO", ""),
("CCLD_DVSN", "00"),
("ORD_GNO_BRNO", ""),
("ODNO", order_no),
("INQR_DVSN_3", "00"),
("INQR_DVSN_1", ""),
("CTX_AREA_FK100", ""),
("CTX_AREA_NK100", ""),
];
let response = api.call(
"/uapi/domestic-stock/v1/trading/inquire-daily-ccld",
&params,
)?;
let value = serde_json::from_str::<serde_json::Value>(&response)
.map_err(|err| format!("[KIS] 주문 조회 응답 파싱 실패: {}", err))?;
ensure_success(&value)?;
let orders = value.get("output1").cloned().unwrap_or_else(|| json!([]));
Ok(json!({
"broker": api.id(),
"account": account.full_name(),
"order_no": if order_no.is_empty() { serde_json::Value::Null } else { json!(order_no) },
"from": start_date,
"to": end_date,
"orders": orders,
"summary": value.get("output2").cloned().unwrap_or_else(|| json!({})),
"raw": value,
})
.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))
}
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 current_kst_date_string() -> String {
let now_seconds = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_secs() as i64)
.unwrap_or(0);
let kst_seconds = now_seconds + 9 * 60 * 60;
let days = kst_seconds.div_euclid(86_400);
let (year, month, day) = civil_from_days(days);
format!("{:04}{:02}{:02}", year, month, day)
}
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)
}
+12
View File
@@ -0,0 +1,12 @@
use crate::apis::kis::order_common::{self, OrderSide};
use crate::apis::kis::KisApi;
pub fn sell(
api: &KisApi,
symbol: &str,
qty: u64,
price: Option<u64>,
market: bool,
) -> Result<String, String> {
order_common::order_cash(api, OrderSide::Sell, symbol.trim(), qty, price, market)
}
+16
View File
@@ -0,0 +1,16 @@
use crate::apis::kis::KisApi;
use crate::core::TraderApi;
pub fn stock_info(api: &KisApi, symbol: &str) -> Result<serde_json::Value, String> {
let params = [
("tr_id", "CTPF1002R"),
("PRDT_TYPE_CD", "300"),
("PDNO", symbol),
];
let response = api.call(
"/uapi/domestic-stock/v1/quotations/search-stock-info",
&params,
)?;
serde_json::from_str(&response)
.map_err(|err| format!("[KIS] 주식기본조회 응답 파싱 실패: {}", err))
}
+10
View File
@@ -0,0 +1,10 @@
/// MockApi 정보 구성
pub fn info(id: &'static str, name: &'static str, description: &'static str) -> Vec<(&'static str, &'static str)> {
vec![
("id", id),
("name", name),
("description", description),
("version", "0.0.0"),
("status", "mock"),
]
}
+14
View File
@@ -0,0 +1,14 @@
use crate::core::LoginArguments;
/// Mock 로그인 인자 (항상 성공)
pub struct MockLoginArguments;
impl LoginArguments for MockLoginArguments {
fn token_env_key(&self) -> &'static str {
"MOCK_ACCESS_TOKEN"
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
}
+55
View File
@@ -0,0 +1,55 @@
pub mod info;
pub mod login;
use crate::core::{TraderApi, TraderBase, LoginArguments};
/// 가상(Mock) 증권사 API 구현체 — 테스트 및 개발용
pub struct MockApi {
base: TraderBase,
token: Option<String>,
}
impl MockApi {
pub fn new(id: &'static str, name: &'static str, description: &'static str) -> Self {
MockApi {
base: TraderBase::new(id, name, description),
token: None,
}
}
}
impl Default for MockApi {
fn default() -> Self {
Self::new("MOCK", "Mock", "가상 증권사 API (개발/테스트용)")
}
}
impl TraderApi for MockApi {
fn base(&self) -> &TraderBase {
&self.base
}
fn login(&mut self, args: &dyn LoginArguments) -> Result<(), String> {
let _mock_args = args
.as_any()
.downcast_ref::<login::MockLoginArguments>()
.ok_or("[Mock] login: 잘못된 로그인 인자 타입입니다")?;
self.token = Some("mock-token".to_string());
println!("[Mock] 로그인 성공");
Ok(())
}
fn call(&self, endpoint: &str, params: &[(&str, &str)]) -> Result<String, String> {
let token = self.token.as_ref().ok_or("로그인이 필요합니다")?;
println!("[Mock] API 호출: {} (토큰: {})", endpoint, token);
for (k, v) in params {
println!("[Mock] {} = {}", k, v);
}
Ok(format!("{{\"endpoint\":\"{}\",\"status\":\"mock\"}}", endpoint))
}
fn info(&self) -> Vec<(&'static str, &'static str)> {
info::info(self.base.id, self.base.name, self.base.description)
}
}
+14
View File
@@ -0,0 +1,14 @@
#[path = "kis/main.rs"]
pub mod kis;
use crate::core::ApiRegistry;
/// 기본 증권사 API를 등록한 레지스트리 생성
pub fn create_default_registry() -> ApiRegistry {
let mut registry = ApiRegistry::new();
// 한국투자증권 KIS 등록
registry.register(Box::new(kis::KisApi::new()));
registry
}
+68
View File
@@ -0,0 +1,68 @@
use crate::apis::kis::KisApi;
use crate::core::TraderApi;
use clap::Subcommand;
#[derive(Subcommand)]
pub enum AccountCommands {
/// 계좌 상태 조회 (잔액, 보유종목)
Status,
}
pub fn handle_account(sub: &AccountCommands) {
match sub {
AccountCommands::Status => {
let api = KisApi::new();
match api.account_status() {
Ok(json) => {
let value = crate::core::output::parse_json_or_text(&json);
println!(
"{}",
crate::core::output::explained_with_raw(
"account status",
"계좌 잔액과 보유종목 조회 결과",
vec![
crate::core::output::field(
"broker",
"조회에 사용한 증권사 API",
value
.get("broker")
.cloned()
.unwrap_or(serde_json::Value::Null),
),
crate::core::output::field(
"account",
"조회한 계좌번호",
value
.get("account")
.cloned()
.unwrap_or(serde_json::Value::Null),
),
crate::core::output::field(
"balance",
"예수금, 총평가금액, 손익 등 계좌 요약",
value
.get("balance")
.cloned()
.unwrap_or_else(|| serde_json::json!([])),
),
crate::core::output::field(
"holdings",
"현재 보유 중인 종목 목록",
value
.get("holdings")
.cloned()
.unwrap_or_else(|| serde_json::json!([])),
),
],
value,
)
);
}
Err(err) => eprintln!(
"{}",
crate::core::output::error("account status", "계좌 상태 조회 실패", &err)
),
}
}
}
}
+412
View File
@@ -0,0 +1,412 @@
use crate::apis::create_default_registry;
use crate::apis::kis::{login::KisLoginArguments, KisApi};
use crate::core::TraderApi;
use clap::{Args, Subcommand};
use std::path::Path;
#[derive(Subcommand)]
pub enum ApiCommands {
/// 증권사 API 목록 조회
List,
/// 한국투자증권(KIS) 실전 API 로그인 (접근토큰 발급)
Login(KisLoginCommand),
/// 한국투자증권(KIS) API 직접 호출
Call(KisCallCommand),
}
#[derive(Args)]
pub struct KisLoginCommand {
/// 한국투자증권 Open API 앱키 (.env의 KIS_APPKEY보다 우선)
#[arg(long)]
appkey: Option<String>,
/// 한국투자증권 Open API 앱시크릿 (.env의 KIS_APPSECRET보다 우선)
#[arg(long)]
appsecret: Option<String>,
/// 유효한 기존 접근토큰이 있어도 새로 발급
#[arg(long)]
force: bool,
}
#[derive(Args)]
pub struct KisCallCommand {
/// 호출할 KIS API 경로 또는 전체 URL
endpoint: String,
/// 요청 파라미터. KEY=VALUE 형식이며 tr_id는 요청 헤더로 전송
#[arg(long = "param", short = 'p')]
params: Vec<String>,
}
pub fn handle_api(sub: &ApiCommands) {
match sub {
ApiCommands::List => {
let registry = create_default_registry();
let apis = registry
.list()
.iter()
.map(|api| api_catalog(api.as_ref()))
.collect::<Vec<_>>();
println!(
"{}",
crate::core::output::explained(
"api list",
"등록된 증권사 API 목록. AI 에이전트가 사용할 수 있도록 각 API의 목적, 인증 요구사항, 지원 명령, 입력 계약, 출력 계약, 부작용 여부를 포함한다.",
vec![
crate::core::output::field(
"count",
"등록된 증권사 API 개수",
serde_json::json!(apis.len()),
),
crate::core::output::field(
"apis",
"사용 가능한 증권사 API 목록. 각 항목의 capabilities는 CLI 명령과 대응되며 side_effect가 none이면 조회, financial_order이면 실전 주문 전송이다.",
serde_json::json!(apis),
),
],
)
);
}
ApiCommands::Login(command) => {
let env = crate::core::dotenv::read_env(Path::new(".env"));
let appkey = command
.appkey
.clone()
.or_else(|| env.get("KIS_APPKEY").cloned())
.unwrap_or_default();
let appsecret = command
.appsecret
.clone()
.or_else(|| env.get("KIS_APPSECRET").cloned())
.unwrap_or_default();
let args = KisLoginArguments::new(appkey, appsecret, command.force);
let mut api = KisApi::new();
match api.login(&args) {
Ok(()) => println!(
"{}",
crate::core::output::explained(
"api login",
"한국투자증권 실전 API 접근토큰 발급 또는 기존 토큰 재사용 결과. 이 명령은 인증 상태를 준비하며 주식 주문이나 조회 요청을 직접 실행하지 않는다.",
vec![
crate::core::output::field(
"broker",
"로그인 대상 증권사 API",
serde_json::json!(api.id()),
),
crate::core::output::field(
"status",
"로그인 처리 결과",
serde_json::json!("success"),
),
crate::core::output::field(
"force",
"기존 유효 토큰을 무시하고 새로 발급했는지 여부",
serde_json::json!(command.force),
),
crate::core::output::field(
"credential_source",
"appkey/appsecret 입력 출처. CLI 옵션이 있으면 우선 사용하고 없으면 .env의 KIS_APPKEY/KIS_APPSECRET을 사용한다.",
serde_json::json!({
"appkey": if command.appkey.is_some() { "cli_argument" } else { ".env:KIS_APPKEY" },
"appsecret": if command.appsecret.is_some() { "cli_argument" } else { ".env:KIS_APPSECRET" },
}),
),
crate::core::output::field(
"token_storage",
"발급 또는 재사용된 접근토큰이 저장되는 위치와 키",
serde_json::json!({
"file": ".env",
"access_token_key": "KIS_ACCESS_TOKEN",
"expiration_key": "KIS_ACCESS_TOKEN_EXPIRED_AT",
}),
),
crate::core::output::field(
"side_effect",
"명령의 외부 부작용. 인증 토큰과 인증 정보를 .env에 저장할 수 있지만 금융 주문은 발생하지 않는다.",
serde_json::json!("writes_auth_state"),
),
],
)
),
Err(err) => eprintln!(
"{}",
crate::core::output::error("api login", "KIS 로그인 실패", &err)
),
}
}
ApiCommands::Call(command) => {
let params = match parse_params(&command.params) {
Ok(params) => params,
Err(err) => {
eprintln!(
"{}",
crate::core::output::error("api call", "KIS API 호출 실패", &err)
);
return;
}
};
let param_refs = params
.iter()
.map(|(key, value)| (key.as_str(), value.as_str()))
.collect::<Vec<_>>();
let api = KisApi::new();
match api.call(&command.endpoint, &param_refs) {
Ok(json) => {
let value = crate::core::output::parse_json_or_text(&json);
println!(
"{}",
crate::core::output::explained_with_raw(
"api call",
"지정한 KIS API 엔드포인트 직접 호출 결과. tr_id 파라미터는 KIS 거래 ID 헤더로 이동하고 나머지 파라미터는 query string으로 전송한다.",
vec![
crate::core::output::field(
"broker",
"호출에 사용한 증권사 API",
serde_json::json!(api.id()),
),
crate::core::output::field(
"endpoint",
"호출한 API 경로 또는 URL",
serde_json::json!(command.endpoint),
),
crate::core::output::field(
"params",
"요청에 사용한 파라미터 목록. tr_id는 HTTP 헤더, 나머지는 URL query parameter로 해석한다.",
serde_json::json!(params_for_output(&params)),
),
crate::core::output::field(
"request_semantics",
"KIS 직접 호출 요청 해석 정보",
serde_json::json!({
"http_method": "GET",
"auth": "KIS_ACCESS_TOKEN Bearer token with KIS_APPKEY and KIS_APPSECRET headers",
"tr_id_handling": "param named tr_id is sent as request header tr_id",
"side_effect": "unknown; depends on endpoint. Use typed commands for known read/order actions.",
}),
),
crate::core::output::field(
"response",
"API 응답 값",
value.clone(),
),
crate::core::output::field(
"response_semantics",
"응답의 주요 top-level 필드 의미. KIS rt_cd가 0이면 성공, msg1은 사람이 읽는 응답 메시지, output/output1/output2는 API별 본문 데이터다.",
explain_response_keys(&value),
),
],
value,
)
);
}
Err(err) => eprintln!(
"{}",
crate::core::output::error("api call", "KIS API 호출 실패", &err)
),
}
}
}
}
fn api_catalog(api: &dyn TraderApi) -> serde_json::Value {
serde_json::json!({
"id": api.id(),
"name": api.name(),
"description": api.description(),
"ai_description": "KIS is the configured live Korea Investment & Securities broker API. It supports authentication, direct endpoint calls, account status lookup, market data lookup, live domestic stock buy/sell orders, and order status lookup.",
"info": api.info()
.into_iter()
.map(|(key, value)| serde_json::json!({
"name": key,
"description": "Broker metadata key-value pair",
"value": value,
}))
.collect::<Vec<_>>(),
"credential_requirements": [
{
"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"],
"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"],
"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"],
"source": ".env written by api login"
},
{
"name": "KIS_ACCOUNT",
"description": "Live account identifier in CANO-ACNT_PRDT_CD format, for example 12345678-01. Required for account and order commands.",
"required_for": ["account status", "order buy", "order sell", "order status"],
"source": ".env"
}
],
"capabilities": [
{
"command": "api login",
"purpose": "Prepare authentication by issuing or reusing a live KIS access token.",
"inputs": [
{"name": "--appkey", "required": false, "description": "Overrides .env KIS_APPKEY."},
{"name": "--appsecret", "required": false, "description": "Overrides .env KIS_APPSECRET."},
{"name": "--force", "required": false, "description": "Issue a new token even if a valid token already exists."}
],
"output_contract": "Explained JSON with broker, status, force, credential_source, token_storage, and side_effect fields.",
"side_effect": "writes_auth_state"
},
{
"command": "api call <endpoint> --param KEY=VALUE",
"purpose": "Call an arbitrary KIS endpoint using the configured access token.",
"inputs": [
{"name": "endpoint", "required": true, "description": "KIS API path or full URL."},
{"name": "--param", "required": false, "description": "KEY=VALUE request parameter. tr_id is sent as an HTTP header."}
],
"output_contract": "Explained JSON with request metadata, parsed response, and response_semantics.",
"side_effect": "unknown"
},
{
"command": "account status",
"purpose": "Read account cash balance, total evaluation, profit/loss, and holdings.",
"inputs": [],
"output_contract": "Explained JSON with broker, account, balance, holdings, and raw KIS response.",
"side_effect": "none"
},
{
"command": "market <symbol>",
"purpose": "Read current price and company/basic stock information for a domestic stock code.",
"inputs": [{"name": "symbol", "required": true, "description": "Six digit domestic stock code such as 005930."}],
"output_contract": "Explained JSON with broker, symbol, price, company, and raw KIS response.",
"side_effect": "none"
},
{
"command": "order buy <symbol> --qty <qty> (--price <price>|--market)",
"purpose": "Submit a live domestic stock buy order.",
"inputs": [
{"name": "symbol", "required": true, "description": "Six digit domestic stock code."},
{"name": "--qty", "required": true, "description": "Order quantity."},
{"name": "--price", "required": false, "description": "Limit order price. Required unless --market is used."},
{"name": "--market", "required": false, "description": "Submit market order."}
],
"output_contract": "Explained JSON with broker, side, symbol, qty, order_type, price, order, and raw KIS response.",
"side_effect": "financial_order"
},
{
"command": "order sell <symbol> --qty <qty> (--price <price>|--market)",
"purpose": "Submit a live domestic stock sell order.",
"inputs": [
{"name": "symbol", "required": true, "description": "Six digit domestic stock code."},
{"name": "--qty", "required": true, "description": "Order quantity."},
{"name": "--price", "required": false, "description": "Limit order price. Required unless --market is used."},
{"name": "--market", "required": false, "description": "Submit market order."}
],
"output_contract": "Explained JSON with broker, side, symbol, qty, order_type, price, order, and raw KIS response.",
"side_effect": "financial_order"
},
{
"command": "order status [order_no] --from YYYYMMDD --to YYYYMMDD",
"purpose": "Read order and execution status for the configured account.",
"inputs": [
{"name": "order_no", "required": false, "description": "Specific order number to filter."},
{"name": "--from", "required": false, "description": "Start date in YYYYMMDD. Defaults to today in KST."},
{"name": "--to", "required": false, "description": "End date in YYYYMMDD. Defaults to today in KST."}
],
"output_contract": "Explained JSON with broker, account, order_no, orders, summary, and raw KIS response.",
"side_effect": "none"
}
]
})
}
fn params_for_output(params: &[(String, String)]) -> Vec<serde_json::Value> {
params
.iter()
.map(|(key, value)| {
serde_json::json!({
"name": key,
"value": value,
"transport": if key.eq_ignore_ascii_case("tr_id") { "http_header" } else { "query_parameter" },
"description": if key.eq_ignore_ascii_case("tr_id") {
"KIS transaction id. Sent as the tr_id HTTP header."
} else {
"Endpoint-specific request parameter. Sent as URL query parameter."
},
})
})
.collect()
}
fn explain_response_keys(value: &serde_json::Value) -> serde_json::Value {
let Some(object) = value.as_object() else {
return serde_json::json!([{
"name": "response",
"description": "Response is not a JSON object.",
"value_type": value_type(value),
}]);
};
serde_json::json!(object
.iter()
.map(|(key, value)| {
serde_json::json!({
"name": key,
"description": response_key_description(key),
"value_type": value_type(value),
})
})
.collect::<Vec<_>>())
}
fn response_key_description(key: &str) -> &'static str {
match key {
"rt_cd" => {
"KIS result code. 0 usually means success; non-zero indicates API-level failure."
}
"msg_cd" => "KIS message code for the response.",
"msg1" => "Human-readable KIS response message.",
"output" => "Main response payload for APIs that return a single output object.",
"output1" => "First response payload. Meaning depends on endpoint, often list/detail rows.",
"output2" => "Second response payload. Meaning depends on endpoint, often summary values.",
"ctx_area_fk100" => "Pagination cursor field used by some KIS list APIs.",
"ctx_area_nk100" => "Pagination cursor field used by some KIS list APIs.",
_ => "Endpoint-specific response field returned by KIS.",
}
}
fn value_type(value: &serde_json::Value) -> &'static str {
match value {
serde_json::Value::Null => "null",
serde_json::Value::Bool(_) => "boolean",
serde_json::Value::Number(_) => "number",
serde_json::Value::String(_) => "string",
serde_json::Value::Array(_) => "array",
serde_json::Value::Object(_) => "object",
}
}
fn parse_params(params: &[String]) -> Result<Vec<(String, String)>, String> {
params
.iter()
.map(|param| {
let (key, value) = param
.split_once('=')
.ok_or_else(|| format!("파라미터는 KEY=VALUE 형식이어야 합니다: {}", param))?;
if key.trim().is_empty() {
return Err(format!("파라미터 키가 비어 있습니다: {}", param));
}
Ok((key.trim().to_string(), value.trim().to_string()))
})
.collect()
}
+57
View File
@@ -0,0 +1,57 @@
use crate::apis::kis::KisApi;
use crate::core::TraderApi;
pub fn handle_market(symbol: &str) {
let api = KisApi::new();
match api.market(symbol) {
Ok(json) => {
let value = crate::core::output::parse_json_or_text(&json);
println!(
"{}",
crate::core::output::explained_with_raw(
"market",
"종목 현재가와 기업 기본 정보 조회 결과",
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(
"price",
"현재가와 시세 관련 값",
value
.get("price")
.cloned()
.unwrap_or_else(|| serde_json::json!({})),
),
crate::core::output::field(
"company",
"종목 및 기업 기본 정보",
value
.get("company")
.cloned()
.unwrap_or_else(|| serde_json::json!({})),
),
],
value,
)
);
}
Err(err) => eprintln!(
"{}",
crate::core::output::error("market", "종목 정보 조회 실패", &err)
),
}
}
+13
View File
@@ -0,0 +1,13 @@
pub mod account;
pub mod api;
pub mod market;
pub mod order;
pub mod search;
mod version;
pub use account::handle_account;
pub use api::handle_api;
pub use market::handle_market;
pub use order::handle_order;
pub use search::handle_search;
pub use version::handle_version;
+205
View File
@@ -0,0 +1,205 @@
use crate::apis::kis::KisApi;
use crate::core::TraderApi;
use clap::{Args, Subcommand};
#[derive(Subcommand)]
pub enum OrderCommands {
/// 국내주식 매수 주문
Buy(OrderPlaceCommand),
/// 국내주식 매도 주문
Sell(OrderPlaceCommand),
/// 주문/체결 조회
Status(OrderStatusCommand),
}
#[derive(Args)]
pub struct OrderPlaceCommand {
/// 종목코드
pub symbol: String,
/// 주문 수량
#[arg(long)]
pub qty: u64,
/// 지정가
#[arg(long, conflicts_with = "market")]
pub price: Option<u64>,
/// 시장가 주문
#[arg(long)]
pub market: bool,
}
#[derive(Args)]
pub struct OrderStatusCommand {
/// 주문번호
pub order_no: Option<String>,
/// 조회 시작일 (YYYYMMDD)
#[arg(long = "from")]
pub from: Option<String>,
/// 조회 종료일 (YYYYMMDD)
#[arg(long = "to")]
pub to: Option<String>,
}
pub fn handle_order(sub: &OrderCommands) {
let api = KisApi::new();
match sub {
OrderCommands::Buy(command) => {
match api.buy(&command.symbol, command.qty, command.price, command.market) {
Ok(json) => print_order_result("order buy", "국내주식 매수 주문 결과", &json),
Err(err) => eprintln!(
"{}",
crate::core::output::error("order buy", "매수 주문 실패", &err)
),
}
}
OrderCommands::Sell(command) => {
match api.sell(&command.symbol, command.qty, command.price, command.market) {
Ok(json) => print_order_result("order sell", "국내주식 매도 주문 결과", &json),
Err(err) => eprintln!(
"{}",
crate::core::output::error("order sell", "매도 주문 실패", &err)
),
}
}
OrderCommands::Status(command) => {
match api.order_status(
command.order_no.as_deref(),
command.from.as_deref(),
command.to.as_deref(),
) {
Ok(json) => print_order_status_result(&json),
Err(err) => eprintln!(
"{}",
crate::core::output::error("order status", "주문 조회 실패", &err)
),
}
}
}
}
fn print_order_result(command: &str, description: &str, json: &str) {
let value = crate::core::output::parse_json_or_text(json);
println!(
"{}",
crate::core::output::explained_with_raw(
command,
description,
vec![
crate::core::output::field(
"broker",
"주문에 사용한 증권사 API",
value
.get("broker")
.cloned()
.unwrap_or(serde_json::Value::Null),
),
crate::core::output::field(
"side",
"주문 방향. buy는 매수, sell은 매도",
value
.get("side")
.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(
"qty",
"주문 수량",
value.get("qty").cloned().unwrap_or(serde_json::Value::Null),
),
crate::core::output::field(
"order_type",
"주문 유형. limit은 지정가, market은 시장가",
value
.get("order_type")
.cloned()
.unwrap_or(serde_json::Value::Null),
),
crate::core::output::field(
"price",
"지정가 주문 가격. 시장가 주문이면 null",
value
.get("price")
.cloned()
.unwrap_or(serde_json::Value::Null),
),
crate::core::output::field(
"order",
"증권사에서 반환한 주문 접수 정보",
value
.get("order")
.cloned()
.unwrap_or_else(|| serde_json::json!({})),
),
],
value,
)
);
}
fn print_order_status_result(json: &str) {
let value = crate::core::output::parse_json_or_text(json);
println!(
"{}",
crate::core::output::explained_with_raw(
"order status",
"주문 및 체결 내역 조회 결과",
vec![
crate::core::output::field(
"broker",
"조회에 사용한 증권사 API",
value
.get("broker")
.cloned()
.unwrap_or(serde_json::Value::Null),
),
crate::core::output::field(
"account",
"조회한 계좌번호",
value
.get("account")
.cloned()
.unwrap_or(serde_json::Value::Null),
),
crate::core::output::field(
"order_no",
"조회 대상으로 지정한 주문번호. 없으면 기간 내 전체 주문",
value
.get("order_no")
.cloned()
.unwrap_or(serde_json::Value::Null),
),
crate::core::output::field(
"orders",
"주문 및 체결 상세 목록",
value
.get("orders")
.cloned()
.unwrap_or_else(|| serde_json::json!([])),
),
crate::core::output::field(
"summary",
"주문 조회 요약 정보",
value
.get("summary")
.cloned()
.unwrap_or_else(|| serde_json::json!({})),
),
],
value,
)
);
}
+47
View File
@@ -0,0 +1,47 @@
use crate::providers::naver;
pub fn handle_search(query: &str) {
match naver::search::search(query) {
Ok(json) => {
let value = crate::core::output::parse_json_or_text(&json);
println!(
"{}",
crate::core::output::explained_with_raw(
"search",
"네이버 증권 기반 종목 이름 검색 결과",
vec![
crate::core::output::field(
"provider",
"종목 검색 데이터를 제공한 외부 서비스",
value
.get("provider")
.cloned()
.unwrap_or(serde_json::Value::Null),
),
crate::core::output::field(
"query",
"사용자가 입력한 검색어",
value
.get("query")
.cloned()
.unwrap_or(serde_json::Value::Null),
),
crate::core::output::field(
"stocks",
"검색어와 일치하는 종목 목록",
value
.get("stocks")
.cloned()
.unwrap_or_else(|| serde_json::json!([])),
),
],
value,
)
);
}
Err(err) => eprintln!(
"{}",
crate::core::output::error("search", "종목 검색 실패", &err)
),
}
}
+21
View File
@@ -0,0 +1,21 @@
pub fn handle_version() {
println!(
"{}",
crate::core::output::explained(
"version",
"openstock CLI 버전 정보",
vec![
crate::core::output::field(
"name",
"프로그램 이름",
serde_json::json!(env!("CARGO_PKG_NAME")),
),
crate::core::output::field(
"version",
"현재 실행 중인 openstock 버전",
serde_json::json!(env!("CARGO_PKG_VERSION")),
),
],
)
);
}
+36
View File
@@ -0,0 +1,36 @@
use std::collections::HashMap;
use std::fs;
use std::path::Path;
/// .env 파일을 읽어 key-value 맵으로 반환한다.
pub fn read_env(path: &Path) -> HashMap<String, String> {
let mut map = HashMap::new();
let content = match fs::read_to_string(path) {
Ok(c) => c,
Err(_) => return map,
};
for line in content.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some((key, value)) = line.split_once('=') {
map.insert(key.trim().to_string(), value.trim().to_string());
}
}
map
}
/// .env 파일에 key-value를 저장한다. (기존 값은 유지, 있으면 갱신)
pub fn write_env(path: &Path, key: &str, value: &str) -> Result<(), String> {
let mut map = read_env(path);
map.insert(key.to_string(), value.to_string());
let mut content = String::new();
// 이미 기존 파일이 있으면 주석과 빈 줄을 유지하기 위해 원본을 우선 쓰고,
// 새 키는 마지막에 추가하는 전략. 단순화를 위해 전체를 다시 쓴다.
for (k, v) in &map {
content.push_str(&format!("{}={}\n", k, v));
}
fs::write(path, content).map_err(|e| format!(".env 쓰기 실패: {}", e))
}
+7
View File
@@ -0,0 +1,7 @@
pub mod dotenv;
pub mod output;
pub mod registry;
pub mod trader;
pub use registry::ApiRegistry;
pub use trader::{LoginArguments, TraderApi, TraderBase};
+66
View File
@@ -0,0 +1,66 @@
use serde_json::{json, Value};
pub struct OutputField {
name: &'static str,
description: &'static str,
value: Value,
}
impl OutputField {
pub fn new(name: &'static str, description: &'static str, value: Value) -> Self {
Self {
name,
description,
value,
}
}
}
pub fn explained(command: &str, description: &str, fields: Vec<OutputField>) -> String {
explained_with_raw(command, description, fields, Value::Null)
}
pub fn error(command: &str, description: &str, message: &str) -> String {
explained(
command,
description,
vec![
field("status", "명령 실행 결과", json!("error")),
field("message", "오류 내용", json!(message)),
],
)
}
pub fn explained_with_raw(
command: &str,
description: &str,
fields: Vec<OutputField>,
raw: Value,
) -> String {
let fields = fields
.into_iter()
.map(|field| {
json!({
"name": field.name,
"description": field.description,
"value": field.value,
})
})
.collect::<Vec<_>>();
json!({
"command": command,
"description": description,
"fields": fields,
"raw": raw,
})
.to_string()
}
pub fn field(name: &'static str, description: &'static str, value: Value) -> OutputField {
OutputField::new(name, description, value)
}
pub fn parse_json_or_text(value: &str) -> Value {
serde_json::from_str(value).unwrap_or_else(|_| json!(value))
}
+23
View File
@@ -0,0 +1,23 @@
use super::trader::TraderApi;
/// API 구현체를 등록하고 관리하는 레지스트리
pub struct ApiRegistry {
apis: Vec<Box<dyn TraderApi>>,
}
impl ApiRegistry {
/// 빈 레지스트리 생성
pub fn new() -> Self {
Self { apis: Vec::new() }
}
/// API 구현체 등록
pub fn register(&mut self, api: Box<dyn TraderApi>) {
self.apis.push(api);
}
/// 등록된 모든 API 목록 반환
pub fn list(&self) -> &[Box<dyn TraderApi>] {
&self.apis
}
}
+87
View File
@@ -0,0 +1,87 @@
/// 증권사 기본 정보
pub struct TraderBase {
pub id: &'static str,
pub name: &'static str,
pub description: &'static str,
}
impl TraderBase {
pub fn new(id: &'static str, name: &'static str, description: &'static str) -> Self {
Self {
id,
name,
description,
}
}
}
/// 증권사별 로그인에 필요한 인자들의 공통 트레이트
pub trait LoginArguments {
/// .env 파일에 저장할 토큰 키 이름 (예: "KIS_ACCESS_TOKEN")
fn token_env_key(&self) -> &'static str;
/// 구체적인 타입으로 다운캐스팅하기 위한 Any 변환
fn as_any(&self) -> &dyn std::any::Any;
}
/// 증권사 API가 구현해야 하는 공통 트레이트
pub trait TraderApi {
/// 기본 정보 반환
fn base(&self) -> &TraderBase;
/// 증권사 ID
fn id(&self) -> &'static str {
self.base().id
}
/// 증권사 이름
fn name(&self) -> &'static str {
self.base().name
}
/// 증권사 설명
fn description(&self) -> &'static str {
self.base().description
}
/// 로그인 (토큰 발급 등 인증 처리)
fn login(&mut self, args: &dyn LoginArguments) -> Result<(), String>;
/// API 호출 (endpoint: API 경로, params: 요청 파라미터)
fn call(&self, endpoint: &str, params: &[(&str, &str)]) -> Result<String, String>;
/// 계좌 상태 조회 (잔액, 보유종목 등)
fn account_status(&self) -> Result<String, String>;
/// 매수 주문
fn buy(
&self,
symbol: &str,
qty: u64,
price: Option<u64>,
market: bool,
) -> Result<String, String>;
/// 매도 주문
fn sell(
&self,
symbol: &str,
qty: u64,
price: Option<u64>,
market: bool,
) -> Result<String, String>;
/// 주문/체결 조회
fn order_status(
&self,
order_no: Option<&str>,
start_date: Option<&str>,
end_date: Option<&str>,
) -> Result<String, String>;
/// 종목 시장 정보 조회
fn market(&self, symbol: &str) -> Result<String, String>;
/// 증권사 정보 (키-값 쌍)
fn info(&self) -> Vec<(&'static str, &'static str)>;
}
+91
View File
@@ -0,0 +1,91 @@
use clap::{Parser, Subcommand};
use commands::account::AccountCommands;
use commands::api::ApiCommands;
use commands::order::OrderCommands;
mod apis;
mod commands;
mod core;
mod providers;
#[derive(Parser)]
#[command(
name = env!("CARGO_PKG_NAME"),
about = "CLI로 사용하는 증권 API",
version = env!("CARGO_PKG_VERSION"),
help_template = "{name}:: {about}\n 사용방법:: {usage}\n\n{all-args}"
)]
struct Cli {
#[command(subcommand)]
command: Option<Commands>,
}
#[derive(Subcommand)]
enum Commands {
/// 애플리케이션 버젼 표시
Version,
/// 증권사 API 리스트 조회 및 API 설정
Api {
#[command(subcommand)]
sub: ApiCommands,
},
/// 계좌 조회 및 관리
Account {
#[command(subcommand)]
sub: AccountCommands,
},
/// 주문 실행 및 조회
Order {
#[command(subcommand)]
sub: OrderCommands,
},
/// 종목 검색
Search {
/// 종목명 또는 종목코드
query: String,
},
/// 종목 정보 및 기업정보 조회
Market {
/// 종목코드
symbol: String,
},
}
fn main() {
let cli = Cli::parse();
match &cli.command {
Some(Commands::Version) => commands::handle_version(),
Some(Commands::Api { sub }) => commands::handle_api(sub),
Some(Commands::Account { sub }) => commands::handle_account(sub),
Some(Commands::Order { sub }) => commands::handle_order(sub),
Some(Commands::Search { query }) => commands::handle_search(query),
Some(Commands::Market { symbol }) => commands::handle_market(symbol),
None => {
println!(
"{}",
core::output::explained(
"help",
"명령이 지정되지 않았을 때의 사용 안내",
vec![
core::output::field(
"program",
"실행한 CLI 프로그램 이름",
serde_json::json!(env!("CARGO_PKG_NAME")),
),
core::output::field(
"usage",
"도움말을 확인하는 명령",
serde_json::json!(format!("{} --help", env!("CARGO_PKG_NAME"))),
),
],
)
);
}
}
}
+1
View File
@@ -0,0 +1 @@
pub mod naver;
+1
View File
@@ -0,0 +1 @@
pub mod search;
+187
View File
@@ -0,0 +1,187 @@
use serde_json::json;
const NAVER_STOCK_SEARCH_URL: &str = "https://m.stock.naver.com/front-api/search/autoComplete";
const NAVER_STOCK_BASE_URL: &str = "https://m.stock.naver.com";
pub fn search(query: &str) -> Result<String, String> {
let query = query.trim();
if query.is_empty() {
return Err("검색어가 비어 있습니다.".to_string());
}
let url = format!(
"{}?query={}&target=stock",
NAVER_STOCK_SEARCH_URL,
percent_encode(query)
);
let response = ureq::get(&url)
.header("User-Agent", "openstock/0.1")
.call()
.map_err(|err| format!("[Naver] 종목 검색 요청 실패: {}", err))?;
let status = response.status();
let body = response
.into_body()
.read_to_string()
.map_err(|err| format!("[Naver] 종목 검색 응답 읽기 실패: {}", err))?;
if !status.is_success() {
return Err(format!("[Naver] 종목 검색 오류 ({}): {}", status, body));
}
let json: serde_json::Value = serde_json::from_str(&body)
.map_err(|err| format!("[Naver] 종목 검색 응답 파싱 실패: {}", err))?;
let stocks = parse_stocks(&json);
Ok(json!({
"provider": "NAVER",
"query": query,
"stocks": stocks,
"raw": json,
})
.to_string())
}
fn parse_stocks(value: &serde_json::Value) -> Vec<serde_json::Value> {
let modern_items = value
.get("result")
.and_then(|result| result.get("items"))
.and_then(|items| items.as_array())
.into_iter()
.flatten();
let legacy_items = value
.get("items")
.and_then(|items| items.as_array())
.into_iter()
.flatten()
.flat_map(|group| group.as_array().into_iter().flatten());
modern_items
.chain(legacy_items)
.filter_map(parse_stock_item)
.collect()
}
fn parse_stock_item(item: &serde_json::Value) -> Option<serde_json::Value> {
if let Some(values) = item.as_array() {
let name = values
.first()
.and_then(|value| value.as_str())
.unwrap_or("");
let code = values.get(1).and_then(|value| value.as_str()).unwrap_or("");
let market = values.get(2).and_then(|value| value.as_str()).unwrap_or("");
if code.is_empty() {
return None;
}
return Some(json!({
"code": code,
"name": strip_html(name),
"market": market,
}));
}
if let Some(object) = item.as_object() {
let code = object
.get("code")
.or_else(|| object.get("itemCode"))
.or_else(|| object.get("symbolCode"))
.or_else(|| object.get("ticker"))
.and_then(|value| value.as_str())
.unwrap_or("");
let name = object
.get("name")
.or_else(|| object.get("stockName"))
.or_else(|| object.get("itemName"))
.and_then(|value| value.as_str())
.unwrap_or("");
let market = object
.get("typeName")
.or_else(|| object.get("stockExchangeName"))
.or_else(|| object.get("marketName"))
.or_else(|| object.get("typeCode"))
.or_else(|| object.get("market"))
.and_then(|value| value.as_str())
.unwrap_or("");
let market_code = object
.get("typeCode")
.or_else(|| object.get("stockExchangeType"))
.and_then(|value| value.as_str())
.unwrap_or("");
let url = object
.get("url")
.or_else(|| object.get("endUrl"))
.and_then(|value| value.as_str())
.unwrap_or("");
let reuters_code = object
.get("reutersCode")
.and_then(|value| value.as_str())
.unwrap_or("");
let nation_code = object
.get("nationCode")
.or_else(|| object.get("nationType"))
.and_then(|value| value.as_str())
.unwrap_or("");
let category = object
.get("category")
.or_else(|| object.get("stockType"))
.and_then(|value| value.as_str())
.unwrap_or("");
if code.is_empty() {
return None;
}
return Some(json!({
"code": code,
"name": strip_html(name),
"market": market,
"market_code": market_code,
"nation_code": nation_code,
"category": category,
"reuters_code": reuters_code,
"url": full_naver_url(url),
}));
}
None
}
fn full_naver_url(url: &str) -> String {
if url.is_empty() || url.starts_with("http://") || url.starts_with("https://") {
return url.to_string();
}
format!("{}{}", NAVER_STOCK_BASE_URL, url)
}
fn strip_html(value: &str) -> String {
let mut result = String::new();
let mut inside_tag = false;
for ch in value.chars() {
match ch {
'<' => inside_tag = true,
'>' => inside_tag = false,
_ if !inside_tag => result.push(ch),
_ => {}
}
}
result
}
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
}