diff --git a/.env.example b/.env.example index 9839246..7b089ca 100644 --- a/.env.example +++ b/.env.example @@ -17,3 +17,7 @@ KIS_APPSECRET=your_appsecret_here # KIS_TOKEN_TYPE= # KIS_ACCESS_TOKEN_EXPIRED_AT= # KIS_ACCESS_TOKEN_EXPIRES_IN= + +# OpenDART 공시정보 API 키 +# https://opendart.fss.or.kr/ 에서 발급 +OPENDART_API_KEY=your_opendart_api_key_here diff --git a/.gitignore b/.gitignore index 5dc70ab..aef89ee 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ /target/ -.env \ No newline at end of file +.env +.openstock/ diff --git a/Cargo.lock b/Cargo.lock index c210445..132d327 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -44,7 +44,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.61.2", + "windows-sys", ] [[package]] @@ -55,7 +55,22 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "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]] @@ -64,6 +79,24 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "bytes" version = "1.11.1" @@ -161,6 +194,22 @@ dependencies = [ "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]] name = "crc32fast" version = "1.5.0" @@ -170,6 +219,22 @@ dependencies = [ "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]] name = "deranged" version = "0.5.8" @@ -179,6 +244,17 @@ dependencies = [ "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]] name = "displaydoc" version = "0.2.6" @@ -199,12 +275,37 @@ dependencies = [ "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]] name = "equivalent" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "find-msvc-tools" version = "0.1.9" @@ -221,6 +322,27 @@ dependencies = [ "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]] name = "form_urlencoded" version = "1.2.2" @@ -232,13 +354,24 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.2.17" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", "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]] @@ -351,6 +484,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "idna" version = "1.1.0" @@ -379,7 +518,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.17.1", + "serde", + "serde_core", ] [[package]] @@ -394,12 +535,24 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "libc" version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litemap" version = "0.8.2" @@ -434,6 +587,23 @@ dependencies = [ "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]] name = "num-conv" version = "0.2.2" @@ -452,13 +622,68 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "openstock" version = "0.1.0" dependencies = [ "clap", + "encoding_rs", + "serde", "serde_json", "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]] @@ -467,6 +692,12 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + [[package]] name = "potential_utf" version = "0.1.5" @@ -482,6 +713,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "proc-macro2" version = "1.0.106" @@ -501,32 +742,22 @@ dependencies = [ ] [[package]] -name = "ring" -version = "0.17.14" +name = "r-efi" +version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" -dependencies = [ - "cc", - "cfg-if", - "getrandom", - "libc", - "untrusted", - "windows-sys 0.52.0", -] +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] -name = "rustls" -version = "0.23.40" +name = "rustix" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "log", - "once_cell", - "ring", - "rustls-pki-types", - "rustls-webpki", - "subtle", - "zeroize", + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", ] [[package]] @@ -539,16 +770,43 @@ dependencies = [ ] [[package]] -name = "rustls-webpki" -version = "0.103.13" +name = "schannel" +version = "0.1.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" dependencies = [ - "ring", - "rustls-pki-types", - "untrusted", + "windows-sys", ] +[[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]] name = "serde" version = "1.0.228" @@ -556,6 +814,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" dependencies = [ "serde_core", + "serde_derive", ] [[package]] @@ -621,12 +880,6 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" -[[package]] -name = "subtle" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" - [[package]] name = "syn" version = "2.0.117" @@ -649,6 +902,39 @@ dependencies = [ "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]] name = "time" version = "0.3.47" @@ -697,10 +983,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] -name = "untrusted" -version = "0.9.0" +name = "unicode-xid" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" [[package]] name = "ureq" @@ -710,16 +996,17 @@ checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" dependencies = [ "base64", "cookie_store", + "der", "flate2", "log", + "native-tls", "percent-encoding", - "rustls", "rustls-pki-types", "serde", "serde_json", "ureq-proto", "utf8-zero", - "webpki-roots", + "webpki-root-certs", ] [[package]] @@ -764,6 +1051,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.5" @@ -771,16 +1064,62 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" +name = "wasip2" +version = "1.0.3+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] [[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" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c" dependencies = [ "rustls-pki-types", ] @@ -791,15 +1130,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" -[[package]] -name = "windows-sys" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" -dependencies = [ - "windows-targets", -] - [[package]] name = "windows-sys" version = "0.61.2" @@ -810,68 +1140,98 @@ dependencies = [ ] [[package]] -name = "windows-targets" -version = "0.52.6" +name = "wit-bindgen" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "wit-bindgen-rust-macro", ] [[package]] -name = "windows_aarch64_gnullvm" -version = "0.52.6" +name = "wit-bindgen" +version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" [[package]] -name = "windows_aarch64_msvc" -version = "0.52.6" +name = "wit-bindgen-core" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] [[package]] -name = "windows_i686_gnu" -version = "0.52.6" +name = "wit-bindgen-rust" +version = "0.51.0" 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]] -name = "windows_i686_gnullvm" -version = "0.52.6" +name = "wit-bindgen-rust-macro" +version = "0.51.0" 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]] -name = "windows_i686_msvc" -version = "0.52.6" +name = "wit-component" +version = "0.244.0" 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]] -name = "windows_x86_64_gnu" -version = "0.52.6" +name = "wit-parser" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] [[package]] name = "writeable" @@ -962,8 +1322,37 @@ dependencies = [ "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]] name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" 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", +] diff --git a/Cargo.toml b/Cargo.toml index b76eafc..3042a65 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,5 +5,8 @@ edition = "2021" [dependencies] 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" +zip = { version = "2", default-features = false, features = ["deflate"] } diff --git a/src/apis/kis/call.rs b/src/apis/kis/call.rs index 539c9ef..a393051 100644 --- a/src/apis/kis/call.rs +++ b/src/apis/kis/call.rs @@ -27,7 +27,8 @@ pub fn call(api: &KisApi, endpoint: &str, params: &[(&str, &str)]) -> Result>(); 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("authorization", &format!("Bearer {}", token)) .header("appkey", appkey) diff --git a/src/apis/kis/login.rs b/src/apis/kis/login.rs index 477eee8..13a0813 100644 --- a/src/apis/kis/login.rs +++ b/src/apis/kis/login.rs @@ -53,7 +53,8 @@ pub fn login(args: &KisLoginArguments) -> Result { "appsecret": args.appsecret, }); - let response = ureq::post(&url) + let response = crate::core::http::agent() + .post(&url) .header("Content-Type", "application/json; charset=UTF-8") .send_json(&request_body) .map_err(|err| format!("[KIS] 접근토큰 발급 요청 실패: {}", err))?; diff --git a/src/apis/kis/main.rs b/src/apis/kis/main.rs index 240eefd..836f665 100644 --- a/src/apis/kis/main.rs +++ b/src/apis/kis/main.rs @@ -5,6 +5,7 @@ pub mod call; pub mod info; pub mod login; pub mod market; +pub mod market_history; pub mod order_common; pub mod order_status; pub mod sell; @@ -88,6 +89,17 @@ impl TraderApi for KisApi { market::market(self, symbol) } + fn market_history( + &self, + symbol: &str, + start_date: &str, + end_date: &str, + period: &str, + adjusted: bool, + ) -> Result { + market_history::market_history(self, symbol, start_date, end_date, period, adjusted) + } + fn info(&self) -> Vec<(&'static str, &'static str)> { info::info() } diff --git a/src/apis/kis/market_history.rs b/src/apis/kis/market_history.rs new file mode 100644 index 0000000..5926cbc --- /dev/null +++ b/src/apis/kis/market_history.rs @@ -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 { + let symbol = symbol.trim(); + let start_date = start_date.trim(); + let end_date = end_date.trim(); + let period = normalize_period(period)?; + validate_symbol(symbol)?; + validate_date(start_date, "--from")?; + validate_date(end_date, "--to")?; + if start_date > end_date { + return Err("--from은 --to보다 늦을 수 없습니다.".to_string()); + } + + let adj_price_code = if adjusted { "0" } else { "1" }; + let params = [ + ("tr_id", "FHKST03010100"), + ("FID_COND_MRKT_DIV_CODE", "J"), + ("FID_INPUT_ISCD", symbol), + ("FID_INPUT_DATE_1", start_date), + ("FID_INPUT_DATE_2", end_date), + ("FID_PERIOD_DIV_CODE", period), + ("FID_ORG_ADJ_PRC", adj_price_code), + ]; + let response = api.call(HISTORY_ENDPOINT, ¶ms)?; + let value = serde_json::from_str::(&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::>(); + 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"); + } +} diff --git a/src/apis/kis/order_common.rs b/src/apis/kis/order_common.rs index 469e9eb..cbe40cb 100644 --- a/src/apis/kis/order_common.rs +++ b/src/apis/kis/order_common.rs @@ -85,7 +85,8 @@ fn post_order(api: &KisApi, tr_id: &str, body: &serde_json::Value) -> Result serde_json::Value { { "name": "KIS_APPKEY", "description": "KIS Open API application key used for authentication and every broker API request.", - "required_for": ["api login", "api call", "account status", "market", "order buy", "order sell", "order status"], + "required_for": ["api login", "api call", "account status", "market", "market history", "order buy", "order sell", "order status"], "source": ".env or api login --appkey" }, { "name": "KIS_APPSECRET", "description": "KIS Open API application secret used for authentication and every broker API request.", - "required_for": ["api login", "api call", "account status", "market", "order buy", "order sell", "order status"], + "required_for": ["api login", "api call", "account status", "market", "market history", "order buy", "order sell", "order status"], "source": ".env or api login --appsecret" }, { "name": "KIS_ACCESS_TOKEN", "description": "Bearer access token issued by api login. Read commands and order commands require it.", - "required_for": ["api call", "account status", "market", "order buy", "order sell", "order status"], + "required_for": ["api call", "account status", "market", "market history", "order buy", "order sell", "order status"], "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.", "side_effect": "none" }, + { + "command": "market history --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 --qty (--price |--market)", "purpose": "Submit a live domestic stock buy order.", diff --git a/src/commands/cache.rs b/src/commands/cache.rs new file mode 100644 index 0000000..2ec1b77 --- /dev/null +++ b/src/commands/cache.rs @@ -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::(); + let deleted_bytes = reports + .iter() + .map(|report| report.deleted_bytes) + .sum::(); + 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, String> { + Ok(vec![ + kind::universe::prune_cache(dry_run)?, + opendart::document::prune_cache(dry_run)?, + ]) +} diff --git a/src/commands/dart.rs b/src/commands/dart.rs new file mode 100644 index 0000000..fe4f909 --- /dev/null +++ b/src/commands/dart.rs @@ -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, + + /// DART 고유번호. symbol보다 우선 + #[arg(long)] + pub corp_code: Option, + + /// 검색 시작일 YYYYMMDD + #[arg(long = "from")] + pub from: Option, + + /// 검색 종료일 YYYYMMDD + #[arg(long = "to")] + pub to: Option, + + /// 법인구분: Y=유가, K=코스닥, N=코넥스, E=기타 + #[arg(long)] + pub corp_cls: Option, + + /// 페이지 번호 + #[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, + + /// 검색 종료일 YYYYMMDD + #[arg(long = "to")] + pub to: Option, + + /// 공시목록에서 열람할 항목 번호. 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, + }), + ) + ); +} diff --git a/src/commands/market.rs b/src/commands/market.rs index bb3d764..4befcf0 100644 --- a/src/commands/market.rs +++ b/src/commands/market.rs @@ -1,7 +1,62 @@ use crate::apis::kis::KisApi; use crate::core::TraderApi; +use clap::{Args, Subcommand}; -pub fn handle_market(symbol: &str) { +#[derive(Args)] +pub struct MarketCommand { + /// 종목코드. 하위 명령 없이 지정하면 현재가와 기업 기본 정보를 조회 + pub symbol: Option, + + #[command(subcommand)] + pub sub: Option, +} + +#[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(); match api.market(symbol) { 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) + ), + } +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index e3fad1c..5298915 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -1,13 +1,19 @@ pub mod account; pub mod api; +pub mod cache; +pub mod dart; pub mod market; pub mod order; pub mod search; +pub mod universe; mod version; pub use account::handle_account; pub use api::handle_api; +pub use cache::handle_cache; +pub use dart::handle_dart; pub use market::handle_market; pub use order::handle_order; pub use search::handle_search; +pub use universe::handle_universe; pub use version::handle_version; diff --git a/src/commands/universe.rs b/src/commands/universe.rs new file mode 100644 index 0000000..87068ad --- /dev/null +++ b/src/commands/universe.rs @@ -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, + + /// 종목 유형 필터: common_stock, preferred_stock, etf, etn, reit, spac + #[arg(long)] + pub kind: Option, + + /// 반환할 최대 종목 수 + #[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, + + /// 종목 유형 필터: common_stock, preferred_stock, etf, etn, reit, spac + #[arg(long)] + pub kind: Option, + + /// 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, +} + +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::>(); + 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, 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::>(); + 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 { + 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 { + 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, + errors: Vec, +} + +#[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 { + 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::>(); + 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, + errors: &mut Vec, + 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 { + 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() +} diff --git a/src/core/cache.rs b/src/core/cache.rs new file mode 100644 index 0000000..1cc56b9 --- /dev/null +++ b/src/core/cache.rs @@ -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, +} + +#[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, +} + +#[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, +} + +#[derive(Debug, Clone)] +struct SnapshotFile { + path: PathBuf, + name: String, + bytes: u64, + modified: SystemTime, +} + +pub fn status() -> Result { + 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::::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 { + 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 { + 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::(); + let deleted_paths = delete + .iter() + .map(|file| file.path.display().to_string()) + .collect::>(); + + 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, + 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, 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) +} diff --git a/src/core/http.rs b/src/core/http.rs new file mode 100644 index 0000000..2bbf629 --- /dev/null +++ b/src/core/http.rs @@ -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) +} diff --git a/src/core/mod.rs b/src/core/mod.rs index 04049da..842a904 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -1,6 +1,9 @@ +pub mod cache; pub mod dotenv; +pub mod http; pub mod output; pub mod registry; +pub mod stock; pub mod trader; pub use registry::ApiRegistry; diff --git a/src/core/stock.rs b/src/core/stock.rs new file mode 100644 index 0000000..028c47f --- /dev/null +++ b/src/core/stock.rs @@ -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, + pub sector: Option, + pub main_product: Option, + pub listed_at: Option, + pub fiscal_month: Option, + pub homepage: Option, + pub region: Option, + pub source: StockSource, + pub source_url: Option, + pub updated_at: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct StockId { + pub symbol: String, + pub isin: Option, + pub reuters_code: Option, +} + +#[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, name: impl Into) -> 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 + ); + } +} diff --git a/src/core/trader.rs b/src/core/trader.rs index 0663e93..62759d3 100644 --- a/src/core/trader.rs +++ b/src/core/trader.rs @@ -82,6 +82,16 @@ pub trait TraderApi { /// 종목 시장 정보 조회 fn market(&self, symbol: &str) -> Result; + /// 종목 기간별 가격 히스토리 조회 + fn market_history( + &self, + symbol: &str, + start_date: &str, + end_date: &str, + period: &str, + adjusted: bool, + ) -> Result; + /// 증권사 정보 (키-값 쌍) fn info(&self) -> Vec<(&'static str, &'static str)>; } diff --git a/src/main.rs b/src/main.rs index f00cef4..d01dd54 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,11 @@ use clap::{Parser, Subcommand}; use commands::account::AccountCommands; use commands::api::ApiCommands; +use commands::cache::CacheCommands; +use commands::dart::DartCommands; +use commands::market::MarketCommand; use commands::order::OrderCommands; +use commands::universe::UniverseCommands; mod apis; mod commands; @@ -31,18 +35,36 @@ enum Commands { sub: ApiCommands, }, + /// OpenDART 공시정보 조회 + Dart { + #[command(subcommand)] + sub: DartCommands, + }, + /// 계좌 조회 및 관리 Account { #[command(subcommand)] sub: AccountCommands, }, + /// 로컬 캐시 상태 조회 및 용량 정리 + Cache { + #[command(subcommand)] + sub: CacheCommands, + }, + /// 주문 실행 및 조회 Order { #[command(subcommand)] sub: OrderCommands, }, + /// 종목 universe 캐시 구축 및 조회 + Universe { + #[command(subcommand)] + sub: UniverseCommands, + }, + /// 종목 검색 Search { /// 종목명 또는 종목코드 @@ -50,10 +72,7 @@ enum Commands { }, /// 종목 정보 및 기업정보 조회 - Market { - /// 종목코드 - symbol: String, - }, + Market(MarketCommand), } fn main() { @@ -62,10 +81,13 @@ fn main() { match &cli.command { Some(Commands::Version) => commands::handle_version(), 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::Cache { sub }) => commands::handle_cache(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::Market { symbol }) => commands::handle_market(symbol), + Some(Commands::Market(command)) => commands::handle_market(command), None => { println!( "{}", diff --git a/src/providers/kind/mod.rs b/src/providers/kind/mod.rs new file mode 100644 index 0000000..b8c5609 --- /dev/null +++ b/src/providers/kind/mod.rs @@ -0,0 +1 @@ +pub mod universe; diff --git a/src/providers/kind/universe.rs b/src/providers/kind/universe.rs new file mode 100644 index 0000000..a50adca --- /dev/null +++ b/src/providers/kind/universe.rs @@ -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, +} + +#[derive(Debug, Clone)] +pub struct UniverseSnapshot { + pub stocks: Vec, + pub meta: UniverseMeta, + pub refreshed: bool, +} + +pub fn load_or_refresh(force: bool) -> Result { + 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 { + 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, String> { + load_any_cache() +} + +pub fn prune_cache(dry_run: bool) -> Result { + 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 { + 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, 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, String> { + let mut rows = Vec::new(); + let mut rest = html; + + while let Some(start) = find_ascii_case_insensitive(rest, "') else { + break; + }; + let row_body_start = open_end + 1; + let Some(close) = find_ascii_case_insensitive(&row_start[row_body_start..], "") 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 + "".len()..]; + } + + if rows.is_empty() { + return Err("[KIND] 상장법인목록에서 종목을 찾지 못했습니다.".to_string()); + } + + Ok(rows) +} + +fn row_to_stock(cells: &[String]) -> Option { + 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 { + let mut cells = Vec::new(); + let mut rest = row; + + loop { + let td = find_ascii_case_insensitive(rest, " 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("" + } else { + "" + }; + 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::>() + .join(" ") +} + +fn decode_entities(value: &str) -> String { + value + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace(""", "\"") + .replace("'", "'") + .replace(" ", " ") +} + +fn find_ascii_case_insensitive(haystack: &str, needle: &str) -> Option { + haystack + .as_bytes() + .windows(needle.len()) + .position(|window| window.eq_ignore_ascii_case(needle.as_bytes())) +} + +fn empty_to_none(value: &str) -> Option { + 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(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, 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, 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::>(&latest_path)?; + let meta = read_json::(&meta_path)?; + Ok(Some(UniverseSnapshot { + stocks, + meta, + refreshed: false, + })) +} + +fn read_json Deserialize<'de>>(path: &Path) -> Result { + 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 { + 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#" + + + + +
회사명시장구분종목코드업종주요제품상장일결산월대표자명홈페이지지역
삼성전자유가005930통신 및 방송 장비 제조업반도체1975-06-1112월대표https://www.samsung.com경기도
삼성전자우유가005935통신 및 방송 장비 제조업반도체1989-01-0112월대표경기도
+ "#; + + 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); + } +} diff --git a/src/providers/mod.rs b/src/providers/mod.rs index d93182d..09bc73b 100644 --- a/src/providers/mod.rs +++ b/src/providers/mod.rs @@ -1 +1,3 @@ +pub mod kind; pub mod naver; +pub mod opendart; diff --git a/src/providers/naver/search.rs b/src/providers/naver/search.rs index dcbfb3a..695f4e6 100644 --- a/src/providers/naver/search.rs +++ b/src/providers/naver/search.rs @@ -15,7 +15,8 @@ pub fn search(query: &str) -> Result { percent_encode(query) ); - let response = ureq::get(&url) + let response = crate::core::http::agent() + .get(&url) .header("User-Agent", "openstock/0.1") .call() .map_err(|err| format!("[Naver] 종목 검색 요청 실패: {}", err))?; diff --git a/src/providers/opendart/corp_codes.rs b/src/providers/opendart/corp_codes.rs new file mode 100644 index 0000000..c6304fc --- /dev/null +++ b/src/providers/opendart/corp_codes.rs @@ -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, + pub meta: DartCorpCodeMeta, + pub refreshed: bool, +} + +pub fn load_or_refresh(force: bool) -> Result { + 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, String> { + load_any_cache() +} + +pub fn refresh() -> Result { + 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::>(); + 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 { + 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, 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::() + ) + })?; + 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, String> { + let mut corps = Vec::new(); + let mut rest = xml; + + while let Some(start) = rest.find("") { + let item_start = start + "".len(); + let Some(end) = rest[item_start..].find("") 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 + "".len()..]; + } + + if corps.is_empty() { + return Err("[OpenDART] 고유번호 XML에서 회사를 찾지 못했습니다.".to_string()); + } + Ok(corps) +} + +fn extract_tag(xml: &str, tag: &str) -> Option { + let open = format!("<{}>", tag); + let close = format!("", tag); + let start = xml.find(&open)? + open.len(); + let end = xml[start..].find(&close)?; + Some(decode_xml_entities(xml[start..start + end].trim())) +} + +fn decode_xml_entities(value: &str) -> String { + value + .replace("<", "<") + .replace(">", ">") + .replace("&", "&") + .replace(""", "\"") + .replace("'", "'") +} + +fn write_cache(corps: &[DartCorpCode], meta: &DartCorpCodeMeta) -> Result<(), String> { + let dir = cache_dir(); + fs::create_dir_all(&dir) + .map_err(|err| format!("[OpenDART] 캐시 디렉터리 생성 실패: {}", err))?; + write_json_atomic(&dir.join(CORP_CODES_FILE), corps)?; + write_json_atomic(&dir.join(META_FILE), meta)?; + Ok(()) +} + +fn write_json_atomic(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, 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, 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 Deserialize<'de>>(path: &Path) -> Result { + 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) +} diff --git a/src/providers/opendart/disclosures.rs b/src/providers/opendart/disclosures.rs new file mode 100644 index 0000000..afff57e --- /dev/null +++ b/src/providers/opendart/disclosures.rs @@ -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, + pub bgn_de: Option, + pub end_de: Option, + pub corp_cls: Option, + pub page_no: u32, + pub page_count: u32, +} + +pub fn list(query: &DisclosureQuery) -> Result { + let key = api_key()?; + validate_query(query)?; + + let mut params = vec![ + ("crtfc_key", key), + ("page_no", query.page_no.to_string()), + ("page_count", query.page_count.to_string()), + ]; + push_optional(&mut params, "corp_code", query.corp_code.as_deref()); + push_optional(&mut params, "bgn_de", query.bgn_de.as_deref()); + push_optional(&mut params, "end_de", query.end_de.as_deref()); + push_optional(&mut params, "corp_cls", query.corp_cls.as_deref()); + + let url = build_url(OPENDART_LIST_URL, ¶ms); + let response = crate::core::http::agent() + .get(&url) + .header("User-Agent", "openstock/0.1") + .call() + .map_err(|err| format!("[OpenDART] 공시목록 요청 실패: {}", err))?; + let status = response.status(); + let body = response + .into_body() + .read_to_string() + .map_err(|err| format!("[OpenDART] 공시목록 응답 읽기 실패: {}", err))?; + + if !status.is_success() { + return Err(format!( + "[OpenDART] 공시목록 요청 오류 ({}): {}", + status, body + )); + } + + let payload = serde_json::from_str::(&body) + .map_err(|err| format!("[OpenDART] 공시목록 응답 파싱 실패: {}", err))?; + ensure_success(&payload)?; + Ok(payload) +} + +fn api_key() -> Result { + 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::>() + .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 + )) +} diff --git a/src/providers/opendart/document.rs b/src/providers/opendart/document.rs new file mode 100644 index 0000000..1a8be5f --- /dev/null +++ b/src/providers/opendart/document.rs @@ -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, + 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 { + 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::prune_snapshots( + "opendart/documents", + &cache_dir(), + &[], + &["zip"], + DOCUMENT_RETENTION_FILES, + DOCUMENT_RETENTION_BYTES, + dry_run, + ) +} + +fn download(rcept_no: &str) -> Result, 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, + text: String, + text_chars: usize, + truncated: bool, +} + +fn parse_document_zip(bytes: &[u8], max_chars: usize) -> Result { + 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::() + ) + })?; + + 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::>() + .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 { + haystack + .as_bytes() + .windows(needle.len()) + .position(|window| window.eq_ignore_ascii_case(needle.as_bytes())) +} + +fn decode_bytes(bytes: &[u8]) -> String { + match std::str::from_utf8(bytes) { + Ok(value) => value.to_string(), + Err(_) => { + let (decoded, _, _) = EUC_KR.decode(bytes); + decoded.into_owned() + } + } +} + +fn looks_like_xml(name: &str, bytes: &[u8]) -> bool { + name.to_ascii_lowercase().ends_with(".xml") + || decode_bytes(&bytes[..bytes.len().min(100)]) + .trim_start() + .starts_with('<') +} + +fn decode_xml_entities(value: &str) -> String { + value + .replace("<", "<") + .replace(">", ">") + .replace("&", "&") + .replace(""", "\"") + .replace("'", "'") + .replace("'", "'") + .replace(" ", " ") +} + +fn write_cache(path: &Path, bytes: &[u8]) -> Result<(), String> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .map_err(|err| format!("[OpenDART] 공시서류 캐시 디렉터리 생성 실패: {}", err))?; + } + let tmp = path.with_extension("tmp"); + fs::write(&tmp, bytes).map_err(|err| format!("[OpenDART] 공시서류 캐시 쓰기 실패: {}", err))?; + fs::rename(&tmp, path).map_err(|err| format!("[OpenDART] 공시서류 캐시 교체 실패: {}", err))?; + Ok(()) +} + +fn cache_path(rcept_no: &str) -> PathBuf { + cache_dir().join(format!("{}.zip", rcept_no)) +} + +fn cache_dir() -> PathBuf { + PathBuf::from(CACHE_DIR) +} + +fn api_key() -> Result { + 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#"사업보고서매출 & 이익
100
"#; + let text = xml_to_text(xml); + assert!(text.contains("사업보고서")); + assert!(text.contains("매출 & 이익")); + assert!(text.contains("100")); + assert!(!text.contains("xforms")); + assert!(!text.contains("")); + } + + #[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); + } +} diff --git a/src/providers/opendart/mod.rs b/src/providers/opendart/mod.rs new file mode 100644 index 0000000..3f1ee8a --- /dev/null +++ b/src/providers/opendart/mod.rs @@ -0,0 +1,3 @@ +pub mod corp_codes; +pub mod disclosures; +pub mod document;