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/reporter.py ADDED
@@ -0,0 +1,227 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from datetime import datetime, timedelta, timezone
5
+ from typing import TYPE_CHECKING
6
+
7
+ try:
8
+ import requests as _requests
9
+ _HAS_REQUESTS = True
10
+ except ImportError:
11
+ _HAS_REQUESTS = False
12
+
13
+ if TYPE_CHECKING:
14
+ from bitsentry.audit_engine import AuditEngine
15
+ from bitsentry.position_monitor import PositionMonitor
16
+ from bitsentry.strategy_evaluator import StrategyEvaluator
17
+
18
+ _VERSION = "0.1.0"
19
+
20
+
21
+ def _now_utc() -> datetime:
22
+ return datetime.now(timezone.utc)
23
+
24
+
25
+ def _date_str(dt: datetime) -> str:
26
+ return dt.strftime("%Y-%m-%d")
27
+
28
+
29
+ class ReportGenerator:
30
+ """
31
+ Generates daily/weekly/monthly plain-text reports and optionally
32
+ sends them to a Telegram chat.
33
+ """
34
+
35
+ def __init__(
36
+ self,
37
+ audit_engine: "AuditEngine",
38
+ strategy_evaluator: "StrategyEvaluator",
39
+ position_monitor: "PositionMonitor | None" = None,
40
+ telegram_token: str | None = None,
41
+ telegram_chat_id: str | None = None,
42
+ ):
43
+ self._audit = audit_engine
44
+ self._evaluator = strategy_evaluator
45
+ self._monitor = position_monitor
46
+
47
+ self._token = telegram_token or os.environ.get("TELEGRAM_BOT_TOKEN", "")
48
+ self._chat_id = telegram_chat_id or os.environ.get("TELEGRAM_CHAT_ID", "")
49
+
50
+ # ── Internal data helpers ─────────────────────────────────────────────────
51
+
52
+ def _audit_data(self) -> dict:
53
+ return self._audit.generate_audit_report()
54
+
55
+ def _position_data(self) -> dict:
56
+ if not self._monitor:
57
+ return {
58
+ "total_positions": 0,
59
+ "green_count": 0,
60
+ "yellow_count": 0,
61
+ "red_count": 0,
62
+ "overall_safety": "UNKNOWN",
63
+ "total_unrealized_pnl": 0.0,
64
+ }
65
+ return self._monitor.get_account_summary()
66
+
67
+ def _strategy_data(self) -> list[dict]:
68
+ return self._evaluator.get_leaderboard()
69
+
70
+ # ── Section builders ──────────────────────────────────────────────────────
71
+
72
+ def _section_risk(self, report: dict) -> str:
73
+ total = report["total_trade_intents"]
74
+ approved = round(total * report["approval_rate"] / 100) if total else 0
75
+ blocked = total - approved
76
+
77
+ lines = [
78
+ "📋 *RISK ACTIVITY*",
79
+ f" Total trade intents : {total}",
80
+ f" Approved : {approved}",
81
+ f" Blocked : {blocked}",
82
+ ]
83
+ if blocked and total:
84
+ lines.append(f" Approval rate : {report['approval_rate']}%")
85
+ return "\n".join(lines)
86
+
87
+ def _section_positions(self, pos: dict) -> str:
88
+ pnl = pos["total_unrealized_pnl"]
89
+ pnl_str = ("+$" if pnl >= 0 else "-$") + f"{abs(pnl):.2f}"
90
+ safety_icon = {"GREEN": "🟢", "YELLOW": "🟡", "RED": "🔴"}.get(
91
+ pos["overall_safety"], "⚪"
92
+ )
93
+
94
+ return "\n".join([
95
+ "📍 *POSITION SAFETY*",
96
+ f" Overall safety : {safety_icon} {pos['overall_safety']}",
97
+ f" Positions : 🟢 {pos['green_count']} 🟡 {pos['yellow_count']} 🔴 {pos['red_count']}",
98
+ f" Unrealized PnL : {pnl_str} USDT",
99
+ ])
100
+
101
+ def _section_strategies(self, strategies: list[dict]) -> str:
102
+ if not strategies:
103
+ return "📈 *STRATEGY PERFORMANCE*\n No strategies recorded yet."
104
+
105
+ verdict_icon = {
106
+ "PERFORMING": "✅",
107
+ "DEGRADING": "⚠️",
108
+ "DEAD": "💀",
109
+ "INSUFFICIENT_DATA": "❓",
110
+ }
111
+
112
+ lines = ["📈 *STRATEGY PERFORMANCE*"]
113
+ best = strategies[0]
114
+ dead_or_degrading = [s for s in strategies if s["verdict"] in ("DEAD", "DEGRADING")]
115
+
116
+ for s in strategies:
117
+ icon = verdict_icon.get(s["verdict"], "❓")
118
+ lines.append(
119
+ f" {icon} `{s['strategy_tag']}` "
120
+ f"WR30d {s['win_rate_30d']:.0%} "
121
+ f"PF {s['profit_factor']:.2f} "
122
+ f"PnL ${s['total_pnl_usdt']:.2f}"
123
+ )
124
+
125
+ lines.append(f" 🏆 Best: `{best['strategy_tag']}` (PF {best['profit_factor']:.2f})")
126
+
127
+ for s in dead_or_degrading:
128
+ icon = verdict_icon[s["verdict"]]
129
+ lines.append(f" {icon} WARNING: `{s['strategy_tag']}` is {s['verdict']}")
130
+
131
+ return "\n".join(lines)
132
+
133
+ def _section_audit(self, report: dict) -> str:
134
+ h = report["integrity_hash"]
135
+ short_hash = h[:16] + "..." if h else "—"
136
+ verified = self._audit.verify_integrity(h) if h else False
137
+ integrity_str = "✅ Verified" if verified else "❌ Tampered"
138
+
139
+ return "\n".join([
140
+ "🔒 *AUDIT INTEGRITY*",
141
+ f" SHA-256 : `{short_hash}`",
142
+ f" Status : {integrity_str}",
143
+ f" Checks : {report['total_risk_checks']} risk checks logged",
144
+ ])
145
+
146
+ def _build_report(self, period_label: str, date_range: str) -> str:
147
+ # strategies first — evaluate() writes checkpoints to the DB
148
+ # audit hash must be taken AFTER those writes to stay consistent
149
+ strategies = self._strategy_data()
150
+ pos = self._position_data()
151
+ report = self._audit_data()
152
+
153
+ divider = "─" * 36
154
+
155
+ return "\n".join([
156
+ f"📊 *BitSentry {period_label} Report — {date_range}*",
157
+ divider,
158
+ self._section_risk(report),
159
+ divider,
160
+ self._section_positions(pos),
161
+ divider,
162
+ self._section_strategies(strategies),
163
+ divider,
164
+ self._section_audit(report),
165
+ divider,
166
+ f"_Generated by BitSentry v{_VERSION}_",
167
+ ])
168
+
169
+ # ── Public report methods ─────────────────────────────────────────────────
170
+
171
+ def generate_daily_report(self) -> str:
172
+ today = _date_str(_now_utc())
173
+ return self._build_report("Daily", today)
174
+
175
+ def generate_weekly_report(self) -> str:
176
+ end = _now_utc()
177
+ start = end - timedelta(days=7)
178
+ span = f"{_date_str(start)} → {_date_str(end)}"
179
+ return self._build_report("Weekly", span)
180
+
181
+ def generate_monthly_report(self) -> str:
182
+ end = _now_utc()
183
+ start = end - timedelta(days=30)
184
+ span = f"{_date_str(start)} → {_date_str(end)}"
185
+ return self._build_report("Monthly", span)
186
+
187
+ # ── Telegram ──────────────────────────────────────────────────────────────
188
+
189
+ def send_telegram(self, message: str) -> bool:
190
+ if not self._token or not self._chat_id:
191
+ print("[bitsentry] No Telegram token configured — printing report to console:\n")
192
+ print(message)
193
+ return False
194
+
195
+ if not _HAS_REQUESTS:
196
+ print("[bitsentry] 'requests' not installed — cannot send Telegram message.")
197
+ return False
198
+
199
+ url = f"https://api.telegram.org/bot{self._token}/sendMessage"
200
+ try:
201
+ resp = _requests.post(
202
+ url,
203
+ json={
204
+ "chat_id": self._chat_id,
205
+ "text": message,
206
+ "parse_mode": "Markdown",
207
+ },
208
+ timeout=10,
209
+ )
210
+ if resp.status_code == 200:
211
+ print(f"[bitsentry] Telegram report sent (chat_id={self._chat_id})")
212
+ return True
213
+ else:
214
+ print(f"[bitsentry] Telegram send failed: {resp.status_code} {resp.text[:200]}")
215
+ return False
216
+ except Exception as exc:
217
+ print(f"[bitsentry] Telegram send error: {exc}")
218
+ return False
219
+
220
+ def send_daily_report(self) -> bool:
221
+ return self.send_telegram(self.generate_daily_report())
222
+
223
+ def send_weekly_report(self) -> bool:
224
+ return self.send_telegram(self.generate_weekly_report())
225
+
226
+ def send_monthly_report(self) -> bool:
227
+ return self.send_telegram(self.generate_monthly_report())
@@ -0,0 +1,256 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from pathlib import Path
5
+ from typing import TYPE_CHECKING, Any
6
+
7
+ import yaml
8
+
9
+ if TYPE_CHECKING:
10
+ from bitsentry.audit_engine import AuditEngine
11
+
12
+
13
+ @dataclass
14
+ class RiskCheckResult:
15
+ approved: bool
16
+ blocking_layer: str | None
17
+ reason: str
18
+ warnings: list[str] = field(default_factory=list)
19
+ risk_score: int = 0
20
+
21
+
22
+ class RiskGuardian:
23
+ """
24
+ 5-layer pre-trade risk middleware.
25
+
26
+ Layers run in order and stop at the first blocking failure.
27
+ A non-blocking warning is added for consecutive loss throttle.
28
+ """
29
+
30
+ def __init__(
31
+ self,
32
+ config_path: str = "config/risk_rules.yaml",
33
+ audit_engine: "AuditEngine | None" = None,
34
+ ):
35
+ self._rules = self._load_rules(config_path)
36
+ self._audit = audit_engine
37
+ self._config_path = config_path
38
+ print(
39
+ f"[bitsentry] RiskGuardian loaded rules from {config_path}: "
40
+ f"leverage_cap={self._rules['leverage_cap']}, "
41
+ f"max_position_size_pct={self._rules['max_position_size_pct']}%, "
42
+ f"daily_loss_limit_pct={self._rules['daily_loss_limit_pct']}%, "
43
+ f"consecutive_loss_limit={self._rules['consecutive_loss_limit']}, "
44
+ f"allowed_symbols={self._rules['allowed_symbols']}"
45
+ )
46
+
47
+ # ── Config loading ───────────────────────────────────────────────────────
48
+
49
+ @staticmethod
50
+ def _load_rules(config_path: str) -> dict[str, Any]:
51
+ path = Path(config_path)
52
+ if not path.exists():
53
+ raise FileNotFoundError(
54
+ f"Risk rules config not found: {config_path}. "
55
+ "Expected YAML with a top-level 'risk_rules' key."
56
+ )
57
+ with path.open() as fh:
58
+ raw = yaml.safe_load(fh)
59
+ rules = raw.get("risk_rules", {})
60
+ required = {
61
+ "leverage_cap", "max_position_size_pct",
62
+ "daily_loss_limit_pct", "consecutive_loss_limit",
63
+ "allowed_symbols",
64
+ }
65
+ missing = required - rules.keys()
66
+ if missing:
67
+ raise ValueError(f"risk_rules.yaml is missing keys: {missing}")
68
+ rules.setdefault("blocked_symbols", [])
69
+ return rules
70
+
71
+ # ── Risk score ───────────────────────────────────────────────────────────
72
+
73
+ @staticmethod
74
+ def _compute_risk_score(
75
+ leverage: float,
76
+ size_usdt: float,
77
+ account_balance_usdt: float,
78
+ daily_pnl_usdt: float,
79
+ consecutive_losses: int,
80
+ ) -> int:
81
+ score = 0
82
+ if leverage > 5:
83
+ score += 20
84
+ if leverage > 10:
85
+ score += 20
86
+ if account_balance_usdt > 0 and (size_usdt / account_balance_usdt) > 0.03:
87
+ score += 20
88
+ if daily_pnl_usdt < 0:
89
+ score += 20
90
+ if consecutive_losses >= 2:
91
+ score += 20
92
+ return min(score, 100)
93
+
94
+ # ── 5 Layers ─────────────────────────────────────────────────────────────
95
+
96
+ def _layer1_symbol_check(self, symbol: str) -> str | None:
97
+ """Returns a block reason string, or None if the symbol passes."""
98
+ if symbol in self._rules["blocked_symbols"]:
99
+ return f"{symbol} is on the blocked symbols list"
100
+ if symbol not in self._rules["allowed_symbols"]:
101
+ return (
102
+ f"{symbol} is not in the allowed symbols list "
103
+ f"({self._rules['allowed_symbols']})"
104
+ )
105
+ return None
106
+
107
+ def _layer2_leverage_cap(self, leverage: float) -> str | None:
108
+ cap = self._rules["leverage_cap"]
109
+ if leverage > cap:
110
+ return f"Leverage {leverage}x exceeds cap of {cap}x"
111
+ return None
112
+
113
+ def _layer3_position_size(
114
+ self, size_usdt: float, account_balance_usdt: float
115
+ ) -> str | None:
116
+ pct = self._rules["max_position_size_pct"]
117
+ max_size = account_balance_usdt * pct / 100
118
+ if size_usdt > max_size:
119
+ return (
120
+ f"Position size ${size_usdt:.2f} exceeds "
121
+ f"{pct}% of account (${max_size:.2f})"
122
+ )
123
+ return None
124
+
125
+ def _layer4_daily_loss_circuit(
126
+ self, daily_pnl_usdt: float, account_balance_usdt: float
127
+ ) -> str | None:
128
+ pct = self._rules["daily_loss_limit_pct"]
129
+ limit = -(account_balance_usdt * pct / 100)
130
+ if daily_pnl_usdt < limit:
131
+ return (
132
+ f"Daily PnL ${daily_pnl_usdt:.2f} breaches "
133
+ f"{pct}% daily loss limit (${limit:.2f})"
134
+ )
135
+ return None
136
+
137
+ def _layer5_consecutive_loss_throttle(
138
+ self, consecutive_losses: int
139
+ ) -> str | None:
140
+ """Returns a warning string if throttle applies, else None."""
141
+ limit = self._rules["consecutive_loss_limit"]
142
+ if consecutive_losses >= limit:
143
+ return (
144
+ f"{consecutive_losses} consecutive losses (≥ limit of {limit}): "
145
+ "recommended size reduced 50%"
146
+ )
147
+ return None
148
+
149
+ # ── Public API ───────────────────────────────────────────────────────────
150
+
151
+ def check(
152
+ self,
153
+ symbol: str,
154
+ side: str,
155
+ size_usdt: float,
156
+ leverage: float,
157
+ account_balance_usdt: float,
158
+ daily_pnl_usdt: float,
159
+ consecutive_losses: int,
160
+ ) -> RiskCheckResult:
161
+ """
162
+ Run all 5 layers in order. Returns a RiskCheckResult.
163
+ Layers 1-4 are hard blocks; layer 5 is a warning only.
164
+ """
165
+ risk_score = self._compute_risk_score(
166
+ leverage, size_usdt, account_balance_usdt,
167
+ daily_pnl_usdt, consecutive_losses,
168
+ )
169
+ warnings: list[str] = []
170
+
171
+ layers = [
172
+ ("symbol_check", lambda: self._layer1_symbol_check(symbol)),
173
+ ("leverage_cap", lambda: self._layer2_leverage_cap(leverage)),
174
+ ("position_size", lambda: self._layer3_position_size(size_usdt, account_balance_usdt)),
175
+ ("daily_loss_circuit", lambda: self._layer4_daily_loss_circuit(daily_pnl_usdt, account_balance_usdt)),
176
+ ]
177
+
178
+ for layer_name, check_fn in layers:
179
+ block_reason = check_fn()
180
+ passed = block_reason is None
181
+
182
+ if self._audit:
183
+ self._audit.log_risk_check(
184
+ symbol=symbol,
185
+ layer_name=layer_name,
186
+ passed=passed,
187
+ reason=block_reason or "passed",
188
+ value_checked=self._layer_value(
189
+ layer_name, leverage, size_usdt,
190
+ account_balance_usdt, daily_pnl_usdt,
191
+ ),
192
+ threshold=self._layer_threshold(layer_name, account_balance_usdt),
193
+ )
194
+
195
+ if not passed:
196
+ return RiskCheckResult(
197
+ approved=False,
198
+ blocking_layer=layer_name,
199
+ reason=block_reason,
200
+ warnings=warnings,
201
+ risk_score=risk_score,
202
+ )
203
+
204
+ # Layer 5 — non-blocking throttle warning
205
+ throttle_warn = self._layer5_consecutive_loss_throttle(consecutive_losses)
206
+ if throttle_warn:
207
+ warnings.append(throttle_warn)
208
+ if self._audit:
209
+ self._audit.log_risk_check(
210
+ symbol=symbol,
211
+ layer_name="consecutive_loss_throttle",
212
+ passed=True,
213
+ reason=throttle_warn,
214
+ value_checked=float(consecutive_losses),
215
+ threshold=float(self._rules["consecutive_loss_limit"]),
216
+ )
217
+
218
+ return RiskCheckResult(
219
+ approved=True,
220
+ blocking_layer=None,
221
+ reason="All risk layers passed",
222
+ warnings=warnings,
223
+ risk_score=risk_score,
224
+ )
225
+
226
+ def get_summary(self) -> dict:
227
+ return {
228
+ "config_path": self._config_path,
229
+ "rules": dict(self._rules),
230
+ }
231
+
232
+ # ── Helpers for audit logging ────────────────────────────────────────────
233
+
234
+ def _layer_value(
235
+ self,
236
+ layer_name: str,
237
+ leverage: float,
238
+ size_usdt: float,
239
+ account_balance_usdt: float,
240
+ daily_pnl_usdt: float,
241
+ ) -> float | None:
242
+ return {
243
+ "leverage_cap": leverage,
244
+ "position_size": size_usdt,
245
+ "daily_loss_circuit": daily_pnl_usdt,
246
+ }.get(layer_name)
247
+
248
+ def _layer_threshold(
249
+ self, layer_name: str, account_balance_usdt: float
250
+ ) -> float | None:
251
+ r = self._rules
252
+ return {
253
+ "leverage_cap": float(r["leverage_cap"]),
254
+ "position_size": account_balance_usdt * r["max_position_size_pct"] / 100,
255
+ "daily_loss_circuit": -(account_balance_usdt * r["daily_loss_limit_pct"] / 100),
256
+ }.get(layer_name)
bitsentry/scheduler.py ADDED
@@ -0,0 +1,81 @@
1
+ from __future__ import annotations
2
+
3
+ import threading
4
+ from datetime import datetime, timezone
5
+ from typing import TYPE_CHECKING
6
+
7
+ if TYPE_CHECKING:
8
+ from bitsentry.reporter import ReportGenerator
9
+
10
+
11
+ class Scheduler:
12
+ """
13
+ Background thread that fires report sends at fixed UTC times.
14
+
15
+ Schedule:
16
+ Daily — 23:00 UTC every day
17
+ Weekly — 23:30 UTC every Sunday
18
+ Monthly — 23:45 UTC on the 1st of each month
19
+ """
20
+
21
+ def __init__(self, reporter: "ReportGenerator", tick_seconds: int = 60):
22
+ self._reporter = reporter
23
+ self._tick = tick_seconds
24
+ self._stop_evt = threading.Event()
25
+ self._thread: threading.Thread | None = None
26
+ self._last_fired: dict[str, str] = {}
27
+
28
+ def _utc_key(self) -> tuple[int, int, int, int]:
29
+ now = datetime.now(timezone.utc)
30
+ return now.year, now.month, now.day, now.hour, now.minute # type: ignore[return-value]
31
+
32
+ def _should_fire(self, key: str, dt: datetime) -> bool:
33
+ tag = f"{key}-{dt.year}-{dt.month}-{dt.day}-{dt.hour}-{dt.minute}"
34
+ if self._last_fired.get(key) == tag:
35
+ return False
36
+ self._last_fired[key] = tag
37
+ return True
38
+
39
+ def _tick_loop(self) -> None:
40
+ while not self._stop_evt.wait(self._tick):
41
+ now = datetime.now(timezone.utc)
42
+ h, m, wd, dom = now.hour, now.minute, now.weekday(), now.day
43
+
44
+ # Daily at 23:00
45
+ if h == 23 and m == 0 and self._should_fire("daily", now):
46
+ print("[bitsentry-scheduler] Sending daily report…")
47
+ try:
48
+ self._reporter.send_daily_report()
49
+ except Exception as exc:
50
+ print(f"[bitsentry-scheduler] Daily report error: {exc}")
51
+
52
+ # Weekly Sunday (weekday=6) at 23:30
53
+ if h == 23 and m == 30 and wd == 6 and self._should_fire("weekly", now):
54
+ print("[bitsentry-scheduler] Sending weekly report…")
55
+ try:
56
+ self._reporter.send_weekly_report()
57
+ except Exception as exc:
58
+ print(f"[bitsentry-scheduler] Weekly report error: {exc}")
59
+
60
+ # Monthly on 1st at 23:45
61
+ if h == 23 and m == 45 and dom == 1 and self._should_fire("monthly", now):
62
+ print("[bitsentry-scheduler] Sending monthly report…")
63
+ try:
64
+ self._reporter.send_monthly_report()
65
+ except Exception as exc:
66
+ print(f"[bitsentry-scheduler] Monthly report error: {exc}")
67
+
68
+ def start(self) -> None:
69
+ if self._thread and self._thread.is_alive():
70
+ return
71
+ self._stop_evt.clear()
72
+ self._thread = threading.Thread(target=self._tick_loop, daemon=True, name="bitsentry-scheduler")
73
+ self._thread.start()
74
+ print(f"[bitsentry-scheduler] Started (tick={self._tick}s). "
75
+ "Daily@23:00 Weekly@Sun23:30 Monthly@1st23:45 UTC")
76
+
77
+ def stop(self) -> None:
78
+ self._stop_evt.set()
79
+ if self._thread:
80
+ self._thread.join(timeout=5)
81
+ print("[bitsentry-scheduler] Stopped.")