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
23 changes: 19 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,16 @@ pytest tests/ --runlive # + live yfinance tests (manual, pre-release)

## Architecture

Three modules in `afterquote/`:
Seven modules in `afterquote/`:

- `_yfinance_wrapper.py` — `YFinanceSecurity`: wraps a yfinance ticker. Provides `info`, `leverage`, `exchange`, `timezone`, `get_price_at(timestamp)`.
- `_yfinance_wrapper.py` — `YFinanceSecurity`: wraps a yfinance ticker. Provides `info`, `leverage`, `exchange`, `timezone`, `currency`, `get_price_at(timestamp)`, `get_history(start, end, interval)`.
- `_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.
- `_security_pair.py` — `SecurityPair`: the main API. Holds a base + underlying, produces synthetic quotes. `QuoteInfo` dataclass structures the output. `correlation()` health check. `info(confidence=)` confidence band.
- `_benchmark.py` — `benchmark(pair, days=90)`: daily backtest of synthetic vs actual next-day open. `metrics(results)`: RMSE, MAE, direction hit-rate, tracking error.
- `_holdings.py` — `portfolio_pnl(path, as_of=None)`: CSV/JSON portfolio ingestion with per-position after-hours P&L.
- `_cli.py` — `main(argv=None)`: argparse CLI entrypoint. Flags: `--pricing`, `--benchmark`, `--correlation`, `--confidence`, `--holdings PATH`, `--as-of`. Mode flags are mutually exclusive.

Public API: `SecurityPair(base, underlying)` with `.info()` and `.pricing()`.
Public API: `SecurityPair(base, underlying)` with `.info()`, `.pricing()`, `.correlation()`. Module-level `benchmark()`, `metrics()`, `portfolio_pnl()`.

## The pricing model

Expand All @@ -50,6 +53,18 @@ Key principles:
- **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.

## FX adjustment

When base and underlying trade in different currencies, `pricing()` fetches the FX rate and applies it as a 1x multiplicative leg alongside the leveraged underlying return. GBp normalised to GBP. Same `_candle_returns` decomposition (gap + intra) shared by both legs.

## Confidence band

`info(confidence=0.95)` attaches `lower_bound`/`upper_bound` from the benchmark's empirical residual percentiles. No Gaussian assumption. First-order: assumes tomorrow's error is drawn from the last ~60 sessions' residuals.

## Correlation health check

`pair.correlation(days=90)` returns Pearson daily-return correlation. Emits `UserWarning` when `|corr| < 0.5`.

## 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.
Expand Down
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

---

## [1.0.0] - 2026-06-22

### Added

- **`as_of` parameter** — `pricing()` and `info()` accept a point-in-time timestamp for historical queries. Resolves to `now()` if omitted.
- **FX adjustment** — cross-currency pairs (e.g. `3TSL.L` in GBp vs `TSLA` in USD) now fetch the FX rate and apply it as a 1x multiplicative leg alongside the leveraged underlying return. GBp normalised to GBP.
- **CLI** — `afterquote BASE UNDERLYING [--as-of] [--pricing]` terminal entrypoint via `[project.scripts]`.
- **Holdings** — `portfolio_pnl(path, as_of=None)` reads CSV/JSON with `base,underlying,quantity` columns, returns per-position P&L + total. `pair_factory` injectable for tests.
- **Cache** — `lru_cache(maxsize=128)` on historical yfinance fetches, keyed by ticker + window + interval. Live price paths bypass the cache.
- **Benchmark** — `benchmark(pair, days=90)` walks each base trading session, applies leveraged + FX daily return to the prior close, compares synthetic open to actual next-day open. `metrics(results)` returns RMSE, MAE, direction hit-rate, tracking error, sample size.
- **Confidence band** — `info(confidence=0.95)` attaches `lower_bound`/`upper_bound` on `QuoteInfo` from the benchmark's empirical residual percentiles. No Gaussian assumption. Honest framing: first-order, assumes tomorrow's error is drawn from the last ~60 sessions' residuals.
- **Correlation health check** — `pair.correlation(days=90)` returns Pearson daily-return correlation between base and underlying. Emits `UserWarning` when `|corr| < 0.5`.

### Changed

- Demo pair changed from `3USL.L`/`SPY` to `3TSL.L`/`TSLA` — exercises both leverage and FX on one pair.
- `requires-python` bumped from `>=3.8` to `>=3.10`.
- `_candle_returns` refactored to a static method, shared by underlying and FX legs.
- Benchmark default `days` raised from 30 to 90 for stable residual percentiles.

---

## [0.3.0] - 2026-06-20

### Fixed
Expand Down
213 changes: 176 additions & 37 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,82 +1,221 @@
# afterquote

**Synthetic after-hours quote generator based on an asset and its underlying security.**
**Synthetic after-hours pricing for leveraged and cross-currency securities.**

[![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?
The London market closes at 4:30pm. TSLA keeps trading until 9pm EST. If you hold `3TSL.L` — a 3× leveraged ETP in GBp — you have no live price for the next several hours. `afterquote` fills that gap: it synthesises a real-time OHLC quote by applying the underlying's move (with leverage and FX adjustment) to the last known close.

`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

