├── .flake8 ├── .github ├── dependabot.yml └── workflows │ ├── cron.yaml │ ├── pullpush.yaml │ └── tag.yaml ├── .gitignore ├── .hound.yml ├── .isort.cfg ├── .markdownlint.json ├── .pre-commit-config.yaml ├── LICENSE ├── README.md ├── custom_components └── avanza_stock │ ├── __init__.py │ ├── const.py │ ├── manifest.json │ └── sensor.py ├── hacs.json ├── renovate.json └── resources.json /.flake8: -------------------------------------------------------------------------------- 1 | # Configured to be compliant with black 2 | [flake8] 3 | max-line-length = 88 4 | # E501: line too long 5 | # W503: Line break occurred before a binary operator 6 | # E203: Whitespace before ':' 7 | # D202 No blank lines allowed after function docstring 8 | # W504 line break after binary operator 9 | ignore = 10 | E501, 11 | W503, 12 | E203, 13 | D202, 14 | W504 15 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | --- 2 | version: 2 3 | updates: 4 | - package-ecosystem: "github-actions" 5 | directory: "/" 6 | schedule: 7 | interval: weekly 8 | time: "06:00" 9 | open-pull-requests-limit: 10 10 | -------------------------------------------------------------------------------- /.github/workflows/cron.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Cron actions 3 | 4 | on: # yamllint disable-line rule:truthy 5 | schedule: 6 | - cron: '0 0 * * *' 7 | 8 | jobs: 9 | validate: 10 | runs-on: "ubuntu-latest" 11 | name: Validate 12 | steps: 13 | - uses: "actions/checkout@v4" 14 | 15 | - name: HACS validation 16 | uses: "hacs/action@main" 17 | with: 18 | CATEGORY: "integration" 19 | 20 | - name: Hassfest validation 21 | uses: "home-assistant/actions/hassfest@master" 22 | -------------------------------------------------------------------------------- /.github/workflows/pullpush.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Push/Pull actions 3 | 4 | on: # yamllint disable-line rule:truthy 5 | push: 6 | branches: 7 | - main 8 | 9 | pull_request: 10 | 11 | jobs: 12 | validate: 13 | runs-on: "ubuntu-latest" 14 | name: Validate 15 | steps: 16 | - uses: "actions/checkout@v4" 17 | 18 | - name: HACS validation 19 | uses: "hacs/action@main" 20 | with: 21 | CATEGORY: "integration" 22 | 23 | - name: Hassfest validation 24 | uses: "home-assistant/actions/hassfest@master" 25 | 26 | style: 27 | runs-on: "ubuntu-latest" 28 | name: Check style formatting 29 | steps: 30 | - uses: actions/checkout@v4.0.0 31 | - uses: actions/setup-python@v5.1.0 32 | with: 33 | python-version: 3.x 34 | - uses: pre-commit/action@v3.0.1 35 | -------------------------------------------------------------------------------- /.github/workflows/tag.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Release 3 | 4 | on: # yamllint disable-line rule:truthy 5 | push: 6 | tags: 7 | - 'v*' 8 | 9 | jobs: 10 | release: 11 | runs-on: "ubuntu-latest" 12 | steps: 13 | - uses: "actions/checkout@v4" 14 | with: 15 | fetch-depth: 0 16 | 17 | - id: changelog 18 | run: | 19 | curr_tag=$(git describe --tags --abbrev=0) 20 | prev_tag=$(git describe --tags --abbrev=0 $curr_tag^) 21 | echo "Previous tag: $prev_tag" 22 | echo "Current tag: $curr_tag" 23 | log="$(git log --format='- %h %s' $prev_tag..$curr_tag)" 24 | log="${log//'%'/'%25'}" 25 | log="${log//$'\n'/'%0A'}" 26 | log="${log//$'\r'/'%0D'}" 27 | echo "::set-output name=body::$log" 28 | 29 | - uses: "actions/create-release@v1" 30 | env: 31 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 32 | with: 33 | tag_name: ${{ github.ref }} 34 | release_name: Release ${{ github.ref }} 35 | body: | 36 | ## Changes 37 | 38 | ${{ steps.changelog.outputs.body }} 39 | draft: false 40 | prerelease: false 41 | -------------------------------------------------------------------------------- /.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 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | -------------------------------------------------------------------------------- /.hound.yml: -------------------------------------------------------------------------------- 1 | --- 2 | fail_on_violations: true 3 | 4 | flake8: 5 | enabled: false 6 | config_file: .flake8 7 | -------------------------------------------------------------------------------- /.isort.cfg: -------------------------------------------------------------------------------- 1 | [settings] 2 | multi_line_output = 3 3 | include_trailing_comma = True 4 | force_grid_wrap = 0 5 | use_parentheses = True 6 | line_length = 88 7 | -------------------------------------------------------------------------------- /.markdownlint.json: -------------------------------------------------------------------------------- 1 | { 2 | "default": true, 3 | "line-length": false 4 | } 5 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | repos: 3 | - repo: https://github.com/pre-commit/pre-commit-hooks 4 | rev: v4.5.0 5 | hooks: 6 | - id: trailing-whitespace 7 | - id: end-of-file-fixer 8 | - id: mixed-line-ending 9 | args: 10 | - --fix=lf 11 | - id: check-json 12 | - id: pretty-format-json 13 | args: 14 | - --autofix 15 | - --no-sort-keys 16 | 17 | - repo: https://github.com/psf/black 18 | rev: 23.10.1 19 | hooks: 20 | - id: black 21 | args: 22 | - --safe 23 | - --quiet 24 | files: ^((custom_components)/.+)?[^/]+\.py$ 25 | 26 | - repo: https://github.com/pycqa/flake8 27 | rev: 6.1.0 28 | hooks: 29 | - id: flake8 30 | additional_dependencies: 31 | - flake8-docstrings==1.6.0 32 | - pydocstyle==6.1.1 33 | files: ^(custom_components)/.+\.py$ 34 | 35 | - repo: https://github.com/PyCQA/isort 36 | rev: 5.12.0 37 | hooks: 38 | - id: isort 39 | 40 | - repo: https://github.com/asottile/pyupgrade 41 | rev: v3.15.0 42 | hooks: 43 | - id: pyupgrade 44 | 45 | - repo: https://github.com/adrienverge/yamllint 46 | rev: v1.32.0 47 | hooks: 48 | - id: yamllint 49 | args: 50 | - --strict 51 | 52 | - repo: https://github.com/igorshubovych/markdownlint-cli 53 | rev: v0.37.0 54 | hooks: 55 | - id: markdownlint 56 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Claes Hallström 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # sensor.avanza_stock 2 | 3 | [![GitHub Release][releases-shield]][releases] 4 | [![GitHub Activity][commits-shield]][commits] 5 | [![License][license-shield]](LICENSE) 6 | 7 | [![hacs][hacsbadge]][hacs] 8 | ![Project Maintenance][maintenance-shield] 9 | [![BuyMeCoffee][buymecoffeebadge]][buymecoffee] 10 | 11 | _Custom component to get stock data from [Avanza](https://www.avanza.se) for 12 | [Home Assistant](https://www.home-assistant.io/)._ 13 | 14 | ## Note 15 | 16 | The api changed recently, I'm trying to keep as much as possible working but some 17 | attributes may still not work. Please open an issues if you find something. The 18 | plan for the future is to create a new component that should be merged to 19 | homeassitant core. 20 | 21 | ## Installation 22 | 23 | - The easiest way is to install it with [HACS](https://hacs.xyz/). First install 24 | [HACS](https://hacs.xyz/) if you don't have it yet. After installation you can 25 | find this custom component in the HACS store under integrations. 26 | 27 | - Alternatively, you can install it manually. Just copy paste the content of the 28 | `sensor.avanza_stock/custom_components` folder in your `config/custom_components` 29 | directory. As example, you will get the `sensor.py` file in the following path: 30 | `/config/custom_components/avanza_stock/sensor.py`. 31 | 32 | ## Configuration 33 | 34 | key | type | description 35 | :--- | :--- | :--- 36 | **platform (Required)** | string | `avanza_stock` 37 | **stock (Required)** | number / list | Stock id or list of stocks, see [here](#stock-configuration) and [here](#finding-stock-or-conversion-currency). 38 | **name (Optional)** | string | Name of the sensor. Default `avanza_stock_{stock}`. Redundant if stock is defined as a list. 39 | **shares (Optional)** | number | Number of shar, can be fractional. Redundant if stock is defined as a list. 40 | **purchase_date (Optional)** | string | Date when stock was purchased. Redundant if stock is defined as a list. Does not get processed further. 41 | **purchase_price (Optional)** | number | Price paid when stock was purchased (per share). Redundant if stock is defined as a list. 42 | **conversion_currency (Optional)** | number | Index id used for currency conversion, see [here](#finding-stock-or-conversion-currency). 43 | **monitored_conditions (Optional)** | list | Attributes to monitor, see [here](#monitored-conditions). 44 | **invert_conversion_currency (Optional)** | boolean | Wether or not to invert the conversion currency, default false. 45 | **currency (Optional)** | string | Overwrite currency given by the api. 46 | 47 | ### Stock configuration 48 | 49 | key | type | description 50 | :--- | :--- | :--- 51 | **id (Required)** | number | Stock id, see [here](#finding-stock-or-conversion-currency). 52 | **name (Optional)** | string | Name of the sensor. Default `avanza_stock_{stock}`. 53 | **shares (Optional)** | number | Number of shares, can be fractional. 54 | **purchase_date (Optional)** | string | Date when stock was purchased. Does not get processed further. 55 | **purchase_price (Optional)** | number | Price paid when stock was purchased (per share). 56 | **conversion_currency (Optional)** | number | Index id used for currency conversion, see [here](#finding-stock-or-conversion-currency). 57 | **invert_conversion_currency (Optional)** | boolean | Wether or not to invert the conversion currency, default false. 58 | **currency (Optional)** | string | Overwrite currency given by the api. 59 | 60 | ### Monitored conditions 61 | 62 | If no `monitored_conditions` is defined, change, changePercent and name will be tracked. Full list of available attributes, see [here](custom_components/avanza_stock/const.py#L12) (With the new api change only change and changePercent is currently supported.) Note that the data from the api is not realtime but lagging behind by 15 minutes. 63 | 64 | ### Finding stock or conversion currency 65 | 66 | Got to [Avanza](https://www.avanza.se) and search for the stock you want to track. In the resulting url there is a number, this is the stock id needed for the configuration. Even though it is a Swedish bank it is possible to find stocks from the following countries: Sweden, USA, Denmark, Norway, Finland, Canada, Belgium, France, Italy, Netherlands, Portugal and Germany. To find conversion currencies search for example "USD/SEK" and look for the id in the resulting url. If you can not find your conversion try and search the reverse (NOK/SEK instead of SEK/NOK) and use the `invert_conversion_currency` to get your preffered currency. 67 | 68 | ## Example 69 | 70 | **Configuration with default settings:** 71 | 72 | ```yaml 73 | sensor: 74 | - platform: avanza_stock 75 | stock: 5361 76 | ``` 77 | 78 | **Configuration with custom settings:** 79 | 80 | ```yaml 81 | sensor: 82 | - platform: avanza_stock 83 | name: Avanza Bank Holding 84 | stock: 5361 85 | monitored_conditions: 86 | - totalVolumeTraded 87 | - totalValueTraded 88 | ``` 89 | 90 | **Configuration with multiple stocks:** 91 | 92 | ```yaml 93 | sensor: 94 | - platform: avanza_stock 95 | stock: 96 | - id: 5361 97 | name: Avanza Bank Holding 98 | - id: 8123 99 | name: Home Assistant 100 | monitored_conditions: 101 | - totalVolumeTraded 102 | - totalValueTraded 103 | ``` 104 | 105 | **Configuration with conversion currency:** 106 | 107 | ```yaml 108 | sensor: 109 | - platform: avanza_stock 110 | stock: 111 | - id: 238449 112 | name: TESLA USD 113 | shares: 1 114 | purchase_price: 600 115 | - id: 238449 116 | name: TESLA SEK 117 | shares: 1 118 | conversion_currency: 19000 119 | purchase_price: 7000 120 | - id: 5361 121 | name: Avanza Bank Holding (NOK) 122 | conversion_currency: 53293 123 | invert_conversion_currency: true 124 | ``` 125 | 126 | **Configuration with untrackable stock, use id 0 and the purchase_price will be the current state:** 127 | 128 | ```yaml 129 | sensor: 130 | - platform: avanza_stock 131 | stock: 132 | - id: 0 133 | name: MY STOCK 134 | shares: 1 135 | purchase_price: 600 136 | currency: SEK 137 | ``` 138 | 139 | ## Usage 140 | 141 | **Automation to send summary at 18:00 using telegram:** 142 | 143 | ```yaml 144 | # Telegram Stock Summary 145 | - alias: 'Telegram Stock Summary' 146 | initial_state: true 147 | 148 | trigger: 149 | - platform: time 150 | at: '18:00:00' 151 | 152 | action: 153 | - service: notify.telegram 154 | data: 155 | message: ' 156 | Stock Summary 157 | 158 | {{ states.sensor.avanza_stock_5361.attributes.name }} : {{ states.sensor.avanza_stock_5361.attributes.changePercent }} 159 | 160 | ' 161 | ``` 162 | 163 | ## Changelog 164 | 165 | - 1.5.3 - Less aggressive rounding 166 | - 1.5.2 - Add minimum home-assistant version 167 | - 1.5.1 - Bump pyavanza (becuase of aiohttp) 168 | - 1.5.0 - Use latest pyavana with latest api changes 169 | - 1.4.0 - Fix more attributes (dure to api changes) 170 | - 1.3.0 - Support manual stock, i.e. not on avanza 171 | - 1.2.0 - Support ETF 172 | - 1.1.2 - Fix dividend 173 | - 1.1.1 - Fix historical changes 174 | - 1.1.0 - Use the new api 175 | - 1.0.12 - Add unique id 176 | - 1.0.11 - Rename device state attributes 177 | - 1.0.10 - Safely remove divident attributes 178 | - 1.0.9 - Add purchase date attribute (string manually set by user) 179 | - 1.0.8 - Correct profit/loss when using conversion currency 180 | - 1.0.7 - Overwrite currency given by the api 181 | - 1.0.6 - Correct unit when using conversion currency 182 | - 1.0.5 - Add option to invert conversion currency 183 | - 1.0.4 - Configure conversion currency and define purchase price 184 | - 1.0.3 - Allow to define multiple stocks 185 | - 1.0.2 - Async update 186 | - 1.0.1 - Allow fractional shares, add more change attributes 187 | - 1.0.0 - Add number of shares as optional configuration 188 | - 0.0.10 - Clean up monitored conditions 189 | - 0.0.9 - Compare payment date with today's date, ignore time 190 | - 0.0.8 - Ignore dividend if amount is zero, add resources.json and manfiest.json 191 | - 0.0.7 - Changed to async setup 192 | - 0.0.6 - Make sure dividend payment date has not passed 193 | - 0.0.5 - Add dividends information 194 | - 0.0.4 - Add companny information (description, marketCapital, sector, totalNumberOfShares) 195 | - 0.0.3 - Add key ratios (directYield, priceEarningsRatio, volatility) 196 | - 0.0.2 - Configure monitored conditions 197 | - 0.0.1 - Initial version 198 | 199 | [buymecoffee]: https://www.buymeacoffee.com/claha 200 | [buymecoffeebadge]: https://img.shields.io/badge/buy%20me%20a%20coffee-donate-yellow.svg?style=for-the-badge 201 | [commits-shield]: https://img.shields.io/github/commit-activity/y/custom-components/sensor.avanza_stock.svg?style=for-the-badge 202 | [commits]: https://github.com/custom-components/sensor.avanza_stock/commits/master 203 | [hacs]: https://github.com/hacs/integration 204 | [hacsbadge]: https://img.shields.io/badge/HACS-Custom-orange.svg?style=for-the-badge 205 | [license-shield]: https://img.shields.io/github/license/custom-components/sensor.avanza_stock.svg?style=for-the-badge 206 | [maintenance-shield]: https://img.shields.io/badge/maintainer-Claes%20Hallström%20%40claha-blue.svg?style=for-the-badge 207 | [releases-shield]: https://img.shields.io/github/release/custom-components/sensor.avanza_stock.svg?style=for-the-badge 208 | [releases]: https://github.com/custom-components/sensor.avanza_stock/releases 209 | -------------------------------------------------------------------------------- /custom_components/avanza_stock/__init__.py: -------------------------------------------------------------------------------- 1 | """Support for Avanaza Stock sensor.""" 2 | -------------------------------------------------------------------------------- /custom_components/avanza_stock/const.py: -------------------------------------------------------------------------------- 1 | """Constants for avanza_stock.""" 2 | __version__ = "1.5.3" 3 | 4 | DEFAULT_NAME = "Avanza Stock" 5 | 6 | CONF_STOCK = "stock" 7 | CONF_SHARES = "shares" 8 | CONF_PURCHASE_DATE = "purchase_date" 9 | CONF_PURCHASE_PRICE = "purchase_price" 10 | CONF_CONVERSION_CURRENCY = "conversion_currency" 11 | CONF_INVERT_CONVERSION_CURRENCY = "invert_conversion_currency" 12 | 13 | MONITORED_CONDITIONS = [ 14 | "country", 15 | "currency", 16 | "dividends", 17 | "hasInvestmentFees", 18 | "highestPrice", 19 | "id", 20 | "isin", 21 | "lastPrice", 22 | "lastPriceUpdated", 23 | "loanFactor", 24 | "lowestPrice", 25 | "marketList", 26 | "marketMakerExpected", 27 | "marketTrades", 28 | "morningStarFactSheetUrl", 29 | "name", 30 | "numberOfOwners", 31 | "orderDepthReceivedTime", 32 | "pushPermitted", 33 | "quoteUpdated", 34 | "shortSellable", 35 | "superLoan", 36 | "tradable", 37 | ] 38 | 39 | MONITORED_CONDITIONS_KEYRATIOS = [ 40 | "directYield", 41 | "priceEarningsRatio", 42 | "volatility", 43 | ] 44 | MONITORED_CONDITIONS += MONITORED_CONDITIONS_KEYRATIOS 45 | 46 | MONITORED_CONDITIONS_LISTING = [ 47 | "tickerSymbol", 48 | "marketPlace", 49 | "flagCode", 50 | ] 51 | MONITORED_CONDITIONS += MONITORED_CONDITIONS_LISTING 52 | 53 | MONITORED_CONDITIONS_COMPANY = [ 54 | "description", 55 | "marketCapital", 56 | "sector", 57 | "totalNumberOfShares", 58 | ] 59 | MONITORED_CONDITIONS += MONITORED_CONDITIONS_COMPANY 60 | 61 | MONITORED_CONDITIONS_DIVIDENDS = [ 62 | "amount", 63 | "exDate", 64 | "exDateStatus", 65 | "paymentDate", 66 | ] 67 | 68 | MONITORED_CONDITIONS_DEFAULT = [ 69 | "change", 70 | "changePercent", 71 | "name", 72 | ] 73 | 74 | MONITORED_CONDITIONS_QUOTE = [ 75 | "change", 76 | "changePercent", 77 | "totalValueTraded", 78 | "totalVolumeTraded", 79 | ] 80 | MONITORED_CONDITIONS += MONITORED_CONDITIONS_QUOTE 81 | 82 | MONITORED_CONDITIONS_PRICE = [ 83 | "priceAtStartOfYear", 84 | "priceFiveYearsAgo", 85 | "priceOneMonthAgo", 86 | "priceOneWeekAgo", 87 | "priceOneYearAgo", 88 | "priceThreeMonthsAgo", 89 | "priceThreeYearsAgo", 90 | ] 91 | MONITORED_CONDITIONS += MONITORED_CONDITIONS_PRICE 92 | 93 | PRICE_MAPPING = { 94 | "priceAtStartOfYear": "startOfYear", 95 | "priceFiveYearsAgo": "fiveYears", 96 | "priceOneMonthAgo": "oneMonth", 97 | "priceOneWeekAgo": "oneWeek", 98 | "priceOneYearAgo": "oneYear", 99 | "priceThreeMonthsAgo": "rhreeMonths", 100 | "priceThreeYearsAgo": "rhreeYears", 101 | } 102 | 103 | CHANGE_PRICE_MAPPING = [ 104 | ("changeOneWeek", "oneWeek"), 105 | ("changeOneMonth", "oneMonth"), 106 | ("changeThreeMonths", "threeMonths"), 107 | ("changeOneYear", "oneYear"), 108 | ("changeThreeYears", "threeYears"), 109 | ("changeFiveYears", "fiveYears"), 110 | ("changeTenYears", "tenYears"), 111 | ("changeCurrentYear", "startOfYear"), 112 | ] 113 | 114 | TOTAL_CHANGE_PRICE_MAPPING = [ 115 | ("totalChangeOneWeek", "oneWeek"), 116 | ("totalChangeOneMonth", "oneMonth"), 117 | ( 118 | "totalChangeThreeMonths", 119 | "threeMonths", 120 | ), 121 | ("totalChangeOneYear", "oneYear"), 122 | ( 123 | "totalChangeThreeYears", 124 | "threeYears", 125 | ), 126 | ("totalChangeFiveYears", "fiveYears"), 127 | ("totalChangeTenYears", "tenYears"), 128 | ( 129 | "totalChangeCurrentYear", 130 | "startOfYear", 131 | ), 132 | ] 133 | 134 | CHANGE_PERCENT_PRICE_MAPPING = [ 135 | ("changePercentOneWeek", "oneWeek"), 136 | ("changePercentOneMonth", "oneMonth"), 137 | ( 138 | "changePercentThreeMonths", 139 | "threeMonths", 140 | ), 141 | ("changePercentOneYear", "oneYear"), 142 | ("changePercentThreeYears", "threeYears"), 143 | ("changePercentFiveYears", "fiveYears"), 144 | ("changePercentTenYears", "tenYears"), 145 | ("changePercentCurrentYear", "startOfYear"), 146 | ] 147 | 148 | CURRENCY_ATTRIBUTE = [ 149 | "change", 150 | "highestPrice", 151 | "lastPrice", 152 | "lowestPrice", 153 | "priceAtStartOfYear", 154 | "priceFiveYearsAgo", 155 | "priceOneMonthAgo", 156 | "priceOneWeekAgo", 157 | "priceOneYearAgo", 158 | "priceSixMonthsAgo", 159 | "priceThreeMonthsAgo", 160 | "priceThreeYearsAgo", 161 | "totalValueTraded", 162 | "marketCapital", 163 | "dividend0_amountPerShare", 164 | "dividend1_amountPerShare", 165 | "dividend2_amountPerShare", 166 | "dividend3_amountPerShare", 167 | "dividend4_amountPerShare", 168 | "dividend5_amountPerShare", 169 | "dividend6_amountPerShare", 170 | "dividend7_amountPerShare", 171 | "dividend8_amountPerShare", 172 | "dividend9_amountPerShare", 173 | "changeOneWeek", 174 | "changeOneMonth", 175 | "changeThreeMonths", 176 | "changeSixMonths", 177 | "changeOneYear", 178 | "changeThreeYears", 179 | "changeFiveYears", 180 | "changeCurrentYear", 181 | "totalChangeOneWeek", 182 | "totalChangeOneMonth", 183 | "totalChangeThreeMonths", 184 | "totalChangeSixMonths", 185 | "totalChangeOneYear", 186 | "totalChangeThreeYears", 187 | "totalChangeFiveYears", 188 | "totalChangeCurrentYear", 189 | "totalValue", 190 | "totalChange", 191 | "profitLoss", 192 | "totalProfitLoss", 193 | ] 194 | -------------------------------------------------------------------------------- /custom_components/avanza_stock/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "avanza_stock", 3 | "name": "Avanza Stock", 4 | "codeowners": [ 5 | "@claha" 6 | ], 7 | "dependencies": [], 8 | "documentation": "https://github.com/custom-components/sensor.avanza_stock", 9 | "iot_class": "cloud_polling", 10 | "issue_tracker": "https://github.com/custom-components/sensor.avanza_stock/issues", 11 | "requirements": [ 12 | "pyavanza==0.7.1" 13 | ], 14 | "version": "1.5.3" 15 | } 16 | -------------------------------------------------------------------------------- /custom_components/avanza_stock/sensor.py: -------------------------------------------------------------------------------- 1 | """ 2 | Support for getting stock data from avanza.se. 3 | 4 | For more details about this platform, please refer to the documentation at 5 | https://github.com/custom-components/sensor.avanza_stock/blob/master/README.md 6 | """ 7 | import logging 8 | from datetime import timedelta 9 | 10 | import homeassistant.helpers.config_validation as cv 11 | import pyavanza 12 | import voluptuous as vol 13 | from homeassistant.components.sensor import ( 14 | PLATFORM_SCHEMA, 15 | SensorDeviceClass, 16 | SensorEntity, 17 | SensorStateClass, 18 | ) 19 | from homeassistant.const import ( 20 | CONF_CURRENCY, 21 | CONF_ID, 22 | CONF_MONITORED_CONDITIONS, 23 | CONF_NAME, 24 | ) 25 | from homeassistant.helpers.aiohttp_client import async_create_clientsession 26 | 27 | from custom_components.avanza_stock.const import ( 28 | CHANGE_PERCENT_PRICE_MAPPING, 29 | CHANGE_PRICE_MAPPING, 30 | CONF_CONVERSION_CURRENCY, 31 | CONF_INVERT_CONVERSION_CURRENCY, 32 | CONF_PURCHASE_DATE, 33 | CONF_PURCHASE_PRICE, 34 | CONF_SHARES, 35 | CONF_STOCK, 36 | CURRENCY_ATTRIBUTE, 37 | DEFAULT_NAME, 38 | MONITORED_CONDITIONS, 39 | MONITORED_CONDITIONS_COMPANY, 40 | MONITORED_CONDITIONS_DEFAULT, 41 | MONITORED_CONDITIONS_DIVIDENDS, 42 | MONITORED_CONDITIONS_KEYRATIOS, 43 | MONITORED_CONDITIONS_LISTING, 44 | MONITORED_CONDITIONS_PRICE, 45 | MONITORED_CONDITIONS_QUOTE, 46 | PRICE_MAPPING, 47 | TOTAL_CHANGE_PRICE_MAPPING, 48 | ) 49 | 50 | _LOGGER = logging.getLogger(__name__) 51 | 52 | SCAN_INTERVAL = timedelta(minutes=60) 53 | 54 | STOCK_SCHEMA = vol.Schema( 55 | { 56 | vol.Required(CONF_ID): cv.positive_int, 57 | vol.Optional(CONF_NAME): cv.string, 58 | vol.Optional(CONF_SHARES): vol.Coerce(float), 59 | vol.Optional(CONF_PURCHASE_DATE): cv.string, 60 | vol.Optional(CONF_PURCHASE_PRICE): vol.Coerce(float), 61 | vol.Optional(CONF_CONVERSION_CURRENCY): cv.positive_int, 62 | vol.Optional(CONF_INVERT_CONVERSION_CURRENCY, default=False): cv.boolean, 63 | vol.Optional(CONF_CURRENCY): cv.string, 64 | } 65 | ) 66 | 67 | PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( 68 | { 69 | vol.Required(CONF_STOCK): vol.Any( 70 | cv.positive_int, vol.All(cv.ensure_list, [STOCK_SCHEMA]) 71 | ), 72 | vol.Optional(CONF_NAME): cv.string, 73 | vol.Optional(CONF_SHARES): vol.Coerce(float), 74 | vol.Optional(CONF_PURCHASE_DATE): cv.string, 75 | vol.Optional(CONF_PURCHASE_PRICE): vol.Coerce(float), 76 | vol.Optional(CONF_CONVERSION_CURRENCY): cv.positive_int, 77 | vol.Optional(CONF_INVERT_CONVERSION_CURRENCY, default=False): cv.boolean, 78 | vol.Optional(CONF_CURRENCY): cv.string, 79 | vol.Optional( 80 | CONF_MONITORED_CONDITIONS, default=MONITORED_CONDITIONS_DEFAULT 81 | ): vol.All(cv.ensure_list, [vol.In(MONITORED_CONDITIONS)]), 82 | } 83 | ) 84 | 85 | 86 | async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): 87 | """Set up the Avanza Stock sensor.""" 88 | session = async_create_clientsession(hass) 89 | monitored_conditions = config.get(CONF_MONITORED_CONDITIONS) 90 | stock = config.get(CONF_STOCK) 91 | entities = [] 92 | if isinstance(stock, int): 93 | name = config.get(CONF_NAME) 94 | shares = config.get(CONF_SHARES) 95 | purchase_date = config.get(CONF_PURCHASE_DATE) 96 | purchase_price = config.get(CONF_PURCHASE_PRICE) 97 | conversion_currency = config.get(CONF_CONVERSION_CURRENCY) 98 | invert_conversion_currency = config.get(CONF_INVERT_CONVERSION_CURRENCY) 99 | currency = config.get(CONF_CURRENCY) 100 | if name is None: 101 | name = DEFAULT_NAME + " " + str(stock) 102 | entities.append( 103 | AvanzaStockSensor( 104 | hass, 105 | stock, 106 | name, 107 | shares, 108 | purchase_date, 109 | purchase_price, 110 | conversion_currency, 111 | invert_conversion_currency, 112 | currency, 113 | monitored_conditions, 114 | session, 115 | ) 116 | ) 117 | _LOGGER.debug("Tracking %s [%d] using Avanza" % (name, stock)) 118 | else: 119 | for s in stock: 120 | id = s.get(CONF_ID) 121 | name = s.get(CONF_NAME) 122 | if name is None: 123 | name = DEFAULT_NAME + " " + str(id) 124 | shares = s.get(CONF_SHARES) 125 | purchase_date = s.get(CONF_PURCHASE_DATE) 126 | purchase_price = s.get(CONF_PURCHASE_PRICE) 127 | conversion_currency = s.get(CONF_CONVERSION_CURRENCY) 128 | invert_conversion_currency = s.get(CONF_INVERT_CONVERSION_CURRENCY) 129 | currency = s.get(CONF_CURRENCY) 130 | entities.append( 131 | AvanzaStockSensor( 132 | hass, 133 | id, 134 | name, 135 | shares, 136 | purchase_date, 137 | purchase_price, 138 | conversion_currency, 139 | invert_conversion_currency, 140 | currency, 141 | monitored_conditions, 142 | session, 143 | ) 144 | ) 145 | _LOGGER.debug("Tracking %s [%d] using Avanza" % (name, id)) 146 | async_add_entities(entities, True) 147 | 148 | 149 | class AvanzaStockSensor(SensorEntity): 150 | """Representation of a Avanza Stock sensor.""" 151 | 152 | def __init__( 153 | self, 154 | hass, 155 | stock, 156 | name, 157 | shares, 158 | purchase_date, 159 | purchase_price, 160 | conversion_currency, 161 | invert_conversion_currency, 162 | currency, 163 | monitored_conditions, 164 | session, 165 | ): 166 | """Initialize a Avanza Stock sensor.""" 167 | self._hass = hass 168 | self._stock = stock 169 | self._name = name 170 | self._shares = shares 171 | self._purchase_date = purchase_date 172 | self._purchase_price = purchase_price 173 | self._conversion_currency = conversion_currency 174 | self._invert_conversion_currency = invert_conversion_currency 175 | self._currency = currency 176 | self._monitored_conditions = monitored_conditions 177 | self._session = session 178 | self._icon = "mdi:cash" 179 | self._state = 0 180 | self._state_attributes = {} 181 | self._unit_of_measurement = "" 182 | 183 | @property 184 | def name(self): 185 | """Return the name of the sensor.""" 186 | return self._name 187 | 188 | @property 189 | def icon(self): 190 | """Icon to use in the frontend, if any.""" 191 | return self._icon 192 | 193 | @property 194 | def state(self): 195 | """Return the state of the device.""" 196 | return self._state 197 | 198 | @property 199 | def extra_state_attributes(self): 200 | """Return the state attributes of the sensor.""" 201 | return self._state_attributes 202 | 203 | @property 204 | def unit_of_measurement(self): 205 | """Return the unit of measurement.""" 206 | return self._unit_of_measurement 207 | 208 | @property 209 | def unique_id(self): 210 | """Return the unique id.""" 211 | return f"{self._stock}_{self._name}_stock" 212 | 213 | @property 214 | def state_class(self): 215 | """Return the state class.""" 216 | return SensorStateClass.MEASUREMENT 217 | 218 | @property 219 | def device_class(self): 220 | """Return the device class.""" 221 | return SensorDeviceClass.MONETARY 222 | 223 | async def async_update(self): 224 | """Update state and attributes.""" 225 | data_conversion_currency = None 226 | if self._stock == 0: # Non trackable, i.e. manual 227 | data = { 228 | "name": self._name.split(" ", 1)[1], 229 | "unit_of_measurement": self._currency, 230 | "quote": { 231 | "last": self._purchase_price, 232 | "change": 0, 233 | "changePercent": 0, 234 | }, 235 | "historicalClosingPrices": { 236 | "oneWeek": self._purchase_price, 237 | "oneMonth": self._purchase_price, 238 | "threeMonths": self._purchase_price, 239 | "oneYear": self._purchase_price, 240 | "threeYears": self._purchase_price, 241 | "fiveYears": self._purchase_price, 242 | "tenYears": self._purchase_price, 243 | "startOfYear": self._purchase_price, 244 | }, 245 | "listing": { 246 | "currency": self._currency, 247 | }, 248 | } 249 | else: 250 | data = await pyavanza.get_stock_async(self._session, self._stock) 251 | if data["type"] == pyavanza.InstrumentType.ExchangeTradedFund: 252 | data = await pyavanza.get_etf_async(self._session, self._stock) 253 | if self._conversion_currency: 254 | data_conversion_currency = await pyavanza.get_stock_async( 255 | self._session, self._conversion_currency 256 | ) 257 | if data: 258 | self._update_state(data) 259 | self._update_unit_of_measurement(data) 260 | self._update_state_attributes(data) 261 | if data_conversion_currency: 262 | self._update_conversion_rate(data_conversion_currency) 263 | if self._currency: 264 | self._unit_of_measurement = self._currency 265 | 266 | def _update_state(self, data): 267 | self._state = data["quote"]["last"] 268 | 269 | def _update_unit_of_measurement(self, data): 270 | self._unit_of_measurement = data["listing"]["currency"] 271 | 272 | def _update_state_attributes(self, data): 273 | for condition in self._monitored_conditions: 274 | if condition in MONITORED_CONDITIONS_KEYRATIOS: 275 | self._update_key_ratios(data, condition) 276 | elif condition in MONITORED_CONDITIONS_COMPANY: 277 | self._update_company(data, condition) 278 | elif condition in MONITORED_CONDITIONS_QUOTE: 279 | self._update_quote(data, condition) 280 | elif condition in MONITORED_CONDITIONS_LISTING: 281 | self._update_listing(data, condition) 282 | elif condition in MONITORED_CONDITIONS_PRICE: 283 | self._update_price(data, condition) 284 | elif condition == "dividends": 285 | self._update_dividends(data) 286 | elif condition == "id": 287 | self._state_attributes[condition] = data.get("orderbookId", None) 288 | else: 289 | self._state_attributes[condition] = data.get(condition, None) 290 | 291 | if condition == "change": 292 | for change, price in CHANGE_PRICE_MAPPING: 293 | if price in data["historicalClosingPrices"]: 294 | self._state_attributes[change] = round( 295 | data["quote"]["last"] 296 | - data["historicalClosingPrices"][price], 297 | 5, 298 | ) 299 | else: 300 | self._state_attributes[change] = "unknown" 301 | 302 | if self._shares is not None: 303 | for change, price in TOTAL_CHANGE_PRICE_MAPPING: 304 | if price in data["historicalClosingPrices"]: 305 | self._state_attributes[change] = round( 306 | self._shares 307 | * ( 308 | data["quote"]["last"] 309 | - data["historicalClosingPrices"][price] 310 | ), 311 | 5, 312 | ) 313 | else: 314 | self._state_attributes[change] = "unknown" 315 | 316 | if condition == "changePercent": 317 | for change, price in CHANGE_PERCENT_PRICE_MAPPING: 318 | if price in data["historicalClosingPrices"]: 319 | self._state_attributes[change] = round( 320 | 100 321 | * ( 322 | data["quote"]["last"] 323 | - data["historicalClosingPrices"][price] 324 | ) 325 | / data["historicalClosingPrices"][price], 326 | 3, 327 | ) 328 | else: 329 | self._state_attributes[change] = "unknown" 330 | 331 | if self._shares is not None: 332 | self._state_attributes["shares"] = self._shares 333 | self._state_attributes["totalValue"] = round( 334 | self._shares * data["quote"]["last"], 5 335 | ) 336 | self._state_attributes["totalChange"] = round( 337 | self._shares * data["quote"]["change"], 5 338 | ) 339 | 340 | self._update_profit_loss(data["quote"]["last"]) 341 | 342 | def _update_key_ratios(self, data, attr): 343 | key_ratios = data.get("keyRatios", {}) 344 | self._state_attributes[attr] = key_ratios.get(attr, None) 345 | 346 | def _update_company(self, data, attr): 347 | company = data.get("company", {}) 348 | self._state_attributes[attr] = company.get(attr, None) 349 | 350 | def _update_quote(self, data, attr): 351 | quote = data.get("quote", {}) 352 | self._state_attributes[attr] = quote.get(attr, None) 353 | 354 | def _update_listing(self, data, attr): 355 | listing = data.get("listing", {}) 356 | if attr == "marketPlace": 357 | self._state_attributes[attr] = listing.get("marketPlaceName", None) 358 | elif attr == "flagCode": 359 | self._state_attributes[attr] = listing.get("countryCode", None) 360 | else: 361 | self._state_attributes[attr] = listing.get(attr, None) 362 | 363 | def _update_price(self, data, attr): 364 | prices = data.get("historicalClosingPrices", {}) 365 | self._state_attributes[attr] = prices.get(PRICE_MAPPING[attr], None) 366 | 367 | def _update_profit_loss(self, price): 368 | if self._purchase_date is not None: 369 | self._state_attributes["purchaseDate"] = self._purchase_date 370 | if self._purchase_price is not None: 371 | self._state_attributes["purchasePrice"] = self._purchase_price 372 | self._state_attributes["profitLoss"] = round( 373 | price - self._purchase_price, 5 374 | ) 375 | self._state_attributes["profitLossPercentage"] = round( 376 | 100 * (price - self._purchase_price) / self._purchase_price, 3 377 | ) 378 | 379 | if self._shares is not None: 380 | self._state_attributes["totalProfitLoss"] = round( 381 | self._shares * (price - self._purchase_price), 5 382 | ) 383 | 384 | def _update_conversion_rate(self, data): 385 | rate = data["quote"]["last"] 386 | if self._invert_conversion_currency: 387 | rate = 1.0 / rate 388 | self._state = round(self._state * rate, 5) 389 | if self._invert_conversion_currency: 390 | self._unit_of_measurement = data["name"].split("/")[0] 391 | else: 392 | self._unit_of_measurement = data["name"].split("/")[1] 393 | for attribute in self._state_attributes: 394 | if ( 395 | attribute in CURRENCY_ATTRIBUTE 396 | and self._state_attributes[attribute] is not None 397 | and self._state_attributes[attribute] != "unknown" 398 | ): 399 | self._state_attributes[attribute] = round( 400 | self._state_attributes[attribute] * rate, 5 401 | ) 402 | self._update_profit_loss(self._state) 403 | 404 | def _update_dividends(self, data): 405 | if "keyIndicators" not in data: 406 | return 407 | if "dividend" not in data["keyIndicators"]: 408 | return 409 | 410 | dividend = data["keyIndicators"]["dividend"] 411 | for dividend_condition in MONITORED_CONDITIONS_DIVIDENDS: 412 | if dividend_condition not in dividend: 413 | continue 414 | attribute = "dividend_{}".format(dividend_condition) 415 | self._state_attributes[attribute] = dividend[dividend_condition] 416 | -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Avanza Stock", 3 | "render_readme": true, 4 | "homeassistant": "2024.1.6" 5 | } 6 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:base", 5 | ":enablePreCommit" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /resources.json: -------------------------------------------------------------------------------- 1 | [ 2 | "https://raw.githubusercontent.com/custom-components/sensor.avanza_stock/master/custom_components/avanza_stock/__init__.py", 3 | "https://raw.githubusercontent.com/custom-components/sensor.avanza_stock/master/custom_components/avanza_stock/manifest.json" 4 | ] 5 | --------------------------------------------------------------------------------