Implement KIS CLI trading workflow
This commit is contained in:
@@ -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"))
|
||||
}
|
||||
@@ -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", ¶ms)?;
|
||||
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())
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"),
|
||||
]
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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", ¶ms)?;
|
||||
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())
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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",
|
||||
¶ms,
|
||||
)?;
|
||||
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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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",
|
||||
¶ms,
|
||||
)?;
|
||||
serde_json::from_str(&response)
|
||||
.map_err(|err| format!("[KIS] 주식기본조회 응답 파싱 실패: {}", err))
|
||||
}
|
||||
@@ -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"),
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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, ¶m_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(¶ms)),
|
||||
),
|
||||
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()
|
||||
}
|
||||
@@ -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)
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -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)
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -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")),
|
||||
),
|
||||
],
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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"))),
|
||||
),
|
||||
],
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod naver;
|
||||
@@ -0,0 +1 @@
|
||||
pub mod search;
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user