### From PyPI:
```bash
pip install afterquote
```

### Locally:
```python
from afterquote import SecurityPair

pair = SecurityPair("3TSL.L", "TSLA")
pair.info()
```

```bash
pip install -e .
```
base_security underlying_security base_is_live leverage base_close_time base_close_price adj_percent_return quote_price
quote_time
2026-06-19 00:59:00+01:00 3TSL.L TSLA False 3 2026-06-18 16:30:00+01:00 177.869995 0.621451 178.975369
```

---

## How it works

When the base exchange is closed and the underlying is still trading, `afterquote` builds a synthetic quote by decomposing each underlying bar into two multiplicative legs:

- **Gap return** — inter-bar move (underlying open vs its previous close), scaled by leverage
- **Intra return** — intra-bar move (underlying close vs its open), scaled by leverage

For cross-currency pairs (e.g. a GBp ETP tracking a USD stock), the FX rate is fetched and applied as a separate 1× leg — so leverage applies only to the underlying's return, not the currency move.

Every synthetic candle grows from a single anchor (the base's last close), so the chain is continuous and `High ≥ max(Open, Close) ≥ Low` holds by construction.

---

## Usage

### Synthetic quote

```python
from afterquote import SecurityPair
pair = SecurityPair("3TSL.L", "TSLA")

pair = SecurityPair("3USL.L", "SPY")
print(pair.info())
print(pair.pricing())
pair.info()
```

## Example Output
```text
base_security underlying_security base_is_live leverage base_close_time base_close_price adj_percent_return quote_price
```
base_security underlying_security base_is_live leverage base_close_time base_close_price adj_percent_return quote_price
quote_time
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
2026-06-19 00:59:00+01:00 3TSL.L TSLA 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
```python
pair.pricing()
```
```
Impl_Open Impl_High Impl_Low Impl_Close
Datetime
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
... ... ... ... ...
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
...
2026-06-19 00:59:00+01:00 178.946613 179.061640 178.946613 178.975369

[509 rows × 4 columns]
```

## Testing
### Confidence band

```python
pair.info(confidence=0.95)
```
```
base_security underlying_security base_is_live leverage base_close_time base_close_price adj_percent_return quote_price lower_bound upper_bound
quote_time
2026-06-19 00:59:00+01:00 3TSL.L TSLA False 3 2026-06-18 16:30:00+01:00 177.869995 0.621451 178.975369 176.420 181.530
```

`lower_bound` / `upper_bound` come from the empirical distribution of past prediction errors — no Gaussian assumption. The band is asymmetric when errors are skewed.

### Benchmark

```python
from afterquote import benchmark, metrics

