Repository: tradinghours/tradinghours-python (language: python)

Issue:
Miscellaneous



Discussion / hints:

Fix patch:
diff --git a/.gitignore b/.gitignore
index 6814a53..de02f25 100644
--- a/.gitignore
+++ b/.gitignore
@@ -136,7 +136,7 @@ celerybeat.pid
 *.sage.py
 
 # Environments
-.env
+*.env
 .venv
 env/
 venv/
diff --git a/README.md b/README.md
index fa502cd..7cdf7e6 100644
--- a/README.md
+++ b/README.md
@@ -1,15 +1,21 @@
 <div align="center">
-<img src="https://www.tradinghours.com/img/logo-512x512.png" alt="TradingHours API Docs" height="100">
-<h1>TradingHours.com Python Library</h1>
+  <img src="https://www.tradinghours.com/img/logo-512x512.png" alt="TradingHours API Docs" height="100">
+  <h1>TradingHours.com Python Library</h1>
+
+  <!-- Badges centered -->
+  <p>
+    <a href="https://badge.fury.io/py/tradinghours">
+      <img src="https://badge.fury.io/py/tradinghours.svg" alt="PyPI version">
+    </a>
+    <img src="https://img.shields.io/badge/python-3.9%20%7C%203.10%20%7C%203.11%20%7C%203.12-green" alt="Python versions">
+    <img src="https://github.com/tradinghours/tradinghours-python/actions/workflows/release.yml/badge.svg?branch=main" alt="GitHub Actions">
+  </p>
 </div>
 
