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