Implement KIS CLI trading workflow

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