Compare commits

...

3 Commits

Author SHA1 Message Date
1fbc93353d add exchange table
All checks were successful
Backend CI / unit-test (push) Successful in 35s
2025-09-22 14:33:32 +02:00
76cc967c42 cycle and trade add exchange field 2025-09-19 23:04:17 +02:00
442da655c0 Fix linting error and linting config 2025-09-19 15:30:41 +02:00
21 changed files with 621 additions and 328 deletions

View File

@@ -1,33 +1,30 @@
from fastapi import FastAPI
import asyncio
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from models import MsgPayload
from fastapi import FastAPI, status
app = FastAPI()
messages_list: dict[int, MsgPayload] = {}
import settings
from trading_journal import db
from trading_journal.dto import TradeCreate, TradeRead
API_BASE = "/api/v1"
_db = db.create_database(settings.settings.database_url)
@app.get("/")
def root() -> dict[str, str]:
return {"message": "Hello"}
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: # noqa: ARG001
await asyncio.to_thread(_db.init_db)
try:
yield
finally:
await asyncio.to_thread(_db.dispose)
# About page route
@app.get("/about")
def about() -> dict[str, str]:
return {"message": "This is the about page."}
app = FastAPI(lifespan=lifespan)
# Route to add a message
@app.post("/messages/{msg_name}/")
def add_msg(msg_name: str) -> dict[str, MsgPayload]:
# Generate an ID for the item based on the highest ID in the messages_list
msg_id = max(messages_list.keys()) + 1 if messages_list else 0
messages_list[msg_id] = MsgPayload(msg_id=msg_id, msg_name=msg_name)
return {"message": messages_list[msg_id]}
# Route to list all messages
@app.get("/messages")
def message_items() -> dict[str, dict[int, MsgPayload]]:
return {"messages:": messages_list}
@app.get(f"{API_BASE}/status")
async def get_status() -> dict[str, str]:
return {"status": "ok"}

View File

@@ -14,12 +14,130 @@ anyio==4.10.0 \
# via
# httpx
# starlette
argon2-cffi==25.1.0 \
--hash=sha256:694ae5cc8a42f4c4e2bf2ca0e64e51e23a040c6a517a85074683d3959e1346c1 \
--hash=sha256:fdc8b074db390fccb6eb4a3604ae7231f219aa669a2652e0f20e16ba513d5741
# via passlib
argon2-cffi-bindings==25.1.0 \
--hash=sha256:1db89609c06afa1a214a69a462ea741cf735b29a57530478c06eb81dd403de99 \
--hash=sha256:1e021e87faa76ae0d413b619fe2b65ab9a037f24c60a1e6cc43457ae20de6dc6 \
--hash=sha256:21378b40e1b8d1655dd5310c84a40fc19a9aa5e6366e835ceb8576bf0fea716d \
--hash=sha256:2630b6240b495dfab90aebe159ff784d08ea999aa4b0d17efa734055a07d2f44 \
--hash=sha256:3c6702abc36bf3ccba3f802b799505def420a1b7039862014a65db3205967f5a \
--hash=sha256:3d3f05610594151994ca9ccb3c771115bdb4daef161976a266f0dd8aa9996b8f \
--hash=sha256:473bcb5f82924b1becbb637b63303ec8d10e84c8d241119419897a26116515d2 \
--hash=sha256:5acb4e41090d53f17ca1110c3427f0a130f944b896fc8c83973219c97f57b690 \
--hash=sha256:5d588dec224e2a83edbdc785a5e6f3c6cd736f46bfd4b441bbb5aa1f5085e584 \
--hash=sha256:6dca33a9859abf613e22733131fc9194091c1fa7cb3e131c143056b4856aa47e \
--hash=sha256:7aef0c91e2c0fbca6fc68e7555aa60ef7008a739cbe045541e438373bc54d2b0 \
--hash=sha256:84a461d4d84ae1295871329b346a97f68eade8c53b6ed9a7ca2d7467f3c8ff6f \
--hash=sha256:87c33a52407e4c41f3b70a9c2d3f6056d88b10dad7695be708c5021673f55623 \
--hash=sha256:8b8efee945193e667a396cbc7b4fb7d357297d6234d30a489905d96caabde56b \
--hash=sha256:a1c70058c6ab1e352304ac7e3b52554daadacd8d453c1752e547c76e9c99ac44 \
--hash=sha256:a98cd7d17e9f7ce244c0803cad3c23a7d379c301ba618a5fa76a67d116618b98 \
--hash=sha256:aecba1723ae35330a008418a91ea6cfcedf6d31e5fbaa056a166462ff066d500 \
--hash=sha256:b0fdbcf513833809c882823f98dc2f931cf659d9a1429616ac3adebb49f5db94 \
--hash=sha256:b55aec3565b65f56455eebc9b9f34130440404f27fe21c3b375bf1ea4d8fbae6 \
--hash=sha256:b957f3e6ea4d55d820e40ff76f450952807013d361a65d7f28acc0acbf29229d \
--hash=sha256:ba92837e4a9aa6a508c8d2d7883ed5a8f6c308c89a4790e1e447a220deb79a85 \
--hash=sha256:c4f9665de60b1b0e99bcd6be4f17d90339698ce954cfd8d9cf4f91c995165a92 \
--hash=sha256:c87b72589133f0346a1cb8d5ecca4b933e3c9b64656c9d175270a000e73b288d \
--hash=sha256:d3e924cfc503018a714f94a49a149fdc0b644eaead5d1f089330399134fa028a \
--hash=sha256:da0c79c23a63723aa5d782250fbf51b768abca630285262fb5144ba5ae01e520 \
--hash=sha256:e2fd3bfbff3c5d74fef31a722f729bf93500910db650c925c2d6ef879a7e51cb
# via argon2-cffi
certifi==2025.8.3 \
--hash=sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407 \
--hash=sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5
# via
# httpcore
# httpx
cffi==2.0.0 \
--hash=sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb \
--hash=sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b \
--hash=sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f \
--hash=sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9 \
--hash=sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44 \
--hash=sha256:0f6084a0ea23d05d20c3edcda20c3d006f9b6f3fefeac38f59262e10cef47ee2 \
--hash=sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c \
--hash=sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75 \
--hash=sha256:1cd13c99ce269b3ed80b417dcd591415d3372bcac067009b6e0f59c7d4015e65 \
--hash=sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e \
--hash=sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a \
--hash=sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e \
--hash=sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25 \
--hash=sha256:2081580ebb843f759b9f617314a24ed5738c51d2aee65d31e02f6f7a2b97707a \
--hash=sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe \
--hash=sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b \
--hash=sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91 \
--hash=sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592 \
--hash=sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187 \
--hash=sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c \
--hash=sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1 \
--hash=sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94 \
--hash=sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba \
--hash=sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb \
--hash=sha256:3f4d46d8b35698056ec29bca21546e1551a205058ae1a181d871e278b0b28165 \
--hash=sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529 \
--hash=sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca \
--hash=sha256:4647afc2f90d1ddd33441e5b0e85b16b12ddec4fca55f0d9671fef036ecca27c \
--hash=sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6 \
--hash=sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c \
--hash=sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0 \
--hash=sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743 \
--hash=sha256:61d028e90346df14fedc3d1e5441df818d095f3b87d286825dfcbd6459b7ef63 \
--hash=sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5 \
--hash=sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5 \
--hash=sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4 \
--hash=sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d \
--hash=sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b \
--hash=sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93 \
--hash=sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205 \
--hash=sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27 \
--hash=sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512 \
--hash=sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d \
--hash=sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c \
--hash=sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037 \
--hash=sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26 \
--hash=sha256:89472c9762729b5ae1ad974b777416bfda4ac5642423fa93bd57a09204712322 \
--hash=sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb \
--hash=sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c \
--hash=sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8 \
--hash=sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4 \
--hash=sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414 \
--hash=sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9 \
--hash=sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664 \
--hash=sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9 \
--hash=sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775 \
--hash=sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739 \
--hash=sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc \
--hash=sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062 \
--hash=sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe \
--hash=sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9 \
--hash=sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92 \
--hash=sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5 \
--hash=sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13 \
--hash=sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d \
--hash=sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26 \
--hash=sha256:cb527a79772e5ef98fb1d700678fe031e353e765d1ca2d409c92263c6d43e09f \
--hash=sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495 \
--hash=sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b \
--hash=sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6 \
--hash=sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c \
--hash=sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef \
--hash=sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5 \
--hash=sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18 \
--hash=sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad \
--hash=sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3 \
--hash=sha256:de8dad4425a6ca6e4e5e297b27b5c824ecc7581910bf9aee86cb6835e6812aa7 \
--hash=sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5 \
--hash=sha256:e6e73b9e02893c764e7e8d5bb5ce277f1a009cd5243f8228f75f842bf937c534 \
--hash=sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49 \
--hash=sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2 \
--hash=sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5 \
--hash=sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453 \
--hash=sha256:fe562eb1a64e67dd297ccc4f5addea2501664954f2692b69a76449ec7913ecbf
# via argon2-cffi-bindings
click==8.2.1 \
--hash=sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202 \
--hash=sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b
@@ -112,10 +230,18 @@ packaging==25.0 \
--hash=sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484 \
--hash=sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f
# via pytest
passlib[argon2]==1.7.4 \
--hash=sha256:aa6bca462b8d8bda89c70b382f0c298a20b5560af6cbfa2dce410c0a2fb669f1 \
--hash=sha256:defd50f72b65c5402ab2c573830a6978e5f202ad0d984793c8dde2c4152ebe04
# via -r requirements.in
pluggy==1.6.0 \
--hash=sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3 \
--hash=sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746
# via pytest
pycparser==2.23 \
--hash=sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2 \
--hash=sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934
# via cffi
pydantic==2.11.7 \
--hash=sha256:d989c3c6cb79469287b1569f7447a17848c998458d49ebe294e975b9baf0f0db \
--hash=sha256:dde5df002701f6de26248661f6835bbe296a47bf73990135c7d07ce741b9623b

