Add market data universe and DART evidence workflows
This commit is contained in:
@@ -17,3 +17,7 @@ KIS_APPSECRET=your_appsecret_here
|
|||||||
# KIS_TOKEN_TYPE=
|
# KIS_TOKEN_TYPE=
|
||||||
# KIS_ACCESS_TOKEN_EXPIRED_AT=
|
# KIS_ACCESS_TOKEN_EXPIRED_AT=
|
||||||
# KIS_ACCESS_TOKEN_EXPIRES_IN=
|
# KIS_ACCESS_TOKEN_EXPIRES_IN=
|
||||||
|
|
||||||
|
# OpenDART 공시정보 API 키
|
||||||
|
# https://opendart.fss.or.kr/ 에서 발급
|
||||||
|
OPENDART_API_KEY=your_opendart_api_key_here
|
||||||
|
|||||||
+2
-1
@@ -1,2 +1,3 @@
|
|||||||
/target/
|
/target/
|
||||||
.env
|
.env
|
||||||
|
.openstock/
|
||||||
|
|||||||
Generated
+488
-99
@@ -44,7 +44,7 @@ version = "1.1.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-sys 0.61.2",
|
"windows-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -55,7 +55,22 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"anstyle",
|
"anstyle",
|
||||||
"once_cell_polyfill",
|
"once_cell_polyfill",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anyhow"
|
||||||
|
version = "1.0.102"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "arbitrary"
|
||||||
|
version = "1.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
|
||||||
|
dependencies = [
|
||||||
|
"derive_arbitrary",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -64,6 +79,24 @@ version = "0.22.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "base64ct"
|
||||||
|
version = "1.8.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bitflags"
|
||||||
|
version = "2.13.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bumpalo"
|
||||||
|
version = "3.20.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bytes"
|
name = "bytes"
|
||||||
version = "1.11.1"
|
version = "1.11.1"
|
||||||
@@ -161,6 +194,22 @@ dependencies = [
|
|||||||
"url",
|
"url",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "core-foundation"
|
||||||
|
version = "0.10.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
|
||||||
|
dependencies = [
|
||||||
|
"core-foundation-sys",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "core-foundation-sys"
|
||||||
|
version = "0.8.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crc32fast"
|
name = "crc32fast"
|
||||||
version = "1.5.0"
|
version = "1.5.0"
|
||||||
@@ -170,6 +219,22 @@ dependencies = [
|
|||||||
"cfg-if",
|
"cfg-if",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crossbeam-utils"
|
||||||
|
version = "0.8.21"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "der"
|
||||||
|
version = "0.8.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "71fd89660b2dc699704064e59e9dba0147b903e85319429e131620d022be411b"
|
||||||
|
dependencies = [
|
||||||
|
"pem-rfc7468",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "deranged"
|
name = "deranged"
|
||||||
version = "0.5.8"
|
version = "0.5.8"
|
||||||
@@ -179,6 +244,17 @@ dependencies = [
|
|||||||
"powerfmt",
|
"powerfmt",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "derive_arbitrary"
|
||||||
|
version = "1.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "displaydoc"
|
name = "displaydoc"
|
||||||
version = "0.2.6"
|
version = "0.2.6"
|
||||||
@@ -199,12 +275,37 @@ dependencies = [
|
|||||||
"litrs",
|
"litrs",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "encoding_rs"
|
||||||
|
version = "0.8.35"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "equivalent"
|
name = "equivalent"
|
||||||
version = "1.0.2"
|
version = "1.0.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "errno"
|
||||||
|
version = "0.3.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"windows-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fastrand"
|
||||||
|
version = "2.4.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "find-msvc-tools"
|
name = "find-msvc-tools"
|
||||||
version = "0.1.9"
|
version = "0.1.9"
|
||||||
@@ -221,6 +322,27 @@ dependencies = [
|
|||||||
"miniz_oxide",
|
"miniz_oxide",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "foldhash"
|
||||||
|
version = "0.1.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "foreign-types"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
|
||||||
|
dependencies = [
|
||||||
|
"foreign-types-shared",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "foreign-types-shared"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "form_urlencoded"
|
name = "form_urlencoded"
|
||||||
version = "1.2.2"
|
version = "1.2.2"
|
||||||
@@ -232,13 +354,24 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "getrandom"
|
name = "getrandom"
|
||||||
version = "0.2.17"
|
version = "0.4.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"libc",
|
"libc",
|
||||||
"wasi",
|
"r-efi",
|
||||||
|
"wasip2",
|
||||||
|
"wasip3",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hashbrown"
|
||||||
|
version = "0.15.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
|
||||||
|
dependencies = [
|
||||||
|
"foldhash",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -351,6 +484,12 @@ dependencies = [
|
|||||||
"zerovec",
|
"zerovec",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "id-arena"
|
||||||
|
version = "2.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "idna"
|
name = "idna"
|
||||||
version = "1.1.0"
|
version = "1.1.0"
|
||||||
@@ -379,7 +518,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
|
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"equivalent",
|
"equivalent",
|
||||||
"hashbrown",
|
"hashbrown 0.17.1",
|
||||||
|
"serde",
|
||||||
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -394,12 +535,24 @@ version = "1.0.18"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "leb128fmt"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libc"
|
name = "libc"
|
||||||
version = "0.2.186"
|
version = "0.2.186"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
|
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "linux-raw-sys"
|
||||||
|
version = "0.12.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "litemap"
|
name = "litemap"
|
||||||
version = "0.8.2"
|
version = "0.8.2"
|
||||||
@@ -434,6 +587,23 @@ dependencies = [
|
|||||||
"simd-adler32",
|
"simd-adler32",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "native-tls"
|
||||||
|
version = "0.2.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"log",
|
||||||
|
"openssl",
|
||||||
|
"openssl-probe",
|
||||||
|
"openssl-sys",
|
||||||
|
"schannel",
|
||||||
|
"security-framework",
|
||||||
|
"security-framework-sys",
|
||||||
|
"tempfile",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-conv"
|
name = "num-conv"
|
||||||
version = "0.2.2"
|
version = "0.2.2"
|
||||||
@@ -452,13 +622,68 @@ version = "1.70.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "openssl"
|
||||||
|
version = "0.10.80"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"cfg-if",
|
||||||
|
"foreign-types",
|
||||||
|
"libc",
|
||||||
|
"openssl-macros",
|
||||||
|
"openssl-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "openssl-macros"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "openssl-probe"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "openssl-sys"
|
||||||
|
version = "0.9.116"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
"libc",
|
||||||
|
"pkg-config",
|
||||||
|
"vcpkg",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openstock"
|
name = "openstock"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"clap",
|
"clap",
|
||||||
|
"encoding_rs",
|
||||||
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"ureq",
|
"ureq",
|
||||||
|
"zip",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pem-rfc7468"
|
||||||
|
version = "1.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a6305423e0e7738146434843d1694d621cce767262b2a86910beab705e4493d9"
|
||||||
|
dependencies = [
|
||||||
|
"base64ct",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -467,6 +692,12 @@ version = "2.3.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pkg-config"
|
||||||
|
version = "0.3.33"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "potential_utf"
|
name = "potential_utf"
|
||||||
version = "0.1.5"
|
version = "0.1.5"
|
||||||
@@ -482,6 +713,16 @@ version = "0.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "prettyplease"
|
||||||
|
version = "0.2.37"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proc-macro2"
|
name = "proc-macro2"
|
||||||
version = "1.0.106"
|
version = "1.0.106"
|
||||||
@@ -501,32 +742,22 @@ dependencies = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ring"
|
name = "r-efi"
|
||||||
version = "0.17.14"
|
version = "6.0.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
|
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||||
dependencies = [
|
|
||||||
"cc",
|
|
||||||
"cfg-if",
|
|
||||||
"getrandom",
|
|
||||||
"libc",
|
|
||||||
"untrusted",
|
|
||||||
"windows-sys 0.52.0",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustls"
|
name = "rustix"
|
||||||
version = "0.23.40"
|
version = "1.1.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b"
|
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"log",
|
"bitflags",
|
||||||
"once_cell",
|
"errno",
|
||||||
"ring",
|
"libc",
|
||||||
"rustls-pki-types",
|
"linux-raw-sys",
|
||||||
"rustls-webpki",
|
"windows-sys",
|
||||||
"subtle",
|
|
||||||
"zeroize",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -539,16 +770,43 @@ dependencies = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustls-webpki"
|
name = "schannel"
|
||||||
version = "0.103.13"
|
version = "0.1.29"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
|
checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ring",
|
"windows-sys",
|
||||||
"rustls-pki-types",
|
|
||||||
"untrusted",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "security-framework"
|
||||||
|
version = "3.7.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"core-foundation",
|
||||||
|
"core-foundation-sys",
|
||||||
|
"libc",
|
||||||
|
"security-framework-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "security-framework-sys"
|
||||||
|
version = "2.17.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
|
||||||
|
dependencies = [
|
||||||
|
"core-foundation-sys",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "semver"
|
||||||
|
version = "1.0.28"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde"
|
name = "serde"
|
||||||
version = "1.0.228"
|
version = "1.0.228"
|
||||||
@@ -556,6 +814,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
|
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde_core",
|
"serde_core",
|
||||||
|
"serde_derive",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -621,12 +880,6 @@ version = "0.11.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "subtle"
|
|
||||||
version = "2.6.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "syn"
|
name = "syn"
|
||||||
version = "2.0.117"
|
version = "2.0.117"
|
||||||
@@ -649,6 +902,39 @@ dependencies = [
|
|||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tempfile"
|
||||||
|
version = "3.27.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||||
|
dependencies = [
|
||||||
|
"fastrand",
|
||||||
|
"getrandom",
|
||||||
|
"once_cell",
|
||||||
|
"rustix",
|
||||||
|
"windows-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "thiserror"
|
||||||
|
version = "2.0.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
|
||||||
|
dependencies = [
|
||||||
|
"thiserror-impl",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "thiserror-impl"
|
||||||
|
version = "2.0.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "time"
|
name = "time"
|
||||||
version = "0.3.47"
|
version = "0.3.47"
|
||||||
@@ -697,10 +983,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "untrusted"
|
name = "unicode-xid"
|
||||||
version = "0.9.0"
|
version = "0.2.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ureq"
|
name = "ureq"
|
||||||
@@ -710,16 +996,17 @@ checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64",
|
||||||
"cookie_store",
|
"cookie_store",
|
||||||
|
"der",
|
||||||
"flate2",
|
"flate2",
|
||||||
"log",
|
"log",
|
||||||
|
"native-tls",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"rustls",
|
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"ureq-proto",
|
"ureq-proto",
|
||||||
"utf8-zero",
|
"utf8-zero",
|
||||||
"webpki-roots",
|
"webpki-root-certs",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -764,6 +1051,12 @@ version = "0.2.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "vcpkg"
|
||||||
|
version = "0.2.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "version_check"
|
name = "version_check"
|
||||||
version = "0.9.5"
|
version = "0.9.5"
|
||||||
@@ -771,16 +1064,62 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasi"
|
name = "wasip2"
|
||||||
version = "0.11.1+wasi-snapshot-preview1"
|
version = "1.0.3+wasi-0.2.9"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6"
|
||||||
|
dependencies = [
|
||||||
|
"wit-bindgen 0.57.1",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "webpki-roots"
|
name = "wasip3"
|
||||||
|
version = "0.4.0+wasi-0.3.0-rc-2026-01-06"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
|
||||||
|
dependencies = [
|
||||||
|
"wit-bindgen 0.51.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasm-encoder"
|
||||||
|
version = "0.244.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
|
||||||
|
dependencies = [
|
||||||
|
"leb128fmt",
|
||||||
|
"wasmparser",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasm-metadata"
|
||||||
|
version = "0.244.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"indexmap",
|
||||||
|
"wasm-encoder",
|
||||||
|
"wasmparser",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasmparser"
|
||||||
|
version = "0.244.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"hashbrown 0.15.5",
|
||||||
|
"indexmap",
|
||||||
|
"semver",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "webpki-root-certs"
|
||||||
version = "1.0.7"
|
version = "1.0.7"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d"
|
checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
]
|
]
|
||||||
@@ -791,15 +1130,6 @@ version = "0.2.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
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]]
|
[[package]]
|
||||||
name = "windows-sys"
|
name = "windows-sys"
|
||||||
version = "0.61.2"
|
version = "0.61.2"
|
||||||
@@ -810,68 +1140,98 @@ dependencies = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-targets"
|
name = "wit-bindgen"
|
||||||
version = "0.52.6"
|
version = "0.51.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
|
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows_aarch64_gnullvm",
|
"wit-bindgen-rust-macro",
|
||||||
"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]]
|
[[package]]
|
||||||
name = "windows_aarch64_gnullvm"
|
name = "wit-bindgen"
|
||||||
version = "0.52.6"
|
version = "0.57.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_aarch64_msvc"
|
name = "wit-bindgen-core"
|
||||||
version = "0.52.6"
|
version = "0.51.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"heck",
|
||||||
|
"wit-parser",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_i686_gnu"
|
name = "wit-bindgen-rust"
|
||||||
version = "0.52.6"
|
version = "0.51.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
|
checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"heck",
|
||||||
|
"indexmap",
|
||||||
|
"prettyplease",
|
||||||
|
"syn",
|
||||||
|
"wasm-metadata",
|
||||||
|
"wit-bindgen-core",
|
||||||
|
"wit-component",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_i686_gnullvm"
|
name = "wit-bindgen-rust-macro"
|
||||||
version = "0.52.6"
|
version = "0.51.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"prettyplease",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
"wit-bindgen-core",
|
||||||
|
"wit-bindgen-rust",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_i686_msvc"
|
name = "wit-component"
|
||||||
version = "0.52.6"
|
version = "0.244.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"bitflags",
|
||||||
|
"indexmap",
|
||||||
|
"log",
|
||||||
|
"serde",
|
||||||
|
"serde_derive",
|
||||||
|
"serde_json",
|
||||||
|
"wasm-encoder",
|
||||||
|
"wasm-metadata",
|
||||||
|
"wasmparser",
|
||||||
|
"wit-parser",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_x86_64_gnu"
|
name = "wit-parser"
|
||||||
version = "0.52.6"
|
version = "0.244.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
|
||||||
|
dependencies = [
|
||||||
[[package]]
|
"anyhow",
|
||||||
name = "windows_x86_64_gnullvm"
|
"id-arena",
|
||||||
version = "0.52.6"
|
"indexmap",
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
"log",
|
||||||
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
"semver",
|
||||||
|
"serde",
|
||||||
[[package]]
|
"serde_derive",
|
||||||
name = "windows_x86_64_msvc"
|
"serde_json",
|
||||||
version = "0.52.6"
|
"unicode-xid",
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
"wasmparser",
|
||||||
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "writeable"
|
name = "writeable"
|
||||||
@@ -962,8 +1322,37 @@ dependencies = [
|
|||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zip"
|
||||||
|
version = "2.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50"
|
||||||
|
dependencies = [
|
||||||
|
"arbitrary",
|
||||||
|
"crc32fast",
|
||||||
|
"crossbeam-utils",
|
||||||
|
"displaydoc",
|
||||||
|
"flate2",
|
||||||
|
"indexmap",
|
||||||
|
"memchr",
|
||||||
|
"thiserror",
|
||||||
|
"zopfli",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zmij"
|
name = "zmij"
|
||||||
version = "1.0.21"
|
version = "1.0.21"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zopfli"
|
||||||
|
version = "0.8.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
|
||||||
|
dependencies = [
|
||||||
|
"bumpalo",
|
||||||
|
"crc32fast",
|
||||||
|
"log",
|
||||||
|
"simd-adler32",
|
||||||
|
]
|
||||||
|
|||||||
+4
-1
@@ -5,5 +5,8 @@ edition = "2021"
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
clap = { version = "4", features = ["derive"] }
|
clap = { version = "4", features = ["derive"] }
|
||||||
ureq = { version = "3", features = ["json"] }
|
encoding_rs = "0.8"
|
||||||
|
ureq = { version = "3", default-features = false, features = ["json", "gzip", "native-tls"] }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
zip = { version = "2", default-features = false, features = ["deflate"] }
|
||||||
|
|||||||
@@ -27,7 +27,8 @@ pub fn call(api: &KisApi, endpoint: &str, params: &[(&str, &str)]) -> Result<Str
|
|||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
let url = build_url(endpoint, &query_params);
|
let url = build_url(endpoint, &query_params);
|
||||||
|
|
||||||
let mut request = ureq::get(&url)
|
let mut request = crate::core::http::agent()
|
||||||
|
.get(&url)
|
||||||
.header("Content-Type", "application/json; charset=UTF-8")
|
.header("Content-Type", "application/json; charset=UTF-8")
|
||||||
.header("authorization", &format!("Bearer {}", token))
|
.header("authorization", &format!("Bearer {}", token))
|
||||||
.header("appkey", appkey)
|
.header("appkey", appkey)
|
||||||
|
|||||||
@@ -53,7 +53,8 @@ pub fn login(args: &KisLoginArguments) -> Result<String, String> {
|
|||||||
"appsecret": args.appsecret,
|
"appsecret": args.appsecret,
|
||||||
});
|
});
|
||||||
|
|
||||||
let response = ureq::post(&url)
|
let response = crate::core::http::agent()
|
||||||
|
.post(&url)
|
||||||
.header("Content-Type", "application/json; charset=UTF-8")
|
.header("Content-Type", "application/json; charset=UTF-8")
|
||||||
.send_json(&request_body)
|
.send_json(&request_body)
|
||||||
.map_err(|err| format!("[KIS] 접근토큰 발급 요청 실패: {}", err))?;
|
.map_err(|err| format!("[KIS] 접근토큰 발급 요청 실패: {}", err))?;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ pub mod call;
|
|||||||
pub mod info;
|
pub mod info;
|
||||||
pub mod login;
|
pub mod login;
|
||||||
pub mod market;
|
pub mod market;
|
||||||
|
pub mod market_history;
|
||||||
pub mod order_common;
|
pub mod order_common;
|
||||||
pub mod order_status;
|
pub mod order_status;
|
||||||
pub mod sell;
|
pub mod sell;
|
||||||
@@ -88,6 +89,17 @@ impl TraderApi for KisApi {
|
|||||||
market::market(self, symbol)
|
market::market(self, symbol)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn market_history(
|
||||||
|
&self,
|
||||||
|
symbol: &str,
|
||||||
|
start_date: &str,
|
||||||
|
end_date: &str,
|
||||||
|
period: &str,
|
||||||
|
adjusted: bool,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
market_history::market_history(self, symbol, start_date, end_date, period, adjusted)
|
||||||
|
}
|
||||||
|
|
||||||
fn info(&self) -> Vec<(&'static str, &'static str)> {
|
fn info(&self) -> Vec<(&'static str, &'static str)> {
|
||||||
info::info()
|
info::info()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
use crate::apis::kis::KisApi;
|
||||||
|
use crate::core::TraderApi;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
const HISTORY_ENDPOINT: &str = "/uapi/domestic-stock/v1/quotations/inquire-daily-itemchartprice";
|
||||||
|
|
||||||
|
pub fn market_history(
|
||||||
|
api: &KisApi,
|
||||||
|
symbol: &str,
|
||||||
|
start_date: &str,
|
||||||
|
end_date: &str,
|
||||||
|
period: &str,
|
||||||
|
adjusted: bool,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let symbol = symbol.trim();
|
||||||
|
let start_date = start_date.trim();
|
||||||
|
let end_date = end_date.trim();
|
||||||
|
let period = normalize_period(period)?;
|
||||||
|
validate_symbol(symbol)?;
|
||||||
|
validate_date(start_date, "--from")?;
|
||||||
|
validate_date(end_date, "--to")?;
|
||||||
|
if start_date > end_date {
|
||||||
|
return Err("--from은 --to보다 늦을 수 없습니다.".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let adj_price_code = if adjusted { "0" } else { "1" };
|
||||||
|
let params = [
|
||||||
|
("tr_id", "FHKST03010100"),
|
||||||
|
("FID_COND_MRKT_DIV_CODE", "J"),
|
||||||
|
("FID_INPUT_ISCD", symbol),
|
||||||
|
("FID_INPUT_DATE_1", start_date),
|
||||||
|
("FID_INPUT_DATE_2", end_date),
|
||||||
|
("FID_PERIOD_DIV_CODE", period),
|
||||||
|
("FID_ORG_ADJ_PRC", adj_price_code),
|
||||||
|
];
|
||||||
|
let response = api.call(HISTORY_ENDPOINT, ¶ms)?;
|
||||||
|
let value = serde_json::from_str::<serde_json::Value>(&response)
|
||||||
|
.map_err(|err| format!("[KIS] 기간별 시세 응답 파싱 실패: {}", err))?;
|
||||||
|
ensure_success(&value)?;
|
||||||
|
|
||||||
|
let mut candles = value
|
||||||
|
.get("output2")
|
||||||
|
.and_then(|value| value.as_array())
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.map(normalize_candle)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
candles.sort_by(|a, b| {
|
||||||
|
a.get("date")
|
||||||
|
.and_then(|value| value.as_str())
|
||||||
|
.cmp(&b.get("date").and_then(|value| value.as_str()))
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(json!({
|
||||||
|
"broker": api.id(),
|
||||||
|
"symbol": symbol,
|
||||||
|
"period": period,
|
||||||
|
"start_date": start_date,
|
||||||
|
"end_date": end_date,
|
||||||
|
"adjusted": adjusted,
|
||||||
|
"count": candles.len(),
|
||||||
|
"summary": value.get("output1").cloned().unwrap_or_else(|| json!({})),
|
||||||
|
"candles": candles,
|
||||||
|
"raw": value,
|
||||||
|
})
|
||||||
|
.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_candle(item: &serde_json::Value) -> serde_json::Value {
|
||||||
|
json!({
|
||||||
|
"date": text(item, "stck_bsop_date"),
|
||||||
|
"open": text(item, "stck_oprc"),
|
||||||
|
"high": text(item, "stck_hgpr"),
|
||||||
|
"low": text(item, "stck_lwpr"),
|
||||||
|
"close": text(item, "stck_clpr"),
|
||||||
|
"volume": text(item, "acml_vol"),
|
||||||
|
"trading_value": text(item, "acml_tr_pbmn"),
|
||||||
|
"change": text(item, "prdy_vrss"),
|
||||||
|
"change_sign": text(item, "prdy_vrss_sign"),
|
||||||
|
"change_rate": text(item, "prdy_ctrt"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn text(item: &serde_json::Value, key: &str) -> String {
|
||||||
|
item.get(key)
|
||||||
|
.and_then(|value| value.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.trim()
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
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 normalize_period(value: &str) -> Result<&'static str, String> {
|
||||||
|
match value.trim().to_ascii_uppercase().as_str() {
|
||||||
|
"D" | "DAY" | "DAILY" => Ok("D"),
|
||||||
|
"W" | "WEEK" | "WEEKLY" => Ok("W"),
|
||||||
|
"M" | "MONTH" | "MONTHLY" => Ok("M"),
|
||||||
|
"Y" | "YEAR" | "YEARLY" => Ok("Y"),
|
||||||
|
_ => Err("--period는 D, W, M, Y 중 하나여야 합니다.".to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_symbol(value: &str) -> Result<(), String> {
|
||||||
|
if value.len() == 6 && value.chars().all(|ch| ch.is_ascii_digit()) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
Err("종목코드는 6자리 숫자여야 합니다. 예: 005930".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))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normalizes_period_aliases() {
|
||||||
|
assert_eq!(normalize_period("d").unwrap(), "D");
|
||||||
|
assert_eq!(normalize_period("weekly").unwrap(), "W");
|
||||||
|
assert_eq!(normalize_period("M").unwrap(), "M");
|
||||||
|
assert!(normalize_period("hour").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normalizes_candle_fields() {
|
||||||
|
let item = json!({
|
||||||
|
"stck_bsop_date": "20260609",
|
||||||
|
"stck_oprc": "70000",
|
||||||
|
"stck_hgpr": "71000",
|
||||||
|
"stck_lwpr": "69000",
|
||||||
|
"stck_clpr": "70500",
|
||||||
|
"acml_vol": "123456",
|
||||||
|
"acml_tr_pbmn": "8000000000",
|
||||||
|
"prdy_vrss": "500",
|
||||||
|
"prdy_vrss_sign": "2",
|
||||||
|
"prdy_ctrt": "0.71"
|
||||||
|
});
|
||||||
|
|
||||||
|
let candle = normalize_candle(&item);
|
||||||
|
assert_eq!(candle["date"], "20260609");
|
||||||
|
assert_eq!(candle["open"], "70000");
|
||||||
|
assert_eq!(candle["close"], "70500");
|
||||||
|
assert_eq!(candle["volume"], "123456");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -85,7 +85,8 @@ fn post_order(api: &KisApi, tr_id: &str, body: &serde_json::Value) -> Result<Str
|
|||||||
.ok_or("KIS_APPSECRET가 .env에 없습니다.")?;
|
.ok_or("KIS_APPSECRET가 .env에 없습니다.")?;
|
||||||
let url = format!("{}{}", KIS_BASE_URL, ORDER_CASH_ENDPOINT);
|
let url = format!("{}{}", KIS_BASE_URL, ORDER_CASH_ENDPOINT);
|
||||||
|
|
||||||
let response = ureq::post(&url)
|
let response = crate::core::http::agent()
|
||||||
|
.post(&url)
|
||||||
.header("Content-Type", "application/json; charset=UTF-8")
|
.header("Content-Type", "application/json; charset=UTF-8")
|
||||||
.header("authorization", &format!("Bearer {}", token))
|
.header("authorization", &format!("Bearer {}", token))
|
||||||
.header("appkey", appkey)
|
.header("appkey", appkey)
|
||||||
|
|||||||
+16
-3
@@ -233,19 +233,19 @@ fn api_catalog(api: &dyn TraderApi) -> serde_json::Value {
|
|||||||
{
|
{
|
||||||
"name": "KIS_APPKEY",
|
"name": "KIS_APPKEY",
|
||||||
"description": "KIS Open API application key used for authentication and every broker API request.",
|
"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"],
|
"required_for": ["api login", "api call", "account status", "market", "market history", "order buy", "order sell", "order status"],
|
||||||
"source": ".env or api login --appkey"
|
"source": ".env or api login --appkey"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "KIS_APPSECRET",
|
"name": "KIS_APPSECRET",
|
||||||
"description": "KIS Open API application secret used for authentication and every broker API request.",
|
"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"],
|
"required_for": ["api login", "api call", "account status", "market", "market history", "order buy", "order sell", "order status"],
|
||||||
"source": ".env or api login --appsecret"
|
"source": ".env or api login --appsecret"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "KIS_ACCESS_TOKEN",
|
"name": "KIS_ACCESS_TOKEN",
|
||||||
"description": "Bearer access token issued by api login. Read commands and order commands require it.",
|
"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"],
|
"required_for": ["api call", "account status", "market", "market history", "order buy", "order sell", "order status"],
|
||||||
"source": ".env written by api login"
|
"source": ".env written by api login"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -291,6 +291,19 @@ fn api_catalog(api: &dyn TraderApi) -> serde_json::Value {
|
|||||||
"output_contract": "Explained JSON with broker, symbol, price, company, and raw KIS response.",
|
"output_contract": "Explained JSON with broker, symbol, price, company, and raw KIS response.",
|
||||||
"side_effect": "none"
|
"side_effect": "none"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"command": "market history <symbol> --from YYYYMMDD --to YYYYMMDD --period D|W|M|Y [--raw-price]",
|
||||||
|
"purpose": "Read OHLCV price history for a domestic stock code. The normalized candles are sorted ascending by date for strategy and backtest use.",
|
||||||
|
"inputs": [
|
||||||
|
{"name": "symbol", "required": true, "description": "Six digit domestic stock code such as 005930."},
|
||||||
|
{"name": "--from", "required": true, "description": "Start date in YYYYMMDD."},
|
||||||
|
{"name": "--to", "required": true, "description": "End date in YYYYMMDD."},
|
||||||
|
{"name": "--period", "required": false, "description": "D daily, W weekly, M monthly, Y yearly. Defaults to D."},
|
||||||
|
{"name": "--raw-price", "required": false, "description": "Use original prices. Without this flag the command requests adjusted prices."}
|
||||||
|
],
|
||||||
|
"output_contract": "Explained JSON with broker, symbol, period, date_range, adjusted, count, candles, summary, and raw KIS response.",
|
||||||
|
"side_effect": "none"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"command": "order buy <symbol> --qty <qty> (--price <price>|--market)",
|
"command": "order buy <symbol> --qty <qty> (--price <price>|--market)",
|
||||||
"purpose": "Submit a live domestic stock buy order.",
|
"purpose": "Submit a live domestic stock buy order.",
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
use crate::providers::{kind, opendart};
|
||||||
|
use clap::{Args, Subcommand};
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
pub enum CacheCommands {
|
||||||
|
/// 로컬 캐시 용량 상태 조회
|
||||||
|
Status,
|
||||||
|
|
||||||
|
/// 보존 정책에 따라 오래된 캐시 스냅샷 정리
|
||||||
|
Prune(CachePruneCommand),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Args)]
|
||||||
|
pub struct CachePruneCommand {
|
||||||
|
/// 실제 삭제하지 않고 삭제 예정 파일만 계산
|
||||||
|
#[arg(long)]
|
||||||
|
pub dry_run: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn handle_cache(sub: &CacheCommands) {
|
||||||
|
match sub {
|
||||||
|
CacheCommands::Status => match crate::core::cache::status() {
|
||||||
|
Ok(status) => println!(
|
||||||
|
"{}",
|
||||||
|
crate::core::output::explained_with_raw(
|
||||||
|
"cache status",
|
||||||
|
"로컬 캐시 용량 상태 조회 결과",
|
||||||
|
vec![
|
||||||
|
crate::core::output::field(
|
||||||
|
"root",
|
||||||
|
"캐시 루트 디렉터리",
|
||||||
|
serde_json::json!(status.root),
|
||||||
|
),
|
||||||
|
crate::core::output::field(
|
||||||
|
"exists",
|
||||||
|
"캐시 루트 디렉터리 존재 여부",
|
||||||
|
serde_json::json!(status.exists),
|
||||||
|
),
|
||||||
|
crate::core::output::field(
|
||||||
|
"total_files",
|
||||||
|
"캐시 파일 총 개수",
|
||||||
|
serde_json::json!(status.total_files),
|
||||||
|
),
|
||||||
|
crate::core::output::field(
|
||||||
|
"total_bytes",
|
||||||
|
"캐시 총 용량(bytes)",
|
||||||
|
serde_json::json!(status.total_bytes),
|
||||||
|
),
|
||||||
|
crate::core::output::field(
|
||||||
|
"namespaces",
|
||||||
|
"최상위 캐시 namespace별 파일 수와 용량",
|
||||||
|
serde_json::json!(status.namespaces),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
serde_json::json!(status),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
Err(err) => eprintln!(
|
||||||
|
"{}",
|
||||||
|
crate::core::output::error("cache status", "캐시 상태 조회 실패", &err)
|
||||||
|
),
|
||||||
|
},
|
||||||
|
CacheCommands::Prune(command) => {
|
||||||
|
let reports = match prune_all(command.dry_run) {
|
||||||
|
Ok(reports) => reports,
|
||||||
|
Err(err) => {
|
||||||
|
eprintln!(
|
||||||
|
"{}",
|
||||||
|
crate::core::output::error("cache prune", "캐시 정리 실패", &err)
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let deleted_files = reports
|
||||||
|
.iter()
|
||||||
|
.map(|report| report.deleted_files)
|
||||||
|
.sum::<usize>();
|
||||||
|
let deleted_bytes = reports
|
||||||
|
.iter()
|
||||||
|
.map(|report| report.deleted_bytes)
|
||||||
|
.sum::<u64>();
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
crate::core::output::explained_with_raw(
|
||||||
|
"cache prune",
|
||||||
|
"로컬 캐시 보존 정책 적용 결과",
|
||||||
|
vec![
|
||||||
|
crate::core::output::field(
|
||||||
|
"dry_run",
|
||||||
|
"실제 삭제 없이 삭제 예정 항목만 계산했는지 여부",
|
||||||
|
serde_json::json!(command.dry_run),
|
||||||
|
),
|
||||||
|
crate::core::output::field(
|
||||||
|
"deleted_files",
|
||||||
|
"삭제했거나 dry-run에서 삭제 예정인 파일 수",
|
||||||
|
serde_json::json!(deleted_files),
|
||||||
|
),
|
||||||
|
crate::core::output::field(
|
||||||
|
"deleted_bytes",
|
||||||
|
"삭제했거나 dry-run에서 삭제 예정인 용량(bytes)",
|
||||||
|
serde_json::json!(deleted_bytes),
|
||||||
|
),
|
||||||
|
crate::core::output::field(
|
||||||
|
"reports",
|
||||||
|
"namespace별 보존 정책과 정리 결과",
|
||||||
|
serde_json::json!(reports),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
serde_json::json!(reports),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prune_all(dry_run: bool) -> Result<Vec<crate::core::cache::CachePruneReport>, String> {
|
||||||
|
Ok(vec![
|
||||||
|
kind::universe::prune_cache(dry_run)?,
|
||||||
|
opendart::document::prune_cache(dry_run)?,
|
||||||
|
])
|
||||||
|
}
|
||||||
@@ -0,0 +1,642 @@
|
|||||||
|
use crate::providers::opendart::{corp_codes, disclosures, document};
|
||||||
|
use clap::{Args, Subcommand};
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
pub enum DartCommands {
|
||||||
|
/// OpenDART 상장사 고유번호 매핑 캐시 갱신
|
||||||
|
Sync(DartSyncCommand),
|
||||||
|
|
||||||
|
/// OpenDART 고유번호 매핑 캐시 상태 조회
|
||||||
|
Status,
|
||||||
|
|
||||||
|
/// 종목코드로 OpenDART 고유번호 조회
|
||||||
|
Corp(DartCorpCommand),
|
||||||
|
|
||||||
|
/// 전체 또는 특정 종목의 공시목록 조회
|
||||||
|
Disclosures(DartDisclosuresCommand),
|
||||||
|
|
||||||
|
/// 접수번호로 공시서류 원본파일 조회
|
||||||
|
Document(DartDocumentCommand),
|
||||||
|
|
||||||
|
/// 종목코드로 공시목록과 선택한 공시 원문 조회
|
||||||
|
Show(DartShowCommand),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Args)]
|
||||||
|
pub struct DartSyncCommand {
|
||||||
|
/// 오늘 이미 갱신된 캐시가 있어도 다시 다운로드
|
||||||
|
#[arg(long)]
|
||||||
|
pub force: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Args)]
|
||||||
|
pub struct DartCorpCommand {
|
||||||
|
/// KRX 종목코드
|
||||||
|
pub symbol: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Args)]
|
||||||
|
pub struct DartDisclosuresCommand {
|
||||||
|
/// KRX 종목코드. 생략하면 전체 공시 검색
|
||||||
|
pub symbol: Option<String>,
|
||||||
|
|
||||||
|
/// DART 고유번호. symbol보다 우선
|
||||||
|
#[arg(long)]
|
||||||
|
pub corp_code: Option<String>,
|
||||||
|
|
||||||
|
/// 검색 시작일 YYYYMMDD
|
||||||
|
#[arg(long = "from")]
|
||||||
|
pub from: Option<String>,
|
||||||
|
|
||||||
|
/// 검색 종료일 YYYYMMDD
|
||||||
|
#[arg(long = "to")]
|
||||||
|
pub to: Option<String>,
|
||||||
|
|
||||||
|
/// 법인구분: Y=유가, K=코스닥, N=코넥스, E=기타
|
||||||
|
#[arg(long)]
|
||||||
|
pub corp_cls: Option<String>,
|
||||||
|
|
||||||
|
/// 페이지 번호
|
||||||
|
#[arg(long, default_value_t = 1)]
|
||||||
|
pub page_no: u32,
|
||||||
|
|
||||||
|
/// 페이지당 건수 (1~100)
|
||||||
|
#[arg(long, default_value_t = 20)]
|
||||||
|
pub page_count: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Args)]
|
||||||
|
pub struct DartDocumentCommand {
|
||||||
|
/// DART 공시 접수번호
|
||||||
|
pub rcept_no: String,
|
||||||
|
|
||||||
|
/// 캐시가 있어도 다시 다운로드
|
||||||
|
#[arg(long)]
|
||||||
|
pub force: bool,
|
||||||
|
|
||||||
|
/// 출력할 본문 텍스트 최대 글자 수
|
||||||
|
#[arg(long, default_value_t = 20_000)]
|
||||||
|
pub max_chars: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Args)]
|
||||||
|
pub struct DartShowCommand {
|
||||||
|
/// KRX 종목코드
|
||||||
|
pub symbol: String,
|
||||||
|
|
||||||
|
/// 검색 시작일 YYYYMMDD
|
||||||
|
#[arg(long = "from")]
|
||||||
|
pub from: Option<String>,
|
||||||
|
|
||||||
|
/// 검색 종료일 YYYYMMDD
|
||||||
|
#[arg(long = "to")]
|
||||||
|
pub to: Option<String>,
|
||||||
|
|
||||||
|
/// 공시목록에서 열람할 항목 번호. 1이면 최신 첫 번째 항목
|
||||||
|
#[arg(long, default_value_t = 1)]
|
||||||
|
pub index: usize,
|
||||||
|
|
||||||
|
/// 공시목록 조회 건수 (1~100)
|
||||||
|
#[arg(long, default_value_t = 20)]
|
||||||
|
pub page_count: u32,
|
||||||
|
|
||||||
|
/// 캐시가 있어도 원문을 다시 다운로드
|
||||||
|
#[arg(long)]
|
||||||
|
pub force: bool,
|
||||||
|
|
||||||
|
/// 출력할 본문 텍스트 최대 글자 수
|
||||||
|
#[arg(long, default_value_t = 20_000)]
|
||||||
|
pub max_chars: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn handle_dart(sub: &DartCommands) {
|
||||||
|
match sub {
|
||||||
|
DartCommands::Sync(command) => match corp_codes::load_or_refresh(command.force) {
|
||||||
|
Ok(snapshot) => print_corp_snapshot(
|
||||||
|
"dart sync",
|
||||||
|
"OpenDART 상장사 고유번호 매핑 캐시 갱신 결과",
|
||||||
|
&snapshot,
|
||||||
|
),
|
||||||
|
Err(err) => eprintln!(
|
||||||
|
"{}",
|
||||||
|
crate::core::output::error(
|
||||||
|
"dart sync",
|
||||||
|
"OpenDART 고유번호 매핑 캐시 갱신 실패",
|
||||||
|
&err
|
||||||
|
)
|
||||||
|
),
|
||||||
|
},
|
||||||
|
DartCommands::Status => match corp_codes::load_cached() {
|
||||||
|
Ok(Some(snapshot)) => {
|
||||||
|
print_corp_snapshot("dart status", "OpenDART 고유번호 매핑 캐시 상태", &snapshot)
|
||||||
|
}
|
||||||
|
Ok(None) => eprintln!(
|
||||||
|
"{}",
|
||||||
|
crate::core::output::error(
|
||||||
|
"dart status",
|
||||||
|
"OpenDART 고유번호 매핑 캐시 상태 조회 실패",
|
||||||
|
"로컬 OpenDART 캐시가 없습니다. `openstock dart sync`를 먼저 실행하세요.",
|
||||||
|
)
|
||||||
|
),
|
||||||
|
Err(err) => eprintln!(
|
||||||
|
"{}",
|
||||||
|
crate::core::output::error(
|
||||||
|
"dart status",
|
||||||
|
"OpenDART 고유번호 매핑 캐시 상태 조회 실패",
|
||||||
|
&err
|
||||||
|
)
|
||||||
|
),
|
||||||
|
},
|
||||||
|
DartCommands::Corp(command) => match corp_codes::load_or_refresh(false) {
|
||||||
|
Ok(snapshot) => {
|
||||||
|
let symbol = command.symbol.trim();
|
||||||
|
match corp_codes::find_by_stock_code(&snapshot.corps, symbol) {
|
||||||
|
Some(corp) => println!(
|
||||||
|
"{}",
|
||||||
|
crate::core::output::explained_with_raw(
|
||||||
|
"dart corp",
|
||||||
|
"KRX 종목코드에 대응하는 OpenDART 고유번호 조회 결과",
|
||||||
|
vec![
|
||||||
|
crate::core::output::field(
|
||||||
|
"stock_code",
|
||||||
|
"KRX 종목코드",
|
||||||
|
serde_json::json!(corp.stock_code),
|
||||||
|
),
|
||||||
|
crate::core::output::field(
|
||||||
|
"corp_code",
|
||||||
|
"OpenDART 공시 API에서 사용하는 8자리 고유번호",
|
||||||
|
serde_json::json!(corp.corp_code),
|
||||||
|
),
|
||||||
|
crate::core::output::field(
|
||||||
|
"corp_name",
|
||||||
|
"OpenDART에 등록된 회사명",
|
||||||
|
serde_json::json!(corp.corp_name),
|
||||||
|
),
|
||||||
|
crate::core::output::field(
|
||||||
|
"modify_date",
|
||||||
|
"OpenDART 고유번호 정보의 최근 변경일자",
|
||||||
|
serde_json::json!(corp.modify_date),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
serde_json::json!({
|
||||||
|
"meta": snapshot.meta,
|
||||||
|
"corp": corp,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
None => eprintln!(
|
||||||
|
"{}",
|
||||||
|
crate::core::output::error(
|
||||||
|
"dart corp",
|
||||||
|
"OpenDART 고유번호 조회 실패",
|
||||||
|
&format!(
|
||||||
|
"종목코드 {}에 대응하는 DART 고유번호를 찾지 못했습니다.",
|
||||||
|
symbol
|
||||||
|
),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(err) => eprintln!(
|
||||||
|
"{}",
|
||||||
|
crate::core::output::error("dart corp", "OpenDART 고유번호 조회 실패", &err)
|
||||||
|
),
|
||||||
|
},
|
||||||
|
DartCommands::Disclosures(command) => match disclosure_query(command) {
|
||||||
|
Ok((query, resolved)) => match disclosures::list(&query) {
|
||||||
|
Ok(value) => print_disclosures(&query, &resolved, value),
|
||||||
|
Err(err) => eprintln!(
|
||||||
|
"{}",
|
||||||
|
crate::core::output::error(
|
||||||
|
"dart disclosures",
|
||||||
|
"OpenDART 공시목록 조회 실패",
|
||||||
|
&err
|
||||||
|
)
|
||||||
|
),
|
||||||
|
},
|
||||||
|
Err(err) => eprintln!(
|
||||||
|
"{}",
|
||||||
|
crate::core::output::error("dart disclosures", "OpenDART 공시목록 조회 실패", &err)
|
||||||
|
),
|
||||||
|
},
|
||||||
|
DartCommands::Document(command) => {
|
||||||
|
let options = document::DartDocumentOptions {
|
||||||
|
rcept_no: command.rcept_no.trim().to_string(),
|
||||||
|
force: command.force,
|
||||||
|
max_chars: command.max_chars,
|
||||||
|
};
|
||||||
|
match document::get(&options) {
|
||||||
|
Ok(value) => print_document(value),
|
||||||
|
Err(err) => eprintln!(
|
||||||
|
"{}",
|
||||||
|
crate::core::output::error(
|
||||||
|
"dart document",
|
||||||
|
"OpenDART 공시서류 원본파일 조회 실패",
|
||||||
|
&err
|
||||||
|
)
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DartCommands::Show(command) => match show_query(command) {
|
||||||
|
Ok((query, resolved)) => match disclosures::list(&query) {
|
||||||
|
Ok(disclosures_value) => {
|
||||||
|
match selected_disclosure(&disclosures_value, command.index) {
|
||||||
|
Ok(selected) => {
|
||||||
|
let Some(rcept_no) =
|
||||||
|
selected.get("rcept_no").and_then(|value| value.as_str())
|
||||||
|
else {
|
||||||
|
eprintln!(
|
||||||
|
"{}",
|
||||||
|
crate::core::output::error(
|
||||||
|
"dart show",
|
||||||
|
"종목 공시 원문 조회 실패",
|
||||||
|
"선택한 공시 항목에 rcept_no가 없습니다.",
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let options = document::DartDocumentOptions {
|
||||||
|
rcept_no: rcept_no.to_string(),
|
||||||
|
force: command.force,
|
||||||
|
max_chars: command.max_chars,
|
||||||
|
};
|
||||||
|
match document::get(&options) {
|
||||||
|
Ok(document) => print_show(
|
||||||
|
command,
|
||||||
|
&query,
|
||||||
|
&resolved,
|
||||||
|
&disclosures_value,
|
||||||
|
selected,
|
||||||
|
document,
|
||||||
|
),
|
||||||
|
Err(err) => eprintln!(
|
||||||
|
"{}",
|
||||||
|
crate::core::output::error(
|
||||||
|
"dart show",
|
||||||
|
"종목 공시 원문 조회 실패",
|
||||||
|
&err
|
||||||
|
)
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(err) => eprintln!(
|
||||||
|
"{}",
|
||||||
|
crate::core::output::error(
|
||||||
|
"dart show",
|
||||||
|
"종목 공시 원문 조회 실패",
|
||||||
|
&err
|
||||||
|
)
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(err) => eprintln!(
|
||||||
|
"{}",
|
||||||
|
crate::core::output::error("dart show", "종목 공시목록 조회 실패", &err)
|
||||||
|
),
|
||||||
|
},
|
||||||
|
Err(err) => eprintln!(
|
||||||
|
"{}",
|
||||||
|
crate::core::output::error("dart show", "종목 공시 원문 조회 실패", &err)
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_corp_snapshot(
|
||||||
|
command: &str,
|
||||||
|
description: &str,
|
||||||
|
snapshot: &corp_codes::DartCorpCodeSnapshot,
|
||||||
|
) {
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
crate::core::output::explained_with_raw(
|
||||||
|
command,
|
||||||
|
description,
|
||||||
|
vec![
|
||||||
|
crate::core::output::field(
|
||||||
|
"source",
|
||||||
|
"공시정보 원천 데이터",
|
||||||
|
serde_json::json!(snapshot.meta.source),
|
||||||
|
),
|
||||||
|
crate::core::output::field(
|
||||||
|
"cache_date",
|
||||||
|
"캐시 기준일",
|
||||||
|
serde_json::json!(snapshot.meta.date),
|
||||||
|
),
|
||||||
|
crate::core::output::field(
|
||||||
|
"refreshed_at",
|
||||||
|
"캐시 갱신 시각",
|
||||||
|
serde_json::json!(snapshot.meta.refreshed_at),
|
||||||
|
),
|
||||||
|
crate::core::output::field(
|
||||||
|
"refreshed",
|
||||||
|
"이번 명령에서 새로 다운로드했는지 여부",
|
||||||
|
serde_json::json!(snapshot.refreshed),
|
||||||
|
),
|
||||||
|
crate::core::output::field(
|
||||||
|
"total_count",
|
||||||
|
"OpenDART 고유번호 전체 항목 수",
|
||||||
|
serde_json::json!(snapshot.meta.total_count),
|
||||||
|
),
|
||||||
|
crate::core::output::field(
|
||||||
|
"listed_count",
|
||||||
|
"stock_code가 존재하는 상장사 매핑 수",
|
||||||
|
serde_json::json!(snapshot.meta.listed_count),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
serde_json::json!(snapshot.meta),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn disclosure_query(
|
||||||
|
command: &DartDisclosuresCommand,
|
||||||
|
) -> Result<(disclosures::DisclosureQuery, serde_json::Value), String> {
|
||||||
|
let mut resolved = serde_json::json!({
|
||||||
|
"input_symbol": command.symbol,
|
||||||
|
"input_corp_code": command.corp_code,
|
||||||
|
});
|
||||||
|
let corp_code = if let Some(corp_code) = command
|
||||||
|
.corp_code
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
{
|
||||||
|
Some(corp_code.to_string())
|
||||||
|
} else if let Some(symbol) = command
|
||||||
|
.symbol
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
{
|
||||||
|
let snapshot = corp_codes::load_or_refresh(false)?;
|
||||||
|
let corp = corp_codes::find_by_stock_code(&snapshot.corps, symbol).ok_or_else(|| {
|
||||||
|
format!(
|
||||||
|
"종목코드 {}에 대응하는 DART 고유번호를 찾지 못했습니다.",
|
||||||
|
symbol
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
resolved["resolved_corp"] = serde_json::json!(corp);
|
||||||
|
Some(corp.corp_code.clone())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok((
|
||||||
|
disclosures::DisclosureQuery {
|
||||||
|
corp_code,
|
||||||
|
bgn_de: command.from.clone(),
|
||||||
|
end_de: command.to.clone(),
|
||||||
|
corp_cls: command.corp_cls.clone(),
|
||||||
|
page_no: command.page_no,
|
||||||
|
page_count: command.page_count,
|
||||||
|
},
|
||||||
|
resolved,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn show_query(
|
||||||
|
command: &DartShowCommand,
|
||||||
|
) -> Result<(disclosures::DisclosureQuery, serde_json::Value), String> {
|
||||||
|
if command.index == 0 {
|
||||||
|
return Err("--index는 1 이상이어야 합니다.".to_string());
|
||||||
|
}
|
||||||
|
let symbol = command.symbol.trim();
|
||||||
|
let snapshot = corp_codes::load_or_refresh(false)?;
|
||||||
|
let corp = corp_codes::find_by_stock_code(&snapshot.corps, symbol).ok_or_else(|| {
|
||||||
|
format!(
|
||||||
|
"종목코드 {}에 대응하는 DART 고유번호를 찾지 못했습니다.",
|
||||||
|
symbol
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok((
|
||||||
|
disclosures::DisclosureQuery {
|
||||||
|
corp_code: Some(corp.corp_code.clone()),
|
||||||
|
bgn_de: command.from.clone(),
|
||||||
|
end_de: command.to.clone(),
|
||||||
|
corp_cls: None,
|
||||||
|
page_no: 1,
|
||||||
|
page_count: command.page_count,
|
||||||
|
},
|
||||||
|
serde_json::json!({
|
||||||
|
"input_symbol": symbol,
|
||||||
|
"resolved_corp": corp,
|
||||||
|
}),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn selected_disclosure(
|
||||||
|
disclosures_value: &serde_json::Value,
|
||||||
|
index: usize,
|
||||||
|
) -> Result<&serde_json::Value, String> {
|
||||||
|
let list = disclosures_value
|
||||||
|
.get("list")
|
||||||
|
.and_then(|value| value.as_array())
|
||||||
|
.ok_or("공시목록 응답에 list가 없습니다.")?;
|
||||||
|
if list.is_empty() {
|
||||||
|
return Err("조회된 공시가 없습니다.".to_string());
|
||||||
|
}
|
||||||
|
list.get(index - 1).ok_or_else(|| {
|
||||||
|
format!(
|
||||||
|
"--index {}에 해당하는 공시가 없습니다. 현재 조회된 공시는 {}건입니다.",
|
||||||
|
index,
|
||||||
|
list.len()
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_disclosures(
|
||||||
|
query: &disclosures::DisclosureQuery,
|
||||||
|
resolved: &serde_json::Value,
|
||||||
|
value: serde_json::Value,
|
||||||
|
) {
|
||||||
|
let list = value
|
||||||
|
.get("list")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| serde_json::json!([]));
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
crate::core::output::explained_with_raw(
|
||||||
|
"dart disclosures",
|
||||||
|
"OpenDART 공시목록 조회 결과",
|
||||||
|
vec![
|
||||||
|
crate::core::output::field(
|
||||||
|
"corp_code",
|
||||||
|
"조회에 사용한 OpenDART 고유번호. 없으면 전체 공시 조회",
|
||||||
|
query
|
||||||
|
.corp_code
|
||||||
|
.as_ref()
|
||||||
|
.map(|value| serde_json::json!(value))
|
||||||
|
.unwrap_or(serde_json::Value::Null),
|
||||||
|
),
|
||||||
|
crate::core::output::field(
|
||||||
|
"from",
|
||||||
|
"검색 시작일 YYYYMMDD",
|
||||||
|
query
|
||||||
|
.bgn_de
|
||||||
|
.as_ref()
|
||||||
|
.map(|value| serde_json::json!(value))
|
||||||
|
.unwrap_or(serde_json::Value::Null),
|
||||||
|
),
|
||||||
|
crate::core::output::field(
|
||||||
|
"to",
|
||||||
|
"검색 종료일 YYYYMMDD",
|
||||||
|
query
|
||||||
|
.end_de
|
||||||
|
.as_ref()
|
||||||
|
.map(|value| serde_json::json!(value))
|
||||||
|
.unwrap_or(serde_json::Value::Null),
|
||||||
|
),
|
||||||
|
crate::core::output::field(
|
||||||
|
"page",
|
||||||
|
"조회 페이지 정보",
|
||||||
|
serde_json::json!({
|
||||||
|
"page_no": query.page_no,
|
||||||
|
"page_count": query.page_count,
|
||||||
|
"total_count": value.get("total_count").cloned().unwrap_or(serde_json::Value::Null),
|
||||||
|
"total_page": value.get("total_page").cloned().unwrap_or(serde_json::Value::Null),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
crate::core::output::field(
|
||||||
|
"resolved",
|
||||||
|
"종목코드를 입력한 경우 OpenDART 고유번호로 변환한 정보",
|
||||||
|
resolved.clone(),
|
||||||
|
),
|
||||||
|
crate::core::output::field("disclosures", "조회된 공시 목록", list),
|
||||||
|
],
|
||||||
|
value,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_document(value: document::DartDocument) {
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
crate::core::output::explained_with_raw(
|
||||||
|
"dart document",
|
||||||
|
"OpenDART 공시서류 원본파일 조회 및 텍스트 추출 결과",
|
||||||
|
vec![
|
||||||
|
crate::core::output::field(
|
||||||
|
"rcept_no",
|
||||||
|
"DART 공시 접수번호",
|
||||||
|
serde_json::json!(value.rcept_no),
|
||||||
|
),
|
||||||
|
crate::core::output::field(
|
||||||
|
"source",
|
||||||
|
"공시서류 원본파일 제공 원천",
|
||||||
|
serde_json::json!(value.source),
|
||||||
|
),
|
||||||
|
crate::core::output::field(
|
||||||
|
"viewer_url",
|
||||||
|
"브라우저에서 확인 가능한 DART 공시 뷰어 URL",
|
||||||
|
serde_json::json!(value.viewer_url),
|
||||||
|
),
|
||||||
|
crate::core::output::field(
|
||||||
|
"cached",
|
||||||
|
"로컬 ZIP 캐시를 재사용했는지 여부",
|
||||||
|
serde_json::json!(value.cached),
|
||||||
|
),
|
||||||
|
crate::core::output::field(
|
||||||
|
"zip_path",
|
||||||
|
"로컬에 저장된 공시서류 ZIP 캐시 경로",
|
||||||
|
serde_json::json!(value.zip_path),
|
||||||
|
),
|
||||||
|
crate::core::output::field(
|
||||||
|
"zip_bytes",
|
||||||
|
"공시서류 ZIP 파일 크기(bytes)",
|
||||||
|
serde_json::json!(value.zip_bytes),
|
||||||
|
),
|
||||||
|
crate::core::output::field(
|
||||||
|
"files",
|
||||||
|
"ZIP 내부 파일 목록과 각 파일에서 추출한 텍스트 길이",
|
||||||
|
serde_json::json!(value.files),
|
||||||
|
),
|
||||||
|
crate::core::output::field(
|
||||||
|
"text",
|
||||||
|
"XML 태그를 제거한 공시 본문 텍스트. max_chars를 넘으면 앞부분만 제공",
|
||||||
|
serde_json::json!(value.text),
|
||||||
|
),
|
||||||
|
crate::core::output::field(
|
||||||
|
"text_chars",
|
||||||
|
"출력된 본문 텍스트 글자 수",
|
||||||
|
serde_json::json!(value.text_chars),
|
||||||
|
),
|
||||||
|
crate::core::output::field(
|
||||||
|
"truncated",
|
||||||
|
"본문이 max_chars 제한으로 잘렸는지 여부",
|
||||||
|
serde_json::json!(value.truncated),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
serde_json::json!(value),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_show(
|
||||||
|
command: &DartShowCommand,
|
||||||
|
query: &disclosures::DisclosureQuery,
|
||||||
|
resolved: &serde_json::Value,
|
||||||
|
disclosures_value: &serde_json::Value,
|
||||||
|
selected: &serde_json::Value,
|
||||||
|
document: document::DartDocument,
|
||||||
|
) {
|
||||||
|
let list = disclosures_value
|
||||||
|
.get("list")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| serde_json::json!([]));
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
crate::core::output::explained_with_raw(
|
||||||
|
"dart show",
|
||||||
|
"종목코드 기준 공시목록과 선택한 공시 원문 조회 결과",
|
||||||
|
vec![
|
||||||
|
crate::core::output::field(
|
||||||
|
"symbol",
|
||||||
|
"조회한 KRX 종목코드",
|
||||||
|
serde_json::json!(command.symbol),
|
||||||
|
),
|
||||||
|
crate::core::output::field(
|
||||||
|
"resolved",
|
||||||
|
"종목코드를 OpenDART 고유번호로 변환한 정보",
|
||||||
|
resolved.clone(),
|
||||||
|
),
|
||||||
|
crate::core::output::field(
|
||||||
|
"date_range",
|
||||||
|
"공시목록 검색 기간. null이면 OpenDART 기본 검색 범위",
|
||||||
|
serde_json::json!({
|
||||||
|
"from": query.bgn_de,
|
||||||
|
"to": query.end_de,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
crate::core::output::field(
|
||||||
|
"selected_index",
|
||||||
|
"원문을 조회한 공시목록 항목 번호. 1부터 시작",
|
||||||
|
serde_json::json!(command.index),
|
||||||
|
),
|
||||||
|
crate::core::output::field(
|
||||||
|
"selected_disclosure",
|
||||||
|
"원문 조회 대상으로 선택된 공시 항목",
|
||||||
|
selected.clone(),
|
||||||
|
),
|
||||||
|
crate::core::output::field(
|
||||||
|
"disclosures",
|
||||||
|
"조회된 공시목록. 다른 항목을 열려면 --index 값을 바꾼다.",
|
||||||
|
list,
|
||||||
|
),
|
||||||
|
crate::core::output::field(
|
||||||
|
"document",
|
||||||
|
"선택된 공시의 원문 ZIP 캐시 정보와 추출 본문",
|
||||||
|
serde_json::json!(document),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
serde_json::json!({
|
||||||
|
"query": query,
|
||||||
|
"resolved": resolved,
|
||||||
|
"selected_disclosure": selected,
|
||||||
|
"disclosures": disclosures_value,
|
||||||
|
"document": document,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
+150
-1
@@ -1,7 +1,62 @@
|
|||||||
use crate::apis::kis::KisApi;
|
use crate::apis::kis::KisApi;
|
||||||
use crate::core::TraderApi;
|
use crate::core::TraderApi;
|
||||||
|
use clap::{Args, Subcommand};
|
||||||
|
|
||||||
pub fn handle_market(symbol: &str) {
|
#[derive(Args)]
|
||||||
|
pub struct MarketCommand {
|
||||||
|
/// 종목코드. 하위 명령 없이 지정하면 현재가와 기업 기본 정보를 조회
|
||||||
|
pub symbol: Option<String>,
|
||||||
|
|
||||||
|
#[command(subcommand)]
|
||||||
|
pub sub: Option<MarketCommands>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
pub enum MarketCommands {
|
||||||
|
/// 기간별 OHLCV 가격 히스토리 조회
|
||||||
|
History(MarketHistoryCommand),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Args)]
|
||||||
|
pub struct MarketHistoryCommand {
|
||||||
|
/// 종목코드
|
||||||
|
pub symbol: String,
|
||||||
|
|
||||||
|
/// 조회 시작일 YYYYMMDD
|
||||||
|
#[arg(long = "from")]
|
||||||
|
pub from: String,
|
||||||
|
|
||||||
|
/// 조회 종료일 YYYYMMDD
|
||||||
|
#[arg(long = "to")]
|
||||||
|
pub to: String,
|
||||||
|
|
||||||
|
/// 기간 구분: D=일봉, W=주봉, M=월봉, Y=년봉
|
||||||
|
#[arg(long, default_value = "D")]
|
||||||
|
pub period: String,
|
||||||
|
|
||||||
|
/// 원주가를 조회. 기본값은 수정주가
|
||||||
|
#[arg(long)]
|
||||||
|
pub raw_price: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn handle_market(command: &MarketCommand) {
|
||||||
|
match &command.sub {
|
||||||
|
Some(MarketCommands::History(history)) => handle_market_history(history),
|
||||||
|
None => match command.symbol.as_deref() {
|
||||||
|
Some(symbol) => handle_market_overview(symbol),
|
||||||
|
None => eprintln!(
|
||||||
|
"{}",
|
||||||
|
crate::core::output::error(
|
||||||
|
"market",
|
||||||
|
"종목 정보 조회 실패",
|
||||||
|
"종목코드를 지정하거나 `market history` 하위 명령을 사용하세요.",
|
||||||
|
)
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_market_overview(symbol: &str) {
|
||||||
let api = KisApi::new();
|
let api = KisApi::new();
|
||||||
match api.market(symbol) {
|
match api.market(symbol) {
|
||||||
Ok(json) => {
|
Ok(json) => {
|
||||||
@@ -55,3 +110,97 @@ pub fn handle_market(symbol: &str) {
|
|||||||
),
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn handle_market_history(command: &MarketHistoryCommand) {
|
||||||
|
let api = KisApi::new();
|
||||||
|
let adjusted = !command.raw_price;
|
||||||
|
match api.market_history(
|
||||||
|
&command.symbol,
|
||||||
|
&command.from,
|
||||||
|
&command.to,
|
||||||
|
&command.period,
|
||||||
|
adjusted,
|
||||||
|
) {
|
||||||
|
Ok(json) => {
|
||||||
|
let value = crate::core::output::parse_json_or_text(&json);
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
crate::core::output::explained_with_raw(
|
||||||
|
"market history",
|
||||||
|
"종목 기간별 OHLCV 가격 히스토리 조회 결과",
|
||||||
|
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(
|
||||||
|
"period",
|
||||||
|
"가격 봉 단위. D=일봉, W=주봉, M=월봉, Y=년봉",
|
||||||
|
value
|
||||||
|
.get("period")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or(serde_json::Value::Null),
|
||||||
|
),
|
||||||
|
crate::core::output::field(
|
||||||
|
"date_range",
|
||||||
|
"조회 요청 기간",
|
||||||
|
serde_json::json!({
|
||||||
|
"from": value.get("start_date").cloned().unwrap_or(serde_json::Value::Null),
|
||||||
|
"to": value.get("end_date").cloned().unwrap_or(serde_json::Value::Null),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
crate::core::output::field(
|
||||||
|
"adjusted",
|
||||||
|
"수정주가 조회 여부. false이면 원주가 조회",
|
||||||
|
value
|
||||||
|
.get("adjusted")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or(serde_json::Value::Null),
|
||||||
|
),
|
||||||
|
crate::core::output::field(
|
||||||
|
"count",
|
||||||
|
"조회된 가격 봉 개수",
|
||||||
|
value
|
||||||
|
.get("count")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or(serde_json::Value::Null),
|
||||||
|
),
|
||||||
|
crate::core::output::field(
|
||||||
|
"candles",
|
||||||
|
"날짜 오름차순 OHLCV 배열. open/high/low/close/volume/trading_value는 문자열 숫자로 보존한다.",
|
||||||
|
value
|
||||||
|
.get("candles")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| serde_json::json!([])),
|
||||||
|
),
|
||||||
|
crate::core::output::field(
|
||||||
|
"summary",
|
||||||
|
"KIS가 반환한 종목 요약 정보",
|
||||||
|
value
|
||||||
|
.get("summary")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| serde_json::json!({})),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
value,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(err) => eprintln!(
|
||||||
|
"{}",
|
||||||
|
crate::core::output::error("market history", "기간별 시세 조회 실패", &err)
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,13 +1,19 @@
|
|||||||
pub mod account;
|
pub mod account;
|
||||||
pub mod api;
|
pub mod api;
|
||||||
|
pub mod cache;
|
||||||
|
pub mod dart;
|
||||||
pub mod market;
|
pub mod market;
|
||||||
pub mod order;
|
pub mod order;
|
||||||
pub mod search;
|
pub mod search;
|
||||||
|
pub mod universe;
|
||||||
mod version;
|
mod version;
|
||||||
|
|
||||||
pub use account::handle_account;
|
pub use account::handle_account;
|
||||||
pub use api::handle_api;
|
pub use api::handle_api;
|
||||||
|
pub use cache::handle_cache;
|
||||||
|
pub use dart::handle_dart;
|
||||||
pub use market::handle_market;
|
pub use market::handle_market;
|
||||||
pub use order::handle_order;
|
pub use order::handle_order;
|
||||||
pub use search::handle_search;
|
pub use search::handle_search;
|
||||||
|
pub use universe::handle_universe;
|
||||||
pub use version::handle_version;
|
pub use version::handle_version;
|
||||||
|
|||||||
@@ -0,0 +1,604 @@
|
|||||||
|
use crate::core::stock::{Stock, StockKind, StockMarket};
|
||||||
|
use crate::providers::kind;
|
||||||
|
use clap::{Args, Subcommand};
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
pub enum UniverseCommands {
|
||||||
|
/// KIND 상장법인목록을 내려받아 로컬 universe 캐시를 갱신
|
||||||
|
Sync(UniverseSyncCommand),
|
||||||
|
|
||||||
|
/// 로컬 universe 캐시 상태 조회
|
||||||
|
Status,
|
||||||
|
|
||||||
|
/// 로컬 universe 종목 목록 조회
|
||||||
|
List(UniverseListCommand),
|
||||||
|
|
||||||
|
/// 로컬 universe를 chunk 단위로 분할
|
||||||
|
Chunks(UniverseChunksCommand),
|
||||||
|
|
||||||
|
/// 로컬 universe 캐시의 크기와 대표 값을 검증
|
||||||
|
Validate(UniverseValidateCommand),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Args)]
|
||||||
|
pub struct UniverseSyncCommand {
|
||||||
|
/// 오늘 이미 갱신된 캐시가 있어도 다시 다운로드
|
||||||
|
#[arg(long)]
|
||||||
|
pub force: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Args)]
|
||||||
|
pub struct UniverseListCommand {
|
||||||
|
/// 시장 필터: KOSPI, KOSDAQ, KONEX
|
||||||
|
#[arg(long)]
|
||||||
|
pub market: Option<String>,
|
||||||
|
|
||||||
|
/// 종목 유형 필터: common_stock, preferred_stock, etf, etn, reit, spac
|
||||||
|
#[arg(long)]
|
||||||
|
pub kind: Option<String>,
|
||||||
|
|
||||||
|
/// 반환할 최대 종목 수
|
||||||
|
#[arg(long, default_value_t = 100)]
|
||||||
|
pub limit: usize,
|
||||||
|
|
||||||
|
/// 건너뛸 종목 수
|
||||||
|
#[arg(long, default_value_t = 0)]
|
||||||
|
pub offset: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Args)]
|
||||||
|
pub struct UniverseChunksCommand {
|
||||||
|
/// 시장 필터: KOSPI, KOSDAQ, KONEX
|
||||||
|
#[arg(long)]
|
||||||
|
pub market: Option<String>,
|
||||||
|
|
||||||
|
/// 종목 유형 필터: common_stock, preferred_stock, etf, etn, reit, spac
|
||||||
|
#[arg(long)]
|
||||||
|
pub kind: Option<String>,
|
||||||
|
|
||||||
|
/// chunk당 종목 수
|
||||||
|
#[arg(long, default_value_t = 100)]
|
||||||
|
pub size: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Args)]
|
||||||
|
pub struct UniverseValidateCommand {
|
||||||
|
/// 기대하는 최소 전체 종목 수
|
||||||
|
#[arg(long, default_value_t = 2500)]
|
||||||
|
pub min_count: usize,
|
||||||
|
|
||||||
|
/// 기대하는 최소 KOSPI 종목 수
|
||||||
|
#[arg(long, default_value_t = 700)]
|
||||||
|
pub min_kospi: usize,
|
||||||
|
|
||||||
|
/// 기대하는 최소 KOSDAQ 종목 수
|
||||||
|
#[arg(long, default_value_t = 1500)]
|
||||||
|
pub min_kosdaq: usize,
|
||||||
|
|
||||||
|
/// 기대하는 최소 KONEX 종목 수
|
||||||
|
#[arg(long, default_value_t = 50)]
|
||||||
|
pub min_konex: usize,
|
||||||
|
|
||||||
|
/// 반드시 존재해야 하는 종목. SYMBOL=NAME 형식
|
||||||
|
#[arg(long = "expect")]
|
||||||
|
pub expects: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn handle_universe(sub: &UniverseCommands) {
|
||||||
|
match sub {
|
||||||
|
UniverseCommands::Sync(command) => match kind::universe::load_or_refresh(command.force) {
|
||||||
|
Ok(snapshot) => {
|
||||||
|
print_snapshot("universe sync", "종목 universe 캐시 갱신 결과", &snapshot)
|
||||||
|
}
|
||||||
|
Err(err) => eprintln!(
|
||||||
|
"{}",
|
||||||
|
crate::core::output::error("universe sync", "종목 universe 캐시 갱신 실패", &err)
|
||||||
|
),
|
||||||
|
},
|
||||||
|
UniverseCommands::Status => match kind::universe::load_cached() {
|
||||||
|
Ok(Some(snapshot)) => {
|
||||||
|
print_snapshot("universe status", "종목 universe 캐시 상태", &snapshot)
|
||||||
|
}
|
||||||
|
Ok(None) => eprintln!(
|
||||||
|
"{}",
|
||||||
|
crate::core::output::error(
|
||||||
|
"universe status",
|
||||||
|
"종목 universe 캐시 상태 조회 실패",
|
||||||
|
"로컬 universe 캐시가 없습니다. `openstock universe sync`를 먼저 실행하세요.",
|
||||||
|
)
|
||||||
|
),
|
||||||
|
Err(err) => eprintln!(
|
||||||
|
"{}",
|
||||||
|
crate::core::output::error(
|
||||||
|
"universe status",
|
||||||
|
"종목 universe 캐시 상태 조회 실패",
|
||||||
|
&err
|
||||||
|
)
|
||||||
|
),
|
||||||
|
},
|
||||||
|
UniverseCommands::List(command) => match kind::universe::load_or_refresh(false) {
|
||||||
|
Ok(snapshot) => match filter_stocks(
|
||||||
|
&snapshot.stocks,
|
||||||
|
command.market.as_deref(),
|
||||||
|
command.kind.as_deref(),
|
||||||
|
) {
|
||||||
|
Ok(filtered) => {
|
||||||
|
let page = filtered
|
||||||
|
.iter()
|
||||||
|
.skip(command.offset)
|
||||||
|
.take(command.limit)
|
||||||
|
.cloned()
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
crate::core::output::explained_with_raw(
|
||||||
|
"universe list",
|
||||||
|
"로컬 universe 캐시에서 종목 목록을 조회한 결과",
|
||||||
|
vec![
|
||||||
|
crate::core::output::field(
|
||||||
|
"source",
|
||||||
|
"universe 원천 데이터",
|
||||||
|
serde_json::json!(snapshot.meta.source),
|
||||||
|
),
|
||||||
|
crate::core::output::field(
|
||||||
|
"cache_date",
|
||||||
|
"캐시 기준일",
|
||||||
|
serde_json::json!(snapshot.meta.date),
|
||||||
|
),
|
||||||
|
crate::core::output::field(
|
||||||
|
"total_count",
|
||||||
|
"필터 적용 전 전체 종목 수",
|
||||||
|
serde_json::json!(snapshot.stocks.len()),
|
||||||
|
),
|
||||||
|
crate::core::output::field(
|
||||||
|
"filtered_count",
|
||||||
|
"필터 적용 후 종목 수",
|
||||||
|
serde_json::json!(filtered.len()),
|
||||||
|
),
|
||||||
|
crate::core::output::field(
|
||||||
|
"offset",
|
||||||
|
"조회 시작 offset",
|
||||||
|
serde_json::json!(command.offset),
|
||||||
|
),
|
||||||
|
crate::core::output::field(
|
||||||
|
"limit",
|
||||||
|
"최대 반환 종목 수",
|
||||||
|
serde_json::json!(command.limit),
|
||||||
|
),
|
||||||
|
crate::core::output::field(
|
||||||
|
"stocks",
|
||||||
|
"조회된 종목 목록",
|
||||||
|
serde_json::json!(page),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
serde_json::json!({
|
||||||
|
"meta": snapshot.meta,
|
||||||
|
"stocks": page,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(err) => eprintln!(
|
||||||
|
"{}",
|
||||||
|
crate::core::output::error(
|
||||||
|
"universe list",
|
||||||
|
"종목 universe 목록 조회 실패",
|
||||||
|
&err
|
||||||
|
)
|
||||||
|
),
|
||||||
|
},
|
||||||
|
Err(err) => eprintln!(
|
||||||
|
"{}",
|
||||||
|
crate::core::output::error("universe list", "종목 universe 목록 조회 실패", &err)
|
||||||
|
),
|
||||||
|
},
|
||||||
|
UniverseCommands::Chunks(command) => match kind::universe::load_or_refresh(false) {
|
||||||
|
Ok(snapshot) => match filter_stocks(
|
||||||
|
&snapshot.stocks,
|
||||||
|
command.market.as_deref(),
|
||||||
|
command.kind.as_deref(),
|
||||||
|
) {
|
||||||
|
Ok(filtered) => {
|
||||||
|
let chunks = kind::universe::chunk_stocks(&filtered, command.size);
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
crate::core::output::explained_with_raw(
|
||||||
|
"universe chunks",
|
||||||
|
"로컬 universe 캐시를 scan 가능한 chunk로 분할한 결과",
|
||||||
|
vec![
|
||||||
|
crate::core::output::field(
|
||||||
|
"source",
|
||||||
|
"universe 원천 데이터",
|
||||||
|
serde_json::json!(snapshot.meta.source),
|
||||||
|
),
|
||||||
|
crate::core::output::field(
|
||||||
|
"cache_date",
|
||||||
|
"캐시 기준일",
|
||||||
|
serde_json::json!(snapshot.meta.date),
|
||||||
|
),
|
||||||
|
crate::core::output::field(
|
||||||
|
"filtered_count",
|
||||||
|
"chunk 생성 대상 종목 수",
|
||||||
|
serde_json::json!(filtered.len()),
|
||||||
|
),
|
||||||
|
crate::core::output::field(
|
||||||
|
"chunk_size",
|
||||||
|
"chunk당 최대 종목 수",
|
||||||
|
serde_json::json!(command.size.max(1)),
|
||||||
|
),
|
||||||
|
crate::core::output::field(
|
||||||
|
"chunk_count",
|
||||||
|
"생성된 chunk 수",
|
||||||
|
serde_json::json!(chunks.len()),
|
||||||
|
),
|
||||||
|
crate::core::output::field(
|
||||||
|
"chunks",
|
||||||
|
"생성된 chunk 목록. start_symbol과 end_symbol은 문자열 정렬 기준 cursor 경계다.",
|
||||||
|
serde_json::json!(chunks),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
serde_json::json!({
|
||||||
|
"meta": snapshot.meta,
|
||||||
|
"chunks": chunks,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(err) => eprintln!(
|
||||||
|
"{}",
|
||||||
|
crate::core::output::error(
|
||||||
|
"universe chunks",
|
||||||
|
"종목 universe chunk 생성 실패",
|
||||||
|
&err
|
||||||
|
)
|
||||||
|
),
|
||||||
|
},
|
||||||
|
Err(err) => eprintln!(
|
||||||
|
"{}",
|
||||||
|
crate::core::output::error(
|
||||||
|
"universe chunks",
|
||||||
|
"종목 universe chunk 생성 실패",
|
||||||
|
&err
|
||||||
|
)
|
||||||
|
),
|
||||||
|
},
|
||||||
|
UniverseCommands::Validate(command) => match kind::universe::load_or_refresh(false) {
|
||||||
|
Ok(snapshot) => match validate_universe(&snapshot.stocks, command) {
|
||||||
|
Ok(report) => println!(
|
||||||
|
"{}",
|
||||||
|
crate::core::output::explained_with_raw(
|
||||||
|
"universe validate",
|
||||||
|
"로컬 universe 캐시의 크기, 중복, 필수 필드, 대표 종목 값을 검증한 결과",
|
||||||
|
vec![
|
||||||
|
crate::core::output::field(
|
||||||
|
"valid",
|
||||||
|
"검증 통과 여부",
|
||||||
|
serde_json::json!(report.valid),
|
||||||
|
),
|
||||||
|
crate::core::output::field(
|
||||||
|
"stock_count",
|
||||||
|
"검증 대상 전체 종목 수",
|
||||||
|
serde_json::json!(report.stock_count),
|
||||||
|
),
|
||||||
|
crate::core::output::field(
|
||||||
|
"counts_by_market",
|
||||||
|
"시장별 종목 수",
|
||||||
|
serde_json::json!(report.counts_by_market),
|
||||||
|
),
|
||||||
|
crate::core::output::field(
|
||||||
|
"checks",
|
||||||
|
"개별 검증 항목과 결과",
|
||||||
|
serde_json::json!(report.checks),
|
||||||
|
),
|
||||||
|
crate::core::output::field(
|
||||||
|
"errors",
|
||||||
|
"실패한 검증 항목 설명",
|
||||||
|
serde_json::json!(report.errors),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
serde_json::json!({
|
||||||
|
"meta": snapshot.meta,
|
||||||
|
"report": report,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
Err(err) => eprintln!(
|
||||||
|
"{}",
|
||||||
|
crate::core::output::error(
|
||||||
|
"universe validate",
|
||||||
|
"종목 universe 검증 실패",
|
||||||
|
&err
|
||||||
|
)
|
||||||
|
),
|
||||||
|
},
|
||||||
|
Err(err) => eprintln!(
|
||||||
|
"{}",
|
||||||
|
crate::core::output::error("universe validate", "종목 universe 검증 실패", &err)
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_snapshot(command: &str, description: &str, snapshot: &kind::universe::UniverseSnapshot) {
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
crate::core::output::explained_with_raw(
|
||||||
|
command,
|
||||||
|
description,
|
||||||
|
vec![
|
||||||
|
crate::core::output::field(
|
||||||
|
"source",
|
||||||
|
"universe 원천 데이터",
|
||||||
|
serde_json::json!(snapshot.meta.source),
|
||||||
|
),
|
||||||
|
crate::core::output::field(
|
||||||
|
"source_url",
|
||||||
|
"universe를 내려받은 원천 URL",
|
||||||
|
serde_json::json!(snapshot.meta.source_url),
|
||||||
|
),
|
||||||
|
crate::core::output::field(
|
||||||
|
"cache_date",
|
||||||
|
"캐시 기준일",
|
||||||
|
serde_json::json!(snapshot.meta.date),
|
||||||
|
),
|
||||||
|
crate::core::output::field(
|
||||||
|
"refreshed_at",
|
||||||
|
"캐시 갱신 시각",
|
||||||
|
serde_json::json!(snapshot.meta.refreshed_at),
|
||||||
|
),
|
||||||
|
crate::core::output::field(
|
||||||
|
"refreshed",
|
||||||
|
"이번 명령에서 새로 다운로드했는지 여부",
|
||||||
|
serde_json::json!(snapshot.refreshed),
|
||||||
|
),
|
||||||
|
crate::core::output::field(
|
||||||
|
"stock_count",
|
||||||
|
"캐시에 저장된 전체 종목 수",
|
||||||
|
serde_json::json!(snapshot.meta.stock_count),
|
||||||
|
),
|
||||||
|
crate::core::output::field(
|
||||||
|
"counts_by_market",
|
||||||
|
"시장별 종목 수",
|
||||||
|
serde_json::json!(snapshot.meta.counts_by_market),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
serde_json::json!(snapshot.meta),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn filter_stocks(
|
||||||
|
stocks: &[Stock],
|
||||||
|
market: Option<&str>,
|
||||||
|
kind: Option<&str>,
|
||||||
|
) -> Result<Vec<Stock>, String> {
|
||||||
|
let market = market.map(parse_market).transpose()?;
|
||||||
|
let kind = kind.map(parse_kind).transpose()?;
|
||||||
|
let mut filtered = stocks
|
||||||
|
.iter()
|
||||||
|
.filter(|stock| market.map(|value| stock.market == value).unwrap_or(true))
|
||||||
|
.filter(|stock| kind.map(|value| stock.kind == value).unwrap_or(true))
|
||||||
|
.cloned()
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
filtered.sort_by(|a, b| {
|
||||||
|
a.market
|
||||||
|
.cmp(&b.market)
|
||||||
|
.then_with(|| a.kind.cmp(&b.kind))
|
||||||
|
.then_with(|| a.id.symbol.cmp(&b.id.symbol))
|
||||||
|
});
|
||||||
|
Ok(filtered)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_market(value: &str) -> Result<StockMarket, String> {
|
||||||
|
match value.trim().to_ascii_uppercase().as_str() {
|
||||||
|
"KOSPI" => Ok(StockMarket::Kospi),
|
||||||
|
"KOSDAQ" => Ok(StockMarket::Kosdaq),
|
||||||
|
"KONEX" => Ok(StockMarket::Konex),
|
||||||
|
_ => Err(format!("지원하지 않는 시장 필터입니다: {}", value)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_kind(value: &str) -> Result<StockKind, String> {
|
||||||
|
match value.trim().to_ascii_lowercase().as_str() {
|
||||||
|
"common_stock" | "common" => Ok(StockKind::CommonStock),
|
||||||
|
"preferred_stock" | "preferred" => Ok(StockKind::PreferredStock),
|
||||||
|
"etf" => Ok(StockKind::Etf),
|
||||||
|
"etn" => Ok(StockKind::Etn),
|
||||||
|
"reit" => Ok(StockKind::Reit),
|
||||||
|
"spac" => Ok(StockKind::Spac),
|
||||||
|
_ => Err(format!("지원하지 않는 종목 유형 필터입니다: {}", value)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Serialize)]
|
||||||
|
struct UniverseValidationReport {
|
||||||
|
valid: bool,
|
||||||
|
stock_count: usize,
|
||||||
|
counts_by_market: serde_json::Value,
|
||||||
|
checks: Vec<ValidationCheck>,
|
||||||
|
errors: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Serialize)]
|
||||||
|
struct ValidationCheck {
|
||||||
|
name: &'static str,
|
||||||
|
description: &'static str,
|
||||||
|
valid: bool,
|
||||||
|
expected: serde_json::Value,
|
||||||
|
actual: serde_json::Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_universe(
|
||||||
|
stocks: &[Stock],
|
||||||
|
command: &UniverseValidateCommand,
|
||||||
|
) -> Result<UniverseValidationReport, String> {
|
||||||
|
let mut checks = Vec::new();
|
||||||
|
let mut errors = Vec::new();
|
||||||
|
let counts = market_counts(stocks);
|
||||||
|
|
||||||
|
push_check(
|
||||||
|
&mut checks,
|
||||||
|
&mut errors,
|
||||||
|
"min_count",
|
||||||
|
"전체 종목 수가 기대 최소값 이상인지 검증",
|
||||||
|
serde_json::json!({ "min": command.min_count }),
|
||||||
|
serde_json::json!(stocks.len()),
|
||||||
|
stocks.len() >= command.min_count,
|
||||||
|
);
|
||||||
|
push_check(
|
||||||
|
&mut checks,
|
||||||
|
&mut errors,
|
||||||
|
"min_kospi",
|
||||||
|
"KOSPI 종목 수가 기대 최소값 이상인지 검증",
|
||||||
|
serde_json::json!({ "min": command.min_kospi }),
|
||||||
|
serde_json::json!(counts.kospi),
|
||||||
|
counts.kospi >= command.min_kospi,
|
||||||
|
);
|
||||||
|
push_check(
|
||||||
|
&mut checks,
|
||||||
|
&mut errors,
|
||||||
|
"min_kosdaq",
|
||||||
|
"KOSDAQ 종목 수가 기대 최소값 이상인지 검증",
|
||||||
|
serde_json::json!({ "min": command.min_kosdaq }),
|
||||||
|
serde_json::json!(counts.kosdaq),
|
||||||
|
counts.kosdaq >= command.min_kosdaq,
|
||||||
|
);
|
||||||
|
push_check(
|
||||||
|
&mut checks,
|
||||||
|
&mut errors,
|
||||||
|
"min_konex",
|
||||||
|
"KONEX 종목 수가 기대 최소값 이상인지 검증",
|
||||||
|
serde_json::json!({ "min": command.min_konex }),
|
||||||
|
serde_json::json!(counts.konex),
|
||||||
|
counts.konex >= command.min_konex,
|
||||||
|
);
|
||||||
|
|
||||||
|
let duplicate_symbols = duplicate_symbols(stocks);
|
||||||
|
push_check(
|
||||||
|
&mut checks,
|
||||||
|
&mut errors,
|
||||||
|
"unique_symbols",
|
||||||
|
"종목코드가 중복 없이 유일한지 검증",
|
||||||
|
serde_json::json!([]),
|
||||||
|
serde_json::json!(duplicate_symbols),
|
||||||
|
duplicate_symbols.is_empty(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let invalid_required = stocks
|
||||||
|
.iter()
|
||||||
|
.filter(|stock| stock.id.symbol.trim().is_empty() || stock.name.trim().is_empty())
|
||||||
|
.map(|stock| stock.id.symbol.clone())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
push_check(
|
||||||
|
&mut checks,
|
||||||
|
&mut errors,
|
||||||
|
"required_fields",
|
||||||
|
"종목코드와 종목명이 비어 있지 않은지 검증",
|
||||||
|
serde_json::json!([]),
|
||||||
|
serde_json::json!(invalid_required),
|
||||||
|
invalid_required.is_empty(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let default_expects = if command.expects.is_empty() {
|
||||||
|
vec![
|
||||||
|
"005930=삼성전자".to_string(),
|
||||||
|
"000020=동화약품".to_string(),
|
||||||
|
"035720=카카오".to_string(),
|
||||||
|
]
|
||||||
|
} else {
|
||||||
|
command.expects.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
for expect in default_expects {
|
||||||
|
let (symbol, name) = expect
|
||||||
|
.split_once('=')
|
||||||
|
.ok_or_else(|| format!("--expect 값은 SYMBOL=NAME 형식이어야 합니다: {}", expect))?;
|
||||||
|
let actual = stocks.iter().find(|stock| stock.id.symbol == symbol);
|
||||||
|
let valid = actual.map(|stock| stock.name == name).unwrap_or(false);
|
||||||
|
push_check(
|
||||||
|
&mut checks,
|
||||||
|
&mut errors,
|
||||||
|
"expected_symbol",
|
||||||
|
"대표 종목코드가 기대 종목명으로 존재하는지 검증",
|
||||||
|
serde_json::json!({ "symbol": symbol, "name": name }),
|
||||||
|
serde_json::json!(actual.map(|stock| &stock.name)),
|
||||||
|
valid,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(UniverseValidationReport {
|
||||||
|
valid: errors.is_empty(),
|
||||||
|
stock_count: stocks.len(),
|
||||||
|
counts_by_market: serde_json::json!({
|
||||||
|
"KOSPI": counts.kospi,
|
||||||
|
"KOSDAQ": counts.kosdaq,
|
||||||
|
"KONEX": counts.konex,
|
||||||
|
"OTHER": counts.other,
|
||||||
|
"UNKNOWN": counts.unknown,
|
||||||
|
}),
|
||||||
|
checks,
|
||||||
|
errors,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_check(
|
||||||
|
checks: &mut Vec<ValidationCheck>,
|
||||||
|
errors: &mut Vec<String>,
|
||||||
|
name: &'static str,
|
||||||
|
description: &'static str,
|
||||||
|
expected: serde_json::Value,
|
||||||
|
actual: serde_json::Value,
|
||||||
|
valid: bool,
|
||||||
|
) {
|
||||||
|
if !valid {
|
||||||
|
errors.push(format!("{} 검증 실패", name));
|
||||||
|
}
|
||||||
|
checks.push(ValidationCheck {
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
valid,
|
||||||
|
expected,
|
||||||
|
actual,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MarketCounts {
|
||||||
|
kospi: usize,
|
||||||
|
kosdaq: usize,
|
||||||
|
konex: usize,
|
||||||
|
other: usize,
|
||||||
|
unknown: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn market_counts(stocks: &[Stock]) -> MarketCounts {
|
||||||
|
let mut counts = MarketCounts {
|
||||||
|
kospi: 0,
|
||||||
|
kosdaq: 0,
|
||||||
|
konex: 0,
|
||||||
|
other: 0,
|
||||||
|
unknown: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
for stock in stocks {
|
||||||
|
match stock.market {
|
||||||
|
StockMarket::Kospi => counts.kospi += 1,
|
||||||
|
StockMarket::Kosdaq => counts.kosdaq += 1,
|
||||||
|
StockMarket::Konex => counts.konex += 1,
|
||||||
|
StockMarket::Other => counts.other += 1,
|
||||||
|
StockMarket::Unknown => counts.unknown += 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
counts
|
||||||
|
}
|
||||||
|
|
||||||
|
fn duplicate_symbols(stocks: &[Stock]) -> Vec<String> {
|
||||||
|
let mut seen = std::collections::BTreeSet::new();
|
||||||
|
let mut duplicates = std::collections::BTreeSet::new();
|
||||||
|
for stock in stocks {
|
||||||
|
if !seen.insert(stock.id.symbol.as_str()) {
|
||||||
|
duplicates.insert(stock.id.symbol.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
duplicates.into_iter().collect()
|
||||||
|
}
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
use serde::Serialize;
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::time::SystemTime;
|
||||||
|
|
||||||
|
const CACHE_ROOT: &str = ".openstock";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct CacheStatus {
|
||||||
|
pub root: String,
|
||||||
|
pub exists: bool,
|
||||||
|
pub total_files: usize,
|
||||||
|
pub total_bytes: u64,
|
||||||
|
pub namespaces: Vec<CacheNamespaceStatus>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct CacheNamespaceStatus {
|
||||||
|
pub namespace: String,
|
||||||
|
pub files: usize,
|
||||||
|
pub bytes: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct CachePrunePolicy {
|
||||||
|
pub namespace: String,
|
||||||
|
pub directory: String,
|
||||||
|
pub max_snapshot_files: usize,
|
||||||
|
pub max_snapshot_bytes: u64,
|
||||||
|
pub protected_files: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct CachePruneReport {
|
||||||
|
pub policy: CachePrunePolicy,
|
||||||
|
pub dry_run: bool,
|
||||||
|
pub deleted_files: usize,
|
||||||
|
pub deleted_bytes: u64,
|
||||||
|
pub retained_snapshot_files: usize,
|
||||||
|
pub retained_snapshot_bytes: u64,
|
||||||
|
pub deleted_paths: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct SnapshotFile {
|
||||||
|
path: PathBuf,
|
||||||
|
name: String,
|
||||||
|
bytes: u64,
|
||||||
|
modified: SystemTime,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn status() -> Result<CacheStatus, String> {
|
||||||
|
let root = Path::new(CACHE_ROOT);
|
||||||
|
if !root.exists() {
|
||||||
|
return Ok(CacheStatus {
|
||||||
|
root: CACHE_ROOT.to_string(),
|
||||||
|
exists: false,
|
||||||
|
total_files: 0,
|
||||||
|
total_bytes: 0,
|
||||||
|
namespaces: Vec::new(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut namespaces = BTreeMap::<String, CacheNamespaceStatus>::new();
|
||||||
|
let mut total_files = 0;
|
||||||
|
let mut total_bytes = 0;
|
||||||
|
collect_status(
|
||||||
|
root,
|
||||||
|
root,
|
||||||
|
&mut namespaces,
|
||||||
|
&mut total_files,
|
||||||
|
&mut total_bytes,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
Ok(CacheStatus {
|
||||||
|
root: CACHE_ROOT.to_string(),
|
||||||
|
exists: true,
|
||||||
|
total_files,
|
||||||
|
total_bytes,
|
||||||
|
namespaces: namespaces.into_values().collect(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn prune_json_snapshots(
|
||||||
|
namespace: &str,
|
||||||
|
dir: &Path,
|
||||||
|
protected_files: &[&str],
|
||||||
|
max_snapshot_files: usize,
|
||||||
|
max_snapshot_bytes: u64,
|
||||||
|
dry_run: bool,
|
||||||
|
) -> Result<CachePruneReport, String> {
|
||||||
|
prune_snapshots(
|
||||||
|
namespace,
|
||||||
|
dir,
|
||||||
|
protected_files,
|
||||||
|
&["json"],
|
||||||
|
max_snapshot_files,
|
||||||
|
max_snapshot_bytes,
|
||||||
|
dry_run,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn prune_snapshots(
|
||||||
|
namespace: &str,
|
||||||
|
dir: &Path,
|
||||||
|
protected_files: &[&str],
|
||||||
|
extensions: &[&str],
|
||||||
|
max_snapshot_files: usize,
|
||||||
|
max_snapshot_bytes: u64,
|
||||||
|
dry_run: bool,
|
||||||
|
) -> Result<CachePruneReport, String> {
|
||||||
|
let policy = CachePrunePolicy {
|
||||||
|
namespace: namespace.to_string(),
|
||||||
|
directory: dir.display().to_string(),
|
||||||
|
max_snapshot_files,
|
||||||
|
max_snapshot_bytes,
|
||||||
|
protected_files: protected_files
|
||||||
|
.iter()
|
||||||
|
.map(|value| value.to_string())
|
||||||
|
.collect(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if !dir.exists() {
|
||||||
|
return Ok(CachePruneReport {
|
||||||
|
policy,
|
||||||
|
dry_run,
|
||||||
|
deleted_files: 0,
|
||||||
|
deleted_bytes: 0,
|
||||||
|
retained_snapshot_files: 0,
|
||||||
|
retained_snapshot_bytes: 0,
|
||||||
|
deleted_paths: Vec::new(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut snapshots = snapshot_files(dir, protected_files, extensions)?;
|
||||||
|
snapshots.sort_by(|a, b| {
|
||||||
|
b.modified
|
||||||
|
.cmp(&a.modified)
|
||||||
|
.then_with(|| b.name.cmp(&a.name))
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut retained_bytes = 0_u64;
|
||||||
|
let mut delete = Vec::new();
|
||||||
|
for (index, snapshot) in snapshots.iter().enumerate() {
|
||||||
|
let keep_by_count = index < max_snapshot_files;
|
||||||
|
let keep_by_bytes = retained_bytes.saturating_add(snapshot.bytes) <= max_snapshot_bytes;
|
||||||
|
if keep_by_count && keep_by_bytes {
|
||||||
|
retained_bytes = retained_bytes.saturating_add(snapshot.bytes);
|
||||||
|
} else {
|
||||||
|
delete.push(snapshot.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let deleted_files = delete.len();
|
||||||
|
let deleted_bytes = delete.iter().map(|file| file.bytes).sum::<u64>();
|
||||||
|
let deleted_paths = delete
|
||||||
|
.iter()
|
||||||
|
.map(|file| file.path.display().to_string())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
if !dry_run {
|
||||||
|
for file in &delete {
|
||||||
|
fs::remove_file(&file.path).map_err(|err| {
|
||||||
|
format!(
|
||||||
|
"[cache] 캐시 파일 삭제 실패 ({}): {}",
|
||||||
|
file.path.display(),
|
||||||
|
err
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(CachePruneReport {
|
||||||
|
policy,
|
||||||
|
dry_run,
|
||||||
|
deleted_files,
|
||||||
|
deleted_bytes,
|
||||||
|
retained_snapshot_files: snapshots.len().saturating_sub(deleted_files),
|
||||||
|
retained_snapshot_bytes: retained_bytes,
|
||||||
|
deleted_paths,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_status(
|
||||||
|
root: &Path,
|
||||||
|
dir: &Path,
|
||||||
|
namespaces: &mut BTreeMap<String, CacheNamespaceStatus>,
|
||||||
|
total_files: &mut usize,
|
||||||
|
total_bytes: &mut u64,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
for entry in
|
||||||
|
fs::read_dir(dir).map_err(|err| format!("[cache] 캐시 디렉터리 읽기 실패: {}", err))?
|
||||||
|
{
|
||||||
|
let entry = entry.map_err(|err| format!("[cache] 캐시 항목 읽기 실패: {}", err))?;
|
||||||
|
let path = entry.path();
|
||||||
|
let metadata = entry
|
||||||
|
.metadata()
|
||||||
|
.map_err(|err| format!("[cache] 캐시 메타데이터 읽기 실패: {}", err))?;
|
||||||
|
if metadata.is_dir() {
|
||||||
|
collect_status(root, &path, namespaces, total_files, total_bytes)?;
|
||||||
|
} else if metadata.is_file() {
|
||||||
|
let bytes = metadata.len();
|
||||||
|
*total_files += 1;
|
||||||
|
*total_bytes = total_bytes.saturating_add(bytes);
|
||||||
|
let namespace = namespace_for(root, &path);
|
||||||
|
let entry = namespaces
|
||||||
|
.entry(namespace.clone())
|
||||||
|
.or_insert(CacheNamespaceStatus {
|
||||||
|
namespace,
|
||||||
|
files: 0,
|
||||||
|
bytes: 0,
|
||||||
|
});
|
||||||
|
entry.files += 1;
|
||||||
|
entry.bytes = entry.bytes.saturating_add(bytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn namespace_for(root: &Path, path: &Path) -> String {
|
||||||
|
let Ok(relative) = path.strip_prefix(root) else {
|
||||||
|
return "unknown".to_string();
|
||||||
|
};
|
||||||
|
relative
|
||||||
|
.components()
|
||||||
|
.next()
|
||||||
|
.map(|component| component.as_os_str().to_string_lossy().to_string())
|
||||||
|
.unwrap_or_else(|| "root".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn snapshot_files(
|
||||||
|
dir: &Path,
|
||||||
|
protected_files: &[&str],
|
||||||
|
extensions: &[&str],
|
||||||
|
) -> Result<Vec<SnapshotFile>, String> {
|
||||||
|
let mut files = Vec::new();
|
||||||
|
for entry in
|
||||||
|
fs::read_dir(dir).map_err(|err| format!("[cache] 캐시 디렉터리 읽기 실패: {}", err))?
|
||||||
|
{
|
||||||
|
let entry = entry.map_err(|err| format!("[cache] 캐시 항목 읽기 실패: {}", err))?;
|
||||||
|
let path = entry.path();
|
||||||
|
let metadata = entry
|
||||||
|
.metadata()
|
||||||
|
.map_err(|err| format!("[cache] 캐시 메타데이터 읽기 실패: {}", err))?;
|
||||||
|
if !metadata.is_file() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(name) = path
|
||||||
|
.file_name()
|
||||||
|
.and_then(|value| value.to_str())
|
||||||
|
.map(str::to_string)
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if protected_files.iter().any(|protected| protected == &name)
|
||||||
|
|| !extensions.iter().any(|extension| {
|
||||||
|
path.extension()
|
||||||
|
.and_then(|value| value.to_str())
|
||||||
|
.map(|value| value.eq_ignore_ascii_case(extension))
|
||||||
|
.unwrap_or(false)
|
||||||
|
})
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
files.push(SnapshotFile {
|
||||||
|
path,
|
||||||
|
name,
|
||||||
|
bytes: metadata.len(),
|
||||||
|
modified: metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(files)
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
pub fn agent() -> ureq::Agent {
|
||||||
|
let config = ureq::config::Config::builder()
|
||||||
|
.tls_config(
|
||||||
|
ureq::tls::TlsConfig::builder()
|
||||||
|
.provider(ureq::tls::TlsProvider::NativeTls)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.build();
|
||||||
|
ureq::Agent::new_with_config(config)
|
||||||
|
}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
|
pub mod cache;
|
||||||
pub mod dotenv;
|
pub mod dotenv;
|
||||||
|
pub mod http;
|
||||||
pub mod output;
|
pub mod output;
|
||||||
pub mod registry;
|
pub mod registry;
|
||||||
|
pub mod stock;
|
||||||
pub mod trader;
|
pub mod trader;
|
||||||
|
|
||||||
pub use registry::ApiRegistry;
|
pub use registry::ApiRegistry;
|
||||||
|
|||||||
@@ -0,0 +1,265 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct Stock {
|
||||||
|
pub id: StockId,
|
||||||
|
pub name: String,
|
||||||
|
pub market: StockMarket,
|
||||||
|
pub kind: StockKind,
|
||||||
|
pub industry: Option<String>,
|
||||||
|
pub sector: Option<String>,
|
||||||
|
pub main_product: Option<String>,
|
||||||
|
pub listed_at: Option<String>,
|
||||||
|
pub fiscal_month: Option<String>,
|
||||||
|
pub homepage: Option<String>,
|
||||||
|
pub region: Option<String>,
|
||||||
|
pub source: StockSource,
|
||||||
|
pub source_url: Option<String>,
|
||||||
|
pub updated_at: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct StockId {
|
||||||
|
pub symbol: String,
|
||||||
|
pub isin: Option<String>,
|
||||||
|
pub reuters_code: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
||||||
|
pub enum StockMarket {
|
||||||
|
Kospi,
|
||||||
|
Kosdaq,
|
||||||
|
Konex,
|
||||||
|
Other,
|
||||||
|
Unknown,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StockMarket {
|
||||||
|
pub fn as_str(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
StockMarket::Kospi => "KOSPI",
|
||||||
|
StockMarket::Kosdaq => "KOSDAQ",
|
||||||
|
StockMarket::Konex => "KONEX",
|
||||||
|
StockMarket::Other => "OTHER",
|
||||||
|
StockMarket::Unknown => "UNKNOWN",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum StockKind {
|
||||||
|
CommonStock,
|
||||||
|
PreferredStock,
|
||||||
|
Etf,
|
||||||
|
Etn,
|
||||||
|
Reit,
|
||||||
|
Spac,
|
||||||
|
Other,
|
||||||
|
Unknown,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StockKind {
|
||||||
|
pub fn as_str(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
StockKind::CommonStock => "common_stock",
|
||||||
|
StockKind::PreferredStock => "preferred_stock",
|
||||||
|
StockKind::Etf => "etf",
|
||||||
|
StockKind::Etn => "etn",
|
||||||
|
StockKind::Reit => "reit",
|
||||||
|
StockKind::Spac => "spac",
|
||||||
|
StockKind::Other => "other",
|
||||||
|
StockKind::Unknown => "unknown",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
||||||
|
pub enum StockSource {
|
||||||
|
Kind,
|
||||||
|
Naver,
|
||||||
|
Kis,
|
||||||
|
Manual,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Stock {
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn new(symbol: impl Into<String>, name: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
id: StockId {
|
||||||
|
symbol: symbol.into(),
|
||||||
|
isin: None,
|
||||||
|
reuters_code: None,
|
||||||
|
},
|
||||||
|
name: name.into(),
|
||||||
|
market: StockMarket::Unknown,
|
||||||
|
kind: StockKind::Unknown,
|
||||||
|
industry: None,
|
||||||
|
sector: None,
|
||||||
|
main_product: None,
|
||||||
|
listed_at: None,
|
||||||
|
fiscal_month: None,
|
||||||
|
homepage: None,
|
||||||
|
region: None,
|
||||||
|
source: StockSource::Manual,
|
||||||
|
source_url: None,
|
||||||
|
updated_at: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn validate(&self) -> Result<(), String> {
|
||||||
|
validate_symbol(&self.id.symbol)?;
|
||||||
|
if self.name.trim().is_empty() {
|
||||||
|
return Err("종목명은 비어 있을 수 없습니다.".to_string());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn validate_symbol(symbol: &str) -> Result<(), String> {
|
||||||
|
let symbol = symbol.trim();
|
||||||
|
if symbol.is_empty() {
|
||||||
|
return Err("종목코드는 비어 있을 수 없습니다.".to_string());
|
||||||
|
}
|
||||||
|
if symbol.len() > 12 {
|
||||||
|
return Err("종목코드는 12자를 넘을 수 없습니다.".to_string());
|
||||||
|
}
|
||||||
|
if !symbol.chars().all(|ch| ch.is_ascii_alphanumeric()) {
|
||||||
|
return Err("종목코드는 영문 대문자/소문자와 숫자만 허용합니다.".to_string());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn normalize_kind_market(value: &str) -> StockMarket {
|
||||||
|
match value.trim() {
|
||||||
|
"유가" | "KOSPI" | "kospi" => StockMarket::Kospi,
|
||||||
|
"코스닥" | "KOSDAQ" | "kosdaq" => StockMarket::Kosdaq,
|
||||||
|
"코넥스" | "KONEX" | "konex" => StockMarket::Konex,
|
||||||
|
"" => StockMarket::Unknown,
|
||||||
|
_ => StockMarket::Other,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn infer_stock_kind(
|
||||||
|
name: &str,
|
||||||
|
industry: Option<&str>,
|
||||||
|
main_product: Option<&str>,
|
||||||
|
) -> StockKind {
|
||||||
|
let text = format!(
|
||||||
|
"{} {} {}",
|
||||||
|
name,
|
||||||
|
industry.unwrap_or(""),
|
||||||
|
main_product.unwrap_or("")
|
||||||
|
)
|
||||||
|
.to_ascii_uppercase();
|
||||||
|
|
||||||
|
if text.contains(" ETF")
|
||||||
|
|| text.starts_with("ETF ")
|
||||||
|
|| text.contains("KODEX")
|
||||||
|
|| text.contains("TIGER")
|
||||||
|
{
|
||||||
|
return StockKind::Etf;
|
||||||
|
}
|
||||||
|
if text.contains(" ETN") || text.starts_with("ETN ") {
|
||||||
|
return StockKind::Etn;
|
||||||
|
}
|
||||||
|
if text.contains("리츠") || text.contains("REIT") {
|
||||||
|
return StockKind::Reit;
|
||||||
|
}
|
||||||
|
if text.contains("스팩") || text.contains("SPAC") {
|
||||||
|
return StockKind::Spac;
|
||||||
|
}
|
||||||
|
if name.ends_with('우') || name.contains("우B") || name.contains("우C") {
|
||||||
|
return StockKind::PreferredStock;
|
||||||
|
}
|
||||||
|
|
||||||
|
StockKind::CommonStock
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn serializes_stock_schema_with_stable_field_names() {
|
||||||
|
let stock = Stock {
|
||||||
|
id: StockId {
|
||||||
|
symbol: "0126Z0".to_string(),
|
||||||
|
isin: None,
|
||||||
|
reuters_code: Some("0126Z0".to_string()),
|
||||||
|
},
|
||||||
|
name: "삼성에피스홀딩스".to_string(),
|
||||||
|
market: StockMarket::Kospi,
|
||||||
|
kind: StockKind::CommonStock,
|
||||||
|
industry: Some("기타 금융업".to_string()),
|
||||||
|
sector: None,
|
||||||
|
main_product: Some("지주회사".to_string()),
|
||||||
|
listed_at: Some("2025-11-24".to_string()),
|
||||||
|
fiscal_month: Some("12월".to_string()),
|
||||||
|
homepage: Some("http://www.samsungepisholdings.com".to_string()),
|
||||||
|
region: Some("인천광역시".to_string()),
|
||||||
|
source: StockSource::Kind,
|
||||||
|
source_url: Some("https://kind.krx.co.kr/corpgeneral/corpList.do".to_string()),
|
||||||
|
updated_at: Some("2026-06-09".to_string()),
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_value(stock).unwrap(),
|
||||||
|
json!({
|
||||||
|
"id": {
|
||||||
|
"symbol": "0126Z0",
|
||||||
|
"isin": null,
|
||||||
|
"reuters_code": "0126Z0"
|
||||||
|
},
|
||||||
|
"name": "삼성에피스홀딩스",
|
||||||
|
"market": "KOSPI",
|
||||||
|
"kind": "common_stock",
|
||||||
|
"industry": "기타 금융업",
|
||||||
|
"sector": null,
|
||||||
|
"main_product": "지주회사",
|
||||||
|
"listed_at": "2025-11-24",
|
||||||
|
"fiscal_month": "12월",
|
||||||
|
"homepage": "http://www.samsungepisholdings.com",
|
||||||
|
"region": "인천광역시",
|
||||||
|
"source": "KIND",
|
||||||
|
"source_url": "https://kind.krx.co.kr/corpgeneral/corpList.do",
|
||||||
|
"updated_at": "2026-06-09"
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn accepts_alphanumeric_symbols() {
|
||||||
|
assert!(validate_symbol("005930").is_ok());
|
||||||
|
assert!(validate_symbol("0126Z0").is_ok());
|
||||||
|
assert!(validate_symbol("0126-0").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normalizes_kind_market_names() {
|
||||||
|
assert_eq!(normalize_kind_market("유가"), StockMarket::Kospi);
|
||||||
|
assert_eq!(normalize_kind_market("코스닥"), StockMarket::Kosdaq);
|
||||||
|
assert_eq!(normalize_kind_market("코넥스"), StockMarket::Konex);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn infers_common_stock_types() {
|
||||||
|
assert_eq!(
|
||||||
|
infer_stock_kind("삼성전자", Some("통신 및 방송 장비 제조업"), None),
|
||||||
|
StockKind::CommonStock
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
infer_stock_kind("삼성전자우", None, None),
|
||||||
|
StockKind::PreferredStock
|
||||||
|
);
|
||||||
|
assert_eq!(infer_stock_kind("KODEX 반도체", None, None), StockKind::Etf);
|
||||||
|
assert_eq!(
|
||||||
|
infer_stock_kind("하나 레버리지 반도체 ETN", None, None),
|
||||||
|
StockKind::Etn
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -82,6 +82,16 @@ pub trait TraderApi {
|
|||||||
/// 종목 시장 정보 조회
|
/// 종목 시장 정보 조회
|
||||||
fn market(&self, symbol: &str) -> Result<String, String>;
|
fn market(&self, symbol: &str) -> Result<String, String>;
|
||||||
|
|
||||||
|
/// 종목 기간별 가격 히스토리 조회
|
||||||
|
fn market_history(
|
||||||
|
&self,
|
||||||
|
symbol: &str,
|
||||||
|
start_date: &str,
|
||||||
|
end_date: &str,
|
||||||
|
period: &str,
|
||||||
|
adjusted: bool,
|
||||||
|
) -> Result<String, String>;
|
||||||
|
|
||||||
/// 증권사 정보 (키-값 쌍)
|
/// 증권사 정보 (키-값 쌍)
|
||||||
fn info(&self) -> Vec<(&'static str, &'static str)>;
|
fn info(&self) -> Vec<(&'static str, &'static str)>;
|
||||||
}
|
}
|
||||||
|
|||||||
+27
-5
@@ -1,7 +1,11 @@
|
|||||||
use clap::{Parser, Subcommand};
|
use clap::{Parser, Subcommand};
|
||||||
use commands::account::AccountCommands;
|
use commands::account::AccountCommands;
|
||||||
use commands::api::ApiCommands;
|
use commands::api::ApiCommands;
|
||||||
|
use commands::cache::CacheCommands;
|
||||||
|
use commands::dart::DartCommands;
|
||||||
|
use commands::market::MarketCommand;
|
||||||
use commands::order::OrderCommands;
|
use commands::order::OrderCommands;
|
||||||
|
use commands::universe::UniverseCommands;
|
||||||
|
|
||||||
mod apis;
|
mod apis;
|
||||||
mod commands;
|
mod commands;
|
||||||
@@ -31,18 +35,36 @@ enum Commands {
|
|||||||
sub: ApiCommands,
|
sub: ApiCommands,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/// OpenDART 공시정보 조회
|
||||||
|
Dart {
|
||||||
|
#[command(subcommand)]
|
||||||
|
sub: DartCommands,
|
||||||
|
},
|
||||||
|
|
||||||
/// 계좌 조회 및 관리
|
/// 계좌 조회 및 관리
|
||||||
Account {
|
Account {
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
sub: AccountCommands,
|
sub: AccountCommands,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/// 로컬 캐시 상태 조회 및 용량 정리
|
||||||
|
Cache {
|
||||||
|
#[command(subcommand)]
|
||||||
|
sub: CacheCommands,
|
||||||
|
},
|
||||||
|
|
||||||
/// 주문 실행 및 조회
|
/// 주문 실행 및 조회
|
||||||
Order {
|
Order {
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
sub: OrderCommands,
|
sub: OrderCommands,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/// 종목 universe 캐시 구축 및 조회
|
||||||
|
Universe {
|
||||||
|
#[command(subcommand)]
|
||||||
|
sub: UniverseCommands,
|
||||||
|
},
|
||||||
|
|
||||||
/// 종목 검색
|
/// 종목 검색
|
||||||
Search {
|
Search {
|
||||||
/// 종목명 또는 종목코드
|
/// 종목명 또는 종목코드
|
||||||
@@ -50,10 +72,7 @@ enum Commands {
|
|||||||
},
|
},
|
||||||
|
|
||||||
/// 종목 정보 및 기업정보 조회
|
/// 종목 정보 및 기업정보 조회
|
||||||
Market {
|
Market(MarketCommand),
|
||||||
/// 종목코드
|
|
||||||
symbol: String,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
@@ -62,10 +81,13 @@ fn main() {
|
|||||||
match &cli.command {
|
match &cli.command {
|
||||||
Some(Commands::Version) => commands::handle_version(),
|
Some(Commands::Version) => commands::handle_version(),
|
||||||
Some(Commands::Api { sub }) => commands::handle_api(sub),
|
Some(Commands::Api { sub }) => commands::handle_api(sub),
|
||||||
|
Some(Commands::Dart { sub }) => commands::handle_dart(sub),
|
||||||
Some(Commands::Account { sub }) => commands::handle_account(sub),
|
Some(Commands::Account { sub }) => commands::handle_account(sub),
|
||||||
|
Some(Commands::Cache { sub }) => commands::handle_cache(sub),
|
||||||
Some(Commands::Order { sub }) => commands::handle_order(sub),
|
Some(Commands::Order { sub }) => commands::handle_order(sub),
|
||||||
|
Some(Commands::Universe { sub }) => commands::handle_universe(sub),
|
||||||
Some(Commands::Search { query }) => commands::handle_search(query),
|
Some(Commands::Search { query }) => commands::handle_search(query),
|
||||||
Some(Commands::Market { symbol }) => commands::handle_market(symbol),
|
Some(Commands::Market(command)) => commands::handle_market(command),
|
||||||
None => {
|
None => {
|
||||||
println!(
|
println!(
|
||||||
"{}",
|
"{}",
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
pub mod universe;
|
||||||
@@ -0,0 +1,445 @@
|
|||||||
|
use crate::core::stock::{
|
||||||
|
infer_stock_kind, normalize_kind_market, validate_symbol, Stock, StockId, StockKind,
|
||||||
|
StockMarket, StockSource,
|
||||||
|
};
|
||||||
|
use encoding_rs::EUC_KR;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
const KIND_UNIVERSE_URL: &str =
|
||||||
|
"https://kind.krx.co.kr/corpgeneral/corpList.do?method=download&searchType=13";
|
||||||
|
const CACHE_DIR: &str = ".openstock/universe/kind";
|
||||||
|
const LATEST_FILE: &str = "latest.json";
|
||||||
|
const META_FILE: &str = "meta.json";
|
||||||
|
const SNAPSHOT_RETENTION_FILES: usize = 7;
|
||||||
|
const SNAPSHOT_RETENTION_BYTES: u64 = 25 * 1024 * 1024;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct UniverseMeta {
|
||||||
|
pub source: String,
|
||||||
|
pub source_url: String,
|
||||||
|
pub date: String,
|
||||||
|
pub refreshed_at: String,
|
||||||
|
pub stock_count: usize,
|
||||||
|
pub counts_by_market: BTreeMap<String, usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct UniverseSnapshot {
|
||||||
|
pub stocks: Vec<Stock>,
|
||||||
|
pub meta: UniverseMeta,
|
||||||
|
pub refreshed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_or_refresh(force: bool) -> Result<UniverseSnapshot, String> {
|
||||||
|
if !force {
|
||||||
|
if let Some(snapshot) = load_today_cache()? {
|
||||||
|
return Ok(snapshot);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match refresh() {
|
||||||
|
Ok(snapshot) => Ok(snapshot),
|
||||||
|
Err(err) => {
|
||||||
|
if let Some(snapshot) = load_any_cache()? {
|
||||||
|
return Ok(UniverseSnapshot {
|
||||||
|
refreshed: false,
|
||||||
|
..snapshot
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn refresh() -> Result<UniverseSnapshot, String> {
|
||||||
|
let body = download()?;
|
||||||
|
let html = decode_euc_kr(&body);
|
||||||
|
let mut stocks = parse_kind_html(&html)?;
|
||||||
|
stocks.sort_by(|a, b| {
|
||||||
|
a.market
|
||||||
|
.cmp(&b.market)
|
||||||
|
.then_with(|| a.kind.cmp(&b.kind))
|
||||||
|
.then_with(|| a.id.symbol.cmp(&b.id.symbol))
|
||||||
|
});
|
||||||
|
|
||||||
|
let today = current_utc_date_string();
|
||||||
|
for stock in &mut stocks {
|
||||||
|
stock.updated_at = Some(today.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
let meta = UniverseMeta {
|
||||||
|
source: "KIND".to_string(),
|
||||||
|
source_url: KIND_UNIVERSE_URL.to_string(),
|
||||||
|
date: today.clone(),
|
||||||
|
refreshed_at: current_utc_datetime_string(),
|
||||||
|
stock_count: stocks.len(),
|
||||||
|
counts_by_market: counts_by_market(&stocks),
|
||||||
|
};
|
||||||
|
|
||||||
|
write_cache(&stocks, &meta)?;
|
||||||
|
|
||||||
|
Ok(UniverseSnapshot {
|
||||||
|
stocks,
|
||||||
|
meta,
|
||||||
|
refreshed: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_cached() -> Result<Option<UniverseSnapshot>, String> {
|
||||||
|
load_any_cache()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn prune_cache(dry_run: bool) -> Result<crate::core::cache::CachePruneReport, String> {
|
||||||
|
crate::core::cache::prune_json_snapshots(
|
||||||
|
"universe/kind",
|
||||||
|
&cache_dir(),
|
||||||
|
&[LATEST_FILE, META_FILE],
|
||||||
|
SNAPSHOT_RETENTION_FILES,
|
||||||
|
SNAPSHOT_RETENTION_BYTES,
|
||||||
|
dry_run,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn chunk_stocks(stocks: &[Stock], size: usize) -> Vec<serde_json::Value> {
|
||||||
|
let size = size.max(1);
|
||||||
|
let mut groups = BTreeMap::<(StockMarket, StockKind), Vec<&Stock>>::new();
|
||||||
|
for stock in stocks {
|
||||||
|
groups
|
||||||
|
.entry((stock.market, stock.kind))
|
||||||
|
.or_default()
|
||||||
|
.push(stock);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut chunks = Vec::new();
|
||||||
|
for ((market, kind), group) in groups {
|
||||||
|
for (index, chunk) in group.chunks(size).enumerate() {
|
||||||
|
let first = chunk.first().copied();
|
||||||
|
let last = chunk.last().copied();
|
||||||
|
chunks.push(serde_json::json!({
|
||||||
|
"chunk_id": format!("{}:{}:{:04}", market.as_str(), kind.as_str(), index + 1),
|
||||||
|
"index": index + 1,
|
||||||
|
"size": size,
|
||||||
|
"count": chunk.len(),
|
||||||
|
"start_symbol": first.map(|stock| stock.id.symbol.as_str()),
|
||||||
|
"end_symbol": last.map(|stock| stock.id.symbol.as_str()),
|
||||||
|
"market": market.as_str(),
|
||||||
|
"kind": kind.as_str(),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
chunks
|
||||||
|
}
|
||||||
|
|
||||||
|
fn download() -> Result<Vec<u8>, String> {
|
||||||
|
let response = crate::core::http::agent()
|
||||||
|
.get(KIND_UNIVERSE_URL)
|
||||||
|
.header("User-Agent", "openstock/0.1")
|
||||||
|
.call()
|
||||||
|
.map_err(|err| format!("[KIND] 상장법인목록 다운로드 실패: {}", err))?;
|
||||||
|
let status = response.status();
|
||||||
|
let body = response
|
||||||
|
.into_body()
|
||||||
|
.read_to_vec()
|
||||||
|
.map_err(|err| format!("[KIND] 상장법인목록 응답 읽기 실패: {}", err))?;
|
||||||
|
|
||||||
|
if !status.is_success() {
|
||||||
|
return Err(format!(
|
||||||
|
"[KIND] 상장법인목록 다운로드 오류 ({}): {} bytes",
|
||||||
|
status,
|
||||||
|
body.len()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_euc_kr(bytes: &[u8]) -> String {
|
||||||
|
let (decoded, _, _) = EUC_KR.decode(bytes);
|
||||||
|
decoded.into_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_kind_html(html: &str) -> Result<Vec<Stock>, String> {
|
||||||
|
let mut rows = Vec::new();
|
||||||
|
let mut rest = html;
|
||||||
|
|
||||||
|
while let Some(start) = find_ascii_case_insensitive(rest, "<tr") {
|
||||||
|
let row_start = &rest[start..];
|
||||||
|
let Some(open_end) = row_start.find('>') else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
let row_body_start = open_end + 1;
|
||||||
|
let Some(close) = find_ascii_case_insensitive(&row_start[row_body_start..], "</tr>") else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
let row_body = &row_start[row_body_start..row_body_start + close];
|
||||||
|
let cells = extract_cells(row_body);
|
||||||
|
if cells.len() >= 10 && cells[0] != "회사명" {
|
||||||
|
if let Some(stock) = row_to_stock(&cells) {
|
||||||
|
rows.push(stock);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rest = &row_start[row_body_start + close + "</tr>".len()..];
|
||||||
|
}
|
||||||
|
|
||||||
|
if rows.is_empty() {
|
||||||
|
return Err("[KIND] 상장법인목록에서 종목을 찾지 못했습니다.".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn row_to_stock(cells: &[String]) -> Option<Stock> {
|
||||||
|
let name = cells.first()?.trim();
|
||||||
|
let market = cells.get(1)?.trim();
|
||||||
|
let symbol = cells.get(2)?.trim();
|
||||||
|
let industry = empty_to_none(cells.get(3).map(String::as_str).unwrap_or(""));
|
||||||
|
let main_product = empty_to_none(cells.get(4).map(String::as_str).unwrap_or(""));
|
||||||
|
|
||||||
|
if name.is_empty() || validate_symbol(symbol).is_err() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(Stock {
|
||||||
|
id: StockId {
|
||||||
|
symbol: symbol.to_string(),
|
||||||
|
isin: None,
|
||||||
|
reuters_code: None,
|
||||||
|
},
|
||||||
|
name: name.to_string(),
|
||||||
|
market: normalize_kind_market(market),
|
||||||
|
kind: infer_stock_kind(name, industry.as_deref(), main_product.as_deref()),
|
||||||
|
industry,
|
||||||
|
sector: None,
|
||||||
|
main_product,
|
||||||
|
listed_at: empty_to_none(cells.get(5).map(String::as_str).unwrap_or("")),
|
||||||
|
fiscal_month: empty_to_none(cells.get(6).map(String::as_str).unwrap_or("")),
|
||||||
|
homepage: empty_to_none(cells.get(8).map(String::as_str).unwrap_or("")),
|
||||||
|
region: empty_to_none(cells.get(9).map(String::as_str).unwrap_or("")),
|
||||||
|
source: StockSource::Kind,
|
||||||
|
source_url: Some(KIND_UNIVERSE_URL.to_string()),
|
||||||
|
updated_at: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_cells(row: &str) -> Vec<String> {
|
||||||
|
let mut cells = Vec::new();
|
||||||
|
let mut rest = row;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let td = find_ascii_case_insensitive(rest, "<td");
|
||||||
|
let th = find_ascii_case_insensitive(rest, "<th");
|
||||||
|
let Some(start) = (match (td, th) {
|
||||||
|
(Some(td), Some(th)) => Some(td.min(th)),
|
||||||
|
(Some(td), None) => Some(td),
|
||||||
|
(None, Some(th)) => Some(th),
|
||||||
|
(None, None) => None,
|
||||||
|
}) else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
|
||||||
|
let cell_start = &rest[start..];
|
||||||
|
let Some(open_end) = cell_start.find('>') else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
let close_tag = if cell_start.to_ascii_lowercase().starts_with("<th") {
|
||||||
|
"</th>"
|
||||||
|
} else {
|
||||||
|
"</td>"
|
||||||
|
};
|
||||||
|
let body_start = open_end + 1;
|
||||||
|
let Some(close) = find_ascii_case_insensitive(&cell_start[body_start..], close_tag) else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
let body = &cell_start[body_start..body_start + close];
|
||||||
|
cells.push(clean_cell(body));
|
||||||
|
rest = &cell_start[body_start + close + close_tag.len()..];
|
||||||
|
}
|
||||||
|
|
||||||
|
cells
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clean_cell(value: &str) -> String {
|
||||||
|
let mut text = String::new();
|
||||||
|
let mut inside_tag = false;
|
||||||
|
|
||||||
|
for ch in value.chars() {
|
||||||
|
match ch {
|
||||||
|
'<' => inside_tag = true,
|
||||||
|
'>' => {
|
||||||
|
inside_tag = false;
|
||||||
|
text.push(' ');
|
||||||
|
}
|
||||||
|
_ if !inside_tag => text.push(ch),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
decode_entities(&text)
|
||||||
|
.split_whitespace()
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_entities(value: &str) -> String {
|
||||||
|
value
|
||||||
|
.replace("&", "&")
|
||||||
|
.replace("<", "<")
|
||||||
|
.replace(">", ">")
|
||||||
|
.replace(""", "\"")
|
||||||
|
.replace("'", "'")
|
||||||
|
.replace(" ", " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_ascii_case_insensitive(haystack: &str, needle: &str) -> Option<usize> {
|
||||||
|
haystack
|
||||||
|
.as_bytes()
|
||||||
|
.windows(needle.len())
|
||||||
|
.position(|window| window.eq_ignore_ascii_case(needle.as_bytes()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn empty_to_none(value: &str) -> Option<String> {
|
||||||
|
let value = value.trim();
|
||||||
|
if value.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(value.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_cache(stocks: &[Stock], meta: &UniverseMeta) -> Result<(), String> {
|
||||||
|
let dir = cache_dir();
|
||||||
|
fs::create_dir_all(&dir).map_err(|err| format!("[KIND] 캐시 디렉터리 생성 실패: {}", err))?;
|
||||||
|
write_json_atomic(&dir.join(LATEST_FILE), stocks)?;
|
||||||
|
write_json_atomic(&dir.join(META_FILE), meta)?;
|
||||||
|
write_json_atomic(&dir.join(format!("{}.json", meta.date)), stocks)?;
|
||||||
|
prune_cache(false)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_json_atomic<T: Serialize + ?Sized>(path: &Path, value: &T) -> Result<(), String> {
|
||||||
|
let tmp = path.with_extension("tmp");
|
||||||
|
let json = serde_json::to_string_pretty(value)
|
||||||
|
.map_err(|err| format!("[KIND] 캐시 JSON 직렬화 실패: {}", err))?;
|
||||||
|
fs::write(&tmp, json).map_err(|err| format!("[KIND] 캐시 임시 파일 쓰기 실패: {}", err))?;
|
||||||
|
fs::rename(&tmp, path).map_err(|err| format!("[KIND] 캐시 파일 교체 실패: {}", err))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_today_cache() -> Result<Option<UniverseSnapshot>, String> {
|
||||||
|
let Some(snapshot) = load_any_cache()? else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
if snapshot.meta.date == current_utc_date_string() {
|
||||||
|
return Ok(Some(snapshot));
|
||||||
|
}
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_any_cache() -> Result<Option<UniverseSnapshot>, String> {
|
||||||
|
let dir = cache_dir();
|
||||||
|
let latest_path = dir.join(LATEST_FILE);
|
||||||
|
let meta_path = dir.join(META_FILE);
|
||||||
|
if !latest_path.exists() || !meta_path.exists() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let stocks = read_json::<Vec<Stock>>(&latest_path)?;
|
||||||
|
let meta = read_json::<UniverseMeta>(&meta_path)?;
|
||||||
|
Ok(Some(UniverseSnapshot {
|
||||||
|
stocks,
|
||||||
|
meta,
|
||||||
|
refreshed: false,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_json<T: for<'de> Deserialize<'de>>(path: &Path) -> Result<T, String> {
|
||||||
|
let text = fs::read_to_string(path)
|
||||||
|
.map_err(|err| format!("[KIND] 캐시 파일 읽기 실패 ({}): {}", path.display(), err))?;
|
||||||
|
serde_json::from_str(&text)
|
||||||
|
.map_err(|err| format!("[KIND] 캐시 JSON 파싱 실패 ({}): {}", path.display(), err))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cache_dir() -> PathBuf {
|
||||||
|
PathBuf::from(CACHE_DIR)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn counts_by_market(stocks: &[Stock]) -> BTreeMap<String, usize> {
|
||||||
|
let mut counts = BTreeMap::new();
|
||||||
|
for stock in stocks {
|
||||||
|
*counts.entry(stock.market.as_str().to_string()).or_insert(0) += 1;
|
||||||
|
}
|
||||||
|
counts
|
||||||
|
}
|
||||||
|
|
||||||
|
fn current_utc_date_string() -> String {
|
||||||
|
let now_seconds = current_unix_seconds();
|
||||||
|
let days = now_seconds.div_euclid(86_400);
|
||||||
|
let (year, month, day) = civil_from_days(days);
|
||||||
|
format!("{:04}-{:02}-{:02}", year, month, day)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn current_utc_datetime_string() -> String {
|
||||||
|
let now_seconds = current_unix_seconds();
|
||||||
|
let days = now_seconds.div_euclid(86_400);
|
||||||
|
let seconds_of_day = now_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}T{:02}:{:02}:{:02}Z",
|
||||||
|
year, month, day, hour, minute, second
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn current_unix_seconds() -> i64 {
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|duration| duration.as_secs() as i64)
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::core::stock::{StockKind, StockMarket};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_kind_html_table() {
|
||||||
|
let html = r#"
|
||||||
|
<table>
|
||||||
|
<tr><th>회사명</th><th>시장구분</th><th>종목코드</th><th>업종</th><th>주요제품</th><th>상장일</th><th>결산월</th><th>대표자명</th><th>홈페이지</th><th>지역</th></tr>
|
||||||
|
<tr><td>삼성전자</td><td>유가</td><td>005930</td><td>통신 및 방송 장비 제조업</td><td>반도체</td><td>1975-06-11</td><td>12월</td><td>대표</td><td>https://www.samsung.com</td><td>경기도</td></tr>
|
||||||
|
<tr><td>삼성전자우</td><td>유가</td><td>005935</td><td>통신 및 방송 장비 제조업</td><td>반도체</td><td>1989-01-01</td><td>12월</td><td>대표</td><td></td><td>경기도</td></tr>
|
||||||
|
</table>
|
||||||
|
"#;
|
||||||
|
|
||||||
|
let stocks = parse_kind_html(html).unwrap();
|
||||||
|
assert_eq!(stocks.len(), 2);
|
||||||
|
assert_eq!(stocks[0].id.symbol, "005930");
|
||||||
|
assert_eq!(stocks[0].market, StockMarket::Kospi);
|
||||||
|
assert_eq!(stocks[0].kind, StockKind::CommonStock);
|
||||||
|
assert_eq!(stocks[1].kind, StockKind::PreferredStock);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1 +1,3 @@
|
|||||||
|
pub mod kind;
|
||||||
pub mod naver;
|
pub mod naver;
|
||||||
|
pub mod opendart;
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ pub fn search(query: &str) -> Result<String, String> {
|
|||||||
percent_encode(query)
|
percent_encode(query)
|
||||||
);
|
);
|
||||||
|
|
||||||
let response = ureq::get(&url)
|
let response = crate::core::http::agent()
|
||||||
|
.get(&url)
|
||||||
.header("User-Agent", "openstock/0.1")
|
.header("User-Agent", "openstock/0.1")
|
||||||
.call()
|
.call()
|
||||||
.map_err(|err| format!("[Naver] 종목 검색 요청 실패: {}", err))?;
|
.map_err(|err| format!("[Naver] 종목 검색 요청 실패: {}", err))?;
|
||||||
|
|||||||
@@ -0,0 +1,297 @@
|
|||||||
|
use crate::core::dotenv;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::fs;
|
||||||
|
use std::io::{Cursor, Read};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
const OPENDART_CORP_CODE_URL: &str = "https://opendart.fss.or.kr/api/corpCode.xml";
|
||||||
|
const CACHE_DIR: &str = ".openstock/opendart";
|
||||||
|
const CORP_CODES_FILE: &str = "corp_codes.json";
|
||||||
|
const META_FILE: &str = "corp_codes_meta.json";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct DartCorpCode {
|
||||||
|
pub corp_code: String,
|
||||||
|
pub corp_name: String,
|
||||||
|
pub stock_code: String,
|
||||||
|
pub modify_date: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct DartCorpCodeMeta {
|
||||||
|
pub source: String,
|
||||||
|
pub source_url: String,
|
||||||
|
pub date: String,
|
||||||
|
pub refreshed_at: String,
|
||||||
|
pub total_count: usize,
|
||||||
|
pub listed_count: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct DartCorpCodeSnapshot {
|
||||||
|
pub corps: Vec<DartCorpCode>,
|
||||||
|
pub meta: DartCorpCodeMeta,
|
||||||
|
pub refreshed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_or_refresh(force: bool) -> Result<DartCorpCodeSnapshot, String> {
|
||||||
|
if !force {
|
||||||
|
if let Some(snapshot) = load_today_cache()? {
|
||||||
|
return Ok(snapshot);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match refresh() {
|
||||||
|
Ok(snapshot) => Ok(snapshot),
|
||||||
|
Err(err) => {
|
||||||
|
if let Some(snapshot) = load_any_cache()? {
|
||||||
|
return Ok(DartCorpCodeSnapshot {
|
||||||
|
refreshed: false,
|
||||||
|
..snapshot
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_cached() -> Result<Option<DartCorpCodeSnapshot>, String> {
|
||||||
|
load_any_cache()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn refresh() -> Result<DartCorpCodeSnapshot, String> {
|
||||||
|
let key = api_key()?;
|
||||||
|
let url = format!("{}?crtfc_key={}", OPENDART_CORP_CODE_URL, key);
|
||||||
|
let response = crate::core::http::agent()
|
||||||
|
.get(&url)
|
||||||
|
.header("User-Agent", "openstock/0.1")
|
||||||
|
.call()
|
||||||
|
.map_err(|err| format!("[OpenDART] 고유번호 다운로드 실패: {}", err))?;
|
||||||
|
let status = response.status();
|
||||||
|
let body = response
|
||||||
|
.into_body()
|
||||||
|
.read_to_vec()
|
||||||
|
.map_err(|err| format!("[OpenDART] 고유번호 응답 읽기 실패: {}", err))?;
|
||||||
|
|
||||||
|
if !status.is_success() {
|
||||||
|
return Err(format!(
|
||||||
|
"[OpenDART] 고유번호 다운로드 오류 ({}): {} bytes",
|
||||||
|
status,
|
||||||
|
body.len()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let all_corps = parse_corp_code_zip(&body)?;
|
||||||
|
let total_count = all_corps.len();
|
||||||
|
let mut listed = all_corps
|
||||||
|
.into_iter()
|
||||||
|
.filter(|corp| !corp.stock_code.trim().is_empty())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
listed.sort_by(|a, b| {
|
||||||
|
a.stock_code
|
||||||
|
.cmp(&b.stock_code)
|
||||||
|
.then_with(|| a.corp_code.cmp(&b.corp_code))
|
||||||
|
});
|
||||||
|
|
||||||
|
let meta = DartCorpCodeMeta {
|
||||||
|
source: "OpenDART".to_string(),
|
||||||
|
source_url: OPENDART_CORP_CODE_URL.to_string(),
|
||||||
|
date: current_utc_date_string(),
|
||||||
|
refreshed_at: current_utc_datetime_string(),
|
||||||
|
total_count,
|
||||||
|
listed_count: listed.len(),
|
||||||
|
};
|
||||||
|
write_cache(&listed, &meta)?;
|
||||||
|
|
||||||
|
Ok(DartCorpCodeSnapshot {
|
||||||
|
corps: listed,
|
||||||
|
meta,
|
||||||
|
refreshed: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn find_by_stock_code<'a>(corps: &'a [DartCorpCode], symbol: &str) -> Option<&'a DartCorpCode> {
|
||||||
|
corps.iter().find(|corp| corp.stock_code == symbol)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn api_key() -> Result<String, String> {
|
||||||
|
dotenv::read_env(Path::new(".env"))
|
||||||
|
.get("OPENDART_API_KEY")
|
||||||
|
.map(|value| value.trim().to_string())
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.ok_or("OPENDART_API_KEY가 .env에 없습니다.".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_corp_code_zip(bytes: &[u8]) -> Result<Vec<DartCorpCode>, String> {
|
||||||
|
let cursor = Cursor::new(bytes);
|
||||||
|
let mut archive = zip::ZipArchive::new(cursor).map_err(|err| {
|
||||||
|
let text = String::from_utf8_lossy(bytes);
|
||||||
|
format!(
|
||||||
|
"[OpenDART] 고유번호 ZIP 파싱 실패: {} / 응답 앞부분: {}",
|
||||||
|
err,
|
||||||
|
text.chars().take(200).collect::<String>()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let mut xml = String::new();
|
||||||
|
archive
|
||||||
|
.by_name("CORPCODE.xml")
|
||||||
|
.map_err(|err| format!("[OpenDART] CORPCODE.xml 파일을 찾지 못했습니다: {}", err))?
|
||||||
|
.read_to_string(&mut xml)
|
||||||
|
.map_err(|err| format!("[OpenDART] CORPCODE.xml 읽기 실패: {}", err))?;
|
||||||
|
parse_corps_xml(&xml)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_corps_xml(xml: &str) -> Result<Vec<DartCorpCode>, String> {
|
||||||
|
let mut corps = Vec::new();
|
||||||
|
let mut rest = xml;
|
||||||
|
|
||||||
|
while let Some(start) = rest.find("<list>") {
|
||||||
|
let item_start = start + "<list>".len();
|
||||||
|
let Some(end) = rest[item_start..].find("</list>") else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
let item = &rest[item_start..item_start + end];
|
||||||
|
let corp_code = extract_tag(item, "corp_code").unwrap_or_default();
|
||||||
|
let corp_name = extract_tag(item, "corp_name").unwrap_or_default();
|
||||||
|
if !corp_code.is_empty() && !corp_name.is_empty() {
|
||||||
|
corps.push(DartCorpCode {
|
||||||
|
corp_code,
|
||||||
|
corp_name,
|
||||||
|
stock_code: extract_tag(item, "stock_code").unwrap_or_default(),
|
||||||
|
modify_date: extract_tag(item, "modify_date").unwrap_or_default(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
rest = &rest[item_start + end + "</list>".len()..];
|
||||||
|
}
|
||||||
|
|
||||||
|
if corps.is_empty() {
|
||||||
|
return Err("[OpenDART] 고유번호 XML에서 회사를 찾지 못했습니다.".to_string());
|
||||||
|
}
|
||||||
|
Ok(corps)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_tag(xml: &str, tag: &str) -> Option<String> {
|
||||||
|
let open = format!("<{}>", tag);
|
||||||
|
let close = format!("</{}>", tag);
|
||||||
|
let start = xml.find(&open)? + open.len();
|
||||||
|
let end = xml[start..].find(&close)?;
|
||||||
|
Some(decode_xml_entities(xml[start..start + end].trim()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_xml_entities(value: &str) -> String {
|
||||||
|
value
|
||||||
|
.replace("<", "<")
|
||||||
|
.replace(">", ">")
|
||||||
|
.replace("&", "&")
|
||||||
|
.replace(""", "\"")
|
||||||
|
.replace("'", "'")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_cache(corps: &[DartCorpCode], meta: &DartCorpCodeMeta) -> Result<(), String> {
|
||||||
|
let dir = cache_dir();
|
||||||
|
fs::create_dir_all(&dir)
|
||||||
|
.map_err(|err| format!("[OpenDART] 캐시 디렉터리 생성 실패: {}", err))?;
|
||||||
|
write_json_atomic(&dir.join(CORP_CODES_FILE), corps)?;
|
||||||
|
write_json_atomic(&dir.join(META_FILE), meta)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_json_atomic<T: Serialize + ?Sized>(path: &Path, value: &T) -> Result<(), String> {
|
||||||
|
let tmp = path.with_extension("tmp");
|
||||||
|
let json = serde_json::to_string_pretty(value)
|
||||||
|
.map_err(|err| format!("[OpenDART] 캐시 JSON 직렬화 실패: {}", err))?;
|
||||||
|
fs::write(&tmp, json).map_err(|err| format!("[OpenDART] 캐시 임시 파일 쓰기 실패: {}", err))?;
|
||||||
|
fs::rename(&tmp, path).map_err(|err| format!("[OpenDART] 캐시 파일 교체 실패: {}", err))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_today_cache() -> Result<Option<DartCorpCodeSnapshot>, String> {
|
||||||
|
let Some(snapshot) = load_any_cache()? else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
if snapshot.meta.date == current_utc_date_string() {
|
||||||
|
return Ok(Some(snapshot));
|
||||||
|
}
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_any_cache() -> Result<Option<DartCorpCodeSnapshot>, String> {
|
||||||
|
let dir = cache_dir();
|
||||||
|
let corps_path = dir.join(CORP_CODES_FILE);
|
||||||
|
let meta_path = dir.join(META_FILE);
|
||||||
|
if !corps_path.exists() || !meta_path.exists() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Some(DartCorpCodeSnapshot {
|
||||||
|
corps: read_json(&corps_path)?,
|
||||||
|
meta: read_json(&meta_path)?,
|
||||||
|
refreshed: false,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_json<T: for<'de> Deserialize<'de>>(path: &Path) -> Result<T, String> {
|
||||||
|
let text = fs::read_to_string(path).map_err(|err| {
|
||||||
|
format!(
|
||||||
|
"[OpenDART] 캐시 파일 읽기 실패 ({}): {}",
|
||||||
|
path.display(),
|
||||||
|
err
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
serde_json::from_str(&text).map_err(|err| {
|
||||||
|
format!(
|
||||||
|
"[OpenDART] 캐시 JSON 파싱 실패 ({}): {}",
|
||||||
|
path.display(),
|
||||||
|
err
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cache_dir() -> PathBuf {
|
||||||
|
PathBuf::from(CACHE_DIR)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn current_utc_date_string() -> String {
|
||||||
|
let now_seconds = current_unix_seconds();
|
||||||
|
let days = now_seconds.div_euclid(86_400);
|
||||||
|
let (year, month, day) = civil_from_days(days);
|
||||||
|
format!("{:04}-{:02}-{:02}", year, month, day)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn current_utc_datetime_string() -> String {
|
||||||
|
let now_seconds = current_unix_seconds();
|
||||||
|
let days = now_seconds.div_euclid(86_400);
|
||||||
|
let seconds_of_day = now_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}T{:02}:{:02}:{:02}Z",
|
||||||
|
year, month, day, hour, minute, second
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn current_unix_seconds() -> i64 {
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|duration| duration.as_secs() as i64)
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
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,131 @@
|
|||||||
|
use crate::core::dotenv;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
const OPENDART_LIST_URL: &str = "https://opendart.fss.or.kr/api/list.json";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct DisclosureQuery {
|
||||||
|
pub corp_code: Option<String>,
|
||||||
|
pub bgn_de: Option<String>,
|
||||||
|
pub end_de: Option<String>,
|
||||||
|
pub corp_cls: Option<String>,
|
||||||
|
pub page_no: u32,
|
||||||
|
pub page_count: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn list(query: &DisclosureQuery) -> Result<serde_json::Value, String> {
|
||||||
|
let key = api_key()?;
|
||||||
|
validate_query(query)?;
|
||||||
|
|
||||||
|
let mut params = vec![
|
||||||
|
("crtfc_key", key),
|
||||||
|
("page_no", query.page_no.to_string()),
|
||||||
|
("page_count", query.page_count.to_string()),
|
||||||
|
];
|
||||||
|
push_optional(&mut params, "corp_code", query.corp_code.as_deref());
|
||||||
|
push_optional(&mut params, "bgn_de", query.bgn_de.as_deref());
|
||||||
|
push_optional(&mut params, "end_de", query.end_de.as_deref());
|
||||||
|
push_optional(&mut params, "corp_cls", query.corp_cls.as_deref());
|
||||||
|
|
||||||
|
let url = build_url(OPENDART_LIST_URL, ¶ms);
|
||||||
|
let response = crate::core::http::agent()
|
||||||
|
.get(&url)
|
||||||
|
.header("User-Agent", "openstock/0.1")
|
||||||
|
.call()
|
||||||
|
.map_err(|err| format!("[OpenDART] 공시목록 요청 실패: {}", err))?;
|
||||||
|
let status = response.status();
|
||||||
|
let body = response
|
||||||
|
.into_body()
|
||||||
|
.read_to_string()
|
||||||
|
.map_err(|err| format!("[OpenDART] 공시목록 응답 읽기 실패: {}", err))?;
|
||||||
|
|
||||||
|
if !status.is_success() {
|
||||||
|
return Err(format!(
|
||||||
|
"[OpenDART] 공시목록 요청 오류 ({}): {}",
|
||||||
|
status, body
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload = serde_json::from_str::<serde_json::Value>(&body)
|
||||||
|
.map_err(|err| format!("[OpenDART] 공시목록 응답 파싱 실패: {}", err))?;
|
||||||
|
ensure_success(&payload)?;
|
||||||
|
Ok(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn api_key() -> Result<String, String> {
|
||||||
|
dotenv::read_env(Path::new(".env"))
|
||||||
|
.get("OPENDART_API_KEY")
|
||||||
|
.map(|value| value.trim().to_string())
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.ok_or("OPENDART_API_KEY가 .env에 없습니다.".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_query(query: &DisclosureQuery) -> Result<(), String> {
|
||||||
|
if let Some(value) = &query.bgn_de {
|
||||||
|
validate_date(value, "--from")?;
|
||||||
|
}
|
||||||
|
if let Some(value) = &query.end_de {
|
||||||
|
validate_date(value, "--to")?;
|
||||||
|
}
|
||||||
|
if query.page_no == 0 {
|
||||||
|
return Err("--page-no는 1 이상이어야 합니다.".to_string());
|
||||||
|
}
|
||||||
|
if query.page_count == 0 || query.page_count > 100 {
|
||||||
|
return Err("--page-count는 1 이상 100 이하이어야 합니다.".to_string());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
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 push_optional(params: &mut Vec<(&str, String)>, key: &'static str, value: Option<&str>) {
|
||||||
|
if let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) {
|
||||||
|
params.push((key, value.to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_url(base: &str, params: &[(&str, String)]) -> String {
|
||||||
|
let query = params
|
||||||
|
.iter()
|
||||||
|
.map(|(key, value)| format!("{}={}", percent_encode(key), percent_encode(value)))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("&");
|
||||||
|
format!("{}?{}", base, 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
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_success(payload: &serde_json::Value) -> Result<(), String> {
|
||||||
|
let status = payload
|
||||||
|
.get("status")
|
||||||
|
.and_then(|value| value.as_str())
|
||||||
|
.unwrap_or("000");
|
||||||
|
if status == "000" || status == "013" {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let message = payload
|
||||||
|
.get("message")
|
||||||
|
.and_then(|value| value.as_str())
|
||||||
|
.unwrap_or("OpenDART API 실패");
|
||||||
|
Err(format!(
|
||||||
|
"[OpenDART] 공시목록 조회 실패 ({}): {}",
|
||||||
|
status, message
|
||||||
|
))
|
||||||
|
}
|
||||||
@@ -0,0 +1,366 @@
|
|||||||
|
use crate::core::dotenv;
|
||||||
|
use encoding_rs::EUC_KR;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::fs;
|
||||||
|
use std::io::{Cursor, Read};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
const OPENDART_DOCUMENT_URL: &str = "https://opendart.fss.or.kr/api/document.xml";
|
||||||
|
const CACHE_DIR: &str = ".openstock/opendart/documents";
|
||||||
|
const DOCUMENT_RETENTION_FILES: usize = 100;
|
||||||
|
const DOCUMENT_RETENTION_BYTES: u64 = 200 * 1024 * 1024;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct DartDocumentOptions {
|
||||||
|
pub rcept_no: String,
|
||||||
|
pub force: bool,
|
||||||
|
pub max_chars: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct DartDocument {
|
||||||
|
pub rcept_no: String,
|
||||||
|
pub source: String,
|
||||||
|
pub source_url: String,
|
||||||
|
pub viewer_url: String,
|
||||||
|
pub zip_path: String,
|
||||||
|
pub cached: bool,
|
||||||
|
pub zip_bytes: u64,
|
||||||
|
pub files: Vec<DartDocumentFile>,
|
||||||
|
pub text: String,
|
||||||
|
pub text_chars: usize,
|
||||||
|
pub truncated: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct DartDocumentFile {
|
||||||
|
pub name: String,
|
||||||
|
pub bytes: u64,
|
||||||
|
pub text_chars: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get(options: &DartDocumentOptions) -> Result<DartDocument, String> {
|
||||||
|
validate_rcept_no(&options.rcept_no)?;
|
||||||
|
let max_chars = options.max_chars.max(1);
|
||||||
|
let zip_path = cache_path(&options.rcept_no);
|
||||||
|
|
||||||
|
let (bytes, cached) = if !options.force && zip_path.exists() {
|
||||||
|
(
|
||||||
|
fs::read(&zip_path).map_err(|err| {
|
||||||
|
format!(
|
||||||
|
"[OpenDART] 공시서류 캐시 읽기 실패 ({}): {}",
|
||||||
|
zip_path.display(),
|
||||||
|
err
|
||||||
|
)
|
||||||
|
})?,
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
let bytes = download(&options.rcept_no)?;
|
||||||
|
write_cache(&zip_path, &bytes)?;
|
||||||
|
prune_cache(false)?;
|
||||||
|
(bytes, false)
|
||||||
|
};
|
||||||
|
|
||||||
|
let parsed = parse_document_zip(&bytes, max_chars)?;
|
||||||
|
let zip_bytes = bytes.len() as u64;
|
||||||
|
|
||||||
|
Ok(DartDocument {
|
||||||
|
rcept_no: options.rcept_no.clone(),
|
||||||
|
source: "OpenDART".to_string(),
|
||||||
|
source_url: OPENDART_DOCUMENT_URL.to_string(),
|
||||||
|
viewer_url: format!(
|
||||||
|
"https://dart.fss.or.kr/dsaf001/main.do?rcpNo={}",
|
||||||
|
options.rcept_no
|
||||||
|
),
|
||||||
|
zip_path: zip_path.display().to_string(),
|
||||||
|
cached,
|
||||||
|
zip_bytes,
|
||||||
|
files: parsed.files,
|
||||||
|
text: parsed.text,
|
||||||
|
text_chars: parsed.text_chars,
|
||||||
|
truncated: parsed.truncated,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn prune_cache(dry_run: bool) -> Result<crate::core::cache::CachePruneReport, String> {
|
||||||
|
crate::core::cache::prune_snapshots(
|
||||||
|
"opendart/documents",
|
||||||
|
&cache_dir(),
|
||||||
|
&[],
|
||||||
|
&["zip"],
|
||||||
|
DOCUMENT_RETENTION_FILES,
|
||||||
|
DOCUMENT_RETENTION_BYTES,
|
||||||
|
dry_run,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn download(rcept_no: &str) -> Result<Vec<u8>, String> {
|
||||||
|
let key = api_key()?;
|
||||||
|
let url = format!(
|
||||||
|
"{}?crtfc_key={}&rcept_no={}",
|
||||||
|
OPENDART_DOCUMENT_URL,
|
||||||
|
percent_encode(&key),
|
||||||
|
percent_encode(rcept_no)
|
||||||
|
);
|
||||||
|
let response = crate::core::http::agent()
|
||||||
|
.get(&url)
|
||||||
|
.header("User-Agent", "openstock/0.1")
|
||||||
|
.call()
|
||||||
|
.map_err(|err| format!("[OpenDART] 공시서류 원본파일 요청 실패: {}", err))?;
|
||||||
|
let status = response.status();
|
||||||
|
let body = response
|
||||||
|
.into_body()
|
||||||
|
.read_to_vec()
|
||||||
|
.map_err(|err| format!("[OpenDART] 공시서류 원본파일 응답 읽기 실패: {}", err))?;
|
||||||
|
|
||||||
|
if !status.is_success() {
|
||||||
|
return Err(format!(
|
||||||
|
"[OpenDART] 공시서류 원본파일 요청 오류 ({}): {} bytes",
|
||||||
|
status,
|
||||||
|
body.len()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ParsedDocument {
|
||||||
|
files: Vec<DartDocumentFile>,
|
||||||
|
text: String,
|
||||||
|
text_chars: usize,
|
||||||
|
truncated: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_document_zip(bytes: &[u8], max_chars: usize) -> Result<ParsedDocument, String> {
|
||||||
|
let cursor = Cursor::new(bytes);
|
||||||
|
let mut archive = zip::ZipArchive::new(cursor).map_err(|err| {
|
||||||
|
let text = decode_bytes(bytes);
|
||||||
|
format!(
|
||||||
|
"[OpenDART] 공시서류 ZIP 파싱 실패: {} / 응답 앞부분: {}",
|
||||||
|
err,
|
||||||
|
text.chars().take(200).collect::<String>()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let mut files = Vec::new();
|
||||||
|
let mut collected = String::new();
|
||||||
|
let mut truncated = false;
|
||||||
|
|
||||||
|
for index in 0..archive.len() {
|
||||||
|
let mut file = archive
|
||||||
|
.by_index(index)
|
||||||
|
.map_err(|err| format!("[OpenDART] 공시서류 ZIP 항목 읽기 실패: {}", err))?;
|
||||||
|
if file.is_dir() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let name = file.name().to_string();
|
||||||
|
let bytes = file.size();
|
||||||
|
let mut file_bytes = Vec::new();
|
||||||
|
file.read_to_end(&mut file_bytes)
|
||||||
|
.map_err(|err| format!("[OpenDART] 공시서류 파일 내용 읽기 실패: {}", err))?;
|
||||||
|
let text = if looks_like_xml(&name, &file_bytes) {
|
||||||
|
xml_to_text(&decode_bytes(&file_bytes))
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
let text_chars = text.chars().count();
|
||||||
|
if !text.is_empty() && !truncated {
|
||||||
|
append_limited(&mut collected, &text, max_chars, &mut truncated);
|
||||||
|
}
|
||||||
|
files.push(DartDocumentFile {
|
||||||
|
name,
|
||||||
|
bytes,
|
||||||
|
text_chars,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if files.is_empty() {
|
||||||
|
return Err("[OpenDART] 공시서류 ZIP에 파일이 없습니다.".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(ParsedDocument {
|
||||||
|
files,
|
||||||
|
text_chars: collected.chars().count(),
|
||||||
|
text: collected,
|
||||||
|
truncated,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn append_limited(target: &mut String, text: &str, max_chars: usize, truncated: &mut bool) {
|
||||||
|
let remaining = max_chars.saturating_sub(target.chars().count());
|
||||||
|
if remaining == 0 {
|
||||||
|
*truncated = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let mut iter = text.chars();
|
||||||
|
for ch in iter.by_ref().take(remaining) {
|
||||||
|
target.push(ch);
|
||||||
|
}
|
||||||
|
if iter.next().is_some() {
|
||||||
|
*truncated = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn xml_to_text(xml: &str) -> String {
|
||||||
|
let xml = remove_element_blocks(xml, "style");
|
||||||
|
let xml = remove_element_blocks(&xml, "script");
|
||||||
|
let mut text = String::new();
|
||||||
|
let mut inside_tag = false;
|
||||||
|
for ch in xml.chars() {
|
||||||
|
match ch {
|
||||||
|
'<' => {
|
||||||
|
inside_tag = true;
|
||||||
|
text.push(' ');
|
||||||
|
}
|
||||||
|
'>' => {
|
||||||
|
inside_tag = false;
|
||||||
|
text.push(' ');
|
||||||
|
}
|
||||||
|
_ if !inside_tag => text.push(ch),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
decode_xml_entities(&text)
|
||||||
|
.split_whitespace()
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remove_element_blocks(xml: &str, tag: &str) -> String {
|
||||||
|
let mut output = String::new();
|
||||||
|
let mut rest = xml;
|
||||||
|
let open_pattern = format!("<{}", tag);
|
||||||
|
let close_pattern = format!("</{}>", tag);
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let Some(open_start) = find_ascii_case_insensitive(rest, &open_pattern) else {
|
||||||
|
output.push_str(rest);
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
output.push_str(&rest[..open_start]);
|
||||||
|
let after_open = &rest[open_start..];
|
||||||
|
let Some(close_start) = find_ascii_case_insensitive(after_open, &close_pattern) else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
let after_close = close_start + close_pattern.len();
|
||||||
|
rest = &after_open[after_close..];
|
||||||
|
}
|
||||||
|
|
||||||
|
output
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_ascii_case_insensitive(haystack: &str, needle: &str) -> Option<usize> {
|
||||||
|
haystack
|
||||||
|
.as_bytes()
|
||||||
|
.windows(needle.len())
|
||||||
|
.position(|window| window.eq_ignore_ascii_case(needle.as_bytes()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_bytes(bytes: &[u8]) -> String {
|
||||||
|
match std::str::from_utf8(bytes) {
|
||||||
|
Ok(value) => value.to_string(),
|
||||||
|
Err(_) => {
|
||||||
|
let (decoded, _, _) = EUC_KR.decode(bytes);
|
||||||
|
decoded.into_owned()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn looks_like_xml(name: &str, bytes: &[u8]) -> bool {
|
||||||
|
name.to_ascii_lowercase().ends_with(".xml")
|
||||||
|
|| decode_bytes(&bytes[..bytes.len().min(100)])
|
||||||
|
.trim_start()
|
||||||
|
.starts_with('<')
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_xml_entities(value: &str) -> String {
|
||||||
|
value
|
||||||
|
.replace("<", "<")
|
||||||
|
.replace(">", ">")
|
||||||
|
.replace("&", "&")
|
||||||
|
.replace(""", "\"")
|
||||||
|
.replace("'", "'")
|
||||||
|
.replace("'", "'")
|
||||||
|
.replace(" ", " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_cache(path: &Path, bytes: &[u8]) -> Result<(), String> {
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent)
|
||||||
|
.map_err(|err| format!("[OpenDART] 공시서류 캐시 디렉터리 생성 실패: {}", err))?;
|
||||||
|
}
|
||||||
|
let tmp = path.with_extension("tmp");
|
||||||
|
fs::write(&tmp, bytes).map_err(|err| format!("[OpenDART] 공시서류 캐시 쓰기 실패: {}", err))?;
|
||||||
|
fs::rename(&tmp, path).map_err(|err| format!("[OpenDART] 공시서류 캐시 교체 실패: {}", err))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cache_path(rcept_no: &str) -> PathBuf {
|
||||||
|
cache_dir().join(format!("{}.zip", rcept_no))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cache_dir() -> PathBuf {
|
||||||
|
PathBuf::from(CACHE_DIR)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn api_key() -> Result<String, String> {
|
||||||
|
dotenv::read_env(Path::new(".env"))
|
||||||
|
.get("OPENDART_API_KEY")
|
||||||
|
.map(|value| value.trim().to_string())
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.ok_or("OPENDART_API_KEY가 .env에 없습니다.".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_rcept_no(value: &str) -> Result<(), String> {
|
||||||
|
if value.len() == 14 && value.chars().all(|ch| ch.is_ascii_digit()) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
Err("rcept_no는 14자리 숫자 접수번호여야 합니다.".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validates_receipt_number() {
|
||||||
|
assert!(validate_rcept_no("20260608800918").is_ok());
|
||||||
|
assert!(validate_rcept_no("2026060880091").is_err());
|
||||||
|
assert!(validate_rcept_no("2026060880091A").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extracts_readable_text_from_xml() {
|
||||||
|
let xml = r#"<DOCUMENT><STYLE>.xforms { color: red; }</STYLE><TITLE>사업보고서</TITLE><BODY>매출 & 이익 <TABLE><TR><TD>100</TD></TR></TABLE></BODY></DOCUMENT>"#;
|
||||||
|
let text = xml_to_text(xml);
|
||||||
|
assert!(text.contains("사업보고서"));
|
||||||
|
assert!(text.contains("매출 & 이익"));
|
||||||
|
assert!(text.contains("100"));
|
||||||
|
assert!(!text.contains("xforms"));
|
||||||
|
assert!(!text.contains("<TITLE>"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn appends_text_with_character_limit() {
|
||||||
|
let mut target = String::new();
|
||||||
|
let mut truncated = false;
|
||||||
|
append_limited(&mut target, "가나다라마", 3, &mut truncated);
|
||||||
|
assert_eq!(target, "가나다");
|
||||||
|
assert!(truncated);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
pub mod corp_codes;
|
||||||
|
pub mod disclosures;
|
||||||
|
pub mod document;
|
||||||
Reference in New Issue
Block a user