-[TradingHours.com](https://www.tradinghours.com) licenses **Market Holidays and Trading Hours data** for over **1,000** exchanges and trading venues around the world.
-This library allows clients to easily integrate market holidays and trading hours data into existing applications.
-This packages downlods all available data from TradingHours.com and then allows you to work with the data locally.
+[TradingHours.com](https://www.tradinghours.com) licenses **Market Holidays and Trading Hours data** for over **1,000** exchanges and trading venues around the world. This library allows clients to easily integrate market holidays and trading hours data into existing applications. This package downloads all available data from TradingHours.com, allowing you to work with the data locally.
 
 ### About the Data
-We support over 1,000 exchanges and trading venues, including all major currencies.
-[See all supported markets](https://www.tradinghours.com/coverage).
+We support over 1,000 exchanges and trading venues, including all major currencies. [See all supported markets.](https://www.tradinghours.com/coverage)
 
 Our comprehensive data covers:
 
@@ -20,36 +26,45 @@ Our comprehensive data covers:
 - Currency holidays
 - Detailed trading phases
 
-### How is data collected?
-Our global research team collects and verifies trading hours and market holidays using exclusively primary sources.
-Manual and automated checks ensure the highest degree of accuracy and reliability.
+### How is the data collected?
+Our global research team collects and verifies trading hours and market holidays using primary sources exclusively. Manual and automated checks ensure the highest degree of accuracy and reliability.
 
-Once data is collected, we then continually monitor for changes to ensure the data is always up-to-date.
-Data is updated daily.
-
-[Learn more »](https://www.tradinghours.com/data)
+Once data is collected, we continually monitor for changes to ensure the data is always up-to-date. Data updates occur daily.
 
 ### Getting Started
 
-Just install `tradinghours` with pip and set your API key. [Click here to get your key](https://www.tradinghours.com/user/api-tokens).
-```
-pip install tradinghours
-
-export TRADINGHOURS_TOKEN=<your-key-goes-here>
+To get started, you'll need an active subscription. [Learn more »](https://www.tradinghours.com/data)
+
+1. Install the `tradinghours` package
+   
+   ```sh
+   pip install tradinghours
+   ```
+   
+2. Set your API Key ([Click here to get your key](https://www.tradinghours.com/user/api-tokens))
+
+   ```sh
+   export TRADINGHOURS_TOKEN=<your-key-goes-here>
+   ```
+
+You can also install with mysql or postgres dependencies, if you wish to use one of these. You can read more about this in [advanced configuration options](#optional-advanced-configuration).
+```sh
+pip install tradinghours[mysql] 
+# or 
+pip install tradinghours[postgres]
 ```
-See [advanced configuration options](#optional-advanced-configuration). 
 
 ### Alternatives
 
-Instead of using this Python Library, clients can also use the web-based [Trading Hours API](https://docs.tradinghours.com/). 
+Instead of using this Python Library, clients can also use the web-based [Trading Hours API](https://docs.tradinghours.com/).
 The web-based API is programming language agnostic.
 
----
 ### Contents
 - [Importing Data](#importing-data)
 - [Markets](#markets)
   - [View Available Markets](#view-available-markets)
-  - [Get A Specific Market](#get-a-specific-market)
+  - [Get a Specific Market](#get-a-specific-market)
+  - [Market Status](#market-status)
   - [Market Holidays](#market-holidays)
   - [Trading Hours](#trading-hours)
 - [Currencies](#currencies)
@@ -57,15 +72,15 @@ The web-based API is programming language agnostic.
   - [Currency Holidays](#currency-holidays)
 - [Advanced](#advanced)
   - [Optional Advanced Configuration](#optional-advanced-configuration)
-  - [Database Schema](#database-schema)
-  - [Time Zone Database](#time-zone-database)
+  - [Database](#database)
+  - [Time Zones](#time-zones)
   - [Model Configuration](#model-configuration)
     - [Change String Format](#change-string-format)
 
 ---
 ## Importing Data
 
-You just need to run the following command to download and import official data. Remember that you need to have a valid **TRADINGHOURS_TOKEN** environment variable.
+Run the following command to download and import official data. Ensure you have set the **TRADINGHOURS_TOKEN** environment variable.
 
 ```console
 $ tradinghours import
@@ -73,7 +88,7 @@ Downloading..... (0.824s)
 Ingesting.......................... (12.066s)
 ```
 
-You can then check current data status with the following subcommand:
+You can check the current data status with the following subcommand:
 
 ```console
 $ tradinghours status --extended
@@ -91,18 +106,32 @@ Extended Information:
 ## Markets
 
 ### View Available Markets
+
 ```python
 from tradinghours import Market
 
 for market in Market.list_all()[:3]:
     print(market)
-    
+
 >>> Market: AE.ADX Abu Dhabi Securities Exchange Asia/Dubai
     Market: AE.DFM Dubai Financial Market Asia/Dubai
     Market: AE.DGCX Dubai Gold & Commodities Exchange Asia/Dubai
 ```
 
-### Get A Specific Market   
+You can also use an `*` to filter the list of markets based on their `fin_id`:
+
+```python
+from tradinghours import Market
+
+for market in Market.list_all("US.*")[:3]:
+  print(market)
+
+>>> Market: US.BTEC.ACTIVES.ASIA BrokerTec America/New_York
+    Market: US.BTEC.ACTIVES.LDN BrokerTec America/New_York
+    Market: US.BTEC.ACTIVES.US BrokerTec America/New_York
+```
+
+### Get a Specific Market
 
 ```python
 from tradinghours import Market
@@ -114,24 +143,22 @@ market = Market.get('XNYS')
 # Easily see what attributes an object has
 # (You can call this on any object)
 market.pprint() # same as pprint(market.to_dict())
->>> {'fin_id': 'US.NYSE',
-     'exchange_name': 'New York Stock Exchange',
+>>> {'exchange_name': 'New York Stock Exchange',
      'market_name': 'Canonical',
      'security_group': None,
+     'timezone': 'America/New_York',
+     'weekend_definition': 'Sat-Sun',
+     'fin_id': 'US.NYSE',
      'mic': 'XNYS',
      'acronym': 'NYSE',
      'asset_type': 'Securities',
      'memo': 'Canonical',
      'permanently_closed': None,
-     'timezone': 'America/New_York',
-     'weekend_definition': 'Sat-Sun',
      'replaced_by': None,
      'country_code': 'US'}
 ```
 
-If a market is marked "permanently closed" it may be replaced or superseded by another market. 
-By default, the newer market will be returned automatically. You can still retrieve the 
-older market object for historical analysis by using the `follow=False` parameter.
+If a market is marked "permanently closed," it may be replaced or superseded by another market. By default, the newer market will be returned automatically. You can retrieve the older market object for historical analysis by using the `follow=False` parameter.
 
 ```python
 from tradinghours import Market
@@ -147,6 +174,41 @@ print(f'{original.fin_id} replaced by {original.replaced_by} on {original.perman
     AR.BCBA replaced by AR.BYMA on 2017-04-17
 ```
 
+### Market Status
+The `Market.status` method will return a `MarketStatus` representing the status of the market at a specific datetime.
+
+```python
+from tradinghours import Market
+import datetime as dt
+
+market = Market.get("US.NYSE")
+status = market.status()
+# The default datetime is the current time.
+now = dt.datetime.now(dt.timezone.utc)
+print(
+  status.status == market.status(now).status
+)
+>>> True
+```
+To use a different datetime, create a timezone-aware datetime object.
+
+```python
+from tradinghours import Market
+from zoneinfo import ZoneInfo
+import datetime as dt
+
+christmas_noon = dt.datetime(2024,12,25,12,tzinfo=ZoneInfo("America/New_York"))
+status = Market.get("US.NYSE").status(christmas_noon)
+
+status.pprint() # same as pprint(status.to_dict())
+>>> {'status': 'Closed',
+     'reason': 'Christmas',
+     'until': '2024-12-26 04:00:00-05:00',
+     'next_bell': '2024-12-26 09:30:00-05:00',
+     'phase': None,
+     'market': 'Market: US.NYSE New York Stock Exchange America/New_York'}
+```
+
 ### Market Holidays
 
 ```python
@@ -163,8 +225,8 @@ for holiday in holidays[:3]:
 ```
 ### Trading Hours
 #### Phases
-To get open and closing times for a particular date range, use the `Market.generate_phases` method.
-This will return a generator yielding `tradinghours.models.Phase` objects, representing specific datetimes. These are based on the "general schedule" of a market (see next section) but consider the impact of holidays and potential changes in the schedule.
+To get opening and closing times for a particular date range, use the `Market.generate_phases` method. This will return a generator yielding `tradinghours.models.Phase` objects, representing specific datetimes based on the "general schedule" of a market, considering holidays and potential schedule changes.
+
 ```python
 from tradinghours import Market
 
@@ -177,7 +239,7 @@ for phase in list(market.generate_phases("2023-09-01", "2023-09-30"))[:3]:
     Phase: 2023-09-01 09:30:00-04:00 - 2023-09-01 09:30:00-04:00 Call Auction
 ```
 #### Schedules
-To get the "general schedule" that phases are based on, use `Market.list_schedules()`. This will provide a list of `tradinghours.models.Schedule` objects, representing the schedule without consideration of holidays. The schedule will include 'Regular,' 'Partial,' and potentially other irregular schedules. Interpreting the general schedule objects can be difficult. In most cases, you will want to use the `Market.generate_phases` method above.
+To get the "general schedule" that phases are based on, use `Market.list_schedules()`. This will provide a list of `tradinghours.models.Schedule` objects, representing the schedule without consideration of holidays. The schedule will include 'Regular,' 'Partial,' and potentially other irregular schedules. Interpreting these schedule objects can be difficult. In most cases, you will want to use the `Market.generate_phases` method above.
 
 `US.NYSE` is one of the simplest examples for schedules:
 ```python
@@ -198,7 +260,8 @@ for schedule in market.list_schedules():
     Schedule: US.NYSE (Regular) 16:00:00 - 20:00:00    Mon-Fri Post-Trading Session
 ```
 
-`US.MGEX` is a more complex example, which has multiple irregular schedules and overnight trading sessions. (More on these fields in the next paragraph)
+`US.MGEX` is a more complex example, which has multiple irregular schedules and overnight trading sessions:
+
 ```python
 from tradinghours import Market
 
@@ -231,10 +294,10 @@ schedule.pprint() # same as pprint(schedule.to_dict())
      'phase_name': 'Pre-Open', # name for the phase as it is used by the market
      'phase_memo': None, # additional description for the phase_name
      'days': 'Wed', # days of the week that this schedule applies to
-     'start': datetime.time(16, 45), # start time of the phase
-     'end': datetime.time(8, 30), # end time of the phase
+     'start': '16:45:00', # start time of the phase
+     'end': '08:30:00', # end time of the phase
      'offset_days': 2, # number of days that need to be added to the end time
-     'duration': '143100', # total length of this phase in seconds
+     'duration': 143100, # total length of this phase in seconds
      'min_start': None, # earliest possible start when random start/stop times apply
      'max_start': None, # latest possible start when random start/stop times apply
      'min_end': None, # earliest possible end when random start/stop times apply
@@ -246,8 +309,7 @@ schedule.pprint() # same as pprint(schedule.to_dict())
      'end_with_offset': '08:30:00 +2', # string representation of the end time with offset_days concatenated
      'has_season': False} # Indicator whether this schedule only applies to a specific season
 ```
-As mentioned previously, it can be very error-prone to interpret these schedules yourself, so we recommend sticking to the `generate_phases` method as much as possible.
-
+As mentioned earlier, it can be very error-prone to interpret these schedules yourself, so we recommend sticking to the `generate_phases` method as much as possible.
 
 ## Currencies
 ### List Currencies
@@ -280,54 +342,51 @@ for holiday in currency.list_holidays("2023-06-01", "2023-12-31")[:3]:
 ## Advanced
 ### Optional Advanced Configuration
 
-By default, the library uses local file storage. Optionally you can 
-configure the library to use an SQL store. You can adjust settings
-using a **tradinghours.ini** file on the current working directory.
+Configuration can be changed by creating a `tradinghours.ini` file in the current directory.
 
-Here is a sample configuration file using file system storage:
+These are possible and optional values, for which explanations follow:
 
 ```ini
 [api]
-token = YOUR-TOKEN-HERE
+token = YOUR-TOKEN
 
 [data]
-use_db = False
-local_dir = /srv/tradinghours/local
-remote_dir = /srv/tradinghours/remote
-```
+db_url = postgresql://postgres:password@localhost:5432/your_database
+table_prefix = thstore_
+remote_dir = path/to/empty/folder
 
-And here you can see one using a local SQL Alchemy database. Note that
-you can use any valid [Database URL](https://docs.sqlalchemy.org/en/20/core/engines.html#database-urls):
-
-```ini
-[api]
-token = YOUR-TOKEN-HERE
-
-[data]
-use_db = True
-db_url = sqlite:///tradinghours.db
-```
-
-### Database Schema
-
-In case you would like to directly access the tables for the SQL mode, you
-can see that they all follow a very simple structure with keys for stored data
-and the data with actual JSON. The library uses this simple structure that
-should be compatible with nearly all database engines around.
-
-Here is the DDL for the tables currently in use:
-
-```sql
-CREATE TABLE thstore_currencies (id INTEGER NOT NULL, slug VARCHAR, "key" VARCHAR, data JSON, PRIMARY KEY (id));
-CREATE TABLE thstore_currency_holidays (id INTEGER NOT NULL, slug VARCHAR, "key" VARCHAR, data JSON, PRIMARY KEY (id));
-CREATE TABLE thstore_holidays (id INTEGER NOT NULL, slug VARCHAR, "key" VARCHAR, data JSON, PRIMARY KEY (id));
-CREATE TABLE thstore_markets (id INTEGER NOT NULL, slug VARCHAR, "key" VARCHAR, data JSON, PRIMARY KEY (id));
-CREATE TABLE thstore_mic_mapping (id INTEGER NOT NULL, slug VARCHAR, "key" VARCHAR, data JSON, PRIMARY KEY (id));
-CREATE TABLE thstore_schedules (id INTEGER NOT NULL, slug VARCHAR, "key" VARCHAR, data JSON, PRIMARY KEY (id));
-CREATE TABLE thstore_season_definitions (id INTEGER NOT NULL, slug VARCHAR, "key" VARCHAR, data JSON, PRIMARY KEY (id));
+[control]
+check_tzdata = False
 ```
 
-### Time Zone Database 
+### Database
+* `[data]`
+  * `db_url`
+    * A connection string to a database. Please read the [caveats](#caveats) before using this setting.
+    * This allows you to download the data once and let your team members use the same database.
+  * `table_prefix`
+    * Every table created in the database will be prefixed with this. `'thstore_'` is the default.
+    * This can be used to avoid conflicts with existing tables.
+  * `remote_dir`
+    * The folder in which to save the raw CSV files after downloading with `tradinghours import`.
+    * The content of these CSV files will immediately be ingested into the database defined in `db_url` and then not used anymore.
+    * Unless you want to access the raw CSV files directly, there is no reason to change this.
+
+#### Caveats
+* This package has been tested with MySQL 8.4 and PostgreSQL 15.8
+* Dependencies:
+  * Running `pip install tradinghours[mysql]` or `pip install tradinghours[postgres]` installs `pymysql` or `psycopg2-binary`, respectively.
+  * You can install any other package (e.g. `mysqlclient`), as long as it allows `sqlalchemy` to communicate with the chosen database.
+* Data ingestion:
+  * Tables used by this package (identified by the `table_prefix`) are dropped and recreated every time `tradinghours import` is run. 
+  * To avoid any complications with existing data, we recommend creating a separate database for the `tradinghours` data, and making this the only database the `db_url` user has access to.
+
+##### Schema
+* The tables are named after the CSV files, with `_` instead of `-` and prefixed with the `table_prefix` setting.
+* To allow flexibility with updates to the raw data, where columns might be added in the future, tables are created dynamically, based on the content of the CSV files.
+* Columns of the tables are named after the columns of the CSV files, although in lower case and with underscores instead of spaces.
+
+### Time Zones
 This package employs `zoneinfo` for timezone management, utilizing the IANA Time Zone Database, 
 which is routinely updated. In certain environments, it's essential to update the `tzdata` package accordingly. 
 `tradinghours` automatically checks your `tzdata` version against PyPI via HTTP request, issuing a warning 
diff --git a/dev-readme.md b/dev-readme.md
index 2969d9b..9753882 100644
--- a/dev-readme.md
+++ b/dev-readme.md
@@ -1,22 +1,134 @@
-## Setup
+# Developer README
 
-* clone the repository
-* create virtual environment and activate it
-* run: `pip install -e .[dev]`, which installs with dev dependencies
-* or run: `pip install -e .`, which only installs regular dependencies
-* run pytest, after importing tradinghours data
+## Setting Up Local Environment
 
+### Clone the Repository
 
+Starting from scratch, go into a test folder and follow these steps to set up the code locally. This will clone the repository, switch to the desired branch, and set up the Python environment:
 
-## CI/CD
+```bash
+git clone https://github.com/tradinghours/tradinghours-python.git
 
-* from whatever branch you are working on, open a PR against `pre-release`
-  * this will trigger the tests.yml workflow
-  * if these pass, merge with `pre-release`
+cd tradinghours-python
 
-* When `pre-release` contains everything for the new release, open a PR against `main`
-  * this will trigger the test_release.yml workflow
-  * if that passes, merge with `main`
+python -m venv venv
 
-* Merging with main triggers the release.yml workflow
-  * this will push to pypi and create a release tag on github
\ No newline at end of file
+venv\Scripts\activate  # on Windows
+# or
+source venv/bin/activate  # on Unix or MacOS
+
+pip install -e .
+```
+
+You can now use the package from this directory.
+
+### Running Tests
+
+To run the tests, you need to install the development requirements (e.g., `pytest`):
+
+```bash
+pip install -e .[dev]
+
+pytest
+```
+
+### Running with a Database
+
+If you want to run it with a MySQL or Postgres database, create a `tradinghours.ini` file in the current directory with the connection string:
+
+```ini
+[data]
+db_url = mysql+pymysql://test_user:test_password@localhost:3306/test_db
+```
+
+Then, install the appropriate dependencies:
+
+```bash
+pip install -e .[mysql]  # or pip install -e .[postgres]
+```
+
+If you don't have one of these databases available, you can run a Docker container defined in `docker-compose.yml`:
+
+```bash
+docker compose up mysql -d  # for MySQL
+# or
+docker compose up postgres -d  # for PostgreSQL
+```
+
+When done, don't forget to shut it down:
+
+```bash
+docker compose down
+```
+
+## CI/CD Workflow
+
+### Overview
+
+The CI/CD process uses GitHub Actions to automate testing and releasing the project. The workflow includes several steps described below:
+
+1. **Create a Pull Request (PR) against `pre-release`:**
+   - This will trigger the `tests.yml` workflow.
+   - If all tests pass, merge with the `pre-release` branch.
+
+2. **PR against `main`:**
+   - Increment the version number in `src\tradinghours\__init__.py`.
+   - Describe the changes in `release_log.txt` under the `## new_release` heading. (But keep the heading as it is)
+   - Open a PR from the `pre-release` branch to `main`.
+   - This will trigger the `test_release.yml` workflow.
+   - If the tests pass, merge with the `main` branch.
+
+3. **Automatic Release:**
+   - Merging with `main` triggers the `release.yml` workflow.
+   - This will push to PyPI and create a release tag on GitHub.
+
+## GitHub Actions Workflows
+
+### `tests.yml`
+
+This workflow runs tests across different configurations including coverage, database integration tests, and various OS and Python versions.
+
+#### Trigger
+
+The workflow is triggered on pull requests to the `pre-release` branch.
+
+#### Sections
+
+1. **Coverage**
+2. **Database Tests**
+3. **OS Versions and API Levels**
+4. **Python Versions**
+
+### `test_release.yml`
+
+This workflow ensures that the package can be pushed and pulled from the test PyPI repository before an actual release.
+
+#### Trigger
+
+The workflow is triggered on pull requests to the `main` branch.
+
+#### Steps
+
+1. **Push to Test PyPI**:
+    - Checks if the version has been incremented.
+    - Releases the package to Test PyPI.
+2. **Test from Test PyPI**:
+    - Installs the package from Test PyPI.
+    - Runs all tests to ensure the package works as expected.
+
+### `release.yml`
+
+This workflow publishes the package to PyPI and creates a release on GitHub.
+
+#### Trigger
+
+The workflow is triggered on push events to the `main` branch.
+
+#### Steps
+
+1. **Update Version and Summary**:
+   - Sets the version and updates the release log.
+2. **Build and Publish**:
+   - Publishes the package to PyPI using Flit.
+3. **Create GitHub Release**:
+   - Creates a new GitHub release with the current version and summary.
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..56ab274
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,46 @@
+version: '3.8'
+
+services:
+  mysql5:
+    image: mysql:5.7
+    environment:
+      MYSQL_ROOT_PASSWORD: root
+      MYSQL_DATABASE: test_db
+      MYSQL_USER: test_user
+      MYSQL_PASSWORD: test_password
+    ports:
+      - "3306:3306"
+    healthcheck:
+      test: [ "CMD", "mysqladmin", "ping", "--silent" ]
+      interval: 10s
+      retries: 3
+      timeout: 5s
+
+  mysql:
+    image: mysql:8.4
+    environment:
+      MYSQL_ROOT_PASSWORD: root
+      MYSQL_DATABASE: test_db
+      MYSQL_USER: test_user
+      MYSQL_PASSWORD: test_password
+    ports:
+      - "3306:3306"
+    healthcheck:
+      test: ["CMD", "mysqladmin", "ping", "--silent"]
+      interval: 10s
+      retries: 3
+      timeout: 5s
+
+  postgres:
+    image: postgres:15.8
+    environment:
+      POSTGRES_USER: postgres
+      POSTGRES_PASSWORD: password
+      POSTGRES_DB: test_db
+    ports:
+      - "5432:5432"
+    healthcheck:
+      test: ["CMD-SHELL", "pg_isready -U postgres"]
+      interval: 10s
+      retries: 5
+      timeout: 5s
diff --git a/pyproject.toml b/pyproject.toml
index d192632..383cbe1 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -17,7 +17,7 @@ classifiers = [
     "Topic :: Office/Business :: Financial",
     "Topic :: Software Development :: Libraries",   
 ]
-dependencies = ['tzdata', 'requests']
+dependencies = ['tzdata', 'requests', 'sqlalchemy']
 
 [project.urls]
 "Homepage" = "https://github.com/tradinghours/tradinghours-python"
@@ -34,8 +34,12 @@ dev = [
     'pytest',
     'pytest-mock',
 ]
-sql = [
-    'sqlalchemy',
+mysql = [
+    'pymysql',
+    'cryptography'
+]
+postgres = [
+    'psycopg2-binary'
 ]
 
 [project.scripts]
diff --git a/release_log.txt b/release_log.txt
index c00541f..e2d75cb 100644
--- a/release_log.txt
+++ b/release_log.txt
@@ -1,3 +1,10 @@
+## new_release
+This update completely overhauls the backend of the package:
+ * Instead of using two storages (files and sql), it uses sqlite by default but can be used with a sql connection string to a remote database.
+ * The interface is mostly the same, except that some functionality, outputs, and exceptions have been added or modified.
+ * It is best to consult the README.md and dev-readme.md as if it's the first time you are working with this package.
+
+
 ## 0.3.0 (2024-02-16)
 Breaking Changes:
   * renamed Market.generate_schedules to Market.generate_phases
diff --git a/src/tradinghours/__init__.py b/src/tradinghours/__init__.py
index 2e9c8c9..ce322dc 100644
--- a/src/tradinghours/__init__.py
+++ b/src/tradinghours/__init__.py
@@ -1,5 +1,7 @@
 """TradingHours Library"""
 
-__version__ = "0.3.0"
+__version__ = "0.4.0"
+
+from .currency import Currency
+from .market import Market
 
-from .models import Market, Currency
diff --git a/src/tradinghours/base.py b/src/tradinghours/base.py
deleted file mode 100644
index 5473420..0000000
--- a/src/tradinghours/base.py
+++ /dev/null
@@ -1,268 +0,0 @@
-import datetime
-from typing import (
-    TYPE_CHECKING,
-    Dict,
-    Generic,
-    List,
-    Optional,
-    Tuple,
-    Type,
-    TypeVar,
-    cast,
-)
-
-from zoneinfo import ZoneInfo
-from .exceptions import PrepareError
-from .structure import FinId, Mic, Weekday, WeekdayPeriod, WeekdaySet
-from .util import snake_dict
-from pprint import pprint
-
-if TYPE_CHECKING:
-    from .catalog import Catalog
-
-T = TypeVar("T")
-
-def class_decorator(cls):
-    fields = []
-    extra_fields = []
-    for att_name in cls.__dict__:
-        if att_name.startswith("_"):
-            continue
-        att = getattr(cls, att_name)
-        if callable(att):
-            continue
-
-        if isinstance(att, property):
-            extra_fields.append(att_name)
-        else:
-            fields.append(att_name)
-
-    cls._fields = fields
-    cls._extra_fields = extra_fields
-    cls.fields = [*fields, *extra_fields]
-    cls.set_string_format(cls._string_format, prefix_class=True)
-    cls._original_string_format = cls.get_string_format()
-    return cls
-
-
-class BaseObject:
-    """Base model objects"""
-
-    fields = [] # set in class_decorator
-    _string_format = "" # set in each
-    _original_string_format = ""
-
-    @classmethod
-    def get_string_format(cls):
-        return cls._string_format
-
-    @classmethod
-    def set_string_format(cls, string_format: str, prefix_class: bool = False):
-        try:
-            string_format.format(**{f: "test" for f in cls.fields})
-        except Exception as e:
-            print(string_format)
-            raise ValueError("Invalid formatting string") from e
-
-        if prefix_class:
-            string_format = f"{cls.__name__}: " + string_format
-
-        cls._string_format = string_format
-
-    @classmethod
-    def reset_string_format(cls):
-        cls._string_format = cls._original_string_format
-
-    @classmethod
-    def from_tuple(cls, data: Tuple):
-        """Used when reading from local/.../.dat for loading"""
-        return cls(data)
-
-    @classmethod
-    def from_dict(cls, data: Dict):
-        """Used when reading from remote/csv for ingestion"""
-        normalized = snake_dict(data)
-        return cls(normalized)
-
-    @classmethod
-    def get_catalog(cls, catalog: Optional["Catalog"]) -> "Catalog":
-        if catalog is None:
-            from .catalog import default_catalog
-
-            return default_catalog
-        return catalog
-
-    def __init__(self, data: [Dict, tuple]):
-        """
-        Sets the instance attributes, prepared according to their type.
-            self.data holds the values of the instance attributes.
-            self.raw_data holds the values as they were retrieved from the files.
-        """
-        if data_is_dict := isinstance(data, dict):
-            self.raw_data = data
-        else:
-            self.raw_data = {}
-
-        self.data = {}
-        # print(self.__class__)
-        # print(self.__class__.fields)
-        # print(data)
-        for i, field in enumerate(self._fields):
-            if data_is_dict:
-                raw_value = data[field]
-            else:
-                raw_value = data[i]
-                self.raw_data[field] = raw_value
-
-            prepared_value = getattr(self, field).safe_prepare(raw_value)
-            if isinstance(prepared_value, tuple):
-                prepared_value, obj = prepared_value
-                setattr(self, f"{field}_obj", obj)
-
-            setattr(self, field, prepared_value)
-            self.data[field] = prepared_value
-
-    def to_dict(self) -> Dict:
-        return {f: getattr(self, f) for f in self.fields}
-
-    # TODO: remove from this, it should be specific to writing the data
-    def _to_tuple(self, raw=False) -> Tuple:
-        """Used when adding data to store for ingestion"""
-        data = self.raw_data if raw else self.data
-        return tuple(data[f] for f in self._fields)
-
-    def pprint(self):
-        pprint(self.to_dict(), sort_dicts=False)
-
-    def __repr__(self):
-        class_name = self.__class__.__name__
-        return f"{class_name}({self.data!r})"
-
-    def __str__(self):
-        if not self._string_format:
-            class_name = self.__class__.__name__
-            all_str = []
-            for current_field in self.fields:
-                current_value = getattr(self, current_field)
-                if current_value:
-                    all_str.append(str(current_value))
-            fields_str = " ".join(all_str)
-            return f"{class_name} {fields_str}"
-
-        all_data = {f: getattr(self, f) for f in self.fields}
-        return self._string_format.format(**all_data)
-
-
-class Field(Generic[T]):
-    """Base field class"""
-    def __set_name__(self, owner: Type[BaseObject], name):
-        self._field_name = f"{owner}.{name}"
-
-    def safe_prepare(self, value: str) -> T:
-        if value in (None, ''):
-            return None
-        try:
-            return self.prepare(value)
-        except Exception as error:
-            raise PrepareError(self, value, inner=error) from error
-
-    def prepare(self, value: str) -> str:
-        return value
-
-class StringField(Field[str]):
-    """Field of string type"""
-    pass
-
-class BooleanField(Field[bool]):
-    """Field of boolean type"""
-
-    def __init__(self, bool_mapping=None):
-        self.bool_mapping = bool_mapping
-
-    def safe_prepare(self, value: str) -> T:
-        try:
-            # print("trying to prepare", repr(value), self.bool_mapping)
-            return self.prepare(value)
-        except Exception as error:
-            raise PrepareError(self, value, inner=error) from error
-
-    def prepare(self, value: [str, bool]) -> [T, tuple]:
-        if self.bool_mapping is None:
-            assert isinstance(value, bool), f"Invalid type passed to BooleanField of {self._field_name}"
-            return value
-        return self.bool_mapping[value]
-
-
-class IntegerField(Field[int]):
-    """Field of int type"""
-
-    def prepare(self, value: str) -> int:
-        return int(value)
-
-
-class DateField(Field[datetime.date]):
-    """Field of date type"""
-
-    def prepare(self, value: str) -> datetime.date:
-        return datetime.date.fromisoformat(value)
-
-
-class DateTimeField(Field[datetime.datetime]):
-    """Field of datetime type"""
-
-    def prepare(self, value: str) -> datetime.datetime:
-        return datetime.datetime.fromisoformat(value)
-
-
-class TimeField(Field[datetime.time]):
-    """Field of time type"""
-
-    def prepare(self, value: str) -> datetime.time:
-        return datetime.time.fromisoformat(value)
-
-
-class ListField(Field[List[T]]):
-    """Field of a list with specific type"""
-    pass
-
-
-class ZoneInfoField(Field[ZoneInfo]):
-    """Field of an Olson Timezone"""
-
-    def prepare(self, value: str) -> Tuple[str, ZoneInfo]:
-        return value, ZoneInfo(value)
-
-
-class WeekdayField(Field[Weekday]):
-    """Field for a Weekday"""
-
-    def prepare(self, value: str) -> Tuple[str, Weekday]:
-        return value, Weekday.from_string(value)
-
-
-class WeekdayPeriodField(Field[WeekdayPeriod]):
-    """Field for period like Mon-Fri"""
-
-    def prepare(self, value: str) -> Tuple[str, Weekday]:
-        return value, WeekdayPeriod.from_string(value)
-
-
-class WeekdaySetField(Field[WeekdaySet]):
-    """Field for set of periods like Mon-Fri"""
-
-    def prepare(self, value: str) -> Tuple[str, WeekdaySet]:
-        return value, WeekdaySet.from_string(value)
-
-
-class FinIdField(Field[FinId]):
-    """Field for a FinID"""
-
-    def prepare(self, value: str) -> Tuple[str, FinId]:
-        return value, FinId.from_string(value)
-
-
-class MicField(Field[Mic]):
-    """Field for a MIC"""
-
-    def prepare(self, value: str) -> Tuple[str, Mic]:
-        return value, Mic.from_string(value)
diff --git a/src/tradinghours/catalog.py b/src/tradinghours/catalog.py
deleted file mode 100644
index 22e1267..0000000
--- a/src/tradinghours/catalog.py
+++ /dev/null
@@ -1,254 +0,0 @@
-from typing import Dict, Generator, Optional, Tuple, Type, TypeVar
-
-from .validate import validate_str_arg
-
-from .base import BaseObject
-from .models.market import Market, MarketHoliday, MicMapping
-from .models.season import SeasonDefinition
-from .models.currency import CurrencyHoliday, Currency
-from .models.schedule import Schedule, PhaseType
-from .remote import default_data_manager
-from .store.engine import Store, default_store
-from .store.file import FileCollection
-from .store.source import SourceFile
-from .typing import StrOrPath
-
-B = TypeVar("B", bound=BaseObject)
-
-
-class DeclaredFile(SourceFile[B]):
-    """Well known source file"""
-
-    known_files: Dict[str, Type["DeclaredFile"]] = {}
-
-    name: str = None
-    model: Type[B] = None
-
-    def __init__(self, root: StrOrPath):
-        super().__init__(root, self.name, self.model)
-
-    def __init_subclass__(cls):
-        super().__init_subclass__()
-        if cls.name is None:
-            raise ValueError("name must be defined")
-        if cls.model is None:
-            raise ValueError("model must be defined")
-        cls.known_files[cls.name] = cls
-
-    def pre_ingest(self, store: "Store"):
-        pass
-
-    def resolve_cluster(self, item: Type[B]) -> Optional[str]:
-        return None
-
-    def resolve_key(self, item: Type[B]) -> Optional[str]:
-        return None
-
-    def ingest(self, store: "Store"):
-        self.pre_ingest(store)
-        for current in self.load_iter():
-            collection = self.name
-            cluster = self.resolve_cluster(current)
-            key = self.resolve_key(current)
-            key = str(key).upper() if key else key
-
-            data = current._to_tuple(raw=True)
-            store.store_tuple(data, collection, cluster=cluster, key=key)
-
-
-class SeasonDefinitionFile(DeclaredFile):
-    name = "season-definitions"
-    model = SeasonDefinition
-
-
-class CurrencyFile(DeclaredFile[Currency]):
-    name = "currencies"
-    model = Currency
-
-    def resolve_key(self, item: Currency) -> Optional[str]:
-        return item.currency_code
-
-
-class CurrencyHolidayFile(DeclaredFile[CurrencyHoliday]):
-    name = "currency_holidays"
-    model = CurrencyHoliday
-
-    def resolve_cluster(self, item: CurrencyHoliday) -> Optional[str]:
-        return item.currency_code
-
-    def resolve_key(self, item: CurrencyHoliday) -> Optional[str]:
-        return item.date
-
-
-class MarketFile(DeclaredFile[Market]):
-    name = "markets"
-    model = Market
-
-    def resolve_cluster(self, item: MarketHoliday) -> Optional[str]:
-        return item.fin_id_obj.country
-
-    def resolve_key(self, item: MarketHoliday) -> Optional[str]:
-        return str(item.fin_id)
-
-
-class MarketHolidayFile(DeclaredFile[MarketHoliday]):
-    name = "holidays"
-    model = MarketHoliday
-
-    def resolve_cluster(self, item: MarketHoliday) -> Optional[str]:
-        return str(item.fin_id)
-
-    def resolve_key(self, item: MarketHoliday) -> Optional[str]:
-        return item.date
-
-
-class MicMappingFile(DeclaredFile[MicMapping]):
-    name = "mic-mapping"
-    model = MicMapping
-
-    def resolve_key(self, item: MicMapping) -> Optional[str]:
-        return item.mic
-
-class ScheduleFile(DeclaredFile[Schedule]):
-    name = "schedules"
-    model = Schedule
-
-    def resolve_cluster(self, item: MarketHoliday) -> Optional[str]:
-        return str(item.fin_id)
-
-    def pre_ingest(self, store: "Store"):
-        store.clear_collection(self.name)
-
-class PhaseTypeFile(DeclaredFile[PhaseType]):
-    name = "phases"
-    model = PhaseType
-
-
-
-class Catalog:
-    """Gives you access to an underlying data store
-
-    TODO: maybe DeclaredFile and Store.ingest all should be here
-
-    """
-
-    def __init__(self, store: Store):
-        self._store = store
-
-    @property
-    def store(self) -> Store:
-        return self._store
-
-    def ingest_all(self):
-        for _, declared_class in DeclaredFile.known_files.items():
-            source = declared_class(default_data_manager.csv_dir)
-            source.ingest(self.store)
-        self.store.flush()
-
-    def find_model_collection(self, model: Type[BaseObject]) -> FileCollection:
-        for name, declared in DeclaredFile.known_files.items():
-            if declared.model is model:
-                return self.store.collections.get(name)
-        return None
-
-    def list_all(self, model: Type[B]) -> Generator[B, None, None]:
-        collection = self.find_model_collection(model)
-        for cluster in collection.clusters:
-            cluster_data = cluster.load_all()
-            for _, current in cluster_data.items():
-                yield model.from_tuple(current)
-
-    def list(
-        self, model: Type[B], cluster: Optional[str] = None
-    ) -> Generator[Tuple[str, B], None, None]:
-        collection = self.find_model_collection(model)
-        cluster_name = cluster or "default"
-        cluster_obj = collection.clusters.get(cluster_name)
-        cluster_data = cluster_obj.load_all()
-        for current_key, data in cluster_data.items():
-            yield current_key, model.from_tuple(data)
-        return None
-
-    def get(
-        self, model: Type[B], key: str, cluster: Optional[str] = None
-    ) -> Optional[B]:
-        key = validate_str_arg("key", key).upper()
-        collection = self.find_model_collection(model)
-        cluster_name = cluster or "default"
-        cluster = collection.clusters.get(cluster_name)
-        cluster_data = cluster.load_all()
-        for current_key, data in cluster_data.items():
-            if current_key == key:
-                return model.from_tuple(data)
-        return None
-
-    def filter(
-        self,
-        model: Type[B],
-        key_start: str,
-        key_end: str,
-        cluster: Optional[str] = None,
-    ) -> Generator[B, None, None]:
-        key_start = validate_str_arg("key_start", key_start).upper()
-        key_end = validate_str_arg("key_end", key_end).upper()
-        collection = self.find_model_collection(model)
-        cluster_name = cluster or "default"
-        cluster = collection.clusters.get(cluster_name)
-        cluster_data = cluster.load_all()
-        for current_key, data in cluster_data.items():
-            if current_key >= key_start and current_key <= key_end:
-                yield model.from_tuple(data)
-        return None
-
-
-default_catalog = Catalog(default_store)
-
-
-if __name__ == "__main__":
-    from datetime import date
-    from time import time
-
-    print("\nDownloading...")
-    start = time()
-    default_data_manager.download()
-    elapsed = time() - start
-    print("Elapsed seconds", elapsed)
-
-    print("\nImporting...")
-    start = time()
-    default_catalog.ingest_all()
-    elapsed = time() - start
-    print("Elapsed seconds", elapsed)
-
-    print("\nLoading Market...")
-    start = time()
-    us_market: Market = default_catalog.get(Market, "US.IEX", "us")
-    elapsed = time() - start
-    print("Elapsed seconds", elapsed)
-
-    print("\nListing Holidays...")
-    start = time()
-    loaded = list(us_market.list_holidays("2023-06-01", "2023-12-31"))
-    elapsed = time() - start
-    print("Elapsed seconds", elapsed, len(loaded))
-
-    print("\nListing Currencies...")
-    start = time()
-    loaded = list(Currency.list_all())
-    elapsed = time() - start
-    print("Elapsed seconds", elapsed, len(loaded))
-
-    print("\nListing Schedules...")
-    start = time()
-    loaded = list(Schedule.list_all())
-    elapsed = time() - start
-    print("Elapsed seconds", elapsed, len(loaded))
-
-    print("\nGenerating Schedules...")
-    start = time()
-    for concrete in us_market.generate_phases(date(2023, 9, 1), date(2023, 9, 30)):
-        print(concrete)
-    elapsed = time() - start
-    print("Elapsed seconds", elapsed, len(loaded))
-
-    print("\nDone")
diff --git a/src/tradinghours/client.py b/src/tradinghours/client.py
new file mode 100644
index 0000000..0858e60
--- /dev/null
+++ b/src/tradinghours/client.py
@@ -0,0 +1,106 @@
+import datetime, json, shutil, tempfile, zipfile, time, os
+from pathlib import Path
+from urllib.error import HTTPError
+from urllib.parse import urljoin
+from urllib.request import Request, urlopen
+
+from .config import main_config
+from .util import timed_action
+from .exceptions import ClientError, TokenError
+
+TOKEN = main_config.get("api", "token")
+BASE_URL = main_config.get("api", "base_url")
+ROOT = Path(main_config.get("data", "remote_dir"))
+ROOT.mkdir(parents=True, exist_ok=True)
+
+
+def get_response(path):
+    url = urljoin(BASE_URL, path)
+    request = Request(url)
+    request.add_header("Authorization", f"Bearer {TOKEN}")
+    try:
+        response = urlopen(request)
+    except HTTPError as error:
+        if error.code == 401:
+            raise TokenError("Token is missing or invalid")
+        raise ClientError("Error getting server response", inner=error)
+
+    return response
+
+
+def download_zip_file(path="download"):
+    response = get_response(path)
+    if response.status == 200:
+        with tempfile.NamedTemporaryFile() as temp_file:
+            shutil.copyfileobj(response, temp_file)
+            temp_file.flush()
+            temp_file.seek(0)
+
+            # clear out the directory to make sure no old csv files
+            # are present if the access level is reduced
+            for path in os.listdir(ROOT):
+                path = ROOT / path
+                if os.path.isdir(path):
+                    shutil.rmtree(path)
+                else:
+                    os.remove(path)
+
+            with zipfile.ZipFile(temp_file, "r") as zip_ref:
+                zip_ref.extractall(ROOT)
+        return True
+    elif response.status == 202:
+        return False
+
+    raise ClientError("Error getting server response")
+
+
+def download_covered_markets():
+    response = get_response("markets?group=all")
+    markets = json.load(response).get("data", [])
+    with open(ROOT / "covered_markets.json", "w") as covered_markets:
+        json.dump(markets, covered_markets)
+
+
+def download_covered_currencies():
+    response = get_response("currencies")
+    markets = json.load(response).get("data", [])
+    with open(ROOT / "covered_currencies.json", "w") as covered_currencies:
+        json.dump(markets, covered_currencies)
+
+
+def download():
+    """
+    Downloads zip file from tradinghours and unzips it into the
+    folder set in main_config.data.remote_dir
+    """
+    try:
+        with timed_action("Downloading") as (change_message, start_time):
+            waited = False
+            while True:
+                downloaded = download_zip_file()
+                if downloaded:
+                    break
+                if (time.time() - start_time) > 120:
+                    raise ClientError("Failed downloading data, please try again.")
+
+                change_message("Generating (~ 1min)")
+                time.sleep(5 if waited else 30)
+                waited = True
+
+        download_covered_markets()
+        download_covered_currencies()
+
+    except TokenError:
+        raise
+    # TODO: think about cleaner error handling (e.g: not a zipfile)
+    except Exception as e:
+        raise
+
+
+def get_remote_timestamp() -> datetime.datetime:
+    response = get_response("last-updated")
+    data = json.load(response)
+    last_updated = data["last_updated"]
+    timestamp = datetime.datetime.fromisoformat(last_updated)
+    return timestamp
+
diff --git a/src/tradinghours/config.py b/src/tradinghours/config.py
index eeb3276..798c6cd 100644
--- a/src/tradinghours/config.py
+++ b/src/tradinghours/config.py
@@ -3,6 +3,8 @@
 from pathlib import Path
 
 PROJECT_PATH = Path(__file__).parent
+DEFAULT_STORE_DIR = PROJECT_PATH / "store_dir"
+os.makedirs(DEFAULT_STORE_DIR, exist_ok=True)
 
 # Define default settings in this dictionary
 default_settings = {
@@ -10,10 +12,9 @@
         "base_url": "https://api.tradinghours.com/v3/",
     },
     "data": {
-        "use_db": False,
-        "local_dir": PROJECT_PATH / "store_dir" / "local",
-        "remote_dir": PROJECT_PATH / "store_dir" / "remote",
-        "db_url": f"sqlite:///{PROJECT_PATH / 'store_dir' / 'local.db'}",
+        "remote_dir": DEFAULT_STORE_DIR / "remote",
+        "db_url": f"sqlite:///{DEFAULT_STORE_DIR / 'tradinghours.db'}",
+        "table_prefix": "thstore_"
     },
     "control": {
         "check_tzdata": True,
@@ -27,3 +28,5 @@
 
 token = os.getenv("TRADINGHOURS_TOKEN", main_config.get("api", "token", fallback=""))
 main_config.set("api", "token", token)
+db_url = os.getenv("TH_DB_URL", main_config.get("data", "db_url", fallback=""))
+main_config.set("data", "db_url", db_url)
diff --git a/src/tradinghours/console.py b/src/tradinghours/console.py
index 4f7ff91..f3ac90a 100644
--- a/src/tradinghours/console.py
+++ b/src/tradinghours/console.py
@@ -1,15 +1,17 @@
-import argparse
-import time
+import argparse, warnings
 import traceback
-from contextlib import contextmanager
 from textwrap import wrap
-from threading import Thread
 
 from . import __version__
-from .catalog import default_catalog
-from .models import Currency, Market
-from .exceptions import TradingHoursError
-from .remote import default_data_manager
+from .store import Writer, db
+from .client import (
+    download as client_download,
+    get_remote_timestamp as client_get_remote_timestamp,
+    timed_action
+)
+from .currency import Currency
+from .market import Market
+from .exceptions import TradingHoursError, NoAccess
 
 EXIT_CODE_EXPECTED_ERROR = 1
 EXIT_CODE_UNKNOWN_ERROR = 2
@@ -22,29 +24,6 @@ def print_help(text):
     print()
 
 
-@contextmanager
-def timed_action(message: str):
-    start = time.time()
-    print(f"{message}...", end="", flush=True)
-
-    done = False
-
-    def print_dots():
-        while not done:
-            print(".", end="", flush=True)
-            time.sleep(0.5)
-
-    thread = Thread(target=print_dots)
-    thread.daemon = True
-    thread.start()
-
-    yield start
-
-    elapsed = time.time() - start
-    done = True
-    thread.join()
-    print(f" ({elapsed:.3f}s)")
-
 
 def create_parser():
     parser = argparse.ArgumentParser(description="TradingHours API Client")
@@ -62,14 +41,16 @@ def create_parser():
     # "import" subcommand
     import_parser = subparsers.add_parser("import", help="Import data")
     import_parser.add_argument("--force", action="store_true", help="Force the import")
+    import_parser.add_argument("--reset", action="store_true", help="Re-ingest data, without downloading. (Resets the database)")
 
     return parser
 
 
 def run_status(args):
+    db.ready()
     with timed_action("Collecting timestamps"):
-        remote_timestamp = default_data_manager.remote_timestamp
-        local_timestamp = default_data_manager.local_timestamp
+        remote_timestamp = client_get_remote_timestamp()
+        local_timestamp = db.get_local_timestamp()
     print("TradingHours Data Status:")
     print("  Remote Timestamp:  ", remote_timestamp.ctime())
     print("  Local Timestamp:   ", local_timestamp and local_timestamp.ctime())
@@ -77,24 +58,45 @@ def run_status(args):
     if args.extended:
         if local_timestamp:
             with timed_action("Reading local data"):
-                all_currencies = list(Currency.list_all())
-                all_markets = list(Market.list_all())
-            print("Extended Information:")
-            print("  Currencies count:  ", len(all_currencies))
-            print("  Markets count:     ", len(all_markets))
+                num_markets, num_currencies = db.get_num_covered()
+                num_permanently_closed = db.get_num_permanently_closed()
+                try:
+                    num_all_currencies = len(list(Currency.list_all()))
+                except NoAccess:
+                    num_all_currencies = 0
+                num_all_markets = len(list(Market.list_all()))
+                num_all_markets -= num_permanently_closed
+
+            print(f"  Currencies count:  {num_all_currencies:4} available out of {num_currencies} total")
+            print(f"  Markets count:     {num_all_markets:4} available out of {num_markets} total")
+            if num_permanently_closed:
+                print()
+                print("Notes:")
+                print(
+                    f"  {num_permanently_closed} permanently closed markets are available but excluded from the totals above.\n"
+                    f"  For access to additional markets, please contact us at <sales@tradinghours.com>."
+                )
         else:
             print("No local data to show extended information")
 
 
 def run_import(args):
-    if args.force or default_data_manager.needs_download:
-        with timed_action("Downloading"):
-            default_data_manager.download()
-        with timed_action("Ingesting"):
-            default_catalog.ingest_all()
+    show_warning = False
+    if args.reset:
+        show_warning = not Writer().ingest_all()
+
+    elif args.force or db.needs_download():
+        client_download()
+        show_warning = not Writer().ingest_all()
     else:
         print("Local data is up-to-date.")
 
+    if show_warning:
+        warnings.warn(
+            "\n\nWarning:\nYou seem to be using a MySQL database that is not configured "
+            "to handle the full unicode set. Unicode characters have been replaced with "
+            "'?'. Consult the MySQL documentation for your version to enable this feature."
+        )
 
 def main():
     try:
diff --git a/src/tradinghours/currency.py b/src/tradinghours/currency.py
new file mode 100644
index 0000000..860c9d5
--- /dev/null
+++ b/src/tradinghours/currency.py
@@ -0,0 +1,83 @@
+from typing import List, Union
+import datetime as dt
+
+from .validate import validate_range_args, validate_date_arg, validate_str_arg
+from .models import BaseModel, CurrencyHoliday
+from .store import db
+from .exceptions import NotCovered, NoAccess
+
+class Currency(BaseModel):
+    _table = "currencies"
+    _original_string_format = "Currency: {currency_code} {currency_name}"
+
+    def __init__(self, data):
+        super().__init__(data)
+        self.currency_code = self._data["currency_code"]
+        self.currency_name = self._data["currency_name"]
+        self.country_code = self._data["country_code"]
+        self.central_bank = self._data["central_bank"]
+        self.financial_capital = self._data["financial_capital"]
+        self.financial_capital_timezone = self._data["financial_capital_timezone"]
+        self.weekend_definition = self._data["weekend_definition"]
+
+    def list_holidays(
+        self, start: Union[str, dt.date], end: Union[str, dt.date]
+    ) -> List["CurrencyHoliday"]:
+        start, end = validate_range_args(
+            validate_date_arg("start", start),
+            validate_date_arg("end", end),
+        )
+        table = CurrencyHoliday.table
+        result = db.query(table).filter(
+            table.c.currency_code == self.currency_code,
+            table.c.date >= start,
+            table.c.date <= end
+        )
+        return [CurrencyHoliday(r) for r in result]
+
+    @classmethod
+    @db.check_access
+    def list_all(cls) -> List["Currency"]:
+        return [cls(r) for r in db.query(cls.table)]
+
+    @classmethod
+    def is_available(cls, code:str) -> bool:
+        try:
+            cls.get(code)
+            return True
+        except (NoAccess, NotCovered):
+            return False
+
+    @classmethod
+    @db.check_access
+    def is_covered(cls, code:str) -> bool:
+        """
+        Returns True or False showing if tradinghours provides data for the Currency.
+        This differs from is_available because is_covered does not mean that the user
+        has access to it under their current plan.
+        """
+        table = db.table("covered_currencies")
+        found = db.query(table).filter(
+            table.c.currency_code == code
+        ).one_or_none()
+        return found is not None
+
+    @classmethod
+    @db.check_access
+    def get(cls, code: str) -> "Currency":
+        validate_str_arg("code", code)
+        result = db.query(cls.table).filter(
+            cls.table.c.currency_code == code
+        ).one_or_none()
+        if result:
+            return cls(result)
+
+        if cls.is_covered(code):
+            raise NoAccess(
+                f"\n\nThe currency '{code}' is supported but not available on your current plan."
+                f"\nPlease learn more or contact sales at https://www.tradinghours.com/data"
+            )
+        # if no result found, raise NotCovered
+        raise NotCovered(
+            f"The currency '{code}' is currently not available."
+        )
diff --git a/src/tradinghours/exceptions.py b/src/tradinghours/exceptions.py
index cc30a72..647ece3 100644
--- a/src/tradinghours/exceptions.py
+++ b/src/tradinghours/exceptions.py
@@ -1,9 +1,5 @@
 from functools import cached_property
-from typing import TYPE_CHECKING, Any, Optional
-
-if TYPE_CHECKING:
-    from .base import Field
-
+from typing import Any, Optional
 
 class TradingHoursError(Exception):
     """Baseclass for all errors from this library"""
@@ -93,13 +89,50 @@ class MissingSqlAlchemyError(TradingHoursError):
 
     def build_help_message(self):
         return (
-            "You need to install SQLAlchemy in order to usee database "
+            "You need to install SQLAlchemy in order to use database "
             "ready store. You should be able to do that by running "
             "`pip install tradinghours[sql]` from the command line."
         )
 
 class NoAccess(TradingHoursError):
+    """
+    Raised when a user attempts accessing a type of data
+    that is not available under their current plan.
+    """
+    pass
+
+class NotCovered(TradingHoursError):
+    """
+    Raised when a user attempts to access a specific data item
+     that is not covered by Tradinghours but the type of data
+     (e.g.: Currencies) are available under their current plan.
+    """
     pass
 
+
+class MICDoesNotExist(TradingHoursError):
+    """
+    Raised when a user tries to get a Market with a mic that can not
+     be matched with a finid.
+    """
+    pass
+
+
 class MissingTzdata(TradingHoursError):
-    pass
\ No newline at end of file
+    pass
+
+
+class DBError(TradingHoursError):
+    """
+    Raised when the database could not be accessed
+    """
+    pass
+
+
+class DateNotAvailable(TradingHoursError):
+    """
+    Raised when the dates passed to generate_phases are outside
+    of the first_ and last_available dates.
+    """
+    pass
+
diff --git a/src/tradinghours/market.py b/src/tradinghours/market.py
new file mode 100644
index 0000000..3b6ec24
--- /dev/null
+++ b/src/tradinghours/market.py
@@ -0,0 +1,459 @@
+import calendar
+import datetime as dt
+from typing import Iterable, Generator, Union
+from zoneinfo import ZoneInfo
+from functools import cached_property
+
+from .models import (
+    BaseModel,
+    Schedule,
+    Phase,
+    PhaseType,
+    MarketHoliday,
+    MicMapping,
+    SeasonDefinition,
+    MarketStatus
+)
+from .validate import (
+    validate_range_args,
+    validate_date_arg,
+    validate_finid_arg,
+    validate_str_arg,
+    validate_mic_arg
+)
+from .store import db
+from .util import weekdays_match
+from .exceptions import NoAccess, NotCovered, MICDoesNotExist, DateNotAvailable
+
+# Arbitrary max offset days for TradingHours data
+MAX_OFFSET_DAYS = 2
+
+class Market(BaseModel):
+    _table = "markets"
+    _original_string_format = "Market: {fin_id} {exchange_name} {timezone}"
+
+    def __init__(self, data):
+        super().__init__(data)
+        self.exchange_name = self._data["exchange_name"]
+        self.market_name = self._data["market_name"]
+        self.security_group = self._data["security_group"]
+        self.timezone = self._data["timezone"]
+        self.weekend_definition = self._data["weekend_definition"]
+        self.fin_id = self._data["fin_id"]
+        self.mic = self._data["mic"]
+        self.acronym = self._data["acronym"]
+        self.asset_type = self._data["asset_type"]
+        self.memo = self._data["memo"]
+        self.permanently_closed = self._data["permanently_closed"]
+        self.replaced_by = self._data["replaced_by"]
+
+    @cached_property
+    def first_available_date(self):
+        """
+        The first available date is the 1st day of
+        the month of the first holiday of the given market.
+        """
+        table = MarketHoliday.table
+        result = db.query(table).filter(
+            table.c.fin_id == self.fin_id
+        ).order_by(
+            table.c.date
+        ).first()
+        return result.date.replace(day=1)
+
+    @cached_property
+    def last_available_date(self):
+        """
+        The last available date is the last day of the month
+        of the last available holiday of the given market.
+        """
+        table = MarketHoliday.table
+        result = db.query(table).filter(
+            table.c.fin_id == self.fin_id
+        ).order_by(
+            table.c.date.desc()
+        ).first()
+        date = result.date
+        _, num_days_in_month = calendar.monthrange(date.year, date.month)
+        return date.replace(day=num_days_in_month)
+
+    def _in_range(self, *dates) -> None:
+        if not all(
+            self.first_available_date <= date <= self.last_available_date for date in dates
+        ):
+            raise DateNotAvailable("the requested data is outside of the available dates for this "
+                                   "Market. You can use the properties `first_available_date` and "
+                                   "`last_available_date` to stay within bounds.")
+
+    @property
+    def country_code(self):
+        """Two-letter country code."""
+        return self.fin_id.split(".")[0]
+
+    def _pick_schedule_group(
+        self,
+        some_date: dt.date,
+        holidays: dict[dt.date, "MarketHoliday"],
+    ) -> tuple[str, bool]:
+
+        if found := holidays.get(some_date):
+            schedule_group = found.schedule.lower()
+            fallback = Schedule.is_group_open(schedule_group)
+        else:
+            schedule_group = "regular"
+            fallback = False
+        return schedule_group, fallback
+
+    def _filter_schedule_group(
+        self, schedule_group: str, schedules: Iterable[Schedule]
+    ) -> Iterable[Schedule]:
+        for current in schedules:
+            if current.schedule_group.lower() == schedule_group.lower():
+                yield current
+
+
+    def _filter_inforce(
+        self, some_date: dt.date, schedules: Iterable[Schedule]
+    ) -> Iterable[Schedule]:
+        for current in schedules:
+            if current.is_in_force(some_date, some_date):
+                yield current
+
+    def _filter_season(
+        self, some_date: dt.date, schedules: Iterable[Schedule]
+    ) -> Iterable[Schedule]:
+        for current in schedules:
+            # If there is no season, it means there is no restriction in terms
+            # of the season when this schedule is valid, and as such it is valid,
+            # from a season-perspective for any date
+            if not current.has_season:
+                yield current
+            else:
+                start_date = SeasonDefinition.get(current.season_start, some_date.year).date
+                end_date = SeasonDefinition.get(current.season_end, some_date.year).date
+
+                if end_date < start_date:
+                    if some_date <= end_date or some_date >= start_date:
+                        yield current
+
+                if some_date >= start_date and some_date <= end_date:
+                    yield current
+
+    def _filter_weekdays(
+        self, weekday: int, schedules: Iterable[Schedule]
+    ) -> Iterable[Schedule]:
+        for current in schedules:
+            if weekdays_match(current.days, weekday):
+                yield current
+
+    def _generate_phases(
+        self, start: Union[str, dt.date], end: Union[str, dt.date],
+        _for_status: bool = False
+    ) -> Generator[Union[Phase, dict], None, None]:
+
+        start, end = validate_range_args(
+            validate_date_arg("start", start),
+            validate_date_arg("end", end),
+        )
+        if not _for_status:
+            self._in_range(start, end)
+
+        phase_types_dict = PhaseType.as_dict()
+
+        # Get required global data
+        offset_start = start - dt.timedelta(days=MAX_OFFSET_DAYS)
+        all_schedules = self.list_schedules()
+        holidays = self.list_holidays(offset_start, end, as_dict=True)
+        if _for_status:
+            yield holidays
+
+        # Iterate through all dates generating phases
+        current_date = offset_start
+        while current_date <= end:
+            current_weekday = current_date.weekday()
+            # Starts with all schedules
+            schedules = all_schedules
+
+            # Filter schedule group based on holiday if any
+            schedule_group, fallback = self._pick_schedule_group(current_date, holidays)
+            schedules = self._filter_schedule_group(schedule_group, schedules)
+
+            # Filters what is in force or for expected season
+            schedules = self._filter_inforce(current_date, schedules)
+            schedules = self._filter_season(current_date, schedules)
+
+            # Save for fallback and filter weekdays
+            before_weekdays = list(schedules)
+            found_schedules = list(self._filter_weekdays(current_weekday, before_weekdays))
+
+            # Consider fallback if needed
+            if not found_schedules and fallback:
+                fallback_weekday = 6 if current_weekday == 0 else current_weekday - 1
+                fallback_schedules = []
+                while not fallback_schedules and fallback_weekday != current_weekday:
+                    fallback_schedules = list(
+                        filter(
+                            lambda s: weekdays_match(s.days, fallback_weekday),
+                            before_weekdays,
+                        ),
+                    )
+                    fallback_weekday = (
+                        6 if fallback_weekday == 0 else fallback_weekday - 1
+                    )
+                found_schedules = fallback_schedules
+
+            # Sort based on start time and duration
+            found_schedules = sorted(
+                found_schedules,
+                key=lambda s: (s.start, s.duration),
+            )
+
+            # Generate phases for current date
+            for current_schedule in found_schedules:
+                start_date = current_date
+                end_date = current_date + dt.timedelta(days=current_schedule.offset_days)
+
+                # Filter out phases not finishing after start because we
+                # began looking a few days ago to cover offset days
+                if end_date >= start:
+                    start_datetime = dt.datetime.combine(start_date, current_schedule.start)
+                    end_datetime = dt.datetime.combine(end_date, current_schedule.end)
+                    # start_datetime = current_schedule.timezone_obj.localize(start_datetime)
+                    # end_datetime = current_schedule.timezone_obj.localize(end_datetime)
+                    zoneinfo_obj = ZoneInfo(current_schedule.timezone)
+                    start_datetime = start_datetime.replace(tzinfo=zoneinfo_obj)
+                    end_datetime = end_datetime.replace(tzinfo=zoneinfo_obj)
+
+                    phase_type = phase_types_dict[current_schedule.phase_type]
+                    yield Phase(
+                        dict(
+                            phase_type=current_schedule.phase_type,
+                            phase_name=current_schedule.phase_name,
+                            phase_memo=current_schedule.phase_memo,
+                            status=phase_type.status,
+                            settlement=phase_type.settlement,
+                            start=start_datetime,
+                            end=end_datetime,
+                        )
+                    )
+
+            # Next date, please
+            current_date += dt.timedelta(days=1)
+
+
+    @db.check_access
+    def generate_phases(
+        self, start: Union[str, dt.date], end: Union[str, dt.date]
+    ) -> Generator[Phase, None, None]:
+        return self._generate_phases(start, end, _for_status=False)
+
+    @classmethod
+    def list_all(cls, sub_set="*") -> list["Market"]:
+        validate_str_arg("sub_set", sub_set)
+        sub_set = sub_set.upper().replace("*", "%")
+        return [cls(r) for r in db.query(cls.table).filter(
+            cls.table.c.fin_id.like(sub_set)
+        )]
+
+    def _last_holiday(self):
+        table = self.table
+        result = db.query(table).filter(
+            table.c.fin_id == self.fin_id
+        ).order_by(
+            table.c.date.desc()
+        ).first()
+        return MarketHoliday(result)
+
+    def list_holidays(
+        self, start: Union[str, dt.date], end: Union[str, dt.date], as_dict: bool = False
+    ) -> Union[list["MarketHoliday"], dict[dt.date, "MarketHoliday"]]:
+        start, end = validate_range_args(
+            validate_date_arg("start", start),
+            validate_date_arg("end", end),
+        )
+        table = MarketHoliday.table
+        result = db.query(table).filter(
+            table.c.fin_id == self.fin_id,
+            table.c.date >= start,
+            table.c.date <= end
+        )
+        if as_dict:
+            dateix = list(table.c.keys()).index("date")
+            return {
+                r[dateix]: MarketHoliday(r) for r in result
+            }
+
+        return [MarketHoliday(r) for r in result]
+
+    @db.check_access
+    def list_schedules(self) -> list["Schedule"]:
+        schedules = db.query(Schedule.table).filter(
+            Schedule.table.c.fin_id == self.fin_id
+        ).order_by(
+            Schedule.table.c.schedule_group.asc(),
+            Schedule.table.c.in_force_start_date.asc(),
+            Schedule.table.c.season_start.asc(),
+            Schedule.table.c.start.asc(),
+            Schedule.table.c.end.asc()
+        )
+        return [Schedule(r) for r in schedules]
+
+    @classmethod
+    def is_available(cls, identifier: str) -> bool:
+        """
+        Return True or False to show if a mic or finid can be accessed
+        under the current plan.
+        """
+        try:
+            cls.get(identifier)
+            return True
+        except (NoAccess, NotCovered, MICDoesNotExist):
+            return False
+
+    @classmethod
+    def is_covered(cls, finid: str) -> bool:
+        """
+        Returns True or False showing if tradinghours provides data for the Market.
+        This differs from is_available because is_covered does not mean that the user
+        has access to it under their current plan.
+        """
+        table = db.table("covered_markets")
+        found = db.query(table).filter(
+            table.c.fin_id == finid
+        ).one_or_none()
+        return found is not None
+
+    @classmethod
+    def _get_by_finid(cls, finid:str, following=None) -> Union[None, tuple]:
+        found = db.query(cls.table).filter(
+            cls.table.c.fin_id == finid
+        ).one_or_none()
+        if found is not None:
+            return found
+
+        # if not found, check if it is covered at all and raise appropriate Exception
+        following = f" (replaced: '{following}')" if following else ""
+        if cls.is_covered(finid):
+            raise NoAccess(
+                f"\n\nThe market '{finid}'{following} is supported but not available on your current plan."
+                f"\nPlease learn more or contact sales at https://www.tradinghours.com/data"
+            )
+        raise NotCovered(
+            f"The market '{finid}'{following} is currently not available."
+        )
+
+    @classmethod
+    def get_by_finid(cls, finid: str, follow=True) -> Union[None, "Market"]:
+        finid = validate_finid_arg(finid)
+        found = cls._get_by_finid(finid)
+
+        while found and (found_obj := cls(found)).replaced_by and follow:
+            found = cls._get_by_finid(found_obj.replaced_by, following=finid)
+
+        return found_obj
+
+
+    @classmethod
+    def get_by_mic(cls, mic: str, follow=True) -> "Market":
+        mic = validate_mic_arg(mic)
+        mapping = db.query(MicMapping.table).filter(
+            MicMapping.table.c.mic == mic
+        ).one_or_none()
+        if mapping:
+            return cls.get_by_finid(mapping.fin_id, follow=follow)
+        raise MICDoesNotExist(f"The MIC {mic} could not be matched with a FinID")
+
+    @classmethod
+    def get(cls, identifier: str, follow=True) -> "Market":
+        identifier = validate_str_arg("identifier", identifier)
+        if "." in identifier:
+            found = cls.get_by_finid(identifier, follow=follow)
+        else:
+            found = cls.get_by_mic(identifier, follow=follow)
+        return found
+
+    @db.check_access
+    def status(self, datetime: Union[dt.datetime, None] = None) -> "MarketStatus":
+        """
+        Will return the status of the market.
+
+        If `time` is None, it will be the current status, otherwise the status
+        at the given `datetime`, which needs to be timezone aware.
+        """
+        if datetime is None:
+            datetime = dt.datetime.now(dt.timezone.utc)
+        elif type(datetime) is not dt.datetime or datetime.tzinfo is None:
+            raise ValueError("You need to pass a timezone aware datetime.")
+
+        date = datetime.date()
+        self._in_range(date)
+        # arbitrarily extending end so that there are definitely following phases
+        end = min(date + dt.timedelta(days=5), self.last_available_date)
+
+        current, nxt = [], []
+        is_primary = False
+        phase_generator = self._generate_phases(start=date, end=end, _for_status=True)
+        holidays = next(phase_generator)
+        for phase in phase_generator:
+            if not is_primary and phase.start <= datetime < phase.end:
+                # is_open means that it's a primary phase
+                # and we just take that one
+                if phase.is_open:
+                    is_primary = True
+                    current = phase
+                else:
+                    current.append(phase)
+            elif datetime < phase.start:
+                nxt.append(phase)
+
+        # if there is no primary session we need to take the one starting first
+        # if there are no sessions at all, we set current to None
+        if not is_primary:
+            if current:
+                current = sorted(current, key=lambda p: p.start)[0]
+            else:
+                current = None
+
+        # set until
+        if current:
+            # check if there are any overlapping phases
+            overlapping = [phase for phase in nxt if phase.start < current.end]
+            if overlapping:
+                # take the first one that overlaps
+                until = sorted(overlapping, key= lambda p: p.start)[0].start
+            else:
+                until = current.end
+        else:
+            until = sorted(nxt, key= lambda p: p.start)[0].start
+
+        # set next_bell
+        if is_primary:
+            next_bell = current.end
+        else:
+            next_bell = None
+            for phase in nxt:
+                if phase.is_open:
+                    next_bell = phase.start
+                    break
+
+        # set reason
+        reason = ""
+        holiday = holidays.get(date)
+        if holiday:
+            reason += holiday.holiday_name
+        if current:
+            reason += f" - {current.phase_type}"
+            if holiday and holiday.schedule.lower() != "regular":
+                reason += f" ({holiday.schedule})"
+
+        reason = reason.strip(" -") if reason else None
+        return MarketStatus({
+            "status": current.status if current else "Closed",
+            "reason": reason,
+            "until": until,
+            "next_bell": next_bell,
+            "phase": current,
+            "market": self
+        })
+
+
diff --git a/src/tradinghours/models.py b/src/tradinghours/models.py
new file mode 100644
index 0000000..0c28690
--- /dev/null
+++ b/src/tradinghours/models.py
@@ -0,0 +1,309 @@
+from typing import Union
+from pprint import pprint
+from sqlalchemy import func
+import datetime as dt
+
+from .store import db
+from .validate import validate_str_arg, validate_int_arg, validate_range_args, validate_date_arg
+from .exceptions import MissingDefinitionError
+
+
+class BaseModel:
+    """
+    Will receive records from the databse and set the instance attributes. The attributes
+     match the column names and some classes have additional properties.
+
+    Besides accessing the data through attributes like `market.exchange_name`,
+     you can also access `data` or `to_dict`. See their docstrings to see how they differ.
+
+    """
+    _table: Union[str, None] = None
+    _string_format: str = ""
+    _original_string_format: str = ""
+    _fields: list = [] # columns in database
+    _extra_fields: list = []  # properties of python class
+    _access_levels: set = set()
+
+    @classmethod
+    @property
+    def table(cls) -> "Table":
+        return db.table(cls._table)
+
+    @classmethod
+    @property
+    def fields(cls):
+        return cls._fields + cls._extra_fields
+
+    def __init__(self, data: Union[dict, tuple]):
+        if not isinstance(data, dict):
+            data = {
+                col_name: value for col_name, value in zip(
+                    self.table.c.keys(), data
+                )
+            }
+
+        self._data = {}
+        _fields = []
+        for key, value in data.items():
+            if key != "id":
+                if key == "observed":
+                    # deal with the fact that MySQL doesn't have a boolean
+                    # field and the value is going to be 0 or 1 because we
+                    # are using sqlalchemy's Core API
+                    value = bool(value)
+                setattr(self, key, value)
+                self._data[key] = value
+                _fields.append(key)
+
+        if not self.__class__._fields:
+            exclude = set(dir(BaseModel))
+            _extra_fields = [] # properties
+            for att in dir(self):
+                if (att[0] != "_" and
+                        att not in exclude
+                        and isinstance(getattr(self.__class__, att, None), property)
+                ):
+                    _extra_fields.append(att)
+
+            self.__class__._fields = _fields
+            self.__class__._extra_fields = _extra_fields
+
+    @property
+    def data(self) -> dict:
+        """
+        Returns a dictionary with the values exactly as they were in the
+         database, excluding properties like .is_open. Keys are exact matches to the
+         column names of the matching table.
+        """
+        return {f: getattr(self, f) for f in self._fields}
+
+    def to_dict(self) -> dict:
+        """
+        Returns a dictionary with the values as they are displayed to the user, including
+         properties like .is_open, which means that there are keys present that don't exist
+         in the matching table.
+        """
+        return {f: getattr(self, f) for f in self.fields}
+
+    def pprint(self) -> None:
+        dct = {}
+        for f in self.fields:
+            val = getattr(self, f)
+            if not isinstance(val, int) and not isinstance(val, float) and val is not None:
+                val = str(val)
+            dct[f] = val
+        pprint(dct, sort_dicts=False)
+
+    def __repr__(self) -> str:
+        class_name = self.__class__.__name__
+        return f"{class_name}({self._data!r})"
+
+    @classmethod
+    def get_string_format(cls):
+        return cls._string_format or cls._original_string_format
+
+    @classmethod
+    def set_string_format(cls, string_format: str, prefix_class: bool = False):
+        if prefix_class:
+            string_format = f"{cls.__name__}: " + string_format
+        cls._string_format = string_format
+
+    @classmethod
+    def reset_string_format(cls):
+        cls._string_format = cls._original_string_format
+
+    def __str__(self):
+        return self.get_string_format().format(**self.to_dict())
+
+
+class MarketHoliday(BaseModel):
+    _table = "holidays"
+    _original_string_format = "MarketHoliday: {fin_id} {date} {holiday_name}"
+
+    def __init__(self, data):
+        super().__init__(data)
+        self.fin_id = self._data["fin_id"]
+        self.date = self._data["date"]
+        self.holiday_name = self._data["holiday_name"]
+        self.schedule = self._data["schedule"]
+        self.settlement = self._data["settlement"]
+        self.observed = self._data["observed"]
+        self.memo = self._data["memo"]
+        self.status = self._data["status"]
+
+    @property
+    def has_settlement(self):
+        return self.settlement == 'Yes'
+
+    @property
+    def is_open(self):
+        return self.status == 'Open'
+
+
+class MicMapping(BaseModel):
+    _table = "mic_mapping"
+    _original_string_format = "MicMapping: {mic} {fin_id}"
+
+    def __init__(self, data):
+        super().__init__(data)
+        self.mic = self._data["mic"]
+        self.fin_id = self._data["fin_id"]
+
+
+class CurrencyHoliday(BaseModel):
+    _table = "currency_holidays"
+    _original_string_format = "CurrencyHoliday: {currency_code} {date} {holiday_name}"
+
+    def __init__(self, data):
+        super().__init__(data)
+        self.currency_code = self._data["currency_code"]
+        self.date = self._data["date"]
+        self.holiday_name = self._data["holiday_name"]
+        self.settlement = self._data["settlement"]
+        self.observed = self._data["observed"]
+        self.memo = self._data["memo"]
+
+
+class PhaseType(BaseModel):
+    _table = "phases"
+
+    def __init__(self, data):
+        super().__init__(data)
+        self.name = self._data["name"]
+        self.status = self._data["status"]
+        self.settlement = self._data["settlement"]
+        self.closing_price = self._data["closing_price"]
+
+    @classmethod
+    def as_dict(cls) -> dict[str, "PhaseType"]:
+        return {pt.name: cls(pt) for pt in db.query(cls.table)}
+
+    @property
+    def has_settlement(self):
+        return self.settlement == 'Yes'
+
+    @property
+    def is_open(self):
+        return self.status == 'Open'
+
+
+class Schedule(BaseModel):
+    _table = "schedules"
+    _original_string_format = "Schedule: {fin_id} ({schedule_group}) {start} - {end_with_offset} {days} {phase_type}"
+
+    def __init__(self, data):
+        super().__init__(data)
+        self.fin_id = self._data["fin_id"]
+        self.schedule_group = self._data["schedule_group"]
+        self.schedule_group_memo = self._data["schedule_group_memo"]
+        self.timezone = self._data["timezone"]
+        self.phase_type = self._data["phase_type"]
+        self.phase_name = self._data["phase_name"]
+        self.phase_memo = self._data["phase_memo"]
+        self.days = self._data["days"]
+        self.start = self._data["start"]
+        self.end = self._data["end"]
+        self.offset_days = self._data["offset_days"]
+        self.duration = self._data["duration"]
+        self.min_start = self._data["min_start"]
+        self.max_start = self._data["max_start"]
+        self.min_end = self._data["min_end"]
+        self.max_end = self._data["max_end"]
+        self.in_force_start_date = self._data["in_force_start_date"]
+        self.in_force_end_date = self._data["in_force_end_date"]
+        self.season_start = self._data["season_start"]
+        self.season_end = self._data["season_end"]
+
+    @property
+    def end_with_offset(self):
+        end = str(self.end)
+        if self.offset_days:
+            return end + f" +{self.offset_days}"
+        return end + "   "
+
+    @classmethod
+    def is_group_open(cls, group):
+        return group.lower() == "regular"
+
+    @property
+    def has_season(self) -> bool:
+        season_start = (self.season_start or "").strip()
+        season_end = (self.season_end or "").strip()
+        return bool(season_start and season_end)
+
+    def is_in_force(self, start: dt.date, end: dt.date) -> bool:
+        if not self.in_force_start_date and not self.in_force_end_date:
+            return True
+        elif self.in_force_start_date is None:
+            return self.in_force_end_date >= start
+        elif self.in_force_end_date is None:
+            return self.in_force_start_date <= end
+        else:
+            return self.in_force_start_date <= end and self.in_force_end_date >= start
+
+
+class SeasonDefinition(BaseModel):
+    _table = "season_definitions"
+    _original_string_format = "SeasonDefinition: {date} {season}"
+
+    def __init__(self, data):
+        super().__init__(data)
+        self.season = self._data["season"]
+        self.year = self._data["year"]
+        self.date = self._data["date"]
+
+    @classmethod
+    def get(cls, season: str, year: int) -> "SeasonDefinition":
+        season = validate_str_arg("season", season)
+        year = validate_int_arg("year", year)
+
+        table = cls.table
+        result = db.query(table).filter(
+            func.lower(table.c["season"]) == season.lower(),
+            table.c["year"] == year
+        ).one_or_none()
+
+        if not result:
+            raise MissingDefinitionError(f"missing definition {season} - {year}")
+        return cls(result)
+
+
+class Phase(BaseModel):
+    _table = None
+    _original_string_format = "Phase: {start} - {end} {phase_type}"
+
+    def __init__(self, data):
+        super().__init__(data)
+        self.phase_type = self._data["phase_type"]
+        self.phase_name = self._data["phase_name"]
+        self.phase_memo = self._data["phase_memo"]
+        self.status = self._data["status"]
+        self.settlement = self._data["settlement"]
+        self.start = self._data["start"]
+        self.end = self._data["end"]
+        self._timezone = str(self.start.tzinfo)
+
+    @property
+    def timezone(self):
+        return self._timezone
+
+    @property
+    def has_settlement(self):
+        return self.settlement == 'Yes'
+
+    @property
+    def is_open(self):
+        return self.status == 'Open'
+
+class MarketStatus(BaseModel):
+    _table = None
+    _original_string_format = "MarketStatus: {status}"
+
+    def __init__(self, data):
+        super().__init__(data)
+        self.status = self._data["status"]
+        self.reason = self._data["reason"]
+        self.until = self._data["until"]
+        self.next_bell = self._data["next_bell"]
+        self.phase = self._data["phase"]
+        self.market = self._data["market"]
diff --git a/src/tradinghours/models/__init__.py b/src/tradinghours/models/__init__.py
deleted file mode 100644
index 65cd75f..0000000
--- a/src/tradinghours/models/__init__.py
+++ /dev/null
@@ -1,8 +0,0 @@
-from ..util import check_if_tzdata_required_and_up_to_date
-check_if_tzdata_required_and_up_to_date()
-
-
-from .currency import Currency, CurrencyHoliday
-from .market import Market, MarketHoliday, MicMapping
-from .schedule import Schedule, Phase
-from .season import SeasonDefinition
diff --git a/src/tradinghours/models/currency.py b/src/tradinghours/models/currency.py
deleted file mode 100644
index ce18040..0000000
--- a/src/tradinghours/models/currency.py
+++ /dev/null
@@ -1,97 +0,0 @@
-from typing import List
-
-from ..base import (
-    class_decorator,
-    BaseObject,
-    BooleanField,
-    DateField,
-    ZoneInfoField,
-    StringField,
-    WeekdaySetField,
-)
-from ..typing import StrOrDate
-from ..validate import validate_date_arg, validate_range_args, validate_str_arg
-
-@class_decorator
-class Currency(BaseObject):
-    """A currency supported by TradingHours."""
-
-    currency_code = StringField()
-    """3-letter code of the currency (ISO 4217)."""
-
-    currency_name = StringField()
-    """English name of the currency."""
-
-    country_code = StringField()
-    """2-letter country code for the currency's country."""
-
-    central_bank = StringField()
-    """Name of the central bank for the currency."""
-
-    financial_capital = StringField()
-    """City where the central bank is located."""
-
-    financial_capital_timezone = ZoneInfoField()
-    """Timezone Olson timezone identifier format."""
-
-    weekend_definition = WeekdaySetField()
-    """Weekend definition. Most markets are Sat-Sun."""
-
-    _string_format = "{currency_code} {currency_name}"
-
-    def list_holidays(
-        self, start: StrOrDate, end: StrOrDate, catalog=None
-    ) -> "CurrencyHoliday":
-        start, end = validate_range_args(
-            validate_date_arg("start", start),
-            validate_date_arg("end", end),
-        )
-        catalog = self.get_catalog(catalog)
-        holidays = list(
-            catalog.filter(
-                CurrencyHoliday,
-                cluster=self.currency_code,
-                key_start=start.isoformat(),
-                key_end=end.isoformat(),
-            )
-        )
-        return holidays
-
-    @classmethod
-    def list_all(cls, catalog=None) -> List["Currency"]:
-        catalog = cls.get_catalog(catalog)
-        return list(catalog.list_all(Currency))
-
-    @classmethod
-    def get(cls, code: str, catalog=None) -> "Currency":
-        code = validate_str_arg("code", code)
-        catalog = cls.get_catalog(catalog)
-        return catalog.get(cls, code)
-
-@class_decorator
-class CurrencyHoliday(BaseObject):
-    """Holiday for an specific currency"""
-
-    currency_code = StringField()
-    """3-letter code of the currency (ISO 4217)."""
-
-    date = DateField()
-    """Shows the date of the holiday for the currency."""
-
-    holiday_name = StringField()
-    """Describes the name of the holiday."""
-
-    settlement = StringField()
-    """Whether the market has settlement for the holiday."""
-
-    observed = BooleanField({'OBS': True, '': False, None: False})
-    """Whether the holiday is observed."""
-
-    memo = StringField()
-    """A description or additional details about the holiday, if applicable."""
-
-    _string_format = "{currency_code} {date} {holiday_name}"
-
-    @property
-    def has_settlement(self):
-        return self.settlement == 'Yes'
diff --git a/src/tradinghours/models/market.py b/src/tradinghours/models/market.py
deleted file mode 100644
index 0336950..0000000
--- a/src/tradinghours/models/market.py
+++ /dev/null
@@ -1,331 +0,0 @@
-import datetime
-from datetime import timedelta
-from pprint import pprint
-from typing import Dict, Generator, Iterable, List, Tuple
-
-from ..base import (
-    class_decorator,
-    BaseObject,
-    BooleanField,
-    DateField,
-    FinIdField,
-    ZoneInfoField,
-    MicField,
-    StringField,
-    WeekdaySetField,
-)
-from .schedule import PhaseType, Phase, Schedule
-from ..typing import StrOrDate, StrOrFinId
-from ..validate import (
-    validate_date_arg,
-    validate_finid_arg,
-    validate_range_args,
-    validate_str_arg,
-)
-
-# Arbitrary max offset days for TradingHours data
-MAX_OFFSET_DAYS = 2
-
-@class_decorator
-class Market(BaseObject):
-    """One known market for TradingHours"""
-
-    fin_id = FinIdField()
-    """The FinID for the market."""
-
-    exchange_name = StringField()
-    """The exchange name of the market."""
-
-    market_name = StringField()
-    """The name of the market."""
-
-    security_group = StringField()
-    """Description of the products or securities group."""
-
-    mic = MicField()
-    """The MIC for the market."""
-
-    acronym = StringField()
-    """The acronym for the market."""
-
-    asset_type = StringField()
-    """Describes the asset type of the market."""
-
-    memo = StringField()
-    """A description or additional details about the trading venue."""
-
-    permanently_closed = DateField()
-    """If a market is permanently closed, this shows the date."""
-
-    timezone = ZoneInfoField()
-    """Gives the timezone the market utilizes."""
-
-    weekend_definition = WeekdaySetField()
-    """Indicates the days of the week when the market regularly closed."""
-
-    replaced_by = FinIdField()
-
-    _string_format = "{fin_id} {exchange_name} {timezone}"
-
-    @property
-    def country_code(self):
-        """Two-letter country code."""
-        return self.fin_id_obj.country
-
-    def _pick_schedule_group(
-        self,
-        some_date: datetime.date,
-        holidays: Dict[datetime.date, "MarketHoliday"],
-    ) -> Tuple[str, bool]:
-        if found := holidays.get(some_date):
-            schedule_group = found.schedule.lower()
-            fallback = Schedule.is_group_open(schedule_group)
-        else:
-            schedule_group = "regular"
-            fallback = False
-        return schedule_group, fallback
-
-    def _filter_schedule_group(
-        self, schedule_group: str, schedules: Iterable[Schedule]
-    ) -> Iterable[Schedule]:
-        for current in schedules:
-            if current.schedule_group.lower() == schedule_group:
-                yield current
-
-    def _filter_inforce(
-        self, some_date: datetime.date, schedules: Iterable[Schedule]
-    ) -> Iterable[Schedule]:
-        for current in schedules:
-            if current.is_in_force(some_date, some_date):
-                yield current
-
-    def _filter_season(
-        self, some_date: datetime.date, schedules: Iterable[Schedule]
-    ) -> Iterable[Schedule]:
-        for current in schedules:
-            if current.match_season(some_date):
-                yield current
-
-    def _filter_weekdays(
-        self, some_date: datetime.date, schedules: Iterable[Schedule]
-    ) -> Iterable[Schedule]:
-        for current in schedules:
-            if current.days_obj.matches(some_date):
-                yield current
-
-    def _build_keyed_holidays(
-        self, start: StrOrDate, end: StrOrDate, catalog=None
-    ) -> Dict[datetime.date, "MarketHoliday"]:
-
-        holidays = self.list_holidays(start, end)
-        keyed = {}
-        for current in holidays:
-            keyed[current.date] = current
-        return keyed
-
-    def generate_phases(
-        self, start: StrOrDate, end: StrOrDate, catalog=None
-    ) -> Generator[Phase, None, None]:
-        start, end = validate_range_args(
-            validate_date_arg("start", start),
-            validate_date_arg("end", end),
-        )
-        catalog = self.get_catalog(catalog)
-        phase_types_dict = PhaseType.as_dict()
-        # pprint(phase_types_dict)
-
-        # Get required global data
-        offset_start = start - timedelta(days=MAX_OFFSET_DAYS)
-        all_schedules = self.list_schedules()
-        holidays = self._build_keyed_holidays(offset_start, end)
-
-        # Iterate through all dates generating phases
-        current_date = offset_start
-        while current_date <= end:
-            # Starts with all schedules
-            schedules = all_schedules
-
-            # Filter schedule group based on holiday if any
-            schedule_group, fallback = self._pick_schedule_group(current_date, holidays)
-            schedules = self._filter_schedule_group(schedule_group, schedules)
-
-            # Filters what is in force or for expected season
-            schedules = self._filter_inforce(current_date, schedules)
-            schedules = self._filter_season(current_date, schedules)
-
-            # Save for fallback and filter weekdays
-            before_weekdays = list(schedules)
-            found_schedules = list(self._filter_weekdays(current_date, before_weekdays))
-
-            # Consider fallback if needed
-            if not found_schedules and fallback:
-                initial_weekday = current_date.weekday()
-                fallback_weekday = 6 if initial_weekday == 0 else initial_weekday - 1
-                fallback_schedules = []
-                while not fallback_schedules and fallback_weekday != initial_weekday:
-                    fallback_schedules = list(
-                        filter(
-                            lambda s: s.days_obj.matches(fallback_weekday),
-                            before_weekdays,
-                        ),
-                    )
-                    fallback_weekday = (
-                        6 if fallback_weekday == 0 else fallback_weekday - 1
-                    )
-                found_schedules = fallback_schedules
-
-            # Sort based on start time and duration
-            found_schedules = sorted(
-                found_schedules,
-                key=lambda s: (s.start, s.duration),
-            )
-
-            # Generate phases for current date
-            for current_schedule in found_schedules:
-                start_date = current_date
-                end_date = current_date + timedelta(days=current_schedule.offset_days)
-
-                # Filter out phases not finishing after start because we
-                # began looking a few days ago to cover offset days
-                if end_date >= start:
-                    start_datetime = datetime.datetime.combine(
-                        start_date, current_schedule.start
-                    )
-                    end_datetime = datetime.datetime.combine(
-                        end_date, current_schedule.end
-                    )
-                    # start_datetime = current_schedule.timezone_obj.localize(start_datetime)
-                    # end_datetime = current_schedule.timezone_obj.localize(end_datetime)
-                    start_datetime = start_datetime.replace(tzinfo=current_schedule.timezone_obj)
-                    end_datetime = end_datetime.replace(tzinfo=current_schedule.timezone_obj)
-
-                    phase_type = phase_types_dict[current_schedule.phase_type]
-                    yield Phase(
-                        dict(
-                            phase_type=current_schedule.phase_type,
-                            phase_name=current_schedule.phase_name,
-                            phase_memo=current_schedule.phase_memo,
-                            status=phase_type.status,
-                            settlement=phase_type.settlement,
-                            start=start_datetime.isoformat(),
-                            end=end_datetime.isoformat(),
-                        )
-                    )
-
-            # Next date, please
-            current_date += timedelta(days=1)
-
-    @classmethod
-    def list_all(cls, catalog=None) -> List["Market"]:
-        catalog = cls.get_catalog(catalog)
-        return list(catalog.list_all(Market))
-
-
-    def list_holidays(
-        self, start: StrOrDate, end: StrOrDate, catalog=None
-    ) -> List["MarketHoliday"]:
-        start, end = validate_range_args(
-            validate_date_arg("start", start),
-            validate_date_arg("end", end),
-        )
-        catalog = self.get_catalog(catalog)
-        holidays = list(
-            catalog.filter(
-                MarketHoliday,
-                start.isoformat(),
-                end.isoformat(),
-                cluster=str(self.fin_id),
-            )
-        )
-        return holidays
-
-    def list_schedules(self, catalog=None) -> List["Schedule"]:
-        catalog = self.get_catalog(catalog)
-        schedules = map(lambda t: t[1], catalog.list(Schedule, cluster=str(self.fin_id)))
-        return sorted(schedules, key= lambda s: (s.schedule_group or "", str(s.in_force_start_date or ""), s.season_start or "", s.start, s.end))
-
-    @classmethod
-    def get_by_finid(cls, finid: StrOrFinId, follow=True, catalog=None) -> "Market":
-        finid = validate_finid_arg("finid", finid)
-        catalog = cls.get_catalog(catalog)
-        found = catalog.get(Market, str(finid), cluster=finid.country)
-        if found and found.replaced_by and follow:
-            found = catalog.get(Market, str(found.replaced_by), cluster=finid.country)
-        return found
-
-    @classmethod
-    def get_by_mic(cls, mic: str, follow=True, catalog=None) -> "Market":
-        mic = validate_str_arg("mic", mic)
-        catalog = cls.get_catalog(catalog)
-        mapping = MicMapping.get(mic, catalog=catalog)
-        if mapping:
-            return cls.get_by_finid(mapping.fin_id, follow=follow)
-        return None
-
-    @classmethod
-    def get(cls, identifier: str, follow=True, catalog=None) -> "Market":
-        identifier = validate_str_arg("identifier", identifier)
-        catalog = cls.get_catalog(catalog)
-        if "." in identifier:
-            found = cls.get_by_finid(identifier, follow=follow)
-        else:
-            found = cls.get_by_mic(identifier, follow=follow)
-        return found
-
-@class_decorator
-class MarketHoliday(BaseObject):
-    """Holidays for a Market"""
-
-    fin_id = FinIdField()
-    """The FinID for the market."""
-
-    date = DateField()
-    """Shows the date of the holiday for the market."""
-
-    holiday_name = StringField()
-    """Describes the name of the holiday."""
-
-    schedule = StringField()
-    """Describes if the market closes for the holiday."""
-
-    settlement = StringField()
-    """Displays Yes/No indicating if the market has settlement for the holiday."""
-
-    observed = BooleanField({'OBS': True, '': False, None: False})
-    """Displays in true/false if the holiday is observed."""
-
-    memo = StringField()
-    """A description or additional details about the holiday."""
-
-    status = StringField()
-    """Displays in true/false if the market is open for the holiday."""
-
-    _string_format = "{fin_id} {date} {holiday_name}"
-
-    @property
-    def has_settlement(self):
-        return self.settlement == 'Yes'
-
-    @property
-    def is_open(self):
-        return self.status == 'Open'
-
-
-@class_decorator
-class MicMapping(BaseObject):
-    """Mapping from MIC to FinId"""
-
-    mic = StringField()
-    """Market Identification Code"""
-
-    fin_id = FinIdField()
-    """TradingHours FinId"""
-
-    _string_format = "{mic} {fin_id}"
-
-    @classmethod
-    def get(cls, mic: str, catalog=None) -> "MicMapping":
-        mic = validate_str_arg("mic", mic)
-        catalog = cls.get_catalog(catalog)
-        return catalog.get(cls, mic)
-
diff --git a/src/tradinghours/models/schedule.py b/src/tradinghours/models/schedule.py
deleted file mode 100644
index 68683b6..0000000
--- a/src/tradinghours/models/schedule.py
+++ /dev/null
@@ -1,180 +0,0 @@
-import datetime
-from typing import List
-
-from .season import SeasonDefinition
-
-from ..base import (
-    class_decorator,
-    BooleanField,
-    BaseObject,
-    DateField,
-    DateTimeField,
-    FinIdField,
-    IntegerField,
-    ZoneInfoField,
-    StringField,
-    TimeField,
-    WeekdaySetField,
-)
-from ..typing import StrOrDate, StrOrFinId
-from ..validate import validate_date_arg, validate_finid_arg, validate_range_args
-
-@class_decorator
-class PhaseType(BaseObject):
-    name = StringField()
-    """The name of the phase type, mapped to phase_type in schedules"""
-
-    status = StringField()
-    """If this type of phase is considered open or closed"""
-
-    settlement = StringField()
-    """Whether settlement occurs during this type of phase"""
-
-    @property
-    def has_settlement(self):
-        return self.settlement == 'Yes'
-
-    @property
-    def is_open(self):
-        return self.status == 'Open'
-
-    @classmethod
-    def as_dict(cls, catalog=None) -> List["Schedule"]:
-        catalog = cls.get_catalog(catalog)
-        return {t[1].name: t[1] for t in catalog.list(cls)}
-
-@class_decorator
-class Phase(BaseObject):
-    """A period within a schedule"""
-
-    phase_type = StringField()
-    """Well known options"""
-
-    phase_name = StringField()
-    """Describes the name for the phase type."""
-
-    phase_memo = StringField()
-    """If applicable, will have additional description or information."""
-
-    status = StringField()
-    """Describes what status the market is currently."""
-
-    settlement = StringField()
-    """Describes what status the market is currently."""
-
-    start = DateTimeField()
-    """The date the market phase type started."""
-
-    end = DateTimeField()
-    """The scheduled date for the market phase type to end."""
-
-    _string_format = "{start} - {end} {phase_type}"
-
-    @property
-    def has_settlement(self):
-        return self.settlement == 'Yes'
-
-    @property
-    def is_open(self):
-        return self.status == 'Open'
-
-
-@class_decorator
-class Schedule(BaseObject):
-    """Schedules definitions from TradingHours"""
-
-    fin_id = FinIdField()
-    schedule_group = StringField()
-    schedule_group_memo = StringField()
-    timezone = ZoneInfoField()
-    phase_type = StringField()
-    phase_name = StringField()
-    phase_memo = StringField()
-    days = WeekdaySetField()
-    start = TimeField()
-    end = TimeField()
-    offset_days = IntegerField()
-    duration = StringField()
-    min_start = TimeField()
-    max_start = TimeField()
-    min_end = TimeField()
-    max_end = TimeField()
-    in_force_start_date = DateField()
-    in_force_end_date = DateField()
-    season_start = StringField()
-    season_end = StringField()
-
-    _string_format = "{fin_id} ({schedule_group}) {start} - {end_with_offset} {days} {phase_type}"
-
-    @property
-    def end_with_offset(self):
-        end = str(self.end)
-        if self.offset_days:
-            return end + f" +{self.offset_days}"
-        return end + "   "
-
-    @property
-    def has_season(self) -> bool:
-        season_start = (self.season_start or "").strip()
-        season_end = (self.season_end or "").strip()
-        return bool(season_start and season_end)
-
-    def is_in_force(self, start: StrOrDate, end: StrOrDate) -> bool:
-        start, end = validate_range_args(
-            validate_date_arg("start", start),
-            validate_date_arg("end", end),
-        )
-        if self.in_force_start_date is None and self.in_force_end_date is None:
-            return True
-        elif self.in_force_start_date is None:
-            return self.in_force_end_date >= start
-        elif self.in_force_end_date is None:
-            return self.in_force_start_date <= end
-        else:
-            return self.in_force_start_date <= end and self.in_force_end_date >= start
-
-    def match_occurrences(self, some_date: StrOrDate) -> List[datetime.date]:
-        """This method will return all matches for one single date"""
-        some_date = validate_date_arg("some_date", some_date)
-
-        # Keep track of all dates matching some_date, considering the offset
-        # for previous dates could match this date too
-        occurrences: List[datetime.date] = []
-
-        # We will scan all dates considering the offset
-        current_date = some_date
-        current_offset = self.offset_days
-        while current_offset >= 0:
-            # Check whether it happens on this specific date
-            happens = self.is_in_force(current_date, current_date)
-            happens = happens and self.days.matches(current_date)
-            if happens:
-                occurrences.append(current_date)
-
-            # Prepare to match now the previous date
-            current_date -= datetime.timedelta(days=1)
-            current_offset -= 1
-
-        # Return all occurences
-        return occurrences
-
-    def match_season(self, some_date: StrOrDate) -> bool:
-        """Indicates whether some_date matches season if any"""
-        some_date = validate_date_arg("some_date", some_date)
-
-        # If there is no season, it means there is no restriction in terms
-        # of the season when this schedule is valid, and as such it is valid,
-        # from a season-perspective for any date
-        if not self.has_season:
-            return True
-
-        start_date = SeasonDefinition.get_date(self.season_start, some_date.year)
-        end_date = SeasonDefinition.get_date(self.season_end, some_date.year)
-        if end_date < start_date:
-            return some_date <= end_date or some_date >= start_date
-        return some_date >= start_date and some_date <= end_date
-
-    @classmethod
-    def is_group_open(cls, group):
-        # TODO: implement a ScheduleGroup type and consider other open groups
-        return group.lower() == "regular"
diff --git a/src/tradinghours/models/season.py b/src/tradinghours/models/season.py
deleted file mode 100644
index 27744cb..0000000
--- a/src/tradinghours/models/season.py
+++ /dev/null
@@ -1,35 +0,0 @@
-import datetime
-
-from ..base import BaseObject, DateField, IntegerField, StringField, class_decorator
-from ..exceptions import MissingDefinitionError
-from ..validate import validate_int_arg, validate_str_arg
-
-@class_decorator
-class SeasonDefinition(BaseObject):
-    """Seasonality information"""
-
-    season = StringField()
-    """Season entry or code"""
-
-    year = IntegerField()
-    """Year when this definition is value"""
-
-    date = DateField()
-    """Especific date for this year"""
-
-    _string_format = "{date} {season}"
-
-    @classmethod
-    def get(cls, season: str, year: int, catalog=None) -> "SeasonDefinition":
-        season = validate_str_arg("season", season)
-        year = validate_int_arg("year", year)
-        catalog = cls.get_catalog(catalog)
-        for _, definition in catalog.list(cls):
-            if definition.season.lower() == season.lower() and definition.year == year:
-                return definition
-        raise MissingDefinitionError()
-
-    @classmethod
-    def get_date(cls, season: str, year: int, catalog=None) -> datetime.date:
-        definition = cls.get(season, year)
-        return definition.date
diff --git a/src/tradinghours/remote.py b/src/tradinghours/remote.py
deleted file mode 100644
index ba6cfd3..0000000
--- a/src/tradinghours/remote.py
+++ /dev/null
@@ -1,121 +0,0 @@
-import datetime
-import json
-import shutil
-import tempfile
-import zipfile
-from contextlib import contextmanager
-from functools import cached_property
-from pathlib import Path
-from typing import Optional
-from urllib.error import HTTPError
-from urllib.parse import urljoin
-from urllib.request import Request, urlopen
-
-from .config import main_config
-from .exceptions import ClientError, TokenError
-from .typing import StrOrPath
-from .validate import validate_instance_arg, validate_path_arg
-
-
-class Client:
-    """TradingHours API client"""
-
-    def __init__(self, token, base_url):
-        self.token = token
-        self.base_url = base_url
-
-    @contextmanager
-    def get_response(self, path):
-        url = urljoin(self.base_url, path)
-        request = Request(url)
-        request.add_header("Authorization", f"Bearer {self.token}")
-        try:
-            yield urlopen(request)
-        except HTTPError as error:
-            if error.code == 401:
-                raise TokenError("Token is missing or invalid")
-            raise ClientError("Error getting server response", inner=error)
-
-    @contextmanager
-    def download_temporary(self, path):
-        with self.get_response(path) as response:
-            with tempfile.NamedTemporaryFile() as temp_file:
-                shutil.copyfileobj(response, temp_file)
-                temp_file.flush()
-                temp_file.seek(0)
-                yield temp_file
-
-    def get_json(self, path):
-        with self.get_response(path) as response:
-            data = json.load(response)
-        return data
-
-
-class DataManager:
-    """Manages accessing remote data"""
-
-    def __init__(self, client: Client, root: StrOrPath):
-        self.client = validate_instance_arg("client", client, Client)
-        self._root = validate_path_arg("root", root)
-
-    @property
-    def root(self) -> Path:
-        return self._root
-
-    @property
-    def csv_dir(self) -> Path:
-        return self.root / "csv"
-
-    @cached_property
-    def remote_timestamp(self) -> datetime.datetime:
-        data = default_client.get_json("last-updated")
-        last_updated = data["last_updated"]
-        timestamp = datetime.datetime.fromisoformat(last_updated)
-        return timestamp
-
-    @cached_property
-    def local_timestamp(self) -> Optional[datetime.datetime]:
-        version_file = self.root / "VERSION.txt"
-        if version_file.exists():
-            timestamp_format = "Generated at %a, %d %b %Y %H:%M:%S %z"
-            content = version_file.read_text()
-            line = content.splitlines()[0]
-            timestamp = datetime.datetime.strptime(line, timestamp_format)
-            return timestamp
-        return None
-
-    @cached_property
-    def needs_download(self) -> bool:
-        if self.local_timestamp:
-            return self.remote_timestamp > self.local_timestamp
-        return True
-
-    def download(self):
-        self.root.mkdir(parents=True, exist_ok=True)
-        with default_client.download_temporary("download") as temp_file:
-            with zipfile.ZipFile(temp_file, "r") as zip_ref:
-                zip_ref.extractall(self.root)
-
-        for file in ("currencies.csv",
-                     "currency-holidays.csv",
-                     "holidays.csv",
-                     "markets.csv",
-                     "mic-mapping.csv",
-                     "phases.csv",
-                     "regional-holidays.csv",
-                     "schedules.csv",
-                     "season-definitions.csv"):
-
-            if not (self.csv_dir/file).exists():
-                open(self.csv_dir/file, "w").close()
-
-
-default_client = Client(
-    main_config.get("api", "token"),
-    main_config.get("api", "base_url"),
-)
-
-default_data_manager = DataManager(
-    default_client,
-    main_config.get("data", "remote_dir"),
-)
diff --git a/src/tradinghours/store.py b/src/tradinghours/store.py
new file mode 100644
index 0000000..f2653b6
--- /dev/null
+++ b/src/tradinghours/store.py
@@ -0,0 +1,454 @@
+import os, csv, json, codecs
+import datetime as dt
+from pathlib import Path
+from pprint import pprint
+from sqlalchemy import (
+    create_engine,
+    MetaData,
+    func,
+    Table,
+    Column,
+    String,
+    Integer,
+    DateTime,
+    Time,
+    Date,
+    Boolean,
+    Text
+)
+from sqlalchemy.orm import sessionmaker
+from contextlib import contextmanager
+from typing import Union
+import functools
+from enum import Enum
+
+from .config import main_config
+from .client import get_remote_timestamp as client_get_remote_timestamp
+from .util import tprefix, tname, clean_name, timed_action
+from .exceptions import DBError, NoAccess
+
+class AccessLevel(Enum):
+    full = "full"
+    no_currencies = "no_currencies"
+    only_holidays = "only_holidays"
+    no_access = None
+
+class DB:
+    _instance = None
+    _types = {
+        "date": (Date, dt.date.fromisoformat),
+        "observed": (Boolean, lambda v: v == "OBS"),
+        "start": (Time, dt.time.fromisoformat),
+        "end": (Time, dt.time.fromisoformat),
+        "offset_days": (Integer, int),
+        "duration": (Integer, int),
+        "min_start": (Time, dt.time.fromisoformat),
+        "max_start": (Time, dt.time.fromisoformat),
+        "min_end": (Time, dt.time.fromisoformat),
+        "max_end": (Time, dt.time.fromisoformat),
+        "in_force_start_date": (Date, dt.date.fromisoformat),
+        "in_force_end_date": (Date, dt.date.fromisoformat),
+        "year": (Integer, int),
+        # Everything else is Text
+    }
+    _default_type = (Text, str)
+    _access = {
+        "Currency.list_all" : {AccessLevel.full},
+        "Currency.get": {AccessLevel.full},
+        "Currency.is_covered": {AccessLevel.full},
+        "Market.list_schedules": {AccessLevel.full, AccessLevel.no_currencies},
+        "Market.generate_phases": {AccessLevel.full, AccessLevel.no_currencies},
+        "Market.status": {AccessLevel.full, AccessLevel.no_currencies}
+    }
+    _no_model_access = {
+        AccessLevel.full: set(),
+        AccessLevel.no_currencies: {"currencies", "currency_holidays"},
+        AccessLevel.only_holidays: {"currencies", "currency_holidays", "phases", "schedules", "season_definitions"}
+    }
+
+    @classmethod
+    def set_no_unicode(cls):
+        """
+        MySQL databases may not be able to handle the full unicode set by default. So if a
+        mysql db is used and the ingestion fails, it is attempted again with the following
+        conversion, which replaces unicode characters with '?'.
+        """
+        cls._default_type = (
+            Text,
+            lambda s: str(s).encode("ascii", "replace").decode("ascii")
+        )
+
+    @classmethod
+    def get_type(cls, col_name):
+        return cls._types.get(col_name, cls._default_type)[0]
+
+    @classmethod
+    def clean(cls, col_name: str, value: Union[bool, str, None]) -> Union[bool, str, None]:
+        """
+        Used to map values from the csv files to what they should be in the database
+         For observed columns 'OBS' is True, anything else is False
+         For other columns, empty strings should be converted to None
+        """
+        converter = cls._types.get(col_name, cls._default_type)[1]
+        if col_name == "observed":
+            return converter(value)
+
+        return converter(value) if value else None
+
+    def __new__(cls):
+        if cls._instance is None:
+            cls._instance = self = super().__new__(cls)
+            self.db_url = main_config.get("data", "db_url")
+            try:
+                self.engine = create_engine(self.db_url)
+            except ModuleNotFoundError as e:
+                raise ModuleNotFoundError(
+                    "You seem to be missing the required dependencies to interact with your chosen database. "
+                    "Please run `pip install tradinghours[mysql]` or `pip install tradinghours[postgres]` if "
+                    "you are trying to access mysql or postgres, respectively. Consult the docs for more information."
+                ) from e
+
+            self.metadata = MetaData()
+            try:
+                self.update_metadata()
+            except Exception:
+                self._failed_to_access = True
+
+            self.Session = sessionmaker(bind=self.engine)
+            self._access_level = None
+
+        return cls._instance
+
+    def table(self, table_name: str) -> Table:
+        try:
+            return self.metadata.tables[tname(table_name)]
+        except KeyError:
+            # using self._access_level instead of property to avoid an infinite recursion
+            # when running on a new database without an access_level. If ._access_level is None,
+            # it would check if table_name is in an empty set, which would make it raise a KeyError,
+            # which is handled properly in .access_level property.
+            if self._access_level == AccessLevel.no_access:
+                raise DBError(f"{table_name} could not be found. Are you sure you ran `tradinghours import`?")
+
+            if table_name in self._no_model_access.get(self._access_level, set()):
+                raise NoAccess(
+                    f"\nIf you are sure you ran `tradinghours import`, {table_name} is not available on your current plan."
+                    f"\nPlease learn more or contact sales at https://www.tradinghours.com/data"
+                )
+            raise
+
+    def ready(self) -> None:
+        if getattr(self, "_failed_to_access", True):
+            raise DBError("Could not access database")
+
+        if tname("admin") not in self.metadata.tables:
+            raise DBError("Database not prepared. Did you run `tradinghours import`?")
+
+    def reset_session(self):
+        if hasattr(self, "_session"):
+           self._session.rollback()
+           self._session.close()
+
+        self._session = self.Session()
+
+    @contextmanager
+    def session(self):
+        if hasattr(self, "_session"):
+            s = self._session
+        else:
+            s = self._session = self.Session()
+        yield s
+
+    def execute(self, *query):
+        with self.session() as s:
+            result = s.execute(*query)
+            s.commit()
+            return result
+
+    def query(self, *query):
+        with self.session() as s:
+            return s.query(*query)
+
+    def get_local_timestamp(self):
+        # admin table is not present when `tradinghours import`
+        # is run for the first time on a given database
+        if tname("admin") not in self.metadata.tables:
+            return
+
+        table = self.table("admin")
+        with self.session() as s:
+            result = s.query(
+                table.c["data_timestamp"]).order_by(
+                    table.c["id"].desc()
+            ).limit(1).scalar()
+            if result:
+                return result.replace(tzinfo=dt.timezone.utc)
+
+    @property
+    def access_level(self) -> AccessLevel:
+        if self._access_level is None:
+            try:
+                table = self.table("admin")
+            except KeyError:
+                # This should only be the case when ingesting into a completely new
+                # database, that doesn't have an admin table yet.
+                level = None
+            else:
+                level = self.query(table.c.access_level).order_by(
+                    table.c.id.desc()
+                ).limit(1).scalar()
+            self._access_level = AccessLevel(level)
+
+        return self._access_level
+
+    @classmethod
+    def check_access(cls, method):
+        """
+        Used as a decorator of Currency and Market methods,
+         to check whether the user has access to the data requested.
+        """
+        method_name = method.__qualname__
+        not_has_access = db.access_level not in cls._access[method_name]
+
+        @functools.wraps(method)
+        def new_method(*args, **kwargs):
+            if not_has_access:
+                raise NoAccess(f"\n\n{method_name} is supported but not available on your current plan."
+                               f"\nPlease learn more or contact sales at https://www.tradinghours.com/data")
+            return method(*args, **kwargs)
+
+        return new_method
+
+
+    def needs_download(self):
+        if local := self.get_local_timestamp():
+            remote_timestamp = client_get_remote_timestamp()
+            return remote_timestamp > local
+        return True
+
+    def update_metadata(self):
+        self.metadata.clear()
+        self.metadata.reflect(bind=self.engine)
+        self._failed_to_access = False
+
+    def get_num_covered(self) -> tuple[int, int]:
+        table = db.table("covered_markets")
+        num_markets = self.query(func.count()).select_from(table).scalar()
+        table = db.table("covered_currencies")
+        num_currencies = self.query(func.count()).select_from(table).scalar()
+        return num_markets, num_currencies
+
+    def get_num_permanently_closed(self) -> int:
+        table = db.table("markets")
+        num = self.query(func.count()).filter(
+            table.c.permanently_closed.isnot(None)
+        ).scalar()
+        return num
+
+########################################################
+# Singleton db instance used across the entire project #
+########################################################
+db = DB()
+
+
+# noinspection PyMethodMayBeStatic
+class Writer:
+
+    def __init__(self):
+        self.remote = Path(main_config.get("data", "remote_dir"))
+
+    def prepare_ingestion(self):
+        """Preserves the last 9 records from the thstore_admin table,
+        drops the table, recreates it, and re-inserts the 9 records."""
+        table_name = tname("admin")
+        last_9_records = []
+        if table_name not in db.metadata.tables:
+            return last_9_records
+
+        table = db.metadata.tables[table_name]
+        columns_to_select = [col for col in table.c.values() if col.name != 'id']
+        result = db.execute(
+            table.select()
+            .with_only_columns(*columns_to_select)
+            .order_by(table.c['id'].desc())
+            .limit(9)
+        )
+
+        # Fetch all results
+        last_9_records = result.fetchall()
+        last_9_records = [
+            {col.name: value for col, value in zip(columns_to_select, row)}
+            for row in last_9_records[::-1]
+        ]
+        table.drop(db.engine)
+        db.update_metadata()
+
+        return last_9_records
+
+    def drop_th_tables(self):
+        """Drops all tables from the database that start with 'thstore_'."""
+        # Iterate over all tables in the metadata
+        for table_name in db.metadata.tables:
+            if table_name.startswith(tprefix):
+                table = db.metadata.tables[table_name]
+                table.drop(db.engine)
+
+        # Clear the metadata cache after dropping tables
+        db.update_metadata()
+        # print(f"Dropped all tables starting with {tprefix}.")
+
+    def create_table_from_csv(self, file_path, table_name):
+        """Creates a SQL table dynamically from a CSV file."""
+
+        with codecs.open(file_path, mode='r', encoding='utf-8-sig') as file:
+            reader = csv.reader(file)
+            # Get the columns (first row of the CSV)
+            columns = next(reader)
+            columns = [clean_name(col_name) for col_name in columns]
+
+            # Define the SQL table dynamically with all columns as Strings
+            table = Table(
+                table_name,
+                db.metadata,
+                Column('id', Integer, primary_key=True),
+                *(Column(col_name, DB.get_type(col_name)) for col_name in columns)
+            )
+            batch = []
+            for i, row in enumerate(reader):
+                values = {col_name: DB.clean(col_name, value) for col_name, value in zip(columns, row)}
+                batch.append(values)
+
+        table.create(db.engine)
+        db.execute(table.insert(), batch)
+
+    def create_table_from_json(self, file_path, table_name):
+        """
+        This method takes a filepath to a json file that should hold a list of dictionaries.
+         It is probably redundant, but it makes sure that the table created is flexible in regard to
+         the content of the dictionaries by following these steps:
+
+        # find all keys that exist
+        # filter out keys that don't exist in every dictionary
+        # clean these keys using clean_name
+        # create a table with the cleaned keys
+        # insert a batch of fields that exist in every dictionary
+        """
+        with open(file_path, "r") as data:
+            data = json.load(data)
+
+        keys = {}
+        len_data = 0
+        for dct in data:
+            len_data += 1
+            for k in dct:
+                keys[k] = keys.setdefault(k, 0) + 1
+
+        columns = [(k, clean_name(k)) for k, n in keys.items() if n == len_data]
+        table = Table(
+            table_name,
+            db.metadata,
+            Column('id', Integer, primary_key=True),
+            *(Column(col_name, DB.get_type(col_name)) for k, col_name in columns)
+        )
+        batch = []
+        for dct in data:
+            batch.append({clean_k: DB.clean(clean_k, dct.get(k, "")) for k, clean_k in columns})
+
+        table.create(db.engine)
+        db.execute(table.insert(), batch)
+
+    def create_admin(self, access_level, last_9_records):
+        version_file = self.remote / "VERSION.txt"
+        timestamp_format = "Generated at %a, %d %b %Y %H:%M:%S %z"
+        content = version_file.read_text()
+        line = content.splitlines()[0]
+        data_timestamp = dt.datetime.strptime(line, timestamp_format)
+
+        table = Table(
+            tname("admin"),
+            db.metadata,
+            Column('id', Integer, primary_key=True),
+            Column('data_timestamp', DateTime, nullable=False),
+            Column('access_level', String(255), nullable=False),
+            Column('download_timestamp', DateTime, nullable=False),
+        )
+        table.create(db.engine)
+        if last_9_records:
+            db.execute(table.insert(), last_9_records)
+
+        db.execute(
+            table.insert().values(
+                data_timestamp=data_timestamp,
+                access_level=access_level.value,
+                download_timestamp=dt.datetime.now(dt.timezone.utc).replace(tzinfo=None)
+            )
+        )
+        db.update_metadata()
+
+    def _ingest_all(self, change_message):
+        """Iterates over CSV files in the remote directory and ingests them."""
+        db.reset_session()
+        last_9_admin_records = self.prepare_ingestion()
+        self.drop_th_tables()
+
+        csv_dir = self.remote / "csv"
+        # Iterate over all CSV files in the directory
+        downloaded_csvs = os.listdir(csv_dir)
+
+        for csv_file in downloaded_csvs:
+            if csv_file.endswith('.csv'):
+                file_path = csv_dir / csv_file
+                table_name = os.path.splitext(csv_file)[0]
+                table_name = tname(clean_name(table_name))
+                change_message(f"  {table_name}")
+                self.create_table_from_csv(file_path, table_name)
+
+        for json_file in ("covered_markets", "covered_currencies"):
+            table_name = tname(json_file)
+            change_message(f"  {table_name}")
+            self.create_table_from_json(
+                self.remote / f"{json_file}.json",
+                table_name
+            )
+
+        db.update_metadata()
+
+        if "schedules.csv" not in downloaded_csvs:
+            access_level = AccessLevel.only_holidays
+        elif "currencies.csv" not in downloaded_csvs:
+            access_level = AccessLevel.no_currencies
+        else:
+            access_level = AccessLevel.full
+
+        self.create_admin(access_level, last_9_admin_records)
+
+    def ingest_all(self) -> bool:
+        with timed_action("Ingesting") as (change_message, start_time):
+            try:
+                self._ingest_all(change_message)
+                return True
+            except Exception as e:
+                if db.engine.dialect.name != "mysql" or "Incorrect string value" not in str(e):
+                    raise
+
+        # Deal with the problem that MySQL may not be able to
+        # handle the full unicode set and then try again
+        print("\nHandling unicode problem, warning will follow")
+        db.set_no_unicode()
+        with timed_action("Ingesting") as (change_message, start_time):
+            self._ingest_all(change_message)
+        return False
+
+
+
+
+
+
+"""
+full = all
+
+only_holidays = no schedules
+ 
+no_currencies = schedules but no currencies
+"""
+
diff --git a/src/tradinghours/store/__init__.py b/src/tradinghours/store/__init__.py
deleted file mode 100644
index e69de29..0000000
diff --git a/src/tradinghours/store/base.py b/src/tradinghours/store/base.py
deleted file mode 100644
index f313527..0000000
--- a/src/tradinghours/store/base.py
+++ /dev/null
@@ -1,88 +0,0 @@
-from abc import ABC, abstractmethod, abstractproperty
-from typing import Dict, Generic, Iterator, List, Optional, Tuple, TypeVar
-
-from ..util import slugify
-
-T = TypeVar("T")
-
-
-class Registry(ABC, Generic[T]):
-    """Keeps track of keyed resources"""
-
-    def __init__(self):
-        self._resources = {}
-        for name in self.discover():
-            self.get(name)
-
-    def get(self, name: str) -> T:
-        slug = slugify(name)
-        resource = self._resources.get(slug, None)
-        if resource is None:
-            resource = self.create(slug)
-            self._resources[slug] = resource
-        return resource
-
-    @abstractmethod
-    def create(self, slug: str) -> T:
-        raise NotImplementedError()
-
-    @abstractmethod
-    def discover(self) -> Iterator[str]:
-        raise NotImplementedError()
-
-    def __iter__(self) -> Iterator[T]:
-        return iter(self._resources.values())
-
-
-class Cluster(ABC):
-    """Generic Cluster interface"""
-
-    def __init__(self, cache_size: Optional[int] = None):
-        self._cached: List[str, Tuple] = []
-        self._cache_size = cache_size or self.DEFAULT_CACHE_SIZE
-
-    @abstractmethod
-    def truncate(self):
-        """Clear all elements from this cluster"""
-        raise NotImplementedError()
-
-    def append(self, key: Optional[str], data: Tuple):
-        """Appends one element to this cluster
-
-        Calling this method does not indicate that the data will be saved in
-        the underlying storage. You need to call `flush` for that.
-
-        """
-        record = [key, data]
-        self._cached.append(record)
-        if len(self._cached) >= self._cache_size:
-            self.flush()
-
-    @abstractmethod
-    def flush(self):
-        """Flushes data to the underlying storage"""
-        raise NotImplementedError()
-
-    @abstractmethod
-    def load_all(self) -> Dict[str, Tuple]:
-        """Loads all data from this cluster"""
-        raise NotImplementedError()
-
-
-class Collection(ABC):
-    """Generic Collection interface"""
-
-    @abstractproperty
-    def clusters(self) -> Registry:
-        """Gets the clusters registry for this collection"""
-        raise NotImplementedError()
-
-    @abstractmethod
-    def touch(self):
-        """Ensure the collection exists"""
-        raise NotImplementedError()
-
-    @abstractmethod
-    def clear(self):
-        """Wipes all clusters in this collection"""
-        raise NotImplementedError()
diff --git a/src/tradinghours/store/engine.py b/src/tradinghours/store/engine.py
deleted file mode 100644
index 370b131..0000000
--- a/src/tradinghours/store/engine.py
+++ /dev/null
@@ -1,70 +0,0 @@
-import os
-from typing import Optional, Tuple
-
-from ..config import main_config
-from .base import Collection, Registry
-
-
-class Store:
-    """Manages data loaded into memory"""
-
-    def __init__(self, collections_registry: Registry):
-        self._collections = collections_registry
-
-    @property
-    def collections(self) -> Registry[Collection]:
-        return self._collections
-
-    @property
-    def token(self):
-        return os.getenv("TRADINGHOURS_TOKEN")
-
-    def clear_collection(self, name: str):
-        collection_obj = self.collections.get(name)
-        collection_obj.clear()
-
-    def store_tuple(
-        self,
-        data: Tuple,
-        collection,
-        cluster: Optional[str] = None,
-        key: Optional[str] = None,
-    ):
-        collection_obj = self.collections.get(collection)
-        if cluster is None:
-            cluster = "default"
-        cluster_obj = collection_obj.clusters.get(cluster)
-        cluster_obj.append(key, data)
-
-    def flush(self):
-        for collection in self.collections:
-            for cluster in collection.clusters:
-                cluster.flush()
-
-
-def create_file_store():
-    from tradinghours.store.file import FileCollectionRegistry
-
-    root = main_config.get("data", "local_dir")
-    registry = FileCollectionRegistry(root)
-    store = Store(registry)
-    return store
-
-
-def create_sql_store():
-    from tradinghours.store.sql import SqlCollectionRegistry
-
-    db_url = main_config.get("data", "db_url")
-    registry = SqlCollectionRegistry(db_url)
-    store = Store(registry)
-    return store
-
-
-def create_default_store():
-    if main_config.getboolean("data", "use_db"):
-        return create_sql_store()
-    else:
-        return create_file_store()
-
-
-default_store = create_default_store()
diff --git a/src/tradinghours/store/file.py b/src/tradinghours/store/file.py
deleted file mode 100644
index 309347f..0000000
--- a/src/tradinghours/store/file.py
+++ /dev/null
@@ -1,129 +0,0 @@
-import csv
-from pathlib import Path
-from typing import Dict, Iterator, List, Optional, Tuple
-
-from .base import Cluster, Collection, Registry
-from ..typing import StrOrPath
-from ..validate import validate_path_arg
-from ..exceptions import NoAccess
-
-
-class FileCluster(Cluster):
-    """Manages one page file with items for a collection"""
-
-    DEFAULT_CACHE_SIZE = 5000
-
-    def __init__(self, location: StrOrPath, cache_size: Optional[int] = None):
-        self._location = validate_path_arg("location", location)
-        self._cached: List[str, Tuple] = []
-        self._cache_size = cache_size or self.DEFAULT_CACHE_SIZE
-
-    @property
-    def location(self) -> Path:
-        return self._location
-
-    def truncate(self):
-        with open(self.location, "a+", encoding="utf-8", newline="") as file:
-            file.seek(0)
-            file.truncate(0)
-
-    def flush(self):
-        with open(self.location, "w", encoding="utf-8", newline="") as file:
-            writer = csv.writer(file)
-            for key, data in self._cached:
-                row = [key, *data]
-                writer.writerow(row)
-            self._cached = []
-
-    def load_all(self) -> Dict[str, Tuple]:
-        keyed_items = {}
-        next_key = 1
-        try:
-            with open(self.location, "r", encoding="utf-8", newline="") as file:
-                for row in csv.reader(file):
-                    key = row[0] or str(next_key)
-                    next_key += 1
-                    data = row[1:]
-                    if key:
-                        keyed_items[key] = data
-            return keyed_items
-
-        except FileNotFoundError:
-            source = self.location.parts[-1]
-            if source == "default.dat":
-                source = self.location.parts[-2]
-            else:
-                source = f'{self.location.parts[-2]}/{source.replace(".dat", "")}'
-
-            raise NoAccess(f"You didn't run `tradinghours import` or you dont have access to {source}.") from None
-
-
-
-class FileClusterRegistry(Registry[FileCluster]):
-    """Holds a series of file clusters"""
-
-    def __init__(self, folder: StrOrPath):
-        self._folder = validate_path_arg("folder", folder)
-        super().__init__()
-
-    @property
-    def folder(self) -> Path:
-        return self._folder
-
-    def create(self, slug: str) -> FileCluster:
-        location = self.folder / f"{slug}.dat"
-        return FileCluster(location)
-
-    def discover(self) -> Iterator[str]:
-        if self.folder.exists():
-            for item in self.folder.iterdir():
-                if item.is_file():
-                    yield item.stem
-
-
-class FileCollection(Collection):
-    """Manages a collection of items in a store"""
-
-    def __init__(self, folder: StrOrPath):
-        self._folder = validate_path_arg("folder", folder)
-        self._clusters = FileClusterRegistry(folder)
-
-    @property
-    def folder(self) -> Path:
-        return self._folder
-
-    @property
-    def clusters(self) -> FileClusterRegistry:
-        return self._clusters
-
-    def touch(self):
-        self.folder.mkdir(parents=True, exist_ok=True)
-
-    def clear(self):
-        for current in self._clusters:
-            current.truncate()
-        self._clusters = FileClusterRegistry(self.folder)
-
-
-class FileCollectionRegistry(Registry[FileCollection]):
-    """Holds a series of collections"""
-
-    def __init__(self, root: StrOrPath):
-        self._root = validate_path_arg("root", root)
-        super().__init__()
-
-    @property
-    def root(self) -> Path:
-        return self._root
-
-    def create(self, slug: str) -> FileCollection:
-        folder = self.root / slug
-        collection = FileCollection(folder)
-        collection.touch()
-        return collection
-
-    def discover(self) -> Iterator[str]:
-        if self.root.exists():
-            for item in self.root.iterdir():
-                if item.is_dir():
-                    yield item.name
diff --git a/src/tradinghours/store/source.py b/src/tradinghours/store/source.py
deleted file mode 100644
index 7734880..0000000
--- a/src/tradinghours/store/source.py
+++ /dev/null
@@ -1,47 +0,0 @@
-import csv
-from pathlib import Path
-from typing import Generator, Generic, Type, TypeVar
-
-from ..base import BaseObject
-from ..typing import StrOrPath
-from ..validate import (
-    validate_path_arg,
-    validate_str_arg,
-    validate_subclass_arg,
-)
-
-B = TypeVar("B", bound=BaseObject)
-
-
-class SourceFile(Generic[B]):
-    """Represents a file to be imported"""
-
-    def __init__(self, root: StrOrPath, name: str, model: Type[B]):
-        self._name = validate_str_arg("name", name)
-        self._root = validate_path_arg("root", root)
-        self._model = validate_subclass_arg("model", model, BaseObject)
-
-    @property
-    def name(self) -> str:
-        return self._name.replace("-", "_")
-
-    @property
-    def root(self) -> Path:
-        return self._root
-
-    @property
-    def model(self) -> Type[B]:
-        return self._model
-
-    @property
-    def filename(self) -> str:
-        return self._name.replace("_", "-") + ".csv"
-
-    @property
-    def path(self) -> Path:
-        return self._root / self.filename
-
-    def load_iter(self) -> Generator[B, None, None]:
-        with open(self.path, "r", encoding="utf-8-sig", errors="replace") as file:
-            for row in csv.DictReader(file):
-                yield self.model.from_dict(row)
diff --git a/src/tradinghours/store/sql.py b/src/tradinghours/store/sql.py
deleted file mode 100644
index 9bb4ab8..0000000
--- a/src/tradinghours/store/sql.py
+++ /dev/null
@@ -1,136 +0,0 @@
-from typing import Dict, Iterator, Tuple
-
-from ..exceptions import MissingSqlAlchemyError
-
-try:
-    from sqlalchemy import (
-        JSON,
-        Column,
-        Engine,
-        Integer,
-        MetaData,
-        String,
-        Table,
-        create_engine,
-        inspect,
-    )
-except ImportError:
-    raise MissingSqlAlchemyError("SQLAlchemy not installed")
-
-
-from tradinghours.store.base import Cluster, Collection, Registry
-from tradinghours.util import get_csv_from_tuple, get_tuple_from_csv
-
-TABLE_NAME_PREFIX = "thstore_"
-
-
-class SqlCluster(Cluster):
-    """Manages one page in a SQL database table"""
-
-    DEFAULT_CACHE_SIZE = 500
-
-    def __init__(self, engine: Engine, table: Table, slug: str, cache_size=None):
-        self._engine = engine
-        self._table = table
-        self._slug = slug
-        self._cached = []
-        self._cache_size = cache_size or self.DEFAULT_CACHE_SIZE
-
-    def truncate(self):
-        with self._engine.connect() as connection:
-            connection.execute(f"DELETE FROM {self._table.name}")
-
-    def flush(self):
-        with self._engine.connect() as connection:
-            with connection.begin() as transaction:
-                for key, data in self._cached:
-                    data_csv = get_csv_from_tuple(data)
-                    db_record = {"slug": self._slug, "key": key, "data": data_csv}
-                    connection.execute(self._table.insert().values(db_record))
-                transaction.commit()
-        self._cached = []
-
-    def load_all(self) -> Dict[str, Tuple]:
-        keyed_items = {}
-        with self._engine.connect() as connection:
-            select_query = (
-                self._table.select()
-                .add_columns(self._table.c.key, self._table.c.data)
-                .where(self._table.c.slug == self._slug)
-            )
-            result = connection.execute(select_query)
-            for row in result:
-                key = row.key or str(row.id)
-                data = get_tuple_from_csv(row.data)
-                keyed_items[key] = data
-        return keyed_items
-
-
-class SqlClusterRegistry(Registry[SqlCluster]):
-    """Holds a series of SQL clusters"""
-
-    def __init__(self, engine: Engine, table: Table):
-        self._engine = engine
-        self._table = table
-        super().__init__()
-
-    def create(self, slug: str) -> SqlCluster:
-        return SqlCluster(self._engine, self._table, slug)
-
-    def discover(self) -> Iterator[str]:
-        with self._engine.connect() as connection:
-            select_query = (
-                self._table.select().add_columns(self._table.c.slug).distinct()
-            )
-            result = connection.execute(select_query)
-            for row in result:
-                yield row.slug
-
-
-class SqlCollection(Collection):
-    def __init__(self, engine: Engine, table: Table):
-        self._engine = engine
-        self._table = table
-        self.touch()
-        self._clusters = SqlClusterRegistry(self._engine, self._table)
-
-    @property
-    def clusters(self) -> SqlClusterRegistry:
-        return self._clusters
-
-    def touch(self):
-        self._table.create(self._engine, checkfirst=True)
-
-    def clear(self):
-        for current in self._clusters:
-            current.truncate()
-        self._clusters = SqlClusterRegistry(self._engine, self._table)
-
-
-class SqlCollectionRegistry(Registry[SqlCollection]):
-    def __init__(self, db_url: str):
-        self._db_url = db_url
-        self._engine = create_engine(self._db_url)
-        self._metadata = MetaData()
-        super().__init__()
-
-    def create(self, slug: str) -> SqlCollection:
-        table_name = TABLE_NAME_PREFIX + slug.replace("-", "_")
-        table = Table(
-            table_name,
-            self._metadata,
-            Column("id", Integer, primary_key=True),
-            Column("slug", String),
-            Column("key", String),
-            Column("data", JSON),
-        )
-        collection = SqlCollection(self._engine, table)
-        collection.touch()
-        return collection
-
-    def discover(self) -> Iterator[str]:
-        inspector = inspect(self._engine)
-        all_names = inspector.get_table_names()
-        for table_name in all_names:
-            if table_name.startswith(TABLE_NAME_PREFIX):
-                yield table_name[len(TABLE_NAME_PREFIX) :]
diff --git a/src/tradinghours/structure.py b/src/tradinghours/structure.py
deleted file mode 100644
index 256e0ee..0000000
--- a/src/tradinghours/structure.py
+++ /dev/null
@@ -1,133 +0,0 @@
-from dataclasses import dataclass, field
-from typing import List, Tuple
-
-from .typing import WeekdaySpec
-from .validate import validate_weekday_arg
-
-@dataclass
-class Weekday:
-    WEEKDAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
-    ordinal: int
-
-    def next(self) -> "Weekday":
-        if self.ordinal == 6:
-            return Weekday(0)
-        return Weekday(self.ordinal + 1)
-
-    def previous(self) -> "Weekday":
-        if self.ordinal == 0:
-            return Weekday(6)
-        return Weekday(self.ordinal - 1)
-
-    @classmethod
-    def from_string(cls, input_string):
-        input_string = input_string.title()
-        if input_string in cls.WEEKDAYS:
-            return cls(cls.WEEKDAYS.index(input_string))
-        else:
-            raise ValueError("Invalid weekday string")
-
-    def __str__(self):
-        return self.WEEKDAYS[self.ordinal]
-
-
-@dataclass
-class WeekdayPeriod:
-    start_day: Weekday
-    end_day: Weekday
-
-    @property
-    def weekdays(self) -> List:
-        captured = []
-        current_day = self.start_day.previous()
-        finished = False
-        while not finished:
-            current_day = current_day.next()
-            captured.append(current_day)
-            if current_day == self.end_day:
-                finished = True
-        return captured
-
-    def matches(self, other: WeekdaySpec) -> bool:
-        other = validate_weekday_arg("other", other)
-        return other in self.weekdays
-
-    @classmethod
-    def from_string(cls, input_string):
-        if "-" in input_string:
-            start, end = input_string.split("-")
-            start_day = Weekday.from_string(start)
-            end_day = Weekday.from_string(end)
-            return cls(start_day, end_day)
-        else:
-            only_day = Weekday.from_string(input_string)
-            return cls(only_day, only_day)
-
-    def __str__(self):
-        if self.start_day == self.end_day:
-            return str(self.start_day)
-        else:
-            return f"{str(self.start_day)}-{str(self.end_day)}"
-
-
-@dataclass
-class WeekdaySet:
-    periods: Tuple[WeekdayPeriod]
-
-    def matches(self, other: WeekdaySpec) -> bool:
-        other = validate_weekday_arg("other", other)
-        for current in self.periods:
-            if current.matches(other):
-                return True
-        return False
-
-    @classmethod
-    def from_string(cls, input_string):
-        all_period_str = input_string.split(",")
-        all_period = []
-        for period_str in all_period_str:
-            period = WeekdayPeriod.from_string(period_str)
-            all_period.append(period)
-        return cls(tuple(all_period))
-
-    def __str__(self):
-        periods = [str(p) for p in self.periods]
-        return ",".join(periods)
-
-
-@dataclass
-class FinId:
-    country: str
-    acronym: str
-    extra: list = field(default_factory=list)
-
-    def __post_init__(self):
-        self.country = self.country.upper()
-        self.acronym = self.acronym.upper()
-        self.extra = [e.upper() for e in self.extra]
-
-    @classmethod
-    def from_string(cls, input_string):
-        segments = input_string.split(".")
-        if len(segments) < 2:
-            raise ValueError("Invalid FinID string")
-        return cls(segments[0], segments[1], segments[2:])
-
-    def __str__(self):
-        segments = [self.country, self.acronym] + self.extra
-        return ".".join(segments)
-
-
-@dataclass
-class Mic:
-    code: str
-
-    def __post_init__(self):
-        self.code = self.code.upper()
-
-    @classmethod
-    def from_string(cls, input_string):
-        return cls(input_string)
-
-    def __str__(self):
-        return self.code
diff --git a/src/tradinghours/typing.py b/src/tradinghours/typing.py
deleted file mode 100644
index 1e335d4..0000000
--- a/src/tradinghours/typing.py
+++ /dev/null
@@ -1,12 +0,0 @@
-import datetime
-from pathlib import Path
-from typing import TYPE_CHECKING, Union
-
-if TYPE_CHECKING:
-    from .structure import FinId, Weekday
-
-
-StrOrPath = Union[str, Path]
-StrOrDate = Union[str, datetime.date]
-StrOrFinId = Union[str, "FinId"]
-WeekdaySpec = Union[str, int, datetime.date, datetime.datetime, "Weekday"]
diff --git a/src/tradinghours/util.py b/src/tradinghours/util.py
index 2129e49..ca7ed60 100644
--- a/src/tradinghours/util.py
+++ b/src/tradinghours/util.py
@@ -1,154 +1,91 @@
-import csv
-import json
-import re
-from io import StringIO
-from typing import Dict
+import re, time
+from contextlib import contextmanager
+from threading import Thread, Event
 
 from zoneinfo import TZPATH
 import importlib.metadata as metadata
 import requests, warnings
 
-from .validate import validate_instance_arg, validate_str_arg
 from .exceptions import MissingTzdata
 from .config import main_config
 
-def snake_case(text):
-    text = validate_str_arg("text", text, strip=True)
-    if "-" in text:
-        text = text.replace("-", "_")
-    words = re.findall(r"[A-Z]?[a-z]+|[A-Z]+|[a-z]+", text)
-    return "_".join(words).lower()
+tprefix = main_config.get("data", "table_prefix")
 
 
-def snake_dict(data: dict):
-    data = validate_instance_arg("data", data, dict)
-    snake_data = {}
-    for key, value in data.items():
-        snake_key = snake_case(key)
-        snake_data[snake_key] = value
-    return snake_data
+@contextmanager
+def timed_action(message: str):
+    start = time.time()
+    print(f"{message}...", end="", flush=True)
 
+    done = False
+    change_message_event = Event()
+    current_message = [message]
+    last_message = [message]
 
-def slugify(name):
-    name = validate_str_arg("name", name, strip=True)
-    slug = []
-    just_added_dash = False
+    def print_dots():
+        last_check = time.time()
+        while not done:
+            if change_message_event.is_set() and current_message != last_message:
+                # Move to the next line and print the new message
+                print(f"\n{current_message[0]}...", end="", flush=True)
+                last_message[0] = current_message[0]
+                change_message_event.clear()
 
-    for char in name.lower():
-        if char.isalnum():  # Check if the character is alphanumeric
-            slug.append(char)
-            just_added_dash = False
-        elif not just_added_dash:
-            slug.append("-")
-            just_added_dash = True
+            if time.time() - last_check > 1:
+                print(".", end="", flush=True)
+                last_check = time.time()
+            time.sleep(0.05)
 
-    return "".join(slug)
+    thread = Thread(target=print_dots)
+    thread.daemon = True
+    thread.start()
 
+    # Function to change the message from within the main block
+    def change_message(new_message):
+        current_message[0] = new_message
+        change_message_event.set()
 
-def create_table_structure(data, num_columns=3, suppress_empty_values=False):
-    sorted_keys = sorted(data.keys())
+    yield change_message, start
 
-    # Filter out empty values if suppress_empty_values is True
-    if suppress_empty_values:
-        sorted_keys = [key for key in sorted_keys if data[key] != ""]
+    elapsed = time.time() - start
+    done = True
+    thread.join()
+    print(f" ({elapsed:.3f}s)", flush=True)
 
-    # Calculate the number of rows needed
-    num_rows = len(sorted_keys) // num_columns
-    if len(sorted_keys) % num_columns != 0:
-        num_rows += 1
 
-    # Initialize an empty table structure
-    table = []
+def tname(table_name):
+    return f"{tprefix}{table_name}"
 
-    for i in range(num_columns):
-        column_keys = sorted_keys[i * num_rows : (i + 1) * num_rows]
-        max_key_width = max(len(key) for key in column_keys)
-        max_value_width = max(len(str(data[key])) for key in column_keys)
+def clean_name(name):
+    name = name.lower().replace('"', '').replace("finid", "fin_id")
+    return re.sub(r'[^a-zA-Z0-9_]', '_', name)
 
-        column_data = [(key, data[key]) for key in column_keys]
-        column_metadata = (max_key_width, max_value_width)
 
-        table.append((column_metadata, column_data))
+WEEKDAYS = {
+    d: i for i, d in enumerate(["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"])
+}
 
-    return table
+def weekdays_match(weekday_set, weekday):
+    for period_str in weekday_set.split(","):
+        if "-" in period_str:
+            day_range = [WEEKDAYS[x] for x in period_str.split("-")]
+            if weekday in day_range:
+                return True
 
+            start_day, end_day = day_range
+            day = start_day
+            while day != end_day:
+                if day == 6:
+                    day = 0
+                else:
+                    day += 1
+                if weekday == day:
+                    return True
 
-def render_ascii_table(table_structure, fixed_value_width=None, max_width=None):
-    num_columns = len(table_structure)
-    num_rows = max(len(column_data) for _, column_data in table_structure)
+        elif weekday == WEEKDAYS[period_str]:
+            return True
 
-    table = ""
-
-    for row_index in range(num_rows):
-        for col_index in range(num_columns):
-            metadata, rows = table_structure[col_index]
-            max_key_width, max_value_width = metadata
-            if row_index < len(rows):
-                key, value = rows[row_index]
-            else:
-                key = ""
-                value = ""
-
-            # Use the fixed_value_width if provided, else use the max_value_width
-            value_width = (
-                fixed_value_width if fixed_value_width is not None else max_value_width
-            )
-            if max_width:
-                value_width = min(max_width, value_width)
-
-            # Truncate long string values to fit the width
-            if len(value) > value_width:
-                value = value[:value_width]
-
-            # Calculate the number of dots to ensure at least one dot
-            dots = "." * (max_key_width - len(key) + 1)
-
-            if key == "" and value == "":
-                key_width = len(key) + len(dots)
-                table += " " * (key_width + value_width + 2)
-            else:
-                table += f"{key}{dots}: {value:{value_width}}"
-
-            if col_index < num_columns - 1:
-                table += "  "  # Add spacing between columns
-            else:
-                table += "\n"  # Newline at the end of the row
-
-    return table
-
-
-def pprint_data(data: Dict):
-    table = create_table_structure(data, suppress_empty_values=True)
-    ascii = render_ascii_table(table, max_width=15)
-    print(ascii)
-
-
-def get_csv_from_tuple(data: tuple) -> str:
-    csv_data = StringIO()
-    writer = csv.writer(csv_data)
-    writer.writerow(data)
-    csv_string = csv_data.getvalue()
-    csv_data.close()
-    return csv_string
-
-
-def get_tuple_from_csv(csv_string: str) -> tuple:
-    csv_data = StringIO(csv_string)
-    reader = csv.reader(csv_data)
-    row = next(reader)
-    csv_data.close()
-    return tuple(row)
-
-
-class StrEncoder(json.JSONEncoder):
-    """Helps encoding flat data"""
-
-    def default(self, obj):
-        try:
-            encoded = super().default(obj)
-        except TypeError:
-            encoded = str(obj)
-        return encoded
+    return False
 
 
 def _get_latest_tzdata_version():
diff --git a/src/tradinghours/validate.py b/src/tradinghours/validate.py
index eb6c557..75c5d4b 100644
--- a/src/tradinghours/validate.py
+++ b/src/tradinghours/validate.py
@@ -1,19 +1,15 @@
-import datetime
-from pathlib import Path
-from typing import TYPE_CHECKING, Any, Optional, Tuple, Type, TypeVar
-
-if TYPE_CHECKING:
-    from .structure import FinId, Weekday
+import datetime as dt
+from typing import Any, Optional, Tuple, TypeVar
 
 T = TypeVar("T")
 
 
-def validate_date_arg(name: str, value: Any) -> datetime.date:
+def validate_date_arg(name: str, value: Any) -> dt.date:
     if value is None:
         raise ValueError(f"Missing {name}")
     if isinstance(value, str):
-        value = datetime.date.fromisoformat(value)
-    if not isinstance(value, datetime.date):
+        value = dt.date.fromisoformat(value)
+    if type(value) is not dt.date:
         raise TypeError(f"Invalid {name} type")
     return value
 
@@ -45,57 +41,20 @@ def validate_int_arg(name: str, value: Any, default: Optional[int] = None) -> in
     return value
 
 
-def validate_finid_arg(name: str, value: Any) -> "FinId":
-    from tradinghours.structure import FinId
-
-    if value is None:
-        raise ValueError(f"Missing {name}")
-    if isinstance(value, str):
-        value = FinId.from_string(value)
-    if not isinstance(value, FinId):
-        raise TypeError(f"Invalid {name} type")
-    return value
-
-
-def validate_weekday_arg(name: str, value: Any) -> "Weekday":
-    from tradinghours.structure import Weekday
-
+def validate_finid_arg(value: Any) -> str:
     if value is None:
-        raise ValueError(f"Missing {name}")
+        raise ValueError(f"Missing FinID")
     if isinstance(value, str):
-        value = Weekday.from_string(value)
-    if isinstance(value, int):
-        value = Weekday(value)
-    if isinstance(value, datetime.date):
-        value = Weekday(value.weekday())
-    if isinstance(value, datetime.datetime):
-        value = Weekday(value.weekday())
-    if not isinstance(value, Weekday):
-        raise TypeError(f"Invalid {name} type")
-    return value
-
+        segments = value.split(".")
+        if len(segments) < 2:
+            raise ValueError("Invalid FinID string")
+    return value.upper()
 
-def validate_path_arg(name: str, value: Any) -> Path:
+def validate_mic_arg(value: Any) -> str:
     if value is None:
-        raise ValueError(f"Missing {name}")
-    if isinstance(value, str):
-        value = Path(value)
-    if not isinstance(value, Path):
-        raise TypeError(f"Invalid {name} type")
-    return value
-
-
-def validate_subclass_arg(name: str, value: Any, baseclass: Type[T]) -> Type[T]:
-    if value is None:
-        raise ValueError(f"Missing {name}")
-    if not issubclass(value, baseclass):
-        raise TypeError(f"Invalid {name} type")
-    return value
-
-
-def validate_instance_arg(name: str, value: Any, baseclass: Type[T]) -> T:
-    if value is None:
-        raise ValueError(f"Missing {name}")
-    if not isinstance(value, baseclass):
-        raise TypeError(f"Invalid {name} type")
-    return value
+        raise ValueError(f"Missing MIC")
+    if not isinstance(value, str):
+        raise TypeError(f"MIC needs to be a str")
+    if not value.isalnum() or len(value) != 4:
+        raise ValueError(f"Invalid MIC string")
+    return value.upper()
\ No newline at end of file


Test patch:
diff --git a/.github/workflows/test_release.yml b/.github/workflows/test_release.yml
index d35f6c9..0f5928b 100644
--- a/.github/workflows/test_release.yml
+++ b/.github/workflows/test_release.yml
@@ -21,7 +21,7 @@ jobs:
 
       - name: get current version
         id: current_version
-        run: |
+        run: | # checks if __init__.__version__ is incremented, and then creates a DEV_VERSION for test.pypi
           PYPI_VERSION=$(pip index versions tradinghours | cut -d'(' -f2 | cut -d')' -f1 | sed 1q)
           NEW_VERSION=$(grep '__version__' src/tradinghours/__init__.py | cut -d '"' -f 2)
           echo "comparing $NEW_VERSION with $PYPI_VERSION"
@@ -43,7 +43,7 @@ jobs:
         run: |
           export FLIT_USERNAME=__token__
           export FLIT_PASSWORD=${{ secrets.TEST_PYPI_TOKEN }}
-          export FLIT_INDEX_URL=https://test.pypi.org/legacy
+          export FLIT_INDEX_URL=https://test.pypi.org/legacy/
           flit publish
 
   test_from_test_pypi:
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 47d8629..e9e0f35 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -29,7 +29,7 @@ jobs:
           export TRADINGHOURS_TOKEN=${{ secrets.TEST_KEY }}
           tradinghours import 
 
-      - name: Run Tests with Coverage
+      - name: Run Tests with Coverage (SQLite)
         run: pytest --cov=src/ tests
 
       - name: Report Coverage
@@ -37,6 +37,86 @@ jobs:
           coverage report
           coverage xml
 
+  # Run Tests for MySQL and PostgreSQL
+  databases:
+    runs-on: ubuntu-latest
+    services:
+      mysql:
+        image: mysql:8.4
+        env:
+          MYSQL_ROOT_PASSWORD: root
+          MYSQL_DATABASE: test_db
+        ports:
+          - 3306:3306
+        options: >-
+          --health-cmd="mysqladmin ping --silent"
+          --health-interval=10s
+          --health-timeout=5s
+          --health-retries=5
+
+      postgres:
+        image: postgres:15.8
+        env:
+          POSTGRES_USER: postgres
+          POSTGRES_PASSWORD: password
+          POSTGRES_DB: test_db
+        ports:
+          - 5432:5432
+        options: >-
+          --health-cmd="pg_isready -U postgres"
+          --health-interval=10s
+          --health-timeout=5s
+          --health-retries=5
+
+    strategy:
+      matrix:
+        db: [mysql, postgres]
+
+    steps:
+      - name: Set up Python
+        uses: actions/setup-python@v4
+        with:
+          python-version: "3.11"
+
+      - name: Checkout code
+        uses: actions/checkout@v4
+
+      - name: Install dependencies
+        run: |
+          pip install pytest pytest-mock
+          pip install -e ".[${{ matrix.db }}]"
+
+      - name: Wait for DB to be ready
+        run: |
+          if [ "${{ matrix.db }}" == "mysql" ]; then
+            while ! mysqladmin ping -h "127.0.0.1" --silent; do
+              sleep 1
+            done
+          elif [ "${{ matrix.db }}" == "postgres" ]; then
+            while ! pg_isready -h "127.0.0.1" -U postgres; do
+              sleep 1
+            done
+          fi
+
+      - name: Set up test data
+        run: |
+          export TRADINGHOURS_TOKEN=${{ secrets.TEST_KEY }}
+          if [ "${{ matrix.db }}" == "mysql" ]; then
+            export TH_DB_URL=mysql+pymysql://root:root@127.0.0.1:3306/test_db
+          elif [ "${{ matrix.db }}" == "postgres" ]; then
+            export TH_DB_URL=postgresql://postgres:password@127.0.0.1:5432/test_db
+          fi
+          tradinghours import 
+
+      - name: Run Tests (MySQL or PostgreSQL)
+        run: |
+          if [ "${{ matrix.db }}" == "mysql" ]; then
+            export TH_DB_URL=mysql+pymysql://root:root@127.0.0.1:3306/test_db
+          elif [ "${{ matrix.db }}" == "postgres" ]; then
+            export TH_DB_URL=postgresql://postgres:password@127.0.0.1:5432/test_db
+          fi
+          pytest tests
+
   os-versions-api-levels:
     runs-on: ${{ matrix.os }}
     strategy:
@@ -58,7 +138,6 @@ jobs:
         run: |
           pip install pytest pytest-mock
           pip install -e .
-        
 
       - name: Set up test data
         shell: bash
diff --git a/test_and_release_readme.txt b/test_and_release_readme.txt
new file mode 100644
index 0000000..b7ab641
--- /dev/null
+++ b/test_and_release_readme.txt
@@ -0,0 +1,37 @@
+This readme covers the Github Actions workflows that manage the automated test and release process. These workflows I will be referring to are located in the .github/workflows folder.
+
+There are 3 steps involved to go from a development branch, which can have any name to the main branch, which automatically results in the release of the package.
+
+1. Pull Request against the `pre-release` branch:
+   Immediately when this PR is created, so before it is merged, the tests.yml workflow runs. This ensures that all tests pass before merging code into the `pre-release` branch.
+
+2. Pull Request against the `main` branch:
+   Immediately when this PR is created, so before it is merged, the test_release.yml workflow runs. This ensures that the code can be built and pushed to pypi successfully and that the code that is pushed still passes all tests after it is downloaded.
+
+3. Merge into the `main` branch:
+   When the merge is completed, the release.yml workflow runs.
+
+Before PR against `main`:
+ * __init__.__version__ must be incremented
+ * release_log.txt needs to be updated
+
+
+## tests.yml
+ This workflow covers three jobs:
+
+ 1. Run the tests with coverage tracking
+ 2. Run the tests with all combinations of different operating systems, and API key access levels
+ 3. Run the tests with different python versions
+
+
+## test_release.yml
+ This workflow covers two jobs:
+
+ 1. Push the code from pre-release to test.pypi.org
+ 2. Install the code from test.pypi.org and run the tests with that code.
+
+
+## release.yml
+ This workflow covers one job:
+
+ 1. It makes the release to pypi.org
diff --git a/tests/conftest.py b/tests/conftest.py
index 1cb52a8..c912f87 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,14 +1,36 @@
 import os
 import pytest
+from contextlib import contextmanager
+from tradinghours.store import db
+
+
+@contextmanager
+def _select_and_delete(result, table):
+    # Retrieve the inserted record
+    select_stmt = table.select().where(table.c.id == result.inserted_primary_key[0])
+    record = db.execute(select_stmt).fetchone()
+
+    yield record
+
+    delete_stmt = table.delete().where(table.c.id == result.inserted_primary_key[0])
+    db.execute(delete_stmt)
+
 
 @pytest.fixture
-def level():
-    """
-    values for API_KEY_LEVEL:
-        full = access to all data
-        no_currencies = full except for currencies
-        only_holidays = only holidays
-    """
-
-    return os.environ.get("API_KEY_LEVEL", "full").strip()
+def covered_market():
+    table = db.table("covered_markets")
+    result = db.execute(table.insert().values(fin_id='XX.TEST'))
+    with _select_and_delete(result, table) as record:
+        yield record
+
+
+@pytest.fixture
+def covered_currency():
+    table = db.table("covered_currencies")
+    result = db.execute(table.insert().values(currency_code='XXX'))
+
+    with _select_and_delete(result, table) as record:
+        yield record
+
+
 
diff --git a/tests/test_access.py b/tests/test_access.py
new file mode 100644
index 0000000..7ab2a22
--- /dev/null
+++ b/tests/test_access.py
@@ -0,0 +1,122 @@
+import pytest
+from tradinghours import store as st
+from tradinghours import exceptions as ex
+from tradinghours import Currency, Market
+
+
+# test that the access_level in db is set correctly
+def test_access_level():
+    tables = st.db.metadata.tables
+
+    if st.tname("schedules") not in tables:
+        should_be = st.AccessLevel.only_holidays
+    elif st.tname("currencies") not in tables:
+        should_be = st.AccessLevel.no_currencies
+    else:
+        should_be = st.AccessLevel.full
+
+    assert st.db.access_level == should_be
+
+
+def test_raises_no_access():
+    """
+    The level doesn't need to be changed in this test.
+     Github Actions will run the test suite with data loaded using
+     API keys of different access levels.
+    """
+    # These should never raise NoAccess
+    Market.list_all()
+    nyse = Market.get("US.NYSE")
+    nyse.list_holidays("2024-01-01", "2025-01-01")
+    if st.db.access_level == st.AccessLevel.full:
+        nyse.list_schedules()
+        list(nyse.generate_phases("2024-09-12", "2024-09-13"))
+        return
+
+    # these should raise NoAccess for any level != full
+    with pytest.raises(ex.NoAccess):
+        Currency.get("EUR")
+    with pytest.raises(ex.NoAccess):
+        Currency.list_all()
+
+    if st.db.access_level == st.AccessLevel.no_currencies:
+        # these should work with full and no_currencies
+        nyse.list_schedules()
+        list(nyse.generate_phases("2024-09-12", "2024-09-13"))
+
+    elif st.db.access_level == st.AccessLevel.only_holidays:
+        with pytest.raises(ex.NoAccess):
+            nyse.list_schedules()
+        with pytest.raises(ex.NoAccess):
+            list(nyse.generate_phases("2024-09-12", "2024-09-13"))
+
+
+def test_raise_not_covered(covered_market, covered_currency):
+    """
+    NotCovered should be raised when
+     Market.get(fin_id)
+        not in csvs and not in covered
+
+     (it should raise NoAccess when
+       Market.get(fin_id) not in csvs but in covered)
+
+    I need to add a test value
+
+     Currency.get(code) should raise NotCovered when it is not found
+    """
+    with pytest.raises(ex.NotCovered):
+        Market.get("XX.NOTCOVERED")
+    assert Market.is_available("XX.NOTCOVERED") is False
+    assert Market.is_covered("XX.NOTCOVERED") is False
+
+    with pytest.raises(ex.NoAccess):
+        Market.get(covered_market.fin_id)
+    assert Market.is_available(covered_market.fin_id) is False
+    assert Market.is_covered(covered_market.fin_id) is True
+
+    # should raise nothing
+    Market.get("US.NYSE")
+    assert Market.is_available("US.NYSE") is True
+    assert Market.is_covered("US.NYSE") is True
+
+    if st.db.access_level == st.AccessLevel.full:
+        with pytest.raises(ex.NotCovered):
+            Currency.get("NOTCOVERED")
+        assert Currency.is_available("NOTCOVERED") is False
+        assert Currency.is_covered("NOTCOVERED") is False
+
+        # should raise NoAccess with the covered_currency,
+        # which is not available under the current plan
+        with pytest.raises(ex.NoAccess):
+            Currency.get(covered_currency.currency_code)
+        assert Currency.is_available(covered_currency.currency_code) is False
+        assert Currency.is_covered(covered_currency.currency_code) is True
+
+        # should raise nothing
+        Currency.get("EUR")
+        assert Currency.is_available("EUR") is True
+        assert Currency.is_covered("EUR") is True
+
+    else:
+        with pytest.raises(ex.NoAccess):
+            Currency.get("NOTCOVERED")
+
+        with pytest.raises(ex.NoAccess):
+            assert Currency.is_covered(covered_currency.currency_code) is True
+        assert Currency.is_available("NOTCOVERED") is False
+
+        with pytest.raises(ex.NoAccess):
+            Currency.get("EUR")
+        assert Currency.is_available("EUR") is False
+
+        with pytest.raises(ex.NoAccess):
+            Currency.is_covered("EUR")
+
+
+
+
+
+
+
+
+
diff --git a/tests/test_generate_phases.py b/tests/test_generate_phases.py
index 96c7d56..cbdf1fa 100644
--- a/tests/test_generate_phases.py
+++ b/tests/test_generate_phases.py
@@ -1,12 +1,17 @@
 import os
 import pytest
-from tradinghours.models.market import Market
+from tradinghours.market import Market
 from tradinghours.exceptions import NoAccess
-from pprint import pprint
+from tradinghours.store import db, AccessLevel
 
-LEVEL = os.environ.get("API_KEY_LEVEL", "full").strip()
+from .utils import fromiso
 
-@pytest.mark.xfail(LEVEL == "only_holidays", reason="No access", strict=True, raises=NoAccess)
+@pytest.mark.xfail(
+    db.access_level == AccessLevel.only_holidays,
+    reason="No access",
+    strict=True,
+    raises=NoAccess
+)
 @pytest.mark.parametrize("fin_id, start, end, expected", [
     ("US.NYSE", "2023-11-15", "2023-11-15",
      [{'phase_type': 'Pre-Trading Session',
@@ -14,55 +19,61 @@
        'phase_memo': None,
        'status': 'Closed',
        'settlement': 'No',
-       'start': ('2023-11-15 04:00:00-05:00', 'UTC-05:00'),
-       'end': ('2023-11-15 09:30:00-05:00', 'UTC-05:00'),
+       'start': fromiso("2023-11-15 04:00:00", "America/New_York"),
+       'end': fromiso("2023-11-15 09:30:00", "America/New_York"),
        'has_settlement': False,
-       'is_open': False},
+       'is_open': False,
+       'timezone': 'America/New_York'},
       {'phase_type': 'Pre-Open',
        'phase_name': 'Pre-Opening Session',
        'phase_memo': None,
        'status': 'Closed',
        'settlement': 'No',
-       'start': ('2023-11-15 06:30:00-05:00', 'UTC-05:00'),
-       'end': ('2023-11-15 09:30:00-05:00', 'UTC-05:00'),
+       'start': fromiso("2023-11-15 06:30:00", "America/New_York"),
+       'end': fromiso("2023-11-15 09:30:00", "America/New_York"),
        'has_settlement': False,
-       'is_open': False},
+       'is_open': False,
+       'timezone': 'America/New_York'},
       {'phase_type': 'Call Auction',
        'phase_name': 'Core Open Auction',
        'phase_memo': None,
        'status': 'Closed',
        'settlement': 'No',
-       'start': ('2023-11-15 09:30:00-05:00', 'UTC-05:00'),
-       'end': ('2023-11-15 09:30:00-05:00', 'UTC-05:00'),
+       'start': fromiso("2023-11-15 09:30:00", "America/New_York"),
+       'end': fromiso("2023-11-15 09:30:00", "America/New_York"),
        'has_settlement': False,
-       'is_open': False},
+       'is_open': False,
+       'timezone': 'America/New_York'},
       {'phase_type': 'Primary Trading Session',
        'phase_name': 'Core Trading Session',
        'phase_memo': None,
        'status': 'Open',
        'settlement': 'Yes',
-       'start': ('2023-11-15 09:30:00-05:00', 'UTC-05:00'),
-       'end': ('2023-11-15 16:00:00-05:00', 'UTC-05:00'),
+       'start': fromiso("2023-11-15 09:30:00", "America/New_York"),
+       'end': fromiso("2023-11-15 16:00:00", "America/New_York"),
        'has_settlement': True,
-       'is_open': True},
+       'is_open': True,
+       'timezone': 'America/New_York'},
       {'phase_type': 'Pre-Close',
        'phase_name': 'Closing Imbalance Period',
        'phase_memo': None,
        'status': 'Closed',
        'settlement': 'No',
-       'start': ('2023-11-15 15:50:00-05:00', 'UTC-05:00'),
-       'end': ('2023-11-15 16:00:00-05:00', 'UTC-05:00'),
+       'start': fromiso("2023-11-15 15:50:00", "America/New_York"),
+       'end': fromiso("2023-11-15 16:00:00", "America/New_York"),
        'has_settlement': False,
-       'is_open': False},
+       'is_open': False,
+       'timezone': 'America/New_York'},
       {'phase_type': 'Post-Trading Session',
        'phase_name': 'Extended Hours',
        'phase_memo': None,
        'status': 'Closed',
        'settlement': 'No',
-       'start': ('2023-11-15 16:00:00-05:00', 'UTC-05:00'),
-       'end': ('2023-11-15 20:00:00-05:00', 'UTC-05:00'),
+       'start': fromiso("2023-11-15 16:00:00", "America/New_York"),
+       'end': fromiso("2023-11-15 20:00:00", "America/New_York"),
        'has_settlement': False,
-       'is_open': False}]
+       'is_open': False,
+       'timezone': 'America/New_York'}]
      ),
 
     ("US.NYSE", "2023-11-11", "2023-11-11",
@@ -75,28 +86,31 @@
        'phase_memo': None,
        'status': 'Closed',
        'settlement': 'No',
-       'start': ('2023-11-24 06:30:00-05:00', 'UTC-05:00'),
-       'end': ('2023-11-24 09:30:00-05:00', 'UTC-05:00'),
+       'start': fromiso("2023-11-24 06:30:00", "America/New_York"),
+       'end': fromiso("2023-11-24 09:30:00", "America/New_York"),
        'has_settlement': False,
-       'is_open': False},
+       'is_open': False,
+       'timezone': 'America/New_York'},
       {'phase_type': 'Primary Trading Session',
        'phase_name': 'Core Trading Session',
        'phase_memo': None,
        'status': 'Open',
        'settlement': 'Yes',
-       'start': ('2023-11-24 09:30:00-05:00', 'UTC-05:00'),
-       'end': ('2023-11-24 13:00:00-05:00', 'UTC-05:00'),
+       'start': fromiso("2023-11-24 09:30:00", "America/New_York"),
+       'end': fromiso("2023-11-24 13:00:00", "America/New_York"),
        'has_settlement': True,
-       'is_open': True},
+       'is_open': True,
+       'timezone': 'America/New_York'},
       {'phase_type': 'Post-Trading Session',
        'phase_name': 'Crossing Session',
        'phase_memo': None,
        'status': 'Closed',
        'settlement': 'No',
-       'start': ('2023-11-24 13:00:00-05:00', 'UTC-05:00'),
-       'end': ('2023-11-24 13:30:00-05:00', 'UTC-05:00'),
+       'start': fromiso("2023-11-24 13:00:00", "America/New_York"),
+       'end': fromiso("2023-11-24 13:30:00", "America/New_York"),
        'has_settlement': False,
-       'is_open': False}]
+       'is_open': False,
+       'timezone': 'America/New_York'}]
      ),
 
     ("US.CME.EQUITY.USINDEX1", "2023-11-13", "2023-11-13",
@@ -105,58 +119,74 @@
        'phase_memo': None,
        'status': 'Open',
        'settlement': 'Yes',
-       'start': ('2023-11-12 17:00:00-06:00', 'UTC-06:00'),
-       'end': ('2023-11-13 16:00:00-06:00', 'UTC-06:00'),
+       'start': fromiso("2023-11-12 17:00:00", "America/Chicago"),
+       'end': fromiso("2023-11-13 16:00:00", "America/Chicago"),
        'has_settlement': True,
-       'is_open': True},
+       'is_open': True,
+       'timezone': 'America/Chicago'},
+      {'phase_type': 'Settlement Window',
+       'phase_name': None,
+       'phase_memo': None,
+       'status': 'Closed',
+       'settlement': 'No',
+       'start': fromiso("2023-11-13 14:59:30", "America/Chicago"),
+       'end': fromiso("2023-11-13 15:00:00", "America/Chicago"),
+       'has_settlement': False,
+       'is_open': False,
+       'timezone': 'America/Chicago'},
       {'phase_type': 'Pre-Open',
        'phase_name': None,
        'phase_memo': None,
        'status': 'Closed',
        'settlement': 'No',
-       'start': ('2023-11-13 16:45:00-06:00', 'UTC-06:00'),
-       'end': ('2023-11-13 17:00:00-06:00', 'UTC-06:00'),
+       'start': fromiso("2023-11-13 16:45:00", "America/Chicago"),
+       'end': fromiso("2023-11-13 17:00:00", "America/Chicago"),
        'has_settlement': False,
-       'is_open': False},
+       'is_open': False,
+       'timezone': 'America/Chicago'},
       {'phase_type': 'Primary Trading Session',
        'phase_name': None,
        'phase_memo': None,
        'status': 'Open',
        'settlement': 'Yes',
-       'start': ('2023-11-13 17:00:00-06:00', 'UTC-06:00'),
-       'end': ('2023-11-14 16:00:00-06:00', 'UTC-06:00'),
+       'start': fromiso("2023-11-13 17:00:00", "America/Chicago"),
+       'end': fromiso("2023-11-14 16:00:00", "America/Chicago"),
        'has_settlement': True,
-       'is_open': True}]
+       'is_open': True,
+       'timezone': 'America/Chicago'}]
      ),
 
     ("US.CME.EQUITY.USINDEX1", "2023-11-23", "2023-11-23",
-     [{'phase_type': 'Primary Trading Session',
+     [{'phase_type': 'Primary Trading Session, No Closing Price',
        'phase_name': None,
        'phase_memo': None,
        'status': 'Open',
-       'settlement': 'Yes',
-       'start': ('2023-11-22 17:00:00-06:00', 'UTC-06:00'),
-       'end': ('2023-11-23 12:00:00-06:00', 'UTC-06:00'),
-       'has_settlement': True,
-       'is_open': True},
+       'settlement': 'No',
+       'start': fromiso("2023-11-22 17:00:00", "America/Chicago"),
+       'end': fromiso("2023-11-23 12:00:00", "America/Chicago"),
+       'has_settlement': False,
+       'is_open': True,
+       'timezone': 'America/Chicago'},
       {'phase_type': 'Pre-Open',
        'phase_name': None,
        'phase_memo': None,
        'status': 'Closed',
        'settlement': 'No',
-       'start': ('2023-11-23 12:00:00-06:00', 'UTC-06:00'),
-       'end': ('2023-11-23 17:00:00-06:00', 'UTC-06:00'),
+       'start': fromiso("2023-11-23 12:00:00", "America/Chicago"),
+       'end': fromiso("2023-11-23 17:00:00", "America/Chicago"),
        'has_settlement': False,
-       'is_open': False},
+       'is_open': False,
+       'timezone': 'America/Chicago'},
       {'phase_type': 'Primary Trading Session',
        'phase_name': None,
        'phase_memo': None,
        'status': 'Open',
        'settlement': 'Yes',
-       'start': ('2023-11-23 17:00:00-06:00', 'UTC-06:00'),
-       'end': ('2023-11-24 12:15:00-06:00', 'UTC-06:00'),
+       'start': fromiso("2023-11-23 17:00:00", "America/Chicago"),
+       'end': fromiso("2023-11-24 12:15:00", "America/Chicago"),
        'has_settlement': True,
-       'is_open': True}]
+       'is_open': True,
+       'timezone': 'America/Chicago'}]
      ),
 
     ("US.CME.EQUITY.USINDEX1", "2023-12-25", "2023-12-25",
@@ -165,19 +195,21 @@
        'phase_memo': None,
        'status': 'Closed',
        'settlement': 'No',
-       'start': ('2023-12-25 16:00:00-06:00', 'UTC-06:00'),
-       'end': ('2023-12-25 17:00:00-06:00', 'UTC-06:00'),
+       'start': fromiso("2023-12-25 16:00:00", "America/Chicago"),
+       'end': fromiso("2023-12-25 17:00:00", "America/Chicago"),
        'has_settlement': False,
-       'is_open': False},
+       'is_open': False,
+       'timezone': 'America/Chicago'},
       {'phase_type': 'Primary Trading Session',
        'phase_name': None,
        'phase_memo': None,
        'status': 'Open',
        'settlement': 'Yes',
-       'start': ('2023-12-25 17:00:00-06:00', 'UTC-06:00'),
-       'end': ('2023-12-26 16:00:00-06:00', 'UTC-06:00'),
+       'start': fromiso("2023-12-25 17:00:00", "America/Chicago"),
+       'end': fromiso("2023-12-26 16:00:00", "America/Chicago"),
        'has_settlement': True,
-       'is_open': True}]
+       'is_open': True,
+       'timezone': 'America/Chicago'}]
      ),
 
     ("CN.CIBM", "2020-01-19", "2020-01-19",
@@ -186,133 +218,197 @@
        'phase_memo': None,
        'status': 'Open',
        'settlement': 'Yes',
-       'start': ('2020-01-19 09:00:00+08:00', 'UTC+08:00'),
-       'end': ('2020-01-19 12:00:00+08:00', 'UTC+08:00'),
+       'start': fromiso("2020-01-19 09:00:00", "Asia/Shanghai"),
+       'end': fromiso("2020-01-19 12:00:00", "Asia/Shanghai"),
        'has_settlement': True,
-       'is_open': True},
+       'is_open': True,
+       'timezone': 'Asia/Shanghai'},
       {'phase_type': 'Intermission',
        'phase_name': 'Intermission',
        'phase_memo': None,
        'status': 'Closed',
        'settlement': 'No',
-       'start': ('2020-01-19 12:00:00+08:00', 'UTC+08:00'),
-       'end': ('2020-01-19 13:30:00+08:00', 'UTC+08:00'),
+       'start': fromiso("2020-01-19 12:00:00", "Asia/Shanghai"),
+       'end': fromiso("2020-01-19 13:30:00", "Asia/Shanghai"),
        'has_settlement': False,
-       'is_open': False},
+       'is_open': False,
+       'timezone': 'Asia/Shanghai'},
       {'phase_type': 'Primary Trading Session',
        'phase_name': 'Second Session',
        'phase_memo': None,
        'status': 'Open',
        'settlement': 'Yes',
-       'start': ('2020-01-19 13:30:00+08:00', 'UTC+08:00'),
-       'end': ('2020-01-19 20:00:00+08:00', 'UTC+08:00'),
+       'start': fromiso("2020-01-19 13:30:00", "Asia/Shanghai"),
+       'end': fromiso("2020-01-19 20:00:00", "Asia/Shanghai"),
        'has_settlement': True,
-       'is_open': True}]
+       'is_open': True,
+       'timezone': 'Asia/Shanghai'}]
      ),
 
