Metadata-Version: 2.4
Name: upticks
Version: 0.3.0
Summary: Price action, honestly — a causality-first price-action research library.
Author-email: Nashit Babber <nashit.babber@gmail.com>
License-Expression: Apache-2.0
Keywords: price action,ohlcv,market structure,pandas,backtesting
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Financial and Insurance Industry
Classifier: Intended Audience :: Science/Research
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Office/Business :: Financial :: Investment
Classifier: Topic :: Scientific/Engineering :: Information Analysis
Classifier: Typing :: Typed
Requires-Python: <3.14,>=3.11
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: pandas<4,>=2.2
Requires-Dist: numpy<3,>=1.24
Requires-Dist: scipy>=1.10
Provides-Extra: fast
Requires-Dist: numba; extra == "fast"
Provides-Extra: plot
Requires-Dist: matplotlib; extra == "plot"
Provides-Extra: cache
Requires-Dist: pyarrow; extra == "cache"
Provides-Extra: dev
Requires-Dist: pytest; extra == "dev"
Requires-Dist: hypothesis; extra == "dev"
Dynamic: license-file

# upticks

**Price action, honestly.** A causality-first price-action research library for Python.
Plain pandas in, plain pandas out.

```bash
pip install upticks
```

Most technical-analysis libraries will happily hand you a number that could not have been
known at the time it is stamped. `upticks` is built so that the leak is structurally
unavailable: every bar carries the instant it became knowable, and that is the only key a
join is allowed to use.

---

## Status: alpha — the foundation and its causality harness, not the whole library

**v0.3.0 is the event layer: one shape every detector will speak.** v0.1.5 loaded and
validated data, inferred sessions, and resampled them correctly. v0.2.0 added the layer that
turns "causality-first" from a design intent into something you can run: a leak-safe join, a
measuring harness, an AST lint, a warm-up model, a locked holdout, and a run manifest. v0.3.0
adds the thing detectors emit — a 35-column `EventFrame` with four timestamps, the operations
over it, three typed satellite tables for geometry that will not fit a fixed row, one documented
total order for same-bar resolution, and the registry and catalog that make a detector
discoverable before it runs.

There are **no detectors yet**. The registry ships empty on purpose: this is the shape, the
construction assertions and the guards, landed before anything fills them.

| shipping now | not here yet |
|---|---|
| `load` / `scan` — ingest, aliasing, dtype coercion, timezone discipline | indicators (moving averages, momentum, volatility, …) |
| 16 hygiene checks as a tidy report; `repair()` as a separate explicit call | candlestick and chart patterns |
| gap-based session inference — no exchange calendar, anywhere | pivots and market structure |
| the session-anchored resampler, `1min` → `YE` | the event-study and backtest engines |
| session parts: opening range, initial balance, closing range | plotting |
| corporate actions and the three adjustment modes | |
| **`align`** — the one join primitive, backward on `avail_ts` and nowhere else | |
| **`check_causality`** — cut the history, recompute, and report what moved | |
| **`lint_report`** — the AST lint over your detector, before it ever runs | |
| **`holdout`** — a tail sealed by a lock that propagates through every verb | |
| **`RunManifest` / `replay` / `diff_runs`** — what was run, on what, and what changed | |
| **`project_events` / `project_levels`** — carrying a coarse frame's answers down without leaking them | |
| **`make_events`** — the one constructor, and the only place an event is validated | |
| **`to_wide` / `to_events` / `dedupe` / `filter_events` / `compose_events`** — the operations over an event set | |
| **`join_bars`** — bar columns onto events, through the one join primitive and no other path | |
| **`precedence`** — the documented total order for everything that resolves on one bar | |
| **`catalog` / `explain` / `lookback`** — what a detector is, and what it will cost you, before you run it | |

If you install this expecting RSI, you will be disappointed. Indicators arrive in the next
stage. What is here is the layer all of that has to be a pure function of — and now also the
instrument that tells you whether it is.

---

## Why session anchoring

Resample a 09:15-opening equity session to hourly bars with pandas and you get this:

```python
df.resample("1h").agg(AGG)      # [45, 60, 60, 60, 60, 60, 30]  ← a 45-minute first bar
```

The grid is anchored to midnight, so the session's first bucket is a stub and every
subsequent boundary is offset from the open. `upticks` anchors each bucket to the session's
own first bar:

```python
up.resample(bars, "1h")         # [60, 60, 60, 60, 60, 60, 15]  ← anchored to 09:15
```

The difference compounds. A `75min` or `125min` frame under a global origin drifts to a
different time of day on every subsequent session; anchored per session it lands on the same
five (or three) boundaries every trading day. A special evening session — NSE's Muhurat
trading, 18:00–18:59 — becomes exactly one hourly bar instead of being shredded or dropped.

