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 +18 -0
- bitsentry/api/__init__.py +0 -0
- bitsentry/api/server.py +273 -0
- bitsentry/audit_engine.py +329 -0
- bitsentry/bgc_client.py +153 -0
- bitsentry/cli.py +10 -0
- bitsentry/mcp/__init__.py +3 -0
- bitsentry/mcp/server.py +169 -0
- bitsentry/position_monitor.py +223 -0
- bitsentry/reporter.py +227 -0
- bitsentry/risk_guardian.py +256 -0
- bitsentry/scheduler.py +81 -0
- bitsentry/strategy_evaluator.py +269 -0
- bitsentry-0.1.0.dist-info/METADATA +69 -0
- bitsentry-0.1.0.dist-info/RECORD +17 -0
- bitsentry-0.1.0.dist-info/WHEEL +4 -0
- bitsentry-0.1.0.dist-info/entry_points.txt +2 -0
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
|
bitsentry/api/server.py
ADDED
|
@@ -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("&", "&").replace("<", "<").replace(">", ">")
|
|
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 — Safety & Audit Layer for Bitget —
|
|
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}")
|