ocean-agent 0.3.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,133 @@
1
+ import os
2
+
3
+
4
+ def _is_testnet(base_url: str) -> bool:
5
+ return "test-api" in base_url
6
+
7
+
8
+ def data_tag() -> str:
9
+ """라이브 계좌 데이터의 네임스페이스: 'testnet' vs 'mainnet'.
10
+
11
+ 두 네트워크의 실매매 기록(상태·관측·승률·calibration·postmortem·예측·로그)이
12
+ 절대 섞이지 않도록 파일을 분리한다. 판단 기준은 PACIFICA_BASE_URL 환경변수
13
+ (MCP는 .mcp.json이, 자율 개체는 시작 시 policy의 base_url로 설정)이며,
14
+ 알 수 없으면 안전하게 'testnet'으로 본다.
15
+ MUSTACHE_DATA_TAG 로 강제 지정하면 그것이 최우선(멀티 인스턴스 격리용)."""
16
+ override = os.environ.get("MUSTACHE_DATA_TAG")
17
+ if override:
18
+ return override
19
+ base = os.environ.get("PACIFICA_BASE_URL", "")
20
+ return "mainnet" if (base and "test-api" not in base) else "testnet"
21
+
22
+
23
+ def data_file(name: str) -> str:
24
+ """네트워크별 라이브 데이터 파일의 홈 경로를 돌려준다.
25
+
26
+ 예) data_file("autonomous.json") -> ~/.ocean_agent_testnet_autonomous.json
27
+ 시장 백테스트 매트릭스는 네트워크와 무관(가격 히스토리)하므로 여기 쓰지 않고
28
+ ~/.ocean_agent_matrix.json 하나로 공유한다."""
29
+ return os.path.join(os.path.expanduser("~"),
30
+ f".ocean_agent_{data_tag()}_{name}")
31
+
32
+
33
+ # Mainnet-only brand name. Testnet keeps the original generic label so the
34
+ # testnet experience is byte-for-byte unchanged. This is DISPLAY ONLY (Telegram
35
+ # alerts, logs, MCP handshake) — it never touches data-file names, state keys,
36
+ # the PyPI package id, or the .mcp.json tool namespace, so it can't collide with
37
+ # the storage/logic layer.
38
+ _BRAND_MAINNET = "Ocean Agent"
39
+ _BRAND_TESTNET = "자율 개체"
40
+
41
+
42
+ def agent_name(default: str = _BRAND_TESTNET) -> str:
43
+ """User-facing display name. 'Ocean Agent' on mainnet, otherwise `default`.
44
+ Driven by the same PACIFICA_BASE_URL as data_tag(), so name and data
45
+ namespace always agree (mainnet name ↔ mainnet files)."""
46
+ return _BRAND_MAINNET if data_tag() == "mainnet" else default
47
+
48
+
49
+ # 네트워크 분리 이전(구버전)의 라이브 파일 이름들. 매트릭스는 공유라 제외.
50
+ _LIVE_FILES = ["autonomous.json", "equity.jsonl", "bot.log", "calibration.json",
51
+ "observations.jsonl", "signal_stats.json", "observe_rotation.txt",
52
+ "postmortem.jsonl", "bot_state.json", "predictions.json"]
53
+
54
+
55
+ def migrate_brand_rename() -> list:
56
+ """옛 이름(~/.mustache_*)의 데이터를 새 이름(~/.ocean_agent_*)으로 1회 옮긴다.
57
+
58
+ 0.3.0 에서 배포 이름이 mustache-mcp → ocean-agent 로 바뀌면서 홈 데이터
59
+ 파일 접두어도 함께 바뀌었다. 이사가 없으면 기존 사용자는 쌓아온 매트릭스·
60
+ 관측·손익 기록을 통째로 잃고 빈 상태로 다시 시작하게 된다.
61
+
62
+ 안전 규칙 두 가지:
63
+ · 새 이름이 이미 있으면 건드리지 않는다 (멱등 — 여러 번 불러도 안전)
64
+ · 복사가 아니라 이름 변경이라 디스크를 두 배로 쓰지 않는다
65
+ (bincache 는 378MB 라 복사하면 느리고, 어차피 재다운로드 가능하다)
66
+ """
67
+ home = os.path.expanduser("~")
68
+ moved = []
69
+ try:
70
+ entries = os.listdir(home)
71
+ except OSError:
72
+ return moved
73
+ for entry in entries:
74
+ if not entry.startswith(".mustache_"):
75
+ continue
76
+ old = os.path.join(home, entry)
77
+ new = os.path.join(home, ".ocean_agent_" + entry[len(".mustache_"):])
78
+ if os.path.exists(new):
79
+ continue # 이미 옮겨졌거나 새로 만들어진 것
80
+ try:
81
+ os.rename(old, new) # 파일·디렉터리 모두 처리
82
+ moved.append(entry)
83
+ except OSError:
84
+ pass # 사용 중이면 다음 실행에 다시 시도
85
+ return moved
86
+
87
+
88
+ def migrate_legacy_data() -> list:
89
+ """구버전 untagged 라이브 파일(~/.ocean_agent_X)을 testnet 태그본으로 1회 이전한다.
90
+
91
+ 지금까지 봇은 테스트넷에서만 돌았으므로 기존 데이터는 전부 테스트넷 것이다 →
92
+ 항상 'testnet' 태그로만 옮긴다(현재 네트워크가 메인넷이어도 옛 테스트넷 데이터를
93
+ 메인넷에 섞지 않기 위해). 태그본이 이미 있으면 건드리지 않아 멱등하며,
94
+ .bak/.polluted 등 접미사 백업은 정확한 이름이 아니라 옮기지 않는다."""
95
+ home = os.path.expanduser("~")
96
+ moved = []
97
+ for name in _LIVE_FILES:
98
+ legacy = os.path.join(home, f".ocean_agent_{name}")
99
+ tagged = os.path.join(home, f".ocean_agent_testnet_{name}")
100
+ if os.path.exists(legacy) and not os.path.exists(tagged):
101
+ try:
102
+ os.replace(legacy, tagged)
103
+ moved.append(name)
104
+ except OSError:
105
+ pass
106
+ return moved
107
+
108
+
109
+ def api_key_from_env(base_url: str = "") -> str:
110
+ """네트워크에 맞는 API 키를 고른다.
111
+
112
+ 에이전트 키는 메인넷·테스트넷에서 각각 따로 승인되므로 키가 다를 수 있다.
113
+ 테스트넷 대상이고 PACIFICA_API_KEY_TESTNET 이 있으면 그것을, 아니면
114
+ PACIFICA_API_KEY (구 AGENT_PRIVATE_KEY 호환)를 사용한다.
115
+ """
116
+ if _is_testnet(base_url):
117
+ tn = os.environ.get("PACIFICA_API_KEY_TESTNET")
118
+ if tn:
119
+ return tn
120
+ return os.environ.get("PACIFICA_API_KEY") or os.environ.get("AGENT_PRIVATE_KEY", "")
121
+
122
+
123
+ def address_from_env(base_url: str = "") -> str:
124
+ """네트워크에 맞는 계정 주소를 고른다.
125
+
126
+ 테스트넷은 별도 지갑을 쓸 수 있으므로 ADDRESS_TESTNET 을 우선 사용한다.
127
+ 없으면 ADDRESS 로 폴백 (양쪽 같은 지갑을 쓰는 경우).
128
+ """
129
+ if _is_testnet(base_url):
130
+ tn = os.environ.get("ADDRESS_TESTNET")
131
+ if tn:
132
+ return tn
133
+ return os.environ.get("ADDRESS", "")
ocean_agent/advisor.py ADDED
@@ -0,0 +1,210 @@
1
+ """계정 상태를 보고 운용 설정을 추천한다 — 레버리지·마진모드·포지션 수.
2
+
3
+ 읽기 전용이다. 설정을 바꾸지 않고, 왜 그 값인지 근거와 함께 제안만 한다.
4
+ 숫자는 전부 계정 잔고·거래소 스펙·측정된 승률에서 나오며, 관습이나 감이 아니다.
5
+
6
+ 설계 원칙 세 가지 (이 파일의 모든 계산이 여기서 나온다):
7
+
8
+ 1. 포지션 크기는 레버리지가 아니라 '손절 거리'가 정한다.
9
+ 명목가 = 자본 × 거래당위험% ÷ 손절폭. 레버리지는 약분되어 사라진다.
10
+ → "레버리지를 올리면 더 번다"는 틀렸다. 묶이는 증거금만 줄어든다.
11
+
12
+ 2. 그래서 레버리지는 '원하는 명목가를 담을 수 있는 최소값'이 최적이다.
13
+ 더 올리면 청산선만 가까워질 뿐 수익은 그대로다. 레버리지는 이득이 아니라
14
+ 비용이다 — 필요한 만큼만 쓴다.
15
+
16
+ 3. 진짜 상한은 '한 번에 감수할 총위험'이다.
17
+ 포지션 수 × 거래당위험. 신호들이 같은 방향이면 분산 효과가 없으므로
18
+ 보수적으로 잡아야 한다.
19
+ """
20
+
21
+ MMR_DIV = 2 # MMR = 1 / 시장최대레버리지 / 2 (독스 기준)
22
+ TAKER_FEE = 0.0004 # 30일 거래량 $5M 미만 티어
23
+ MAKER_FEE = 0.00015
24
+
25
+
26
+ def _market_specs(client) -> dict:
27
+ """심볼 -> {max_leverage, mmr}. 거래소가 주는 값만 쓴다."""
28
+ out = {}
29
+ try:
30
+ for m in client.get_markets():
31
+ ml = int(m.get("max_leverage") or 0)
32
+ if ml > 0:
33
+ out[m["symbol"]] = {"max_leverage": ml,
34
+ "mmr": 1.0 / ml / MMR_DIV}
35
+ except Exception:
36
+ pass
37
+ return out
38
+
39
+
40
+ def _tradable(client, policy) -> list:
41
+ """정책이 실제로 진입을 허용하는 심볼들."""
42
+ if policy.get("entry_deep_history_only", False):
43
+ try:
44
+ from .historical_data import BINANCE_SYMBOL
45
+ return sorted(BINANCE_SYMBOL)
46
+ except Exception:
47
+ pass
48
+ try:
49
+ return sorted(m["symbol"] for m in client.get_markets())
50
+ except Exception:
51
+ return []
52
+
53
+
54
+ def _measured_edges(policy) -> list:
55
+ """정책 관문을 통과하는 (신호, 시간봉, 실측승률, 표본). 없으면 빈 리스트."""
56
+ try:
57
+ from .walkforward import _load_curve
58
+ from .signal_scanner import _matrix_rejects, MIN_EDGE
59
+ c = _load_curve()
60
+ if not c or not c.get("sig_tf"):
61
+ return []
62
+ mw = float(policy.get("min_win_rate", 0.52))
63
+ rows = []
64
+ for k, (wr, n) in c["sig_tf"].items():
65
+ sig, tf = k.split("|", 1)
66
+ if _matrix_rejects(sig, tf) or wr < mw or wr - 0.5 < MIN_EDGE:
67
+ continue
68
+ rows.append((wr, n, sig, tf))
69
+ rows.sort(reverse=True)
70
+ return rows
71
+ except Exception:
72
+ return []
73
+
74
+
75
+ def recommend(client, policy: dict, equity_usd: float = 0.0) -> dict:
76
+ """계정을 보고 설정을 제안한다. 반환은 dict (표시는 format_recommendation)."""
77
+ if equity_usd <= 0:
78
+ try:
79
+ equity_usd = float(client.get_account().get("account_equity") or 0)
80
+ except Exception:
81
+ equity_usd = 0.0
82
+ specs = _market_specs(client)
83
+ syms = [s for s in _tradable(client, policy) if s in specs]
84
+ edges = _measured_edges(policy)
85
+
86
+ # --- 거래당 위험 ---
87
+ # 자본이 작을수록 최소 주문 단위 때문에 미세한 사이징이 불가능해진다.
88
+ # $300 전후에서 1%는 $3짜리 위험이라 손절폭이 조금만 좁아도 주문이 최소
89
+ # 단위에 걸린다. 그래서 소액에서는 2%가 현실적인 하한이다.
90
+ risk_pct = 0.02 if equity_usd < 2000 else 0.01
91
+
92
+ # --- 동시 보유 수 ---
93
+ # 총위험 = 개수 × 거래당위험. 6%를 넘기지 않는다.
94
+ # 측정된 신호가 전부 같은 방향(과매도 반등 롱)이면 분산 효과가 없어
95
+ # '동시에 다 맞을 수 있다'고 보고 잡아야 한다.
96
+ same_side = False
97
+ if edges:
98
+ try:
99
+ from .signal_scanner import _signal_side
100
+ sides = {_signal_side(s) for _wr, _n, s, _tf in edges[:8]}
101
+ sides.discard(None)
102
+ same_side = len(sides) <= 1
103
+ except Exception:
104
+ pass
105
+ max_total_risk = 0.06 if not same_side else 0.05
106
+ n_pos = max(1, int(max_total_risk / risk_pct))
107
+
108
+ # --- 명목가 상한 ---
109
+ # 손절폭이 좁을수록 같은 위험에 더 큰 명목가가 필요하다. 실측 손절폭
110
+ # 중앙값을 1.5%로 보고, 포지션 수만큼 곱해 필요한 총 명목가를 구한다.
111
+ typical_sl = 0.015
112
+ notional_per = risk_pct / typical_sl # 자본 대비 배수
113
+ notional_total = notional_per * n_pos # 자본 대비 배수
114
+
115
+ # --- 레버리지: 그 명목가를 담는 최소값 (+ 여유 20%) ---
116
+ # 증거금 = 명목가 / 레버리지 ≤ 자본 × 0.8 (여유 20% 남김)
117
+ need_lev = notional_total / 0.8
118
+ market_min = min((specs[s]["max_leverage"] for s in syms), default=10)
119
+ lev = max(2, min(int(need_lev + 0.999), market_min))
120
+
121
+ # --- 청산 여유 확인 ---
122
+ worst = None
123
+ for s in syms:
124
+ mmr = specs[s]["mmr"]
125
+ dist = (1.0 / lev) - mmr
126
+ if worst is None or dist < worst[1]:
127
+ worst = (s, dist)
128
+ liq_dist = worst[1] if worst else 0.0
129
+ # 손절(1~3%)보다 청산이 최소 5배는 멀어야 갭에 여유가 생긴다
130
+ while lev > 2 and liq_dist < 0.03 * 5:
131
+ lev -= 1
132
+ liq_dist = (1.0 / lev) - (worst[1] and specs[worst[0]]["mmr"] or 0)
133
+
134
+ # --- 마진 모드 ---
135
+ # 격리: 포지션마다 격벽. 코드 사고가 계좌 전체로 번지지 않는다.
136
+ # 크로스: 미실현이익이 다른 포지션을 받쳐 증거금 효율이 높다.
137
+ # 증거금이 자본의 절반도 안 쓰이면 크로스의 효율 이점이 없다 → 격리.
138
+ margin_used = notional_total / lev
139
+ isolated = margin_used < 0.7
140
+ margin_reason = ("증거금이 자본의 %.0f%%만 쓰여 크로스의 효율 이점이 없다"
141
+ % (margin_used * 100)) if isolated else \
142
+ ("증거금이 자본의 %.0f%%라 크로스의 상계가 도움이 된다"
143
+ % (margin_used * 100))
144
+
145
+ return {
146
+ "equity": equity_usd,
147
+ "risk_pct": risk_pct,
148
+ "n_positions": n_pos,
149
+ "leverage": lev,
150
+ "isolated": isolated,
151
+ "margin_reason": margin_reason,
152
+ "notional_total_x": notional_total,
153
+ "notional_per_x": notional_per,
154
+ "margin_used_x": margin_used,
155
+ "liq_distance": liq_dist,
156
+ "total_risk": n_pos * risk_pct,
157
+ "same_side": same_side,
158
+ "edges": edges[:6],
159
+ "tradable": len(syms),
160
+ "market_min_leverage": market_min,
161
+ "current": {
162
+ "leverage": policy.get("max_leverage"),
163
+ "concurrent": policy.get("max_concurrent"),
164
+ "risk_pct": policy.get("risk_per_trade_pct"),
165
+ "notional_x": policy.get("max_net_exposure_pct"),
166
+ },
167
+ }
168
+
169
+
170
+ def format_recommendation(r: dict) -> str:
171
+ eq = r["equity"]
172
+ L = []
173
+ L.append("계정 잔고 $%,.2f 기준 권장 설정".replace(",.2f", ".2f") % eq)
174
+ L.append("")
175
+ L.append(" 항목 권장 현재 근거")
176
+ cur = r["current"]
177
+ L.append(" 거래당 위험 %.0f%% %s %s"
178
+ % (r["risk_pct"] * 100,
179
+ ("%.0f%%" % (float(cur['risk_pct']) * 100)) if cur['risk_pct'] else '?',
180
+ "소액은 최소주문 단위 때문에 1%로 못 쪼갬" if eq < 2000
181
+ else "자본이 커서 1%로 충분히 쪼개짐"))
182
+ L.append(" 동시 보유 %d개 %s개 총위험 %.0f%%로 제한%s"
183
+ % (r["n_positions"], cur["concurrent"], r["total_risk"] * 100,
184
+ " (신호가 전부 같은 방향이라 더 보수적)" if r["same_side"] else ""))
185
+ L.append(" 레버리지 %d배 %s배 명목가 %.1f배를 담는 최소값"
186
+ % (r["leverage"], cur["leverage"], r["notional_total_x"]))
187
+ L.append(" 마진 모드 %s - %s"
188
+ % ("격리" if r["isolated"] else "크로스", r["margin_reason"]))
189
+ L.append(" 명목가 상한 자본의 %.1f배 자본의 %s배 포지션 %d개 × 손절 1.5%% 기준"
190
+ % (r["notional_total_x"], cur["notional_x"], r["n_positions"]))
191
+ L.append("")
192
+ L.append(" 증거금 사용 자본의 %.0f%% ($%.0f) — 나머지는 여유"
193
+ % (r["margin_used_x"] * 100, eq * r["margin_used_x"]))
194
+ L.append(" 청산까지 %.1f%% (손절 1~3%%의 %.0f배 거리)"
195
+ % (r["liq_distance"] * 100, r["liq_distance"] / 0.02))
196
+ L.append(" 1회 손절 -$%.2f · %d개 동시 손절 -$%.2f (자본의 %.0f%%)"
197
+ % (eq * r["risk_pct"], r["n_positions"],
198
+ eq * r["total_risk"], r["total_risk"] * 100))
199
+ L.append("")
200
+ if r["edges"]:
201
+ L.append(" 근거가 된 실측 신호 (워크포워드 9년):")
202
+ for wr, n, sig, tf in r["edges"]:
203
+ L.append(" %-20s %-4s %.1f%% 표본 %s" % (sig, tf, wr * 100, f"{n:,}"))
204
+ else:
205
+ L.append(" ⚠️ 측정된 신호가 없습니다 — walkforward 를 먼저 돌리세요.")
206
+ L.append("")
207
+ L.append(" ※ 레버리지는 수익을 키우지 않습니다. 포지션 크기는 손절 거리가")
208
+ L.append(" 정하고, 레버리지는 묶이는 증거금만 바꿉니다. 그래서 '필요한")
209
+ L.append(" 최소값'이 최적이며, 더 올리면 청산선만 가까워집니다.")
210
+ return "\n".join(L)
@@ -0,0 +1,327 @@
1
+ """Pacifica REST API 클라이언트.
2
+
3
+ - 공개 데이터: 서명 없이 GET
4
+ - 주문/계정 변경: 에이전트 키(또는 메인 키)로 서명해 POST
5
+ 에이전트 키의 공개키가 ADDRESS와 다르면 agent_wallet 필드를 자동으로 붙인다.
6
+ """
7
+
8
+ import uuid
9
+
10
+ import requests
11
+
12
+ from .signing import keypair_from_base58, sign_operation
13
+
14
+
15
+ class PacificaError(Exception):
16
+ pass
17
+
18
+
19
+ class PacificaClient:
20
+ def __init__(self, base_url: str, address: str = "", private_key: str = ""):
21
+ self.base = base_url.rstrip("/") + "/api/v1"
22
+ self.address = address
23
+ self.keypair = keypair_from_base58(private_key) if private_key else None
24
+ self.session = requests.Session()
25
+
26
+ # ---------- 공개 GET ----------
27
+
28
+ @staticmethod
29
+ def _parse_json(r, ctx: str) -> dict:
30
+ """레이트리밋(429) 등 비JSON 응답을 명확한 오류로 변환."""
31
+ try:
32
+ return r.json()
33
+ except ValueError:
34
+ raise PacificaError(
35
+ f"{ctx} 비정상 응답 (HTTP {r.status_code}) — 레이트리밋 가능성. "
36
+ f"본문: {r.text[:80]!r}")
37
+
38
+ # 429 재시도 대기(초). 무거운 측정(rematrix·harness)이 같은 API 를 두드리면
39
+ # 10초 한 번으로는 못 넘긴다 — 2026-08-06 에 사이클 13개가 이걸로 날아갔다.
40
+ _RETRY_BACKOFF = (5, 20, 45)
41
+
42
+ def _get(self, path: str, params: dict | None = None) -> dict:
43
+ import time as _t
44
+ for attempt in range(len(self._RETRY_BACKOFF) + 1):
45
+ r = self.session.get(f"{self.base}/{path}", params=params, timeout=15)
46
+ if r.status_code == 429 and attempt < len(self._RETRY_BACKOFF):
47
+ _t.sleep(self._RETRY_BACKOFF[attempt])
48
+ continue
49
+ body = self._parse_json(r, f"GET /{path}")
50
+ if not body.get("success", False):
51
+ raise PacificaError(f"GET /{path} 실패: {body.get('error')} (code={body.get('code')})")
52
+ return body["data"]
53
+ raise PacificaError(f"GET /{path} 레이트리밋 지속 (429)")
54
+
55
+ def get_markets(self) -> list[dict]:
56
+ """전 마켓 스펙 (perp + spot). funding_rate, lot_size, instrument_type 포함."""
57
+ return self._get("info")
58
+
59
+ def get_prices(self) -> list[dict]:
60
+ """전 마켓 실시간 가격/펀딩비 (funding, next_funding, mid, mark 등)."""
61
+ return self._get("info/prices")
62
+
63
+ def get_account(self) -> dict:
64
+ return self._get("account", {"account": self.address})
65
+
66
+ def get_positions(self) -> list[dict]:
67
+ return self._get("positions", {"account": self.address})
68
+
69
+ # ---------- 서명 POST ----------
70
+
71
+ def _signed_post(self, path: str, op_type: str, payload: dict) -> dict:
72
+ if not self.keypair:
73
+ raise PacificaError("API 키가 없어 주문을 보낼 수 없습니다 "
74
+ "(.env의 PACIFICA_API_KEY에 app.pacifica.fi/apikey 발급 키 입력)")
75
+ import time as _t
76
+ signer_pubkey = str(self.keypair.pubkey())
77
+ for attempt in (1, 2):
78
+ # 재시도 시 서명도 새로 (서명에 타임스탬프가 들어가므로 재사용 불가)
79
+ signed = sign_operation(op_type, payload, self.keypair)
80
+ request = {"account": self.address, **signed, **payload}
81
+ if signer_pubkey != self.address:
82
+ request["agent_wallet"] = signer_pubkey
83
+ r = self.session.post(f"{self.base}/{path}", json=request, timeout=15)
84
+ if r.status_code == 429 and attempt == 1:
85
+ _t.sleep(10) # 레이트리밋 — 한 번만 쉬고 재서명·재시도
86
+ continue
87
+ body = self._parse_json(r, f"POST /{path}")
88
+ # success 키가 없으면 실패로 간주(안전). 200인데 스펙 밖 응답이
89
+ # 오면 '주문 성공'으로 오독하지 않는다 — 돈이 걸린 경로라 보수적으로.
90
+ if r.status_code != 200 or not body.get("success", False):
91
+ raise PacificaError(f"POST /{path} 실패 ({r.status_code}): {r.text}")
92
+ return body.get("data", body)
93
+ raise PacificaError(f"POST /{path} 레이트리밋 지속 (429)")
94
+
95
+ # 하드 사이징 가드 — 한 주문의 명목가 절대 상한(USD). 봇 정책($3,000)보다
96
+ # 넉넉히 높게 두되, '폭주'는 확실히 막는 최후 방어선. 봇·스크립트·웹 등
97
+ # 어느 경로로 온 주문이든 이 함수를 통과하므로 여기서 한 번에 막는다.
98
+ # reduce_only(청산)는 예외 — 큰 포지션 정리를 막으면 안 됨.
99
+ MAX_ORDER_NOTIONAL_USD = 20_000
100
+
101
+ def create_market_order(self, symbol: str, side: str, amount: str,
102
+ slippage_percent: str, reduce_only: bool = False,
103
+ builder_code: str = "",
104
+ take_profit_price: str = "",
105
+ stop_loss_price: str = "",
106
+ trigger_price_type: str = "mark_price") -> dict:
107
+ """시장가 주문. side는 'bid'(매수) 또는 'ask'(매도).
108
+
109
+ take_profit_price / stop_loss_price 를 넘기면 거래소에 TP/SL을 함께
110
+ 등록한다 — 봇/PC가 꺼져도 거래소가 해당 가격에서 포지션을 청산한다.
111
+
112
+ builder_code가 계정에서 미승인 상태라 거부되면, 사용자를 막지 않도록
113
+ 코드 없이 1회 재시도한다 (승인된 계정에서만 수수료가 붙는 구조).
114
+
115
+ 하드 가드: 신규 주문의 명목가가 MAX_ORDER_NOTIONAL_USD를 넘으면 거부.
116
+ (2026-07-24 BTC $355k 폭주 재발 방지 — 원인 불문 최후 차단)
117
+ """
118
+ # ── 폭주 방어: 신규 주문 명목가 상한 검사 (청산은 제외) ──
119
+ if not reduce_only:
120
+ try:
121
+ amt_f = abs(float(amount))
122
+ px = None
123
+ for p in self.get_prices():
124
+ if p.get("symbol") == symbol:
125
+ px = float(p.get("mark") or p.get("mid") or 0)
126
+ break
127
+ if px and amt_f * px > self.MAX_ORDER_NOTIONAL_USD:
128
+ raise PacificaError(
129
+ f"🛑 주문 거부(하드 가드): {symbol} {amt_f} × ${px:,.0f} = "
130
+ f"${amt_f*px:,.0f} > 상한 ${self.MAX_ORDER_NOTIONAL_USD:,.0f}. "
131
+ f"폭주 방어. 의도된 대형 주문이면 MAX_ORDER_NOTIONAL_USD 조정.")
132
+ except PacificaError:
133
+ raise
134
+ except Exception:
135
+ pass # 가격 조회 실패 시 가드 건너뜀(주문 자체는 진행, 별도 안전망 있음)
136
+ payload = {
137
+ "symbol": symbol,
138
+ "side": side,
139
+ "amount": str(amount),
140
+ "slippage_percent": str(slippage_percent),
141
+ "reduce_only": reduce_only,
142
+ "client_order_id": str(uuid.uuid4()),
143
+ }
144
+ if builder_code:
145
+ payload["builder_code"] = builder_code
146
+ if take_profit_price:
147
+ payload["take_profit"] = {"stop_price": str(take_profit_price),
148
+ "trigger_price_type": trigger_price_type}
149
+ if stop_loss_price:
150
+ payload["stop_loss"] = {"stop_price": str(stop_loss_price),
151
+ "trigger_price_type": trigger_price_type}
152
+ try:
153
+ return self._signed_post("orders/create_market",
154
+ "create_market_order", payload)
155
+ except PacificaError as e:
156
+ msg = str(e).lower()
157
+ if builder_code and ("builder" in msg or "fee_rate" in msg
158
+ or "max_fee" in msg):
159
+ payload.pop("builder_code", None)
160
+ payload["client_order_id"] = str(uuid.uuid4())
161
+ return self._signed_post("orders/create_market",
162
+ "create_market_order", payload)
163
+ raise
164
+
165
+ def _sign_action_data(self, op_type: str, payload: dict) -> dict:
166
+ """배치 액션 하나의 서명된 data 블록을 만든다 (개별 서명)."""
167
+ signed = sign_operation(op_type, payload, self.keypair)
168
+ data = {"account": self.address, **signed, **payload}
169
+ signer = str(self.keypair.pubkey())
170
+ if signer != self.address:
171
+ data["agent_wallet"] = signer
172
+ return data
173
+
174
+ def batch_market_orders(self, orders: list[dict],
175
+ builder_code: str = "") -> dict:
176
+ """여러 시장가 주문을 원자적 배치(Atomics)로 전송 — 한 번에, 순서대로,
177
+ 남의 주문에 끼이지 않고 실행. OI 헤지 양다리나 바스켓 진입에 최적.
178
+
179
+ orders: [{"symbol","side","amount","slippage_percent"?,"reduce_only"?}, ...]
180
+ 최대 10개. 개별 서명되어 actions 배열로 묶인다.
181
+ """
182
+ if not self.keypair:
183
+ raise PacificaError("API 키가 없어 주문을 보낼 수 없습니다")
184
+ if not 1 <= len(orders) <= 10:
185
+ raise PacificaError("배치는 1~10개 주문만 가능합니다")
186
+ # 하드 가드 — 배치 내 각 신규 주문도 명목가 상한 검사 (청산 제외)
187
+ try:
188
+ px_map = {p.get("symbol"): float(p.get("mark") or p.get("mid") or 0)
189
+ for p in self.get_prices()}
190
+ for o in orders:
191
+ if o.get("reduce_only"):
192
+ continue
193
+ px = px_map.get(o["symbol"], 0)
194
+ notl = abs(float(o["amount"])) * px
195
+ if px and notl > self.MAX_ORDER_NOTIONAL_USD:
196
+ raise PacificaError(
197
+ f"🛑 배치 주문 거부(하드 가드): {o['symbol']} "
198
+ f"${notl:,.0f} > 상한 ${self.MAX_ORDER_NOTIONAL_USD:,.0f}")
199
+ except PacificaError:
200
+ raise
201
+ except Exception:
202
+ pass
203
+ import time as _t
204
+ for attempt in (1, 2):
205
+ # 재시도 시 서명·주문ID 새로 (서명에 타임스탬프 포함)
206
+ actions = []
207
+ for o in orders:
208
+ payload = {
209
+ "symbol": o["symbol"],
210
+ "side": o["side"],
211
+ "amount": str(o["amount"]),
212
+ "slippage_percent": str(o.get("slippage_percent", "0.5")),
213
+ "reduce_only": bool(o.get("reduce_only", False)),
214
+ "client_order_id": str(uuid.uuid4()),
215
+ }
216
+ if builder_code:
217
+ payload["builder_code"] = builder_code
218
+ actions.append({"type": "CreateMarket",
219
+ "data": self._sign_action_data(
220
+ "create_market_order", payload)})
221
+ r = self.session.post(f"{self.base}/orders/batch",
222
+ json={"actions": actions}, timeout=20)
223
+ if r.status_code == 429 and attempt == 1:
224
+ _t.sleep(10) # 레이트리밋 — 한 번만 쉬고 재서명·재시도
225
+ continue
226
+ body = self._parse_json(r, "POST /orders/batch")
227
+ # success 없으면 실패로 간주(안전) — 배치도 돈이 걸린 경로.
228
+ if r.status_code != 200 or not body.get("success", False):
229
+ # 빌더 코드 미승인이면 코드 없이 재시도
230
+ if builder_code and "builder" in r.text.lower():
231
+ return self.batch_market_orders(orders, builder_code="")
232
+ raise PacificaError(f"배치 주문 실패 ({r.status_code}): {r.text}")
233
+ return body.get("data", body)
234
+ raise PacificaError("POST /orders/batch 레이트리밋 지속 (429)")
235
+
236
+ def set_position_tpsl(self, symbol: str, side: str,
237
+ take_profit_price: str = "",
238
+ stop_loss_price: str = "",
239
+ trigger_price_type: str = "mark_price") -> dict:
240
+ """이미 열려 있는 포지션에 TP/SL을 사후 부착한다.
241
+
242
+ side는 '포지션의 방향'(bid=롱, ask=숏)을 넘긴다. 파시피카 TP/SL 주문은
243
+ 포지션을 청산하는 방향(반대 side)을 요구하므로, 여기서 반대로 변환한다.
244
+ (호출자가 포지션 side를 넘겨도 안전하도록 함수가 책임진다. 포지션 ask →
245
+ 스탑주문 bid. 이걸 안 뒤집으면 "Invalid stop order side" 422.)
246
+ TP/SL 중 최소 하나는 필요. 거래소에 등록되어 봇/PC 꺼져도 작동한다.
247
+ """
248
+ if not take_profit_price and not stop_loss_price:
249
+ raise PacificaError("TP/SL 중 최소 하나는 지정해야 합니다")
250
+ close_side = "ask" if side == "bid" else "bid"
251
+ payload = {"symbol": symbol, "side": close_side}
252
+ if take_profit_price:
253
+ payload["take_profit"] = {"stop_price": str(take_profit_price),
254
+ "trigger_price_type": trigger_price_type,
255
+ "client_order_id": str(uuid.uuid4())}
256
+ if stop_loss_price:
257
+ payload["stop_loss"] = {"stop_price": str(stop_loss_price),
258
+ "trigger_price_type": trigger_price_type,
259
+ "client_order_id": str(uuid.uuid4())}
260
+ return self._signed_post("positions/tpsl", "set_position_tpsl", payload)
261
+
262
+ # ---------- Print (⚠️ 비공개 API — 웹앱과 동일 경로, 예고 없이 바뀔 수 있음) ----------
263
+ # Print의 내부 이름은 'game'. 목표가(strike)에 걸어두고 기다리는 동안
264
+ # 프리미엄(이자)을 받는 옵션형 상품. 24시간 체크포인트에 체결 판정.
265
+
266
+ # ---------- 계정 설정 (마켓별) ----------
267
+
268
+ def get_account_settings(self) -> list[dict]:
269
+ """마켓별 레버리지·마진모드 설정. 봇의 정책 상한과 별개로, 거래소 쪽
270
+ 설정이 낮으면 그쪽이 실제 상한이 된다."""
271
+ return self._get("account/settings", {"account": self.address})
272
+
273
+ def update_leverage(self, symbol: str, leverage: int) -> dict:
274
+ """마켓 레버리지 변경 (서명: update_leverage).
275
+ 정책의 max_leverage 를 올려도 여기가 낮으면 주문이 그 값으로 제한된다."""
276
+ return self._signed_post("account/leverage", "update_leverage",
277
+ {"symbol": symbol, "leverage": int(leverage)})
278
+
279
+ def update_margin_mode(self, symbol: str, is_isolated: bool) -> dict:
280
+ """마진 모드 변경 (서명: update_margin_mode).
281
+ 격리는 포지션마다 증거금을 격벽으로 나눠, 한 포지션 사고가 계좌 전체로
282
+ 번지는 것을 막는다. 열린 포지션이 있으면 거부될 수 있다."""
283
+ return self._signed_post("account/margin", "update_margin_mode",
284
+ {"symbol": symbol, "is_isolated": bool(is_isolated)})
285
+
286
+ def print_games(self) -> list[dict]:
287
+ """Print 마켓 목록 (예: BTC_24H, ETH_24H)과 예치 한도."""
288
+ return self._get("game/config").get("configs", [])
289
+
290
+ def print_sim(self, game: str, deposit_usd: str, direction: int,
291
+ strike_price: str, leverage: str) -> dict:
292
+ """Print 주문 시뮬레이션 — 프리미엄(수익), 내재변동성, 청산가 견적.
293
+ direction: 0=롱(아래 목표가에 매수 대기), 1=숏(위 목표가에 매도 대기)."""
294
+ return self._get("game/sim", {
295
+ "game": game, "deposit_amount": str(deposit_usd),
296
+ "direction": int(direction), "strike_price": str(strike_price),
297
+ "leverage": str(leverage)})
298
+
299
+ def print_positions(self) -> dict:
300
+ """내 Print 예치 현황."""
301
+ return self._get("game/accounts", {"account": self.address})
302
+
303
+ def print_open(self, game: str, deposit_usd: str, direction: int,
304
+ strike_price: str, leverage: str) -> dict:
305
+ """Print 주문 (서명: deposit_to_game). 웹 UI와 동일한 요청."""
306
+ return self._signed_post("game/deposit", "deposit_to_game", {
307
+ "game": game, "deposit_amount": str(deposit_usd),
308
+ "direction": int(direction), "strike_price": str(strike_price),
309
+ "leverage": str(leverage)})
310
+
311
+ def print_end(self, game_account: str) -> dict:
312
+ """Print 조기 종료 (서명: end_game).
313
+
314
+ 서버가 요구하는 필드는 마켓 이름(game)이 아니라 예치 계정 주소
315
+ (game_account)다. 예전 코드는 {"game": ...}를 보내 항상 400으로
316
+ 실패했다 — 즉 조기 종료가 한 번도 동작한 적이 없다.
317
+ 주소는 print_positions()의 game_accounts[].address 값이다."""
318
+ return self._signed_post("game/end", "end_game",
319
+ {"game_account": game_account})
320
+
321
+ def print_withdraw(self, game_account: str, amount: str = "") -> dict:
322
+ """Print 예치금 회수 (서명: withdraw_from_game). game_end와 같은 이유로
323
+ 마켓 이름이 아니라 예치 계정 주소를 보낸다."""
324
+ payload = {"game_account": game_account}
325
+ if amount:
326
+ payload["amount"] = str(amount)
327
+ return self._signed_post("game/withdraw", "withdraw_from_game", payload)