Both behaviours remain reachable: `anchor="midnight"` is the correct choice for a 24-hour
instrument, and it is the only way to obtain the naive grid. You cannot get it by accident.

---

## Causality, concretely

Every resampled bar carries an **18-column bar contract** beside the frame, and the column
that matters is `avail_ts` — the instant the bar became knowable:

```python
bars.contract[["bar_open_ts", "bar_close_ts", "avail_ts", "is_complete", "is_forming"]]
```

- **`bar_close_ts` is the actual last constituent plus one interval**, never the nominal
  period end. On a truncated or special session those differ, and the nominal answer is
  wrong.
- **`avail_ts` is the only legal join key.** Joining on a bar's label is what leaks, so
  labels are not offered as join keys anywhere.
- **`is_forming` marks a bar that can still change.** By default the still-open final bucket
  is withheld entirely (`forming="drop"`), so nothing knowable-early reaches a backtest. A
  daily bar for a session the feed has not finished publishing is not handed to you as
  settled.
- **Resampling to a finer frequency is refused** with a typed error. There is no public
  forward-fill-to-finer verb, because that is the leak.
- **Resample first, compute second.** `RSI(14)` on hourly bars is a different quantity from
  `RSI(14)` computed on minutes and aggregated; no public path produces the latter.

Tested as named properties, not asserted as design intent: volume conservation, extreme
preservation, `1min→1h→1D` bitwise identical to `1min→1D`, idempotence, no bucket spanning
two sessions, and `avail_ts > bar_open_ts` on every emitted bar.

**On look-ahead itself the claim is bounded, deliberately:**
**_no detectable look-ahead under these tests_ — never _provably none_.**
The bound has not moved; what has changed is that **"these tests" is now an enumerable,
named list you can run yourself**, rather than a promise about the next stage. It is 342
tests across ten modules, and every one of them names the property it holds:

| the layer | what it catches | the tests |
|---|---|---|
| schema assertions at construction | a frame that cannot be reasoned about at all | throughout |
| `check_causality` | a settled row that changes when the future is removed | `tests/test_causality.py` (29) |
| planted bugs | a harness that reports clean because it is broken | `tests/test_planted.py` (55), over `tests/planted/` |
| the AST lint | full-series `.max()`, `shift(-n)`, `rolling(center=True)`, bare `.ewm(` | `tests/test_lint.py` (56) |
| `align`, the one join primitive | a join that reads a bar before it published | `tests/test_align.py` (28) |
| provenance-only leak scanning | the 20,685 false positives value-equality scanning produces | `tests/test_align_causality.py` (13) |
| the warm-up model | a recursive indicator whose divergence stops shrinking | `tests/test_warmup.py` (27) |
| the locked holdout | a tail you read without meaning to | `tests/test_holdout.py` (35) |
| the dependence register | a non-causal transform used where causality matters | `tests/test_dependence.py` (28) |
| the run manifest | a result you cannot reproduce or diff | `tests/test_manifest.py` (52) |
| composition | a chained route publishing earlier than the direct one | `tests/test_composition.py` (19) |

Read the claim as exactly what it says: these tests did not detect a leak, which is not the
same as there being none. The difference now is that you can read the list, run it against
your own detector, and see precisely which classes of leak it does and does not cover.

---

## Sessions without a calendar

There is no exchange-calendar dependency anywhere in this library, and there never will be.
Sessions are inferred from the multiplicative structure of the data's own gaps; the holiday
table is the complement of the observed trading days.

This handles, on real data:

- **special sessions** — an evening or afternoon session that matches no modal shape is kept
  as a first-class session, never merged into a neighbour
- **half-days and truncated feeds** — flagged `is_short`, and `is_provisional` when they sit
  at the data tail and the vendor may still revise them
- **lunch-break markets** — a mid-session gap is absorbed as a *break*, not a boundary, so
  Tokyo's 11:30–12:30 does not split the day in two
- **midnight-crossing sessions** — a CME Globex 23:30 bar belongs to the next calendar day's
  trade date, and the session is one session
- **trading halts** — a 90-minute hole is a shape deviation, not a session split
- **DST transitions** — boundaries are computed in integer nanoseconds from the session open,
  so a spring-forward week does not shift them

A declared `SessionShape` bypasses inference entirely where the heuristic is unsafe.

---

## Quick start

```python
import upticks as up

bars = up.load("NIFTY_1min.csv", tz="Asia/Kolkata", exchange="NSE", preset="nse_intraday")

print(bars.report())
# 500 sessions | 8 short | 2 off-hours (Muhurat) | tick 0.05 | 186747 bars | 2024-08-07 → 2026-08-12

bars.quality          # the 16 hygiene checks, one row each
bars.sessions.table   # one row per session, with its flags

hourly = up.resample(bars, "1h")
daily  = up.resample(bars, "1D")          # one bar per SESSION, never a midnight resample
weekly = up.resample(bars, "W-FRI")       # restamped to the last actual session of the week

orb = up.opening_range(bars, 15)          # one bar per session, first 15 minutes
```

