tickbloom 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.
tickbloom/__init__.py ADDED
@@ -0,0 +1,158 @@
1
+ """tickbloom — data integrity you can put in a document.
2
+
3
+ import tickbloom as tb
4
+ rep = tb.audit(df)
5
+ print(rep.score) # 92.6
6
+ print(rep.breakdown.table())
7
+ rep.to_json("audit.json")
8
+
9
+ Runs entirely in your process. Nothing is sent anywhere.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import hashlib
15
+ import json
16
+ from dataclasses import dataclass, field
17
+ from pathlib import Path
18
+
19
+ import pandas as pd
20
+
21
+ from . import checks as _checks
22
+ from . import report as _report
23
+ from . import scan as _scan
24
+ from . import scoring as _scoring
25
+ from .checks import Finding
26
+ from .scan import scan
27
+ from .scoring import ScoreBreakdown
28
+
29
+ __version__ = "0.1.0"
30
+ __all__ = ["audit", "scan", "export", "AuditReport", "Finding", "ScoreBreakdown", "__version__"]
31
+
32
+
33
+ def _hash_frame(df: pd.DataFrame) -> str:
34
+ """Stable content hash of the input, for the manifest.
35
+
36
+ Uses pandas' row hasher rather than repr so the value does not depend on
37
+ display options or column ordering.
38
+ """
39
+ h = pd.util.hash_pandas_object(df[sorted(df.columns)], index=True).values
40
+ return hashlib.sha256(h.tobytes()).hexdigest()
41
+
42
+
43
+ @dataclass
44
+ class AuditReport:
45
+ findings: list[Finding]
46
+ breakdown: ScoreBreakdown
47
+ manifest: dict = field(default_factory=dict)
48
+
49
+ @property
50
+ def score(self) -> float:
51
+ return self.breakdown.score
52
+
53
+ @property
54
+ def verdict(self) -> str:
55
+ return self.breakdown.verdict
56
+
57
+ def failing(self) -> list[Finding]:
58
+ return [f for f in self.findings if f.severity == "fail"]
59
+
60
+ def to_dict(self) -> dict:
61
+ return {
62
+ "score": self.score,
63
+ "verdict": self.verdict,
64
+ "penalties": [p.__dict__ for p in self.breakdown.penalties],
65
+ "findings": [f.to_dict() for f in self.findings],
66
+ "manifest": self.manifest,
67
+ }
68
+
69
+ def to_html(self, **kwargs) -> str:
70
+ return _report.to_html(self, **kwargs)
71
+
72
+ def to_json(self, path: str | Path | None = None) -> str:
73
+ s = json.dumps(self.to_dict(), indent=2, sort_keys=True, default=str)
74
+ if path:
75
+ Path(path).write_text(s)
76
+ return s
77
+
78
+ def __repr__(self) -> str:
79
+ fails = len(self.failing())
80
+ flags = len([f for f in self.findings if f.severity == "flag"])
81
+ return (f"<AuditReport score={self.score} verdict={self.verdict} "
82
+ f"fail={fails} flag={flags}>")
83
+
84
+
85
+ def audit(df: pd.DataFrame, *, calendar=None, code_path=None,
86
+ lookahead_failed: bool = False, survivorship_failed: bool = False,
87
+ weights: dict | None = None, source: str = "unspecified") -> AuditReport:
88
+ """Run the integrity suite and produce a scored, reproducible report.
89
+
90
+ Pass `code_path` to scan strategy source for look-ahead leaks in the same
91
+ call — the scanner's real findings then replace the hand-passed boolean,
92
+ which is what you want in anything other than a test.
93
+
94
+ lookahead_failed / survivorship_failed remain available for callers who
95
+ ran the scan separately or reconstruct the universe themselves. They are
96
+ structural verdicts, so the caller passes a boolean rather than a rate.
97
+ """
98
+ findings: list[Finding] = []
99
+ scan_findings: list[Finding] = []
100
+ if code_path is not None:
101
+ scan_findings = _scan.scan(code_path)
102
+ lookahead_failed = lookahead_failed or _scan.has_certain_leak(scan_findings)
103
+ for fn in _checks.ALL_CHECKS:
104
+ findings.append(fn(df, calendar) if fn is _checks.check_gaps else fn(df))
105
+
106
+ defects: dict[str, dict] = {f.check: {"rate": f.defect_rate} for f in findings}
107
+ defects["lookahead"] = {"failed": lookahead_failed}
108
+ defects["survivorship"] = {"failed": survivorship_failed}
109
+
110
+ if scan_findings:
111
+ # Real scanner output — specific files and lines — beats a placeholder.
112
+ findings.extend(scan_findings)
113
+ elif lookahead_failed:
114
+ findings.append(Finding(
115
+ id="TB-006", check="lookahead", severity="fail", failed=True,
116
+ title="Look-ahead leak reported by scanner",
117
+ description="A feature reads a value not observable at decision time. "
118
+ "Any backtest using it trades on information it would not have had.",
119
+ impact="observed performance is fabricated; severe"))
120
+ if survivorship_failed:
121
+ findings.append(Finding(
122
+ id="TB-007", check="survivorship", severity="fail", failed=True,
123
+ title="Universe is not survivorship-adjusted",
124
+ description="Delisted symbols are absent from the historical universe, "
125
+ "so the equity curve reflects only names that survived.",
126
+ impact="equity returns inflated 1-4% annually; severe"))
127
+
128
+ breakdown = _scoring.compute(defects, weights)
129
+
130
+ manifest = {
131
+ "tickbloom_version": __version__,
132
+ "scoring_version": _scoring.SCORING_VERSION,
133
+ "source": source,
134
+ "rows": int(len(df)),
135
+ "columns": sorted(df.columns.tolist()),
136
+ "input_sha256": _hash_frame(df),
137
+ "weights": {k: v["weight"] for k, v in (weights or _scoring.WEIGHTS).items()},
138
+ "tolerances": {k: v["tolerance"] for k, v in (weights or _scoring.WEIGHTS).items()},
139
+ "checks_run": sorted({f.check for f in findings}),
140
+ "code_scanned": str(code_path) if code_path else None,
141
+ }
142
+ return AuditReport(findings=findings, breakdown=breakdown, manifest=manifest)
143
+
144
+
145
+ def export(report: AuditReport, path: str | Path, fmt: str = "html", **kwargs) -> Path:
146
+ """Write a report to disk in a sendable format.
147
+
148
+ `html` produces a single self-contained file with no external assets — it
149
+ opens from an email attachment on a locked-down laptop, which is where it
150
+ will actually be read.
151
+ """
152
+ fmt = fmt.lower()
153
+ if fmt == "html":
154
+ return _report.write_html(report, path, **kwargs)
155
+ if fmt == "json":
156
+ report.to_json(path)
157
+ return Path(path)
158
+ raise ValueError(f"unsupported format {fmt!r}; use 'html' or 'json'")
tickbloom/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from tickbloom.cli import main
2
+
3
+ raise SystemExit(main())
tickbloom/checks.py ADDED
@@ -0,0 +1,163 @@
1
+ """Integrity checks.
2
+
3
+ Every check returns a Finding carrying the defect rate that scoring.py turns
4
+ into points, plus enough locality (row indices, timestamps) that a user can go
5
+ look at the offending data instead of taking our word for it.
6
+
7
+ A check never raises on dirty data — dirty data is the input, not an error.
8
+ It raises only on a malformed frame, which is a bug in the loader.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from dataclasses import dataclass, field
14
+ from typing import Any
15
+
16
+ import pandas as pd
17
+
18
+ REQUIRED_COLUMNS = ["ts_event", "symbol", "price", "size"]
19
+
20
+
21
+ @dataclass
22
+ class Finding:
23
+ id: str
24
+ check: str
25
+ severity: str # pass | flag | fail
26
+ title: str
27
+ description: str
28
+ defect_rate: float | None = None
29
+ failed: bool = False
30
+ locations: list[Any] = field(default_factory=list)
31
+ impact: str = ""
32
+
33
+ def to_dict(self) -> dict:
34
+ return {
35
+ "id": self.id, "check": self.check, "severity": self.severity,
36
+ "title": self.title, "description": self.description,
37
+ "defect_rate": self.defect_rate, "failed": self.failed,
38
+ "locations": self.locations[:50], "impact": self.impact,
39
+ }
40
+
41
+
42
+ def _validate(df: pd.DataFrame) -> None:
43
+ missing = [c for c in REQUIRED_COLUMNS if c not in df.columns]
44
+ if missing:
45
+ raise ValueError(f"frame is missing required columns: {missing}. "
46
+ "Loaders must emit the normalized schema.")
47
+
48
+
49
+ def _sev(rate: float, tol: float) -> str:
50
+ if rate == 0:
51
+ return "pass"
52
+ return "fail" if rate >= tol else "flag"
53
+
54
+
55
+ def check_duplicates(df: pd.DataFrame) -> Finding:
56
+ """Identical (ts_event, price, size) appearing more than once."""
57
+ _validate(df)
58
+ dup_mask = df.duplicated(subset=["ts_event", "price", "size"], keep="first")
59
+ n = int(dup_mask.sum())
60
+ rate = n / len(df) if len(df) else 0.0
61
+ return Finding(
62
+ id="TB-002", check="duplicates", severity=_sev(rate, 0.005),
63
+ title=f"{n} duplicate ticks detected" if n else "No duplicate ticks",
64
+ description=(
65
+ "Identical timestamp, price, and size appearing more than once. "
66
+ "Duplicates inflate volume-weighted features and can double-count a "
67
+ "fill during replay."
68
+ ) if n else "No repeated (timestamp, price, size) triples in the sample.",
69
+ defect_rate=rate,
70
+ locations=df.index[dup_mask].tolist(),
71
+ impact="VWAP-family features biased; low unless rate is high" if n else "none",
72
+ )
73
+
74
+
75
+ def check_order(df: pd.DataFrame) -> Finding:
76
+ """Timestamps that go backwards — breaks the causality assumption."""
77
+ _validate(df)
78
+ ts = pd.to_datetime(df["ts_event"], utc=True)
79
+ bad = ts.diff() < pd.Timedelta(0)
80
+ n = int(bad.sum())
81
+ rate = n / len(df) if len(df) else 0.0
82
+ return Finding(
83
+ id="TB-003", check="order", severity=_sev(rate, 0.001),
84
+ title=f"{n} out-of-order timestamps" if n else "Timestamps monotonic",
85
+ description=(
86
+ "A tick arrives with an earlier timestamp than its predecessor. Every "
87
+ "rolling feature assumes ordering, so this silently corrupts state."
88
+ ) if n else "ts_event is monotonically non-decreasing across the sample.",
89
+ defect_rate=rate,
90
+ locations=df.index[bad.fillna(False)].tolist(),
91
+ impact="rolling features computed on corrupted state; high" if n else "none",
92
+ )
93
+
94
+
95
+ def check_zero_volume(df: pd.DataFrame) -> Finding:
96
+ """Prints with no size — they move indicators but were never tradeable."""
97
+ _validate(df)
98
+ bad = df["size"] <= 0
99
+ n = int(bad.sum())
100
+ rate = n / len(df) if len(df) else 0.0
101
+ return Finding(
102
+ id="TB-004", check="zero_volume", severity=_sev(rate, 0.01),
103
+ title=f"{n} zero-or-negative volume prints" if n else "All prints have size",
104
+ description=(
105
+ "Prints with no size still move price-based indicators but represent "
106
+ "nothing you could have traded."
107
+ ) if n else "No zero-or-negative size values.",
108
+ defect_rate=rate,
109
+ locations=df.index[bad].tolist(),
110
+ impact="indicators respond to untradeable prints; low" if n else "none",
111
+ )
112
+
113
+
114
+ def check_gaps(df: pd.DataFrame, calendar: list | None = None) -> Finding:
115
+ """Sessions the calendar expects that the data does not contain."""
116
+ _validate(df)
117
+ ts = pd.to_datetime(df["ts_event"], utc=True)
118
+ present = set(ts.dt.date)
119
+ if calendar:
120
+ expected = {pd.Timestamp(d).date() for d in calendar}
121
+ else:
122
+ # Fall back to business days spanned by the sample. Documented as a
123
+ # weaker check — a real exchange calendar catches holidays correctly.
124
+ expected = set(pd.bdate_range(ts.min().date(), ts.max().date()).date)
125
+ missing = sorted(expected - present)
126
+ rate = len(missing) / len(expected) if expected else 0.0
127
+ return Finding(
128
+ id="TB-001", check="gaps", severity=_sev(rate, 0.02),
129
+ title=f"{len(missing)} expected sessions missing" if missing
130
+ else f"No gaps across {len(expected)} sessions",
131
+ description=(
132
+ "Sessions the calendar expects are absent from the data. Gaps silently "
133
+ "change the sample — a gap across a volatile week removes exactly the "
134
+ "periods that determine your tail risk."
135
+ ) if missing else "Every session in the expected calendar is present.",
136
+ defect_rate=rate,
137
+ locations=[str(d) for d in missing],
138
+ impact="tail risk understated; severe" if missing else "none",
139
+ )
140
+
141
+
142
+ def check_session_border(df: pd.DataFrame, open_h: int = 13, close_h: int = 21) -> Finding:
143
+ """Prints outside the venue's published session window (UTC hours)."""
144
+ _validate(df)
145
+ ts = pd.to_datetime(df["ts_event"], utc=True)
146
+ bad = (ts.dt.hour < open_h) | (ts.dt.hour >= close_h)
147
+ n = int(bad.sum())
148
+ rate = n / len(df) if len(df) else 0.0
149
+ return Finding(
150
+ id="TB-005", check="session", severity=_sev(rate, 0.005),
151
+ title=f"{n} ticks outside session window" if n else "Session borders consistent",
152
+ description=(
153
+ f"Prints falling outside {open_h:02d}:00–{close_h:02d}:00 UTC. Overnight "
154
+ "activity leaking into a regular-hours frame contaminates open and close logic."
155
+ ) if n else "All prints fall inside the published session window.",
156
+ defect_rate=rate,
157
+ locations=df.index[bad].tolist(),
158
+ impact="open/close features contaminated; moderate" if n else "none",
159
+ )
160
+
161
+
162
+ ALL_CHECKS = [check_gaps, check_order, check_duplicates,
163
+ check_zero_volume, check_session_border]
tickbloom/cli.py ADDED
@@ -0,0 +1,183 @@
1
+ """Command-line entry point.
2
+
3
+ python -m tickbloom audit data.csv -o report.html
4
+
5
+ Built for the concierge audits in Phase 0: someone sends you a CSV, you run one
6
+ command, you send back an HTML file. The whole loop should take under a minute,
7
+ because you are going to do it five times before you write another line of
8
+ product code.
9
+
10
+ Column mapping is deliberately forgiving. Real exports from real traders have
11
+ columns called `timestamp`, `datetime`, `Date`, `px`, `last`, `qty`, `volume`.
12
+ Making them rename columns before you can help them is how a free audit turns
13
+ into an unanswered email.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import sys
20
+ from pathlib import Path
21
+
22
+ # Common aliases seen in the wild -> our normalized schema.
23
+ ALIASES = {
24
+ "ts_event": ["ts_event", "timestamp", "datetime", "date", "time", "ts", "dt"],
25
+ "symbol": ["symbol", "ticker", "instrument", "contract", "sym"],
26
+ "price": ["price", "px", "last", "close", "trade_price"],
27
+ "size": ["size", "qty", "quantity", "volume", "vol", "amount"],
28
+ }
29
+
30
+
31
+ def normalize_columns(df, verbose: bool = True):
32
+ """Map whatever they sent us onto the normalized schema."""
33
+ lower = {c.lower().strip(): c for c in df.columns}
34
+ mapping, missing = {}, []
35
+ for target, options in ALIASES.items():
36
+ hit = next((lower[o] for o in options if o in lower), None)
37
+ if hit:
38
+ if hit != target:
39
+ mapping[hit] = target
40
+ else:
41
+ missing.append(target)
42
+
43
+ if mapping:
44
+ df = df.rename(columns=mapping)
45
+ if verbose:
46
+ for src, dst in mapping.items():
47
+ print(f" mapped column {src!r} -> {dst!r}")
48
+
49
+ if "symbol" in missing:
50
+ df["symbol"] = "UNKNOWN"
51
+ missing.remove("symbol")
52
+ if verbose:
53
+ print(" no symbol column — defaulting to 'UNKNOWN'")
54
+
55
+ if missing:
56
+ cols = ", ".join(repr(c) for c in df.columns)
57
+ sys.exit(
58
+ f"error: could not find a column for {missing}.\n"
59
+ f" columns present: {cols}\n"
60
+ f" rename them, or add an alias in tickbloom/cli.py"
61
+ )
62
+ return df
63
+
64
+
65
+ def cmd_audit(args) -> int:
66
+ import pandas as pd
67
+
68
+ import tickbloom as tb
69
+ from tickbloom import report as tb_report
70
+
71
+ src = Path(args.input)
72
+ if not src.exists():
73
+ sys.exit(f"error: no such file: {src}")
74
+
75
+ print(f"reading {src.name}…")
76
+ if src.suffix.lower() in (".parquet", ".pq"):
77
+ df = pd.read_parquet(src)
78
+ else:
79
+ df = pd.read_csv(src)
80
+ print(f" {len(df):,} rows, {len(df.columns)} columns")
81
+
82
+ df = normalize_columns(df, verbose=True)
83
+
84
+ if args.code:
85
+ print(f"scanning {args.code} for look-ahead leaks…")
86
+
87
+ rep = tb.audit(
88
+ df,
89
+ code_path=args.code,
90
+ source=args.source or src.name,
91
+ lookahead_failed=args.lookahead_failed,
92
+ survivorship_failed=args.survivorship_failed,
93
+ )
94
+
95
+ print()
96
+ print(rep.breakdown.table())
97
+ print()
98
+
99
+ fails = rep.failing()
100
+ if fails:
101
+ print("BLOCKING FINDINGS")
102
+ for f in fails:
103
+ print(f" [{f.id}] {f.title}")
104
+ print()
105
+
106
+ out = Path(args.output)
107
+ tb_report.write_html(
108
+ rep, out,
109
+ subject=args.source or src.name,
110
+ generated=args.generated or "",
111
+ )
112
+ print(f"wrote {out} ({out.stat().st_size:,} bytes)")
113
+
114
+ if args.json:
115
+ rep.to_json(args.json)
116
+ print(f"wrote {args.json}")
117
+
118
+ # Exit code carries the verdict so this composes in CI.
119
+ if args.fail_under is not None and rep.score < args.fail_under:
120
+ print(f"\nFAIL: score {rep.score} is below --fail-under {args.fail_under}")
121
+ return 1
122
+ return 0
123
+
124
+
125
+ def cmd_scan(args) -> int:
126
+ from tickbloom.scan import scan
127
+
128
+ findings = scan(args.path)
129
+ if not findings:
130
+ print(f"no look-ahead patterns found in {args.path}")
131
+ return 0
132
+
133
+ for f in findings:
134
+ mark = "FAIL" if f.severity == "fail" else "FLAG"
135
+ print(f"\n[{mark}] {f.id} {f.title}")
136
+ print(f" {f.description}")
137
+ for loc in f.locations[:20]:
138
+ print(f" {loc}")
139
+ if len(f.locations) > 20:
140
+ print(f" … and {len(f.locations) - 20} more")
141
+
142
+ certain = [f for f in findings if f.severity == "fail"]
143
+ print(f"\n{len(certain)} certain, {len(findings) - len(certain)} likely")
144
+ if certain:
145
+ print("Suppress intentional forward-looking code with: # tickbloom: allow")
146
+ return 1 if certain else 0
147
+
148
+
149
+ def main(argv=None) -> int:
150
+ p = argparse.ArgumentParser(
151
+ prog="tickbloom",
152
+ description="Validate market data and produce a sendable audit report.",
153
+ )
154
+ sub = p.add_subparsers(dest="cmd", required=True)
155
+
156
+ a = sub.add_parser("audit", help="audit a CSV or Parquet file")
157
+ a.add_argument("input", help="path to .csv or .parquet")
158
+ a.add_argument("-o", "--output", default="audit-report.html",
159
+ help="HTML report path (default: audit-report.html)")
160
+ a.add_argument("--json", help="also write the raw report as JSON")
161
+ a.add_argument("--source", help="label for the report header, e.g. 'databento:ES.c.0'")
162
+ a.add_argument("--generated", help="timestamp to stamp on the report; "
163
+ "omit to keep output byte-reproducible")
164
+ a.add_argument("--fail-under", type=float,
165
+ help="exit 1 if the score falls below this (for CI)")
166
+ a.add_argument("--code", help="also scan a strategy file or directory for "
167
+ "look-ahead leaks and fold the result in")
168
+ a.add_argument("--lookahead-failed", action="store_true",
169
+ help="record a look-ahead leak reported by an external scan")
170
+ a.add_argument("--survivorship-failed", action="store_true",
171
+ help="record that the universe is not survivorship-adjusted")
172
+ a.set_defaults(func=cmd_audit)
173
+
174
+ sc = sub.add_parser("scan", help="scan Python source for look-ahead leaks")
175
+ sc.add_argument("path", help="a .py file or a directory")
176
+ sc.set_defaults(func=cmd_scan)
177
+
178
+ args = p.parse_args(argv)
179
+ return args.func(args)
180
+
181
+
182
+ if __name__ == "__main__":
183
+ raise SystemExit(main())