firstrate-data 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,38 @@
1
+ from firstrate_data.domain import (
2
+ Adjustment,
3
+ AssetType,
4
+ BarType,
5
+ ContinuousFuturesAdjustment,
6
+ ContractFiles,
7
+ DelistedArchive,
8
+ DelistedUpdate,
9
+ EquitiesAdjustment,
10
+ OtherData,
11
+ Period,
12
+ TickerListing,
13
+ Timeframe,
14
+ TradingHours,
15
+ Unadjusted,
16
+ )
17
+ from firstrate_data.download.client import Client
18
+ from firstrate_data.store.store import Ingested, Store
19
+
20
+ __all__ = [
21
+ "Adjustment",
22
+ "AssetType",
23
+ "BarType",
24
+ "Client",
25
+ "ContinuousFuturesAdjustment",
26
+ "ContractFiles",
27
+ "DelistedArchive",
28
+ "DelistedUpdate",
29
+ "EquitiesAdjustment",
30
+ "Ingested",
31
+ "OtherData",
32
+ "Period",
33
+ "Store",
34
+ "TickerListing",
35
+ "Timeframe",
36
+ "TradingHours",
37
+ "Unadjusted",
38
+ ]
@@ -0,0 +1,49 @@
1
+ import os
2
+ from pathlib import Path
3
+
4
+ from dotenv import find_dotenv, load_dotenv
5
+
6
+ DEFAULT_BASE_URL = "https://firstratedata.com/api"
7
+ _BASE_URL_KEY = "FIRSTRATE_BASE_URL"
8
+
9
+
10
+ class MissingSettingError(KeyError):
11
+ pass
12
+
13
+
14
+ def _find_and_load_dotenv_cwd() -> None:
15
+ load_dotenv(find_dotenv(usecwd=True))
16
+
17
+
18
+ def _raise_if_missing(env_variable: str) -> str:
19
+ _find_and_load_dotenv_cwd()
20
+ value = os.getenv(env_variable)
21
+ if value:
22
+ return value
23
+ msg = f"set {env_variable} environment variable"
24
+ raise MissingSettingError(msg)
25
+
26
+
27
+ # ----------------------------------------------------------------------
28
+ # Store config
29
+ # ----------------------------------------------------------------------
30
+
31
+
32
+ def firstrate_data_path() -> Path:
33
+ return Path(_raise_if_missing("FIRSTRATE_DATA_PATH"))
34
+
35
+
36
+ # ----------------------------------------------------------------------
37
+ # Client config
38
+ # ----------------------------------------------------------------------
39
+
40
+
41
+ def firstrate_user_id() -> str:
42
+ return _raise_if_missing("FIRSTRATE_USERID")
43
+
44
+
45
+ def base_url() -> str:
46
+ """Return the vendor's API root."""
47
+ # FIRSTRATE_BASE_URL lets a test point the client at a stand-in server.
48
+ _find_and_load_dotenv_cwd()
49
+ return os.getenv(_BASE_URL_KEY, DEFAULT_BASE_URL)
@@ -0,0 +1,33 @@
1
+ from firstrate_data.domain.bar_type import BarType
2
+ from firstrate_data.domain.enums import (
3
+ Adjustment,
4
+ AssetType,
5
+ ContinuousFuturesAdjustment,
6
+ ContractFiles,
7
+ DelistedArchive,
8
+ DelistedUpdate,
9
+ EquitiesAdjustment,
10
+ OtherData,
11
+ Period,
12
+ Timeframe,
13
+ TradingHours,
14
+ Unadjusted,
15
+ )
16
+ from firstrate_data.domain.ticker_listing import TickerListing
17
+
18
+ __all__ = [
19
+ "Adjustment",
20
+ "AssetType",
21
+ "BarType",
22
+ "ContinuousFuturesAdjustment",
23
+ "ContractFiles",
24
+ "DelistedArchive",
25
+ "DelistedUpdate",
26
+ "EquitiesAdjustment",
27
+ "OtherData",
28
+ "Period",
29
+ "TickerListing",
30
+ "Timeframe",
31
+ "TradingHours",
32
+ "Unadjusted",
33
+ ]
@@ -0,0 +1,34 @@
1
+ from dataclasses import dataclass, fields, replace
2
+ from typing import Self
3
+
4
+ from firstrate_data.domain.enums import Adjustment, AssetType, Timeframe
5
+
6
+
7
+ @dataclass(frozen=True, slots=True)
8
+ class BarType:
9
+ # declared in the same order used as the store's paths.
10
+ asset_type: AssetType | None = None
11
+ adjustment: Adjustment | None = None
12
+ timeframe: Timeframe | None = None
13
+ ticker: str | None = None
14
+
15
+ def from_ticker(self, ticker: str | None) -> Self:
16
+ """Return a copy of this `BarType` with `ticker` swapped in."""
17
+ return replace(self, ticker=ticker)
18
+
19
+ def levels(self) -> dict[str, str | None]:
20
+ """Every level of the tree, in the tree's order. None where unstated."""
21
+ return {
22
+ f.name: None if (value := getattr(self, f.name)) is None else str(value)
23
+ for f in fields(self)
24
+ }
25
+
26
+ def stated_levels(self) -> dict[str, str]:
27
+ """The levels this bar type names, in the tree's order."""
28
+ return {
29
+ name: value for name, value in self.levels().items() if value is not None
30
+ }
31
+
32
+ @classmethod
33
+ def fields(cls) -> list[str]:
34
+ return [f.name for f in fields(cls)]
@@ -0,0 +1,120 @@
1
+ from __future__ import annotations
2
+
3
+ from enum import StrEnum, auto
4
+ from typing import Self
5
+
6
+
7
+ class AssetType(StrEnum):
8
+ STOCK = auto()
9
+ ETF = auto()
10
+ INDEX = auto()
11
+ FUTURES = auto()
12
+ CRYPTO = auto()
13
+ FX = auto()
14
+ OPTIONS = auto()
15
+
16
+ def timezone(self) -> str:
17
+ if self is AssetType.CRYPTO:
18
+ return "UTC"
19
+ return "America/New_York"
20
+
21
+
22
+ # adjustments ----------------------------------------------------------
23
+
24
+
25
+ class Adjustment(StrEnum):
26
+ @property
27
+ def changes_past(self) -> bool:
28
+ """Return `True` for all adjustments which are not `UNADJUSTED`.
29
+
30
+ A series holding these kind of adjustments need to be fully replaced
31
+ when a new adjustment comes.
32
+ """
33
+ return self.name != "UNADJUSTED"
34
+
35
+
36
+ class Unadjusted(Adjustment):
37
+ UNADJUSTED = "UNADJUSTED"
38
+
39
+
40
+ class EquitiesAdjustment(Adjustment):
41
+ SPLIT = "adj_split"
42
+ SPLIT_AND_DIVIDEND = "adj_splitdiv"
43
+ UNADJUSTED = "UNADJUSTED"
44
+
45
+
46
+ class ContinuousFuturesAdjustment(Adjustment):
47
+ RATIO = "contin_adj_ratio"
48
+ ABSOLUTE = "contin_adj_absolute"
49
+ UNADJUSTED = "contin_UNadj"
50
+
51
+
52
+ class Timeframe(StrEnum):
53
+ MIN_1 = "1min"
54
+ MIN_5 = "5min"
55
+ MIN_30 = "30min"
56
+ HOUR_1 = "1hour"
57
+ DAY_1 = "1day"
58
+
59
+ def is_higher_than(self, other_timeframe: Self) -> bool:
60
+ order = list(Timeframe)
61
+ return order.index(self) > order.index(other_timeframe)
62
+
63
+
64
+ # ----------------------------------------------------------------------
65
+
66
+
67
+ class TradingHours(StrEnum):
68
+ ALL = auto()
69
+ REGULAR = auto()
70
+
71
+
72
+ class Period(StrEnum):
73
+ FULL = auto()
74
+ MONTH = auto()
75
+ WEEK = auto()
76
+ DAY = auto()
77
+
78
+
79
+ class OtherData(StrEnum):
80
+ SPLITS = auto()
81
+ DIVIDENDS = auto()
82
+ COMPANY_PROFILES = auto()
83
+ CONTRACT_DATES = "contin_audit"
84
+
85
+
86
+ class ContractFiles(StrEnum):
87
+ """Which half of the individual-contract dataset a request names.
88
+
89
+ The vendor splits it in two: the archive freezes everything up to 2025,
90
+ and the update carries the contracts trading from 2026, refreshed daily.
91
+ They name different contracts, so neither contains the other.
92
+ """
93
+
94
+ ARCHIVE = auto()
95
+ UPDATE = auto()
96
+
97
+
98
+ # ----------------------------------------------------------------------
99
+ # delisted data
100
+ # ----------------------------------------------------------------------
101
+
102
+
103
+ class DelistedArchive(StrEnum):
104
+ """One slice of the pre-2026 delisted history, downloaded on its own."""
105
+
106
+ # the docs list accepted values 1-5 but say "the four historical archives"
107
+ # in the same breath. The values win: a fifth archive that doesn't exist
108
+ # fails on the request, while omitting one that does loses data unseen.
109
+ ARCHIVE_1 = "1"
110
+ ARCHIVE_2 = "2"
111
+ ARCHIVE_3 = "3"
112
+ ARCHIVE_4 = "4"
113
+ ARCHIVE_5 = "5"
114
+
115
+
116
+ class DelistedUpdate(StrEnum):
117
+ """The 2026+ delisted data, refreshed each Sunday at 11 PM, Eastern."""
118
+
119
+ WEEK = auto()
120
+ YEAR = auto()
@@ -0,0 +1,78 @@
1
+ import csv
2
+ from dataclasses import dataclass
3
+ from datetime import date
4
+ from typing import Self
5
+
6
+ _DELISTED_SUFFIX = "-DELISTED"
7
+
8
+
9
+ @dataclass(frozen=True, slots=True)
10
+ class TickerListing:
11
+ # example structure of a ticker listing file: https://firstratedata.com/api_ticker_files/etf_ticker_dates_listing.txt
12
+ # Ticker,Name,First Date,Last Date
13
+ # AAA,Listed Funds Trust Aaf First Priority Clo Bond ETF,2020-09-09,2026-08-26
14
+ ticker: str
15
+ full_name: str
16
+ start_date: date
17
+ end_date: date
18
+ is_delisted: bool
19
+
20
+ @classmethod
21
+ def from_csv(cls, csv_body: str) -> list[Self]:
22
+ # get one TickerListing for each row of file_body; the vendor heads the
23
+ # file with "Ticker,Name,First Date,Last Date"
24
+ rows = csv.reader(csv_body.splitlines())
25
+ listed_tickers = [
26
+ cls._from_row(row)
27
+ for row in rows
28
+ if any(row) and row[0].strip().casefold() != "ticker"
29
+ ]
30
+ if not listed_tickers:
31
+ msg = f"ticker_listing answered with no rows: {csv_body}"
32
+ raise ValueError(msg)
33
+ return listed_tickers
34
+
35
+ # ------------------------------------------------------------------
36
+ # helpers
37
+ # ------------------------------------------------------------------
38
+
39
+ _ROW_FIELDS = 4
40
+
41
+ @classmethod
42
+ def _from_row(cls, row: list[str]) -> Self:
43
+ if len(row) < cls._ROW_FIELDS:
44
+ msg = (
45
+ f"ticker_listing row {','.join(row)!r} is not "
46
+ f"{{ticker}},{{name}},{{startDate}},{{endDate}}"
47
+ )
48
+ raise ValueError(
49
+ msg,
50
+ )
51
+
52
+ # from both ends rather than by position: an unquoted comma in a name
53
+ # ("Dow Jones Industrial Average, Total Return") splits into extra fields.
54
+ # Those fields belong to the name.
55
+ symbol, *full_name, start_date, end_date = row
56
+ is_delisted = symbol.endswith(_DELISTED_SUFFIX)
57
+ return cls(
58
+ symbol.removesuffix(_DELISTED_SUFFIX),
59
+ # strip the name whole rather than field by field: the space after
60
+ # the comma in "S&P 500, Total Return" belongs to the name
61
+ ",".join(full_name).strip(),
62
+ cls._listing_date_from_str(start_date.strip(), row),
63
+ cls._listing_date_from_str(end_date.strip(), row),
64
+ is_delisted,
65
+ )
66
+
67
+ @staticmethod
68
+ def _listing_date_from_str(field: str, row: list[str]) -> date:
69
+ try:
70
+ return date.fromisoformat(field)
71
+ except ValueError as unreadable:
72
+ msg = (
73
+ f"ticker_listing row {','.join(row)!r} carries {field!r} "
74
+ "where a date belongs"
75
+ )
76
+ raise ValueError(
77
+ msg,
78
+ ) from unreadable
File without changes
@@ -0,0 +1,201 @@
1
+ from collections.abc import Callable, Iterable, Iterator
2
+ from dataclasses import dataclass
3
+ from string import ascii_uppercase
4
+
5
+ from firstrate_data.domain import AssetType, BarType
6
+ from firstrate_data.domain.enums import (
7
+ ContinuousFuturesAdjustment,
8
+ ContractFiles,
9
+ DelistedArchive,
10
+ EquitiesAdjustment,
11
+ OtherData,
12
+ Period,
13
+ Timeframe,
14
+ Unadjusted,
15
+ )
16
+ from firstrate_data.download.requests import (
17
+ BarsRequest,
18
+ ContractBarsRequest,
19
+ DelistedBarsRequest,
20
+ NotOfferedError,
21
+ OtherDataRequest,
22
+ Request,
23
+ )
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class BundleConfig:
28
+ """Enough for Indices, FX and Crypto.
29
+
30
+ [Index docs](https://firstratedata.com/_readme/index.txt)
31
+ [FX docs](https://firstratedata.com/_readme/fx.txt)
32
+ [Crypto docs](https://firstratedata.com/_readme/crypto.txt)
33
+ """
34
+
35
+ asset_type: AssetType
36
+ # None -> all the available timeframes
37
+ timeframes: Iterable[Timeframe] | None
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class EquitiesBundleConfig(BundleConfig):
42
+ """Use this for ETFs.
43
+
44
+ [docs](https://firstratedata.com/_readme/etf.txt).
45
+ """
46
+
47
+ adjustment: EquitiesAdjustment
48
+ # None behaves like ["A", ..., "Z"]
49
+ ticker_range: Iterable[str] | None
50
+ include_splits: bool
51
+ include_dividends: bool
52
+
53
+
54
+ UNADJUSTED_DELISTED_TIMEFRAMES = (Timeframe.DAY_1, Timeframe.MIN_1)
55
+
56
+
57
+ @dataclass(frozen=True)
58
+ class StocksBundleConfig(EquitiesBundleConfig):
59
+ """[Stocks docs](https://firstratedata.com/_readme/stock.txt)."""
60
+
61
+ include_company_profiles: bool
62
+ # false -> no archives are downloaded, true -> all of them,
63
+ # otherwise pass an iterable to select them.
64
+ include_delisted_archives: bool | Iterable[DelistedArchive]
65
+
66
+
67
+ @dataclass(frozen=True)
68
+ class FuturesBundleConfig(BundleConfig):
69
+ """[Future docs](https://firstratedata.com/_readme/futures.txt)."""
70
+
71
+ adjustment: ContinuousFuturesAdjustment
72
+ include_individual_contracts: bool | Iterable[Timeframe]
73
+ # Downloads a folder with one .txt per contract.
74
+ # Each row follows the YYYY-MM-DD,CONTRACT_CODE format.
75
+ # Useful to know rollover dates used by FirstRate in contract construction.
76
+ include_contract_dates: bool
77
+
78
+
79
+ def bundle_requests(config: BundleConfig) -> Iterator[Request]:
80
+ """Yield every request the bundle downloads, in download order."""
81
+ for timeframe in config.timeframes or tuple(Timeframe):
82
+ yield from _bar_requests(config, timeframe)
83
+ yield from _meta_requests(config)
84
+
85
+
86
+ def _bar_requests(config: BundleConfig, timeframe: Timeframe) -> Iterator[Request]:
87
+ if isinstance(config, FuturesBundleConfig):
88
+ yield from _futures_requests(config, timeframe)
89
+ elif isinstance(config, EquitiesBundleConfig):
90
+ yield from _equities_requests(config, timeframe)
91
+ else:
92
+ # UNADJUSTED is the store's word for it, not the vendor's: index, FX
93
+ # and crypto carry no adjustment on the wire, and the bar type path
94
+ # names one at every level
95
+ yield from _offered(
96
+ lambda: BarsRequest(
97
+ BarType(
98
+ config.asset_type,
99
+ timeframe=timeframe,
100
+ adjustment=Unadjusted.UNADJUSTED,
101
+ ),
102
+ Period.FULL,
103
+ ),
104
+ )
105
+
106
+
107
+ def _equities_requests(
108
+ config: EquitiesBundleConfig,
109
+ timeframe: Timeframe,
110
+ ) -> Iterator[Request]:
111
+ bar_type = BarType(
112
+ config.asset_type,
113
+ timeframe=timeframe,
114
+ adjustment=config.adjustment,
115
+ )
116
+ for letter in config.ticker_range or ascii_uppercase:
117
+ yield from _offered(
118
+ lambda letter=letter: BarsRequest(
119
+ bar_type,
120
+ Period.FULL,
121
+ ticker_range=letter,
122
+ ),
123
+ )
124
+
125
+ if not isinstance(config, StocksBundleConfig):
126
+ return
127
+ for archive in _delisted_archives(selection=config.include_delisted_archives):
128
+ yield from _offered(
129
+ lambda archive=archive: DelistedBarsRequest(bar_type, selector=archive),
130
+ )
131
+
132
+
133
+ def _futures_requests(
134
+ config: FuturesBundleConfig,
135
+ timeframe: Timeframe,
136
+ ) -> Iterator[Request]:
137
+ yield from _offered(
138
+ lambda: BarsRequest(
139
+ BarType(
140
+ AssetType.FUTURES,
141
+ timeframe=timeframe,
142
+ adjustment=config.adjustment,
143
+ ),
144
+ Period.FULL,
145
+ ),
146
+ )
147
+
148
+ if not _wants_contracts(timeframe, selection=config.include_individual_contracts):
149
+ return
150
+ # both halves name different contracts, so neither one contains the other
151
+ for half in ContractFiles:
152
+ yield ContractBarsRequest(
153
+ BarType(AssetType.FUTURES, timeframe=timeframe),
154
+ contract_files=half,
155
+ )
156
+
157
+
158
+ def _meta_requests(config: BundleConfig) -> Iterator[Request]:
159
+ # ponytail: include_company_profiles is unserved -- the vendor documents no
160
+ # endpoint for it. Yield an OtherDataRequest here once one exists.
161
+ if isinstance(config, EquitiesBundleConfig):
162
+ if config.include_splits:
163
+ yield OtherDataRequest(config.asset_type, OtherData.SPLITS)
164
+ if config.include_dividends:
165
+ yield OtherDataRequest(config.asset_type, OtherData.DIVIDENDS)
166
+ if isinstance(config, FuturesBundleConfig) and config.include_contract_dates:
167
+ yield OtherDataRequest(AssetType.FUTURES, OtherData.CONTRACT_DATES)
168
+
169
+
170
+ def _delisted_archives(
171
+ *,
172
+ selection: bool | Iterable[DelistedArchive],
173
+ ) -> tuple[DelistedArchive, ...]:
174
+ if selection is True:
175
+ return tuple(DelistedArchive)
176
+ if selection is False:
177
+ return ()
178
+ return tuple(selection)
179
+
180
+
181
+ def _wants_contracts(
182
+ timeframe: Timeframe,
183
+ *,
184
+ selection: bool | Iterable[Timeframe],
185
+ ) -> bool:
186
+ if isinstance(selection, bool):
187
+ return selection
188
+ return timeframe in tuple(selection)
189
+
190
+
191
+ def _offered(build: Callable[[], Request]) -> Iterator[Request]:
192
+ """Yield the request, or nothing when the vendor doesn't serve it.
193
+
194
+ A bundle names timeframes and adjustments across a whole universe, and the
195
+ vendor serves some pairs and not others. The pairs it refuses are dropped
196
+ so the rest of the bundle still comes down.
197
+ """
198
+ try:
199
+ yield build()
200
+ except NotOfferedError:
201
+ return