From 8e308753515a258686967eb32f2b85228cbf00d9 Mon Sep 17 00:00:00 2001 From: Tianyu Liu Date: Fri, 12 Sep 2025 15:05:01 +0000 Subject: [PATCH 01/16] add model wip --- backend/dev-requirements.in | 2 +- backend/dev-requirements.txt | 166 ++++++++++++++++++++++++------ backend/requirements.in | 3 +- backend/requirements.txt | 121 ++++++++++++++++++++++ backend/trading_journal/models.py | 63 ++++++++++++ 5 files changed, 320 insertions(+), 35 deletions(-) create mode 100644 backend/trading_journal/models.py diff --git a/backend/dev-requirements.in b/backend/dev-requirements.in index f2578c6..d9cb060 100644 --- a/backend/dev-requirements.in +++ b/backend/dev-requirements.in @@ -1,2 +1,2 @@ --r requirements.txt +-r requirements.in pytest \ No newline at end of file diff --git a/backend/dev-requirements.txt b/backend/dev-requirements.txt index 14da341..0cab2f5 100644 --- a/backend/dev-requirements.txt +++ b/backend/dev-requirements.txt @@ -7,55 +7,101 @@ annotated-types==0.7.0 \ --hash=sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53 \ --hash=sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89 - # via - # -r requirements.txt - # pydantic + # via pydantic anyio==4.10.0 \ --hash=sha256:3f3fae35c96039744587aa5b8371e7e8e603c0702999535961dd336026973ba6 \ --hash=sha256:60e474ac86736bbfd6f210f7a61218939c318f43f9972497381f1c5e930ed3d1 # via - # -r requirements.txt # httpx # starlette certifi==2025.8.3 \ --hash=sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407 \ --hash=sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5 # via - # -r requirements.txt # httpcore # httpx click==8.2.1 \ --hash=sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202 \ --hash=sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b - # via - # -r requirements.txt - # uvicorn + # via uvicorn fastapi==0.116.1 \ --hash=sha256:c46ac7c312df840f0c9e220f7964bada936781bc4e2e6eb71f1c4d7553786565 \ --hash=sha256:ed52cbf946abfd70c5a0dccb24673f0670deeb517a88b3544d03c2a6bf283143 - # via -r requirements.txt + # via -r requirements.in +greenlet==3.2.4 \ + --hash=sha256:00fadb3fedccc447f517ee0d3fd8fe49eae949e1cd0f6a611818f4f6fb7dc83b \ + --hash=sha256:061dc4cf2c34852b052a8620d40f36324554bc192be474b9e9770e8c042fd735 \ + --hash=sha256:0db5594dce18db94f7d1650d7489909b57afde4c580806b8d9203b6e79cdc079 \ + --hash=sha256:0dca0d95ff849f9a364385f36ab49f50065d76964944638be9691e1832e9f86d \ + --hash=sha256:16458c245a38991aa19676900d48bd1a6f2ce3e16595051a4db9d012154e8433 \ + --hash=sha256:18d9260df2b5fbf41ae5139e1be4e796d99655f023a636cd0e11e6406cca7d58 \ + --hash=sha256:1987de92fec508535687fb807a5cea1560f6196285a4cde35c100b8cd632cc52 \ + --hash=sha256:1a921e542453fe531144e91e1feedf12e07351b1cf6c9e8a3325ea600a715a31 \ + --hash=sha256:1ee8fae0519a337f2329cb78bd7a8e128ec0f881073d43f023c7b8d4831d5246 \ + --hash=sha256:20fb936b4652b6e307b8f347665e2c615540d4b42b3b4c8a321d8286da7e520f \ + --hash=sha256:23768528f2911bcd7e475210822ffb5254ed10d71f4028387e5a99b4c6699671 \ + --hash=sha256:2523e5246274f54fdadbce8494458a2ebdcdbc7b802318466ac5606d3cded1f8 \ + --hash=sha256:27890167f55d2387576d1f41d9487ef171849ea0359ce1510ca6e06c8bece11d \ + --hash=sha256:299fd615cd8fc86267b47597123e3f43ad79c9d8a22bebdce535e53550763e2f \ + --hash=sha256:3b3812d8d0c9579967815af437d96623f45c0f2ae5f04e366de62a12d83a8fb0 \ + --hash=sha256:3b67ca49f54cede0186854a008109d6ee71f66bd57bb36abd6d0a0267b540cdd \ + --hash=sha256:44358b9bf66c8576a9f57a590d5f5d6e72fa4228b763d0e43fee6d3b06d3a337 \ + --hash=sha256:49a30d5fda2507ae77be16479bdb62a660fa51b1eb4928b524975b3bde77b3c0 \ + --hash=sha256:4d1378601b85e2e5171b99be8d2dc85f594c79967599328f95c1dc1a40f1c633 \ + --hash=sha256:554b03b6e73aaabec3745364d6239e9e012d64c68ccd0b8430c64ccc14939a8b \ + --hash=sha256:55e9c5affaa6775e2c6b67659f3a71684de4c549b3dd9afca3bc773533d284fa \ + --hash=sha256:58b97143c9cc7b86fc458f215bd0932f1757ce649e05b640fea2e79b54cedb31 \ + --hash=sha256:5c9320971821a7cb77cfab8d956fa8e39cd07ca44b6070db358ceb7f8797c8c9 \ + --hash=sha256:65458b409c1ed459ea899e939f0e1cdb14f58dbc803f2f93c5eab5694d32671b \ + --hash=sha256:671df96c1f23c4a0d4077a325483c1503c96a1b7d9db26592ae770daa41233d4 \ + --hash=sha256:710638eb93b1fa52823aa91bf75326f9ecdfd5e0466f00789246a5280f4ba0fc \ + --hash=sha256:73f49b5368b5359d04e18d15828eecc1806033db5233397748f4ca813ff1056c \ + --hash=sha256:81701fd84f26330f0d5f4944d4e92e61afe6319dcd9775e39396e39d7c3e5f98 \ + --hash=sha256:8854167e06950ca75b898b104b63cc646573aa5fef1353d4508ecdd1ee76254f \ + --hash=sha256:8c68325b0d0acf8d91dde4e6f930967dd52a5302cd4062932a6b2e7c2969f47c \ + --hash=sha256:94385f101946790ae13da500603491f04a76b6e4c059dab271b3ce2e283b2590 \ + --hash=sha256:94abf90142c2a18151632371140b3dba4dee031633fe614cb592dbb6c9e17bc3 \ + --hash=sha256:96378df1de302bc38e99c3a9aa311967b7dc80ced1dcc6f171e99842987882a2 \ + --hash=sha256:9c40adce87eaa9ddb593ccb0fa6a07caf34015a29bf8d344811665b573138db9 \ + --hash=sha256:9fe0a28a7b952a21e2c062cd5756d34354117796c6d9215a87f55e38d15402c5 \ + --hash=sha256:a7d4e128405eea3814a12cc2605e0e6aedb4035bf32697f72deca74de4105e02 \ + --hash=sha256:abbf57b5a870d30c4675928c37278493044d7c14378350b3aa5d484fa65575f0 \ + --hash=sha256:b4a1870c51720687af7fa3e7cda6d08d801dae660f75a76f3845b642b4da6ee1 \ + --hash=sha256:b6a7c19cf0d2742d0809a4c05975db036fdff50cd294a93632d6a310bf9ac02c \ + --hash=sha256:b90654e092f928f110e0007f572007c9727b5265f7632c2fa7415b4689351594 \ + --hash=sha256:c17b6b34111ea72fc5a4e4beec9711d2226285f0386ea83477cbb97c30a3f3a5 \ + --hash=sha256:c2ca18a03a8cfb5b25bc1cbe20f3d9a4c80d8c3b13ba3df49ac3961af0b1018d \ + --hash=sha256:c5111ccdc9c88f423426df3fd1811bfc40ed66264d35aa373420a34377efc98a \ + --hash=sha256:c60a6d84229b271d44b70fb6e5fa23781abb5d742af7b808ae3f6efd7c9c60f6 \ + --hash=sha256:c8c9e331e58180d0d83c5b7999255721b725913ff6bc6cf39fa2a45841a4fd4b \ + --hash=sha256:c9913f1a30e4526f432991f89ae263459b1c64d1608c0d22a5c79c287b3c70df \ + --hash=sha256:cd3c8e693bff0fff6ba55f140bf390fa92c994083f838fece0f63be121334945 \ + --hash=sha256:d25c5091190f2dc0eaa3f950252122edbbadbb682aa7b1ef2f8af0f8c0afefae \ + --hash=sha256:d2e685ade4dafd447ede19c31277a224a239a0a1a4eca4e6390efedf20260cfb \ + --hash=sha256:d76383238584e9711e20ebe14db6c88ddcedc1829a9ad31a584389463b5aa504 \ + --hash=sha256:ddf9164e7a5b08e9d22511526865780a576f19ddd00d62f8a665949327fde8bb \ + --hash=sha256:e37ab26028f12dbb0ff65f29a8d3d44a765c61e729647bf2ddfbbed621726f01 \ + --hash=sha256:f10fd42b5ee276335863712fa3da6608e93f70629c631bf77145021600abc23c \ + --hash=sha256:f28588772bb5fb869a8eb331374ec06f24a83a9c25bfa1f38b6993afe9c1e968 + # via sqlalchemy h11==0.16.0 \ --hash=sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1 \ --hash=sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86 # via - # -r requirements.txt # httpcore # uvicorn httpcore==1.0.9 \ --hash=sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55 \ --hash=sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8 - # via - # -r requirements.txt - # httpx + # via httpx httpx==0.28.1 \ --hash=sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc \ --hash=sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad - # via -r requirements.txt + # via -r requirements.in idna==3.10 \ --hash=sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9 \ --hash=sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3 # via - # -r requirements.txt # anyio # httpx iniconfig==2.1.0 \ @@ -74,9 +120,9 @@ pydantic==2.11.7 \ --hash=sha256:d989c3c6cb79469287b1569f7447a17848c998458d49ebe294e975b9baf0f0db \ --hash=sha256:dde5df002701f6de26248661f6835bbe296a47bf73990135c7d07ce741b9623b # via - # -r requirements.txt # fastapi # pydantic-settings + # sqlmodel pydantic-core==2.33.2 \ --hash=sha256:0069c9acc3f3981b9ff4cdfaf088e98d83440a4c7ea1bc07460af3d4dc22e72d \ --hash=sha256:031c57d67ca86902726e0fae2214ce6770bbe2f710dc33063187a68744a5ecac \ @@ -177,13 +223,11 @@ pydantic-core==2.33.2 \ --hash=sha256:fa754d1850735a0b0e03bcffd9d4b4343eb417e47196e4485d9cca326073a42c \ --hash=sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6 \ --hash=sha256:fe5b32187cbc0c862ee201ad66c30cf218e5ed468ec8dc1cf49dec66e160cc4d - # via - # -r requirements.txt - # pydantic + # via pydantic pydantic-settings==2.10.1 \ --hash=sha256:06f0062169818d0f5524420a360d632d5857b83cffd4d42fe29597807a1614ee \ --hash=sha256:a60952460b99cf661dc25c29c0ef171721f98bfcb52ef8d9ea4c943d7c8cc796 - # via -r requirements.txt + # via -r requirements.in pygments==2.19.2 \ --hash=sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887 \ --hash=sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b @@ -195,9 +239,7 @@ pytest==8.4.2 \ python-dotenv==1.1.1 \ --hash=sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc \ --hash=sha256:a8a6399716257f45be6a007360200409fce5cda2661e3dec71d23dc15f6189ab - # via - # -r requirements.txt - # pydantic-settings + # via pydantic-settings pyyaml==6.0.2 \ --hash=sha256:01179a4a8559ab5de078078f37e5c1a30d76bb88519906844fd7bdea1b7729ff \ --hash=sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48 \ @@ -252,38 +294,96 @@ pyyaml==6.0.2 \ --hash=sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba \ --hash=sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12 \ --hash=sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4 - # via -r requirements.txt + # via -r requirements.in sniffio==1.3.1 \ --hash=sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2 \ --hash=sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc - # via - # -r requirements.txt - # anyio + # via anyio +sqlalchemy==2.0.43 \ + --hash=sha256:022e436a1cb39b13756cf93b48ecce7aa95382b9cfacceb80a7d263129dfd019 \ + --hash=sha256:03d73ab2a37d9e40dec4984d1813d7878e01dbdc742448d44a7341b7a9f408c7 \ + --hash=sha256:07097c0a1886c150ef2adba2ff7437e84d40c0f7dcb44a2c2b9c905ccfc6361c \ + --hash=sha256:11b9503fa6f8721bef9b8567730f664c5a5153d25e247aadc69247c4bc605227 \ + --hash=sha256:11f43c39b4b2ec755573952bbcc58d976779d482f6f832d7f33a8d869ae891bf \ + --hash=sha256:13194276e69bb2af56198fef7909d48fd34820de01d9c92711a5fa45497cc7ed \ + --hash=sha256:136063a68644eca9339d02e6693932116f6a8591ac013b0014479a1de664e40a \ + --hash=sha256:14111d22c29efad445cd5021a70a8b42f7d9152d8ba7f73304c4d82460946aaa \ + --hash=sha256:1681c21dd2ccee222c2fe0bef671d1aef7c504087c9c4e800371cfcc8ac966fc \ + --hash=sha256:1a113da919c25f7f641ffbd07fbc9077abd4b3b75097c888ab818f962707eb48 \ + --hash=sha256:1c6d85327ca688dbae7e2b06d7d84cfe4f3fffa5b5f9e21bb6ce9d0e1a0e0e0a \ + --hash=sha256:20d81fc2736509d7a2bd33292e489b056cbae543661bb7de7ce9f1c0cd6e7f24 \ + --hash=sha256:21b27b56eb2f82653168cefe6cb8e970cdaf4f3a6cb2c5e3c3c1cf3158968ff9 \ + --hash=sha256:21ba7a08a4253c5825d1db389d4299f64a100ef9800e4624c8bf70d8f136e6ed \ + --hash=sha256:227119ce0a89e762ecd882dc661e0aa677a690c914e358f0dd8932a2e8b2765b \ + --hash=sha256:25b9fc27650ff5a2c9d490c13c14906b918b0de1f8fcbb4c992712d8caf40e83 \ + --hash=sha256:334f41fa28de9f9be4b78445e68530da3c5fa054c907176460c81494f4ae1f5e \ + --hash=sha256:413391b2239db55be14fa4223034d7e13325a1812c8396ecd4f2c08696d5ccad \ + --hash=sha256:4286a1139f14b7d70141c67a8ae1582fc2b69105f1b09d9573494eb4bb4b2687 \ + --hash=sha256:44337823462291f17f994d64282a71c51d738fc9ef561bf265f1d0fd9116a782 \ + --hash=sha256:46293c39252f93ea0910aababa8752ad628bcce3a10d3f260648dd472256983f \ + --hash=sha256:4bf0edb24c128b7be0c61cd17eef432e4bef507013292415f3fb7023f02b7d4b \ + --hash=sha256:4d3d9b904ad4a6b175a2de0738248822f5ac410f52c2fd389ada0b5262d6a1e3 \ + --hash=sha256:4e6aeb2e0932f32950cf56a8b4813cb15ff792fc0c9b3752eaf067cfe298496a \ + --hash=sha256:4fb1a8c5438e0c5ea51afe9c6564f951525795cf432bed0c028c1cb081276685 \ + --hash=sha256:529064085be2f4d8a6e5fab12d36ad44f1909a18848fcfbdb59cc6d4bbe48efe \ + --hash=sha256:52d9b73b8fb3e9da34c2b31e6d99d60f5f99fd8c1225c9dad24aeb74a91e1d29 \ + --hash=sha256:5cda6b51faff2639296e276591808c1726c4a77929cfaa0f514f30a5f6156921 \ + --hash=sha256:5d79f9fdc9584ec83d1b3c75e9f4595c49017f5594fee1a2217117647225d738 \ + --hash=sha256:61f964a05356f4bca4112e6334ed7c208174511bd56e6b8fc86dad4d024d4185 \ + --hash=sha256:6772e3ca8a43a65a37c88e2f3e2adfd511b0b1da37ef11ed78dea16aeae85bd9 \ + --hash=sha256:6e2bf13d9256398d037fef09fd8bf9b0bf77876e22647d10761d35593b9ac547 \ + --hash=sha256:70322986c0c699dca241418fcf18e637a4369e0ec50540a2b907b184c8bca069 \ + --hash=sha256:788bfcef6787a7764169cfe9859fe425bf44559619e1d9f56f5bddf2ebf6f417 \ + --hash=sha256:7f1ac7828857fcedb0361b48b9ac4821469f7694089d15550bbcf9ab22564a1d \ + --hash=sha256:87accdbba88f33efa7b592dc2e8b2a9c2cdbca73db2f9d5c510790428c09c154 \ + --hash=sha256:8cee08f15d9e238ede42e9bbc1d6e7158d0ca4f176e4eab21f88ac819ae3bd7b \ + --hash=sha256:971ba928fcde01869361f504fcff3b7143b47d30de188b11c6357c0505824197 \ + --hash=sha256:9c2e02f06c68092b875d5cbe4824238ab93a7fa35d9c38052c033f7ca45daa18 \ + --hash=sha256:9c5a9da957c56e43d72126a3f5845603da00e0293720b03bde0aacffcf2dc04f \ + --hash=sha256:9df7126fd9db49e3a5a3999442cc67e9ee8971f3cb9644250107d7296cb2a164 \ + --hash=sha256:b3edaec7e8b6dc5cd94523c6df4f294014df67097c8217a89929c99975811414 \ + --hash=sha256:b535d35dea8bbb8195e7e2b40059e2253acb2b7579b73c1b432a35363694641d \ + --hash=sha256:bcf0724a62a5670e5718957e05c56ec2d6850267ea859f8ad2481838f889b42c \ + --hash=sha256:c00e7845d2f692ebfc7d5e4ec1a3fd87698e4337d09e58d6749a16aedfdf8612 \ + --hash=sha256:c379e37b08c6c527181a397212346be39319fb64323741d23e46abd97a400d34 \ + --hash=sha256:c5d1730b25d9a07727d20ad74bc1039bbbb0a6ca24e6769861c1aa5bf2c4c4a8 \ + --hash=sha256:c5e73ba0d76eefc82ec0219d2301cb33bfe5205ed7a2602523111e2e56ccbd20 \ + --hash=sha256:c697575d0e2b0a5f0433f679bda22f63873821d991e95a90e9e52aae517b2e32 \ + --hash=sha256:cdeff998cb294896a34e5b2f00e383e7c5c4ef3b4bfa375d9104723f15186443 \ + --hash=sha256:ceb5c832cc30663aeaf5e39657712f4c4241ad1f638d487ef7216258f6d41fe7 \ + --hash=sha256:d34c0f6dbefd2e816e8f341d0df7d4763d382e3f452423e752ffd1e213da2512 \ + --hash=sha256:db691fa174e8f7036afefe3061bc40ac2b770718be2862bfb03aabae09051aca \ + --hash=sha256:e7a903b5b45b0d9fa03ac6a331e1c1d6b7e0ab41c63b6217b3d10357b83c8b00 \ + --hash=sha256:e7c08f57f75a2bb62d7ee80a89686a5e5669f199235c6d1dac75cd59374091c3 \ + --hash=sha256:f42f23e152e4545157fa367b2435a1ace7571cab016ca26038867eb7df2c3631 \ + --hash=sha256:fe2b3b4927d0bc03d02ad883f402d5de201dbc8894ac87d2e981e7d87430e60d + # via sqlmodel +sqlmodel==0.0.24 \ + --hash=sha256:6778852f09370908985b667d6a3ab92910d0d5ec88adcaf23dbc242715ff7193 \ + --hash=sha256:cc5c7613c1a5533c9c7867e1aab2fd489a76c9e8a061984da11b4e613c182423 + # via -r requirements.in starlette==0.47.3 \ --hash=sha256:6bc94f839cc176c4858894f1f8908f0ab79dfec1a6b8402f6da9be26ebea52e9 \ --hash=sha256:89c0778ca62a76b826101e7c709e70680a1699ca7da6b44d38eb0a7e61fe4b51 - # via - # -r requirements.txt - # fastapi + # via fastapi typing-extensions==4.15.0 \ --hash=sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466 \ --hash=sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548 # via - # -r requirements.txt # anyio # fastapi # pydantic # pydantic-core + # sqlalchemy # starlette # typing-inspection typing-inspection==0.4.1 \ --hash=sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51 \ --hash=sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28 # via - # -r requirements.txt # pydantic # pydantic-settings uvicorn==0.35.0 \ --hash=sha256:197535216b25ff9b785e29a0b79199f55222193d47f820816e7da751e9bc8d4a \ --hash=sha256:bc662f087f7cf2ce11a1d7fd70b90c9f98ef2e2831556dd078d131b96cc94a01 - # via -r requirements.txt + # via -r requirements.in diff --git a/backend/requirements.in b/backend/requirements.in index 8f1de34..76cec58 100644 --- a/backend/requirements.in +++ b/backend/requirements.in @@ -2,4 +2,5 @@ fastapi uvicorn httpx pyyaml -pydantic-settings \ No newline at end of file +pydantic-settings +sqlmodel \ No newline at end of file diff --git a/backend/requirements.txt b/backend/requirements.txt index d9bde87..18a2164 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -28,6 +28,62 @@ fastapi==0.116.1 \ --hash=sha256:c46ac7c312df840f0c9e220f7964bada936781bc4e2e6eb71f1c4d7553786565 \ --hash=sha256:ed52cbf946abfd70c5a0dccb24673f0670deeb517a88b3544d03c2a6bf283143 # via -r requirements.in +greenlet==3.2.4 \ + --hash=sha256:00fadb3fedccc447f517ee0d3fd8fe49eae949e1cd0f6a611818f4f6fb7dc83b \ + --hash=sha256:061dc4cf2c34852b052a8620d40f36324554bc192be474b9e9770e8c042fd735 \ + --hash=sha256:0db5594dce18db94f7d1650d7489909b57afde4c580806b8d9203b6e79cdc079 \ + --hash=sha256:0dca0d95ff849f9a364385f36ab49f50065d76964944638be9691e1832e9f86d \ + --hash=sha256:16458c245a38991aa19676900d48bd1a6f2ce3e16595051a4db9d012154e8433 \ + --hash=sha256:18d9260df2b5fbf41ae5139e1be4e796d99655f023a636cd0e11e6406cca7d58 \ + --hash=sha256:1987de92fec508535687fb807a5cea1560f6196285a4cde35c100b8cd632cc52 \ + --hash=sha256:1a921e542453fe531144e91e1feedf12e07351b1cf6c9e8a3325ea600a715a31 \ + --hash=sha256:1ee8fae0519a337f2329cb78bd7a8e128ec0f881073d43f023c7b8d4831d5246 \ + --hash=sha256:20fb936b4652b6e307b8f347665e2c615540d4b42b3b4c8a321d8286da7e520f \ + --hash=sha256:23768528f2911bcd7e475210822ffb5254ed10d71f4028387e5a99b4c6699671 \ + --hash=sha256:2523e5246274f54fdadbce8494458a2ebdcdbc7b802318466ac5606d3cded1f8 \ + --hash=sha256:27890167f55d2387576d1f41d9487ef171849ea0359ce1510ca6e06c8bece11d \ + --hash=sha256:299fd615cd8fc86267b47597123e3f43ad79c9d8a22bebdce535e53550763e2f \ + --hash=sha256:3b3812d8d0c9579967815af437d96623f45c0f2ae5f04e366de62a12d83a8fb0 \ + --hash=sha256:3b67ca49f54cede0186854a008109d6ee71f66bd57bb36abd6d0a0267b540cdd \ + --hash=sha256:44358b9bf66c8576a9f57a590d5f5d6e72fa4228b763d0e43fee6d3b06d3a337 \ + --hash=sha256:49a30d5fda2507ae77be16479bdb62a660fa51b1eb4928b524975b3bde77b3c0 \ + --hash=sha256:4d1378601b85e2e5171b99be8d2dc85f594c79967599328f95c1dc1a40f1c633 \ + --hash=sha256:554b03b6e73aaabec3745364d6239e9e012d64c68ccd0b8430c64ccc14939a8b \ + --hash=sha256:55e9c5affaa6775e2c6b67659f3a71684de4c549b3dd9afca3bc773533d284fa \ + --hash=sha256:58b97143c9cc7b86fc458f215bd0932f1757ce649e05b640fea2e79b54cedb31 \ + --hash=sha256:5c9320971821a7cb77cfab8d956fa8e39cd07ca44b6070db358ceb7f8797c8c9 \ + --hash=sha256:65458b409c1ed459ea899e939f0e1cdb14f58dbc803f2f93c5eab5694d32671b \ + --hash=sha256:671df96c1f23c4a0d4077a325483c1503c96a1b7d9db26592ae770daa41233d4 \ + --hash=sha256:710638eb93b1fa52823aa91bf75326f9ecdfd5e0466f00789246a5280f4ba0fc \ + --hash=sha256:73f49b5368b5359d04e18d15828eecc1806033db5233397748f4ca813ff1056c \ + --hash=sha256:81701fd84f26330f0d5f4944d4e92e61afe6319dcd9775e39396e39d7c3e5f98 \ + --hash=sha256:8854167e06950ca75b898b104b63cc646573aa5fef1353d4508ecdd1ee76254f \ + --hash=sha256:8c68325b0d0acf8d91dde4e6f930967dd52a5302cd4062932a6b2e7c2969f47c \ + --hash=sha256:94385f101946790ae13da500603491f04a76b6e4c059dab271b3ce2e283b2590 \ + --hash=sha256:94abf90142c2a18151632371140b3dba4dee031633fe614cb592dbb6c9e17bc3 \ + --hash=sha256:96378df1de302bc38e99c3a9aa311967b7dc80ced1dcc6f171e99842987882a2 \ + --hash=sha256:9c40adce87eaa9ddb593ccb0fa6a07caf34015a29bf8d344811665b573138db9 \ + --hash=sha256:9fe0a28a7b952a21e2c062cd5756d34354117796c6d9215a87f55e38d15402c5 \ + --hash=sha256:a7d4e128405eea3814a12cc2605e0e6aedb4035bf32697f72deca74de4105e02 \ + --hash=sha256:abbf57b5a870d30c4675928c37278493044d7c14378350b3aa5d484fa65575f0 \ + --hash=sha256:b4a1870c51720687af7fa3e7cda6d08d801dae660f75a76f3845b642b4da6ee1 \ + --hash=sha256:b6a7c19cf0d2742d0809a4c05975db036fdff50cd294a93632d6a310bf9ac02c \ + --hash=sha256:b90654e092f928f110e0007f572007c9727b5265f7632c2fa7415b4689351594 \ + --hash=sha256:c17b6b34111ea72fc5a4e4beec9711d2226285f0386ea83477cbb97c30a3f3a5 \ + --hash=sha256:c2ca18a03a8cfb5b25bc1cbe20f3d9a4c80d8c3b13ba3df49ac3961af0b1018d \ + --hash=sha256:c5111ccdc9c88f423426df3fd1811bfc40ed66264d35aa373420a34377efc98a \ + --hash=sha256:c60a6d84229b271d44b70fb6e5fa23781abb5d742af7b808ae3f6efd7c9c60f6 \ + --hash=sha256:c8c9e331e58180d0d83c5b7999255721b725913ff6bc6cf39fa2a45841a4fd4b \ + --hash=sha256:c9913f1a30e4526f432991f89ae263459b1c64d1608c0d22a5c79c287b3c70df \ + --hash=sha256:cd3c8e693bff0fff6ba55f140bf390fa92c994083f838fece0f63be121334945 \ + --hash=sha256:d25c5091190f2dc0eaa3f950252122edbbadbb682aa7b1ef2f8af0f8c0afefae \ + --hash=sha256:d2e685ade4dafd447ede19c31277a224a239a0a1a4eca4e6390efedf20260cfb \ + --hash=sha256:d76383238584e9711e20ebe14db6c88ddcedc1829a9ad31a584389463b5aa504 \ + --hash=sha256:ddf9164e7a5b08e9d22511526865780a576f19ddd00d62f8a665949327fde8bb \ + --hash=sha256:e37ab26028f12dbb0ff65f29a8d3d44a765c61e729647bf2ddfbbed621726f01 \ + --hash=sha256:f10fd42b5ee276335863712fa3da6608e93f70629c631bf77145021600abc23c \ + --hash=sha256:f28588772bb5fb869a8eb331374ec06f24a83a9c25bfa1f38b6993afe9c1e968 + # via sqlalchemy h11==0.16.0 \ --hash=sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1 \ --hash=sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86 @@ -54,6 +110,7 @@ pydantic==2.11.7 \ # via # fastapi # pydantic-settings + # sqlmodel pydantic-core==2.33.2 \ --hash=sha256:0069c9acc3f3981b9ff4cdfaf088e98d83440a4c7ea1bc07460af3d4dc22e72d \ --hash=sha256:031c57d67ca86902726e0fae2214ce6770bbe2f710dc33063187a68744a5ecac \ @@ -222,6 +279,69 @@ sniffio==1.3.1 \ --hash=sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2 \ --hash=sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc # via anyio +sqlalchemy==2.0.43 \ + --hash=sha256:022e436a1cb39b13756cf93b48ecce7aa95382b9cfacceb80a7d263129dfd019 \ + --hash=sha256:03d73ab2a37d9e40dec4984d1813d7878e01dbdc742448d44a7341b7a9f408c7 \ + --hash=sha256:07097c0a1886c150ef2adba2ff7437e84d40c0f7dcb44a2c2b9c905ccfc6361c \ + --hash=sha256:11b9503fa6f8721bef9b8567730f664c5a5153d25e247aadc69247c4bc605227 \ + --hash=sha256:11f43c39b4b2ec755573952bbcc58d976779d482f6f832d7f33a8d869ae891bf \ + --hash=sha256:13194276e69bb2af56198fef7909d48fd34820de01d9c92711a5fa45497cc7ed \ + --hash=sha256:136063a68644eca9339d02e6693932116f6a8591ac013b0014479a1de664e40a \ + --hash=sha256:14111d22c29efad445cd5021a70a8b42f7d9152d8ba7f73304c4d82460946aaa \ + --hash=sha256:1681c21dd2ccee222c2fe0bef671d1aef7c504087c9c4e800371cfcc8ac966fc \ + --hash=sha256:1a113da919c25f7f641ffbd07fbc9077abd4b3b75097c888ab818f962707eb48 \ + --hash=sha256:1c6d85327ca688dbae7e2b06d7d84cfe4f3fffa5b5f9e21bb6ce9d0e1a0e0e0a \ + --hash=sha256:20d81fc2736509d7a2bd33292e489b056cbae543661bb7de7ce9f1c0cd6e7f24 \ + --hash=sha256:21b27b56eb2f82653168cefe6cb8e970cdaf4f3a6cb2c5e3c3c1cf3158968ff9 \ + --hash=sha256:21ba7a08a4253c5825d1db389d4299f64a100ef9800e4624c8bf70d8f136e6ed \ + --hash=sha256:227119ce0a89e762ecd882dc661e0aa677a690c914e358f0dd8932a2e8b2765b \ + --hash=sha256:25b9fc27650ff5a2c9d490c13c14906b918b0de1f8fcbb4c992712d8caf40e83 \ + --hash=sha256:334f41fa28de9f9be4b78445e68530da3c5fa054c907176460c81494f4ae1f5e \ + --hash=sha256:413391b2239db55be14fa4223034d7e13325a1812c8396ecd4f2c08696d5ccad \ + --hash=sha256:4286a1139f14b7d70141c67a8ae1582fc2b69105f1b09d9573494eb4bb4b2687 \ + --hash=sha256:44337823462291f17f994d64282a71c51d738fc9ef561bf265f1d0fd9116a782 \ + --hash=sha256:46293c39252f93ea0910aababa8752ad628bcce3a10d3f260648dd472256983f \ + --hash=sha256:4bf0edb24c128b7be0c61cd17eef432e4bef507013292415f3fb7023f02b7d4b \ + --hash=sha256:4d3d9b904ad4a6b175a2de0738248822f5ac410f52c2fd389ada0b5262d6a1e3 \ + --hash=sha256:4e6aeb2e0932f32950cf56a8b4813cb15ff792fc0c9b3752eaf067cfe298496a \ + --hash=sha256:4fb1a8c5438e0c5ea51afe9c6564f951525795cf432bed0c028c1cb081276685 \ + --hash=sha256:529064085be2f4d8a6e5fab12d36ad44f1909a18848fcfbdb59cc6d4bbe48efe \ + --hash=sha256:52d9b73b8fb3e9da34c2b31e6d99d60f5f99fd8c1225c9dad24aeb74a91e1d29 \ + --hash=sha256:5cda6b51faff2639296e276591808c1726c4a77929cfaa0f514f30a5f6156921 \ + --hash=sha256:5d79f9fdc9584ec83d1b3c75e9f4595c49017f5594fee1a2217117647225d738 \ + --hash=sha256:61f964a05356f4bca4112e6334ed7c208174511bd56e6b8fc86dad4d024d4185 \ + --hash=sha256:6772e3ca8a43a65a37c88e2f3e2adfd511b0b1da37ef11ed78dea16aeae85bd9 \ + --hash=sha256:6e2bf13d9256398d037fef09fd8bf9b0bf77876e22647d10761d35593b9ac547 \ + --hash=sha256:70322986c0c699dca241418fcf18e637a4369e0ec50540a2b907b184c8bca069 \ + --hash=sha256:788bfcef6787a7764169cfe9859fe425bf44559619e1d9f56f5bddf2ebf6f417 \ + --hash=sha256:7f1ac7828857fcedb0361b48b9ac4821469f7694089d15550bbcf9ab22564a1d \ + --hash=sha256:87accdbba88f33efa7b592dc2e8b2a9c2cdbca73db2f9d5c510790428c09c154 \ + --hash=sha256:8cee08f15d9e238ede42e9bbc1d6e7158d0ca4f176e4eab21f88ac819ae3bd7b \ + --hash=sha256:971ba928fcde01869361f504fcff3b7143b47d30de188b11c6357c0505824197 \ + --hash=sha256:9c2e02f06c68092b875d5cbe4824238ab93a7fa35d9c38052c033f7ca45daa18 \ + --hash=sha256:9c5a9da957c56e43d72126a3f5845603da00e0293720b03bde0aacffcf2dc04f \ + --hash=sha256:9df7126fd9db49e3a5a3999442cc67e9ee8971f3cb9644250107d7296cb2a164 \ + --hash=sha256:b3edaec7e8b6dc5cd94523c6df4f294014df67097c8217a89929c99975811414 \ + --hash=sha256:b535d35dea8bbb8195e7e2b40059e2253acb2b7579b73c1b432a35363694641d \ + --hash=sha256:bcf0724a62a5670e5718957e05c56ec2d6850267ea859f8ad2481838f889b42c \ + --hash=sha256:c00e7845d2f692ebfc7d5e4ec1a3fd87698e4337d09e58d6749a16aedfdf8612 \ + --hash=sha256:c379e37b08c6c527181a397212346be39319fb64323741d23e46abd97a400d34 \ + --hash=sha256:c5d1730b25d9a07727d20ad74bc1039bbbb0a6ca24e6769861c1aa5bf2c4c4a8 \ + --hash=sha256:c5e73ba0d76eefc82ec0219d2301cb33bfe5205ed7a2602523111e2e56ccbd20 \ + --hash=sha256:c697575d0e2b0a5f0433f679bda22f63873821d991e95a90e9e52aae517b2e32 \ + --hash=sha256:cdeff998cb294896a34e5b2f00e383e7c5c4ef3b4bfa375d9104723f15186443 \ + --hash=sha256:ceb5c832cc30663aeaf5e39657712f4c4241ad1f638d487ef7216258f6d41fe7 \ + --hash=sha256:d34c0f6dbefd2e816e8f341d0df7d4763d382e3f452423e752ffd1e213da2512 \ + --hash=sha256:db691fa174e8f7036afefe3061bc40ac2b770718be2862bfb03aabae09051aca \ + --hash=sha256:e7a903b5b45b0d9fa03ac6a331e1c1d6b7e0ab41c63b6217b3d10357b83c8b00 \ + --hash=sha256:e7c08f57f75a2bb62d7ee80a89686a5e5669f199235c6d1dac75cd59374091c3 \ + --hash=sha256:f42f23e152e4545157fa367b2435a1ace7571cab016ca26038867eb7df2c3631 \ + --hash=sha256:fe2b3b4927d0bc03d02ad883f402d5de201dbc8894ac87d2e981e7d87430e60d + # via sqlmodel +sqlmodel==0.0.24 \ + --hash=sha256:6778852f09370908985b667d6a3ab92910d0d5ec88adcaf23dbc242715ff7193 \ + --hash=sha256:cc5c7613c1a5533c9c7867e1aab2fd489a76c9e8a061984da11b4e613c182423 + # via -r requirements.in starlette==0.47.3 \ --hash=sha256:6bc94f839cc176c4858894f1f8908f0ab79dfec1a6b8402f6da9be26ebea52e9 \ --hash=sha256:89c0778ca62a76b826101e7c709e70680a1699ca7da6b44d38eb0a7e61fe4b51 @@ -234,6 +354,7 @@ typing-extensions==4.15.0 \ # fastapi # pydantic # pydantic-core + # sqlalchemy # starlette # typing-inspection typing-inspection==0.4.1 \ diff --git a/backend/trading_journal/models.py b/backend/trading_journal/models.py new file mode 100644 index 0000000..c92f90f --- /dev/null +++ b/backend/trading_journal/models.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +from enum import Enum +from typing import TYPE_CHECKING + +from sqlmodel import Column, DateTime, Field, Relationship, SQLModel +from sqlmodel import Enum as SQLEnum + +if TYPE_CHECKING: + from datetime import date, datetime + + +class TradeType(str, Enum): + SELL_PUT = "SELL_PUT" + ASSIGNMENT = "ASSIGNMENT" + SELL_CALL = "SELL_CALL" + EXERCISE_CALL = "EXERCISE_CALL" + + +class CycleStatus(str, Enum): + OPEN = "OPEN" + CLOSED = "CLOSED" + + +class FundingSource(str, Enum): + CASH = "CASH" + MARGIN = "MARGIN" + MIXED = "MIXED" + + +class Trades(SQLModel, table=True): + __tablename__ = "trades" + id: str = Field(default=None, primary_key=True) + user_id: str + symbol: str + underlying_currency: str + trade_type: TradeType = Field(sa_column=Column(SQLEnum(TradeType, name="trade_type_enum")), nullable=False) + trade_time_utc: datetime = Field(sa_column=Column(DateTime(timezone=True)), nullable=False) + expiry_date: date | None = Field(default=None, nullable=True) + strike_price_cents: int | None = Field(default=None, nullable=True) + quantity: int + price_cents: int + gross_cash_flow_cents: int + commission_cents: int + net_cash_flow_cents: int + cycle_id: str | None = Field(default=None, foreign_key="cycles.id", nullable=True) + cycle: Cycles | None = Relationship(back_populates="trades") + + +class Cycles(SQLModel, table=True): + __tablename__ = "cycles" + id: str = Field(default=None, primary_key=True) + user_id: str + symbol: str + underlying_currency: str + start_date: date + end_date: date | None = Field(default=None, nullable=True) + status: CycleStatus = Field(sa_column=Column(SQLEnum(CycleStatus, name="cycle_status_enum")), nullable=False) + funding_source: FundingSource = Field(sa_column=Column(SQLEnum(FundingSource, name="funding_source_enum")), nullable=False) + capital_exposure_cents: int + loan_amount_cents: int | None = Field(default=None, nullable=True) + loan_interest_rate_bps: int | None = Field(default=None, nullable=True) + trades: list[Trades] = Relationship(back_populates="cycle") From b56a506edee057df1b15197920acfa5f399f83e2 Mon Sep 17 00:00:00 2001 From: Tianyu Liu Date: Fri, 12 Sep 2025 21:19:36 +0000 Subject: [PATCH 02/16] another wip --- backend/dev-requirements.txt | 76 ++++++++++++++++++++++++- backend/requirements.in | 3 +- backend/requirements.txt | 76 ++++++++++++++++++++++++- backend/tests/test_db.py | 72 +++++++++++++++++++++++ backend/trading_journal/db.py | 65 +++++++++++++++++++++ backend/trading_journal/db_migration.py | 76 +++++++++++++++++++++++++ backend/trading_journal/models.py | 17 +++--- 7 files changed, 372 insertions(+), 13 deletions(-) create mode 100644 backend/tests/test_db.py create mode 100644 backend/trading_journal/db.py create mode 100644 backend/trading_journal/db_migration.py diff --git a/backend/dev-requirements.txt b/backend/dev-requirements.txt index 0cab2f5..8a90195 100644 --- a/backend/dev-requirements.txt +++ b/backend/dev-requirements.txt @@ -4,6 +4,10 @@ # # pip-compile --generate-hashes dev-requirements.in # +alembic==1.16.5 \ + --hash=sha256:a88bb7f6e513bd4301ecf4c7f2206fe93f9913f9b48dac3b78babde2d6fe765e \ + --hash=sha256:e845dfe090c5ffa7b92593ae6687c5cb1a101e91fa53868497dbd79847f9dbe3 + # via -r requirements.in annotated-types==0.7.0 \ --hash=sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53 \ --hash=sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89 @@ -108,6 +112,73 @@ iniconfig==2.1.0 \ --hash=sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7 \ --hash=sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760 # via pytest +mako==1.3.10 \ + --hash=sha256:99579a6f39583fa7e5630a28c3c1f440e4e97a414b80372649c0ce338da2ea28 \ + --hash=sha256:baef24a52fc4fc514a0887ac600f9f1cff3d82c61d4d700a1fa84d597b88db59 + # via alembic +markupsafe==3.0.2 \ + --hash=sha256:0bff5e0ae4ef2e1ae4fdf2dfd5b76c75e5c2fa4132d05fc1b0dabcd20c7e28c4 \ + --hash=sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30 \ + --hash=sha256:1225beacc926f536dc82e45f8a4d68502949dc67eea90eab715dea3a21c1b5f0 \ + --hash=sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9 \ + --hash=sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396 \ + --hash=sha256:1a9d3f5f0901fdec14d8d2f66ef7d035f2157240a433441719ac9a3fba440b13 \ + --hash=sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028 \ + --hash=sha256:1e084f686b92e5b83186b07e8a17fc09e38fff551f3602b249881fec658d3eca \ + --hash=sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557 \ + --hash=sha256:2cb8438c3cbb25e220c2ab33bb226559e7afb3baec11c4f218ffa7308603c832 \ + --hash=sha256:3169b1eefae027567d1ce6ee7cae382c57fe26e82775f460f0b2778beaad66c0 \ + --hash=sha256:3809ede931876f5b2ec92eef964286840ed3540dadf803dd570c3b7e13141a3b \ + --hash=sha256:38a9ef736c01fccdd6600705b09dc574584b89bea478200c5fbf112a6b0d5579 \ + --hash=sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a \ + --hash=sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c \ + --hash=sha256:48032821bbdf20f5799ff537c7ac3d1fba0ba032cfc06194faffa8cda8b560ff \ + --hash=sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c \ + --hash=sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22 \ + --hash=sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094 \ + --hash=sha256:57cb5a3cf367aeb1d316576250f65edec5bb3be939e9247ae594b4bcbc317dfb \ + --hash=sha256:5b02fb34468b6aaa40dfc198d813a641e3a63b98c2b05a16b9f80b7ec314185e \ + --hash=sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5 \ + --hash=sha256:6af100e168aa82a50e186c82875a5893c5597a0c1ccdb0d8b40240b1f28b969a \ + --hash=sha256:6c89876f41da747c8d3677a2b540fb32ef5715f97b66eeb0c6b66f5e3ef6f59d \ + --hash=sha256:6e296a513ca3d94054c2c881cc913116e90fd030ad1c656b3869762b754f5f8a \ + --hash=sha256:70a87b411535ccad5ef2f1df5136506a10775d267e197e4cf531ced10537bd6b \ + --hash=sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8 \ + --hash=sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225 \ + --hash=sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c \ + --hash=sha256:88b49a3b9ff31e19998750c38e030fc7bb937398b1f78cfa599aaef92d693144 \ + --hash=sha256:8c4e8c3ce11e1f92f6536ff07154f9d49677ebaaafc32db9db4620bc11ed480f \ + --hash=sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87 \ + --hash=sha256:9025b4018f3a1314059769c7bf15441064b2207cb3f065e6ea1e7359cb46db9d \ + --hash=sha256:93335ca3812df2f366e80509ae119189886b0f3c2b81325d39efdb84a1e2ae93 \ + --hash=sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf \ + --hash=sha256:9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158 \ + --hash=sha256:a123e330ef0853c6e822384873bef7507557d8e4a082961e1defa947aa59ba84 \ + --hash=sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb \ + --hash=sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48 \ + --hash=sha256:b424c77b206d63d500bcb69fa55ed8d0e6a3774056bdc4839fc9298a7edca171 \ + --hash=sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c \ + --hash=sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6 \ + --hash=sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd \ + --hash=sha256:bbcb445fa71794da8f178f0f6d66789a28d7319071af7a496d4d507ed566270d \ + --hash=sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1 \ + --hash=sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d \ + --hash=sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca \ + --hash=sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a \ + --hash=sha256:cfad01eed2c2e0c01fd0ecd2ef42c492f7f93902e39a42fc9ee1692961443a29 \ + --hash=sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe \ + --hash=sha256:d8213e09c917a951de9d09ecee036d5c7d36cb6cb7dbaece4c71a60d79fb9798 \ + --hash=sha256:e07c3764494e3776c602c1e78e298937c3315ccc9043ead7e685b7f2b8d47b3c \ + --hash=sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8 \ + --hash=sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f \ + --hash=sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f \ + --hash=sha256:eaa0a10b7f72326f1372a713e73c3f739b524b3af41feb43e4921cb529f5929a \ + --hash=sha256:eb7972a85c54febfb25b5c4b4f3af4dcc731994c7da0d8a0b4a6eb0640e1d178 \ + --hash=sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0 \ + --hash=sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79 \ + --hash=sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430 \ + --hash=sha256:fcabf5ff6eea076f859677f5f0b6b5c1a51e70a376b0579e0eadef8db48c6b50 + # via mako packaging==25.0 \ --hash=sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484 \ --hash=sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f @@ -357,7 +428,9 @@ sqlalchemy==2.0.43 \ --hash=sha256:e7c08f57f75a2bb62d7ee80a89686a5e5669f199235c6d1dac75cd59374091c3 \ --hash=sha256:f42f23e152e4545157fa367b2435a1ace7571cab016ca26038867eb7df2c3631 \ --hash=sha256:fe2b3b4927d0bc03d02ad883f402d5de201dbc8894ac87d2e981e7d87430e60d - # via sqlmodel + # via + # alembic + # sqlmodel sqlmodel==0.0.24 \ --hash=sha256:6778852f09370908985b667d6a3ab92910d0d5ec88adcaf23dbc242715ff7193 \ --hash=sha256:cc5c7613c1a5533c9c7867e1aab2fd489a76c9e8a061984da11b4e613c182423 @@ -370,6 +443,7 @@ typing-extensions==4.15.0 \ --hash=sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466 \ --hash=sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548 # via + # alembic # anyio # fastapi # pydantic diff --git a/backend/requirements.in b/backend/requirements.in index 76cec58..72cf6d5 100644 --- a/backend/requirements.in +++ b/backend/requirements.in @@ -3,4 +3,5 @@ uvicorn httpx pyyaml pydantic-settings -sqlmodel \ No newline at end of file +sqlmodel +alembic \ No newline at end of file diff --git a/backend/requirements.txt b/backend/requirements.txt index 18a2164..8ba4682 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -4,6 +4,10 @@ # # pip-compile --generate-hashes requirements.in # +alembic==1.16.5 \ + --hash=sha256:a88bb7f6e513bd4301ecf4c7f2206fe93f9913f9b48dac3b78babde2d6fe765e \ + --hash=sha256:e845dfe090c5ffa7b92593ae6687c5cb1a101e91fa53868497dbd79847f9dbe3 + # via -r requirements.in annotated-types==0.7.0 \ --hash=sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53 \ --hash=sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89 @@ -104,6 +108,73 @@ idna==3.10 \ # via # anyio # httpx +mako==1.3.10 \ + --hash=sha256:99579a6f39583fa7e5630a28c3c1f440e4e97a414b80372649c0ce338da2ea28 \ + --hash=sha256:baef24a52fc4fc514a0887ac600f9f1cff3d82c61d4d700a1fa84d597b88db59 + # via alembic +markupsafe==3.0.2 \ + --hash=sha256:0bff5e0ae4ef2e1ae4fdf2dfd5b76c75e5c2fa4132d05fc1b0dabcd20c7e28c4 \ + --hash=sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30 \ + --hash=sha256:1225beacc926f536dc82e45f8a4d68502949dc67eea90eab715dea3a21c1b5f0 \ + --hash=sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9 \ + --hash=sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396 \ + --hash=sha256:1a9d3f5f0901fdec14d8d2f66ef7d035f2157240a433441719ac9a3fba440b13 \ + --hash=sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028 \ + --hash=sha256:1e084f686b92e5b83186b07e8a17fc09e38fff551f3602b249881fec658d3eca \ + --hash=sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557 \ + --hash=sha256:2cb8438c3cbb25e220c2ab33bb226559e7afb3baec11c4f218ffa7308603c832 \ + --hash=sha256:3169b1eefae027567d1ce6ee7cae382c57fe26e82775f460f0b2778beaad66c0 \ + --hash=sha256:3809ede931876f5b2ec92eef964286840ed3540dadf803dd570c3b7e13141a3b \ + --hash=sha256:38a9ef736c01fccdd6600705b09dc574584b89bea478200c5fbf112a6b0d5579 \ + --hash=sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a \ + --hash=sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c \ + --hash=sha256:48032821bbdf20f5799ff537c7ac3d1fba0ba032cfc06194faffa8cda8b560ff \ + --hash=sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c \ + --hash=sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22 \ + --hash=sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094 \ + --hash=sha256:57cb5a3cf367aeb1d316576250f65edec5bb3be939e9247ae594b4bcbc317dfb \ + --hash=sha256:5b02fb34468b6aaa40dfc198d813a641e3a63b98c2b05a16b9f80b7ec314185e \ + --hash=sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5 \ + --hash=sha256:6af100e168aa82a50e186c82875a5893c5597a0c1ccdb0d8b40240b1f28b969a \ + --hash=sha256:6c89876f41da747c8d3677a2b540fb32ef5715f97b66eeb0c6b66f5e3ef6f59d \ + --hash=sha256:6e296a513ca3d94054c2c881cc913116e90fd030ad1c656b3869762b754f5f8a \ + --hash=sha256:70a87b411535ccad5ef2f1df5136506a10775d267e197e4cf531ced10537bd6b \ + --hash=sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8 \ + --hash=sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225 \ + --hash=sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c \ + --hash=sha256:88b49a3b9ff31e19998750c38e030fc7bb937398b1f78cfa599aaef92d693144 \ + --hash=sha256:8c4e8c3ce11e1f92f6536ff07154f9d49677ebaaafc32db9db4620bc11ed480f \ + --hash=sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87 \ + --hash=sha256:9025b4018f3a1314059769c7bf15441064b2207cb3f065e6ea1e7359cb46db9d \ + --hash=sha256:93335ca3812df2f366e80509ae119189886b0f3c2b81325d39efdb84a1e2ae93 \ + --hash=sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf \ + --hash=sha256:9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158 \ + --hash=sha256:a123e330ef0853c6e822384873bef7507557d8e4a082961e1defa947aa59ba84 \ + --hash=sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb \ + --hash=sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48 \ + --hash=sha256:b424c77b206d63d500bcb69fa55ed8d0e6a3774056bdc4839fc9298a7edca171 \ + --hash=sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c \ + --hash=sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6 \ + --hash=sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd \ + --hash=sha256:bbcb445fa71794da8f178f0f6d66789a28d7319071af7a496d4d507ed566270d \ + --hash=sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1 \ + --hash=sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d \ + --hash=sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca \ + --hash=sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a \ + --hash=sha256:cfad01eed2c2e0c01fd0ecd2ef42c492f7f93902e39a42fc9ee1692961443a29 \ + --hash=sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe \ + --hash=sha256:d8213e09c917a951de9d09ecee036d5c7d36cb6cb7dbaece4c71a60d79fb9798 \ + --hash=sha256:e07c3764494e3776c602c1e78e298937c3315ccc9043ead7e685b7f2b8d47b3c \ + --hash=sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8 \ + --hash=sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f \ + --hash=sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f \ + --hash=sha256:eaa0a10b7f72326f1372a713e73c3f739b524b3af41feb43e4921cb529f5929a \ + --hash=sha256:eb7972a85c54febfb25b5c4b4f3af4dcc731994c7da0d8a0b4a6eb0640e1d178 \ + --hash=sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0 \ + --hash=sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79 \ + --hash=sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430 \ + --hash=sha256:fcabf5ff6eea076f859677f5f0b6b5c1a51e70a376b0579e0eadef8db48c6b50 + # via mako pydantic==2.11.7 \ --hash=sha256:d989c3c6cb79469287b1569f7447a17848c998458d49ebe294e975b9baf0f0db \ --hash=sha256:dde5df002701f6de26248661f6835bbe296a47bf73990135c7d07ce741b9623b @@ -337,7 +408,9 @@ sqlalchemy==2.0.43 \ --hash=sha256:e7c08f57f75a2bb62d7ee80a89686a5e5669f199235c6d1dac75cd59374091c3 \ --hash=sha256:f42f23e152e4545157fa367b2435a1ace7571cab016ca26038867eb7df2c3631 \ --hash=sha256:fe2b3b4927d0bc03d02ad883f402d5de201dbc8894ac87d2e981e7d87430e60d - # via sqlmodel + # via + # alembic + # sqlmodel sqlmodel==0.0.24 \ --hash=sha256:6778852f09370908985b667d6a3ab92910d0d5ec88adcaf23dbc242715ff7193 \ --hash=sha256:cc5c7613c1a5533c9c7867e1aab2fd489a76c9e8a061984da11b4e613c182423 @@ -350,6 +423,7 @@ typing-extensions==4.15.0 \ --hash=sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466 \ --hash=sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548 # via + # alembic # anyio # fastapi # pydantic diff --git a/backend/tests/test_db.py b/backend/tests/test_db.py new file mode 100644 index 0000000..9990959 --- /dev/null +++ b/backend/tests/test_db.py @@ -0,0 +1,72 @@ +from collections.abc import Generator +from contextlib import contextmanager, suppress + +import pytest +from sqlalchemy import text +from sqlmodel import Session + +from trading_journal.db import Database, create_database + + +@contextmanager +def session_ctx(db: Database) -> Generator[Session, None, None]: + """ + Drive Database.get_session() generator and correctly propagate exceptions + into the generator so the generator's except/rollback path runs. + """ + gen = db.get_session() + session = next(gen) + try: + yield session + except Exception as exc: + # Propagate the exception into the dependency generator so it can rollback. + with suppress(StopIteration): + gen.throw(exc) + raise + else: + # Normal completion: advance generator to let it commit/close. + with suppress(StopIteration): + next(gen) + + +def test_select_one_executes() -> None: + db = create_database(None) # in-memory by default + with session_ctx(db) as session: + val = session.exec(text("SELECT 1")).scalar_one() + assert int(val) == 1 + + +def test_in_memory_persists_across_sessions_when_using_staticpool() -> None: + db = create_database(None) # in-memory with StaticPool + with session_ctx(db) as s1: + s1.exec(text("CREATE TABLE IF NOT EXISTS t (id INTEGER PRIMARY KEY, val TEXT);")) + s1.exec(text("INSERT INTO t (val) VALUES (:v)").bindparams(v="hello")) + with session_ctx(db) as s2: + got = s2.exec(text("SELECT val FROM t")).scalar_one() + assert got == "hello" + + +def test_sqlite_pragmas_applied() -> None: + db = create_database(None) + # PRAGMA returns integer 1 when foreign_keys ON + with session_ctx(db) as session: + fk = session.exec(text("PRAGMA foreign_keys")).scalar_one() + assert int(fk) == 1 + + +def test_rollback_on_exception() -> None: + db = create_database(None) + db.init_db() + # Create table then insert and raise inside the same session to force rollback + + with pytest.raises(RuntimeError): # noqa: PT012, SIM117 + with session_ctx(db) as s: + s.exec(text("CREATE TABLE IF NOT EXISTS t_rb (id INTEGER PRIMARY KEY, val TEXT);")) + s.exec(text("INSERT INTO t_rb (val) VALUES (:v)").bindparams(v="will_rollback")) + # simulate handler error -> should trigger rollback in get_session + raise RuntimeError("simulated failure") # noqa: TRY003, EM101 + + # New session should not see the inserted row + with session_ctx(db) as s2: + rows = list(s2.exec(text("SELECT val FROM t_rb")).scalars()) + assert rows == [] diff --git a/backend/trading_journal/db.py b/backend/trading_journal/db.py new file mode 100644 index 0000000..e098fef --- /dev/null +++ b/backend/trading_journal/db.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from sqlalchemy import event +from sqlalchemy.pool import StaticPool +from sqlmodel import Session, SQLModel, create_engine + +if TYPE_CHECKING: + from collections.abc import Generator + + from sqlalchemy.engine import Connection + + +class Database: + def __init__(self, database_url: str | None = None, *, echo: bool = False, connect_args: dict | None = None) -> None: + self._database_url = database_url or "sqlite:///:memory:" + + default_connect = {"check_same_thread": False, "timeout": 30} if self._database_url.startswith("sqlite") else {} + merged_connect = {**default_connect, **(connect_args or {})} + + if self._database_url == "sqlite:///:memory:": + logger = logging.getLogger(__name__) + logger.warning("Using in-memory SQLite database; all data will be lost when the application stops.") + self._engine = create_engine(self._database_url, echo=echo, connect_args=merged_connect, poolclass=StaticPool) + else: + self._engine = create_engine(self._database_url, echo=echo, connect_args=merged_connect) + + if self._database_url.startswith("sqlite"): + + def _enable_sqlite_pragmas(dbapi_conn: Connection, _connection_record: object) -> None: + try: + cur = dbapi_conn.cursor() + cur.execute("PRAGMA journal_mode=WAL;") + cur.execute("PRAGMA synchronous=NORMAL;") + cur.execute("PRAGMA foreign_keys=ON;") + cur.execute("PRAGMA busy_timeout=30000;") + cur.close() + except Exception: + logger = logging.getLogger(__name__) + logger.exception("Failed to set sqlite pragmas on new connection: ") + + event.listen(self._engine, "connect", _enable_sqlite_pragmas) + + def init_db(self) -> None: + pass + + def get_session(self) -> Generator[Session, None, None]: + session = Session(self._engine) + try: + yield session + session.commit() + except Exception: + session.rollback() + raise + finally: + session.close() + + def dispose(self) -> None: + self._engine.dispose() + + +def create_database(database_url: str | None = None, *, echo: bool = False, connect_args: dict | None = None) -> Database: + return Database(database_url, echo=echo, connect_args=connect_args) diff --git a/backend/trading_journal/db_migration.py b/backend/trading_journal/db_migration.py new file mode 100644 index 0000000..dec8e0b --- /dev/null +++ b/backend/trading_journal/db_migration.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +from typing import Callable + +from sqlalchemy import text +from sqlalchemy.engine import Engine +from sqlmodel import SQLModel + +# 最新 schema 版本号 +LATEST_VERSION = 1 + + +def _mig_0_1(engine: Engine) -> None: + """ + Initial schema: create all tables from SQLModel models. + Safe to call on an empty DB; idempotent for missing tables. + """ + # Ensure all models are imported before this is called (import side-effect registers tables) + # e.g. trading_journal.models is imported in the caller / app startup. + SQLModel.metadata.create_all(bind=engine) + + +# map current_version -> function that migrates from current_version -> current_version+1 +MIGRATIONS: dict[int, Callable[[Engine], None]] = { + 0: _mig_0_1, +} + + +def _get_sqlite_user_version(conn) -> int: + row = conn.execute(text("PRAGMA user_version")).fetchone() + return int(row[0]) if row and row[0] is not None else 0 + + +def _set_sqlite_user_version(conn, v: int) -> None: + conn.execute(text(f"PRAGMA user_version = {int(v)}")) + + +def run_migrations(engine: Engine, target_version: int | None = None) -> int: + """ + Run migrations up to target_version (or LATEST_VERSION). + Returns final applied version. + """ + target = target_version or LATEST_VERSION + with engine.begin() as conn: + driver = conn.engine.name.lower() + if driver == "sqlite": + cur_version = _get_sqlite_user_version(conn) + while cur_version < target: + fn = MIGRATIONS.get(cur_version) + if fn is None: + raise RuntimeError(f"No migration from {cur_version} -> {cur_version + 1}") + # call migration with Engine (fn should use transactions) + fn(engine) + _set_sqlite_user_version(conn, cur_version + 1) + cur_version += 1 + return cur_version + else: + # generic migrations table for non-sqlite + conn.execute( + text(""" + CREATE TABLE IF NOT EXISTS migrations ( + version INTEGER PRIMARY KEY, + applied_at TEXT DEFAULT CURRENT_TIMESTAMP + ) + """) + ) + row = conn.execute(text("SELECT MAX(version) FROM migrations")).fetchone() + cur_version = int(row[0]) if row and row[0] is not None else 0 + while cur_version < target: + fn = MIGRATIONS.get(cur_version) + if fn is None: + raise RuntimeError(f"No migration from {cur_version} -> {cur_version + 1}") + fn(engine) + conn.execute(text("INSERT INTO migrations(version) VALUES (:v)"), {"v": cur_version + 1}) + cur_version += 1 + return cur_version diff --git a/backend/trading_journal/models.py b/backend/trading_journal/models.py index c92f90f..a3ae112 100644 --- a/backend/trading_journal/models.py +++ b/backend/trading_journal/models.py @@ -1,14 +1,11 @@ from __future__ import annotations +from datetime import date, datetime # noqa: TC003 from enum import Enum -from typing import TYPE_CHECKING from sqlmodel import Column, DateTime, Field, Relationship, SQLModel from sqlmodel import Enum as SQLEnum -if TYPE_CHECKING: - from datetime import date, datetime - class TradeType(str, Enum): SELL_PUT = "SELL_PUT" @@ -30,12 +27,12 @@ class FundingSource(str, Enum): class Trades(SQLModel, table=True): __tablename__ = "trades" - id: str = Field(default=None, primary_key=True) + id: str | None = Field(default=None, primary_key=True) user_id: str symbol: str underlying_currency: str - trade_type: TradeType = Field(sa_column=Column(SQLEnum(TradeType, name="trade_type_enum")), nullable=False) - trade_time_utc: datetime = Field(sa_column=Column(DateTime(timezone=True)), nullable=False) + trade_type: TradeType = Field(sa_column=Column(SQLEnum(TradeType, name="trade_type_enum"), nullable=False)) + trade_time_utc: datetime = Field(sa_column=Column(DateTime(timezone=True), nullable=False)) expiry_date: date | None = Field(default=None, nullable=True) strike_price_cents: int | None = Field(default=None, nullable=True) quantity: int @@ -49,14 +46,14 @@ class Trades(SQLModel, table=True): class Cycles(SQLModel, table=True): __tablename__ = "cycles" - id: str = Field(default=None, primary_key=True) + id: str | None = Field(default=None, primary_key=True) user_id: str symbol: str underlying_currency: str start_date: date end_date: date | None = Field(default=None, nullable=True) - status: CycleStatus = Field(sa_column=Column(SQLEnum(CycleStatus, name="cycle_status_enum")), nullable=False) - funding_source: FundingSource = Field(sa_column=Column(SQLEnum(FundingSource, name="funding_source_enum")), nullable=False) + status: CycleStatus = Field(sa_column=Column(SQLEnum(CycleStatus, name="cycle_status_enum"), nullable=False)) + funding_source: FundingSource = Field(sa_column=Column(SQLEnum(FundingSource, name="funding_source_enum"), nullable=False)) capital_exposure_cents: int loan_amount_cents: int | None = Field(default=None, nullable=True) loan_interest_rate_bps: int | None = Field(default=None, nullable=True) From 6f6170cca44b8fb1f591df0ec2af1e6789bb1d00 Mon Sep 17 00:00:00 2001 From: Tianyu Liu Date: Fri, 12 Sep 2025 23:24:46 +0200 Subject: [PATCH 03/16] remove almebic now --- backend/dev-requirements.txt | 76 +----------------------------------- backend/requirements.in | 3 +- backend/requirements.txt | 76 +----------------------------------- 3 files changed, 3 insertions(+), 152 deletions(-) diff --git a/backend/dev-requirements.txt b/backend/dev-requirements.txt index 8a90195..0cab2f5 100644 --- a/backend/dev-requirements.txt +++ b/backend/dev-requirements.txt @@ -4,10 +4,6 @@ # # pip-compile --generate-hashes dev-requirements.in # -alembic==1.16.5 \ - --hash=sha256:a88bb7f6e513bd4301ecf4c7f2206fe93f9913f9b48dac3b78babde2d6fe765e \ - --hash=sha256:e845dfe090c5ffa7b92593ae6687c5cb1a101e91fa53868497dbd79847f9dbe3 - # via -r requirements.in annotated-types==0.7.0 \ --hash=sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53 \ --hash=sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89 @@ -112,73 +108,6 @@ iniconfig==2.1.0 \ --hash=sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7 \ --hash=sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760 # via pytest -mako==1.3.10 \ - --hash=sha256:99579a6f39583fa7e5630a28c3c1f440e4e97a414b80372649c0ce338da2ea28 \ - --hash=sha256:baef24a52fc4fc514a0887ac600f9f1cff3d82c61d4d700a1fa84d597b88db59 - # via alembic -markupsafe==3.0.2 \ - --hash=sha256:0bff5e0ae4ef2e1ae4fdf2dfd5b76c75e5c2fa4132d05fc1b0dabcd20c7e28c4 \ - --hash=sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30 \ - --hash=sha256:1225beacc926f536dc82e45f8a4d68502949dc67eea90eab715dea3a21c1b5f0 \ - --hash=sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9 \ - --hash=sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396 \ - --hash=sha256:1a9d3f5f0901fdec14d8d2f66ef7d035f2157240a433441719ac9a3fba440b13 \ - --hash=sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028 \ - --hash=sha256:1e084f686b92e5b83186b07e8a17fc09e38fff551f3602b249881fec658d3eca \ - --hash=sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557 \ - --hash=sha256:2cb8438c3cbb25e220c2ab33bb226559e7afb3baec11c4f218ffa7308603c832 \ - --hash=sha256:3169b1eefae027567d1ce6ee7cae382c57fe26e82775f460f0b2778beaad66c0 \ - --hash=sha256:3809ede931876f5b2ec92eef964286840ed3540dadf803dd570c3b7e13141a3b \ - --hash=sha256:38a9ef736c01fccdd6600705b09dc574584b89bea478200c5fbf112a6b0d5579 \ - --hash=sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a \ - --hash=sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c \ - --hash=sha256:48032821bbdf20f5799ff537c7ac3d1fba0ba032cfc06194faffa8cda8b560ff \ - --hash=sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c \ - --hash=sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22 \ - --hash=sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094 \ - --hash=sha256:57cb5a3cf367aeb1d316576250f65edec5bb3be939e9247ae594b4bcbc317dfb \ - --hash=sha256:5b02fb34468b6aaa40dfc198d813a641e3a63b98c2b05a16b9f80b7ec314185e \ - --hash=sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5 \ - --hash=sha256:6af100e168aa82a50e186c82875a5893c5597a0c1ccdb0d8b40240b1f28b969a \ - --hash=sha256:6c89876f41da747c8d3677a2b540fb32ef5715f97b66eeb0c6b66f5e3ef6f59d \ - --hash=sha256:6e296a513ca3d94054c2c881cc913116e90fd030ad1c656b3869762b754f5f8a \ - --hash=sha256:70a87b411535ccad5ef2f1df5136506a10775d267e197e4cf531ced10537bd6b \ - --hash=sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8 \ - --hash=sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225 \ - --hash=sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c \ - --hash=sha256:88b49a3b9ff31e19998750c38e030fc7bb937398b1f78cfa599aaef92d693144 \ - --hash=sha256:8c4e8c3ce11e1f92f6536ff07154f9d49677ebaaafc32db9db4620bc11ed480f \ - --hash=sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87 \ - --hash=sha256:9025b4018f3a1314059769c7bf15441064b2207cb3f065e6ea1e7359cb46db9d \ - --hash=sha256:93335ca3812df2f366e80509ae119189886b0f3c2b81325d39efdb84a1e2ae93 \ - --hash=sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf \ - --hash=sha256:9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158 \ - --hash=sha256:a123e330ef0853c6e822384873bef7507557d8e4a082961e1defa947aa59ba84 \ - --hash=sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb \ - --hash=sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48 \ - --hash=sha256:b424c77b206d63d500bcb69fa55ed8d0e6a3774056bdc4839fc9298a7edca171 \ - --hash=sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c \ - --hash=sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6 \ - --hash=sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd \ - --hash=sha256:bbcb445fa71794da8f178f0f6d66789a28d7319071af7a496d4d507ed566270d \ - --hash=sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1 \ - --hash=sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d \ - --hash=sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca \ - --hash=sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a \ - --hash=sha256:cfad01eed2c2e0c01fd0ecd2ef42c492f7f93902e39a42fc9ee1692961443a29 \ - --hash=sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe \ - --hash=sha256:d8213e09c917a951de9d09ecee036d5c7d36cb6cb7dbaece4c71a60d79fb9798 \ - --hash=sha256:e07c3764494e3776c602c1e78e298937c3315ccc9043ead7e685b7f2b8d47b3c \ - --hash=sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8 \ - --hash=sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f \ - --hash=sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f \ - --hash=sha256:eaa0a10b7f72326f1372a713e73c3f739b524b3af41feb43e4921cb529f5929a \ - --hash=sha256:eb7972a85c54febfb25b5c4b4f3af4dcc731994c7da0d8a0b4a6eb0640e1d178 \ - --hash=sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0 \ - --hash=sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79 \ - --hash=sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430 \ - --hash=sha256:fcabf5ff6eea076f859677f5f0b6b5c1a51e70a376b0579e0eadef8db48c6b50 - # via mako packaging==25.0 \ --hash=sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484 \ --hash=sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f @@ -428,9 +357,7 @@ sqlalchemy==2.0.43 \ --hash=sha256:e7c08f57f75a2bb62d7ee80a89686a5e5669f199235c6d1dac75cd59374091c3 \ --hash=sha256:f42f23e152e4545157fa367b2435a1ace7571cab016ca26038867eb7df2c3631 \ --hash=sha256:fe2b3b4927d0bc03d02ad883f402d5de201dbc8894ac87d2e981e7d87430e60d - # via - # alembic - # sqlmodel + # via sqlmodel sqlmodel==0.0.24 \ --hash=sha256:6778852f09370908985b667d6a3ab92910d0d5ec88adcaf23dbc242715ff7193 \ --hash=sha256:cc5c7613c1a5533c9c7867e1aab2fd489a76c9e8a061984da11b4e613c182423 @@ -443,7 +370,6 @@ typing-extensions==4.15.0 \ --hash=sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466 \ --hash=sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548 # via - # alembic # anyio # fastapi # pydantic diff --git a/backend/requirements.in b/backend/requirements.in index 72cf6d5..76cec58 100644 --- a/backend/requirements.in +++ b/backend/requirements.in @@ -3,5 +3,4 @@ uvicorn httpx pyyaml pydantic-settings -sqlmodel -alembic \ No newline at end of file +sqlmodel \ No newline at end of file diff --git a/backend/requirements.txt b/backend/requirements.txt index 8ba4682..18a2164 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -4,10 +4,6 @@ # # pip-compile --generate-hashes requirements.in # -alembic==1.16.5 \ - --hash=sha256:a88bb7f6e513bd4301ecf4c7f2206fe93f9913f9b48dac3b78babde2d6fe765e \ - --hash=sha256:e845dfe090c5ffa7b92593ae6687c5cb1a101e91fa53868497dbd79847f9dbe3 - # via -r requirements.in annotated-types==0.7.0 \ --hash=sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53 \ --hash=sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89 @@ -108,73 +104,6 @@ idna==3.10 \ # via # anyio # httpx -mako==1.3.10 \ - --hash=sha256:99579a6f39583fa7e5630a28c3c1f440e4e97a414b80372649c0ce338da2ea28 \ - --hash=sha256:baef24a52fc4fc514a0887ac600f9f1cff3d82c61d4d700a1fa84d597b88db59 - # via alembic -markupsafe==3.0.2 \ - --hash=sha256:0bff5e0ae4ef2e1ae4fdf2dfd5b76c75e5c2fa4132d05fc1b0dabcd20c7e28c4 \ - --hash=sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30 \ - --hash=sha256:1225beacc926f536dc82e45f8a4d68502949dc67eea90eab715dea3a21c1b5f0 \ - --hash=sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9 \ - --hash=sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396 \ - --hash=sha256:1a9d3f5f0901fdec14d8d2f66ef7d035f2157240a433441719ac9a3fba440b13 \ - --hash=sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028 \ - --hash=sha256:1e084f686b92e5b83186b07e8a17fc09e38fff551f3602b249881fec658d3eca \ - --hash=sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557 \ - --hash=sha256:2cb8438c3cbb25e220c2ab33bb226559e7afb3baec11c4f218ffa7308603c832 \ - --hash=sha256:3169b1eefae027567d1ce6ee7cae382c57fe26e82775f460f0b2778beaad66c0 \ - --hash=sha256:3809ede931876f5b2ec92eef964286840ed3540dadf803dd570c3b7e13141a3b \ - --hash=sha256:38a9ef736c01fccdd6600705b09dc574584b89bea478200c5fbf112a6b0d5579 \ - --hash=sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a \ - --hash=sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c \ - --hash=sha256:48032821bbdf20f5799ff537c7ac3d1fba0ba032cfc06194faffa8cda8b560ff \ - --hash=sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c \ - --hash=sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22 \ - --hash=sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094 \ - --hash=sha256:57cb5a3cf367aeb1d316576250f65edec5bb3be939e9247ae594b4bcbc317dfb \ - --hash=sha256:5b02fb34468b6aaa40dfc198d813a641e3a63b98c2b05a16b9f80b7ec314185e \ - --hash=sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5 \ - --hash=sha256:6af100e168aa82a50e186c82875a5893c5597a0c1ccdb0d8b40240b1f28b969a \ - --hash=sha256:6c89876f41da747c8d3677a2b540fb32ef5715f97b66eeb0c6b66f5e3ef6f59d \ - --hash=sha256:6e296a513ca3d94054c2c881cc913116e90fd030ad1c656b3869762b754f5f8a \ - --hash=sha256:70a87b411535ccad5ef2f1df5136506a10775d267e197e4cf531ced10537bd6b \ - --hash=sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8 \ - --hash=sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225 \ - --hash=sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c \ - --hash=sha256:88b49a3b9ff31e19998750c38e030fc7bb937398b1f78cfa599aaef92d693144 \ - --hash=sha256:8c4e8c3ce11e1f92f6536ff07154f9d49677ebaaafc32db9db4620bc11ed480f \ - --hash=sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87 \ - --hash=sha256:9025b4018f3a1314059769c7bf15441064b2207cb3f065e6ea1e7359cb46db9d \ - --hash=sha256:93335ca3812df2f366e80509ae119189886b0f3c2b81325d39efdb84a1e2ae93 \ - --hash=sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf \ - --hash=sha256:9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158 \ - --hash=sha256:a123e330ef0853c6e822384873bef7507557d8e4a082961e1defa947aa59ba84 \ - --hash=sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb \ - --hash=sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48 \ - --hash=sha256:b424c77b206d63d500bcb69fa55ed8d0e6a3774056bdc4839fc9298a7edca171 \ - --hash=sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c \ - --hash=sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6 \ - --hash=sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd \ - --hash=sha256:bbcb445fa71794da8f178f0f6d66789a28d7319071af7a496d4d507ed566270d \ - --hash=sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1 \ - --hash=sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d \ - --hash=sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca \ - --hash=sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a \ - --hash=sha256:cfad01eed2c2e0c01fd0ecd2ef42c492f7f93902e39a42fc9ee1692961443a29 \ - --hash=sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe \ - --hash=sha256:d8213e09c917a951de9d09ecee036d5c7d36cb6cb7dbaece4c71a60d79fb9798 \ - --hash=sha256:e07c3764494e3776c602c1e78e298937c3315ccc9043ead7e685b7f2b8d47b3c \ - --hash=sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8 \ - --hash=sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f \ - --hash=sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f \ - --hash=sha256:eaa0a10b7f72326f1372a713e73c3f739b524b3af41feb43e4921cb529f5929a \ - --hash=sha256:eb7972a85c54febfb25b5c4b4f3af4dcc731994c7da0d8a0b4a6eb0640e1d178 \ - --hash=sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0 \ - --hash=sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79 \ - --hash=sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430 \ - --hash=sha256:fcabf5ff6eea076f859677f5f0b6b5c1a51e70a376b0579e0eadef8db48c6b50 - # via mako pydantic==2.11.7 \ --hash=sha256:d989c3c6cb79469287b1569f7447a17848c998458d49ebe294e975b9baf0f0db \ --hash=sha256:dde5df002701f6de26248661f6835bbe296a47bf73990135c7d07ce741b9623b @@ -408,9 +337,7 @@ sqlalchemy==2.0.43 \ --hash=sha256:e7c08f57f75a2bb62d7ee80a89686a5e5669f199235c6d1dac75cd59374091c3 \ --hash=sha256:f42f23e152e4545157fa367b2435a1ace7571cab016ca26038867eb7df2c3631 \ --hash=sha256:fe2b3b4927d0bc03d02ad883f402d5de201dbc8894ac87d2e981e7d87430e60d - # via - # alembic - # sqlmodel + # via sqlmodel sqlmodel==0.0.24 \ --hash=sha256:6778852f09370908985b667d6a3ab92910d0d5ec88adcaf23dbc242715ff7193 \ --hash=sha256:cc5c7613c1a5533c9c7867e1aab2fd489a76c9e8a061984da11b4e613c182423 @@ -423,7 +350,6 @@ typing-extensions==4.15.0 \ --hash=sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466 \ --hash=sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548 # via - # alembic # anyio # fastapi # pydantic From 64a2726c73e4c70347d13b2f030c603de84b243c Mon Sep 17 00:00:00 2001 From: Tianyu Liu Date: Sat, 13 Sep 2025 12:58:46 +0200 Subject: [PATCH 04/16] wip --- backend/trading_journal/db.py | 11 +++++------ backend/trading_journal/db_migration.py | 4 ++-- backend/trading_journal/models.py | 1 + 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/backend/trading_journal/db.py b/backend/trading_journal/db.py index e098fef..c147df7 100644 --- a/backend/trading_journal/db.py +++ b/backend/trading_journal/db.py @@ -5,12 +5,13 @@ from typing import TYPE_CHECKING from sqlalchemy import event from sqlalchemy.pool import StaticPool -from sqlmodel import Session, SQLModel, create_engine +from sqlmodel import Session, create_engine + +import trading_journal.db_migration if TYPE_CHECKING: from collections.abc import Generator - - from sqlalchemy.engine import Connection + from sqlite3 import Connection as DBAPIConnection class Database: @@ -29,11 +30,9 @@ class Database: if self._database_url.startswith("sqlite"): - def _enable_sqlite_pragmas(dbapi_conn: Connection, _connection_record: object) -> None: + def _enable_sqlite_pragmas(dbapi_conn: DBAPIConnection, _connection_record: object) -> None: try: cur = dbapi_conn.cursor() - cur.execute("PRAGMA journal_mode=WAL;") - cur.execute("PRAGMA synchronous=NORMAL;") cur.execute("PRAGMA foreign_keys=ON;") cur.execute("PRAGMA busy_timeout=30000;") cur.close() diff --git a/backend/trading_journal/db_migration.py b/backend/trading_journal/db_migration.py index dec8e0b..678634f 100644 --- a/backend/trading_journal/db_migration.py +++ b/backend/trading_journal/db_migration.py @@ -3,7 +3,7 @@ from __future__ import annotations from typing import Callable from sqlalchemy import text -from sqlalchemy.engine import Engine +from sqlalchemy.engine import Connection, Engine from sqlmodel import SQLModel # 最新 schema 版本号 @@ -26,7 +26,7 @@ MIGRATIONS: dict[int, Callable[[Engine], None]] = { } -def _get_sqlite_user_version(conn) -> int: +def _get_sqlite_user_version(conn: Connection) -> int: row = conn.execute(text("PRAGMA user_version")).fetchone() return int(row[0]) if row and row[0] is not None else 0 diff --git a/backend/trading_journal/models.py b/backend/trading_journal/models.py index a3ae112..051c876 100644 --- a/backend/trading_journal/models.py +++ b/backend/trading_journal/models.py @@ -2,6 +2,7 @@ from __future__ import annotations from datetime import date, datetime # noqa: TC003 from enum import Enum +from typing import TYPE_CHECKING from sqlmodel import Column, DateTime, Field, Relationship, SQLModel from sqlmodel import Enum as SQLEnum From 738df559cb0efeb77c787e6f1a508c03091124db Mon Sep 17 00:00:00 2001 From: Tianyu Liu Date: Sat, 13 Sep 2025 18:46:16 +0200 Subject: [PATCH 05/16] add db models --- backend/ruff.toml | 4 +- backend/trading_journal/db.py | 6 ++- backend/trading_journal/db_migration.py | 32 +++--------- backend/trading_journal/models.py | 9 +++- backend/trading_journal/models_v1.py | 68 +++++++++++++++++++++++++ 5 files changed, 90 insertions(+), 29 deletions(-) create mode 100644 backend/trading_journal/models_v1.py diff --git a/backend/ruff.toml b/backend/ruff.toml index a4b5c62..4bcadc2 100644 --- a/backend/ruff.toml +++ b/backend/ruff.toml @@ -4,7 +4,7 @@ line-length = 144 [lint] select = ["ALL"] fixable = ["UP034", "I001"] -ignore = ["T201", "D", "ANN101", "TD002", "TD003"] +ignore = ["T201", "D", "ANN101", "TD002", "TD003", "TRY003", "EM102"] [lint.extend-per-file-ignores] -"test*.py" = ["S101"] \ No newline at end of file +"test*.py" = ["S101"] diff --git a/backend/trading_journal/db.py b/backend/trading_journal/db.py index c147df7..229284a 100644 --- a/backend/trading_journal/db.py +++ b/backend/trading_journal/db.py @@ -7,7 +7,7 @@ from sqlalchemy import event from sqlalchemy.pool import StaticPool from sqlmodel import Session, create_engine -import trading_journal.db_migration +from trading_journal import db_migration if TYPE_CHECKING: from collections.abc import Generator @@ -33,6 +33,8 @@ class Database: def _enable_sqlite_pragmas(dbapi_conn: DBAPIConnection, _connection_record: object) -> None: try: cur = dbapi_conn.cursor() + cur.execute("PRAGMA journal_mode=WAL;") + cur.execute("PRAGMA synchronous=NORMAL;") cur.execute("PRAGMA foreign_keys=ON;") cur.execute("PRAGMA busy_timeout=30000;") cur.close() @@ -43,7 +45,7 @@ class Database: event.listen(self._engine, "connect", _enable_sqlite_pragmas) def init_db(self) -> None: - pass + db_migration.run_migrations(self._engine) def get_session(self) -> Generator[Session, None, None]: session = Session(self._engine) diff --git a/backend/trading_journal/db_migration.py b/backend/trading_journal/db_migration.py index 678634f..fe9b0ba 100644 --- a/backend/trading_journal/db_migration.py +++ b/backend/trading_journal/db_migration.py @@ -1,12 +1,13 @@ from __future__ import annotations -from typing import Callable +from typing import TYPE_CHECKING, Callable from sqlalchemy import text -from sqlalchemy.engine import Connection, Engine from sqlmodel import SQLModel -# 最新 schema 版本号 +if TYPE_CHECKING: + from sqlalchemy.engine import Connection, Engine + LATEST_VERSION = 1 @@ -17,6 +18,8 @@ def _mig_0_1(engine: Engine) -> None: """ # Ensure all models are imported before this is called (import side-effect registers tables) # e.g. trading_journal.models is imported in the caller / app startup. + from trading_journal import models_v1 # noqa: PLC0415, F401 + SQLModel.metadata.create_all(bind=engine) @@ -31,7 +34,7 @@ def _get_sqlite_user_version(conn: Connection) -> int: return int(row[0]) if row and row[0] is not None else 0 -def _set_sqlite_user_version(conn, v: int) -> None: +def _set_sqlite_user_version(conn: Connection, v: int) -> None: conn.execute(text(f"PRAGMA user_version = {int(v)}")) @@ -54,23 +57,4 @@ def run_migrations(engine: Engine, target_version: int | None = None) -> int: _set_sqlite_user_version(conn, cur_version + 1) cur_version += 1 return cur_version - else: - # generic migrations table for non-sqlite - conn.execute( - text(""" - CREATE TABLE IF NOT EXISTS migrations ( - version INTEGER PRIMARY KEY, - applied_at TEXT DEFAULT CURRENT_TIMESTAMP - ) - """) - ) - row = conn.execute(text("SELECT MAX(version) FROM migrations")).fetchone() - cur_version = int(row[0]) if row and row[0] is not None else 0 - while cur_version < target: - fn = MIGRATIONS.get(cur_version) - if fn is None: - raise RuntimeError(f"No migration from {cur_version} -> {cur_version + 1}") - fn(engine) - conn.execute(text("INSERT INTO migrations(version) VALUES (:v)"), {"v": cur_version + 1}) - cur_version += 1 - return cur_version + return -1 # unknown / unsupported driver; no-op diff --git a/backend/trading_journal/models.py b/backend/trading_journal/models.py index 051c876..7382741 100644 --- a/backend/trading_journal/models.py +++ b/backend/trading_journal/models.py @@ -2,7 +2,6 @@ from __future__ import annotations from datetime import date, datetime # noqa: TC003 from enum import Enum -from typing import TYPE_CHECKING from sqlmodel import Column, DateTime, Field, Relationship, SQLModel from sqlmodel import Enum as SQLEnum @@ -15,6 +14,13 @@ class TradeType(str, Enum): EXERCISE_CALL = "EXERCISE_CALL" +class TradeStrategy(str, Enum): + WHEELS = "WHEEL" + FX = "FX" + SPOT = "SPOT" + OTHER = "OTHER" + + class CycleStatus(str, Enum): OPEN = "OPEN" CLOSED = "CLOSED" @@ -33,6 +39,7 @@ class Trades(SQLModel, table=True): symbol: str underlying_currency: str trade_type: TradeType = Field(sa_column=Column(SQLEnum(TradeType, name="trade_type_enum"), nullable=False)) + trade_strategy: TradeStrategy = Field(sa_column=Column(SQLEnum(TradeStrategy, name="trade_strategy_enum"), nullable=False)) trade_time_utc: datetime = Field(sa_column=Column(DateTime(timezone=True), nullable=False)) expiry_date: date | None = Field(default=None, nullable=True) strike_price_cents: int | None = Field(default=None, nullable=True) diff --git a/backend/trading_journal/models_v1.py b/backend/trading_journal/models_v1.py new file mode 100644 index 0000000..7382741 --- /dev/null +++ b/backend/trading_journal/models_v1.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +from datetime import date, datetime # noqa: TC003 +from enum import Enum + +from sqlmodel import Column, DateTime, Field, Relationship, SQLModel +from sqlmodel import Enum as SQLEnum + + +class TradeType(str, Enum): + SELL_PUT = "SELL_PUT" + ASSIGNMENT = "ASSIGNMENT" + SELL_CALL = "SELL_CALL" + EXERCISE_CALL = "EXERCISE_CALL" + + +class TradeStrategy(str, Enum): + WHEELS = "WHEEL" + FX = "FX" + SPOT = "SPOT" + OTHER = "OTHER" + + +class CycleStatus(str, Enum): + OPEN = "OPEN" + CLOSED = "CLOSED" + + +class FundingSource(str, Enum): + CASH = "CASH" + MARGIN = "MARGIN" + MIXED = "MIXED" + + +class Trades(SQLModel, table=True): + __tablename__ = "trades" + id: str | None = Field(default=None, primary_key=True) + user_id: str + symbol: str + underlying_currency: str + trade_type: TradeType = Field(sa_column=Column(SQLEnum(TradeType, name="trade_type_enum"), nullable=False)) + trade_strategy: TradeStrategy = Field(sa_column=Column(SQLEnum(TradeStrategy, name="trade_strategy_enum"), nullable=False)) + trade_time_utc: datetime = Field(sa_column=Column(DateTime(timezone=True), nullable=False)) + expiry_date: date | None = Field(default=None, nullable=True) + strike_price_cents: int | None = Field(default=None, nullable=True) + quantity: int + price_cents: int + gross_cash_flow_cents: int + commission_cents: int + net_cash_flow_cents: int + cycle_id: str | None = Field(default=None, foreign_key="cycles.id", nullable=True) + cycle: Cycles | None = Relationship(back_populates="trades") + + +class Cycles(SQLModel, table=True): + __tablename__ = "cycles" + id: str | None = Field(default=None, primary_key=True) + user_id: str + symbol: str + underlying_currency: str + start_date: date + end_date: date | None = Field(default=None, nullable=True) + status: CycleStatus = Field(sa_column=Column(SQLEnum(CycleStatus, name="cycle_status_enum"), nullable=False)) + funding_source: FundingSource = Field(sa_column=Column(SQLEnum(FundingSource, name="funding_source_enum"), nullable=False)) + capital_exposure_cents: int + loan_amount_cents: int | None = Field(default=None, nullable=True) + loan_interest_rate_bps: int | None = Field(default=None, nullable=True) + trades: list[Trades] = Relationship(back_populates="cycle") From 616232b76d856acb5b2c9cc5e47cc782bd880fa7 Mon Sep 17 00:00:00 2001 From: Tianyu Liu Date: Sat, 13 Sep 2025 21:14:14 +0200 Subject: [PATCH 06/16] add migration and enable ci --- .github/workflows/backend-ci.yml | 29 +++++++++++ backend/ruff.toml | 12 ++++- backend/tests/test_db.py | 2 +- backend/tests/test_db_migration.py | 77 ++++++++++++++++++++++++++++++ 4 files changed, 118 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/backend-ci.yml create mode 100644 backend/tests/test_db_migration.py diff --git a/.github/workflows/backend-ci.yml b/.github/workflows/backend-ci.yml new file mode 100644 index 0000000..c00d4cd --- /dev/null +++ b/.github/workflows/backend-ci.yml @@ -0,0 +1,29 @@ +name: Backend CI + +on: + push: + pull_request: + workflow_dispatch: + +jobs: + unit-test: + runs-on: ubuntu-latest + defaults: + run: + working-directory: backend + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: '3.11' + + - name: Install deps + run: pip install -r dev-requirements.txt + + - name: Run tests + run: | + pytest -q \ No newline at end of file diff --git a/backend/ruff.toml b/backend/ruff.toml index 4bcadc2..c5a0394 100644 --- a/backend/ruff.toml +++ b/backend/ruff.toml @@ -4,7 +4,17 @@ line-length = 144 [lint] select = ["ALL"] fixable = ["UP034", "I001"] -ignore = ["T201", "D", "ANN101", "TD002", "TD003", "TRY003", "EM102"] +ignore = [ + "T201", + "D", + "ANN101", + "TD002", + "TD003", + "TRY003", + "EM101", + "EM102", + "PLC0405", +] [lint.extend-per-file-ignores] "test*.py" = ["S101"] diff --git a/backend/tests/test_db.py b/backend/tests/test_db.py index 9990959..6d5c505 100644 --- a/backend/tests/test_db.py +++ b/backend/tests/test_db.py @@ -64,7 +64,7 @@ def test_rollback_on_exception() -> None: s.exec(text("CREATE TABLE IF NOT EXISTS t_rb (id INTEGER PRIMARY KEY, val TEXT);")) s.exec(text("INSERT INTO t_rb (val) VALUES (:v)").bindparams(v="will_rollback")) # simulate handler error -> should trigger rollback in get_session - raise RuntimeError("simulated failure") # noqa: TRY003, EM101 + raise RuntimeError("simulated failure") # New session should not see the inserted row with session_ctx(db) as s2: diff --git a/backend/tests/test_db_migration.py b/backend/tests/test_db_migration.py new file mode 100644 index 0000000..f68bdc4 --- /dev/null +++ b/backend/tests/test_db_migration.py @@ -0,0 +1,77 @@ +import pytest +from sqlalchemy import text +from sqlalchemy.pool import StaticPool +from sqlmodel import create_engine + +from trading_journal import db_migration + + +def _base_type_of(compiled: str) -> str: + """Return base type name (e.g. VARCHAR from VARCHAR(13)), upper-cased.""" + return compiled.split("(")[0].strip().upper() + + +def test_run_migrations_0_to_1(monkeypatch: pytest.MonkeyPatch) -> None: + # in-memory engine that preserves the same connection (StaticPool) + engine = create_engine( + "sqlite:///:memory:", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + + # ensure target is the LATEST_VERSION we expect for the test + monkeypatch.setattr(db_migration, "LATEST_VERSION", 1) + + # run real migrations (will import trading_journal.models_v1 inside _mig_0_1) + final_version = db_migration.run_migrations(engine) + assert final_version == 1 + + # import snapshot models to validate schema + from trading_journal import models_v1 + + expected_tables = { + "trades": models_v1.Trades.__table__, + "cycles": models_v1.Cycles.__table__, + } + + with engine.connect() as conn: + # check tables exist + rows = conn.execute( + text("SELECT name FROM sqlite_master WHERE type='table'") + ).fetchall() + found_tables = {r[0] for r in rows} + assert set(expected_tables.keys()).issubset(found_tables), ( + f"missing tables: {set(expected_tables.keys()) - found_tables}" + ) + + # check user_version + uv = conn.execute(text("PRAGMA user_version")).fetchone() + assert uv is not None + assert int(uv[0]) == 1 + + # validate columns and (base) types for each expected table + dialect = conn.dialect + for tbl_name, table in expected_tables.items(): + info_rows = conn.execute(text(f"PRAGMA table_info({tbl_name})")).fetchall() + # build mapping: column name -> declared type (upper) + actual_cols = {r[1]: (r[2] or "").upper() for r in info_rows} + for col in table.columns: + assert col.name in actual_cols, ( + f"column {col.name} missing in table {tbl_name}" + ) + # compile expected type against this dialect + try: + compiled = col.type.compile( + dialect=dialect + ) # e.g. VARCHAR(13), DATETIME + except Exception: + compiled = str(col.type) + expected_base = _base_type_of(compiled) + actual_type = actual_cols[col.name] + actual_base = _base_type_of(actual_type) if actual_type else "" + # accept either direction (some dialect vs sqlite naming differences) + assert (expected_base in actual_base) or ( + actual_base in expected_base + ), ( + f"type mismatch for {tbl_name}.{col.name}: expected {expected_base}, got {actual_base}" + ) From 1e2bfbeedbda80cd40e772cd75460b8d27722aa1 Mon Sep 17 00:00:00 2001 From: Tianyu Liu Date: Sat, 13 Sep 2025 21:29:48 +0200 Subject: [PATCH 07/16] for better speed use slim image --- .github/workflows/backend-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/backend-ci.yml b/.github/workflows/backend-ci.yml index c00d4cd..a4229a1 100644 --- a/.github/workflows/backend-ci.yml +++ b/.github/workflows/backend-ci.yml @@ -7,7 +7,7 @@ on: jobs: unit-test: - runs-on: ubuntu-latest + runs-on: ubuntu-latest-slim defaults: run: working-directory: backend From 0a906535dc5cceb416ae53c6e148ec9f22eb1a95 Mon Sep 17 00:00:00 2001 From: Tianyu Liu Date: Sat, 13 Sep 2025 21:33:54 +0200 Subject: [PATCH 08/16] Revert "for better speed use slim image" This reverts commit 1e2bfbeedbda80cd40e772cd75460b8d27722aa1. --- .github/workflows/backend-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/backend-ci.yml b/.github/workflows/backend-ci.yml index a4229a1..c00d4cd 100644 --- a/.github/workflows/backend-ci.yml +++ b/.github/workflows/backend-ci.yml @@ -7,7 +7,7 @@ on: jobs: unit-test: - runs-on: ubuntu-latest-slim + runs-on: ubuntu-latest defaults: run: working-directory: backend From 479d5cd230b3841353ae36a45f1cea65a6aa1967 Mon Sep 17 00:00:00 2001 From: Tianyu Liu Date: Sun, 14 Sep 2025 15:40:11 +0200 Subject: [PATCH 09/16] add user table --- backend/.gitignore | 4 + backend/tests/test_db_migration.py | 115 +++++++++++++++++------- backend/trading_journal/crud.py | 1 + backend/trading_journal/db.py | 40 +++++++-- backend/trading_journal/db_migration.py | 15 +++- backend/trading_journal/models.py | 78 ++++++++++++---- backend/trading_journal/models_v1.py | 78 ++++++++++++---- 7 files changed, 253 insertions(+), 78 deletions(-) create mode 100644 backend/trading_journal/crud.py diff --git a/backend/.gitignore b/backend/.gitignore index 51f9037..6321b92 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -11,3 +11,7 @@ venv.bak/ __pycache__/ .pytest_cache/ + +*.db +*.db-shm +*.db-wal \ No newline at end of file diff --git a/backend/tests/test_db_migration.py b/backend/tests/test_db_migration.py index f68bdc4..d61768d 100644 --- a/backend/tests/test_db_migration.py +++ b/backend/tests/test_db_migration.py @@ -18,20 +18,59 @@ def test_run_migrations_0_to_1(monkeypatch: pytest.MonkeyPatch) -> None: connect_args={"check_same_thread": False}, poolclass=StaticPool, ) - - # ensure target is the LATEST_VERSION we expect for the test monkeypatch.setattr(db_migration, "LATEST_VERSION", 1) - - # run real migrations (will import trading_journal.models_v1 inside _mig_0_1) final_version = db_migration.run_migrations(engine) assert final_version == 1 - # import snapshot models to validate schema - from trading_journal import models_v1 + expected_schema = { + "users": { + "id": ("INTEGER", 1, 1), + "username": ("TEXT", 1, 0), + "password_hash": ("TEXT", 1, 0), + "is_active": ("BOOLEAN", 1, 0), + }, + "cycles": { + "id": ("INTEGER", 1, 1), + "user_id": ("INTEGER", 1, 0), + "friendly_name": ("TEXT", 0, 0), + "symbol": ("TEXT", 1, 0), + "underlying_currency": ("TEXT", 1, 0), + "status": ("TEXT", 1, 0), + "funding_source": ("TEXT", 1, 0), + "capital_exposure_cents": ("INTEGER", 1, 0), + "loan_amount_cents": ("INTEGER", 0, 0), + "loan_interest_rate_bps": ("INTEGER", 0, 0), + "start_date": ("DATE", 1, 0), + "end_date": ("DATE", 0, 0), + }, + "trades": { + "id": ("INTEGER", 1, 1), + "user_id": ("INTEGER", 1, 0), + "friendly_name": ("TEXT", 0, 0), + "symbol": ("TEXT", 1, 0), + "underlying_currency": ("TEXT", 1, 0), + "trade_type": ("TEXT", 1, 0), + "trade_strategy": ("TEXT", 1, 0), + "trade_time_utc": ("DATETIME", 1, 0), + "expiry_date": ("DATE", 0, 0), + "strike_price_cents": ("INTEGER", 0, 0), + "quantity": ("INTEGER", 1, 0), + "price_cents": ("INTEGER", 1, 0), + "gross_cash_flow_cents": ("INTEGER", 1, 0), + "commission_cents": ("INTEGER", 1, 0), + "net_cash_flow_cents": ("INTEGER", 1, 0), + "cycle_id": ("INTEGER", 0, 0), + }, + } - expected_tables = { - "trades": models_v1.Trades.__table__, - "cycles": models_v1.Cycles.__table__, + expected_fks = { + "trades": [ + {"table": "cycles", "from": "cycle_id", "to": "id"}, + {"table": "users", "from": "user_id", "to": "id"}, + ], + "cycles": [ + {"table": "users", "from": "user_id", "to": "id"}, + ], } with engine.connect() as conn: @@ -40,8 +79,8 @@ def test_run_migrations_0_to_1(monkeypatch: pytest.MonkeyPatch) -> None: text("SELECT name FROM sqlite_master WHERE type='table'") ).fetchall() found_tables = {r[0] for r in rows} - assert set(expected_tables.keys()).issubset(found_tables), ( - f"missing tables: {set(expected_tables.keys()) - found_tables}" + assert set(expected_schema.keys()).issubset(found_tables), ( + f"missing tables: {set(expected_schema.keys()) - found_tables}" ) # check user_version @@ -49,29 +88,37 @@ def test_run_migrations_0_to_1(monkeypatch: pytest.MonkeyPatch) -> None: assert uv is not None assert int(uv[0]) == 1 - # validate columns and (base) types for each expected table - dialect = conn.dialect - for tbl_name, table in expected_tables.items(): + # validate each table columns + for tbl_name, cols in expected_schema.items(): info_rows = conn.execute(text(f"PRAGMA table_info({tbl_name})")).fetchall() - # build mapping: column name -> declared type (upper) - actual_cols = {r[1]: (r[2] or "").upper() for r in info_rows} - for col in table.columns: - assert col.name in actual_cols, ( - f"column {col.name} missing in table {tbl_name}" + # map: name -> (type, notnull, pk) + actual = { + r[1]: ((r[2] or "").upper(), int(r[3]), int(r[5])) for r in info_rows + } + for colname, (exp_type, exp_notnull, exp_pk) in cols.items(): + assert colname in actual, f"{tbl_name}: missing column {colname}" + act_type, act_notnull, act_pk = actual[colname] + # compare base type (e.g. VARCHAR(13) -> VARCHAR) + if act_type: + act_base = _base_type_of(act_type) + else: + act_base = "" + assert exp_type in act_base or act_base in exp_type, ( + f"type mismatch {tbl_name}.{colname}: expected {exp_type}, got {act_base}" ) - # compile expected type against this dialect - try: - compiled = col.type.compile( - dialect=dialect - ) # e.g. VARCHAR(13), DATETIME - except Exception: - compiled = str(col.type) - expected_base = _base_type_of(compiled) - actual_type = actual_cols[col.name] - actual_base = _base_type_of(actual_type) if actual_type else "" - # accept either direction (some dialect vs sqlite naming differences) - assert (expected_base in actual_base) or ( - actual_base in expected_base - ), ( - f"type mismatch for {tbl_name}.{col.name}: expected {expected_base}, got {actual_base}" + assert act_notnull == exp_notnull, ( + f"notnull mismatch {tbl_name}.{colname}: expected {exp_notnull}, got {act_notnull}" ) + assert act_pk == exp_pk, ( + f"pk mismatch {tbl_name}.{colname}: expected {exp_pk}, got {act_pk}" + ) + for tbl_name, fks in expected_fks.items(): + fk_rows = conn.execute( + text(f"PRAGMA foreign_key_list('{tbl_name}')") + ).fetchall() + # fk_rows columns: (id, seq, table, from, to, on_update, on_delete, match) + actual_fk_list = [ + {"table": r[2], "from": r[3], "to": r[4]} for r in fk_rows + ] + for efk in fks: + assert efk in actual_fk_list, f"missing FK on {tbl_name}: {efk}" diff --git a/backend/trading_journal/crud.py b/backend/trading_journal/crud.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/trading_journal/crud.py @@ -0,0 +1 @@ + diff --git a/backend/trading_journal/db.py b/backend/trading_journal/db.py index 229284a..d09a53d 100644 --- a/backend/trading_journal/db.py +++ b/backend/trading_journal/db.py @@ -15,22 +15,43 @@ if TYPE_CHECKING: class Database: - def __init__(self, database_url: str | None = None, *, echo: bool = False, connect_args: dict | None = None) -> None: + def __init__( + self, + database_url: str | None = None, + *, + echo: bool = False, + connect_args: dict | None = None, + ) -> None: self._database_url = database_url or "sqlite:///:memory:" - default_connect = {"check_same_thread": False, "timeout": 30} if self._database_url.startswith("sqlite") else {} + default_connect = ( + {"check_same_thread": False, "timeout": 30} + if self._database_url.startswith("sqlite") + else {} + ) merged_connect = {**default_connect, **(connect_args or {})} if self._database_url == "sqlite:///:memory:": logger = logging.getLogger(__name__) - logger.warning("Using in-memory SQLite database; all data will be lost when the application stops.") - self._engine = create_engine(self._database_url, echo=echo, connect_args=merged_connect, poolclass=StaticPool) + logger.warning( + "Using in-memory SQLite database; all data will be lost when the application stops." + ) + self._engine = create_engine( + self._database_url, + echo=echo, + connect_args=merged_connect, + poolclass=StaticPool, + ) else: - self._engine = create_engine(self._database_url, echo=echo, connect_args=merged_connect) + self._engine = create_engine( + self._database_url, echo=echo, connect_args=merged_connect + ) if self._database_url.startswith("sqlite"): - def _enable_sqlite_pragmas(dbapi_conn: DBAPIConnection, _connection_record: object) -> None: + def _enable_sqlite_pragmas( + dbapi_conn: DBAPIConnection, _connection_record: object + ) -> None: try: cur = dbapi_conn.cursor() cur.execute("PRAGMA journal_mode=WAL;") @@ -62,5 +83,10 @@ class Database: self._engine.dispose() -def create_database(database_url: str | None = None, *, echo: bool = False, connect_args: dict | None = None) -> Database: +def create_database( + database_url: str | None = None, + *, + echo: bool = False, + connect_args: dict | None = None, +) -> Database: return Database(database_url, echo=echo, connect_args=connect_args) diff --git a/backend/trading_journal/db_migration.py b/backend/trading_journal/db_migration.py index fe9b0ba..c59e3b0 100644 --- a/backend/trading_journal/db_migration.py +++ b/backend/trading_journal/db_migration.py @@ -18,9 +18,16 @@ def _mig_0_1(engine: Engine) -> None: """ # Ensure all models are imported before this is called (import side-effect registers tables) # e.g. trading_journal.models is imported in the caller / app startup. - from trading_journal import models_v1 # noqa: PLC0415, F401 + from trading_journal import models_v1 - SQLModel.metadata.create_all(bind=engine) + SQLModel.metadata.create_all( + bind=engine, + tables=[ + models_v1.Trades.__table__, + models_v1.Cycles.__table__, + models_v1.Users.__table__, + ], + ) # map current_version -> function that migrates from current_version -> current_version+1 @@ -51,7 +58,9 @@ def run_migrations(engine: Engine, target_version: int | None = None) -> int: while cur_version < target: fn = MIGRATIONS.get(cur_version) if fn is None: - raise RuntimeError(f"No migration from {cur_version} -> {cur_version + 1}") + raise RuntimeError( + f"No migration from {cur_version} -> {cur_version + 1}" + ) # call migration with Engine (fn should use transactions) fn(engine) _set_sqlite_user_version(conn, cur_version + 1) diff --git a/backend/trading_journal/models.py b/backend/trading_journal/models.py index 7382741..745bbbd 100644 --- a/backend/trading_journal/models.py +++ b/backend/trading_journal/models.py @@ -3,8 +3,8 @@ from __future__ import annotations from datetime import date, datetime # noqa: TC003 from enum import Enum +from sqlalchemy import Date, Text, UniqueConstraint from sqlmodel import Column, DateTime, Field, Relationship, SQLModel -from sqlmodel import Enum as SQLEnum class TradeType(str, Enum): @@ -12,6 +12,18 @@ class TradeType(str, Enum): ASSIGNMENT = "ASSIGNMENT" SELL_CALL = "SELL_CALL" EXERCISE_CALL = "EXERCISE_CALL" + LONG_SPOT = "LONG_SPOT" + CLOSE_LONG_SPOT = "CLOSE_LONG_SPOT" + SHORT_SPOT = "SHORT_SPOT" + CLOSE_SHORT_SPOT = "CLOSE_SHORT_SPOT" + LONG_CFD = "LONG_CFD" + CLOSE_LONG_CFD = "CLOSE_LONG_CFD" + SHORT_CFD = "SHORT_CFD" + CLOSE_SHORT_CFD = "CLOSE_SHORT_CFD" + LONG_OTHER = "LONG_OTHER" + CLOSE_LONG_OTHER = "CLOSE_LONG_OTHER" + SHORT_OTHER = "SHORT_OTHER" + CLOSE_SHORT_OTHER = "CLOSE_SHORT_OTHER" class TradeStrategy(str, Enum): @@ -34,13 +46,25 @@ class FundingSource(str, Enum): class Trades(SQLModel, table=True): __tablename__ = "trades" - id: str | None = Field(default=None, primary_key=True) - user_id: str - symbol: str - underlying_currency: str - trade_type: TradeType = Field(sa_column=Column(SQLEnum(TradeType, name="trade_type_enum"), nullable=False)) - trade_strategy: TradeStrategy = Field(sa_column=Column(SQLEnum(TradeStrategy, name="trade_strategy_enum"), nullable=False)) - trade_time_utc: datetime = Field(sa_column=Column(DateTime(timezone=True), nullable=False)) + __table_args__ = ( + UniqueConstraint( + "user_id", "friendly_name", name="uq_trades_user_friendly_name" + ), + ) + + id: int | None = Field(default=None, primary_key=True) + user_id: int = Field(foreign_key="users.id", nullable=False, index=True) + # allow null while user may omit friendly_name; uniqueness enforced per-user by constraint + friendly_name: str | None = Field( + default=None, sa_column=Column(Text, nullable=True) + ) + symbol: str = Field(sa_column=Column(Text, nullable=False)) + underlying_currency: str = Field(sa_column=Column(Text, nullable=False)) + trade_type: TradeType = Field(sa_column=Column(Text, nullable=False)) + trade_strategy: TradeStrategy = Field(sa_column=Column(Text, nullable=False)) + trade_time_utc: datetime = Field( + sa_column=Column(DateTime(timezone=True), nullable=False) + ) expiry_date: date | None = Field(default=None, nullable=True) strike_price_cents: int | None = Field(default=None, nullable=True) quantity: int @@ -48,21 +72,41 @@ class Trades(SQLModel, table=True): gross_cash_flow_cents: int commission_cents: int net_cash_flow_cents: int - cycle_id: str | None = Field(default=None, foreign_key="cycles.id", nullable=True) + cycle_id: int | None = Field( + default=None, foreign_key="cycles.id", nullable=True, index=True + ) cycle: Cycles | None = Relationship(back_populates="trades") class Cycles(SQLModel, table=True): __tablename__ = "cycles" - id: str | None = Field(default=None, primary_key=True) - user_id: str - symbol: str - underlying_currency: str - start_date: date - end_date: date | None = Field(default=None, nullable=True) - status: CycleStatus = Field(sa_column=Column(SQLEnum(CycleStatus, name="cycle_status_enum"), nullable=False)) - funding_source: FundingSource = Field(sa_column=Column(SQLEnum(FundingSource, name="funding_source_enum"), nullable=False)) + __table_args__ = ( + UniqueConstraint( + "user_id", "friendly_name", name="uq_cycles_user_friendly_name" + ), + ) + + id: int | None = Field(default=None, primary_key=True) + user_id: int = Field(foreign_key="users.id", nullable=False, index=True) + friendly_name: str | None = Field( + default=None, sa_column=Column(Text, nullable=True) + ) + symbol: str = Field(sa_column=Column(Text, nullable=False)) + underlying_currency: str = Field(sa_column=Column(Text, nullable=False)) + status: CycleStatus = Field(sa_column=Column(Text, nullable=False)) + funding_source: FundingSource = Field(sa_column=Column(Text, nullable=False)) capital_exposure_cents: int loan_amount_cents: int | None = Field(default=None, nullable=True) loan_interest_rate_bps: int | None = Field(default=None, nullable=True) + start_date: date = Field(sa_column=Column(Date, nullable=False)) + end_date: date | None = Field(default=None, sa_column=Column(Date, nullable=True)) trades: list[Trades] = Relationship(back_populates="cycle") + + +class Users(SQLModel, table=True): + __tablename__ = "users" + id: int | None = Field(default=None, primary_key=True) + # unique=True already creates an index; no need to also set index=True + username: str = Field(sa_column=Column(Text, nullable=False, unique=True)) + password_hash: str = Field(sa_column=Column(Text, nullable=False)) + is_active: bool = Field(default=True, nullable=False) diff --git a/backend/trading_journal/models_v1.py b/backend/trading_journal/models_v1.py index 7382741..745bbbd 100644 --- a/backend/trading_journal/models_v1.py +++ b/backend/trading_journal/models_v1.py @@ -3,8 +3,8 @@ from __future__ import annotations from datetime import date, datetime # noqa: TC003 from enum import Enum +from sqlalchemy import Date, Text, UniqueConstraint from sqlmodel import Column, DateTime, Field, Relationship, SQLModel -from sqlmodel import Enum as SQLEnum class TradeType(str, Enum): @@ -12,6 +12,18 @@ class TradeType(str, Enum): ASSIGNMENT = "ASSIGNMENT" SELL_CALL = "SELL_CALL" EXERCISE_CALL = "EXERCISE_CALL" + LONG_SPOT = "LONG_SPOT" + CLOSE_LONG_SPOT = "CLOSE_LONG_SPOT" + SHORT_SPOT = "SHORT_SPOT" + CLOSE_SHORT_SPOT = "CLOSE_SHORT_SPOT" + LONG_CFD = "LONG_CFD" + CLOSE_LONG_CFD = "CLOSE_LONG_CFD" + SHORT_CFD = "SHORT_CFD" + CLOSE_SHORT_CFD = "CLOSE_SHORT_CFD" + LONG_OTHER = "LONG_OTHER" + CLOSE_LONG_OTHER = "CLOSE_LONG_OTHER" + SHORT_OTHER = "SHORT_OTHER" + CLOSE_SHORT_OTHER = "CLOSE_SHORT_OTHER" class TradeStrategy(str, Enum): @@ -34,13 +46,25 @@ class FundingSource(str, Enum): class Trades(SQLModel, table=True): __tablename__ = "trades" - id: str | None = Field(default=None, primary_key=True) - user_id: str - symbol: str - underlying_currency: str - trade_type: TradeType = Field(sa_column=Column(SQLEnum(TradeType, name="trade_type_enum"), nullable=False)) - trade_strategy: TradeStrategy = Field(sa_column=Column(SQLEnum(TradeStrategy, name="trade_strategy_enum"), nullable=False)) - trade_time_utc: datetime = Field(sa_column=Column(DateTime(timezone=True), nullable=False)) + __table_args__ = ( + UniqueConstraint( + "user_id", "friendly_name", name="uq_trades_user_friendly_name" + ), + ) + + id: int | None = Field(default=None, primary_key=True) + user_id: int = Field(foreign_key="users.id", nullable=False, index=True) + # allow null while user may omit friendly_name; uniqueness enforced per-user by constraint + friendly_name: str | None = Field( + default=None, sa_column=Column(Text, nullable=True) + ) + symbol: str = Field(sa_column=Column(Text, nullable=False)) + underlying_currency: str = Field(sa_column=Column(Text, nullable=False)) + trade_type: TradeType = Field(sa_column=Column(Text, nullable=False)) + trade_strategy: TradeStrategy = Field(sa_column=Column(Text, nullable=False)) + trade_time_utc: datetime = Field( + sa_column=Column(DateTime(timezone=True), nullable=False) + ) expiry_date: date | None = Field(default=None, nullable=True) strike_price_cents: int | None = Field(default=None, nullable=True) quantity: int @@ -48,21 +72,41 @@ class Trades(SQLModel, table=True): gross_cash_flow_cents: int commission_cents: int net_cash_flow_cents: int - cycle_id: str | None = Field(default=None, foreign_key="cycles.id", nullable=True) + cycle_id: int | None = Field( + default=None, foreign_key="cycles.id", nullable=True, index=True + ) cycle: Cycles | None = Relationship(back_populates="trades") class Cycles(SQLModel, table=True): __tablename__ = "cycles" - id: str | None = Field(default=None, primary_key=True) - user_id: str - symbol: str - underlying_currency: str - start_date: date - end_date: date | None = Field(default=None, nullable=True) - status: CycleStatus = Field(sa_column=Column(SQLEnum(CycleStatus, name="cycle_status_enum"), nullable=False)) - funding_source: FundingSource = Field(sa_column=Column(SQLEnum(FundingSource, name="funding_source_enum"), nullable=False)) + __table_args__ = ( + UniqueConstraint( + "user_id", "friendly_name", name="uq_cycles_user_friendly_name" + ), + ) + + id: int | None = Field(default=None, primary_key=True) + user_id: int = Field(foreign_key="users.id", nullable=False, index=True) + friendly_name: str | None = Field( + default=None, sa_column=Column(Text, nullable=True) + ) + symbol: str = Field(sa_column=Column(Text, nullable=False)) + underlying_currency: str = Field(sa_column=Column(Text, nullable=False)) + status: CycleStatus = Field(sa_column=Column(Text, nullable=False)) + funding_source: FundingSource = Field(sa_column=Column(Text, nullable=False)) capital_exposure_cents: int loan_amount_cents: int | None = Field(default=None, nullable=True) loan_interest_rate_bps: int | None = Field(default=None, nullable=True) + start_date: date = Field(sa_column=Column(Date, nullable=False)) + end_date: date | None = Field(default=None, sa_column=Column(Date, nullable=True)) trades: list[Trades] = Relationship(back_populates="cycle") + + +class Users(SQLModel, table=True): + __tablename__ = "users" + id: int | None = Field(default=None, primary_key=True) + # unique=True already creates an index; no need to also set index=True + username: str = Field(sa_column=Column(Text, nullable=False, unique=True)) + password_hash: str = Field(sa_column=Column(Text, nullable=False)) + is_active: bool = Field(default=True, nullable=False) From 1d215c803249957aecfe43b35606cfc771745257 Mon Sep 17 00:00:00 2001 From: Tianyu Liu Date: Sun, 14 Sep 2025 17:03:39 +0200 Subject: [PATCH 10/16] wip crud --- backend/tests/test_crud.py | 76 ++++++++++++++++++++++++++++ backend/tests/test_db_migration.py | 4 +- backend/trading_journal/crud.py | 62 +++++++++++++++++++++++ backend/trading_journal/models.py | 10 ++-- backend/trading_journal/models_v1.py | 10 ++-- 5 files changed, 148 insertions(+), 14 deletions(-) create mode 100644 backend/tests/test_crud.py diff --git a/backend/tests/test_crud.py b/backend/tests/test_crud.py new file mode 100644 index 0000000..9f5bf1f --- /dev/null +++ b/backend/tests/test_crud.py @@ -0,0 +1,76 @@ +from collections.abc import Generator +from datetime import datetime + +import pytest +from sqlalchemy import create_engine +from sqlalchemy.engine import Engine +from sqlalchemy.pool import StaticPool +from sqlmodel import Session, SQLModel + +from trading_journal import crud, models + + +@pytest.fixture +def engine() -> Engine: + e = create_engine( + "sqlite:///:memory:", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + SQLModel.metadata.create_all(e) + return e + + +@pytest.fixture +def session(engine: Engine) -> Generator[Session, None, None]: + with Session(engine) as s: + yield s + + +def make_user(session: Session, username: str = "testuser") -> int: + user = models.Users(username=username, password_hash="hashedpassword") + session.add(user) + session.commit() + session.refresh(user) + return user.id + + +def make_cycle(session, user_id: int, friendly_name: str = "Test Cycle") -> int: + cycle = models.Cycles( + user_id=user_id, + friendly_name=friendly_name, + symbol="AAPL", + underlying_currency="USD", + status="open", + start_date=datetime.now().date(), + ) + session.add(cycle) + session.commit() + session.refresh(cycle) + return cycle.id + + +def test_create_trade_success(session: Session): + user_id = make_user(session) + cycle_id = make_cycle(session, user_id) + + trade_data = { + "user_id": user_id, + "friendly_name": "Test Trade", + "symbol": "AAPL", + "underlying_currency": "USD", + "trade_type": "LONG_SPOT", + "trade_strategy": "SPOT", + "trade_time_utc": datetime.now(), + "quantity": 10, + "price_cents": 15000, + "gross_cash_flow_cents": -150000, + "commission_cents": 500, + "net_cash_flow_cents": -150500, + "cycle_id": cycle_id, + } + + trade = crud.create_trade(session, trade_data) + assert trade.id is not None + assert trade.user_id == user_id + assert trade.cycle_id == cycle_id diff --git a/backend/tests/test_db_migration.py b/backend/tests/test_db_migration.py index d61768d..2ea7fee 100644 --- a/backend/tests/test_db_migration.py +++ b/backend/tests/test_db_migration.py @@ -36,8 +36,8 @@ def test_run_migrations_0_to_1(monkeypatch: pytest.MonkeyPatch) -> None: "symbol": ("TEXT", 1, 0), "underlying_currency": ("TEXT", 1, 0), "status": ("TEXT", 1, 0), - "funding_source": ("TEXT", 1, 0), - "capital_exposure_cents": ("INTEGER", 1, 0), + "funding_source": ("TEXT", 0, 0), + "capital_exposure_cents": ("INTEGER", 0, 0), "loan_amount_cents": ("INTEGER", 0, 0), "loan_interest_rate_bps": ("INTEGER", 0, 0), "start_date": ("DATE", 1, 0), diff --git a/backend/trading_journal/crud.py b/backend/trading_journal/crud.py index 8b13789..99a4b8e 100644 --- a/backend/trading_journal/crud.py +++ b/backend/trading_journal/crud.py @@ -1 +1,63 @@ +from typing import Mapping +from sqlalchemy.exc import IntegrityError +from sqlmodel import Session + +from trading_journal import models + + +def _coerce_enum(enum_cls, value, field_name: str): + if value is None: + raise ValueError(f"{field_name} is required") + # already an enum member + if isinstance(value, enum_cls): + return value + # strict string match: must match exactly enum name or enum value (case-sensitive) + if isinstance(value, str): + for m in enum_cls: + if m.name == value or str(m.value) == value: + return m + allowed = [m.name for m in enum_cls] + raise ValueError(f"Invalid {field_name!s}: {value!r}. Allowed: {allowed}") + + +# Trades +def create_trade(session: Session, trade_data: Mapping) -> models.Trades: + if hasattr(trade_data, "dict"): + data = trade_data.dict(exclude_unset=True) + else: + data = dict(trade_data) + allowed = {c.name for c in models.Trades.__table__.columns} + payload = {k: v for k, v in data.items() if k in allowed} + if "trade_type" not in payload: + raise ValueError("trade_type is required") + payload["trade_type"] = _coerce_enum( + models.TradeType, payload["trade_type"], "trade_type" + ) + + if "trade_strategy" not in payload: + raise ValueError("trade_strategy is required") + payload["trade_strategy"] = _coerce_enum( + models.TradeStrategy, payload["trade_strategy"], "trade_strategy" + ) + cycle_id = payload.get("cycle_id") + user_id = payload.get("user_id") + + if cycle_id is not None: + cycle = session.get(models.Cycles, cycle_id) + if cycle is None: + pass # TODO: create a cycle with basic info here + else: + if cycle.user_id != user_id: + raise ValueError("cycle.user_id does not match trade.user_id") + else: + raise ValueError("trade must have a cycle_id.") + t = models.Trades(**payload) + session.add(t) + try: + session.flush() + except IntegrityError as e: + session.rollback() + raise ValueError("create_trade integrity error") from e + session.refresh(t) + return t diff --git a/backend/trading_journal/models.py b/backend/trading_journal/models.py index 745bbbd..ee0e18b 100644 --- a/backend/trading_journal/models.py +++ b/backend/trading_journal/models.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from datetime import date, datetime # noqa: TC003 from enum import Enum @@ -75,7 +73,7 @@ class Trades(SQLModel, table=True): cycle_id: int | None = Field( default=None, foreign_key="cycles.id", nullable=True, index=True ) - cycle: Cycles | None = Relationship(back_populates="trades") + cycle: "Cycles" = Relationship(back_populates="trades") class Cycles(SQLModel, table=True): @@ -94,13 +92,13 @@ class Cycles(SQLModel, table=True): symbol: str = Field(sa_column=Column(Text, nullable=False)) underlying_currency: str = Field(sa_column=Column(Text, nullable=False)) status: CycleStatus = Field(sa_column=Column(Text, nullable=False)) - funding_source: FundingSource = Field(sa_column=Column(Text, nullable=False)) - capital_exposure_cents: int + funding_source: FundingSource = Field(sa_column=Column(Text, nullable=True)) + capital_exposure_cents: int | None = Field(default=None, nullable=True) loan_amount_cents: int | None = Field(default=None, nullable=True) loan_interest_rate_bps: int | None = Field(default=None, nullable=True) start_date: date = Field(sa_column=Column(Date, nullable=False)) end_date: date | None = Field(default=None, sa_column=Column(Date, nullable=True)) - trades: list[Trades] = Relationship(back_populates="cycle") + trades: list["Trades"] = Relationship(back_populates="cycle") class Users(SQLModel, table=True): diff --git a/backend/trading_journal/models_v1.py b/backend/trading_journal/models_v1.py index 745bbbd..ee0e18b 100644 --- a/backend/trading_journal/models_v1.py +++ b/backend/trading_journal/models_v1.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from datetime import date, datetime # noqa: TC003 from enum import Enum @@ -75,7 +73,7 @@ class Trades(SQLModel, table=True): cycle_id: int | None = Field( default=None, foreign_key="cycles.id", nullable=True, index=True ) - cycle: Cycles | None = Relationship(back_populates="trades") + cycle: "Cycles" = Relationship(back_populates="trades") class Cycles(SQLModel, table=True): @@ -94,13 +92,13 @@ class Cycles(SQLModel, table=True): symbol: str = Field(sa_column=Column(Text, nullable=False)) underlying_currency: str = Field(sa_column=Column(Text, nullable=False)) status: CycleStatus = Field(sa_column=Column(Text, nullable=False)) - funding_source: FundingSource = Field(sa_column=Column(Text, nullable=False)) - capital_exposure_cents: int + funding_source: FundingSource = Field(sa_column=Column(Text, nullable=True)) + capital_exposure_cents: int | None = Field(default=None, nullable=True) loan_amount_cents: int | None = Field(default=None, nullable=True) loan_interest_rate_bps: int | None = Field(default=None, nullable=True) start_date: date = Field(sa_column=Column(Date, nullable=False)) end_date: date | None = Field(default=None, sa_column=Column(Date, nullable=True)) - trades: list[Trades] = Relationship(back_populates="cycle") + trades: list["Trades"] = Relationship(back_populates="cycle") class Users(SQLModel, table=True): From 5753ad376766c06ff585e47cb9a18769dabe136d Mon Sep 17 00:00:00 2001 From: Tianyu Liu Date: Sun, 14 Sep 2025 17:17:48 +0200 Subject: [PATCH 11/16] fix test teardown --- backend/tests/test_crud.py | 11 +- backend/tests/test_db.py | 73 +++++++--- backend/tests/test_db_migration.py | 209 +++++++++++++++-------------- 3 files changed, 167 insertions(+), 126 deletions(-) diff --git a/backend/tests/test_crud.py b/backend/tests/test_crud.py index 9f5bf1f..199d493 100644 --- a/backend/tests/test_crud.py +++ b/backend/tests/test_crud.py @@ -11,14 +11,19 @@ from trading_journal import crud, models @pytest.fixture -def engine() -> Engine: +def engine() -> Generator[Engine, None, None]: e = create_engine( "sqlite:///:memory:", connect_args={"check_same_thread": False}, poolclass=StaticPool, ) SQLModel.metadata.create_all(e) - return e + try: + yield e + finally: + SQLModel.metadata.drop_all(e) + SQLModel.metadata.clear() + e.dispose() @pytest.fixture @@ -41,7 +46,7 @@ def make_cycle(session, user_id: int, friendly_name: str = "Test Cycle") -> int: friendly_name=friendly_name, symbol="AAPL", underlying_currency="USD", - status="open", + status=models.CycleStatus.OPEN, start_date=datetime.now().date(), ) session.add(cycle) diff --git a/backend/tests/test_db.py b/backend/tests/test_db.py index 6d5c505..9660792 100644 --- a/backend/tests/test_db.py +++ b/backend/tests/test_db.py @@ -3,7 +3,7 @@ from contextlib import contextmanager, suppress import pytest from sqlalchemy import text -from sqlmodel import Session +from sqlmodel import Session, SQLModel from trading_journal.db import Database, create_database @@ -27,46 +27,75 @@ def session_ctx(db: Database) -> Generator[Session, None, None]: # Normal completion: advance generator to let it commit/close. with suppress(StopIteration): next(gen) + finally: + # close the generator but DO NOT dispose the engine here + gen.close() + + +@contextmanager +def database_ctx(db: Database) -> Generator[Database, None, None]: + """ + Test-scoped context manager to ensure the Database (engine) is disposed at test end. + Use this to wrap test logic that needs the same in-memory engine across multiple sessions. + """ + try: + yield db + finally: + db.dispose() + SQLModel.metadata.clear() def test_select_one_executes() -> None: db = create_database(None) # in-memory by default - with session_ctx(db) as session: - val = session.exec(text("SELECT 1")).scalar_one() + with database_ctx(db): + with session_ctx(db) as session: + val = session.exec(text("SELECT 1")).scalar_one() assert int(val) == 1 def test_in_memory_persists_across_sessions_when_using_staticpool() -> None: db = create_database(None) # in-memory with StaticPool - with session_ctx(db) as s1: - s1.exec(text("CREATE TABLE IF NOT EXISTS t (id INTEGER PRIMARY KEY, val TEXT);")) - s1.exec(text("INSERT INTO t (val) VALUES (:v)").bindparams(v="hello")) - with session_ctx(db) as s2: - got = s2.exec(text("SELECT val FROM t")).scalar_one() + with database_ctx(db): + with session_ctx(db) as s1: + s1.exec( + text("CREATE TABLE IF NOT EXISTS t (id INTEGER PRIMARY KEY, val TEXT);") + ) + s1.exec(text("INSERT INTO t (val) VALUES (:v)").bindparams(v="hello")) + with session_ctx(db) as s2: + got = s2.exec(text("SELECT val FROM t")).scalar_one() assert got == "hello" def test_sqlite_pragmas_applied() -> None: db = create_database(None) - # PRAGMA returns integer 1 when foreign_keys ON - with session_ctx(db) as session: - fk = session.exec(text("PRAGMA foreign_keys")).scalar_one() + with database_ctx(db): + # PRAGMA returns integer 1 when foreign_keys ON + with session_ctx(db) as session: + fk = session.exec(text("PRAGMA foreign_keys")).scalar_one() assert int(fk) == 1 def test_rollback_on_exception() -> None: db = create_database(None) db.init_db() - # Create table then insert and raise inside the same session to force rollback + with database_ctx(db): + # Create table then insert and raise inside the same session to force rollback + with pytest.raises(RuntimeError): # noqa: PT012, SIM117 + with session_ctx(db) as s: + s.exec( + text( + "CREATE TABLE IF NOT EXISTS t_rb (id INTEGER PRIMARY KEY, val TEXT);" + ) + ) + s.exec( + text("INSERT INTO t_rb (val) VALUES (:v)").bindparams( + v="will_rollback" + ) + ) + # simulate handler error -> should trigger rollback in get_session + raise RuntimeError("simulated failure") - with pytest.raises(RuntimeError): # noqa: PT012, SIM117 - with session_ctx(db) as s: - s.exec(text("CREATE TABLE IF NOT EXISTS t_rb (id INTEGER PRIMARY KEY, val TEXT);")) - s.exec(text("INSERT INTO t_rb (val) VALUES (:v)").bindparams(v="will_rollback")) - # simulate handler error -> should trigger rollback in get_session - raise RuntimeError("simulated failure") - - # New session should not see the inserted row - with session_ctx(db) as s2: - rows = list(s2.exec(text("SELECT val FROM t_rb")).scalars()) + # New session should not see the inserted row + with session_ctx(db) as s2: + rows = list(s2.exec(text("SELECT val FROM t_rb")).scalars()) assert rows == [] diff --git a/backend/tests/test_db_migration.py b/backend/tests/test_db_migration.py index 2ea7fee..1f7fc3d 100644 --- a/backend/tests/test_db_migration.py +++ b/backend/tests/test_db_migration.py @@ -1,7 +1,7 @@ import pytest from sqlalchemy import text from sqlalchemy.pool import StaticPool -from sqlmodel import create_engine +from sqlmodel import SQLModel, create_engine from trading_journal import db_migration @@ -18,107 +18,114 @@ def test_run_migrations_0_to_1(monkeypatch: pytest.MonkeyPatch) -> None: connect_args={"check_same_thread": False}, poolclass=StaticPool, ) - monkeypatch.setattr(db_migration, "LATEST_VERSION", 1) - final_version = db_migration.run_migrations(engine) - assert final_version == 1 + try: + monkeypatch.setattr(db_migration, "LATEST_VERSION", 1) + final_version = db_migration.run_migrations(engine) + assert final_version == 1 - expected_schema = { - "users": { - "id": ("INTEGER", 1, 1), - "username": ("TEXT", 1, 0), - "password_hash": ("TEXT", 1, 0), - "is_active": ("BOOLEAN", 1, 0), - }, - "cycles": { - "id": ("INTEGER", 1, 1), - "user_id": ("INTEGER", 1, 0), - "friendly_name": ("TEXT", 0, 0), - "symbol": ("TEXT", 1, 0), - "underlying_currency": ("TEXT", 1, 0), - "status": ("TEXT", 1, 0), - "funding_source": ("TEXT", 0, 0), - "capital_exposure_cents": ("INTEGER", 0, 0), - "loan_amount_cents": ("INTEGER", 0, 0), - "loan_interest_rate_bps": ("INTEGER", 0, 0), - "start_date": ("DATE", 1, 0), - "end_date": ("DATE", 0, 0), - }, - "trades": { - "id": ("INTEGER", 1, 1), - "user_id": ("INTEGER", 1, 0), - "friendly_name": ("TEXT", 0, 0), - "symbol": ("TEXT", 1, 0), - "underlying_currency": ("TEXT", 1, 0), - "trade_type": ("TEXT", 1, 0), - "trade_strategy": ("TEXT", 1, 0), - "trade_time_utc": ("DATETIME", 1, 0), - "expiry_date": ("DATE", 0, 0), - "strike_price_cents": ("INTEGER", 0, 0), - "quantity": ("INTEGER", 1, 0), - "price_cents": ("INTEGER", 1, 0), - "gross_cash_flow_cents": ("INTEGER", 1, 0), - "commission_cents": ("INTEGER", 1, 0), - "net_cash_flow_cents": ("INTEGER", 1, 0), - "cycle_id": ("INTEGER", 0, 0), - }, - } + expected_schema = { + "users": { + "id": ("INTEGER", 1, 1), + "username": ("TEXT", 1, 0), + "password_hash": ("TEXT", 1, 0), + "is_active": ("BOOLEAN", 1, 0), + }, + "cycles": { + "id": ("INTEGER", 1, 1), + "user_id": ("INTEGER", 1, 0), + "friendly_name": ("TEXT", 0, 0), + "symbol": ("TEXT", 1, 0), + "underlying_currency": ("TEXT", 1, 0), + "status": ("TEXT", 1, 0), + "funding_source": ("TEXT", 0, 0), + "capital_exposure_cents": ("INTEGER", 0, 0), + "loan_amount_cents": ("INTEGER", 0, 0), + "loan_interest_rate_bps": ("INTEGER", 0, 0), + "start_date": ("DATE", 1, 0), + "end_date": ("DATE", 0, 0), + }, + "trades": { + "id": ("INTEGER", 1, 1), + "user_id": ("INTEGER", 1, 0), + "friendly_name": ("TEXT", 0, 0), + "symbol": ("TEXT", 1, 0), + "underlying_currency": ("TEXT", 1, 0), + "trade_type": ("TEXT", 1, 0), + "trade_strategy": ("TEXT", 1, 0), + "trade_time_utc": ("DATETIME", 1, 0), + "expiry_date": ("DATE", 0, 0), + "strike_price_cents": ("INTEGER", 0, 0), + "quantity": ("INTEGER", 1, 0), + "price_cents": ("INTEGER", 1, 0), + "gross_cash_flow_cents": ("INTEGER", 1, 0), + "commission_cents": ("INTEGER", 1, 0), + "net_cash_flow_cents": ("INTEGER", 1, 0), + "cycle_id": ("INTEGER", 0, 0), + }, + } - expected_fks = { - "trades": [ - {"table": "cycles", "from": "cycle_id", "to": "id"}, - {"table": "users", "from": "user_id", "to": "id"}, - ], - "cycles": [ - {"table": "users", "from": "user_id", "to": "id"}, - ], - } + expected_fks = { + "trades": [ + {"table": "cycles", "from": "cycle_id", "to": "id"}, + {"table": "users", "from": "user_id", "to": "id"}, + ], + "cycles": [ + {"table": "users", "from": "user_id", "to": "id"}, + ], + } - with engine.connect() as conn: - # check tables exist - rows = conn.execute( - text("SELECT name FROM sqlite_master WHERE type='table'") - ).fetchall() - found_tables = {r[0] for r in rows} - assert set(expected_schema.keys()).issubset(found_tables), ( - f"missing tables: {set(expected_schema.keys()) - found_tables}" - ) - - # check user_version - uv = conn.execute(text("PRAGMA user_version")).fetchone() - assert uv is not None - assert int(uv[0]) == 1 - - # validate each table columns - for tbl_name, cols in expected_schema.items(): - info_rows = conn.execute(text(f"PRAGMA table_info({tbl_name})")).fetchall() - # map: name -> (type, notnull, pk) - actual = { - r[1]: ((r[2] or "").upper(), int(r[3]), int(r[5])) for r in info_rows - } - for colname, (exp_type, exp_notnull, exp_pk) in cols.items(): - assert colname in actual, f"{tbl_name}: missing column {colname}" - act_type, act_notnull, act_pk = actual[colname] - # compare base type (e.g. VARCHAR(13) -> VARCHAR) - if act_type: - act_base = _base_type_of(act_type) - else: - act_base = "" - assert exp_type in act_base or act_base in exp_type, ( - f"type mismatch {tbl_name}.{colname}: expected {exp_type}, got {act_base}" - ) - assert act_notnull == exp_notnull, ( - f"notnull mismatch {tbl_name}.{colname}: expected {exp_notnull}, got {act_notnull}" - ) - assert act_pk == exp_pk, ( - f"pk mismatch {tbl_name}.{colname}: expected {exp_pk}, got {act_pk}" - ) - for tbl_name, fks in expected_fks.items(): - fk_rows = conn.execute( - text(f"PRAGMA foreign_key_list('{tbl_name}')") + with engine.connect() as conn: + # check tables exist + rows = conn.execute( + text("SELECT name FROM sqlite_master WHERE type='table'") ).fetchall() - # fk_rows columns: (id, seq, table, from, to, on_update, on_delete, match) - actual_fk_list = [ - {"table": r[2], "from": r[3], "to": r[4]} for r in fk_rows - ] - for efk in fks: - assert efk in actual_fk_list, f"missing FK on {tbl_name}: {efk}" + found_tables = {r[0] for r in rows} + assert set(expected_schema.keys()).issubset(found_tables), ( + f"missing tables: {set(expected_schema.keys()) - found_tables}" + ) + + # check user_version + uv = conn.execute(text("PRAGMA user_version")).fetchone() + assert uv is not None + assert int(uv[0]) == 1 + + # validate each table columns + for tbl_name, cols in expected_schema.items(): + info_rows = conn.execute( + text(f"PRAGMA table_info({tbl_name})") + ).fetchall() + # map: name -> (type, notnull, pk) + actual = { + r[1]: ((r[2] or "").upper(), int(r[3]), int(r[5])) + for r in info_rows + } + for colname, (exp_type, exp_notnull, exp_pk) in cols.items(): + assert colname in actual, f"{tbl_name}: missing column {colname}" + act_type, act_notnull, act_pk = actual[colname] + # compare base type (e.g. VARCHAR(13) -> VARCHAR) + if act_type: + act_base = _base_type_of(act_type) + else: + act_base = "" + assert exp_type in act_base or act_base in exp_type, ( + f"type mismatch {tbl_name}.{colname}: expected {exp_type}, got {act_base}" + ) + assert act_notnull == exp_notnull, ( + f"notnull mismatch {tbl_name}.{colname}: expected {exp_notnull}, got {act_notnull}" + ) + assert act_pk == exp_pk, ( + f"pk mismatch {tbl_name}.{colname}: expected {exp_pk}, got {act_pk}" + ) + for tbl_name, fks in expected_fks.items(): + fk_rows = conn.execute( + text(f"PRAGMA foreign_key_list('{tbl_name}')") + ).fetchall() + # fk_rows columns: (id, seq, table, from, to, on_update, on_delete, match) + actual_fk_list = [ + {"table": r[2], "from": r[3], "to": r[4]} for r in fk_rows + ] + for efk in fks: + assert efk in actual_fk_list, f"missing FK on {tbl_name}: {efk}" + finally: + engine.dispose() + SQLModel.metadata.clear() From 2c22f20b487f7294fa4437ad883c01295a6dd2d8 Mon Sep 17 00:00:00 2001 From: Tianyu Liu Date: Sun, 14 Sep 2025 21:01:12 +0200 Subject: [PATCH 12/16] continue on crud --- backend/tests/test_crud.py | 264 ++++++++++++++++++++++++++- backend/trading_journal/crud.py | 128 ++++++++++++- backend/trading_journal/models.py | 44 ++++- backend/trading_journal/models_v1.py | 21 ++- 4 files changed, 435 insertions(+), 22 deletions(-) diff --git a/backend/tests/test_crud.py b/backend/tests/test_crud.py index 199d493..8fb3c49 100644 --- a/backend/tests/test_crud.py +++ b/backend/tests/test_crud.py @@ -9,6 +9,8 @@ from sqlmodel import Session, SQLModel from trading_journal import crud, models +# TODO: If needed, add failing flow tests, but now only add happy flow. + @pytest.fixture def engine() -> Generator[Engine, None, None]: @@ -45,7 +47,7 @@ def make_cycle(session, user_id: int, friendly_name: str = "Test Cycle") -> int: user_id=user_id, friendly_name=friendly_name, symbol="AAPL", - underlying_currency="USD", + underlying_currency=models.UnderlyingCurrency.USD, status=models.CycleStatus.OPEN, start_date=datetime.now().date(), ) @@ -55,7 +57,40 @@ def make_cycle(session, user_id: int, friendly_name: str = "Test Cycle") -> int: return cycle.id -def test_create_trade_success(session: Session): +def make_trade( + session, user_id: int, cycle_id: int, friendly_name: str = "Test Trade" +) -> int: + trade = models.Trades( + user_id=user_id, + friendly_name=friendly_name, + symbol="AAPL", + underlying_currency=models.UnderlyingCurrency.USD, + trade_type=models.TradeType.LONG_SPOT, + trade_strategy=models.TradeStrategy.SPOT, + trade_date=datetime.now().date(), + trade_time_utc=datetime.now(), + quantity=10, + price_cents=15000, + gross_cash_flow_cents=-150000, + commission_cents=500, + net_cash_flow_cents=-150500, + cycle_id=cycle_id, + ) + session.add(trade) + session.commit() + session.refresh(trade) + return trade.id + + +def make_trade_by_trade_data(session, trade_data: dict) -> int: + trade = models.Trades(**trade_data) + session.add(trade) + session.commit() + session.refresh(trade) + return trade.id + + +def test_create_trade_success_with_cycle(session: Session): user_id = make_user(session) cycle_id = make_cycle(session, user_id) @@ -79,3 +114,228 @@ def test_create_trade_success(session: Session): assert trade.id is not None assert trade.user_id == user_id assert trade.cycle_id == cycle_id + session.refresh(trade) + + actual_trade = session.get(models.Trades, trade.id) + assert actual_trade is not None + assert actual_trade.friendly_name == trade_data["friendly_name"] + assert actual_trade.symbol == trade_data["symbol"] + assert actual_trade.underlying_currency == trade_data["underlying_currency"] + assert actual_trade.trade_type == trade_data["trade_type"] + assert actual_trade.trade_strategy == trade_data["trade_strategy"] + assert actual_trade.quantity == trade_data["quantity"] + assert actual_trade.price_cents == trade_data["price_cents"] + assert actual_trade.gross_cash_flow_cents == trade_data["gross_cash_flow_cents"] + assert actual_trade.commission_cents == trade_data["commission_cents"] + assert actual_trade.net_cash_flow_cents == trade_data["net_cash_flow_cents"] + assert actual_trade.cycle_id == trade_data["cycle_id"] + + +def test_create_trade_with_auto_created_cycle(session: Session): + user_id = make_user(session) + + trade_data = { + "user_id": user_id, + "friendly_name": "Test Trade with Auto Cycle", + "symbol": "AAPL", + "underlying_currency": "USD", + "trade_type": "LONG_SPOT", + "trade_strategy": "SPOT", + "trade_time_utc": datetime.now(), + "quantity": 5, + "price_cents": 15500, + } + + trade = crud.create_trade(session, trade_data) + assert trade.id is not None + assert trade.user_id == user_id + assert trade.cycle_id is not None + session.refresh(trade) + + actual_trade = session.get(models.Trades, trade.id) + assert actual_trade is not None + assert actual_trade.friendly_name == trade_data["friendly_name"] + assert actual_trade.symbol == trade_data["symbol"] + assert actual_trade.underlying_currency == trade_data["underlying_currency"] + assert actual_trade.trade_type == trade_data["trade_type"] + assert actual_trade.trade_strategy == trade_data["trade_strategy"] + assert actual_trade.quantity == trade_data["quantity"] + assert actual_trade.price_cents == trade_data["price_cents"] + assert actual_trade.cycle_id == trade.cycle_id + + # Verify the auto-created cycle + auto_cycle = session.get(models.Cycles, trade.cycle_id) + assert auto_cycle is not None + assert auto_cycle.user_id == user_id + assert auto_cycle.symbol == trade_data["symbol"] + assert auto_cycle.underlying_currency == trade_data["underlying_currency"] + assert auto_cycle.status == models.CycleStatus.OPEN + assert auto_cycle.friendly_name.startswith("Auto-created Cycle by trade") + + +def test_create_trade_missing_required_fields(session: Session): + user_id = make_user(session) + + base_trade_data = { + "user_id": user_id, + "friendly_name": "Incomplete Trade", + "symbol": "AAPL", + "underlying_currency": "USD", + "trade_type": "LONG_SPOT", + "trade_strategy": "SPOT", + "trade_time_utc": datetime.now(), + "quantity": 10, + "price_cents": 15000, + } + + # Missing symbol + trade_data = base_trade_data.copy() + trade_data.pop("symbol", None) + with pytest.raises(ValueError) as excinfo: + crud.create_trade(session, trade_data) + assert "symbol is required" in str(excinfo.value) + + # Missing underlying_currency + trade_data = base_trade_data.copy() + trade_data.pop("underlying_currency", None) + with pytest.raises(ValueError) as excinfo: + crud.create_trade(session, trade_data) + assert "underlying_currency is required" in str(excinfo.value) + + # Missing trade_type + trade_data = base_trade_data.copy() + trade_data.pop("trade_type", None) + with pytest.raises(ValueError) as excinfo: + crud.create_trade(session, trade_data) + assert "trade_type is required" in str(excinfo.value) + + # Missing trade_strategy + trade_data = base_trade_data.copy() + trade_data.pop("trade_strategy", None) + with pytest.raises(ValueError) as excinfo: + crud.create_trade(session, trade_data) + assert "trade_strategy is required" in str(excinfo.value) + + # Missing quantity + trade_data = base_trade_data.copy() + trade_data.pop("quantity", None) + with pytest.raises(ValueError) as excinfo: + crud.create_trade(session, trade_data) + assert "quantity is required" in str(excinfo.value) + + # Missing price_cents + trade_data = base_trade_data.copy() + trade_data.pop("price_cents", None) + with pytest.raises(ValueError) as excinfo: + crud.create_trade(session, trade_data) + assert "price_cents is required" in str(excinfo.value) + + +def test_get_trade_by_id(session: Session): + user_id = make_user(session) + cycle_id = make_cycle(session, user_id) + trade_data = { + "user_id": user_id, + "friendly_name": "Test Trade for Get", + "symbol": "AAPL", + "underlying_currency": models.UnderlyingCurrency.USD, + "trade_type": models.TradeType.LONG_SPOT, + "trade_strategy": models.TradeStrategy.SPOT, + "trade_date": datetime.now().date(), + "trade_time_utc": datetime.now(), + "quantity": 10, + "price_cents": 15000, + "gross_cash_flow_cents": -150000, + "commission_cents": 500, + "net_cash_flow_cents": -150500, + "cycle_id": cycle_id, + } + trade_id = make_trade_by_trade_data(session, trade_data) + trade = crud.get_trade_by_id(session, trade_id) + assert trade is not None + assert trade.id == trade_id + assert trade.friendly_name == trade_data["friendly_name"] + assert trade.symbol == trade_data["symbol"] + assert trade.underlying_currency == trade_data["underlying_currency"] + assert trade.trade_type == trade_data["trade_type"] + assert trade.trade_strategy == trade_data["trade_strategy"] + assert trade.quantity == trade_data["quantity"] + assert trade.price_cents == trade_data["price_cents"] + assert trade.gross_cash_flow_cents == trade_data["gross_cash_flow_cents"] + assert trade.commission_cents == trade_data["commission_cents"] + assert trade.net_cash_flow_cents == trade_data["net_cash_flow_cents"] + assert trade.cycle_id == trade_data["cycle_id"] + assert trade.trade_date == trade_data["trade_date"] + + +def test_get_trade_by_user_id_and_friendly_name(session: Session): + user_id = make_user(session) + cycle_id = make_cycle(session, user_id) + friendly_name = "Unique Trade Name" + trade_data = { + "user_id": user_id, + "friendly_name": friendly_name, + "symbol": "AAPL", + "underlying_currency": models.UnderlyingCurrency.USD, + "trade_type": models.TradeType.LONG_SPOT, + "trade_strategy": models.TradeStrategy.SPOT, + "trade_date": datetime.now().date(), + "trade_time_utc": datetime.now(), + "quantity": 10, + "price_cents": 15000, + "gross_cash_flow_cents": -150000, + "commission_cents": 500, + "net_cash_flow_cents": -150500, + "cycle_id": cycle_id, + } + make_trade_by_trade_data(session, trade_data) + trade = crud.get_trade_by_user_id_and_friendly_name(session, user_id, friendly_name) + assert trade is not None + assert trade.friendly_name == friendly_name + assert trade.user_id == user_id + + +def test_create_cycle(session: Session): + user_id = make_user(session) + cycle_data = { + "user_id": user_id, + "friendly_name": "My First Cycle", + "symbol": "GOOGL", + "underlying_currency": "USD", + "status": models.CycleStatus.OPEN, + "start_date": datetime.now().date(), + } + cycle = crud.create_cycle(session, cycle_data) + assert cycle.id is not None + assert cycle.user_id == user_id + assert cycle.friendly_name == cycle_data["friendly_name"] + assert cycle.symbol == cycle_data["symbol"] + assert cycle.underlying_currency == cycle_data["underlying_currency"] + assert cycle.status == cycle_data["status"] + assert cycle.start_date == cycle_data["start_date"] + + session.refresh(cycle) + actual_cycle = session.get(models.Cycles, cycle.id) + assert actual_cycle is not None + assert actual_cycle.friendly_name == cycle_data["friendly_name"] + assert actual_cycle.symbol == cycle_data["symbol"] + assert actual_cycle.underlying_currency == cycle_data["underlying_currency"] + assert actual_cycle.status == cycle_data["status"] + assert actual_cycle.start_date == cycle_data["start_date"] + + +def test_create_user(session: Session): + user_data = { + "username": "newuser", + "password_hash": "newhashedpassword", + } + user = crud.create_user(session, user_data) + assert user.id is not None + assert user.username == user_data["username"] + assert user.password_hash == user_data["password_hash"] + + session.refresh(user) + actual_user = session.get(models.Users, user.id) + assert actual_user is not None + assert actual_user.username == user_data["username"] + assert actual_user.password_hash == user_data["password_hash"] diff --git a/backend/trading_journal/crud.py b/backend/trading_journal/crud.py index 99a4b8e..3515c8d 100644 --- a/backend/trading_journal/crud.py +++ b/backend/trading_journal/crud.py @@ -1,12 +1,13 @@ +from datetime import datetime, timezone from typing import Mapping from sqlalchemy.exc import IntegrityError -from sqlmodel import Session +from sqlmodel import Session, select from trading_journal import models -def _coerce_enum(enum_cls, value, field_name: str): +def _check_enum(enum_cls, value, field_name: str): if value is None: raise ValueError(f"{field_name} is required") # already an enum member @@ -29,29 +30,66 @@ def create_trade(session: Session, trade_data: Mapping) -> models.Trades: data = dict(trade_data) allowed = {c.name for c in models.Trades.__table__.columns} payload = {k: v for k, v in data.items() if k in allowed} + if "symbol" not in payload: + raise ValueError("symbol is required") + if "underlying_currency" not in payload: + raise ValueError("underlying_currency is required") + payload["underlying_currency"] = _check_enum( + models.UnderlyingCurrency, payload["underlying_currency"], "underlying_currency" + ) if "trade_type" not in payload: raise ValueError("trade_type is required") - payload["trade_type"] = _coerce_enum( + payload["trade_type"] = _check_enum( models.TradeType, payload["trade_type"], "trade_type" ) - if "trade_strategy" not in payload: raise ValueError("trade_strategy is required") - payload["trade_strategy"] = _coerce_enum( + payload["trade_strategy"] = _check_enum( models.TradeStrategy, payload["trade_strategy"], "trade_strategy" ) + now = datetime.now(timezone.utc) + payload.pop("trade_time_utc", None) + payload["trade_time_utc"] = now + if "trade_date" not in payload or payload.get("trade_date") is None: + payload["trade_date"] = payload["trade_time_utc"].date() cycle_id = payload.get("cycle_id") user_id = payload.get("user_id") - + if "quantity" not in payload: + raise ValueError("quantity is required") + if "price_cents" not in payload: + raise ValueError("price_cents is required") + if "commission_cents" not in payload: + payload["commission_cents"] = 0 + quantity: int = payload["quantity"] + price_cents: int = payload["price_cents"] + commission_cents: int = payload["commission_cents"] + if "gross_cash_flow_cents" not in payload: + payload["gross_cash_flow_cents"] = -quantity * price_cents + if "net_cash_flow_cents" not in payload: + payload["net_cash_flow_cents"] = ( + payload["gross_cash_flow_cents"] - commission_cents + ) + if cycle_id is None: + cycle_id = create_cycle( + session, + { + "user_id": user_id, + "symbol": payload["symbol"], + "underlying_currency": payload["underlying_currency"], + "friendly_name": "Auto-created Cycle by trade " + + payload.get("friendly_name", ""), + "status": models.CycleStatus.OPEN, + "start_date": payload["trade_date"], + }, + ).id + payload["cycle_id"] = cycle_id if cycle_id is not None: cycle = session.get(models.Cycles, cycle_id) if cycle is None: - pass # TODO: create a cycle with basic info here + raise ValueError("cycle_id does not exist") else: if cycle.user_id != user_id: raise ValueError("cycle.user_id does not match trade.user_id") - else: - raise ValueError("trade must have a cycle_id.") t = models.Trades(**payload) session.add(t) try: @@ -61,3 +99,75 @@ def create_trade(session: Session, trade_data: Mapping) -> models.Trades: raise ValueError("create_trade integrity error") from e session.refresh(t) return t + + +def get_trade_by_id(session: Session, trade_id: int) -> models.Trades | None: + return session.get(models.Trades, trade_id) + + +def get_trade_by_user_id_and_friendly_name( + session: Session, user_id: int, friendly_name: str +) -> models.Trades | None: + statement = select(models.Trades).where( + models.Trades.user_id == user_id, + models.Trades.friendly_name == friendly_name, + ) + return session.exec(statement).first() + + +# Cycles +def create_cycle(session: Session, cycle_data: Mapping) -> models.Cycles: + if hasattr(cycle_data, "dict"): + data = cycle_data.dict(exclude_unset=True) + else: + data = dict(cycle_data) + allowed = {c.name for c in models.Cycles.__table__.columns} + payload = {k: v for k, v in data.items() if k in allowed} + if "user_id" not in payload: + raise ValueError("user_id is required") + if "symbol" not in payload: + raise ValueError("symbol is required") + if "underlying_currency" not in payload: + raise ValueError("underlying_currency is required") + payload["underlying_currency"] = _check_enum( + models.UnderlyingCurrency, payload["underlying_currency"], "underlying_currency" + ) + if "status" not in payload: + raise ValueError("status is required") + payload["status"] = _check_enum(models.CycleStatus, payload["status"], "status") + if "start_date" not in payload: + raise ValueError("start_date is required") + + c = models.Cycles(**payload) + session.add(c) + try: + session.flush() + except IntegrityError as e: + session.rollback() + raise ValueError("create_cycle integrity error") from e + session.refresh(c) + return c + + +# Users +def create_user(session: Session, user_data: Mapping) -> models.Users: + if hasattr(user_data, "dict"): + data = user_data.dict(exclude_unset=True) + else: + data = dict(user_data) + allowed = {c.name for c in models.Users.__table__.columns} + payload = {k: v for k, v in data.items() if k in allowed} + if "username" not in payload: + raise ValueError("username is required") + if "password_hash" not in payload: + raise ValueError("password_hash is required") + + u = models.Users(**payload) + session.add(u) + try: + session.flush() + except IntegrityError as e: + session.rollback() + raise ValueError("create_user integrity error") from e + session.refresh(u) + return u diff --git a/backend/trading_journal/models.py b/backend/trading_journal/models.py index ee0e18b..b916e1b 100644 --- a/backend/trading_journal/models.py +++ b/backend/trading_journal/models.py @@ -1,8 +1,17 @@ from datetime import date, datetime # noqa: TC003 from enum import Enum -from sqlalchemy import Date, Text, UniqueConstraint -from sqlmodel import Column, DateTime, Field, Relationship, SQLModel +from sqlmodel import ( + Column, + Date, + DateTime, + Field, + Integer, + Relationship, + SQLModel, + Text, + UniqueConstraint, +) class TradeType(str, Enum): @@ -36,6 +45,18 @@ class CycleStatus(str, Enum): CLOSED = "CLOSED" +class UnderlyingCurrency(str, Enum): + EUR = "EUR" + USD = "USD" + GBP = "GBP" + JPY = "JPY" + AUD = "AUD" + CAD = "CAD" + CHF = "CHF" + NZD = "NZD" + CNY = "CNY" + + class FundingSource(str, Enum): CASH = "CASH" MARGIN = "MARGIN" @@ -57,19 +78,22 @@ class Trades(SQLModel, table=True): default=None, sa_column=Column(Text, nullable=True) ) symbol: str = Field(sa_column=Column(Text, nullable=False)) - underlying_currency: str = Field(sa_column=Column(Text, nullable=False)) + underlying_currency: UnderlyingCurrency = Field( + sa_column=Column(Text, nullable=False) + ) trade_type: TradeType = Field(sa_column=Column(Text, nullable=False)) trade_strategy: TradeStrategy = Field(sa_column=Column(Text, nullable=False)) + trade_date: date = Field(sa_column=Column(Date, nullable=False)) trade_time_utc: datetime = Field( sa_column=Column(DateTime(timezone=True), nullable=False) ) expiry_date: date | None = Field(default=None, nullable=True) strike_price_cents: int | None = Field(default=None, nullable=True) - quantity: int - price_cents: int - gross_cash_flow_cents: int - commission_cents: int - net_cash_flow_cents: int + quantity: int = Field(sa_column=Column(Integer, nullable=False)) + price_cents: int = Field(sa_column=Column(Integer, nullable=False)) + gross_cash_flow_cents: int = Field(sa_column=Column(Integer, nullable=False)) + commission_cents: int = Field(sa_column=Column(Integer, nullable=False)) + net_cash_flow_cents: int = Field(sa_column=Column(Integer, nullable=False)) cycle_id: int | None = Field( default=None, foreign_key="cycles.id", nullable=True, index=True ) @@ -90,7 +114,9 @@ class Cycles(SQLModel, table=True): default=None, sa_column=Column(Text, nullable=True) ) symbol: str = Field(sa_column=Column(Text, nullable=False)) - underlying_currency: str = Field(sa_column=Column(Text, nullable=False)) + underlying_currency: UnderlyingCurrency = Field( + sa_column=Column(Text, nullable=False) + ) status: CycleStatus = Field(sa_column=Column(Text, nullable=False)) funding_source: FundingSource = Field(sa_column=Column(Text, nullable=True)) capital_exposure_cents: int | None = Field(default=None, nullable=True) diff --git a/backend/trading_journal/models_v1.py b/backend/trading_journal/models_v1.py index ee0e18b..b7f3696 100644 --- a/backend/trading_journal/models_v1.py +++ b/backend/trading_journal/models_v1.py @@ -36,6 +36,18 @@ class CycleStatus(str, Enum): CLOSED = "CLOSED" +class UnderlyingCurrency(str, Enum): + EUR = "EUR" + USD = "USD" + GBP = "GBP" + JPY = "JPY" + AUD = "AUD" + CAD = "CAD" + CHF = "CHF" + NZD = "NZD" + CNY = "CNY" + + class FundingSource(str, Enum): CASH = "CASH" MARGIN = "MARGIN" @@ -57,9 +69,12 @@ class Trades(SQLModel, table=True): default=None, sa_column=Column(Text, nullable=True) ) symbol: str = Field(sa_column=Column(Text, nullable=False)) - underlying_currency: str = Field(sa_column=Column(Text, nullable=False)) + underlying_currency: UnderlyingCurrency = Field( + sa_column=Column(Text, nullable=False) + ) trade_type: TradeType = Field(sa_column=Column(Text, nullable=False)) trade_strategy: TradeStrategy = Field(sa_column=Column(Text, nullable=False)) + trade_date: date = Field(sa_column=Column(Date, nullable=False)) trade_time_utc: datetime = Field( sa_column=Column(DateTime(timezone=True), nullable=False) ) @@ -90,7 +105,9 @@ class Cycles(SQLModel, table=True): default=None, sa_column=Column(Text, nullable=True) ) symbol: str = Field(sa_column=Column(Text, nullable=False)) - underlying_currency: str = Field(sa_column=Column(Text, nullable=False)) + underlying_currency: UnderlyingCurrency = Field( + sa_column=Column(Text, nullable=False) + ) status: CycleStatus = Field(sa_column=Column(Text, nullable=False)) funding_source: FundingSource = Field(sa_column=Column(Text, nullable=True)) capital_exposure_cents: int | None = Field(default=None, nullable=True) From 7041cc654e96f68d9fe1304beba994478df08a3c Mon Sep 17 00:00:00 2001 From: Tianyu Liu Date: Sun, 14 Sep 2025 21:09:26 +0200 Subject: [PATCH 13/16] refine teardown --- backend/tests/test_crud.py | 1 - backend/tests/test_db.py | 2 +- backend/tests/test_db_migration.py | 1 + 3 files changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/tests/test_crud.py b/backend/tests/test_crud.py index 8fb3c49..6e8700c 100644 --- a/backend/tests/test_crud.py +++ b/backend/tests/test_crud.py @@ -24,7 +24,6 @@ def engine() -> Generator[Engine, None, None]: yield e finally: SQLModel.metadata.drop_all(e) - SQLModel.metadata.clear() e.dispose() diff --git a/backend/tests/test_db.py b/backend/tests/test_db.py index 9660792..4275b1c 100644 --- a/backend/tests/test_db.py +++ b/backend/tests/test_db.py @@ -42,7 +42,6 @@ def database_ctx(db: Database) -> Generator[Database, None, None]: yield db finally: db.dispose() - SQLModel.metadata.clear() def test_select_one_executes() -> None: @@ -77,6 +76,7 @@ def test_sqlite_pragmas_applied() -> None: def test_rollback_on_exception() -> None: db = create_database(None) + SQLModel.metadata.clear() db.init_db() with database_ctx(db): # Create table then insert and raise inside the same session to force rollback diff --git a/backend/tests/test_db_migration.py b/backend/tests/test_db_migration.py index 1f7fc3d..e1c8850 100644 --- a/backend/tests/test_db_migration.py +++ b/backend/tests/test_db_migration.py @@ -13,6 +13,7 @@ def _base_type_of(compiled: str) -> str: def test_run_migrations_0_to_1(monkeypatch: pytest.MonkeyPatch) -> None: # in-memory engine that preserves the same connection (StaticPool) + SQLModel.metadata.clear() engine = create_engine( "sqlite:///:memory:", connect_args={"check_same_thread": False}, From a0898fa29ede1e319a2b77dada989110bc82b100 Mon Sep 17 00:00:00 2001 From: Tianyu Liu Date: Mon, 15 Sep 2025 20:30:32 +0200 Subject: [PATCH 14/16] refine modle --- backend/trading_journal/models.py | 2 +- backend/trading_journal/models_v1.py | 25 +++++++++++++++++-------- 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/backend/trading_journal/models.py b/backend/trading_journal/models.py index b916e1b..d9d8944 100644 --- a/backend/trading_journal/models.py +++ b/backend/trading_journal/models.py @@ -34,7 +34,7 @@ class TradeType(str, Enum): class TradeStrategy(str, Enum): - WHEELS = "WHEEL" + WHEEL = "WHEEL" FX = "FX" SPOT = "SPOT" OTHER = "OTHER" diff --git a/backend/trading_journal/models_v1.py b/backend/trading_journal/models_v1.py index b7f3696..d9d8944 100644 --- a/backend/trading_journal/models_v1.py +++ b/backend/trading_journal/models_v1.py @@ -1,8 +1,17 @@ from datetime import date, datetime # noqa: TC003 from enum import Enum -from sqlalchemy import Date, Text, UniqueConstraint -from sqlmodel import Column, DateTime, Field, Relationship, SQLModel +from sqlmodel import ( + Column, + Date, + DateTime, + Field, + Integer, + Relationship, + SQLModel, + Text, + UniqueConstraint, +) class TradeType(str, Enum): @@ -25,7 +34,7 @@ class TradeType(str, Enum): class TradeStrategy(str, Enum): - WHEELS = "WHEEL" + WHEEL = "WHEEL" FX = "FX" SPOT = "SPOT" OTHER = "OTHER" @@ -80,11 +89,11 @@ class Trades(SQLModel, table=True): ) expiry_date: date | None = Field(default=None, nullable=True) strike_price_cents: int | None = Field(default=None, nullable=True) - quantity: int - price_cents: int - gross_cash_flow_cents: int - commission_cents: int - net_cash_flow_cents: int + quantity: int = Field(sa_column=Column(Integer, nullable=False)) + price_cents: int = Field(sa_column=Column(Integer, nullable=False)) + gross_cash_flow_cents: int = Field(sa_column=Column(Integer, nullable=False)) + commission_cents: int = Field(sa_column=Column(Integer, nullable=False)) + net_cash_flow_cents: int = Field(sa_column=Column(Integer, nullable=False)) cycle_id: int | None = Field( default=None, foreign_key="cycles.id", nullable=True, index=True ) From d1f1b3e66c21c75a9dc10684c2b3aeab3debdf7a Mon Sep 17 00:00:00 2001 From: Tianyu Liu Date: Wed, 17 Sep 2025 16:36:56 +0200 Subject: [PATCH 15/16] Add invalidate not yet with tests --- backend/tests/test_crud.py | 44 +++++++++++++++++++++++++++++++ backend/trading_journal/crud.py | 7 +++++ backend/trading_journal/models.py | 8 ++++++ 3 files changed, 59 insertions(+) diff --git a/backend/tests/test_crud.py b/backend/tests/test_crud.py index 6e8700c..325e270 100644 --- a/backend/tests/test_crud.py +++ b/backend/tests/test_crud.py @@ -294,6 +294,50 @@ def test_get_trade_by_user_id_and_friendly_name(session: Session): assert trade.user_id == user_id +def test_get_trades_by_user_id(session: Session): + user_id = make_user(session) + cycle_id = make_cycle(session, user_id) + trade_data_1 = { + "user_id": user_id, + "friendly_name": "Trade One", + "symbol": "AAPL", + "underlying_currency": models.UnderlyingCurrency.USD, + "trade_type": models.TradeType.LONG_SPOT, + "trade_strategy": models.TradeStrategy.SPOT, + "trade_date": datetime.now().date(), + "trade_time_utc": datetime.now(), + "quantity": 10, + "price_cents": 15000, + "gross_cash_flow_cents": -150000, + "commission_cents": 500, + "net_cash_flow_cents": -150500, + "cycle_id": cycle_id, + } + trade_data_2 = { + "user_id": user_id, + "friendly_name": "Trade Two", + "symbol": "GOOGL", + "underlying_currency": models.UnderlyingCurrency.USD, + "trade_type": models.TradeType.SHORT_SPOT, + "trade_strategy": models.TradeStrategy.SPOT, + "trade_date": datetime.now().date(), + "trade_time_utc": datetime.now(), + "quantity": 5, + "price_cents": 280000, + "gross_cash_flow_cents": 1400000, + "commission_cents": 700, + "net_cash_flow_cents": 1399300, + "cycle_id": cycle_id, + } + make_trade_by_trade_data(session, trade_data_1) + make_trade_by_trade_data(session, trade_data_2) + + trades = crud.get_trades_by_user_id(session, user_id) + assert len(trades) == 2 + friendly_names = {trade.friendly_name for trade in trades} + assert friendly_names == {"Trade One", "Trade Two"} + + def test_create_cycle(session: Session): user_id = make_user(session) cycle_data = { diff --git a/backend/trading_journal/crud.py b/backend/trading_journal/crud.py index 3515c8d..b37e065 100644 --- a/backend/trading_journal/crud.py +++ b/backend/trading_journal/crud.py @@ -115,6 +115,13 @@ def get_trade_by_user_id_and_friendly_name( return session.exec(statement).first() +def get_trades_by_user_id(session: Session, user_id: int) -> list[models.Trades]: + statement = select(models.Trades).where( + models.Trades.user_id == user_id, + ) + return session.exec(statement).all() + + # Cycles def create_cycle(session: Session, cycle_data: Mapping) -> models.Cycles: if hasattr(cycle_data, "dict"): diff --git a/backend/trading_journal/models.py b/backend/trading_journal/models.py index d9d8944..0e5857f 100644 --- a/backend/trading_journal/models.py +++ b/backend/trading_journal/models.py @@ -94,6 +94,14 @@ class Trades(SQLModel, table=True): gross_cash_flow_cents: int = Field(sa_column=Column(Integer, nullable=False)) commission_cents: int = Field(sa_column=Column(Integer, nullable=False)) net_cash_flow_cents: int = Field(sa_column=Column(Integer, nullable=False)) + is_invalidated: bool = Field(default=False, nullable=False) + invalidated_at: datetime | None = Field( + default=None, sa_column=Column(DateTime(timezone=True), nullable=True) + ) + replaced_by_trade_id: int | None = Field( + default=None, foreign_key="trades.id", nullable=True + ) + notes: str | None = Field(default=None, sa_column=Column(Text, nullable=True)) cycle_id: int | None = Field( default=None, foreign_key="cycles.id", nullable=True, index=True ) From eb1f8c0e37f95807c943f0ac66ad280779593be9 Mon Sep 17 00:00:00 2001 From: Tianyu Liu Date: Thu, 18 Sep 2025 14:26:55 +0200 Subject: [PATCH 16/16] db ferst version is done. --- backend/tests/test_crud.py | 178 +++++++++++++++++++++++++-- backend/trading_journal/crud.py | 150 +++++++++++++++++++--- backend/trading_journal/models_v1.py | 8 ++ 3 files changed, 312 insertions(+), 24 deletions(-) diff --git a/backend/tests/test_crud.py b/backend/tests/test_crud.py index 325e270..3ee2fce 100644 --- a/backend/tests/test_crud.py +++ b/backend/tests/test_crud.py @@ -74,6 +74,7 @@ def make_trade( commission_cents=500, net_cash_flow_cents=-150500, cycle_id=cycle_id, + notes="Initial test trade", ) session.add(trade) session.commit() @@ -97,9 +98,9 @@ def test_create_trade_success_with_cycle(session: Session): "user_id": user_id, "friendly_name": "Test Trade", "symbol": "AAPL", - "underlying_currency": "USD", - "trade_type": "LONG_SPOT", - "trade_strategy": "SPOT", + "underlying_currency": models.UnderlyingCurrency.USD, + "trade_type": models.TradeType.LONG_SPOT, + "trade_strategy": models.TradeStrategy.SPOT, "trade_time_utc": datetime.now(), "quantity": 10, "price_cents": 15000, @@ -137,9 +138,9 @@ def test_create_trade_with_auto_created_cycle(session: Session): "user_id": user_id, "friendly_name": "Test Trade with Auto Cycle", "symbol": "AAPL", - "underlying_currency": "USD", - "trade_type": "LONG_SPOT", - "trade_strategy": "SPOT", + "underlying_currency": models.UnderlyingCurrency.USD, + "trade_type": models.TradeType.LONG_SPOT, + "trade_strategy": models.TradeStrategy.SPOT, "trade_time_utc": datetime.now(), "quantity": 5, "price_cents": 15500, @@ -179,9 +180,9 @@ def test_create_trade_missing_required_fields(session: Session): "user_id": user_id, "friendly_name": "Incomplete Trade", "symbol": "AAPL", - "underlying_currency": "USD", - "trade_type": "LONG_SPOT", - "trade_strategy": "SPOT", + "underlying_currency": models.UnderlyingCurrency.USD, + "trade_type": models.TradeType.LONG_SPOT, + "trade_strategy": models.TradeStrategy.SPOT, "trade_time_utc": datetime.now(), "quantity": 10, "price_cents": 15000, @@ -338,13 +339,89 @@ def test_get_trades_by_user_id(session: Session): assert friendly_names == {"Trade One", "Trade Two"} +def test_update_trade_note(session: Session): + user_id = make_user(session) + cycle_id = make_cycle(session, user_id) + trade_id = make_trade(session, user_id, cycle_id) + + new_note = "This is an updated note." + updated_trade = crud.update_trade_note(session, trade_id, new_note) + assert updated_trade is not None + assert updated_trade.id == trade_id + assert updated_trade.notes == new_note + + session.refresh(updated_trade) + actual_trade = session.get(models.Trades, trade_id) + assert actual_trade is not None + assert actual_trade.notes == new_note + + +def test_invalidate_trade(session: Session): + user_id = make_user(session) + cycle_id = make_cycle(session, user_id) + trade_id = make_trade(session, user_id, cycle_id) + + invalidated_trade = crud.invalidate_trade(session, trade_id) + assert invalidated_trade is not None + assert invalidated_trade.id == trade_id + assert invalidated_trade.is_invalidated is True + + session.refresh(invalidated_trade) + actual_trade = session.get(models.Trades, trade_id) + assert actual_trade is not None + assert actual_trade.is_invalidated is True + + +def test_replace_trade(session: Session): + user_id = make_user(session) + cycle_id = make_cycle(session, user_id) + old_trade_id = make_trade(session, user_id, cycle_id) + + new_trade_data = { + "user_id": user_id, + "friendly_name": "Replaced Trade", + "symbol": "MSFT", + "underlying_currency": models.UnderlyingCurrency.USD, + "trade_type": models.TradeType.LONG_SPOT, + "trade_strategy": models.TradeStrategy.SPOT, + "trade_time_utc": datetime.now(), + "quantity": 20, + "price_cents": 25000, + } + + new_trade = crud.replace_trade(session, old_trade_id, new_trade_data) + assert new_trade.id is not None + assert new_trade.id != old_trade_id + assert new_trade.user_id == user_id + assert new_trade.symbol == new_trade_data["symbol"] + assert new_trade.quantity == new_trade_data["quantity"] + + # Verify the old trade is invalidated + old_trade = session.get(models.Trades, old_trade_id) + assert old_trade is not None + assert old_trade.is_invalidated is True + + # Verify the new trade exists + session.refresh(new_trade) + actual_new_trade = session.get(models.Trades, new_trade.id) + assert actual_new_trade is not None + assert actual_new_trade.friendly_name == new_trade_data["friendly_name"] + assert actual_new_trade.symbol == new_trade_data["symbol"] + assert actual_new_trade.underlying_currency == new_trade_data["underlying_currency"] + assert actual_new_trade.trade_type == new_trade_data["trade_type"] + assert actual_new_trade.trade_strategy == new_trade_data["trade_strategy"] + assert actual_new_trade.quantity == new_trade_data["quantity"] + assert actual_new_trade.price_cents == new_trade_data["price_cents"] + assert actual_new_trade.replaced_by_trade_id == old_trade_id + + def test_create_cycle(session: Session): user_id = make_user(session) cycle_data = { "user_id": user_id, "friendly_name": "My First Cycle", "symbol": "GOOGL", - "underlying_currency": "USD", + "underlying_currency": models.UnderlyingCurrency.USD, "status": models.CycleStatus.OPEN, "start_date": datetime.now().date(), } @@ -367,6 +444,50 @@ def test_create_cycle(session: Session): assert actual_cycle.start_date == cycle_data["start_date"] +def test_update_cycle(session: Session): + user_id = make_user(session) + cycle_id = make_cycle(session, user_id, friendly_name="Initial Cycle Name") + + update_data = { + "friendly_name": "Updated Cycle Name", + "status": models.CycleStatus.CLOSED, + } + updated_cycle = crud.update_cycle(session, cycle_id, update_data) + assert updated_cycle is not None + assert updated_cycle.id == cycle_id + assert updated_cycle.friendly_name == update_data["friendly_name"] + assert updated_cycle.status == update_data["status"] + + session.refresh(updated_cycle) + actual_cycle = session.get(models.Cycles, cycle_id) + assert actual_cycle is not None + assert actual_cycle.friendly_name == update_data["friendly_name"] + assert actual_cycle.status == update_data["status"] + + +def test_update_cycle_immutable_fields(session: Session): + user_id = make_user(session) + cycle_id = make_cycle(session, user_id, friendly_name="Initial Cycle Name") + + # Attempt to update immutable fields + update_data = { + "id": cycle_id + 1, # Trying to change the ID + "user_id": user_id + 1, # Trying to change the user_id + "start_date": datetime(2020, 1, 1).date(), # Trying to change start_date + "created_at": datetime(2020, 1, 1), # Trying to change created_at + "friendly_name": "Valid Update", # Valid field to update + } + + with pytest.raises(ValueError) as excinfo: + crud.update_cycle(session, cycle_id, update_data) + assert ( + "field 'id' is immutable" in str(excinfo.value) + or "field 'user_id' is immutable" in str(excinfo.value) + or "field 'start_date' is immutable" in str(excinfo.value) + or "field 'created_at' is immutable" in str(excinfo.value) + ) + + def test_create_user(session: Session): user_data = { "username": "newuser", @@ -382,3 +503,40 @@ def test_create_user(session: Session): assert actual_user is not None assert actual_user.username == user_data["username"] assert actual_user.password_hash == user_data["password_hash"] + + +def test_update_user(session: Session): + user_id = make_user(session, username="updatableuser") + + update_data = { + "password_hash": "updatedhashedpassword", + } + updated_user = crud.update_user(session, user_id, update_data) + assert updated_user is not None + assert updated_user.id == user_id + assert updated_user.password_hash == update_data["password_hash"] + + session.refresh(updated_user) + actual_user = session.get(models.Users, user_id) + assert actual_user is not None + assert actual_user.password_hash == update_data["password_hash"] + + +def test_update_user_immutable_fields(session: Session): + user_id = make_user(session, username="immutableuser") + + # Attempt to update immutable fields + update_data = { + "id": user_id + 1, # Trying to change the ID + "username": "newusername", # Trying to change the username + "created_at": datetime(2020, 1, 1), # Trying to change created_at + "password_hash": "validupdate", # Valid field to update + } + + with pytest.raises(ValueError) as excinfo: + crud.update_user(session, user_id, update_data) + assert ( + "field 'id' is immutable" in str(excinfo.value) + or "field 'username' is immutable" in str(excinfo.value) + or "field 'created_at' is immutable" in str(excinfo.value) + ) diff --git a/backend/trading_journal/crud.py b/backend/trading_journal/crud.py index b37e065..386c1f4 100644 --- a/backend/trading_journal/crud.py +++ b/backend/trading_journal/crud.py @@ -47,6 +47,7 @@ def create_trade(session: Session, trade_data: Mapping) -> models.Trades: payload["trade_strategy"] = _check_enum( models.TradeStrategy, payload["trade_strategy"], "trade_strategy" ) + # trade_time_utc is the creation moment: always set to now (caller shouldn't provide) now = datetime.now(timezone.utc) payload.pop("trade_time_utc", None) payload["trade_time_utc"] = now @@ -69,20 +70,24 @@ def create_trade(session: Session, trade_data: Mapping) -> models.Trades: payload["net_cash_flow_cents"] = ( payload["gross_cash_flow_cents"] - commission_cents ) + + # If no cycle_id provided, create Cycle instance but don't call create_cycle() + created_cycle = None if cycle_id is None: - cycle_id = create_cycle( - session, - { - "user_id": user_id, - "symbol": payload["symbol"], - "underlying_currency": payload["underlying_currency"], - "friendly_name": "Auto-created Cycle by trade " - + payload.get("friendly_name", ""), - "status": models.CycleStatus.OPEN, - "start_date": payload["trade_date"], - }, - ).id - payload["cycle_id"] = cycle_id + c_payload = { + "user_id": user_id, + "symbol": payload["symbol"], + "underlying_currency": payload["underlying_currency"], + "friendly_name": "Auto-created Cycle by trade " + + payload.get("friendly_name", ""), + "status": models.CycleStatus.OPEN, + "start_date": payload["trade_date"], + } + created_cycle = models.Cycles(**c_payload) + session.add(created_cycle) + # do NOT flush here; will flush together with trade below + + # If cycle_id provided, validate existence and ownership if cycle_id is not None: cycle = session.get(models.Cycles, cycle_id) if cycle is None: @@ -90,7 +95,16 @@ def create_trade(session: Session, trade_data: Mapping) -> models.Trades: else: if cycle.user_id != user_id: raise ValueError("cycle.user_id does not match trade.user_id") - t = models.Trades(**payload) + + # Build trade instance; if we created a Cycle instance, link via relationship so a single flush will persist both and populate ids + t_payload = dict(payload) + # remove cycle_id if we're using created_cycle; relationship will set it on flush + if created_cycle is not None: + t_payload.pop("cycle_id", None) + t = models.Trades(**t_payload) + if created_cycle is not None: + t.cycle = created_cycle + session.add(t) try: session.flush() @@ -122,6 +136,52 @@ def get_trades_by_user_id(session: Session, user_id: int) -> list[models.Trades] return session.exec(statement).all() +def update_trade_note(session: Session, trade_id: int, note: str) -> models.Trades: + trade: models.Trades | None = session.get(models.Trades, trade_id) + if trade is None: + raise ValueError("trade_id does not exist") + trade.notes = note + session.add(trade) + try: + session.flush() + except IntegrityError as e: + session.rollback() + raise ValueError("update_trade_note integrity error") from e + session.refresh(trade) + return trade + + +def invalidate_trade(session: Session, trade_id: int) -> models.Trades: + trade: models.Trades | None = session.get(models.Trades, trade_id) + if trade is None: + raise ValueError("trade_id does not exist") + if trade.is_invalidated: + raise ValueError("trade is already invalidated") + trade.is_invalidated = True + trade.invalidated_at = datetime.now(timezone.utc) + session.add(trade) + try: + session.flush() + except IntegrityError as e: + session.rollback() + raise ValueError("invalidate_trade integrity error") from e + session.refresh(trade) + return trade + + +def replace_trade( + session: Session, old_trade_id: int, new_trade_data: Mapping +) -> models.Trades: + invalidate_trade(session, old_trade_id) + if hasattr(new_trade_data, "dict"): + data = new_trade_data.dict(exclude_unset=True) + else: + data = dict(new_trade_data) + data["replaced_by_trade_id"] = old_trade_id + new_trade = create_trade(session, data) + return new_trade + + # Cycles def create_cycle(session: Session, cycle_data: Mapping) -> models.Cycles: if hasattr(cycle_data, "dict"): @@ -156,7 +216,45 @@ def create_cycle(session: Session, cycle_data: Mapping) -> models.Cycles: return c +IMMUTABLE_CYCLE_FIELDS = {"id", "user_id", "start_date", "created_at"} + + +def update_cycle( + session: Session, cycle_id: int, update_data: Mapping +) -> models.Cycles: + cycle: models.Cycles | None = session.get(models.Cycles, cycle_id) + if cycle is None: + raise ValueError("cycle_id does not exist") + if hasattr(update_data, "dict"): + data = update_data.dict(exclude_unset=True) + else: + data = dict(update_data) + + allowed = {c.name for c in models.Cycles.__table__.columns} + for k, v in data.items(): + if k in IMMUTABLE_CYCLE_FIELDS: + raise ValueError(f"field {k!r} is immutable") + if k not in allowed: + continue + if k == "underlying_currency": + v = _check_enum(models.UnderlyingCurrency, v, "underlying_currency") + if k == "status": + v = _check_enum(models.CycleStatus, v, "status") + setattr(cycle, k, v) + session.add(cycle) + try: + session.flush() + except IntegrityError as e: + session.rollback() + raise ValueError("update_cycle integrity error") from e + session.refresh(cycle) + return cycle + + # Users +IMMUTABLE_USER_FIELDS = {"id", "username", "created_at"} + + def create_user(session: Session, user_data: Mapping) -> models.Users: if hasattr(user_data, "dict"): data = user_data.dict(exclude_unset=True) @@ -178,3 +276,27 @@ def create_user(session: Session, user_data: Mapping) -> models.Users: raise ValueError("create_user integrity error") from e session.refresh(u) return u + + +def update_user(session: Session, user_id: int, update_data: Mapping) -> models.Users: + user: models.Users | None = session.get(models.Users, user_id) + if user is None: + raise ValueError("user_id does not exist") + if hasattr(update_data, "dict"): + data = update_data.dict(exclude_unset=True) + else: + data = dict(update_data) + allowed = {c.name for c in models.Users.__table__.columns} + for k, v in data.items(): + if k in IMMUTABLE_USER_FIELDS: + raise ValueError(f"field {k!r} is immutable") + if k in allowed: + setattr(user, k, v) + session.add(user) + try: + session.flush() + except IntegrityError as e: + session.rollback() + raise ValueError("update_user integrity error") from e + session.refresh(user) + return user diff --git a/backend/trading_journal/models_v1.py b/backend/trading_journal/models_v1.py index d9d8944..0e5857f 100644 --- a/backend/trading_journal/models_v1.py +++ b/backend/trading_journal/models_v1.py @@ -94,6 +94,14 @@ class Trades(SQLModel, table=True): gross_cash_flow_cents: int = Field(sa_column=Column(Integer, nullable=False)) commission_cents: int = Field(sa_column=Column(Integer, nullable=False)) net_cash_flow_cents: int = Field(sa_column=Column(Integer, nullable=False)) + is_invalidated: bool = Field(default=False, nullable=False) + invalidated_at: datetime | None = Field( + default=None, sa_column=Column(DateTime(timezone=True), nullable=True) + ) + replaced_by_trade_id: int | None = Field( + default=None, foreign_key="trades.id", nullable=True + ) + notes: str | None = Field(default=None, sa_column=Column(Text, nullable=True)) cycle_id: int | None = Field( default=None, foreign_key="cycles.id", nullable=True, index=True )