View File

@@ -1,7 +0,0 @@
from typing import Optional
from pydantic import BaseModel
class MsgPayload(BaseModel):
msg_id: Optional[int]
msg_name: str

View File

@@ -4,3 +4,4 @@ httpx
pyyaml
pydantic-settings
sqlmodel
passlib[argon2]

View File

@@ -14,12 +14,130 @@ anyio==4.10.0 \
# via
# httpx
# starlette
argon2-cffi==25.1.0 \
--hash=sha256:694ae5cc8a42f4c4e2bf2ca0e64e51e23a040c6a517a85074683d3959e1346c1 \
--hash=sha256:fdc8b074db390fccb6eb4a3604ae7231f219aa669a2652e0f20e16ba513d5741
# via passlib
argon2-cffi-bindings==25.1.0 \
--hash=sha256:1db89609c06afa1a214a69a462ea741cf735b29a57530478c06eb81dd403de99 \
--hash=sha256:1e021e87faa76ae0d413b619fe2b65ab9a037f24c60a1e6cc43457ae20de6dc6 \
--hash=sha256:21378b40e1b8d1655dd5310c84a40fc19a9aa5e6366e835ceb8576bf0fea716d \
--hash=sha256:2630b6240b495dfab90aebe159ff784d08ea999aa4b0d17efa734055a07d2f44 \
--hash=sha256:3c6702abc36bf3ccba3f802b799505def420a1b7039862014a65db3205967f5a \
--hash=sha256:3d3f05610594151994ca9ccb3c771115bdb4daef161976a266f0dd8aa9996b8f \
--hash=sha256:473bcb5f82924b1becbb637b63303ec8d10e84c8d241119419897a26116515d2 \
--hash=sha256:5acb4e41090d53f17ca1110c3427f0a130f944b896fc8c83973219c97f57b690 \
--hash=sha256:5d588dec224e2a83edbdc785a5e6f3c6cd736f46bfd4b441bbb5aa1f5085e584 \
--hash=sha256:6dca33a9859abf613e22733131fc9194091c1fa7cb3e131c143056b4856aa47e \
--hash=sha256:7aef0c91e2c0fbca6fc68e7555aa60ef7008a739cbe045541e438373bc54d2b0 \
--hash=sha256:84a461d4d84ae1295871329b346a97f68eade8c53b6ed9a7ca2d7467f3c8ff6f \
--hash=sha256:87c33a52407e4c41f3b70a9c2d3f6056d88b10dad7695be708c5021673f55623 \
--hash=sha256:8b8efee945193e667a396cbc7b4fb7d357297d6234d30a489905d96caabde56b \
--hash=sha256:a1c70058c6ab1e352304ac7e3b52554daadacd8d453c1752e547c76e9c99ac44 \
--hash=sha256:a98cd7d17e9f7ce244c0803cad3c23a7d379c301ba618a5fa76a67d116618b98 \
--hash=sha256:aecba1723ae35330a008418a91ea6cfcedf6d31e5fbaa056a166462ff066d500 \
--hash=sha256:b0fdbcf513833809c882823f98dc2f931cf659d9a1429616ac3adebb49f5db94 \
--hash=sha256:b55aec3565b65f56455eebc9b9f34130440404f27fe21c3b375bf1ea4d8fbae6 \
--hash=sha256:b957f3e6ea4d55d820e40ff76f450952807013d361a65d7f28acc0acbf29229d \
--hash=sha256:ba92837e4a9aa6a508c8d2d7883ed5a8f6c308c89a4790e1e447a220deb79a85 \
--hash=sha256:c4f9665de60b1b0e99bcd6be4f17d90339698ce954cfd8d9cf4f91c995165a92 \
--hash=sha256:c87b72589133f0346a1cb8d5ecca4b933e3c9b64656c9d175270a000e73b288d \
--hash=sha256:d3e924cfc503018a714f94a49a149fdc0b644eaead5d1f089330399134fa028a \
--hash=sha256:da0c79c23a63723aa5d782250fbf51b768abca630285262fb5144ba5ae01e520 \
--hash=sha256:e2fd3bfbff3c5d74fef31a722f729bf93500910db650c925c2d6ef879a7e51cb
# via argon2-cffi
certifi==2025.8.3 \
--hash=sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407 \
--hash=sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5
# via
# httpcore
# httpx
cffi==2.0.0 \
--hash=sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb \
--hash=sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b \
--hash=sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f \
--hash=sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9 \
--hash=sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44 \
--hash=sha256:0f6084a0ea23d05d20c3edcda20c3d006f9b6f3fefeac38f59262e10cef47ee2 \
--hash=sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c \
--hash=sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75 \
--hash=sha256:1cd13c99ce269b3ed80b417dcd591415d3372bcac067009b6e0f59c7d4015e65 \
--hash=sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e \
--hash=sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a \
--hash=sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e \
--hash=sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25 \
--hash=sha256:2081580ebb843f759b9f617314a24ed5738c51d2aee65d31e02f6f7a2b97707a \
--hash=sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe \
--hash=sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b \
--hash=sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91 \
--hash=sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592 \
--hash=sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187 \
--hash=sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c \
--hash=sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1 \
--hash=sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94 \
--hash=sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba \
--hash=sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb \
--hash=sha256:3f4d46d8b35698056ec29bca21546e1551a205058ae1a181d871e278b0b28165 \
--hash=sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529 \
--hash=sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca \
--hash=sha256:4647afc2f90d1ddd33441e5b0e85b16b12ddec4fca55f0d9671fef036ecca27c \
--hash=sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6 \
--hash=sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c \
--hash=sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0 \
--hash=sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743 \
--hash=sha256:61d028e90346df14fedc3d1e5441df818d095f3b87d286825dfcbd6459b7ef63 \
--hash=sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5 \
--hash=sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5 \
--hash=sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4 \
--hash=sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d \
--hash=sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b \
--hash=sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93 \
--hash=sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205 \
--hash=sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27 \
--hash=sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512 \
--hash=sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d \
--hash=sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c \
--hash=sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037 \
--hash=sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26 \
--hash=sha256:89472c9762729b5ae1ad974b777416bfda4ac5642423fa93bd57a09204712322 \
--hash=sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb \
--hash=sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c \
--hash=sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8 \
--hash=sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4 \
--hash=sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414 \
--hash=sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9 \
--hash=sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664 \
--hash=sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9 \
--hash=sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775 \
--hash=sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739 \
--hash=sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc \
--hash=sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062 \
--hash=sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe \
--hash=sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9 \
--hash=sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92 \
--hash=sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5 \
--hash=sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13 \
--hash=sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d \
--hash=sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26 \
--hash=sha256:cb527a79772e5ef98fb1d700678fe031e353e765d1ca2d409c92263c6d43e09f \
--hash=sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495 \
--hash=sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b \
--hash=sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6 \
--hash=sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c \
--hash=sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef \
--hash=sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5 \
--hash=sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18 \
--hash=sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad \
--hash=sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3 \
--hash=sha256:de8dad4425a6ca6e4e5e297b27b5c824ecc7581910bf9aee86cb6835e6812aa7 \
--hash=sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5 \
--hash=sha256:e6e73b9e02893c764e7e8d5bb5ce277f1a009cd5243f8228f75f842bf937c534 \
--hash=sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49 \
--hash=sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2 \
--hash=sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5 \
--hash=sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453 \
--hash=sha256:fe562eb1a64e67dd297ccc4f5addea2501664954f2692b69a76449ec7913ecbf
# via argon2-cffi-bindings
click==8.2.1 \
--hash=sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202 \
--hash=sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b
@@ -104,6 +222,14 @@ idna==3.10 \
# via
# anyio
# httpx
passlib[argon2]==1.7.4 \
--hash=sha256:aa6bca462b8d8bda89c70b382f0c298a20b5560af6cbfa2dce410c0a2fb669f1 \
--hash=sha256:defd50f72b65c5402ab2c573830a6978e5f202ad0d984793c8dde2c4152ebe04
# via -r requirements.in
pycparser==2.23 \
--hash=sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2 \
--hash=sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934
# via cffi
pydantic==2.11.7 \
--hash=sha256:d989c3c6cb79469287b1569f7447a17848c998458d49ebe294e975b9baf0f0db \
--hash=sha256:dde5df002701f6de26248661f6835bbe296a47bf73990135c7d07ce741b9623b

