├── .all-contributorsrc ├── .coveragerc ├── .devcontainer ├── Dockerfile └── devcontainer.json ├── .dockerignore ├── .editorconfig ├── .env.sample ├── .github ├── .stale.yml ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── config.yml │ ├── feature_request.md │ └── question.md ├── PULL_REQUEST_TEMPLATE copy.md ├── dependabot.yml ├── pull_request_template.md ├── release-drafter.yml └── workflows │ ├── docs.yml │ ├── release.yml │ └── test.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .tool-versions ├── AUTHORS ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE.md ├── Makefile ├── README.md ├── SECURITY.md ├── docs ├── api.md ├── datasource.md └── index.md ├── extra └── pythainav.png ├── mkdocs.yml ├── poetry.lock ├── pyproject.toml ├── pythainav ├── __init__.py ├── api.py ├── fund.py ├── nav.py ├── sources.py └── utils │ ├── _optional.py │ └── date.py ├── setup.cfg └── tests ├── __init__.py ├── factories ├── __init__.py ├── dailynav.py ├── search_class_fund.py └── search_fund.py ├── sources ├── __init__.py ├── conftest.py ├── helpers │ ├── __init__.py │ └── sec_data.py ├── test_onde.py └── test_sec.py └── test_core.py /.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "files": [ 3 | "README.md" 4 | ], 5 | "imageSize": 100, 6 | "commit": false, 7 | "contributors": [ 8 | { 9 | "login": "CircleOnCircles", 10 | "name": "Nutchanon Ninyawee", 11 | "avatar_url": "https://avatars2.githubusercontent.com/u/8089231?v=4", 12 | "profile": "http://nutchanon.org", 13 | "contributions": [ 14 | "code", 15 | "infra" 16 | ] 17 | }, 18 | { 19 | "login": "sctnightcore", 20 | "name": "sctnightcore", 21 | "avatar_url": "https://avatars2.githubusercontent.com/u/23263315?v=4", 22 | "profile": "https://github.com/sctnightcore", 23 | "contributions": [ 24 | "code", 25 | "talk", 26 | "ideas" 27 | ] 28 | }, 29 | { 30 | "login": "angonyfox", 31 | "name": "angonyfox", 32 | "avatar_url": "https://avatars3.githubusercontent.com/u/1295513?v=4", 33 | "profile": "https://github.com/angonyfox", 34 | "contributions": [ 35 | "code", 36 | "test" 37 | ] 38 | }, 39 | { 40 | "login": "samupra", 41 | "name": "Pongpira Upra", 42 | "avatar_url": "https://avatars.githubusercontent.com/u/24209940?v=4", 43 | "profile": "https://github.com/samupra", 44 | "contributions": [ 45 | "code" 46 | ] 47 | }, 48 | { 49 | "login": "namv2012", 50 | "name": "namv2012", 51 | "avatar_url": "https://avatars.githubusercontent.com/u/50385570?v=4", 52 | "profile": "https://github.com/namv2012", 53 | "contributions": [ 54 | "code" 55 | ] 56 | } 57 | ], 58 | "contributorsPerLine": 7, 59 | "projectName": "pythainav", 60 | "projectOwner": "CircleOnCircles", 61 | "repoType": "github", 62 | "repoHost": "https://github.com", 63 | "skipCi": true 64 | } 65 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = True 3 | source = pythainav 4 | 5 | [report] 6 | exclude_lines = 7 | if self.debug: 8 | pragma: no cover 9 | raise NotImplementedError 10 | if __name__ == .__main__.: 11 | ignore_errors = True 12 | omit = 13 | tests/* 14 | .venv/* 15 | get-poetry.py 16 | -------------------------------------------------------------------------------- /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | # See here for image contents: https://github.com/microsoft/vscode-dev-containers/tree/v0.194.3/containers/python-3/.devcontainer/base.Dockerfile 2 | 3 | # [Choice] Python version: 3, 3.9, 3.8, 3.7, 3.6 4 | ARG VARIANT="3.9" 5 | FROM mcr.microsoft.com/vscode/devcontainers/python:0-${VARIANT} 6 | 7 | # [Choice] Node.js version: none, lts/*, 16, 14, 12, 10 8 | ARG NODE_VERSION="none" 9 | RUN if [ "${NODE_VERSION}" != "none" ]; then su vscode -c "umask 0002 && . /usr/local/share/nvm/nvm.sh && nvm install ${NODE_VERSION} 2>&1"; fi 10 | 11 | # [Optional] If your pip requirements rarely change, uncomment this section to add them to the image. 12 | # COPY requirements.txt /tmp/pip-tmp/ 13 | # RUN pip3 --disable-pip-version-check --no-cache-dir install -r /tmp/pip-tmp/requirements.txt \ 14 | # && rm -rf /tmp/pip-tmp 15 | 16 | # [Optional] Uncomment this section to install additional OS packages. 17 | # RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ 18 | # && apt-get -y install --no-install-recommends 19 | 20 | # [Optional] Uncomment this line to install global node packages. 21 | # RUN su vscode -c "source /usr/local/share/nvm/nvm.sh && npm install -g " 2>&1 22 | 23 | COPY pyproject.toml ./ 24 | 25 | # Install Poetry 26 | RUN curl -sSL https://raw.githubusercontent.com/python-poetry/poetry/master/get-poetry.py | POETRY_HOME=/opt/poetry python && \ 27 | cd /usr/local/bin && \ 28 | ln -s /opt/poetry/bin/poetry && \ 29 | poetry config virtualenvs.create false 30 | 31 | RUN chmod +x /usr/local/bin/poetry 32 | 33 | RUN poetry install --no-root 34 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the README at: 2 | // https://github.com/microsoft/vscode-dev-containers/tree/v0.194.3/containers/python-3 3 | { 4 | "name": "Python 3", 5 | "build": { 6 | "dockerfile": "Dockerfile", 7 | "context": "..", 8 | "args": { 9 | // Update 'VARIANT' to pick a Python version: 3, 3.6, 3.7, 3.8, 3.9 10 | "VARIANT": "3", 11 | // Options 12 | "NODE_VERSION": "none" 13 | } 14 | }, 15 | 16 | // Set *default* container specific settings.json values on container create. 17 | "settings": { 18 | "python.pythonPath": "/usr/local/bin/python", 19 | "python.languageServer": "Pylance", 20 | "python.linting.enabled": true, 21 | "python.linting.pylintEnabled": true, 22 | "python.formatting.autopep8Path": "/usr/local/py-utils/bin/autopep8", 23 | "python.formatting.blackPath": "/usr/local/py-utils/bin/black", 24 | "python.formatting.yapfPath": "/usr/local/py-utils/bin/yapf", 25 | "python.linting.banditPath": "/usr/local/py-utils/bin/bandit", 26 | "python.linting.flake8Path": "/usr/local/py-utils/bin/flake8", 27 | "python.linting.mypyPath": "/usr/local/py-utils/bin/mypy", 28 | "python.linting.pycodestylePath": "/usr/local/py-utils/bin/pycodestyle", 29 | "python.linting.pydocstylePath": "/usr/local/py-utils/bin/pydocstyle", 30 | "python.linting.pylintPath": "/usr/local/py-utils/bin/pylint" 31 | }, 32 | 33 | // Add the IDs of extensions you want installed when the container is created. 34 | "extensions": [ 35 | "ms-python.python", 36 | "ms-python.vscode-pylance" 37 | ], 38 | 39 | // Use 'forwardPorts' to make a list of ports inside the container available locally. 40 | // "forwardPorts": [], 41 | 42 | // Use 'postCreateCommand' to run commands after the container is created. 43 | // "postCreateCommand": "pip3 install --user -r requirements.txt", 44 | 45 | // Comment out connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root. 46 | "remoteUser": "vscode" 47 | } 48 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # Git 2 | .git 3 | .gitignore 4 | .github 5 | 6 | # Docker 7 | .dockerignore 8 | 9 | # IDE 10 | .idea 11 | .vscode 12 | 13 | # Byte-compiled / optimized / DLL files 14 | __pycache__/ 15 | **/__pycache__/ 16 | *.pyc 17 | *.pyo 18 | *.pyd 19 | .Python 20 | *.py[cod] 21 | *$py.class 22 | .pytest_cache/ 23 | ..mypy_cache/ 24 | 25 | # poetry 26 | .venv 27 | 28 | # C extensions 29 | *.so 30 | 31 | # Virtual environment 32 | .venv 33 | venv 34 | 35 | .DS_Store 36 | .AppleDouble 37 | .LSOverride 38 | ._* 39 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # Check http://editorconfig.org for more information 2 | # This is the main config file for this project: 3 | root = true 4 | 5 | [*] 6 | charset = utf-8 7 | end_of_line = lf 8 | insert_final_newline = true 9 | indent_style = space 10 | indent_size = 2 11 | trim_trailing_whitespace = true 12 | 13 | [*.{py, pyi}] 14 | indent_style = space 15 | indent_size = 4 16 | 17 | [Makefile] 18 | indent_style = tab 19 | 20 | [*.md] 21 | trim_trailing_whitespace = false 22 | 23 | [*.{diff,patch}] 24 | trim_trailing_whitespace = false 25 | -------------------------------------------------------------------------------- /.env.sample: -------------------------------------------------------------------------------- 1 | # use secondarty key 2 | FUND_FACTSHEET_KEY=cb439c5047834xxxxxxxxx 3 | FUND_DAILY_INFO_KEY=94129xxxxxxxxxx 4 | -------------------------------------------------------------------------------- /.github/.stale.yml: -------------------------------------------------------------------------------- 1 | # Number of days of inactivity before an issue becomes stale 2 | daysUntilStale: 60 3 | # Number of days of inactivity before a stale issue is closed 4 | daysUntilClose: 7 5 | # Issues with these labels will never be considered stale 6 | exemptLabels: 7 | - pinned 8 | - security 9 | # Label to use when marking an issue as stale 10 | staleLabel: wontfix 11 | # Comment to post when marking an issue as stale. Set to `false` to disable 12 | markComment: > 13 | This issue has been automatically marked as stale because it has not had 14 | recent activity. It will be closed if no further activity occurs. Thank you 15 | for your contributions. 16 | # Comment to post when closing a stale issue. Set to `false` to disable 17 | closeComment: false 18 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: CircleOnCircles 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: CircleOnCircles 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 13 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 🐛 Bug report 3 | about: If something isn't working 🔧 4 | title: '' 5 | labels: bug 6 | assignees: 7 | --- 8 | 9 | ## 🐛 Bug Report 10 | 11 | 12 | 13 | ## 🔬 How To Reproduce 14 | 15 | Steps to reproduce the behavior: 16 | 17 | 1. ... 18 | 19 | ### Code sample 20 | 21 | 22 | 23 | ### Environment 24 | 25 | * OS: [e.g. Linux / Windows / macOS] 26 | * Python version, get it with: 27 | 28 | ```bash 29 | python --version 30 | ``` 31 | 32 | ### Screenshots 33 | 34 | 35 | 36 | ## 📈 Expected behavior 37 | 38 | 39 | 40 | ## 📎 Additional context 41 | 42 | 43 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | # Configuration: https://help.github.com/en/github/building-a-strong-community/configuring-issue-templates-for-your-repository 2 | 3 | blank_issues_enabled: false 4 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 🚀 Feature request 3 | about: Suggest an idea for this project 🏖 4 | title: '' 5 | labels: enhancement 6 | assignees: 7 | --- 8 | 9 | ## 🚀 Feature Request 10 | 11 | 12 | 13 | ## 🔈 Motivation 14 | 15 | 16 | 17 | ## 🛰 Alternatives 18 | 19 | 20 | 21 | ## 📎 Additional context 22 | 23 | 24 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/question.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: ❓ Question 3 | about: Ask a question about this project 🎓 4 | title: '' 5 | labels: question 6 | assignees: 7 | --- 8 | 9 | ## Checklist 10 | 11 | 12 | 13 | - [ ] I've searched the project's [`issues`](https://github.com/circleoncircles/pythainav/issues?q=is%3Aissue). 14 | 15 | ## ❓ Question 16 | 17 | 18 | 19 | How can I [...]? 20 | 21 | Is it possible to [...]? 22 | 23 | ## 📎 Additional context 24 | 25 | 26 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE copy.md: -------------------------------------------------------------------------------- 1 | ## Description 2 | 3 | 4 | 5 | ## Related Issue 6 | 7 | 8 | 9 | ## Type of Change 10 | 11 | 12 | 13 | - [ ] 📚 Examples / docs / tutorials / dependencies update 14 | - [ ] 🔧 Bug fix (non-breaking change which fixes an issue) 15 | - [ ] 🥂 Improvement (non-breaking change which improves an existing feature) 16 | - [ ] 🚀 New feature (non-breaking change which adds functionality) 17 | - [ ] 💥 Breaking change (fix or feature that would cause existing functionality to change) 18 | - [ ] 🔐 Security fix 19 | 20 | ## Checklist 21 | 22 | 23 | 24 | - [ ] I've read the [`CODE_OF_CONDUCT.md`](https://github.com/circleoncircles/pythainav/blob/master/CODE_OF_CONDUCT.md) document. 25 | - [ ] I've read the [`CONTRIBUTING.md`](https://github.com/circleoncircles/pythainav/blob/master/CONTRIBUTING.md) guide. 26 | - [ ] I've updated the code style using `make codestyle`. 27 | - [ ] I've written tests for all new methods and classes that I created. 28 | - [ ] I've written the docstring in Google format for all the methods and classes that I used. 29 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # Configuration: https://dependabot.com/docs/config-file/ 2 | # Docs: https://docs.github.com/en/github/administering-a-repository/keeping-your-dependencies-updated-automatically 3 | 4 | version: 2 5 | 6 | updates: 7 | - package-ecosystem: "pip" 8 | directory: "/" 9 | schedule: 10 | interval: "daily" 11 | allow: 12 | - dependency-type: "all" 13 | commit-message: 14 | prefix: ":arrow_up:" 15 | - package-ecosystem: "github-actions" 16 | directory: "/" 17 | schedule: 18 | interval: "daily" 19 | allow: 20 | - dependency-type: "all" 21 | commit-message: 22 | prefix: ":arrow_up:" 23 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | Hi maintainers ✋, 2 | 3 | I did 4 | - [ ] lint code using `pre-commit run --all-files` 5 | 6 | Feel free to review my changes, 7 | A dev dude 😉 8 | -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | # Release drafter configuration https://github.com/release-drafter/release-drafter#configuration 2 | # Emojis were chosen to match the https://gitmoji.carloscuesta.me/ 3 | 4 | name-template: "v$NEXT_PATCH_VERSION" 5 | tag-template: "v$NEXT_PATCH_VERSION" 6 | 7 | categories: 8 | - title: ":rocket: Features" 9 | labels: [enhancement, feature] 10 | - title: ":wrench: Fixes & Refactoring" 11 | labels: [bug, refactoring, bugfix, fix] 12 | - title: ":package: Build System & CI/CD" 13 | labels: [build, ci, testing] 14 | - title: ":boom: Breaking Changes" 15 | labels: [breaking] 16 | - title: ":pencil: Documentation" 17 | labels: [documentation] 18 | - title: ":arrow_up: Dependencies updates" 19 | labels: [dependencies] 20 | 21 | template: | 22 | ## What’s Changed 23 | 24 | $CHANGES 25 | 26 | ## :busts_in_silhouette: List of contributors 27 | 28 | $CONTRIBUTORS 29 | -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: Build Docs 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | build: 10 | 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v3 15 | 16 | - name: Set up Python 3.8 17 | uses: actions/setup-python@v4 18 | with: 19 | python-version: 3.8 20 | 21 | - name: Install poetry 22 | run: make download-poetry 23 | 24 | - name: Install dependencies 25 | run: | 26 | source "$HOME/.poetry/env" 27 | poetry config virtualenvs.in-project true 28 | poetry install 29 | 30 | - name: Set up cache 31 | id: cache 32 | uses: actions/cache@v3 33 | with: 34 | path: ./site 35 | key: site-${{ hashFiles('docs/*') }}-${{ hashFiles('mkdocs.yml') }}-${{ hashFiles('**/poetry.lock') }} 36 | 37 | - name: Build Docs 38 | if: steps.cache.outputs.cache-hit != 'true' 39 | run: | 40 | source $HOME/.poetry/env 41 | poetry run mkdocs build 42 | 43 | - name: Add Custom Domain Settings 44 | run: | 45 | echo "pythainav.nutchanon.org" >> ./site/CNAME 46 | 47 | - name: Deploy Docs 48 | if: steps.cache.outputs.cache-hit != 'true' 49 | uses: peaceiris/actions-gh-pages@v3 50 | with: 51 | github_token: ${{ secrets.GITHUB_TOKEN }} 52 | publish_dir: ./site 53 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | 2 | name: Python package to PyPI 3 | on: 4 | push: 5 | tags: 6 | - 'v*.*.*' 7 | 8 | jobs: 9 | publish: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v3 13 | 14 | - name: Get tag 15 | id: tag 16 | run: echo ::set-output name=tag::${GITHUB_REF#refs/tags/} 17 | 18 | - uses: actions/setup-python@v4 19 | with: 20 | python-version: 3.9 21 | 22 | - name: Install and configure Poetry 23 | uses: snok/install-poetry@v1 24 | with: 25 | version: latest 26 | virtualenvs-create: false 27 | virtualenvs-in-project: false 28 | 29 | - name: Build project for distribution 30 | run: poetry build 31 | 32 | - name: Check Version 33 | id: check-version 34 | run: | 35 | [[ "$(poetry version --short)" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] \ 36 | || echo ::set-output name=prerelease::true 37 | 38 | - name: Create Release 39 | uses: ncipollo/release-action@v1 40 | with: 41 | artifacts: "dist/*" 42 | token: ${{ secrets.GITHUB_TOKEN }} 43 | draft: false 44 | prerelease: steps.check-version.outputs.prerelease == 'true' 45 | 46 | - name: publish to pypi 47 | env: 48 | POETRY_PYPI_TOKEN_PYPI: ${{ secrets.PYPI_TOKEN }} 49 | run: poetry publish 50 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test: 7 | runs-on: ${{ matrix.os }} 8 | continue-on-error: ${{ matrix.experimental }} 9 | strategy: 10 | fail-fast: false 11 | matrix: 12 | os: [ubuntu-latest, macos-latest, windows-latest] 13 | python-version: ['3.7', '3.8', '3.9'] 14 | experimental: [false] 15 | include: 16 | - os: ubuntu-latest 17 | python-version: "3.10-dev" 18 | experimental: true 19 | steps: 20 | - uses: actions/checkout@v3 21 | - name: Set up Python ${{ matrix.python-version }} 22 | uses: actions/setup-python@v4 23 | with: 24 | python-version: ${{ matrix.python-version }} 25 | 26 | # https://github.com/python-poetry/poetry/blob/master/.github/workflows/main.yml 27 | - name: Bootstrap poetry 28 | shell: bash 29 | run: | 30 | curl -sL https://raw.githubusercontent.com/python-poetry/poetry/master/install-poetry.py \ 31 | | python - -y 32 | 33 | - name: Update PATH 34 | if: ${{ matrix.os != 'windows-latest' }} 35 | shell: bash 36 | run: echo "$HOME/.local/bin" >> $GITHUB_PATH 37 | 38 | - name: Update Path for Windows 39 | if: ${{ matrix.os == 'windows-latest' }} 40 | shell: bash 41 | run: echo "$APPDATA\Python\Scripts" >> $GITHUB_PATH 42 | 43 | - name: Set up cache 44 | uses: actions/cache@v3 45 | with: 46 | path: .venv 47 | key: venv-${{ matrix.python-version }}-${{ hashFiles('pyproject.toml') }}-${{ hashFiles('poetry.lock') }} 48 | 49 | - name: Install dependencies 50 | run: | 51 | poetry config virtualenvs.in-project true 52 | poetry install 53 | 54 | - name: Run safety checks and style check 55 | run: | 56 | poetry run pre-commit run --all-files 57 | 58 | - name: Run tests 59 | env: 60 | FUND_FACTSHEET_KEY: ${{ secrets.FUND_FACTSHEET_KEY }} 61 | FUND_DAILY_INFO_KEY: ${{ secrets.FUND_DAILY_INFO_KEY }} 62 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 63 | run: | 64 | poetry run pytest 65 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | 53 | # Translations 54 | *.mo 55 | *.pot 56 | 57 | # Django stuff: 58 | *.log 59 | local_settings.py 60 | db.sqlite3 61 | db.sqlite3-journal 62 | 63 | # Flask stuff: 64 | instance/ 65 | .webassets-cache 66 | 67 | # Scrapy stuff: 68 | .scrapy 69 | 70 | # Sphinx documentation 71 | docs/_build/ 72 | 73 | # PyBuilder 74 | target/ 75 | 76 | # Jupyter Notebook 77 | .ipynb_checkpoints 78 | 79 | # IPython 80 | profile_default/ 81 | ipython_config.py 82 | 83 | # pyenv 84 | .python-version 85 | 86 | # pipenv 87 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 88 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 89 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 90 | # install all needed dependencies. 91 | #Pipfile.lock 92 | 93 | # celery beat schedule file 94 | celerybeat-schedule 95 | 96 | # SageMath parsed files 97 | *.sage.py 98 | 99 | # Environments 100 | .env 101 | .venv 102 | env/ 103 | venv/ 104 | ENV/ 105 | env.bak/ 106 | venv.bak/ 107 | 108 | # Spyder project settings 109 | .spyderproject 110 | .spyproject 111 | 112 | # Rope project settings 113 | .ropeproject 114 | 115 | # mkdocs documentation 116 | /site 117 | 118 | # mypy 119 | .mypy_cache/ 120 | .dmypy.json 121 | dmypy.json 122 | 123 | # Pyre type checker 124 | .pyre/ 125 | 126 | .vscode/ 127 | 128 | 129 | 130 | # Created by https://www.gitignore.io/api/macos,windows 131 | # Edit at https://www.gitignore.io/?templates=macos,windows 132 | 133 | ### macOS ### 134 | # General 135 | .DS_Store 136 | .AppleDouble 137 | .LSOverride 138 | 139 | # Icon must end with two \r 140 | Icon 141 | 142 | # Thumbnails 143 | ._* 144 | 145 | # Files that might appear in the root of a volume 146 | .DocumentRevisions-V100 147 | .fseventsd 148 | .Spotlight-V100 149 | .TemporaryItems 150 | .Trashes 151 | .VolumeIcon.icns 152 | .com.apple.timemachine.donotpresent 153 | 154 | # Directories potentially created on remote AFP share 155 | .AppleDB 156 | .AppleDesktop 157 | Network Trash Folder 158 | Temporary Items 159 | .apdisk 160 | 161 | ### Windows ### 162 | # Windows thumbnail cache files 163 | Thumbs.db 164 | Thumbs.db:encryptable 165 | ehthumbs.db 166 | ehthumbs_vista.db 167 | 168 | # Dump file 169 | *.stackdump 170 | 171 | # Folder config file 172 | [Dd]esktop.ini 173 | 174 | # Recycle Bin used on file shares 175 | $RECYCLE.BIN/ 176 | 177 | # Windows Installer files 178 | *.cab 179 | *.msi 180 | *.msix 181 | *.msm 182 | *.msp 183 | 184 | # Windows shortcuts 185 | *.lnk 186 | 187 | # End of https://www.gitignore.io/api/macos,windows 188 | 189 | poetry.lock 190 | 191 | .idea/ 192 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | default_stages: [commit, push] 2 | 3 | repos: 4 | - repo: https://github.com/pre-commit/pre-commit-hooks 5 | rev: v4.0.1 6 | hooks: 7 | - id: trailing-whitespace 8 | - id: check-yaml 9 | - id: end-of-file-fixer 10 | - id: debug-statements 11 | 12 | - repo: local 13 | hooks: 14 | - id: pyupgrade 15 | name: pyupgrade 16 | entry: poetry run pyupgrade --py37-plus 17 | types: [python] 18 | language: system 19 | 20 | - repo: local 21 | hooks: 22 | - id: isort 23 | name: isort 24 | entry: poetry run isort --settings-path pyproject.toml 25 | types: [python] 26 | language: system 27 | 28 | - repo: local 29 | hooks: 30 | - id: black 31 | name: black 32 | entry: poetry run black --config pyproject.toml 33 | types: [python] 34 | language: system 35 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | python 3.7.16 2 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | GitHub 2 | Nutchanon Ninyawee 3 | sctnightcore <23263315+sctnightcore@users.noreply.github.com> 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Release Note 2 | 3 | ## Pending 4 | ### Add 5 | - new data source Sec.or.th 6 | 7 | ## 0.1.5 - 9 March 2020 8 | 9 | ### Fixes 10 | - down pandas min version to 0.25.3 for google colab support 11 | 12 | ## 0.1.5 - 9 March 2020 13 | 14 | ### Added 15 | - get() now support date parameter 16 | - add get_all() 17 | - update docs, readme, source of data 18 | 19 | ## 0.1.3 - 25 Jan 2020 20 | 21 | ### Fixes 22 | - dataclasses error on python 3.6 23 | 24 | ## 0.1.2 - 25 Jan 2020 25 | 26 | ### Features 27 | 28 | - Change to api call = more lightweight + faster 29 | 30 | ### Fixes 31 | - Add compatibility to python 3.6 32 | 33 | ## 0.1.1 - 24 Jan 2020 34 | 35 | ### Misc. 36 | - Setup skeleton for future development 37 | - Refactor the code 38 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at me@nutchanon.org. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to contribute 2 | 3 | ## Dependencies 4 | 5 | We use `poetry` to manage the [dependencies](https://github.com/python-poetry/poetry). 6 | If you dont have `poetry` installed, you should run the command below. 7 | 8 | ```bash 9 | make download-poetry 10 | ``` 11 | 12 | To install dependencies and prepare [`pre-commit`](https://pre-commit.com/) hooks you would need to run `install` command: 13 | 14 | ```bash 15 | make install 16 | ``` 17 | 18 | To activate your `virtualenv` run `poetry shell`. 19 | 20 | ## Codestyle 21 | 22 | After you run `make install` you can execute the automatic code formatting. 23 | 24 | ```bash 25 | make codestyle 26 | ``` 27 | 28 | ### Checks 29 | 30 | Many checks are configured for this project. Command `make check-style` will run black diffs, darglint docstring style and mypy. 31 | The `make check-safety` command will look at the security of your code. 32 | 33 | You can also use `STRICT=1` flag to make the check be strict. 34 | 35 | ### Before submitting 36 | 37 | Before submitting your code please do the following steps: 38 | 39 | 1. Add any changes you want 40 | 1. Add tests for the new changes 41 | 1. Edit documentation if you have changed something significant 42 | 1. Run `make codestyle` to format your changes. 43 | 1. Run `STRICT=1 make check-style` to ensure that types and docs are correct 44 | 1. Run `STRICT=1 make check-safety` to ensure that security of your code is correct 45 | 46 | ## Other help 47 | 48 | You can contribute by spreading a word about this library. 49 | It would also be a huge contribution to write 50 | a short article on how you are using this project. 51 | You can also share your best practices with us. 52 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # 2 | The ISC License (ISC) 3 | 4 | Copyright 2020 Nutchanon Ninyawee 5 | 6 | Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. 7 | 8 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 9 | 10 | # Pandas - `pandas.utils._optional` 11 | 12 | BSD 3-Clause License 13 | 14 | Copyright (c) 2008-2011, AQR Capital Management, LLC, Lambda Foundry, Inc. and PyData Development Team 15 | All rights reserved. 16 | 17 | Copyright (c) 2011-2020, Open source contributors. 18 | 19 | Redistribution and use in source and binary forms, with or without 20 | modification, are permitted provided that the following conditions are met: 21 | 22 | * Redistributions of source code must retain the above copyright notice, this 23 | list of conditions and the following disclaimer. 24 | 25 | * Redistributions in binary form must reproduce the above copyright notice, 26 | this list of conditions and the following disclaimer in the documentation 27 | and/or other materials provided with the distribution. 28 | 29 | * Neither the name of the copyright holder nor the names of its 30 | contributors may be used to endorse or promote products derived from 31 | this software without specific prior written permission. 32 | 33 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 34 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 35 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 36 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 37 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 38 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 39 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 40 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 41 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 42 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 43 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # linting: 2 | # pre-commit run --all-files 3 | 4 | 5 | 6 | # serve/docs: 7 | # mkdocs serve 8 | 9 | # build/docs: 10 | # mkdocs build 11 | 12 | # tests: 13 | # pytest tests/ 14 | 15 | SHELL := /usr/bin/env bash 16 | 17 | IMAGE := pythainav 18 | VERSION := latest 19 | 20 | #! An ugly hack to create individual flags 21 | ifeq ($(STRICT), 1) 22 | POETRY_COMMAND_FLAG = 23 | PIP_COMMAND_FLAG = 24 | SAFETY_COMMAND_FLAG = 25 | BANDIT_COMMAND_FLAG = 26 | SECRETS_COMMAND_FLAG = 27 | BLACK_COMMAND_FLAG = 28 | DARGLINT_COMMAND_FLAG = 29 | ISORT_COMMAND_FLAG = 30 | MYPY_COMMAND_FLAG = 31 | else 32 | POETRY_COMMAND_FLAG = - 33 | PIP_COMMAND_FLAG = - 34 | SAFETY_COMMAND_FLAG = - 35 | BANDIT_COMMAND_FLAG = - 36 | SECRETS_COMMAND_FLAG = - 37 | BLACK_COMMAND_FLAG = - 38 | DARGLINT_COMMAND_FLAG = - 39 | ISORT_COMMAND_FLAG = - 40 | MYPY_COMMAND_FLAG = - 41 | endif 42 | 43 | ##! Please tell me how to use `for loops` to create variables in Makefile :( 44 | ##! If you have better idea, please PR me in https://github.com/TezRomacH/python-package-template 45 | 46 | ifeq ($(POETRY_STRICT), 1) 47 | POETRY_COMMAND_FLAG = 48 | else ifeq ($(POETRY_STRICT), 0) 49 | POETRY_COMMAND_FLAG = - 50 | endif 51 | 52 | ifeq ($(PIP_STRICT), 1) 53 | PIP_COMMAND_FLAG = 54 | else ifeq ($(PIP_STRICT), 0) 55 | PIP_COMMAND_FLAG = - 56 | endif 57 | 58 | ifeq ($(SAFETY_STRICT), 1) 59 | SAFETY_COMMAND_FLAG = 60 | else ifeq ($SAFETY_STRICT), 0) 61 | SAFETY_COMMAND_FLAG = - 62 | endif 63 | 64 | ifeq ($(BANDIT_STRICT), 1) 65 | BANDIT_COMMAND_FLAG = 66 | else ifeq ($(BANDIT_STRICT), 0) 67 | BANDIT_COMMAND_FLAG = - 68 | endif 69 | 70 | ifeq ($(SECRETS_STRICT), 1) 71 | SECRETS_COMMAND_FLAG = 72 | else ifeq ($(SECRETS_STRICT), 0) 73 | SECRETS_COMMAND_FLAG = - 74 | endif 75 | 76 | ifeq ($(BLACK_STRICT), 1) 77 | BLACK_COMMAND_FLAG = 78 | else ifeq ($(BLACK_STRICT), 0) 79 | BLACK_COMMAND_FLAG = - 80 | endif 81 | 82 | ifeq ($(DARGLINT_STRICT), 1) 83 | DARGLINT_COMMAND_FLAG = 84 | else ifeq (DARGLINT_STRICT), 0) 85 | DARGLINT_COMMAND_FLAG = - 86 | endif 87 | 88 | ifeq ($(ISORT_STRICT), 1) 89 | ISORT_COMMAND_FLAG = 90 | else ifeq ($(ISORT_STRICT), 0) 91 | ISORT_COMMAND_FLAG = - 92 | endif 93 | 94 | 95 | ifeq ($(MYPY_STRICT), 1) 96 | MYPY_COMMAND_FLAG = 97 | else ifeq ($(MYPY_STRICT), 0) 98 | MYPY_COMMAND_FLAG = - 99 | endif 100 | 101 | #! The end of the ugly part. I'm really sorry 102 | 103 | .PHONY: download-poetry 104 | download-poetry: 105 | curl -sSL https://raw.githubusercontent.com/python-poetry/poetry/master/get-poetry.py | python 106 | 107 | .PHONY: install 108 | install: 109 | poetry lock -n 110 | poetry install -n 111 | ifneq ($(NO_PRE_COMMIT), 1) 112 | poetry run pre-commit install 113 | endif 114 | 115 | .PHONY: check-safety 116 | check-safety: 117 | $(POETRY_COMMAND_FLAG)poetry check 118 | $(PIP_COMMAND_FLAG)poetry run pip check 119 | $(SAFETY_COMMAND_FLAG)poetry run safety check --full-report 120 | $(BANDIT_COMMAND_FLAG)poetry run bandit -ll -r pythainav/ 121 | 122 | .PHONY: check-style 123 | check-style: 124 | $(BLACK_COMMAND_FLAG)poetry run black --config pyproject.toml --diff --check ./ 125 | # $(DARGLINT_COMMAND_FLAG)poetry run darglint -v 2 **/*.py 126 | $(ISORT_COMMAND_FLAG)poetry run isort --settings-path pyproject.toml --check-only **/*.py 127 | # $(MYPY_COMMAND_FLAG)poetry run mypy --config-file setup.cfg pythainav tests/**/*.py 128 | 129 | .PHONY: codestyle 130 | codestyle: 131 | -poetry run pyupgrade --py37-plus **/*.py 132 | poetry run isort --settings-path pyproject.toml **/*.py 133 | poetry run black --config pyproject.toml ./ 134 | 135 | .PHONY: test 136 | test: 137 | poetry run pytest 138 | 139 | .PHONY: lint 140 | lint: test check-safety check-style 141 | 142 | # Example: make docker VERSION=latest 143 | # Example: make docker IMAGE=some_name VERSION=0.1.0 144 | .PHONY: docker 145 | docker: 146 | @echo Building docker $(IMAGE):$(VERSION) ... 147 | docker build \ 148 | -t $(IMAGE):$(VERSION) . \ 149 | -f ./docker/Dockerfile --no-cache 150 | 151 | # Example: make clean_docker VERSION=latest 152 | # Example: make clean_docker IMAGE=some_name VERSION=0.1.0 153 | .PHONY: clean_docker 154 | clean_docker: 155 | @echo Removing docker $(IMAGE):$(VERSION) ... 156 | docker rmi -f $(IMAGE):$(VERSION) 157 | 158 | .PHONY: clean_build 159 | clean: 160 | rm -rf build/ 161 | 162 | .PHONY: clean 163 | clean: clean_build clean_docker 164 | 165 | .PHONY: publish 166 | publish: lint 167 | poetry publish --build 168 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PythaiNAV: ทำให้การดึงข้อมูลกองทุนไทยเป็นเรื่องง่าย 2 | 3 | [![All Contributors](https://img.shields.io/badge/all_contributors-5-orange.svg?style=flat-square)](#contributors-) 4 | 5 | [![FOSSA Status](https://app.fossa.io/api/projects/git%2Bgithub.com%2FCircleOnCircles%2Fpythainav.svg?type=shield)](https://app.fossa.io/projects/git%2Bgithub.com%2FCircleOnCircles%2Fpythainav?ref=badge_shield) 6 | ![Tests](https://github.com/CircleOnCircles/pythainav/workflows/Tests/badge.svg?branch=master) 7 | [![codecov](https://codecov.io/gh/CircleOnCircles/pythainav/branch/develop/graph/badge.svg)](https://codecov.io/gh/CircleOnCircles/pythainav) 8 | [![Codacy Badge](https://api.codacy.com/project/badge/Grade/f868488db4ba4266a112c3432301c6b4)](https://www.codacy.com/manual/nutchanon/pythainav?utm_source=github.com&utm_medium=referral&utm_content=CircleOnCircles/pythainav&utm_campaign=Badge_Grade) 9 | 10 | 11 | 12 | ![cover image](https://github.com/CircleOnCircles/pythainav/raw/master/extra/pythainav.png) 13 | 14 | 15 | 16 | > อยากชวนทุกคนมาร่วมพัฒนา ติชม แนะนำ เพื่อให้ทุกคนเข้าถึงข้อมูลการง่ายขึ้น [เริ่มต้นได้ที่นี้](https://github.com/CircleOnCircles/pythainav/issues) หรือเข้ามา Chat ใน [Discord](https://discord.gg/jjuMcKZ) ได้เลย 😊 17 | 18 | 📖 Documentation is here. คู่มือการใช้งานอยู่ที่นี่ 19 | 20 | ## Get Started - เริ่มต้นใช้งาน 21 | 22 | ```bash 23 | $ pip install pythainav 24 | ``` 25 | 26 | ```python 27 | import pythainav as nav 28 | 29 | nav.get("KT-PRECIOUS") 30 | > Nav(value=4.2696, updated='20/01/2020', tags={'latest'}, fund='KT-PRECIOUS') 31 | 32 | nav.get("TISTECH-A", date="1 week ago") 33 | > Nav(value=12.9976, updated='14/01/2020', tags={}, fund='TISTECH-A') 34 | 35 | nav.get_all("TISTECH-A", range="MAX") 36 | > [Nav(value=12.9976, updated='21/01/2020', tags={}, fund='TISTECH-A'), Nav(value=12.9002, updated='20/01/2020', tags={}, fund='TISTECH-A'), ...] 37 | 38 | nav.get_all("KT-PRECIOUS", asDataFrame=True) 39 | > pd.DataFrame [2121 rows x 4 columns] 40 | ``` 41 | 42 | ## Source of Data - ที่มาข้อมูล 43 | 44 | ดูจาก 45 | 46 | ## Disclaimer 47 | 48 | เราไม่รับประกันความเสียหายใดๆทั้งสิ้นที่เกิดจาก แหล่งข้อมูล, library, source code,sample code, documentation, library dependencies และอื่นๆ 49 | 50 | ## Contributors ✨ 51 | 52 | Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)): 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 |

