From b86c34760dd2ee304ad3ed087ac05827ea283bf9 Mon Sep 17 00:00:00 2001 From: Hana Date: Mon, 8 Jun 2026 11:22:46 +0000 Subject: [PATCH] Implement KIS CLI trading workflow --- .env.example | 19 + .gitignore | 2 + .plan/command.md | 14 + Cargo.lock | 969 +++++++++++++++++++++++++++++++++ Cargo.toml | 9 + src/apis/kis/account_config.rs | 46 ++ src/apis/kis/account_status.rs | 33 ++ src/apis/kis/buy.rs | 12 + src/apis/kis/call.rs | 92 ++++ src/apis/kis/info.rs | 10 + src/apis/kis/login.rs | 224 ++++++++ src/apis/kis/main.rs | 94 ++++ src/apis/kis/market.rs | 40 ++ src/apis/kis/order_common.rs | 144 +++++ src/apis/kis/order_status.rs | 110 ++++ src/apis/kis/sell.rs | 12 + src/apis/kis/stock_info.rs | 16 + src/apis/mock/info.rs | 10 + src/apis/mock/login.rs | 14 + src/apis/mock/main.rs | 55 ++ src/apis/mod.rs | 14 + src/commands/account.rs | 68 +++ src/commands/api.rs | 412 ++++++++++++++ src/commands/market.rs | 57 ++ src/commands/mod.rs | 13 + src/commands/order.rs | 205 +++++++ src/commands/search.rs | 47 ++ src/commands/version.rs | 21 + src/core/dotenv.rs | 36 ++ src/core/mod.rs | 7 + src/core/output.rs | 66 +++ src/core/registry.rs | 23 + src/core/trader.rs | 87 +++ src/main.rs | 91 ++++ src/providers/mod.rs | 1 + src/providers/naver/mod.rs | 1 + src/providers/naver/search.rs | 187 +++++++ 37 files changed, 3261 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 .plan/command.md create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 src/apis/kis/account_config.rs create mode 100644 src/apis/kis/account_status.rs create mode 100644 src/apis/kis/buy.rs create mode 100644 src/apis/kis/call.rs create mode 100644 src/apis/kis/info.rs create mode 100644 src/apis/kis/login.rs create mode 100644 src/apis/kis/main.rs create mode 100644 src/apis/kis/market.rs create mode 100644 src/apis/kis/order_common.rs create mode 100644 src/apis/kis/order_status.rs create mode 100644 src/apis/kis/sell.rs create mode 100644 src/apis/kis/stock_info.rs create mode 100644 src/apis/mock/info.rs create mode 100644 src/apis/mock/login.rs create mode 100644 src/apis/mock/main.rs create mode 100644 src/apis/mod.rs create mode 100644 src/commands/account.rs create mode 100644 src/commands/api.rs create mode 100644 src/commands/market.rs create mode 100644 src/commands/mod.rs create mode 100644 src/commands/order.rs create mode 100644 src/commands/search.rs create mode 100644 src/commands/version.rs create mode 100644 src/core/dotenv.rs create mode 100644 src/core/mod.rs create mode 100644 src/core/output.rs create mode 100644 src/core/registry.rs create mode 100644 src/core/trader.rs create mode 100644 src/main.rs create mode 100644 src/providers/mod.rs create mode 100644 src/providers/naver/mod.rs create mode 100644 src/providers/naver/search.rs diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9839246 --- /dev/null +++ b/.env.example @@ -0,0 +1,19 @@ +# 한국투자증권 (KIS) 실전 API 로그인 필수 설정 +# https://apiportal.koreainvestment.com/ 에서 앱 등록 후 발급 + +KIS_APPKEY=your_appkey_here +KIS_APPSECRET=your_appsecret_here + +# 계좌 상태 조회용 고정 계좌 설정 +# KIS_ACCOUNT=12345678-01 +# 또는 +# KIS_CANO=12345678 +# KIS_ACNT_PRDT_CD=01 + +# 로그인 후 자동 저장되는 토큰 (직접 입력 불필요) +# 유효한 토큰이 있으면 `openstock api login`은 재발급하지 않습니다. +# 강제 재발급은 `openstock api login --force`를 사용하세요. +# KIS_ACCESS_TOKEN= +# KIS_TOKEN_TYPE= +# KIS_ACCESS_TOKEN_EXPIRED_AT= +# KIS_ACCESS_TOKEN_EXPIRES_IN= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5dc70ab --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/target/ +.env \ No newline at end of file diff --git a/.plan/command.md b/.plan/command.md new file mode 100644 index 0000000..46369fb --- /dev/null +++ b/.plan/command.md @@ -0,0 +1,14 @@ +# 만들 커맨드 목록 + +1. help + - 명령어 설명 +2. version + - 버젼 보여주기 +3. api + - 증권사, apikey 등으로 api 연결 + - 연결 가능한 증권사 리스트를 볼 수 있어야 함 (openstock api list) +4. account + - 계좌정보 조회/관리 + - 계좌를 리얼환경/페이퍼환경을 전환 가능해야 함 +5. market + - 시세정보 보기 \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..c210445 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,969 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]] +name = "cookie_store" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206" +dependencies = [ + "cookie", + "document-features", + "idna", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "time", + "url", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "http" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "log" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" + +[[package]] +name = "memchr" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "openstock" +version = "0.1.0" +dependencies = [ + "clap", + "serde_json", + "ureq", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" +dependencies = [ + "base64", + "cookie_store", + "flate2", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "ureq-proto", + "utf8-zero", + "webpki-roots", +] + +[[package]] +name = "ureq-proto" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" +dependencies = [ + "base64", + "http", + "httparse", + "log", +] + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..b76eafc --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "openstock" +version = "0.1.0" +edition = "2021" + +[dependencies] +clap = { version = "4", features = ["derive"] } +ureq = { version = "3", features = ["json"] } +serde_json = "1" diff --git a/src/apis/kis/account_config.rs b/src/apis/kis/account_config.rs new file mode 100644 index 0000000..1594c06 --- /dev/null +++ b/src/apis/kis/account_config.rs @@ -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 { + 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")) +} diff --git a/src/apis/kis/account_status.rs b/src/apis/kis/account_status.rs new file mode 100644 index 0000000..1f2bb6b --- /dev/null +++ b/src/apis/kis/account_status.rs @@ -0,0 +1,33 @@ +use crate::apis::kis::KisApi; +use crate::core::TraderApi; +use serde_json::json; + +pub fn account_status(api: &KisApi) -> Result { + 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()) +} diff --git a/src/apis/kis/buy.rs b/src/apis/kis/buy.rs new file mode 100644 index 0000000..10df429 --- /dev/null +++ b/src/apis/kis/buy.rs @@ -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, + market: bool, +) -> Result { + order_common::order_cash(api, OrderSide::Buy, symbol.trim(), qty, price, market) +} diff --git a/src/apis/kis/call.rs b/src/apis/kis/call.rs new file mode 100644 index 0000000..539c9ef --- /dev/null +++ b/src/apis/kis/call.rs @@ -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 { + 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::>(); + 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::>() + .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 +} diff --git a/src/apis/kis/info.rs b/src/apis/kis/info.rs new file mode 100644 index 0000000..be096a4 --- /dev/null +++ b/src/apis/kis/info.rs @@ -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"), + ] +} diff --git a/src/apis/kis/login.rs b/src/apis/kis/login.rs new file mode 100644 index 0000000..477eee8 --- /dev/null +++ b/src/apis/kis/login.rs @@ -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 { + 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::(&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) -> 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")); + } +} diff --git a/src/apis/kis/main.rs b/src/apis/kis/main.rs new file mode 100644 index 0000000..240eefd --- /dev/null +++ b/src/apis/kis/main.rs @@ -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, +} + +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::() + .ok_or("[KIS] login: 잘못된 로그인 인자 타입입니다")?; + + let token = login::login(kis_args)?; + self.token = Some(token); + Ok(()) + } + + fn call(&self, endpoint: &str, params: &[(&str, &str)]) -> Result { + call::call(self, endpoint, params) + } + + fn account_status(&self) -> Result { + account_status::account_status(self) + } + + fn buy( + &self, + symbol: &str, + qty: u64, + price: Option, + market: bool, + ) -> Result { + buy::buy(self, symbol, qty, price, market) + } + + fn sell( + &self, + symbol: &str, + qty: u64, + price: Option, + market: bool, + ) -> Result { + sell::sell(self, symbol, qty, price, market) + } + + fn order_status( + &self, + order_no: Option<&str>, + start_date: Option<&str>, + end_date: Option<&str>, + ) -> Result { + order_status::order_status(self, order_no, start_date, end_date) + } + + fn market(&self, symbol: &str) -> Result { + market::market(self, symbol) + } + + fn info(&self) -> Vec<(&'static str, &'static str)> { + info::info() + } +} diff --git a/src/apis/kis/market.rs b/src/apis/kis/market.rs new file mode 100644 index 0000000..a9b2676 --- /dev/null +++ b/src/apis/kis/market.rs @@ -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 { + 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 { + 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()) +} diff --git a/src/apis/kis/order_common.rs b/src/apis/kis/order_common.rs new file mode 100644 index 0000000..469e9eb --- /dev/null +++ b/src/apis/kis/order_common.rs @@ -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, + market: bool, +) -> Result { + 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::(&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 { + 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, 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(()) +} diff --git a/src/apis/kis/order_status.rs b/src/apis/kis/order_status.rs new file mode 100644 index 0000000..b23abfb --- /dev/null +++ b/src/apis/kis/order_status.rs @@ -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 { + 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::(&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) +} diff --git a/src/apis/kis/sell.rs b/src/apis/kis/sell.rs new file mode 100644 index 0000000..331c829 --- /dev/null +++ b/src/apis/kis/sell.rs @@ -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, + market: bool, +) -> Result { + order_common::order_cash(api, OrderSide::Sell, symbol.trim(), qty, price, market) +} diff --git a/src/apis/kis/stock_info.rs b/src/apis/kis/stock_info.rs new file mode 100644 index 0000000..5079f2e --- /dev/null +++ b/src/apis/kis/stock_info.rs @@ -0,0 +1,16 @@ +use crate::apis::kis::KisApi; +use crate::core::TraderApi; + +pub fn stock_info(api: &KisApi, symbol: &str) -> Result { + 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)) +} diff --git a/src/apis/mock/info.rs b/src/apis/mock/info.rs new file mode 100644 index 0000000..ad33172 --- /dev/null +++ b/src/apis/mock/info.rs @@ -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"), + ] +} diff --git a/src/apis/mock/login.rs b/src/apis/mock/login.rs new file mode 100644 index 0000000..2d44fcb --- /dev/null +++ b/src/apis/mock/login.rs @@ -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 + } +} diff --git a/src/apis/mock/main.rs b/src/apis/mock/main.rs new file mode 100644 index 0000000..6b0fb86 --- /dev/null +++ b/src/apis/mock/main.rs @@ -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, +} + +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::() + .ok_or("[Mock] login: 잘못된 로그인 인자 타입입니다")?; + + self.token = Some("mock-token".to_string()); + println!("[Mock] 로그인 성공"); + Ok(()) + } + + fn call(&self, endpoint: &str, params: &[(&str, &str)]) -> Result { + 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) + } +} diff --git a/src/apis/mod.rs b/src/apis/mod.rs new file mode 100644 index 0000000..1cba892 --- /dev/null +++ b/src/apis/mod.rs @@ -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 +} diff --git a/src/commands/account.rs b/src/commands/account.rs new file mode 100644 index 0000000..707e940 --- /dev/null +++ b/src/commands/account.rs @@ -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) + ), + } + } + } +} diff --git a/src/commands/api.rs b/src/commands/api.rs new file mode 100644 index 0000000..6c592dd --- /dev/null +++ b/src/commands/api.rs @@ -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, + + /// 한국투자증권 Open API 앱시크릿 (.env의 KIS_APPSECRET보다 우선) + #[arg(long)] + appsecret: Option, + + /// 유효한 기존 접근토큰이 있어도 새로 발급 + #[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, +} + +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::>(); + 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::>(); + 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::>(), + "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 --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 ", + "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 --qty (--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 --qty (--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 { + 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::>()) +} + +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, 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() +} diff --git a/src/commands/market.rs b/src/commands/market.rs new file mode 100644 index 0000000..bb3d764 --- /dev/null +++ b/src/commands/market.rs @@ -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) + ), + } +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs new file mode 100644 index 0000000..e3fad1c --- /dev/null +++ b/src/commands/mod.rs @@ -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; diff --git a/src/commands/order.rs b/src/commands/order.rs new file mode 100644 index 0000000..da9722e --- /dev/null +++ b/src/commands/order.rs @@ -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, + + /// 시장가 주문 + #[arg(long)] + pub market: bool, +} + +#[derive(Args)] +pub struct OrderStatusCommand { + /// 주문번호 + pub order_no: Option, + + /// 조회 시작일 (YYYYMMDD) + #[arg(long = "from")] + pub from: Option, + + /// 조회 종료일 (YYYYMMDD) + #[arg(long = "to")] + pub to: Option, +} + +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, + ) + ); +} diff --git a/src/commands/search.rs b/src/commands/search.rs new file mode 100644 index 0000000..3e24854 --- /dev/null +++ b/src/commands/search.rs @@ -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) + ), + } +} diff --git a/src/commands/version.rs b/src/commands/version.rs new file mode 100644 index 0000000..8f7df2b --- /dev/null +++ b/src/commands/version.rs @@ -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")), + ), + ], + ) + ); +} diff --git a/src/core/dotenv.rs b/src/core/dotenv.rs new file mode 100644 index 0000000..a68c76d --- /dev/null +++ b/src/core/dotenv.rs @@ -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 { + 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)) +} diff --git a/src/core/mod.rs b/src/core/mod.rs new file mode 100644 index 0000000..04049da --- /dev/null +++ b/src/core/mod.rs @@ -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}; diff --git a/src/core/output.rs b/src/core/output.rs new file mode 100644 index 0000000..a6e9b44 --- /dev/null +++ b/src/core/output.rs @@ -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) -> 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, + raw: Value, +) -> String { + let fields = fields + .into_iter() + .map(|field| { + json!({ + "name": field.name, + "description": field.description, + "value": field.value, + }) + }) + .collect::>(); + + 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)) +} diff --git a/src/core/registry.rs b/src/core/registry.rs new file mode 100644 index 0000000..43803cf --- /dev/null +++ b/src/core/registry.rs @@ -0,0 +1,23 @@ +use super::trader::TraderApi; + +/// API 구현체를 등록하고 관리하는 레지스트리 +pub struct ApiRegistry { + apis: Vec>, +} + +impl ApiRegistry { + /// 빈 레지스트리 생성 + pub fn new() -> Self { + Self { apis: Vec::new() } + } + + /// API 구현체 등록 + pub fn register(&mut self, api: Box) { + self.apis.push(api); + } + + /// 등록된 모든 API 목록 반환 + pub fn list(&self) -> &[Box] { + &self.apis + } +} diff --git a/src/core/trader.rs b/src/core/trader.rs new file mode 100644 index 0000000..0663e93 --- /dev/null +++ b/src/core/trader.rs @@ -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; + + /// 계좌 상태 조회 (잔액, 보유종목 등) + fn account_status(&self) -> Result; + + /// 매수 주문 + fn buy( + &self, + symbol: &str, + qty: u64, + price: Option, + market: bool, + ) -> Result; + + /// 매도 주문 + fn sell( + &self, + symbol: &str, + qty: u64, + price: Option, + market: bool, + ) -> Result; + + /// 주문/체결 조회 + fn order_status( + &self, + order_no: Option<&str>, + start_date: Option<&str>, + end_date: Option<&str>, + ) -> Result; + + /// 종목 시장 정보 조회 + fn market(&self, symbol: &str) -> Result; + + /// 증권사 정보 (키-값 쌍) + fn info(&self) -> Vec<(&'static str, &'static str)>; +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..f00cef4 --- /dev/null +++ b/src/main.rs @@ -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, +} + +#[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"))), + ), + ], + ) + ); + } + } +} diff --git a/src/providers/mod.rs b/src/providers/mod.rs new file mode 100644 index 0000000..d93182d --- /dev/null +++ b/src/providers/mod.rs @@ -0,0 +1 @@ +pub mod naver; diff --git a/src/providers/naver/mod.rs b/src/providers/naver/mod.rs new file mode 100644 index 0000000..a557bff --- /dev/null +++ b/src/providers/naver/mod.rs @@ -0,0 +1 @@ +pub mod search; diff --git a/src/providers/naver/search.rs b/src/providers/naver/search.rs new file mode 100644 index 0000000..dcbfb3a --- /dev/null +++ b/src/providers/naver/search.rs @@ -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 { + 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 { + 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 { + 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 +}