View File

@@ -13,8 +13,14 @@ ignore = [
"TRY003",
"EM101",
"EM102",
"PLC0405",
"SIM108",
"C901",
"PLR0912",
"PLR0915",
"PLR0913",
"PLC0415",
]
[lint.extend-per-file-ignores]
"test*.py" = ["S101"]
"test*.py" = ["S101", "S105", "S106", "PT011", "PLR2004"]
"models*.py" = ["FA102"]

View File

@@ -12,6 +12,7 @@ class Settings(BaseSettings):
port: int = 8000
workers: int = 1
log_level: str = "info"
database_url: str = "sqlite:///:memory:"
model_config = ConfigDict(env_file=".env", env_file_encoding="utf-8")

18
backend/tests/test_app.py Normal file
View File

@@ -0,0 +1,18 @@
from collections.abc import Generator
import pytest
from fastapi.testclient import TestClient
from app import API_BASE, app
@pytest.fixture
def client() -> Generator[TestClient, None, None]:
with TestClient(app) as client:
yield client
def test_get_status(client: TestClient) -> None:
response = client.get(f"{API_BASE}/status")
assert response.status_code == 200
assert response.json() == {"status": "ok"}

View File

@@ -1,15 +1,19 @@
from collections.abc import Generator
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from typing import TYPE_CHECKING
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
# TODO: If needed, add failing flow tests, but now only add happy flow.
if TYPE_CHECKING:
from collections.abc import Generator
from sqlalchemy.engine import Engine
@pytest.fixture
@@ -29,8 +33,11 @@ def engine() -> Generator[Engine, None, None]:
@pytest.fixture
def session(engine: Engine) -> Generator[Session, None, None]:
with Session(engine) as s:
yield s
session = Session(engine)
try:
yield session
finally:
session.close()
def make_user(session: Session, username: str = "testuser") -> int:
@@ -41,16 +48,23 @@ def make_user(session: Session, username: str = "testuser") -> int:
return user.id
def make_cycle(
session: Session, user_id: int, friendly_name: str = "Test Cycle"
) -> int:
def make_exchange(session: Session, name: str = "NASDAQ") -> int:
exchange = models.Exchanges(name=name, notes="Test exchange")
session.add(exchange)
session.commit()
session.refresh(exchange)
return exchange.id
def make_cycle(session: Session, user_id: int, exchange_id: int, friendly_name: str = "Test Cycle") -> int:
cycle = models.Cycles(
user_id=user_id,
friendly_name=friendly_name,
symbol="AAPL",
exchange_id=exchange_id,
underlying_currency=models.UnderlyingCurrency.USD,
status=models.CycleStatus.OPEN,
start_date=datetime.now().date(),
start_date=datetime.now(timezone.utc).date(),
)
session.add(cycle)
session.commit()
@@ -58,18 +72,19 @@ def make_cycle(
return cycle.id
def make_trade(
session: Session, user_id: int, cycle_id: int, friendly_name: str = "Test Trade"
) -> int:
def make_trade(session: Session, user_id: int, cycle_id: int, friendly_name: str = "Test Trade") -> int:
cycle: models.Cycles = session.get(models.Cycles, cycle_id)
exchange_id = cycle.exchange_id
trade = models.Trades(
user_id=user_id,
friendly_name=friendly_name,
symbol="AAPL",
exchange_id=exchange_id,
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(),
trade_date=datetime.now(timezone.utc).date(),
trade_time_utc=datetime.now(timezone.utc),
quantity=10,
price_cents=15000,
gross_cash_flow_cents=-150000,
@@ -113,9 +128,18 @@ def make_login_session(session: Session, created_at: datetime) -> models.Session
return login_session
def test_create_trade_success_with_cycle(session: Session):
def _ensure_utc_aware(dt: datetime) -> datetime | None:
if dt is None:
return None
if dt.tzinfo is None:
return dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc)
def test_create_trade_success_with_cycle(session: Session) -> None:
user_id = make_user(session)
cycle_id = make_cycle(session, user_id)
exchange_id = make_exchange(session)
cycle_id = make_cycle(session, user_id, exchange_id)
trade_data = {
"user_id": user_id,
@@ -124,7 +148,7 @@ def test_create_trade_success_with_cycle(session: Session):
"underlying_currency": models.UnderlyingCurrency.USD,
"trade_type": models.TradeType.LONG_SPOT,
"trade_strategy": models.TradeStrategy.SPOT,
"trade_time_utc": datetime.now(),
"trade_time_utc": datetime.now(timezone.utc),
"quantity": 10,
"price_cents": 15000,
"gross_cash_flow_cents": -150000,
@@ -154,17 +178,19 @@ def test_create_trade_success_with_cycle(session: Session):
assert actual_trade.cycle_id == trade_data["cycle_id"]
def test_create_trade_with_auto_created_cycle(session: Session):
def test_create_trade_with_auto_created_cycle(session: Session) -> None:
user_id = make_user(session)
exchange_id = make_exchange(session)
trade_data = {
"user_id": user_id,
"friendly_name": "Test Trade with Auto Cycle",
"symbol": "AAPL",
"exchange_id": exchange_id,
"underlying_currency": models.UnderlyingCurrency.USD,
"trade_type": models.TradeType.LONG_SPOT,
"trade_strategy": models.TradeStrategy.SPOT,
"trade_time_utc": datetime.now(),
"trade_time_utc": datetime.now(timezone.utc),
"quantity": 5,
"price_cents": 15500,
}
@@ -196,17 +222,19 @@ def test_create_trade_with_auto_created_cycle(session: Session):
assert auto_cycle.friendly_name.startswith("Auto-created Cycle by trade")
def test_create_trade_missing_required_fields(session: Session):
def test_create_trade_missing_required_fields(session: Session) -> None:
user_id = make_user(session)
exchange_id = make_exchange(session)
base_trade_data = {
"user_id": user_id,
"friendly_name": "Incomplete Trade",
"symbol": "AAPL",
"exchange_id": exchange_id,
"underlying_currency": models.UnderlyingCurrency.USD,
"trade_type": models.TradeType.LONG_SPOT,
"trade_strategy": models.TradeStrategy.SPOT,
"trade_time_utc": datetime.now(),
"trade_time_utc": datetime.now(timezone.utc),
"quantity": 10,
"price_cents": 15000,
}
@@ -218,6 +246,13 @@ def test_create_trade_missing_required_fields(session: Session):
crud.create_trade(session, trade_data)
assert "symbol is required" in str(excinfo.value)
# Missing exchange and cycle together
trade_data = base_trade_data.copy()
trade_data.pop("exchange_id", None)
with pytest.raises(ValueError) as excinfo:
crud.create_trade(session, trade_data)
assert "exchange_id is required when no cycle is attached" in str(excinfo.value)
# Missing underlying_currency
trade_data = base_trade_data.copy()
trade_data.pop("underlying_currency", None)
@@ -254,18 +289,20 @@ def test_create_trade_missing_required_fields(session: Session):
assert "price_cents is required" in str(excinfo.value)
def test_get_trade_by_id(session: Session):
def test_get_trade_by_id(session: Session) -> None:
user_id = make_user(session)
cycle_id = make_cycle(session, user_id)
exchange_id = make_exchange(session)
cycle_id = make_cycle(session, user_id, exchange_id)
trade_data = {
"user_id": user_id,
"friendly_name": "Test Trade for Get",
"symbol": "AAPL",
"exchange_id": exchange_id,
"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(),
"trade_date": datetime.now(timezone.utc).date(),
"trade_time_utc": datetime.now(timezone.utc),
"quantity": 10,
"price_cents": 15000,
"gross_cash_flow_cents": -150000,
@@ -291,19 +328,21 @@ def test_get_trade_by_id(session: Session):
assert trade.trade_date == trade_data["trade_date"]
def test_get_trade_by_user_id_and_friendly_name(session: Session):
def test_get_trade_by_user_id_and_friendly_name(session: Session) -> None:
user_id = make_user(session)
cycle_id = make_cycle(session, user_id)
exchange_id = make_exchange(session)
cycle_id = make_cycle(session, user_id, exchange_id)
friendly_name = "Unique Trade Name"
trade_data = {
"user_id": user_id,
"friendly_name": friendly_name,
"symbol": "AAPL",
"exchange_id": exchange_id,
"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(),
"trade_date": datetime.now(timezone.utc).date(),
"trade_time_utc": datetime.now(timezone.utc),
"quantity": 10,
"price_cents": 15000,
"gross_cash_flow_cents": -150000,
@@ -318,18 +357,20 @@ 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):
def test_get_trades_by_user_id(session: Session) -> None:
user_id = make_user(session)
cycle_id = make_cycle(session, user_id)
exchange_id = make_exchange(session)
cycle_id = make_cycle(session, user_id, exchange_id)
trade_data_1 = {
"user_id": user_id,
"friendly_name": "Trade One",
"symbol": "AAPL",
"exchange_id": exchange_id,
"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(),
"trade_date": datetime.now(timezone.utc).date(),
"trade_time_utc": datetime.now(timezone.utc),
"quantity": 10,
"price_cents": 15000,
"gross_cash_flow_cents": -150000,
@@ -341,11 +382,12 @@ def test_get_trades_by_user_id(session: Session):
"user_id": user_id,
"friendly_name": "Trade Two",
"symbol": "GOOGL",
"exchange_id": exchange_id,
"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(),
"trade_date": datetime.now(timezone.utc).date(),
"trade_time_utc": datetime.now(timezone.utc),
"quantity": 5,
"price_cents": 280000,
"gross_cash_flow_cents": 1400000,
@@ -362,9 +404,10 @@ def test_get_trades_by_user_id(session: Session):
assert friendly_names == {"Trade One", "Trade Two"}
def test_update_trade_note(session: Session):
def test_update_trade_note(session: Session) -> None:
user_id = make_user(session)
cycle_id = make_cycle(session, user_id)
exchange_id = make_exchange(session)
cycle_id = make_cycle(session, user_id, exchange_id)
trade_id = make_trade(session, user_id, cycle_id)
new_note = "This is an updated note."
@@ -379,9 +422,10 @@ def test_update_trade_note(session: Session):
assert actual_trade.notes == new_note
def test_invalidate_trade(session: Session):
def test_invalidate_trade(session: Session) -> None:
user_id = make_user(session)
cycle_id = make_cycle(session, user_id)
exchange_id = make_exchange(session)
cycle_id = make_cycle(session, user_id, exchange_id)
trade_id = make_trade(session, user_id, cycle_id)
invalidated_trade = crud.invalidate_trade(session, trade_id)
@@ -395,19 +439,21 @@ def test_invalidate_trade(session: Session):
assert actual_trade.is_invalidated is True
def test_replace_trade(session: Session):
def test_replace_trade(session: Session) -> None:
user_id = make_user(session)
cycle_id = make_cycle(session, user_id)
exchange_id = make_exchange(session)
cycle_id = make_cycle(session, user_id, exchange_id)
old_trade_id = make_trade(session, user_id, cycle_id)
new_trade_data = {
"user_id": user_id,
"friendly_name": "Replaced Trade",
"symbol": "MSFT",
"exchange_id": exchange_id,
"underlying_currency": models.UnderlyingCurrency.USD,
"trade_type": models.TradeType.LONG_SPOT,
"trade_strategy": models.TradeStrategy.SPOT,
"trade_time_utc": datetime.now(),
"trade_time_utc": datetime.now(timezone.utc),
"quantity": 20,
"price_cents": 25000,
}
@@ -438,15 +484,17 @@ def test_replace_trade(session: Session):
assert actual_new_trade.replaced_by_trade_id == old_trade_id
def test_create_cycle(session: Session):
def test_create_cycle(session: Session) -> None:
user_id = make_user(session)
exchange_id = make_exchange(session)
cycle_data = {
"user_id": user_id,
"friendly_name": "My First Cycle",
"symbol": "GOOGL",
"exchange_id": exchange_id,
"underlying_currency": models.UnderlyingCurrency.USD,
"status": models.CycleStatus.OPEN,
"start_date": datetime.now().date(),
"start_date": datetime.now(timezone.utc).date(),
}
cycle = crud.create_cycle(session, cycle_data)
assert cycle.id is not None
@@ -467,9 +515,10 @@ def test_create_cycle(session: Session):
assert actual_cycle.start_date == cycle_data["start_date"]
def test_update_cycle(session: Session):
def test_update_cycle(session: Session) -> None:
user_id = make_user(session)
cycle_id = make_cycle(session, user_id, friendly_name="Initial Cycle Name")
exchange_id = make_exchange(session)
cycle_id = make_cycle(session, user_id, exchange_id, friendly_name="Initial Cycle Name")
update_data = {
"friendly_name": "Updated Cycle Name",
@@ -488,16 +537,17 @@ def test_update_cycle(session: Session):
assert actual_cycle.status == update_data["status"]
def test_update_cycle_immutable_fields(session: Session):
def test_update_cycle_immutable_fields(session: Session) -> None:
user_id = make_user(session)
cycle_id = make_cycle(session, user_id, friendly_name="Initial Cycle Name")
exchange_id = make_exchange(session)
cycle_id = make_cycle(session, user_id, exchange_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
"start_date": datetime(2020, 1, 1, tzinfo=timezone.utc).date(), # Trying to change start_date
"created_at": datetime(2020, 1, 1, tzinfo=timezone.utc), # Trying to change created_at
"friendly_name": "Valid Update", # Valid field to update
}
@@ -511,7 +561,7 @@ def test_update_cycle_immutable_fields(session: Session):
)
def test_create_user(session: Session):
def test_create_user(session: Session) -> None:
user_data = {
"username": "newuser",
"password_hash": "newhashedpassword",
@@ -528,7 +578,7 @@ def test_create_user(session: Session):
assert actual_user.password_hash == user_data["password_hash"]
def test_update_user(session: Session):
def test_update_user(session: Session) -> None:
user_id = make_user(session, username="updatableuser")
update_data = {
@@ -545,14 +595,14 @@ def test_update_user(session: Session):
assert actual_user.password_hash == update_data["password_hash"]
def test_update_user_immutable_fields(session: Session):
def test_update_user_immutable_fields(session: Session) -> None:
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
"created_at": datetime(2020, 1, 1, tzinfo=timezone.utc), # Trying to change created_at
"password_hash": "validupdate", # Valid field to update
}
@@ -566,7 +616,7 @@ def test_update_user_immutable_fields(session: Session):
# login sessions
def test_create_login_session(session: Session):
def test_create_login_session(session: Session) -> None:
user_id = make_user(session, username="testuser")
session_token_hash = "sessiontokenhashed"
login_session = crud.create_login_session(session, user_id, session_token_hash)
@@ -575,7 +625,7 @@ def test_create_login_session(session: Session):
assert login_session.session_token_hash == session_token_hash
def test_create_login_session_with_invalid_user(session: Session):
def test_create_login_session_with_invalid_user(session: Session) -> None:
invalid_user_id = 9999 # Assuming this user ID does not exist
session_token_hash = "sessiontokenhashed"
with pytest.raises(ValueError) as excinfo:
@@ -583,40 +633,34 @@ def test_create_login_session_with_invalid_user(session: Session):
assert "user_id does not exist" in str(excinfo.value)
def test_get_login_session_by_token_and_user_id(session: Session):
now = datetime.now()
def test_get_login_session_by_token_and_user_id(session: Session) -> None:
now = datetime.now(timezone.utc)
created_session = make_login_session(session, now)
fetched_session = crud.get_login_session_by_token_hash_and_user_id(
session, created_session.session_token_hash, created_session.user_id
)
fetched_session = crud.get_login_session_by_token_hash_and_user_id(session, created_session.session_token_hash, created_session.user_id)
assert fetched_session is not None
assert fetched_session.id == created_session.id
assert fetched_session.user_id == created_session.user_id
assert fetched_session.session_token_hash == created_session.session_token_hash
def test_update_login_session(session: Session):
now = datetime.now()
def test_update_login_session(session: Session) -> None:
now = datetime.now(timezone.utc)
created_session = make_login_session(session, now)
update_data = {
"last_seen_at": now + timedelta(hours=1),
"last_used_ip": "192.168.1.1",
}
updated_session = crud.update_login_session(
session, created_session.session_token_hash, update_data
)
updated_session = crud.update_login_session(session, created_session.session_token_hash, update_data)
assert updated_session is not None
assert updated_session.last_seen_at == update_data["last_seen_at"]
assert _ensure_utc_aware(updated_session.last_seen_at) == update_data["last_seen_at"]
assert updated_session.last_used_ip == update_data["last_used_ip"]
def test_delete_login_session(session: Session):
now = datetime.now()
def test_delete_login_session(session: Session) -> None:
now = datetime.now(timezone.utc)
created_session = make_login_session(session, now)
crud.delete_login_session(session, created_session.session_token_hash)
deleted_session = crud.get_login_session_by_token_hash_and_user_id(
session, created_session.session_token_hash, created_session.user_id
)
deleted_session = crud.get_login_session_by_token_hash_and_user_id(session, created_session.session_token_hash, created_session.user_id)
assert deleted_session is None

View File

@@ -46,8 +46,7 @@ def database_ctx(db: Database) -> Generator[Database, None, None]:
def test_select_one_executes() -> None:
db = create_database(None) # in-memory by default
with database_ctx(db):
with session_ctx(db) as session:
with database_ctx(db), session_ctx(db) as session:
val = session.exec(text("SELECT 1")).scalar_one()
assert int(val) == 1
@@ -56,9 +55,7 @@ def test_in_memory_persists_across_sessions_when_using_staticpool() -> None:
db = create_database(None) # in-memory with StaticPool
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("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()
@@ -67,9 +64,8 @@ def test_in_memory_persists_across_sessions_when_using_staticpool() -> None:
def test_sqlite_pragmas_applied() -> None:
db = create_database(None)
with database_ctx(db):
with database_ctx(db), session_ctx(db) as session:
# 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
@@ -82,16 +78,8 @@ def test_rollback_on_exception() -> None:
# 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"
)
)
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")

View File

@@ -36,6 +36,7 @@ def test_run_migrations_0_to_1(monkeypatch: pytest.MonkeyPatch) -> None:
"user_id": ("INTEGER", 1, 0),
"friendly_name": ("TEXT", 0, 0),
"symbol": ("TEXT", 1, 0),
"exchange_id": ("INTEGER", 1, 0),
"underlying_currency": ("TEXT", 1, 0),
"status": ("TEXT", 1, 0),
"funding_source": ("TEXT", 0, 0),
@@ -50,9 +51,11 @@ def test_run_migrations_0_to_1(monkeypatch: pytest.MonkeyPatch) -> None:
"user_id": ("INTEGER", 1, 0),
"friendly_name": ("TEXT", 0, 0),
"symbol": ("TEXT", 1, 0),
"exchange_id": ("INTEGER", 1, 0),
"underlying_currency": ("TEXT", 1, 0),
"trade_type": ("TEXT", 1, 0),
"trade_strategy": ("TEXT", 1, 0),
"trade_date": ("DATE", 1, 0),
"trade_time_utc": ("DATETIME", 1, 0),
"expiry_date": ("DATE", 0, 0),
"strike_price_cents": ("INTEGER", 0, 0),
@@ -61,6 +64,10 @@ def test_run_migrations_0_to_1(monkeypatch: pytest.MonkeyPatch) -> None:
"gross_cash_flow_cents": ("INTEGER", 1, 0),
"commission_cents": ("INTEGER", 1, 0),
"net_cash_flow_cents": ("INTEGER", 1, 0),
"is_invalidated": ("BOOLEAN", 1, 0),
"invalidated_at": ("DATETIME", 0, 0),
"replaced_by_trade_id": ("INTEGER", 0, 0),
"notes": ("TEXT", 0, 0),
"cycle_id": ("INTEGER", 0, 0),
},
"sessions": {
@@ -80,21 +87,26 @@ def test_run_migrations_0_to_1(monkeypatch: pytest.MonkeyPatch) -> None:
"trades": [
{"table": "cycles", "from": "cycle_id", "to": "id"},
{"table": "users", "from": "user_id", "to": "id"},
{"table": "exchanges", "from": "exchange_id", "to": "id"},
],
"cycles": [
{"table": "users", "from": "user_id", "to": "id"},
{"table": "exchanges", "from": "exchange_id", "to": "id"},
],
"sessions": [
{"table": "users", "from": "user_id", "to": "id"},
],
"users": [],
"exchanges": [],
}
with engine.connect() as conn:
# check tables exist
rows = conn.execute(
text("SELECT name FROM sqlite_master WHERE type='table'")
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}"
)
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()
@@ -103,14 +115,9 @@ def test_run_migrations_0_to_1(monkeypatch: pytest.MonkeyPatch) -> None:
# validate each table columns
for tbl_name, cols in expected_schema.items():
info_rows = conn.execute(
text(f"PRAGMA table_info({tbl_name})")
).fetchall()
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
}
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]
@@ -122,20 +129,12 @@ def test_run_migrations_0_to_1(monkeypatch: pytest.MonkeyPatch) -> None:
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}"
)
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 = 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
]
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:

