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.
@@ -0,0 +1,153 @@
1
+ import json
2
+ import os
3
+ import subprocess
4
+ from typing import Any
5
+
6
+
7
+ class BGCError(Exception):
8
+ """Raised when a bgc CLI call fails."""
9
+
10
+
11
+ class BGCClient:
12
+ """
13
+ Wraps the bgc CLI via subprocess.
14
+
15
+ demo=True appends --paper-trading to every call and requires
16
+ Demo API Key credentials in the environment.
17
+ """
18
+
19
+ def __init__(self, demo: bool = False):
20
+ self.demo = demo
21
+ self._verify_credentials()
22
+
23
+ def _verify_credentials(self) -> None:
24
+ missing = [
25
+ v for v in ("BITGET_API_KEY", "BITGET_SECRET_KEY", "BITGET_PASSPHRASE")
26
+ if not os.environ.get(v)
27
+ ]
28
+ if missing:
29
+ raise BGCError(f"Missing environment variables: {', '.join(missing)}")
30
+
31
+ # Quick connectivity check — public endpoint, no auth needed
32
+ try:
33
+ self.run("spot", "spot_get_ticker", symbol="BTCUSDT")
34
+ mode = "DEMO/paper-trading" if self.demo else "LIVE"
35
+ print(f"[bitsentry] BGCClient connected successfully ({mode})")
36
+ except BGCError as exc:
37
+ raise BGCError(f"BGCClient connection check failed: {exc}") from exc
38
+
39
+ def run(self, module: str, tool: str, **params: Any) -> Any:
40
+ """
41
+ Execute: bgc [--paper-trading] <module> <tool> [--key value ...]
42
+
43
+ Returns the parsed `data` field from the JSON response.
44
+ Raises BGCError on non-zero exit code or error JSON.
45
+ """
46
+ cmd = ["bgc"]
47
+ if self.demo:
48
+ cmd.append("--paper-trading")
49
+ cmd += [module, tool]
50
+ for key, value in params.items():
51
+ cmd += [f"--{key}", str(value)]
52
+
53
+ result = subprocess.run(
54
+ cmd,
55
+ capture_output=True,
56
+ text=True,
57
+ env=os.environ.copy(),
58
+ )
59
+
60
+ raw = result.stdout.strip() or result.stderr.strip()
61
+
62
+ if result.returncode != 0:
63
+ # Try to parse structured error JSON from stderr
64
+ try:
65
+ payload = json.loads(result.stderr.strip())
66
+ msg = payload.get("error", {}).get("message", result.stderr.strip())
67
+ err_type = payload.get("error", {}).get("type", "Unknown")
68
+ raise BGCError(f"[{err_type}] {msg}")
69
+ except (json.JSONDecodeError, AttributeError):
70
+ raise BGCError(result.stderr.strip() or f"bgc exited {result.returncode}")
71
+
72
+ try:
73
+ payload = json.loads(raw)
74
+ except json.JSONDecodeError as exc:
75
+ raise BGCError(f"bgc returned non-JSON output: {raw[:200]}") from exc
76
+
77
+ # Structured error inside a 200-exit response
78
+ if isinstance(payload, dict) and payload.get("ok") is False:
79
+ err = payload.get("error", {})
80
+ raise BGCError(f"[{err.get('type', 'Error')}] {err.get('message', 'unknown error')}")
81
+
82
+ return payload.get("data", payload)
83
+
84
+ # ── High-level convenience methods ─────────────────────────────────────
85
+
86
+ def get_ticker(self, symbol: str) -> dict:
87
+ """Return spot ticker data for *symbol* (e.g. 'BTCUSDT')."""
88
+ data = self.run("spot", "spot_get_ticker", symbol=symbol)
89
+ # data is a list; return the first match
90
+ if isinstance(data, list):
91
+ for item in data:
92
+ if item.get("symbol") == symbol:
93
+ return item
94
+ return data[0] if data else {}
95
+ return data
96
+
97
+ def get_account_balance(self) -> list[dict]:
98
+ """
99
+ Return account asset balances.
100
+
101
+ Note: the Bitget demo environment does not support the
102
+ /api/v2/account/all-account-balance endpoint (returns 404).
103
+ In that case a descriptive dict is returned instead of raising.
104
+ """
105
+ try:
106
+ data = self.run("account", "get_account_assets")
107
+ return data if isinstance(data, list) else [data]
108
+ except BGCError as exc:
109
+ if "404" in str(exc) or "NOT FOUND" in str(exc):
110
+ return [
111
+ {
112
+ "warning": "account balance endpoint not available in demo environment",
113
+ "detail": str(exc),
114
+ "suggestion": "Use get_positions() for futures PnL or switch to a live key.",
115
+ }
116
+ ]
117
+ raise
118
+
119
+ def get_positions(self, product_type: str = "USDT-FUTURES") -> list[dict]:
120
+ """Return open futures positions for *product_type*."""
121
+ data = self.run("futures", "futures_get_positions", productType=product_type)
122
+ return data if isinstance(data, list) else []
123
+
124
+ def get_order_history(self, symbol: str | None = None, product_type: str = "USDT-FUTURES") -> list[dict]:
125
+ """
126
+ Return recent open orders.
127
+
128
+ Fetches spot unfilled orders (symbol required) and futures
129
+ pending orders and combines them.
130
+ """
131
+ orders: list[dict] = []
132
+
133
+ # Spot orders — symbol required
134
+ if symbol:
135
+ try:
136
+ spot_data = self.run("spot", "spot_get_orders", symbol=symbol)
137
+ if isinstance(spot_data, list):
138
+ orders.extend(spot_data)
139
+ except BGCError:
140
+ pass
141
+
142
+ # Futures pending orders
143
+ try:
144
+ futures_data = self.run("futures", "futures_get_orders", productType=product_type)
145
+ if isinstance(futures_data, dict):
146
+ entries = futures_data.get("entrustedList") or []
147
+ orders.extend(entries)
148
+ elif isinstance(futures_data, list):
149
+ orders.extend(futures_data)
150
+ except BGCError:
151
+ pass
152
+
153
+ return orders
bitsentry/cli.py ADDED
@@ -0,0 +1,10 @@
1
+ def main():
2
+ import uvicorn
3
+ import os
4
+ from dotenv import load_dotenv
5
+ load_dotenv()
6
+ print("Starting BitSentry server on http://127.0.0.1:8000")
7
+ uvicorn.run("bitsentry.api.server:app", host="127.0.0.1", port=8000, reload=False)
8
+
9
+ if __name__ == "__main__":
10
+ main()
@@ -0,0 +1,3 @@
1
+ from bitsentry.mcp.server import mcp
2
+
3
+ __all__ = ["mcp"]
@@ -0,0 +1,169 @@
1
+ """
2
+ BitSentry MCP Server
3
+
4
+ Exposes BitSentry risk, position, strategy, and audit tools
5
+ so any MCP-compatible agent (Claude, Agent Hub, etc.) can call them.
6
+
7
+ Run standalone:
8
+ python -m bitsentry.mcp.server
9
+
10
+ Or as stdio MCP server (Claude Code config):
11
+ command: /opt/miniconda3/bin/python3.13 -m bitsentry.mcp.server
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import dataclasses
16
+ import os
17
+ from typing import Any
18
+
19
+ from mcp.server.fastmcp import FastMCP
20
+
21
+ from bitsentry.audit_engine import AuditEngine
22
+ from bitsentry.bgc_client import BGCClient, BGCError
23
+ from bitsentry.position_monitor import PositionMonitor
24
+ from bitsentry.risk_guardian import RiskGuardian
25
+ from bitsentry.strategy_evaluator import StrategyEvaluator
26
+
27
+ # ── Server instance ───────────────────────────────────────────────────────────
28
+
29
+ mcp = FastMCP("bitsentry")
30
+
31
+ # ── Component initialisation ──────────────────────────────────────────────────
32
+
33
+ demo = os.environ.get("BITGET_DEMO_MODE", "true").lower() == "true"
34
+
35
+ try:
36
+ _client = BGCClient(demo=demo)
37
+ except BGCError as exc:
38
+ print(f"[bitsentry-mcp] WARNING: BGCClient init failed: {exc}")
39
+ _client = None
40
+
41
+ _audit = AuditEngine()
42
+ _guardian = RiskGuardian(audit_engine=_audit)
43
+ _monitor = PositionMonitor(bgc_client=_client, audit_engine=_audit) if _client else None
44
+ _evaluator = StrategyEvaluator(audit_engine=_audit)
45
+
46
+ print(f"[bitsentry-mcp] Ready. demo={demo}, bgc={'ok' if _client else 'unavailable'}")
47
+
48
+ # ── Helpers ───────────────────────────────────────────────────────────────────
49
+
50
+ def _asdict(obj: Any) -> Any:
51
+ if dataclasses.is_dataclass(obj) and not isinstance(obj, type):
52
+ return {k: _asdict(v) for k, v in dataclasses.asdict(obj).items()}
53
+ if isinstance(obj, list):
54
+ return [_asdict(i) for i in obj]
55
+ return obj
56
+
57
+
58
+ # ── Tool 1: check_risk ────────────────────────────────────────────────────────
59
+
60
+ @mcp.tool()
61
+ def check_risk(
62
+ symbol: str,
63
+ side: str,
64
+ size_usdt: float,
65
+ leverage: float,
66
+ account_balance_usdt: float,
67
+ daily_pnl_usdt: float,
68
+ consecutive_losses: int,
69
+ ) -> dict:
70
+ """Check if a trade is safe to execute according to BitSentry risk rules.
71
+ Call this before placing any order on Bitget."""
72
+ result = _guardian.check(
73
+ symbol=symbol,
74
+ side=side,
75
+ size_usdt=size_usdt,
76
+ leverage=leverage,
77
+ account_balance_usdt=account_balance_usdt,
78
+ daily_pnl_usdt=daily_pnl_usdt,
79
+ consecutive_losses=consecutive_losses,
80
+ )
81
+ return _asdict(result)
82
+
83
+
84
+ # ── Tool 2: get_position_safety ───────────────────────────────────────────────
85
+
86
+ @mcp.tool()
87
+ def get_position_safety() -> dict:
88
+ """Get safety ratings for all open Bitget positions.
89
+ Returns GREEN/YELLOW/RED rating for each position."""
90
+ if not _monitor:
91
+ return {"error": "PositionMonitor unavailable — BGCClient failed to initialize"}
92
+ positions = _monitor.get_positions()
93
+ summary = _monitor.get_account_summary()
94
+ return {
95
+ "overall_safety": summary["overall_safety"],
96
+ "positions": _asdict(positions),
97
+ }
98
+
99
+
100
+ # ── Tool 3: get_account_summary ───────────────────────────────────────────────
101
+
102
+ @mcp.tool()
103
+ def get_account_summary() -> dict:
104
+ """Get a summary of current account safety status including position counts
105
+ by safety rating."""
106
+ if not _monitor:
107
+ return {"error": "PositionMonitor unavailable — BGCClient failed to initialize"}
108
+ return _monitor.get_account_summary()
109
+
110
+
111
+ # ── Tool 4: evaluate_strategy ─────────────────────────────────────────────────
112
+
113
+ @mcp.tool()
114
+ def evaluate_strategy(strategy_tag: str) -> dict:
115
+ """Evaluate the performance of a trading strategy.
116
+ Returns PERFORMING, DEGRADING, or DEAD verdict."""
117
+ health = _evaluator.evaluate(strategy_tag)
118
+ return _asdict(health)
119
+
120
+
121
+ # ── Tool 5: record_trade ──────────────────────────────────────────────────────
122
+
123
+ @mcp.tool()
124
+ def record_trade(
125
+ strategy_tag: str,
126
+ symbol: str,
127
+ side: str,
128
+ entry_price: float,
129
+ exit_price: float,
130
+ size_usdt: float,
131
+ market_condition: str = "",
132
+ ) -> dict:
133
+ """Record a completed trade result for strategy performance tracking."""
134
+ trade_id = _evaluator.record_trade_result(
135
+ strategy_tag=strategy_tag,
136
+ symbol=symbol,
137
+ side=side,
138
+ entry_price=entry_price,
139
+ exit_price=exit_price,
140
+ size_usdt=size_usdt,
141
+ market_condition=market_condition,
142
+ )
143
+ # Re-evaluate to get updated outcome in response
144
+ pnl_usdt, _ = _evaluator._calc_pnl(side, entry_price, exit_price, size_usdt)
145
+ outcome = "WIN" if pnl_usdt > 0 else "LOSS"
146
+ health = _evaluator.evaluate(strategy_tag)
147
+ return {
148
+ "recorded": True,
149
+ "trade_id": trade_id,
150
+ "outcome": outcome,
151
+ "pnl_usdt": round(pnl_usdt, 4),
152
+ "strategy_verdict": health.verdict,
153
+ "win_rate_30d": health.win_rate_30d,
154
+ }
155
+
156
+
157
+ # ── Tool 6: get_audit_report ──────────────────────────────────────────────────
158
+
159
+ @mcp.tool()
160
+ def get_audit_report() -> dict:
161
+ """Get the full BitSentry audit report with SHA-256 integrity hash.
162
+ Use this to verify all trading decisions are logged."""
163
+ return _audit.generate_audit_report()
164
+
165
+
166
+ # ── Entrypoint ────────────────────────────────────────────────────────────────
167
+
168
+ if __name__ == "__main__":
169
+ mcp.run()
@@ -0,0 +1,223 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from datetime import datetime, timezone
5
+ from typing import TYPE_CHECKING
6
+
7
+ if TYPE_CHECKING:
8
+ from bitsentry.audit_engine import AuditEngine
9
+ from bitsentry.bgc_client import BGCClient
10
+
11
+
12
+ @dataclass
13
+ class PositionSnapshot:
14
+ symbol: str
15
+ side: str # "long" or "short"
16
+ size: float # base-asset size
17
+ entry_price: float
18
+ mark_price: float
19
+ unrealized_pnl: float
20
+ unrealized_pnl_pct: float # as % of margin_used
21
+ margin_used: float
22
+ leverage: int
23
+ safety_rating: str # GREEN / YELLOW / RED
24
+ safety_message: str
25
+ timestamp: str
26
+
27
+
28
+ def _safe_float(val, default: float = 0.0) -> float:
29
+ try:
30
+ return float(val)
31
+ except (TypeError, ValueError):
32
+ return default
33
+
34
+
35
+ def _safe_int(val, default: int = 1) -> int:
36
+ try:
37
+ return int(float(val))
38
+ except (TypeError, ValueError):
39
+ return default
40
+
41
+
42
+ class PositionMonitor:
43
+ """
44
+ Fetches live Bitget futures positions and assigns safety ratings.
45
+
46
+ Safety tiers:
47
+ GREEN — unrealized_pnl_pct > -3 % AND margin_ratio ≤ 0.5
48
+ YELLOW — pnl_pct in [-7, -3) OR margin_ratio in (0.5, 0.8]
49
+ RED — pnl_pct < -7 % OR margin_ratio > 0.8
50
+ """
51
+
52
+ def __init__(
53
+ self,
54
+ bgc_client: "BGCClient",
55
+ audit_engine: "AuditEngine | None" = None,
56
+ poll_interval_seconds: int = 30,
57
+ ):
58
+ self._client = bgc_client
59
+ self._audit = audit_engine
60
+ self.poll_interval_seconds = poll_interval_seconds
61
+
62
+ # ── Safety rating ────────────────────────────────────────────────────────
63
+
64
+ @staticmethod
65
+ def _rate(pnl_pct: float, margin_ratio: float) -> tuple[str, str]:
66
+ """Return (rating, message) for a position."""
67
+ if pnl_pct < -7.0 or margin_ratio > 0.8:
68
+ if pnl_pct < -7.0:
69
+ msg = f"Unrealized loss {pnl_pct:.2f}% exceeds 7% danger threshold"
70
+ else:
71
+ msg = f"Margin ratio {margin_ratio:.2f} exceeds 0.80 — liquidation risk"
72
+ return "RED", msg
73
+
74
+ if pnl_pct < -3.0 or margin_ratio > 0.5:
75
+ if pnl_pct < -3.0:
76
+ msg = f"Unrealized loss {pnl_pct:.2f}% in caution zone (-3% to -7%)"
77
+ else:
78
+ msg = f"Margin ratio {margin_ratio:.2f} elevated (above 0.50)"
79
+ return "YELLOW", msg
80
+
81
+ return "GREEN", f"Position healthy: PnL {pnl_pct:.2f}%, margin ratio {margin_ratio:.2f}"
82
+
83
+ # ── Position parsing ─────────────────────────────────────────────────────
84
+
85
+ def _parse(self, raw: dict) -> PositionSnapshot:
86
+ """
87
+ Convert a raw Bitget v2 position dict into a PositionSnapshot.
88
+
89
+ Bitget v2 field names for /api/v2/mix/position/all-position:
90
+ holdSide, total, openPriceAvg, markPrice, unrealizedPL,
91
+ marginSize, leverage, marginRatio
92
+ """
93
+ symbol = raw.get("symbol", "UNKNOWN")
94
+ side = raw.get("holdSide", "unknown")
95
+
96
+ size = _safe_float(raw.get("total", raw.get("size", 0)))
97
+ entry_price = _safe_float(raw.get("openPriceAvg", raw.get("openPrice", 0)))
98
+ mark_price = _safe_float(raw.get("markPrice", entry_price))
99
+ unrealized_pnl = _safe_float(raw.get("unrealizedPL", raw.get("unrealisedPnl", 0)))
100
+ margin_used = _safe_float(raw.get("marginSize", raw.get("margin", 0)))
101
+ leverage = _safe_int(raw.get("leverage", 1))
102
+
103
+ # PnL as % of margin_used — avoids division-by-zero
104
+ pnl_pct = (unrealized_pnl / margin_used * 100) if margin_used > 0 else 0.0
105
+
106
+ # Bitget may supply marginRatio directly; otherwise estimate from keepMarginRate
107
+ margin_ratio = _safe_float(
108
+ raw.get("marginRatio", raw.get("keepMarginRate", 0.0))
109
+ )
110
+
111
+ rating, message = self._rate(pnl_pct, margin_ratio)
112
+
113
+ return PositionSnapshot(
114
+ symbol=symbol,
115
+ side=side,
116
+ size=size,
117
+ entry_price=entry_price,
118
+ mark_price=mark_price,
119
+ unrealized_pnl=unrealized_pnl,
120
+ unrealized_pnl_pct=round(pnl_pct, 4),
121
+ margin_used=margin_used,
122
+ leverage=leverage,
123
+ safety_rating=rating,
124
+ safety_message=message,
125
+ timestamp=datetime.now(timezone.utc).isoformat(),
126
+ )
127
+
128
+ # ── Public API ───────────────────────────────────────────────────────────
129
+
130
+ def get_positions(self, product_type: str = "USDT-FUTURES") -> list[PositionSnapshot]:
131
+ """Fetch all open futures positions and return rated snapshots."""
132
+ raw_list = self._client.get_positions(product_type=product_type)
133
+ snapshots = [self._parse(r) for r in raw_list]
134
+
135
+ if self._audit and snapshots:
136
+ for snap in snapshots:
137
+ self._audit.log_risk_check(
138
+ symbol=snap.symbol,
139
+ layer_name="position_monitor",
140
+ passed=snap.safety_rating != "RED",
141
+ reason=snap.safety_message,
142
+ value_checked=snap.unrealized_pnl_pct,
143
+ threshold=-7.0,
144
+ )
145
+
146
+ return snapshots
147
+
148
+ def get_account_summary(self, product_type: str = "USDT-FUTURES") -> dict:
149
+ """Return aggregate safety summary across all open positions."""
150
+ positions = self.get_positions(product_type=product_type)
151
+
152
+ counts = {"GREEN": 0, "YELLOW": 0, "RED": 0}
153
+ total_pnl = 0.0
154
+ for p in positions:
155
+ counts[p.safety_rating] += 1
156
+ total_pnl += p.unrealized_pnl
157
+
158
+ # Worst-of-all: RED > YELLOW > GREEN
159
+ if counts["RED"] > 0:
160
+ overall = "RED"
161
+ elif counts["YELLOW"] > 0:
162
+ overall = "YELLOW"
163
+ else:
164
+ overall = "GREEN"
165
+
166
+ return {
167
+ "total_positions": len(positions),
168
+ "green_count": counts["GREEN"],
169
+ "yellow_count": counts["YELLOW"],
170
+ "red_count": counts["RED"],
171
+ "overall_safety": overall,
172
+ "total_unrealized_pnl": round(total_pnl, 4),
173
+ }
174
+
175
+ def get_safe_to_trade(self, symbol: str, direction: str) -> dict:
176
+ """
177
+ Check whether opening a new position on *symbol* is safe.
178
+
179
+ Returns:
180
+ safe: bool
181
+ reason: str
182
+ current_exposure: float (total margin in open positions for symbol)
183
+ """
184
+ positions = self.get_positions()
185
+
186
+ symbol_positions = [p for p in positions if p.symbol == symbol]
187
+ current_exposure = sum(p.margin_used for p in symbol_positions)
188
+
189
+ # Block if there is already a RED-rated position on the same symbol
190
+ red_positions = [p for p in symbol_positions if p.safety_rating == "RED"]
191
+ if red_positions:
192
+ return {
193
+ "safe": False,
194
+ "reason": (
195
+ f"{symbol} already has a RED-rated position — "
196
+ "resolve it before adding more exposure"
197
+ ),
198
+ "current_exposure": current_exposure,
199
+ }
200
+
201
+ # Block if opening in the same direction as an existing position with negative PnL
202
+ same_side = [
203
+ p for p in symbol_positions
204
+ if p.side == direction and p.unrealized_pnl_pct < -3.0
205
+ ]
206
+ if same_side:
207
+ return {
208
+ "safe": False,
209
+ "reason": (
210
+ f"Existing {direction} position on {symbol} is down "
211
+ f"{same_side[0].unrealized_pnl_pct:.2f}% — averaging down blocked"
212
+ ),
213
+ "current_exposure": current_exposure,
214
+ }
215
+
216
+ return {
217
+ "safe": True,
218
+ "reason": (
219
+ f"No blocking positions on {symbol}. "
220
+ f"Current {symbol} exposure: ${current_exposure:.2f}"
221
+ ),
222
+ "current_exposure": current_exposure,
223
+ }