Nutchanon Ninyawee

💻 🚇

sctnightcore

💻 📢 🤔

angonyfox

💻 ⚠️

Pongpira Upra

💻

namv2012

💻
66 | 67 | 68 | 69 | 70 | 71 | 72 | This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome! 73 | 74 | 75 | ## License 76 | [![FOSSA Status](https://app.fossa.io/api/projects/git%2Bgithub.com%2FCircleOnCircles%2Fpythainav.svg?type=large)](https://app.fossa.io/projects/git%2Bgithub.com%2FCircleOnCircles%2Fpythainav?ref=badge_large) 77 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security 2 | 3 | ## 🔐 Reporting Security Issues 4 | 5 | > Do not open issues that might have security implications! 6 | > It is critical that security related issues are reported privately so we have time to address them before they become public knowledge. 7 | 8 | Vulnerabilities can be reported by emailing core members: 9 | 10 | - CircleOnCircles [me@nutchanon.org](mailto:me@nutchanon.org) 11 | 12 | Please include the requested information listed below (as much as you can provide) to help us better understand the nature and scope of the possible issue: 13 | 14 | - Type of issue (e.g. buffer overflow, SQL injection, cross-site scripting, etc.) 15 | - Full paths of source file(s) related to the manifestation of the issue 16 | - The location of the affected source code (tag/branch/commit or direct URL) 17 | - Any special configuration required to reproduce the issue 18 | - Environment (e.g. Linux / Windows / macOS) 19 | - Step-by-step instructions to reproduce the issue 20 | - Proof-of-concept or exploit code (if possible) 21 | - Impact of the issue, including how an attacker might exploit the issue 22 | 23 | This information will help us triage your report more quickly. 24 | 25 | ## Preferred Languages 26 | 27 | We prefer all communications to be in English. 28 | -------------------------------------------------------------------------------- /docs/api.md: -------------------------------------------------------------------------------- 1 | # API Reference - อ้างอิง API 2 | 3 | ::: pythainav.get 4 | :docstring: 5 | 6 | 7 | ::: pythainav.get_all 8 | :docstring: 9 | -------------------------------------------------------------------------------- /docs/datasource.md: -------------------------------------------------------------------------------- 1 | # Data Sources - แหล่งข้อมูล 2 | 3 | PythaiNAV สามารถดึงข้อมูลได้จากแหล่งข้อมูล 3 แหล่ง 4 | 5 | | แหล่งข้อมูล | parameter name | require key | อ้างอิง API | หมายเหตุ | 6 | | --------------------------------------------------------------------------------------------------------------- | :------------: | :--------------------: | :--------------------------------------------------------------------: | ------------------------------------------------------------------------------------------------------------------------------------ | 7 | | | `"finnomena"` | - | [Postman](https://www.getpostman.com/collections/b5263e2bf12b42d87061) | | 8 | | | `"sec"` | `subscription_key` | [Postman](https://www.getpostman.com/collections/7283814ab1851c58b68a) | กำลังพัฒนา | 9 | | [http://dataexchange.onde.go.th/](http://dataexchange.onde.go.th/DataSet/3C154331-4622-406E-94FB-443199D35523#) | `"onde"` | `subscription_key`\*\* | [Postman](https://www.getpostman.com/collections/acc26820945b2c6776fd) | ไม่สามารถสมัครเพื่อขอรับ `subscription_key` ได้ [*ref*](http://dataexchange.onde.go.th/DataSet/92b67f7e-023e-4ce8-b4ba-08989d44ff78) | 10 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # PythaiNAV 2 | *ทำให้การดึงข้อมูล* ***กองทุนไทย*** *เป็นเรื่องง่าย* 3 | 4 | 5 | ## Get Started - เริ่มต้นใช้งาน 6 | 7 | ติดตั้ง PythaiNAV ก่อน 8 | ```bash 9 | pip install pythainav 10 | ``` 11 | 12 | ```python 13 | import pythainav as nav 14 | 15 | nav.get("KT-PRECIOUS") 16 | > Nav(value=4.2696, updated='20/01/2020', tags={'latest'}, fund='KT-PRECIOUS') 17 | 18 | nav.get("TISTECH-A", date="1 week ago") 19 | > Nav(value=12.9976, updated='14/01/2020', tags={}, fund='TISTECH-A') 20 | 21 | nav.get_all("TISTECH-A", range="MAX") 22 | > [Nav(value=12.9976, updated='21/01/2020', tags={}, fund='TISTECH-A'), Nav(value=12.9002, updated='20/01/2020', tags={}, fund='TISTECH-A'), ...] 23 | 24 | nav.get_all("KT-PRECIOUS", asDataFrame=True) 25 | > pd.DataFrame [2121 rows x 4 columns] 26 | ``` 27 | -------------------------------------------------------------------------------- /extra/pythainav.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wasdee/pythainav/2fe371d98b6d5f814bcfd46157cdf5d7be211c32/extra/pythainav.png -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | 2 | theme: 3 | name: 'material' 4 | 5 | # HOW-TO customize 6 | 7 | repo_name: 'CircleOnCircles/pythainav' 8 | repo_url: 'https://github.com/CircleOnCircles/pythainav' 9 | 10 | markdown_extensions: 11 | - codehilite: 12 | linenums: true 13 | - toc: 14 | permalink: true 15 | - mkautodoc 16 | 17 | site_name: PythaiNAV documentation 18 | nav: 19 | - Get Started: index.md 20 | - API Reference: api.md 21 | - Data Source: datasource.md 22 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "pythainav" 3 | version = "0.3.0" 4 | description = "a Python interface to pull thai mutual fund NAV" 5 | authors = ["Nutchanon Ninyawee "] 6 | maintainers = ["Nutchanon Ninyawee "] 7 | readme = 'README.md' 8 | repository = "https://github.com/CircleOnCircles/pythainav" 9 | license = "MIT" 10 | 11 | [tool.poetry.dependencies] 12 | python = "^3.7" 13 | furl = "^2.1" 14 | dateparser = "^1.0.0" 15 | dataclasses = {version = "^0.7.0", python = "3.6"} 16 | requests = "^2.22" 17 | fuzzywuzzy = {extras = ["speedup"], version = ">=0.17,<0.19"} 18 | importlib-metadata = "^4.8.1" 19 | typing-extensions = "^3.10.0" 20 | 21 | [tool.poetry.group.dev.dependencies] 22 | pytest = "^6.1" 23 | pygments = "^2.7" 24 | pymdown-extensions = "^9.0" 25 | mkautodoc = "^0.1.0" 26 | pandas = "^1.0.1" 27 | pre-commit = "^2.8.2" 28 | pyupgrade = "^2.7.3" 29 | isort = "^5.6.4" 30 | black = "^21.9b0" 31 | mypy = "^0.910" 32 | bandit = "^1.6.2" 33 | safety = "^1.9.0" 34 | pylint = "^2.6.0" 35 | pydocstyle = "^6.1.1" 36 | python-decouple = "^3.3" 37 | httpretty = "^1.0.2" 38 | mkdocs-material = "^7.3.0" 39 | mkdocs = "^1.2.3" 40 | 41 | [tool.black] 42 | # https://github.com/psf/black 43 | line-length = 80 44 | target-version = ["py37"] 45 | 46 | [tool.isort] 47 | # https://github.com/timothycrosley/isort/ 48 | known_typing = "typing,types,typing_extensions,mypy,mypy_extensions" 49 | sections = "FUTURE,TYPING,STDLIB,THIRDPARTY,FIRSTPARTY,LOCALFOLDER" 50 | include_trailing_comma = true 51 | default_section = "FIRSTPARTY" 52 | multi_line_output = 3 53 | indent = 4 54 | force_grid_wrap = 0 55 | use_parentheses = true 56 | line_length = 80 57 | 58 | 59 | [build-system] 60 | requires = ["poetry>=0.12"] 61 | build-backend = "poetry.masonry.api" 62 | -------------------------------------------------------------------------------- /pythainav/__init__.py: -------------------------------------------------------------------------------- 1 | try: 2 | from importlib.metadata import PackageNotFoundError, version 3 | except ImportError: # pragma: no cover 4 | from importlib_metadata import PackageNotFoundError, version 5 | 6 | 7 | try: 8 | __version__ = version(__name__) 9 | except PackageNotFoundError: # pragma: no cover 10 | __version__ = "unknown" 11 | 12 | from .api import get # lgtm [py/import-own-module] 13 | from .api import get_all 14 | -------------------------------------------------------------------------------- /pythainav/api.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | try: 4 | from typing import Literal 5 | except ImportError: 6 | from typing_extensions import Literal 7 | 8 | from . import sources 9 | from .nav import Nav 10 | from .utils._optional import import_optional_dependency 11 | 12 | 13 | def get(fund_name, *, source="finnomena", date=None, **kargs) -> Nav: 14 | """ 15 | Gets the latest NAV 16 | 17 | **Parameters:** 18 | 19 | * **fund_name** - Fund name found in finnomena such as `TISTECH-A` 20 | * **source** - *(optional)* Data source for pull data. See Data Sources 21 | section in the documentation for all availiable options. 22 | * **date** - *(optional)* get latest price of a given date 23 | * **subscription_key** - *(optional)* Subscription key that required for 24 | a data source like `sec` (a.k.a) 25 | 26 | **Returns:** `Nav` 27 | 28 | Usage: 29 | ``` 30 | >>> import pythainav as nav 31 | 32 | >>> nav.get("KT-PRECIOUS") 33 | Nav(value=4.2696, updated='20/01/2020', tags={'latest'}, fund='KT-PRECIOUS') 34 | ``` 35 | """ 36 | fund_name = fund_name 37 | 38 | source2class = { 39 | "finnomena": sources.Finnomena, 40 | "sec": sources.Sec, 41 | # "onde": sources.Onde, 42 | } 43 | _source = source2class[source](**kargs) 44 | 45 | nav = _source.get(fund_name, date) 46 | 47 | return nav 48 | 49 | 50 | def get_all( 51 | fund_name, 52 | *, 53 | source="finnomena", 54 | asDataFrame=False, 55 | range: Literal[ 56 | "1D", "1W", "1M", "6M", "YTD", "1Y", "3Y", "5Y", "10Y", "MAX" 57 | ] = "1Y", 58 | **kargs, 59 | ) -> List[Nav]: 60 | """ 61 | Gets the latest NAV 62 | 63 | **Parameters:** 64 | 65 | * **fund_name** - Fund name found in finnomena such as `TISTECH-A` 66 | * **source** - *(optional)* Data source for pull data. See Data Sources 67 | section in the documentation for all availiable options. 68 | * **range** - *(optional)* time period defalut to 1 year, avaliable options are "1D", "1W", "1M", "6M", "YTD", "1Y", "3Y", "5Y", "10Y", "MAX" 69 | * **asDataFrame** - *(optional)* return pandas dataframe instead. 70 | * **subscription_key** - *(optional)* Subscription key that required for 71 | a data source like `sec` (a.k.a) 72 | 73 | 74 | **Returns:** `List[Nav]` or `pd.DataFrame` 75 | 76 | Usage: 77 | ``` 78 | >>> import pythainav as nav 79 | 80 | >>> nav.get_all("KT-PRECIOUS") 81 | [Nav(value=4.2696, updated='20/01/2020', tags={'latest'}, fund='KT-PRECIOUS'), ...] 82 | 83 | >>> nav.get_all("KT-PRECIOUS", asDataFrame=True) 84 | value updated tags fund 85 | 0 10.0001 2010-11-19 {} KT-PRECIOUS 86 | 1 10.0566 2010-11-22 {} KT-PRECIOUS 87 | 2 10.0326 2010-11-23 {} KT-PRECIOUS 88 | 3 10.0428 2010-11-24 {} KT-PRECIOUS 89 | 4 10.0253 2010-11-25 {} KT-PRECIOUS 90 | ... ... ... ... ... 91 | 2260 5.5777 2020-10-07 {} KT-PRECIOUS 92 | 2261 5.6468 2020-10-08 {} KT-PRECIOUS 93 | 2262 5.8868 2020-10-09 {} KT-PRECIOUS 94 | 2263 5.9086 2020-10-14 {} KT-PRECIOUS 95 | 2264 5.8438 2020-10-15 {} KT-PRECIOUS 96 | 97 | [2265 rows x 4 columns] 98 | ``` 99 | """ 100 | fund_name = fund_name.lower() 101 | 102 | source2class = { 103 | "finnomena": sources.Finnomena, 104 | "sec": sources.Sec, 105 | } 106 | _source = source2class[source](**kargs) 107 | 108 | navs = _source.get_range(fund_name, range=range) 109 | 110 | if asDataFrame: 111 | pd = import_optional_dependency("pandas") 112 | from dataclasses import asdict 113 | 114 | navs = pd.DataFrame([asdict(x) for x in navs]) 115 | 116 | return navs 117 | -------------------------------------------------------------------------------- /pythainav/fund.py: -------------------------------------------------------------------------------- 1 | class Fund: 2 | """a fund object""" 3 | 4 | pass 5 | -------------------------------------------------------------------------------- /pythainav/nav.py: -------------------------------------------------------------------------------- 1 | from typing import Set 2 | 3 | from dataclasses import dataclass 4 | from datetime import datetime 5 | 6 | 7 | @dataclass 8 | class Nav: 9 | """Class for store the NAV value with references""" 10 | 11 | value: float 12 | updated: datetime 13 | tags: Set[str] 14 | fund: str 15 | -------------------------------------------------------------------------------- /pythainav/sources.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | try: 4 | from typing import Literal 5 | except ImportError: 6 | from typing_extensions import Literal 7 | 8 | 9 | import datetime 10 | from abc import ABC, abstractmethod 11 | from functools import lru_cache 12 | 13 | import dateparser 14 | import requests 15 | from furl import furl 16 | 17 | from .nav import Nav 18 | from .utils.date import convert_buddhist_to_gregorian, date_range 19 | 20 | 21 | class Source(ABC): 22 | @abstractmethod 23 | def get(self, fund: str): 24 | pass 25 | 26 | @abstractmethod 27 | def list(self): 28 | pass 29 | 30 | 31 | class Finnomena(Source): 32 | base = furl("https://www.finnomena.com/fn3/api/fund/") 33 | base_v2 = furl("https://www.finnomena.com/fn3/api/fund/v2/") 34 | 35 | def __init__(self): 36 | super().__init__() 37 | 38 | def _find_earliest(self, navs: List[Nav], date: str): 39 | navs.sort(key=lambda x: x.updated, reverse=True) 40 | 41 | date = dateparser.parse(date) 42 | for nav in navs: 43 | if date >= nav.updated: 44 | return nav 45 | return None 46 | 47 | def get(self, fund: str, date: str = None): 48 | fund = fund.lower() 49 | 50 | if date: 51 | navs = self.get_range(fund) 52 | return self._find_earliest(navs, date) 53 | 54 | name2fund = self.list() 55 | 56 | url = self.base / "nav" / "latest" 57 | url.args["fund"] = name2fund[fund]["id"] 58 | 59 | # convert to str 60 | url = url.url 61 | 62 | nav = requests.get(url).json() 63 | nav = Nav( 64 | value=float(nav["value"]), 65 | updated=datetime.datetime.strptime(nav["nav_date"], "%Y-%m-%d"), 66 | tags={"latest"}, 67 | fund=fund, 68 | ) 69 | return nav 70 | 71 | # cache here should be sensible since the fund is not regulary update 72 | # TODO: change or ttl cache with timeout = [1 hour, 1 day] 73 | @lru_cache(maxsize=1024) 74 | def get_range_v1(self, fund: str, period="SI"): 75 | name2fund = self.list() 76 | 77 | url = self.base / "nav" / "q" 78 | url.args["fund"] = name2fund[fund]["id"] 79 | url.args["range"] = period 80 | 81 | # convert to str 82 | url = url.url 83 | 84 | navs_response = requests.get(url).json() 85 | 86 | navs = [] 87 | for nav_resp in navs_response: 88 | nav = Nav( 89 | value=float(nav_resp["value"]), 90 | updated=dateparser.parse(nav_resp["nav_date"]), 91 | tags={}, 92 | fund=fund, 93 | ) 94 | nav.amount = nav_resp["amount"] 95 | navs.append(nav) 96 | 97 | return navs 98 | 99 | # cache here should be sensible since the fund is not regulary update 100 | # TODO: change or ttl cache with timeout = [1 hour, 1 day] 101 | @lru_cache(maxsize=1024) 102 | def get_range( 103 | self, 104 | fund: str, 105 | range: Literal[ 106 | "1D", "1W", "1M", "6M", "YTD", "1Y", "3Y", "5Y", "10Y", "MAX" 107 | ] = "1Y", 108 | ): 109 | name2fund = self.list() 110 | 111 | # /fn3/api/fund/v2/ public/funds/F00000IT9T/nav/q 112 | url = ( 113 | self.base_v2 114 | / "public" 115 | / "funds" 116 | / name2fund[fund]["id"] 117 | / "nav" 118 | / "q" 119 | ) 120 | url.args["range"] = range 121 | 122 | # convert to str 123 | url = url.url 124 | 125 | navs_response = requests.get(url).json() 126 | 127 | if not navs_response["status"]: 128 | raise Exception(f"response to {url} is invalid") 129 | 130 | navs = [] 131 | for nav_resp in navs_response["data"]["navs"]: 132 | date = dateparser.parse(nav_resp["date"]) 133 | date = date.replace(tzinfo=None) 134 | nav = Nav( 135 | value=float(nav_resp["value"]), 136 | updated=date, 137 | tags={}, 138 | fund=fund, 139 | ) 140 | nav.amount = nav_resp["amount"] 141 | navs.append(nav) 142 | 143 | return navs 144 | 145 | # cache here should be sensible since the fund is not regulary update 146 | # TODO: change or ttl cache with timeout = [1 hour, 1 day] 147 | # TODO: New API exists /fn3/api/fund/public/filter/overview 148 | @lru_cache(maxsize=1) 149 | def list(self): 150 | url = self.base / "public" / "list" 151 | url = url.url 152 | funds = requests.get(url).json() 153 | return {fund["short_code"].lower(): fund for fund in funds} 154 | 155 | # def _list(self, ) 156 | 157 | 158 | class Sec(Source): 159 | base = furl("https://api.sec.or.th/") 160 | 161 | def __init__(self, subscription_key: dict = None): 162 | super().__init__() 163 | if subscription_key is None: 164 | # TODO: Create specific exception for this 165 | raise ValueError("Missing subscription key") 166 | if not all( 167 | [ 168 | True if key in subscription_key else False 169 | for key in ["fundfactsheet", "funddailyinfo"] 170 | ] 171 | ): 172 | raise ValueError( 173 | "subscription_key must contain 'fundfactsheet' and 'funddailyinfo' key" 174 | ) 175 | self.subscription_key = subscription_key 176 | self.headers = { 177 | "Content-Type": "application/json", 178 | } 179 | self.base_url = { 180 | "fundfactsheet": self.base.copy().add( 181 | path=["FundFactsheet", "fund"] 182 | ), 183 | "funddailyinfo": self.base.copy().add(path="FundDailyInfo"), 184 | } 185 | self.session = requests.Session() 186 | self.session.headers.update(self.headers) 187 | 188 | def __get_api_data( 189 | self, url, headers=None, subscription_key="fundfactsheet" 190 | ): 191 | if headers is None: 192 | headers = self.headers 193 | headers.update( 194 | { 195 | "Ocp-Apim-Subscription-Key": self.subscription_key[ 196 | subscription_key 197 | ] 198 | } 199 | ) 200 | response = self.session.get(url, headers=headers) 201 | # check status code 202 | response.raise_for_status() 203 | if response.status_code == 200: 204 | if response.headers["content-length"] == "0": 205 | return None 206 | return response.json() 207 | # No content 208 | elif response.status_code == 204: 209 | return None 210 | 211 | def get(self, fund: str, date: str = None): 212 | if date: 213 | if isinstance(date, str): 214 | query_date = dateparser.parse(date).date() 215 | elif isinstance(date, datetime.date): 216 | query_date = date 217 | elif isinstance(date, datetime.datetime): 218 | query_date = date.date() 219 | else: 220 | # TODO: Upgrade to smarter https://stackoverflow.com/questions/2224742/most-recent-previous-business-day-in-python 221 | # PS. should i add pandas as dep? it's so largeee. 222 | query_date = last_bus_day = datetime.date.today() 223 | wk_day = datetime.date.weekday(last_bus_day) 224 | if wk_day > 4: # if it's Saturday or Sunday 225 | last_bus_day = last_bus_day - datetime.timedelta( 226 | days=wk_day - 4 227 | ) # then make it Friday 228 | query_date = last_bus_day 229 | 230 | if not fund: 231 | raise ValueError("Must specify fund") 232 | 233 | list_fund = self.search(fund) 234 | if list_fund: 235 | fund_info = list_fund.pop(0) 236 | fund_id = fund_info["proj_id"] 237 | nav = self.get_nav_from_fund_id(fund_id, query_date) 238 | 239 | if isinstance(nav, Nav): 240 | nav.fund = fund_info["proj_abbr_name"] 241 | if query_date == datetime.date.today(): 242 | nav.tags = {"latest"} 243 | return nav 244 | else: 245 | return nav 246 | else: 247 | # Fund not found 248 | # due to query_date is a week day that also a holiday 249 | return None 250 | 251 | def get_range(self, fund: str, period="SI"): 252 | list_fund = self.search(fund) 253 | if list_fund: 254 | fund_info = list_fund.pop(0) 255 | today = datetime.date.today() 256 | if period == "SI": 257 | if fund_info["regis_date"] != "-": 258 | inception_date = dateparser.parse( 259 | fund_info["regis_date"] 260 | ).date() 261 | data_date = date_range(inception_date, today) 262 | else: 263 | date_date = [today] 264 | else: 265 | query_date = dateparser.parse(period).date() 266 | data_date = date_range(query_date, today) 267 | list_nav = [] 268 | # Remove weekend 269 | data_date = [ 270 | dd for dd in date_date if dd.isoweekday() not in [6, 7] 271 | ] 272 | for dd in data_date: 273 | nav = self.get_nav_from_fund_id(fund, dd) 274 | if nav: 275 | list_nav.append(nav) 276 | return list_nav 277 | else: 278 | # Fund not found 279 | return None 280 | 281 | @lru_cache(maxsize=1024) 282 | def get_nav_from_fund_id(self, fund_id: str, nav_date: datetime.date): 283 | url = ( 284 | self.base_url["funddailyinfo"] 285 | .copy() 286 | .add(path=[fund_id, "dailynav", nav_date.isoformat()]) 287 | .url 288 | ) 289 | headers = self.headers 290 | headers.update( 291 | { 292 | "Ocp-Apim-Subscription-Key": self.subscription_key[ 293 | "funddailyinfo" 294 | ] 295 | } 296 | ) 297 | response = self.session.get(url, headers=headers) 298 | # check status code 299 | response.raise_for_status() 300 | if response.status_code == 200: 301 | if response.headers["content-length"] == "0": 302 | raise requests.exceptions.ConnectionError("No data received") 303 | result = response.json() 304 | # Multi class fund 305 | if ( 306 | float(result["last_val"]) == 0.0 307 | and float(result["previous_val"]) == 0 308 | ): 309 | remark_en = result["amc_info"][0]["remark_en"] 310 | multi_class_nav = { 311 | k.strip(): float(v) 312 | for x in remark_en.split("/") 313 | for k, v in [x.split("=")] 314 | } 315 | list_nav = [] 316 | for fund_name, nav_val in multi_class_nav.items(): 317 | n = Nav( 318 | value=float(nav_val), 319 | updated=dateparser.parse(result["nav_date"]), 320 | tags={}, 321 | fund=fund_name, 322 | ) 323 | n.amount = result["net_asset"] 324 | list_nav.append(n) 325 | return list_nav 326 | else: 327 | n = Nav( 328 | value=float(result["last_val"]), 329 | updated=dateparser.parse(result["nav_date"]), 330 | tags={}, 331 | fund=fund_id, 332 | ) 333 | n.amount = result["net_asset"] 334 | return n 335 | # No content 336 | elif response.status_code == 204: 337 | return None 338 | 339 | @lru_cache(maxsize=1024) 340 | def list(self): 341 | return self.search_fund(name="") 342 | 343 | def search(self, name: str): 344 | result = self.search_fund(name) 345 | if result is None: 346 | result = self.search_class_fund(name) 347 | return result 348 | 349 | @lru_cache(maxsize=1024) 350 | def search_fund(self, name: str): 351 | url = self.base_url["fundfactsheet"].url 352 | headers = self.headers 353 | headers.update( 354 | { 355 | "Ocp-Apim-Subscription-Key": self.subscription_key[ 356 | "fundfactsheet" 357 | ] 358 | } 359 | ) 360 | response = self.session.post(url, headers=headers, json={"name": name}) 361 | # check status code 362 | response.raise_for_status() 363 | if response.status_code == 200: 364 | if response.headers["content-length"] == "0": 365 | raise requests.exceptions.ConnectionError("No data received") 366 | return response.json() 367 | # No content 368 | elif response.status_code == 204: 369 | return None 370 | 371 | @lru_cache(maxsize=1024) 372 | def search_class_fund(self, name: str): 373 | url = self.base_url["fundfactsheet"].copy().add(path="class_fund").url 374 | headers = self.headers 375 | headers.update( 376 | { 377 | "Ocp-Apim-Subscription-Key": self.subscription_key[ 378 | "fundfactsheet" 379 | ] 380 | } 381 | ) 382 | response = self.session.post(url, headers=headers, json={"name": name}) 383 | # check status code 384 | response.raise_for_status() 385 | if response.status_code == 200: 386 | if response.headers["content-length"] == "0": 387 | raise requests.exceptions.ConnectionError("No data received") 388 | return response.json() 389 | # No content 390 | elif response.status_code == 204: 391 | return None 392 | 393 | def list_amc(self): 394 | url = self.base_url["fundfactsheet"].copy().add(path="amc").url 395 | result = self.__get_api_data(url) 396 | return result 397 | 398 | def list_fund_under_amc(self, amc_id): 399 | if amc_id is None and not amc_id: 400 | raise ValueError("Missing amc_id") 401 | url = ( 402 | self.base_url["fundfactsheet"].copy().add(path=["amc", amc_id]).url 403 | ) 404 | result = self.__get_api_data(url) 405 | return result 406 | 407 | def get_fund_factsheet_url(self, fund_id): 408 | if not fund_id: 409 | raise ValueError("Must specify fund") 410 | url = ( 411 | self.base_url["fundfactsheet"] 412 | .copy() 413 | .add(path=[fund_id, "URLs"]) 414 | .url 415 | ) 416 | result = self.__get_api_data(url) 417 | return result 418 | 419 | def get_fund_ipo(self, fund_id): 420 | if not fund_id: 421 | raise ValueError("Must specify fund") 422 | url = ( 423 | self.base_url["fundfactsheet"].copy().add(path=[fund_id, "IPO"]).url 424 | ) 425 | result = self.__get_api_data(url) 426 | return result 427 | 428 | def get_fund_investment(self, fund_id): 429 | if not fund_id: 430 | raise ValueError("Must specify fund") 431 | url = ( 432 | self.base_url["fundfactsheet"] 433 | .copy() 434 | .add(path=[fund_id, "investment"]) 435 | .url 436 | ) 437 | result = self.__get_api_data(url) 438 | return result 439 | 440 | def get_fund_project_type(self, fund_id): 441 | if not fund_id: 442 | raise ValueError("Must specify fund") 443 | url = ( 444 | self.base_url["fundfactsheet"] 445 | .copy() 446 | .add(path=[fund_id, "project_type"]) 447 | .url 448 | ) 449 | result = self.__get_api_data(url) 450 | return result 451 | 452 | def get_fund_policy(self, fund_id): 453 | if not fund_id: 454 | raise ValueError("Must specify fund") 455 | url = ( 456 | self.base_url["fundfactsheet"] 457 | .copy() 458 | .add(path=[fund_id, "policy"]) 459 | .url 460 | ) 461 | result = self.__get_api_data(url) 462 | if "investment_policy_desc" in result and len( 463 | result["investment_policy_desc"] 464 | ): 465 | result["investment_policy_desc"] = base64.b64decode( 466 | result["investment_policy_desc"] 467 | ).decode("utf-8") 468 | return result 469 | 470 | def get_fund_specification(self, fund_id): 471 | if not fund_id: 472 | raise ValueError("Must specify fund") 473 | url = ( 474 | self.base_url["fundfactsheet"] 475 | .copy() 476 | .add(path=[fund_id, "specification"]) 477 | .url 478 | ) 479 | result = self.__get_api_data(url) 480 | return result 481 | 482 | def get_fund_feeder_fund(self, fund_id): 483 | if not fund_id: 484 | raise ValueError("Must specify fund") 485 | url = ( 486 | self.base_url["fundfactsheet"] 487 | .copy() 488 | .add(path=[fund_id, "feeder_fund"]) 489 | .url 490 | ) 491 | result = self.__get_api_data(url) 492 | return result 493 | 494 | def get_fund_redemption(self, fund_id): 495 | if not fund_id: 496 | raise ValueError("Must specify fund") 497 | url = ( 498 | self.base_url["fundfactsheet"] 499 | .copy() 500 | .add(path=[fund_id, "redemption"]) 501 | .url 502 | ) 503 | result = self.__get_api_data(url) 504 | return result 505 | 506 | def get_fund_suitability(self, fund_id): 507 | if not fund_id: 508 | raise ValueError("Must specify fund") 509 | url = ( 510 | self.base_url["fundfactsheet"] 511 | .copy() 512 | .add(path=[fund_id, "suitability"]) 513 | .url 514 | ) 515 | result = self.__get_api_data(url) 516 | for key in [ 517 | "fund_suitable_desc", 518 | "fund_not_suitable_desc", 519 | "important_notice", 520 | "risk_spectrum_desc", 521 | ]: 522 | if key in result: 523 | result[key] = base64.b64decode(result[key]).decode("utf-8") 524 | return result 525 | 526 | def get_fund_risk(self, fund_id): 527 | if not fund_id: 528 | raise ValueError("Must specify fund") 529 | url = ( 530 | self.base_url["fundfactsheet"] 531 | .copy() 532 | .add(path=[fund_id, "risk"]) 533 | .url 534 | ) 535 | result = self.__get_api_data(url) 536 | return result 537 | 538 | def get_fund_asset(self, fund_id): 539 | if not fund_id: 540 | raise ValueError("Must specify fund") 541 | url = ( 542 | self.base_url["fundfactsheet"] 543 | .copy() 544 | .add(path=[fund_id, "asset"]) 545 | .url 546 | ) 547 | result = self.__get_api_data(url) 548 | return result 549 | 550 | def get_fund_turnover_ratio(self, fund_id): 551 | if not fund_id: 552 | raise ValueError("Must specify fund") 553 | url = ( 554 | self.base_url["fundfactsheet"] 555 | .copy() 556 | .add(path=[fund_id, "turnover_ratio"]) 557 | .url 558 | ) 559 | result = self.__get_api_data(url) 560 | return result 561 | 562 | def get_fund_return(self, fund_id): 563 | if not fund_id: 564 | raise ValueError("Must specify fund") 565 | url = ( 566 | self.base_url["fundfactsheet"] 567 | .copy() 568 | .add(path=[fund_id, "return"]) 569 | .url 570 | ) 571 | result = self.__get_api_data(url) 572 | return result 573 | 574 | def get_fund_buy_and_hold(self, fund_id): 575 | if not fund_id: 576 | raise ValueError("Must specify fund") 577 | url = ( 578 | self.base_url["fundfactsheet"] 579 | .copy() 580 | .add(path=[fund_id, "buy_and_hold"]) 581 | .url 582 | ) 583 | result = self.__get_api_data(url) 584 | return result 585 | 586 | def get_fund_benchmark(self, fund_id): 587 | if not fund_id: 588 | raise ValueError("Must specify fund") 589 | url = ( 590 | self.base_url["fundfactsheet"] 591 | .copy() 592 | .add(path=[fund_id, "benchmark"]) 593 | .url 594 | ) 595 | result = self.__get_api_data(url) 596 | return result 597 | 598 | def get_fund_compare(self, fund_id): 599 | if not fund_id: 600 | raise ValueError("Must specify fund") 601 | url = ( 602 | self.base_url["fundfactsheet"] 603 | .copy() 604 | .add(path=[fund_id, "fund_compare"]) 605 | .url 606 | ) 607 | result = self.__get_api_data(url) 608 | return result 609 | 610 | def get_class_fund(self, fund_id): 611 | if not fund_id: 612 | raise ValueError("Must specify fund") 613 | url = ( 614 | self.base_url["fundfactsheet"] 615 | .copy() 616 | .add(path=[fund_id, "class_fund"]) 617 | .url 618 | ) 619 | result = self.__get_api_data(url) 620 | return result 621 | 622 | def get_fund_performance(self, fund_id): 623 | if not fund_id: 624 | raise ValueError("Must specify fund") 625 | url = ( 626 | self.base_url["fundfactsheet"] 627 | .copy() 628 | .add(path=[fund_id, "performance"]) 629 | .url 630 | ) 631 | result = self.__get_api_data(url) 632 | return result 633 | 634 | def get_fund_5yearlost(self, fund_id): 635 | if not fund_id: 636 | raise ValueError("Must specify fund") 637 | url = ( 638 | self.base_url["fundfactsheet"] 639 | .copy() 640 | .add(path=[fund_id, "5YearLost"]) 641 | .url 642 | ) 643 | result = self.__get_api_data(url) 644 | return result 645 | 646 | def get_fund_dividend_policy(self, fund_id): 647 | if not fund_id: 648 | raise ValueError("Must specify fund") 649 | url = ( 650 | self.base_url["fundfactsheet"] 651 | .copy() 652 | .add(path=[fund_id, "dividend"]) 653 | .url 654 | ) 655 | result = self.__get_api_data(url) 656 | for record in result: 657 | if "dividend_details" in record: 658 | for dividend_record in record["dividend_details"]: 659 | if dividend_record["book_closing_date"] != "-": 660 | dividend_record["book_closing_date"] = ( 661 | convert_buddhist_to_gregorian( 662 | dividend_record["book_closing_date"] 663 | ) 664 | .date() 665 | .isoformat() 666 | ) 667 | if dividend_record["payment_date"] != "-": 668 | dividend_record["payment_date"] = ( 669 | convert_buddhist_to_gregorian( 670 | dividend_record["payment_date"] 671 | ) 672 | .date() 673 | .isoformat() 674 | ) 675 | return result 676 | 677 | def get_fund_fee(self, fund_id): 678 | if not fund_id: 679 | raise ValueError("Must specify fund") 680 | url = ( 681 | self.base_url["fundfactsheet"].copy().add(path=[fund_id, "fee"]).url 682 | ) 683 | result = self.__get_api_data(url) 684 | return result 685 | 686 | def get_fund_involveparty(self, fund_id): 687 | if not fund_id: 688 | raise ValueError("Must specify fund") 689 | url = ( 690 | self.base_url["fundfactsheet"] 691 | .copy() 692 | .add(path=[fund_id, "InvolveParty"]) 693 | .url 694 | ) 695 | result = self.__get_api_data(url) 696 | for record in result: 697 | if "effective_date" in record and record["effective_date"] != "-": 698 | record["effective_date"] = ( 699 | convert_buddhist_to_gregorian(record["effective_date"]) 700 | .date() 701 | .isoformat() 702 | ) 703 | return result 704 | 705 | def get_fund_port(self, fund_id, period): 706 | if not fund_id: 707 | raise ValueError("Must specify fund") 708 | url = ( 709 | self.base_url["fundfactsheet"] 710 | .copy() 711 | .add(path=[fund_id, "FundPort", period]) 712 | .url 713 | ) 714 | result = self.__get_api_data(url) 715 | return result 716 | 717 | def get_fund_full_port(self, fund_id, period): 718 | if not fund_id: 719 | raise ValueError("Must specify fund") 720 | url = ( 721 | self.base_url["fundfactsheet"] 722 | .copy() 723 | .add(path=[fund_id, "FundPort", period]) 724 | .url 725 | ) 726 | result = self.__get_api_data(url) 727 | return result 728 | 729 | def get_fund_top5_port(self, fund_id, period): 730 | if not fund_id: 731 | raise ValueError("Must specify fund") 732 | url = ( 733 | self.base_url["fundfactsheet"] 734 | .copy() 735 | .add(path=[fund_id, "FundTop5", period]) 736 | .url 737 | ) 738 | result = self.__get_api_data(url) 739 | return result 740 | 741 | def get_fund_dividend(self, fund_id): 742 | if not fund_id: 743 | raise ValueError("Must specify fund") 744 | url = ( 745 | self.base_url["funddailyinfo"] 746 | .copy() 747 | .add(path=[fund_id, "dividend"]) 748 | .url 749 | ) 750 | result = self.__get_api_data(url, subscription_key="funddailyinfo") 751 | return result 752 | 753 | def get_amc_submit_dailyinfo(self): 754 | url = self.base_url["funddailyinfo"].copy().add(path=["amc"]).url 755 | result = self.__get_api_data(url, subscription_key="funddailyinfo") 756 | return result 757 | -------------------------------------------------------------------------------- /pythainav/utils/_optional.py: -------------------------------------------------------------------------------- 1 | # Borrow from 2 | # https://github.com/pandas-dev/pandas/blob/774498b667e82bf6e826da44135a3ef99590ead6/pandas/compat/_optional.py 3 | 4 | import types 5 | 6 | import distutils.version 7 | import importlib 8 | import warnings 9 | 10 | VERSIONS = {"pandas": "0.25.3"} 11 | 12 | 13 | def _get_version(module: types.ModuleType) -> str: 14 | version = getattr(module, "__version__", None) 15 | if version is None: 16 | # xlrd uses a capitalized attribute name 17 | version = getattr(module, "__VERSION__", None) 18 | 19 | if version is None: 20 | raise ImportError(f"Can't determine version for {module.__name__}") 21 | return version 22 | 23 | 24 | def import_optional_dependency( 25 | name: str, 26 | extra: str = "", 27 | raise_on_missing: bool = True, 28 | on_version: str = "raise", 29 | ): 30 | """ 31 | Import an optional dependency. 32 | By default, if a dependency is missing an ImportError with a nice 33 | message will be raised. If a dependency is present, but too old, 34 | we raise. 35 | Parameters 36 | ---------- 37 | name : str 38 | The module name. This should be top-level only, so that the 39 | version may be checked. 40 | extra : str 41 | Additional text to include in the ImportError message. 42 | raise_on_missing : bool, default True 43 | Whether to raise if the optional dependency is not found. 44 | When False and the module is not present, None is returned. 45 | on_version : str {'raise', 'warn'} 46 | What to do when a dependency's version is too old. 47 | * raise : Raise an ImportError 48 | * warn : Warn that the version is too old. Returns None 49 | * ignore: Return the module, even if the version is too old. 50 | It's expected that users validate the version locally when 51 | using ``on_version="ignore"`` (see. ``io/html.py``) 52 | Returns 53 | ------- 54 | maybe_module : Optional[ModuleType] 55 | The imported module, when found and the version is correct. 56 | None is returned when the package is not found and `raise_on_missing` 57 | is False, or when the package's version is too old and `on_version` 58 | is ``'warn'``. 59 | """ 60 | msg = ( 61 | f"Missing optional dependency '{name}'. {extra} " 62 | f"Use pip or conda to install {name}." 63 | ) 64 | try: 65 | module = importlib.import_module(name) 66 | except ImportError: 67 | if raise_on_missing: 68 | raise ImportError(msg) from None 69 | else: 70 | return None 71 | 72 | minimum_version = VERSIONS.get(name) 73 | if minimum_version: 74 | version = _get_version(module) 75 | if distutils.version.LooseVersion(version) < minimum_version: 76 | assert on_version in {"warn", "raise", "ignore"} 77 | msg = ( 78 | f"Pandas requires version '{minimum_version}' or newer of '{name}' " 79 | f"(version '{version}' currently installed)." 80 | ) 81 | if on_version == "warn": 82 | warnings.warn(msg, UserWarning) 83 | return None 84 | elif on_version == "raise": 85 | raise ImportError(msg) 86 | 87 | return module 88 | -------------------------------------------------------------------------------- /pythainav/utils/date.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | import dateparser 4 | 5 | 6 | def date_range(start_date, end_date): 7 | return [ 8 | start_date + datetime.timedelta(days=x) 9 | for x in range((end_date - start_date).days + 1) 10 | ] 11 | 12 | 13 | def convert_buddhist_to_gregorian(input_date): 14 | if isinstance(input_date, str): 15 | input_date = dateparser.parse(input_date) 16 | year = input_date.year - 543 17 | input_date = input_date.replace(year=year) 18 | return input_date 19 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | # All configuration for plugins and other utils is defined here. 2 | # Read more about `setup.cfg`: 3 | # https://docs.python.org/3/distutils/configfile.html 4 | 5 | # [darglint] 6 | # # darglint configuration: 7 | # # https://github.com/terrencepreilly/darglint 8 | # strictness = long 9 | # docstring_style = google 10 | 11 | [mypy] 12 | # mypy configurations: http://bit.ly/2zEl9WI 13 | python_version = 3.7 14 | pretty = True 15 | allow_redefinition = False 16 | check_untyped_defs = True 17 | disallow_any_generics = True 18 | disallow_incomplete_defs = True 19 | ignore_missing_imports = True 20 | implicit_reexport = False 21 | strict_optional = True 22 | strict_equality = True 23 | no_implicit_optional = True 24 | warn_no_return = True 25 | warn_unused_ignores = True 26 | warn_redundant_casts = True 27 | warn_unused_configs = True 28 | warn_return_any = True 29 | warn_unreachable = True 30 | show_error_codes = True 31 | show_column_numbers = True 32 | show_error_context = True 33 | 34 | # plugins = pydantic.mypy, sqlmypy 35 | 36 | # [pydantic-mypy] 37 | # init_typed = True 38 | # warn_untyped_fields = True 39 | 40 | [tool:pytest] 41 | # Directories that are not visited by pytest collector: 42 | norecursedirs = *.egg .eggs dist build docs .tox .git __pycache__ 43 | doctest_optionflags = NUMBER NORMALIZE_WHITESPACE IGNORE_EXCEPTION_DETAIL 44 | 45 | # Extra options: 46 | addopts = 47 | --strict 48 | --tb=short 49 | # --doctest-modules 50 | --doctest-continue-on-failure 51 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wasdee/pythainav/2fe371d98b6d5f814bcfd46157cdf5d7be211c32/tests/__init__.py -------------------------------------------------------------------------------- /tests/factories/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wasdee/pythainav/2fe371d98b6d5f814bcfd46157cdf5d7be211c32/tests/factories/__init__.py -------------------------------------------------------------------------------- /tests/factories/dailynav.py: -------------------------------------------------------------------------------- 1 | # import factory 2 | # 3 | # 4 | # class AMCInfoFactory(factory.Factory): 5 | # class Meta: 6 | # model = dict 7 | # 8 | # class Params: 9 | # class_fund = factory.Trait( 10 | # sell_price=0.0, 11 | # buy_price=0.0, 12 | # sell_swap_price=0.0, 13 | # buy_swap_price=0.0, 14 | # remark_th="กองทุน A= 10.3393/กองทุน D= 10.3516/กองทุน R= 10.3055", 15 | # remark_en="Fund-A= 10.3393/Fund-D= 10.3516/Fund-R= 10.3055", 16 | # ) 17 | # 18 | # unique_id = factory.Sequence(lambda n: "C{:0>10}".format(n)) 19 | # sell_price = 10.9585 20 | # buy_price = 10.7226 21 | # sell_swap_price = 10.9585 22 | # buy_swap_price = 10.7226 23 | # remark_th = " " 24 | # remark_en = " " 25 | # 26 | # 27 | # class DailyNavFactory(factory.Factory): 28 | # class Meta: 29 | # model = dict 30 | # 31 | # class Params: 32 | # class_fund = factory.Trait( 33 | # last_val=0.0, 34 | # previous_val=0.00, 35 | # # amc_info = factory.List([factory.SubFactory(AMCInfoFactory, factory.SelfAttribute("..class_fund"))]) 36 | # ) 37 | # 38 | # last_upd_date = "2020-01-01T01:02:03" 39 | # nav_date = "2020-01-01" 40 | # net_asset = 1234567890 41 | # last_val = 10.7226 42 | # previous_val = 10.5931 43 | # amc_info = factory.List([factory.SubFactory(AMCInfoFactory)]) 44 | -------------------------------------------------------------------------------- /tests/factories/search_class_fund.py: -------------------------------------------------------------------------------- 1 | # import itertools 2 | # 3 | # import factory 4 | # 5 | # 6 | # class_type = ["A", "D", "R"] 7 | # class_type_name = [ 8 | # "ชนิดสะสมมูลค่า", 9 | # "ชนิดจ่ายเงินปันผล", 10 | # "ชนิดขายคืนหน่วยลงทุนอัตโนมัติ", 11 | # ] 12 | # 13 | # 14 | # def get_class_name(o): 15 | # return class_type_name[class_type.index(o.class_abbr_name)] 16 | # 17 | # 18 | # class SearchClassFundFactory(factory.Factory): 19 | # class Meta: 20 | # model = dict 21 | # 22 | # proj_id = factory.Sequence(lambda n: "M{:0>4}_25{:0>2}".format(n, n)) 23 | # last_upd_date = "2020-01-01T01:02:03" 24 | # proj_abbr_name = factory.Sequence(lambda n: "FUND-{:0>2}".format(n)) 25 | # class_abbr_name = factory.Iterator(itertools.cycle(class_type)) 26 | # class_name = factory.LazyAttribute(get_class_name) 27 | # class_additional_desc = factory.Faker("text") 28 | -------------------------------------------------------------------------------- /tests/factories/search_fund.py: -------------------------------------------------------------------------------- 1 | # import random 2 | # 3 | # import factory 4 | # 5 | # 6 | # def get_fund_status(): 7 | # choices = ["RG", "EX", "SE", "CA", "LI"] 8 | # return random.choices(choices, weights=[93, 1, 4, 1, 1], k=1).pop() 9 | # 10 | # 11 | # def get_invest_country_flag(): 12 | # choices = ["1", "2", "3", "4"] 13 | # return random.choice(choices) 14 | # 15 | # 16 | # class SearchFundFactory(factory.Factory): 17 | # class Meta: 18 | # model = dict 19 | # 20 | # proj_id = factory.Sequence(lambda n: "M{:0>4}_25{:0>2}".format(n, n)) 21 | # regis_id = factory.Sequence( 22 | # lambda n: "{:0>3}_25{:0>2}".format(n, n) if n % 5 == 0 else "-" 23 | # ) 24 | # regis_date = factory.Sequence( 25 | # lambda n: "2020-01-{:0>2}".format(n + 1) if n % 5 == 0 else "-" 26 | # ) 27 | # cancel_date = factory.Sequence( 28 | # lambda n: "2020-01-{:0>2}".format(n + 1) if n % 5 == 0 else "-" 29 | # ) 30 | # proj_name_th = factory.Sequence(lambda n: "กองทุน {}".format(n)) 31 | # proj_name_en = factory.Sequence(lambda n: "Fund {}".format(n)) 32 | # proj_abbr_name = factory.Sequence(lambda n: "FUND-{:0>2}".format(n)) 33 | # funds_status = factory.LazyFunction(get_fund_status) 34 | # unique_id = factory.Sequence(lambda n: "C{:0>10}".format(n)) 35 | # permit_us_investment = "-" 36 | # invest_country_flag = factory.LazyFunction(get_invest_country_flag) 37 | # last_upd_date = "2020-01-01T01:02:03" 38 | -------------------------------------------------------------------------------- /tests/sources/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wasdee/pythainav/2fe371d98b6d5f814bcfd46157cdf5d7be211c32/tests/sources/__init__.py -------------------------------------------------------------------------------- /tests/sources/conftest.py: -------------------------------------------------------------------------------- 1 | import json 2 | import re 3 | 4 | import httpretty 5 | import pytest 6 | from furl import furl 7 | 8 | # from tests.factories.dailynav import AMCInfoFactory 9 | # from tests.factories.dailynav import DailyNavFactory 10 | # from tests.factories.search_class_fund import SearchClassFundFactory 11 | # from tests.factories.search_fund import SearchFundFactory 12 | 13 | 14 | @pytest.fixture 15 | def subscription_key(): 16 | subscription_key = { 17 | "fundfactsheet": "fact_key", 18 | "funddailyinfo": "daily_key", 19 | } 20 | return subscription_key 21 | 22 | 23 | # @pytest.fixture 24 | # def dataset(): 25 | # dataset = {} 26 | # dataset["search_fund_data"] = SearchFundFactory.build_batch(5) 27 | # dataset["search_class_fund_data"] = SearchClassFundFactory.build_batch(5) 28 | # dataset["dailynav_data"] = DailyNavFactory.create() 29 | # 30 | # multi_class_dailynav_data = DailyNavFactory.create(class_fund=True) 31 | # multi_class_amc_info = AMCInfoFactory(class_fund=True) 32 | # multi_class_dailynav_data["amc_info"] = [multi_class_amc_info] 33 | # dataset["multi_class_dailynav_data"] = multi_class_dailynav_data 34 | # return dataset 35 | 36 | 37 | @pytest.fixture(autouse=True) 38 | def set_global_test_data(request): 39 | httpretty.reset() 40 | if not httpretty.is_enabled(): 41 | httpretty.enable() 42 | 43 | dataset = request.getfixturevalue("dataset") 44 | 45 | # 46 | # Sec 47 | # 48 | # FundFactsheet 3 49 | httpretty.register_uri( 50 | httpretty.POST, 51 | "https://api.sec.or.th/FundFactsheet/fund", 52 | body=json.dumps(dataset["search_fund_data"]), 53 | ) 54 | 55 | # FundFactsheet 21 56 | httpretty.register_uri( 57 | httpretty.POST, 58 | "https://api.sec.or.th/FundFactsheet/fund/class_fund", 59 | body=json.dumps(dataset["search_class_fund_data"]), 60 | ) 61 | 62 | # FundDailyInfo 1 63 | httpretty.register_uri( 64 | httpretty.GET, 65 | re.compile("https://api.sec.or.th/FundDailyInfo/.*/dailynav/.*"), 66 | body=json.dumps(dataset["dailynav_data"]), 67 | ) 68 | 69 | # 70 | # Onde 71 | # 72 | base_url = { 73 | "fundfactsheet": furl( 74 | "http://dataexchange.onde.go.th/api/ApiProxy/Data/3C154331-4622-406E-94FB-443199D35523/f2529128-0332-44a0-9066-034093b07837/fund" 75 | ) 76 | } 77 | # FundFactsheet 3 78 | httpretty.register_uri( 79 | httpretty.POST, 80 | base_url["fundfactsheet"].url, 81 | body=json.dumps(dataset["search_fund_data"]), 82 | ) 83 | 84 | # FundFactsheet 21 85 | httpretty.register_uri( 86 | httpretty.POST, 87 | base_url["fundfactsheet"].copy().add(path=["class_fund"]).url, 88 | body=json.dumps(dataset["search_class_fund_data"]), 89 | ) 90 | 91 | # FundDailyInfo 1 92 | httpretty.register_uri( 93 | httpretty.GET, 94 | re.compile( 95 | "http://dataexchange.onde.go.th/api/ApiProxy/Data/92b67f7e-023e-4ce8-b4ba-08989d44ff78/ed6867e7-2d97-49e3-b25b-8bb0edb18e1c/.*/dailynav/.*" 96 | ), 97 | body=json.dumps(dataset["dailynav_data"]), 98 | ) 99 | yield 100 | httpretty.disable() 101 | -------------------------------------------------------------------------------- /tests/sources/helpers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wasdee/pythainav/2fe371d98b6d5f814bcfd46157cdf5d7be211c32/tests/sources/helpers/__init__.py -------------------------------------------------------------------------------- /tests/sources/helpers/sec_data.py: -------------------------------------------------------------------------------- 1 | import json 2 | import re 3 | 4 | from tests.factories.dailynav import AMCInfoFactory, DailyNavFactory 5 | from tests.factories.search_class_fund import SearchClassFundFactory 6 | from tests.factories.search_fund import SearchFundFactory 7 | 8 | # def setup_sec_data(unit_test, httpretty): 9 | # httpretty.reset() 10 | # if not httpretty.is_enabled(): 11 | # httpretty.enable() 12 | # 13 | # unit_test.search_fund_data = SearchFundFactory.build_batch(5) 14 | # unit_test.search_class_fund_data = SearchClassFundFactory.build_batch(5) 15 | # unit_test.dailynav_data = DailyNavFactory.create() 16 | # 17 | # multi_class_dailynav_data = DailyNavFactory.create(class_fund=True) 18 | # multi_class_amc_info = AMCInfoFactory(class_fund=True) 19 | # multi_class_dailynav_data["amc_info"] = [multi_class_amc_info] 20 | # unit_test.multi_class_dailynav_data = multi_class_dailynav_data 21 | # 22 | # # FundFactsheet 3 23 | # httpretty.register_uri( 24 | # httpretty.POST, 25 | # "https://api.sec.or.th/FundFactsheet/fund", 26 | # body=json.dumps(unit_test.search_fund_data), 27 | # ) 28 | # 29 | # # FundFactsheet 21 30 | # httpretty.register_uri( 31 | # httpretty.POST, 32 | # "https://api.sec.or.th/FundFactsheet/fund/class_fund", 33 | # body=json.dumps(unit_test.search_class_fund_data), 34 | # ) 35 | # 36 | # # FundDailyInfo 1 37 | # httpretty.register_uri( 38 | # httpretty.GET, 39 | # re.compile("https://api.sec.or.th/FundDailyInfo/.*/dailynav/.*"), 40 | # body=json.dumps(unit_test.dailynav_data), 41 | # ) 42 | -------------------------------------------------------------------------------- /tests/sources/test_onde.py: -------------------------------------------------------------------------------- 1 | # import datetime 2 | # import json 3 | # import re 4 | # 5 | # from unittest.mock import patch 6 | # 7 | # import dateparser 8 | # import pytest 9 | # 10 | # import httpretty 11 | # 12 | # from pythainav.nav import Nav 13 | # from pythainav.sources import Onde 14 | # 15 | # 16 | # # 17 | # # search_fund 18 | # # 19 | # def test_search_fund_success_with_content(dataset): 20 | # source = Onde() 21 | # result = source.search_fund("FUND") 22 | # 23 | # assert result == dataset["search_fund_data"] 24 | # 25 | # 26 | # def test_search_fund_no_content(): 27 | # # status code 204 28 | # httpretty.reset() 29 | # 30 | # httpretty.register_uri( 31 | # httpretty.POST, 32 | # "http://dataexchange.onde.go.th/api/ApiProxy/Data/3C154331-4622-406E-94FB-443199D35523/f2529128-0332-44a0-9066-034093b07837/fund", 33 | # status=204, 34 | # ) 35 | # 36 | # source = Onde() 37 | # result = source.search_fund("FUND") 38 | # assert result is None 39 | # 40 | # 41 | # # 42 | # # search_class_fund 43 | # # 44 | # def test_search_class_fund_success_with_content(dataset): 45 | # source = Onde() 46 | # result = source.search_class_fund("FUND") 47 | # 48 | # assert result == dataset["search_class_fund_data"] 49 | # 50 | # 51 | # def test_search_class_fund_no_content(): 52 | # # status code 204 53 | # httpretty.reset() 54 | # 55 | # httpretty.register_uri( 56 | # httpretty.POST, 57 | # "http://dataexchange.onde.go.th/api/ApiProxy/Data/3C154331-4622-406E-94FB-443199D35523/f2529128-0332-44a0-9066-034093b07837/fund/class_fund", 58 | # status=204, 59 | # ) 60 | # 61 | # source = Onde() 62 | # result = source.search_class_fund("FUND") 63 | # assert result is None 64 | # 65 | # 66 | # # 67 | # # search 68 | # # 69 | # @patch("pythainav.sources.Onde.search_class_fund") 70 | # @patch("pythainav.sources.Onde.search_fund") 71 | # def test_search_result(mock_search_fund, mock_search_class_fund, dataset): 72 | # # search_fund found fund 73 | # mock_search_fund.return_value = dataset["search_fund_data"] 74 | # source = Onde() 75 | # result = source.search("FUND") 76 | # 77 | # assert result == dataset["search_fund_data"] 78 | # 79 | # # search_fund return empty 80 | # mock_search_fund.return_value = None 81 | # mock_search_class_fund.return_value = dataset["search_class_fund_data"] 82 | # result = source.search("FUND") 83 | # assert mock_search_class_fund.called 84 | # assert result == dataset["search_class_fund_data"] 85 | # 86 | # # both return empty 87 | # mock_search_fund.return_value = None 88 | # mock_search_class_fund.return_value = None 89 | # result = source.search("FUND") 90 | # assert result is None 91 | # 92 | # 93 | # # 94 | # # list 95 | # # 96 | # def test_list_result(dataset): 97 | # source = Onde() 98 | # result = source.list() 99 | # 100 | # assert len(result) == len(dataset["search_fund_data"]) 101 | # 102 | # 103 | # # 104 | # # get_nav_from_fund_id 105 | # # 106 | # def test_get_nav_from_fund_id_success_with_content(dataset): 107 | # # status code 200 108 | # expect_return = Nav( 109 | # value=float(dataset["dailynav_data"]["last_val"]), 110 | # updated=dateparser.parse(dataset["dailynav_data"]["nav_date"]), 111 | # tags={}, 112 | # fund="FUND_ID", 113 | # ) 114 | # 115 | # nav_date = datetime.date(2020, 1, 1) 116 | # source = Onde() 117 | # result = source.get_nav_from_fund_id("FUND_ID", nav_date) 118 | # 119 | # assert result == expect_return 120 | # 121 | # 122 | # def test_get_nav_from_fund_id_no_content(): 123 | # # status code 204 124 | # httpretty.reset() 125 | # 126 | # httpretty.register_uri( 127 | # httpretty.GET, 128 | # re.compile( 129 | # "http://dataexchange.onde.go.th/api/ApiProxy/Data/92b67f7e-023e-4ce8-b4ba-08989d44ff78/ed6867e7-2d97-49e3-b25b-8bb0edb18e1c/.*/dailynav/.*" 130 | # ), 131 | # status=204, 132 | # ) 133 | # nav_date = datetime.date(2020, 1, 1) 134 | # source = Onde() 135 | # result = source.get_nav_from_fund_id("FUND_ID", nav_date) 136 | # assert result is None 137 | # 138 | # 139 | # def test_get_nav_from_fund_id_multi_class(dataset): 140 | # httpretty.reset() 141 | # 142 | # httpretty.register_uri( 143 | # httpretty.GET, 144 | # re.compile( 145 | # "http://dataexchange.onde.go.th/api/ApiProxy/Data/92b67f7e-023e-4ce8-b4ba-08989d44ff78/ed6867e7-2d97-49e3-b25b-8bb0edb18e1c/.*/dailynav/.*" 146 | # ), 147 | # body=json.dumps(dataset["multi_class_dailynav_data"]), 148 | # ) 149 | # 150 | # fund_name = "FUND_ID" 151 | # remark_en = dataset["multi_class_dailynav_data"]["amc_info"][0]["remark_en"] 152 | # multi_class_nav = {k.strip(): float(v) for x in remark_en.split("/") for k, v in [x.split("=")]} 153 | # expect_return = [] 154 | # for fund_name, nav_val in multi_class_nav.items(): 155 | # n = Nav( 156 | # value=float(nav_val), 157 | # updated=dateparser.parse(dataset["multi_class_dailynav_data"]["nav_date"]), 158 | # tags={}, 159 | # fund=fund_name, 160 | # ) 161 | # n.amount = dataset["multi_class_dailynav_data"]["net_asset"] 162 | # expect_return.append(n) 163 | # 164 | # nav_date = datetime.date(2020, 1, 1) 165 | # source = Onde() 166 | # result = source.get_nav_from_fund_id(fund_name, nav_date) 167 | # assert result == expect_return 168 | # 169 | # 170 | # # 171 | # # get 172 | # # 173 | # def test_get_params(): 174 | # nav_date = datetime.date(2020, 1, 1) 175 | # source = Onde() 176 | # 177 | # # date: str 178 | # try: 179 | # source.get("FUND", nav_date.isoformat()) 180 | # except Exception: 181 | # pytest.fail("raise exception unexpectedly") 182 | # 183 | # # Empty Fund 184 | # with pytest.raises(ValueError): 185 | # source.get("", nav_date.isoformat()) 186 | # 187 | # # date: datetime.date 188 | # try: 189 | # source.get("FUND", nav_date) 190 | # except Exception: 191 | # pytest.fail("raise exception unexpectedly") 192 | # 193 | # # date: datetime.datetime 194 | # try: 195 | # source.get("FUND", datetime.datetime.combine(nav_date, datetime.datetime.min.time())) 196 | # except Exception: 197 | # pytest.fail("raise exception unexpectedly") 198 | -------------------------------------------------------------------------------- /tests/sources/test_sec.py: -------------------------------------------------------------------------------- 1 | # import datetime 2 | # import json 3 | # import re 4 | # 5 | # from unittest.mock import patch 6 | # 7 | # import dateparser 8 | # import pytest 9 | # import requests 10 | # 11 | # import httpretty 12 | # 13 | # from pythainav.nav import Nav 14 | # from pythainav.sources import Sec 15 | # 16 | # 17 | # def test_no_subscription_key(): 18 | # with pytest.raises(ValueError): 19 | # Sec() 20 | # 21 | # 22 | # def test_subscription_key_is_none(): 23 | # subscription_key = None 24 | # with pytest.raises(ValueError): 25 | # Sec(subscription_key) 26 | # 27 | # with pytest.raises(ValueError): 28 | # Sec(subscription_key=subscription_key) 29 | # 30 | # 31 | # def test_subscription_key_missing_key(): 32 | # 33 | # with pytest.raises(ValueError): 34 | # Sec(subscription_key={"wrong_name": "some_key"}) 35 | # 36 | # with pytest.raises(ValueError): 37 | # Sec(subscription_key={"fundfactsheet": "some_key"}) 38 | # 39 | # with pytest.raises(ValueError): 40 | # Sec(subscription_key={"funddailyinfo": "some_key"}) 41 | # 42 | # test_sec = Sec( 43 | # subscription_key={"fundfactsheet": "some_key", "funddailyinfo": "some_key"} 44 | # ) 45 | # assert list(test_sec.subscription_key.keys()) == ["fundfactsheet", "funddailyinfo"] 46 | # 47 | # 48 | # # 49 | # # search_fund 50 | # # 51 | # def test_search_fund_setting_headers(subscription_key): 52 | # source = Sec(subscription_key=subscription_key) 53 | # source.search_fund("FUND") 54 | # 55 | # # contain Ocp-Apim-Subscription-Key in header 56 | # assert "Ocp-Apim-Subscription-Key" in httpretty.last_request().headers 57 | # assert ( 58 | # httpretty.last_request().headers["Ocp-Apim-Subscription-Key"] 59 | # == subscription_key["fundfactsheet"] 60 | # ) 61 | # 62 | # 63 | # def test_search_fund_invalid_key(subscription_key): 64 | # httpretty.reset() 65 | # error_responses = [ 66 | # httpretty.Response( 67 | # status=401, 68 | # body=json.dumps( 69 | # { 70 | # "statusCode": 401, 71 | # "message": "Access denied due to invalid subscription key. Make sure to provide a valid key for an active subscription.", 72 | # } 73 | # ), 74 | # ) 75 | # ] 76 | # 77 | # httpretty.register_uri( 78 | # httpretty.POST, 79 | # "https://api.sec.or.th/FundFactsheet/fund", 80 | # responses=error_responses, 81 | # ) 82 | # source = Sec(subscription_key=subscription_key) 83 | # with pytest.raises(requests.exceptions.HTTPError): 84 | # source.search_fund("FUND") 85 | # 86 | # 87 | # def test_search_fund_success_with_content(subscription_key, dataset): 88 | # source = Sec(subscription_key=subscription_key) 89 | # result = source.search_fund("FUND") 90 | # 91 | # assert result == dataset["search_fund_data"] 92 | # 93 | # 94 | # def test_search_fund_no_content(subscription_key): 95 | # # status code 204 96 | # httpretty.reset() 97 | # 98 | # httpretty.register_uri( 99 | # httpretty.POST, "https://api.sec.or.th/FundFactsheet/fund", status=204 100 | # ) 101 | # 102 | # source = Sec(subscription_key=subscription_key) 103 | # result = source.search_fund("FUND") 104 | # assert result is None 105 | # 106 | # 107 | # # 108 | # # search_class_fund 109 | # # 110 | # def test_search_class_fund_setting_headers(subscription_key): 111 | # source = Sec(subscription_key=subscription_key) 112 | # source.search_class_fund("FUND") 113 | # 114 | # # contain Ocp-Apim-Subscription-Key in header 115 | # assert "Ocp-Apim-Subscription-Key" in httpretty.last_request().headers 116 | # assert ( 117 | # httpretty.last_request().headers["Ocp-Apim-Subscription-Key"] 118 | # == subscription_key["fundfactsheet"] 119 | # ) 120 | # 121 | # 122 | # def test_search_class_fund_invalid_key(subscription_key): 123 | # httpretty.reset() 124 | # error_responses = [ 125 | # httpretty.Response( 126 | # status=401, 127 | # body=json.dumps( 128 | # { 129 | # "statusCode": 401, 130 | # "message": "Access denied due to invalid subscription key. Make sure to provide a valid key for an active subscription.", 131 | # } 132 | # ), 133 | # ) 134 | # ] 135 | # 136 | # httpretty.register_uri( 137 | # httpretty.POST, 138 | # "https://api.sec.or.th/FundFactsheet/fund/class_fund", 139 | # responses=error_responses, 140 | # ) 141 | # source = Sec(subscription_key=subscription_key) 142 | # with pytest.raises(requests.exceptions.HTTPError): 143 | # source.search_class_fund("FUND") 144 | # 145 | # 146 | # def test_search_class_fund_success_with_content(subscription_key, dataset): 147 | # source = Sec(subscription_key=subscription_key) 148 | # result = source.search_class_fund("FUND") 149 | # 150 | # assert result == dataset["search_class_fund_data"] 151 | # 152 | # 153 | # def test_search_class_fund_no_content(subscription_key): 154 | # # status code 204 155 | # httpretty.reset() 156 | # 157 | # httpretty.register_uri( 158 | # httpretty.POST, 159 | # "https://api.sec.or.th/FundFactsheet/fund/class_fund", 160 | # status=204, 161 | # ) 162 | # 163 | # source = Sec(subscription_key=subscription_key) 164 | # result = source.search_class_fund("FUND") 165 | # assert result is None 166 | # 167 | # 168 | # # 169 | # # search 170 | # # 171 | # @patch("pythainav.sources.Sec.search_class_fund") 172 | # @patch("pythainav.sources.Sec.search_fund") 173 | # def test_search_result( 174 | # mock_search_fund, mock_search_class_fund, subscription_key, dataset 175 | # ): 176 | # # search_fund found fund 177 | # mock_search_fund.return_value = dataset["search_fund_data"] 178 | # source = Sec(subscription_key=subscription_key) 179 | # result = source.search("FUND") 180 | # 181 | # assert result == dataset["search_fund_data"] 182 | # 183 | # # search_fund return empty 184 | # mock_search_fund.return_value = None 185 | # mock_search_class_fund.return_value = dataset["search_class_fund_data"] 186 | # result = source.search("FUND") 187 | # assert mock_search_class_fund.called 188 | # assert result == dataset["search_class_fund_data"] 189 | # 190 | # # both return empty 191 | # mock_search_fund.return_value = None 192 | # mock_search_class_fund.return_value = None 193 | # result = source.search("FUND") 194 | # assert result is None 195 | # 196 | # 197 | # # 198 | # # list 199 | # # 200 | # def test_list_result(subscription_key, dataset): 201 | # source = Sec(subscription_key=subscription_key) 202 | # result = source.list() 203 | # 204 | # assert len(result) == len(dataset["search_fund_data"]) 205 | # 206 | # 207 | # # 208 | # # get_nav_from_fund_id 209 | # # 210 | # def test_get_nav_from_fund_id_success_with_content(subscription_key, dataset): 211 | # # status code 200 212 | # expect_return = Nav( 213 | # value=float(dataset["dailynav_data"]["last_val"]), 214 | # updated=dateparser.parse(dataset["dailynav_data"]["nav_date"]), 215 | # tags={}, 216 | # fund="FUND_ID", 217 | # ) 218 | # 219 | # nav_date = datetime.date(2020, 1, 1) 220 | # source = Sec(subscription_key=subscription_key) 221 | # result = source.get_nav_from_fund_id("FUND_ID", nav_date) 222 | # 223 | # assert result == expect_return 224 | # 225 | # 226 | # def test_get_nav_from_fund_id_no_content(subscription_key): 227 | # # status code 204 228 | # httpretty.reset() 229 | # 230 | # httpretty.register_uri( 231 | # httpretty.GET, 232 | # re.compile("https://api.sec.or.th/FundDailyInfo/.*/dailynav/.*"), 233 | # status=204, 234 | # ) 235 | # nav_date = datetime.date(2020, 1, 1) 236 | # source = Sec(subscription_key=subscription_key) 237 | # result = source.get_nav_from_fund_id("FUND_ID", nav_date) 238 | # assert result is None 239 | # 240 | # 241 | # def test_get_nav_from_fund_id_multi_class(subscription_key, dataset): 242 | # httpretty.reset() 243 | # 244 | # httpretty.register_uri( 245 | # httpretty.GET, 246 | # re.compile("https://api.sec.or.th/FundDailyInfo/.*/dailynav/.*"), 247 | # body=json.dumps(dataset["multi_class_dailynav_data"]), 248 | # ) 249 | # 250 | # fund_name = "FUND_ID" 251 | # remark_en = dataset["multi_class_dailynav_data"]["amc_info"][0]["remark_en"] 252 | # multi_class_nav = { 253 | # k.strip(): float(v) for x in remark_en.split("/") for k, v in [x.split("=")] 254 | # } 255 | # expect_return = [] 256 | # for fund_name, nav_val in multi_class_nav.items(): 257 | # n = Nav( 258 | # value=float(nav_val), 259 | # updated=dateparser.parse(dataset["multi_class_dailynav_data"]["nav_date"]), 260 | # tags={}, 261 | # fund=fund_name, 262 | # ) 263 | # n.amount = dataset["multi_class_dailynav_data"]["net_asset"] 264 | # expect_return.append(n) 265 | # 266 | # nav_date = datetime.date(2020, 1, 1) 267 | # source = Sec(subscription_key=subscription_key) 268 | # result = source.get_nav_from_fund_id(fund_name, nav_date) 269 | # assert result == expect_return 270 | # 271 | # 272 | # # 273 | # # get 274 | # # 275 | # def test_get_params(subscription_key): 276 | # nav_date = datetime.date(2020, 1, 1) 277 | # source = Sec(subscription_key=subscription_key) 278 | # 279 | # # date: str 280 | # try: 281 | # source.get("FUND", nav_date.isoformat()) 282 | # except Exception: 283 | # pytest.fail("raise exception unexpectedly") 284 | # 285 | # # Empty Fund 286 | # with pytest.raises(ValueError): 287 | # source.get("", nav_date.isoformat()) 288 | # 289 | # # date: datetime.date 290 | # try: 291 | # source.get("FUND", nav_date) 292 | # except Exception: 293 | # pytest.fail("raise exception unexpectedly") 294 | # 295 | # # date: datetime.datetime 296 | # try: 297 | # source.get( 298 | # "FUND", datetime.datetime.combine(nav_date, datetime.datetime.min.time()) 299 | # ) 300 | # except Exception: 301 | # pytest.fail("raise exception unexpectedly") 302 | -------------------------------------------------------------------------------- /tests/test_core.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import pythainav as nav 3 | 4 | 5 | def test_get_nav(): 6 | kt_nav = nav.get("KT-PRECIOUS") 7 | print(kt_nav) 8 | assert kt_nav.value >= 0 9 | 10 | 11 | def test_get_nav_with_date(): 12 | oil_nav = nav.get("TMBOIL", date="1 week ago") 13 | print(oil_nav) 14 | assert oil_nav.value >= 0 15 | 16 | 17 | def test_get_all(): 18 | kt_navs = nav.get_all("KT-PRECIOUS") 19 | print(kt_navs) 20 | assert len(kt_navs) >= 0 21 | 22 | 23 | def test_get_all_pandas(): 24 | import pandas as pd 25 | 26 | df = nav.get_all("KT-PRECIOUS", asDataFrame=True) 27 | assert isinstance(df, pd.DataFrame) 28 | 29 | 30 | @pytest.mark.skip(reason="For dev only, required real api key") 31 | def test_sec_source(): 32 | from decouple import config 33 | 34 | subs_key = { 35 | "fundfactsheet": config("FUND_FACTSHEET_KEY"), 36 | "funddailyinfo": config("FUND_DAILY_INFO_KEY"), 37 | } 38 | 39 | # should auto convert to friday if it is a weekend 40 | # kt_nav = nav.get("KT-PRECIOUS", source="sec", subscription_key=subs_key) 41 | # print(kt_nav) 42 | # assert kt_nav.value >= 0 43 | 44 | kt_nav = nav.get( 45 | "KT-PRECIOUS", 46 | source="sec", 47 | subscription_key=subs_key, 48 | date="03/04/2020", 49 | ) 50 | print(kt_nav) 51 | assert kt_nav.value >= 0 52 | 53 | def test_no_name_collide(): 54 | f = nav.sources.Finnomena() 55 | l = f.list() 56 | l2 = list(l.keys()) 57 | assert len(l2) == len(set([x.lower() for x in l2])) 58 | 59 | def test_case_insensitive(): 60 | kt_nav = nav.get("kt-precious") 61 | print(kt_nav) 62 | assert kt_nav.value >= 0 63 | 64 | 65 | --------------------------------------------------------------------------------