View File

@@ -1,22 +0,0 @@
import pytest
from fastapi.testclient import TestClient
from app import app
@pytest.fixture
def client():
with TestClient(app) as client:
yield client
def test_home_route(client):
response = client.get("/")
assert response.status_code == 200
assert response.json() == {"message": "Hello"}
def test_about_route(client):
response = client.get("/about")
assert response.status_code == 200
assert response.json() == {"message": "This is the about page."}

View File

@@ -0,0 +1,4 @@
from trading_journal import security
def test_hash_password() -> None:
plain = "password"

View File

@@ -12,7 +12,7 @@ def test_default_settings(monkeypatch: pytest.MonkeyPatch) -> None:
s = load_settings()
assert s.host == "0.0.0.0" # noqa: S104
assert s.port == 8000 # noqa: PLR2004
assert s.port == 8000
assert s.workers == 1
assert s.log_level == "info"
@@ -26,8 +26,8 @@ def test_env_overrides(monkeypatch: pytest.MonkeyPatch) -> None:
s = load_settings()
assert s.host == "127.0.0.1"
assert s.port == 9000 # noqa: PLR2004
assert s.workers == 3 # noqa: PLR2004
assert s.port == 9000
assert s.workers == 3
assert s.log_level == "debug"
@@ -40,6 +40,6 @@ def test_yaml_config_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> No
s = load_settings()
assert s.host == "10.0.0.5"
assert s.port == 8088 # noqa: PLR2004
assert s.workers == 5 # noqa: PLR2004
assert s.port == 8088
assert s.workers == 5
assert s.log_level == "debug"

