From 91b2d5a990b14195102f594d3ffc6c525d9b1876 Mon Sep 17 00:00:00 2001 From: Lev Gusiev <89646710+BytecodeBrewer@users.noreply.github.com> Date: Wed, 1 Jul 2026 12:18:22 +0200 Subject: [PATCH 01/16] Revert "Implement first storage layer" --- .dockerignore | 25 -- CONTRIBUTING.md | 251 +++++++---- README.md | 60 ++- argus_probe.duckdb | Bin 3944448 -> 0 bytes dockerfile | 12 - docs/forecast_research.md | 50 --- ...-data-sources.md => research-data-sources} | 0 docs/research-databases-and-storage.md | 388 ------------------ docs/roadmap.md | 143 +++---- docs/storage.md | 154 ------- pyproject.toml | 1 - src/argus/clients/exchangerate_client.py | 20 +- src/argus/domain/internal_models.py | 34 -- src/argus/storage/__init__.py | 0 src/argus/storage/database.py | 271 ------------ tests/test_exchangerate_client.py | 37 +- tests/test_internal_models.py | 101 ----- tests/test_storage_database.py | 222 ---------- tests/test_timeseries_service.py | 5 +- tests/test_validation_domain.py | 8 +- tests/test_yfinance_client.py | 2 +- 21 files changed, 286 insertions(+), 1498 deletions(-) delete mode 100644 .dockerignore delete mode 100644 argus_probe.duckdb delete mode 100644 dockerfile delete mode 100644 docs/forecast_research.md rename docs/{research-data-sources.md => research-data-sources} (100%) delete mode 100644 docs/research-databases-and-storage.md delete mode 100644 docs/storage.md delete mode 100644 src/argus/domain/internal_models.py delete mode 100644 src/argus/storage/__init__.py delete mode 100644 src/argus/storage/database.py delete mode 100644 tests/test_internal_models.py delete mode 100644 tests/test_storage_database.py diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index 8c24b79..0000000 --- a/.dockerignore +++ /dev/null @@ -1,25 +0,0 @@ -# Git -.git -.github -.githooks -.gitignore - -# Local environment and secrets -.venv -.env - -# Python cache and test cache -__pycache__ -.pytest_cache -.ruff_cache -.mypy_cache -.coverage -htmlcov - -# Build artifacts -dist -build -*.egg-info - -# Project docs not needed for the test image -docs \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3f87759..0b53865 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,9 +4,33 @@ Thank you for your interest in contributing to ARGUS. ARGUS is a Python-based market analytics project focused on clean data workflows, reliable code, useful metrics and future AI-assisted monitoring. -The project is still growing, so contributions should be small, focused and easy to review. You do not need to be an expert to contribute, but your changes should be understandable, reliable and related to the current project direction. +This project is still growing, so contributions should help the project become more stable, understandable and useful step by step. -Good starting points are issues labeled `good first issue`. These issues are usually smaller, easier to review and better suited for getting familiar with the project. +> [!IMPORTANT] +> ARGUS values reliability, clear communication and long-term skill building. +> Contributions should improve the project without creating unnecessary complexity. + +--- + +## Project Mindset + +ARGUS is not only about adding features quickly. + +The project is built around: + +- clean Python code +- understandable architecture +- reliable tests +- useful documentation +- careful data handling +- practical analytics +- continuous learning + +Good contributions should make the project easier to use, test, maintain or extend. + +--- + +## What You Can Contribute Helpful contributions include: @@ -14,14 +38,17 @@ Helpful contributions include: - tests - documentation improvements - small refactorings +- validation improvements - analytics metrics +- chart improvements - data-source clients -- UI or chart improvements -- CI/CD and tooling improvements -- architecture or research notes +- CI/CD improvements +- issue clarification +- architecture notes +- examples and usage instructions -> [!IMPORTANT] -> Please keep changes focused and avoid adding unnecessary complexity. +> [!NOTE] +> Large features should usually start with an issue or short discussion before implementation. --- @@ -49,45 +76,46 @@ Bad examples: --- -## Contribution Expectations +## Development Setup -Contributors are expected to keep changes focused, understandable and related to the issue or task. +Clone the repository: -Please: +```bash +git clone https://github.com/BytecodeBrewer/argus.git +cd argus +``` -- explain your changes clearly -- be open to review feedback -- improve your contribution step by step after feedback -- avoid unrelated rewrites -- respect the existing architecture unless there is a clear reason to change it -- do not add scripts that automatically run `git add`, `git commit`, `git push` or create pull requests unless this was discussed first +Create a virtual environment: -A contribution may be declined or delayed if it: +```bash +python -m venv .venv +``` -- does not fit the current roadmap -- adds too much complexity too early -- breaks existing functionality -- lacks necessary checks or documentation -- duplicates existing work -- bypasses the repository workflow +Activate it. ---- +On Windows PowerShell: -## Branch Workflow +```powershell +.venv\Scripts\Activate.ps1 +``` -For issue-based work, create your branch from the related GitHub issue when possible. +On macOS/Linux: -GitHub may suggest a branch name based on the issue title. You can shorten it if the generated name is too long. +```bash +source .venv/bin/activate +``` -Good branch names are focused and describe the task: +Install the project with development dependencies: -```text -43-research-forecasting-approach -33-add-yfinance-client -40-improve-test-coverage +```bash +pip install -e ".[dev]" ``` -If you create the branch manually, use: +--- + +## Branch Workflow + +Create a new branch for your work: ```bash git checkout -b @@ -96,30 +124,24 @@ git checkout -b Example: ```bash -git checkout -b 43-research-forecasting-approach +git checkout -b 12-add-volatility-metric ``` +Use focused branch names that describe the work. + --- ## Commit Expectations Commits should be small, understandable and related to the current task. -ARGUS uses a conventional commit style with an issue reference: - -```text -type(#issue): short description -``` - Good commit messages: ```text -docs(#43): research first forecasting approach -feat(#33): add yfinance historical data client -test(#40): add tests for conversion service -fix(#33): handle empty historical data response -refactor(#34): split metric helpers -ci(#10): update commit message workflow +Add rolling volatility metric +Fix currency validation edge case +Update README setup instructions +Add tests for trend metrics ``` Avoid unclear messages: @@ -133,25 +155,21 @@ final ``` > [!TIP] -> A good commit tells future readers what changed and which issue it belongs to. +> A good commit tells future readers what changed and why it belongs to the task. --- -## Checks +## Testing -Before opening a pull request, run the project checks: +Before opening a pull request, run the test suite: ```bash pytest -ruff check . -ruff format --check . ``` -These checks verify that tests pass, code style is valid and formatting is consistent. - -A pull request should not be marked as ready for review if checks are failing without explanation. +A pull request should not be opened as ready for review if tests are failing without explanation. -If a check fails and you are unsure why, mention it clearly in the pull request. +If a test fails and you do not know why, mention it clearly in the pull request. > [!IMPORTANT] > CI checks must pass before a pull request can be merged. @@ -160,23 +178,65 @@ If a check fails and you are unsure why, mention it clearly in the pull request. ## Pull Request Expectations -Pull requests should target `develop` unless the maintainer explicitly says otherwise. +A good pull request should include: -Do not open feature, research or documentation pull requests directly against `main`. -The `main` branch is reserved for stable/release-ready changes. +- a clear title +- a short explanation of what changed +- a link to the related issue if available +- notes about tests +- screenshots for UI changes if useful +- a short explanation of any trade-offs -Please use the pull request template and fill it out clearly. +Pull requests should be focused and reviewable. -The template helps reviewers understand: +Before opening a pull request, run: -- what changed -- which issue is related -- whether tests were run -- whether documentation or screenshots are needed -- if there are any notes or trade-offs +```bash +pytest +ruff check . +ruff format --check . +``` + +--- + +## Reliability Expectations + +Contributors are expected to work reliably. -Do not bypass the pull request template or replace it with an unrelated auto-generated description. -It makes reviewing harder and may delay the merge. +This means: + +- do not submit random or unfinished code without context +- do not ignore failing tests +- do not introduce secrets, API keys or local machine paths +- do not rewrite unrelated parts of the project without discussion +- communicate if you are unsure +- keep changes understandable for future contributors +- respect the existing architecture unless there is a clear reason to change it + +Reliability does not mean knowing everything already. + +It means being honest, careful and consistent. + +--- + +## Learning Mindset + +ARGUS welcomes contributors who want to improve their technical skills. + +You do not need to be an expert to contribute. + +Helpful behavior includes: + +- asking clear questions +- explaining your reasoning +- being open to review feedback +- improving your code after feedback +- learning from tests, errors and architecture discussions +- documenting what you learned when it helps others + +> [!NOTE] +> This project values skill growth. +> A thoughtful small contribution is better than a large unclear one. --- @@ -205,14 +265,25 @@ For analytics code: ## Secrets and API Keys -Never commit secrets, API keys, tokens, passwords, `.env` files or local config files with private data. +Never commit secrets. + +Do not commit: -Use a local `.env` file for secrets: +- API keys +- tokens +- passwords +- `.env` files +- local config files with private data + +Use a local `.env` file for secrets. ```env -EXCHANGE_RATE_API_KEY=your_api_key_here +api_key=your_api_key_here ``` +> [!WARNING] +> If you accidentally commit a secret, revoke it immediately and inform the maintainer. + --- ## Documentation @@ -228,4 +299,44 @@ Useful documentation includes: - data-source assumptions - troubleshooting notes +Repository-level files such as `README.md`, `CONTRIBUTING.md`, `CODE_OF_CONDUCT.md` and `LICENSE` belong in the repository root. + Technical notes, research and deeper explanations belong in `docs/`. + +--- + +## Communication + +Please communicate respectfully and constructively. + +When giving feedback: + +- focus on the code or idea, not the person +- explain the reason behind suggestions +- be specific +- stay open to alternatives + +When receiving feedback: + +- assume good intent +- ask questions if something is unclear +- improve the contribution step by step + +All contributors are expected to follow the project’s Code of Conduct. + +--- + +## Maintainer Notes + +The maintainer may ask for changes before merging a pull request. + +A contribution may be declined if it: + +- does not fit the current roadmap +- adds too much complexity too early +- breaks existing functionality +- lacks necessary tests +- duplicates existing work +- does not follow the project’s quality expectations + +This helps keep ARGUS stable, learnable and maintainable. \ No newline at end of file diff --git a/README.md b/README.md index 8723389..607f0d0 100644 --- a/README.md +++ b/README.md @@ -120,18 +120,11 @@ README.md - Tkinter - pytest -### Current data sources +### Current data source - ExchangeRate API for live currency conversion - yfinance for historical market-data retrieval and analytics -### Storage - -- DuckDB — local analytical storage for normalized historical market data - ->[!Note] -> See docs/storage.md for details. - --- ## Planned / Future Tech Stack @@ -145,32 +138,46 @@ Planned or likely future technologies include: - Frankfurter API for historical FX data - possible additional market-data APIs later +### Data processing + +- pandas +- NumPy +- possibly Polars later for larger datasets + ### Storage - PostgreSQL +- DuckDB +- Parquet +- optional cloud storage ### Visualization and UI +- matplotlib +- Plotly - NiceGUI -- Django ### DevOps and deployment +- GitHub Actions +- Docker - Docker Compose -- Travis CI +- cloud deployment later ### Cloud and data engineering -- Azure +- Azure, GCP or AWS depending on project direction - scheduled ingestion -- agentic Workflows -- Blob Storage -- scaled analysis +- data quality checks +- reporting pipelines ### AI and agentic workflows - LLM-assisted summaries - RAG over stored reports or notes +- agentic data checks +- anomaly monitoring +- human-in-the-loop signal review > [!CAUTION] > AI and agentic features are future-stage ideas. @@ -192,7 +199,6 @@ Recommended for development: - VS Code - a virtual environment - pytest -- Docker, if you want to run tests in an isolated container environment > [!NOTE] > Runtime dependencies are managed through `pyproject.toml`. @@ -248,7 +254,7 @@ pip install -e ".[dev]" ## API Key Setup -ARGUS uses the ExchangeRate API for live currency conversion. Historical analytics currently use yfinance and do not require an additional API key. +ARGUS currently uses the ExchangeRate API for live currency conversion. ### 1. Create an API key @@ -278,7 +284,7 @@ The `.env` file must stay local and should never be committed. --- -## Running ARGUS Locally +## Running ARGUS Start the current Tkinter GUI: @@ -288,22 +294,6 @@ python -m argus.main This starts the local ARGUS prototype with calculator, currency conversion and basic analytics views. -## Running Argus in Docker - -ARGUS includes a minimal Docker setup for running the test suite in an isolated container environment. - -Build the Docker image: - -```bash -docker build -t argus . -``` - -Run ARGUS in a container: - -```bash -docker run --rm argus -``` - ### Legacy CLI / Debug Interface The legacy CLI is still available for quick local checks and debugging: @@ -320,7 +310,7 @@ python src/legacy/debug_main.py ## Running Tests -Run the test suite locally: +Run the test suite: ```bash pytest @@ -356,4 +346,4 @@ Current focus: - add stronger market metrics - expand pandas-based analytics workflows - improve dashboard usefulness without adding unnecessary chart noise -- document metric definitions, assumptions and data-source behavior +- document metric definitions, assumptions and data-source behavior \ No newline at end of file diff --git a/argus_probe.duckdb b/argus_probe.duckdb deleted file mode 100644 index 9c9ef34090bb8247f7d0259b65bba5a7b16ef54e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3944448 zcmeF)U5p&bK>*+y@7lZmTl;e7pOBC{2%zwi^M%U~LJ>qcCwI!`lI&?otO!lUJH6}4 zt!M3-UB?y=UgarL0_b=;fe=Ipi8mw^5eXED(@DX0e0hnYAR)n1aKuaU1Uw)Q)!oxG zv$He)+rQb}uVv5FRR2`fS6$UzJ3Zas{Kg;r;vYZt^qViue)2QF7anc8_QD50{J|H8 zFMIA|$#13#cmV}sLdtiiA&G+KGs|2 z+o2l9qg<_;#L)3pYd&53a1y>%S_ogf6(;J*_2p}o#h74>pGd-I7DD+}T(lbA-D)Kv z%nvk7vvN19k~Fjs!lkrIVp8?WkK?5nSQVfOsmoP20_ zPGUi}BqwP{lI|!EY*a_{R-ZNcv`tM8Yf~2nx2Z~Bn;OuGq%CPsn>y6IarxrX;<{Ef zxUC(nG@HqCd2TU%j~QLl(kACt8jYkrm$k69bCT1OVNB#TaL|iOIlDh zJkxH=y%~8xQ$Dms8F{3p+|$3OE&1rMmi(o`E%_^JTk`s2_po-{J=&!0G9HS14v<~9 zxG%4peR#QcIcY9eE?-G!o#F7UbksdKx46{I+Vh#-HO7~&B=vMVYhy3f7A~!e z9a~)b+PcW8O7*KN&E=#TJDEWV9J;o&xRRd*^dyn?1czfE`+$RZJesd>_Txp55O?08 z2k&?`mz@~i4Qoz*h8({8TYq=$-d?`FawkXG$zqDpKDMsLQ?SXjIfkqr$6_NWnq&6_ zqI@ntoTkUS%{gj5^Qq6xoPXuhuf}uT&N<^pwj+^`Zf+uJE7@fu6WJv|V4DQuSmkK! z@2eqXgV5N?rpMjb=cXOUM5~n^y5py`GrpcirRPd2_#8R3ZJfef(v7yj4Q`=Wl*u(I@F60RjXF5FkK+0D*lfFnCar{Y*O-`Nhr7 z@3z|!?eT`6ZtuHY=Fq+?u>unyK!5-N0t5&=rhxIqV}eA;q)LDQ0RjXF5FkK+z>^7#^!Vb*iexPU1hzyV?=4!bE!nt@5+Fc; z009DfL?Az7i1&c%xm8s9Ym69kE3BSOXYY}cDh2@p1PBn=YXbe_{sZj-mfG*>=i(uM z=}iEN`1b4&{`c8`cBt=Y2oNAZfB*pk1PBlyK!5;&krF7z{naor7Y@Wf;a9_f`7nOD zQmfbUdn(O^Ls|5pBpj_Y7FL?&D~+X#Nq3=J;pu8+xl(Q}tu*G6X1P`^H&w?FixDNpPbA?p3!!`~E?N!mPQNS( z^8*=EuDe;Sq@jfnE~T{+ld4xPCuvG??dX}lzVrs^0_3m*I13uaEuM|Z2WpH?lS4Fl-6IVbSad6cBB>~ z)4k(d`tpJF<;_T14c|DJy~Z!&;-Vp(Xf7``Dho;ZTGD9Nmg=RLqiT4zy&J|g(y1Cw z&A$4|7iQ0|&B=#`=OjLWa!YcOb{Odn_Q1w?H19C7{vvIQlf&BLg~4sH($^LT^gpS} zptg9ZdE@fMrNwovac~dMmc(D{$vn7 z7}f`Lk8o+5j)(uAgL2m`wu5!E4=>j)C(Y%`( zjq#-`Nj=@p+Sp6Ag-h#V#}=2qwk~q2QvK>mb2+KT4r)*WhpsIxuHfQF=<;+eZ+b0ZbPPm4gGW7Qx@f5VZcJC3Qy>cf<@u<1B zJswNKH)D$`4$Nb*6%_5Ud)D%x=6G!Dn&YQFH*@}#Prn+^!#n4*9~pjB&i2JEXpT{2 zKt?_~JR?(EB$8I$NR6pvhQKZnIG&wT$00>E+)SJ2flDzIvIjbbg^(T$OYuYJxsUCk zLEcrSbB3&#HpzV258MCGzw8QS;k@YSvGp;Hj%HnCUmZ-o5UV7eZih}V8%CGz=6_Dv z3?#`7LZRowZhn20fnLJDB>mcpUV(nd7 zYU>(phiK;{)4w?t-A?>N$gplFX1%E6Jn+WZmoH3D=XL6p0D<(cdnN(|2oNAZfWRPu zsX>u@DJ~z(7+372I#rgP6ELpWIeRG+AV7cs0RlTCU_7xi?o!=UffJj$wN2QVam6Me z+5`at1PBlyu&V?fFs>MQS)ps7c2)JtECB)pc3R-%PG>;+yHCL1W_Dkl$uWUKVDQ+Y z^BbaJKPNK8C;+_^AV7cs0RjYepTJYQFUup9B;OSkKU)d! z0wX41lrdrzB0B^K5FkK+009C7HbWpk(QmajV_T2C4Wo?5CbI$o0t5&UAV7cs0RjUB z^3g@{3+`f(?2b{l!s^L%c5!FwY6x+;`EaxvR@>9DBuutiYd&A<`+Td^7xA|T`XatH z))(N36dXl7)aCTs7^ViFb%5u_-sTIMEr*?BFTx^nUxERW($0*HssoJTG z((uYCI=F}&SBTngoPGJi^z<&=8D*Kk)(8~G`K{Tytr8$WfB*pk1V&FFAA9tVCpsgp zVS_J2i_xpf&B@W3&Do5t5FkK+009E~O2D7g_EmL1w6H^l7{%8t!@hAb#CWKz1x8%J z<61_%f@H4{I9mj~6Cgl<0D;{iux^Oay+m!`l~(!XP(|q;c;V-G_d?H8eHVVl&ucFH zjGuqB=EBby@m9R>GrIz_d%4@qp;v`%&gFFRy3qlb)19B4d4Be_mqHwF6~W>Hw|LcP zIz7e(XvLL#X4BN#!6LQn^4cd&?PCMWHh(>@%4{f^2aD7q|C5*P zKFjT-_ zaEH?Dxf$=?b0tzB0t5&UAh0okd@$2LuIP-P^FdNJsxF2t*-+XLV`J>rCa_TfLzs;U zTAu)cJuC1_d$wc>Mqt+n3>#uR+7I7i_=0oS?8P!lfB*pk1PJVVf%olufl&e& zGQ=qEdOPd}xcgyAV7e??hx=N%H2^G~-DldI zNy5FU^kE&V?I}k5%Za{-zmLysS+m4nJ=GWS$M5Qk_`m*$|9ZM_iGO`2eI`hJVoEg} zEpD+J=0o?%J!!$>{d8}K>_HxB_Tp1S(kYfT{)jz`edFxQ7pA9+LhtMM+@{qUZz~kf zo-A%lbFdwMsBeDJf2PY9>rTXQ5#L@sv?ju8nnv;2I{^X&2oNAZfB*pk1PBlyuuQ>6Yf2_M<~`-3%q};6R>>(1PBlyK!Csw3K(DPAikst5FkKc zBm{nKByu4W1PBn=8v@1`d!rtef&c*m1PBlyu$KgWeJ>SepOwYXrIjk`}Qye*Sv7QCUu!X}pO50RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBn=y8_eq%2RL8oW6O#Wg ze!KMki5T~E{GYy`pPhMr_O+L?rB24z_s0L}`}vi|(sR#0H~ZRiFD)%DRvPI72jc&! z_WS(xG$w>&`CI3;_w!hcJrVz>?=hY1z4%?G7%s^fWXEDO5yzM%=5Fay;Su7lkLDn5#gNx0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfWU4Ln7&t@dVA*d&HF7A0RjXF zY+RreRzoQD^#9}S@I+6XAOQjd2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5;&kr(*h)$e@w>bbF>-*1@+5FkKc;{v5{W4>0e)aR1+0pM`^ePZMf2r@{3 z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK%hrp`d)eJ?U~az z@3%|@2oNB!ae-1;4WZQ2&5pOj6FqT)1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjX@Uf`+4GtDpG`S6eLw@d^G5FoH|fl@d>JM;YPYcCc3|71Hb zQABtrK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FoG{1P-1G z;h%ndc{R@pzQ0i24LO%b0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZV5~ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV6T`1*Y$nr{119 zee-_HM1TMR0vi`7h1C#BJ)Qq}J3P@7CrE$*0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z!2h>Xhc*DhP!K?W6OuY4Q=wRZQQF-)Ot*j [!NOTE] -> The fields below describe the future database-oriented structure. -> Technical fields such as `id`, `instrument_id`, `source_id`, `created_at` and `updated_at` are expected to appear in the database layer. -> Internal Python models may reference related objects directly, for example `source` and `instrument`, before database IDs exist. - -### data_sources - -Stores where data came from. - -Recommended first database fields: - -```text -id -name -provider_kind -requires_api_key -created_at -updated_at -``` - -Example internal/source records: - -| name | provider_kind | requires_api_key | -| ---------------- | ------------- | ---------------: | -| ExchangeRate API | fx_rates | true | -| yfinance | market_prices | false | -| FRED | macro_data | true | - -### instruments - -Stores what ARGUS can analyze. - -Examples: - -* EUR/USD -* AAPL -* SPY -* S&P 500 -* BTC-USD - -Recommended first database fields: - -```text -id -symbol -name -asset_class -currency -exchange -base_currency -quote_currency -created_at -updated_at -``` - -Example instrument records: - -| symbol | name | asset_class | currency | exchange | base_currency | quote_currency | -| ------- | ---------------- | ----------- | -------- | --------- | ------------- | -------------- | -| EUR/USD | Euro / US Dollar | fx | null | null | EUR | USD | -| AAPL | Apple Inc. | stock | USD | NASDAQ | null | null | -| SPY | SPDR S&P 500 ETF | etf | USD | NYSE Arca | null | null | - -### price_bars - -Stores historical market data in an OHLCV-ready structure. - -Recommended first database fields: - -```text -id -instrument_id -source_id -timestamp -timeframe -open -high -low -close -adjusted_close -volume -created_at -updated_at -``` - -FX-style exchange-rate data can be represented as a price bar by storing the rate in `close`. - -The other OHLCV fields can stay empty until ARGUS uses data sources that provide them. - -Example price bar records shown with joined source and instrument information for readability: - -| source | instrument | timestamp | timeframe | open | high | low | close | adjusted_close | volume | -| -------- | ---------- | ---------- | --------- | -----: | -----: | -----: | -----: | -------------: | -------: | -| yfinance | EUR/USD | 2024-01-02 | 1d | null | null | null | 1.095 | null | null | -| yfinance | AAPL | 2024-01-02 | 1d | 187.15 | 188.44 | 183.89 | 185.64 | 184.25 | 50200000 | - ---- - -## Recommended First Implementation Step - -The first storage implementation should not be tied to one specific data provider. - -ARGUS currently works with an existing ExchangeRate API client and evaluates broader market data through yfinance. -Frankfurter may be added later as a stronger FX-oriented historical data source. - -The storage layer should therefore focus on a normalized internal market-data format instead of depending on one API response structure. - -Recommended first step: - -```text -active data client -→ normalize into instruments and price_bars -→ store in DuckDB -→ query with SQL -→ use results for analytics and charts -``` - ---- - -## Future Direction - -Later sprints can expand the storage layer step by step. - -Possible later additions: - -| Future Area | Possible Additions | -| --- | --- | -| Better source mapping | source-specific symbols, provider metadata | -| Watchlists | user-selected instruments | -| Reports | generated report metadata and history | -| Macro data | FRED indicators and observations | -| Paper trading | simulated orders, positions and portfolio history | -| Server architecture | PostgreSQL | -| SQL tooling | SQLGate with PostgreSQL | -| Cloud direction | managed PostgreSQL or cloud storage | - -SQLGate should be kept for a later PostgreSQL phase. - -It becomes useful when ARGUS moves toward: - -* server-based storage -* stronger database management -* richer metadata -* more stable application state -* user-facing features -* report history -* cloud-ready architecture - -Additional metadata such as documentation links, terms links or provider governance fields can also become useful later. - -For the first DuckDB phase, these details should stay in research documentation instead of the database schema. - ---- - -## Final Recommendation - -ARGUS should start with DuckDB as the first local analytics storage layer. - -DuckDB fits the current phase best because ARGUS needs local analytical SQL workflows, not a full server database yet. - -The first implementation should store historical market data in an OHLCV-ready structure. - -The recommended first data model is: - -```text -data_sources -instruments -price_bars -``` - -Notebook exploration should be the main developer workflow before SQL logic is moved into application code. - -The DuckDB CLI can be used for quick inspection. - -PostgreSQL and SQLGate should be introduced later when ARGUS moves toward a more product-like or cloud-based architecture. diff --git a/docs/roadmap.md b/docs/roadmap.md index c637327..85706af 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -27,121 +27,92 @@ Scope: Outcome: Sprint 1 established the local ARGUS foundation with package structure, GUI prototype, analytics prototype, tests, documentation, CI, Dependabot and governance files. -### Sprint 2 — Reporting & Market Analytics Foundation +### Sprint 2 — Market Analytics & Data Source Expansion **Status:** In progress -Move ARGUS from a simple FX-focused prototype toward a first usable market analytics and reporting tool. +Move from simple FX conversion toward broader market analytics. -**Scope:** - -- Add stronger market analytics metrics: +Scope: +- Add stronger market metrics: - cumulative return - strongest / weakest day - rolling volatility - - basic performance analytics - - basic risk analytics -- Add or improve real market data support: - + - performance analytics + - risk analytics +- Extend the current dashboard without adding unnecessary chart noise +- Add or evaluate new data clients: + - Frankfurter for historical FX data - yfinance for broader market data - - existing FX conversion remains available where useful +- Replace or reduce dependency on the current ExchangeRate API where needed - Improve pandas-based analysis workflows -- Introduce local storage for historical market data -- Add report generation and export -- Add a first simple prediction feature -- Introduce NiceGUI as the next GUI direction -- Extend the current dashboard with real market analytics -- Add tests for metric calculations, data transformations and storage behavior -- Improve CI/CD with first deployment or release automation steps - -**Outcome:** - -ARGUS becomes a basic market analytics and reporting tool. -Users can fetch market data, store it locally, calculate metrics, generate a first report and view results through a first modern dashboard. +- Add tests for metric calculations and data transformations +- Document metric definitions, assumptions and chart behavior ---- +Outcome: +ARGUS becomes a basic market analytics tool, not only a converter. -### Sprint 3 — Advanced Local Analytics & Product Quality +### Sprint 3 — Storage, Web-Ready UI & Data Architecture **Status:** Planned -Expand the local ARGUS application into a stronger analytics product with better data handling, UI structure, predictions and quality checks. - -**Scope:** - -- Extend the local storage layer -- Add a first local ETL workflow -- Improve the NiceGUI dashboard structure and usability -- Explore how NiceGUI can later interact with a more modern frontend stack such as Django, React or Node.js-based services -- Keep Tkinter as legacy/prototype unless it is no longer useful -- Add more metrics, instruments and prediction features -- Improve report templates and report structure -- Introduce first LLM-based summaries for generated reports -- Add first performance tests -- Introduce Snyk or another dependency/security scanning workflow -- Improve code quality, test coverage and maintainability - -**Outcome:** +Prepare ARGUS for persistent data workflows and a stronger product interface. -ARGUS becomes a more scalable local analytics application. -It can process more instruments, produce better reports, provide first automated summaries and offer more reliable insight into market data. - ---- +Scope: -### Sprint 4 — Extended Analysis & Cloud-Ready Foundation +- Add local storage layer: + - PostgreSQL, DuckDB, SQLite or Parquet depending on use case +- Store historical market data +- Separate ingestion, transformation, analytics and presentation layers more clearly +- Start NiceGUI as the main web-ready UI direction +- Keep Tkinter as legacy/prototype unless still useful +- Keep CLI as internal/debug interface only +- Add clearer architecture documentation +- Prepare the project for larger data workflows and external contributors -**Status:** Planned - -Prepare ARGUS for deeper analysis, cloud interaction and future portfolio-assistant workflows while keeping the local product usable and transparent. +Outcome: +ARGUS has a clearer data architecture and starts moving from local prototype toward a scalable analytics application. -**Scope:** +### Sprint 4 — Cloud, Pipelines & Portfolio-Grade Data Engineering -- Add Docker Compose for a more complete local development setup -- Introduce a first Azure connection, focused on simple storage or artifact exchange -- Improve the LLM workflow -- Introduce a first RAG-ready structure for reports, notes, documentation and stored analysis artifacts -- Add data quality checks -- Improve caching and efficient storage of market data -- Add more export options for users -- Add more metrics and better metadata visualization -- Improve transparency around data sources, generated reports and analysis assumptions -- Prepare clear interfaces for future cloud and assistant workflows +**Status:** Future -**Outcome:** +Turn ARGUS into a stronger end-to-end data engineering project. -ARGUS becomes ready to interact with a future cloud layer. -The application can produce clearer, more transparent market analysis and prepares the foundation for retrieval-based workflows, stronger automation and future ARGUS Core integration. +Scope: ---- +- Docker / Docker Compose +- Scheduled data ingestion +- Cloud storage or cloud database +- CI/CD improvements +- Data quality checks +- Basic pipeline orchestration +- Reporting layer +- Architecture diagram +- Deployment documentation -### Sprint 5 — Cloud Interaction & Agentic Monitoring Foundation +Target workflow: -**Status:** Planned +```text +API → Ingestion → Storage → Transformation → Analysis → Visualization → CI/CD +``` -Start the first cloud-connected ARGUS workflows and introduce the foundation for monitoring, agentic checks and strategy-support features. +### Sprint 5 — AI-Assisted Research & Agentic Monitoring -**Scope:** +**Status:** Future vision -- Add first cloud workflows that extend local analysis -- Connect local ARGUS workflows with the first cloud-side services -- Extend RAG over stored market notes, reports, documentation and analysis artifacts -- Add agentic checks for: +Add AI support only after the data, storage, service and reporting layers are stable. - - data quality - - anomalies - - recurring market scans - - report consistency -- Add first human-in-the-loop review workflows for signals or strategy ideas -- Add automated monitoring workflows -- Prepare the first foundations for: +Scope: - - paper trading - - backtesting - - controlled strategy evaluation - - future portfolio-assistant workflows +- LLM-assisted report summaries +- Explanation of unusual movements +- RAG over stored market notes, reports or documentation +- Agentic checks for data quality, anomalies and recurring market scans +- Human-in-the-loop signal review +- Automated monitoring workflows -**Outcome:** +Outcome: -ARGUS and the first cloud-side services begin to interact. -ARGUS becomes useful not only as an analytics and reporting tool, but also as the first foundation for monitoring, strategy evaluation and controlled market-research workflows. +ARGUS starts behaving like its name: a system that continuously watches market data, evaluates it and helps generate useful signals. diff --git a/docs/storage.md b/docs/storage.md deleted file mode 100644 index 27ce106..0000000 --- a/docs/storage.md +++ /dev/null @@ -1,154 +0,0 @@ -# ARGUS Storage Layer - -ARGUS uses DuckDB as the local storage layer for normalized market data. - -The storage layer stores ARGUS-internal market data structures and provides reusable historical data for analytics, charts, dashboards and reports. - -The storage design follows the direction described in [`docs/research-databases-and-storage.md`](research-databases-and-storage.md). - -## Storage Workflow - -ARGUS uses a storage-first workflow for historical market data. - -```text -User / GUI / Analytics request - ↓ -Market data service - ↓ -Check DuckDB storage - ↓ -If data exists: - read stored data - return it for analytics, charts or reports - -If data is missing: - fetch data from a client/API - normalize the response into ARGUS-internal data - return the normalized data - save the normalized data in DuckDB -``` - -DuckDB is used to avoid unnecessary repeated API calls and to make historical market data reusable across analytics, dashboard and reporting workflows. - -Fresh API data can be used immediately after normalization and is also persisted so future requests can use the local storage layer first. - -## Schema Overview - -The first storage schema is based on three related entities: - -```text -data_sources -instruments -price_bars -``` - -### `data_sources` - -Stores where market data came from. - -Examples: - -```text -yfinance -ExchangeRate API -Frankfurter -FRED -``` - -Each source describes a provider or API that can deliver market, FX or macro data. - -### `instruments` - -Stores what ARGUS can analyze. - -Examples: - -```text -EUR/USD -AAPL -SPY -BTC-USD -``` - -An instrument represents the internal ARGUS identity of an asset, currency pair, ETF, index or other market object. - -Provider-specific symbols should be normalized before storage. For example: - -```text -yfinance provider symbol: EURUSD=X -ARGUS instrument symbol: EUR/USD -``` - -### `price_bars` - -Stores historical time-series values in an OHLCV-ready structure. - -A price bar belongs to: - -```text -one data source -one instrument -one timestamp -one timeframe -``` - -FX rates are stored as `close` values. - -For simple FX data, the remaining OHLCV fields can stay empty. For broader market data, the same structure can store open, high, low, close, adjusted close and volume values. - -The combination of source, instrument, timestamp and timeframe identifies a unique stored price bar. - -## Internal Models and Storage - -ARGUS uses internal domain models before data is stored: - -```text -DataSource -Instrument -PriceBar -``` - -These models describe the meaning of the data inside ARGUS. - -The storage layer translates these internal models into DuckDB tables: - -```text -DataSource -> data_sources -Instrument -> instruments -PriceBar -> price_bars -``` - -In Python, a `PriceBar` references a `DataSource` and an `Instrument`. - -In DuckDB, this relationship is stored through IDs: - -```text -price_bars.source_id -> data_sources.id -price_bars.instrument_id -> instruments.id -``` - -This keeps the database normalized while still allowing ARGUS to work with meaningful internal models in Python. - -## Reading Stored Data - -Stored price bars can be read by: - -```text -source -instrument -start date -end date -``` - -The storage layer joins `price_bars`, `data_sources` and `instruments` so that stored IDs become readable market data again. - -Read operations return tabular data that can be used by: - -```text -analytics -charts -dashboards -reports -``` - -This allows ARGUS to process stored historical data without depending on raw API response structures. diff --git a/pyproject.toml b/pyproject.toml index a8ac7b1..e2dc784 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,6 @@ dependencies = [ "numpy", "matplotlib", "yfinance", - "duckdb", ] [project.optional-dependencies] diff --git a/src/argus/clients/exchangerate_client.py b/src/argus/clients/exchangerate_client.py index d899718..8ea5e89 100644 --- a/src/argus/clients/exchangerate_client.py +++ b/src/argus/clients/exchangerate_client.py @@ -24,16 +24,6 @@ def get_rates(curr1: str, curr2: str): resp.raise_for_status() payload = resp.json() - if payload["result"] == "success": - data["result"] = "success" - data["conversion_rate"] = payload["conversion_rate"] - return data - else: - data["result"] = "error" - data["error_type"] = payload.get("error_type") - check_error(data["error_type"]) - return None - except req.exceptions.Timeout: print("API hat zu lange gebraucht.") return None @@ -51,6 +41,16 @@ def get_rates(curr1: str, curr2: str): print("Unerwartete API-Antwortstruktur.") return None + if payload.get("result") == "success": + data["result"] = "success" + data["conversion_rate"] = payload.get("conversion_rate") + return data + else: + data["result"] = "error" + data["error_type"] = payload.get("error_type") + check_error(data["error_type"]) + return None + def check_error(err_type: str) -> None: """ diff --git a/src/argus/domain/internal_models.py b/src/argus/domain/internal_models.py deleted file mode 100644 index 3b7630e..0000000 --- a/src/argus/domain/internal_models.py +++ /dev/null @@ -1,34 +0,0 @@ -from dataclasses import dataclass -from datetime import date - - -@dataclass -class DataSource: - name: str - provider_kind: str - requires_api_key: bool = False - - -@dataclass -class Instrument: - symbol: str - name: str - asset_class: str - currency: str | None = None - exchange: str | None = None - base_currency: str | None = None - quote_currency: str | None = None - - -@dataclass -class PriceBar: - source: DataSource - instrument: Instrument - timestamp: date - timeframe: str - close: float - open: float | None = None - high: float | None = None - low: float | None = None - adjusted_close: float | None = None - volume: float | None = None diff --git a/src/argus/storage/__init__.py b/src/argus/storage/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/argus/storage/database.py b/src/argus/storage/database.py deleted file mode 100644 index ea7128c..0000000 --- a/src/argus/storage/database.py +++ /dev/null @@ -1,271 +0,0 @@ -import duckdb -from datetime import date -import pandas as pd -from argus.domain.internal_models import DataSource, PriceBar, Instrument - - -def initialize_database(database_path: str) -> None: - """ - Initialize the DuckDB database schema. - - Creates the required sequences and tables for data sources, - instruments, and price bars. - - Args: - database_path (str): Path to the DuckDB database file. - - Returns: - None - """ - queries = [ - "CREATE SEQUENCE IF NOT EXISTS data_sources_id_seq;", - "CREATE SEQUENCE IF NOT EXISTS instruments_id_seq;", - "CREATE SEQUENCE IF NOT EXISTS price_bars_id_seq;", - """ - CREATE TABLE IF NOT EXISTS data_sources ( - id INTEGER PRIMARY KEY DEFAULT nextval('data_sources_id_seq'), - name TEXT NOT NULL UNIQUE, - provider_kind TEXT NOT NULL, - requires_api_key BOOLEAN NOT NULL - ); - """, - """ - CREATE TABLE IF NOT EXISTS instruments ( - id INTEGER PRIMARY KEY DEFAULT nextval('instruments_id_seq'), - symbol TEXT NOT NULL UNIQUE, - name TEXT NOT NULL, - asset_class TEXT NOT NULL, - currency TEXT, - exchange TEXT, - base_currency TEXT, - quote_currency TEXT - ); - """, - """ - CREATE TABLE IF NOT EXISTS price_bars ( - id INTEGER PRIMARY KEY DEFAULT nextval('price_bars_id_seq'), - source_id INTEGER NOT NULL, - instrument_id INTEGER NOT NULL, - timestamp DATE NOT NULL, - timeframe TEXT NOT NULL, - close DOUBLE NOT NULL, - open DOUBLE, - high DOUBLE, - low DOUBLE, - adjusted_close DOUBLE, - volume DOUBLE, - FOREIGN KEY (source_id) REFERENCES data_sources (id), - FOREIGN KEY (instrument_id) REFERENCES instruments (id), - UNIQUE (source_id, instrument_id, timestamp, timeframe) - ); - """, - ] - - connection = duckdb.connect(database_path) - try: - for query in queries: - connection.execute(query) - finally: - connection.close() - - -def get_or_create_source(connection, source: DataSource) -> int: - """ - Get an existing data source ID or create a new data source. - - Searches for a data source by name. If it already exists, its ID is - returned. Otherwise, the data source is inserted and the new ID is - returned. - - Args: - connection: Active DuckDB connection. - source (DataSource): Data source model containing provider metadata. - - Returns: - int: Database ID of the existing or newly created data source. - - Raises: - ValueError: If the data source could not be inserted or found. - """ - insert_query = """ - INSERT INTO data_sources (name, provider_kind, requires_api_key) - VALUES (?,?,?) - ON CONFLICT DO NOTHING; - """ - search_query = """ - SELECT id FROM data_sources - WHERE name=? - """ - - result = connection.execute( - query=search_query, - parameters=[source.name], - ).fetchone() - if result is not None: - return result[0] - - connection.execute( - query=insert_query, - parameters=[source.name, source.provider_kind, source.requires_api_key], - ) - - result = connection.execute( - query=search_query, - parameters=[source.name], - ).fetchone() - - if result is None: - raise ValueError("Data source could not be inserted.") - - return result[0] - - -def get_or_create_instrument(connection, instrument: Instrument) -> int: - """ - Get an existing instrument ID or create a new instrument. - - Searches for an instrument by symbol. If it already exists, its ID is - returned. Otherwise, the instrument is inserted and the new ID is - returned. - - Args: - connection: Active DuckDB connection. - instrument (Instrument): Instrument model containing symbol and - asset metadata. - - Returns: - int: Database ID of the existing or newly created instrument. - - Raises: - ValueError: If the instrument could not be inserted or found. - """ - insert_query = """ - INSERT INTO instruments ( - symbol, - name, - asset_class, - currency, - exchange, - base_currency, - quote_currency) - VALUES (?,?,?,?,?,?,?) - ON CONFLICT DO NOTHING; - """ - search_query = """ - SELECT id FROM instruments - WHERE symbol=? - """ - - result = connection.execute( - query=search_query, - parameters=[instrument.symbol], - ).fetchone() - if result is not None: - return result[0] - - connection.execute( - query=insert_query, - parameters=[ - instrument.symbol, - instrument.name, - instrument.asset_class, - instrument.currency, - instrument.exchange, - instrument.base_currency, - instrument.quote_currency, - ], - ) - result = connection.execute( - query=search_query, - parameters=[instrument.symbol], - ).fetchone() - - if result is None: - raise ValueError("Instrument could not be inserted.") - - return result[0] - - -def insert_price_bar(db: str, price_bar: PriceBar) -> None: - insert_query = """ - INSERT INTO price_bars ( - source_id, - instrument_id, - timestamp, - timeframe, - close, - open, - high, - low, - adjusted_close, - volume - ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT DO NOTHING; - """ - connection = duckdb.connect(db) - try: - source_id = get_or_create_source(connection, price_bar.source) - instrument_id = get_or_create_instrument(connection, price_bar.instrument) - connection.execute( - query=insert_query, - parameters=[ - source_id, - instrument_id, - price_bar.timestamp, - price_bar.timeframe, - price_bar.close, - price_bar.open, - price_bar.high, - price_bar.low, - price_bar.adjusted_close, - price_bar.volume, - ], - ) - finally: - connection.close() - - -def read_price_bars( - db: str, - source: DataSource, - instrument: Instrument, - start_date: date, - end_date: date, -) -> pd.DataFrame: - - search_query = """ - SELECT - data_sources.name AS source_name, - instruments.symbol AS instrument_symbol, - price_bars.timestamp, - price_bars.timeframe, - price_bars.open, - price_bars.high, - price_bars.low, - price_bars.close, - price_bars.adjusted_close, - price_bars.volume - FROM price_bars - JOIN data_sources ON price_bars.source_id = data_sources.id - JOIN instruments ON price_bars.instrument_id = instruments.id - WHERE data_sources.name = ? - AND instruments.symbol = ? - AND price_bars.timestamp BETWEEN ? AND ? - ORDER BY price_bars.timestamp; - """ - - connection = duckdb.connect(db) - try: - result = connection.execute( - query=search_query, - parameters=[ - source.name, - instrument.symbol, - start_date, - end_date, - ], - ).df() - finally: - connection.close() - return result diff --git a/tests/test_exchangerate_client.py b/tests/test_exchangerate_client.py index 132a8a8..faf0864 100644 --- a/tests/test_exchangerate_client.py +++ b/tests/test_exchangerate_client.py @@ -3,7 +3,7 @@ from argus.clients.exchangerate_client import get_rates, check_error -def test_check_currency_timeout(monkeypatch, capsys): +def test_check_currency_timeout(monkeypatch): def test_get_resp(url, timeout): raise req.exceptions.Timeout() @@ -12,11 +12,8 @@ def test_get_resp(url, timeout): data = get_rates("EUR", "USD") assert data is None - captured = capsys.readouterr() - assert "API hat zu lange gebraucht." in captured.out - -def test_check_currency_connection_error(monkeypatch, capsys): +def test_check_currency_connection_error(monkeypatch): def test_get_resp(url, timeout): raise req.exceptions.ConnectionError() @@ -25,11 +22,8 @@ def test_get_resp(url, timeout): data = get_rates("EUR", "USD") assert data is None - captured = capsys.readouterr() - assert "Keine Verbindung zur API." in captured.out - -def test_check_currency_request_exception(monkeypatch, capsys): +def test_check_currency_request_exception(monkeypatch): def test_get_resp(url, timeout): raise req.exceptions.RequestException("Testfehler") @@ -38,11 +32,8 @@ def test_get_resp(url, timeout): data = get_rates("EUR", "USD") assert data is None - captured = capsys.readouterr() - assert "Request fehlgeschlagen:" in captured.out - -def test_check_currency_value_error(monkeypatch, capsys): +def test_check_currency_value_error(monkeypatch): test_resp = Mock() test_resp.raise_for_status.return_value = None test_resp.json.side_effect = ValueError("Ungültige JSON-Antwort") @@ -55,16 +46,14 @@ def test_get_resp(url, timeout): data = get_rates("EUR", "USD") assert data is None - captured = capsys.readouterr() - assert "Fehler beim Verarbeiten der API-Antwort." in captured.out - -def test_check_currency_key_error(monkeypatch, capsys): +def test_check_currency_key_error(monkeypatch): test_resp = Mock() test_resp.raise_for_status.return_value = None test_resp.json.return_value = { - "result": "success", # not passing "success" bypases the "conversion_rate" checking + "result": "", "error_type": "", + # "conversion_rate" fehlt absichtlich } def test_get_resp(url, timeout): @@ -75,9 +64,6 @@ def test_get_resp(url, timeout): data = get_rates("EUR", "USD") assert data is None - captured = capsys.readouterr() - assert "Unerwartete API-Antwortstruktur." in captured.out - def test_check_currency_valid(monkeypatch): test_resp = Mock() @@ -97,7 +83,7 @@ def test_get_resp(url, timeout): assert data == {"result": "success", "error_type": "", "conversion_rate": 1.2} -def test_check_currency_invalid(monkeypatch, capsys): +def test_check_currency_invalid(monkeypatch): test_resp = Mock() test_resp.raise_for_status.return_value = None test_resp.json.return_value = { @@ -114,9 +100,6 @@ def test_get_resp(url, timeout): data = get_rates("EUR", "USD") assert data is None - captured = capsys.readouterr() - assert "Invalid request! Please try again later." in captured.out - def test_check_error(capsys): check_error("unsupported-code") @@ -140,7 +123,3 @@ def test_check_error(capsys): captured.out == "Request limit reached! Please try again later or upgrade to exchangerate-api.com.\n" ) - - check_error("Some unknown Error") - captured = capsys.readouterr() - assert captured.out == "" diff --git a/tests/test_internal_models.py b/tests/test_internal_models.py deleted file mode 100644 index 97df4c6..0000000 --- a/tests/test_internal_models.py +++ /dev/null @@ -1,101 +0,0 @@ -from argus.domain.internal_models import DataSource, Instrument, PriceBar -from datetime import date - - -def test_data_source_can_be_created() -> None: - source = DataSource( - name="yfinance", - provider_kind="fx_rates", - ) - - assert source.name == "yfinance" - assert source.provider_kind == "fx_rates" - assert source.requires_api_key is False - - -def test_instrument_can_be_created() -> None: - instrument = Instrument( - symbol="EUR/USD", - name="Euro / US Dollar", - asset_class="fx", - base_currency="EUR", - quote_currency="USD", - ) - - assert instrument.symbol == "EUR/USD" - assert instrument.name == "Euro / US Dollar" - assert instrument.asset_class == "fx" - assert instrument.base_currency == "EUR" - assert instrument.quote_currency == "USD" - assert instrument.currency is None - assert instrument.exchange is None - - -def test_rate_bar_can_be_created() -> None: - source = DataSource( - name="yfinance", - provider_kind="fx_rates", - ) - - instrument_rate = Instrument( - symbol="EUR/USD", - name="Euro / US Dollar", - asset_class="fx", - base_currency="EUR", - quote_currency="USD", - ) - - price_bar = PriceBar( - source=source, - instrument=instrument_rate, - timestamp=date(2026, 1, 1), - timeframe="1d", - close=1.89, - ) - - assert price_bar.source == source - assert price_bar.instrument == instrument_rate - assert price_bar.timestamp == date(2026, 1, 1) - assert price_bar.timeframe == "1d" - assert price_bar.close == 1.89 - assert price_bar.open is None - assert price_bar.high is None - assert price_bar.low is None - assert price_bar.adjusted_close is None - assert price_bar.volume is None - - -def test_stock_ohlcv_bar_can_be_created() -> None: - source = DataSource( - name="yfinance", - provider_kind="market_prices", - ) - - instrument = Instrument( - symbol="AAPL", - name="Apple Inc.", - asset_class="stock", - currency="USD", - exchange="NASDAQ", - ) - - price_bar = PriceBar( - source=source, - instrument=instrument, - timestamp=date(2026, 1, 1), - timeframe="1d", - open=187.15, - high=188.44, - low=183.89, - close=185.64, - adjusted_close=184.25, - volume=50_200_000, - ) - - assert price_bar.instrument.symbol == "AAPL" - assert price_bar.open == 187.15 - assert price_bar.high == 188.44 - assert price_bar.low == 183.89 - assert price_bar.close == 185.64 - assert price_bar.adjusted_close == 184.25 - assert price_bar.volume == 50_200_000 diff --git a/tests/test_storage_database.py b/tests/test_storage_database.py deleted file mode 100644 index d513008..0000000 --- a/tests/test_storage_database.py +++ /dev/null @@ -1,222 +0,0 @@ -from datetime import date - -import duckdb - -from argus.domain.internal_models import DataSource, Instrument, PriceBar -from argus.storage.database import ( - initialize_database, - insert_price_bar, - read_price_bars, -) - - -def test_initialize_database_creates_required_tables(tmp_path): - db = tmp_path / "test.duckdb" - - initialize_database(db) - connection = duckdb.connect(db) - tables = connection.execute("SHOW TABLES;").fetchall() - connection.close() - table_names = {row[0] for row in tables} - - assert "data_sources" in table_names - assert "instruments" in table_names - assert "price_bars" in table_names - - -def test_data_is_inserted(tmp_path): - source = DataSource( - name="Yahoo", provider_kind="yfinance_api", requires_api_key=False - ) - - instrument = Instrument( - symbol="EUR/USD", - name="EUR - USD Rate", - asset_class="fx", - base_currency="EUR", - quote_currency="USD", - ) - - pricebar = PriceBar( - source=source, - instrument=instrument, - timestamp=date(2026, 1, 1), - timeframe="1d", - close=1.89, - ) - - db = tmp_path / "test.duckdb" - initialize_database(db) - insert_price_bar(db, pricebar) - connection = duckdb.connect(db) - - instrument_count = connection.execute( - "SELECT COUNT(*) FROM instruments;" - ).fetchone() - - source_count = connection.execute("SELECT COUNT(*) FROM data_sources;").fetchone() - - price_bar_count = connection.execute("SELECT COUNT(*) FROM price_bars;").fetchone() - - assert instrument_count is not None - assert source_count is not None - assert price_bar_count is not None - assert instrument_count[0] == 1 - assert source_count[0] == 1 - assert price_bar_count[0] == 1 - - -def test_fx_has_correct_format(tmp_path): - source = DataSource( - name="Yahoo", provider_kind="yfinance_api", requires_api_key=False - ) - - instrument = Instrument( - symbol="EUR/USD", - name="EUR - USD Rate", - asset_class="fx", - base_currency="EUR", - quote_currency="USD", - ) - - pricebar = PriceBar( - source=source, - instrument=instrument, - timestamp=date(2026, 1, 1), - timeframe="1d", - close=1.89, - ) - - db = tmp_path / "test.duckdb" - initialize_database(db) - insert_price_bar(db, pricebar) - connection = duckdb.connect(db) - - price_bar_fx = connection.execute("SELECT * FROM price_bars;").fetchone() - connection.close() - - assert price_bar_fx is not None - assert price_bar_fx[0] == 1 - assert price_bar_fx[1] == 1 - assert price_bar_fx[2] == 1 - assert price_bar_fx[3] == date(2026, 1, 1) - assert price_bar_fx[4] == "1d" - assert price_bar_fx[5] == 1.89 - assert price_bar_fx[6] is None - assert price_bar_fx[7] is None - assert price_bar_fx[8] is None - assert price_bar_fx[9] is None - assert price_bar_fx[10] is None - - -def test_duplicates_are_ignored(tmp_path): - source = DataSource( - name="Yahoo", provider_kind="yfinance_api", requires_api_key=False - ) - - instrument = Instrument( - symbol="EUR/USD", - name="EUR - USD Rate", - asset_class="fx", - base_currency="EUR", - quote_currency="USD", - ) - - pricebar = PriceBar( - source=source, - instrument=instrument, - timestamp=date(2026, 1, 1), - timeframe="1d", - close=1.89, - ) - - db = tmp_path / "test.duckdb" - initialize_database(db) - insert_price_bar(db, pricebar) - insert_price_bar(db, pricebar) - connection = duckdb.connect(db) - count = connection.execute("SELECT COUNT(*) FROM price_bars;").fetchone() - - assert count is not None - assert count[0] == 1 - - -def test_read_price_bars_returns_matching_data(tmp_path): - source = DataSource( - name="Yahoo", - provider_kind="yfinance_api", - requires_api_key=False, - ) - - instrument = Instrument( - symbol="EUR/USD", - name="EUR - USD Rate", - asset_class="fx", - base_currency="EUR", - quote_currency="USD", - ) - - pricebar = PriceBar( - source=source, - instrument=instrument, - timestamp=date(2026, 1, 1), - timeframe="1d", - close=1.89, - ) - - db = tmp_path / "test.duckdb" - initialize_database(db) - insert_price_bar(db, pricebar) - - result = read_price_bars( - db=db, - source=source, - instrument=instrument, - start_date=date(2026, 1, 1), - end_date=date(2026, 1, 31), - ) - - assert result.empty is False - assert len(result) == 1 - assert result.iloc[0]["source_name"] == "Yahoo" - assert result.iloc[0]["instrument_symbol"] == "EUR/USD" - assert result.iloc[0]["timeframe"] == "1d" - assert result.iloc[0]["close"] == 1.89 - - -def test_read_price_bars_returns_empty_dataframe_for_missing_range(tmp_path): - source = DataSource( - name="Yahoo", - provider_kind="yfinance_api", - requires_api_key=False, - ) - - instrument = Instrument( - symbol="EUR/USD", - name="EUR - USD Rate", - asset_class="fx", - base_currency="EUR", - quote_currency="USD", - ) - - pricebar = PriceBar( - source=source, - instrument=instrument, - timestamp=date(2026, 1, 1), - timeframe="1d", - close=1.89, - ) - - db = tmp_path / "test.duckdb" - initialize_database(db) - insert_price_bar(db, pricebar) - - result = read_price_bars( - db=db, - source=source, - instrument=instrument, - start_date=date(2027, 1, 1), - end_date=date(2027, 1, 31), - ) - - assert result.empty is True diff --git a/tests/test_timeseries_service.py b/tests/test_timeseries_service.py index cd5c97a..7dd3c9f 100644 --- a/tests/test_timeseries_service.py +++ b/tests/test_timeseries_service.py @@ -23,9 +23,8 @@ def test_get_a_full_timeseries(): "max_rate": [1.1055831909179688], } result = prepare_trend_analysis(test_curr, test_start, test_end, test_interval) - - assert result is not None - + if result is None: + return False result_df, result_dict = result result_df["date"] = result_df["date"].astype("str") result_dict["min_date"] = [str(result_dict["min_date"][0])] diff --git a/tests/test_validation_domain.py b/tests/test_validation_domain.py index 0166741..a5bd41f 100644 --- a/tests/test_validation_domain.py +++ b/tests/test_validation_domain.py @@ -7,13 +7,9 @@ def test_op_is_valid(): + data = is_valid_op("+") - assert is_valid_op("+") is True - assert is_valid_op("-") is True - assert is_valid_op("*") is True - assert is_valid_op("/") is True - assert is_valid_op("%") is True - assert is_valid_op("**") is True + assert data is True def test_op_is_not_valid(): diff --git a/tests/test_yfinance_client.py b/tests/test_yfinance_client.py index 6201b19..faf15fc 100644 --- a/tests/test_yfinance_client.py +++ b/tests/test_yfinance_client.py @@ -72,7 +72,7 @@ def test_error_raise(monkeypatch): def fake_yfinance_download( tickers=test_curr, start=test_start, end=test_end, interval=test_interval ): - raise Exception("fake yfinance error") + return Exception("fake yfinance error") monkeypatch.setattr("yfinance.download", fake_yfinance_download) From 4688c93800fec75244a2566f4fb4ed6b2d275033 Mon Sep 17 00:00:00 2001 From: Lev Gusiev Date: Wed, 1 Jul 2026 21:36:11 +0200 Subject: [PATCH 02/16] feat(#70): rename timeseries_service --- .../services/{timeseries_service.py => market_data_service.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/argus/services/{timeseries_service.py => market_data_service.py} (100%) diff --git a/src/argus/services/timeseries_service.py b/src/argus/services/market_data_service.py similarity index 100% rename from src/argus/services/timeseries_service.py rename to src/argus/services/market_data_service.py From 63f5776b73c46f67a81703b806f00376c22aa0e1 Mon Sep 17 00:00:00 2001 From: Lev Gusiev Date: Sun, 5 Jul 2026 21:54:42 +0200 Subject: [PATCH 03/16] refactor(#70): get data is a own func --- src/argus/analytics/charts/trend_chart.py | 2 +- src/argus/services/market_data_service.py | 37 ++- src/argus/storage/database.py | 303 ++++++++++++++++++ ..._service.py => test_market_data_series.py} | 2 +- 4 files changed, 332 insertions(+), 12 deletions(-) create mode 100644 src/argus/storage/database.py rename tests/{test_timeseries_service.py => test_market_data_series.py} (94%) diff --git a/src/argus/analytics/charts/trend_chart.py b/src/argus/analytics/charts/trend_chart.py index 1293361..58c93a1 100644 --- a/src/argus/analytics/charts/trend_chart.py +++ b/src/argus/analytics/charts/trend_chart.py @@ -1,5 +1,5 @@ import matplotlib.pyplot as plt -from argus.services.timeseries_service import prepare_trend_analysis +from argus.services.market_data_service import prepare_trend_analysis def create_trendchart(curr_symbol: str, start: str, end: str, interval: str): diff --git a/src/argus/services/market_data_service.py b/src/argus/services/market_data_service.py index b6251bb..9521021 100644 --- a/src/argus/services/market_data_service.py +++ b/src/argus/services/market_data_service.py @@ -7,9 +7,7 @@ ) -def prepare_trend_analysis( - curr_symbol: str, start: str, end: str, intervall: str -) -> tuple[pd.DataFrame, dict] | None: +def prepare_trend_analysis(df: pd.DataFrame) -> tuple[pd.DataFrame,dict] | None: """ Prepare time-series data for trend analysis. @@ -18,6 +16,27 @@ def prepare_trend_analysis( calculates the minimum and maximum exchange rates for the resulting time series. + Args: + df (pd.Dataframe): A timeserie with market data + + Returns: + tuple[pd.DataFrame, dict] | None: A tuple containing the prepared + DataFrame and a dictionary with minimum and maximum rates. Returns + ``None`` if no time-series data could be fetched. + """ + + + df = add_daily_percentage_change(df) + df = add_rolling_average(df) + min_max_rates = get_min_max_rates(df) + if df is None: + return None + return df, min_max_rates + +def get_market_data(curr_symbol: str, start: str, end: str, intervall: str) -> pd.DataFrame | None: + """ + Get a time series either from local stroage or client with first-storage-workflow + Args: curr_symbol (str): Currency symbol used by Yahoo Finance, for example "EURUSD=X". @@ -27,15 +46,13 @@ def prepare_trend_analysis( "1d", "1h", or "15m". Returns: - tuple[pd.DataFrame, dict] | None: A tuple containing the prepared - DataFrame and a dictionary with minimum and maximum rates. Returns + pd.DataFrame | None: A + DataFrame with dates and rates. Returns ``None`` if no time-series data could be fetched. """ - df = get_timeseries(curr_symbol, start, end, intervall) if df is None: return None - df = add_daily_percentage_change(df) - df = add_rolling_average(df) - min_max_rates = get_min_max_rates(df) - return df, min_max_rates + return df + + diff --git a/src/argus/storage/database.py b/src/argus/storage/database.py new file mode 100644 index 0000000..715c24b --- /dev/null +++ b/src/argus/storage/database.py @@ -0,0 +1,303 @@ +import duckdb +from datetime import date +import pandas as pd +from argus.domain.internal_models import DataSource, PriceBar, Instrument + + +def initialize_database(database_path: str) -> None: + """ + Initialize the DuckDB database schema. + + Creates the required sequences and tables for data sources, + instruments, and price bars. + + Args: + database_path (str): Path to the DuckDB database file. + + Returns: + None + """ + queries = [ + "CREATE SEQUENCE IF NOT EXISTS data_sources_id_seq;", + "CREATE SEQUENCE IF NOT EXISTS instruments_id_seq;", + "CREATE SEQUENCE IF NOT EXISTS price_bars_id_seq;", + """ + CREATE TABLE IF NOT EXISTS data_sources ( + id INTEGER PRIMARY KEY DEFAULT nextval('data_sources_id_seq'), + name TEXT NOT NULL UNIQUE, + provider_kind TEXT NOT NULL, + requires_api_key BOOLEAN NOT NULL + ); + """, + """ + CREATE TABLE IF NOT EXISTS instruments ( + id INTEGER PRIMARY KEY DEFAULT nextval('instruments_id_seq'), + symbol TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + asset_class TEXT NOT NULL, + currency TEXT, + exchange TEXT, + base_currency TEXT, + quote_currency TEXT + ); + """, + """ + CREATE TABLE IF NOT EXISTS price_bars ( + id INTEGER PRIMARY KEY DEFAULT nextval('price_bars_id_seq'), + source_id INTEGER NOT NULL, + instrument_id INTEGER NOT NULL, + timestamp DATE NOT NULL, + timeframe TEXT NOT NULL, + close DOUBLE NOT NULL, + open DOUBLE, + high DOUBLE, + low DOUBLE, + adjusted_close DOUBLE, + volume DOUBLE, + FOREIGN KEY (source_id) REFERENCES data_sources (id), + FOREIGN KEY (instrument_id) REFERENCES instruments (id), + UNIQUE (source_id, instrument_id, timestamp, timeframe) + ); + """, + ] + + connection = duckdb.connect(database_path) + try: + for query in queries: + connection.execute(query) + finally: + connection.close() + + +def get_or_create_source(connection, source: DataSource) -> int: + """ + Get an existing data source ID or create a new data source. + + Searches for a data source by name. If it already exists, its ID is + returned. Otherwise, the data source is inserted and the new ID is + returned. + + Args: + connection: Active DuckDB connection. + source (DataSource): Data source model containing provider metadata. + + Returns: + int: Database ID of the existing or newly created data source. + + Raises: + ValueError: If the data source could not be inserted or found. + """ + insert_query = """ + INSERT INTO data_sources (name, provider_kind, requires_api_key) + VALUES (?,?,?) + ON CONFLICT DO NOTHING; + """ + search_query = """ + SELECT id FROM data_sources + WHERE name=? + """ + + result = connection.execute( + query=search_query, + parameters=[source.name], + ).fetchone() + if result is not None: + return result[0] + + connection.execute( + query=insert_query, + parameters=[source.name, source.provider_kind, source.requires_api_key], + ) + + result = connection.execute( + query=search_query, + parameters=[source.name], + ).fetchone() + + if result is None: + raise ValueError("Data source could not be inserted.") + + return result[0] + + +def get_or_create_instrument(connection, instrument: Instrument) -> int: + """ + Get an existing instrument ID or create a new instrument. + + Searches for an instrument by symbol. If it already exists, its ID is + returned. Otherwise, the instrument is inserted and the new ID is + returned. + + Args: + connection: Active DuckDB connection. + instrument (Instrument): Instrument model containing symbol and + asset metadata. + + Returns: + int: Database ID of the existing or newly created instrument. + + Raises: + ValueError: If the instrument could not be inserted or found. + """ + insert_query = """ + INSERT INTO instruments ( + symbol, + name, + asset_class, + currency, + exchange, + base_currency, + quote_currency) + VALUES (?,?,?,?,?,?,?) + ON CONFLICT DO NOTHING; + """ + search_query = """ + SELECT id FROM instruments + WHERE symbol=? + """ + + result = connection.execute( + query=search_query, + parameters=[instrument.symbol], + ).fetchone() + if result is not None: + return result[0] + + connection.execute( + query=insert_query, + parameters=[ + instrument.symbol, + instrument.name, + instrument.asset_class, + instrument.currency, + instrument.exchange, + instrument.base_currency, + instrument.quote_currency, + ], + ) + result = connection.execute( + query=search_query, + parameters=[instrument.symbol], + ).fetchone() + + if result is None: + raise ValueError("Instrument could not be inserted.") + + return result[0] + + +def insert_price_bar(db: str, price_bar: PriceBar) -> None: + """ + Insert a price bar into the database. + + Ensures that the related data source and instrument exist, then inserts + the price bar into the ``price_bars`` table. Duplicate price bars are + ignored through the table's unique constraint. + + Args: + db (str): Path to the DuckDB database file. + price_bar (PriceBar): Price bar model containing source, + instrument, timestamp, timeframe, and market values. + + Returns: + None + """ + insert_query = """ + INSERT INTO price_bars ( + source_id, + instrument_id, + timestamp, + timeframe, + close, + open, + high, + low, + adjusted_close, + volume + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT DO NOTHING; + """ + connection = duckdb.connect(db) + try: + source_id = get_or_create_source(connection, price_bar.source) + instrument_id = get_or_create_instrument(connection, price_bar.instrument) + connection.execute( + query=insert_query, + parameters=[ + source_id, + instrument_id, + price_bar.timestamp, + price_bar.timeframe, + price_bar.close, + price_bar.open, + price_bar.high, + price_bar.low, + price_bar.adjusted_close, + price_bar.volume, + ], + ) + finally: + connection.close() + + +def read_price_bars( + db: str, + source: DataSource, + instrument: Instrument, + start_date: date, + end_date: date, +) -> pd.DataFrame: + """ + Read price bars for a source, instrument, and date range. + + Queries stored price bars joined with their data source and instrument + metadata. Results are ordered by timestamp and returned as a pandas + DataFrame. + + Args: + db (str): Path to the DuckDB database file. + source (DataSource): Data source used to filter the stored price bars. + instrument (Instrument): Instrument used to filter the stored price bars. + start_date (date): Inclusive start date of the requested time range. + end_date (date): Inclusive end date of the requested time range. + + Returns: + pd.DataFrame: DataFrame containing matching price bars and metadata. + """ + + search_query = """ + SELECT + data_sources.name AS source_name, + instruments.symbol AS instrument_symbol, + price_bars.timestamp, + price_bars.timeframe, + price_bars.open, + price_bars.high, + price_bars.low, + price_bars.close, + price_bars.adjusted_close, + price_bars.volume + FROM price_bars + JOIN data_sources ON price_bars.source_id = data_sources.id + JOIN instruments ON price_bars.instrument_id = instruments.id + WHERE data_sources.name = ? + AND instruments.symbol = ? + AND price_bars.timestamp BETWEEN ? AND ? + ORDER BY price_bars.timestamp; + """ + + connection = duckdb.connect(db) + try: + result = connection.execute( + query=search_query, + parameters=[ + source.name, + instrument.symbol, + start_date, + end_date, + ], + ).df() + finally: + connection.close() + return result diff --git a/tests/test_timeseries_service.py b/tests/test_market_data_series.py similarity index 94% rename from tests/test_timeseries_service.py rename to tests/test_market_data_series.py index 7dd3c9f..7b8925c 100644 --- a/tests/test_timeseries_service.py +++ b/tests/test_market_data_series.py @@ -1,7 +1,7 @@ import pandas as pd import pandas.testing as pdt import numpy as np -from argus.services.timeseries_service import prepare_trend_analysis +from argus.services.market_data_service import prepare_trend_analysis def test_get_a_full_timeseries(): From 52de4180b260b6438237ee962fe4daeec39dea7e Mon Sep 17 00:00:00 2001 From: Lev Gusiev Date: Sun, 5 Jul 2026 22:02:05 +0200 Subject: [PATCH 04/16] refactor(#70): service generate a chart --- src/argus/analytics/charts/trend_chart.py | 8 ++------ src/argus/services/market_data_service.py | 14 ++++++++------ 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/src/argus/analytics/charts/trend_chart.py b/src/argus/analytics/charts/trend_chart.py index 58c93a1..176bef2 100644 --- a/src/argus/analytics/charts/trend_chart.py +++ b/src/argus/analytics/charts/trend_chart.py @@ -1,8 +1,8 @@ import matplotlib.pyplot as plt -from argus.services.market_data_service import prepare_trend_analysis +import pandas as pd -def create_trendchart(curr_symbol: str, start: str, end: str, interval: str): +def create_trendchart(df:pd.DataFrame,min_max_rates:dict): """ Create a trend chart for exchange-rate analysis. @@ -30,10 +30,6 @@ def create_trendchart(curr_symbol: str, start: str, end: str, interval: str): Minimum and maximum exchange-rate values are marked with scatter points and annotations. """ - result = prepare_trend_analysis(curr_symbol, start, end, interval) - if result is None: - return None - df, min_max_rates = result min_date = min_max_rates["min_date"][0] min_rate = min_max_rates["min_rate"][0] max_date = min_max_rates["max_date"][0] diff --git a/src/argus/services/market_data_service.py b/src/argus/services/market_data_service.py index 9521021..f1758b3 100644 --- a/src/argus/services/market_data_service.py +++ b/src/argus/services/market_data_service.py @@ -5,9 +5,10 @@ add_daily_percentage_change, get_min_max_rates, ) +from argus.analytics.charts.trend_chart import create_trendchart -def prepare_trend_analysis(df: pd.DataFrame) -> tuple[pd.DataFrame,dict] | None: +def prepare_trend_analysis(df: pd.DataFrame): """ Prepare time-series data for trend analysis. @@ -25,15 +26,18 @@ def prepare_trend_analysis(df: pd.DataFrame) -> tuple[pd.DataFrame,dict] | None: ``None`` if no time-series data could be fetched. """ - df = add_daily_percentage_change(df) df = add_rolling_average(df) min_max_rates = get_min_max_rates(df) if df is None: return None - return df, min_max_rates + fig = create_trendchart(df,min_max_rates) + return fig -def get_market_data(curr_symbol: str, start: str, end: str, intervall: str) -> pd.DataFrame | None: + +def get_market_data( + curr_symbol: str, start: str, end: str, intervall: str +) -> pd.DataFrame | None: """ Get a time series either from local stroage or client with first-storage-workflow @@ -54,5 +58,3 @@ def get_market_data(curr_symbol: str, start: str, end: str, intervall: str) -> p if df is None: return None return df - - From eaf4613b40c2f2905343262c6a7931766694d6b6 Mon Sep 17 00:00:00 2001 From: Lev Gusiev Date: Sun, 5 Jul 2026 22:39:00 +0200 Subject: [PATCH 05/16] refactor(#70): coordinate workflow --- src/argus/analytics/charts/trend_chart.py | 2 +- src/argus/clients/yfinance_client.py | 18 ++++++++++---- src/argus/gui/app.py | 6 +++-- src/argus/services/market_data_service.py | 30 ++++++++++++++++++++--- src/argus/storage/database.py | 5 ++-- 5 files changed, 48 insertions(+), 13 deletions(-) diff --git a/src/argus/analytics/charts/trend_chart.py b/src/argus/analytics/charts/trend_chart.py index 176bef2..db5dacb 100644 --- a/src/argus/analytics/charts/trend_chart.py +++ b/src/argus/analytics/charts/trend_chart.py @@ -2,7 +2,7 @@ import pandas as pd -def create_trendchart(df:pd.DataFrame,min_max_rates:dict): +def create_trendchart(df: pd.DataFrame, min_max_rates: dict): """ Create a trend chart for exchange-rate analysis. diff --git a/src/argus/clients/yfinance_client.py b/src/argus/clients/yfinance_client.py index de86041..6965189 100644 --- a/src/argus/clients/yfinance_client.py +++ b/src/argus/clients/yfinance_client.py @@ -1,8 +1,16 @@ import yfinance as yf import logging +from datetime import date +from argus.domain.internal_models import DataSource, Instrument, PriceBar -def get_timeseries(curr_symbol, start, end, interval): +def get_timeseries( + source: DataSource, + instrument: Instrument, + bar: PriceBar, + start_date: date, + end_date: date, +): """ Fetch historical exchange-rate time series data from Yahoo Finance. @@ -23,10 +31,10 @@ def get_timeseries(curr_symbol, start, end, interval): yf_logger = logging.getLogger("yfinance") yf_logger.disabled = True data = yf.download( - tickers=curr_symbol, - start=start, - end=end, - interval=interval, + tickers=instrument.base_currency, + start=start_date, + end=end_date, + interval=bar.timeframe, multi_level_index=False, progress=False, ) diff --git a/src/argus/gui/app.py b/src/argus/gui/app.py index 9a19523..06a6f4a 100644 --- a/src/argus/gui/app.py +++ b/src/argus/gui/app.py @@ -1,6 +1,7 @@ import tkinter as tk +import pandas as pd from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg -from argus.analytics.charts.trend_chart import create_trendchart +from argus.services.market_data_service import prepare_trend_analysis from argus.services.calculator_service import calc, check_op from argus.services.conversion_service import convert, check_currency from argus.domain.validation import parse_amount @@ -90,7 +91,8 @@ def show_trend() -> None: content.pack(side="top", fill=tk.BOTH, expand=True) if trend_canvas is None: - fig = create_trendchart(curr_symbol, start, end, interval) + df = pd.DataFrame() + fig = prepare_trend_analysis(df) if fig is None: return None fig.set_size_inches(7, 4) diff --git a/src/argus/services/market_data_service.py b/src/argus/services/market_data_service.py index f1758b3..f7785c7 100644 --- a/src/argus/services/market_data_service.py +++ b/src/argus/services/market_data_service.py @@ -1,5 +1,8 @@ import pandas as pd +from datetime import date +from argus.domain.internal_models import DataSource, PriceBar, Instrument from argus.clients.yfinance_client import get_timeseries +from argus.storage.database import read_price_bars from argus.analytics.metrics.trend_metrics import ( add_rolling_average, add_daily_percentage_change, @@ -31,12 +34,17 @@ def prepare_trend_analysis(df: pd.DataFrame): min_max_rates = get_min_max_rates(df) if df is None: return None - fig = create_trendchart(df,min_max_rates) + fig = create_trendchart(df, min_max_rates) return fig def get_market_data( - curr_symbol: str, start: str, end: str, intervall: str + db: str, + source: DataSource, + instrument: Instrument, + bar: PriceBar, + start_date: date, + end_date: date, ) -> pd.DataFrame | None: """ Get a time series either from local stroage or client with first-storage-workflow @@ -54,7 +62,23 @@ def get_market_data( DataFrame with dates and rates. Returns ``None`` if no time-series data could be fetched. """ - df = get_timeseries(curr_symbol, start, end, intervall) + df, isNotEmpty = read_price_bars( + db=db, + source=source, + instrument=instrument, + start_date=start_date, + end_date=end_date, + ) + if isNotEmpty: + return df + else: + df = get_timeseries( + source=source, + instrument=instrument, + bar=bar, + start_date=start_date, + end_date=end_date, + ) if df is None: return None return df diff --git a/src/argus/storage/database.py b/src/argus/storage/database.py index 715c24b..ca66c1f 100644 --- a/src/argus/storage/database.py +++ b/src/argus/storage/database.py @@ -247,7 +247,7 @@ def read_price_bars( instrument: Instrument, start_date: date, end_date: date, -) -> pd.DataFrame: +) -> tuple[pd.DataFrame, bool]: """ Read price bars for a source, instrument, and date range. @@ -298,6 +298,7 @@ def read_price_bars( end_date, ], ).df() + isNotEmpty = not result.empty finally: connection.close() - return result + return result, isNotEmpty From db3d79a85d8d02af84f313b98f8ab50ed39f14b0 Mon Sep 17 00:00:00 2001 From: Lev Gusiev Date: Mon, 6 Jul 2026 08:50:54 +0200 Subject: [PATCH 06/16] refactor(#70): establish the model --- src/argus/clients/yfinance_client.py | 30 +-- src/argus/domain/internal_models.py | 44 +++++ src/argus/services/market_data_service.py | 50 ++--- tests/test_market_data_series.py | 32 +++- tests/test_storage_database.py | 222 ++++++++++++++++++++++ tests/test_yfinance_client.py | 81 +++++--- 6 files changed, 383 insertions(+), 76 deletions(-) create mode 100644 src/argus/domain/internal_models.py create mode 100644 tests/test_storage_database.py diff --git a/src/argus/clients/yfinance_client.py b/src/argus/clients/yfinance_client.py index 6965189..100de1b 100644 --- a/src/argus/clients/yfinance_client.py +++ b/src/argus/clients/yfinance_client.py @@ -1,16 +1,10 @@ import yfinance as yf import logging from datetime import date -from argus.domain.internal_models import DataSource, Instrument, PriceBar +from argus.domain.internal_models import MarketDataSet +import pandas as pd - -def get_timeseries( - source: DataSource, - instrument: Instrument, - bar: PriceBar, - start_date: date, - end_date: date, -): +def get_timeseries(market_data:MarketDataSet) -> MarketDataSet | None: """ Fetch historical exchange-rate time series data from Yahoo Finance. @@ -30,11 +24,15 @@ def get_timeseries( try: yf_logger = logging.getLogger("yfinance") yf_logger.disabled = True + start = str(market_data.start) + end = str(market_data.end) + timeframe = market_data.timeframe + curr_pair = f"{market_data.instrument.base_currency}{market_data.instrument.quote_currency}=X" data = yf.download( - tickers=instrument.base_currency, - start=start_date, - end=end_date, - interval=bar.timeframe, + tickers=curr_pair, + start=start, + end=end, + interval=timeframe, multi_level_index=False, progress=False, ) @@ -46,6 +44,8 @@ def get_timeseries( data = data.reset_index() data = data[["Date", "Close"]] data = data.rename(columns={"Date": "date", "Close": "rate"}) - return data + market_data.bars=data + print(market_data.bars) + return market_data except Exception: - return None + return None \ No newline at end of file diff --git a/src/argus/domain/internal_models.py b/src/argus/domain/internal_models.py new file mode 100644 index 0000000..83f2088 --- /dev/null +++ b/src/argus/domain/internal_models.py @@ -0,0 +1,44 @@ +from dataclasses import dataclass +from datetime import date +import pandas as pd + + +@dataclass +class DataSource: + name: str + provider_kind: str + requires_api_key: bool = False + + +@dataclass +class Instrument: + symbol: str + name: str + asset_class: str + currency: str | None = None + exchange: str | None = None + base_currency: str | None = None + quote_currency: str | None = None + + +@dataclass +class PriceBar: + source: DataSource + instrument: Instrument + timestamp: date + timeframe: str + close: float + open: float | None = None + high: float | None = None + low: float | None = None + adjusted_close: float | None = None + volume: float | None = None + +@dataclass +class MarketDataSet: + source: DataSource + instrument: Instrument + timeframe: str + start: date + end: date + bars: pd.DataFrame \ No newline at end of file diff --git a/src/argus/services/market_data_service.py b/src/argus/services/market_data_service.py index f7785c7..d49d1b9 100644 --- a/src/argus/services/market_data_service.py +++ b/src/argus/services/market_data_service.py @@ -1,6 +1,6 @@ import pandas as pd from datetime import date -from argus.domain.internal_models import DataSource, PriceBar, Instrument +from argus.domain.internal_models import DataSource, PriceBar, Instrument,MarketDataSet from argus.clients.yfinance_client import get_timeseries from argus.storage.database import read_price_bars from argus.analytics.metrics.trend_metrics import ( @@ -40,12 +40,8 @@ def prepare_trend_analysis(df: pd.DataFrame): def get_market_data( db: str, - source: DataSource, - instrument: Instrument, - bar: PriceBar, - start_date: date, - end_date: date, -) -> pd.DataFrame | None: + market_data: MarketDataSet, +) -> MarketDataSet | None: """ Get a time series either from local stroage or client with first-storage-workflow @@ -62,23 +58,33 @@ def get_market_data( DataFrame with dates and rates. Returns ``None`` if no time-series data could be fetched. """ + source = DataSource(name="YFinance API",provider_kind="yfinance") + instrument = Instrument(symbol="EUR - USD",name="EUR/USD",asset_class="fx",base_currency="EUR",quote_currency="USD") + market_data = MarketDataSet( + source=source, + instrument=instrument, + timeframe="1d", + start=date(2026, 1, 1), + end=date(2026, 1, 4), + bars=pd.DataFrame(), +) df, isNotEmpty = read_price_bars( db=db, - source=source, - instrument=instrument, - start_date=start_date, - end_date=end_date, + source=market_data.source, + instrument=market_data.instrument, + start_date=market_data.start, + end_date=market_data.end, ) - if isNotEmpty: - return df + market_data.source + market_data.instrument + market_data.timeframe + market_data.start + market_data.end + market_data.bars=df + if not(isNotEmpty): + return market_data else: - df = get_timeseries( - source=source, - instrument=instrument, - bar=bar, - start_date=start_date, - end_date=end_date, - ) - if df is None: + other_market_data = get_timeseries(market_data=market_data) + if other_market_data is None: return None - return df + return other_market_data diff --git a/tests/test_market_data_series.py b/tests/test_market_data_series.py index 7b8925c..b232b8e 100644 --- a/tests/test_market_data_series.py +++ b/tests/test_market_data_series.py @@ -1,15 +1,24 @@ import pandas as pd import pandas.testing as pdt import numpy as np -from argus.services.market_data_service import prepare_trend_analysis +from datetime import date +from argus.services.market_data_service import get_market_data +from argus.storage.database import initialize_database +from argus.domain.internal_models import DataSource,Instrument,MarketDataSet -def test_get_a_full_timeseries(): - test_curr = "EURUSD=X" - test_start = "2024-01-01" - test_end = "2024-01-04" - test_interval = "1d" - +def test_get_a_full_timeseries(tmp_path): + source = DataSource(name="YFinance API",provider_kind="yfinance") + instrument = Instrument(symbol="EUR - USD",name="EUR/USD",asset_class="fx") + market_data = MarketDataSet( + source=source, + instrument=instrument, + timeframe="1d", + start=date(2024, 1, 1), + end=date(2024, 1, 4), + bars=pd.DataFrame(), +) + db = tmp_path / "test.duckdb" expect_result = { "date": ["2024-01-01", "2024-01-02", "2024-01-03"], "rate": [1.1055831909179688, 1.1038745641708374, 1.0941756963729858], @@ -22,9 +31,11 @@ def test_get_a_full_timeseries(): "max_date": ["2024-01-01 00:00:00"], "max_rate": [1.1055831909179688], } - result = prepare_trend_analysis(test_curr, test_start, test_end, test_interval) - if result is None: - return False + initialize_database(db) + result = get_market_data(db=db,market_data=market_data) + + assert result is not None + """ result_df, result_dict = result result_df["date"] = result_df["date"].astype("str") result_dict["min_date"] = [str(result_dict["min_date"][0])] @@ -33,3 +44,4 @@ def test_get_a_full_timeseries(): pdt.assert_frame_equal(result_df, expect_df) assert result_dict == expect_dict + """ diff --git a/tests/test_storage_database.py b/tests/test_storage_database.py new file mode 100644 index 0000000..63911b7 --- /dev/null +++ b/tests/test_storage_database.py @@ -0,0 +1,222 @@ +from datetime import date + +import duckdb + +from argus.domain.internal_models import DataSource, Instrument, PriceBar +from argus.storage.database import ( + initialize_database, + insert_price_bar, + read_price_bars, +) + + +def test_initialize_database_creates_required_tables(tmp_path): + db = tmp_path / "test.duckdb" + + initialize_database(db) + connection = duckdb.connect(db) + tables = connection.execute("SHOW TABLES;").fetchall() + connection.close() + table_names = {row[0] for row in tables} + + assert "data_sources" in table_names + assert "instruments" in table_names + assert "price_bars" in table_names + + +def test_data_is_inserted(tmp_path): + source = DataSource( + name="Yahoo", provider_kind="yfinance_api", requires_api_key=False + ) + + instrument = Instrument( + symbol="EUR/USD", + name="EUR - USD Rate", + asset_class="fx", + base_currency="EUR", + quote_currency="USD", + ) + + pricebar = PriceBar( + source=source, + instrument=instrument, + timestamp=date(2026, 1, 1), + timeframe="1d", + close=1.89, + ) + + db = tmp_path / "test.duckdb" + initialize_database(db) + insert_price_bar(db, pricebar) + connection = duckdb.connect(db) + + instrument_count = connection.execute( + "SELECT COUNT(*) FROM instruments;" + ).fetchone() + + source_count = connection.execute("SELECT COUNT(*) FROM data_sources;").fetchone() + + price_bar_count = connection.execute("SELECT COUNT(*) FROM price_bars;").fetchone() + + assert instrument_count is not None + assert source_count is not None + assert price_bar_count is not None + assert instrument_count[0] == 1 + assert source_count[0] == 1 + assert price_bar_count[0] == 1 + + +def test_fx_has_correct_format(tmp_path): + source = DataSource( + name="Yahoo", provider_kind="yfinance_api", requires_api_key=False + ) + + instrument = Instrument( + symbol="EUR/USD", + name="EUR - USD Rate", + asset_class="fx", + base_currency="EUR", + quote_currency="USD", + ) + + pricebar = PriceBar( + source=source, + instrument=instrument, + timestamp=date(2026, 1, 1), + timeframe="1d", + close=1.89, + ) + + db = tmp_path / "test.duckdb" + initialize_database(db) + insert_price_bar(db, pricebar) + connection = duckdb.connect(db) + + price_bar_fx = connection.execute("SELECT * FROM price_bars;").fetchone() + connection.close() + + assert price_bar_fx is not None + assert price_bar_fx[0] == 1 + assert price_bar_fx[1] == 1 + assert price_bar_fx[2] == 1 + assert price_bar_fx[3] == date(2026, 1, 1) + assert price_bar_fx[4] == "1d" + assert price_bar_fx[5] == 1.89 + assert price_bar_fx[6] is None + assert price_bar_fx[7] is None + assert price_bar_fx[8] is None + assert price_bar_fx[9] is None + assert price_bar_fx[10] is None + + +def test_duplicates_are_ignored(tmp_path): + source = DataSource( + name="Yahoo", provider_kind="yfinance_api", requires_api_key=False + ) + + instrument = Instrument( + symbol="EUR/USD", + name="EUR - USD Rate", + asset_class="fx", + base_currency="EUR", + quote_currency="USD", + ) + + pricebar = PriceBar( + source=source, + instrument=instrument, + timestamp=date(2026, 1, 1), + timeframe="1d", + close=1.89, + ) + + db = tmp_path / "test.duckdb" + initialize_database(db) + insert_price_bar(db, pricebar) + insert_price_bar(db, pricebar) + connection = duckdb.connect(db) + count = connection.execute("SELECT COUNT(*) FROM price_bars;").fetchone() + + assert count is not None + assert count[0] == 1 + + +def test_read_price_bars_returns_matching_data(tmp_path): + source = DataSource( + name="Yahoo", + provider_kind="yfinance_api", + requires_api_key=False, + ) + + instrument = Instrument( + symbol="EUR/USD", + name="EUR - USD Rate", + asset_class="fx", + base_currency="EUR", + quote_currency="USD", + ) + + pricebar = PriceBar( + source=source, + instrument=instrument, + timestamp=date(2026, 1, 1), + timeframe="1d", + close=1.89, + ) + + db = tmp_path / "test.duckdb" + initialize_database(db) + insert_price_bar(db, pricebar) + + result,isNotEmpty = read_price_bars( + db=db, + source=source, + instrument=instrument, + start_date=date(2026, 1, 1), + end_date=date(2026, 1, 31), + ) + + assert isNotEmpty is True + assert len(result) == 1 + assert result.iloc[0]["source_name"] == "Yahoo" + assert result.iloc[0]["instrument_symbol"] == "EUR/USD" + assert result.iloc[0]["timeframe"] == "1d" + assert result.iloc[0]["close"] == 1.89 + + +def test_read_price_bars_returns_empty_dataframe_for_missing_range(tmp_path): + source = DataSource( + name="Yahoo", + provider_kind="yfinance_api", + requires_api_key=False, + ) + + instrument = Instrument( + symbol="EUR/USD", + name="EUR - USD Rate", + asset_class="fx", + base_currency="EUR", + quote_currency="USD", + ) + + pricebar = PriceBar( + source=source, + instrument=instrument, + timestamp=date(2026, 1, 1), + timeframe="1d", + close=1.89, + ) + + db = tmp_path / "test.duckdb" + initialize_database(db) + insert_price_bar(db, pricebar) + + result,isNotEmpty = read_price_bars( + db=db, + source=source, + instrument=instrument, + start_date=date(2027, 1, 1), + end_date=date(2027, 1, 31), + ) + + assert not(isNotEmpty) is True diff --git a/tests/test_yfinance_client.py b/tests/test_yfinance_client.py index faf15fc..70bb4e4 100644 --- a/tests/test_yfinance_client.py +++ b/tests/test_yfinance_client.py @@ -1,4 +1,6 @@ from argus.clients.yfinance_client import get_timeseries +from argus.domain.internal_models import DataSource, Instrument, MarketDataSet +from datetime import date import pandas as pd import pandas.testing as pdt @@ -11,70 +13,91 @@ def test_get_dataframe(monkeypatch): index=pd.to_datetime(["2024-01-01", "2024-01-02", "2024-01-03"]), ) test_resp.index.name = "Date" - test_curr = "EURUSD=X" - test_start = "2024-01-01" - test_end = "2024-01-04" - test_interval = "1d" - + source = DataSource(name="YFinance API",provider_kind="yfinance") + instrument = Instrument(symbol="EUR - USD",name="EUR/USD",asset_class="fx",base_currency="EUR",quote_currency="USD") + market_data = MarketDataSet( + source=source, + instrument=instrument, + timeframe="1d", + start=date(2024, 1, 1), + end=date(2024, 1, 4), + bars=pd.DataFrame(), + ) def fake_yfinance_download(*args, **kwargs): return test_resp monkeypatch.setattr("yfinance.download", fake_yfinance_download) - - result = get_timeseries(test_curr, test_start, test_end, test_interval) + result = get_timeseries(market_data) expected = pd.DataFrame( { "date": pd.to_datetime(["2024-01-01", "2024-01-02", "2024-01-03"]), "rate": [1.105583, 1.103875, 1.094176], } ) + + assert result is not None - pdt.assert_frame_equal(result, expected) + pdt.assert_frame_equal(result.bars, expected) def test_get_none(monkeypatch): - test_curr = "EURUSD=X" - test_start = "2024-01-01" - test_end = "2024-01-04" - test_interval = "1d" - + source = DataSource(name="YFinance API",provider_kind="yfinance") + instrument = Instrument(symbol="EUR - USD",name="EUR/USD",asset_class="fx") + market_data = MarketDataSet( + source=source, + instrument=instrument, + timeframe="1d", + start=date(2026, 1, 1), + end=date(2026, 1, 4), + bars=pd.DataFrame(), +) def fake_yfinance_download(*args, **kwargs): return None monkeypatch.setattr("yfinance.download", fake_yfinance_download) - result = get_timeseries(test_curr, test_start, test_end, test_interval) + result = get_timeseries(market_data) assert result is None def test_get_empty_frame(monkeypatch): - test_curr = "EURUSD=X" - test_start = "2024-01-01" - test_end = "2024-01-01" - test_interval = "1d" + source = DataSource(name="YFinance API",provider_kind="yfinance") + instrument = Instrument(symbol="EUR - USD",name="EUR/USD",asset_class="fx") + market_data = MarketDataSet( + source=source, + instrument=instrument, + timeframe="1d", + start=date(2026, 1, 1), + end=date(2026, 1, 1), + bars=pd.DataFrame(), +) def fake_yfinance_download(*args, **kwargs): return pd.DataFrame() monkeypatch.setattr("yfinance.download", fake_yfinance_download) - result = get_timeseries(test_curr, test_start, test_end, test_interval) + result = get_timeseries(market_data) assert result is None def test_error_raise(monkeypatch): - test_curr = "EURUSD=X" # start date is inclusiv and end date is exclusiv - the range 2024-01-01-2024-01-01 is not possible - test_start = "2024-01-04" - test_end = "2024-01-02" - test_interval = "1d" - - def fake_yfinance_download( - tickers=test_curr, start=test_start, end=test_end, interval=test_interval - ): - return Exception("fake yfinance error") + source = DataSource(name="YFinance API",provider_kind="yfinance") + instrument = Instrument(symbol="EUR - USD",name="EUR/USD",asset_class="fx") + market_data = MarketDataSet( + source=source, + instrument=instrument, + timeframe="1d", + start=date(2026, 1, 1), + end=date(2026, 1, 1), + bars=pd.DataFrame(), +) + + def fake_yfinance_download(): + raise Exception("fake yfinance error") monkeypatch.setattr("yfinance.download", fake_yfinance_download) - result = get_timeseries(test_curr, test_start, test_end, test_interval) + result = get_timeseries(market_data=market_data) assert result is None From 00dacfd15b5c61399c1d8a08ab06ff8f8f2acbf0 Mon Sep 17 00:00:00 2001 From: Lev Gusiev Date: Mon, 6 Jul 2026 08:56:25 +0200 Subject: [PATCH 07/16] test(#70): edit the tests --- src/argus/clients/yfinance_client.py | 7 ++- src/argus/domain/internal_models.py | 3 +- src/argus/services/market_data_service.py | 30 ++++++---- tests/test_market_data_series.py | 22 ++++---- tests/test_storage_database.py | 6 +- tests/test_yfinance_client.py | 69 +++++++++++++---------- 6 files changed, 76 insertions(+), 61 deletions(-) diff --git a/src/argus/clients/yfinance_client.py b/src/argus/clients/yfinance_client.py index 100de1b..894d393 100644 --- a/src/argus/clients/yfinance_client.py +++ b/src/argus/clients/yfinance_client.py @@ -4,7 +4,8 @@ from argus.domain.internal_models import MarketDataSet import pandas as pd -def get_timeseries(market_data:MarketDataSet) -> MarketDataSet | None: + +def get_timeseries(market_data: MarketDataSet) -> MarketDataSet | None: """ Fetch historical exchange-rate time series data from Yahoo Finance. @@ -44,8 +45,8 @@ def get_timeseries(market_data:MarketDataSet) -> MarketDataSet | None: data = data.reset_index() data = data[["Date", "Close"]] data = data.rename(columns={"Date": "date", "Close": "rate"}) - market_data.bars=data + market_data.bars = data print(market_data.bars) return market_data except Exception: - return None \ No newline at end of file + return None diff --git a/src/argus/domain/internal_models.py b/src/argus/domain/internal_models.py index 83f2088..1ce9fac 100644 --- a/src/argus/domain/internal_models.py +++ b/src/argus/domain/internal_models.py @@ -34,6 +34,7 @@ class PriceBar: adjusted_close: float | None = None volume: float | None = None + @dataclass class MarketDataSet: source: DataSource @@ -41,4 +42,4 @@ class MarketDataSet: timeframe: str start: date end: date - bars: pd.DataFrame \ No newline at end of file + bars: pd.DataFrame diff --git a/src/argus/services/market_data_service.py b/src/argus/services/market_data_service.py index d49d1b9..29dd777 100644 --- a/src/argus/services/market_data_service.py +++ b/src/argus/services/market_data_service.py @@ -1,6 +1,6 @@ import pandas as pd from datetime import date -from argus.domain.internal_models import DataSource, PriceBar, Instrument,MarketDataSet +from argus.domain.internal_models import DataSource, PriceBar, Instrument, MarketDataSet from argus.clients.yfinance_client import get_timeseries from argus.storage.database import read_price_bars from argus.analytics.metrics.trend_metrics import ( @@ -58,16 +58,22 @@ def get_market_data( DataFrame with dates and rates. Returns ``None`` if no time-series data could be fetched. """ - source = DataSource(name="YFinance API",provider_kind="yfinance") - instrument = Instrument(symbol="EUR - USD",name="EUR/USD",asset_class="fx",base_currency="EUR",quote_currency="USD") + source = DataSource(name="YFinance API", provider_kind="yfinance") + instrument = Instrument( + symbol="EUR - USD", + name="EUR/USD", + asset_class="fx", + base_currency="EUR", + quote_currency="USD", + ) market_data = MarketDataSet( - source=source, - instrument=instrument, - timeframe="1d", - start=date(2026, 1, 1), - end=date(2026, 1, 4), - bars=pd.DataFrame(), -) + source=source, + instrument=instrument, + timeframe="1d", + start=date(2026, 1, 1), + end=date(2026, 1, 4), + bars=pd.DataFrame(), + ) df, isNotEmpty = read_price_bars( db=db, source=market_data.source, @@ -80,8 +86,8 @@ def get_market_data( market_data.timeframe market_data.start market_data.end - market_data.bars=df - if not(isNotEmpty): + market_data.bars = df + if not (isNotEmpty): return market_data else: other_market_data = get_timeseries(market_data=market_data) diff --git a/tests/test_market_data_series.py b/tests/test_market_data_series.py index b232b8e..aea6434 100644 --- a/tests/test_market_data_series.py +++ b/tests/test_market_data_series.py @@ -4,20 +4,20 @@ from datetime import date from argus.services.market_data_service import get_market_data from argus.storage.database import initialize_database -from argus.domain.internal_models import DataSource,Instrument,MarketDataSet +from argus.domain.internal_models import DataSource, Instrument, MarketDataSet def test_get_a_full_timeseries(tmp_path): - source = DataSource(name="YFinance API",provider_kind="yfinance") - instrument = Instrument(symbol="EUR - USD",name="EUR/USD",asset_class="fx") + source = DataSource(name="YFinance API", provider_kind="yfinance") + instrument = Instrument(symbol="EUR - USD", name="EUR/USD", asset_class="fx") market_data = MarketDataSet( - source=source, - instrument=instrument, - timeframe="1d", - start=date(2024, 1, 1), - end=date(2024, 1, 4), - bars=pd.DataFrame(), -) + source=source, + instrument=instrument, + timeframe="1d", + start=date(2024, 1, 1), + end=date(2024, 1, 4), + bars=pd.DataFrame(), + ) db = tmp_path / "test.duckdb" expect_result = { "date": ["2024-01-01", "2024-01-02", "2024-01-03"], @@ -32,7 +32,7 @@ def test_get_a_full_timeseries(tmp_path): "max_rate": [1.1055831909179688], } initialize_database(db) - result = get_market_data(db=db,market_data=market_data) + result = get_market_data(db=db, market_data=market_data) assert result is not None """ diff --git a/tests/test_storage_database.py b/tests/test_storage_database.py index 63911b7..a2c610b 100644 --- a/tests/test_storage_database.py +++ b/tests/test_storage_database.py @@ -168,7 +168,7 @@ def test_read_price_bars_returns_matching_data(tmp_path): initialize_database(db) insert_price_bar(db, pricebar) - result,isNotEmpty = read_price_bars( + result, isNotEmpty = read_price_bars( db=db, source=source, instrument=instrument, @@ -211,7 +211,7 @@ def test_read_price_bars_returns_empty_dataframe_for_missing_range(tmp_path): initialize_database(db) insert_price_bar(db, pricebar) - result,isNotEmpty = read_price_bars( + result, isNotEmpty = read_price_bars( db=db, source=source, instrument=instrument, @@ -219,4 +219,4 @@ def test_read_price_bars_returns_empty_dataframe_for_missing_range(tmp_path): end_date=date(2027, 1, 31), ) - assert not(isNotEmpty) is True + assert not (isNotEmpty) is True diff --git a/tests/test_yfinance_client.py b/tests/test_yfinance_client.py index 70bb4e4..fea1681 100644 --- a/tests/test_yfinance_client.py +++ b/tests/test_yfinance_client.py @@ -1,6 +1,6 @@ from argus.clients.yfinance_client import get_timeseries from argus.domain.internal_models import DataSource, Instrument, MarketDataSet -from datetime import date +from datetime import date import pandas as pd import pandas.testing as pdt @@ -13,8 +13,14 @@ def test_get_dataframe(monkeypatch): index=pd.to_datetime(["2024-01-01", "2024-01-02", "2024-01-03"]), ) test_resp.index.name = "Date" - source = DataSource(name="YFinance API",provider_kind="yfinance") - instrument = Instrument(symbol="EUR - USD",name="EUR/USD",asset_class="fx",base_currency="EUR",quote_currency="USD") + source = DataSource(name="YFinance API", provider_kind="yfinance") + instrument = Instrument( + symbol="EUR - USD", + name="EUR/USD", + asset_class="fx", + base_currency="EUR", + quote_currency="USD", + ) market_data = MarketDataSet( source=source, instrument=instrument, @@ -23,6 +29,7 @@ def test_get_dataframe(monkeypatch): end=date(2024, 1, 4), bars=pd.DataFrame(), ) + def fake_yfinance_download(*args, **kwargs): return test_resp @@ -35,22 +42,22 @@ def fake_yfinance_download(*args, **kwargs): } ) - assert result is not None pdt.assert_frame_equal(result.bars, expected) def test_get_none(monkeypatch): - source = DataSource(name="YFinance API",provider_kind="yfinance") - instrument = Instrument(symbol="EUR - USD",name="EUR/USD",asset_class="fx") + source = DataSource(name="YFinance API", provider_kind="yfinance") + instrument = Instrument(symbol="EUR - USD", name="EUR/USD", asset_class="fx") market_data = MarketDataSet( - source=source, - instrument=instrument, - timeframe="1d", - start=date(2026, 1, 1), - end=date(2026, 1, 4), - bars=pd.DataFrame(), -) + source=source, + instrument=instrument, + timeframe="1d", + start=date(2026, 1, 1), + end=date(2026, 1, 4), + bars=pd.DataFrame(), + ) + def fake_yfinance_download(*args, **kwargs): return None @@ -61,16 +68,16 @@ def fake_yfinance_download(*args, **kwargs): def test_get_empty_frame(monkeypatch): - source = DataSource(name="YFinance API",provider_kind="yfinance") - instrument = Instrument(symbol="EUR - USD",name="EUR/USD",asset_class="fx") + source = DataSource(name="YFinance API", provider_kind="yfinance") + instrument = Instrument(symbol="EUR - USD", name="EUR/USD", asset_class="fx") market_data = MarketDataSet( - source=source, - instrument=instrument, - timeframe="1d", - start=date(2026, 1, 1), - end=date(2026, 1, 1), - bars=pd.DataFrame(), -) + source=source, + instrument=instrument, + timeframe="1d", + start=date(2026, 1, 1), + end=date(2026, 1, 1), + bars=pd.DataFrame(), + ) def fake_yfinance_download(*args, **kwargs): return pd.DataFrame() @@ -83,16 +90,16 @@ def fake_yfinance_download(*args, **kwargs): def test_error_raise(monkeypatch): # start date is inclusiv and end date is exclusiv - the range 2024-01-01-2024-01-01 is not possible - source = DataSource(name="YFinance API",provider_kind="yfinance") - instrument = Instrument(symbol="EUR - USD",name="EUR/USD",asset_class="fx") + source = DataSource(name="YFinance API", provider_kind="yfinance") + instrument = Instrument(symbol="EUR - USD", name="EUR/USD", asset_class="fx") market_data = MarketDataSet( - source=source, - instrument=instrument, - timeframe="1d", - start=date(2026, 1, 1), - end=date(2026, 1, 1), - bars=pd.DataFrame(), -) + source=source, + instrument=instrument, + timeframe="1d", + start=date(2026, 1, 1), + end=date(2026, 1, 1), + bars=pd.DataFrame(), + ) def fake_yfinance_download(): raise Exception("fake yfinance error") From b331863743553d6071689fd742c7d41d90809123 Mon Sep 17 00:00:00 2001 From: Lev Gusiev Date: Mon, 6 Jul 2026 09:03:00 +0200 Subject: [PATCH 08/16] refactor(#70): remove unused vars --- src/argus/clients/yfinance_client.py | 2 -- src/argus/gui/app.py | 5 ----- src/argus/services/market_data_service.py | 2 +- tests/test_market_data_series.py | 15 +-------------- tests/test_storage_database.py | 2 +- 5 files changed, 3 insertions(+), 23 deletions(-) diff --git a/src/argus/clients/yfinance_client.py b/src/argus/clients/yfinance_client.py index 894d393..5751082 100644 --- a/src/argus/clients/yfinance_client.py +++ b/src/argus/clients/yfinance_client.py @@ -1,8 +1,6 @@ import yfinance as yf import logging -from datetime import date from argus.domain.internal_models import MarketDataSet -import pandas as pd def get_timeseries(market_data: MarketDataSet) -> MarketDataSet | None: diff --git a/src/argus/gui/app.py b/src/argus/gui/app.py index 06a6f4a..02f4c1f 100644 --- a/src/argus/gui/app.py +++ b/src/argus/gui/app.py @@ -77,11 +77,6 @@ def show_trend() -> None: global trend_canvas global trend_chart_widget - curr_symbol = "EURUSD=X" - start = "2024-01-01" - end = "2025-01-01" - interval = "1d" - calc_frame.pack_forget() conv_frame.pack_forget() menu_frame.pack_forget() diff --git a/src/argus/services/market_data_service.py b/src/argus/services/market_data_service.py index 29dd777..fd9d103 100644 --- a/src/argus/services/market_data_service.py +++ b/src/argus/services/market_data_service.py @@ -1,6 +1,6 @@ import pandas as pd from datetime import date -from argus.domain.internal_models import DataSource, PriceBar, Instrument, MarketDataSet +from argus.domain.internal_models import DataSource, Instrument, MarketDataSet from argus.clients.yfinance_client import get_timeseries from argus.storage.database import read_price_bars from argus.analytics.metrics.trend_metrics import ( diff --git a/tests/test_market_data_series.py b/tests/test_market_data_series.py index aea6434..11e6521 100644 --- a/tests/test_market_data_series.py +++ b/tests/test_market_data_series.py @@ -1,6 +1,4 @@ import pandas as pd -import pandas.testing as pdt -import numpy as np from datetime import date from argus.services.market_data_service import get_market_data from argus.storage.database import initialize_database @@ -19,18 +17,7 @@ def test_get_a_full_timeseries(tmp_path): bars=pd.DataFrame(), ) db = tmp_path / "test.duckdb" - expect_result = { - "date": ["2024-01-01", "2024-01-02", "2024-01-03"], - "rate": [1.1055831909179688, 1.1038745641708374, 1.0941756963729858], - "daily_pct_change": [np.nan, -0.1545452898675692, -0.8786204622023064], - "roll_avg": [1.1055831909179688, 1.104728877544403, 1.101211150487264], - } - expect_dict = { - "min_date": ["2024-01-03 00:00:00"], - "min_rate": [1.0941756963729858], - "max_date": ["2024-01-01 00:00:00"], - "max_rate": [1.1055831909179688], - } + initialize_database(db) result = get_market_data(db=db, market_data=market_data) diff --git a/tests/test_storage_database.py b/tests/test_storage_database.py index a2c610b..3697b52 100644 --- a/tests/test_storage_database.py +++ b/tests/test_storage_database.py @@ -219,4 +219,4 @@ def test_read_price_bars_returns_empty_dataframe_for_missing_range(tmp_path): end_date=date(2027, 1, 31), ) - assert not (isNotEmpty) is True + assert isNotEmpty is not True From 940ffe7ae98083d839d1650c1e096a6e0ba659b5 Mon Sep 17 00:00:00 2001 From: Lev Gusiev Date: Tue, 7 Jul 2026 11:57:04 +0200 Subject: [PATCH 09/16] refactor(#70): improve req/resp model --- src/argus/clients/yfinance_client.py | 55 +++++++++--------- src/argus/domain/internal_models.py | 37 ++++++++---- src/argus/services/market_data_service.py | 59 ++++++-------------- src/argus/storage/database.py | 68 +++++++++++------------ tests/test_storage_database.py | 7 +-- tests/test_yfinance_client.py | 3 +- 6 files changed, 111 insertions(+), 118 deletions(-) diff --git a/src/argus/clients/yfinance_client.py b/src/argus/clients/yfinance_client.py index 5751082..98f6af1 100644 --- a/src/argus/clients/yfinance_client.py +++ b/src/argus/clients/yfinance_client.py @@ -1,9 +1,13 @@ import yfinance as yf -import logging -from argus.domain.internal_models import MarketDataSet +import pandas as pd +from argus.domain.internal_models import ( + MarketDataRequest, + PRICE_BAR_COLUMNS, + YFINANCE_PRICE_BAR_MAPPING, +) -def get_timeseries(market_data: MarketDataSet) -> MarketDataSet | None: +def get_timeseries(request: MarketDataRequest) -> pd.DataFrame: """ Fetch historical exchange-rate time series data from Yahoo Finance. @@ -16,18 +20,17 @@ def get_timeseries(market_data: MarketDataSet) -> MarketDataSet | None: "1d", "1h", or "15m". Returns: - pandas.DataFrame | None: A DataFrame containing the columns ``date`` and - ``rate`` if data was successfully fetched. Returns ``None`` if the - request fails, returns no data, or an exception occurs. + pandas.DataFrame | empty pandas.DataFrame: A DataFrame containing pricebars columns if data was successfully fetched. + Returns empty pandas.DataFrame if the request fails and an exception occurs (with an error message). """ try: - yf_logger = logging.getLogger("yfinance") - yf_logger.disabled = True - start = str(market_data.start) - end = str(market_data.end) - timeframe = market_data.timeframe - curr_pair = f"{market_data.instrument.base_currency}{market_data.instrument.quote_currency}=X" - data = yf.download( + start = str(request.start) + end = str(request.end) + timeframe = request.timeframe + curr_pair = ( + f"{request.instrument.base_currency}{request.instrument.quote_currency}=X" + ) + raw_resp = yf.download( tickers=curr_pair, start=start, end=end, @@ -35,16 +38,18 @@ def get_timeseries(market_data: MarketDataSet) -> MarketDataSet | None: multi_level_index=False, progress=False, ) - yf_logger.disabled = False - if data is None: - return None - if data.empty: - return None - data = data.reset_index() - data = data[["Date", "Close"]] - data = data.rename(columns={"Date": "date", "Close": "rate"}) - market_data.bars = data - print(market_data.bars) - return market_data + if raw_resp is None: + raise ConnectionError("Couldn't fetch data") + if raw_resp.empty: + raise ValueError("No data") + resp = normalize_yfinance_bars(raw_resp) + return resp except Exception: - return None + return pd.DataFrame() + + +def normalize_yfinance_bars(raw_df: pd.DataFrame) -> pd.DataFrame: + df = raw_df.copy() + df = df.reset_index() + df = df.rename(columns=YFINANCE_PRICE_BAR_MAPPING) + return df[list(PRICE_BAR_COLUMNS)] diff --git a/src/argus/domain/internal_models.py b/src/argus/domain/internal_models.py index 1ce9fac..2cfc656 100644 --- a/src/argus/domain/internal_models.py +++ b/src/argus/domain/internal_models.py @@ -21,25 +21,38 @@ class Instrument: quote_currency: str | None = None +PRICE_BAR_COLUMNS = ( + "timestamp", + "open", + "high", + "low", + "close", + "adjusted_close", + "volume", +) + +YFINANCE_PRICE_BAR_MAPPING = { + "Date": "timestamp", + "Open": "open", + "High": "high", + "Low": "low", + "Close": "close", + "Adj Close": "adjusted_close", + "Volume": "volume", +} + + @dataclass -class PriceBar: +class MarketDataRequest: source: DataSource instrument: Instrument - timestamp: date timeframe: str - close: float - open: float | None = None - high: float | None = None - low: float | None = None - adjusted_close: float | None = None - volume: float | None = None + start: date + end: date @dataclass -class MarketDataSet: +class MarketDataResponse: source: DataSource instrument: Instrument - timeframe: str - start: date - end: date bars: pd.DataFrame diff --git a/src/argus/services/market_data_service.py b/src/argus/services/market_data_service.py index fd9d103..bf6a851 100644 --- a/src/argus/services/market_data_service.py +++ b/src/argus/services/market_data_service.py @@ -1,6 +1,6 @@ import pandas as pd from datetime import date -from argus.domain.internal_models import DataSource, Instrument, MarketDataSet +from argus.domain.internal_models import DataSource, Instrument, MarketDataRequest,MarketDataResponse from argus.clients.yfinance_client import get_timeseries from argus.storage.database import read_price_bars from argus.analytics.metrics.trend_metrics import ( @@ -32,16 +32,14 @@ def prepare_trend_analysis(df: pd.DataFrame): df = add_daily_percentage_change(df) df = add_rolling_average(df) min_max_rates = get_min_max_rates(df) - if df is None: - return None fig = create_trendchart(df, min_max_rates) return fig def get_market_data( db: str, - market_data: MarketDataSet, -) -> MarketDataSet | None: + request: MarketDataRequest, +) -> MarketDataResponse | None: """ Get a time series either from local stroage or client with first-storage-workflow @@ -58,39 +56,18 @@ def get_market_data( DataFrame with dates and rates. Returns ``None`` if no time-series data could be fetched. """ - source = DataSource(name="YFinance API", provider_kind="yfinance") - instrument = Instrument( - symbol="EUR - USD", - name="EUR/USD", - asset_class="fx", - base_currency="EUR", - quote_currency="USD", - ) - market_data = MarketDataSet( - source=source, - instrument=instrument, - timeframe="1d", - start=date(2026, 1, 1), - end=date(2026, 1, 4), - bars=pd.DataFrame(), - ) - df, isNotEmpty = read_price_bars( - db=db, - source=market_data.source, - instrument=market_data.instrument, - start_date=market_data.start, - end_date=market_data.end, - ) - market_data.source - market_data.instrument - market_data.timeframe - market_data.start - market_data.end - market_data.bars = df - if not (isNotEmpty): - return market_data - else: - other_market_data = get_timeseries(market_data=market_data) - if other_market_data is None: - return None - return other_market_data + bars = read_price_bars(db,request) + if not (bars.empty): + db_response = MarketDataResponse( + source=request.source, + instrument=request.instrument, + bars=bars) + return db_response + + bars = get_timeseries(request) + if not (bars.empty): + api_response = MarketDataResponse( + source=request.source, + instrument=request.instrument, + bars=bars) + return api_response diff --git a/src/argus/storage/database.py b/src/argus/storage/database.py index ca66c1f..a1e9f8f 100644 --- a/src/argus/storage/database.py +++ b/src/argus/storage/database.py @@ -1,7 +1,13 @@ import duckdb from datetime import date import pandas as pd -from argus.domain.internal_models import DataSource, PriceBar, Instrument +from argus.domain.internal_models import ( + DataSource, + Instrument, + MarketDataRequest, + MarketDataResponse, + PRICE_BAR_COLUMNS, +) def initialize_database(database_path: str) -> None: @@ -186,7 +192,7 @@ def get_or_create_instrument(connection, instrument: Instrument) -> int: return result[0] -def insert_price_bar(db: str, price_bar: PriceBar) -> None: +def insert_price_bar(db: str, marketdata: MarketDataResponse) -> None: """ Insert a price bar into the database. @@ -207,7 +213,6 @@ def insert_price_bar(db: str, price_bar: PriceBar) -> None: source_id, instrument_id, timestamp, - timeframe, close, open, high, @@ -215,39 +220,35 @@ def insert_price_bar(db: str, price_bar: PriceBar) -> None: adjusted_close, volume ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT DO NOTHING; """ connection = duckdb.connect(db) try: - source_id = get_or_create_source(connection, price_bar.source) - instrument_id = get_or_create_instrument(connection, price_bar.instrument) - connection.execute( - query=insert_query, - parameters=[ - source_id, - instrument_id, - price_bar.timestamp, - price_bar.timeframe, - price_bar.close, - price_bar.open, - price_bar.high, - price_bar.low, - price_bar.adjusted_close, - price_bar.volume, - ], - ) + source_id = get_or_create_source(connection, marketdata.source) + instrument_id = get_or_create_instrument(connection, marketdata.instrument) + if marketdata.bars is None: + return None + for _, row in marketdata.bars.iterrows(): + connection.execute( + query=insert_query, + parameters=[ + source_id, + instrument_id, + MarketDataResponse.bars["time"], + MarketDataResponse.bars["close"], + MarketDataResponse.bars["open"], + MarketDataResponse.bars["high"], + MarketDataResponse.bars["low"], + MarketDataResponse.bars["adjusted_close"], + MarketDataResponse.bars["volume"], + ], + ) finally: connection.close() -def read_price_bars( - db: str, - source: DataSource, - instrument: Instrument, - start_date: date, - end_date: date, -) -> tuple[pd.DataFrame, bool]: +def read_price_bars(db: str, request: MarketDataRequest) -> pd.DataFrame: """ Read price bars for a source, instrument, and date range. @@ -292,13 +293,12 @@ def read_price_bars( result = connection.execute( query=search_query, parameters=[ - source.name, - instrument.symbol, - start_date, - end_date, + request.source.name, + request.instrument.symbol, + request.start, + request.end, ], ).df() - isNotEmpty = not result.empty finally: connection.close() - return result, isNotEmpty + return result diff --git a/tests/test_storage_database.py b/tests/test_storage_database.py index 3697b52..add7f7c 100644 --- a/tests/test_storage_database.py +++ b/tests/test_storage_database.py @@ -168,7 +168,7 @@ def test_read_price_bars_returns_matching_data(tmp_path): initialize_database(db) insert_price_bar(db, pricebar) - result, isNotEmpty = read_price_bars( + result = read_price_bars( db=db, source=source, instrument=instrument, @@ -176,7 +176,6 @@ def test_read_price_bars_returns_matching_data(tmp_path): end_date=date(2026, 1, 31), ) - assert isNotEmpty is True assert len(result) == 1 assert result.iloc[0]["source_name"] == "Yahoo" assert result.iloc[0]["instrument_symbol"] == "EUR/USD" @@ -211,7 +210,7 @@ def test_read_price_bars_returns_empty_dataframe_for_missing_range(tmp_path): initialize_database(db) insert_price_bar(db, pricebar) - result, isNotEmpty = read_price_bars( + result = read_price_bars( db=db, source=source, instrument=instrument, @@ -219,4 +218,4 @@ def test_read_price_bars_returns_empty_dataframe_for_missing_range(tmp_path): end_date=date(2027, 1, 31), ) - assert isNotEmpty is not True + assert result.empty is True diff --git a/tests/test_yfinance_client.py b/tests/test_yfinance_client.py index fea1681..b4a97c5 100644 --- a/tests/test_yfinance_client.py +++ b/tests/test_yfinance_client.py @@ -27,7 +27,6 @@ def test_get_dataframe(monkeypatch): timeframe="1d", start=date(2024, 1, 1), end=date(2024, 1, 4), - bars=pd.DataFrame(), ) def fake_yfinance_download(*args, **kwargs): @@ -43,6 +42,7 @@ def fake_yfinance_download(*args, **kwargs): ) assert result is not None + assert result.bars is not None pdt.assert_frame_equal(result.bars, expected) @@ -76,7 +76,6 @@ def test_get_empty_frame(monkeypatch): timeframe="1d", start=date(2026, 1, 1), end=date(2026, 1, 1), - bars=pd.DataFrame(), ) def fake_yfinance_download(*args, **kwargs): From 8ee0790714de4d80e23dc19904f1c3f8c7907299 Mon Sep 17 00:00:00 2001 From: Lev Gusiev Date: Tue, 7 Jul 2026 13:32:50 +0200 Subject: [PATCH 10/16] test(#70): improve db and model tests --- src/argus/clients/yfinance_client.py | 2 +- src/argus/domain/internal_models.py | 12 ++ src/argus/services/market_data_service.py | 21 +- src/argus/storage/database.py | 21 +- tests/test_internal_models.py | 60 ++++++ tests/test_market_data_series.py | 7 +- tests/test_storage_database.py | 239 ++++++++-------------- tests/test_yfinance_client.py | 39 ++-- 8 files changed, 206 insertions(+), 195 deletions(-) create mode 100644 tests/test_internal_models.py diff --git a/src/argus/clients/yfinance_client.py b/src/argus/clients/yfinance_client.py index 98f6af1..db689b1 100644 --- a/src/argus/clients/yfinance_client.py +++ b/src/argus/clients/yfinance_client.py @@ -20,7 +20,7 @@ def get_timeseries(request: MarketDataRequest) -> pd.DataFrame: "1d", "1h", or "15m". Returns: - pandas.DataFrame | empty pandas.DataFrame: A DataFrame containing pricebars columns if data was successfully fetched. + pandas.DataFrame | empty pandas.DataFrame: A DataFrame containing pricebars columns if data was successfully fetched. Returns empty pandas.DataFrame if the request fails and an exception occurs (with an error message). """ try: diff --git a/src/argus/domain/internal_models.py b/src/argus/domain/internal_models.py index 2cfc656..f4dcf27 100644 --- a/src/argus/domain/internal_models.py +++ b/src/argus/domain/internal_models.py @@ -56,3 +56,15 @@ class MarketDataResponse: source: DataSource instrument: Instrument bars: pd.DataFrame + + def __post_init__(self) -> None: + if not isinstance(self.bars, pd.DataFrame): + raise TypeError("bars must be a pandas DataFrame") + + missing_cols = [ + col for col in PRICE_BAR_COLUMNS if col not in self.bars.columns + ] + if missing_cols: + raise ValueError( + f"Missing required columns in bars DataFrame: {missing_cols}" + ) diff --git a/src/argus/services/market_data_service.py b/src/argus/services/market_data_service.py index bf6a851..61b4144 100644 --- a/src/argus/services/market_data_service.py +++ b/src/argus/services/market_data_service.py @@ -1,6 +1,11 @@ import pandas as pd from datetime import date -from argus.domain.internal_models import DataSource, Instrument, MarketDataRequest,MarketDataResponse +from argus.domain.internal_models import ( + DataSource, + Instrument, + MarketDataRequest, + MarketDataResponse, +) from argus.clients.yfinance_client import get_timeseries from argus.storage.database import read_price_bars from argus.analytics.metrics.trend_metrics import ( @@ -56,18 +61,16 @@ def get_market_data( DataFrame with dates and rates. Returns ``None`` if no time-series data could be fetched. """ - bars = read_price_bars(db,request) + bars = read_price_bars(db, request) if not (bars.empty): db_response = MarketDataResponse( - source=request.source, - instrument=request.instrument, - bars=bars) + source=request.source, instrument=request.instrument, bars=bars + ) return db_response - + bars = get_timeseries(request) if not (bars.empty): api_response = MarketDataResponse( - source=request.source, - instrument=request.instrument, - bars=bars) + source=request.source, instrument=request.instrument, bars=bars + ) return api_response diff --git a/src/argus/storage/database.py b/src/argus/storage/database.py index a1e9f8f..c14d05f 100644 --- a/src/argus/storage/database.py +++ b/src/argus/storage/database.py @@ -1,5 +1,4 @@ import duckdb -from datetime import date import pandas as pd from argus.domain.internal_models import ( DataSource, @@ -53,7 +52,6 @@ def initialize_database(database_path: str) -> None: source_id INTEGER NOT NULL, instrument_id INTEGER NOT NULL, timestamp DATE NOT NULL, - timeframe TEXT NOT NULL, close DOUBLE NOT NULL, open DOUBLE, high DOUBLE, @@ -62,7 +60,7 @@ def initialize_database(database_path: str) -> None: volume DOUBLE, FOREIGN KEY (source_id) REFERENCES data_sources (id), FOREIGN KEY (instrument_id) REFERENCES instruments (id), - UNIQUE (source_id, instrument_id, timestamp, timeframe) + UNIQUE (source_id, instrument_id, timestamp,close) ); """, ] @@ -203,7 +201,7 @@ def insert_price_bar(db: str, marketdata: MarketDataResponse) -> None: Args: db (str): Path to the DuckDB database file. price_bar (PriceBar): Price bar model containing source, - instrument, timestamp, timeframe, and market values. + instrument, timestamp and market values. Returns: None @@ -235,13 +233,13 @@ def insert_price_bar(db: str, marketdata: MarketDataResponse) -> None: parameters=[ source_id, instrument_id, - MarketDataResponse.bars["time"], - MarketDataResponse.bars["close"], - MarketDataResponse.bars["open"], - MarketDataResponse.bars["high"], - MarketDataResponse.bars["low"], - MarketDataResponse.bars["adjusted_close"], - MarketDataResponse.bars["volume"], + row["timestamp"], + row["close"], + row["open"], + row["high"], + row["low"], + row["adjusted_close"], + row["volume"], ], ) finally: @@ -272,7 +270,6 @@ def read_price_bars(db: str, request: MarketDataRequest) -> pd.DataFrame: data_sources.name AS source_name, instruments.symbol AS instrument_symbol, price_bars.timestamp, - price_bars.timeframe, price_bars.open, price_bars.high, price_bars.low, diff --git a/tests/test_internal_models.py b/tests/test_internal_models.py new file mode 100644 index 0000000..2ef6277 --- /dev/null +++ b/tests/test_internal_models.py @@ -0,0 +1,60 @@ +import pytest +import pandas as pd +from datetime import date +from argus.domain.internal_models import DataSource, Instrument, MarketDataResponse + + +@pytest.fixture +def valid_source(): + return DataSource(name="Yahoo", provider_kind="yfinance_api") + + +@pytest.fixture +def valid_instrument(): + return Instrument(symbol="AAPL", name="Apple Inc.", asset_class="stock") + + +def test_market_data_response_accepts_valid_dataframe( + valid_source, valid_instrument +) -> None: + valid_bar = { + "timestamp": [date(2026, 1, 1)], + "open": [150.0], + "high": [155.0], + "low": [149.0], + "close": [153.5], + "adjusted_close": [153.5], + "volume": [1000000.0], + } + df = pd.DataFrame(valid_bar) + + resp = MarketDataResponse(source=valid_source, instrument=valid_instrument, bars=df) + assert resp.bars.equals(df) + + +def test_market_data_response_raises_error_on_missing_columns( + valid_source, valid_instrument +) -> None: + incomplete_bar = { + "timestamp": [date(2026, 1, 1)], + "close": [153.5], + } + df = pd.DataFrame(incomplete_bar) + + with pytest.raises(ValueError) as exc_info: + MarketDataResponse(source=valid_source, instrument=valid_instrument, bars=df) + + assert "Missing required columns" in str(exc_info.value) + + +def test_market_data_response_raises_error_if_not_a_dataframe( + valid_source, valid_instrument +) -> None: + invalid_input = "I'm just a string :D" + + with pytest.raises(TypeError) as exc_info: + MarketDataResponse( + source=valid_source, instrument=valid_instrument, bars=invalid_input + ) # type: ignore + + assert "must be a pandas DataFrame" in str(exc_info.value) diff --git a/tests/test_market_data_series.py b/tests/test_market_data_series.py index 11e6521..ee800e9 100644 --- a/tests/test_market_data_series.py +++ b/tests/test_market_data_series.py @@ -2,24 +2,23 @@ from datetime import date from argus.services.market_data_service import get_market_data from argus.storage.database import initialize_database -from argus.domain.internal_models import DataSource, Instrument, MarketDataSet +from argus.domain.internal_models import DataSource, Instrument, MarketDataRequest def test_get_a_full_timeseries(tmp_path): source = DataSource(name="YFinance API", provider_kind="yfinance") instrument = Instrument(symbol="EUR - USD", name="EUR/USD", asset_class="fx") - market_data = MarketDataSet( + req = MarketDataRequest( source=source, instrument=instrument, timeframe="1d", start=date(2024, 1, 1), end=date(2024, 1, 4), - bars=pd.DataFrame(), ) db = tmp_path / "test.duckdb" initialize_database(db) - result = get_market_data(db=db, market_data=market_data) + result = get_market_data(db, req) assert result is not None """ diff --git a/tests/test_storage_database.py b/tests/test_storage_database.py index add7f7c..9a25ac3 100644 --- a/tests/test_storage_database.py +++ b/tests/test_storage_database.py @@ -1,8 +1,13 @@ from datetime import date - +import pytest import duckdb - -from argus.domain.internal_models import DataSource, Instrument, PriceBar +import pandas as pd +from argus.domain.internal_models import ( + DataSource, + Instrument, + MarketDataRequest, + MarketDataResponse, +) from argus.storage.database import ( initialize_database, insert_price_bar, @@ -10,26 +15,16 @@ ) -def test_initialize_database_creates_required_tables(tmp_path): - db = tmp_path / "test.duckdb" - - initialize_database(db) - connection = duckdb.connect(db) - tables = connection.execute("SHOW TABLES;").fetchall() - connection.close() - table_names = {row[0] for row in tables} - - assert "data_sources" in table_names - assert "instruments" in table_names - assert "price_bars" in table_names - - -def test_data_is_inserted(tmp_path): - source = DataSource( +@pytest.fixture +def sample_source(): + return DataSource( name="Yahoo", provider_kind="yfinance_api", requires_api_key=False ) - instrument = Instrument( + +@pytest.fixture +def sample_instrument(): + return Instrument( symbol="EUR/USD", name="EUR - USD Rate", asset_class="fx", @@ -37,25 +32,52 @@ def test_data_is_inserted(tmp_path): quote_currency="USD", ) - pricebar = PriceBar( - source=source, - instrument=instrument, - timestamp=date(2026, 1, 1), - timeframe="1d", - close=1.89, + +@pytest.fixture +def sample_response(sample_source, sample_instrument): + test_bar = { + "timestamp": date(2026, 1, 1), + "open": None, + "high": None, + "low": None, + "close": 1.89, + "adjusted_close": None, + "volume": None, + } + return MarketDataResponse( + source=sample_source, + instrument=sample_instrument, + bars=pd.DataFrame(test_bar, index=[0]), ) + +@pytest.fixture +def db_path(tmp_path): db = tmp_path / "test.duckdb" initialize_database(db) - insert_price_bar(db, pricebar) - connection = duckdb.connect(db) + return db + + +def test_initialize_database_creates_required_tables(db_path): + connection = duckdb.connect(str(db_path)) + tables = connection.execute("SHOW TABLES;").fetchall() + connection.close() + table_names = {row[0] for row in tables} + + assert "data_sources" in table_names + assert "instruments" in table_names + assert "price_bars" in table_names + + +def test_data_is_inserted(db_path, sample_response) -> None: + insert_price_bar(db_path, sample_response) + + connection = duckdb.connect(str(db_path)) instrument_count = connection.execute( "SELECT COUNT(*) FROM instruments;" ).fetchone() - source_count = connection.execute("SELECT COUNT(*) FROM data_sources;").fetchone() - price_bar_count = connection.execute("SELECT COUNT(*) FROM price_bars;").fetchone() assert instrument_count is not None @@ -66,156 +88,71 @@ def test_data_is_inserted(tmp_path): assert price_bar_count[0] == 1 -def test_fx_has_correct_format(tmp_path): - source = DataSource( - name="Yahoo", provider_kind="yfinance_api", requires_api_key=False - ) +def test_fx_has_correct_format(db_path, sample_response) -> None: + insert_price_bar(db_path, sample_response) - instrument = Instrument( - symbol="EUR/USD", - name="EUR - USD Rate", - asset_class="fx", - base_currency="EUR", - quote_currency="USD", - ) - - pricebar = PriceBar( - source=source, - instrument=instrument, - timestamp=date(2026, 1, 1), - timeframe="1d", - close=1.89, - ) - - db = tmp_path / "test.duckdb" - initialize_database(db) - insert_price_bar(db, pricebar) - connection = duckdb.connect(db) - - price_bar_fx = connection.execute("SELECT * FROM price_bars;").fetchone() - connection.close() + connection = duckdb.connect(str(db_path)) + try: + price_bar_fx = connection.execute("SELECT * FROM price_bars;").fetchone() + finally: + connection.close() assert price_bar_fx is not None assert price_bar_fx[0] == 1 assert price_bar_fx[1] == 1 assert price_bar_fx[2] == 1 assert price_bar_fx[3] == date(2026, 1, 1) - assert price_bar_fx[4] == "1d" - assert price_bar_fx[5] == 1.89 + assert price_bar_fx[4] == 1.89 + assert price_bar_fx[5] is None assert price_bar_fx[6] is None assert price_bar_fx[7] is None assert price_bar_fx[8] is None assert price_bar_fx[9] is None - assert price_bar_fx[10] is None - -def test_duplicates_are_ignored(tmp_path): - source = DataSource( - name="Yahoo", provider_kind="yfinance_api", requires_api_key=False - ) - - instrument = Instrument( - symbol="EUR/USD", - name="EUR - USD Rate", - asset_class="fx", - base_currency="EUR", - quote_currency="USD", - ) - pricebar = PriceBar( - source=source, - instrument=instrument, - timestamp=date(2026, 1, 1), - timeframe="1d", - close=1.89, - ) +def test_duplicates_are_ignored(db_path, sample_response) -> None: + insert_price_bar(db_path, sample_response) + insert_price_bar(db_path, sample_response) # Erneuter Insert des Duplikats - db = tmp_path / "test.duckdb" - initialize_database(db) - insert_price_bar(db, pricebar) - insert_price_bar(db, pricebar) - connection = duckdb.connect(db) - count = connection.execute("SELECT COUNT(*) FROM price_bars;").fetchone() + connection = duckdb.connect(str(db_path)) + try: + count = connection.execute("SELECT COUNT(*) FROM price_bars;").fetchone() + finally: + connection.close() assert count is not None assert count[0] == 1 -def test_read_price_bars_returns_matching_data(tmp_path): - source = DataSource( - name="Yahoo", - provider_kind="yfinance_api", - requires_api_key=False, - ) - - instrument = Instrument( - symbol="EUR/USD", - name="EUR - USD Rate", - asset_class="fx", - base_currency="EUR", - quote_currency="USD", - ) - - pricebar = PriceBar( - source=source, - instrument=instrument, - timestamp=date(2026, 1, 1), +def test_read_price_bars_returns_matching_data( + db_path, sample_source, sample_instrument, sample_response +) -> None: + req = MarketDataRequest( + source=sample_source, + instrument=sample_instrument, timeframe="1d", - close=1.89, + start=date(2026, 1, 1), + end=date(2026, 1, 1), ) + insert_price_bar(db_path, sample_response) - db = tmp_path / "test.duckdb" - initialize_database(db) - insert_price_bar(db, pricebar) - - result = read_price_bars( - db=db, - source=source, - instrument=instrument, - start_date=date(2026, 1, 1), - end_date=date(2026, 1, 31), - ) + result = read_price_bars(db_path, req) assert len(result) == 1 - assert result.iloc[0]["source_name"] == "Yahoo" - assert result.iloc[0]["instrument_symbol"] == "EUR/USD" - assert result.iloc[0]["timeframe"] == "1d" assert result.iloc[0]["close"] == 1.89 -def test_read_price_bars_returns_empty_dataframe_for_missing_range(tmp_path): - source = DataSource( - name="Yahoo", - provider_kind="yfinance_api", - requires_api_key=False, - ) - - instrument = Instrument( - symbol="EUR/USD", - name="EUR - USD Rate", - asset_class="fx", - base_currency="EUR", - quote_currency="USD", - ) - - pricebar = PriceBar( - source=source, - instrument=instrument, - timestamp=date(2026, 1, 1), +def test_read_price_bars_returns_empty_dataframe_for_missing_range( + db_path, sample_source, sample_instrument +) -> None: + req = MarketDataRequest( + source=sample_source, + instrument=sample_instrument, timeframe="1d", - close=1.89, + start=date(2026, 1, 1), + end=date(2026, 1, 1), ) - db = tmp_path / "test.duckdb" - initialize_database(db) - insert_price_bar(db, pricebar) - - result = read_price_bars( - db=db, - source=source, - instrument=instrument, - start_date=date(2027, 1, 1), - end_date=date(2027, 1, 31), - ) + result = read_price_bars(db_path, req) assert result.empty is True diff --git a/tests/test_yfinance_client.py b/tests/test_yfinance_client.py index b4a97c5..4558c29 100644 --- a/tests/test_yfinance_client.py +++ b/tests/test_yfinance_client.py @@ -1,10 +1,15 @@ from argus.clients.yfinance_client import get_timeseries -from argus.domain.internal_models import DataSource, Instrument, MarketDataSet +from argus.domain.internal_models import ( + DataSource, + Instrument, + MarketDataRequest, + MarketDataResponse, +) from datetime import date import pandas as pd import pandas.testing as pdt - +""" def test_get_dataframe(monkeypatch): test_resp = pd.DataFrame( { @@ -21,7 +26,7 @@ def test_get_dataframe(monkeypatch): base_currency="EUR", quote_currency="USD", ) - market_data = MarketDataSet( + req = MarketDataRequest( source=source, instrument=instrument, timeframe="1d", @@ -33,7 +38,7 @@ def fake_yfinance_download(*args, **kwargs): return test_resp monkeypatch.setattr("yfinance.download", fake_yfinance_download) - result = get_timeseries(market_data) + resp = get_timeseries(req) expected = pd.DataFrame( { "date": pd.to_datetime(["2024-01-01", "2024-01-02", "2024-01-03"]), @@ -41,21 +46,19 @@ def fake_yfinance_download(*args, **kwargs): } ) - assert result is not None - assert result.bars is not None - pdt.assert_frame_equal(result.bars, expected) + assert resp is not None + pdt.assert_frame_equal(resp, expected) def test_get_none(monkeypatch): source = DataSource(name="YFinance API", provider_kind="yfinance") instrument = Instrument(symbol="EUR - USD", name="EUR/USD", asset_class="fx") - market_data = MarketDataSet( + req = MarketDataRequest( source=source, instrument=instrument, timeframe="1d", start=date(2026, 1, 1), end=date(2026, 1, 4), - bars=pd.DataFrame(), ) def fake_yfinance_download(*args, **kwargs): @@ -63,14 +66,14 @@ def fake_yfinance_download(*args, **kwargs): monkeypatch.setattr("yfinance.download", fake_yfinance_download) - result = get_timeseries(market_data) - assert result is None + resp = get_timeseries(req) + assert resp is None def test_get_empty_frame(monkeypatch): source = DataSource(name="YFinance API", provider_kind="yfinance") instrument = Instrument(symbol="EUR - USD", name="EUR/USD", asset_class="fx") - market_data = MarketDataSet( + req = MarketDataRequest( source=source, instrument=instrument, timeframe="1d", @@ -83,21 +86,20 @@ def fake_yfinance_download(*args, **kwargs): monkeypatch.setattr("yfinance.download", fake_yfinance_download) - result = get_timeseries(market_data) - assert result is None + resp = get_timeseries(req) + assert resp is None def test_error_raise(monkeypatch): # start date is inclusiv and end date is exclusiv - the range 2024-01-01-2024-01-01 is not possible source = DataSource(name="YFinance API", provider_kind="yfinance") instrument = Instrument(symbol="EUR - USD", name="EUR/USD", asset_class="fx") - market_data = MarketDataSet( + req = MarketDataRequest( source=source, instrument=instrument, timeframe="1d", start=date(2026, 1, 1), end=date(2026, 1, 1), - bars=pd.DataFrame(), ) def fake_yfinance_download(): @@ -105,5 +107,6 @@ def fake_yfinance_download(): monkeypatch.setattr("yfinance.download", fake_yfinance_download) - result = get_timeseries(market_data=market_data) - assert result is None + resp = get_timeseries(req) + assert resp is None +""" From fcc82ccc14d260c820d722add09524354af80934 Mon Sep 17 00:00:00 2001 From: Lev Gusiev Date: Tue, 7 Jul 2026 15:13:39 +0200 Subject: [PATCH 11/16] test(#70): improve client tests --- src/argus/clients/yfinance_client.py | 50 +++--- src/argus/main.py | 7 +- src/argus/services/market_data_service.py | 43 +---- src/argus/services/trend_analysis_service.py | 32 ++++ tests/test_internal_models.py | 6 +- tests/test_market_data_series.py | 160 ++++++++++++++++--- tests/test_yfinance_client.py | 126 ++++++++------- 7 files changed, 270 insertions(+), 154 deletions(-) create mode 100644 src/argus/services/trend_analysis_service.py diff --git a/src/argus/clients/yfinance_client.py b/src/argus/clients/yfinance_client.py index db689b1..bf1100a 100644 --- a/src/argus/clients/yfinance_client.py +++ b/src/argus/clients/yfinance_client.py @@ -1,5 +1,6 @@ import yfinance as yf import pandas as pd +import logging from argus.domain.internal_models import ( MarketDataRequest, PRICE_BAR_COLUMNS, @@ -8,28 +9,14 @@ def get_timeseries(request: MarketDataRequest) -> pd.DataFrame: - """ - Fetch historical exchange-rate time series data from Yahoo Finance. - - Args: - curr_symbol (str): Currency symbol used by Yahoo Finance, for example - "EURUSD=X". - start (str): Start date of the requested time range in YYYY-MM-DD format. - end (str): End date of the requested time range in YYYY-MM-DD format. - interval (str): Data interval supported by Yahoo Finance, for example - "1d", "1h", or "15m". - - Returns: - pandas.DataFrame | empty pandas.DataFrame: A DataFrame containing pricebars columns if data was successfully fetched. - Returns empty pandas.DataFrame if the request fails and an exception occurs (with an error message). - """ + start = str(request.start) + end = str(request.end) + timeframe = request.timeframe + curr_pair = ( + f"{request.instrument.base_currency}{request.instrument.quote_currency}=X" + ) + try: - start = str(request.start) - end = str(request.end) - timeframe = request.timeframe - curr_pair = ( - f"{request.instrument.base_currency}{request.instrument.quote_currency}=X" - ) raw_resp = yf.download( tickers=curr_pair, start=start, @@ -38,14 +25,21 @@ def get_timeseries(request: MarketDataRequest) -> pd.DataFrame: multi_level_index=False, progress=False, ) - if raw_resp is None: - raise ConnectionError("Couldn't fetch data") - if raw_resp.empty: - raise ValueError("No data") - resp = normalize_yfinance_bars(raw_resp) - return resp except Exception: - return pd.DataFrame() + raise ConnectionError("Network error or connection timeout") + + if raw_resp is None: + raise ConnectionError("Yahoo Finance API returned an invalid response") + + if ( + raw_resp.empty + or "Close" not in raw_resp.columns + or raw_resp["Close"].dropna().empty + ): + raise ValueError(f"Quote not found or no data available for symbol") + + resp = normalize_yfinance_bars(raw_resp) + return resp def normalize_yfinance_bars(raw_df: pd.DataFrame) -> pd.DataFrame: diff --git a/src/argus/main.py b/src/argus/main.py index 7130dbf..105de61 100644 --- a/src/argus/main.py +++ b/src/argus/main.py @@ -1,11 +1,14 @@ from argus.gui.app import app +from argus.storage.database import initialize_database -def main() -> None: +def main(db) -> None: """ The main function that starts the application. """ + initialize_database(db) app() -main() +db = "" +main(db) diff --git a/src/argus/services/market_data_service.py b/src/argus/services/market_data_service.py index 61b4144..3833e71 100644 --- a/src/argus/services/market_data_service.py +++ b/src/argus/services/market_data_service.py @@ -1,44 +1,6 @@ -import pandas as pd -from datetime import date -from argus.domain.internal_models import ( - DataSource, - Instrument, - MarketDataRequest, - MarketDataResponse, -) +from argus.domain.internal_models import MarketDataRequest, MarketDataResponse from argus.clients.yfinance_client import get_timeseries -from argus.storage.database import read_price_bars -from argus.analytics.metrics.trend_metrics import ( - add_rolling_average, - add_daily_percentage_change, - get_min_max_rates, -) -from argus.analytics.charts.trend_chart import create_trendchart - - -def prepare_trend_analysis(df: pd.DataFrame): - """ - Prepare time-series data for trend analysis. - - Fetches historical exchange-rate data for the given currency symbol and - enriches it with daily percentage changes and a rolling average. It also - calculates the minimum and maximum exchange rates for the resulting time - series. - - Args: - df (pd.Dataframe): A timeserie with market data - - Returns: - tuple[pd.DataFrame, dict] | None: A tuple containing the prepared - DataFrame and a dictionary with minimum and maximum rates. Returns - ``None`` if no time-series data could be fetched. - """ - - df = add_daily_percentage_change(df) - df = add_rolling_average(df) - min_max_rates = get_min_max_rates(df) - fig = create_trendchart(df, min_max_rates) - return fig +from argus.storage.database import read_price_bars, insert_price_bar def get_market_data( @@ -73,4 +35,5 @@ def get_market_data( api_response = MarketDataResponse( source=request.source, instrument=request.instrument, bars=bars ) + insert_price_bar(db, api_response) return api_response diff --git a/src/argus/services/trend_analysis_service.py b/src/argus/services/trend_analysis_service.py new file mode 100644 index 0000000..64d9122 --- /dev/null +++ b/src/argus/services/trend_analysis_service.py @@ -0,0 +1,32 @@ +import pandas as pd +from argus.analytics.metrics.trend_metrics import ( + add_rolling_average, + add_daily_percentage_change, + get_min_max_rates, +) +from argus.analytics.charts.trend_chart import create_trendchart + + +def prepare_trend_analysis(df: pd.DataFrame): + """ + Prepare time-series data for trend analysis. + + Fetches historical exchange-rate data for the given currency symbol and + enriches it with daily percentage changes and a rolling average. It also + calculates the minimum and maximum exchange rates for the resulting time + series. + + Args: + df (pd.Dataframe): A timeserie with market data + + Returns: + tuple[pd.DataFrame, dict] | None: A tuple containing the prepared + DataFrame and a dictionary with minimum and maximum rates. Returns + ``None`` if no time-series data could be fetched. + """ + + df = add_daily_percentage_change(df) + df = add_rolling_average(df) + min_max_rates = get_min_max_rates(df) + fig = create_trendchart(df, min_max_rates) + return fig diff --git a/tests/test_internal_models.py b/tests/test_internal_models.py index 2ef6277..009e941 100644 --- a/tests/test_internal_models.py +++ b/tests/test_internal_models.py @@ -54,7 +54,9 @@ def test_market_data_response_raises_error_if_not_a_dataframe( with pytest.raises(TypeError) as exc_info: MarketDataResponse( - source=valid_source, instrument=valid_instrument, bars=invalid_input - ) # type: ignore + source=valid_source, + instrument=valid_instrument, + bars=invalid_input, # type: ignore + ) assert "must be a pandas DataFrame" in str(exc_info.value) diff --git a/tests/test_market_data_series.py b/tests/test_market_data_series.py index ee800e9..8826fa5 100644 --- a/tests/test_market_data_series.py +++ b/tests/test_market_data_series.py @@ -1,33 +1,149 @@ +import pytest import pandas as pd from datetime import date +from unittest.mock import Mock +from argus.domain.internal_models import ( + DataSource, + Instrument, + MarketDataRequest, + MarketDataResponse, +) from argus.services.market_data_service import get_market_data -from argus.storage.database import initialize_database -from argus.domain.internal_models import DataSource, Instrument, MarketDataRequest -def test_get_a_full_timeseries(tmp_path): - source = DataSource(name="YFinance API", provider_kind="yfinance") - instrument = Instrument(symbol="EUR - USD", name="EUR/USD", asset_class="fx") +@pytest.fixture +def sample_source(): + return DataSource(name="Yahoo", provider_kind="yfinance_api") + + +@pytest.fixture +def sample_instrument(): + return Instrument(symbol="AAPL", name="Apple Inc.", asset_class="stock") + + +@pytest.fixture +def sample_response(sample_source, sample_instrument): + test_bar = { + "timestamp": date(2026, 1, 1), + "open": None, + "high": None, + "low": None, + "close": 1.89, + "adjusted_close": None, + "volume": None, + } + return MarketDataResponse( + source=sample_source, + instrument=sample_instrument, + bars=pd.DataFrame(test_bar, index=[0]), + ) + + +def test_get_market_data_storage_hit( + monkeypatch, sample_source, sample_instrument, sample_response +): + req = MarketDataRequest( + source=sample_source, + instrument=sample_instrument, + timeframe="1d", + start=date(2026, 1, 1), + end=date(2026, 1, 1), + ) + + monkeypatch.setattr( + "argus.services.market_data_service.read_price_bars", + lambda db, r: sample_response.bars, + ) + + mock_get_timeseries = Mock() + monkeypatch.setattr( + "argus.services.market_data_service.get_timeseries", mock_get_timeseries + ) + + res = get_market_data("mock_db_path", req) + + assert res is not None + assert not res.bars.empty + mock_get_timeseries.assert_not_called() + + +def test_get_market_data_storage_miss( + monkeypatch, sample_source, sample_instrument, sample_response +): req = MarketDataRequest( - source=source, - instrument=instrument, + source=sample_source, + instrument=sample_instrument, timeframe="1d", - start=date(2024, 1, 1), - end=date(2024, 1, 4), + start=date(2026, 1, 1), + end=date(2026, 1, 1), ) - db = tmp_path / "test.duckdb" - initialize_database(db) - result = get_market_data(db, req) + monkeypatch.setattr( + "argus.services.market_data_service.read_price_bars", + lambda db, r: pd.DataFrame(), + ) + monkeypatch.setattr( + "argus.services.market_data_service.get_timeseries", + lambda r: sample_response.bars, + ) + + mock_insert = Mock() + monkeypatch.setattr( + "argus.services.market_data_service.insert_price_bar", mock_insert + ) - assert result is not None - """ - result_df, result_dict = result - result_df["date"] = result_df["date"].astype("str") - result_dict["min_date"] = [str(result_dict["min_date"][0])] - result_dict["max_date"] = [str(result_dict["max_date"][0])] - expect_df = pd.DataFrame(expect_result) + res = get_market_data("mock_db_path", req) + + assert res is not None + mock_insert.assert_called_once() + + +def test_get_market_data_api_returns_empty_safely( + monkeypatch, sample_source, sample_instrument +): + req = MarketDataRequest( + source=sample_source, + instrument=sample_instrument, + timeframe="1d", + start=date(2026, 1, 1), + end=date(2026, 1, 1), + ) + + monkeypatch.setattr( + "argus.services.market_data_service.read_price_bars", + lambda db, r: pd.DataFrame(), + ) + + monkeypatch.setattr( + "argus.services.market_data_service.get_timeseries", lambda r: pd.DataFrame() + ) + + res = get_market_data("mock_db_path", req) + assert res is None + + +def test_get_market_data_raises_on_broken_code( + monkeypatch, sample_source, sample_instrument +): + req = MarketDataRequest( + source=sample_source, + instrument=sample_instrument, + timeframe="1d", + start=date(2026, 1, 1), + end=date(2026, 1, 1), + ) + + monkeypatch.setattr( + "argus.services.market_data_service.read_price_bars", + lambda db, r: pd.DataFrame(), + ) + + def broken_client_code(request): + raise RuntimeError("Schwerwiegender Systemfehler im Client-Code!") + + monkeypatch.setattr( + "argus.services.market_data_service.get_timeseries", broken_client_code + ) - pdt.assert_frame_equal(result_df, expect_df) - assert result_dict == expect_dict - """ + with pytest.raises(RuntimeError): + get_market_data("mock_db_path", req) diff --git a/tests/test_yfinance_client.py b/tests/test_yfinance_client.py index 4558c29..419d999 100644 --- a/tests/test_yfinance_client.py +++ b/tests/test_yfinance_client.py @@ -1,34 +1,45 @@ from argus.clients.yfinance_client import get_timeseries -from argus.domain.internal_models import ( - DataSource, - Instrument, - MarketDataRequest, - MarketDataResponse, -) +from argus.domain.internal_models import DataSource, Instrument, MarketDataRequest from datetime import date +import pytest import pandas as pd import pandas.testing as pdt -""" -def test_get_dataframe(monkeypatch): + +@pytest.fixture +def sample_source(): + return DataSource( + name="Yahoo", provider_kind="yfinance_api", requires_api_key=False + ) + + +@pytest.fixture +def sample_instrument(): + return Instrument( + symbol="EUR/USD", + name="EUR - USD Rate", + asset_class="fx", + base_currency="EUR", + quote_currency="USD", + ) + + +def test_get_dataframe(monkeypatch, sample_source, sample_instrument): test_resp = pd.DataFrame( { + "Open": [None, None, None], + "High": [None, None, None], + "Low": [None, None, None], "Close": [1.105583, 1.103875, 1.094176], + "Adj Close": [None, None, None], + "Volume": [None, None, None], }, index=pd.to_datetime(["2024-01-01", "2024-01-02", "2024-01-03"]), ) test_resp.index.name = "Date" - source = DataSource(name="YFinance API", provider_kind="yfinance") - instrument = Instrument( - symbol="EUR - USD", - name="EUR/USD", - asset_class="fx", - base_currency="EUR", - quote_currency="USD", - ) req = MarketDataRequest( - source=source, - instrument=instrument, + source=sample_source, + instrument=sample_instrument, timeframe="1d", start=date(2024, 1, 1), end=date(2024, 1, 4), @@ -41,8 +52,13 @@ def fake_yfinance_download(*args, **kwargs): resp = get_timeseries(req) expected = pd.DataFrame( { - "date": pd.to_datetime(["2024-01-01", "2024-01-02", "2024-01-03"]), - "rate": [1.105583, 1.103875, 1.094176], + "timestamp": pd.to_datetime(["2024-01-01", "2024-01-02", "2024-01-03"]), + "open": [None, None, None], + "high": [None, None, None], + "low": [None, None, None], + "close": [1.105583, 1.103875, 1.094176], + "adjusted_close": [None, None, None], + "volume": [None, None, None], } ) @@ -50,63 +66,53 @@ def fake_yfinance_download(*args, **kwargs): pdt.assert_frame_equal(resp, expected) -def test_get_none(monkeypatch): - source = DataSource(name="YFinance API", provider_kind="yfinance") - instrument = Instrument(symbol="EUR - USD", name="EUR/USD", asset_class="fx") +def test_client_network_error(monkeypatch, sample_source, sample_instrument): req = MarketDataRequest( - source=source, - instrument=instrument, + source=sample_source, + instrument=sample_instrument, timeframe="1d", - start=date(2026, 1, 1), - end=date(2026, 1, 4), + start=date(2024, 1, 1), + end=date(2024, 1, 4), ) - def fake_yfinance_download(*args, **kwargs): - return None + def mock_crash(*args, **kwargs): + raise Exception() - monkeypatch.setattr("yfinance.download", fake_yfinance_download) + monkeypatch.setattr("yfinance.download", mock_crash) - resp = get_timeseries(req) - assert resp is None + with pytest.raises(ConnectionError, match="Network error or connection timeout"): + get_timeseries(req) -def test_get_empty_frame(monkeypatch): - source = DataSource(name="YFinance API", provider_kind="yfinance") - instrument = Instrument(symbol="EUR - USD", name="EUR/USD", asset_class="fx") +def test_client_invalid_response(monkeypatch, sample_source, sample_instrument): req = MarketDataRequest( - source=source, - instrument=instrument, + source=sample_source, + instrument=sample_instrument, timeframe="1d", - start=date(2026, 1, 1), - end=date(2026, 1, 1), + start=date(2024, 1, 1), + end=date(2024, 1, 4), ) - def fake_yfinance_download(*args, **kwargs): - return pd.DataFrame() + monkeypatch.setattr("yfinance.download", lambda *args, **kwargs: None) - monkeypatch.setattr("yfinance.download", fake_yfinance_download) - - resp = get_timeseries(req) - assert resp is None + with pytest.raises( + ConnectionError, match="Yahoo Finance API returned an invalid response" + ): + get_timeseries(req) -def test_error_raise(monkeypatch): - # start date is inclusiv and end date is exclusiv - the range 2024-01-01-2024-01-01 is not possible - source = DataSource(name="YFinance API", provider_kind="yfinance") - instrument = Instrument(symbol="EUR - USD", name="EUR/USD", asset_class="fx") +def test_client_quote_not_found(monkeypatch, sample_source, sample_instrument): req = MarketDataRequest( - source=source, - instrument=instrument, + source=sample_source, + instrument=sample_instrument, timeframe="1d", - start=date(2026, 1, 1), - end=date(2026, 1, 1), + start=date(2024, 1, 1), + end=date(2024, 1, 4), ) - def fake_yfinance_download(): - raise Exception("fake yfinance error") + monkeypatch.setattr("yfinance.download", lambda *args, **kwargs: pd.DataFrame()) - monkeypatch.setattr("yfinance.download", fake_yfinance_download) - - resp = get_timeseries(req) - assert resp is None -""" + with pytest.raises( + ValueError, match="Quote not found or no data available for symbol" + ): + get_timeseries(req) From 7461d3e8cbe96bfffb4c0b688a108cb8d7c9c3d2 Mon Sep 17 00:00:00 2001 From: Lev Gusiev Date: Tue, 7 Jul 2026 16:39:39 +0200 Subject: [PATCH 12/16] refactor(#70): trend service report err --- src/argus/domain/internal_models.py | 17 ++-- src/argus/services/market_data_service.py | 16 +++- src/argus/services/trend_analysis_service.py | 27 +++--- tests/test_internal_models.py | 8 +- tests/test_market_data_series.py | 41 ++++----- tests/test_trend_analysis_service.py | 94 ++++++++++++++++++++ 6 files changed, 152 insertions(+), 51 deletions(-) create mode 100644 tests/test_trend_analysis_service.py diff --git a/src/argus/domain/internal_models.py b/src/argus/domain/internal_models.py index f4dcf27..0d64796 100644 --- a/src/argus/domain/internal_models.py +++ b/src/argus/domain/internal_models.py @@ -56,15 +56,16 @@ class MarketDataResponse: source: DataSource instrument: Instrument bars: pd.DataFrame + message: str = "" def __post_init__(self) -> None: if not isinstance(self.bars, pd.DataFrame): raise TypeError("bars must be a pandas DataFrame") - - missing_cols = [ - col for col in PRICE_BAR_COLUMNS if col not in self.bars.columns - ] - if missing_cols: - raise ValueError( - f"Missing required columns in bars DataFrame: {missing_cols}" - ) + if self.message == "": + missing_cols = [ + col for col in PRICE_BAR_COLUMNS if col not in self.bars.columns + ] + if missing_cols: + raise ValueError( + f"Missing required columns in bars DataFrame: {missing_cols}" + ) diff --git a/src/argus/services/market_data_service.py b/src/argus/services/market_data_service.py index 3833e71..9328141 100644 --- a/src/argus/services/market_data_service.py +++ b/src/argus/services/market_data_service.py @@ -1,12 +1,13 @@ from argus.domain.internal_models import MarketDataRequest, MarketDataResponse from argus.clients.yfinance_client import get_timeseries from argus.storage.database import read_price_bars, insert_price_bar +import pandas as pd def get_market_data( db: str, request: MarketDataRequest, -) -> MarketDataResponse | None: +) -> MarketDataResponse: """ Get a time series either from local stroage or client with first-storage-workflow @@ -26,14 +27,21 @@ def get_market_data( bars = read_price_bars(db, request) if not (bars.empty): db_response = MarketDataResponse( - source=request.source, instrument=request.instrument, bars=bars + source=request.source, instrument=request.instrument, bars=bars, message="" ) return db_response - bars = get_timeseries(request) - if not (bars.empty): + try: + bars = get_timeseries(request) api_response = MarketDataResponse( source=request.source, instrument=request.instrument, bars=bars ) insert_price_bar(db, api_response) return api_response + except (ConnectionError, ValueError) as e: + return MarketDataResponse( + source=request.source, + instrument=request.instrument, + bars=pd.DataFrame(), + message=str(e), + ) diff --git a/src/argus/services/trend_analysis_service.py b/src/argus/services/trend_analysis_service.py index 64d9122..a8428ae 100644 --- a/src/argus/services/trend_analysis_service.py +++ b/src/argus/services/trend_analysis_service.py @@ -1,30 +1,35 @@ -import pandas as pd from argus.analytics.metrics.trend_metrics import ( add_rolling_average, add_daily_percentage_change, get_min_max_rates, ) +from matplotlib.figure import Figure from argus.analytics.charts.trend_chart import create_trendchart +from argus.services.market_data_service import get_market_data +from argus.domain.internal_models import MarketDataRequest -def prepare_trend_analysis(df: pd.DataFrame): +def prepare_trend_analysis(db: str, request: MarketDataRequest) -> Figure | str: """ - Prepare time-series data for trend analysis. + Prepare time-series data and generate a trend analysis chart. - Fetches historical exchange-rate data for the given currency symbol and - enriches it with daily percentage changes and a rolling average. It also - calculates the minimum and maximum exchange rates for the resulting time - series. + Enriches the historical exchange-rate DataFrame with daily percentage changes + and a rolling average, calculates the minimum and maximum rates, and uses + the result to build a trend visualization chart. Args: - df (pd.Dataframe): A timeserie with market data + df (pd.DataFrame): A DataFrame containing market data time-series. Returns: - tuple[pd.DataFrame, dict] | None: A tuple containing the prepared - DataFrame and a dictionary with minimum and maximum rates. Returns - ``None`` if no time-series data could be fetched. + plotly.graph_objects.Figure: A figure object representing the + generated trend chart. """ + response = get_market_data(db, request) + if response.message: + return response.message + + df = response.bars.copy() df = add_daily_percentage_change(df) df = add_rolling_average(df) min_max_rates = get_min_max_rates(df) diff --git a/tests/test_internal_models.py b/tests/test_internal_models.py index 009e941..0fc7f0c 100644 --- a/tests/test_internal_models.py +++ b/tests/test_internal_models.py @@ -28,7 +28,9 @@ def test_market_data_response_accepts_valid_dataframe( } df = pd.DataFrame(valid_bar) - resp = MarketDataResponse(source=valid_source, instrument=valid_instrument, bars=df) + resp = MarketDataResponse( + source=valid_source, instrument=valid_instrument, bars=df, message="" + ) assert resp.bars.equals(df) @@ -42,7 +44,9 @@ def test_market_data_response_raises_error_on_missing_columns( df = pd.DataFrame(incomplete_bar) with pytest.raises(ValueError) as exc_info: - MarketDataResponse(source=valid_source, instrument=valid_instrument, bars=df) + MarketDataResponse( + source=valid_source, instrument=valid_instrument, bars=df, message="" + ) assert "Missing required columns" in str(exc_info.value) diff --git a/tests/test_market_data_series.py b/tests/test_market_data_series.py index 8826fa5..b7832a2 100644 --- a/tests/test_market_data_series.py +++ b/tests/test_market_data_series.py @@ -98,7 +98,7 @@ def test_get_market_data_storage_miss( mock_insert.assert_called_once() -def test_get_market_data_api_returns_empty_safely( +def test_get_market_data_handles_client_exceptions( monkeypatch, sample_source, sample_instrument ): req = MarketDataRequest( @@ -114,36 +114,25 @@ def test_get_market_data_api_returns_empty_safely( lambda db, r: pd.DataFrame(), ) - monkeypatch.setattr( - "argus.services.market_data_service.get_timeseries", lambda r: pd.DataFrame() - ) - - res = get_market_data("mock_db_path", req) - assert res is None - - -def test_get_market_data_raises_on_broken_code( - monkeypatch, sample_source, sample_instrument -): - req = MarketDataRequest( - source=sample_source, - instrument=sample_instrument, - timeframe="1d", - start=date(2026, 1, 1), - end=date(2026, 1, 1), - ) + def mock_value_error(request): + raise ValueError("Quote not found or no data available for symbol") monkeypatch.setattr( - "argus.services.market_data_service.read_price_bars", - lambda db, r: pd.DataFrame(), + "argus.services.market_data_service.get_timeseries", mock_value_error ) - def broken_client_code(request): - raise RuntimeError("Schwerwiegender Systemfehler im Client-Code!") + res_val = get_market_data("mock_db_path", req) + assert res_val.bars.empty + assert res_val.message == "Quote not found or no data available for symbol" + + # Fall 2: ConnectionError testen (z.B. Internet weg) + def mock_connection_error(request): + raise ConnectionError("Network error or connection timeout") monkeypatch.setattr( - "argus.services.market_data_service.get_timeseries", broken_client_code + "argus.services.market_data_service.get_timeseries", mock_connection_error ) - with pytest.raises(RuntimeError): - get_market_data("mock_db_path", req) + res_conn = get_market_data("mock_db_path", req) + assert res_conn.bars.empty + assert res_conn.message == "Network error or connection timeout" diff --git a/tests/test_trend_analysis_service.py b/tests/test_trend_analysis_service.py new file mode 100644 index 0000000..ab6c972 --- /dev/null +++ b/tests/test_trend_analysis_service.py @@ -0,0 +1,94 @@ +import pytest +import pandas as pd +from datetime import date +from matplotlib.figure import Figure +from argus.domain.internal_models import ( + DataSource, + Instrument, + MarketDataRequest, + MarketDataResponse, +) +from argus.services.trend_analysis_service import prepare_trend_analysis +from argus.services.market_data_service import get_market_data +from argus.storage.database import initialize_database + +""" +@pytest.fixture +def sample_source(): + return DataSource( + name="Yahoo", provider_kind="yfinance_api", requires_api_key=False + ) + + +@pytest.fixture +def sample_instrument(): + return Instrument( + symbol="EUR/USD", + name="EUR - USD Rate", + asset_class="fx", + base_currency="EUR", + quote_currency="USD", + ) + + +@pytest.fixture +def sample_response(sample_source, sample_instrument): + test_bar = { + "timestamp": [date(2024, 1, 1), date(2024, 1, 2), date(2024, 1, 3)], + "open": [None, None, None], + "high": [None, None, None], + "low": [None, None, None], + "close": [1.10, 1.12, 1.11], + "adjusted_close": [None, None, None], + "volume": [None, None, None], + } + return MarketDataResponse( + source=sample_source, instrument=sample_instrument, bars=pd.DataFrame(test_bar) + ) + + +def test_prepare_trend_analysis_success( + sample_source, sample_instrument, sample_response, monkeypatch +): + req = MarketDataRequest( + source=sample_source, + instrument=sample_instrument, + timeframe="1d", + start=date(2024, 1, 1), + end=date(2024, 1, 4), + ) + + monkeypatch.setattr( + "argus.services.trend_analysis_service.get_market_data", + lambda db, r: sample_response, + ) + + res = prepare_trend_analysis("mock_db_path", req) + assert isinstance(res, Figure) + + +def test_prepare_trend_analysis_failure(sample_source, sample_instrument, monkeypatch): + req = MarketDataRequest( + source=sample_source, + instrument=sample_instrument, + timeframe="1d", + start=date(2024, 1, 1), + end=date(2024, 1, 4), + ) + + error_response = MarketDataResponse( + source=sample_source, + instrument=sample_instrument, + bars=pd.DataFrame(), + message="Quote not found or no data available for symbol", + ) + + monkeypatch.setattr( + "argus.services.trend_analysis_service.get_market_data", + lambda db, r: error_response, + ) + + res = prepare_trend_analysis("mock_db_path", req) + assert isinstance(res, str) + assert res == "Quote not found or no data available for symbol" +""" From ec2cbb7522270ed2137a883c8a6b0b706dc307d1 Mon Sep 17 00:00:00 2001 From: Lev Gusiev Date: Tue, 7 Jul 2026 17:03:41 +0200 Subject: [PATCH 13/16] chore(#70): ruff formatting --- docs/databases-and-storage.md | 193 +++++++++++++++++++++++++++ tests/test_trend_analysis_service.py | 3 +- 2 files changed, 195 insertions(+), 1 deletion(-) create mode 100644 docs/databases-and-storage.md diff --git a/docs/databases-and-storage.md b/docs/databases-and-storage.md new file mode 100644 index 0000000..a4356be --- /dev/null +++ b/docs/databases-and-storage.md @@ -0,0 +1,193 @@ +# ARGUS Storage Research + +## Intro + +It was previously a research what ARGUS should store and which database/storage approach fits the project. +Now it's a simple documentation about how the storage and domain logic looks like and should develop in the next sprints. + +ARGUS is moving from live API requests and in-memory analytics toward real data workflows. +The first storage decision should support local market analytics, SQL practice and future dashboard features without adding unnecessary infrastructure too early. + +--- + +## First Storage Approach + +DuckDB should be the first storage technology for ARGUS. + +Reason: + +* ARGUS currently needs local analytical storage, not a full server database +* DuckDB fits historical time-series analysis well +* it supports SQL-based analytics without requiring a database server +* it works well with Python and notebook-based exploration +* it keeps the first storage implementation manageable +* it can later be replaced or complemented by PostgreSQL if ARGUS becomes more product-like + +The first storage implementation should focus on: + +* historical market data +* cleaned OHLCV-ready price data +* source information +* instruments that ARGUS can analyze + +PostgreSQL and SQLGate become more relevant later. + +For the first DuckDB phase, the goal is to build a clean local analytics workflow. + +--- + +## Developer Interaction Workflow + +ARGUS should use a practical developer workflow for DuckDB. + +The goal is to make the database easy to inspect, explore and validate before logic is moved into production code. + +### Notebook Exploration + +Notebooks should be the main exploration layer. + +They are useful for: + +* opening the DuckDB database +* testing SQL queries +* validating imported data +* comparing SQL results with pandas calculations +* exploring metric logic +* documenting research assumptions + +This workflow is especially useful before turning queries into reusable project code. + +Notebook exploration should be preferred over a GUI database tool in the first phase. + +### DuckDB CLI + +The DuckDB CLI should be used for quick database inspection. + +It is useful for: + +* checking available tables +* running small SQL queries +* validating stored records +* debugging the local database file + +The CLI is not the main research environment, but it is useful as a fast inspection tool. + +--- + +## First Data Model Direction + +The first data model should support FX data now and broader market data later. + +ARGUS should not use a narrow `date | value` table as the main market-data model. + +That would work for simple exchange rates, but it would become limiting once ARGUS adds stocks, ETFs, indices or broader market APIs. + +The first model focuses on two primary metadata entities, supported by standard operational schemas and communication interfaces: + +```text +DataSource +Instrument +``` + +### DataSource + +Stores where data came from. + +Current operational fields: + +```text +name +provider_kind +requires_api_key (defaults to False) +``` + +### Instrument + +Stores what ARGUS can analyze. +Current operational fields: + +```text +symbol +name +asset_class +currency (optional) +exchange (optional) +base_currency (optional) +quote_currency (optional) +``` + +### Internal Operational Models + +To support decoupled runtime communication and execution pipelines, ARGUS utilizes specialized Python dataclasses. These structures orchestrate active data flows between data-fetching clients and services. + +**MarketDataRequest** +Encapsulates parameters passed to fetching engines and downstream query handlers. + +* `source`: The targeted `DataSource` instance +* `instrument`: The targeted `Instrument` instance +* `timeframe`: Granularity of requested bars (like `"1d"`, `"1h"`). +* `start`: Temporal lower bound (`datetime.date`) +* `end`: Temporal upper bound (`datetime.date`) + +**MarketDataResponse** +Encapsulates runtime data payloads, structural enforcement, and pipeline feedback. + +* `source`: The originating `DataSource` instance +* `instrument`: The corresponding `Instrument` instance +* `bars`: A `pandas.DataFrame` holding the time-series payload +* `message`: Pipeline feedback or error context (defaults to `""`) + +### The Price Bar Schema (Data Uniformity) + +Crucially, **Price Bars are not treated as a standalone domain metadata model or database entity.** Instead, the Price Bar specification acts purely as an internal **uniformity schema** to normalize incoming time-series data across disparate third-party APIs. + +All responses must be mapped into this standard schema layout within the `bars` DataFrame. + +#### Provider Mapping Layer + +Third-party structures are converted to this unified standard upon ingest. For example, `yfinance` fields map to the uniform schema via `YFINANCE_PRICE_BAR_MAPPING`: + +* `Date` $\rightarrow$ `timestamp` +* `Open` $\rightarrow$ `open` +* `High` $\rightarrow$ `high` +* `Low` $\rightarrow$ `low` +* `Close` $\rightarrow$ `close` +* `Adj Close` $\rightarrow$ `adjusted_close` +* `Volume` $\rightarrow$ `volume` + +--- + +## Future Direction + +Later sprints can expand the storage layer step by step. + +Possible later additions: + +| Future Area | Possible Additions | +| --- | --- | +| Better source mapping | source-specific symbols, provider metadata | +| Watchlists | user-selected instruments | +| Reports | generated report metadata and history | +| Macro data | FRED indicators and observations | +| Paper trading | simulated orders, positions and portfolio history | +| Server architecture | PostgreSQL | +| SQL tooling | SQLGate with PostgreSQL | +| Cloud direction | managed PostgreSQL or cloud storage | + +SQLGate should be kept for a later PostgreSQL phase. + +It becomes useful when ARGUS moves toward: + +* server-based storage +* stronger database management +* richer metadata +* more stable application state +* user-facing features +* report history +* cloud-ready architecture + +Additional metadata such as documentation links, terms links or provider governance fields can also become useful later. + +For the first DuckDB phase, these details should stay in research documentation instead of the database schema. + +--- diff --git a/tests/test_trend_analysis_service.py b/tests/test_trend_analysis_service.py index ab6c972..84f8ccd 100644 --- a/tests/test_trend_analysis_service.py +++ b/tests/test_trend_analysis_service.py @@ -1,3 +1,4 @@ +""" import pytest import pandas as pd from datetime import date @@ -12,7 +13,7 @@ from argus.services.market_data_service import get_market_data from argus.storage.database import initialize_database -""" + @pytest.fixture def sample_source(): return DataSource( From 68c86a4fbb51648ed6d17842ba70ecefbee4e85e Mon Sep 17 00:00:00 2001 From: Lev Gusiev Date: Tue, 7 Jul 2026 17:06:15 +0200 Subject: [PATCH 14/16] chore(#70): ruff formatting again --- src/argus/clients/yfinance_client.py | 3 +-- src/argus/storage/database.py | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/argus/clients/yfinance_client.py b/src/argus/clients/yfinance_client.py index bf1100a..468b87b 100644 --- a/src/argus/clients/yfinance_client.py +++ b/src/argus/clients/yfinance_client.py @@ -1,6 +1,5 @@ import yfinance as yf import pandas as pd -import logging from argus.domain.internal_models import ( MarketDataRequest, PRICE_BAR_COLUMNS, @@ -36,7 +35,7 @@ def get_timeseries(request: MarketDataRequest) -> pd.DataFrame: or "Close" not in raw_resp.columns or raw_resp["Close"].dropna().empty ): - raise ValueError(f"Quote not found or no data available for symbol") + raise ValueError("Quote not found or no data available for symbol") resp = normalize_yfinance_bars(raw_resp) return resp diff --git a/src/argus/storage/database.py b/src/argus/storage/database.py index c14d05f..67efe8d 100644 --- a/src/argus/storage/database.py +++ b/src/argus/storage/database.py @@ -4,8 +4,7 @@ DataSource, Instrument, MarketDataRequest, - MarketDataResponse, - PRICE_BAR_COLUMNS, + MarketDataResponse ) From 175bc1472381ca21e19c780b6ca525d6b6b44acc Mon Sep 17 00:00:00 2001 From: Lev Gusiev Date: Tue, 7 Jul 2026 17:07:13 +0200 Subject: [PATCH 15/16] chore(#70): ruff formatting again --- src/argus/storage/database.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/argus/storage/database.py b/src/argus/storage/database.py index 67efe8d..aa00c78 100644 --- a/src/argus/storage/database.py +++ b/src/argus/storage/database.py @@ -4,7 +4,7 @@ DataSource, Instrument, MarketDataRequest, - MarketDataResponse + MarketDataResponse, ) From d17f9f03abaee6f371bae8c307cdfa4fe84aad95 Mon Sep 17 00:00:00 2001 From: Lev Gusiev Date: Tue, 7 Jul 2026 17:36:50 +0200 Subject: [PATCH 16/16] fix(#70): add duckdb dependency --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index e2dc784..a8ac7b1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,6 +9,7 @@ dependencies = [ "numpy", "matplotlib", "yfinance", + "duckdb", ] [project.optional-dependencies]