pit-adjuster 0.1.0__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.
@@ -0,0 +1,23 @@
1
+ """pit-adjuster: point-in-time fixed-basis back-adjustment engine.
2
+
3
+ Rebuild daily price history so that any day reads exactly what that day
4
+ could have known: fixed-basis hfq prices from current-vintage qfq bars plus
5
+ a point-in-time corporate-action archive, with verifiable inversion,
6
+ forward-adjustment drift detection and snapshot equivalence checks.
7
+
8
+ Zero dependencies. Python 3.11+.
9
+ """
10
+
11
+ from .chain import build_multipliers, events_from_actions, rebuild_bars
12
+ from .validation import compare_raw_closes, compare_snapshots, validate_inversion
13
+
14
+ __version__ = "0.1.0"
15
+
16
+ __all__ = [
17
+ "build_multipliers",
18
+ "compare_raw_closes",
19
+ "compare_snapshots",
20
+ "events_from_actions",
21
+ "rebuild_bars",
22
+ "validate_inversion",
23
+ ]
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
pit_adjuster/chain.py ADDED
@@ -0,0 +1,184 @@
1
+ """Core adjustment chain: PIT-safe fixed-basis back-adjusted (hfq) prices.
2
+
3
+ Builds a PIT-safe daily history from:
4
+
5
+ - current-vintage forward-adjusted (qfq) daily bars, and
6
+ - a point-in-time corporate-action archive (one ``adjustment_factor`` per
7
+ event).
8
+
9
+ Standard A-share adjustment math (exchange reference-price convention):
10
+
11
+ factor_e = (prior_close - cash) / (prior_close * (1 + bonus + transfer))
12
+ qfq_t = raw_t * prod_{e: ex_date_e > t} factor_e
13
+ hfq_t = raw_t * prod_{e: ex_date_e <= t} (1 / factor_e)
14
+
15
+ The rebuild inverts the vendor qfq chain back to raw prices, then re-applies
16
+ only events whose ex-date is on or before each bar date (fixed basis at the
17
+ archive coverage start). Raw open/close are kept alongside so
18
+ execution-level backtests can map adjusted prices back to nominal prices.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import bisect
24
+ import math
25
+ from typing import Any
26
+
27
+ DEFAULT_VOLUME_TO_SHARES = 100.0
28
+ DEFAULT_NATIVE_SHARE_PREFIXES = ("688", "689")
29
+
30
+
31
+ def events_from_actions(
32
+ actions: list[dict[str, Any]], *, as_of_date: str
33
+ ) -> list[dict[str, Any]]:
34
+ """Return actions sorted by ex-date, keeping only events <= as_of_date.
35
+
36
+ Each returned event carries ``ex_date``, ``factor`` and ``available_at``.
37
+ Invalid records (missing ex-date or non-positive factor) are dropped.
38
+ """
39
+ events: list[dict[str, Any]] = []
40
+ for action in actions or []:
41
+ ex_date = str(action.get("ex_date") or "")
42
+ factor = action.get("adjustment_factor")
43
+ try:
44
+ factor = float(factor)
45
+ except (TypeError, ValueError):
46
+ continue
47
+ if len(ex_date) < 10 or not math.isfinite(factor) or factor <= 0:
48
+ continue
49
+ if ex_date[:10] > as_of_date:
50
+ continue
51
+ events.append(
52
+ {
53
+ "ex_date": ex_date[:10],
54
+ "factor": factor,
55
+ "available_at": str(action.get("available_at") or ""),
56
+ }
57
+ )
58
+ events.sort(key=lambda item: item["ex_date"])
59
+ return events
60
+
61
+
62
+ def build_multipliers(
63
+ events: list[dict[str, Any]],
64
+ ) -> tuple[list[str], list[float], list[float]]:
65
+ """Return ``(ex_dates, hfq_prefix, qfq_suffix)`` cumulative multipliers.
66
+
67
+ ``hfq_prefix[k]`` = prod_{j<k} (1 / factor_j)
68
+ ``qfq_suffix[k]`` = prod_{j>=k} factor_j
69
+ Both arrays have length ``n + 1``; ``k`` is the count of events whose
70
+ ex-date is on or before a bar date (0 .. n).
71
+ """
72
+ ex_dates = [event["ex_date"] for event in events]
73
+ count = len(events)
74
+ hfq_prefix = [1.0] * (count + 1)
75
+ acc = 1.0
76
+ for index, event in enumerate(events):
77
+ acc *= 1.0 / float(event["factor"])
78
+ hfq_prefix[index + 1] = acc
79
+ qfq_suffix = [1.0] * (count + 1)
80
+ acc = 1.0
81
+ for index in range(count - 1, -1, -1):
82
+ acc *= float(events[index]["factor"])
83
+ qfq_suffix[index] = acc
84
+ return ex_dates, hfq_prefix, qfq_suffix
85
+
86
+
87
+ def _bar_date(bar: dict[str, Any]) -> str:
88
+ return str(bar.get("date") or "")[:10]
89
+
90
+
91
+ def _raw_close(bar: dict[str, Any]) -> float | None:
92
+ value = bar.get("raw_close")
93
+ try:
94
+ value = float(value)
95
+ except (TypeError, ValueError):
96
+ return None
97
+ return value if math.isfinite(value) and value > 0 else None
98
+
99
+
100
+ def rebuild_bars(
101
+ bars: list[dict[str, Any]],
102
+ events: list[dict[str, Any]],
103
+ *,
104
+ as_of_date: str,
105
+ code: str | None = None,
106
+ volume_to_shares: float | None = None,
107
+ native_share_prefixes: tuple[str, ...] = DEFAULT_NATIVE_SHARE_PREFIXES,
108
+ ) -> tuple[list[dict[str, Any]], dict[str, int]]:
109
+ """Rebuild one code's bars to fixed-basis hfq (PIT).
110
+
111
+ Returns ``(new_bars, stats)``. Every valid bar gets ``open/high/low/
112
+ close`` as hfq prices, ``raw_open/raw_close`` as nominal prices, and
113
+ ``adj_factor`` as the cumulative hfq multiplier. Volume is converted to
114
+ shares: codes whose prefix is in ``native_share_prefixes`` already store
115
+ shares (no conversion), all other codes use ``volume_to_shares``
116
+ (default: 1 lot = 100 shares). Amount, turnover and any extra fields
117
+ are preserved.
118
+
119
+ The PIT property: a price at time ``t`` is a function only of events
120
+ whose ex-date is on or before ``t`` (fixed basis at archive coverage
121
+ start).
122
+ """
123
+ effective_multiplier = (
124
+ volume_to_shares
125
+ if volume_to_shares is not None
126
+ else (
127
+ 1.0
128
+ if str(code or "").startswith(native_share_prefixes)
129
+ else DEFAULT_VOLUME_TO_SHARES
130
+ )
131
+ )
132
+ events = events_from_actions(events, as_of_date=as_of_date)
133
+ ex_dates, hfq_prefix, qfq_suffix = build_multipliers(events)
134
+ new_bars: list[dict[str, Any]] = []
135
+ stats: dict[str, int] = {"bars": 0, "invalid_bars": 0}
136
+ for bar in bars or []:
137
+ date_text = _bar_date(bar)
138
+ if not date_text:
139
+ stats["invalid_bars"] += 1
140
+ continue
141
+ close = bar.get("close")
142
+ try:
143
+ close_f = float(close)
144
+ except (TypeError, ValueError):
145
+ close_f = math.nan
146
+ valid = math.isfinite(close_f) and close_f > 0
147
+ k = bisect.bisect_right(ex_dates, date_text) if ex_dates else 0
148
+ qfq_mult = qfq_suffix[k] if ex_dates else 1.0
149
+ hfq_mult = hfq_prefix[k] if ex_dates else 1.0
150
+ rebuilt = dict(bar)
151
+ try:
152
+ volume_f = float(bar.get("volume") or 0.0)
153
+ except (TypeError, ValueError):
154
+ volume_f = math.nan
155
+ if math.isfinite(volume_f) and volume_f > 0:
156
+ rebuilt["volume"] = round(volume_f * effective_multiplier, 2)
157
+ else:
158
+ rebuilt["volume"] = volume_f if math.isfinite(volume_f) else None
159
+ if valid:
160
+ raw_close = close_f / qfq_mult
161
+ rebuilt["raw_close"] = round(raw_close, 6)
162
+ rebuilt["adj_factor"] = round(hfq_mult, 12)
163
+ for key in ("open", "high", "low"):
164
+ value = bar.get(key)
165
+ try:
166
+ value_f = float(value)
167
+ except (TypeError, ValueError):
168
+ value_f = math.nan
169
+ if math.isfinite(value_f) and value_f > 0:
170
+ raw_value = value_f / qfq_mult
171
+ rebuilt[key] = round(raw_value * hfq_mult, 6)
172
+ if key == "open":
173
+ rebuilt["raw_open"] = round(raw_value, 6)
174
+ else:
175
+ rebuilt[key] = None
176
+ rebuilt["close"] = round(raw_close * hfq_mult, 6)
177
+ stats["bars"] += 1
178
+ else:
179
+ rebuilt["raw_close"] = None
180
+ rebuilt["raw_open"] = None
181
+ rebuilt["adj_factor"] = 1.0
182
+ stats["invalid_bars"] += 1
183
+ new_bars.append(rebuilt)
184
+ return new_bars, stats
pit_adjuster/cli.py ADDED
@@ -0,0 +1,146 @@
1
+ """Command-line interface for pit-adjuster.
2
+
3
+ Subcommands:
4
+
5
+ - ``rebuild`` rebuild bars to fixed-basis hfq (PIT)
6
+ - ``invert-check`` ex-date continuity sanity check
7
+ - ``drift-check`` static forward-adjustment detection vs live closes
8
+ - ``snapshot-equivalence`` before/after equivalence of two rebuilds
9
+ - ``version`` print version
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import json
16
+ import sys
17
+ from typing import Any
18
+
19
+ from . import __version__
20
+ from .chain import events_from_actions, rebuild_bars
21
+ from .io import load_actions, load_bars, write_json
22
+ from .validation import compare_raw_closes, compare_snapshots, validate_inversion
23
+
24
+
25
+ def _print_summary(heading: str, body: dict[str, Any]) -> None:
26
+ print(f"== {heading} ==")
27
+ print(json.dumps(body, ensure_ascii=False, indent=2))
28
+
29
+
30
+ def _add_common_inputs(parser: argparse.ArgumentParser) -> None:
31
+ parser.add_argument("--bars", required=True, help="bars JSON/JSONL path")
32
+ parser.add_argument("--actions", required=True, help="corporate-action archive JSON/JSONL path")
33
+ parser.add_argument("--as-of", required=True, help="PIT cutoff date (YYYY-MM-DD)")
34
+
35
+
36
+ def build_parser() -> argparse.ArgumentParser:
37
+ parser = argparse.ArgumentParser(
38
+ prog="padj",
39
+ description="Point-in-time fixed-basis back-adjustment engine.",
40
+ )
41
+ sub = parser.add_subparsers(dest="command", required=True)
42
+
43
+ rebuild = sub.add_parser("rebuild", help="rebuild bars to fixed-basis hfq (PIT)")
44
+ _add_common_inputs(rebuild)
45
+ rebuild.add_argument("--code", default=None, help="instrument code (prefix rules for volume)")
46
+ rebuild.add_argument("--volume-to-shares", type=float, default=None, help="override volume multiplier")
47
+ rebuild.add_argument("--out", default=None, help="output path (JSON); default: stdout summary")
48
+
49
+ invert = sub.add_parser("invert-check", help="ex-date continuity sanity check")
50
+ _add_common_inputs(invert)
51
+ invert.add_argument("--tolerance", type=float, default=0.03)
52
+
53
+ drift = sub.add_parser("drift-check", help="static forward-adjustment detection vs live closes")
54
+ _add_common_inputs(drift)
55
+ drift.add_argument("--live", required=True, help="live raw closes JSON (date -> close)")
56
+ drift.add_argument("--sample-days", type=int, default=5)
57
+
58
+ snap = sub.add_parser("snapshot-equivalence", help="before/after equivalence of two rebuilds")
59
+ snap.add_argument("--before", required=True, help="rebuilt bars JSON (old pipeline)")
60
+ snap.add_argument("--after", required=True, help="rebuilt bars JSON (new pipeline)")
61
+ snap.add_argument("--tolerance", type=float, default=1e-6)
62
+
63
+ sub.add_parser("version", help="print version")
64
+ return parser
65
+
66
+
67
+ def main(argv: list[str] | None = None) -> int:
68
+ parser = build_parser()
69
+ args = parser.parse_args(argv)
70
+
71
+ if args.command == "version":
72
+ print(__version__)
73
+ return 0
74
+
75
+ if args.command == "rebuild":
76
+ bars = load_bars(args.bars)
77
+ actions = load_actions(args.actions)
78
+ new_bars, stats = rebuild_bars(
79
+ bars,
80
+ actions,
81
+ as_of_date=args.as_of,
82
+ code=args.code,
83
+ volume_to_shares=args.volume_to_shares,
84
+ )
85
+ if args.out:
86
+ write_json(args.out, new_bars)
87
+ print(f"rebuild: {stats['bars']} bars, {stats['invalid_bars']} invalid -> {args.out}")
88
+ else:
89
+ _print_summary(f"rebuild ({stats['bars']} bars, {stats['invalid_bars']} invalid)", new_bars[-5:])
90
+ return 0
91
+
92
+ if args.command == "invert-check":
93
+ bars = load_bars(args.bars)
94
+ actions = load_actions(args.actions)
95
+ violations = validate_inversion(bars, actions, as_of_date=args.as_of, tolerance=args.tolerance)
96
+ if violations:
97
+ _print_summary(f"invert-check: {len(violations)} violation(s)", {"violations": violations})
98
+ return 1
99
+ print("invert-check: ok (no ex-date continuity violations)")
100
+ return 0
101
+
102
+ if args.command == "drift-check":
103
+ bars = load_bars(args.bars)
104
+ actions = load_actions(args.actions)
105
+ live = _load_live(args.live)
106
+ report = compare_raw_closes(bars, live, sample_days=args.sample_days)
107
+ _print_summary("drift-check", report)
108
+ if report["worst_deviation"] is not None and report["worst_deviation"] > 0.01:
109
+ return 1
110
+ return 0
111
+
112
+ if args.command == "snapshot-equivalence":
113
+ before = load_bars(args.before)
114
+ after = load_bars(args.after)
115
+ report = compare_snapshots(before, after, tolerance=args.tolerance)
116
+ _print_summary("snapshot-equivalence", report)
117
+ return 0 if report["equal"] else 1
118
+
119
+ parser.error(f"unknown command: {args.command}")
120
+ return 2
121
+
122
+
123
+ def _load_live(path: str) -> dict[str, float]:
124
+ import os
125
+
126
+ if path.endswith(".jsonl"):
127
+ rows: dict[str, float] = {}
128
+ with open(path, "r", encoding="utf-8") as handle:
129
+ for line in handle:
130
+ line = line.strip()
131
+ if not line:
132
+ continue
133
+ row = json.loads(line)
134
+ if isinstance(row, dict) and row.get("date"):
135
+ rows[str(row["date"])[:10]] = float(row["close"])
136
+ return rows
137
+ if os.path.exists(path):
138
+ with open(path, "r", encoding="utf-8") as handle:
139
+ loaded = json.load(handle)
140
+ if isinstance(loaded, dict):
141
+ return {str(key)[:10]: float(value) for key, value in loaded.items()}
142
+ raise ValueError(f"live closes must be a JSON object mapping date -> close: {path}")
143
+
144
+
145
+ if __name__ == "__main__":
146
+ sys.exit(main())
pit_adjuster/io.py ADDED
@@ -0,0 +1,62 @@
1
+ """Tiny JSON/JSONL IO helpers (stdlib only).
2
+
3
+ Bars and actions are plain JSON; a path or a list are interchangeable
4
+ inputs, so the library works on both files and in-memory data.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+ from typing import Any
12
+
13
+
14
+ def read_json(path: str | os.PathLike[str]) -> Any:
15
+ """Read one JSON document from a file."""
16
+ with open(path, "r", encoding="utf-8") as handle:
17
+ return json.load(handle)
18
+
19
+
20
+ def read_jsonl(path: str | os.PathLike[str]) -> list[Any]:
21
+ """Read a JSON-lines file into a list of records."""
22
+ rows: list[Any] = []
23
+ with open(path, "r", encoding="utf-8") as handle:
24
+ for line in handle:
25
+ line = line.strip()
26
+ if line:
27
+ rows.append(json.loads(line))
28
+ return rows
29
+
30
+
31
+ def write_json(path: str | os.PathLike[str], value: Any) -> None:
32
+ """Write a JSON document, atomically via a temp file."""
33
+ tmp = f"{path}.tmp"
34
+ with open(tmp, "w", encoding="utf-8") as handle:
35
+ json.dump(value, handle, ensure_ascii=False, indent=2)
36
+ os.replace(tmp, path)
37
+
38
+
39
+ def load_bars(source: str | os.PathLike[str] | list[dict[str, Any]]) -> list[dict[str, Any]]:
40
+ """Accept a path (JSON or JSONL) or an in-memory list of bars."""
41
+ if isinstance(source, list):
42
+ return source
43
+ text = os.fspath(source)
44
+ if text.endswith(".jsonl"):
45
+ return [row for row in read_jsonl(text) if isinstance(row, dict)]
46
+ loaded = read_json(text)
47
+ if isinstance(loaded, list):
48
+ return [row for row in loaded if isinstance(row, dict)]
49
+ raise ValueError(f"bars source must be a JSON list of objects: {text}")
50
+
51
+
52
+ def load_actions(source: str | os.PathLike[str] | list[dict[str, Any]]) -> list[dict[str, Any]]:
53
+ """Accept a path (JSON or JSONL) or an in-memory list of actions."""
54
+ if isinstance(source, list):
55
+ return source
56
+ text = os.fspath(source)
57
+ if text.endswith(".jsonl"):
58
+ return [row for row in read_jsonl(text) if isinstance(row, dict)]
59
+ loaded = read_json(text)
60
+ if isinstance(loaded, list):
61
+ return [row for row in loaded if isinstance(row, dict)]
62
+ raise ValueError(f"actions source must be a JSON list of objects: {text}")
@@ -0,0 +1,178 @@
1
+ """Verification checks: inversion continuity, drift detection, equivalence.
2
+
3
+ The checks here are the "look-ahead freedom as a verifiable property" core
4
+ of pit-adjuster. They operate on the value-independent fragment of the
5
+ adjustment problem (factor chains, ex-date ordering, chain inversion) where
6
+ the checks are exact; the general value-dependent case falls back to
7
+ heuristic guards, stated explicitly per check.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import bisect
13
+ import math
14
+ from typing import Any
15
+
16
+ from .chain import _bar_date, _raw_close, events_from_actions
17
+
18
+
19
+ def validate_inversion(
20
+ bars: list[dict[str, Any]],
21
+ events: list[dict[str, Any]],
22
+ *,
23
+ as_of_date: str,
24
+ tolerance: float = 0.03,
25
+ ) -> list[dict[str, Any]]:
26
+ """Sanity check of ex-date continuity on rebuilt (raw_close) bars.
27
+
28
+ Theoretical raw price on the ex-date is ``raw_{ex-1} * factor_e`` (the
29
+ exchange ex-right reference price). This is informational: with real
30
+ data the ex-date itself carries a normal overnight return, so violations
31
+ can be false positives. The authoritative divergence check is
32
+ ``compare_raw_closes`` against live raw closes.
33
+ """
34
+ events = events_from_actions(events, as_of_date=as_of_date)
35
+ if not events:
36
+ return []
37
+ violations: list[dict[str, Any]] = []
38
+ by_date: dict[str, dict[str, Any]] = {}
39
+ for bar in bars:
40
+ date_text = _bar_date(bar)
41
+ if date_text:
42
+ by_date[date_text] = bar
43
+ dates = sorted(by_date)
44
+ for event in events:
45
+ ex_date = event["ex_date"]
46
+ index = bisect.bisect_left(dates, ex_date)
47
+ if index <= 0 or index >= len(dates):
48
+ continue
49
+ prev_date = dates[index - 1]
50
+ cur_date = dates[index]
51
+ prev_raw = _raw_close(by_date[prev_date])
52
+ cur_raw = _raw_close(by_date[cur_date])
53
+ if prev_raw is None or cur_raw is None or prev_raw <= 0:
54
+ continue
55
+ expected = prev_raw * float(event["factor"])
56
+ deviation = abs(cur_raw - expected) / expected
57
+ if deviation > tolerance:
58
+ violations.append(
59
+ {
60
+ "ex_date": ex_date,
61
+ "first_bar_on_or_after": cur_date,
62
+ "factor": float(event["factor"]),
63
+ "prev_date": prev_date,
64
+ "prev_raw_close": prev_raw,
65
+ "raw_close": cur_raw,
66
+ "expected_raw_close": round(expected, 6),
67
+ "deviation": round(deviation, 6),
68
+ "tolerance": tolerance,
69
+ }
70
+ )
71
+ return violations
72
+
73
+
74
+ def compare_raw_closes(
75
+ bars: list[dict[str, Any]],
76
+ live_closes: dict[str, float],
77
+ *,
78
+ sample_days: int = 5,
79
+ ) -> dict[str, Any]:
80
+ """Compare inverted raw closes against live raw closes.
81
+
82
+ Directly detects vendor qfq chains that diverge from the archive
83
+ adjustment factors (deviation above tolerance is authoritative). This
84
+ is the **static forward-adjustment detector**: if a vendor silently
85
+ swaps adjustment conventions, inverted raws stop matching live raws and
86
+ this check fires.
87
+ """
88
+ dated = [bar for bar in bars if bar.get("date") and bar.get("raw_close")]
89
+ dated.sort(key=lambda bar: str(bar["date"]))
90
+ sampled = dated[-max(1, sample_days):]
91
+ rows: list[dict[str, Any]] = []
92
+ for bar in sampled:
93
+ day = str(bar["date"])[:10]
94
+ live = live_closes.get(day)
95
+ try:
96
+ live_f = float(live)
97
+ except (TypeError, ValueError):
98
+ continue
99
+ inverted = float(bar["raw_close"])
100
+ if not math.isfinite(live_f) or live_f <= 0 or not math.isfinite(inverted):
101
+ continue
102
+ deviation = abs(inverted - live_f) / live_f
103
+ rows.append(
104
+ {
105
+ "date": day,
106
+ "inverted": round(inverted, 6),
107
+ "live": live_f,
108
+ "deviation": round(deviation, 6),
109
+ }
110
+ )
111
+ worst = max((row["deviation"] for row in rows), default=None)
112
+ return {"checked": len(rows), "worst_deviation": worst, "rows": rows}
113
+
114
+
115
+ def compare_snapshots(
116
+ before: list[dict[str, Any]],
117
+ after: list[dict[str, Any]],
118
+ *,
119
+ tolerance: float = 1e-6,
120
+ ) -> dict[str, Any]:
121
+ """Compare two rebuilt outputs date-by-date (equivalence gate).
122
+
123
+ Used to prove that a pipeline migration, archive refresh or parameter
124
+ change did not silently alter history. Compares ``close`` and
125
+ ``adj_factor`` per date; rows missing from either side are reported.
126
+ Returns ``{"equal": bool, "checked": int, "differences": [...], "missing": [...]}``.
127
+ """
128
+ def _index(bars: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
129
+ out: dict[str, dict[str, Any]] = {}
130
+ for bar in bars:
131
+ date_text = str(bar.get("date") or "")[:10]
132
+ if date_text:
133
+ out[date_text] = bar
134
+ return out
135
+
136
+ before_map = _index(before)
137
+ after_map = _index(after)
138
+ differences: list[dict[str, Any]] = []
139
+ missing: list[dict[str, Any]] = []
140
+ for date_text in sorted(set(before_map) | set(after_map)):
141
+ before_bar = before_map.get(date_text)
142
+ after_bar = after_map.get(date_text)
143
+ if before_bar is None or after_bar is None:
144
+ missing.append(
145
+ {
146
+ "date": date_text,
147
+ "present_in_before": before_bar is not None,
148
+ "present_in_after": after_bar is not None,
149
+ }
150
+ )
151
+ continue
152
+ for key in ("close", "adj_factor"):
153
+ before_value = before_bar.get(key)
154
+ after_value = after_bar.get(key)
155
+ try:
156
+ before_f = float(before_value)
157
+ after_f = float(after_value)
158
+ except (TypeError, ValueError):
159
+ continue
160
+ if not (math.isfinite(before_f) and math.isfinite(after_f)):
161
+ continue
162
+ deviation = abs(before_f - after_f) / max(abs(after_f), 1e-12)
163
+ if deviation > tolerance:
164
+ differences.append(
165
+ {
166
+ "date": date_text,
167
+ "field": key,
168
+ "before": before_value,
169
+ "after": after_value,
170
+ "deviation": round(deviation, 12),
171
+ }
172
+ )
173
+ return {
174
+ "equal": not differences and not missing,
175
+ "checked": len(before_map),
176
+ "differences": differences,
177
+ "missing": missing,
178
+ }
@@ -0,0 +1,210 @@
1
+ Metadata-Version: 2.4
2
+ Name: pit-adjuster
3
+ Version: 0.1.0
4
+ Summary: Point-in-time fixed-basis back-adjustment engine: adjustment factor chains, static forward-adjustment detection, snapshot equivalence checks.
5
+ License-Expression: MIT
6
+ Keywords: point-in-time,back-adjustment,hfq,qfq,corporate-actions,quant,pandas-free
7
+ Classifier: Development Status :: 3 - Alpha
8
+ Classifier: Environment :: Console
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: Intended Audience :: Financial and Insurance Industry
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Topic :: Office/Business :: Financial :: Investment
16
+ Classifier: Topic :: Scientific/Engineering
17
+ Requires-Python: >=3.11
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ Dynamic: license-file
21
+
22
+ # pit-adjuster
23
+
24
+ Point-in-time fixed-basis back-adjustment engine for daily price history:
25
+ rebuild prices so that **any day reads exactly what that day could have
26
+ known** — plus drift detection for vendors that silently switch adjustment
27
+ conventions. Python 3.11+, **zero dependencies**, Windows / Linux / macOS.
28
+
29
+ ![adjustment chain](https://img.shields.io/badge/deps-0-brightgreen)
30
+ ![python](https://img.shields.io/badge/python-3.11%2B-blue)
31
+
32
+ **Status:** v0.1 — alpha. The adjustment math is battle-tested inside a
33
+ production research pipeline, but this standalone package is new: expect the
34
+ CLI and schema to shift before v1.0.
35
+
36
+ ## Why this exists
37
+
38
+ A-share (and most equity) history arrives from vendors in **current-vintage**
39
+ adjusted form. Two silent dangers:
40
+
41
+ 1. **The convention itself is not point-in-time.** Prices you see today
42
+ embed every adjustment event that ever happened — including events that
43
+ were announced *after* a historical date. A backtest that uses them reads
44
+ the future.
45
+ 2. **Vendors switch conventions silently.** One day your data source starts
46
+ serving forward-adjusted prices where it served back-adjusted prices
47
+ yesterday. Nothing in the CSV changes shape; every historical signal
48
+ silently changes value.
49
+
50
+ `pit-adjuster` rebuilds history from two ingredients — current-vintage
51
+ forward-adjusted (qfq) bars plus a **point-in-time corporate-action archive**
52
+ — into a fixed-basis back-adjusted (hfq) chain where each day's price depends
53
+ only on events whose ex-date is on or before that day. Then it *checks*: did
54
+ the rebuild invert the vendor chain correctly, and does the vendor chain
55
+ still agree with live raw prices today?
56
+
57
+ ## Philosophy
58
+
59
+ Price history must be reversible. A research pipeline that cannot prove its
60
+ prices were knowable in the past is not doing backtesting — it is doing
61
+ wishful thinking. `pit-adjuster` treats **look-ahead freedom as a
62
+ verifiable property**, not a style preference:
63
+
64
+ - **PIT principle** — every price, factor, and calibration depends only on
65
+ information available at that historical point. See
66
+ [Kelly et al., "Scaling Point-in-Time Language Models"](https://www.nber.org/papers/w35247)
67
+ (NBER w35247) and
68
+ [Look-Ahead-Bench](https://ar5iv.labs.arxiv.org/html/2601.13770)
69
+ (arXiv:2601.13770) for why the whole industry is converging on this.
70
+ - **Look-ahead bias is measurable** — Daniel, Sornette & Wohrmann (2008),
71
+ ["Look-Ahead Benchmark Bias in Portfolio Performance Evaluation"](https://papers.ssrn.com/sol3/papers.cfm?abstract_id=1289222)
72
+ (arXiv:0810.1922) quantify how ex-post benchmark construction inflates
73
+ performance. A vendor that silently swaps adjustment conventions is doing
74
+ exactly this, inside your price column.
75
+ - **Formal ground** — Fonseca (2026),
76
+ ["Look-Ahead-Freedom as Temporal Non-Interference"](https://econpapers.repec.org/paper/arxpapers/2607.04958.htm)
77
+ (arXiv:2607.04958) proves look-ahead-freedom is *undecidable* in general
78
+ (Π⁰₁-hard when availability depends on data values), but admits a
79
+ **linear-time decidable type-effect system on the value-independent
80
+ fragment** — windowing, resampling, joins, PIT and vintage reads.
81
+
82
+ **Honest boundary:** this package implements verifiable checks for the
83
+ value-independent fragment of the problem (factor chains, ex-date ordering,
84
+ snapshot equivalence, chain inversion). For the general value-dependent case
85
+ we fall back to heuristic guards and say so explicitly — verifiability is
86
+ claimed only where the theory allows it.
87
+
88
+ ## Quick start
89
+
90
+ ```bash
91
+ # install from PyPI (once published)
92
+ pip install pit-adjuster
93
+
94
+ # or run without installing anything:
95
+ # PYTHONPATH=src python -m pit_adjuster --help
96
+
97
+ # try it on synthetic data (builds a fake qfq history + action archive,
98
+ # rebuilds to hfq, runs invert-check and drift-check)
99
+ python examples/demo.py
100
+ ```
101
+
102
+ Rebuild your own history:
103
+
104
+ ```bash
105
+ padj rebuild \
106
+ --bars bars.json --actions actions.json \
107
+ --as-of 2026-08-11 --code 600000 --out hfq.json
108
+
109
+ padj invert-check --bars hfq.json --actions actions.json --as-of 2026-08-11
110
+ padj drift-check --bars hfq.json --actions actions.json \
111
+ --as-of 2026-08-11 --live live_closes.json
112
+ ```
113
+
114
+ `padj rebuild` is the workhorse: it inverts the vendor qfq chain back to raw
115
+ prices, then re-applies only events whose ex-date is on or before each bar
116
+ date (fixed basis at the archive coverage start). Raw open/close are kept
117
+ alongside adjusted prices so execution-level work can map back to nominal
118
+ prices.
119
+
120
+ ## Commands
121
+
122
+ | Command | What it does |
123
+ | --- | --- |
124
+ | `rebuild` | Rebuild bars to fixed-basis hfq: `open/high/low/close` adjusted, `raw_open/raw_close` nominal, `adj_factor` cumulative multiplier, volume normalized to shares |
125
+ | `invert-check` | Ex-date continuity sanity check: is `raw_{ex-1} × factor_e ≈ raw_ex`? Informational — real ex-dates carry overnight returns, so violations can be false positives |
126
+ | `drift-check` | **Static forward-adjustment detection.** Compares inverted raw closes against live raw closes; divergence above tolerance is authoritative — a vendor chain that no longer matches the archive |
127
+ | `snapshot-equivalence` | Compare two rebuilt outputs (e.g. old and new pipeline versions) date-by-date within tolerance — the "did anything change?" gate |
128
+ | `version` | Print version |
129
+
130
+ Global flags: `--help` on every subcommand; JSON outputs via `--out` where
131
+ supported; everything else prints a human-readable summary.
132
+
133
+ ## Data model
134
+
135
+ **Bars** — a JSON list of daily bars, each with at least `date` (ISO) and
136
+ `close`; `open/high/low/volume/amount/turnover` are preserved through the
137
+ rebuild:
138
+
139
+ ```json
140
+ {"date": "2026-06-12", "open": 95.0, "high": 96.0, "low": 94.5, "close": 95.5, "volume": 1234500}
141
+ ```
142
+
143
+ **Actions** — a point-in-time corporate-action archive, one record per
144
+ event, with `ex_date`, `adjustment_factor` and `available_at`:
145
+
146
+ ```json
147
+ {"ex_date": "2026-06-15", "adjustment_factor": 0.95, "available_at": "2026-06-14T18:00:00", "action_type": "cash_dividend_stock_distribution"}
148
+ ```
149
+
150
+ Invalid records (missing ex-date, non-positive or non-finite factor) are
151
+ dropped; only events with `ex_date <= as_of_date` participate. The schema
152
+ lives in [schema/corporate-action.schema.json](schema/corporate-action.schema.json).
153
+
154
+ ## Adjustment math
155
+
156
+ Standard A-share factor math (as documented by exchange reference-price
157
+ rules):
158
+
159
+ ```
160
+ factor_e = (prior_close - cash) / (prior_close * (1 + bonus + transfer))
161
+ qfq_t = raw_t * prod_{e: ex_date_e > t} factor_e
162
+ hfq_t = raw_t * prod_{e: ex_date_e <= t} (1 / factor_e)
163
+ ```
164
+
165
+ `rebuild` inverts the vendor qfq chain back to raw prices, then applies the
166
+ hfq chain with a fixed basis at the archive coverage start. **Key property
167
+ (under test):** hfq and qfq yield identical adjusted *returns* for the same
168
+ factor chain, while hfq additionally guarantees that a price at time `t` is
169
+ untouched by events with ex-date after `t`.
170
+
171
+ Volume normalization follows the A-share convention: most codes store volume
172
+ in lots (×100 to shares); STAR-market codes (688/689 prefixes) store native
173
+ shares. Both are parameterizable — see `--volume-to-shares` and the
174
+ `native_share_prefixes` argument in `rebuild_bars`.
175
+
176
+ ## Verification model
177
+
178
+ `pit-adjuster` never trusts its inputs:
179
+
180
+ - `invert-check` — factor continuity at ex-dates (sanity, false-positive
181
+ tolerant)
182
+ - `drift-check` — inverted raws vs live raws (authoritative divergence
183
+ detection; this is the "static forward-adjustment detector" — if a vendor
184
+ swaps conventions, this fires)
185
+ - `snapshot-equivalence` — before/after equivalence of two rebuilds, the
186
+ reproducibility gate for pipeline migrations
187
+
188
+ Every check is read-only. Nothing here trades, prices, or decides.
189
+
190
+ ## Development
191
+
192
+ ```bash
193
+ python -m pip install -e . pytest
194
+ python -m pytest
195
+ ```
196
+
197
+ CI runs the full test suite on Ubuntu, Windows and macOS with Python 3.11 and
198
+ 3.12. Issues are handled on weekends; pull requests are welcome.
199
+
200
+ ## Related work
201
+
202
+ - [Daniel, Sornette & Wohrmann (2008)](https://papers.ssrn.com/sol3/papers.cfm?abstract_id=1289222) — look-ahead benchmark bias, quantified
203
+ - [Fonseca (2026)](https://econpapers.repec.org/paper/arxpapers/2607.04958.htm) — look-ahead-freedom as temporal non-interference (the verifiability boundary)
204
+ - [Point-in-Time Backtesting: A Formal Bias Taxonomy](https://www.mdpi.com/2227-7390/14/12/2182) (Mathematics 2026, 14(12):2182)
205
+ - [Kelly et al., Scaling Point-in-Time Language Models](https://www.nber.org/papers/w35247) (NBER w35247)
206
+ - [Look-Ahead-Bench](https://ar5iv.labs.arxiv.org/html/2601.13770) (arXiv:2601.13770) — measuring look-ahead bias in PIT LLMs
207
+
208
+ ## License
209
+
210
+ MIT
@@ -0,0 +1,12 @@
1
+ pit_adjuster/__init__.py,sha256=qSv0RHdecpPLim-wO-5KizNhHHfz0soXwyy4V100n2k,733
2
+ pit_adjuster/__main__.py,sha256=MHKZ_ae3fSLGTLUUMOx15fWdeOnJSHhq-zslRP5F5Lc,79
3
+ pit_adjuster/chain.py,sha256=KXoXftqvT8SePerOMczbeav6o-TsZ2i7HAnCH-B8VNM,6729
4
+ pit_adjuster/cli.py,sha256=wxN3hRCG4VXnvSSkV91Yy-l8Asjio2ADI_sRuoF2hfY,5679
5
+ pit_adjuster/io.py,sha256=toWof-L0WMAoe-wagq76hdI5NyY0watl3t_K9e-4nYc,2212
6
+ pit_adjuster/validation.py,sha256=xtAaBoPX9oi4kkRGCcb2DHrPKU9TqbxDR7AMPo7i1WY,6569
7
+ pit_adjuster-0.1.0.dist-info/licenses/LICENSE,sha256=RR_pyaq5AlpbBet0GBNpz4JzymibqG61y8FXaJk7PNw,1082
8
+ pit_adjuster-0.1.0.dist-info/METADATA,sha256=V8Fwe6a4xRy77igwL0fhQUp-6nL2BiRhH9nsnbqwvq0,9757
9
+ pit_adjuster-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
10
+ pit_adjuster-0.1.0.dist-info/entry_points.txt,sha256=dOwSZaI-GuUbrbz3CMBhc-gkQuIsbGa_3Ldsyb7H2qA,84
11
+ pit_adjuster-0.1.0.dist-info/top_level.txt,sha256=wPasPKK8rKNC5TpJkjftF5zHm_C0vtMrgabifCfbkew,13
12
+ pit_adjuster-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ padj = pit_adjuster.cli:main
3
+ pit-adjuster = pit_adjuster.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Pit Adjuster contributors
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
+ pit_adjuster