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/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.")
|