From 9b721caa163261e346d60ec97b8f4a982bbda8ed Mon Sep 17 00:00:00 2001 From: Mohammad Junaid Date: Sat, 20 Jun 2026 19:20:39 +0100 Subject: [PATCH 01/13] fix(pricing): correct OHLC anchoring to single close anchor and capture inter-bar gaps --- afterquote/_security_pair.py | 29 ++++++++++++++++++++--------- 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/afterquote/_security_pair.py b/afterquote/_security_pair.py index 78c766a..8cf7722 100644 --- a/afterquote/_security_pair.py +++ b/afterquote/_security_pair.py @@ -110,14 +110,25 @@ def pricing(self, interval: str = "1m") -> pd.DataFrame: ) leverage_factor = self.base_yf.get_leverage() - - # Iteratively generate synthetic pricing - for col in ["Open", "Close"]: - change_series = underlying_pricing[col].pct_change() - synthetic_pricing[f"Impl_{col}"] = ( - close_price[col] - * (1 + (leverage_factor * change_series.fillna(0))).cumprod() - ) + anchor_price = close_price["Close"] + + # Gap from the underlying's prev close to this bar's open + open_gap = ( + underlying_pricing["Open"] / underlying_pricing["Close"].shift() + ).fillna(1) + intra_close = ( + underlying_pricing["Close"] - underlying_pricing["Open"] + ) / underlying_pricing["Open"] + + gap_return = 1 + leverage_factor * (open_gap - 1) + intra_return = 1 + leverage_factor * intra_close + total_bar_return = gap_return * intra_return + + cumulative_close = anchor_price * total_bar_return.cumprod() + synthetic_pricing["Impl_Open"] = ( + cumulative_close.shift().fillna(anchor_price) * gap_return + ) + synthetic_pricing["Impl_Close"] = synthetic_pricing["Impl_Open"] * intra_return # Scaling the high and low prices for col in ["High", "Low"]: @@ -125,7 +136,7 @@ def pricing(self, interval: str = "1m") -> pd.DataFrame: underlying_pricing[col] - underlying_pricing["Open"] ) / underlying_pricing["Open"] synthetic_pricing[f"Impl_{col}"] = synthetic_pricing["Impl_Open"] * ( - 1 + (relative_diff) + 1 + leverage_factor * relative_diff ) # Reordering column names to match yfinance history method From d3ed620459461cadce2f3a68fd89a967dfd4a4f6 Mon Sep 17 00:00:00 2001 From: Mohammad Junaid Date: Sat, 20 Jun 2026 19:21:59 +0100 Subject: [PATCH 02/13] fix(validation): reject fake tickers in is_real_security --- afterquote/_yfinance_wrapper.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/afterquote/_yfinance_wrapper.py b/afterquote/_yfinance_wrapper.py index 5ddf557..f773888 100644 --- a/afterquote/_yfinance_wrapper.py +++ b/afterquote/_yfinance_wrapper.py @@ -18,8 +18,7 @@ def is_real_security(self) -> bool: """Returns whether yfinance found the ticker""" try: - self.yf_ticker.info.get("longName") - return True + return bool(self.yf_ticker.info.get("longName")) except AttributeError: return False From 50e47e18080b2263893a58fe9aa3e40ce2e2dccf Mon Sep 17 00:00:00 2001 From: Mohammad Junaid Date: Sat, 20 Jun 2026 19:23:09 +0100 Subject: [PATCH 03/13] fix(wrapper): widen close-price lookup window and return nearest bar --- afterquote/_yfinance_wrapper.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/afterquote/_yfinance_wrapper.py b/afterquote/_yfinance_wrapper.py index f773888..e7f2892 100644 --- a/afterquote/_yfinance_wrapper.py +++ b/afterquote/_yfinance_wrapper.py @@ -60,7 +60,7 @@ def get_price_at(self, timestamp: pd.Timestamp) -> pd.Series: """Fetches a row of price data closest to the given timestamp using 1m interval data""" data = self.yf_ticker.history( - start=timestamp - timedelta(minutes=5), + start=timestamp - timedelta(minutes=30), end=timestamp + timedelta(minutes=5), interval="1m", prepost=True, @@ -71,4 +71,4 @@ def get_price_at(self, timestamp: pd.Timestamp) -> pd.Series: ) if timestamp in data.index: return data.loc[[timestamp]].iloc[0] # always returns a Series - return data.iloc[0] + return data.iloc[-1] From ab9e9ceb0a5f22a8a5722012def445f8cfbb4264 Mon Sep 17 00:00:00 2001 From: Mohammad Junaid Date: Sat, 20 Jun 2026 19:25:34 +0100 Subject: [PATCH 04/13] fix(pricing): pass end= to yfinance history call to return rows --- afterquote/_security_pair.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/afterquote/_security_pair.py b/afterquote/_security_pair.py index 8cf7722..c341955 100644 --- a/afterquote/_security_pair.py +++ b/afterquote/_security_pair.py @@ -100,7 +100,10 @@ def pricing(self, interval: str = "1m") -> pd.DataFrame: start_time = close_time.astimezone(target_timezone) underlying_pricing = self.underlying_yf.yf_ticker.history( - start=start_time, interval=interval, prepost=True + start=start_time, + end=pd.Timestamp.now(tz=target_timezone), + interval=interval, + prepost=True, ) # Change timezone to that of the base security From dd932abfd2c69df4dd4c3a2666b17e3c0ef23f75 Mon Sep 17 00:00:00 2001 From: Mohammad Junaid Date: Sat, 20 Jun 2026 19:30:16 +0100 Subject: [PATCH 05/13] fix(info): return base close when underlying has no newer data than base --- afterquote/_security_pair.py | 85 +++++++++++++++++++++++------------- 1 file changed, 54 insertions(+), 31 deletions(-) diff --git a/afterquote/_security_pair.py b/afterquote/_security_pair.py index c341955..69229ad 100644 --- a/afterquote/_security_pair.py +++ b/afterquote/_security_pair.py @@ -1,10 +1,32 @@ """Providing a quote for a security from its underlying asset""" +from dataclasses import dataclass, asdict +from typing import Optional + import pandas as pd from ._yfinance_wrapper import YFinanceSecurity from ._market_calendar import MarketCalendar +@dataclass +class QuoteInfo: + base_security: str + underlying_security: str + base_is_live: bool + leverage: int + quote_time: pd.Timestamp + base_close_time: Optional[pd.Timestamp] = None + base_close_price: Optional[float] = None + adj_percent_return: Optional[float] = None + quote_price: Optional[float] = None + + def to_frame(self) -> pd.DataFrame: + data = {k: v for k, v in asdict(self).items() if v is not None} + df = pd.DataFrame([data]) + df.set_index("quote_time", inplace=True) + return df + + class SecurityPair: """Class holding the base asset and its underlying security""" @@ -40,46 +62,47 @@ def info(self) -> pd.DataFrame: if self.calendar.is_exchange_open(self.base_yf.get_exchange()): last_price_time = self.base_yf.get_price_at(pd.Timestamp.now()) - df = pd.DataFrame( - [ - { - "base_security": self.base_yf.ticker, - "underlying_security": self.underlying_yf.ticker, - "base_is_live": True, # The base security is currently live - "leverage": self.base_yf.get_leverage(), - "quote_time": last_price_time.name, - } - ] - ) - df.set_index("quote_time", inplace=True) - return df + return QuoteInfo( + base_security=self.base_yf.ticker, + underlying_security=self.underlying_yf.ticker, + base_is_live=True, + leverage=self.base_yf.get_leverage(), + quote_time=last_price_time.name, + ).to_frame() - # Get the last closing time of the base security close_time = self.calendar.get_closing_time(self.base_yf.get_exchange()) close_price = self.base_yf.get_price_at(close_time).Close pricing_data = self.pricing() + if pricing_data.empty: + # Underlying has no trading data after the base's last close — the + # base is already the most current source, no synthetic is needed. + return QuoteInfo( + base_security=self.base_yf.ticker, + underlying_security=self.underlying_yf.ticker, + base_is_live=False, + leverage=self.base_yf.get_leverage(), + quote_time=close_time, + base_close_time=close_time, + base_close_price=close_price, + quote_price=close_price, + ).to_frame() + change = pricing_data["Impl_Close"].iloc[-1] - pricing_data["Impl_Open"].iloc[0] leveraged_return = (change / pricing_data["Impl_Open"].iloc[0]) * 100 - df = pd.DataFrame( - [ - { - "base_security": self.base_yf.ticker, - "underlying_security": self.underlying_yf.ticker, - "base_is_live": False, # The base security is currently closed - "leverage": self.base_yf.get_leverage(), - "base_close_time": pricing_data.index[0], - "base_close_price": close_price, - "adj_percent_return": leveraged_return, - "quote_time": pricing_data.index[-1], - "quote_price": pricing_data["Impl_Close"].iloc[-1], - } - ] - ) - df.set_index("quote_time", inplace=True) - return df + return QuoteInfo( + base_security=self.base_yf.ticker, + underlying_security=self.underlying_yf.ticker, + base_is_live=False, + leverage=self.base_yf.get_leverage(), + quote_time=pricing_data.index[-1], + base_close_time=pricing_data.index[0], + base_close_price=close_price, + adj_percent_return=leveraged_return, + quote_price=pricing_data["Impl_Close"].iloc[-1], + ).to_frame() def pricing(self, interval: str = "1m") -> pd.DataFrame: """Returns a df with the calculated extended hours pricing for the base security""" From c11c0d3c155975a77b77e34cfdeba3f6b03d7470 Mon Sep 17 00:00:00 2001 From: Mohammad Junaid Date: Sat, 20 Jun 2026 19:40:05 +0100 Subject: [PATCH 06/13] fix(calendar): add 7 missing exchange mappings --- afterquote/_market_calendar.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/afterquote/_market_calendar.py b/afterquote/_market_calendar.py index 08165ca..20d2f74 100644 --- a/afterquote/_market_calendar.py +++ b/afterquote/_market_calendar.py @@ -25,6 +25,13 @@ class MarketCalendar: "NMS": "NASDAQ", "NGM": "NASDAQ", "BTS": "BATS", + "PCX": "NYSE", + "NYQ": "NYSE", + "AMS": "XAMS", + "GER": "XFRA", + "HKG": "XHKG", + "NSI": "XNSE", + "TOR": "TSX", } def __init__(self): From d05c3cbe991cc6fa062b269779f20cf06bdf7b94 Mon Sep 17 00:00:00 2001 From: Mohammad Junaid Date: Sat, 20 Jun 2026 19:45:39 +0100 Subject: [PATCH 07/13] chore: bump to 0.3.0 and pin dependencies --- pyproject.toml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index da12b83..edc49cd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "afterquote" -version = "0.2.1" +version = "0.3.0" description = "Synthetic after-hours quote generator" authors = [{ name = "Mohammad Junaid", email = "mohammadjunaiduk@gmail.com" }] readme = "README.md" @@ -8,10 +8,10 @@ license = "MIT" requires-python = ">=3.8" dependencies = [ - "yfinance", - "pandas", - "pytz", - "pandas_market_calendars" + "yfinance>=1.4.1", + "pandas>=2.0", + "pytz>=2024", + "pandas_market_calendars>=5.0", ] [project.optional-dependencies] From 8240d49d32b2444828c6b276c420d2c134b23102 Mon Sep 17 00:00:00 2001 From: Mohammad Junaid Date: Sat, 20 Jun 2026 19:50:34 +0100 Subject: [PATCH 08/13] test: add pytest suite with mocked yfinance and live e2e tests --- pyproject.toml | 3 + pytest.ini | 3 + tests/__init__.py | 0 tests/conftest.py | 155 +++++++++++++++++++++++++++++++++++++++ tests/test_info.py | 82 +++++++++++++++++++++ tests/test_live.py | 33 +++++++++ tests/test_pricing.py | 143 ++++++++++++++++++++++++++++++++++++ tests/test_validation.py | 47 ++++++++++++ 8 files changed, 466 insertions(+) create mode 100644 pytest.ini create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/test_info.py create mode 100644 tests/test_live.py create mode 100644 tests/test_pricing.py create mode 100644 tests/test_validation.py diff --git a/pyproject.toml b/pyproject.toml index edc49cd..abfb9d8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,6 +19,9 @@ dev = [ "black", "pylint", ] +test = [ + "pytest", +] [build-system] requires = ["setuptools>=61.0"] diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..133ca4e --- /dev/null +++ b/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +markers = + live: tests that hit real yfinance (skipped unless --runlive) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..a8e4945 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,155 @@ +"""Shared test fixtures — fakes yfinance so tests never touch the network.""" + +import pandas as pd +import pytz +import pytest + +from afterquote._yfinance_wrapper import YFinanceSecurity +from afterquote._market_calendar import MarketCalendar +from afterquote._security_pair import SecurityPair + + +class FakeYFinanceSecurity: + """Drop-in replacement for YFinanceSecurity with hardcoded data.""" + + def __init__(self, ticker, info, history_df): + self.ticker = ticker + self._info = info + self._history = history_df + self.yf_ticker = ( + self # pricing() calls self.underlying_yf.yf_ticker.history(...) + ) + + def history(self, start=None, end=None, interval="1m", prepost=True): + return self._history + + def is_real_security(self) -> bool: + return bool(self._info.get("longName")) + + def get_leverage(self) -> int: + return self._info.get("leverage", 1) + + def get_timezone(self): + return pytz.timezone(self._info["timeZoneFullName"]) + + def get_exchange(self) -> str: + return self._info["exchange"] + + def get_price_at(self, timestamp: pd.Timestamp) -> pd.Series: + if timestamp in self._history.index: + return self._history.loc[[timestamp]].iloc[0] + return self._history.iloc[-1] + + +class FakeMarketCalendar: + """Drop-in replacement for MarketCalendar with fixed open/close state.""" + + def __init__( + self, + base_open=False, + underlying_open=True, + close_time=None, + tz="America/New_York", + ): + self._base_open = base_open + self._underlying_open = underlying_open + self._close_time = close_time + self._tz = tz + + def is_exchange_open(self, exchange, timestamp=None) -> bool: + if exchange == "LSE": + return self._base_open + return self._underlying_open + + def get_closing_time(self, exchange) -> pd.Timestamp: + return self._close_time + + def get_exchange_tz(self, exchange): + return pytz.timezone(self._tz) + + +def make_ohlc(rows, start="2026-06-18 11:30", freq="1min", tz="America/New_York"): + """Build a fake OHLC DataFrame from a list of (O, H, L, C) tuples.""" + index = pd.date_range(start, periods=len(rows), freq=freq, tz=tz) + data = {"Open": [], "High": [], "Low": [], "Close": []} + for o, h, l, c in rows: + data["Open"].append(o) + data["High"].append(h) + data["Low"].append(l) + data["Close"].append(c) + return pd.DataFrame(data, index=index) + + +def make_security_pair( + base_info, + base_history, + underlying_info, + underlying_history, + base_open=False, + underlying_open=True, + close_time=None, + tz="America/New_York", +): + """Build a SecurityPair with fakes wired in — no network calls.""" + pair = SecurityPair.__new__(SecurityPair) + pair.base_yf = FakeYFinanceSecurity("BASE", base_info, base_history) + pair.underlying_yf = FakeYFinanceSecurity( + "UNDER", underlying_info, underlying_history + ) + pair.calendar = FakeMarketCalendar(base_open, underlying_open, close_time, tz) + return pair + + +@pytest.fixture +def simple_pair(): + """3-bar underlying, +1% close-to-close each, no gaps, leverage=2, anchor=200.""" + underlying = make_ohlc( + [ + (100.0, 101.0, 99.5, 101.0), + (101.0, 102.0, 100.5, 102.0), + (102.0, 103.0, 101.5, 103.0), + ] + ) + base_close_bar = make_ohlc( + [(200.0, 200.5, 199.5, 200.0)], start="2026-06-18 16:30", tz="Europe/London" + ) + base_info = { + "longName": "2x Leveraged Thing", + "leverage": 2, + "exchange": "LSE", + "timeZoneFullName": "Europe/London", + } + underlying_info = { + "longName": "Underlying ETF", + "leverage": 1, + "exchange": "PCX", + "timeZoneFullName": "America/New_York", + } + close_time = pd.Timestamp("2026-06-18 16:30:00+01:00") + return make_security_pair( + base_info, + base_close_bar, + underlying_info, + underlying, + base_open=False, + underlying_open=True, + close_time=close_time, + ) + + +def pytest_addoption(parser): + parser.addoption( + "--runlive", + action="store_true", + default=False, + help="run tests that hit real yfinance", + ) + + +def pytest_collection_modifyitems(config, items): + if config.getoption("--runlive"): + return + skip_live = pytest.mark.skip(reason="needs --runlive to hit real yfinance") + for item in items: + if "live" in item.keywords: + item.add_marker(skip_live) diff --git a/tests/test_info.py b/tests/test_info.py new file mode 100644 index 0000000..826fdfe --- /dev/null +++ b/tests/test_info.py @@ -0,0 +1,82 @@ +"""Tests for info() — the three paths: live, synthetic, no-data.""" + +import pandas as pd + +from tests.conftest import make_ohlc, make_security_pair + + +class TestInfoLive: + """When the base exchange is open, info returns base_is_live=True.""" + + def test_live_returns_true(self): + base_close = make_ohlc( + [(200.0, 200.5, 199.5, 200.0)], start="2026-06-18 16:30", tz="Europe/London" + ) + pair = make_security_pair( + { + "longName": "2x Lev", + "leverage": 2, + "exchange": "LSE", + "timeZoneFullName": "Europe/London", + }, + base_close, + { + "longName": "Under", + "leverage": 1, + "exchange": "PCX", + "timeZoneFullName": "America/New_York", + }, + base_close, + base_open=True, + close_time=pd.Timestamp("2026-06-18 16:30:00+01:00"), + ) + info = pair.info() + assert info["base_is_live"].iloc[0] == True + assert "adj_percent_return" not in info.columns + + +class TestInfoSynthetic: + """Normal case: base closed, underlying has data -> synthetic with adj_return.""" + + def test_synthetic_has_adj_return(self, simple_pair): + info = simple_pair.info() + assert info["base_is_live"].iloc[0] == False + assert "adj_percent_return" in info.columns + assert "quote_price" in info.columns + + def test_quote_price_is_last_impl_close(self, simple_pair): + info = simple_pair.info() + pricing = simple_pair.pricing() + assert info["quote_price"].iloc[0] == pricing["Impl_Close"].iloc[-1] + + +class TestInfoNoData: + """When underlying has no data after base close, return base close as quote.""" + + def test_no_data_returns_base_close(self): + empty = pd.DataFrame(columns=["Open", "High", "Low", "Close"]) + base_close = make_ohlc( + [(200.0, 200.5, 199.5, 200.0)], start="2026-06-18 16:30", tz="Europe/London" + ) + pair = make_security_pair( + { + "longName": "2x Lev", + "leverage": 2, + "exchange": "LSE", + "timeZoneFullName": "Europe/London", + }, + base_close, + { + "longName": "Under", + "leverage": 1, + "exchange": "PCX", + "timeZoneFullName": "America/New_York", + }, + empty, + close_time=pd.Timestamp("2026-06-18 16:30:00+01:00"), + ) + info = pair.info() + assert info["base_is_live"].iloc[0] == False + assert "adj_percent_return" not in info.columns + assert info["quote_price"].iloc[0] == info["base_close_price"].iloc[0] + assert info["quote_price"].iloc[0] == 200.0 diff --git a/tests/test_live.py b/tests/test_live.py new file mode 100644 index 0000000..f333a78 --- /dev/null +++ b/tests/test_live.py @@ -0,0 +1,33 @@ +"""Live end-to-end test — hits real yfinance. Skipped unless --runlive passed.""" + +import pytest + + +@pytest.mark.live +class TestLiveEndToEnd: + """Sanity check against real market data. Run manually before releases.""" + + def test_real_pair_info(self): + from afterquote import SecurityPair + + pair = SecurityPair("3USL.L", "SPY") + info = pair.info() + assert info["base_security"].iloc[0] == "3USL.L" + assert info["underlying_security"].iloc[0] == "SPY" + assert "base_is_live" in info.columns + + def test_real_pair_pricing_when_closed(self): + from afterquote import SecurityPair + + pair = SecurityPair("3USL.L", "SPY") + if pair.is_pair_fully_live(): + pytest.skip("base is live, synthetic not applicable right now") + pricing = pair.pricing() + if pricing.empty: + pytest.skip("no underlying data since base's last close") + assert list(pricing.columns) == [ + "Impl_Open", + "Impl_High", + "Impl_Low", + "Impl_Close", + ] diff --git a/tests/test_pricing.py b/tests/test_pricing.py new file mode 100644 index 0000000..50ae4df --- /dev/null +++ b/tests/test_pricing.py @@ -0,0 +1,143 @@ +"""Tests for the synthetic pricing algorithm — the core math of afterquote.""" + +import pandas as pd + +from tests.conftest import make_ohlc, make_security_pair + + +class TestPricingAnchor: + """The anchor: every synthetic price grows from the base's last close.""" + + def test_first_open_is_the_anchor(self, simple_pair): + pricing = simple_pair.pricing() + assert pricing["Impl_Open"].iloc[0] == 200.0 + + def test_no_phantom_gap_at_t0(self, simple_pair): + pricing = simple_pair.pricing() + gap = pricing["Impl_Open"].iloc[0] - pricing["Impl_Close"].iloc[0] + assert gap != 0 # t0 has an intra-bar move, not a phantom seed gap + + +class TestPricingChain: + """The synthetic chain: Open[t] carries from Close[t-1] via the gap.""" + + def test_open_carries_from_prev_close(self, simple_pair): + pricing = simple_pair.pricing() + for i in range(1, len(pricing)): + # open_gap is 1.0 (no gap) in simple_pair, so Open[t] == Close[t-1] + assert pricing["Impl_Open"].iloc[i] == pricing["Impl_Close"].iloc[i - 1] + + +class TestPricingCandleValidity: + """Every candle must satisfy High >= max(Open,Close) >= Low.""" + + def test_all_candles_valid(self, simple_pair): + pricing = simple_pair.pricing() + for i in range(len(pricing)): + row = pricing.iloc[i] + assert row["Impl_High"] >= row["Impl_Open"], f"tick {i}: High < Open" + assert row["Impl_High"] >= row["Impl_Close"], f"tick {i}: High < Close" + assert row["Impl_Low"] <= row["Impl_Open"], f"tick {i}: Low > Open" + assert row["Impl_Low"] <= row["Impl_Close"], f"tick {i}: Low > Close" + + +class TestPricingLeverage: + """Leverage scales the underlying's moves into the synthetic.""" + + def test_close_moves_2x(self, simple_pair): + # underlying went 100->101 (+1%), leverage=2, anchor=200 + # expected: 200 * (1 + 2*0.01) = 204 + pricing = simple_pair.pricing() + assert pricing["Impl_Close"].iloc[0] == 204.0 + + def test_high_applies_leverage(self, simple_pair): + # underlying High=101, Open=100 (+1%), leverage=2 + # Impl_High = Impl_Open * (1 + 2*0.01) = 200 * 1.02 = 204 + pricing = simple_pair.pricing() + assert pricing["Impl_High"].iloc[0] == 204.0 + + def test_low_applies_leverage(self, simple_pair): + # underlying Low=99.5, Open=100 (-0.5%), leverage=2 + # Impl_Low = 200 * (1 + 2*(-0.005)) = 200 * 0.99 = 198 + pricing = simple_pair.pricing() + assert pricing["Impl_Low"].iloc[0] == 198.0 + + +class TestPricingGap: + """Inter-bar gaps (underlying open != prev close) are captured.""" + + def test_gap_appears_in_synthetic_open(self): + # underlying bar 1 opens ABOVE bar 0's close (gap up) + underlying = make_ohlc( + [ + (100.0, 101.0, 99.0, 100.0), # flat close + (102.0, 103.0, 101.0, 102.0), # opens at 102 (gap up from 100) + ] + ) + base_close = make_ohlc( + [(200.0, 200.5, 199.5, 200.0)], start="2026-06-18 16:30", tz="Europe/London" + ) + pair = make_security_pair( + { + "longName": "2x Lev", + "leverage": 2, + "exchange": "LSE", + "timeZoneFullName": "Europe/London", + }, + base_close, + { + "longName": "Under", + "leverage": 1, + "exchange": "PCX", + "timeZoneFullName": "America/New_York", + }, + underlying, + close_time=pd.Timestamp("2026-06-18 16:30:00+01:00"), + ) + pricing = pair.pricing() + # tick 0: anchor=200, intra flat (close=open=100), so close=200 + # tick 1: gap = 102/100 - 1 = 0.02, leveraged = 2*0.02 = 0.04 + # Impl_Open[1] = 200 * (1 + 0.04) = 208 + assert pricing["Impl_Open"].iloc[1] == 208.0 + + +class TestPricingEmpty: + """Empty underlying data returns empty pricing.""" + + def test_empty_underlying_returns_empty(self): + empty = pd.DataFrame(columns=["Open", "High", "Low", "Close"]) + base_close = make_ohlc( + [(200.0, 200.5, 199.5, 200.0)], start="2026-06-18 16:30", tz="Europe/London" + ) + pair = make_security_pair( + { + "longName": "2x Lev", + "leverage": 2, + "exchange": "LSE", + "timeZoneFullName": "Europe/London", + }, + base_close, + { + "longName": "Under", + "leverage": 1, + "exchange": "PCX", + "timeZoneFullName": "America/New_York", + }, + empty, + close_time=pd.Timestamp("2026-06-18 16:30:00+01:00"), + ) + pricing = pair.pricing() + assert pricing.empty + + +class TestPricingColumns: + """Output has the expected columns and order.""" + + def test_columns(self, simple_pair): + pricing = simple_pair.pricing() + assert list(pricing.columns) == [ + "Impl_Open", + "Impl_High", + "Impl_Low", + "Impl_Close", + ] diff --git a/tests/test_validation.py b/tests/test_validation.py new file mode 100644 index 0000000..d95ed36 --- /dev/null +++ b/tests/test_validation.py @@ -0,0 +1,47 @@ +"""Tests for ticker validation — fake tickers should be rejected.""" + +import pandas as pd +import pytest + +from afterquote._security_pair import SecurityPair +from tests.conftest import FakeYFinanceSecurity, make_ohlc + +REAL_INFO = { + "longName": "Real Thing", + "leverage": 2, + "exchange": "LSE", + "timeZoneFullName": "Europe/London", +} +FAKE_INFO = { + "longName": None, + "leverage": 1, + "exchange": None, + "timeZoneFullName": None, +} +EMPTY_HIST = pd.DataFrame(columns=["Open", "High", "Low", "Close"]) + + +def _build_pair(base_info, underlying_info): + """Build a SecurityPair bypassing __init__, with fakes wired in.""" + pair = SecurityPair.__new__(SecurityPair) + pair.base_yf = FakeYFinanceSecurity("BASE", base_info, EMPTY_HIST) + pair.underlying_yf = FakeYFinanceSecurity("UNDER", underlying_info, EMPTY_HIST) + pair.calendar = None # not used by is_valid_pair + return pair + + +class TestValidation: + def test_fake_pair_rejected(self): + pair = _build_pair(FAKE_INFO, FAKE_INFO) + assert pair.is_valid_pair() is False + + def test_mixed_pair_rejected(self): + pair = _build_pair(REAL_INFO, FAKE_INFO) + assert pair.is_valid_pair() is False + + pair2 = _build_pair(FAKE_INFO, REAL_INFO) + assert pair2.is_valid_pair() is False + + def test_real_pair_accepted(self): + pair = _build_pair(REAL_INFO, REAL_INFO) + assert pair.is_valid_pair() is True From 355aea3b08cfd859fc485919cca577976de2e002 Mon Sep 17 00:00:00 2001 From: Mohammad Junaid Date: Sat, 20 Jun 2026 19:53:16 +0100 Subject: [PATCH 09/13] ci: replace black+pylint with ruff, add mypy and pytest, test on 3.10/3.11/3.12 --- .github/workflows/pipeline.yml | 39 ++++++++++++++++++--------------- pyproject.toml | 4 ++-- requirements.txt | Bin 1522 -> 0 bytes tests/conftest.py | 6 ++--- tests/test_info.py | 7 +++--- tests/test_validation.py | 3 +-- 6 files changed, 29 insertions(+), 30 deletions(-) delete mode 100644 requirements.txt diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index b2e9f9e..7d18e82 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -1,4 +1,4 @@ -name: CI/CD Check +name: CI on: pull_request: @@ -9,24 +9,27 @@ on: - main jobs: - build: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: pip install -e ".[dev]" + - run: ruff format --check . + - run: ruff check . + - run: mypy afterquote/ + + test: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.12"] + python-version: ["3.10", "3.11", "3.12"] steps: - - uses: actions/checkout@v4 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v3 - with: - python-version: ${{ matrix.python-version }} - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -r requirements.txt - - name: Run Black (check formatting) - run: | - black --check . - - name: Run Pylint (fail if score < 9.0) - run: | - pylint $(git ls-files '*.py') --fail-under=9 + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - run: pip install -e ".[test]" + - run: pytest tests/ diff --git a/pyproject.toml b/pyproject.toml index abfb9d8..96c972c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,8 +16,8 @@ dependencies = [ [project.optional-dependencies] dev = [ - "black", - "pylint", + "ruff", + "mypy", ] test = [ "pytest", diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index fb8b7cf2576f04eb337a927db3b81da1a8ebf503..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1522 zcmaJ>+fL(94D|C#`zfF%Ew=~01+{6~jmV`WDUg0VyEC3+5|CI`Nvj@vJRXnF-;b>g zHd=3;RkpWBeAagDPi8CoZNF@1rETqusLBrbu94B<`(zuB-ylBoj7^HK5ntG|pXPSs z=hq3X09v20suZy@MG+~tTSqBS>wt=6lsUqkzsabuKd8`yM+u}WYr&_t2LIK_xOj!= zM3~CrY4E!DS~IH-xkTAI4zpK?V0Q310?^8F-lA6jnpkn9!FoxVmN6x=6_b?}@dqNw)TI9o- zpTLzj%`*3rP-Fkast3voa7vG#XSH@EH7&a2-LvPrLCA48F`~3rJi%ODQL_eJ)0gU6 zj%4qu&j{WIVn_UWzei|#a>b7SajkZEiFir+XiIY-%9*ODn}w6uVaCCiDC&uh*+GrY e9ZpY5mCWx}@#j`geq(ZzTSU&=sV#Z%sr~~-a^IH# diff --git a/tests/conftest.py b/tests/conftest.py index a8e4945..5fce915 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,8 +4,6 @@ import pytz import pytest -from afterquote._yfinance_wrapper import YFinanceSecurity -from afterquote._market_calendar import MarketCalendar from afterquote._security_pair import SecurityPair @@ -72,10 +70,10 @@ def make_ohlc(rows, start="2026-06-18 11:30", freq="1min", tz="America/New_York" """Build a fake OHLC DataFrame from a list of (O, H, L, C) tuples.""" index = pd.date_range(start, periods=len(rows), freq=freq, tz=tz) data = {"Open": [], "High": [], "Low": [], "Close": []} - for o, h, l, c in rows: + for o, h, low, c in rows: data["Open"].append(o) data["High"].append(h) - data["Low"].append(l) + data["Low"].append(low) data["Close"].append(c) return pd.DataFrame(data, index=index) diff --git a/tests/test_info.py b/tests/test_info.py index 826fdfe..5ab75fd 100644 --- a/tests/test_info.py +++ b/tests/test_info.py @@ -31,7 +31,7 @@ def test_live_returns_true(self): close_time=pd.Timestamp("2026-06-18 16:30:00+01:00"), ) info = pair.info() - assert info["base_is_live"].iloc[0] == True + assert bool(info["base_is_live"].iloc[0]) assert "adj_percent_return" not in info.columns @@ -40,9 +40,8 @@ class TestInfoSynthetic: def test_synthetic_has_adj_return(self, simple_pair): info = simple_pair.info() - assert info["base_is_live"].iloc[0] == False + assert not bool(info["base_is_live"].iloc[0]) assert "adj_percent_return" in info.columns - assert "quote_price" in info.columns def test_quote_price_is_last_impl_close(self, simple_pair): info = simple_pair.info() @@ -76,7 +75,7 @@ def test_no_data_returns_base_close(self): close_time=pd.Timestamp("2026-06-18 16:30:00+01:00"), ) info = pair.info() - assert info["base_is_live"].iloc[0] == False + assert not bool(info["base_is_live"].iloc[0]) assert "adj_percent_return" not in info.columns assert info["quote_price"].iloc[0] == info["base_close_price"].iloc[0] assert info["quote_price"].iloc[0] == 200.0 diff --git a/tests/test_validation.py b/tests/test_validation.py index d95ed36..ccf5aa4 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -1,10 +1,9 @@ """Tests for ticker validation — fake tickers should be rejected.""" import pandas as pd -import pytest from afterquote._security_pair import SecurityPair -from tests.conftest import FakeYFinanceSecurity, make_ohlc +from tests.conftest import FakeYFinanceSecurity REAL_INFO = { "longName": "Real Thing", From 43d4c97286be4592b03c0aad4ec521ac57ef030e Mon Sep 17 00:00:00 2001 From: Mohammad Junaid Date: Sat, 20 Jun 2026 19:54:30 +0100 Subject: [PATCH 10/13] docs: update README and CHANGELOG for 0.3.0 --- CHANGELOG.md | 29 +++++++++++++++++++++++++++++ README.md | 51 ++++++++++++++++++++++++++++++++------------------- 2 files changed, 61 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 315f702..a05a5f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 --- +## [0.3.0] - 2026-06-20 + +### Fixed + +- **OHLC anchoring** — `Impl_Open` and `Impl_Close` now both seed from the base's last close price (single anchor), fixing a phantom gap at t=0 and 99% of invalid candles. +- **Inter-bar gaps** — synthetic pricing now captures the gap between the underlying's prev close and current open, fixing a cumulative return error over long sessions. +- **Ticker validation** — `is_real_security()` now actually rejects fake tickers instead of always returning `True`. +- **Exchange mappings** — added 7 missing mappings (PCX, NYQ, AMS, GER, HKG, NSI, TOR) so common US/EU/Asia tickers work without `UnsupportedExchangeError`. +- **History fetch** — `pricing()` now passes `end=` to yfinance, fixing 0-row returns on newer yfinance versions. +- **Close-price lookup** — widened the lookup window from ±5min to −30min/+5min and returns the nearest bar, fixing crashes on thinly-traded securities. +- **No-data case** — `info()` now returns the base's real close when the underlying has no newer data, instead of crashing with `IndexError`. +- **Leverage on High/Low** — synthetic High/Low now correctly apply the leverage factor (previously un-leveraged). + +### Added + +- `QuoteInfo` dataclass for structured quote data with `to_frame()` method. +- pytest test suite (17 tests) with mocked yfinance — no network calls in CI. +- Live end-to-end tests (skipped by default, run with `--runlive`). +- ruff for formatting + linting (replaces black + pylint). +- mypy type checking in CI. +- CI now runs tests on Python 3.10, 3.11, 3.12. + +### Changed + +- Pinned all dependencies with lower bounds to prevent silent breakage from upstream changes. +- CI installs from `pyproject.toml` instead of a stale `requirements.txt` (deleted). + +--- + ## [0.2.1] - 2025-05-18 ### Changed diff --git a/README.md b/README.md index 8c95fa6..f970c8d 100644 --- a/README.md +++ b/README.md @@ -3,17 +3,18 @@ **Synthetic after-hours quote generator based on an asset and its underlying security.** [![PyPI version](https://img.shields.io/pypi/v/afterquote)](https://pypi.org/project/afterquote/) +[![PyPI downloads](https://static.pepy.tech/badge/afterquote)](https://pepy.tech/projects/afterquote) [![CI](https://github.com/Junaid2005/afterquote/actions/workflows/pipeline.yml/badge.svg)](https://github.com/Junaid2005/afterquote/actions/workflows/pipeline.yml) --- -## 📦 What is this? +## What is this? `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. --- -## 🚀 Installation +## Installation ### From PyPI: ```bash @@ -26,44 +27,56 @@ pip install afterquote pip install -e . ``` -## 🧪 Usage +## Usage ```python from afterquote import SecurityPair -pair = SecurityPair("MAG5.L", "MAGS") +pair = SecurityPair("3USL.L", "SPY") print(pair.info()) print(pair.pricing()) ``` -## 📘 Example Output +## Example Output ```text base_security underlying_security base_is_live leverage base_close_time base_close_price adj_percent_return quote_price quote_time -2025-05-17 00:59:00+01:00 MAG5.L MAGS False 5 2025-05-16 16:30:00+01:00 1160.0 -9.524988 1049.510137 +2026-06-19 00:59:00+01:00 3USL.L SPY False 3 2026-06-18 16:30:00+01:00 177.869995 0.621451 178.975369 ``` ```text - Impl_Open Impl_High Impl_Low Impl_Close + Impl_Open Impl_High Impl_Low Impl_Close Datetime -2025-05-16 16:30:00+01:00 1160.000000 1161.112373 1160.000000 1160.000000 -2025-05-16 16:31:00+01:00 1166.674491 1166.895738 1166.450943 1160.000000 -2025-05-16 16:32:00+01:00 1168.417608 1168.417608 1168.068568 1162.845038 -2025-05-16 16:33:00+01:00 1168.910139 1169.357836 1168.869835 1164.448536 -2025-05-16 16:34:00+01:00 1170.029168 1170.029168 1169.805191 1163.333992 +2026-06-18 16:30:00+01:00 177.869995 178.077587 177.733974 178.070422 +2026-06-18 16:31:00+01:00 178.070422 178.185074 177.941470 177.941470 +2026-06-18 16:32:00+01:00 177.927178 177.962971 177.769626 177.884217 +2026-06-18 16:33:00+01:00 177.884217 177.934294 177.619327 177.741023 +2026-06-18 16:34:00+01:00 177.762466 178.141756 177.762466 177.991464 ... ... ... ... ... -2025-05-17 00:55:00+01:00 1055.186402 1055.934313 1055.108109 1049.593529 -2025-05-17 00:56:00+01:00 1054.805236 1054.805236 1054.187307 1049.603777 -2025-05-17 00:57:00+01:00 1053.775354 1055.833509 1053.775354 1058.827009 -2025-05-17 00:58:00+01:00 1054.804431 1054.804431 1054.186503 1046.453387 -2025-05-17 00:59:00+01:00 1051.714787 1052.331267 1051.714787 1049.510137 +2026-06-19 00:55:00+01:00 179.061663 179.147951 179.040091 179.090425 +2026-06-19 00:56:00+01:00 179.083234 179.131847 178.953792 179.004130 +2026-06-19 00:57:00+01:00 179.004130 179.032887 178.982563 179.004130 +2026-06-19 00:58:00+01:00 178.986158 179.025695 178.960997 179.025695 +2026-06-19 00:59:00+01:00 179.007721 179.061640 178.946613 178.975369 ``` -## 🤝 Contributing +## Testing + +```bash +pip install -e ".[test]" +pytest tests/ +``` + +Live tests that hit real yfinance (skipped by default): +```bash +pytest tests/ --runlive +``` + +## Contributing Feel free to open issues or submit pull requests if you find bugs or want to improve the package - Junaid :) -## 📄 License +## License MIT License. See the [LICENSE](./LICENSE) file for full details. From 0d34b3b9ec4f1334a0943ffa24cfd37aab9a6efe Mon Sep 17 00:00:00 2001 From: Mohammad Junaid Date: Sat, 20 Jun 2026 20:19:23 +0100 Subject: [PATCH 11/13] docs: add AGENTS.md with project guide and symlink CLAUDE.md --- AGENTS.md | 65 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ CLAUDE.md | 1 + 2 files changed, 66 insertions(+) create mode 100644 AGENTS.md create mode 120000 CLAUDE.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..6bb68d7 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,65 @@ +# AGENTS.md + +## What is this + +`afterquote` is a Python package that generates synthetic after-hours price quotes for a financial security based on a correlated underlying asset that's still trading. Useful when one market is closed and the other is open. + +## Tech stack + +- Python 3.10+ +- yfinance (market data), pandas (data), pandas_market_calendars (exchange hours), pytz (timezones) +- ruff (formatting + linting), mypy (type checking), pytest (testing) +- GitHub Actions for CI, PyPI for publishing + +## Install + +```bash +pip install -e . # runtime +pip install -e ".[dev]" # + ruff, mypy +pip install -e ".[test]" # + pytest +``` + +## Commands + +```bash +ruff format --check . # format check (CI runs this) +ruff check . # lint (CI runs this) +mypy afterquote/ # type check (CI runs this) +pytest tests/ # unit tests, mocked, ~0.1s (CI runs this) +pytest tests/ --runlive # + live yfinance tests (manual, pre-release) +``` + +## Architecture + +Three modules in `afterquote/`: + +- `_yfinance_wrapper.py` — `YFinanceSecurity`: wraps a yfinance ticker. Provides `info`, `leverage`, `exchange`, `timezone`, `get_price_at(timestamp)`. +- `_market_calendar.py` — `MarketCalendar`: wraps pandas_market_calendars. Maps yfinance exchange codes (NMS, PCX, LSE, etc.) to calendar names. Provides `is_exchange_open`, `get_closing_time`, `get_exchange_tz`. +- `_security_pair.py` — `SecurityPair`: the main API. Holds a base + underlying, produces synthetic quotes. `QuoteInfo` dataclass structures the output. + +Public API: `SecurityPair(base, underlying)` with `.info()` and `.pricing()`. + +## The pricing model + +When the base exchange is closed but the underlying is trading, `pricing()` synthesizes OHLC bars for the base by applying the underlying's moves (×leverage) onto the base's last close price. + +Key principles: +- **Single anchor** — every synthetic price grows from the base's last **Close** (the settlement), not its Open. One seed, not two. +- **Two legs per bar** — inter-bar gap (underlying open vs prev close) then intra-bar move (underlying close vs its open). Multiplied, not added, because they're sequential. +- **Carry-forward** — `Impl_Open[t] == gap applied to Impl_Close[t-1]`. One continuous chain. +- **Leverage on everything** — Open, High, Low, Close all get the leverage factor. A 3x ETC's entire candle scales 3x. +- **Candles valid by construction** — High/Low/Close all derive from the same `Impl_Open`, so `High >= max(Open,Close) >= Low` always holds. + +## Tests + +Tests mock yfinance via `FakeYFinanceSecurity` and `FakeMarketCalendar` in `tests/conftest.py` — no network calls in CI. Live tests in `tests/test_live.py` are skipped unless `--runlive` is passed. + +Test data uses hand-computable numbers (e.g. anchor=200, leverage=2, underlying +1% → synthetic 204) so assertions are exact. + +## Conventions + +- Keep code terse — no excessive variables, no 1-letter names, no comments unless the code isn't self-explanatory +- Commit messages: brief title, optional 1-line description. `type(scope): summary` format (e.g. `fix(pricing): correct OHLC anchoring`) +- One concern per commit, every commit should pass CI +- Follow existing style — ruff handles formatting, don't fight it +- `pyproject.toml` is the source of truth for deps (no requirements.txt) diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file From 6705f8bea0d9933dedb97213563b2f159cb3e04a Mon Sep 17 00:00:00 2001 From: Mohammad Junaid Date: Sat, 20 Jun 2026 20:21:18 +0100 Subject: [PATCH 12/13] ci: add pre-commit hooks for ruff and mypy --- .pre-commit-config.yaml | 21 +++++++++++++++++++++ pyproject.toml | 4 ++++ 2 files changed, 25 insertions(+) create mode 100644 .pre-commit-config.yaml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..9e18b04 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,21 @@ +repos: + - repo: local + hooks: + - id: ruff-format + name: ruff format + entry: ruff format --check + language: system + types: [python] + pass_filenames: true + - id: ruff-check + name: ruff check + entry: ruff check + language: system + types: [python] + pass_filenames: true + - id: mypy + name: mypy + entry: mypy afterquote/ + language: system + types: [python] + pass_filenames: false diff --git a/pyproject.toml b/pyproject.toml index 96c972c..1835ae7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,7 @@ dependencies = [ dev = [ "ruff", "mypy", + "pre-commit", ] test = [ "pytest", @@ -26,3 +27,6 @@ test = [ [build-system] requires = ["setuptools>=61.0"] build-backend = "setuptools.build_meta" + +[tool.mypy] +ignore_missing_imports = true From 9550da2b445829d421a461e38a9e6dc61b4d5798 Mon Sep 17 00:00:00 2001 From: Mohammad Junaid Date: Sat, 20 Jun 2026 20:22:55 +0100 Subject: [PATCH 13/13] ci: harden PyPI workflow with split build/publish, least-privilege permissions --- .github/workflows/pypi.yml | 46 ++++++++++++++++++++++---------------- 1 file changed, 27 insertions(+), 19 deletions(-) diff --git a/.github/workflows/pypi.yml b/.github/workflows/pypi.yml index 699bf2e..4001eef 100644 --- a/.github/workflows/pypi.yml +++ b/.github/workflows/pypi.yml @@ -1,31 +1,39 @@ -name: Push to PyPI +name: Publish to PyPI on: push: tags: - "v*" +permissions: + contents: read + jobs: - publish: + build: runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Set up Python - uses: actions/setup-python@v5 + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 with: python-version: "3.12" + - run: pip install build + - run: python -m build + - uses: actions/upload-artifact@v4 + with: + name: dist + path: dist/ - - name: Install tools and build - run: | - python -m pip install --upgrade pip - pip install build twine - python -m build - - - name: Upload to PyPI - env: - TWINE_USERNAME: __token__ - TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} - run: twine upload dist/* + publish: + needs: build + runs-on: ubuntu-latest + environment: pypi + permissions: + id-token: write + steps: + - uses: actions/download-artifact@v4 + with: + name: dist + path: dist/ + - uses: pypa/gh-action-pypi-publish@release/v1 + with: + password: ${{ secrets.PYPI_API_TOKEN }}