afterquote 0.1.1__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.
afterquote/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """
2
+ afterquote: A lightweight package for generating synthetic after-hours
3
+ returns between a given security and its given underlying asset.
4
+ """
5
+
6
+ from ._security_pair import SecurityPair
7
+
8
+ __all__ = ["SecurityPair"]
@@ -0,0 +1,102 @@
1
+ """Market calendar logic, to find open/close and trading days"""
2
+
3
+ from datetime import datetime, timedelta
4
+ import pandas as pd
5
+ import pandas_market_calendars as mcal
6
+ import pytz
7
+
8
+
9
+ class UnsupportedExchangeError(KeyError):
10
+ """
11
+ Exception raised when a market calendar name is not found in pandas_market_calendars.
12
+
13
+ This usually occurs because yfinance exchange codes do not always match the calendar
14
+ names expected by pandas_market_calendars. If you encounter a missing or unrecognized
15
+ exchange, consider contributing to the exchange mapping in this package to improve coverage.
16
+ """
17
+
18
+
19
+ class MarketCalendar:
20
+ """Encapsulates exchange calendar logic for various exchanges"""
21
+
22
+ # Please contribute to this list if you find more exchanges
23
+ # Or find a better way (please) :) - Junaid
24
+ _exchange_map = {
25
+ "NMS": "NASDAQ",
26
+ "NGM": "NASDAQ",
27
+ "BTS": "BATS",
28
+ }
29
+
30
+ def __init__(self):
31
+ pass
32
+
33
+ def is_exchange_open(
34
+ self,
35
+ yf_exchange_name,
36
+ timestamp: pd.Timestamp = pd.Timestamp(datetime.now(pytz.utc)),
37
+ ) -> bool:
38
+ """Checks if an exchange is trading at a given timestamp"""
39
+
40
+ cal = self.__get_calendar(yf_exchange_name)
41
+ schedule = self.__get_schedule(cal)
42
+
43
+ converted_timestamp = timestamp.tz_convert(cal.tz)
44
+
45
+ try:
46
+ return cal.open_at_time(schedule, converted_timestamp)
47
+ except (ValueError, IndexError):
48
+ return False
49
+
50
+ def get_closing_time(self, yf_exchange_name) -> pd.Timestamp:
51
+ """Returns last closing time of the exchange"""
52
+
53
+ exchange = self.__get_calendar(yf_exchange_name)
54
+ schedule = exchange.schedule(
55
+ start_date=datetime.now().today() - timedelta(days=5),
56
+ end_date=datetime.now().today(),
57
+ )
58
+ recent_closes = schedule[-2:]["market_close"].tolist()
59
+ recent_closes.reverse()
60
+
61
+ for close in recent_closes:
62
+ if close < datetime.now(pytz.utc):
63
+ return close
64
+
65
+ raise ValueError("Cannot find the last market close")
66
+
67
+ def get_exchange_tz(self, yf_exchange_name) -> pytz.tzinfo.BaseTzInfo:
68
+ """Returns the timezone for a given exchange"""
69
+
70
+ cal = self.__get_calendar(yf_exchange_name)
71
+ return cal.tz
72
+
73
+ def __get_calendar(self, yf_exchange_name) -> mcal.MarketCalendar:
74
+ """Retrieves a pandas_market_calendars calendar for the given exchange name"""
75
+
76
+ try:
77
+ return mcal.get_calendar(
78
+ self._exchange_map.get(yf_exchange_name, yf_exchange_name)
79
+ )
80
+ except RuntimeError as e:
81
+ raise UnsupportedExchangeError(
82
+ f"Could not retrieve calendar for exchange '{yf_exchange_name}'. "
83
+ f"Please consider contributing to this file to add support - Junaid"
84
+ ) from e
85
+
86
+ def __get_schedule(
87
+ self,
88
+ exchange_cal: mcal.MarketCalendar,
89
+ start=datetime.now().today(),
90
+ end=datetime.now().today(),
91
+ ) -> pd.DataFrame:
92
+ """Retrieves a schedule for a pandas market calendar"""
93
+ try:
94
+ return exchange_cal.schedule(
95
+ start_date=start, end_date=end, start="pre", end="post"
96
+ )
97
+ # Handle exchanges with no extended hours
98
+ except ValueError:
99
+ return exchange_cal.schedule(
100
+ start_date=start,
101
+ end_date=end,
102
+ )
@@ -0,0 +1,88 @@
1
+ """Providing a quote for a security from its underlying asset"""
2
+
3
+ import pandas as pd
4
+ from ._yfinance_wrapper import YFinanceSecurity
5
+ from ._market_calendar import MarketCalendar
6
+
7
+
8
+ class SecurityPair:
9
+ """Class holding the leveraged etf and its underlying security"""
10
+
11
+ def __init__(self, base, underlying):
12
+ self.base_yf = YFinanceSecurity(base)
13
+ self.underlying_yf = YFinanceSecurity(underlying)
14
+
15
+ if not self.is_valid_pair():
16
+ raise ValueError(
17
+ f"Invalid security pair: {base}, {underlying}",
18
+ "Please use yfinance tickers",
19
+ )
20
+
21
+ self.calendar = MarketCalendar()
22
+
23
+ def is_valid_pair(self) -> bool:
24
+ """Returns if both of the tickers provided are found by yfinance"""
25
+
26
+ return self.underlying_yf.is_real_security() and self.base_yf.is_real_security()
27
+
28
+ def is_pair_fully_live(self) -> bool:
29
+ """
30
+ Returns if both of the securities are currently trading,
31
+ meaning no synthetic return is needed
32
+ """
33
+
34
+ return self.calendar.is_exchange_open(
35
+ self.base_yf.get_exchange()
36
+ ) and self.calendar.is_exchange_open(self.underlying_yf.get_exchange())
37
+
38
+ def quote(self) -> pd.DataFrame:
39
+ """Returns a df with the latest possible quote for the underlying security"""
40
+
41
+ if self.is_pair_fully_live():
42
+ raise RuntimeError(
43
+ "Cannot compute synthetic return โ€” both securities are currently trading."
44
+ )
45
+
46
+ # Get the last closing time of the base security
47
+ close_time = self.calendar.get_closing_time(self.base_yf.get_exchange())
48
+ close_price = self.base_yf.get_price_at(close_time)
49
+ # Convert that to the timezone of the underlying security
50
+ target_timezone = self.calendar.get_exchange_tz(
51
+ self.underlying_yf.get_exchange()
52
+ )
53
+ # The close of the base security is our start for the underlying security
54
+ start_time = close_time.astimezone(target_timezone)
55
+
56
+ # Get the start price of the underlying security
57
+ start_price = self.underlying_yf.get_price_at(start_time)
58
+ # Get the current price of the underlying security
59
+ live_data = self.underlying_yf.yf_ticker.history(
60
+ period="5d", interval="1m", prepost=True
61
+ )
62
+ latest_price = live_data["Close"].iloc[-1]
63
+ latest_time = live_data.index[-1]
64
+
65
+ # Calculate the percentage return of the underlying security
66
+ change = latest_price - start_price
67
+ percent_return = (change / start_price) * 100
68
+
69
+ leverage_factor = self.base_yf.get_leverage()
70
+ leveraged_return = percent_return * leverage_factor
71
+
72
+ # Calculate the quote price based on the leveraged return
73
+ quote_price = close_price * (1 + (leveraged_return / 100))
74
+
75
+ return pd.DataFrame(
76
+ [
77
+ {
78
+ "base_security": self.base_yf.ticker,
79
+ "underlying_security": self.underlying_yf.ticker,
80
+ "leverage": leverage_factor,
81
+ "base_close_time": start_time,
82
+ "base_close_price": close_price,
83
+ "adj_percent_return": leveraged_return,
84
+ "quote_time": latest_time,
85
+ "quote_price": quote_price,
86
+ }
87
+ ]
88
+ )
@@ -0,0 +1,78 @@
1
+ """Used for querying information and pricing for securities"""
2
+
3
+ import re
4
+ from datetime import timedelta
5
+ import pandas as pd
6
+ import pytz
7
+ import yfinance as yf
8
+
9
+
10
+ class YFinanceSecurity:
11
+ """Wrapper for yfinance objects"""
12
+
13
+ def __init__(self, ticker):
14
+ self.ticker = ticker
15
+ self.yf_ticker = yf.Ticker(ticker)
16
+
17
+ def is_real_security(self) -> bool:
18
+ """Returns whether yfinance found the ticker"""
19
+
20
+ try:
21
+ self.yf_ticker.info.get("longName")
22
+ return True
23
+ except AttributeError:
24
+ return False
25
+
26
+ def get_leverage(self) -> int:
27
+ """Returns the leverage for a security"""
28
+
29
+ info = self.yf_ticker.info
30
+ long_name = info.get("longName", "Error finding long name")
31
+ match = re.search(r"(-?\d+x)", long_name)
32
+ if match:
33
+ leverage = match.group(0).replace("x", "")
34
+ if (
35
+ "short" in long_name.lower() or "inverse" in long_name.lower()
36
+ ) and leverage[0] != "-":
37
+ leverage = f"-{leverage}"
38
+ return int(leverage)
39
+
40
+ return 1
41
+
42
+ def get_timezone(self) -> pytz.tzinfo.BaseTzInfo:
43
+ """Returns a pytz timezone for a security"""
44
+
45
+ info = self.yf_ticker.info
46
+ timezone_name = info.get("timeZoneFullName")
47
+ if not timezone_name:
48
+ raise ValueError(f"Timezone not found for {self.ticker}")
49
+ return pytz.timezone(timezone_name)
50
+
51
+ def get_exchange(self) -> str:
52
+ """Returns the exchange for a security"""
53
+
54
+ info = self.yf_ticker.info
55
+ exchange_name = info.get("exchange")
56
+ if not exchange_name:
57
+ raise ValueError(f"Exchange not found for {self.ticker}")
58
+ return exchange_name
59
+
60
+ def get_price_at(self, timestamp: pd.Timestamp) -> float:
61
+ """Fetches the price closest to the given timestamp using 1-minute interval data"""
62
+
63
+ data = self.yf_ticker.history(
64
+ start=timestamp - timedelta(minutes=5),
65
+ end=timestamp + timedelta(minutes=5),
66
+ interval="1m",
67
+ prepost=True,
68
+ )
69
+ if data.empty:
70
+ raise ValueError(
71
+ f"No pricing data for {self.ticker} found around {timestamp.isoformat()}"
72
+ )
73
+ if timestamp in data.index:
74
+ price = data.loc[timestamp].Open
75
+ else:
76
+ price = data.iloc[0].Open
77
+
78
+ return price
@@ -0,0 +1,68 @@
1
+ Metadata-Version: 2.4
2
+ Name: afterquote
3
+ Version: 0.1.1
4
+ Summary: Synthetic after-hours quote generator
5
+ Author-email: Mohammad Junaid <mohammadjunaiduk@gmail.com>
6
+ License-Expression: MIT
7
+ Requires-Python: >=3.8
8
+ Description-Content-Type: text/markdown
9
+ License-File: LICENSE
10
+ Requires-Dist: yfinance
11
+ Requires-Dist: pandas
12
+ Requires-Dist: pytz
13
+ Requires-Dist: pandas_market_calendars
14
+ Provides-Extra: dev
15
+ Requires-Dist: black; extra == "dev"
16
+ Requires-Dist: pylint; extra == "dev"
17
+ Dynamic: license-file
18
+
19
+ # afterquote
20
+
21
+ **Synthetic after-hours quote generator based on an asset and its correlated underlying.**
22
+
23
+ ---
24
+
25
+ ## ๐Ÿ“ฆ What is this?
26
+
27
+ `afterquote` lets you estimate synthetic prices for a financial security based on the real-time performance of a given correlated underlying asset โ€” useful when one market is closed and the other is still trading.
28
+
29
+ ---
30
+
31
+ ## ๐Ÿš€ Installation
32
+
33
+ ### From PyPI (planned):
34
+ ```bash
35
+ pip install afterquote
36
+ ```
37
+
38
+ ### Locally:
39
+
40
+ ```bash
41
+ pip install -e .
42
+ ```
43
+
44
+ ## ๐Ÿงช Usage
45
+
46
+ ```python
47
+ from afterquote import SecurityPair
48
+
49
+ pair = SecurityPair("MAG5.L", "MAGS")
50
+ quote_df = pair.quote()
51
+ print(quote_df)
52
+ ```
53
+
54
+ ## ๐Ÿ“˜ Example Output
55
+
56
+ ```text
57
+ base_security underlying_security leverage base_close_time base_close_price adj_percent_return quote_time quote_price
58
+ 0 MAG5.L MAGS 5 2025-05-09 11:30:00-04:00 792.0 -1.044288 2025-05-09 19:59:00-04:00 783.729235
59
+ ```
60
+
61
+ ## ๐Ÿค Contributing
62
+
63
+ Feel free to open issues or submit pull requests if you find bugs or want to improve the package - Junaid :)
64
+
65
+
66
+ ## ๐Ÿ“„ License
67
+
68
+ MIT License. See the [LICENSE](./LICENSE) file for full details.
@@ -0,0 +1,9 @@
1
+ afterquote/__init__.py,sha256=UNwVeYKd1pXTSOfX9JA3LQpa7IP5EMZO6fgo9xYO4eA,222
2
+ afterquote/_market_calendar.py,sha256=pEBMJ8zbN_pll9rT3ZwAg3cNW3O_NDVqO7KlTH6cYbI,3545
3
+ afterquote/_security_pair.py,sha256=oliTmSe3QxkG10yhUaxqBmIDokyJ7TFbYgxF1UjdGyE,3467
4
+ afterquote/_yfinance_wrapper.py,sha256=jHwtrqQyROGnfjvHCDMgoPdVWIE2_vUg4AMYmwynkT8,2495
5
+ afterquote-0.1.1.dist-info/licenses/LICENSE,sha256=pZy9gOa0pJIhpdbZ_i5fthJHzDU_7cXV4YtXwb1c--s,1084
6
+ afterquote-0.1.1.dist-info/METADATA,sha256=BJmApJITdgsrJU5y35RNEA9F2jy2hwIn56h7zg0BcY8,1742
7
+ afterquote-0.1.1.dist-info/WHEEL,sha256=DnLRTWE75wApRYVsjgc6wsVswC54sMSJhAEd4xhDpBk,91
8
+ afterquote-0.1.1.dist-info/top_level.txt,sha256=3WDLaV4J6s2NvRiavlyKcCFV_U3ITVMMrmhAJluBiPU,11
9
+ afterquote-0.1.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.4.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Junaid
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ afterquote