bitsentry 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.
bitsentry/__init__.py ADDED
@@ -0,0 +1,18 @@
1
+ # bitsentry — Safety, audit, and intelligence layer for Bitget trading agents
2
+ __version__ = "0.1.0"
3
+
4
+ from bitsentry.bgc_client import BGCClient
5
+ from bitsentry.audit_engine import AuditEngine
6
+ from bitsentry.risk_guardian import RiskGuardian, RiskCheckResult
7
+ from bitsentry.position_monitor import PositionMonitor, PositionSnapshot
8
+ from bitsentry.strategy_evaluator import StrategyEvaluator, StrategyHealth
9
+ from bitsentry.reporter import ReportGenerator
10
+ from bitsentry.scheduler import Scheduler
11
+
12
+ __all__ = [
13
+ "BGCClient", "AuditEngine",
14
+ "RiskGuardian", "RiskCheckResult",
15
+ "PositionMonitor", "PositionSnapshot",
16
+ "StrategyEvaluator", "StrategyHealth",
17
+ "ReportGenerator", "Scheduler",
18
+ ]
File without changes
@@ -0,0 +1,273 @@
1
+ from __future__ import annotations
2
+
3
+ import dataclasses
4
+ import os
5
+ from contextlib import asynccontextmanager
6
+ from typing import Any
7
+
8
+ from fastapi import FastAPI, HTTPException, Query
9
+ from fastapi.middleware.cors import CORSMiddleware
10
+ from fastapi.responses import FileResponse
11
+ from fastapi.staticfiles import StaticFiles
12
+ from pydantic import BaseModel
13
+
14
+ from bitsentry.audit_engine import AuditEngine
15
+ from bitsentry.bgc_client import BGCClient, BGCError
16
+ from bitsentry.position_monitor import PositionMonitor
17
+ from bitsentry.reporter import ReportGenerator
18
+ from bitsentry.risk_guardian import RiskGuardian
19
+ from bitsentry.strategy_evaluator import StrategyEvaluator
20
+
21
+ # ── Global component references ───────────────────────────────────────────────
22
+ # Populated during lifespan startup; all routes read from here.
23
+
24
+ _state: dict[str, Any] = {}
25
+
26
+
27
+ def _asdict(obj: Any) -> Any:
28
+ """Recursively convert dataclasses to dicts for JSON serialization."""
29
+ if dataclasses.is_dataclass(obj) and not isinstance(obj, type):
30
+ return {k: _asdict(v) for k, v in dataclasses.asdict(obj).items()}
31
+ if isinstance(obj, list):
32
+ return [_asdict(i) for i in obj]
33
+ return obj
34
+
35
+
36
+ # ── Startup / shutdown ────────────────────────────────────────────────────────
37
+
38
+ @asynccontextmanager
39
+ async def lifespan(application: FastAPI):
40
+ demo = os.environ.get("BITGET_DEMO", "true").lower() != "false"
41
+ try:
42
+ client = BGCClient(demo=demo)
43
+ except BGCError as exc:
44
+ print(f"[bitsentry] WARNING: BGCClient failed to initialize: {exc}")
45
+ client = None
46
+
47
+ audit = AuditEngine()
48
+ guardian = RiskGuardian(audit_engine=audit)
49
+ monitor = PositionMonitor(bgc_client=client, audit_engine=audit) if client else None
50
+ evaluator = StrategyEvaluator(audit_engine=audit)
51
+
52
+ reporter = ReportGenerator(
53
+ audit_engine=audit,
54
+ strategy_evaluator=evaluator,
55
+ position_monitor=monitor,
56
+ )
57
+
58
+ _state.update({
59
+ "client": client,
60
+ "audit": audit,
61
+ "guardian": guardian,
62
+ "monitor": monitor,
63
+ "evaluator": evaluator,
64
+ "reporter": reporter,
65
+ "demo": demo,
66
+ })
67
+
68
+ print("[bitsentry] API server ready.")
69
+ yield
70
+ _state.clear()
71
+
72
+
73
+ # ── App ───────────────────────────────────────────────────────────────────────
74
+
75
+ app = FastAPI(
76
+ title="BitSentry API",
77
+ description="Safety, audit, and intelligence layer for Bitget trading agents",
78
+ version="0.1.0",
79
+ lifespan=lifespan,
80
+ )
81
+
82
+ app.add_middleware(
83
+ CORSMiddleware,
84
+ allow_origins=["*"],
85
+ allow_methods=["*"],
86
+ allow_headers=["*"],
87
+ )
88
+
89
+ # Serve dashboard static files at /dashboard/*
90
+ _dashboard_path = os.path.join(os.path.dirname(__file__), "../../dashboard")
91
+ if os.path.exists(_dashboard_path):
92
+ app.mount("/dashboard", StaticFiles(directory=_dashboard_path, html=True), name="dashboard")
93
+
94
+
95
+ # ── Request / response models ─────────────────────────────────────────────────
96
+
97
+ class RiskCheckRequest(BaseModel):
98
+ symbol: str
99
+ side: str
100
+ size_usdt: float
101
+ leverage: float
102
+ account_balance_usdt: float
103
+ daily_pnl_usdt: float
104
+ consecutive_losses: int
105
+
106
+
107
+ class RecordTradeRequest(BaseModel):
108
+ strategy_tag: str
109
+ symbol: str
110
+ side: str
111
+ entry_price: float
112
+ exit_price: float
113
+ size_usdt: float
114
+ market_condition: str = ""
115
+
116
+
117
+ # ── Routes ────────────────────────────────────────────────────────────────────
118
+
119
+ @app.get("/ui")
120
+ async def serve_dashboard():
121
+ dashboard_file = os.path.join(os.path.dirname(__file__), "../../dashboard/index.html")
122
+ return FileResponse(dashboard_file)
123
+
124
+
125
+ @app.get("/")
126
+ def root():
127
+ return {
128
+ "name": "BitSentry",
129
+ "version": "0.1.0",
130
+ "docs": "/docs",
131
+ "status": "running",
132
+ }
133
+
134
+
135
+ @app.get("/health")
136
+ def health():
137
+ return {
138
+ "status": "ok",
139
+ "version": "0.1.0",
140
+ "demo_mode": _state.get("demo", True),
141
+ }
142
+
143
+
144
+ # ── Positions ─────────────────────────────────────────────────────────────────
145
+
146
+ @app.get("/positions")
147
+ def get_positions():
148
+ monitor: PositionMonitor | None = _state.get("monitor")
149
+ if not monitor:
150
+ raise HTTPException(503, "PositionMonitor unavailable — BGCClient failed to initialize")
151
+ return _asdict(monitor.get_positions())
152
+
153
+
154
+ @app.get("/positions/summary")
155
+ def get_positions_summary():
156
+ monitor: PositionMonitor | None = _state.get("monitor")
157
+ if not monitor:
158
+ raise HTTPException(503, "PositionMonitor unavailable — BGCClient failed to initialize")
159
+ return monitor.get_account_summary()
160
+
161
+
162
+ @app.get("/positions/safe-to-trade")
163
+ def safe_to_trade(
164
+ symbol: str = Query(..., description="Trading symbol, e.g. BTCUSDT"),
165
+ direction: str = Query(..., description="long or short"),
166
+ ):
167
+ monitor: PositionMonitor | None = _state.get("monitor")
168
+ if not monitor:
169
+ raise HTTPException(503, "PositionMonitor unavailable — BGCClient failed to initialize")
170
+ return monitor.get_safe_to_trade(symbol=symbol, direction=direction)
171
+
172
+
173
+ # ── Risk ──────────────────────────────────────────────────────────────────────
174
+
175
+ @app.post("/risk/check")
176
+ def risk_check(body: RiskCheckRequest):
177
+ guardian: RiskGuardian = _state["guardian"]
178
+ result = guardian.check(
179
+ symbol=body.symbol,
180
+ side=body.side,
181
+ size_usdt=body.size_usdt,
182
+ leverage=body.leverage,
183
+ account_balance_usdt=body.account_balance_usdt,
184
+ daily_pnl_usdt=body.daily_pnl_usdt,
185
+ consecutive_losses=body.consecutive_losses,
186
+ )
187
+ return _asdict(result)
188
+
189
+
190
+ # ── Strategy — leaderboard must be declared before /{strategy_tag} ────────────
191
+
192
+ @app.get("/strategy/leaderboard")
193
+ def strategy_leaderboard():
194
+ evaluator: StrategyEvaluator = _state["evaluator"]
195
+ return evaluator.get_leaderboard()
196
+
197
+
198
+ @app.get("/strategy/{strategy_tag}")
199
+ def strategy_health(strategy_tag: str):
200
+ evaluator: StrategyEvaluator = _state["evaluator"]
201
+ health = evaluator.evaluate(strategy_tag)
202
+ return _asdict(health)
203
+
204
+
205
+ @app.post("/strategy/record")
206
+ def record_trade(body: RecordTradeRequest):
207
+ evaluator: StrategyEvaluator = _state["evaluator"]
208
+ trade_id = evaluator.record_trade_result(
209
+ strategy_tag=body.strategy_tag,
210
+ symbol=body.symbol,
211
+ side=body.side,
212
+ entry_price=body.entry_price,
213
+ exit_price=body.exit_price,
214
+ size_usdt=body.size_usdt,
215
+ market_condition=body.market_condition,
216
+ )
217
+ return {"recorded": True, "trade_id": trade_id}
218
+
219
+
220
+ # ── Audit ─────────────────────────────────────────────────────────────────────
221
+
222
+ @app.get("/audit/report")
223
+ def audit_report():
224
+ audit: AuditEngine = _state["audit"]
225
+ return audit.generate_audit_report()
226
+
227
+
228
+ @app.get("/audit/verify")
229
+ def audit_verify():
230
+ audit: AuditEngine = _state["audit"]
231
+ report = audit.generate_audit_report()
232
+ integrity_hash = report["integrity_hash"]
233
+ verified = audit.verify_integrity(integrity_hash)
234
+ return {"verified": verified, "hash": integrity_hash}
235
+
236
+
237
+ # ── Reports ───────────────────────────────────────────────────────────────────
238
+
239
+ class SendReportRequest(BaseModel):
240
+ type: str # "daily" | "weekly" | "monthly"
241
+
242
+
243
+ @app.get("/report/daily")
244
+ def report_daily():
245
+ reporter: ReportGenerator = _state["reporter"]
246
+ return {"report": reporter.generate_daily_report()}
247
+
248
+
249
+ @app.get("/report/weekly")
250
+ def report_weekly():
251
+ reporter: ReportGenerator = _state["reporter"]
252
+ return {"report": reporter.generate_weekly_report()}
253
+
254
+
255
+ @app.get("/report/monthly")
256
+ def report_monthly():
257
+ reporter: ReportGenerator = _state["reporter"]
258
+ return {"report": reporter.generate_monthly_report()}
259
+
260
+
261
+ @app.post("/report/send")
262
+ def report_send(body: SendReportRequest):
263
+ reporter: ReportGenerator = _state["reporter"]
264
+ t = body.type.lower()
265
+ if t == "daily":
266
+ sent = reporter.send_daily_report()
267
+ elif t == "weekly":
268
+ sent = reporter.send_weekly_report()
269
+ elif t == "monthly":
270
+ sent = reporter.send_monthly_report()
271
+ else:
272
+ raise HTTPException(400, f"Unknown report type '{body.type}'. Use daily, weekly, or monthly.")
273
+ return {"sent": sent, "type": t}
@@ -0,0 +1,329 @@
1
+ import hashlib
2
+ import json
3
+ import os
4
+ import sqlite3
5
+ from datetime import datetime, timezone
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+
10
+ class AuditEngine:
11
+ """
12
+ Append-only audit trail stored in SQLite.
13
+ Every record is hashed together for tamper detection.
14
+ """
15
+
16
+ def __init__(self, db_path: str = "bitsentry_audit.db"):
17
+ self.db_path = db_path
18
+ self._init_db()
19
+
20
+ # ── Schema ──────────────────────────────────────────────────────────────
21
+
22
+ def _init_db(self) -> None:
23
+ with self._conn() as conn:
24
+ conn.executescript("""
25
+ CREATE TABLE IF NOT EXISTS trade_intents (
26
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
27
+ timestamp TEXT NOT NULL,
28
+ symbol TEXT NOT NULL,
29
+ side TEXT NOT NULL,
30
+ size REAL NOT NULL,
31
+ leverage REAL NOT NULL,
32
+ signal_source TEXT NOT NULL,
33
+ reasoning TEXT,
34
+ approved INTEGER NOT NULL DEFAULT 0,
35
+ block_reason TEXT
36
+ );
37
+
38
+ CREATE TABLE IF NOT EXISTS risk_checks (
39
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
40
+ timestamp TEXT NOT NULL,
41
+ symbol TEXT NOT NULL,
42
+ layer_name TEXT NOT NULL,
43
+ passed INTEGER NOT NULL,
44
+ reason TEXT,
45
+ value_checked REAL,
46
+ threshold REAL
47
+ );
48
+
49
+ CREATE TABLE IF NOT EXISTS strategy_checkpoints (
50
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
51
+ timestamp TEXT NOT NULL,
52
+ strategy_tag TEXT NOT NULL,
53
+ win_rate_7d REAL,
54
+ win_rate_30d REAL,
55
+ total_trades INTEGER,
56
+ verdict TEXT,
57
+ market_condition TEXT
58
+ );
59
+ """)
60
+
61
+ def _conn(self) -> sqlite3.Connection:
62
+ conn = sqlite3.connect(self.db_path)
63
+ conn.row_factory = sqlite3.Row
64
+ return conn
65
+
66
+ @staticmethod
67
+ def _now() -> str:
68
+ return datetime.now(timezone.utc).isoformat()
69
+
70
+ # ── Write methods ────────────────────────────────────────────────────────
71
+
72
+ def log_trade_intent(
73
+ self,
74
+ symbol: str,
75
+ side: str,
76
+ size: float,
77
+ leverage: float,
78
+ signal_source: str,
79
+ reasoning: str = "",
80
+ approved: bool = False,
81
+ block_reason: str | None = None,
82
+ ) -> int:
83
+ with self._conn() as conn:
84
+ cur = conn.execute(
85
+ """
86
+ INSERT INTO trade_intents
87
+ (timestamp, symbol, side, size, leverage, signal_source,
88
+ reasoning, approved, block_reason)
89
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
90
+ """,
91
+ (
92
+ self._now(), symbol, side, size, leverage,
93
+ signal_source, reasoning, int(approved), block_reason,
94
+ ),
95
+ )
96
+ return cur.lastrowid
97
+
98
+ def log_risk_check(
99
+ self,
100
+ symbol: str,
101
+ layer_name: str,
102
+ passed: bool,
103
+ reason: str = "",
104
+ value_checked: float | None = None,
105
+ threshold: float | None = None,
106
+ ) -> int:
107
+ with self._conn() as conn:
108
+ cur = conn.execute(
109
+ """
110
+ INSERT INTO risk_checks
111
+ (timestamp, symbol, layer_name, passed, reason,
112
+ value_checked, threshold)
113
+ VALUES (?, ?, ?, ?, ?, ?, ?)
114
+ """,
115
+ (
116
+ self._now(), symbol, layer_name, int(passed),
117
+ reason, value_checked, threshold,
118
+ ),
119
+ )
120
+ return cur.lastrowid
121
+
122
+ def log_strategy_checkpoint(
123
+ self,
124
+ strategy_tag: str,
125
+ win_rate_7d: float,
126
+ win_rate_30d: float,
127
+ total_trades: int,
128
+ verdict: str,
129
+ market_condition: str = "",
130
+ ) -> int:
131
+ with self._conn() as conn:
132
+ cur = conn.execute(
133
+ """
134
+ INSERT INTO strategy_checkpoints
135
+ (timestamp, strategy_tag, win_rate_7d, win_rate_30d,
136
+ total_trades, verdict, market_condition)
137
+ VALUES (?, ?, ?, ?, ?, ?, ?)
138
+ """,
139
+ (
140
+ self._now(), strategy_tag, win_rate_7d, win_rate_30d,
141
+ total_trades, verdict, market_condition,
142
+ ),
143
+ )
144
+ return cur.lastrowid
145
+
146
+ # ── Integrity hash ───────────────────────────────────────────────────────
147
+
148
+ def _all_records_canonical(self) -> str:
149
+ """Return a deterministic JSON string over all rows in all tables."""
150
+ with self._conn() as conn:
151
+ intents = [dict(r) for r in conn.execute(
152
+ "SELECT * FROM trade_intents ORDER BY id"
153
+ )]
154
+ checks = [dict(r) for r in conn.execute(
155
+ "SELECT * FROM risk_checks ORDER BY id"
156
+ )]
157
+ checkpoints = [dict(r) for r in conn.execute(
158
+ "SELECT * FROM strategy_checkpoints ORDER BY id"
159
+ )]
160
+
161
+ payload = {
162
+ "trade_intents": intents,
163
+ "risk_checks": checks,
164
+ "strategy_checkpoints": checkpoints,
165
+ }
166
+ return json.dumps(payload, sort_keys=True, separators=(",", ":"))
167
+
168
+ def _compute_hash(self) -> str:
169
+ canonical = self._all_records_canonical()
170
+ return hashlib.sha256(canonical.encode()).hexdigest()
171
+
172
+ # ── Report ───────────────────────────────────────────────────────────────
173
+
174
+ def generate_audit_report(self) -> dict:
175
+ with self._conn() as conn:
176
+ total_intents = conn.execute(
177
+ "SELECT COUNT(*) FROM trade_intents"
178
+ ).fetchone()[0]
179
+ total_checks = conn.execute(
180
+ "SELECT COUNT(*) FROM risk_checks"
181
+ ).fetchone()[0]
182
+ approved_count = conn.execute(
183
+ "SELECT COUNT(*) FROM trade_intents WHERE approved = 1"
184
+ ).fetchone()[0]
185
+
186
+ approval_rate = (approved_count / total_intents * 100) if total_intents else 0.0
187
+ rejection_rate = 100.0 - approval_rate if total_intents else 0.0
188
+
189
+ return {
190
+ "total_trade_intents": total_intents,
191
+ "total_risk_checks": total_checks,
192
+ "approval_rate": round(approval_rate, 2),
193
+ "rejection_rate": round(rejection_rate, 2),
194
+ "integrity_hash": self._compute_hash(),
195
+ "generated_at": self._now(),
196
+ }
197
+
198
+ def verify_integrity(self, expected_hash: str) -> bool:
199
+ return self._compute_hash() == expected_hash
200
+
201
+ # ── HTML export ──────────────────────────────────────────────────────────
202
+
203
+ def export_html_report(self, output_path: str = "validation/audit_report.html") -> None:
204
+ Path(output_path).parent.mkdir(parents=True, exist_ok=True)
205
+
206
+ report = self.generate_audit_report()
207
+
208
+ with self._conn() as conn:
209
+ intents = [dict(r) for r in conn.execute(
210
+ "SELECT * FROM trade_intents ORDER BY id DESC"
211
+ )]
212
+ checks = [dict(r) for r in conn.execute(
213
+ "SELECT * FROM risk_checks ORDER BY id DESC"
214
+ )]
215
+ checkpoints = [dict(r) for r in conn.execute(
216
+ "SELECT * FROM strategy_checkpoints ORDER BY id DESC"
217
+ )]
218
+
219
+ def _rows(records: list[dict[str, Any]], cols: list[str]) -> str:
220
+ if not records:
221
+ return f"<tr><td colspan='{len(cols)}' class='empty'>No records</td></tr>"
222
+ rows = []
223
+ for rec in records:
224
+ cells = "".join(f"<td>{_esc(rec.get(c, ''))}</td>" for c in cols)
225
+ rows.append(f"<tr>{cells}</tr>")
226
+ return "\n".join(rows)
227
+
228
+ def _esc(val: Any) -> str:
229
+ return str(val).replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
230
+
231
+ def _thead(cols: list[str]) -> str:
232
+ return "<tr>" + "".join(f"<th>{c}</th>" for c in cols) + "</tr>"
233
+
234
+ intent_cols = [
235
+ "id", "timestamp", "symbol", "side", "size", "leverage",
236
+ "signal_source", "reasoning", "approved", "block_reason",
237
+ ]
238
+ check_cols = [
239
+ "id", "timestamp", "symbol", "layer_name", "passed",
240
+ "reason", "value_checked", "threshold",
241
+ ]
242
+ cp_cols = [
243
+ "id", "timestamp", "strategy_tag", "win_rate_7d", "win_rate_30d",
244
+ "total_trades", "verdict", "market_condition",
245
+ ]
246
+
247
+ html = f"""<!DOCTYPE html>
248
+ <html lang="en">
249
+ <head>
250
+ <meta charset="UTF-8">
251
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
252
+ <title>BitSentry Audit Report</title>
253
+ <style>
254
+ * {{ box-sizing: border-box; margin: 0; padding: 0; }}
255
+ body {{ font-family: 'Segoe UI', system-ui, sans-serif; background: #0f1117; color: #e2e8f0; padding: 2rem; }}
256
+ h1 {{ font-size: 1.8rem; color: #f97316; margin-bottom: 0.25rem; }}
257
+ .subtitle {{ color: #64748b; font-size: 0.9rem; margin-bottom: 2rem; }}
258
+ .integrity-box {{
259
+ background: #1e293b; border: 2px solid #22c55e; border-radius: 8px;
260
+ padding: 1.25rem 1.5rem; margin-bottom: 2rem;
261
+ }}
262
+ .integrity-box h2 {{ color: #22c55e; font-size: 1rem; margin-bottom: 0.5rem; }}
263
+ .hash {{ font-family: monospace; font-size: 0.85rem; color: #86efac; word-break: break-all; }}
264
+ .stats {{ display: flex; gap: 1rem; margin-bottom: 2rem; flex-wrap: wrap; }}
265
+ .stat {{
266
+ background: #1e293b; border-radius: 8px; padding: 1rem 1.5rem;
267
+ flex: 1; min-width: 160px; text-align: center;
268
+ }}
269
+ .stat .val {{ font-size: 2rem; font-weight: 700; color: #f97316; }}
270
+ .stat .lbl {{ font-size: 0.8rem; color: #64748b; margin-top: 0.25rem; }}
271
+ h3 {{ color: #cbd5e1; font-size: 1.1rem; margin: 1.5rem 0 0.75rem; }}
272
+ .table-wrap {{ overflow-x: auto; margin-bottom: 1rem; }}
273
+ table {{ width: 100%; border-collapse: collapse; font-size: 0.82rem; }}
274
+ th {{ background: #1e293b; color: #94a3b8; padding: 0.6rem 0.75rem; text-align: left; white-space: nowrap; }}
275
+ td {{ padding: 0.55rem 0.75rem; border-bottom: 1px solid #1e293b; vertical-align: top; }}
276
+ tr:hover td {{ background: #1e293b44; }}
277
+ .empty {{ color: #475569; text-align: center; padding: 1rem; }}
278
+ .footer {{ margin-top: 2rem; color: #334155; font-size: 0.78rem; }}
279
+ </style>
280
+ </head>
281
+ <body>
282
+ <h1>BitSentry Audit Report</h1>
283
+ <p class="subtitle">Generated at {report["generated_at"]}</p>
284
+
285
+ <div class="integrity-box">
286
+ <h2>SHA-256 Integrity Hash</h2>
287
+ <div class="hash">{report["integrity_hash"]}</div>
288
+ </div>
289
+
290
+ <div class="stats">
291
+ <div class="stat"><div class="val">{report["total_trade_intents"]}</div><div class="lbl">Trade Intents</div></div>
292
+ <div class="stat"><div class="val">{report["total_risk_checks"]}</div><div class="lbl">Risk Checks</div></div>
293
+ <div class="stat"><div class="val">{report["approval_rate"]}%</div><div class="lbl">Approval Rate</div></div>
294
+ <div class="stat"><div class="val">{report["rejection_rate"]}%</div><div class="lbl">Rejection Rate</div></div>
295
+ </div>
296
+
297
+ <h3>Trade Intents</h3>
298
+ <div class="table-wrap">
299
+ <table>
300
+ <thead>{_thead(intent_cols)}</thead>
301
+ <tbody>{_rows(intents, intent_cols)}</tbody>
302
+ </table>
303
+ </div>
304
+
305
+ <h3>Risk Checks</h3>
306
+ <div class="table-wrap">
307
+ <table>
308
+ <thead>{_thead(check_cols)}</thead>
309
+ <tbody>{_rows(checks, check_cols)}</tbody>
310
+ </table>
311
+ </div>
312
+
313
+ <h3>Strategy Checkpoints</h3>
314
+ <div class="table-wrap">
315
+ <table>
316
+ <thead>{_thead(cp_cols)}</thead>
317
+ <tbody>{_rows(checkpoints, cp_cols)}</tbody>
318
+ </table>
319
+ </div>
320
+
321
+ <div class="footer">
322
+ BitSentry &mdash; Safety &amp; Audit Layer for Bitget &mdash;
323
+ Integrity hash covers all records in all three tables.
324
+ </div>
325
+ </body>
326
+ </html>"""
327
+
328
+ Path(output_path).write_text(html, encoding="utf-8")
329
+ print(f"[bitsentry] HTML report written to {output_path}")