-    ("US.CBOE.VIX", "2020-10-15", "2020-10-15",
+    ("US.CBOE.VIX", "2024-10-15", "2024-10-15",
      [{'phase_type': 'Primary Trading Session',
        'phase_name': 'Extended Trading Hours',
        'phase_memo': None,
        'status': 'Open',
        'settlement': 'Yes',
-       'start': ('2020-10-14 17:00:00-05:00', 'UTC-05:00'),
-       'end': ('2020-10-15 08:30:00-05:00', 'UTC-05:00'),
+       'start': fromiso("2024-10-14 17:00:00", "America/Chicago"),
+       'end': fromiso("2024-10-15 08:30:00", "America/Chicago"),
        'has_settlement': True,
-       'is_open': True},
+       'is_open': True,
+       'timezone': 'America/Chicago'},
       {'phase_type': 'Trading-at-Last',
        'phase_name': 'Trade at Settlement',
        'phase_memo': None,
        'status': 'Closed',
        'settlement': 'No',
-       'start': ('2020-10-14 17:00:00-05:00', 'UTC-05:00'),
-       'end': ('2020-10-15 15:00:00-05:00', 'UTC-05:00'),
+       'start': fromiso("2024-10-14 17:00:00", "America/Chicago"),
+       'end': fromiso("2024-10-15 15:00:00", "America/Chicago"),
        'has_settlement': False,
-       'is_open': False},
+       'is_open': False,
+       'timezone': 'America/Chicago'},
       {'phase_type': 'Primary Trading Session',
        'phase_name': 'Regular Trading Hours',
        'phase_memo': 'Market Order Acceptance Time',
        'status': 'Open',
        'settlement': 'Yes',
-       'start': ('2020-10-15 08:30:00-05:00', 'UTC-05:00'),
-       'end': ('2020-10-15 15:00:00-05:00', 'UTC-05:00'),
+       'start': fromiso("2024-10-15 08:30:00", "America/Chicago"),
+       'end': fromiso("2024-10-15 15:00:00", "America/Chicago"),
        'has_settlement': True,
-       'is_open': True},
+       'is_open': True,
+       'timezone': 'America/Chicago'},
+      {'phase_type': 'Settlement Window',
+       'phase_name': 'Daily Settlement Price',
+       'phase_memo': None,
+       'status': 'Closed',
+       'settlement': 'No',
+       'start': fromiso("2024-10-15 15:00:00", "America/Chicago"),
+       'end': fromiso("2024-10-15 15:00:00", "America/Chicago"),
+       'has_settlement': False,
+       'is_open': False,
+       'timezone': 'America/Chicago'},
       {'phase_type': 'Post-Trading Session',
        'phase_name': 'Extended Trading Hours',
        'phase_memo': 'Post Settlement',
        'status': 'Closed',
        'settlement': 'No',
-       'start': ('2020-10-15 15:00:00-05:00', 'UTC-05:00'),
-       'end': ('2020-10-15 16:00:00-05:00', 'UTC-05:00'),
+       'start': fromiso("2024-10-15 15:00:00", "America/Chicago"),
+       'end': fromiso("2024-10-15 16:00:00", "America/Chicago"),
        'has_settlement': False,
-       'is_open': False},
+       'is_open': False,
+       'timezone': 'America/Chicago'},
       {'phase_type': 'Pre-Open',
-       'phase_name': None,
+       'phase_name': 'Pre-Market Session',
        'phase_memo': None,
        'status': 'Closed',
        'settlement': 'No',
-       'start': ('2020-10-15 16:45:00-05:00', 'UTC-05:00'),
-       'end': ('2020-10-15 17:00:00-05:00', 'UTC-05:00'),
+       'start': fromiso("2024-10-15 16:45:00", "America/Chicago"),
+       'end': fromiso("2024-10-15 17:00:00", "America/Chicago"),
        'has_settlement': False,
-       'is_open': False},
+       'is_open': False,
+       'timezone': 'America/Chicago'},
       {'phase_type': 'Primary Trading Session',
        'phase_name': 'Extended Trading Hours',
        'phase_memo': None,
        'status': 'Open',
        'settlement': 'Yes',
-       'start': ('2020-10-15 17:00:00-05:00', 'UTC-05:00'),
-       'end': ('2020-10-16 08:30:00-05:00', 'UTC-05:00'),
+       'start': fromiso("2024-10-15 17:00:00", "America/Chicago"),
+       'end': fromiso("2024-10-16 08:30:00", "America/Chicago"),
        'has_settlement': True,
-       'is_open': True},
+       'is_open': True,
+       'timezone': 'America/Chicago'},
       {'phase_type': 'Trading-at-Last',
        'phase_name': 'Trade at Settlement',
        'phase_memo': None,
        'status': 'Closed',
        'settlement': 'No',
-       'start': ('2020-10-15 17:00:00-05:00', 'UTC-05:00'),
-       'end': ('2020-10-16 15:00:00-05:00', 'UTC-05:00'),
+       'start': fromiso("2024-10-15 17:00:00", "America/Chicago"),
+       'end': fromiso("2024-10-16 15:00:00", "America/Chicago"),
        'has_settlement': False,
-       'is_open': False}]
+       'is_open': False,
+       'timezone': 'America/Chicago'}]
      ),
 