results = benchmark(pair, days=90)
print(results)
```
```
base_close synth_open actual_open residual direction_correct
2026-03-26 148.320007 163.052340 152.440002 10.612338 True
2026-03-27 152.440002 144.918760 161.800003 -16.881240 False
2026-03-28 161.800003 174.338120 168.220001 6.118119 True
2026-03-31 168.220001 159.774480 163.559998 -3.785518 True
2026-04-01 163.559998 141.832900 129.680008 12.152892 True
...
2026-06-18 177.869995 179.341200 178.240005 1.101195 True

[62 rows × 5 columns]
```

```python
metrics(results)
```
```python
{'rmse': 74.1, 'mae': 58.3, 'direction_correct': 0.71, 'tracking_error': 61.2, 'n': 62}
```

The model called the direction right **71% of the time** over 62 sessions.

### Correlation health check

```python
pair.correlation()
# 0.7612
```

Pearson daily-return correlation between base and underlying over the last 90 days. Emits `UserWarning` when `|corr| < 0.5`.

### Portfolio P&L

`holdings.csv`:
```
base,underlying,quantity
3TSL.L,TSLA,1000
3USL.L,SPY,500
```

```python
from afterquote import portfolio_pnl

portfolio_pnl("holdings.csv")
```
```
base underlying quantity base_close_price quote_price pnl
3TSL.L TSLA 1000.0 177.869995 178.975369 1105.37
3USL.L SPY 500.0 312.540001 313.706240 583.12
TOTAL 1500.0 NaN NaN 1688.49
```

### Point-in-time queries

All methods accept `as_of` for historical reconstruction — no look-ahead bias:

```python
pair.info(as_of=pd.Timestamp("2026-06-18 20:00:00-04:00"))
pair.pricing(as_of=pd.Timestamp("2026-06-18 20:00:00-04:00"))
```

---

## CLI

```bash
pip install -e ".[test]"
pytest tests/
afterquote 3TSL.L TSLA # synthetic quote
afterquote 3TSL.L TSLA --confidence 0.95 # with confidence band
afterquote 3TSL.L TSLA --pricing # full OHLC bars
afterquote 3TSL.L TSLA --benchmark # backtest + metrics
afterquote 3TSL.L TSLA --correlation # correlation check
afterquote 3TSL.L TSLA --as-of "2026-06-18 20:00-04:00" # historical query
afterquote --holdings holdings.csv # portfolio P&L
```

```
$ afterquote 3TSL.L TSLA --benchmark

base_close synth_open actual_open residual direction_correct
2026-03-26 148.320007 163.052340 152.440002 10.612338 True
...
2026-06-18 177.869995 179.341200 178.240005 1.101195 True

rmse: 74.1
mae: 58.3
direction_correct: 0.71
tracking_error: 61.2
n: 62
```

```
$ afterquote 3TSL.L TSLA --correlation

correlation: 0.7612
```

---

## Demo pairs

| Pair | Leverage | FX | Use case |
|------|----------|----|----------|
| `3TSL.L` / `TSLA` | 3× | GBp → USD | Both leverage and FX active — the full model |
| `3USL.L` / `SPY` | 3× | — | Leverage only — same-currency baseline |

---

## Testing

Live tests that hit real yfinance (skipped by default):
```bash
pytest tests/ --runlive
pip install -e ".[test]"
pytest tests/ # 68 unit tests, mocked, ~0.4s — no network calls
pytest tests/ --runlive # + live yfinance validation
```

## Contributing
---

Feel free to open issues or submit pull requests if you find bugs or want to improve the package - Junaid :)
## Contributing

Issues and PRs welcome. — Junaid

## License

MIT License. See the [LICENSE](./LICENSE) file for full details.
MIT. See [LICENSE](./LICENSE).
4 changes: 3 additions & 1 deletion afterquote/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,7 @@
"""

from ._security_pair import SecurityPair
from ._holdings import portfolio_pnl
from ._benchmark import benchmark, metrics

__all__ = ["SecurityPair"]
__all__ = ["SecurityPair", "portfolio_pnl", "benchmark", "metrics"]
Loading
Loading