Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 21 additions & 18 deletions .github/workflows/pipeline.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
name: CI/CD Check
name: CI

on:
pull_request:
Expand All @@ -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/
46 changes: 27 additions & 19 deletions .github/workflows/pypi.yml
Original file line number Diff line number Diff line change
@@ -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 }}
21 changes: 21 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -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
65 changes: 65 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -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)
29 changes: 29 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions CLAUDE.md
51 changes: 32 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
7 changes: 7 additions & 0 deletions afterquote/_market_calendar.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
Loading
Loading