-    ("US.CBOE.VIX", "2020-10-16", "2020-10-16",
+    ("US.CBOE.VIX", "2024-10-16", "2024-10-16",
      [{'phase_type': 'Primary Trading Session',
        'phase_name': 'Extended Trading Hours',
        'phase_memo': None,
        'status': 'Open',
        'settlement': 'Yes',
-       'start': ('2020-10-15 17:00:00-05:00', 'UTC-05:00'),
-       'end': ('2020-10-16 08:30:00-05:00', 'UTC-05:00'),
+       'start': fromiso("2024-10-15 17:00:00", "America/Chicago"),
+       'end': fromiso("2024-10-16 08:30:00", "America/Chicago"),
        'has_settlement': True,
-       'is_open': True},
+       'is_open': True,
+       'timezone': 'America/Chicago'},
       {'phase_type': 'Trading-at-Last',
        'phase_name': 'Trade at Settlement',
        'phase_memo': None,
        'status': 'Closed',
        'settlement': 'No',
-       'start': ('2020-10-15 17:00:00-05:00', 'UTC-05:00'),
-       'end': ('2020-10-16 15:00:00-05:00', 'UTC-05:00'),
+       'start': fromiso("2024-10-15 17:00:00", "America/Chicago"),
+       'end': fromiso("2024-10-16 15:00:00", "America/Chicago"),
        'has_settlement': False,
-       'is_open': False},
+       'is_open': False,
+       'timezone': 'America/Chicago'},
       {'phase_type': 'Primary Trading Session',
        'phase_name': 'Regular Trading Hours',
        'phase_memo': 'Market Order Acceptance Time',
        'status': 'Open',
        'settlement': 'Yes',
-       'start': ('2020-10-16 08:30:00-05:00', 'UTC-05:00'),
-       'end': ('2020-10-16 15:00:00-05:00', 'UTC-05:00'),
+       'start': fromiso("2024-10-16 08:30:00", "America/Chicago"),
+       'end': fromiso("2024-10-16 15:00:00", "America/Chicago"),
        'has_settlement': True,
-       'is_open': True},
+       'is_open': True,
+       'timezone': 'America/Chicago'},
+      {'phase_type': 'Settlement Window',
+       'phase_name': 'Daily Settlement Price',
+       'phase_memo': None,
+       'status': 'Closed',
+       'settlement': 'No',
+       'start': fromiso("2024-10-16 15:00:00", "America/Chicago"),
+       'end': fromiso("2024-10-16 15:00:00", "America/Chicago"),
+       'has_settlement': False,
+       'is_open': False,
+       'timezone': 'America/Chicago'},
       {'phase_type': 'Post-Trading Session',
        'phase_name': 'Extended Trading Hours',
        'phase_memo': 'Post Settlement',
        'status': 'Closed',
        'settlement': 'No',
-       'start': ('2020-10-16 15:00:00-05:00', 'UTC-05:00'),
-       'end': ('2020-10-16 16:00:00-05:00', 'UTC-05:00'),
-       'has_settlement': False,
-       'is_open': False}]
+       'start': fromiso("2024-10-16 15:00:00", "America/Chicago"),
+       'end': fromiso("2024-10-16 16:00:00", "America/Chicago"),
+       'has_settlement': False,
+       'is_open': False,
+       'timezone': 'America/Chicago'},
+    {'phase_type': 'Pre-Open',
+     'phase_name': 'Pre-Market Session',
+     'phase_memo': None,
+     'status': 'Closed',
+     'settlement': 'No',
+     'start': fromiso('2024-10-16 16:45:00', 'America/Chicago'),
+     'end': fromiso('2024-10-16 17:00:00', 'America/Chicago'),
+     'has_settlement': False,
+     'is_open': False,
+     'timezone': 'America/Chicago'},
+    {'phase_type': 'Primary Trading Session',
+     'phase_name': 'Extended Trading Hours',
+     'phase_memo': None,
+     'status': 'Open',
+     'settlement': 'Yes',
+     'start': fromiso('2024-10-16 17:00:00', 'America/Chicago'),
+     'end': fromiso('2024-10-17 08:30:00', 'America/Chicago'),
+     'has_settlement': True,
+     'is_open': True,
+     'timezone': 'America/Chicago'},
+    {'phase_type': 'Trading-at-Last',
+     'phase_name': 'Trade at Settlement',
+     'phase_memo': None,
+     'status': 'Closed',
+     'settlement': 'No',
+     'start': fromiso('2024-10-16 17:00:00', 'America/Chicago'),
+     'end': fromiso('2024-10-17 15:00:00', 'America/Chicago'),
+     'has_settlement': False,
+     'is_open': False,
+     'timezone': 'America/Chicago'}]
      ),
 
     ("US.CME.AGRI.DAIRY1", "2022-01-16", "2022-01-16",
@@ -321,10 +417,11 @@
        'phase_memo': None,
        'status': 'Closed',
        'settlement': 'No',
-       'start': ('2022-01-16 16:00:00-06:00', 'UTC-06:00'),
-       'end': ('2022-01-17 17:00:00-06:00', 'UTC-06:00'),
+       'start': fromiso("2022-01-16 16:00:00", "America/Chicago"),
+       'end': fromiso("2022-01-17 17:00:00", "America/Chicago"),
        'has_settlement': False,
-       'is_open': False}]
+       'is_open': False,
+       'timezone': 'America/Chicago'}]
      ),
 
     ("US.CME.AGRI.DAIRY1", "2022-01-17", "2022-01-17",
@@ -333,19 +430,21 @@
        'phase_memo': None,
        'status': 'Closed',
        'settlement': 'No',
-       'start': ('2022-01-16 16:00:00-06:00', 'UTC-06:00'),
-       'end': ('2022-01-17 17:00:00-06:00', 'UTC-06:00'),
+       'start': fromiso("2022-01-16 16:00:00", "America/Chicago"),
+       'end': fromiso("2022-01-17 17:00:00", "America/Chicago"),
        'has_settlement': False,
-       'is_open': False},
+       'is_open': False,
+       'timezone': 'America/Chicago'},
       {'phase_type': 'Primary Trading Session',
        'phase_name': None,
        'phase_memo': None,
        'status': 'Open',
        'settlement': 'Yes',
-       'start': ('2022-01-17 17:00:00-06:00', 'UTC-06:00'),
-       'end': ('2022-01-18 16:00:00-06:00', 'UTC-06:00'),
+       'start': fromiso("2022-01-17 17:00:00", "America/Chicago"),
+       'end': fromiso("2022-01-18 16:00:00", "America/Chicago"),
        'has_settlement': True,
-       'is_open': True}]
+       'is_open': True,
+       'timezone': 'America/Chicago'}]
      ),
 
     ("US.CME.AGRI.DAIRY1", "2022-01-09", "2022-01-09",
@@ -354,19 +453,31 @@
        'phase_memo': None,
        'status': 'Closed',
        'settlement': 'No',
-       'start': ('2022-01-09 16:00:00-06:00', 'UTC-06:00'),
-       'end': ('2022-01-09 17:00:00-06:00', 'UTC-06:00'),
+       'start': fromiso("2022-01-09 16:00:00", "America/Chicago"),
+       'end': fromiso("2022-01-09 17:00:00", "America/Chicago"),
        'has_settlement': False,
-       'is_open': False},
+       'is_open': False,
+       'timezone': 'America/Chicago'},
       {'phase_type': 'Primary Trading Session',
        'phase_name': None,
        'phase_memo': None,
        'status': 'Open',
        'settlement': 'Yes',
-       'start': ('2022-01-09 17:00:00-06:00', 'UTC-06:00'),
-       'end': ('2022-01-10 16:00:00-06:00', 'UTC-06:00'),
+       'start': fromiso("2022-01-09 17:00:00", "America/Chicago"),
+       'end': fromiso("2022-01-10 16:00:00", "America/Chicago"),
        'has_settlement': True,
-       'is_open': True}]
+       'is_open': True,
+       'timezone': 'America/Chicago'},
+      {'phase_type': 'Other',
+       'phase_name': 'CME Clearport',
+       'phase_memo': None,
+       'status': 'Closed',
+       'settlement': 'No',
+       'start': fromiso("2022-01-09 17:00:00", "America/Chicago"),
+       'end': fromiso("2022-01-10 17:45:00", "America/Chicago"),
+       'has_settlement': False,
+       'is_open': False,
+       'timezone': 'America/Chicago'}]
      ),
 
     ("US.CME.AGRI.DAIRY1", "2022-01-10", "2022-01-10",
@@ -375,28 +486,71 @@
        'phase_memo': None,
        'status': 'Open',
        'settlement': 'Yes',
-       'start': ('2022-01-09 17:00:00-06:00', 'UTC-06:00'),
-       'end': ('2022-01-10 16:00:00-06:00', 'UTC-06:00'),
+       'start': fromiso("2022-01-09 17:00:00", "America/Chicago"),
+       'end': fromiso("2022-01-10 16:00:00", "America/Chicago"),
        'has_settlement': True,
-       'is_open': True},
+       'is_open': True,
+       'timezone': 'America/Chicago'},
+      {'phase_type': 'Other',
+       'phase_name': 'CME Clearport',
+       'phase_memo': None,
+       'status': 'Closed',
+       'settlement': 'No',
+       'start': fromiso("2022-01-09 17:00:00", "America/Chicago"),
+       'end': fromiso("2022-01-10 17:45:00", "America/Chicago"),
+       'has_settlement': False,
+       'is_open': False,
+       'timezone': 'America/Chicago'},
+      {'phase_type': 'Settlement Window',
+       'phase_name': None,
+       'phase_memo': None,
+       'status': 'Closed',
+       'settlement': 'No',
+       'start': fromiso("2022-01-10 13:09:30", "America/Chicago"),
+       'end': fromiso("2022-01-10 13:10:00", "America/Chicago"),
+       'has_settlement': False,
+       'is_open': False,
+       'timezone': 'America/Chicago'},
       {'phase_type': 'Pre-Open',
        'phase_name': None,
        'phase_memo': None,
        'status': 'Closed',
        'settlement': 'No',
-       'start': ('2022-01-10 16:45:00-06:00', 'UTC-06:00'),
-       'end': ('2022-01-10 17:00:00-06:00', 'UTC-06:00'),
+       'start': fromiso("2022-01-10 16:45:00", "America/Chicago"),
+       'end': fromiso("2022-01-10 17:00:00", "America/Chicago"),
        'has_settlement': False,
-       'is_open': False},
+       'is_open': False,
+       'timezone': 'America/Chicago'},
       {'phase_type': 'Primary Trading Session',
        'phase_name': None,
        'phase_memo': None,
        'status': 'Open',
        'settlement': 'Yes',
-       'start': ('2022-01-10 17:00:00-06:00', 'UTC-06:00'),
-       'end': ('2022-01-11 16:00:00-06:00', 'UTC-06:00'),
+       'start': fromiso("2022-01-10 17:00:00", "America/Chicago"),
+       'end': fromiso("2022-01-11 16:00:00", "America/Chicago"),
        'has_settlement': True,
-       'is_open': True}]
+       'is_open': True,
+       'timezone': 'America/Chicago'},
+      {'phase_type': 'Other',
+       'phase_name': 'CME Clearport',
+       'phase_memo': None,
+       'status': 'Closed',
+       'settlement': 'No',
+       'start': fromiso("2022-01-10 17:00:00", "America/Chicago"),
+       'end': fromiso("2022-01-11 17:45:00", "America/Chicago"),
+       'has_settlement': False,
+       'is_open': False,
+       'timezone': 'America/Chicago'},
+      {'phase_type': 'Other',
+       'phase_name': 'CME Clearport with no reporting',
+       'phase_memo': None,
+       'status': 'Closed',
+       'settlement': 'No',
+       'start': fromiso("2022-01-10 17:45:00", "America/Chicago"),
+       'end': fromiso("2022-01-10 18:00:00", "America/Chicago"),
+       'has_settlement': False,
+       'is_open': False,
+       'timezone': 'America/Chicago'}]
      ),
 
     ("US.BTEC.ACTIVES.US", "2023-03-09", "2023-03-09",
@@ -405,19 +559,21 @@
        'phase_memo': 'Winter Season',
        'status': 'Open',
        'settlement': 'Yes',
-       'start': ('2023-03-08 18:30:00-05:00', 'UTC-05:00'),
-       'end': ('2023-03-09 17:30:00-05:00', 'UTC-05:00'),
+       'start': fromiso("2023-03-08 18:30:00", "America/New_York"),
+       'end': fromiso("2023-03-09 17:30:00", "America/New_York"),
        'has_settlement': True,
-       'is_open': True},
+       'is_open': True,
+       'timezone': 'America/New_York'},
       {'phase_type': 'Primary Trading Session',
        'phase_name': None,
        'phase_memo': 'Winter Season',
        'status': 'Open',
        'settlement': 'Yes',
-       'start': ('2023-03-09 18:30:00-05:00', 'UTC-05:00'),
-       'end': ('2023-03-10 17:30:00-05:00', 'UTC-05:00'),
+       'start': fromiso("2023-03-09 18:30:00", "America/New_York"),
+       'end': fromiso("2023-03-10 17:30:00", "America/New_York"),
        'has_settlement': True,
-       'is_open': True}]
+       'is_open': True,
+       'timezone': 'America/New_York'}]
      ),
 
     ("US.BTEC.ACTIVES.US", "2023-11-12", "2023-11-12",
@@ -426,10 +582,11 @@
        'phase_memo': 'Winter Season',
        'status': 'Open',
        'settlement': 'Yes',
-       'start': ('2023-11-12 18:30:00-05:00', 'UTC-05:00'),
-       'end': ('2023-11-13 17:30:00-05:00', 'UTC-05:00'),
+       'start': fromiso("2023-11-12 18:30:00", "America/New_York"),
+       'end': fromiso("2023-11-13 17:30:00", "America/New_York"),
        'has_settlement': True,
-       'is_open': True}]
+       'is_open': True,
+       'timezone': 'America/New_York'}]
      ),
 
     ("CN.SGE", "2023-01-02", "2023-01-02",
@@ -442,73 +599,81 @@
        'phase_memo': None,
        'status': 'Open',
        'settlement': 'Yes',
-       'start': ('2023-01-03 09:00:00+08:00', 'UTC+08:00'),
-       'end': ('2023-01-03 15:30:00+08:00', 'UTC+08:00'),
+       'start': fromiso("2023-01-03 09:00:00", "Asia/Shanghai"),
+       'end': fromiso("2023-01-03 15:30:00", "Asia/Shanghai"),
        'has_settlement': True,
-       'is_open': True},
+       'is_open': True,
+       'timezone': 'Asia/Shanghai'},
       {'phase_type': 'Other',
        'phase_name': 'Delivery Tender Submission',
        'phase_memo': None,
        'status': 'Closed',
        'settlement': 'No',
-       'start': ('2023-01-03 15:00:00+08:00', 'UTC+08:00'),
-       'end': ('2023-01-03 15:30:00+08:00', 'UTC+08:00'),
+       'start': fromiso("2023-01-03 15:00:00", "Asia/Shanghai"),
+       'end': fromiso("2023-01-03 15:30:00", "Asia/Shanghai"),
        'has_settlement': False,
-       'is_open': False},
+       'is_open': False,
+       'timezone': 'Asia/Shanghai'},
       {'phase_type': 'Pre-Close',
        'phase_name': 'Pre Market Close',
        'phase_memo': None,
        'status': 'Closed',
        'settlement': 'No',
-       'start': ('2023-01-03 15:30:00+08:00', 'UTC+08:00'),
-       'end': ('2023-01-03 15:45:00+08:00', 'UTC+08:00'),
+       'start': fromiso("2023-01-03 15:30:00", "Asia/Shanghai"),
+       'end': fromiso("2023-01-03 15:45:00", "Asia/Shanghai"),
        'has_settlement': False,
-       'is_open': False},
+       'is_open': False,
+       'timezone': 'Asia/Shanghai'},
       {'phase_type': 'Other',
        'phase_name': 'Equalizer Tender Submission',
        'phase_memo': None,
        'status': 'Closed',
        'settlement': 'No',
-       'start': ('2023-01-03 15:31:00+08:00', 'UTC+08:00'),
-       'end': ('2023-01-03 15:40:00+08:00', 'UTC+08:00'),
+       'start': fromiso("2023-01-03 15:31:00", "Asia/Shanghai"),
+       'end': fromiso("2023-01-03 15:40:00", "Asia/Shanghai"),
        'has_settlement': False,
-       'is_open': False},
+       'is_open': False,
+       'timezone': 'Asia/Shanghai'},
       {'phase_type': 'Other',
        'phase_name': 'Deliver Tender Matching',
        'phase_memo': None,
        'status': 'Closed',
        'settlement': 'No',
-       'start': ('2023-01-03 15:40:00+08:00', 'UTC+08:00'),
-       'end': ('2023-01-03 15:40:00+08:00', 'UTC+08:00'),
+       'start': fromiso("2023-01-03 15:40:00", "Asia/Shanghai"),
+       'end': fromiso("2023-01-03 15:40:00", "Asia/Shanghai"),
        'has_settlement': False,
-       'is_open': False},
+       'is_open': False,
+       'timezone': 'Asia/Shanghai'},
       {'phase_type': 'Pre-Open',
        'phase_name': 'Market Opening',
        'phase_memo': None,
        'status': 'Closed',
        'settlement': 'No',
-       'start': ('2023-01-03 19:45:00+08:00', 'UTC+08:00'),
-       'end': ('2023-01-03 20:00:00+08:00', 'UTC+08:00'),
+       'start': fromiso("2023-01-03 19:45:00", "Asia/Shanghai"),
+       'end': fromiso("2023-01-03 20:00:00", "Asia/Shanghai"),
        'has_settlement': False,
-       'is_open': False},
+       'is_open': False,
+       'timezone': 'Asia/Shanghai'},
       {'phase_type': 'Other',
        'phase_name': 'Order Matching',
        'phase_memo': None,
        'status': 'Closed',
        'settlement': 'No',
-       'start': ('2023-01-03 19:50:00+08:00', 'UTC+08:00'),
-       'end': ('2023-01-03 19:59:00+08:00', 'UTC+08:00'),
+       'start': fromiso("2023-01-03 19:50:00", "Asia/Shanghai"),
+       'end': fromiso("2023-01-03 19:59:00", "Asia/Shanghai"),
        'has_settlement': False,
-       'is_open': False},
+       'is_open': False,
+       'timezone': 'Asia/Shanghai'},
       {'phase_type': 'Primary Trading Session',
        'phase_name': 'Night Trading Session',
        'phase_memo': None,
        'status': 'Open',
        'settlement': 'Yes',
-       'start': ('2023-01-03 20:00:00+08:00', 'UTC+08:00'),
-       'end': ('2023-01-04 02:30:00+08:00', 'UTC+08:00'),
+       'start': fromiso("2023-01-03 20:00:00", "Asia/Shanghai"),
+       'end': fromiso("2023-01-04 02:30:00", "Asia/Shanghai"),
        'has_settlement': True,
-       'is_open': True}]
+       'is_open': True,
+       'timezone': 'Asia/Shanghai'}]
      ),
 
     ("CN.SGE", "2024-09-27", "2024-10-04",
@@ -517,222 +682,248 @@
        'phase_memo': None,
        'status': 'Open',
        'settlement': 'Yes',
-       'start': ('2024-09-26 20:00:00+08:00', 'UTC+08:00'),
-       'end': ('2024-09-27 02:30:00+08:00', 'UTC+08:00'),
+       'start': fromiso("2024-09-26 20:00:00", "Asia/Shanghai"),
+       'end': fromiso("2024-09-27 02:30:00", "Asia/Shanghai"),
        'has_settlement': True,
-       'is_open': True},
+       'is_open': True,
+       'timezone': 'Asia/Shanghai'},
       {'phase_type': 'Primary Trading Session',
        'phase_name': 'Day Trading Session',
        'phase_memo': None,
        'status': 'Open',
        'settlement': 'Yes',
-       'start': ('2024-09-27 09:00:00+08:00', 'UTC+08:00'),
-       'end': ('2024-09-27 15:30:00+08:00', 'UTC+08:00'),
+       'start': fromiso("2024-09-27 09:00:00", "Asia/Shanghai"),
+       'end': fromiso("2024-09-27 15:30:00", "Asia/Shanghai"),
        'has_settlement': True,
-       'is_open': True},
+       'is_open': True,
+       'timezone': 'Asia/Shanghai'},
       {'phase_type': 'Other',
        'phase_name': 'Delivery Tender Submission',
        'phase_memo': None,
        'status': 'Closed',
        'settlement': 'No',
-       'start': ('2024-09-27 15:00:00+08:00', 'UTC+08:00'),
-       'end': ('2024-09-27 15:30:00+08:00', 'UTC+08:00'),
+       'start': fromiso("2024-09-27 15:00:00", "Asia/Shanghai"),
+       'end': fromiso("2024-09-27 15:30:00", "Asia/Shanghai"),
        'has_settlement': False,
-       'is_open': False},
+       'is_open': False,
+       'timezone': 'Asia/Shanghai'},
       {'phase_type': 'Pre-Close',
        'phase_name': 'Pre Market Close',
        'phase_memo': None,
        'status': 'Closed',
        'settlement': 'No',
-       'start': ('2024-09-27 15:30:00+08:00', 'UTC+08:00'),
-       'end': ('2024-09-27 15:45:00+08:00', 'UTC+08:00'),
+       'start': fromiso("2024-09-27 15:30:00", "Asia/Shanghai"),
+       'end': fromiso("2024-09-27 15:45:00", "Asia/Shanghai"),
        'has_settlement': False,
-       'is_open': False},
+       'is_open': False,
+       'timezone': 'Asia/Shanghai'},
       {'phase_type': 'Other',
        'phase_name': 'Equalizer Tender Submission',
        'phase_memo': None,
        'status': 'Closed',
        'settlement': 'No',
-       'start': ('2024-09-27 15:31:00+08:00', 'UTC+08:00'),
-       'end': ('2024-09-27 15:40:00+08:00', 'UTC+08:00'),
+       'start': fromiso("2024-09-27 15:31:00", "Asia/Shanghai"),
+       'end': fromiso("2024-09-27 15:40:00", "Asia/Shanghai"),
        'has_settlement': False,
-       'is_open': False},
+       'is_open': False,
+       'timezone': 'Asia/Shanghai'},
       {'phase_type': 'Other',
        'phase_name': 'Deliver Tender Matching',
        'phase_memo': None,
        'status': 'Closed',
        'settlement': 'No',
-       'start': ('2024-09-27 15:40:00+08:00', 'UTC+08:00'),
-       'end': ('2024-09-27 15:40:00+08:00', 'UTC+08:00'),
+       'start': fromiso("2024-09-27 15:40:00", "Asia/Shanghai"),
+       'end': fromiso("2024-09-27 15:40:00", "Asia/Shanghai"),
        'has_settlement': False,
-       'is_open': False},
+       'is_open': False,
+       'timezone': 'Asia/Shanghai'},
       {'phase_type': 'Pre-Open',
        'phase_name': 'Market Opening',
        'phase_memo': None,
        'status': 'Closed',
        'settlement': 'No',
-       'start': ('2024-09-27 19:45:00+08:00', 'UTC+08:00'),
-       'end': ('2024-09-27 20:00:00+08:00', 'UTC+08:00'),
+       'start': fromiso("2024-09-27 19:45:00", "Asia/Shanghai"),
+       'end': fromiso("2024-09-27 20:00:00", "Asia/Shanghai"),
        'has_settlement': False,
-       'is_open': False},
+       'is_open': False,
+       'timezone': 'Asia/Shanghai'},
       {'phase_type': 'Other',
        'phase_name': 'Order Matching',
        'phase_memo': None,
        'status': 'Closed',
        'settlement': 'No',
-       'start': ('2024-09-27 19:50:00+08:00', 'UTC+08:00'),
-       'end': ('2024-09-27 19:59:00+08:00', 'UTC+08:00'),
+       'start': fromiso("2024-09-27 19:50:00", "Asia/Shanghai"),
+       'end': fromiso("2024-09-27 19:59:00", "Asia/Shanghai"),
        'has_settlement': False,
-       'is_open': False},
+       'is_open': False,
+       'timezone': 'Asia/Shanghai'},
       {'phase_type': 'Primary Trading Session',
        'phase_name': 'Night Trading Session',
        'phase_memo': None,
        'status': 'Open',
        'settlement': 'Yes',
-       'start': ('2024-09-27 20:00:00+08:00', 'UTC+08:00'),
-       'end': ('2024-09-28 02:30:00+08:00', 'UTC+08:00'),
+       'start': fromiso("2024-09-27 20:00:00", "Asia/Shanghai"),
+       'end': fromiso("2024-09-28 02:30:00", "Asia/Shanghai"),
        'has_settlement': True,
-       'is_open': True},
+       'is_open': True,
+       'timezone': 'Asia/Shanghai'},
       {'phase_type': 'Primary Trading Session',
        'phase_name': 'Day Trading Session',
        'phase_memo': None,
        'status': 'Open',
        'settlement': 'Yes',
-       'start': ('2024-09-30 09:00:00+08:00', 'UTC+08:00'),
-       'end': ('2024-09-30 15:30:00+08:00', 'UTC+08:00'),
+       'start': fromiso("2024-09-30 09:00:00", "Asia/Shanghai"),
+       'end': fromiso("2024-09-30 15:30:00", "Asia/Shanghai"),
        'has_settlement': True,
-       'is_open': True},
+       'is_open': True,
+       'timezone': 'Asia/Shanghai'},
       {'phase_type': 'Other',
        'phase_name': 'Delivery Tender Submission',
        'phase_memo': None,
        'status': 'Closed',
        'settlement': 'No',
-       'start': ('2024-09-30 15:00:00+08:00', 'UTC+08:00'),
-       'end': ('2024-09-30 15:30:00+08:00', 'UTC+08:00'),
+       'start': fromiso("2024-09-30 15:00:00", "Asia/Shanghai"),
+       'end': fromiso("2024-09-30 15:30:00", "Asia/Shanghai"),
        'has_settlement': False,
-       'is_open': False},
+       'is_open': False,
+       'timezone': 'Asia/Shanghai'},
       {'phase_type': 'Pre-Close',
        'phase_name': 'Pre Market Close',
        'phase_memo': None,
        'status': 'Closed',
        'settlement': 'No',
-       'start': ('2024-09-30 15:30:00+08:00', 'UTC+08:00'),
-       'end': ('2024-09-30 15:45:00+08:00', 'UTC+08:00'),
+       'start': fromiso("2024-09-30 15:30:00", "Asia/Shanghai"),
+       'end': fromiso("2024-09-30 15:45:00", "Asia/Shanghai"),
        'has_settlement': False,
-       'is_open': False},
+       'is_open': False,
+       'timezone': 'Asia/Shanghai'},
       {'phase_type': 'Other',
        'phase_name': 'Equalizer Tender Submission',
        'phase_memo': None,
        'status': 'Closed',
        'settlement': 'No',
-       'start': ('2024-09-30 15:31:00+08:00', 'UTC+08:00'),
-       'end': ('2024-09-30 15:40:00+08:00', 'UTC+08:00'),
+       'start': fromiso("2024-09-30 15:31:00", "Asia/Shanghai"),
+       'end': fromiso("2024-09-30 15:40:00", "Asia/Shanghai"),
        'has_settlement': False,
-       'is_open': False},
+       'is_open': False,
+       'timezone': 'Asia/Shanghai'},
       {'phase_type': 'Other',
        'phase_name': 'Deliver Tender Matching',
        'phase_memo': None,
        'status': 'Closed',
        'settlement': 'No',
-       'start': ('2024-09-30 15:40:00+08:00', 'UTC+08:00'),
-       'end': ('2024-09-30 15:40:00+08:00', 'UTC+08:00'),
+       'start': fromiso("2024-09-30 15:40:00", "Asia/Shanghai"),
+       'end': fromiso("2024-09-30 15:40:00", "Asia/Shanghai"),
        'has_settlement': False,
-       'is_open': False}]
+       'is_open': False,
+       'timezone': 'Asia/Shanghai'}]
      ),
 
