commit 479e3f16d4854c0517e980199c5534cc2dcc448d Author: shay7sev Date: Tue Jun 23 09:43:56 2026 +0800 chore: bootstrap housing research platform diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f3b5c56 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +.DS_Store +__pycache__/ +*.py[cod] +.pytest_cache/ +.coverage +.venv/ +venv/ +target/ +.env +.env.* +data/*.sqlite +reports/*.md +!reports/.gitkeep diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..87020e0 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2149 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "axum-macros", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-macros" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aa268c23bfbbd2c4363b9cd302a4f504fb2a9dfe7e3451d66f35dd392e20aca" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "base64" +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" +dependencies = [ + "serde_core", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" + +[[package]] +name = "cc" +version = "1.2.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dad887fd958be91b5098c0248def011f4523ab786cd411be668777e55063501f" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + +[[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" +dependencies = [ + "serde", +] + +[[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 0.61.2", +] + +[[package]] +name = "etcetera" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +dependencies = [ + "cfg-if", + "home", + "windows-sys 0.48.0", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "http", + "http-body", + "hyper", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" +dependencies = [ + "bitflags", + "libc", + "plain", + "redox_syscall 0.8.1", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "pkg-config", + "vcpkg", +] + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_syscall" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b44b894f2a6e36457d665d1e08c3866add6ed5e70050c1b4ba8a8ddedb02ce7" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shanghai-housing-api" +version = "0.1.0" +dependencies = [ + "anyhow", + "axum", + "serde", + "serde_json", + "sqlx", + "thiserror", + "tokio", + "tower-http", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sqlx" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" +dependencies = [ + "base64", + "bytes", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.15.5", + "hashlink", + "indexmap", + "log", + "memchr", + "once_cell", + "percent-encoding", + "rustls", + "serde", + "serde_json", + "sha2", + "smallvec", + "thiserror", + "tokio", + "tokio-stream", + "tracing", + "url", + "webpki-roots 0.26.11", +] + +[[package]] +name = "sqlx-macros" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" +dependencies = [ + "dotenvy", + "either", + "heck", + "hex", + "once_cell", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" +dependencies = [ + "atoi", + "base64", + "bitflags", + "byteorder", + "bytes", + "crc", + "digest", + "dotenvy", + "either", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "generic-array", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "percent-encoding", + "rand", + "rsa", + "serde", + "sha1", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-postgres" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" +dependencies = [ + "atoi", + "base64", + "bitflags", + "byteorder", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac", + "home", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "rand", + "serde", + "serde_json", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" +dependencies = [ + "atoi", + "flume", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "serde_urlencoded", + "sqlx-core", + "thiserror", + "tracing", + "url", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[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.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "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 = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "http", + "http-body", + "pin-project-lite", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.8", +] + +[[package]] +name = "webpki-roots" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "whoami" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" +dependencies = [ + "libredox", + "wasite", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[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.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..a8c2f3d --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,3 @@ +[workspace] +members = ["apps/api"] +resolver = "2" diff --git a/README.md b/README.md new file mode 100644 index 0000000..2f503d2 --- /dev/null +++ b/README.md @@ -0,0 +1,109 @@ +# 上海房市投资研究系统 + +这是一个面向长期研究和投资判断的上海房市分析底座。第一版重点不是预测神谕式的涨跌,而是建立可复用的数据结构、指标口径和报告流程。 + +当前版本包含: + +- SQLite 数据库 schema +- 官方/市场数据源目录 +- 样例数据加载器 +- 板块投资评分模型 +- 月度 Markdown 报告生成 +- Rust Axum API 骨架 +- PostgreSQL migrations +- 单元测试 + +> `data/sample` 中的数据是演示样例,不代表真实行情。正式研究时应替换为可审计的数据源。 + +## 快速开始 + +```bash +PYTHONPATH=src python3 -m shanghai_housing init-db +PYTHONPATH=src python3 -m shanghai_housing load-sample +PYTHONPATH=src python3 -m shanghai_housing report --month 2026-05 +``` + +生成报告位置: + +```bash +reports/monthly-2026-05.md +``` + +运行测试: + +```bash +PYTHONPATH=src python3 -m unittest discover -s tests +cargo test +``` + +## API 开发 + +API 服务位于 `apps/api`,采用 Rust Axum + SQLx。环境变量配置见: + +- [环境配置](docs/config/environment.md) + +本地启动示例: + +```bash +export DATABASE_URL="postgres://:@:/shanghai_housing_research_dev" +export RUN_MIGRATIONS=true +cargo run -p shanghai-housing-api +``` + +当前接口: + +```text +GET /health +GET /ready +GET /api/v1/areas/scores?month=2026-05 +GET /api/v1/market/overview?month=2026-05 +``` + +## 研究目标 + +系统要长期回答的不是单一“涨/跌”,而是: + +- 当前上海市场处于什么周期位置? +- 哪些板块流动性更强? +- 哪些资产有租金和产业支撑? +- 哪些区域存在供应压力或价格压力? +- 哪些小区进入观察池? +- 触发买入、卖出、观望的条件是什么? + +## MVP 架构 + +```text +config/source_catalog.json 数据源目录 +data/sample/ 演示样例数据 +src/shanghai_housing/schema.sql 数据库结构 +src/shanghai_housing/db.py 初始化和加载数据 +src/shanghai_housing/indicators.py 指标和评分模型 +src/shanghai_housing/reporting.py 报告生成 +src/shanghai_housing/cli.py 命令行入口 +docs/ 研究框架和数据字典 +``` + +## 第一版指标 + +- 成交活跃度 +- 成交价格动量 +- 挂牌库存压力 +- 成交/挂牌折价 +- 租金收益率 +- 新增供应压力 +- 信贷和政策环境 +- 综合投资观察评分 + +## 下一步 + +1. 接入真实月度数据源。 +2. 增加小区级评分和观察池。 +3. 建立板块历史分位和异常检测。 +4. 加入政策事件表和利率情景推演。 +5. 增加可视化看板。 + +## 长期架构 + +当前项目是研究内核 MVP,不是最终产品形态。完整方案见: + +- [系统架构与开发计划](docs/system_architecture_and_development_plan.md) diff --git a/apps/api/Cargo.toml b/apps/api/Cargo.toml new file mode 100644 index 0000000..aadeac3 --- /dev/null +++ b/apps/api/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "shanghai-housing-api" +version = "0.1.0" +edition = "2021" + +[dependencies] +anyhow = "1" +axum = { version = "0.8", features = ["macros"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "migrate"] } +thiserror = "2" +tokio = { version = "1", features = ["macros", "rt-multi-thread", "signal"] } +tower-http = { version = "0.6", features = ["cors", "trace"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } diff --git a/apps/api/migrations/202606180001_init.sql b/apps/api/migrations/202606180001_init.sql new file mode 100644 index 0000000..f2ea81e --- /dev/null +++ b/apps/api/migrations/202606180001_init.sql @@ -0,0 +1,134 @@ +CREATE SCHEMA IF NOT EXISTS raw; +CREATE SCHEMA IF NOT EXISTS bronze; +CREATE SCHEMA IF NOT EXISTS silver; +CREATE SCHEMA IF NOT EXISTS gold; +CREATE SCHEMA IF NOT EXISTS app; +CREATE SCHEMA IF NOT EXISTS audit; + +CREATE TABLE IF NOT EXISTS audit.data_sources ( + source_id BIGSERIAL PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + source_type TEXT NOT NULL, + url TEXT, + cadence TEXT, + reliability TEXT NOT NULL DEFAULT 'unknown', + notes TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS audit.ingestion_runs ( + run_id BIGSERIAL PRIMARY KEY, + source_id BIGINT REFERENCES audit.data_sources(source_id), + status TEXT NOT NULL, + started_at TIMESTAMPTZ NOT NULL DEFAULT now(), + finished_at TIMESTAMPTZ, + raw_uri TEXT, + row_count INTEGER, + error_message TEXT +); + +CREATE TABLE IF NOT EXISTS silver.areas ( + area_id TEXT PRIMARY KEY, + name TEXT NOT NULL, + district TEXT NOT NULL, + segment TEXT NOT NULL, + notes TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS silver.area_monthly_metrics ( + area_id TEXT NOT NULL REFERENCES silver.areas(area_id), + month TEXT NOT NULL CHECK (month ~ '^[0-9]{4}-[0-9]{2}$'), + transaction_count INTEGER NOT NULL CHECK (transaction_count >= 0), + transaction_price_psm DOUBLE PRECISION NOT NULL CHECK (transaction_price_psm >= 0), + listing_count INTEGER NOT NULL CHECK (listing_count >= 0), + listing_price_psm DOUBLE PRECISION NOT NULL CHECK (listing_price_psm >= 0), + median_days_on_market DOUBLE PRECISION NOT NULL CHECK (median_days_on_market >= 0), + rent_price_psm DOUBLE PRECISION NOT NULL CHECK (rent_price_psm >= 0), + new_supply_units INTEGER NOT NULL CHECK (new_supply_units >= 0), + land_residential_gfa_sqm DOUBLE PRECISION NOT NULL CHECK (land_residential_gfa_sqm >= 0), + mortgage_rate_pct DOUBLE PRECISION NOT NULL CHECK (mortgage_rate_pct >= 0), + policy_signal INTEGER NOT NULL DEFAULT 0 CHECK (policy_signal BETWEEN -2 AND 2), + source TEXT NOT NULL DEFAULT 'manual', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (area_id, month) +); + +CREATE TABLE IF NOT EXISTS silver.neighborhoods ( + neighborhood_id TEXT PRIMARY KEY, + area_id TEXT NOT NULL REFERENCES silver.areas(area_id), + name TEXT NOT NULL, + built_year INTEGER, + property_type TEXT NOT NULL, + metro_distance_m INTEGER, + school_quality TEXT, + notes TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS silver.neighborhood_monthly_metrics ( + neighborhood_id TEXT NOT NULL REFERENCES silver.neighborhoods(neighborhood_id), + month TEXT NOT NULL CHECK (month ~ '^[0-9]{4}-[0-9]{2}$'), + transaction_count INTEGER NOT NULL CHECK (transaction_count >= 0), + transaction_price_psm DOUBLE PRECISION NOT NULL CHECK (transaction_price_psm >= 0), + listing_count INTEGER NOT NULL CHECK (listing_count >= 0), + listing_price_psm DOUBLE PRECISION NOT NULL CHECK (listing_price_psm >= 0), + rent_price_psm DOUBLE PRECISION NOT NULL CHECK (rent_price_psm >= 0), + median_days_on_market DOUBLE PRECISION NOT NULL CHECK (median_days_on_market >= 0), + available_units INTEGER NOT NULL CHECK (available_units >= 0), + source TEXT NOT NULL DEFAULT 'manual', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (neighborhood_id, month) +); + +CREATE TABLE IF NOT EXISTS gold.area_scores ( + area_id TEXT NOT NULL REFERENCES silver.areas(area_id), + name TEXT NOT NULL, + district TEXT NOT NULL, + segment TEXT NOT NULL, + month TEXT NOT NULL CHECK (month ~ '^[0-9]{4}-[0-9]{2}$'), + investment_score DOUBLE PRECISION NOT NULL CHECK (investment_score BETWEEN 0 AND 100), + recommendation TEXT NOT NULL, + liquidity_score DOUBLE PRECISION NOT NULL CHECK (liquidity_score BETWEEN 0 AND 100), + momentum_score DOUBLE PRECISION NOT NULL CHECK (momentum_score BETWEEN 0 AND 100), + rent_support_score DOUBLE PRECISION NOT NULL CHECK (rent_support_score BETWEEN 0 AND 100), + safety_margin_score DOUBLE PRECISION NOT NULL CHECK (safety_margin_score BETWEEN 0 AND 100), + credit_support_score DOUBLE PRECISION NOT NULL CHECK (credit_support_score BETWEEN 0 AND 100), + supply_risk_score DOUBLE PRECISION NOT NULL CHECK (supply_risk_score BETWEEN 0 AND 100), + valuation_pressure_score DOUBLE PRECISION NOT NULL CHECK (valuation_pressure_score BETWEEN 0 AND 100), + transaction_count INTEGER NOT NULL CHECK (transaction_count >= 0), + transaction_price_psm DOUBLE PRECISION NOT NULL CHECK (transaction_price_psm >= 0), + listing_count INTEGER NOT NULL CHECK (listing_count >= 0), + listing_price_psm DOUBLE PRECISION NOT NULL CHECK (listing_price_psm >= 0), + rent_price_psm DOUBLE PRECISION NOT NULL CHECK (rent_price_psm >= 0), + median_days_on_market DOUBLE PRECISION NOT NULL CHECK (median_days_on_market >= 0), + annual_rent_yield_pct DOUBLE PRECISION NOT NULL CHECK (annual_rent_yield_pct >= 0), + listing_pressure_ratio DOUBLE PRECISION NOT NULL CHECK (listing_pressure_ratio >= 0), + discount_pct DOUBLE PRECISION NOT NULL, + price_momentum_pct DOUBLE PRECISION NOT NULL, + volume_momentum_pct DOUBLE PRECISION NOT NULL, + model_version TEXT NOT NULL DEFAULT 'sample-v1', + computed_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (area_id, month) +); + +CREATE TABLE IF NOT EXISTS app.watchlist_items ( + watchlist_item_id BIGSERIAL PRIMARY KEY, + neighborhood_id TEXT REFERENCES silver.neighborhoods(neighborhood_id), + area_id TEXT REFERENCES silver.areas(area_id), + target_price_psm DOUBLE PRECISION CHECK (target_price_psm >= 0), + status TEXT NOT NULL DEFAULT 'active', + notes TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CHECK (neighborhood_id IS NOT NULL OR area_id IS NOT NULL) +); + +CREATE INDEX IF NOT EXISTS idx_area_monthly_metrics_month ON silver.area_monthly_metrics(month); +CREATE INDEX IF NOT EXISTS idx_neighborhoods_area ON silver.neighborhoods(area_id); +CREATE INDEX IF NOT EXISTS idx_gold_area_scores_month ON gold.area_scores(month, investment_score DESC); +CREATE INDEX IF NOT EXISTS idx_watchlist_status ON app.watchlist_items(status); diff --git a/apps/api/migrations/202606180002_seed_sample.sql b/apps/api/migrations/202606180002_seed_sample.sql new file mode 100644 index 0000000..c5a7d5a --- /dev/null +++ b/apps/api/migrations/202606180002_seed_sample.sql @@ -0,0 +1,115 @@ +INSERT INTO audit.data_sources (name, source_type, url, cadence, reliability, notes) +VALUES + ('sample', 'manual', NULL, 'ad hoc', 'demo', 'Demonstration data. Not real market data.') +ON CONFLICT (name) DO UPDATE +SET source_type = EXCLUDED.source_type, + cadence = EXCLUDED.cadence, + reliability = EXCLUDED.reliability, + notes = EXCLUDED.notes; + +INSERT INTO silver.areas (area_id, name, district, segment, notes) +VALUES + ('qiantan', '前滩', '浦东新区', '核心改善', '产业和公共配套强,供应节奏需要持续跟踪'), + ('xujiahui', '徐家汇', '徐汇区', '核心成熟', '成熟商圈和教育医疗资源强,价格弹性通常较小'), + ('danning', '大宁', '静安区', '内中环改善', '居住氛围和商业配套较均衡'), + ('zhangjiang', '张江', '浦东新区', '产业成长', '产业人口支撑强,产品分化明显'), + ('hongqiao', '大虹桥', '闵行区', '规划成长', '受商务区兑现和供应影响较大') +ON CONFLICT (area_id) DO UPDATE +SET name = EXCLUDED.name, + district = EXCLUDED.district, + segment = EXCLUDED.segment, + notes = EXCLUDED.notes, + updated_at = now(); + +INSERT INTO silver.area_monthly_metrics ( + area_id, + month, + transaction_count, + transaction_price_psm, + listing_count, + listing_price_psm, + median_days_on_market, + rent_price_psm, + new_supply_units, + land_residential_gfa_sqm, + mortgage_rate_pct, + policy_signal, + source +) +VALUES + ('qiantan', '2026-05', 93, 122500, 380, 126500, 53, 192, 60, 0, 3.35, 1, 'sample'), + ('xujiahui', '2026-05', 67, 127500, 348, 133000, 66, 207, 20, 0, 3.35, 1, 'sample'), + ('danning', '2026-05', 103, 98200, 488, 102200, 69, 158, 110, 0, 3.35, 1, 'sample'), + ('zhangjiang', '2026-05', 126, 84600, 570, 89000, 60, 139, 180, 0, 3.35, 1, 'sample'), + ('hongqiao', '2026-05', 82, 75800, 735, 82000, 96, 119, 360, 0, 3.35, 0, 'sample') +ON CONFLICT (area_id, month) DO UPDATE +SET transaction_count = EXCLUDED.transaction_count, + transaction_price_psm = EXCLUDED.transaction_price_psm, + listing_count = EXCLUDED.listing_count, + listing_price_psm = EXCLUDED.listing_price_psm, + median_days_on_market = EXCLUDED.median_days_on_market, + rent_price_psm = EXCLUDED.rent_price_psm, + new_supply_units = EXCLUDED.new_supply_units, + land_residential_gfa_sqm = EXCLUDED.land_residential_gfa_sqm, + mortgage_rate_pct = EXCLUDED.mortgage_rate_pct, + policy_signal = EXCLUDED.policy_signal, + source = EXCLUDED.source, + updated_at = now(); + +INSERT INTO gold.area_scores ( + area_id, + name, + district, + segment, + month, + investment_score, + recommendation, + liquidity_score, + momentum_score, + rent_support_score, + safety_margin_score, + credit_support_score, + supply_risk_score, + valuation_pressure_score, + transaction_count, + transaction_price_psm, + listing_count, + listing_price_psm, + rent_price_psm, + median_days_on_market, + annual_rent_yield_pct, + listing_pressure_ratio, + discount_pct, + price_momentum_pct, + volume_momentum_pct, + model_version +) +VALUES + ('zhangjiang', '张江', '浦东新区', '产业成长', '2026-05', 72.0, '观察池', 93.0, 60.5, 69.4, 41.6, 66.7, 21.8, 62.9, 126, 84600, 570, 89000, 139, 60, 1.97, 4.52, 4.94, 1.32, 6.78, 'sample-v1'), + ('qiantan', '前滩', '浦东新区', '核心改善', '2026-05', 66.4, '观察池', 79.2, 61.6, 62.9, 25.4, 66.7, 8.1, 78.6, 93, 122500, 380, 126500, 192, 53, 1.88, 4.09, 3.16, 1.24, 8.14, 'sample-v1'), + ('danning', '大宁', '静安区', '内中环改善', '2026-05', 65.6, '观察池', 76.9, 57.9, 66.5, 32.4, 66.7, 20.0, 72.8, 103, 98200, 488, 102200, 158, 69, 1.93, 4.74, 3.91, 1.24, 5.10, 'sample-v1'), + ('xujiahui', '徐家汇', '徐汇区', '核心成熟', '2026-05', 61.9, '中性观望', 59.8, 57.1, 67.7, 34.5, 66.7, 18.5, 70.2, 67, 127500, 348, 133000, 207, 66, 1.95, 5.19, 4.14, 0.55, 8.06, 'sample-v1'), + ('hongqiao', '大虹桥', '闵行区', '规划成长', '2026-05', 55.2, '中性观望', 53.5, 50.9, 63.1, 73.4, 59.2, 99.6, 24.6, 82, 75800, 735, 82000, 119, 96, 1.88, 8.96, 7.56, 0.40, 3.80, 'sample-v1') +ON CONFLICT (area_id, month) DO UPDATE +SET investment_score = EXCLUDED.investment_score, + recommendation = EXCLUDED.recommendation, + liquidity_score = EXCLUDED.liquidity_score, + momentum_score = EXCLUDED.momentum_score, + rent_support_score = EXCLUDED.rent_support_score, + safety_margin_score = EXCLUDED.safety_margin_score, + credit_support_score = EXCLUDED.credit_support_score, + supply_risk_score = EXCLUDED.supply_risk_score, + valuation_pressure_score = EXCLUDED.valuation_pressure_score, + transaction_count = EXCLUDED.transaction_count, + transaction_price_psm = EXCLUDED.transaction_price_psm, + listing_count = EXCLUDED.listing_count, + listing_price_psm = EXCLUDED.listing_price_psm, + rent_price_psm = EXCLUDED.rent_price_psm, + median_days_on_market = EXCLUDED.median_days_on_market, + annual_rent_yield_pct = EXCLUDED.annual_rent_yield_pct, + listing_pressure_ratio = EXCLUDED.listing_pressure_ratio, + discount_pct = EXCLUDED.discount_pct, + price_momentum_pct = EXCLUDED.price_momentum_pct, + volume_momentum_pct = EXCLUDED.volume_momentum_pct, + model_version = EXCLUDED.model_version, + computed_at = now(); diff --git a/apps/api/src/config.rs b/apps/api/src/config.rs new file mode 100644 index 0000000..4774230 --- /dev/null +++ b/apps/api/src/config.rs @@ -0,0 +1,39 @@ +use std::env; +use std::net::SocketAddr; + +use anyhow::{Context, Result}; + +#[derive(Debug, Clone)] +pub struct AppConfig { + pub database_url: String, + pub host: String, + pub port: u16, + pub run_migrations: bool, +} + +impl AppConfig { + pub fn from_env() -> Result { + let database_url = env::var("DATABASE_URL").context("DATABASE_URL is required")?; + let host = env::var("API_HOST").unwrap_or_else(|_| "127.0.0.1".to_string()); + let port = env::var("API_PORT") + .unwrap_or_else(|_| "8080".to_string()) + .parse::() + .context("API_PORT must be a valid port")?; + let run_migrations = env::var("RUN_MIGRATIONS") + .map(|value| matches!(value.as_str(), "1" | "true" | "TRUE" | "yes" | "YES")) + .unwrap_or(false); + + Ok(Self { + database_url, + host, + port, + run_migrations, + }) + } + + pub fn bind_addr(&self) -> SocketAddr { + format!("{}:{}", self.host, self.port) + .parse() + .expect("validated API host and port") + } +} diff --git a/apps/api/src/db.rs b/apps/api/src/db.rs new file mode 100644 index 0000000..447a216 --- /dev/null +++ b/apps/api/src/db.rs @@ -0,0 +1,13 @@ +use anyhow::{Context, Result}; +use sqlx::postgres::PgPoolOptions; +use sqlx::PgPool; +use std::time::Duration; + +pub async fn create_pool(database_url: &str) -> Result { + PgPoolOptions::new() + .max_connections(8) + .acquire_timeout(Duration::from_secs(5)) + .connect(database_url) + .await + .context("failed to connect to PostgreSQL") +} diff --git a/apps/api/src/error.rs b/apps/api/src/error.rs new file mode 100644 index 0000000..091ac2d --- /dev/null +++ b/apps/api/src/error.rs @@ -0,0 +1,33 @@ +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use axum::Json; +use serde::Serialize; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum ApiError { + #[error("bad request: {0}")] + BadRequest(String), + #[error("database error")] + Database(#[from] sqlx::Error), +} + +#[derive(Debug, Serialize)] +struct ErrorBody { + error: String, +} + +impl IntoResponse for ApiError { + fn into_response(self) -> Response { + let status = match self { + ApiError::BadRequest(_) => StatusCode::BAD_REQUEST, + ApiError::Database(_) => StatusCode::INTERNAL_SERVER_ERROR, + }; + let body = Json(ErrorBody { + error: self.to_string(), + }); + (status, body).into_response() + } +} + +pub type ApiResult = Result; diff --git a/apps/api/src/handlers.rs b/apps/api/src/handlers.rs new file mode 100644 index 0000000..ffb4ae5 --- /dev/null +++ b/apps/api/src/handlers.rs @@ -0,0 +1,151 @@ +use axum::extract::{Query, State}; +use axum::Json; +use serde::Serialize; + +use crate::error::{ApiError, ApiResult}; +use crate::models::{AreaScore, AreaScoreSummary, MarketOverview, MonthQuery}; +use crate::state::AppState; + +#[derive(Debug, Serialize)] +pub struct HealthResponse { + pub status: &'static str, + pub service: &'static str, +} + +pub async fn health() -> Json { + Json(HealthResponse { + status: "ok", + service: "shanghai-housing-api", + }) +} + +pub async fn ready(State(state): State) -> ApiResult> { + sqlx::query("select 1").execute(&state.pool).await?; + Ok(Json(HealthResponse { + status: "ok", + service: "shanghai-housing-api", + })) +} + +pub async fn list_area_scores( + State(state): State, + Query(query): Query, +) -> ApiResult>> { + validate_month(&query.month)?; + let scores = fetch_area_scores(&state, &query.month).await?; + Ok(Json(scores)) +} + +pub async fn market_overview( + State(state): State, + Query(query): Query, +) -> ApiResult> { + validate_month(&query.month)?; + let scores = fetch_area_scores(&state, &query.month).await?; + let area_count = scores.len(); + let average_investment_score = average(scores.iter().map(|score| score.investment_score)); + let average_rent_yield_pct = average(scores.iter().map(|score| score.annual_rent_yield_pct)); + let top_area = scores.first().map(AreaScoreSummary::from); + let weakest_area = scores.last().map(AreaScoreSummary::from); + let highest_supply_risk_area = scores + .iter() + .max_by(|left, right| { + left.supply_risk_score + .partial_cmp(&right.supply_risk_score) + .unwrap_or(std::cmp::Ordering::Equal) + }) + .map(AreaScoreSummary::from); + + Ok(Json(MarketOverview { + month: query.month, + area_count, + average_investment_score, + average_rent_yield_pct, + top_area, + weakest_area, + highest_supply_risk_area, + })) +} + +async fn fetch_area_scores(state: &AppState, month: &str) -> Result, sqlx::Error> { + sqlx::query_as::<_, AreaScore>( + r#" + SELECT + area_id, + name, + district, + segment, + month, + investment_score, + recommendation, + liquidity_score, + momentum_score, + rent_support_score, + safety_margin_score, + credit_support_score, + supply_risk_score, + valuation_pressure_score, + transaction_count, + transaction_price_psm, + listing_count, + listing_price_psm, + rent_price_psm, + median_days_on_market, + annual_rent_yield_pct, + listing_pressure_ratio, + discount_pct, + price_momentum_pct, + volume_momentum_pct + FROM gold.area_scores + WHERE month = $1 + ORDER BY investment_score DESC, area_id ASC + "#, + ) + .bind(month) + .fetch_all(&state.pool) + .await +} + +fn average(values: impl Iterator) -> f64 { + let (sum, count) = values.fold((0.0, 0usize), |(sum, count), value| { + (sum + value, count + 1) + }); + if count == 0 { + 0.0 + } else { + (sum / count as f64 * 10.0).round() / 10.0 + } +} + +fn validate_month(month: &str) -> ApiResult<()> { + let valid = month.len() == 7 + && month.as_bytes()[4] == b'-' + && month[..4].chars().all(|item| item.is_ascii_digit()) + && month[5..].chars().all(|item| item.is_ascii_digit()) + && matches!(month[5..].parse::(), Ok(value) if (1..=12).contains(&value)); + + if valid { + Ok(()) + } else { + Err(ApiError::BadRequest( + "month must use YYYY-MM format".to_string(), + )) + } +} + +#[cfg(test)] +mod tests { + use super::validate_month; + + #[test] + fn accepts_valid_month() { + assert!(validate_month("2026-05").is_ok()); + } + + #[test] + fn rejects_invalid_month() { + assert!(validate_month("2026-5").is_err()); + assert!(validate_month("2026-13").is_err()); + assert!(validate_month("abcd-05").is_err()); + } +} diff --git a/apps/api/src/main.rs b/apps/api/src/main.rs new file mode 100644 index 0000000..597bf0a --- /dev/null +++ b/apps/api/src/main.rs @@ -0,0 +1,75 @@ +mod config; +mod db; +mod error; +mod handlers; +mod models; +mod routes; +mod state; + +use anyhow::Context; +use tokio::net::TcpListener; +use tracing::info; + +use crate::config::AppConfig; +use crate::db::create_pool; +use crate::routes::build_router; +use crate::state::AppState; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "shanghai_housing_api=info,tower_http=info".into()), + ) + .init(); + + let config = AppConfig::from_env()?; + let pool = create_pool(&config.database_url).await?; + + if config.run_migrations { + info!("running database migrations"); + sqlx::migrate!("./migrations") + .run(&pool) + .await + .context("failed to run database migrations")?; + } + + let state = AppState { pool }; + let app = build_router(state); + let listener = TcpListener::bind(config.bind_addr()) + .await + .context("failed to bind API listener")?; + + info!("listening on http://{}", listener.local_addr()?); + axum::serve(listener, app) + .with_graceful_shutdown(shutdown_signal()) + .await + .context("server failed")?; + + Ok(()) +} + +async fn shutdown_signal() { + let ctrl_c = async { + tokio::signal::ctrl_c() + .await + .expect("failed to install Ctrl+C handler"); + }; + + #[cfg(unix)] + let terminate = async { + tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) + .expect("failed to install signal handler") + .recv() + .await; + }; + + #[cfg(not(unix))] + let terminate = std::future::pending::<()>(); + + tokio::select! { + _ = ctrl_c => {}, + _ = terminate => {}, + } +} diff --git a/apps/api/src/models.rs b/apps/api/src/models.rs new file mode 100644 index 0000000..ca849d0 --- /dev/null +++ b/apps/api/src/models.rs @@ -0,0 +1,68 @@ +use serde::{Deserialize, Serialize}; +use sqlx::FromRow; + +#[derive(Debug, Deserialize)] +pub struct MonthQuery { + pub month: String, +} + +#[derive(Debug, Clone, Serialize, FromRow)] +pub struct AreaScore { + pub area_id: String, + pub name: String, + pub district: String, + pub segment: String, + pub month: String, + pub investment_score: f64, + pub recommendation: String, + pub liquidity_score: f64, + pub momentum_score: f64, + pub rent_support_score: f64, + pub safety_margin_score: f64, + pub credit_support_score: f64, + pub supply_risk_score: f64, + pub valuation_pressure_score: f64, + pub transaction_count: i32, + pub transaction_price_psm: f64, + pub listing_count: i32, + pub listing_price_psm: f64, + pub rent_price_psm: f64, + pub median_days_on_market: f64, + pub annual_rent_yield_pct: f64, + pub listing_pressure_ratio: f64, + pub discount_pct: f64, + pub price_momentum_pct: f64, + pub volume_momentum_pct: f64, +} + +#[derive(Debug, Serialize)] +pub struct AreaScoreSummary { + pub area_id: String, + pub name: String, + pub district: String, + pub investment_score: f64, + pub recommendation: String, +} + +impl From<&AreaScore> for AreaScoreSummary { + fn from(score: &AreaScore) -> Self { + Self { + area_id: score.area_id.clone(), + name: score.name.clone(), + district: score.district.clone(), + investment_score: score.investment_score, + recommendation: score.recommendation.clone(), + } + } +} + +#[derive(Debug, Serialize)] +pub struct MarketOverview { + pub month: String, + pub area_count: usize, + pub average_investment_score: f64, + pub average_rent_yield_pct: f64, + pub top_area: Option, + pub weakest_area: Option, + pub highest_supply_risk_area: Option, +} diff --git a/apps/api/src/routes.rs b/apps/api/src/routes.rs new file mode 100644 index 0000000..3791ce9 --- /dev/null +++ b/apps/api/src/routes.rs @@ -0,0 +1,22 @@ +use axum::routing::get; +use axum::Router; +use tower_http::cors::CorsLayer; +use tower_http::trace::TraceLayer; + +use crate::handlers::{health, list_area_scores, market_overview, ready}; +use crate::state::AppState; + +pub fn build_router(state: AppState) -> Router { + Router::new() + .route("/health", get(health)) + .route("/ready", get(ready)) + .nest( + "/api/v1", + Router::new() + .route("/areas/scores", get(list_area_scores)) + .route("/market/overview", get(market_overview)), + ) + .layer(TraceLayer::new_for_http()) + .layer(CorsLayer::permissive()) + .with_state(state) +} diff --git a/apps/api/src/state.rs b/apps/api/src/state.rs new file mode 100644 index 0000000..330747b --- /dev/null +++ b/apps/api/src/state.rs @@ -0,0 +1,6 @@ +use sqlx::PgPool; + +#[derive(Clone)] +pub struct AppState { + pub pool: PgPool, +} diff --git a/config/source_catalog.json b/config/source_catalog.json new file mode 100644 index 0000000..ba624ec --- /dev/null +++ b/config/source_catalog.json @@ -0,0 +1,50 @@ +{ + "official_sources": [ + { + "name": "上海市统计局", + "url": "https://tjj.sh.gov.cn/", + "cadence": "monthly", + "use": "房地产开发投资、商品房销售、宏观人口与收入数据", + "reliability": "high" + }, + { + "name": "上海市房屋管理局", + "url": "https://fgj.sh.gov.cn/", + "cadence": "event/monthly", + "use": "房产交易、政策通知、市场监管信息", + "reliability": "high" + }, + { + "name": "上海土地市场", + "url": "https://biz.ghzyj.sh.gov.cn/shtdsc/wz/tdjy/index.jhtml", + "cadence": "event", + "use": "住宅用地公告、成交、楼面价、溢价率", + "reliability": "high" + }, + { + "name": "中国货币网 LPR", + "url": "https://www.chinamoney.com.cn/chinese/bklpr/", + "cadence": "monthly", + "use": "贷款市场报价利率,尤其是五年期以上 LPR", + "reliability": "high" + } + ], + "market_sources": [ + { + "name": "中介挂牌与成交数据", + "url": null, + "cadence": "daily/weekly", + "use": "挂牌量、挂牌价、成交周期、议价空间、小区微观样本", + "reliability": "medium", + "note": "需要记录采集时间、口径、去重规则和合规边界。" + }, + { + "name": "租赁平台样本", + "url": null, + "cadence": "weekly/monthly", + "use": "租金、空置压力、租售比、板块居住需求", + "reliability": "medium", + "note": "挂牌租金不等于成交租金,应单独标注。" + } + ] +} diff --git a/data/sample/area_monthly_metrics.csv b/data/sample/area_monthly_metrics.csv new file mode 100644 index 0000000..4b60ef2 --- /dev/null +++ b/data/sample/area_monthly_metrics.csv @@ -0,0 +1,16 @@ +area_id,month,transaction_count,transaction_price_psm,listing_count,listing_price_psm,median_days_on_market,rent_price_psm,new_supply_units,land_residential_gfa_sqm,mortgage_rate_pct,policy_signal,source +qiantan,2026-03,78,119000,410,125000,62,188,120,0,3.45,1,sample +qiantan,2026-04,86,121000,398,126000,58,190,80,0,3.40,1,sample +qiantan,2026-05,93,122500,380,126500,53,192,60,0,3.35,1,sample +xujiahui,2026-03,64,126000,355,132000,68,205,30,0,3.45,1,sample +xujiahui,2026-04,62,126800,360,132500,70,206,20,0,3.40,1,sample +xujiahui,2026-05,67,127500,348,133000,66,207,20,0,3.35,1,sample +danning,2026-03,91,96000,520,101000,76,155,180,12000,3.45,1,sample +danning,2026-04,98,97000,505,101500,72,156,130,0,3.40,1,sample +danning,2026-05,103,98200,488,102200,69,158,110,0,3.35,1,sample +zhangjiang,2026-03,105,82000,610,87500,70,135,260,36000,3.45,1,sample +zhangjiang,2026-04,118,83500,590,88200,64,137,220,0,3.40,1,sample +zhangjiang,2026-05,126,84600,570,89000,60,139,180,0,3.35,1,sample +hongqiao,2026-03,83,76000,690,82000,88,118,420,68000,3.45,0,sample +hongqiao,2026-04,79,75500,720,81800,94,118,390,0,3.40,0,sample +hongqiao,2026-05,82,75800,735,82000,96,119,360,0,3.35,0,sample diff --git a/data/sample/areas.csv b/data/sample/areas.csv new file mode 100644 index 0000000..9e306f2 --- /dev/null +++ b/data/sample/areas.csv @@ -0,0 +1,6 @@ +area_id,name,district,segment,notes +qiantan,前滩,浦东新区,核心改善,产业和公共配套强,供应节奏需要持续跟踪 +xujiahui,徐家汇,徐汇区,核心成熟,成熟商圈和教育医疗资源强,价格弹性通常较小 +danning,大宁,静安区,内中环改善,居住氛围和商业配套较均衡 +zhangjiang,张江,浦东新区,产业成长,产业人口支撑强,产品分化明显 +hongqiao,大虹桥,闵行区,规划成长,受商务区兑现和供应影响较大 diff --git a/data/sample/neighborhood_monthly_metrics.csv b/data/sample/neighborhood_monthly_metrics.csv new file mode 100644 index 0000000..5ad0b59 --- /dev/null +++ b/data/sample/neighborhood_monthly_metrics.csv @@ -0,0 +1,6 @@ +neighborhood_id,month,transaction_count,transaction_price_psm,listing_count,listing_price_psm,rent_price_psm,median_days_on_market,available_units,source +qiantan_a,2026-05,12,124000,38,128000,195,50,38,sample +xujiahui_a,2026-05,8,131000,30,137000,215,62,30,sample +danning_a,2026-05,14,100000,45,104000,162,65,45,sample +zhangjiang_a,2026-05,18,86000,58,90500,143,58,58,sample +hongqiao_a,2026-05,9,77000,76,83500,121,94,76,sample diff --git a/data/sample/neighborhoods.csv b/data/sample/neighborhoods.csv new file mode 100644 index 0000000..197e46e --- /dev/null +++ b/data/sample/neighborhoods.csv @@ -0,0 +1,6 @@ +neighborhood_id,area_id,name,built_year,property_type,metro_distance_m,school_quality,notes +qiantan_a,qiantan,前滩样例小区A,2018,商品住宅,450,good,演示样例 +xujiahui_a,xujiahui,徐家汇样例小区A,2008,商品住宅,300,excellent,演示样例 +danning_a,danning,大宁样例小区A,2015,商品住宅,600,good,演示样例 +zhangjiang_a,zhangjiang,张江样例小区A,2016,商品住宅,700,normal,演示样例 +hongqiao_a,hongqiao,大虹桥样例小区A,2020,商品住宅,900,normal,演示样例 diff --git a/docs/config/environment.md b/docs/config/environment.md new file mode 100644 index 0000000..60b48a1 --- /dev/null +++ b/docs/config/environment.md @@ -0,0 +1,26 @@ +# 环境配置 + +不要把真实数据库密码写入仓库。开发时在当前 shell 中设置环境变量。 + +## API + +```bash +export DATABASE_URL="postgres://:@:/shanghai_housing_research_dev" +export API_HOST="127.0.0.1" +export API_PORT="8080" +export RUN_MIGRATIONS="true" +``` + +`RUN_MIGRATIONS=true` 会在 API 启动时运行 `apps/api/migrations` 中的 SQLx migrations。生产环境建议由单独的 migration job 执行。 + +## 数据库命名 + +建议使用: + +```text +shanghai_housing_research_dev +shanghai_housing_research_test +shanghai_housing_research +``` + +管理员账户只用于 bootstrap。应用运行建议使用专用低权限账户。 diff --git a/docs/data_dictionary.md b/docs/data_dictionary.md new file mode 100644 index 0000000..0132b62 --- /dev/null +++ b/docs/data_dictionary.md @@ -0,0 +1,40 @@ +# 数据字典 + +## areas + +板块基础信息。 + +| 字段 | 含义 | +| --- | --- | +| area_id | 板块唯一 ID | +| name | 板块名称 | +| district | 行政区 | +| segment | 板块类型 | +| notes | 备注 | + +## area_monthly_metrics + +板块月度指标。 + +| 字段 | 含义 | +| --- | --- | +| transaction_count | 二手或样本成交套数 | +| transaction_price_psm | 成交均价,元/平方米 | +| listing_count | 挂牌套数 | +| listing_price_psm | 挂牌均价,元/平方米 | +| median_days_on_market | 中位成交周期,天 | +| rent_price_psm | 月租金,元/平方米/月 | +| new_supply_units | 新增供应套数 | +| land_residential_gfa_sqm | 住宅土地建筑面积,平方米 | +| mortgage_rate_pct | 主流按揭利率 | +| policy_signal | 政策方向,-2 到 2 | + +## 派生指标 + +| 指标 | 公式 | +| --- | --- | +| 年化租金收益率 | 月租金 / 成交价 * 12 | +| 去化压力 | 挂牌套数 / 成交套数 | +| 成交折价 | (挂牌均价 - 成交均价) / 挂牌均价 | +| 价格动量 | 当月成交均价相对上月变化 | +| 成交动量 | 当月成交套数相对上月变化 | diff --git a/docs/research_framework.md b/docs/research_framework.md new file mode 100644 index 0000000..00bd3da --- /dev/null +++ b/docs/research_framework.md @@ -0,0 +1,56 @@ +# 上海房市投资研究框架 + +## 基本原则 + +长期研究的核心不是寻找一个万能预测因子,而是建立稳定、可审计、可复盘的判断系统。 + +第一版框架采用四个判断层次: + +1. 市场周期:成交量、成交周期、挂牌压力、信贷环境。 +2. 板块质量:产业、交通、公共资源、供给约束、租赁需求。 +3. 资产质量:小区楼龄、物业、产品稀缺性、流动性、硬伤。 +4. 交易安全边际:成交/挂牌折价、历史价格分位、租金收益率、替代品价格。 + +## 投资判断问题 + +系统应持续回答以下问题: + +- 上海整体处于放量、缩量、企稳、补跌还是结构性修复? +- 哪些板块成交量改善领先于价格? +- 哪些板块挂牌库存持续堆积? +- 租金收益率是否能支撑当前价格? +- 新房供应是否压制二手房议价能力? +- 政策和利率变化对改善、刚需、豪宅的影响是否不同? +- 哪些小区流动性足够好,能承担投资退出需求? +- 当前合理买入价相对挂牌价需要多少折价? + +## 模型路线 + +第一阶段使用可解释评分: + +- 流动性评分 +- 动量评分 +- 租金支撑评分 +- 安全边际评分 +- 信贷政策评分 +- 供应风险评分 + +第二阶段再加入: + +- 历史分位模型 +- 异常成交识别 +- 小区相似资产比较 +- 板块领先/滞后关系 +- 利率和政策情景推演 + +## 决策输出 + +最终输出应是具体动作,而不是抽象观点: + +- 重点研究 +- 观察池 +- 中性观望 +- 谨慎等待 +- 合理买入价区间 +- 触发买入条件 +- 触发退出条件 diff --git a/docs/system_architecture_and_development_plan.md b/docs/system_architecture_and_development_plan.md new file mode 100644 index 0000000..05dec77 --- /dev/null +++ b/docs/system_architecture_and_development_plan.md @@ -0,0 +1,543 @@ +# 上海房市投资研究系统架构与开发计划 + +## 0. 当前状态 + +当前项目是一个纯 Python 的研究内核 MVP: + +- SQLite 存储 +- 样例数据 +- 板块指标计算 +- Markdown 月报生成 +- 命令行入口 + +它的价值是验证研究口径和指标框架。它不是最终系统形态。后续应演进为一个具备数据采集、资产库、模型、API、前端看板、报告和预警能力的长期研究平台。 + +## 1. 系统定位 + +目标是搭建一个面向上海房市长期投资判断的研究操作系统。系统最终要回答: + +- 市场处在什么周期位置? +- 哪些板块值得重点跟踪? +- 哪些小区具有流动性、租金和稀缺性支撑? +- 合理买入价区间是多少? +- 什么信号触发买入、观望、卖出或换仓? +- 判断依据是否可复盘、可解释、可更新? + +系统不追求单点预测“下月涨跌”,而追求持续形成稳定的投资判断流程。 + +## 2. 总体架构 + +```mermaid +flowchart LR + A["数据源
官方/市场/手工/文档"] --> B["采集层
Ingestion"] + B --> C["原始数据层
Raw Lake"] + C --> D["标准化层
Normalized Store"] + D --> E["特征与指标层
Feature Mart"] + E --> F["模型层
Scoring/Forecast/Scenario"] + F --> G["API 层
Rust Axum"] + G --> H["前端
React + TypeScript + shadcn/ui"] + F --> I["报告与预警
周报/月报/观察池"] + D --> J["研究工作台
Notebook/CLI"] +``` + +## 3. 推荐技术路线 + +### 3.1 推荐结论 + +长期看,推荐采用混合架构: + +- 前端:TypeScript + React + shadcn/ui +- API 后端:Rust Axum + SQLx +- 分析与模型服务:Python,必要时引入 Rust 数据处理组件 +- 主数据库:PostgreSQL + PostGIS +- 本地分析引擎:DuckDB + Parquet +- 数据处理:Python Polars 起步,Rust Polars/DataFusion 用于稳定高性能任务 +- 后台任务:先用轻量 cron/脚本,后续升级 Prefect 或 Airflow + +本项目按长期平台建设,因此不采用 FastAPI 过渡。API 层从第一版就采用 Rust Axum,Python 保留为分析、建模、数据实验和离线任务服务。 + +### 3.2 为什么不是纯 Python + +纯 Python 很适合研究、建模和快速迭代,但当系统开始承载更多前端交互、权限、并发查询、任务调度和长期服务稳定性时,单体 Python 项目会逐渐变重。 + +更成熟的做法是: + +- Python 专注数据、模型、研究逻辑。 +- Rust API 专注服务边界、权限、查询、任务编排。 +- 前端专注研究工作台和投资决策界面。 + +### 3.3 为什么不是纯 Rust + +Rust 很适合高可靠服务和性能敏感 API,但房市研究最重的工作通常不是 API 性能,而是数据清洗、特征工程、统计建模、回测、报告解释和探索式分析。这些领域 Python 生态明显更成熟。 + +因此不建议把模型和研究逻辑全部用 Rust 重写。那会降低迭代速度。 + +## 4. Rust 后端 vs Python 后端 + +| 维度 | Rust 后端 | Python 后端 | +| --- | --- | --- | +| 性能 | 很强,适合高并发、低延迟、重查询网关 | 足够支撑早期和中等规模内部系统 | +| 稳定性 | 编译期约束强,运行期类型错误少 | 依赖测试和类型检查,长期维护需更强纪律 | +| 开发速度 | 初期慢,类型和生命周期成本更高 | 很快,尤其适合快速验证业务逻辑 | +| 数据科学生态 | 可用但不如 Python 丰富 | 极强,Polars、pandas、statsmodels、sklearn 等成熟 | +| API 工程 | Axum + SQLx 很稳,适合长期服务 | FastAPI 开发快,OpenAPI 友好 | +| 团队门槛 | 较高 | 较低 | +| 部署 | 单二进制优势明显 | 依赖环境和包管理,容器化后可控 | +| 与模型集成 | 通常需要调用 Python 服务或共享数据库 | 原生集成最顺 | +| 推荐阶段 | 从第一版开始承担主 API、权限、查询网关 | 只保留为模型服务、数据任务、研究实验 | + +推荐策略: + +1. 第一阶段:Rust Axum 建立主 API 和服务边界。 +2. Python 保持研究内核、数据处理、模型和报告生成。 +3. 前后端通过 OpenAPI 或共享 schema 对齐契约。 +4. 稳定、重复、高性能的数据处理任务可逐步迁入 Rust Polars/DataFusion。 + +本项目采用 Rust API + Python analytics 双服务作为默认路线。 + +## 4.1 Rust 数据处理 vs Python 数据处理 + +Rust 的数据处理能力并不弱。Polars 有 Rust 版本,DataFusion 本身就是基于 Rust 和 Apache Arrow 的高性能查询引擎,适合构建稳定、高性能、可嵌入的数据系统。 + +但对本系统来说,Python 仍然在以下方面更强: + +- 探索式分析更快,交互式 Notebook、临时统计、画图、验证假设都更顺手。 +- 统计建模和机器学习生态更完整,尤其是回归、时间序列、聚类、解释性分析、模型评估。 +- 采集、清洗、Excel/CSV、报告、可视化周边库更丰富。 +- 研究逻辑经常变化,Python 的迭代成本低。 + +Rust 更适合: + +- API 查询层和权限层。 +- 稳定的数据校验、标准化和导入管线。 +- 大批量 CSV/Parquet 扫描、聚合、特征预计算。 +- 需要长期运行、低内存占用、高并发的服务。 +- 已经沉淀稳定的数据处理逻辑。 + +因此判断不是“Rust 弱”,而是“Rust 更工程化,Python 更研究化”。本系统的最佳分工是: + +```text +Rust:API、数据库访问、权限、稳定导入、稳定特征计算、高性能查询 +Python:探索分析、模型实验、统计预测、报告解释、策略研究 +``` + +## 5. 前端方案 + +你的偏好是 TypeScript + React + shadcn/ui,这个方向适合本系统。 + +推荐栈: + +- Next.js App Router 或 Vite + React +- TypeScript +- shadcn/ui +- Tailwind CSS +- TanStack Query +- Recharts 或 ECharts +- MapLibre GL,用于地图和板块空间分析 +- Zod,用于前端数据校验 + +选择建议: + +- 如果要做完整产品、登录、路由、服务端渲染、报告页分享,选 Next.js。 +- 如果只是本地研究工作台和内部 SPA,选 Vite + React 更轻。 + +我建议采用 Next.js,因为后续报告页、板块详情页、观察池和权限体系会自然变复杂。 + +## 6. 前端信息架构 + +第一版前端应直接进入研究工作台,不做营销式首页。 + +核心页面: + +1. 市场总览 + - 上海整体成交、挂牌、库存、租金、利率、政策事件 + - 周期状态判断 + - 关键风险提示 + +2. 板块地图 + - 按综合评分、供应压力、租售比、价格动量着色 + - 支持行政区、环线、地铁、产业标签筛选 + +3. 板块详情 + - 成交/挂牌/租金走势 + - 新房供应与土地成交 + - 小区排行 + - 相似板块对比 + +4. 小区观察池 + - 自定义关注小区 + - 合理买入价 + - 挂牌变化 + - 成交样本 + - 风险标签 + +5. 策略与情景 + - 利率变化情景 + - 政策松紧情景 + - 收入/租金/供应变化情景 + +6. 报告中心 + - 周报 + - 月报 + - 板块专题 + - 投资备忘录 + +## 7. 后端模块设计 + +### 7.1 API 模块 + +职责: + +- 用户、权限、配置 +- 板块、小区、指标查询 +- 观察池管理 +- 报告查询 +- 模型结果查询 +- 任务触发和状态查询 + +典型接口: + +- `GET /api/market/overview?month=YYYY-MM` +- `GET /api/areas` +- `GET /api/areas/{area_id}/scores` +- `GET /api/areas/{area_id}/metrics` +- `GET /api/neighborhoods/{id}` +- `POST /api/watchlist` +- `GET /api/reports/monthly/{month}` +- `POST /api/jobs/ingest` + +### 7.2 Analytics 模块 + +职责: + +- 数据清洗 +- 特征生成 +- 板块评分 +- 小区评分 +- 异常检测 +- 情景推演 +- 报告生成 + +当前 Python 代码应逐步演进为这个模块。 + +### 7.3 Ingestion 模块 + +职责: + +- 官方数据导入 +- 市场数据导入 +- 手工 CSV/Excel 导入 +- 数据源版本记录 +- 失败重试 +- 采集日志 + +所有采集结果必须保留原始文件或原始响应,不直接覆盖。 + +### 7.4 Report 模块 + +职责: + +- 周报/月报 +- 板块专题 +- 小区备忘录 +- 组合观察报告 + +报告不应只是文本,应保留结构化结论,方便前端二次展示。 + +## 8. 数据架构 + +### 8.1 数据分层 + +```text +raw 原始数据,保留来源、采集时间、文件哈希 +bronze 初步解析,字段类型基本清理 +silver 标准化实体,统一板块、小区、月份、口径 +gold 指标、特征、模型输出、报告结论 +``` + +### 8.2 核心实体 + +- `areas`:板块 +- `districts`:行政区 +- `neighborhoods`:小区 +- `transactions`:成交样本 +- `listings`:挂牌样本 +- `rents`:租赁样本 +- `new_projects`:新房项目 +- `land_sales`:土地成交 +- `policy_events`:政策事件 +- `rates`:利率 +- `area_monthly_features`:板块月度特征 +- `neighborhood_monthly_features`:小区月度特征 +- `model_runs`:模型运行版本 +- `watchlists`:观察池 +- `reports`:报告 + +### 8.3 数据治理要求 + +每条关键数据至少记录: + +- 来源 +- 采集时间 +- 原始字段 +- 清洗规则版本 +- 是否估算 +- 是否人工修正 +- 置信度 + +这是系统长期可信的核心。 + +## 9. 模型体系 + +### 9.1 第一阶段:可解释评分 + +- 流动性评分 +- 价格动量评分 +- 成交动量评分 +- 租金支撑评分 +- 安全边际评分 +- 供应风险评分 +- 政策/信贷环境评分 + +输出: + +- 重点研究 +- 观察池 +- 中性观望 +- 谨慎等待 + +### 9.2 第二阶段:相对估值 + +- 同板块小区比较 +- 相似小区比较 +- 同总价段比较 +- 同楼龄/地铁距离/物业类型比较 +- 历史价格分位 + +输出: + +- 合理买入价区间 +- 溢价/折价解释 +- 替代标的推荐 + +### 9.3 第三阶段:预测与情景推演 + +先做情景推演,再做点预测。 + +情景变量: + +- 利率 +- 首付比例 +- 限购/限贷政策 +- 新房供应 +- 租金变化 +- 板块产业兑现 +- 人口和就业变化 + +输出: + +- 乐观/基准/谨慎情景 +- 价格压力区间 +- 流动性风险区间 +- 买入触发条件 + +## 10. 推荐仓库结构 + +```text +apps/ + web/ React + TypeScript + shadcn/ui + api/ Rust Axum + SQLx +services/ + analytics/ Python 指标、模型、报告 + ingestion/ 数据采集与导入任务 +packages/ + contracts/ OpenAPI schema / shared types + config/ 共享配置 +data/ + raw/ + bronze/ + silver/ + gold/ + sample/ +infra/ + docker-compose.yml + migrations/ +docs/ + system_architecture_and_development_plan.md + research_framework.md + data_dictionary.md +tests/ +``` + +当前 `src/shanghai_housing` 后续可以迁移到 `services/analytics`。 + +## 11. 开发路线 + +### Phase 0:研究内核验证,已开始 + +目标: + +- 建立基本数据表 +- 建立板块评分 +- 生成月报 +- 验证指标口径 + +状态: + +- 已完成 MVP。 + +### Phase 1:数据底座 + +目标: + +- 引入 PostgreSQL/PostGIS +- 建立 raw/bronze/silver/gold 数据分层 +- 建立数据源版本记录 +- 增加 CSV/Excel 导入 +- 增加官方数据手工导入模板 + +交付: + +- 可导入真实数据 +- 可追踪数据来源 +- 可复跑指标 + +### Phase 2:API 与前端骨架 + +目标: + +- 建立 API 服务 +- 建立 React 前端 +- 实现市场总览、板块列表、板块详情 +- 前端接入真实 API + +推荐实现: + +- 前端:Next.js + TypeScript + shadcn/ui +- API:Rust Axum + SQLx +- 数据库:PostgreSQL/PostGIS + +交付: + +- 可浏览市场总览 +- 可查看板块评分 +- 可生成并查看月报 + +### Phase 3:小区观察池 + +目标: + +- 小区资产库 +- 小区评分 +- 合理买入价 +- 关注列表 +- 风险标签 + +交付: + +- 小区筛选 +- 观察池 +- 标的对比 +- 投资备忘录 + +### Phase 4:模型与情景推演 + +目标: + +- 历史分位 +- 相似资产比较 +- 异常成交检测 +- 利率/政策/供应情景推演 + +交付: + +- 板块和小区估值解释 +- 买入价区间 +- 情景报告 + +### Phase 5:自动化与预警 + +目标: + +- 定时数据更新 +- 指标重算 +- 报告自动生成 +- 预警规则 + +交付: + +- 周报自动生成 +- 板块风险预警 +- 观察池价格变动提醒 + +## 12. 工程质量要求 + +### 12.1 数据质量 + +- 所有数据有来源和时间戳 +- 所有清洗规则版本化 +- 样本数据和真实数据严格隔离 +- 模型输出记录运行版本 + +### 12.2 API 质量 + +- OpenAPI 契约 +- 统一错误格式 +- 分页、排序、筛选 +- 请求日志 +- 慢查询追踪 + +### 12.3 前端质量 + +- 页面以研究工作流为中心 +- 表格、图表、地图是核心,不做装饰性首页 +- 所有图表口径可查看 +- 所有评分可解释 +- 重要结论可回溯到数据 + +### 12.4 测试 + +- 指标单元测试 +- 数据导入测试 +- API 契约测试 +- 前端组件测试 +- 端到端测试 + +## 13. 技术决策建议 + +推荐默认路线: + +```text +前端:Next.js + TypeScript + React + shadcn/ui +API:Rust Axum + SQLx +分析:Python + Polars + DuckDB +数据库:PostgreSQL + PostGIS +任务:先脚本/cron,后 Prefect 或 Airflow +图表:Recharts 起步,复杂图表可加入 ECharts +地图:MapLibre GL +``` + +Python 分析服务保留为长期组件: + +```text +API:Rust Axum 负责在线服务 +Analytics:Python 负责研究、建模、报告和实验 +Stable ETL:Rust Polars/DataFusion 可逐步承接稳定高性能任务 +Contract:OpenAPI 或共享 schema +``` + +这条路线从第一天就按强工程化平台建设,同时保留 Python 的研究效率。 + +## 14. 官方资料参考 + +- React TypeScript 文档:https://react.dev/learn/typescript +- shadcn/ui 文档:https://ui.shadcn.com/ +- Next.js 文档:https://nextjs.org/docs +- TanStack Query 文档:https://tanstack.com/query/latest/docs/framework/react/overview +- Recharts 文档:https://recharts.org/en-US/ +- Axum 文档:https://docs.rs/axum/latest/axum/ +- SQLx 文档:https://docs.rs/sqlx/latest/sqlx/ +- Polars 文档:https://docs.pola.rs/ +- DataFusion 文档:https://datafusion.apache.org/user-guide/introduction.html +- DuckDB 文档:https://duckdb.org/docs/ +- PostgreSQL 文档:https://www.postgresql.org/docs/ diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..b46e988 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,9 @@ +[project] +name = "shanghai-housing-research" +version = "0.1.0" +description = "A lightweight Shanghai housing market research and investment analysis system." +requires-python = ">=3.9" +dependencies = [] + +[tool.setuptools.packages.find] +where = ["src"] diff --git a/reports/.gitkeep b/reports/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/reports/.gitkeep @@ -0,0 +1 @@ + diff --git a/src/shanghai_housing/__init__.py b/src/shanghai_housing/__init__.py new file mode 100644 index 0000000..706fc73 --- /dev/null +++ b/src/shanghai_housing/__init__.py @@ -0,0 +1,3 @@ +"""Shanghai housing market research toolkit.""" + +__version__ = "0.1.0" diff --git a/src/shanghai_housing/__main__.py b/src/shanghai_housing/__main__.py new file mode 100644 index 0000000..a049ad7 --- /dev/null +++ b/src/shanghai_housing/__main__.py @@ -0,0 +1,5 @@ +from .cli import main + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/shanghai_housing/cli.py b/src/shanghai_housing/cli.py new file mode 100644 index 0000000..1523135 --- /dev/null +++ b/src/shanghai_housing/cli.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +import argparse +from pathlib import Path +from typing import Optional + +from .db import DEFAULT_DB_PATH, connect, init_db, load_sample_data +from .indicators import compute_area_scores +from .reporting import format_scores_table, write_monthly_report + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="shanghai_housing", + description="Shanghai housing market research toolkit.", + ) + parser.add_argument( + "--db", + type=Path, + default=DEFAULT_DB_PATH, + help=f"SQLite database path. Default: {DEFAULT_DB_PATH}", + ) + + subparsers = parser.add_subparsers(dest="command", required=True) + subparsers.add_parser("init-db", help="Create or update the SQLite schema.") + subparsers.add_parser("load-sample", help="Load demonstration sample data.") + + score_parser = subparsers.add_parser("score", help="Print area scores for a month.") + score_parser.add_argument("--month", required=True, help="Month in YYYY-MM format.") + + report_parser = subparsers.add_parser("report", help="Generate a monthly Markdown report.") + report_parser.add_argument("--month", required=True, help="Month in YYYY-MM format.") + report_parser.add_argument( + "--output", + type=Path, + help="Output Markdown path. Default: reports/monthly-YYYY-MM.md", + ) + + return parser + + +def main(argv: Optional[list[str]] = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + + if args.command == "init-db": + path = init_db(args.db) + print(f"Initialized database: {path}") + return 0 + + if args.command == "load-sample": + load_sample_data(args.db) + print(f"Loaded sample data into: {args.db}") + return 0 + + if args.command == "score": + with connect(args.db) as conn: + scores = compute_area_scores(conn, args.month) + print(format_scores_table(scores)) + return 0 + + if args.command == "report": + output = args.output or Path("reports") / f"monthly-{args.month}.md" + with connect(args.db) as conn: + path = write_monthly_report(conn, args.month, output) + print(f"Generated report: {path}") + return 0 + + parser.error(f"Unknown command: {args.command}") + return 2 diff --git a/src/shanghai_housing/db.py b/src/shanghai_housing/db.py new file mode 100644 index 0000000..8e7fa3c --- /dev/null +++ b/src/shanghai_housing/db.py @@ -0,0 +1,109 @@ +from __future__ import annotations + +import csv +import sqlite3 +from pathlib import Path +from typing import Iterable, Mapping, Union + + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +DEFAULT_DB_PATH = PROJECT_ROOT / "data" / "shanghai_housing.sqlite" +SCHEMA_PATH = Path(__file__).with_name("schema.sql") +SAMPLE_DIR = PROJECT_ROOT / "data" / "sample" + + +def connect(db_path: Union[Path, str] = DEFAULT_DB_PATH) -> sqlite3.Connection: + conn = sqlite3.connect(str(db_path)) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA foreign_keys = ON") + return conn + + +def init_db(db_path: Union[Path, str] = DEFAULT_DB_PATH) -> Path: + path = Path(db_path) + path.parent.mkdir(parents=True, exist_ok=True) + with connect(path) as conn: + conn.executescript(SCHEMA_PATH.read_text(encoding="utf-8")) + return path + + +def load_sample_data(db_path: Union[Path, str] = DEFAULT_DB_PATH) -> None: + init_db(db_path) + with connect(db_path) as conn: + clear_tables( + conn, + [ + "neighborhood_monthly_metrics", + "neighborhoods", + "area_monthly_metrics", + "policy_events", + "areas", + ], + ) + load_csv(conn, "areas", SAMPLE_DIR / "areas.csv") + load_csv(conn, "area_monthly_metrics", SAMPLE_DIR / "area_monthly_metrics.csv") + load_csv(conn, "neighborhoods", SAMPLE_DIR / "neighborhoods.csv") + load_csv( + conn, + "neighborhood_monthly_metrics", + SAMPLE_DIR / "neighborhood_monthly_metrics.csv", + ) + + +def clear_tables(conn: sqlite3.Connection, tables: Iterable[str]) -> None: + for table in tables: + conn.execute(f"DELETE FROM {table}") + + +def load_csv(conn: sqlite3.Connection, table: str, path: Path) -> int: + with path.open("r", encoding="utf-8-sig", newline="") as handle: + rows = list(csv.DictReader(handle)) + if not rows: + return 0 + + columns = list(rows[0].keys()) + placeholders = ", ".join(["?"] * len(columns)) + column_sql = ", ".join(columns) + update_sql = ", ".join([f"{column}=excluded.{column}" for column in columns]) + sql = ( + f"INSERT INTO {table} ({column_sql}) VALUES ({placeholders}) " + f"ON CONFLICT DO UPDATE SET {update_sql}" + ) + conn.executemany(sql, [tuple(row[column] for column in columns) for row in rows]) + return len(rows) + + +def fetch_area_metrics(conn: sqlite3.Connection, month: str) -> list[sqlite3.Row]: + return conn.execute( + """ + SELECT + a.area_id, + a.name, + a.district, + a.segment, + m.* + FROM area_monthly_metrics m + JOIN areas a ON a.area_id = m.area_id + WHERE m.month = ? + ORDER BY a.district, a.name + """, + (month,), + ).fetchall() + + +def fetch_area_history( + conn: sqlite3.Connection, area_id: str, through_month: str +) -> list[sqlite3.Row]: + return conn.execute( + """ + SELECT * + FROM area_monthly_metrics + WHERE area_id = ? AND month <= ? + ORDER BY month + """, + (area_id, through_month), + ).fetchall() + + +def row_to_dict(row: Union[sqlite3.Row, Mapping[str, object]]) -> dict[str, object]: + return {key: row[key] for key in row.keys()} if isinstance(row, sqlite3.Row) else dict(row) diff --git a/src/shanghai_housing/indicators.py b/src/shanghai_housing/indicators.py new file mode 100644 index 0000000..45aea13 --- /dev/null +++ b/src/shanghai_housing/indicators.py @@ -0,0 +1,212 @@ +from __future__ import annotations + +import sqlite3 +from dataclasses import asdict, dataclass +from datetime import date +from typing import Optional + +from .db import fetch_area_history, fetch_area_metrics + + +@dataclass(frozen=True) +class AreaScore: + area_id: str + name: str + district: str + segment: str + month: str + investment_score: float + recommendation: str + liquidity_score: float + momentum_score: float + rent_support_score: float + safety_margin_score: float + credit_support_score: float + supply_risk_score: float + valuation_pressure_score: float + transaction_count: int + transaction_price_psm: float + listing_count: int + listing_price_psm: float + rent_price_psm: float + median_days_on_market: float + annual_rent_yield_pct: float + listing_pressure_ratio: float + discount_pct: float + price_momentum_pct: float + volume_momentum_pct: float + + def to_dict(self) -> dict[str, object]: + return asdict(self) + + +def previous_month(month: str) -> str: + year, month_num = [int(part) for part in month.split("-")] + current = date(year, month_num, 1) + if current.month == 1: + return f"{current.year - 1}-12" + return f"{current.year}-{current.month - 1:02d}" + + +def compute_area_scores(conn: sqlite3.Connection, month: str) -> list[AreaScore]: + current_rows = fetch_area_metrics(conn, month) + if not current_rows: + raise ValueError(f"No area metrics found for month {month}.") + + prior_month = previous_month(month) + prior_rows = {row["area_id"]: row for row in fetch_area_metrics(conn, prior_month)} + max_transactions = max(float(row["transaction_count"]) for row in current_rows) or 1.0 + + scores = [ + score_area(row, prior_rows.get(row["area_id"]), max_transactions, conn) + for row in current_rows + ] + return sorted(scores, key=lambda score: score.investment_score, reverse=True) + + +def score_area( + row: sqlite3.Row, + prior_row: Optional[sqlite3.Row], + max_transactions: float, + conn: sqlite3.Connection, +) -> AreaScore: + transaction_count = int(row["transaction_count"]) + transaction_price = float(row["transaction_price_psm"]) + listing_count = int(row["listing_count"]) + listing_price = float(row["listing_price_psm"]) + rent_price = float(row["rent_price_psm"]) + days_on_market = float(row["median_days_on_market"]) + new_supply_units = int(row["new_supply_units"]) + mortgage_rate = float(row["mortgage_rate_pct"]) + policy_signal = int(row["policy_signal"]) + + annual_rent_yield = safe_div(rent_price * 12, transaction_price) * 100 + listing_pressure = safe_div(listing_count, transaction_count) + supply_ratio = safe_div(new_supply_units, transaction_count) + discount_pct = safe_div(listing_price - transaction_price, listing_price) * 100 + price_momentum = momentum_pct( + transaction_price, + float(prior_row["transaction_price_psm"]) if prior_row else None, + ) + volume_momentum = momentum_pct( + transaction_count, + int(prior_row["transaction_count"]) if prior_row else None, + ) + + history = fetch_area_history(conn, row["area_id"], row["month"]) + price_percentile = historical_percentile( + transaction_price, + [float(item["transaction_price_psm"]) for item in history], + ) + + liquidity_score = clamp( + 0.65 * (transaction_count / max_transactions * 100) + + 0.35 * low_better(days_on_market, good=45, bad=120) + ) + momentum_score = clamp( + 0.55 * high_better(volume_momentum, bad=-20, good=25) + + 0.45 * high_better(price_momentum, bad=-3, good=4) + ) + rent_support_score = high_better(annual_rent_yield, bad=1.0, good=2.4) + supply_risk_score = clamp( + 0.60 * high_better(listing_pressure, bad=3.5, good=9.0) + + 0.40 * high_better(supply_ratio, bad=0.5, good=4.0) + ) + valuation_pressure_score = clamp( + 0.60 * price_percentile + 0.40 * (100 - rent_support_score) + ) + safety_margin_score = clamp( + 0.55 * high_better(discount_pct, bad=1.5, good=8.0) + + 0.45 * (100 - valuation_pressure_score) + ) + credit_support_score = clamp( + 0.70 * low_better(mortgage_rate, good=3.2, bad=5.0) + + 0.30 * ((policy_signal + 2) / 4 * 100) + ) + + investment_score = clamp( + 0.25 * liquidity_score + + 0.20 * momentum_score + + 0.20 * rent_support_score + + 0.15 * safety_margin_score + + 0.10 * credit_support_score + + 0.10 * (100 - supply_risk_score) + ) + + return AreaScore( + area_id=row["area_id"], + name=row["name"], + district=row["district"], + segment=row["segment"], + month=row["month"], + investment_score=round(investment_score, 1), + recommendation=recommendation(investment_score), + liquidity_score=round(liquidity_score, 1), + momentum_score=round(momentum_score, 1), + rent_support_score=round(rent_support_score, 1), + safety_margin_score=round(safety_margin_score, 1), + credit_support_score=round(credit_support_score, 1), + supply_risk_score=round(supply_risk_score, 1), + valuation_pressure_score=round(valuation_pressure_score, 1), + transaction_count=transaction_count, + transaction_price_psm=round(transaction_price, 1), + listing_count=listing_count, + listing_price_psm=round(listing_price, 1), + rent_price_psm=round(rent_price, 1), + median_days_on_market=round(days_on_market, 1), + annual_rent_yield_pct=round(annual_rent_yield, 2), + listing_pressure_ratio=round(listing_pressure, 2), + discount_pct=round(discount_pct, 2), + price_momentum_pct=round(price_momentum, 2), + volume_momentum_pct=round(volume_momentum, 2), + ) + + +def safe_div(numerator: float, denominator: float) -> float: + return numerator / denominator if denominator else 0.0 + + +def momentum_pct(current: float, previous: Optional[float]) -> float: + if previous is None or previous == 0: + return 0.0 + return (current - previous) / previous * 100 + + +def historical_percentile(current: float, values: list[float]) -> float: + if not values: + return 50.0 + low = min(values) + high = max(values) + if high == low: + return 50.0 + return clamp((current - low) / (high - low) * 100) + + +def high_better(value: float, bad: float, good: float) -> float: + if value <= bad: + return 0.0 + if value >= good: + return 100.0 + return clamp((value - bad) / (good - bad) * 100) + + +def low_better(value: float, good: float, bad: float) -> float: + if value <= good: + return 100.0 + if value >= bad: + return 0.0 + return clamp((bad - value) / (bad - good) * 100) + + +def clamp(value: float, lower: float = 0.0, upper: float = 100.0) -> float: + return max(lower, min(upper, value)) + + +def recommendation(score: float) -> str: + if score >= 75: + return "重点研究" + if score >= 65: + return "观察池" + if score >= 50: + return "中性观望" + return "谨慎等待" diff --git a/src/shanghai_housing/reporting.py b/src/shanghai_housing/reporting.py new file mode 100644 index 0000000..9ba53bc --- /dev/null +++ b/src/shanghai_housing/reporting.py @@ -0,0 +1,131 @@ +from __future__ import annotations + +import sqlite3 +from pathlib import Path + +from .indicators import AreaScore, compute_area_scores + + +def build_monthly_report(conn: sqlite3.Connection, month: str) -> str: + scores = compute_area_scores(conn, month) + top = scores[0] + weakest = scores[-1] + high_supply_risk = max(scores, key=lambda item: item.supply_risk_score) + avg_score = sum(score.investment_score for score in scores) / len(scores) + avg_yield = sum(score.annual_rent_yield_pct for score in scores) / len(scores) + + lines = [ + f"# 上海房市投资研究月报 {month}", + "", + "> 本报告由本地研究系统生成。当前数据若来自 `data/sample`,仅用于演示方法,不构成真实投资建议。", + "", + "## 核心结论", + "", + f"- 综合评分最高板块:{top.name}({top.investment_score},{top.recommendation})。", + f"- 综合评分最低板块:{weakest.name}({weakest.investment_score},{weakest.recommendation})。", + f"- 供应压力最高板块:{high_supply_risk.name}(供应风险 {high_supply_risk.supply_risk_score})。", + f"- 样本平均投资观察评分:{avg_score:.1f};样本平均年化租金收益率:{avg_yield:.2f}%。", + "", + "## 板块评分", + "", + "| 排名 | 板块 | 行政区 | 类型 | 综合分 | 建议 | 流动性 | 动量 | 租金支撑 | 安全边际 | 供应风险 |", + "| --- | --- | --- | --- | ---: | --- | ---: | ---: | ---: | ---: | ---: |", + ] + + for index, score in enumerate(scores, start=1): + lines.append( + "| {rank} | {name} | {district} | {segment} | {investment_score:.1f} | " + "{recommendation} | {liquidity_score:.1f} | {momentum_score:.1f} | " + "{rent_support_score:.1f} | {safety_margin_score:.1f} | {supply_risk_score:.1f} |".format( + rank=index, + name=score.name, + district=score.district, + segment=score.segment, + investment_score=score.investment_score, + recommendation=score.recommendation, + liquidity_score=score.liquidity_score, + momentum_score=score.momentum_score, + rent_support_score=score.rent_support_score, + safety_margin_score=score.safety_margin_score, + supply_risk_score=score.supply_risk_score, + ) + ) + + lines.extend( + [ + "", + "## 关键指标明细", + "", + "| 板块 | 成交套数 | 成交均价/㎡ | 挂牌套数 | 挂牌均价/㎡ | 去化压力 | 成交周期 | 年化租金收益率 | 成交折价 | 价格动量 | 成交动量 |", + "| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |", + ] + ) + + for score in scores: + lines.append( + "| {name} | {transaction_count} | {transaction_price_psm:,.0f} | {listing_count} | " + "{listing_price_psm:,.0f} | {listing_pressure_ratio:.2f} | " + "{median_days_on_market:.0f}天 | {annual_rent_yield_pct:.2f}% | " + "{discount_pct:.2f}% | {price_momentum_pct:.2f}% | {volume_momentum_pct:.2f}% |".format( + **score.to_dict() + ) + ) + + lines.extend( + [ + "", + "## 模型口径", + "", + "- 综合分由流动性、成交/价格动量、租金支撑、安全边际、信贷政策环境、供应风险共同决定。", + "- 供应风险越高表示挂牌库存、新增供应相对成交越重;综合分会对其反向处理。", + "- 安全边际主要来自成交/挂牌折价和历史价格分位。", + "- 第一版模型适合建立观察池,不适合单独作为买卖决策。", + "", + "## 下一步人工复核", + "", + "- 检查高分板块内的小区分化,剔除楼龄、物业、噪音、硬伤户型等不可量化风险。", + "- 对供应风险高的板块追踪新房开盘节奏、竞品库存和开发商价格策略。", + "- 将政策事件和信贷变化作为情景变量,而不是简单线性外推。", + "", + ] + ) + return "\n".join(lines) + + +def write_monthly_report( + conn: sqlite3.Connection, month: str, output_path: Path +) -> Path: + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(build_monthly_report(conn, month), encoding="utf-8") + return output_path + + +def format_scores_table(scores: list[AreaScore]) -> str: + rows = [ + ( + score.name, + f"{score.investment_score:.1f}", + score.recommendation, + f"{score.liquidity_score:.1f}", + f"{score.momentum_score:.1f}", + f"{score.rent_support_score:.1f}", + f"{score.supply_risk_score:.1f}", + ) + for score in scores + ] + header = ("板块", "综合分", "建议", "流动性", "动量", "租金", "供应风险") + return render_plain_table(header, rows) + + +def render_plain_table(header: tuple[str, ...], rows: list[tuple[str, ...]]) -> str: + widths = [ + max(len(str(row[index])) for row in [header, *rows]) + for index in range(len(header)) + ] + lines = [format_table_row(header, widths), format_table_row(tuple("-" * width for width in widths), widths)] + lines.extend(format_table_row(row, widths) for row in rows) + return "\n".join(lines) + + +def format_table_row(row: tuple[str, ...], widths: list[int]) -> str: + return " ".join(str(value).ljust(widths[index]) for index, value in enumerate(row)) diff --git a/src/shanghai_housing/schema.sql b/src/shanghai_housing/schema.sql new file mode 100644 index 0000000..0200411 --- /dev/null +++ b/src/shanghai_housing/schema.sql @@ -0,0 +1,68 @@ +PRAGMA foreign_keys = ON; + +CREATE TABLE IF NOT EXISTS areas ( + area_id TEXT PRIMARY KEY, + name TEXT NOT NULL, + district TEXT NOT NULL, + segment TEXT NOT NULL, + notes TEXT DEFAULT '' +); + +CREATE TABLE IF NOT EXISTS area_monthly_metrics ( + area_id TEXT NOT NULL, + month TEXT NOT NULL, + transaction_count INTEGER NOT NULL CHECK (transaction_count >= 0), + transaction_price_psm REAL NOT NULL CHECK (transaction_price_psm >= 0), + listing_count INTEGER NOT NULL CHECK (listing_count >= 0), + listing_price_psm REAL NOT NULL CHECK (listing_price_psm >= 0), + median_days_on_market REAL NOT NULL CHECK (median_days_on_market >= 0), + rent_price_psm REAL NOT NULL CHECK (rent_price_psm >= 0), + new_supply_units INTEGER NOT NULL CHECK (new_supply_units >= 0), + land_residential_gfa_sqm REAL NOT NULL CHECK (land_residential_gfa_sqm >= 0), + mortgage_rate_pct REAL NOT NULL CHECK (mortgage_rate_pct >= 0), + policy_signal INTEGER NOT NULL DEFAULT 0 CHECK (policy_signal BETWEEN -2 AND 2), + source TEXT NOT NULL DEFAULT 'manual', + PRIMARY KEY (area_id, month), + FOREIGN KEY (area_id) REFERENCES areas(area_id) +); + +CREATE TABLE IF NOT EXISTS neighborhoods ( + neighborhood_id TEXT PRIMARY KEY, + area_id TEXT NOT NULL, + name TEXT NOT NULL, + built_year INTEGER, + property_type TEXT NOT NULL, + metro_distance_m INTEGER, + school_quality TEXT, + notes TEXT DEFAULT '', + FOREIGN KEY (area_id) REFERENCES areas(area_id) +); + +CREATE TABLE IF NOT EXISTS neighborhood_monthly_metrics ( + neighborhood_id TEXT NOT NULL, + month TEXT NOT NULL, + transaction_count INTEGER NOT NULL CHECK (transaction_count >= 0), + transaction_price_psm REAL NOT NULL CHECK (transaction_price_psm >= 0), + listing_count INTEGER NOT NULL CHECK (listing_count >= 0), + listing_price_psm REAL NOT NULL CHECK (listing_price_psm >= 0), + rent_price_psm REAL NOT NULL CHECK (rent_price_psm >= 0), + median_days_on_market REAL NOT NULL CHECK (median_days_on_market >= 0), + available_units INTEGER NOT NULL CHECK (available_units >= 0), + source TEXT NOT NULL DEFAULT 'manual', + PRIMARY KEY (neighborhood_id, month), + FOREIGN KEY (neighborhood_id) REFERENCES neighborhoods(neighborhood_id) +); + +CREATE TABLE IF NOT EXISTS policy_events ( + event_id INTEGER PRIMARY KEY AUTOINCREMENT, + event_date TEXT NOT NULL, + level TEXT NOT NULL, + title TEXT NOT NULL, + impact_direction INTEGER NOT NULL CHECK (impact_direction BETWEEN -2 AND 2), + notes TEXT DEFAULT '', + source_url TEXT +); + +CREATE INDEX IF NOT EXISTS idx_area_metrics_month ON area_monthly_metrics(month); +CREATE INDEX IF NOT EXISTS idx_neighborhood_metrics_month ON neighborhood_monthly_metrics(month); +CREATE INDEX IF NOT EXISTS idx_policy_events_date ON policy_events(event_date); diff --git a/tests/test_indicators.py b/tests/test_indicators.py new file mode 100644 index 0000000..1e58e05 --- /dev/null +++ b/tests/test_indicators.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +import tempfile +import unittest +from pathlib import Path + +from shanghai_housing.db import connect, load_sample_data +from shanghai_housing.indicators import compute_area_scores, previous_month + + +class IndicatorTests(unittest.TestCase): + def test_previous_month_handles_year_boundary(self) -> None: + self.assertEqual(previous_month("2026-01"), "2025-12") + self.assertEqual(previous_month("2026-05"), "2026-04") + + def test_area_scores_are_bounded_and_sorted(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + db_path = Path(tmpdir) / "test.sqlite" + load_sample_data(db_path) + with connect(db_path) as conn: + scores = compute_area_scores(conn, "2026-05") + + self.assertEqual(len(scores), 5) + self.assertEqual( + [score.investment_score for score in scores], + sorted([score.investment_score for score in scores], reverse=True), + ) + for score in scores: + self.assertGreaterEqual(score.investment_score, 0) + self.assertLessEqual(score.investment_score, 100) + self.assertGreaterEqual(score.supply_risk_score, 0) + self.assertLessEqual(score.supply_risk_score, 100) + + +if __name__ == "__main__": + unittest.main()