View File

@@ -1,13 +1,18 @@
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from typing import Mapping
from typing import TYPE_CHECKING
from sqlalchemy.exc import IntegrityError
from sqlmodel import Session, select
from trading_journal import models
if TYPE_CHECKING:
from collections.abc import Mapping
def _check_enum(enum_cls, value, field_name: str):
def _check_enum(enum_cls: any, value: any, field_name: str) -> any:
if value is None:
raise ValueError(f"{field_name} is required")
# already an enum member
@@ -30,30 +35,26 @@ 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}
cycle_id = payload.get("cycle_id")
if "symbol" not in payload:
raise ValueError("symbol is required")
if "exchange_id" not in payload and cycle_id is None:
raise ValueError("exchange_id is required when no cycle is attached")
if "underlying_currency" not in payload:
raise ValueError("underlying_currency is required")
payload["underlying_currency"] = _check_enum(
models.UnderlyingCurrency, payload["underlying_currency"], "underlying_currency"
)
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"] = _check_enum(
models.TradeType, payload["trade_type"], "trade_type"
)
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"] = _check_enum(
models.TradeStrategy, payload["trade_strategy"], "trade_strategy"
)
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
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")
@@ -67,9 +68,7 @@ def create_trade(session: Session, trade_data: Mapping) -> models.Trades:
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
)
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
@@ -77,9 +76,9 @@ def create_trade(session: Session, trade_data: Mapping) -> models.Trades:
c_payload = {
"user_id": user_id,
"symbol": payload["symbol"],
"exchange_id": payload["exchange_id"],
"underlying_currency": payload["underlying_currency"],
"friendly_name": "Auto-created Cycle by trade "
+ payload.get("friendly_name", ""),
"friendly_name": "Auto-created Cycle by trade " + payload.get("friendly_name", ""),
"status": models.CycleStatus.OPEN,
"start_date": payload["trade_date"],
}
@@ -90,9 +89,11 @@ def create_trade(session: Session, trade_data: Mapping) -> models.Trades:
# 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:
raise ValueError("cycle_id does not exist")
else:
payload.pop("exchange_id", None) # ignore exchange_id if provided; use cycle's exchange_id
payload["exchange_id"] = cycle.exchange_id
if cycle.user_id != user_id:
raise ValueError("cycle.user_id does not match trade.user_id")
@@ -119,9 +120,7 @@ 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:
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,
@@ -169,17 +168,14 @@ def invalidate_trade(session: Session, trade_id: int) -> models.Trades:
return trade
def replace_trade(
session: Session, old_trade_id: int, new_trade_data: Mapping
) -> models.Trades:
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
return create_trade(session, data)
# Cycles
@@ -194,11 +190,11 @@ def create_cycle(session: Session, cycle_data: Mapping) -> models.Cycles:
raise ValueError("user_id is required")
if "symbol" not in payload:
raise ValueError("symbol is required")
if "exchange_id" not in payload:
raise ValueError("exchange_id 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"
)
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")
@@ -219,9 +215,7 @@ def create_cycle(session: Session, cycle_data: Mapping) -> models.Cycles:
IMMUTABLE_CYCLE_FIELDS = {"id", "user_id", "start_date", "created_at"}
def update_cycle(
session: Session, cycle_id: int, update_data: Mapping
) -> models.Cycles:
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")
@@ -237,9 +231,9 @@ def update_cycle(
if k not in allowed:
continue
if k == "underlying_currency":
v = _check_enum(models.UnderlyingCurrency, v, "underlying_currency")
v = _check_enum(models.UnderlyingCurrency, v, "underlying_currency") # noqa: PLW2901
if k == "status":
v = _check_enum(models.CycleStatus, v, "status")
v = _check_enum(models.CycleStatus, v, "status") # noqa: PLW2901
setattr(cycle, k, v)
session.add(cycle)
try:
@@ -337,9 +331,7 @@ def create_login_session(
return s
def get_login_session_by_token_hash_and_user_id(
session: Session, session_token_hash: str, user_id: int
) -> models.Sessions | None:
def get_login_session_by_token_hash_and_user_id(session: Session, session_token_hash: str, user_id: int) -> models.Sessions | None:
statement = select(models.Sessions).where(
models.Sessions.session_token_hash == session_token_hash,
models.Sessions.user_id == user_id,
@@ -352,14 +344,12 @@ def get_login_session_by_token_hash_and_user_id(
IMMUTABLE_SESSION_FIELDS = {"id", "user_id", "session_token_hash", "created_at"}
def update_login_session(
session: Session, session_token_hashed: str, update_session: Mapping
) -> models.Sessions | None:
def update_login_session(session: Session, session_token_hashed: str, update_session: Mapping) -> models.Sessions | None:
login_session: models.Sessions | None = session.exec(
select(models.Sessions).where(
models.Sessions.session_token_hash == session_token_hashed,
models.Sessions.expires_at > datetime.now(timezone.utc),
)
),
).first()
if login_session is None:
return None
@@ -385,7 +375,7 @@ def delete_login_session(session: Session, session_token_hash: str) -> None:
login_session: models.Sessions | None = session.exec(
select(models.Sessions).where(
models.Sessions.session_token_hash == session_token_hash,
)
),
).first()
if login_session is None:
return

View File

@@ -24,17 +24,13 @@ class Database:
) -> 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."
"Using in-memory SQLite database; all data will be lost when the application stops.",
)
self._engine = create_engine(
self._database_url,
@@ -43,15 +39,11 @@ class Database:
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;")
@@ -66,7 +58,8 @@ class Database:
event.listen(self._engine, "connect", _enable_sqlite_pragmas)
def init_db(self) -> None:
db_migration.run_migrations(self._engine)
# db_migration.run_migrations(self._engine)
pass
def get_session(self) -> Generator[Session, None, None]:
session = Session(self._engine)

View File

@@ -60,7 +60,7 @@ def run_migrations(engine: Engine, target_version: int | None = None) -> int:
fn = MIGRATIONS.get(cur_version)
if fn is None:
raise RuntimeError(
f"No migration from {cur_version} -> {cur_version + 1}"
f"No migration from {cur_version} -> {cur_version + 1}",
)
# call migration with Engine (fn should use transactions)
fn(engine)

View File

@@ -0,0 +1,56 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from sqlmodel import SQLModel
if TYPE_CHECKING:
from datetime import date, datetime
from trading_journal.models import TradeStrategy, TradeType, UnderlyingCurrency
class TradeBase(SQLModel):
user_id: int
friendly_name: str | None
symbol: str
exchange: str
underlying_currency: UnderlyingCurrency
trade_type: TradeType
trade_strategy: TradeStrategy
trade_date: date
trade_time_utc: datetime
quantity: int
price_cents: int
gross_cash_flow_cents: int
commission_cents: int
net_cash_flow_cents: int
notes: str | None
cycle_id: int | None = None
class TradeCreate(TradeBase):
expiry_date: date | None = None
strike_price_cents: int | None = None
is_invalidated: bool = False
invalidated_at: datetime | None = None
replaced_by_trade_id: int | None = None
class TradeRead(TradeBase):
id: int
is_invalidated: bool
invalidated_at: datetime | None
class UserBase(SQLModel):
username: str
is_active: bool = True
class UserCreate(UserBase):
password: str
class UserRead(UserBase):
id: int

View File

@@ -1,4 +1,4 @@
from datetime import date, datetime # noqa: TC003
from datetime import date, datetime
from enum import Enum
from sqlmodel import (
@@ -65,28 +65,20 @@ class FundingSource(str, Enum):
class Trades(SQLModel, table=True):
__tablename__ = "trades"
__table_args__ = (
UniqueConstraint(
"user_id", "friendly_name", name="uq_trades_user_friendly_name"
),
)
__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)
)
friendly_name: str | None = Field(default=None, sa_column=Column(Text, nullable=True))
symbol: str = Field(sa_column=Column(Text, nullable=False))
underlying_currency: UnderlyingCurrency = Field(
sa_column=Column(Text, nullable=False)
)
exchange_id: int = Field(foreign_key="exchanges.id", nullable=False, index=True)
exchange: "Exchanges" = Relationship(back_populates="trades")
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)
)
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 = Field(sa_column=Column(Integer, nullable=False))
@@ -95,36 +87,24 @@ class Trades(SQLModel, table=True):
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
)
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
)
cycle_id: int | None = Field(default=None, foreign_key="cycles.id", nullable=True, index=True)
cycle: "Cycles" = Relationship(back_populates="trades")
class Cycles(SQLModel, table=True):
__tablename__ = "cycles"
__table_args__ = (
UniqueConstraint(
"user_id", "friendly_name", name="uq_cycles_user_friendly_name"
),
)
__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)
)
friendly_name: str | None = Field(default=None, sa_column=Column(Text, nullable=True))
symbol: str = Field(sa_column=Column(Text, nullable=False))
underlying_currency: UnderlyingCurrency = Field(
sa_column=Column(Text, nullable=False)
)
exchange_id: int = Field(foreign_key="exchanges.id", nullable=False, index=True)
exchange: "Exchanges" = Relationship(back_populates="cycles")
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)
@@ -135,6 +115,15 @@ class Cycles(SQLModel, table=True):
trades: list["Trades"] = Relationship(back_populates="cycle")
class Exchanges(SQLModel, table=True):
__tablename__ = "exchanges"
id: int | None = Field(default=None, primary_key=True)
name: str = Field(sa_column=Column(Text, nullable=False, unique=True))
notes: str | None = Field(default=None, sa_column=Column(Text, nullable=True))
trades: list["Trades"] = Relationship(back_populates="exchange")
cycles: list["Cycles"] = Relationship(back_populates="exchange")
class Users(SQLModel, table=True):
__tablename__ = "users"
id: int | None = Field(default=None, primary_key=True)
@@ -149,17 +138,9 @@ class Sessions(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True)
user_id: int = Field(foreign_key="users.id", nullable=False, index=True)
session_token_hash: str = Field(sa_column=Column(Text, nullable=False, unique=True))
created_at: datetime = Field(
sa_column=Column(DateTime(timezone=True), nullable=False)
)
expires_at: datetime = Field(
sa_column=Column(DateTime(timezone=True), nullable=False, index=True)
)
last_seen_at: datetime | None = Field(
sa_column=Column(DateTime(timezone=True), nullable=True)
)
last_used_ip: str | None = Field(
default=None, sa_column=Column(Text, nullable=True)
)
created_at: datetime = Field(sa_column=Column(DateTime(timezone=True), nullable=False))
expires_at: datetime = Field(sa_column=Column(DateTime(timezone=True), nullable=False, index=True))
last_seen_at: datetime | None = Field(sa_column=Column(DateTime(timezone=True), nullable=True))
last_used_ip: str | None = Field(default=None, sa_column=Column(Text, nullable=True))
user_agent: str | None = Field(default=None, sa_column=Column(Text, nullable=True))
device_name: str | None = Field(default=None, sa_column=Column(Text, nullable=True))