-    ("AR.BCBA", "2017-04-10", "2017-04-19",
+    ("AR.BCBA", "2017-05-17", "2017-05-19",
      [{'phase_type': 'Pre-Trading Session',
        'phase_name': 'Opening Session',
        'phase_memo': None,
        'status': 'Closed',
        'settlement': 'No',
-       'start': ('2017-04-17 10:30:00-03:00', 'UTC-03:00'),
-       'end': ('2017-04-17 11:00:00-03:00', 'UTC-03:00'),
+       'start': fromiso("2017-05-17 10:30:00", "America/Argentina/Buenos_Aires"),
+       'end': fromiso("2017-05-17 11:00:00", "America/Argentina/Buenos_Aires"),
        'has_settlement': False,
-       'is_open': False},
+       'is_open': False,
+       'timezone': 'America/Argentina/Buenos_Aires'},
       {'phase_type': 'Primary Trading Session',
        'phase_name': 'Trading Session',
        'phase_memo': None,
        'status': 'Open',
        'settlement': 'Yes',
-       'start': ('2017-04-17 11:00:00-03:00', 'UTC-03:00'),
-       'end': ('2017-04-17 17:00:00-03:00', 'UTC-03:00'),
+       'start': fromiso("2017-05-17 11:00:00", "America/Argentina/Buenos_Aires"),
+       'end': fromiso("2017-05-17 17:00:00", "America/Argentina/Buenos_Aires"),
        'has_settlement': True,
-       'is_open': True},
+       'is_open': True,
+       'timezone': 'America/Argentina/Buenos_Aires'},
       {'phase_type': 'Post-Trading Session',
        'phase_name': 'Extended Hours',
        'phase_memo': None,
        'status': 'Closed',
        'settlement': 'No',
-       'start': ('2017-04-17 17:05:00-03:00', 'UTC-03:00'),
-       'end': ('2017-04-17 17:15:00-03:00', 'UTC-03:00'),
+       'start': fromiso("2017-05-17 17:05:00", "America/Argentina/Buenos_Aires"),
+       'end': fromiso("2017-05-17 17:15:00", "America/Argentina/Buenos_Aires"),
        'has_settlement': False,
-       'is_open': False},
+       'is_open': False,
+       'timezone': 'America/Argentina/Buenos_Aires'},
       {'phase_type': 'Pre-Trading Session',
        'phase_name': 'Opening Session',
        'phase_memo': None,
        'status': 'Closed',
        'settlement': 'No',
-       'start': ('2017-04-18 10:30:00-03:00', 'UTC-03:00'),
-       'end': ('2017-04-18 11:00:00-03:00', 'UTC-03:00'),
+       'start': fromiso("2017-05-18 10:30:00", "America/Argentina/Buenos_Aires"),
+       'end': fromiso("2017-05-18 11:00:00", "America/Argentina/Buenos_Aires"),
        'has_settlement': False,
-       'is_open': False},
+       'is_open': False,
+       'timezone': 'America/Argentina/Buenos_Aires'},
       {'phase_type': 'Primary Trading Session',
        'phase_name': 'Trading Session',
        'phase_memo': None,
        'status': 'Open',
        'settlement': 'Yes',
-       'start': ('2017-04-18 11:00:00-03:00', 'UTC-03:00'),
-       'end': ('2017-04-18 17:00:00-03:00', 'UTC-03:00'),
+       'start': fromiso("2017-05-18 11:00:00", "America/Argentina/Buenos_Aires"),
+       'end': fromiso("2017-05-18 17:00:00", "America/Argentina/Buenos_Aires"),
        'has_settlement': True,
-       'is_open': True},
+       'is_open': True,
+       'timezone': 'America/Argentina/Buenos_Aires'},
       {'phase_type': 'Post-Trading Session',
        'phase_name': 'Extended Hours',
        'phase_memo': None,
        'status': 'Closed',
        'settlement': 'No',
-       'start': ('2017-04-18 17:05:00-03:00', 'UTC-03:00'),
-       'end': ('2017-04-18 17:15:00-03:00', 'UTC-03:00'),
+       'start': fromiso("2017-05-18 17:05:00", "America/Argentina/Buenos_Aires"),
+       'end': fromiso("2017-05-18 17:15:00", "America/Argentina/Buenos_Aires"),
        'has_settlement': False,
-       'is_open': False},
+       'is_open': False,
+       'timezone': 'America/Argentina/Buenos_Aires'},
       {'phase_type': 'Pre-Trading Session',
        'phase_name': 'Opening Session',
        'phase_memo': None,
        'status': 'Closed',
        'settlement': 'No',
-       'start': ('2017-04-19 10:30:00-03:00', 'UTC-03:00'),
-       'end': ('2017-04-19 11:00:00-03:00', 'UTC-03:00'),
+       'start': fromiso("2017-05-19 10:30:00", "America/Argentina/Buenos_Aires"),
+       'end': fromiso("2017-05-19 11:00:00", "America/Argentina/Buenos_Aires"),
        'has_settlement': False,
-       'is_open': False},
+       'is_open': False,
+       'timezone': 'America/Argentina/Buenos_Aires'},
       {'phase_type': 'Primary Trading Session',
        'phase_name': 'Trading Session',
        'phase_memo': None,
        'status': 'Open',
        'settlement': 'Yes',
-       'start': ('2017-04-19 11:00:00-03:00', 'UTC-03:00'),
-       'end': ('2017-04-19 17:00:00-03:00', 'UTC-03:00'),
+       'start': fromiso("2017-05-19 11:00:00", "America/Argentina/Buenos_Aires"),
+       'end': fromiso("2017-05-19 17:00:00", "America/Argentina/Buenos_Aires"),
        'has_settlement': True,
-       'is_open': True},
+       'is_open': True,
+       'timezone': 'America/Argentina/Buenos_Aires'},
       {'phase_type': 'Post-Trading Session',
        'phase_name': 'Extended Hours',
        'phase_memo': None,
        'status': 'Closed',
        'settlement': 'No',
-       'start': ('2017-04-19 17:05:00-03:00', 'UTC-03:00'),
-       'end': ('2017-04-19 17:15:00-03:00', 'UTC-03:00'),
+       'start': fromiso("2017-05-19 17:05:00", "America/Argentina/Buenos_Aires"),
+       'end': fromiso("2017-05-19 17:15:00", "America/Argentina/Buenos_Aires"),
        'has_settlement': False,
-       'is_open': False}]
+       'is_open': False,
+       'timezone': 'America/Argentina/Buenos_Aires'}]
      ),
 ])