Nothing is repaired behind your back. `load()` reports; `repair()` is a separate call that
takes an explicit policy and records it in `Meta`.

Then the causality half:

```python
joined = up.align(bars, daily, columns=["close"])   # backward on avail_ts; no other key exists

report = up.check_causality(my_detector, bars)      # cut the history, recompute, compare
report.is_causal, report.confirmation_lag, report.n_repainting_bars

up.lint_report("my_package")                        # the AST lint, before anything ever runs

split = up.holdout(bars, frac=0.2)                  # the tail is locked, not merely separate
split.train                                         # the lock rides on split.test and every
                                                    # handle derived from it; re-splitting,
                                                    # widening or re-attaching one raises
                                                    # HoldoutLocked. Reading it does not — the
                                                    # seal refuses by verb, not by read barrier.

run = up.RunManifest.capture(                       # what ran, on what data, with what config
    bars, steps=[up.Step("load", load_kwargs),      # a chain begins at its source, so step 0 is
                 up.Step("resample", {"timeframe": "1h"})]   # the load that produced `bars`
)
rebuilt = up.replay(run, source="RELIANCE.csv")     # re-execute it, verifying every digest — the
                                                    # manifest records what was read, never bytes
up.diff_runs(run, other_run)                        # …and name what differs between two runs
```

`align` is the only join in the library, and it is backward on `avail_ts`. There is no
`'nearest'`, no `'forward'`, and no label-keyed join anywhere on the public surface.

---

## Design commitments

**Three runtime dependencies: pandas, numpy, scipy.** Nothing else, ever. `pyarrow`,
`matplotlib` and `numba` are optional extras, imported lazily inside the one function that
needs them, and their absence raises an error naming the extra.

**`bars.df` is a plain DataFrame** and never a subclass, so anything that consumes pandas can
consume it. Metadata that pandas drops — timezone, tick grid, session table, fingerprint,
adjustment lineage — lives on the handle instead.

**Refusals name the missing data.** Every error carries a remedy that says what to pass.
An ambiguous frequency alias is refused rather than guessed: `60m` means sixty minutes in
MetaTrader and sixty month-ends in pandas, so `upticks` refuses it and names both.

**Defaults are documented, not folklore.** `up.defaults_provenance()` returns every numeric
default with its origin and citation.

---

## Supported versions

Python 3.11–3.13. pandas 2.2 through 3.x, and the test suite is run under **both** majors —
pandas 3 changed the default datetime resolution from nanoseconds to microseconds, which
silently breaks naive integer-time arithmetic, so this is verified rather than assumed.

---

## Honest limitations

- **Alpha.** The public surface of this stage is stable and tested, but later stages will add
  to it. Pin the version.
- **Session inference is a heuristic.** It is validated against every session of a
  two-year 1-minute reference file and against synthetic fixtures for four other market
  shapes, but a genuinely novel session structure may need a declared `SessionShape`.
- **Corporate-action detection is candidate-only.** Splits and bonuses are matched against a
  small set of rational overnight ratios. An ex-dividend drop is observationally identical to
  an ordinary news gap without a dividend feed, and is reported as a candidate, never a fact.
- **Back-adjustment is non-causal by construction** and says so: rescaling pre-ex-date bars
  uses information from the ex-date. It is available, registered as non-causal, and refused
  by default where causality matters.
- **No exchange calendar means no forward-looking holidays.** The data is the calendar, so a
  holiday after the last bar is unknowable; supply one explicitly if you need it.
- **`check_causality` is a measurement, not a proof.** It cuts the history at a finite set of
  points and compares. A leak that only fires at a cut point it did not choose is a leak it
  will not report, which is why the AST lint and the planted-bug corpus sit beside it and
  catch different classes. `is_causal` is additionally allowed to be *undetermined* — with
  the reason named — rather than being forced to a boolean it cannot support.
- **The AST lint is syntactic.** It reads your source, so a banned operation reached through
  `getattr` or a third-party helper is invisible to it. It is a cheap filter in front of the
  expensive check, never a substitute for it.
- **Value-equality leak scanning is not offered, deliberately.** On the reference file it
  produces **20,685** false positives on the 1D→1min join — 11.10 % of it — from 48 duplicated
  values across 56 daily bars. The two sessions that happen to close at exactly 1355.0 contribute
  **375** of those; that figure is an attribution and never the total, and quoting it alone
  understates the method's error rate 55.2×. At 3min the total is 166,755. Leak tests here compare
  provenance.

---

## License

Apache-2.0. Copyright (c) Nashit Babber.