View File

@@ -1,4 +1,4 @@
from datetime import date, datetime # noqa: TC003
from datetime import date, datetime
from enum import Enum
from sqlmodel import (
@@ -65,28 +65,20 @@ class FundingSource(str, Enum):
class Trades(SQLModel, table=True):
__tablename__ = "trades"
__table_args__ = (
UniqueConstraint(
"user_id", "friendly_name", name="uq_trades_user_friendly_name"
),
)
__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)
)
friendly_name: str | None = Field(default=None, sa_column=Column(Text, nullable=True))
symbol: str = Field(sa_column=Column(Text, nullable=False))
underlying_currency: UnderlyingCurrency = Field(
sa_column=Column(Text, nullable=False)
)
exchange_id: int = Field(foreign_key="exchanges.id", nullable=False, index=True)
exchange: "Exchanges" = Relationship(back_populates="trades")
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)
)
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 = Field(sa_column=Column(Integer, nullable=False))
@@ -95,36 +87,24 @@ class Trades(SQLModel, table=True):
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
)
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
)
cycle_id: int | None = Field(default=None, foreign_key="cycles.id", nullable=True, index=True)
cycle: "Cycles" = Relationship(back_populates="trades")
class Cycles(SQLModel, table=True):
__tablename__ = "cycles"
__table_args__ = (
UniqueConstraint(
"user_id", "friendly_name", name="uq_cycles_user_friendly_name"
),
)
__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)
)
friendly_name: str | None = Field(default=None, sa_column=Column(Text, nullable=True))
symbol: str = Field(sa_column=Column(Text, nullable=False))
underlying_currency: UnderlyingCurrency = Field(
sa_column=Column(Text, nullable=False)
)
exchange_id: int = Field(foreign_key="exchanges.id", nullable=False, index=True)
exchange: "Exchanges" = Relationship(back_populates="cycles")
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)
@@ -135,6 +115,15 @@ class Cycles(SQLModel, table=True):
trades: list["Trades"] = Relationship(back_populates="cycle")
class Exchanges(SQLModel, table=True):
__tablename__ = "exchanges"
id: int | None = Field(default=None, primary_key=True)
name: str = Field(sa_column=Column(Text, nullable=False, unique=True))
notes: str | None = Field(default=None, sa_column=Column(Text, nullable=True))
trades: list["Trades"] = Relationship(back_populates="exchange")
cycles: list["Cycles"] = Relationship(back_populates="exchange")
class Users(SQLModel, table=True):
__tablename__ = "users"
id: int | None = Field(default=None, primary_key=True)
@@ -149,17 +138,9 @@ class Sessions(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True)
user_id: int = Field(foreign_key="users.id", nullable=False, index=True)
session_token_hash: str = Field(sa_column=Column(Text, nullable=False, unique=True))
created_at: datetime = Field(
sa_column=Column(DateTime(timezone=True), nullable=False)
)
expires_at: datetime = Field(
sa_column=Column(DateTime(timezone=True), nullable=False, index=True)
)
last_seen_at: datetime | None = Field(
sa_column=Column(DateTime(timezone=True), nullable=True)
)
last_used_ip: str | None = Field(
default=None, sa_column=Column(Text, nullable=True)
)
created_at: datetime = Field(sa_column=Column(DateTime(timezone=True), nullable=False))
expires_at: datetime = Field(sa_column=Column(DateTime(timezone=True), nullable=False, index=True))
last_seen_at: datetime | None = Field(sa_column=Column(DateTime(timezone=True), nullable=True))
last_used_ip: str | None = Field(default=None, sa_column=Column(Text, nullable=True))
user_agent: str | None = Field(default=None, sa_column=Column(Text, nullable=True))
device_name: str | None = Field(default=None, sa_column=Column(Text, nullable=True))

View File

@@ -0,0 +1,11 @@
from passlib.context import CryptContext
pwd_ctx = CryptContext(schemes=["argon2"], deprecated="auto")
def hash_password(plain: str) -> str:
return pwd_ctx.hash(plain)
def verify_password(plain: str, hashed: str) -> bool:
return pwd_ctx.verify(plain, hashed)