-def test_generate_phases(level, fin_id, start, end, expected):
+def test_generate_phases(fin_id, start, end, expected):
     market = Market.get(fin_id)
     calculated = list(market.generate_phases(start, end))
 
     assert len(calculated) == len(expected)
     for calced, exp in zip(calculated, expected):
         calced = calced.to_dict()
-        calced["start"] = str(calced["start"]), calced["start"].tzname()
-        calced["end"] = str(calced["end"]), calced["end"].tzname()
+        assert calced["start"] == exp["start"]
+        assert calced["end"] == exp["end"]
+
+        assert (calced["timezone"] == market.timezone and
+                calced["timezone"] == str(calced["start"].tzinfo) == str(calced["end"].tzinfo))
 
         assert ((calced["has_settlement"] is False and calced["settlement"] == 'No') or
                 (calced["has_settlement"] is True and calced["settlement"] == 'Yes'))
@@ -795,4 +986,5 @@ def test_generate_phases(level, fin_id, start, end, expected):
 #     # replaced market transition (2017-04-16)
 #     # TODO: Should following a replaced market get rid off the replaced data?
 #     ("AR.BCBA", "2017-04-10", "2017-04-19",
-# ]
\ No newline at end of file
+# ]
+
diff --git a/tests/test_market.py b/tests/test_market.py
index f6a4d54..ac5cba2 100644
--- a/tests/test_market.py
+++ b/tests/test_market.py
@@ -1,5 +1,10 @@
-import pytest
-from tradinghours.models import Market
+import pytest, calendar
+from tradinghours import Market
+from tradinghours.models import MarketHoliday
+from tradinghours.exceptions import DateNotAvailable, NoAccess
+import tradinghours.store as st
+
+from .utils import fromiso
 
 # Test whether you can follow or not a permanently closed market
 @pytest.mark.parametrize("method, args, expected", [
@@ -12,7 +17,7 @@
     (Market.get_by_finid, ("AR.BCBA",), "AR.BYMA"),
     (Market.get_by_finid, ("AR.BCBA", False), "AR.BCBA"),
 ])
-def test_market_follow(level, method, args, expected):
+def test_market_follow(method, args, expected):
 
     market = method(*args)
     result = str(market.fin_id)
@@ -27,7 +32,156 @@ def test_market_follow(level, method, args, expected):
     (Market.get_by_mic, "xbue", "AR.BYMA"),
     (Market.get_by_mic, "xBuE", "AR.BYMA"),
 ])
-def test_market_case_insensitivity(level, method, identifier, expected):
+def test_market_case_insensitivity(method, identifier, expected):
     market = method(identifier)
     result = str(market.fin_id)
     assert result == expected
+
+
+def test_market_list_all():
+    found = Market.list_all()
+    assert len(found) == len(list(st.db.query(Market.table)))
+
+    found = Market.list_all("US*")
+    assert all(f.fin_id.startswith("US") for f in found)
+
+@pytest.mark.parametrize("fin_id", [
+    "US.NYSE", "US.CME.EQUITY.USINDEX1", "US.CBOE.VIX"
+])
+def test_market_available_dates(fin_id):
+    market = Market.get(fin_id)
+    table = MarketHoliday.table
+
+    first_should_be = st.db.query(table).filter(
+            table.c.fin_id == fin_id
+        ).order_by(
+            table.c.date
+        ).first().date.replace(day=1)
+
+    last_should_be = st.db.query(table).filter(
+            table.c.fin_id == fin_id
+        ).order_by(
+            table.c.date.desc()
+        ).first().date
+    _, num_days_in_month = calendar.monthrange(last_should_be.year, last_should_be.month)
+    last_should_be = last_should_be.replace(day=num_days_in_month)
+
+    assert market.first_available_date == first_should_be
+    assert market.last_available_date == last_should_be
+
+    if st.db.access_level != st.AccessLevel.only_holidays:
+        with pytest.raises(DateNotAvailable):
+            list(market.generate_phases("1900-01-01", "2020-01-01"))
+        with pytest.raises(DateNotAvailable):
+            list(market.generate_phases("2020-01-01", "2099-01-01"))
+        with pytest.raises(DateNotAvailable):
+            list(market.generate_phases("1900-01-01", "2099-01-01"))
+
+        with pytest.raises(DateNotAvailable):
+            market.status(fromiso("1900-01-01", "America/New_York"))
+        with pytest.raises(DateNotAvailable):
+            market.status(fromiso("2099-01-01", "America/New_York"))
+
+
+@pytest.mark.xfail(
+    st.db.access_level == st.AccessLevel.only_holidays,
+    reason="No access",
+    strict=True,
+    raises=NoAccess
+)
+@pytest.mark.parametrize("fin_id, datetime, expected", [
+    ("US.NYSE", fromiso("2023-11-15 12:00", "America/New_York"),
+     {
+         "status": "Open",
+         "reason": "Primary Trading Session",
+         "until": fromiso("2023-11-15 15:50", "America/New_York"),
+         "next_bell": fromiso("2023-11-15 16:00", "America/New_York"),
+         # "timezone": "America/New_York",
+     }),
+    ("US.NYSE", fromiso("2023-11-15 18:00", "America/New_York"),
+     {
+         "status": "Closed",
+         "reason": "Post-Trading Session",
+         "until": fromiso("2023-11-15 20:00", "America/New_York"),
+         "next_bell": fromiso("2023-11-16 09:30", "America/New_York"),
+#          "timezone": "America/New_York",
+     }),
+    ("US.NYSE", fromiso("2023-11-11 18:00", "America/New_York"),
+     {
+         "status": "Closed",
+         "reason": None,
+         "until": fromiso("2023-11-13 04:00", "America/New_York"),
+         "next_bell": fromiso("2023-11-13 09:30", "America/New_York"),
+#          "timezone": "America/New_York",
+     }),
+    ("US.NYSE", fromiso("2023-11-24 10:00", "America/New_York"),
+     {
+         "status": "Open",
+         "reason": "Thanksgiving Day - Primary Trading Session (Partial)",
+         "until": fromiso("2023-11-24 13:00", "America/New_York"),
+         "next_bell": fromiso("2023-11-24 13:00", "America/New_York"),
+#          "timezone": "America/New_York",
+     }),
+    ("US.NYSE", fromiso("2024-12-25 10:00", "America/New_York"),
+     {
+         "status": "Closed",
+         "reason": "Christmas",
+         "until": fromiso("2024-12-26 04:00", "America/New_York"),
+         "next_bell": fromiso("2024-12-26 09:30", "America/New_York"),
+#          "timezone": "America/New_York",
+     }),
+    ("US.NYSE", fromiso("2023-11-15 16:00", "America/New_York"),
+     {
+         "status": "Closed",
+         "reason": "Post-Trading Session",
+         "until": fromiso("2023-11-15 20:00", "America/New_York"),
+         "next_bell": fromiso("2023-11-16 09:30", "America/New_York"),
+#          "timezone": "America/New_York",
+     }),
+    ("US.NYSE", fromiso("2023-11-15 15:59", "America/New_York"),
+     {
+         "status": "Open",
+         "reason": "Primary Trading Session",
+         "until": fromiso("2023-11-15 16:00", "America/New_York"),
+         "next_bell": fromiso("2023-11-15 16:00", "America/New_York"),
+#          "timezone": "America/New_York",
+     }),
+     ("US.MGEX", fromiso("2024-09-30 07:45", "America/Chicago"),
+     {
+         "status": "Closed",
+         "reason": None,
+         "until": fromiso("2024-09-30 08:00", "America/Chicago"),
+         "next_bell": fromiso("2024-09-30 08:30", "America/Chicago"),
+#          "timezone": "America/New_York",
+     }),
+     ("US.MGEX", fromiso("2024-09-30 08:00", "America/Chicago"),
+     {
+         "status": "Closed",
+         "reason": "Pre-Open",
+         "until": fromiso("2024-09-30 08:30", "America/Chicago"),
+         "next_bell": fromiso("2024-09-30 08:30", "America/Chicago"),
+#          "timezone": "America/New_York",
+     }),
+     ("US.MGEX", fromiso("2024-09-30 15:00", "America/Chicago"),
+     {
+         "status": "Closed",
+         "reason": "Post-Trading Session",
+         "until": fromiso("2024-09-30 16:00", "America/Chicago"),
+         "next_bell": fromiso("2024-09-30 19:00", "America/Chicago"),
+#          "timezone": "America/New_York",
+     }),
+     ("US.MGEX", fromiso("2024-10-04 00:00", "America/Chicago"),
+     {
+         "status": "Open",
+         "reason": "Primary Trading Session",
+         "until": fromiso("2024-10-04 07:45", "America/Chicago"),
+         "next_bell": fromiso("2024-10-04 07:45", "America/Chicago"),
+#          "timezone": "America/New_York",
+     })
+])
+def test_market_status(fin_id, datetime, expected):
+    market = Market.get(fin_id)
+    status = market.status(datetime=datetime)
+    status = status.to_dict()
+    status = {k: status.get(k) for k in expected}
+    assert status == expected
diff --git a/tests/test_models.py b/tests/test_models.py
index 6f5a99b..4ea55ff 100644
--- a/tests/test_models.py
+++ b/tests/test_models.py
@@ -1,107 +1,101 @@
 import os
 import pytest
 
