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/bgc_client.py
ADDED
|
@@ -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()
|
bitsentry/mcp/server.py
ADDED
|
@@ -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
|
+
}
|