Add market data universe and DART evidence workflows

This commit is contained in:
2026-06-09 04:47:37 +00:00
parent b86c34760d
commit bb983cf246
29 changed files with 4057 additions and 114 deletions
+4
View File
@@ -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
View File
@@ -1,2 +1,3 @@
/target/ /target/
.env .env
.openstock/
Generated
+488 -99
View File
@@ -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
View File
@@ -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"] }
+2 -1
View File
@@ -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)
+2 -1
View File
@@ -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))?;
+12
View File
@@ -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()
} }
+166
View File
@@ -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, &params)?;
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");
}
}
+2 -1
View File
@@ -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
View File
@@ -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.",
+121
View File
@@ -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)?,
])
}
+642
View File
@@ -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
View File
@@ -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)
),
}
}
+6
View File
@@ -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;
+604
View File
@@ -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()
}
+274
View File
@@ -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)
}
+10
View File
@@ -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)
}
+3
View File
@@ -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;
+265
View File
@@ -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
);
}
}
+10
View File
@@ -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
View File
@@ -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!(
"{}", "{}",
+1
View File
@@ -0,0 +1 @@
pub mod universe;
+445
View File
@@ -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("&amp;", "&")
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&quot;", "\"")
.replace("&#39;", "'")
.replace("&nbsp;", " ")
}
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);
}
}
+2
View File
@@ -1 +1,3 @@
pub mod kind;
pub mod naver; pub mod naver;
pub mod opendart;
+2 -1
View File
@@ -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))?;
+297
View File
@@ -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("&lt;", "<")
.replace("&gt;", ">")
.replace("&amp;", "&")
.replace("&quot;", "\"")
.replace("&apos;", "'")
}
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)
}
+131
View File
@@ -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, &params);
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
))
}
+366
View File
@@ -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("&lt;", "<")
.replace("&gt;", ">")
.replace("&amp;", "&")
.replace("&quot;", "\"")
.replace("&apos;", "'")
.replace("&#39;", "'")
.replace("&nbsp;", " ")
}
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>매출 &amp; 이익 <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);
}
}
+3
View File
@@ -0,0 +1,3 @@
pub mod corp_codes;
pub mod disclosures;
pub mod document;