├── .gitattributes ├── .github └── workflows │ └── python-package.yml ├── .gitignore ├── LICENSE ├── README.md ├── finstruments ├── __init__.py ├── common │ ├── __init__.py │ ├── base.py │ ├── base_enum.py │ ├── date │ │ ├── __init__.py │ │ ├── enum.py │ │ └── function.py │ ├── decorators │ │ ├── __init__.py │ │ └── serializable │ │ │ ├── __init__.py │ │ │ ├── __util.py │ │ │ └── decorators.py │ ├── enum.py │ ├── function.py │ ├── period.py │ └── type.py ├── instrument │ ├── __init__.py │ ├── abstract.py │ ├── commodity │ │ ├── __init__.py │ │ └── instrument.py │ ├── common │ │ ├── __init__.py │ │ ├── currency_pair.py │ │ ├── cut.py │ │ ├── enum.py │ │ ├── exercise_style.py │ │ ├── forward │ │ │ ├── __init__.py │ │ │ └── instrument.py │ │ ├── future │ │ │ ├── __init__.py │ │ │ └── instrument.py │ │ └── option │ │ │ ├── __init__.py │ │ │ ├── enum.py │ │ │ ├── instrument.py │ │ │ └── payoff.py │ ├── cryptocurrency │ │ ├── __init__.py │ │ └── instrument.py │ └── equity │ │ ├── __init__.py │ │ ├── enum.py │ │ └── instrument.py └── portfolio │ ├── __init__.py │ └── portfolio.py ├── pyproject.toml ├── requirements.txt ├── setup.py └── tests ├── __init__.py └── unit ├── __init__.py ├── common ├── __init__.py ├── base │ ├── __init__.py │ ├── copy_test.py │ └── hash_test.py ├── date │ └── __init__.py ├── decorators │ ├── __init__.py │ └── serializable_test.py ├── enum_test.py ├── function_test.py └── period_test.py ├── deserialization ├── __init__.py ├── currency_pair_deserialization.py ├── equity_forward_deserialization_test.py ├── equity_option_deserialization_test.py ├── portfolio_deserialization.py └── util.py ├── instrument ├── __init__.py ├── abstract_test.py ├── common │ ├── __init__.py │ ├── cut_test.py │ ├── exercise_style_test.py │ └── option │ │ ├── __init__.py │ │ └── payoff_test.py └── cryptocurrency │ ├── __init__.py │ └── instrument_test.py └── portfolio ├── __init__.py └── portfolio_test.py /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | *.py text eol=lf 3 | -------------------------------------------------------------------------------- /.github/workflows/python-package.yml: -------------------------------------------------------------------------------- 1 | name: Python Package CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - '**' 7 | tags: 8 | - 'v*' 9 | pull_request: 10 | branches: 11 | - '**' 12 | 13 | jobs: 14 | lint: 15 | runs-on: ubuntu-22.04 16 | steps: 17 | - name: Check out the code 18 | uses: actions/checkout@v3 19 | with: 20 | fetch-depth: 0 21 | 22 | - name: Set up Python 23 | uses: actions/setup-python@v4 24 | with: 25 | python-version: '3.7' 26 | 27 | - name: Install black 28 | run: | 29 | python -m pip install --upgrade "pip<23.0" 30 | pip install black==22.12.0 31 | 32 | - name: Run black to check code formatting 33 | run: | 34 | black --check --verbose . 35 | 36 | test: 37 | runs-on: ubuntu-22.04 38 | steps: 39 | - name: Check out the code 40 | uses: actions/checkout@v3 41 | with: 42 | fetch-depth: 0 43 | 44 | - name: Set up Python 45 | uses: actions/setup-python@v4 46 | with: 47 | python-version: '3.7' 48 | 49 | - name: Install dependencies 50 | run: | 51 | python -m pip install --upgrade "pip<23.0" 52 | pip install -r requirements.txt 53 | pip install pytest 54 | 55 | - name: Run tests 56 | run: | 57 | pytest 58 | 59 | version: 60 | runs-on: ubuntu-22.04 61 | if: github.ref == 'refs/heads/master' 62 | steps: 63 | - name: Check out the code 64 | uses: actions/checkout@v3 65 | with: 66 | fetch-depth: 0 67 | tags: true # Fetch all tags to ensure setuptools_scm can use them 68 | 69 | - name: Set up Python 70 | uses: actions/setup-python@v4 71 | with: 72 | python-version: '3.7' 73 | 74 | - name: Upgrade setuptools and setuptools_scm 75 | run: | 76 | python -m pip install --upgrade "pip<23.0" 77 | python -m pip install --upgrade --force-reinstall "setuptools<68" 78 | python -m pip install --upgrade --force-reinstall "setuptools_scm[toml]<8" 79 | 80 | - name: Determine the clean version 81 | id: get_version 82 | run: | 83 | VERSION=$(python -c "import setuptools_scm; print(setuptools_scm.get_version())") 84 | CLEAN_VERSION=$(echo $VERSION | sed 's/\.dev[0-9]\+.*//') 85 | echo "VERSION=$CLEAN_VERSION" >> $GITHUB_ENV 86 | 87 | - name: Create and push a clean tag 88 | env: 89 | VERSION: ${{ env.VERSION }} 90 | run: | 91 | git config --local user.name "GitHub Actions" 92 | git config --local user.email "actions@github.com" 93 | git tag -a "$VERSION" -m "Release version $VERSION" 94 | git push origin "$VERSION" 95 | 96 | publish: 97 | needs: [ lint, test, version ] 98 | runs-on: ubuntu-22.04 99 | if: github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/tags/v') 100 | steps: 101 | - name: Check out the code 102 | uses: actions/checkout@v3 103 | with: 104 | fetch-depth: 0 105 | 106 | - name: Set up Python 107 | uses: actions/setup-python@v4 108 | with: 109 | python-version: '3.7' 110 | 111 | - name: Upgrade setuptools and setuptools_scm 112 | run: | 113 | python -m pip install --upgrade "pip<23.0" 114 | python -m pip install --upgrade --force-reinstall "setuptools<68" 115 | python -m pip install --upgrade --force-reinstall "setuptools_scm[toml]<8" 116 | 117 | - name: Install build dependencies 118 | run: | 119 | python -m pip install --upgrade "pip<23.0" 120 | pip install build twine 121 | 122 | - name: Clean up previous builds 123 | run: rm -rf dist 124 | 125 | - name: Build the package with the correct version 126 | env: 127 | VERSION: ${{ env.VERSION }} 128 | run: | 129 | python -m build --sdist --wheel --outdir dist 130 | 131 | - name: Publish to PyPI 132 | env: 133 | TWINE_USERNAME: __token__ 134 | TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} 135 | run: | 136 | python -m twine upload dist/* 137 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # data 4 | .ipynb_checkpoints/ 5 | venv/ 6 | *.egg-info/ 7 | 8 | **/__pycache__ 9 | 10 | # compiled output 11 | /dist 12 | /tmp 13 | /out-tsc 14 | 15 | # dependencies 16 | /node_modules 17 | 18 | # IDEs and editors 19 | /.idea 20 | .project 21 | .classpath 22 | .c9/ 23 | *.launch 24 | .settings/ 25 | *.sublime-workspace 26 | /.eggs/ 27 | 28 | # IDE - VSCode 29 | .vscode/* 30 | !.vscode/settings.json 31 | !.vscode/tasks.json 32 | !.vscode/launch.json 33 | !.vscode/extensions.json 34 | 35 | # misc 36 | /.sass-cache 37 | /connect.lock 38 | /coverage 39 | /libpeerconnection.log 40 | npm-debug.log 41 | yarn-error.log 42 | testem.log 43 | /typings 44 | /finstruments.egg-info 45 | /build/lib 46 | 47 | # System Files 48 | .DS_Store 49 | Thumbs.db 50 | 51 | # docs 52 | /docs/generated 53 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Kyle Loomis 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 | # Finstruments: Financial Instrument Definitions 2 | 3 | `finstruments` is a financial instrument definition library built with Python and Pydantic. Out of the box, it comes with the most common financial 4 | instruments, including equity forwards and options, as well as position, trade, and portfolio models. If an instrument 5 | doesn't already exist, you can leverage the building blocks to easily create a new instrument for any asset class. These building 6 | blocks also provide the functionality to serialize and deserialize to and from JSON, enabling API integration and storage in a document database. 7 | 8 | ## Key Features 9 | 10 | - Support for common financial instruments, including equity forwards and options 11 | - Ability to extend and create custom instruments 12 | - JSON serialization and deserialization capabilities 13 | - Functions for date handling, business day calculations, payoffs, and other financial operations 14 | - Lightweight with minimal dependencies 15 | 16 | ## Serialization and Deserialization 17 | 18 | `finstruments` includes built-in support for serialization and deserialization of financial instruments, making it easy 19 | to save and load objects in formats like JSON. This feature allows users to easily store the state of financial 20 | instruments, share data between systems, or integrate with other applications. 21 | 22 | ## Installation 23 | 24 | Install `finstruments` using `pip`: 25 | 26 | ```bash 27 | pip install finstruments 28 | ``` 29 | 30 | ## Usage 31 | 32 | An equity option requires a `BaseEquity` instrument object (e.g. `CommonStock`) as input for the underlying field. The 33 | payoff (`VanillaPayoff`, `DigitalPayoff`) and exercise_type (`EuropeanExerciseStyle`, `AmericanExerciseStyle`, 34 | `BermudanExerciseStyle`) fields need to be populated with objects as well. 35 | 36 | ```python 37 | from datetime import date 38 | 39 | from finstruments.common.enum import Currency 40 | from finstruments.instrument.common.cut import NysePMCut 41 | from finstruments.instrument.common.exercise_style import AmericanExerciseStyle 42 | from finstruments.instrument.common.option.enum import OptionType 43 | from finstruments.instrument.common.option.payoff import VanillaPayoff 44 | from finstruments.instrument.equity import EquityOption, CommonStock 45 | 46 | equity_option = EquityOption( 47 | underlying=CommonStock(ticker='AAPL'), 48 | payoff=VanillaPayoff( 49 | option_type=OptionType.PUT, 50 | strike_price=100 51 | ), 52 | exercise_type=AmericanExerciseStyle( 53 | minimum_exercise_date=date(2022, 1, 3), 54 | expiration_date=date(2025, 1, 3), 55 | cut=NysePMCut() 56 | ), 57 | denomination_currency=Currency.USD, 58 | contract_size=100 59 | ) 60 | ``` 61 | 62 | ## Linting and Code Formatting 63 | 64 | This project uses [black](https://github.com/psf/black) for code linting and auto-formatting. If the CI pipeline fails 65 | at the linting stage, you can auto-format the code by running: 66 | 67 | ```bash 68 | # Install black if not already installed 69 | pip install black==22.12.0 70 | 71 | # Auto-format code 72 | black ./finstruments 73 | black ./tests 74 | ``` 75 | 76 | ## Documentation 77 | 78 | We use [pdoc3](https://pdoc3.github.io/pdoc/) to automatically generate documentation. All Python code must follow 79 | the [Google docstring format](https://sphinxcontrib-napoleon.readthedocs.io/en/latest/example_google.html) for 80 | compatibility with `pdoc3`. 81 | 82 | ### Generating HTML Documentation 83 | 84 | To generate the documentation in HTML format, run: 85 | 86 | ```bash 87 | pdoc3 --html ./finstruments/ --output-dir ./docs/generated --force 88 | ``` 89 | 90 | ### Generating Markdown Documentation 91 | 92 | To generate the documentation in Markdown format, run: 93 | 94 | ```bash 95 | pdoc3 ./finstruments/ --template-dir ./docs/templates --output-dir ./docs/md --force --config='docformat="google"' 96 | ``` 97 | 98 | ## Contributing 99 | 100 | We welcome contributions! If you have suggestions, find bugs, or want to add features, feel free to open an issue or 101 | submit a pull request. 102 | 103 | ### Setting Up a Development Environment 104 | 105 | 1. Clone the repository: 106 | ```bash 107 | git clone https://github.com/kyleloomis/finstruments.git 108 | ``` 109 | 110 | 2. Install dependencies: 111 | ```bash 112 | pip install . 113 | ``` 114 | 115 | 3. Run the tests to ensure everything is set up correctly: 116 | ```bash 117 | pytest 118 | ``` 119 | 120 | ## Help and Support 121 | 122 | For help or feedback, please reach out via email at [kyle@spotlight.dev](mailto:kyle@spotlight.dev). 123 | 124 | ## License 125 | 126 | This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details. 127 | -------------------------------------------------------------------------------- /finstruments/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Financial Instruments. 3 | """ 4 | -------------------------------------------------------------------------------- /finstruments/common/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Common utilities and helper functions. 3 | """ 4 | -------------------------------------------------------------------------------- /finstruments/common/base.py: -------------------------------------------------------------------------------- 1 | """ 2 | Base classes with custom attributes for updating, serializing and deserializing data classes and enums. 3 | """ 4 | 5 | import base64 6 | import copy 7 | import json 8 | from abc import ABC 9 | from datetime import date, datetime, tzinfo 10 | from typing import List 11 | 12 | import pydash as _ 13 | from pydantic import BaseModel 14 | from pydantic.utils import deep_update 15 | 16 | from finstruments.common.date import date_to_timestamp, datetime_to_timestamp 17 | from finstruments.common.function import to_nested_dict 18 | 19 | 20 | class Base(ABC, BaseModel): 21 | """ 22 | Base class used for all data classes. 23 | """ 24 | 25 | class Config: 26 | # use enum values when using .dict() on object 27 | use_enum_values = True 28 | 29 | json_encoders = { 30 | date: date_to_timestamp, 31 | datetime: datetime_to_timestamp, 32 | tzinfo: str, 33 | } 34 | 35 | @classmethod 36 | def cls_name(cls) -> str: 37 | """ 38 | Get class name. 39 | 40 | Returns: 41 | str: Class name 42 | """ 43 | return cls.__name__ 44 | 45 | def request_dict(self) -> dict: 46 | """ 47 | Convert data class to dict. Used instead of `.dict()` to serialize dates as timestamps. 48 | 49 | Returns: 50 | dict: Serialized data class as dict 51 | """ 52 | return json.loads(self.json(by_alias=True)) 53 | 54 | def base64_encoded(self, exclude=None) -> bytes: 55 | """ 56 | Base-64 encode data class. 57 | 58 | Returns: 59 | bytes: Base-64 encoded data class as bytes 60 | """ 61 | json_str = json.dumps(self.json(exclude=exclude), sort_keys=True) 62 | bytes_rep = bytes(json_str, "utf-8") 63 | return base64.b64encode(bytes_rep) 64 | 65 | def __hash__(self): 66 | """ 67 | Pydantic doesn't support hashing its base models so this is a work around 68 | 69 | https://stackoverflow.com/a/63774573/8189527 70 | """ 71 | return hash((type(self),) + tuple(self.__dict__.items())) 72 | 73 | def copy(self, ignored_fields: List[str] = None, **kwargs) -> "Base": 74 | """ 75 | Create a copy of the object 76 | 77 | Parameters: 78 | ignored_fields: fields to ignore from the original object when copying 79 | **kwargs: key value pairs of fields you want to change during the copy, for nested fields delimit the keys 80 | with a period (e.g. `.`) 81 | 82 | Returns: 83 | Copy of the opbject 84 | """ 85 | cls = self.__class__ 86 | obj_dict = copy.deepcopy(self.request_dict()) 87 | ignored_fields = [] if ignored_fields is None else ignored_fields 88 | 89 | updated_fields = to_nested_dict(kwargs) 90 | obj_dict = deep_update(obj_dict, updated_fields) 91 | [_.unset(obj_dict, field) for field in ignored_fields] 92 | return cls(**obj_dict) 93 | -------------------------------------------------------------------------------- /finstruments/common/base_enum.py: -------------------------------------------------------------------------------- 1 | """ 2 | Base enum class. 3 | """ 4 | 5 | from enum import Enum 6 | 7 | 8 | class BaseEnum(str, Enum): 9 | """ 10 | Base enum class used by all enum classes. 11 | 12 | Note: Inheriting from str is necessary to correctly serialize output of enum 13 | """ 14 | 15 | pass 16 | -------------------------------------------------------------------------------- /finstruments/common/date/__init__.py: -------------------------------------------------------------------------------- 1 | from finstruments.common.date.function import ( 2 | date_to_timestamp, 3 | datetime_to_timestamp, 4 | datetime_to_utc, 5 | date_to_datetime, 6 | create_dates_between, 7 | ) 8 | -------------------------------------------------------------------------------- /finstruments/common/date/enum.py: -------------------------------------------------------------------------------- 1 | """ 2 | Enums for payment frequency, day count convention and period. 3 | """ 4 | 5 | from finstruments.common.base_enum import BaseEnum 6 | 7 | 8 | class PaymentFrequency(BaseEnum): 9 | """ 10 | Payment frequency used to discount cashflows and accrue interest. 11 | """ 12 | 13 | DAILY = "DAILY" 14 | WEEKLY = "WEEKLY" 15 | SEMI_MONTHLY = "SEMI_MONTHLY" 16 | MONTHLY = "MONTHLY" 17 | SEMI_QUARTERLY = "SEMI_QUARTERLY" 18 | QUARTERLY = "QUARTERLY" 19 | TRI_ANNUALLY = "TRI_ANNUALLY" 20 | SEMI_ANNUALLY = "SEMI_ANNUALLY" 21 | ANNUALLY = "ANNUALLY" 22 | 23 | def __int__(self): 24 | if self == PaymentFrequency.DAILY: 25 | return 252 26 | elif self == PaymentFrequency.WEEKLY: 27 | return 52 28 | elif self == PaymentFrequency.SEMI_MONTHLY: 29 | return 26 30 | elif self == PaymentFrequency.MONTHLY: 31 | return 12 32 | elif self == PaymentFrequency.SEMI_QUARTERLY: 33 | return 6 34 | elif self == PaymentFrequency.QUARTERLY: 35 | return 4 36 | elif self == PaymentFrequency.TRI_ANNUALLY: 37 | return 3 38 | elif self == PaymentFrequency.SEMI_ANNUALLY: 39 | return 2 40 | elif self == PaymentFrequency.ANNUALLY: 41 | return 1 42 | else: 43 | raise Exception(f"PaymentFrequency '{self}' not supported") 44 | 45 | 46 | class DayCountConvention(BaseEnum): 47 | """ 48 | Day count convention to determine how interest accrues over payment periods. 49 | """ 50 | 51 | # Actual/360: Number of days between dates divided by 360 52 | ACTUAL_360 = "ACTUAL_360" 53 | 54 | # Actual/364: Number of days between dates divided by 364 55 | ACTUAL_364 = "ACTUAL_364" 56 | 57 | # Actual/365 FIXED: Number of days between dates divided by 365 58 | ACTUAL_365F = "ACTUAL_365F" 59 | 60 | # Actual/365_2425: Number of days between dates divided by 365.25 61 | ACTUAL_365_2425 = "ACTUAL_365_2425" 62 | 63 | def __float__(self): 64 | if self == DayCountConvention.ACTUAL_360: 65 | return 360 66 | elif self == DayCountConvention.ACTUAL_364: 67 | return 364 68 | elif self == DayCountConvention.ACTUAL_365F: 69 | return 365 70 | elif self == DayCountConvention.ACTUAL_365_2425: 71 | return 365.2425 72 | else: 73 | raise Exception(f"DayCountConvention '{self}' not supported") 74 | 75 | def __int__(self): 76 | return int(float(self)) 77 | 78 | 79 | class TimeUnit(BaseEnum): 80 | """ 81 | Time unit. 82 | """ 83 | 84 | DAY = "DAY" 85 | WEEK = "WEEK" 86 | MONTH = "MONTH" 87 | YEAR = "YEAR" 88 | 89 | 90 | class CompoundingConvention(BaseEnum): 91 | SIMPLE = "SIMPLE" 92 | COMPOUNDED = "COMPOUNDED" 93 | CONTINUOUS = "CONTINUOUS" 94 | SIMPLE_THEN_COMPOUNDED = "SIMPLE_THEN_COMPOUNDED" 95 | COMPOUNDED_THEN_SIMPLE = "COMPOUNDED_THEN_SIMPLE" 96 | 97 | 98 | class Frequency(BaseEnum): 99 | NO_FREQUENCY = "NO_FREQUENCY" 100 | ONCE = "ONCE" 101 | ANNUAL = "ANNUAL" 102 | SEMIANNUAL = "SEMIANNUAL" 103 | EVERY_FOURTH_MONTH = "EVERY_FOURTH_MONTH" 104 | QUARTERLY = "QUARTERLY" 105 | BIMONTHLY = "BIMONTHLY" 106 | MONTHLY = "MONTHLY" 107 | EVERY_FOURTH_WEEK = "EVERY_FOURTH_WEEK" 108 | BIWEEKLY = "BIWEEKLY" 109 | WEEKLY = "WEEKLY" 110 | DAILY = "DAILY" 111 | 112 | 113 | class BusinessDayConvention(BaseEnum): 114 | """ 115 | Business day convention 116 | FOLLOWING: The date is corrected to the first working day that follows. 117 | 118 | MODIFIED_FOLLOWING: The date is corrected to the first working day after that, unless this working day is in the 119 | next month; if the modified working day is in the next month, the date is corrected to the last working day 120 | that appears before, to ensure the original The date and the revised date are in the same month. 121 | 122 | PRECEDING: Correct the date to the last business day that Preceding before. 123 | 124 | MODIFIED_PRECEDING: Modify the date to the last working day that appeared before, unless the working sunrise is 125 | now the previous month; if the modified working sunrise is now the previous month, the date is modified to the 126 | first working day after that The original date and the revised date are guaranteed to be in the same month. 127 | 128 | UNADJUSTED: No adjustment. 129 | """ 130 | 131 | FOLLOWING = "FOLLOWING" 132 | MODIFIED_FOLLOWING = "MODIFIED_FOLLOWING" 133 | PRECEDING = "PRECEDING" 134 | MODIFIED_PRECEDING = "MODIFIED_PRECEDING" 135 | UNADJUSTED = "UNADJUSTED" 136 | 137 | 138 | SECONDS_IN_MINUTE = 60 139 | MINUTES_IN_HOUR = 60 140 | HOURS_IN_DAY = 24 141 | DAYS_IN_WEEK = 7 142 | 143 | SECONDS_IN_DAY = SECONDS_IN_MINUTE * MINUTES_IN_HOUR * HOURS_IN_DAY 144 | -------------------------------------------------------------------------------- /finstruments/common/date/function.py: -------------------------------------------------------------------------------- 1 | """ 2 | Date and time functions. 3 | """ 4 | 5 | import calendar 6 | from datetime import date, datetime, time, timedelta 7 | from functools import lru_cache 8 | from typing import List 9 | 10 | import pytz 11 | from workalendar.usa import UnitedStates 12 | 13 | from finstruments.common.date.enum import DayCountConvention, SECONDS_IN_DAY 14 | 15 | 16 | @lru_cache(10) 17 | def seconds_in_year(day_count_convention: DayCountConvention) -> float: 18 | """ 19 | Calculate number of seconds per year based on day count convention. LRU cache is used to have 4x performance. 20 | 21 | Args: 22 | day_count_convention (DayCountConvention): Day count convention to use in calculation 23 | 24 | Returns: 25 | float: Number of seconds per year based on day count convention 26 | """ 27 | return SECONDS_IN_DAY * float(day_count_convention) 28 | 29 | 30 | def date_to_timestamp(as_of_date: date) -> int: 31 | """ 32 | Convert date to epoch timestamp in milliseconds. 33 | 34 | Args: 35 | as_of_date (date): Python date 36 | 37 | Returns: 38 | int: Epoch timestamp in milliseconds 39 | """ 40 | return calendar.timegm(as_of_date.timetuple()) * 1000 41 | 42 | 43 | def datetime_to_timestamp(dt: datetime) -> int: 44 | """ 45 | Convert datetime to epoch timestamp in milliseconds. 46 | 47 | Args: 48 | dt (datetime): Python datetime 49 | 50 | Returns: 51 | int: Epoch timestamp in milliseconds 52 | """ 53 | return calendar.timegm(dt.utctimetuple()) * 1000 54 | 55 | 56 | def datetime_to_utc(dt: datetime) -> datetime: 57 | """ 58 | Standardize datetime to UTC. Assume that datetime where `tzinfo=None` is already in UTC. 59 | 60 | Args: 61 | dt (datetime): Python datetime 62 | 63 | Returns: 64 | datetime: Python datetime with standardized UTC timezone (`tzinfo=None`) 65 | """ 66 | # assume that datetime without timezone is already in UTC 67 | if dt.tzinfo is None: 68 | return dt 69 | return dt.astimezone(pytz.utc).replace(tzinfo=None) 70 | 71 | 72 | def date_to_datetime( 73 | as_of_date: date, as_of_time: time = datetime.min.time() 74 | ) -> datetime: 75 | """ 76 | Convert date and optional time to datetime. NOTE: time should not contain a timezone or else offset may not be 77 | correct. 78 | 79 | Args: 80 | as_of_date (date): Python date 81 | as_of_time (time): Python time 82 | 83 | Returns: 84 | datetime: Python datetime 85 | """ 86 | return datetime.combine(as_of_date, as_of_time) 87 | 88 | 89 | def create_dates_between(start: date, end: date, frequency: str = "B") -> List[date]: 90 | """ 91 | Create dates between start and end date (inclusive). Frequency used to determine which days of the week are used. 92 | 93 | Args: 94 | start (date): Python date 95 | end (date): Python date 96 | frequency (str): Frequency for date range, "B" for business days, "D" for daily 97 | 98 | Returns: 99 | List[date]: List of dates 100 | """ 101 | if frequency == "B": # Business days, excluding weekends 102 | cal = UnitedStates() 103 | current = start 104 | dates = [] 105 | while current <= end: 106 | if cal.is_working_day(current): 107 | dates.append(current) 108 | current += timedelta(days=1) 109 | return dates 110 | elif frequency == "D": # Daily frequency 111 | return [start + timedelta(days=i) for i in range((end - start).days + 1)] 112 | else: 113 | raise ValueError( 114 | "Unsupported frequency. Use 'B' for business days or 'D' for daily." 115 | ) 116 | -------------------------------------------------------------------------------- /finstruments/common/decorators/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Common decorators. 3 | """ 4 | 5 | from finstruments.common.decorators.serializable import ( 6 | serializable, 7 | serializable_base_class, 8 | ) 9 | -------------------------------------------------------------------------------- /finstruments/common/decorators/serializable/__init__.py: -------------------------------------------------------------------------------- 1 | from finstruments.common.decorators.serializable.decorators import ( 2 | serializable, 3 | serializable_base_class, 4 | ) 5 | -------------------------------------------------------------------------------- /finstruments/common/decorators/serializable/__util.py: -------------------------------------------------------------------------------- 1 | def init_subclass(cls): 2 | cls._subtypes_[cls.__name__] = cls 3 | 4 | 5 | def parse_obj(cls, obj): 6 | return cls._convert_to_real_type_(obj) 7 | 8 | 9 | def get_validators(cls): 10 | yield cls._convert_to_real_type_ 11 | 12 | 13 | def convert_to_real_type(cls, data): 14 | if not isinstance(data, dict): 15 | return data 16 | 17 | data_type = data.get("descriptor") 18 | 19 | if data_type is None: 20 | raise ValueError("Missing 'descriptor'") 21 | 22 | searched_types = set() 23 | 24 | def search_for_subtype(cls): 25 | nonlocal searched_types, data_type 26 | 27 | sub = cls._subtypes_.get(data_type) 28 | if sub: 29 | return sub 30 | 31 | searched_types.add(cls) 32 | for _, sub_type in cls._subtypes_.items(): 33 | if sub_type not in searched_types: 34 | result = search_for_subtype(sub_type) 35 | if result: 36 | return result 37 | return None 38 | 39 | sub = search_for_subtype(cls) 40 | if sub is None: 41 | raise TypeError(f"Unsupport sub-type: {data_type}") 42 | 43 | return sub(**data) 44 | -------------------------------------------------------------------------------- /finstruments/common/decorators/serializable/decorators.py: -------------------------------------------------------------------------------- 1 | from typing import Callable, Union 2 | 3 | from pydantic import Field 4 | 5 | from finstruments.common.base import Base 6 | from finstruments.common.decorators.serializable.__util import ( 7 | parse_obj, 8 | init_subclass, 9 | get_validators, 10 | convert_to_real_type, 11 | ) 12 | 13 | 14 | def serializable(_cls: Base = None) -> Union[Callable, Base]: 15 | """ 16 | This decorator adds an identifier to the decorated class for identification purposes when serializing and 17 | deserializing. 18 | 19 | NOTE: This decorator should be used on all children of base abstract classes that use the @serializable_base_class 20 | decorator. 21 | 22 | Args: 23 | _cls (object): The class being decorated 24 | 25 | Returns: 26 | cls: The updated class with the descriptor field 27 | """ 28 | 29 | def wrap(cls): 30 | return type(cls.cls_name(), (cls,), {"descriptor": Field(cls.cls_name())}) 31 | 32 | if _cls is None: 33 | return wrap 34 | 35 | return wrap(_cls) 36 | 37 | 38 | def serializable_base_class(_cls: Base = None) -> Union[Callable, Base]: 39 | """ 40 | This is a decorator sets up functionality to track all subtypes of a parent class. If you want to use a base 41 | class as a type signature and want the subtypes to be properly deserialized you need to add this decorator to 42 | the base class and @serializable to all of its children. 43 | 44 | NOTE: You should only use this decorator for base abstract classes 45 | 46 | Args: 47 | _cls (object): The class being decorated 48 | 49 | Returns: 50 | cls: The updated class with the descriptor field 51 | """ 52 | 53 | def wrap(cls): 54 | setattr(cls, "_subtypes_", dict()) 55 | setattr(cls, "parse_obj", classmethod(parse_obj)) 56 | setattr(cls, "__init_subclass__", classmethod(init_subclass)) 57 | setattr(cls, "__get_validators__", classmethod(get_validators)) 58 | setattr(cls, "_convert_to_real_type_", classmethod(convert_to_real_type)) 59 | return cls 60 | 61 | if _cls is None: 62 | return wrap 63 | 64 | return wrap(_cls) 65 | -------------------------------------------------------------------------------- /finstruments/common/enum.py: -------------------------------------------------------------------------------- 1 | """ 2 | Common enums. 3 | """ 4 | 5 | from functools import reduce 6 | from operator import mul 7 | from typing import List 8 | 9 | from finstruments.common.base_enum import BaseEnum 10 | 11 | 12 | class Currency(BaseEnum): 13 | """Currency, ISO 4217 currency code or exchange quote modifier (e.g. GBP vs GBp)""" 14 | 15 | _ = "" 16 | ACU = "ACU" 17 | ADP = "ADP" 18 | AED = "AED" 19 | AFA = "AFA" 20 | ALL = "ALL" 21 | AMD = "AMD" 22 | ANG = "ANG" 23 | AOA = "AOA" 24 | AOK = "AOK" 25 | AON = "AON" 26 | ARA = "ARA" 27 | ARS = "ARS" 28 | ARZ = "ARZ" 29 | ATS = "ATS" 30 | AUD = "AUD" 31 | AUZ = "AUZ" 32 | AZM = "AZM" 33 | AZN = "AZN" 34 | B03 = "B03" 35 | BAD = "BAD" 36 | BAK = "BAK" 37 | BAM = "BAM" 38 | BBD = "BBD" 39 | BDN = "BDN" 40 | BDT = "BDT" 41 | BEF = "BEF" 42 | BGL = "BGL" 43 | BGN = "BGN" 44 | BHD = "BHD" 45 | BIF = "BIF" 46 | BMD = "BMD" 47 | BND = "BND" 48 | BOB = "BOB" 49 | BR6 = "BR6" 50 | BRE = "BRE" 51 | BRF = "BRF" 52 | BRL = "BRL" 53 | BRR = "BRR" 54 | BSD = "BSD" 55 | BTC = "BTC" 56 | BTN = "BTN" 57 | BTR = "BTR" 58 | BWP = "BWP" 59 | BYR = "BYR" 60 | BZD = "BZD" 61 | C23 = "C23" 62 | CAC = "CAC" 63 | CAD = "CAD" 64 | CAZ = "CAZ" 65 | CCI = "CCI" 66 | CDF = "CDF" 67 | CFA = "CFA" 68 | CHF = "CHF" 69 | CHZ = "CHZ" 70 | CLF = "CLF" 71 | CLP = "CLP" 72 | CLZ = "CLZ" 73 | CNH = "CNH" 74 | CNO = "CNO" 75 | CNY = "CNY" 76 | CNZ = "CNZ" 77 | COP = "COP" 78 | COZ = "COZ" 79 | CPB = "CPB" 80 | CPI = "CPI" 81 | CRC = "CRC" 82 | CUP = "CUP" 83 | CVE = "CVE" 84 | CYP = "CYP" 85 | CZH = "CZH" 86 | CZK = "CZK" 87 | DAX = "DAX" 88 | DEM = "DEM" 89 | DIJ = "DIJ" 90 | DJF = "DJF" 91 | DKK = "DKK" 92 | DOP = "DOP" 93 | DZD = "DZD" 94 | E51 = "E51" 95 | E52 = "E52" 96 | E53 = "E53" 97 | E54 = "E54" 98 | ECI = "ECI" 99 | ECS = "ECS" 100 | ECU = "ECU" 101 | EEK = "EEK" 102 | EF0 = "EF0" 103 | EGP = "EGP" 104 | ESP = "ESP" 105 | ETB = "ETB" 106 | EUR = "EUR" 107 | EUZ = "EUZ" 108 | F06 = "F06" 109 | FED = "FED" 110 | FIM = "FIM" 111 | FJD = "FJD" 112 | FKP = "FKP" 113 | FRF = "FRF" 114 | FT1 = "FT1" 115 | GBP = "GBP" 116 | GBZ = "GBZ" 117 | GEK = "GEK" 118 | GEL = "GEL" 119 | GHC = "GHC" 120 | GHS = "GHS" 121 | GHY = "GHY" 122 | GIP = "GIP" 123 | GLD = "GLD" 124 | GLR = "GLR" 125 | GMD = "GMD" 126 | GNF = "GNF" 127 | GQE = "GQE" 128 | GRD = "GRD" 129 | GTQ = "GTQ" 130 | GWP = "GWP" 131 | GYD = "GYD" 132 | HKB = "HKB" 133 | HKD = "HKD" 134 | HNL = "HNL" 135 | HRK = "HRK" 136 | HSI = "HSI" 137 | HTG = "HTG" 138 | HUF = "HUF" 139 | IDB = "IDB" 140 | IDO = "IDO" 141 | IDR = "IDR" 142 | IEP = "IEP" 143 | IGP = "IGP" 144 | ILS = "ILS" 145 | INO = "INO" 146 | INP = "INP" 147 | INR = "INR" 148 | IPA = "IPA" 149 | IPX = "IPX" 150 | IQD = "IQD" 151 | IRR = "IRR" 152 | IRS = "IRS" 153 | ISI = "ISI" 154 | ISK = "ISK" 155 | ISO = "ISO" 156 | ITL = "ITL" 157 | J05 = "J05" 158 | JMD = "JMD" 159 | JNI = "JNI" 160 | JOD = "JOD" 161 | JPY = "JPY" 162 | JPZ = "JPZ" 163 | JZ9 = "JZ9" 164 | KES = "KES" 165 | KGS = "KGS" 166 | KHR = "KHR" 167 | KMF = "KMF" 168 | KOR = "KOR" 169 | KPW = "KPW" 170 | KRW = "KRW" 171 | KWD = "KWD" 172 | KYD = "KYD" 173 | KZT = "KZT" 174 | LAK = "LAK" 175 | LBA = "LBA" 176 | LBP = "LBP" 177 | LHY = "LHY" 178 | LKR = "LKR" 179 | LRD = "LRD" 180 | LSL = "LSL" 181 | LSM = "LSM" 182 | LTL = "LTL" 183 | LUF = "LUF" 184 | LVL = "LVL" 185 | LYD = "LYD" 186 | MAD = "MAD" 187 | MDL = "MDL" 188 | MGF = "MGF" 189 | MKD = "MKD" 190 | MMK = "MMK" 191 | MNT = "MNT" 192 | MOP = "MOP" 193 | MRO = "MRO" 194 | MTP = "MTP" 195 | MUR = "MUR" 196 | MVR = "MVR" 197 | MWK = "MWK" 198 | MXB = "MXB" 199 | MXN = "MXN" 200 | MXP = "MXP" 201 | MXW = "MXW" 202 | MXZ = "MXZ" 203 | MYO = "MYO" 204 | MYR = "MYR" 205 | MZM = "MZM" 206 | MZN = "MZN" 207 | NAD = "NAD" 208 | ND3 = "ND3" 209 | NGF = "NGF" 210 | NGI = "NGI" 211 | NGN = "NGN" 212 | NIC = "NIC" 213 | NLG = "NLG" 214 | NOK = "NOK" 215 | NOZ = "NOZ" 216 | NPR = "NPR" 217 | NZD = "NZD" 218 | NZZ = "NZZ" 219 | O08 = "O08" 220 | OMR = "OMR" 221 | PAB = "PAB" 222 | PEI = "PEI" 223 | PEN = "PEN" 224 | PEZ = "PEZ" 225 | PGK = "PGK" 226 | PHP = "PHP" 227 | PKR = "PKR" 228 | PLN = "PLN" 229 | PLZ = "PLZ" 230 | PSI = "PSI" 231 | PTE = "PTE" 232 | PYG = "PYG" 233 | QAR = "QAR" 234 | R2K = "R2K" 235 | ROL = "ROL" 236 | RON = "RON" 237 | RSD = "RSD" 238 | RUB = "RUB" 239 | RUF = "RUF" 240 | RUR = "RUR" 241 | RWF = "RWF" 242 | SAR = "SAR" 243 | SBD = "SBD" 244 | SCR = "SCR" 245 | SDP = "SDP" 246 | SDR = "SDR" 247 | SEK = "SEK" 248 | SET = "SET" 249 | SGD = "SGD" 250 | SGS = "SGS" 251 | SHP = "SHP" 252 | SKK = "SKK" 253 | SLL = "SLL" 254 | SRG = "SRG" 255 | SSI = "SSI" 256 | STD = "STD" 257 | SUR = "SUR" 258 | SVC = "SVC" 259 | SVT = "SVT" 260 | SYP = "SYP" 261 | SZL = "SZL" 262 | T21 = "T21" 263 | T51 = "T51" 264 | T52 = "T52" 265 | T53 = "T53" 266 | T54 = "T54" 267 | T55 = "T55" 268 | T71 = "T71" 269 | TE0 = "TE0" 270 | TED = "TED" 271 | TF9 = "TF9" 272 | THB = "THB" 273 | THO = "THO" 274 | TMM = "TMM" 275 | TND = "TND" 276 | TNT = "TNT" 277 | TOP = "TOP" 278 | TPE = "TPE" 279 | TPX = "TPX" 280 | TRB = "TRB" 281 | TRL = "TRL" 282 | TRY = "TRY" 283 | TRZ = "TRZ" 284 | TTD = "TTD" 285 | TWD = "TWD" 286 | TZS = "TZS" 287 | UAH = "UAH" 288 | UCB = "UCB" 289 | UDI = "UDI" 290 | UFC = "UFC" 291 | UFZ = "UFZ" 292 | UGS = "UGS" 293 | UGX = "UGX" 294 | USB = "USB" 295 | USD = "USD" 296 | UVR = "UVR" 297 | UYP = "UYP" 298 | UYU = "UYU" 299 | UZS = "UZS" 300 | VAC = "VAC" 301 | VEB = "VEB" 302 | VEF = "VEF" 303 | VES = "VES" 304 | VND = "VND" 305 | VUV = "VUV" 306 | WST = "WST" 307 | XAF = "XAF" 308 | XAG = "XAG" 309 | XAU = "XAU" 310 | XPD = "XPD" 311 | XPT = "XPT" 312 | XCD = "XCD" 313 | XDR = "XDR" 314 | XEU = "XEU" 315 | XOF = "XOF" 316 | XPF = "XPF" 317 | YDD = "YDD" 318 | YER = "YER" 319 | YUD = "YUD" 320 | YUN = "YUN" 321 | ZAL = "ZAL" 322 | ZAR = "ZAR" 323 | ZAZ = "ZAZ" 324 | ZMK = "ZMK" 325 | ZMW = "ZMW" 326 | ZRN = "ZRN" 327 | ZRZ = "ZRZ" 328 | ZWD = "ZWD" 329 | AUd = "AUd" 330 | BWp = "BWp" 331 | EUr = "EUr" 332 | GBp = "GBp" 333 | ILs = "ILs" 334 | KWd = "KWd" 335 | MWk = "MWk" 336 | SGd = "SGd" 337 | SZl = "SZl" 338 | USd = "USd" 339 | ZAr = "ZAr" 340 | 341 | 342 | class Average(BaseEnum): 343 | """""" 344 | 345 | ARITHMETIC = "ARITHMETIC" 346 | GEOMETRIC = "GEOMETRIC" 347 | 348 | def apply(self, values: List[float]) -> float: 349 | """ 350 | Apply the average calculation over the given array. 351 | 352 | Args: 353 | values (list[float]): A list of numeric values. 354 | 355 | Returns: 356 | float: The calculated average value. 357 | 358 | Raises: 359 | ValueError: If the input array is empty. 360 | Exception: If the average type is not supported. 361 | """ 362 | if not values: 363 | raise ValueError("The input array cannot be empty") 364 | 365 | if self == Average.ARITHMETIC: 366 | return sum(values) / len(values) 367 | elif self == Average.GEOMETRIC: 368 | product = reduce(mul, values, 1) 369 | return product ** (1 / len(values)) 370 | else: 371 | raise Exception(f"Average type '{self}' not supported") 372 | -------------------------------------------------------------------------------- /finstruments/common/function.py: -------------------------------------------------------------------------------- 1 | """ 2 | Helper functions. 3 | """ 4 | 5 | 6 | def to_nested_dict(data, separator="."): 7 | """ 8 | This method takes a dict and splits keys by the seperator to create a nested dict 9 | 10 | Parameters: 11 | data: dict to unwind into a nested dict 12 | separator: seperator used to split keys to build the nested dict 13 | 14 | Returns: 15 | A nested dict 16 | """ 17 | nested_dict = {} 18 | for key, value in data.items(): 19 | keys = key.split(separator) 20 | d = nested_dict 21 | for subkey in keys[:-1]: 22 | if subkey not in d: 23 | d[subkey] = {} 24 | d = d[subkey] 25 | d[keys[-1]] = value 26 | return nested_dict 27 | -------------------------------------------------------------------------------- /finstruments/common/period.py: -------------------------------------------------------------------------------- 1 | from datetime import date 2 | 3 | from dateutil.relativedelta import relativedelta 4 | 5 | from finstruments.common.base import Base 6 | from finstruments.common.date.enum import TimeUnit 7 | 8 | 9 | class Period(Base): 10 | """ 11 | Period class contains n periods (e.g. 3) and time unit (e.g. MONTH). 12 | """ 13 | 14 | unit: TimeUnit 15 | n: int 16 | 17 | class Config(Base.Config): 18 | # override to ensure that TimeUnit stays as an enum until serialized 19 | use_enum_values = False 20 | 21 | def advance(self, as_of_date: date) -> date: 22 | if self.unit == TimeUnit.DAY: 23 | return as_of_date + relativedelta(days=self.n) 24 | elif self.unit == TimeUnit.WEEK: 25 | return as_of_date + relativedelta(weeks=self.n) 26 | elif self.unit == TimeUnit.MONTH: 27 | return as_of_date + relativedelta(months=self.n) 28 | elif self.unit == TimeUnit.YEAR: 29 | return as_of_date + relativedelta(years=self.n) 30 | else: 31 | raise Exception(f"TimeUnit '{self.unit}' not supported") 32 | -------------------------------------------------------------------------------- /finstruments/common/type.py: -------------------------------------------------------------------------------- 1 | """ 2 | Common types. 3 | """ 4 | 5 | from datetime import date, datetime 6 | from typing import NewType, Union 7 | 8 | Datetime = NewType("Datetime", Union[date, datetime]) 9 | -------------------------------------------------------------------------------- /finstruments/instrument/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Instrument definitions. 3 | """ 4 | 5 | from finstruments.instrument.abstract import BaseInstrument 6 | -------------------------------------------------------------------------------- /finstruments/instrument/abstract.py: -------------------------------------------------------------------------------- 1 | from abc import ABC 2 | from datetime import date 3 | from typing import Optional 4 | 5 | from pydantic import Field 6 | 7 | from finstruments.common.base import Base 8 | from finstruments.common.decorators import serializable_base_class 9 | from finstruments.common.enum import Currency 10 | 11 | 12 | @serializable_base_class 13 | class BaseInstrument(Base, ABC): 14 | """ 15 | Base instrument data class to be inherited from all instrument subclasses. Contains core fields that are applicable 16 | to all instruments. 17 | """ 18 | 19 | agreed_discount_rate: Optional[str] = Field(default=None) 20 | pillar_date: Optional[date] = Field(default=None) 21 | denomination_currency: Optional[Currency] = Field(default=None) 22 | code: str 23 | descriptor: str = Field(default=None) 24 | 25 | @property 26 | def underlying_instrument(self) -> "BaseInstrument": 27 | """ 28 | Get and return BaseInstrument in "underlying" field if exists, else self. 29 | 30 | Returns: 31 | BaseInstrument: BaseInstrument in "underlying" field if exists, else self 32 | """ 33 | return getattr(self, "underlying", None) or self 34 | -------------------------------------------------------------------------------- /finstruments/instrument/commodity/__init__.py: -------------------------------------------------------------------------------- 1 | from finstruments.instrument.commodity.instrument import ( 2 | BaseCommodity, 3 | CommodityIndex, 4 | Commodity, 5 | CommodityForward, 6 | CommodityFuture, 7 | CommodityOption, 8 | CommodityFutureOption, 9 | ) 10 | -------------------------------------------------------------------------------- /finstruments/instrument/commodity/instrument.py: -------------------------------------------------------------------------------- 1 | from abc import ABC 2 | from datetime import date 3 | from typing import Optional 4 | 5 | from pydantic import Field 6 | 7 | from finstruments.common.decorators import serializable, serializable_base_class 8 | from finstruments.common.enum import Currency 9 | from finstruments.instrument.abstract import BaseInstrument 10 | from finstruments.instrument.common.enum import SettlementType 11 | from finstruments.instrument.common.exercise_style import ( 12 | EuropeanExerciseStyle, 13 | BaseExerciseStyle, 14 | ) 15 | from finstruments.instrument.common.forward import VanillaForward 16 | from finstruments.instrument.common.future import VanillaFuture 17 | from finstruments.instrument.common.option import VanillaOption 18 | from finstruments.instrument.common.option.payoff import BaseFixedStrikePayoff 19 | 20 | 21 | @serializable_base_class 22 | class BaseCommodity(BaseInstrument, ABC): 23 | """ 24 | Commodity base class. 25 | """ 26 | 27 | name: str 28 | agreed_discount_rate: Optional[str] = Field(init=False, default=None) 29 | pillar_date: Optional[date] = Field(init=False, default=None) 30 | denomination_currency: Optional[Currency] = Field(default=None) 31 | code: str 32 | 33 | 34 | @serializable 35 | class CommodityIndex(BaseCommodity): 36 | """ 37 | Commodity index. 38 | """ 39 | 40 | code: str = Field(init=False, default="COMMODITY_INDEX") 41 | 42 | 43 | @serializable 44 | class Commodity(BaseCommodity): # TODO rename 45 | """ 46 | Commodity. 47 | """ 48 | 49 | settlement_type: SettlementType 50 | code: str = Field(init=False, default="COMMODITY") 51 | 52 | 53 | @serializable 54 | class CommodityForward(VanillaForward): 55 | underlying: BaseCommodity 56 | exercise_type: EuropeanExerciseStyle 57 | strike_price: float 58 | contract_size: float 59 | unit: str 60 | denomination_currency: Currency 61 | agreed_discount_rate: Optional[str] = Field(default=None) 62 | code: str = Field(init=False, default="COMMODITY_FORWARD") 63 | 64 | 65 | @serializable 66 | class CommodityFuture(VanillaFuture): 67 | underlying: BaseCommodity 68 | exercise_type: EuropeanExerciseStyle 69 | strike_price: float 70 | contract_size: float 71 | unit: str 72 | denomination_currency: Currency 73 | agreed_discount_rate: Optional[str] = Field(default=None) 74 | code: str = Field(init=False, default="COMMODITY_FUTURE") 75 | 76 | 77 | @serializable 78 | class CommodityOption(VanillaOption): 79 | underlying: BaseCommodity 80 | payoff: BaseFixedStrikePayoff 81 | exercise_type: BaseExerciseStyle 82 | contract_size: float 83 | unit: str 84 | denomination_currency: Currency 85 | agreed_discount_rate: Optional[str] = Field(default=None) 86 | code: str = Field(init=False, default="COMMODITY_OPTION") 87 | 88 | 89 | @serializable 90 | class CommodityFutureOption(VanillaOption): 91 | underlying: CommodityFuture 92 | payoff: BaseFixedStrikePayoff 93 | exercise_type: BaseExerciseStyle 94 | contract_size: float 95 | unit: str 96 | denomination_currency: Currency 97 | agreed_discount_rate: Optional[str] = Field(default=None) 98 | code: str = Field(init=False, default="COMMODITY_FUTURE_OPTION") 99 | -------------------------------------------------------------------------------- /finstruments/instrument/common/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kyleloomis/finstruments/f700ad52987bea74567a309889ae6f688d7d25b5/finstruments/instrument/common/__init__.py -------------------------------------------------------------------------------- /finstruments/instrument/common/currency_pair.py: -------------------------------------------------------------------------------- 1 | from typing import Union 2 | 3 | from finstruments.common.base import Base 4 | from finstruments.common.decorators import serializable 5 | from finstruments.common.enum import Currency 6 | from finstruments.instrument.cryptocurrency import Cryptocurrency 7 | 8 | 9 | @serializable 10 | class CurrencyPair(Base): 11 | """ 12 | Represents a trading pair between two assets, which can be cryptocurrencies or fiat currencies. 13 | """ 14 | 15 | base_currency: Union[Cryptocurrency, Currency] 16 | quote_currency: Union[Cryptocurrency, Currency] 17 | 18 | def __str__(self): 19 | base_currency = ( 20 | self.base_currency.value 21 | if isinstance(self.base_currency, Currency) 22 | else self.base_currency.ticker 23 | ) 24 | quote_currency = ( 25 | self.quote_currency.value 26 | if isinstance(self.quote_currency, Currency) 27 | else self.quote_currency.ticker 28 | ) 29 | return f"{base_currency}/{quote_currency}" 30 | -------------------------------------------------------------------------------- /finstruments/instrument/common/cut.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from datetime import date, datetime, time 3 | 4 | from pydantic import validator, Field 5 | from pytz import timezone 6 | from pytz.tzinfo import DstTzInfo 7 | 8 | from finstruments.common.base import Base 9 | from finstruments.common.date import date_to_datetime 10 | from finstruments.common.decorators import serializable, serializable_base_class 11 | 12 | 13 | @serializable_base_class 14 | class BaseObservationCut(Base, ABC): 15 | class Config(Base.Config): 16 | arbitrary_types_allowed = True 17 | 18 | timezone: DstTzInfo 19 | 20 | @validator("timezone", pre=True, allow_reuse=True) 21 | def parse_timezone(cls, value): 22 | return timezone(value) 23 | 24 | @abstractmethod 25 | def get_observation_datetime(self, as_of_date: date) -> datetime: 26 | pass 27 | 28 | 29 | @serializable 30 | class NyseAMCut(BaseObservationCut): 31 | timezone: DstTzInfo = Field(default=timezone("US/Eastern"), init=False) 32 | 33 | def get_observation_datetime(self, as_of_date: date) -> datetime: 34 | dt = date_to_datetime(as_of_date, time(9, 30, 0)) 35 | return self.timezone.localize(dt) 36 | 37 | 38 | @serializable 39 | class NysePMCut(BaseObservationCut): 40 | timezone: DstTzInfo = Field(default=timezone("US/Eastern"), init=False) 41 | 42 | def get_observation_datetime(self, as_of_date: date) -> datetime: 43 | dt = date_to_datetime(as_of_date, time(16, 0, 0)) 44 | return self.timezone.localize(dt) 45 | -------------------------------------------------------------------------------- /finstruments/instrument/common/enum.py: -------------------------------------------------------------------------------- 1 | from finstruments.common.base_enum import BaseEnum 2 | 3 | 4 | class SettlementType(BaseEnum): 5 | CASH = "CASH" 6 | PHYSICAL = "PHYSICAL" 7 | -------------------------------------------------------------------------------- /finstruments/instrument/common/exercise_style.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from datetime import date 3 | from typing import List 4 | 5 | from finstruments.common.base import Base 6 | from finstruments.common.date import create_dates_between 7 | from finstruments.common.decorators import serializable, serializable_base_class 8 | from finstruments.instrument.common.cut import BaseObservationCut 9 | 10 | 11 | @serializable_base_class 12 | class BaseExerciseStyle(Base, ABC): 13 | """Exercise Style""" 14 | 15 | expiration_date: date 16 | cut: BaseObservationCut 17 | 18 | @abstractmethod 19 | def can_exercise(self, as_of_date: date) -> bool: 20 | """ 21 | Returns boolean value depending on if instrument can be exercised. 22 | 23 | Args: 24 | as_of_date (date): Python date 25 | 26 | Returns: 27 | bool: Boolean value depending on if instrument can be exercised 28 | """ 29 | pass 30 | 31 | @abstractmethod 32 | def get_schedule(self) -> List[date]: 33 | """ 34 | Get all available dates that instrument can be exercised on. 35 | 36 | Returns: 37 | List[date]: All available dates that instrument can be exercised on 38 | """ 39 | pass 40 | 41 | 42 | @serializable 43 | class EuropeanExerciseStyle(BaseExerciseStyle): 44 | """European Exercise Style""" 45 | 46 | def can_exercise(self, as_of_date: date) -> bool: 47 | """ 48 | Returns True if date is equal to the expiration date, else False. 49 | 50 | Args: 51 | as_of_date (date): Python date 52 | 53 | Returns: 54 | bool: True if date is equal to the expiration date, else false 55 | """ 56 | return as_of_date == self.expiration_date 57 | 58 | def get_schedule(self) -> List[date]: 59 | """ 60 | Get all available dates that instrument can be exercised on - only exercise date. 61 | 62 | Returns: 63 | List[date]: All available dates that instrument can be exercised on - only exercise date 64 | """ 65 | return [self.expiration_date] 66 | 67 | 68 | @serializable 69 | class AmericanExerciseStyle(BaseExerciseStyle): 70 | """American Exercise Style""" 71 | 72 | minimum_exercise_date: date 73 | 74 | def can_exercise(self, as_of_date: date) -> bool: 75 | """ 76 | Returns True if date is less than or equal to the expiration date, else False. 77 | 78 | Args: 79 | as_of_date (date): Python date 80 | 81 | Returns: 82 | bool: True if date is less than or equal to the expiration date, else False 83 | """ 84 | return as_of_date <= self.expiration_date 85 | 86 | def get_schedule(self) -> List[date]: 87 | """ 88 | Get all available dates that instrument can be exercised on - all dates between minimum exercise date 89 | and expiration date. 90 | 91 | Returns: 92 | List[date]: All available dates that instrument can be exercised on - all dates between minimum exercise 93 | date and expiration date 94 | """ 95 | return create_dates_between(self.minimum_exercise_date, self.expiration_date) 96 | 97 | 98 | @serializable 99 | class BermudanExerciseStyle(BaseExerciseStyle): 100 | """Bermudan Exercise Style""" 101 | 102 | early_exercise_dates: List[date] # should not include expiration date 103 | 104 | def can_exercise(self, as_of_date: date) -> bool: 105 | """ 106 | Returns True if date is contained in early exercise or expiration dates. 107 | 108 | Args: 109 | as_of_date (date): Python date 110 | 111 | Returns: 112 | bool: True if date is contained in early exercise or expiration dates 113 | """ 114 | schedule = self.get_schedule() 115 | return as_of_date in schedule 116 | 117 | def get_schedule(self) -> List[date]: 118 | """ 119 | Get all available dates that instrument can be exercised on - combination of early exercise and expiration 120 | dates. 121 | 122 | Returns: 123 | List[date]: All available dates that instrument can be exercised on - combination of early exercise and 124 | expiration dates 125 | """ 126 | return self.early_exercise_dates + [self.expiration_date] 127 | -------------------------------------------------------------------------------- /finstruments/instrument/common/forward/__init__.py: -------------------------------------------------------------------------------- 1 | from finstruments.instrument.common.forward.instrument import VanillaForward 2 | -------------------------------------------------------------------------------- /finstruments/instrument/common/forward/instrument.py: -------------------------------------------------------------------------------- 1 | from abc import ABC 2 | from typing import Optional, Union 3 | 4 | from pydantic import Field 5 | 6 | from finstruments.common.decorators import serializable_base_class 7 | from finstruments.common.enum import Currency 8 | from finstruments.instrument.abstract import BaseInstrument 9 | from finstruments.instrument.common.exercise_style import BaseExerciseStyle 10 | 11 | 12 | @serializable_base_class 13 | class VanillaForward(BaseInstrument, ABC): 14 | underlying: BaseInstrument 15 | exercise_type: BaseExerciseStyle 16 | strike_price: float 17 | denomination_currency: Currency 18 | agreed_discount_rate: Optional[str] = Field(default=None) 19 | code: str 20 | 21 | def __init__(self, **data): 22 | exercise_type: Union[dict, BaseExerciseStyle] = data.get("exercise_type") 23 | if isinstance(exercise_type, dict): 24 | data["pillar_date"] = exercise_type["expiration_date"] 25 | else: 26 | data["pillar_date"] = exercise_type.expiration_date 27 | super().__init__(**data) 28 | -------------------------------------------------------------------------------- /finstruments/instrument/common/future/__init__.py: -------------------------------------------------------------------------------- 1 | from finstruments.instrument.common.future.instrument import VanillaFuture 2 | -------------------------------------------------------------------------------- /finstruments/instrument/common/future/instrument.py: -------------------------------------------------------------------------------- 1 | from abc import ABC 2 | from typing import Optional, Union 3 | 4 | from pydantic import Field 5 | 6 | from finstruments.common.decorators import serializable_base_class 7 | from finstruments.common.enum import Currency 8 | from finstruments.instrument.abstract import BaseInstrument 9 | from finstruments.instrument.common.exercise_style import BaseExerciseStyle 10 | 11 | 12 | @serializable_base_class 13 | class VanillaFuture(BaseInstrument, ABC): 14 | underlying: BaseInstrument 15 | exercise_type: BaseExerciseStyle 16 | strike_price: float 17 | denomination_currency: Currency 18 | agreed_discount_rate: Optional[str] = Field(default=None) 19 | code: str 20 | 21 | def __init__(self, **data): 22 | exercise_type: Union[dict, BaseExerciseStyle] = data.get("exercise_type") 23 | if isinstance(exercise_type, dict): 24 | data["pillar_date"] = exercise_type["expiration_date"] 25 | else: 26 | data["pillar_date"] = exercise_type.expiration_date 27 | super().__init__(**data) 28 | -------------------------------------------------------------------------------- /finstruments/instrument/common/option/__init__.py: -------------------------------------------------------------------------------- 1 | from finstruments.instrument.common.option.instrument import VanillaOption 2 | -------------------------------------------------------------------------------- /finstruments/instrument/common/option/enum.py: -------------------------------------------------------------------------------- 1 | from finstruments.common.base_enum import BaseEnum 2 | 3 | 4 | class OptionType(BaseEnum): 5 | """Option Type""" 6 | 7 | CALL = "CALL" 8 | PUT = "PUT" 9 | 10 | 11 | class BarrierType(BaseEnum): 12 | UP_IN = "UP_IN" 13 | UP_OUT = "UP_OUT" 14 | DOWN_IN = "DOWN_IN" 15 | DOWN_OUT = "DOWN_OUT" 16 | 17 | 18 | class DoubleBarrierType(BaseEnum): 19 | KNOCK_IN = "KNOCK_IN" 20 | KNOCK_OUT = "KNOCK_OUT" 21 | KNOCK_IN_KNOCK_OUT = "KNOCK_IN_KNOCK_OUT" 22 | KNOCK_OUT_KNOCK_IN = "KNOCK_OUT_KNOCK_IN" 23 | -------------------------------------------------------------------------------- /finstruments/instrument/common/option/instrument.py: -------------------------------------------------------------------------------- 1 | from abc import ABC 2 | from typing import Optional, Union 3 | 4 | from pydantic import Field 5 | 6 | from finstruments.common.decorators import serializable_base_class 7 | from finstruments.common.enum import Currency 8 | from finstruments.instrument.abstract import BaseInstrument 9 | from finstruments.instrument.common.exercise_style import BaseExerciseStyle 10 | from finstruments.instrument.common.option.enum import BarrierType, DoubleBarrierType 11 | from finstruments.instrument.common.option.payoff import BaseFixedStrikePayoff 12 | 13 | 14 | @serializable_base_class 15 | class VanillaOption(BaseInstrument, ABC): 16 | payoff: BaseFixedStrikePayoff 17 | exercise_type: BaseExerciseStyle 18 | denomination_currency: Currency 19 | agreed_discount_rate: Optional[str] = Field(default=None) 20 | code: str 21 | 22 | def __init__(self, **data): 23 | exercise_type: Union[dict, BaseExerciseStyle] = data.get("exercise_type") 24 | if isinstance(exercise_type, dict): 25 | data["pillar_date"] = exercise_type["expiration_date"] 26 | else: 27 | data["pillar_date"] = exercise_type.expiration_date 28 | super().__init__(**data) 29 | 30 | 31 | @serializable_base_class 32 | class BarrierOption(BaseInstrument, ABC): 33 | barrier_type: BarrierType 34 | barrier: float 35 | rebate: float 36 | payoff: BaseFixedStrikePayoff 37 | exercise_type: BaseExerciseStyle 38 | denomination_currency: Currency 39 | agreed_discount_rate: Optional[str] = Field(default=None) 40 | code: str 41 | 42 | def __init__(self, **data): 43 | exercise_type: Union[dict, BaseExerciseStyle] = data.get("exercise_type") 44 | if isinstance(exercise_type, dict): 45 | data["pillar_date"] = exercise_type["expiration_date"] 46 | else: 47 | data["pillar_date"] = exercise_type.expiration_date 48 | super().__init__(**data) 49 | 50 | 51 | @serializable_base_class 52 | class DoubleBarrierOption(BaseInstrument, ABC): 53 | barrier_type: DoubleBarrierType 54 | barrier_high: float 55 | barrier_low: float 56 | rebate: float 57 | payoff: BaseFixedStrikePayoff 58 | exercise_type: BaseExerciseStyle 59 | denomination_currency: Currency 60 | agreed_discount_rate: Optional[str] = Field(default=None) 61 | code: str 62 | 63 | def __init__(self, **data): 64 | exercise_type: Union[dict, BaseExerciseStyle] = data.get("exercise_type") 65 | if isinstance(exercise_type, dict): 66 | data["pillar_date"] = exercise_type["expiration_date"] 67 | else: 68 | data["pillar_date"] = exercise_type.expiration_date 69 | super().__init__(**data) 70 | -------------------------------------------------------------------------------- /finstruments/instrument/common/option/payoff.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | 3 | from finstruments.common.base import Base 4 | from finstruments.common.decorators import serializable, serializable_base_class 5 | from finstruments.instrument.common.option.enum import OptionType 6 | 7 | 8 | @serializable_base_class 9 | class BasePayoff(Base, ABC): 10 | @abstractmethod 11 | def compute_payoff(self, reference_level: float) -> float: 12 | """ 13 | Compute payoff based on reference level. 14 | 15 | Args: 16 | reference_level (float): Spot price at expiration 17 | 18 | Returns: 19 | float: Payoff result 20 | """ 21 | pass 22 | 23 | 24 | @serializable_base_class 25 | class BaseFixedStrikePayoff(BasePayoff, ABC): 26 | option_type: OptionType 27 | strike_price: float 28 | 29 | 30 | @serializable 31 | class VanillaPayoff(BaseFixedStrikePayoff): 32 | def compute_payoff(self, reference_level: float) -> float: 33 | """ 34 | Compute payoff for vanilla call or put. 35 | 36 | Args: 37 | reference_level (float): Spot price at expiration 38 | 39 | Returns: 40 | float: Payoff result calculated using reference level and strike price 41 | """ 42 | if self.option_type == OptionType.CALL: 43 | return ( 44 | reference_level - self.strike_price 45 | if reference_level > self.strike_price 46 | else 0 47 | ) 48 | elif self.option_type == OptionType.PUT: 49 | return ( 50 | self.strike_price - reference_level 51 | if self.strike_price > reference_level 52 | else 0 53 | ) 54 | 55 | 56 | @serializable 57 | class DigitalPayoff(BaseFixedStrikePayoff): 58 | cash_payout: float 59 | 60 | def compute_payoff(self, reference_level: float) -> float: 61 | """ 62 | Compute payoff for digital call or put. 63 | 64 | Args: 65 | reference_level (float): Spot price at expiration 66 | 67 | Returns: 68 | float: Payoff result equivalent to cash_payout field 69 | """ 70 | if self.option_type == OptionType.CALL: 71 | return self.cash_payout if reference_level > self.strike_price else 0 72 | elif self.option_type == OptionType.PUT: 73 | return self.cash_payout if self.strike_price > reference_level else 0 74 | -------------------------------------------------------------------------------- /finstruments/instrument/cryptocurrency/__init__.py: -------------------------------------------------------------------------------- 1 | from finstruments.instrument.cryptocurrency.instrument import ( 2 | BaseCryptocurrency, 3 | Cryptocurrency, 4 | ) 5 | -------------------------------------------------------------------------------- /finstruments/instrument/cryptocurrency/instrument.py: -------------------------------------------------------------------------------- 1 | from abc import ABC 2 | from datetime import date 3 | from typing import Optional 4 | 5 | from pydantic import Field 6 | 7 | from finstruments.common.decorators import serializable, serializable_base_class 8 | from finstruments.common.enum import Currency 9 | from finstruments.instrument.abstract import BaseInstrument 10 | 11 | 12 | @serializable_base_class 13 | class BaseCryptocurrency(BaseInstrument, ABC): 14 | """ 15 | Cryptocurrency base class. 16 | """ 17 | 18 | ticker: str 19 | full_name: Optional[str] = Field(default=None) 20 | network: Optional[str] = Field(default=None) 21 | contract_address: Optional[str] = Field(default=None) 22 | agreed_discount_rate: Optional[str] = Field(init=False, default=None) 23 | pillar_date: Optional[date] = Field(init=False, default=None) 24 | denomination_currency: Optional[Currency] = Field(init=False, default=None) 25 | code: str 26 | 27 | 28 | @serializable 29 | class Cryptocurrency(BaseCryptocurrency): 30 | """ 31 | Cryptocurrency. 32 | """ 33 | 34 | code: str = Field(init=False, default="CRYPTOCURRENCY") 35 | -------------------------------------------------------------------------------- /finstruments/instrument/equity/__init__.py: -------------------------------------------------------------------------------- 1 | from finstruments.instrument.equity.instrument import ( 2 | BaseEquity, 3 | EquityIndex, 4 | EquityETF, 5 | CommonStock, 6 | EquityOption, 7 | EquityForward, 8 | EquityFuture, 9 | ) 10 | -------------------------------------------------------------------------------- /finstruments/instrument/equity/enum.py: -------------------------------------------------------------------------------- 1 | from finstruments.common.base_enum import BaseEnum 2 | 3 | 4 | class EquityIndexType(BaseEnum): 5 | TOTAL_RETURN = "TOTAL_RETURN" 6 | PRICE = "PRICE" 7 | -------------------------------------------------------------------------------- /finstruments/instrument/equity/instrument.py: -------------------------------------------------------------------------------- 1 | from abc import ABC 2 | from datetime import date 3 | from typing import Optional 4 | 5 | from pydantic import Field 6 | 7 | from finstruments.common.decorators import serializable, serializable_base_class 8 | from finstruments.common.enum import Currency 9 | from finstruments.instrument.abstract import BaseInstrument 10 | from finstruments.instrument.common.exercise_style import ( 11 | EuropeanExerciseStyle, 12 | BaseExerciseStyle, 13 | ) 14 | from finstruments.instrument.common.forward import VanillaForward 15 | from finstruments.instrument.common.future import VanillaFuture 16 | from finstruments.instrument.common.option import VanillaOption 17 | from finstruments.instrument.common.option.payoff import BaseFixedStrikePayoff 18 | from finstruments.instrument.equity.enum import EquityIndexType 19 | 20 | 21 | @serializable_base_class 22 | class BaseEquity(BaseInstrument, ABC): 23 | """ 24 | Equity base class. 25 | """ 26 | 27 | ticker: str 28 | agreed_discount_rate: Optional[str] = Field(init=False, default=None) 29 | pillar_date: Optional[date] = Field(init=False, default=None) 30 | denomination_currency: Optional[Currency] = Field(default=None) 31 | code: str 32 | 33 | 34 | @serializable 35 | class EquityIndex(BaseEquity): 36 | """ 37 | Equity index. 38 | """ 39 | 40 | index_type: EquityIndexType 41 | code: str = Field(init=False, default="EQUITY_INDEX") 42 | 43 | 44 | @serializable 45 | class EquityETF(BaseEquity): 46 | """ 47 | Equity ETF. 48 | """ 49 | 50 | code: str = Field(init=False, default="EQUITY_ETF") 51 | 52 | 53 | @serializable 54 | class CommonStock(BaseEquity): 55 | """ 56 | Common stock. 57 | """ 58 | 59 | code: str = Field(init=False, default="COMMON_STOCK") 60 | 61 | 62 | @serializable 63 | class EquityForward(VanillaForward): 64 | underlying: BaseEquity 65 | exercise_type: EuropeanExerciseStyle 66 | strike_price: float 67 | contract_size: float 68 | denomination_currency: Currency 69 | agreed_discount_rate: Optional[str] = Field(default=None) 70 | code: str = Field(init=False, default="EQUITY_FORWARD") 71 | 72 | 73 | @serializable 74 | class EquityFuture(VanillaFuture): 75 | underlying: BaseEquity 76 | exercise_type: EuropeanExerciseStyle 77 | strike_price: float 78 | contract_size: float 79 | denomination_currency: Currency 80 | agreed_discount_rate: Optional[str] = Field(default=None) 81 | code: str = Field(init=False, default="EQUITY_FUTURE") 82 | 83 | 84 | @serializable 85 | class EquityOption(VanillaOption): 86 | underlying: BaseEquity 87 | payoff: BaseFixedStrikePayoff 88 | exercise_type: BaseExerciseStyle 89 | contract_size: float 90 | denomination_currency: Currency 91 | agreed_discount_rate: Optional[str] = Field(default=None) 92 | code: str = Field(init=False, default="EQUITY_OPTION") 93 | -------------------------------------------------------------------------------- /finstruments/portfolio/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Position, Trade, and Portfolio definitions. 3 | """ 4 | 5 | from finstruments.portfolio.portfolio import Position, Trade, Portfolio 6 | -------------------------------------------------------------------------------- /finstruments/portfolio/portfolio.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from datetime import date 3 | from itertools import chain 4 | from typing import List 5 | 6 | from pydantic import Field 7 | 8 | from finstruments.common.base import Base 9 | from finstruments.common.decorators import serializable 10 | from finstruments.instrument import BaseInstrument 11 | 12 | 13 | @serializable 14 | class Position(Base): 15 | """ 16 | A position is composed of instrument and size. 17 | """ 18 | 19 | instrument: BaseInstrument 20 | size: float 21 | id: str = Field(default_factory=lambda: str(uuid.uuid4())) 22 | 23 | 24 | @serializable 25 | class Trade(Base): 26 | """ 27 | A trade, which could involve a number of instruments. For example, as straddle could be managed as a single unit, 28 | but it is composed of a call and a put. 29 | """ 30 | 31 | positions: List[Position] = Field(default_factory=lambda: []) 32 | 33 | def get_expired_positions(self, as_of_date: date) -> List[Position]: 34 | return [ 35 | x 36 | for x in self.positions 37 | if (x.instrument.pillar_date is not None) 38 | and x.instrument.pillar_date <= as_of_date 39 | ] 40 | 41 | def filter_expired_positions(self, as_of_date: date) -> "Trade": 42 | positions = [ 43 | x 44 | for x in self.positions 45 | if (x.instrument.pillar_date is None) 46 | or (x.instrument.pillar_date > as_of_date) 47 | ] 48 | 49 | return Trade(positions=positions) 50 | 51 | def filter_positions(self, ids: List[str]) -> "Trade": 52 | positions = [x for x in self.positions if not (x.id in ids)] 53 | 54 | return Trade(positions=positions) 55 | 56 | 57 | @serializable 58 | class Portfolio(Base): 59 | """ 60 | A list of trades. For example, multiple straddles. 61 | """ 62 | 63 | trades: List[Trade] = Field(default_factory=lambda: []) 64 | 65 | def __sub__(self, other) -> List[Position]: 66 | return list(set(self.positions) - set(other.positions)) 67 | 68 | def get_expired_positions(self, as_of_date: date) -> List[Position]: 69 | return [ 70 | x 71 | for x in self.positions 72 | if (x.instrument.pillar_date is not None) 73 | and x.instrument.pillar_date <= as_of_date 74 | ] 75 | 76 | def filter_expired_positions(self, as_of_date: date) -> "Portfolio": 77 | trades = [trade.filter_expired_positions(as_of_date) for trade in self.trades] 78 | 79 | return Portfolio(trades=trades) 80 | 81 | def filter_positions(self, ids: List[str]) -> "Portfolio": 82 | trades = [trade.filter_positions(ids) for trade in self.trades] 83 | # filter out empty trades 84 | filtered_trades = [trade for trade in trades if len(trade.positions) > 0] 85 | 86 | return Portfolio(trades=filtered_trades) 87 | 88 | @property 89 | def positions(self) -> List[Position]: 90 | trade_positions: List[List[Position]] = [x.positions for x in self.trades] 91 | positions: List[Position] = list(chain(*trade_positions)) 92 | 93 | return positions 94 | 95 | def add_trade(self, trade: Trade) -> "Portfolio": 96 | return Portfolio(trades=self.trades + [trade]) 97 | 98 | def add_position(self, position: Position) -> "Portfolio": 99 | return Portfolio(trades=self.trades + [Trade(positions=[position])]) 100 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools<68", "setuptools_scm[toml]<8"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [tool.black] 6 | line-length = 88 7 | target-version = ['py37'] 8 | 9 | [tool.setuptools_scm] 10 | version_scheme = "post-release" 11 | local_scheme = "no-local-version" 12 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pytest==7.1.2 2 | pydash>=7.0.3 3 | pydantic==1.10.17 4 | pytz==2024.2 5 | workalendar==17.0.0 6 | python-dateutil==2.9.0.post0 7 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import setuptools 2 | 3 | try: 4 | import pypandoc 5 | 6 | try: 7 | long_description = pypandoc.convert_file("README.md", "rst") 8 | except Exception as e: 9 | print(f"Warning: pypandoc failed with {e}, falling back to raw README.md") 10 | with open("README.md", encoding="utf-8") as f: 11 | long_description = f.read() 12 | except ImportError: 13 | print("Warning: pypandoc not found, using raw README.md") 14 | with open("README.md", encoding="utf-8") as f: 15 | long_description = f.read() 16 | 17 | setuptools.setup( 18 | name="finstruments", 19 | use_scm_version=True, # Enable setuptools_scm for versioning 20 | setup_requires=["setuptools_scm"], # Ensure setuptools_scm is available for setup 21 | author="Kyle Loomis", 22 | author_email="kyle@spotlight.dev", 23 | description="Financial Instruments.", 24 | long_description=long_description, 25 | long_description_content_type="text/markdown", 26 | url="https://kyleloomis.com/articles/financial-instrument-library", 27 | classifiers=[ 28 | "Programming Language :: Python :: 3", 29 | "Operating System :: OS Independent", 30 | ], 31 | python_requires=">=3.7", 32 | packages=setuptools.find_packages(include=["finstruments*"], exclude=["tests.*"]), 33 | install_requires=[ 34 | "pytest==7.1.2", 35 | "pydash>=7.0.3", 36 | "pydantic==1.10.17", 37 | "pytz==2024.2", 38 | "workalendar==17.0.0", 39 | "python-dateutil==2.9.0.post0", 40 | ], 41 | ) 42 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kyleloomis/finstruments/f700ad52987bea74567a309889ae6f688d7d25b5/tests/__init__.py -------------------------------------------------------------------------------- /tests/unit/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kyleloomis/finstruments/f700ad52987bea74567a309889ae6f688d7d25b5/tests/unit/__init__.py -------------------------------------------------------------------------------- /tests/unit/common/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kyleloomis/finstruments/f700ad52987bea74567a309889ae6f688d7d25b5/tests/unit/common/__init__.py -------------------------------------------------------------------------------- /tests/unit/common/base/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kyleloomis/finstruments/f700ad52987bea74567a309889ae6f688d7d25b5/tests/unit/common/base/__init__.py -------------------------------------------------------------------------------- /tests/unit/common/base/copy_test.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from datetime import date 3 | 4 | from finstruments.common.enum import Currency 5 | from finstruments.instrument.common.cut import NysePMCut 6 | from finstruments.instrument.common.exercise_style import EuropeanExerciseStyle 7 | from finstruments.instrument.common.option.enum import OptionType 8 | from finstruments.instrument.common.option.payoff import VanillaPayoff 9 | from finstruments.instrument.equity import EquityOption, CommonStock 10 | from finstruments.portfolio import Position 11 | 12 | 13 | class TestCopy(unittest.TestCase): 14 | def setUp(self) -> None: 15 | self.position = Position( 16 | instrument=EquityOption( 17 | underlying=CommonStock(ticker="TEST"), 18 | payoff=VanillaPayoff(option_type=OptionType.CALL, strike_price=100), 19 | exercise_type=EuropeanExerciseStyle( 20 | expiration_date=date(2022, 6, 1), cut=NysePMCut() 21 | ), 22 | denomination_currency=Currency.USD, 23 | contract_size=100, 24 | ), 25 | size=100, 26 | ) 27 | 28 | def test_copy(self): 29 | copy = self.position.copy() 30 | self.assertEqual(self.position, copy) 31 | 32 | def test_copy_with_ignored_fields(self): 33 | copy = self.position.copy(ignored_fields=["id"]) 34 | self.assertNotEqual(self.position.id, copy.id) 35 | 36 | def test_copy_with_ignored_fields_and_nested_alterations(self): 37 | copy = self.position.copy( 38 | ignored_fields=["instrument.agreed_discount_rate"], 39 | **{"instrument.underlying": CommonStock(ticker="TEST2")}, 40 | ) 41 | self.assertEqual(copy.instrument.agreed_discount_rate, None) 42 | self.assertEqual(self.position.id, copy.id) 43 | self.assertEqual(self.position.size, copy.size) 44 | self.assertNotEqual( 45 | self.position.instrument.underlying, copy.instrument.underlying 46 | ) 47 | 48 | def test_copy_with_nested_alterations(self): 49 | copy = self.position.copy( 50 | **{"instrument.underlying": CommonStock(ticker="TEST2")} 51 | ) 52 | self.assertEqual(self.position.id, copy.id) 53 | self.assertEqual(self.position.size, copy.size) 54 | self.assertNotEqual( 55 | self.position.instrument.underlying, copy.instrument.underlying 56 | ) 57 | 58 | def test_copy_with_alterations(self): 59 | copy = self.position.copy(size=10) 60 | self.assertEqual(self.position.id, copy.id) 61 | self.assertEqual(self.position.instrument, copy.instrument) 62 | self.assertNotEqual(self.position.size, copy.size) 63 | -------------------------------------------------------------------------------- /tests/unit/common/base/hash_test.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from datetime import date 3 | 4 | from finstruments.common.enum import Currency 5 | from finstruments.instrument.common.cut import NysePMCut 6 | from finstruments.instrument.common.exercise_style import EuropeanExerciseStyle 7 | from finstruments.instrument.common.option.enum import OptionType 8 | from finstruments.instrument.common.option.payoff import VanillaPayoff 9 | from finstruments.instrument.equity import EquityOption, CommonStock 10 | from finstruments.portfolio import Position 11 | 12 | 13 | class TestHash(unittest.TestCase): 14 | def setUp(self) -> None: 15 | self.position = Position( 16 | instrument=EquityOption( 17 | underlying=CommonStock(ticker="TEST"), 18 | payoff=VanillaPayoff(option_type=OptionType.CALL, strike_price=100), 19 | exercise_type=EuropeanExerciseStyle( 20 | expiration_date=date(2022, 6, 1), cut=NysePMCut() 21 | ), 22 | denomination_currency=Currency.USD, 23 | contract_size=100, 24 | ), 25 | size=100, 26 | ) 27 | 28 | def test_hash(self): 29 | original_hash = hash(self.position) 30 | copy_hash = hash(self.position.copy()) 31 | copy_hash_with_changes = hash(self.position.copy(ignored_fields=["id"])) 32 | 33 | self.assertEqual(original_hash, copy_hash) 34 | self.assertNotEqual(original_hash, copy_hash_with_changes) 35 | self.assertNotEqual(copy_hash, copy_hash_with_changes) 36 | 37 | def test_to_set(self): 38 | position_list = [self.position for _ in range(10)] 39 | position_set = set(position_list) 40 | self.assertEqual(len(position_set), 1) 41 | -------------------------------------------------------------------------------- /tests/unit/common/date/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kyleloomis/finstruments/f700ad52987bea74567a309889ae6f688d7d25b5/tests/unit/common/date/__init__.py -------------------------------------------------------------------------------- /tests/unit/common/decorators/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kyleloomis/finstruments/f700ad52987bea74567a309889ae6f688d7d25b5/tests/unit/common/decorators/__init__.py -------------------------------------------------------------------------------- /tests/unit/common/decorators/serializable_test.py: -------------------------------------------------------------------------------- 1 | import json 2 | import unittest 3 | from abc import ABC 4 | from datetime import datetime 5 | from typing import List 6 | 7 | from pytz import timezone 8 | 9 | from finstruments.common.base import Base 10 | from finstruments.common.decorators.serializable import ( 11 | serializable, 12 | serializable_base_class, 13 | ) 14 | 15 | 16 | @serializable_base_class 17 | class Parent(Base, ABC): 18 | x: int 19 | y: int 20 | 21 | 22 | @serializable 23 | class Foo(Parent): 24 | pass 25 | 26 | 27 | @serializable 28 | class Bar(Parent): 29 | pass 30 | 31 | 32 | class Container(Base): 33 | foo: Parent 34 | bar: Parent 35 | generic: Parent 36 | 37 | collection: List[Parent] 38 | 39 | 40 | class ContainerSquared(Base): 41 | container: Container 42 | 43 | 44 | class TestAnnotatedSerialization(unittest.TestCase): 45 | def setUp(self) -> None: 46 | self.foo = Foo(x=0, y=0) 47 | self.bar = Bar(x=1, y=1) 48 | 49 | def assert_serialization(self, obj, cls): 50 | serialized_data = json.loads(obj.json()) 51 | deserialized_data = cls(**serialized_data) 52 | self.assertEqual(obj, deserialized_data) 53 | 54 | def test_nested_serialization(self): 55 | container = Container( 56 | foo=self.foo, 57 | bar=self.bar, 58 | generic=self.foo, 59 | collection=[self.foo, self.bar], 60 | ) 61 | container_squared = ContainerSquared(container=container) 62 | 63 | self.assert_serialization(container, Container) 64 | self.assert_serialization(container_squared, ContainerSquared) 65 | 66 | def test_updated_parent(self): 67 | @serializable 68 | class Fizz(Parent): 69 | pass 70 | 71 | fizz = Fizz(x=2, y=2) 72 | container = Container( 73 | foo=self.foo, 74 | bar=self.bar, 75 | generic=fizz, 76 | collection=[self.foo, self.bar, fizz], 77 | ) 78 | container_squared = ContainerSquared(container=container) 79 | 80 | self.assert_serialization(container, Container) 81 | self.assert_serialization(container_squared, ContainerSquared) 82 | 83 | def test_datetime_tz_serialization(self): 84 | class A(Base): 85 | dt: datetime 86 | 87 | tz = timezone("US/Eastern") 88 | dt = datetime(2022, 1, 1, 5, 0, 0) 89 | dt_tz = tz.localize(datetime(2022, 1, 1, 0, 0, 0)) 90 | 91 | a = A(dt=dt) 92 | a_tz = A(dt=dt_tz) 93 | 94 | self.assertEqual(a.request_dict(), {"dt": 1641013200000}) 95 | self.assertEqual(a_tz.request_dict(), {"dt": 1641013200000}) 96 | 97 | def test_datetime_tz_to_timestamp_daylight_savings_serialization(self): 98 | class A(Base): 99 | dt: datetime 100 | 101 | tz = timezone("US/Eastern") 102 | dt = datetime(2022, 7, 1, 4, 0, 0) 103 | dt_tz = tz.localize(datetime(2022, 7, 1, 0, 0, 0)) 104 | 105 | a = A(dt=dt) 106 | a_tz = A(dt=dt_tz) 107 | 108 | self.assertEqual(a.request_dict(), {"dt": 1656648000000}) 109 | self.assertEqual(a_tz.request_dict(), {"dt": 1656648000000}) 110 | -------------------------------------------------------------------------------- /tests/unit/common/enum_test.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from functools import reduce 3 | from operator import mul 4 | 5 | from finstruments.common.enum import Average 6 | 7 | 8 | class AverageTest(unittest.TestCase): 9 | def test_arithmetic_average(self): 10 | values = [1, 2, 3, 4, 5] 11 | result = Average.ARITHMETIC.apply(values) 12 | expected = sum(values) / len(values) 13 | self.assertAlmostEqual(result, expected, places=6) 14 | 15 | def test_geometric_average(self): 16 | values = [1, 2, 3, 4, 5] 17 | result = Average.GEOMETRIC.apply(values) 18 | 19 | # Compute product without math.prod() 20 | product = reduce(mul, values, 1) 21 | expected = product ** (1 / len(values)) 22 | 23 | self.assertAlmostEqual(result, expected, places=6) 24 | 25 | def test_empty_list(self): 26 | with self.assertRaises(ValueError): 27 | Average.ARITHMETIC.apply([]) 28 | 29 | with self.assertRaises(ValueError): 30 | Average.GEOMETRIC.apply([]) 31 | 32 | def test_unsupported_average(self): 33 | # Attempt to create a fake enum member for unsupported test 34 | with self.assertRaises(Exception): 35 | Average("UNSUPPORTED").apply([1, 2, 3]) 36 | -------------------------------------------------------------------------------- /tests/unit/common/function_test.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from datetime import date, datetime 3 | 4 | from pytz import timezone 5 | 6 | from finstruments.common.date import ( 7 | date_to_timestamp, 8 | datetime_to_timestamp, 9 | datetime_to_utc, 10 | ) 11 | 12 | 13 | class FunctionTest(unittest.TestCase): 14 | def test_date_to_timestamp(self): 15 | self.assertEqual(date_to_timestamp(date(2022, 1, 1)), 1640995200000) 16 | 17 | def test_datetime_to_timestamp(self): 18 | self.assertEqual( 19 | datetime_to_timestamp(datetime(2022, 1, 1, 0, 0, 0)), 1640995200000 20 | ) 21 | 22 | def test_datetime_to_utc(self): 23 | tz = timezone("US/Eastern") 24 | dt = datetime(2022, 1, 1, 5, 0, 0) 25 | dt_tz = tz.localize(datetime(2022, 1, 1, 0, 0, 0)) 26 | 27 | self.assertNotEqual(datetime_to_utc(dt), dt_tz) 28 | self.assertEqual(datetime_to_utc(dt), datetime_to_utc(dt_tz)) 29 | self.assertEqual(dt, datetime_to_utc(dt_tz)) 30 | 31 | def test_datetime_to_utc_daylight_savings(self): 32 | tz = timezone("US/Eastern") 33 | dt = datetime(2022, 7, 1, 4, 0, 0) 34 | dt_tz = tz.localize(datetime(2022, 7, 1, 0, 0, 0)) 35 | 36 | self.assertNotEqual(datetime_to_utc(dt), dt_tz) 37 | self.assertEqual(datetime_to_utc(dt), datetime_to_utc(dt_tz)) 38 | self.assertEqual(dt, datetime_to_utc(dt_tz)) 39 | 40 | def test_datetime_tz_to_timestamp(self): 41 | tz = timezone("US/Eastern") 42 | dt = datetime(2022, 1, 1, 5, 0, 0) 43 | dt_tz = tz.localize(datetime(2022, 1, 1, 0, 0, 0)) 44 | 45 | self.assertEqual(datetime_to_timestamp(dt_tz), datetime_to_timestamp(dt)) 46 | 47 | def test_datetime_tz_to_timestamp_daylight_savings(self): 48 | tz = timezone("US/Eastern") 49 | dt = datetime(2022, 7, 1, 4, 0, 0) 50 | dt_tz = tz.localize(datetime(2022, 7, 1, 0, 0, 0)) 51 | 52 | self.assertEqual(datetime_to_timestamp(dt_tz), datetime_to_timestamp(dt)) 53 | -------------------------------------------------------------------------------- /tests/unit/common/period_test.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from datetime import datetime, date 3 | 4 | from dateutil.relativedelta import relativedelta 5 | 6 | from finstruments.common.date.enum import TimeUnit 7 | from finstruments.common.period import Period 8 | 9 | 10 | class PeriodTest(unittest.TestCase): 11 | def compare_dates(self, date_first: date, date_second: date): 12 | self.assertEqual( 13 | date_first.strftime("%y-%m-%d"), date_second.strftime("%y-%m-%d") 14 | ) 15 | 16 | def test_period_advance(self): 17 | d = datetime.now().date() 18 | 19 | self.compare_dates( 20 | Period(unit=TimeUnit.DAY, n=1).advance(d), d + relativedelta(days=1) 21 | ) 22 | self.compare_dates( 23 | Period(unit=TimeUnit.WEEK, n=2).advance(d), d + relativedelta(weeks=2) 24 | ) 25 | self.compare_dates( 26 | Period(unit=TimeUnit.MONTH, n=5).advance(d), d + relativedelta(months=5) 27 | ) 28 | self.compare_dates( 29 | Period(unit=TimeUnit.YEAR, n=2).advance(d), d + relativedelta(years=2) 30 | ) 31 | -------------------------------------------------------------------------------- /tests/unit/deserialization/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kyleloomis/finstruments/f700ad52987bea74567a309889ae6f688d7d25b5/tests/unit/deserialization/__init__.py -------------------------------------------------------------------------------- /tests/unit/deserialization/currency_pair_deserialization.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from finstruments.common.enum import Currency 4 | from finstruments.instrument.common.currency_pair import CurrencyPair 5 | from finstruments.instrument.cryptocurrency import Cryptocurrency 6 | from tests.unit.deserialization.util import assert_serialization 7 | 8 | 9 | class TestCurrencyPairDeserialization(unittest.TestCase): 10 | def test_cryptocurrency_pair(self): 11 | sol = Cryptocurrency( 12 | ticker="SOL", full_name="Solana", network="Solana", contract_address=None 13 | ) 14 | bingus = Cryptocurrency( 15 | ticker="BINGUS", 16 | full_name="Bingus the Cat", 17 | network="Solana", 18 | contract_address="AQuuQ4xktyzGBFnbKHnYsXHxsKVQetAoiPeCEG97NUJw", 19 | ) 20 | sol_bingus = CurrencyPair(base_currency=sol, quote_currency=bingus) 21 | 22 | assert_serialization(sol_bingus, CurrencyPair) 23 | 24 | def test_currency_pair(self): 25 | eur_usd = CurrencyPair(base_currency=Currency.EUR, quote_currency=Currency.USD) 26 | 27 | assert_serialization(eur_usd, CurrencyPair) 28 | 29 | def test_mixed_currency_pair(self): 30 | btc = Cryptocurrency( 31 | ticker="BTC", full_name="Bitcoin", network="Bitcoin", contract_address=None 32 | ) 33 | btc_usd = CurrencyPair( 34 | base_currency=btc, 35 | quote_currency=Currency.USD, 36 | ) 37 | 38 | assert_serialization(btc_usd, CurrencyPair) 39 | -------------------------------------------------------------------------------- /tests/unit/deserialization/equity_forward_deserialization_test.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from datetime import date 3 | 4 | from finstruments.common.enum import Currency 5 | from finstruments.instrument.common.cut import NysePMCut 6 | from finstruments.instrument.common.exercise_style import EuropeanExerciseStyle 7 | from finstruments.instrument.equity import CommonStock 8 | from finstruments.instrument.equity import EquityForward 9 | from tests.unit.deserialization.util import ( 10 | assert_serialization, 11 | AdditionalEquity, 12 | ) 13 | 14 | 15 | class TestEquityForwardDeserialization(unittest.TestCase): 16 | def test_base_serialization(self): 17 | expected = EquityForward( 18 | underlying=CommonStock(ticker="TEST"), 19 | exercise_type=EuropeanExerciseStyle( 20 | expiration_date=date(2022, 6, 1), cut=NysePMCut() 21 | ), 22 | strike_price=100, 23 | denomination_currency=Currency.USD, 24 | contract_size=100, 25 | ) 26 | assert_serialization(expected, EquityForward) 27 | 28 | def test_equity_annotation_update(self): 29 | expected = EquityForward( 30 | underlying=AdditionalEquity(ticker="TEST"), 31 | exercise_type=EuropeanExerciseStyle( 32 | expiration_date=date(2022, 6, 1), cut=NysePMCut() 33 | ), 34 | strike_price=100, 35 | denomination_currency=Currency.USD, 36 | contract_size=100, 37 | ) 38 | assert_serialization(expected, EquityForward) 39 | assert_serialization(expected.underlying, AdditionalEquity) 40 | -------------------------------------------------------------------------------- /tests/unit/deserialization/equity_option_deserialization_test.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from datetime import date 3 | 4 | from finstruments.common.enum import Currency 5 | from finstruments.instrument.common.cut import NysePMCut 6 | from finstruments.instrument.common.exercise_style import ( 7 | EuropeanExerciseStyle, 8 | BermudanExerciseStyle, 9 | ) 10 | from finstruments.instrument.common.option.enum import OptionType 11 | from finstruments.instrument.common.option.payoff import VanillaPayoff, DigitalPayoff 12 | from finstruments.instrument.equity import CommonStock, EquityIndex 13 | from finstruments.instrument.equity import EquityOption 14 | from finstruments.instrument.equity.enum import EquityIndexType 15 | from tests.unit.deserialization.util import ( 16 | assert_serialization, 17 | AdditionalEquity, 18 | AdditionalPayoff, 19 | AdditionalExerciseStyle, 20 | ) 21 | 22 | 23 | class TestEquityOptionDeserialization(unittest.TestCase): 24 | def test_base_serialization(self): 25 | expected = EquityOption( 26 | underlying=CommonStock(ticker="TEST"), 27 | payoff=VanillaPayoff(option_type=OptionType.CALL, strike_price=100), 28 | exercise_type=EuropeanExerciseStyle( 29 | expiration_date=date(2022, 6, 1), cut=NysePMCut() 30 | ), 31 | denomination_currency=Currency.USD, 32 | contract_size=100, 33 | ) 34 | 35 | expected_two = EquityOption( 36 | underlying=EquityIndex( 37 | ticker="TEST", index_type=EquityIndexType.TOTAL_RETURN 38 | ), 39 | payoff=DigitalPayoff( 40 | option_type=OptionType.CALL, strike_price=100, cash_payout=20 41 | ), 42 | exercise_type=BermudanExerciseStyle( 43 | expiration_date=date(2022, 6, 1), 44 | early_exercise_dates=[date(2022, 1, 1), date(2022, 3, 1)], 45 | cut=NysePMCut(), 46 | ), 47 | denomination_currency=Currency.USD, 48 | contract_size=100, 49 | ) 50 | 51 | assert_serialization(expected, EquityOption) 52 | assert_serialization(expected_two, EquityOption) 53 | 54 | def test_equity_annotation_update(self): 55 | expected = EquityOption( 56 | underlying=AdditionalEquity(ticker="TEST"), 57 | payoff=VanillaPayoff(option_type=OptionType.CALL, strike_price=100), 58 | exercise_type=EuropeanExerciseStyle( 59 | expiration_date=date(2022, 6, 1), cut=NysePMCut() 60 | ), 61 | denomination_currency=Currency.USD, 62 | contract_size=100, 63 | ) 64 | assert_serialization(expected, EquityOption) 65 | assert_serialization(expected.underlying, AdditionalEquity) 66 | 67 | def test_fixed_strike_payoff_annotation_update(self): 68 | expected = EquityOption( 69 | underlying=CommonStock(ticker="TEST"), 70 | payoff=AdditionalPayoff(option_type=OptionType.CALL, strike_price=100), 71 | exercise_type=EuropeanExerciseStyle( 72 | expiration_date=date(2022, 6, 1), cut=NysePMCut() 73 | ), 74 | denomination_currency=Currency.USD, 75 | contract_size=100, 76 | ) 77 | assert_serialization(expected, EquityOption) 78 | 79 | def test_exercise_type_annotation_update(self): 80 | expected = EquityOption( 81 | underlying=CommonStock(ticker="TEST"), 82 | payoff=VanillaPayoff(option_type=OptionType.CALL, strike_price=100), 83 | exercise_type=AdditionalExerciseStyle( 84 | expiration_date=date(2022, 6, 1), cut=NysePMCut() 85 | ), 86 | denomination_currency=Currency.USD, 87 | contract_size=100, 88 | ) 89 | assert_serialization(expected, EquityOption) 90 | -------------------------------------------------------------------------------- /tests/unit/deserialization/portfolio_deserialization.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from datetime import date 3 | 4 | from finstruments.common.enum import Currency 5 | from finstruments.instrument.common.cut import NyseAMCut, NysePMCut 6 | from finstruments.instrument.common.exercise_style import ( 7 | BermudanExerciseStyle, 8 | EuropeanExerciseStyle, 9 | ) 10 | from finstruments.instrument.common.option.enum import OptionType 11 | from finstruments.instrument.common.option.payoff import DigitalPayoff, VanillaPayoff 12 | from finstruments.instrument.equity import EquityETF, CommonStock 13 | from finstruments.instrument.equity import EquityForward 14 | from finstruments.instrument.equity import EquityFuture 15 | from finstruments.instrument.equity import EquityOption 16 | from finstruments.portfolio import Portfolio, Position, Trade 17 | from tests.unit.deserialization.util import assert_serialization 18 | 19 | 20 | class TestPortfolioDeserialization(unittest.TestCase): 21 | def test_base_serialization(self): 22 | expected = Portfolio( 23 | trades=[ 24 | Trade( 25 | positions=[ 26 | Position( 27 | instrument=EquityOption( 28 | underlying=CommonStock(ticker="TEST"), 29 | payoff=VanillaPayoff( 30 | option_type=OptionType.CALL, strike_price=100 31 | ), 32 | exercise_type=EuropeanExerciseStyle( 33 | expiration_date=date(2022, 6, 1), cut=NysePMCut() 34 | ), 35 | denomination_currency=Currency.USD, 36 | contract_size=100, 37 | ), 38 | size=100, 39 | ), 40 | Position( 41 | instrument=EquityOption( 42 | underlying=EquityETF(ticker="TEST"), 43 | payoff=DigitalPayoff( 44 | option_type=OptionType.CALL, 45 | strike_price=100, 46 | cash_payout=20, 47 | ), 48 | exercise_type=BermudanExerciseStyle( 49 | expiration_date=date(2022, 6, 1), 50 | early_exercise_dates=[ 51 | date(2022, 1, 1), 52 | date(2022, 3, 1), 53 | ], 54 | cut=NyseAMCut(), 55 | ), 56 | denomination_currency=Currency.USD, 57 | contract_size=100, 58 | ), 59 | size=5, 60 | ), 61 | ] 62 | ) 63 | ] 64 | ) 65 | expected_two = Portfolio( 66 | trades=[ 67 | Trade( 68 | positions=[ 69 | Position( 70 | instrument=EquityForward( 71 | underlying=CommonStock(ticker="TEST"), 72 | exercise_type=EuropeanExerciseStyle( 73 | expiration_date=date(2022, 6, 1), cut=NysePMCut() 74 | ), 75 | strike_price=100, 76 | denomination_currency=Currency.USD, 77 | contract_size=100, 78 | ), 79 | size=100, 80 | ), 81 | Position( 82 | instrument=EquityFuture( 83 | underlying=EquityETF(ticker="TEST"), 84 | exercise_type=EuropeanExerciseStyle( 85 | expiration_date=date(2022, 6, 1), cut=NysePMCut() 86 | ), 87 | strike_price=100, 88 | denomination_currency=Currency.USD, 89 | contract_size=100, 90 | ), 91 | size=5, 92 | ), 93 | ] 94 | ) 95 | ] 96 | ) 97 | assert_serialization(expected, Portfolio) 98 | assert_serialization(expected_two, Portfolio) 99 | 100 | def test_simple_portfolio(self): 101 | stock = CommonStock(ticker="AAPL") 102 | stock_position = Position(instrument=stock, size=100) 103 | trade = Trade(positions=[stock_position]) 104 | expected = Portfolio(trades=[trade]) 105 | 106 | assert_serialization(expected, Portfolio) 107 | 108 | def test_instrument_annotation_update(self): 109 | expected = Portfolio( 110 | trades=[ 111 | Trade( 112 | positions=[ 113 | Position(instrument=CommonStock(ticker="TEST"), size=100) 114 | ] 115 | ) 116 | ] 117 | ) 118 | assert_serialization(expected, Portfolio) 119 | -------------------------------------------------------------------------------- /tests/unit/deserialization/util.py: -------------------------------------------------------------------------------- 1 | from datetime import date 2 | from typing import List 3 | 4 | from pydantic import Field 5 | 6 | from finstruments.common.base import Base 7 | from finstruments.common.decorators import serializable 8 | from finstruments.instrument.common.exercise_style import BaseExerciseStyle 9 | from finstruments.instrument.common.option.payoff import BaseFixedStrikePayoff 10 | from finstruments.instrument.equity.instrument import BaseEquity 11 | 12 | 13 | def assert_serialization(expected: Base, object_type): 14 | data = expected.request_dict() 15 | result = object_type(**data) 16 | assert expected == result 17 | 18 | 19 | @serializable 20 | class AdditionalEquity(BaseEquity): 21 | code: str = Field(init=False, default="ADDITIONAL_EQUITY") 22 | 23 | 24 | @serializable 25 | class AdditionalPayoff(BaseFixedStrikePayoff): 26 | def compute_payoff(self, reference_level: float) -> float: 27 | return 0.0 28 | 29 | 30 | @serializable 31 | class AdditionalExerciseStyle(BaseExerciseStyle): 32 | def can_exercise(self, as_of_date: date) -> bool: 33 | return True 34 | 35 | def get_schedule(self) -> List[date]: 36 | return [] 37 | 38 | 39 | @serializable 40 | class AdditionalInstrument(BaseExerciseStyle): 41 | def can_exercise(self, as_of_date: date) -> bool: 42 | return True 43 | 44 | def get_schedule(self) -> List[date]: 45 | return [] 46 | -------------------------------------------------------------------------------- /tests/unit/instrument/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kyleloomis/finstruments/f700ad52987bea74567a309889ae6f688d7d25b5/tests/unit/instrument/__init__.py -------------------------------------------------------------------------------- /tests/unit/instrument/abstract_test.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from datetime import date 3 | 4 | from finstruments.common.enum import Currency 5 | from finstruments.instrument.common.cut import NysePMCut 6 | from finstruments.instrument.common.exercise_style import EuropeanExerciseStyle 7 | from finstruments.instrument.common.option.enum import OptionType 8 | from finstruments.instrument.common.option.payoff import VanillaPayoff 9 | from finstruments.instrument.equity import EquityETF, EquityOption 10 | 11 | 12 | class BaseInstrumentTest(unittest.TestCase): 13 | def test_base_instrument(self): 14 | etf: EquityETF = EquityETF(ticker="SPY") 15 | option: EquityOption = EquityOption( 16 | underlying=EquityETF(ticker="SPY"), 17 | payoff=VanillaPayoff(option_type=OptionType.CALL, strike_price=100), 18 | exercise_type=EuropeanExerciseStyle( 19 | expiration_date=date(2022, 5, 2), cut=NysePMCut() 20 | ), 21 | denomination_currency=Currency.USD, 22 | contract_size=100, 23 | ) 24 | 25 | self.assertEqual(option.underlying_instrument, EquityETF(ticker="SPY")) 26 | self.assertEqual(option.underlying_instrument, option.underlying) 27 | # `.underlying_instrument` should return self 28 | self.assertEqual(etf.underlying_instrument, etf) 29 | -------------------------------------------------------------------------------- /tests/unit/instrument/common/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kyleloomis/finstruments/f700ad52987bea74567a309889ae6f688d7d25b5/tests/unit/instrument/common/__init__.py -------------------------------------------------------------------------------- /tests/unit/instrument/common/cut_test.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from datetime import date, time 3 | 4 | from finstruments.common.date import datetime_to_timestamp, date_to_datetime 5 | from finstruments.instrument.common.cut import NyseAMCut, NysePMCut 6 | 7 | 8 | class ExerciseStyleCutTest(unittest.TestCase): 9 | def test_nyse_am_cut(self): 10 | cut = NyseAMCut() 11 | d = date(2020, 1, 1) 12 | cut_dt = cut.get_observation_datetime(d) 13 | dt = date_to_datetime(d, time(14, 30, 0)) 14 | 15 | self.assertEqual(datetime_to_timestamp(cut_dt), datetime_to_timestamp(dt)) 16 | 17 | def test_nyse_pm_cut(self): 18 | cut = NysePMCut() 19 | d = date(2020, 1, 1) 20 | cut_dt = cut.get_observation_datetime(d) 21 | dt = date_to_datetime(d, time(21, 0, 0)) 22 | 23 | self.assertEqual(datetime_to_timestamp(cut_dt), datetime_to_timestamp(dt)) 24 | -------------------------------------------------------------------------------- /tests/unit/instrument/common/exercise_style_test.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from datetime import date 3 | 4 | from finstruments.common.date import create_dates_between 5 | from finstruments.instrument.common.cut import NysePMCut 6 | from finstruments.instrument.common.exercise_style import ( 7 | EuropeanExerciseStyle, 8 | AmericanExerciseStyle, 9 | BermudanExerciseStyle, 10 | ) 11 | 12 | 13 | class ExerciseStyleTest(unittest.TestCase): 14 | def test_european_exercise_style_exercise(self): 15 | style = EuropeanExerciseStyle(expiration_date=date(2022, 1, 1), cut=NysePMCut()) 16 | self.assertFalse(style.can_exercise(date(2021, 1, 1))) 17 | self.assertFalse(style.can_exercise(date(2022, 1, 2))) 18 | self.assertTrue(style.can_exercise(date(2022, 1, 1))) 19 | 20 | def test_european_exercise_style_schedule(self): 21 | style = EuropeanExerciseStyle(expiration_date=date(2022, 1, 1), cut=NysePMCut()) 22 | self.assertListEqual(style.get_schedule(), [date(2022, 1, 1)]) 23 | 24 | def test_american_exercise_style_exercise(self): 25 | style = AmericanExerciseStyle( 26 | minimum_exercise_date=date(2021, 1, 1), 27 | expiration_date=date(2022, 1, 1), 28 | cut=NysePMCut(), 29 | ) 30 | self.assertFalse(style.can_exercise(date(2022, 1, 2))) 31 | self.assertTrue(style.can_exercise(date(2021, 6, 1))) 32 | self.assertTrue(style.can_exercise(date(2022, 1, 1))) 33 | 34 | def test_american_exercise_style_schedule(self): 35 | style = AmericanExerciseStyle( 36 | minimum_exercise_date=date(2021, 1, 1), 37 | expiration_date=date(2022, 1, 1), 38 | cut=NysePMCut(), 39 | ) 40 | self.assertListEqual( 41 | style.get_schedule(), 42 | create_dates_between(date(2021, 1, 1), date(2022, 1, 1)), 43 | ) 44 | 45 | def test_bermudan_exercise_style_exercise(self): 46 | style = BermudanExerciseStyle( 47 | early_exercise_dates=[date(2021, 1, 1), date(2021, 7, 1)], 48 | expiration_date=date(2022, 1, 1), 49 | cut=NysePMCut(), 50 | ) 51 | self.assertFalse(style.can_exercise(date(2022, 1, 2))) 52 | self.assertFalse(style.can_exercise(date(2021, 7, 2))) 53 | self.assertTrue(style.can_exercise(date(2021, 1, 1))) 54 | self.assertTrue(style.can_exercise(date(2021, 7, 1))) 55 | self.assertTrue(style.can_exercise(date(2022, 1, 1))) 56 | 57 | def test_bermudan_exercise_style_schedule(self): 58 | style = BermudanExerciseStyle( 59 | early_exercise_dates=[date(2021, 1, 1), date(2021, 7, 1)], 60 | expiration_date=date(2022, 1, 1), 61 | cut=NysePMCut(), 62 | ) 63 | self.assertListEqual( 64 | style.get_schedule(), [date(2021, 1, 1), date(2021, 7, 1), date(2022, 1, 1)] 65 | ) 66 | -------------------------------------------------------------------------------- /tests/unit/instrument/common/option/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kyleloomis/finstruments/f700ad52987bea74567a309889ae6f688d7d25b5/tests/unit/instrument/common/option/__init__.py -------------------------------------------------------------------------------- /tests/unit/instrument/common/option/payoff_test.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from finstruments.instrument.common.option.enum import OptionType 4 | from finstruments.instrument.common.option.payoff import VanillaPayoff, DigitalPayoff 5 | 6 | 7 | class PayoffTest(unittest.TestCase): 8 | def test_vanilla_payoff(self): 9 | call_payoff = VanillaPayoff(option_type=OptionType.CALL, strike_price=100) 10 | put_payoff = VanillaPayoff(option_type=OptionType.PUT, strike_price=100) 11 | 12 | self.assertEqual(call_payoff.compute_payoff(99), 0) 13 | self.assertEqual(call_payoff.compute_payoff(106), 6) 14 | self.assertEqual(put_payoff.compute_payoff(99), 1) 15 | self.assertEqual(put_payoff.compute_payoff(106), 0) 16 | 17 | def test_digital_payoff(self): 18 | call_payoff = DigitalPayoff( 19 | option_type=OptionType.CALL, strike_price=100, cash_payout=20 20 | ) 21 | put_payoff = DigitalPayoff( 22 | option_type=OptionType.PUT, strike_price=100, cash_payout=20 23 | ) 24 | 25 | self.assertEqual(call_payoff.compute_payoff(99), 0) 26 | self.assertEqual(call_payoff.compute_payoff(106), 20) 27 | self.assertEqual(put_payoff.compute_payoff(99), 20) 28 | self.assertEqual(put_payoff.compute_payoff(106), 0) 29 | -------------------------------------------------------------------------------- /tests/unit/instrument/cryptocurrency/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kyleloomis/finstruments/f700ad52987bea74567a309889ae6f688d7d25b5/tests/unit/instrument/cryptocurrency/__init__.py -------------------------------------------------------------------------------- /tests/unit/instrument/cryptocurrency/instrument_test.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from finstruments.common.enum import Currency 4 | from finstruments.instrument.common.currency_pair import CurrencyPair 5 | from finstruments.instrument.cryptocurrency import Cryptocurrency 6 | 7 | 8 | class CryptocurrencyInstrumentTest(unittest.TestCase): 9 | def test_cryptocurrency_instrument(self): 10 | btc = Cryptocurrency( 11 | ticker="BTC", full_name="Bitcoin", network="Bitcoin", contract_address=None 12 | ) 13 | btc_usd = CurrencyPair( 14 | base_currency=btc, 15 | quote_currency=Currency.USD, 16 | ) 17 | 18 | self.assertEqual(str(btc_usd), "BTC/USD") 19 | -------------------------------------------------------------------------------- /tests/unit/portfolio/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kyleloomis/finstruments/f700ad52987bea74567a309889ae6f688d7d25b5/tests/unit/portfolio/__init__.py -------------------------------------------------------------------------------- /tests/unit/portfolio/portfolio_test.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from datetime import date 3 | 4 | from finstruments.common.enum import Currency 5 | from finstruments.instrument.common.cut import NysePMCut 6 | from finstruments.instrument.common.exercise_style import EuropeanExerciseStyle 7 | from finstruments.instrument.common.option.payoff import VanillaPayoff, OptionType 8 | from finstruments.instrument.equity import CommonStock 9 | from finstruments.instrument.equity import EquityOption 10 | from finstruments.portfolio import Position, Trade, Portfolio 11 | 12 | 13 | class PortfolioTest(unittest.TestCase): 14 | def test_trade_get_expired_positions_empty(self): 15 | trade = Trade(positions=[]) 16 | 17 | d = date(2022, 1, 1) 18 | 19 | self.assertListEqual(trade.get_expired_positions(d), []) 20 | 21 | def test_trade_get_expired_positions(self): 22 | d = date(2022, 1, 1) 23 | 24 | equity = Position(instrument=CommonStock(ticker="TEST"), size=0) 25 | 26 | option = Position( 27 | instrument=EquityOption( 28 | underlying=CommonStock(ticker="TEST"), 29 | payoff=VanillaPayoff(option_type=OptionType.CALL, strike_price=100), 30 | exercise_type=EuropeanExerciseStyle(expiration_date=d, cut=NysePMCut()), 31 | denomination_currency=Currency.USD, 32 | contract_size=100, 33 | ), 34 | size=0, 35 | ) 36 | trade = Trade(positions=[equity, option]) 37 | 38 | self.assertListEqual(trade.get_expired_positions(d), [option]) 39 | 40 | def test_trade_filter_expired_positions_empty(self): 41 | trade = Trade(positions=[]) 42 | 43 | d = date(2022, 1, 1) 44 | 45 | self.assertEqual(trade.filter_expired_positions(d), trade) 46 | 47 | def test_trade_filter_expired_positions(self): 48 | d = date(2022, 1, 1) 49 | 50 | equity = Position(instrument=CommonStock(ticker="TEST"), size=0) 51 | 52 | option = Position( 53 | instrument=EquityOption( 54 | underlying=CommonStock(ticker="TEST"), 55 | payoff=VanillaPayoff(option_type=OptionType.CALL, strike_price=100), 56 | exercise_type=EuropeanExerciseStyle(expiration_date=d, cut=NysePMCut()), 57 | denomination_currency=Currency.USD, 58 | contract_size=100, 59 | ), 60 | size=0, 61 | ) 62 | 63 | option2 = Position( 64 | instrument=EquityOption( 65 | underlying=CommonStock(ticker="TEST"), 66 | payoff=VanillaPayoff(option_type=OptionType.CALL, strike_price=100), 67 | exercise_type=EuropeanExerciseStyle( 68 | expiration_date=date(2023, 1, 1), cut=NysePMCut() 69 | ), 70 | denomination_currency=Currency.USD, 71 | contract_size=100, 72 | ), 73 | size=0, 74 | ) 75 | trade = Trade(positions=[equity, option, option2]) 76 | 77 | self.assertEqual( 78 | trade.filter_expired_positions(d), Trade(positions=[equity, option2]) 79 | ) 80 | 81 | def test_trade_filter_positions(self): 82 | empty_trade = Trade(positions=[]) 83 | self.assertEqual(empty_trade.filter_positions(["test", "test2"]), empty_trade) 84 | 85 | trade = Trade( 86 | positions=[ 87 | Position(size=1, instrument=CommonStock(ticker="AAPL")), 88 | Position(size=1, instrument=CommonStock(ticker="AAPL")), 89 | Position(size=1, instrument=CommonStock(ticker="AAPL")), 90 | ] 91 | ) 92 | 93 | self.assertEqual(trade.filter_positions(["test"]), trade) 94 | self.assertEqual( 95 | trade.filter_positions([p.id for p in trade.positions]), Trade() 96 | ) 97 | self.assertEqual( 98 | trade.filter_positions([trade.positions[0].id]), 99 | Trade(positions=trade.positions[1:3]), 100 | ) 101 | 102 | def test_portfolio_filter_positions(self): 103 | empty_portfolio = Portfolio(trades=[]) 104 | self.assertEqual( 105 | empty_portfolio.filter_positions(["test", "test2"]), empty_portfolio 106 | ) 107 | 108 | trade = Trade( 109 | positions=[ 110 | Position(size=1, instrument=CommonStock(ticker="AAPL")), 111 | Position(size=1, instrument=CommonStock(ticker="AAPL")), 112 | Position(size=1, instrument=CommonStock(ticker="AAPL")), 113 | ] 114 | ) 115 | portfolio = Portfolio(trades=[trade]) 116 | 117 | self.assertEqual(portfolio.filter_positions(["test"]), portfolio) 118 | self.assertEqual( 119 | portfolio.filter_positions([p.id for p in trade.positions]), Portfolio() 120 | ) 121 | self.assertEqual( 122 | portfolio.filter_positions([trade.positions[0].id]), 123 | Portfolio(trades=[Trade(positions=trade.positions[1:3])]), 124 | ) 125 | 126 | def test_portfolio_get_expired_positions(self): 127 | d = date(2022, 1, 1) 128 | 129 | equity = Position(instrument=CommonStock(ticker="TEST"), size=0) 130 | 131 | option = Position( 132 | instrument=EquityOption( 133 | underlying=CommonStock(ticker="TEST"), 134 | payoff=VanillaPayoff(option_type=OptionType.CALL, strike_price=100), 135 | exercise_type=EuropeanExerciseStyle(expiration_date=d, cut=NysePMCut()), 136 | denomination_currency=Currency.USD, 137 | contract_size=100, 138 | ), 139 | size=0, 140 | ) 141 | 142 | option2 = Position( 143 | instrument=EquityOption( 144 | underlying=CommonStock(ticker="TEST"), 145 | payoff=VanillaPayoff(option_type=OptionType.CALL, strike_price=100), 146 | exercise_type=EuropeanExerciseStyle( 147 | expiration_date=date(2023, 1, 1), cut=NysePMCut() 148 | ), 149 | denomination_currency=Currency.USD, 150 | contract_size=100, 151 | ), 152 | size=0, 153 | ) 154 | trade = Trade(positions=[equity, option, option2]) 155 | portfolio = Portfolio(trades=[trade]) 156 | 157 | self.assertListEqual(portfolio.get_expired_positions(d), [option]) 158 | 159 | def test_portfolio_filter_expired_positions(self): 160 | d = date(2022, 1, 1) 161 | 162 | equity = Position(instrument=CommonStock(ticker="TEST"), size=0) 163 | 164 | option = Position( 165 | instrument=EquityOption( 166 | underlying=CommonStock(ticker="TEST"), 167 | payoff=VanillaPayoff(option_type=OptionType.CALL, strike_price=100), 168 | exercise_type=EuropeanExerciseStyle(expiration_date=d, cut=NysePMCut()), 169 | denomination_currency=Currency.USD, 170 | contract_size=100, 171 | ), 172 | size=0, 173 | ) 174 | 175 | option2 = Position( 176 | instrument=EquityOption( 177 | underlying=CommonStock(ticker="TEST"), 178 | payoff=VanillaPayoff(option_type=OptionType.CALL, strike_price=100), 179 | exercise_type=EuropeanExerciseStyle( 180 | expiration_date=date(2023, 1, 1), cut=NysePMCut() 181 | ), 182 | denomination_currency=Currency.USD, 183 | contract_size=100, 184 | ), 185 | size=0, 186 | ) 187 | trade = Trade(positions=[equity, option, option2]) 188 | portfolio = Portfolio(trades=[trade]) 189 | 190 | self.assertEqual( 191 | portfolio.filter_expired_positions(d), 192 | Portfolio(trades=[Trade(positions=[equity, option2])]), 193 | ) 194 | 195 | def test_portfolio_get_positions(self): 196 | d = date(2022, 1, 1) 197 | equity = Position(instrument=CommonStock(ticker="TEST"), size=0) 198 | 199 | option = Position( 200 | instrument=EquityOption( 201 | underlying=CommonStock(ticker="TEST"), 202 | payoff=VanillaPayoff(option_type=OptionType.CALL, strike_price=100), 203 | exercise_type=EuropeanExerciseStyle(expiration_date=d, cut=NysePMCut()), 204 | denomination_currency=Currency.USD, 205 | contract_size=100, 206 | ), 207 | size=0, 208 | ) 209 | 210 | option2 = Position( 211 | instrument=EquityOption( 212 | underlying=CommonStock(ticker="TEST"), 213 | payoff=VanillaPayoff(option_type=OptionType.CALL, strike_price=100), 214 | exercise_type=EuropeanExerciseStyle( 215 | expiration_date=date(2023, 1, 1), cut=NysePMCut() 216 | ), 217 | denomination_currency=Currency.USD, 218 | contract_size=100, 219 | ), 220 | size=0, 221 | ) 222 | portfolio = Portfolio( 223 | trades=[Trade(positions=[equity]), Trade(positions=[option, option2])] 224 | ) 225 | 226 | self.assertListEqual(portfolio.positions, [equity, option, option2]) 227 | 228 | def test_portfolio_add_trade(self): 229 | d = date(2022, 1, 1) 230 | equity = Position(instrument=CommonStock(ticker="TEST"), size=0) 231 | 232 | option = Position( 233 | instrument=EquityOption( 234 | underlying=CommonStock(ticker="TEST"), 235 | payoff=VanillaPayoff(option_type=OptionType.CALL, strike_price=100), 236 | exercise_type=EuropeanExerciseStyle(expiration_date=d, cut=NysePMCut()), 237 | denomination_currency=Currency.USD, 238 | contract_size=100, 239 | ), 240 | size=0, 241 | ) 242 | 243 | option2 = Position( 244 | instrument=EquityOption( 245 | underlying=CommonStock(ticker="TEST"), 246 | payoff=VanillaPayoff(option_type=OptionType.CALL, strike_price=100), 247 | exercise_type=EuropeanExerciseStyle( 248 | expiration_date=date(2023, 1, 1), cut=NysePMCut() 249 | ), 250 | denomination_currency=Currency.USD, 251 | contract_size=100, 252 | ), 253 | size=0, 254 | ) 255 | portfolio = Portfolio(trades=[Trade(positions=[equity])]) 256 | 257 | new_portfolio = portfolio.add_trade(Trade(positions=[option, option2])) 258 | 259 | self.assertEqual( 260 | Portfolio( 261 | trades=[Trade(positions=[equity]), Trade(positions=[option, option2])] 262 | ), 263 | new_portfolio, 264 | ) 265 | 266 | def test_portfolio_add_position(self): 267 | d = date(2022, 1, 1) 268 | equity = Position(instrument=CommonStock(ticker="TEST"), size=0) 269 | 270 | option = Position( 271 | instrument=EquityOption( 272 | underlying=CommonStock(ticker="TEST"), 273 | payoff=VanillaPayoff(option_type=OptionType.CALL, strike_price=100), 274 | exercise_type=EuropeanExerciseStyle(expiration_date=d, cut=NysePMCut()), 275 | denomination_currency=Currency.USD, 276 | contract_size=100, 277 | ), 278 | size=0, 279 | ) 280 | 281 | portfolio = Portfolio(trades=[Trade(positions=[equity])]) 282 | 283 | new_portfolio = portfolio.add_position(option) 284 | 285 | self.assertEqual( 286 | Portfolio(trades=[Trade(positions=[equity]), Trade(positions=[option])]), 287 | new_portfolio, 288 | ) 289 | --------------------------------------------------------------------------------