-from tradinghours.models.market import Market, MarketHoliday, MicMapping
-from tradinghours.models.currency import Currency, CurrencyHoliday
-from tradinghours.models.schedule import Schedule, PhaseType
-from tradinghours.models.season import SeasonDefinition
-from tradinghours.util import snake_case
+from tradinghours.market import Market, MarketHoliday
+from tradinghours.currency import Currency
+from tradinghours.models import PhaseType, SeasonDefinition
 from tradinghours.exceptions import NoAccess
-
-LEVEL = os.environ.get("API_KEY_LEVEL", "full").strip()
-
-from pprint import pprint
-
-@pytest.mark.parametrize("model, columns", [
-    (Market, ["Exchange Name",
-                      "Market Name",
-                      "Security Group",
-                      "Timezone",
-                      "Weekend Definition",
-                      "FinID",
-                      "MIC",
-                      "Acronym",
-                      "Asset Type",
-                      "Memo",
-                      "Permanently Closed",
-                      "Replaced By"]
-     ),
-    (Currency, ["Currency Code",
-                "Currency Name",
-                "Country Code",
-                "Central Bank", 
-                "Financial Capital",
-                "Financial Capital Timezone",
-                "Weekend Definition"]
-     ),
-    (CurrencyHoliday, ["Currency Code",
-                       "Date",
-                       "Holiday Name",
-                       "Settlement",
-                       "Observed",
-                       "Memo"]
-     ),
-    (MarketHoliday, ["FinID",
-                     "Date",
-                     "Holiday Name",
-                     "Schedule",
-                     "Settlement",
-                     "Observed",
-                     "Memo",
-                     "Status"]
-     ),
-    (MicMapping, ["MIC",
-                 "FinID"]
-     ),
-    (Schedule, ["FinID",
-                "Schedule Group",
-                "Schedule Group Memo",
-                "Timezone",
-                "Phase Type",
-                "Phase Name",
-                "Phase Memo",
-                "Days",
-                "Start",
-                "End",
-                "Offset Days",
-                "Duration",
-                "Min Start",
-                "Max Start",
-                "Min End",
-                "Max End",
-                "In Force Start Date",
-                "In Force End Date",
-                "Season Start",
-                "Season End"]
-     ),
-    (SeasonDefinition, ["Season",
-                        "Year",
-                        "Date"]
-     ),
-    (PhaseType, ["Name",
-                 "Status",
-                 "Settlement"]
-     )
-])
-def test_model_fields(level, model, columns):
-
-    column_snakes = sorted([snake_case(c) for c in columns])
-    field_names = sorted(model._fields)
-    assert field_names == column_snakes
-
-
-def test_market_instance_fields(level):
+import tradinghours.store as st
+
+# @pytest.mark.parametrize("model, columns", [
+#     (Market, ["Exchange Name",
+#                       "Market Name",
+#                       "Security Group",
+#                       "Timezone",
+#                       "Weekend Definition",
+#                       "FinID",
+#                       "MIC",
+#                       "Acronym",
+#                       "Asset Type",
+#                       "Memo",
+#                       "Permanently Closed",
+#                       "Replaced By"]
+#      ),
+#     (Currency, ["Currency Code",
+#                 "Currency Name",
+#                 "Country Code",
+#                 "Central Bank",
+#                 "Financial Capital",
+#                 "Financial Capital Timezone",
+#                 "Weekend Definition"]
+#      ),
+#     (CurrencyHoliday, ["Currency Code",
+#                        "Date",
+#                        "Holiday Name",
+#                        "Settlement",
+#                        "Observed",
+#                        "Memo"]
+#      ),
+#     (MarketHoliday, ["FinID",
+#                      "Date",
+#                      "Holiday Name",
+#                      "Schedule",
+#                      "Settlement",
+#                      "Observed",
+#                      "Memo",
+#                      "Status"]
+#      ),
+#     (MicMapping, ["MIC",
+#                  "FinID"]
+#      ),
+#     (Schedule, ["FinID",
+#                 "Schedule Group",
+#                 "Schedule Group Memo",
+#                 "Timezone",
+#                 "Phase Type",
+#                 "Phase Name",
+#                 "Phase Memo",
+#                 "Days",
+#                 "Start",
+#                 "End",
+#                 "Offset Days",
+#                 "Duration",
+#                 "Min Start",
+#                 "Max Start",
+#                 "Min End",
+#                 "Max End",
+#                 "In Force Start Date",
+#                 "In Force End Date",
+#                 "Season Start",
+#                 "Season End"]
+#      ),
+#     (SeasonDefinition, ["Season",
+#                         "Year",
+#                         "Date"]
+#      ),
+#     (PhaseType, ["Name",
+#                  "Status",
+#                  "Settlement"]
+#      )
+# ])
+# def test_model_fields(model, columns):
+#     column_snakes = sorted([snake_case(c) for c in columns])
+#     field_names = sorted(model._fields)
+#     assert field_names == column_snakes
+
+
+def test_market_instance_fields():
     nyse = Market.get("US.NYSE")
     assert nyse.fin_id == "US.NYSE"
-    assert nyse.fin_id_obj.country == "US"
-    assert nyse.fin_id_obj.acronym == "NYSE"
-    assert str(nyse.fin_id_obj) == "US.NYSE"
+    # assert nyse.fin_id_obj.country == "US"
+    # assert nyse.fin_id_obj.acronym == "NYSE"
+    # assert str(nyse.fin_id_obj) == "US.NYSE"
 
     assert nyse.mic == "XNYS"
     assert nyse.weekend_definition == "Sat-Sun"
-    assert str(nyse.weekend_definition_obj) == "Sat-Sun"
+    # assert str(nyse.weekend_definition_obj) == "Sat-Sun"
 
-def test_market_holiday_instance_fields(level):
+def test_market_holiday_instance_fields():
     nyse = Market.get("XNYS")
     holidays = nyse.list_holidays("2007-11-20", "2007-11-23")
     assert len(holidays) == 2
@@ -122,36 +116,39 @@ def test_market_holiday_instance_fields(level):
     assert second.observed is True
 
 
-@pytest.mark.xfail(LEVEL != "full", reason="No access", strict=True, raises=NoAccess)
-def test_currency_instance_fields(level):
+@pytest.mark.xfail(
+    st.db.access_level != st.AccessLevel.full,
+    reason="No access", strict=True, raises=NoAccess
+)
+def test_currency_instance_fields():
     aud = Currency.get("AUD")
     assert aud.weekend_definition == "Sat-Sun"
 
 
-def test_phase_type_instance_fields(level):
-    if level == "only_holidays":
+def test_phase_type_instance_fields():
+    if st.db.access_level == st.AccessLevel.only_holidays:
         with pytest.raises(NoAccess) as exception:
             PhaseType.as_dict()
-        assert str(exception.value) == "You didn't run `tradinghours import` or you dont have access to phases."
         return
     else:
         phase_types = PhaseType.as_dict()
 
-    assert len(phase_types) == 11
+    assert len(phase_types) == 13
     expected = {
-        'Primary Trading Session': ('Primary Trading Session', 'Open', 'Yes', True, True,),
-        'Primary Trading Session, No Settlement': ('Primary Trading Session, No Settlement', 'Open', 'No', False, True,),
-        'Intermission': ('Intermission', 'Closed', 'No', False, False,),
-        'Pre-Trading Session': ('Pre-Trading Session', 'Closed', 'No', False, False,),
-        'Post-Trading Session': ('Post-Trading Session', 'Closed', 'No', False, False,),
-        'Trading-at-Last': ('Trading-at-Last', 'Closed', 'No', False, False,),
-        'Pre-Open': ('Pre-Open', 'Closed', 'No', False, False,),
-        'Pre-Close': ('Pre-Close', 'Closed', 'No', False, False,),
-        'Order Collection Period': ('Order Collection Period', 'Closed', 'No', False, False,),
-        'Call Auction': ('Call Auction', 'Closed', 'No', False, False,),
-        'Other': ('Other', 'Closed', 'No', False, False,),
+        'Primary Trading Session': ('Primary Trading Session', 'Open', 'Yes', True, True),
+        'Primary Trading Session, No Settlement': ('Primary Trading Session, No Settlement', 'Open', 'No', False, True),
+        'Primary Trading Session, No Closing Price': ('Primary Trading Session, No Closing Price', 'Open', 'No', False, True),
+        'Intermission': ('Intermission', 'Closed', 'No', False, False),
+        'Pre-Trading Session': ('Pre-Trading Session', 'Closed', 'No', False, False),
+        'Post-Trading Session': ('Post-Trading Session', 'Closed', 'No', False, False),
+        'Trading-at-Last': ('Trading-at-Last', 'Closed', 'No', False, False),
+        'Pre-Open': ('Pre-Open', 'Closed', 'No', False, False),
+        'Pre-Close': ('Pre-Close', 'Closed', 'No', False, False),
+        'Order Collection Period': ('Order Collection Period', 'Closed', 'No', False, False),
+        'Call Auction': ('Call Auction', 'Closed', 'No', False, False),
+        'Settlement Window': ('Settlement Window', 'Closed', 'No', False, False),
+        'Other': ('Other', 'Closed', 'No', False, False),
     }
-
     for k, phase in phase_types.items():
         assert expected[k] == (str(phase.name),
                                str(phase.status),
@@ -160,17 +157,18 @@ def test_phase_type_instance_fields(level):
                                phase.is_open)
 
 
-def test_string_format(level):
+def test_string_format():
+    # TODO: go over the error messages that are supposed to be shown
     market = Market.get('US.NYSE')
     assert str(market) == 'Market: US.NYSE New York Stock Exchange America/New_York'
 
     market_holiday = market.list_holidays("2007-11-20", "2007-11-23")[0]
     assert str(market_holiday) == 'MarketHoliday: US.NYSE 2007-11-22 Thanksgiving Day'
 
-    if level != "full":
-        with pytest.raises(NoAccess) as exception:
+    if st.db.access_level != st.AccessLevel.full:
+        with pytest.raises(NoAccess):
             Currency.get('AUD')
-        assert str(exception.value) == "You didn't run `tradinghours import` or you dont have access to currencies."
+        # assert str(exception.value) == "You didn't run `tradinghours import` or you dont have access to currencies."
     else:
         currency = Currency.get('AUD')
         assert str(currency) == 'Currency: AUD Australian Dollar'
@@ -178,18 +176,18 @@ def test_string_format(level):
         currency_holiday = currency.list_holidays("2020-01-27", "2020-01-27")[0]
         assert str(currency_holiday) == 'CurrencyHoliday: AUD 2020-01-27 Australia Day'
 
-    if level == "only_holidays":
+    if st.db.access_level == st.AccessLevel.only_holidays:
         with pytest.raises(NoAccess) as exception:
             Market.get("US.NYSE").list_schedules()
-        assert str(exception.value) == r"You didn't run `tradinghours import` or you dont have access to schedules/us-nyse."
+        # assert str(exception.value) == r"You didn't run `tradinghours import` or you dont have access to schedules/us-nyse."
 
         with pytest.raises(NoAccess) as exception:
             list(market.generate_phases("2024-02-06", "2024-02-06"))
-        assert str(exception.value) == r"You didn't run `tradinghours import` or you dont have access to phases."
+        # assert str(exception.value) == r"You didn't run `tradinghours import` or you dont have access to phases."
 
         with pytest.raises(NoAccess) as exception:
             SeasonDefinition.get("First day of March", 2022)
-        assert str(exception.value) == r"You didn't run `tradinghours import` or you dont have access to season-definitions."
+        # assert str(exception.value) == r"You didn't run `tradinghours import` or you dont have access to season-definitions."
 
     else:
         schedule = Market.get("US.NYSE").list_schedules()
@@ -204,7 +202,8 @@ def test_string_format(level):
         season = SeasonDefinition.get("First day of March", 2022)
         assert str(season) == 'SeasonDefinition: 2022-03-01 First day of March'
 
-def test_set_string_format(level):
+
+def test_set_string_format():
     market = Market.get('ZA.JSE.SAFEX')
 
     # change format
@@ -226,27 +225,22 @@ def test_set_string_format(level):
 
     MarketHoliday.reset_string_format()
 
-def test_market_raw_data(level):
+def test_market_raw_data():
 
     nyse = Market.get("XNYS")
     holiday = nyse.list_holidays("2007-11-20", "2007-11-23")[0]
-    assert holiday.raw_data["settlement"] == "No"
-    assert holiday.raw_data["status"] == "Closed"
-    assert holiday.raw_data["observed"] == ""
     assert holiday.data["settlement"] == "No"
     assert holiday.data["status"] == "Closed"
     assert holiday.data["observed"] is False
 
 
-@pytest.mark.xfail(LEVEL != "full", reason="No access", strict=True, raises=NoAccess)
-def test_currency_raw_data(level):
-
-    # assert 1 == 1
-
+@pytest.mark.xfail(
+    st.db.access_level != st.AccessLevel.full,
+    reason="No access", strict=True, raises=NoAccess
+)
+def test_currency_raw_data():
     currency = Currency.get('AUD')
     holiday = currency.list_holidays("2020-01-27", "2020-01-27")[0]
-    assert holiday.raw_data["settlement"] == "No"
-    assert holiday.raw_data["observed"] == "OBS"
     assert holiday.data["settlement"] == "No"
     assert holiday.data["observed"] is True
 
diff --git a/tests/test_readme.py b/tests/test_readme.py
index 0a24cae..7017eaf 100644
--- a/tests/test_readme.py
+++ b/tests/test_readme.py
@@ -1,21 +1,20 @@
 import pytest, io, sys, os
 
-from tradinghours.models.market import Market, MarketHoliday
-from tradinghours.models.currency import Currency, CurrencyHoliday
-from tradinghours.models.schedule import Phase
+from tradinghours import Market
+from tradinghours.currency import Currency, CurrencyHoliday
+from tradinghours.models import Phase, MarketHoliday
 from tradinghours.exceptions import NoAccess
-
+import tradinghours.store as st
 from pathlib import Path
-from pprint import pprint
-LEVEL = os.environ.get("API_KEY_LEVEL", "full").strip()
 
-def test_market_list_all(level):
+
+def test_market_list_all():
 
     for obj in Market.list_all():
-        assert str(obj) == Market.get_string_format().format(**obj.data)
+        assert str(obj) == Market.get_string_format().format(**obj.to_dict())
 
 
-def test_get_by_finid_or_mic(level):
+def test_get_by_finid_or_mic():
     # Get by either FinID or MIC
     market = Market.get('US.NYSE')
     assert str(market) == "Market: US.NYSE New York Stock Exchange America/New_York"
@@ -23,7 +22,7 @@ def test_get_by_finid_or_mic(level):
     assert str(market) == "Market: US.NYSE New York Stock Exchange America/New_York"
 
 
-def test_follow_market(level):
+def test_follow_market():
     # AR.BCBA is permanently closed and replaced by AR.BYMA
     market = Market.get('AR.BCBA')
     original = Market.get('AR.BCBA', follow=False)
@@ -32,33 +31,42 @@ def test_follow_market(level):
     assert original.fin_id == "AR.BCBA"
 
 
-def test_market_list_holidays(level):
+def test_market_list_holidays():
     holidays = Market.get('US.NYSE').list_holidays("2024-01-01", "2024-12-31")
 
     for obj in holidays[:3]:
-        assert str(obj) == MarketHoliday.get_string_format().format(**obj.data)
+        assert str(obj) == MarketHoliday.get_string_format().format(**obj.to_dict())
 
 
-@pytest.mark.xfail(LEVEL == "only_holidays", reason="No access", strict=True, raises=NoAccess)
-def test_generate_phases(level):
+@pytest.mark.xfail(
+    st.db.access_level == st.AccessLevel.only_holidays,
+    reason="No access", strict=True, raises=NoAccess
+)
+def test_generate_phases():
     market = Market.get('XNYS')
     schedules = market.generate_phases("2023-09-01", "2023-09-30")
 
     for obj in schedules:
-        assert str(obj) == Phase.get_string_format().format(**obj.data)
+        assert str(obj) == Phase.get_string_format().format(**obj.to_dict())
 
 
-@pytest.mark.xfail(LEVEL != "full", reason="No access", strict=True, raises=NoAccess)
-def test_currencies_list_all(level):
+@pytest.mark.xfail(
+    st.db.access_level != st.AccessLevel.full,
+    reason="No access", strict=True, raises=NoAccess
+)
+def test_currencies_list_all():
     for obj in Currency.list_all():
-        assert str(obj) == Currency.get_string_format().format(**obj.data)
+        assert str(obj) == Currency.get_string_format().format(**obj.to_dict())
 
 
-@pytest.mark.xfail(LEVEL != "full", reason="No access", strict=True, raises=NoAccess)
-def test_currency_list_holidays(level):
+@pytest.mark.xfail(
+    st.db.access_level != st.AccessLevel.full,
+    reason="No access", strict=True, raises=NoAccess
+)
+def test_currency_list_holidays():
     currency = Currency.get('AUD')
     for obj in currency.list_holidays("2023-06-01", "2023-12-31"):
-        assert str(obj) == CurrencyHoliday.get_string_format().format(**obj.data)
+        assert str(obj) == CurrencyHoliday.get_string_format().format(**obj.to_dict())
 
 def strip(line, sub):
     try:
@@ -66,9 +74,12 @@ def strip(line, sub):
     except ValueError:
         return line
 
-@pytest.mark.xfail(LEVEL != "full", reason="No access", strict=True, raises=NoAccess)
+@pytest.mark.xfail(
+    st.db.access_level != st.AccessLevel.full,
+    reason="No access", strict=True, raises=NoAccess
+)
 def test_code_blocks():
-    with open(Path("README.md"), "r") as readme:
+    with open(Path("README.md"), "r", encoding="utf-8") as readme:
         readme = readme.readlines()
 
     code_blocks = []
diff --git a/tests/test_remote.py b/tests/test_remote.py
index a05b3ea..5762a74 100644
--- a/tests/test_remote.py
+++ b/tests/test_remote.py
@@ -1,45 +1,46 @@
-import pytest
+import pytest, json
 import csv
 
-from tradinghours.catalog import default_catalog, default_data_manager, MarketFile
-from tradinghours.models.market import Market
+from tradinghours import Market
 
 import pytest, datetime
 from unittest.mock import MagicMock, patch
 from urllib.request import HTTPError
 
-from tradinghours.remote import Client, default_data_manager
+from tradinghours import client
 from tradinghours.exceptions import TokenError, ClientError
 
 @pytest.fixture
 def client_urlopen():
-    client = Client(token="test_token", base_url="http://example.com")
-    with patch("tradinghours.remote.urlopen") as mock_urlopen:
+    client.TOKEN = "test_token"
+    client.BASE_URL = "http://example.com"
+    with patch("tradinghours.client.urlopen") as mock_urlopen:
         yield client, mock_urlopen
 
 @pytest.fixture
-def client():
+def mocked_client():
     """Provide a client instance."""
-    return Client(token="test_token", base_url="http://example.com")
+    client.TOKEN = "test_token"
+    client.BASE_URL = "http://example.com"
+    return client
 
 @pytest.fixture
 def patch_response(mocker):
     """Patch response with specific content."""
     def _patch(content):
-        mock_response = MagicMock()
         content = content.encode("utf-8")
-        mock_response.__enter__.return_value.read.side_effect = [content, b""]
-        mock_response.__enter__.return_value.__exit__.return_value = False
-        return mocker.patch("tradinghours.remote.Client.get_response", return_value=mock_response)
+        class MockResponse:
+            status = 200
+            def read(self):
+                return content
+        return mocker.patch("tradinghours.client.get_response", return_value=MockResponse())
     return _patch
 
 @pytest.fixture
 def patch_response_error(mocker):
     """Patch response to raise an exception."""
     def _patch(exception):
-        mock_response = MagicMock()
-        mock_response.__enter__.side_effect = exception
-        return mocker.patch("tradinghours.remote.Client.get_response", return_value=mock_response)
+        return mocker.patch("tradinghours.client.get_response", side_effect=exception)
     return _patch
 
 @pytest.fixture
@@ -48,18 +49,15 @@ def patch_response_file(mocker):
     def _patch(file_path):
         with open(file_path, "rb") as file:
             content = file.read()
-        mock_response = MagicMock()
-        mock_response.__enter__.return_value.read.side_effect = [content, b""]
-        mock_response.__enter__.return_value.__exit__.return_value = False
-        return mocker.patch("tradinghours.remote.Client.get_response", return_value=mock_response)
+        return mocker.patch("tradinghours.client.get_response", return_value=[content, b""])
     return _patch
 
 def test_urlopen_successful(client_urlopen):
     client, mock_urlopen = client_urlopen
     mock_urlopen.return_value = "foobar"
 
-    with client.get_response("/test") as response:
-        assert response == "foobar"
+    response = client.get_response("/test")
+    assert response == "foobar"
 
 def test_urlopen_token_error(client_urlopen):
     client, mock_urlopen = client_urlopen
@@ -78,66 +76,45 @@ def test_urlopen_client_error(client_urlopen):
             pass
 
 
-def test_get_json_successful(client, patch_response):
-    patch_response('{"key": "value"}')
-    data = client.get_json("/test")
-    assert data == {"key": "value"}
 
-def test_get_json_token_error(client, patch_response_error):
+def test_download_temporary_token_error(mocked_client, patch_response_error):
     patch_response_error(TokenError("Token is missing or invalid"))
     with pytest.raises(TokenError):
-        client.get_json("/test")
+        client.download_zip_file("/test")
 
-def test_get_json_client_error(client, patch_response_error):
+def test_download_temporary_client_error(mocked_client, patch_response_error):
     patch_response_error(ClientError("Error getting server response"))
     with pytest.raises(ClientError):
-        client.get_json("/test")
+        client.download_zip_file("/test")
 
-def test_download_temporary_successful(client, patch_response):
-    patch_response('{"key": "value"}')
-    with client.download_temporary("/test") as temp_file:
-        content = temp_file.read().decode("utf-8")
-        assert content == '{"key": "value"}'
-
-def test_download_temporary_token_error(client, patch_response_error):
-    patch_response_error(TokenError("Token is missing or invalid"))
-    with pytest.raises(TokenError):
-        with client.download_temporary("/test"):
-            pass
-
-def test_download_temporary_client_error(client, patch_response_error):
-    patch_response_error(ClientError("Error getting server response"))
-    with pytest.raises(ClientError):
-        with client.download_temporary("/test"):
-            pass
-
-
-def test_remote_timestamp(mocker):
+def test_remote_timestamp(patch_response):
     timestamp = "2023-10-27T12:00:00"
-    mocker.patch("tradinghours.remote.default_client.get_json", return_value={"last_updated": timestamp})
+    patch_response("{" + f'"last_updated":"{timestamp}"' + "}")
 
     expected_datetime = datetime.datetime.fromisoformat(timestamp)
-    assert default_data_manager.remote_timestamp == expected_datetime
-
-
-def test_no_duplicates(level):
-    collection = default_catalog.find_model_collection(Market)
-    cluster = collection.clusters.get("us")
-
-    market_file = MarketFile(default_data_manager.csv_dir)
-    market_file.ingest(default_catalog.store)
-    cluster.flush()
-
-    market_file.ingest(default_catalog.store)
-    cluster.flush()
-
-    keys = set()
-    with open(cluster.location, "r", encoding="utf-8", newline="") as file:
-        for row in csv.reader(file):
-            if row[0] in keys:
-                pytest.fail("Data was duplicated")
-            keys.add(row[0])
-
+    assert client.get_remote_timestamp() == expected_datetime
+
+##
+# TODO: This was for the weird bug when rows were appended to csv files
+#   see if it's worth keeping
+# def test_no_duplicates():
+#     collection = default_catalog.find_model_collection(Market)
+#     cluster = collection.clusters.get("us")
+#
+#     market_file = MarketFile(default_data_manager.csv_dir)
+#     market_file.ingest(default_catalog.store)
+#     cluster.flush()
+#
+#     market_file.ingest(default_catalog.store)
+#     cluster.flush()
+#
+#     keys = set()
+#     with open(cluster.location, "r", encoding="utf-8", newline="") as file:
+#         for row in csv.reader(file):
+#             if row[0] in keys:
+#                 pytest.fail("Data was duplicated")
+#             keys.add(row[0])
+#
 
 
 
diff --git a/tests/test_schedules.py b/tests/test_schedules.py
index 2029e68..780baae 100644
--- a/tests/test_schedules.py
+++ b/tests/test_schedules.py
@@ -1,24 +1,27 @@
 import pytest, os, datetime
+from pprint import pformat
 from tradinghours import Market
 from tradinghours.exceptions import NoAccess
