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 +158 -0
- tickbloom/__main__.py +3 -0
- tickbloom/checks.py +163 -0
- tickbloom/cli.py +183 -0
- tickbloom/report.py +242 -0
- tickbloom/scan.py +374 -0
- tickbloom/scoring.py +185 -0
- tickbloom-0.1.0.dist-info/METADATA +168 -0
- tickbloom-0.1.0.dist-info/RECORD +12 -0
- tickbloom-0.1.0.dist-info/WHEEL +4 -0
- tickbloom-0.1.0.dist-info/entry_points.txt +2 -0
- tickbloom-0.1.0.dist-info/licenses/LICENSE +21 -0
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
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())
|