+import tradinghours.store as st
 
 
-LEVEL = os.getenv("API_KEY_LEVEL", "full").strip()
-
-@pytest.mark.xfail(LEVEL == "only_holidays", reason="No access", strict=True, raises=NoAccess)
+@pytest.mark.xfail(
+ st.db.access_level == st.AccessLevel.only_holidays,
+ reason="No access", strict=True, raises=NoAccess
+)
 @pytest.mark.parametrize("fin_id, expected", [
     ("US.NYSE", [{'schedule_group': 'Partial', 'in_force_start_date': None, 'season_start': None,
                   'start': datetime.time(6, 30), 'end': datetime.time(9, 30), 'fin_id': 'US.NYSE',
                   'schedule_group_memo': None, 'timezone': 'America/New_York', 'phase_type': 'Pre-Trading Session',
                   'phase_name': 'Pre-Opening Session', 'phase_memo': None, 'days': 'Mon-Fri',
-                  'offset_days': 0, 'duration': '10800', 'min_start': None, 'max_start': None, 'min_end': None,
+                  'offset_days': 0, 'duration': 10800, 'min_start': None, 'max_start': None, 'min_end': None,
                   'max_end': None, 'in_force_end_date': None, 'season_end': None, 'end_with_offset': '09:30:00   ',
                   'has_season': False},
                  {'schedule_group': 'Partial', 'in_force_start_date': None, 'season_start': None,
                   'start': datetime.time(9, 30), 'end': datetime.time(13, 0),
                   'fin_id': 'US.NYSE', 'schedule_group_memo': None, 'timezone': 'America/New_York',
                   'phase_type': 'Primary Trading Session', 'phase_name': 'Core Trading Session',
-                  'phase_memo': None, 'days': 'Mon-Fri', 'offset_days': 0, 'duration': '12600',
+                  'phase_memo': None, 'days': 'Mon-Fri', 'offset_days': 0, 'duration': 12600,
                   'min_start': None, 'max_start': None, 'min_end': None, 'max_end': None,
                   'in_force_end_date': None, 'season_end': None, 'end_with_offset': '13:00:00   ',
                   'has_season': False},
@@ -26,46 +29,46 @@
                   'start': datetime.time(13, 0), 'end': datetime.time(13, 30), 'fin_id': 'US.NYSE',
                   'schedule_group_memo': None, 'timezone': 'America/New_York', 'phase_type': 'Post-Trading Session',
                   'phase_name': 'Crossing Session', 'phase_memo': None, 'days': 'Mon-Fri',
-                  'offset_days': 0, 'duration': '1800', 'min_start': None, 'max_start': None, 'min_end': None,
+                  'offset_days': 0, 'duration': 1800, 'min_start': None, 'max_start': None, 'min_end': None,
                   'max_end': None, 'in_force_end_date': None, 'season_end': None, 'end_with_offset': '13:30:00   ',
                   'has_season': False},
                  {'schedule_group': 'Regular', 'in_force_start_date': None, 'season_start': None,
                   'start': datetime.time(4, 0), 'end': datetime.time(9, 30), 'fin_id': 'US.NYSE',
                   'schedule_group_memo': None, 'timezone': 'America/New_York',
                   'phase_type': 'Pre-Trading Session', 'phase_name': 'Pre-Trading Session', 'phase_memo': None,
-                  'days': 'Mon-Fri', 'offset_days': 0, 'duration': '19800', 'min_start': None,
+                  'days': 'Mon-Fri', 'offset_days': 0, 'duration': 19800, 'min_start': None,
                   'max_start': None, 'min_end': None, 'max_end': None, 'in_force_end_date': None,
                   'season_end': None, 'end_with_offset': '09:30:00   ', 'has_season': False},
                  {'schedule_group': 'Regular', 'in_force_start_date': None, 'season_start': None,
                   'start': datetime.time(6, 30), 'end': datetime.time(9, 30), 'fin_id': 'US.NYSE',
                   'schedule_group_memo': None, 'timezone': 'America/New_York', 'phase_type': 'Pre-Open',
                   'phase_name': 'Pre-Opening Session', 'phase_memo': None, 'days': 'Mon-Fri',
-                  'offset_days': 0, 'duration': '10800', 'min_start': None, 'max_start': None, 'min_end': None,
+                  'offset_days': 0, 'duration': 10800, 'min_start': None, 'max_start': None, 'min_end': None,
                   'max_end': None, 'in_force_end_date': None, 'season_end': None, 'end_with_offset': '09:30:00   ',
                   'has_season': False},
                  {'schedule_group': 'Regular', 'in_force_start_date': None, 'season_start': None,
                   'start': datetime.time(9, 30), 'end': datetime.time(9, 30), 'fin_id': 'US.NYSE',
                   'schedule_group_memo': None, 'timezone': 'America/New_York', 'phase_type': 'Call Auction', 'phase_name': 'Core Open Auction', 'phase_memo': None,
-                  'days': 'Mon-Fri', 'offset_days': 0, 'duration': '0', 'min_start': None,
+                  'days': 'Mon-Fri', 'offset_days': 0, 'duration': 0, 'min_start': None,
                   'max_start': None, 'min_end': None, 'max_end': None, 'in_force_end_date': None, 'season_end': None,
                   'end_with_offset': '09:30:00   ', 'has_season': False},
                  {'schedule_group': 'Regular', 'in_force_start_date': None, 'season_start': None,
                   'start': datetime.time(9, 30), 'end': datetime.time(16, 0), 'fin_id': 'US.NYSE', 'schedule_group_memo': None, 'timezone': 'America/New_York',
                   'phase_type': 'Primary Trading Session', 'phase_name': 'Core Trading Session', 'phase_memo': None,
-                  'days': 'Mon-Fri', 'offset_days': 0, 'duration': '23400', 'min_start': None, 'max_start': None,
+                  'days': 'Mon-Fri', 'offset_days': 0, 'duration': 23400, 'min_start': None, 'max_start': None,
                   'min_end': None, 'max_end': None, 'in_force_end_date': None, 'season_end': None,
                   'end_with_offset': '16:00:00   ', 'has_season': False},
                  {'schedule_group': 'Regular', 'in_force_start_date': None, 'season_start': None,
                   'start': datetime.time(15, 50), 'end': datetime.time(16, 0), 'fin_id': 'US.NYSE',
                   'schedule_group_memo': None, 'timezone': 'America/New_York', 'phase_type': 'Pre-Close',
                   'phase_name': 'Closing Imbalance Period', 'phase_memo': None, 'days': 'Mon-Fri', 'offset_days': 0,
-                  'duration': '600', 'min_start': None, 'max_start': None, 'min_end': None, 'max_end': None,
+                  'duration': 600, 'min_start': None, 'max_start': None, 'min_end': None, 'max_end': None,
                   'in_force_end_date': None, 'season_end': None, 'end_with_offset': '16:00:00   ', 'has_season': False},
                  {'schedule_group': 'Regular', 'in_force_start_date': None, 'season_start': None,
                   'start': datetime.time(16, 0), 'end': datetime.time(20, 0), 'fin_id': 'US.NYSE',
                   'schedule_group_memo': None, 'timezone': 'America/New_York', 'phase_type': 'Post-Trading Session',
                   'phase_name': 'Extended Hours', 'phase_memo': None, 'days': 'Mon-Fri', 'offset_days': 0,
-                  'duration': '14400', 'min_start': None, 'max_start': None, 'min_end': None, 'max_end': None,
+                  'duration': 14400, 'min_start': None, 'max_start': None, 'min_end': None, 'max_end': None,
                   'in_force_end_date': None, 'season_end': None, 'end_with_offset': '20:00:00   ',
                   'has_season': False}
                  ]
@@ -75,35 +78,35 @@
          'start': datetime.time(12, 30), 'end': datetime.time(12, 45), 'fin_id': 'BR.BOVESPA',
          'schedule_group_memo': None, 'timezone': 'America/Sao_Paulo', 'phase_type': 'Pre-Open',
          'phase_name': 'Order Cancelation', 'phase_memo': None, 'days': 'Mon-Fri', 'offset_days': 0,
-         'duration': '900', 'min_start': None, 'max_start': None, 'min_end': None,
+         'duration': 900, 'min_start': None, 'max_start': None, 'min_end': None,
          'max_end': None, 'in_force_end_date': datetime.date(2022, 12, 18), 'season_end': None, 'end_with_offset': '12:45:00   ',
          'has_season': False},
         {'schedule_group': 'Partial', 'in_force_start_date': None, 'season_start': None,
          'start': datetime.time(12, 45), 'end': datetime.time(13, 0), 'fin_id': 'BR.BOVESPA',
          'schedule_group_memo': None, 'timezone': 'America/Sao_Paulo', 'phase_type': 'Pre-Open',
          'phase_name': 'Opening Session', 'phase_memo': None, 'days': 'Mon-Fri', 'offset_days': 0,
-         'duration': '900', 'min_start': None, 'max_start': None, 'min_end': None, 'max_end': None,
+         'duration': 900, 'min_start': None, 'max_start': None, 'min_end': None, 'max_end': None,
          'in_force_end_date': datetime.date(2022, 12, 18), 'season_end': None, 'end_with_offset': '13:00:00   ',
          'has_season': False},
         {'schedule_group': 'Partial', 'in_force_start_date': None, 'season_start': None,
          'start': datetime.time(13, 0), 'end': datetime.time(16, 55), 'fin_id': 'BR.BOVESPA',
          'schedule_group_memo': None, 'timezone': 'America/Sao_Paulo', 'phase_type': 'Primary Trading Session',
          'phase_name': 'Trading Session', 'phase_memo': None, 'days': 'Mon-Fri', 'offset_days': 0,
-         'duration': '14100', 'min_start': None, 'max_start': None, 'min_end': None, 'max_end': None,
+         'duration': 14100, 'min_start': None, 'max_start': None, 'min_end': None, 'max_end': None,
          'in_force_end_date': datetime.date(2022, 12, 18), 'season_end': None, 'end_with_offset': '16:55:00   ',
          'has_season': False},
         {'schedule_group': 'Partial', 'in_force_start_date': None, 'season_start': None,
          'start': datetime.time(16, 55), 'end': datetime.time(17, 0), 'fin_id': 'BR.BOVESPA',
          'schedule_group_memo': None, 'timezone': 'America/Sao_Paulo', 'phase_type': 'Call Auction',
          'phase_name': 'Closing Call', 'phase_memo': None, 'days': 'Mon-Fri', 'offset_days': 0,
-         'duration': '300', 'min_start': None, 'max_start': None, 'min_end': None, 'max_end': None,
+         'duration': 300, 'min_start': None, 'max_start': None, 'min_end': None, 'max_end': None,
          'in_force_end_date': datetime.date(2022, 12, 18), 'season_end': None, 'end_with_offset': '17:00:00   ',
          'has_season': False},
         {'schedule_group': 'Partial', 'in_force_start_date': None, 'season_start': None,
          'start': datetime.time(17, 25), 'end': datetime.time(17, 30), 'fin_id': 'BR.BOVESPA',
          'schedule_group_memo': None, 'timezone': 'America/Sao_Paulo', 'phase_type': 'Other',
          'phase_name': 'After Market - Order Cancelation', 'phase_memo': None, 'days': 'Mon-Fri',
-         'offset_days': 0, 'duration': '300', 'min_start': None, 'max_start': None,
+         'offset_days': 0, 'duration': 300, 'min_start': None, 'max_start': None,
          'min_end': None, 'max_end': None, 'in_force_end_date': datetime.date(2022, 12, 18),
          'season_end': None, 'end_with_offset': '17:30:00   ',
          'has_season': False},
@@ -111,129 +114,129 @@
          'start': datetime.time(17, 30), 'end': datetime.time(18, 0), 'fin_id': 'BR.BOVESPA',
          'schedule_group_memo': None, 'timezone': 'America/Sao_Paulo', 'phase_type': 'Post-Trading Session',
          'phase_name': 'After Market - Trading Session', 'phase_memo': None, 'days': 'Mon-Fri',
-         'offset_days': 0, 'duration': '1800', 'min_start': None, 'max_start': None,
+         'offset_days': 0, 'duration': 1800, 'min_start': None, 'max_start': None,
          'min_end': None, 'max_end': None, 'in_force_end_date': datetime.date(2022, 12, 18),
          'season_end': None, 'end_with_offset': '18:00:00   ', 'has_season': False},
         {'schedule_group': 'Partial', 'in_force_start_date': None, 'season_start': None,
          'start': datetime.time(18, 30), 'end': datetime.time(18, 45), 'fin_id': 'BR.BOVESPA',
          'schedule_group_memo': None, 'timezone': 'America/Sao_Paulo', 'phase_type': 'Other',
          'phase_name': 'After Market - Order Cancelation', 'phase_memo': None, 'days': 'Mon-Fri',
-         'offset_days': 0, 'duration': '900', 'min_start': None, 'max_start': None, 'min_end': None,
+         'offset_days': 0, 'duration': 900, 'min_start': None, 'max_start': None, 'min_end': None,
          'max_end': None, 'in_force_end_date': datetime.date(2022, 12, 18), 'season_end': None,
          'end_with_offset': '18:45:00   ', 'has_season': False},
         {'schedule_group': 'Partial', 'in_force_start_date': datetime.date(2022, 12, 19), 'season_start': None,
          'start': datetime.time(12, 30), 'end': datetime.time(12, 45), 'fin_id': 'BR.BOVESPA',
          'schedule_group_memo': None, 'timezone': 'America/Sao_Paulo', 'phase_type': 'Pre-Open',
          'phase_name': 'Order Cancelation', 'phase_memo': None, 'days': 'Mon-Fri', 'offset_days': 0,
-         'duration': '900', 'min_start': None, 'max_start': None, 'min_end': None, 'max_end': None,
+         'duration': 900, 'min_start': None, 'max_start': None, 'min_end': None, 'max_end': None,
          'in_force_end_date': None, 'season_end': None, 'end_with_offset': '12:45:00   ',
          'has_season': False},
         {'schedule_group': 'Partial', 'in_force_start_date': datetime.date(2022, 12, 19), 'season_start': None,
          'start': datetime.time(12, 45), 'end': datetime.time(13, 0), 'fin_id': 'BR.BOVESPA',
          'schedule_group_memo': None, 'timezone': 'America/Sao_Paulo', 'phase_type': 'Pre-Open',
          'phase_name': 'Opening Session', 'phase_memo': None, 'days': 'Mon-Fri', 'offset_days': 0,
-         'duration': '900', 'min_start': None, 'max_start': None, 'min_end': None, 'max_end': None,
+         'duration': 900, 'min_start': None, 'max_start': None, 'min_end': None, 'max_end': None,
          'in_force_end_date': None, 'season_end': None, 'end_with_offset': '13:00:00   ',
          'has_season': False},
         {'schedule_group': 'Partial', 'in_force_start_date': datetime.date(2022, 12, 19), 'season_start': None,
          'start': datetime.time(13, 0), 'end': datetime.time(16, 55), 'fin_id': 'BR.BOVESPA',
          'schedule_group_memo': None, 'timezone': 'America/Sao_Paulo', 'phase_type': 'Primary Trading Session',
          'phase_name': 'Trading Session', 'phase_memo': None, 'days': 'Mon-Fri', 'offset_days': 0,
-         'duration': '14100', 'min_start': None, 'max_start': None, 'min_end': None, 'max_end': None,
+         'duration': 14100, 'min_start': None, 'max_start': None, 'min_end': None, 'max_end': None,
          'in_force_end_date': None, 'season_end': None, 'end_with_offset': '16:55:00   ', 'has_season': False},
         {'schedule_group': 'Partial', 'in_force_start_date': datetime.date(2022, 12, 19), 'season_start': None,
          'start': datetime.time(16, 55), 'end': datetime.time(17, 0), 'fin_id': 'BR.BOVESPA',
          'schedule_group_memo': None, 'timezone': 'America/Sao_Paulo', 'phase_type': 'Call Auction',
-         'phase_name': 'Closing Call', 'phase_memo': None, 'days': 'Mon-Fri', 'offset_days': 0, 'duration': '300',
+         'phase_name': 'Closing Call', 'phase_memo': None, 'days': 'Mon-Fri', 'offset_days': 0, 'duration': 300,
          'min_start': None, 'max_start': None, 'min_end': None, 'max_end': None, 'in_force_end_date': None,
          'season_end': None, 'end_with_offset': '17:00:00   ', 'has_season': False},
         {'schedule_group': 'Partial', 'in_force_start_date': datetime.date(2022, 12, 19), 'season_start': None,
          'start': datetime.time(17, 25), 'end': datetime.time(17, 30), 'fin_id': 'BR.BOVESPA',
          'schedule_group_memo': None, 'timezone': 'America/Sao_Paulo', 'phase_type': 'Other',
          'phase_name': 'After Market - Order Cancelation', 'phase_memo': None, 'days': 'Mon-Fri', 'offset_days': 0,
-         'duration': '300', 'min_start': None, 'max_start': None, 'min_end': None, 'max_end': None,
+         'duration': 300, 'min_start': None, 'max_start': None, 'min_end': None, 'max_end': None,
          'in_force_end_date': None, 'season_end': None, 'end_with_offset': '17:30:00   ', 'has_season': False},
         {'schedule_group': 'Partial', 'in_force_start_date': datetime.date(2022, 12, 19), 'season_start': None,
          'start': datetime.time(17, 30), 'end': datetime.time(18, 0), 'fin_id': 'BR.BOVESPA',
          'schedule_group_memo': None, 'timezone': 'America/Sao_Paulo', 'phase_type': 'Post-Trading Session',
          'phase_name': 'After Market - Trading Session', 'phase_memo': None, 'days': 'Mon-Fri', 'offset_days': 0,
-         'duration': '1800', 'min_start': None, 'max_start': None, 'min_end': None, 'max_end': None,
+         'duration': 1800, 'min_start': None, 'max_start': None, 'min_end': None, 'max_end': None,
          'in_force_end_date': None, 'season_end': None, 'end_with_offset': '18:00:00   ', 'has_season': False},
         {'schedule_group': 'Partial', 'in_force_start_date': datetime.date(2022, 12, 19), 'season_start': None,
          'start': datetime.time(18, 25), 'end': datetime.time(18, 45), 'fin_id': 'BR.BOVESPA',
          'schedule_group_memo': None, 'timezone': 'America/Sao_Paulo', 'phase_type': 'Other',
          'phase_name': 'After Market - Order Cancelation', 'phase_memo': None, 'days': 'Mon-Fri',
-         'offset_days': 0, 'duration': '1200', 'min_start': None, 'max_start': None, 'min_end': None,
+         'offset_days': 0, 'duration': 1200, 'min_start': None, 'max_start': None, 'min_end': None,
          'max_end': None, 'in_force_end_date': None, 'season_end': None, 'end_with_offset': '18:45:00   ',
          'has_season': False},
         {'schedule_group': 'Regular', 'in_force_start_date': None, 'season_start': 'First Sunday of November',
          'start': datetime.time(9, 30), 'end': datetime.time(9, 45), 'fin_id': 'BR.BOVESPA',
          'schedule_group_memo': None, 'timezone': 'America/Sao_Paulo', 'phase_type': 'Other',
          'phase_name': 'Order Cancelation', 'phase_memo': None, 'days': 'Mon-Fri', 'offset_days': 0,
-         'duration': '900', 'min_start': None, 'max_start': None, 'min_end': None, 'max_end': None,
+         'duration': 900, 'min_start': None, 'max_start': None, 'min_end': None, 'max_end': None,
          'in_force_end_date': datetime.date(2022, 12, 18), 'season_end': 'Previous day Second Sunday of march',
          'end_with_offset': '09:45:00   ', 'has_season': True},
         {'schedule_group': 'Regular', 'in_force_start_date': None, 'season_start': 'First Sunday of November',
          'start': datetime.time(9, 45), 'end': datetime.time(10, 0), 'fin_id': 'BR.BOVESPA',
          'schedule_group_memo': None, 'timezone': 'America/Sao_Paulo', 'phase_type': 'Pre-Open',
          'phase_name': 'Opening Session', 'phase_memo': None, 'days': 'Mon-Fri', 'offset_days': 0,
-         'duration': '900', 'min_start': None, 'max_start': None, 'min_end': None, 'max_end': None,
+         'duration': 900, 'min_start': None, 'max_start': None, 'min_end': None, 'max_end': None,
          'in_force_end_date': datetime.date(2022, 12, 18), 'season_end': 'Previous day Second Sunday of march',
          'end_with_offset': '10:00:00   ', 'has_season': True},
         {'schedule_group': 'Regular', 'in_force_start_date': None, 'season_start': 'First Sunday of November',
          'start': datetime.time(10, 0), 'end': datetime.time(17, 55), 'fin_id': 'BR.BOVESPA',
          'schedule_group_memo': None, 'timezone': 'America/Sao_Paulo', 'phase_type': 'Primary Trading Session',
          'phase_name': 'Trading Session', 'phase_memo': None, 'days': 'Mon-Fri', 'offset_days': 0,
-         'duration': '28500', 'min_start': None, 'max_start': None, 'min_end': None, 'max_end': None,
+         'duration': 28500, 'min_start': None, 'max_start': None, 'min_end': None, 'max_end': None,
          'in_force_end_date': datetime.date(2022, 12, 18), 'season_end': 'Previous day Second Sunday of march',
          'end_with_offset': '17:55:00   ', 'has_season': True},
         {'schedule_group': 'Regular', 'in_force_start_date': None, 'season_start': 'First Sunday of November',
          'start': datetime.time(17, 55), 'end': datetime.time(18, 0), 'fin_id': 'BR.BOVESPA',
          'schedule_group_memo': None, 'timezone': 'America/Sao_Paulo', 'phase_type': 'Call Auction',
          'phase_name': 'Closing Call', 'phase_memo': None, 'days': 'Mon-Fri', 'offset_days': 0,
-         'duration': '300', 'min_start': None, 'max_start': None, 'min_end': None, 'max_end': None,
+         'duration': 300, 'min_start': None, 'max_start': None, 'min_end': None, 'max_end': None,
          'in_force_end_date': datetime.date(2022, 12, 18), 'season_end': 'Previous day Second Sunday of march',
          'end_with_offset': '18:00:00   ', 'has_season': True},
         {'schedule_group': 'Regular', 'in_force_start_date': None, 'season_start': 'Second Sunday of March',
          'start': datetime.time(9, 30), 'end': datetime.time(9, 45), 'fin_id': 'BR.BOVESPA',
          'schedule_group_memo': None, 'timezone': 'America/Sao_Paulo', 'phase_type': 'Other',
          'phase_name': 'Order Cancelation', 'phase_memo': None, 'days': 'Mon-Fri', 'offset_days': 0,
-         'duration': '900', 'min_start': None, 'max_start': None, 'min_end': None, 'max_end': None,
+         'duration': 900, 'min_start': None, 'max_start': None, 'min_end': None, 'max_end': None,
          'in_force_end_date': datetime.date(2022, 12, 18), 'season_end': 'Previous day first Sunday of November',
          'end_with_offset': '09:45:00   ', 'has_season': True},
         {'schedule_group': 'Regular', 'in_force_start_date': None, 'season_start': 'Second Sunday of March',
          'start': datetime.time(9, 45), 'end': datetime.time(10, 0), 'fin_id': 'BR.BOVESPA',
          'schedule_group_memo': None, 'timezone': 'America/Sao_Paulo', 'phase_type': 'Pre-Open',
          'phase_name': 'Opening Session', 'phase_memo': None, 'days': 'Mon-Fri', 'offset_days': 0,
-         'duration': '900', 'min_start': None, 'max_start': None, 'min_end': None, 'max_end': None,
+         'duration': 900, 'min_start': None, 'max_start': None, 'min_end': None, 'max_end': None,
          'in_force_end_date': datetime.date(2022, 12, 18), 'season_end': 'Previous day first Sunday of November',
          'end_with_offset': '10:00:00   ', 'has_season': True},
         {'schedule_group': 'Regular', 'in_force_start_date': None, 'season_start': 'Second Sunday of March',
          'start': datetime.time(10, 0), 'end': datetime.time(16, 55), 'fin_id': 'BR.BOVESPA',
          'schedule_group_memo': None, 'timezone': 'America/Sao_Paulo', 'phase_type': 'Primary Trading Session',
          'phase_name': 'Trading Session', 'phase_memo': None, 'days': 'Mon-Fri', 'offset_days': 0,
-         'duration': '24900', 'min_start': None, 'max_start': None, 'min_end': None, 'max_end': None,
+         'duration': 24900, 'min_start': None, 'max_start': None, 'min_end': None, 'max_end': None,
          'in_force_end_date': datetime.date(2022, 12, 18), 'season_end': 'Previous day first Sunday of November',
          'end_with_offset': '16:55:00   ', 'has_season': True},
         {'schedule_group': 'Regular', 'in_force_start_date': None, 'season_start': 'Second Sunday of March',
          'start': datetime.time(16, 55), 'end': datetime.time(17, 0), 'fin_id': 'BR.BOVESPA',
          'schedule_group_memo': None, 'timezone': 'America/Sao_Paulo', 'phase_type': 'Call Auction',
          'phase_name': 'Closing Call', 'phase_memo': None, 'days': 'Mon-Fri', 'offset_days': 0,
-         'duration': '300', 'min_start': None, 'max_start': None, 'min_end': None, 'max_end': None,
+         'duration': 300, 'min_start': None, 'max_start': None, 'min_end': None, 'max_end': None,
          'in_force_end_date': datetime.date(2022, 12, 18), 'season_end': 'Previous day first Sunday of November',
          'end_with_offset': '17:00:00   ', 'has_season': True},
         {'schedule_group': 'Regular', 'in_force_start_date': None, 'season_start': 'Second Sunday of March',
          'start': datetime.time(17, 25), 'end': datetime.time(17, 30), 'fin_id': 'BR.BOVESPA',
          'schedule_group_memo': None, 'timezone': 'America/Sao_Paulo', 'phase_type': 'Other',
          'phase_name': 'After Market - Order Cancelation', 'phase_memo': None, 'days': 'Mon-Fri',
-         'offset_days': 0, 'duration': '300', 'min_start': None, 'max_start': None, 'min_end': None, 'max_end': None,
+         'offset_days': 0, 'duration': 300, 'min_start': None, 'max_start': None, 'min_end': None, 'max_end': None,
          'in_force_end_date': datetime.date(2022, 12, 18), 'season_end': 'Previous day first Sunday of November',
          'end_with_offset': '17:30:00   ', 'has_season': True},
         {'schedule_group': 'Regular', 'in_force_start_date': None, 'season_start': 'Second Sunday of March',
          'start': datetime.time(17, 30), 'end': datetime.time(18, 0), 'fin_id': 'BR.BOVESPA',
          'schedule_group_memo': None, 'timezone': 'America/Sao_Paulo', 'phase_type': 'Post-Trading Session',
          'phase_name': 'After Market - Trading Session', 'phase_memo': None, 'days': 'Mon-Fri',
-         'offset_days': 0, 'duration': '1800', 'min_start': None, 'max_start': None, 'min_end': None,
+         'offset_days': 0, 'duration': 1800, 'min_start': None, 'max_start': None, 'min_end': None,
          'max_end': None, 'in_force_end_date': datetime.date(2022, 12, 18),
          'season_end': 'Previous day first Sunday of November', 'end_with_offset': '18:00:00   ',
          'has_season': True},
@@ -241,21 +244,21 @@
          'start': datetime.time(18, 30), 'end': datetime.time(18, 45), 'fin_id': 'BR.BOVESPA',
          'schedule_group_memo': None, 'timezone': 'America/Sao_Paulo', 'phase_type': 'Other',
          'phase_name': 'After Market - Order Cancelation', 'phase_memo': None, 'days': 'Mon-Fri',
-         'offset_days': 0, 'duration': '900', 'min_start': None, 'max_start': None, 'min_end': None, 'max_end': None,
+         'offset_days': 0, 'duration': 900, 'min_start': None, 'max_start': None, 'min_end': None, 'max_end': None,
          'in_force_end_date': datetime.date(2022, 12, 18), 'season_end': 'Previous day first Sunday of November',
          'end_with_offset': '18:45:00   ', 'has_season': True},
         {'schedule_group': 'Regular', 'in_force_start_date': datetime.date(2022, 12, 19), 'season_start': None,
          'start': datetime.time(18, 25), 'end': datetime.time(18, 45), 'fin_id': 'BR.BOVESPA',
          'schedule_group_memo': None, 'timezone': 'America/Sao_Paulo', 'phase_type': 'Other',
          'phase_name': 'After Market - Order Cancelation', 'phase_memo': None, 'days': 'Mon-Fri',
-         'offset_days': 0, 'duration': '1200', 'min_start': None, 'max_start': None,
+         'offset_days': 0, 'duration': 1200, 'min_start': None, 'max_start': None,
          'min_end': None, 'max_end': None, 'in_force_end_date': None, 'season_end': None,
          'end_with_offset': '18:45:00   ', 'has_season': False},
         {'schedule_group': 'Regular', 'in_force_start_date': datetime.date(2022, 12, 19),
          'season_start': 'First Sunday of November', 'start': datetime.time(9, 30), 'end': datetime.time(9, 45),
          'fin_id': 'BR.BOVESPA', 'schedule_group_memo': None, 'timezone': 'America/Sao_Paulo',
          'phase_type': 'Other', 'phase_name': 'Order Cancelation', 'phase_memo': None,
-         'days': 'Mon-Fri', 'offset_days': 0, 'duration': '900', 'min_start': None, 'max_start': None,
+         'days': 'Mon-Fri', 'offset_days': 0, 'duration': 900, 'min_start': None, 'max_start': None,
          'min_end': None, 'max_end': None, 'in_force_end_date': None,
          'season_end': 'Previous day Second Sunday of march', 'end_with_offset': '09:45:00   ',
          'has_season': True},
@@ -263,59 +266,59 @@
          'season_start': 'First Sunday of November', 'start': datetime.time(9, 45), 'end': datetime.time(10, 0),
          'fin_id': 'BR.BOVESPA', 'schedule_group_memo': None, 'timezone': 'America/Sao_Paulo',
          'phase_type': 'Pre-Open', 'phase_name': 'Opening Session', 'phase_memo': None, 'days': 'Mon-Fri',
-         'offset_days': 0, 'duration': '900', 'min_start': None, 'max_start': None, 'min_end': None,
+         'offset_days': 0, 'duration': 900, 'min_start': None, 'max_start': None, 'min_end': None,
          'max_end': None, 'in_force_end_date': None, 'season_end': 'Previous day Second Sunday of march',
          'end_with_offset': '10:00:00   ', 'has_season': True},
         {'schedule_group': 'Regular', 'in_force_start_date': datetime.date(2022, 12, 19),
          'season_start': 'First Sunday of November', 'start': datetime.time(10, 0),
          'end': datetime.time(17, 55), 'fin_id': 'BR.BOVESPA', 'schedule_group_memo': None,
          'timezone': 'America/Sao_Paulo', 'phase_type': 'Primary Trading Session', 'phase_name': 'Trading Session',
-         'phase_memo': None, 'days': 'Mon-Fri', 'offset_days': 0, 'duration': '28500', 'min_start': None,
+         'phase_memo': None, 'days': 'Mon-Fri', 'offset_days': 0, 'duration': 28500, 'min_start': None,
          'max_start': None, 'min_end': None, 'max_end': None, 'in_force_end_date': None,
          'season_end': 'Previous day Second Sunday of march', 'end_with_offset': '17:55:00   ',
          'has_season': True},
      {'schedule_group': 'Regular', 'in_force_start_date': datetime.date(2022, 12, 19),
       'season_start': 'First Sunday of November', 'start': datetime.time(17, 55), 'end': datetime.time(18, 0),
       'fin_id': 'BR.BOVESPA', 'schedule_group_memo': None, 'timezone': 'America/Sao_Paulo', 'phase_type': 'Call Auction',
-      'phase_name': 'Closing Call', 'phase_memo': None, 'days': 'Mon-Fri', 'offset_days': 0, 'duration': '300',
+      'phase_name': 'Closing Call', 'phase_memo': None, 'days': 'Mon-Fri', 'offset_days': 0, 'duration': 300,
       'min_start': None, 'max_start': None, 'min_end': None, 'max_end': None, 'in_force_end_date': None,
       'season_end': 'Previous day Second Sunday of march', 'end_with_offset': '18:00:00   ', 'has_season': True},
      {'schedule_group': 'Regular', 'in_force_start_date': datetime.date(2022, 12, 19), 'season_start': 'Second Sunday of March',
       'start': datetime.time(9, 30), 'end': datetime.time(9, 45), 'fin_id': 'BR.BOVESPA', 'schedule_group_memo': None,
       'timezone': 'America/Sao_Paulo', 'phase_type': 'Other', 'phase_name': 'Order Cancelation', 'phase_memo': None,
-      'days': 'Mon-Fri', 'offset_days': 0, 'duration': '900', 'min_start': None, 'max_start': None,
+      'days': 'Mon-Fri', 'offset_days': 0, 'duration': 900, 'min_start': None, 'max_start': None,
       'min_end': None, 'max_end': None, 'in_force_end_date': None, 'season_end': 'Previous day first Sunday of November',
       'end_with_offset': '09:45:00   ', 'has_season': True},
      {'schedule_group': 'Regular', 'in_force_start_date': datetime.date(2022, 12, 19),
       'season_start': 'Second Sunday of March', 'start': datetime.time(9, 45), 'end': datetime.time(10, 0),
       'fin_id': 'BR.BOVESPA', 'schedule_group_memo': None, 'timezone': 'America/Sao_Paulo', 'phase_type': 'Pre-Open',
-      'phase_name': 'Opening Session', 'phase_memo': None, 'days': 'Mon-Fri', 'offset_days': 0, 'duration': '900',
+      'phase_name': 'Opening Session', 'phase_memo': None, 'days': 'Mon-Fri', 'offset_days': 0, 'duration': 900,
       'min_start': None, 'max_start': None, 'min_end': None, 'max_end': None, 'in_force_end_date': None,
       'season_end': 'Previous day first Sunday of November', 'end_with_offset': '10:00:00   ', 'has_season': True},
      {'schedule_group': 'Regular', 'in_force_start_date': datetime.date(2022, 12, 19),
       'season_start': 'Second Sunday of March', 'start': datetime.time(10, 0), 'end': datetime.time(16, 55),
       'fin_id': 'BR.BOVESPA', 'schedule_group_memo': None, 'timezone': 'America/Sao_Paulo',
       'phase_type': 'Primary Trading Session', 'phase_name': 'Trading Session', 'phase_memo': None,
-      'days': 'Mon-Fri', 'offset_days': 0, 'duration': '24900', 'min_start': None, 'max_start': None,
+      'days': 'Mon-Fri', 'offset_days': 0, 'duration': 24900, 'min_start': None, 'max_start': None,
       'min_end': None, 'max_end': None, 'in_force_end_date': None, 'season_end': 'Previous day first Sunday of November',
       'end_with_offset': '16:55:00   ', 'has_season': True},
      {'schedule_group': 'Regular', 'in_force_start_date': datetime.date(2022, 12, 19), 'season_start': 'Second Sunday of March',
       'start': datetime.time(16, 55), 'end': datetime.time(17, 0), 'fin_id': 'BR.BOVESPA', 'schedule_group_memo': None,
       'timezone': 'America/Sao_Paulo', 'phase_type': 'Call Auction', 'phase_name': 'Closing Call',
-      'phase_memo': None, 'days': 'Mon-Fri', 'offset_days': 0, 'duration': '300', 'min_start': None,
+      'phase_memo': None, 'days': 'Mon-Fri', 'offset_days': 0, 'duration': 300, 'min_start': None,
       'max_start': None, 'min_end': None, 'max_end': None, 'in_force_end_date': None,
       'season_end': 'Previous day first Sunday of November', 'end_with_offset': '17:00:00   ', 'has_season': True},
      {'schedule_group': 'Regular', 'in_force_start_date': datetime.date(2022, 12, 19), 'season_start': 'Second Sunday of March',
       'start': datetime.time(17, 25), 'end': datetime.time(17, 30), 'fin_id': 'BR.BOVESPA', 'schedule_group_memo': None,
       'timezone': 'America/Sao_Paulo', 'phase_type': 'Other', 'phase_name': 'After Market - Order Cancelation',
-      'phase_memo': None, 'days': 'Mon-Fri', 'offset_days': 0, 'duration': '300', 'min_start': None, 'max_start': None,
+      'phase_memo': None, 'days': 'Mon-Fri', 'offset_days': 0, 'duration': 300, 'min_start': None, 'max_start': None,
       'min_end': None, 'max_end': None, 'in_force_end_date': None, 'season_end': 'Previous day first Sunday of November',
       'end_with_offset': '17:30:00   ', 'has_season': True},
      {'schedule_group': 'Regular', 'in_force_start_date': datetime.date(2022, 12, 19),
       'season_start': 'Second Sunday of March', 'start': datetime.time(17, 30), 'end': datetime.time(18, 0),
       'fin_id': 'BR.BOVESPA', 'schedule_group_memo': None, 'timezone': 'America/Sao_Paulo',
       'phase_type': 'Post-Trading Session', 'phase_name': 'After Market - Trading Session', 'phase_memo': None,
-      'days': 'Mon-Fri', 'offset_days': 0, 'duration': '1800', 'min_start': None, 'max_start': None, 'min_end': None,
+      'days': 'Mon-Fri', 'offset_days': 0, 'duration': 1800, 'min_start': None, 'max_start': None, 'min_end': None,
       'max_end': None, 'in_force_end_date': None, 'season_end': 'Previous day first Sunday of November', 'end_with_offset': '18:00:00   ',
       'has_season': True}]
 )
@@ -323,16 +326,24 @@
 def test_list_schedules(fin_id, expected):
     scheds = Market.get(fin_id).list_schedules()
     scheds = [s.to_dict() for s in scheds]
-    schedules = []
-    for s in scheds:
-        # apply custom sorting of dictionaries
-        # for easier check if the sorting in .list_schedules is correct
-        schedules.append(
-            {"schedule_group": s["schedule_group"],
-             "in_force_start_date": s["in_force_start_date"],
-             "season_start": s["season_start"],
-             "start": s["start"],
-             "end": s["end"],
-             **s}
-        )
-    assert schedules == expected
\ No newline at end of file
+
+    assert len(scheds) == len(expected)
+
+    matched = []
+    for sched in scheds:
+        equals = 0
+        for i, expect in enumerate(expected):
+            if i in matched: continue
+            if (sorted(sched.keys()) == sorted(expect.keys()) and
+                    all(sched[k] == expect[k] for k in sched)):
+                    matched.append(i)
+                    equals += 1
+        assert equals == 1
+
+
+        # # apply custom sorting of dictionaries
+        # # for easier check if the sorting in .list_schedules is correct
+        # assert sorted(sched.keys()) == sorted(expect.keys())
+        # for k in sched:
+        #     assert sched[k] == expect[k], f"{k} failed, expected:\n{pformat(expect)}"
+        #
diff --git a/tests/test_timezone.py b/tests/test_timezone.py
index 114872e..03671b5 100644
--- a/tests/test_timezone.py
+++ b/tests/test_timezone.py
@@ -4,8 +4,8 @@
 
 from tradinghours import Currency, Market
 from tradinghours.exceptions import NoAccess
+import tradinghours.store as st
 
-LEVEL = os.environ.get("API_KEY_LEVEL", "full").strip()
 
 def _convert(naive_dt, timezone_str):
     return naive_dt.replace(tzinfo=ZoneInfo(timezone_str))
@@ -51,7 +51,10 @@ def test_timezone_conversion(dt, new_timezone_str, expected_dt):
     assert dt.astimezone(ZoneInfo(new_timezone_str)) == expected_dt
 
 
-@pytest.mark.xfail(LEVEL != "full", reason="No access", strict=True, raises=NoAccess)
+@pytest.mark.xfail(
+    st.db.access_level != st.AccessLevel.full,
+    reason="No access", strict=True, raises=NoAccess
+)
 @pytest.mark.parametrize("currency, timezone", [
     ("BRL", "America/Sao_Paulo"),
     ("CAD", "America/Toronto"),
@@ -61,8 +64,7 @@ def test_timezone_conversion(dt, new_timezone_str, expected_dt):
 def test_currency_timezone(currency, timezone):
     currency = Currency.get(currency)
     assert currency.financial_capital_timezone == timezone
-    assert isinstance(currency.financial_capital_timezone_obj, ZoneInfo)
-    assert currency.financial_capital_timezone_obj == ZoneInfo(timezone)
+    assert ZoneInfo(currency.financial_capital_timezone) == ZoneInfo(timezone)
 
 
 
@@ -75,8 +77,7 @@ def test_currency_timezone(currency, timezone):
 def test_market_timezone(market, timezone):
     market = Market.get(market)
     assert market.timezone == timezone
-    assert isinstance(market.timezone_obj, ZoneInfo)
-    assert market.timezone_obj == ZoneInfo(timezone)
+    assert ZoneInfo(market.timezone) == ZoneInfo(timezone)
 
 
 
diff --git a/tests/test_util.py b/tests/test_util.py
index 03b0b3a..fd9fba0 100644
--- a/tests/test_util.py
+++ b/tests/test_util.py
@@ -6,86 +6,18 @@
 import requests.exceptions
 from requests.models import Response
 
-from tradinghours.util import (StrEncoder,
-                               slugify,
-                               snake_case,
-                               snake_dict,
-                               _get_latest_tzdata_version,
+from tradinghours.util import (_get_latest_tzdata_version,
                                check_if_tzdata_required_and_up_to_date)
 
 from tradinghours.exceptions import MissingTzdata
 import importlib.metadata as metadata
 
-
-@pytest.mark.parametrize("name, expected", [
-    ("Hello World", "hello-world"),
-    ("Hello!@#$%^World", "hello-world"),
-    ("Hello    World", "hello-world"),
-])
-def test_slugify(level, name, expected):
-    assert slugify(name) == expected
-
-
-@pytest.mark.parametrize("name, exception", [
-    (None, ValueError),
-    (123, TypeError),
-])
-def test_slugify_with_errors(level, name, exception):
-    with pytest.raises(exception):
-        slugify(name)
-
-
-@pytest.mark.parametrize("text, expected", [
-    ("hello-world", "hello_world"),
-    ("helloWorld", "hello_world"),
-    ("HELLO_WORLD", "hello_world"),
-])
-def test_snake_case(level, text, expected):
-    assert snake_case(text) == expected
-
-
-@pytest.mark.parametrize("text, exception", [
-    (None, ValueError),
-    (123, TypeError),
-])
-def test_snake_case_with_errors(level, text, exception):
-    with pytest.raises(exception):
-        snake_case(text)
-
-
-def test_snake_dict(level):
-    data = {"First Name": "John", "Last Name": "Doe", "Age": 30}
-    expected = {"first_name": "John", "last_name": "Doe", "age": 30}
-    assert snake_dict(data) == expected
-
-def test_snake_dict_with_errors(level):
-    with pytest.raises(TypeError):
-        snake_dict("This is not a dictionary")
-
-def test_str_encoder(level):
-    data = {"name": "John", "age": 30}
-    expected = '{"name": "John", "age": 30}'
-    assert json.dumps(data, cls=StrEncoder) == expected
-
-def test_str_encoder_non_serializable(level):
-    data = {"name": "John", "age": 30, "non_serializable": set()}
-    expected = '{"name": "John", "age": 30, "non_serializable": "set()"}'
-    assert json.dumps(data, cls=StrEncoder) == expected
-
-
-################
-# TZDATA CHECK #
-################
-
 @pytest.fixture
 def mock_requests_get(mocker):
     mock_response = MagicMock(spec=Response)
     mocker.patch("tradinghours.util.requests.get", return_value=mock_response)
     return mock_response
 
-#############################
-### _get_latest_version tests
-
 def test_latest_version_success(mock_requests_get):
     mock_requests_get.status_code = 200
     mock_requests_get.json.return_value = {"info": {"version": "2021.1"}}
@@ -102,10 +34,6 @@ def test_latest_version_failure(mock_requests_get):
     assert _get_latest_tzdata_version() is None
 
 
-
-#################################################
-### check_if_tzdata_required_and_up_to_date tests
-
 def test_check_tzdata_disbaled(mocker):
     mocker.patch("tradinghours.util.main_config.getboolean", return_value=False)
     assert check_if_tzdata_required_and_up_to_date() is False
diff --git a/tests/test_validate.py b/tests/test_validate.py
new file mode 100644
index 0000000..0d3e41c
--- /dev/null
+++ b/tests/test_validate.py
@@ -0,0 +1,48 @@
+import pytest
+import datetime as dt
+import tradinghours.validate as val
+
+def test_validate_date_arg():
+    with pytest.raises(ValueError):
+        val.validate_date_arg("test", None)
+
+    with pytest.raises(TypeError):
+        val.validate_date_arg("test", dt.datetime.now())
+
+    with pytest.raises(ValueError):
+        val.validate_date_arg("test", "2024-01-01 00:00:00")
+
+    with pytest.raises(ValueError):
+        val.validate_date_arg("test", "sdgssdg")
+
+    date = dt.date.fromisoformat("2024-01-01")
+    assert date == val.validate_date_arg("test", date)
+    assert date == val.validate_date_arg("test", "2024-01-01")
+
+
+def test_validate_range_args():
+    with pytest.raises(ValueError):
+        val.validate_range_args(2, 1)
+
+    assert val.validate_range_args(1, 1) == (1, 1)
+
+    assert val.validate_range_args(1, 2) == (1, 2)
+
+
+def test_validate_mic_arg():
+    with pytest.raises(ValueError):
+        val.validate_mic_arg(None)
+
+    with pytest.raises(TypeError):
+        val.validate_mic_arg(123)
+
+    with pytest.raises(ValueError):
+        val.validate_mic_arg("12345")
+
+    with pytest.raises(ValueError):
+        val.validate_mic_arg("US.YE")
+
+    assert val.validate_mic_arg("nyse") == "NYSE"
+    assert val.validate_mic_arg("12Me") == "12ME"
+
+
diff --git a/tests/utils.py b/tests/utils.py
new file mode 100644
index 0000000..473bf18
--- /dev/null
+++ b/tests/utils.py
@@ -0,0 +1,7 @@
+import datetime as dt
+from zoneinfo import ZoneInfo
+
+
+def fromiso(iso: str, tz: str) -> dt.datetime:
+    d = dt.datetime.fromisoformat(iso)
+    return d.replace(tzinfo=ZoneInfo(tz))