diff --git a/pyproject.toml b/pyproject.toml index 79ba580..0cfbd7e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "toss-browser-bridge" -version = "0.3.0" +version = "0.4.0" description = "Unofficial read-only browser bridge for Toss Securities web" readme = "README.md" requires-python = ">=3.12" diff --git a/src/toss_browser_bridge/__init__.py b/src/toss_browser_bridge/__init__.py index 747d46f..db6c2cb 100644 --- a/src/toss_browser_bridge/__init__.py +++ b/src/toss_browser_bridge/__init__.py @@ -1,3 +1,3 @@ __all__ = ["__version__"] -__version__ = "0.3.0" +__version__ = "0.4.0" diff --git a/src/toss_browser_bridge/cli.py b/src/toss_browser_bridge/cli.py index 916b808..9a67c41 100644 --- a/src/toss_browser_bridge/cli.py +++ b/src/toss_browser_bridge/cli.py @@ -130,6 +130,12 @@ def main() -> None: place_order.add_argument("--confirm", action="store_true") place_order.add_argument("--confirm-text", required=True) place_order.add_argument("--auto-verify", action="store_true") + place_order.add_argument( + "--reservation-mode", + choices=["auto", "on", "off"], + default="auto", + help="auto: omit (bridge default False) | on: isReservationOrder=true | off: isReservationOrder=false (explicit)", + ) verify_order = sub.add_parser("verify-order") verify_order.add_argument("--mutation-id", required=True) @@ -184,6 +190,11 @@ def main() -> None: "confirm_text": args.confirm_text, "auto_verify": args.auto_verify, } + if args.reservation_mode == "on": + params["is_reservation_order"] = True + elif args.reservation_mode == "off": + params["is_reservation_order"] = False + # "auto" → key omit → bridge default False elif args.command == "verify-order": params = {"mutation_id": args.mutation_id} payload = invoke(command_map[args.command], params=params) diff --git a/src/toss_browser_bridge/daemon.py b/src/toss_browser_bridge/daemon.py index d4f331a..df0f637 100644 --- a/src/toss_browser_bridge/daemon.py +++ b/src/toss_browser_bridge/daemon.py @@ -411,6 +411,7 @@ def __init__(self) -> None: self._final_submit_enabled, self._final_submit_guard_reason = resolve_final_submit_state() self._verify_poll_attempts = 3 self._verify_poll_delay_seconds = 1.0 + self._capture_listeners: tuple[Any, Any, Any] | None = None def bind_server(self, server: HTTPServer) -> None: self._shutdown_server = server @@ -479,8 +480,300 @@ def execute(self, kind: str, params: dict[str, Any] | None = None) -> dict[str, return self.place_order(params or {}) if kind == "verify_order": return self.verify_order(params or {}) + if kind == "ui_buy_capture": + return self.ui_buy_capture(params or {}) + if kind == "ui_arm_capture": + return self.ui_arm_capture(params or {}) + if kind == "ui_disarm_capture": + return self.ui_disarm_capture() raise ValueError(f"unsupported kind: {kind}") + def ui_buy_capture(self, params: dict[str, Any]) -> dict[str, Any]: + """P6 진단용 임시 헬퍼 — 토스 web UI click 자동화 + 모든 토스 API request/response capture. + + 사용 시나리오: bridge 의 fetch 만으로 broker create 가 PIN 응답 받는 경우, UI click 흐름과 어떤 차이가 있는지 비교 capture. + 진단 끝나면 제거 예정. + + Params: + wait_seconds (default 5): listener 활성 시간. 사용자 click 동안 capture 하려면 30~60. + auto_click (default true): UI click 자동화 수행 여부. false 면 listener 만 등록 + wait. + """ + product_code = str(params.get("product_code") or "US20111020005") + side = str(params.get("side") or "buy").lower() + quantity = int(params.get("quantity") or 1) + limit_price = float(params.get("limit_price") or 0) + wait_seconds = int(params.get("wait_seconds") or 5) + auto_click = bool(params.get("auto_click", True)) + action_label = "구매" if side == "buy" else "판매" + + page = self.ensure_page() + captured_requests: list[dict[str, Any]] = [] + captured_responses: list[dict[str, Any]] = [] + + def on_request(request: Any) -> None: + try: + if "tossinvest.com/api/" in request.url: + captured_requests.append({ + "method": request.method, + "url": request.url, + "post_data": request.post_data, + "headers": dict(request.headers), + "ts": now_kst(), + }) + except Exception: + pass + + def on_response(response: Any) -> None: + try: + if "tossinvest.com/api/" in response.url: + try: + body_text = response.text() + except Exception: + body_text = None + captured_responses.append({ + "url": response.url, + "status": response.status, + "method": response.request.method, + "body": body_text, + "ts": now_kst(), + }) + except Exception: + pass + + page.on("request", on_request) + page.on("response", on_response) + + steps_log: list[str] = [] + error: str | None = None + try: + order_url = f"https://www.tossinvest.com/stocks/{product_code}/order" + if not page.url.startswith(order_url): + page.goto(order_url, wait_until="domcontentloaded") + steps_log.append(f"navigated to {order_url}") + else: + steps_log.append(f"already on {order_url}") + page.wait_for_timeout(2000) + + if auto_click: + try: + page.get_by_role("tab", name="일반주문").first.click(timeout=2000) + steps_log.append("clicked 일반주문 tab") + except Exception as exc: + steps_log.append(f"일반주문 tab skip: {exc}") + try: + page.get_by_role("tab", name=action_label, exact=True).first.click(timeout=2000) + steps_log.append(f"clicked {action_label} tab") + except Exception as exc: + steps_log.append(f"{action_label} tab skip: {exc}") + try: + price_inputs = page.locator('input[type="text"]').all() + if price_inputs: + price_inputs[0].fill(str(limit_price), timeout=2000) + steps_log.append(f"filled price={limit_price}") + if len(price_inputs) > 1: + price_inputs[1].fill(str(quantity), timeout=2000) + steps_log.append(f"filled qty={quantity}") + except Exception as exc: + steps_log.append(f"input fill error: {exc}") + page.wait_for_timeout(500) + try: + cta = page.get_by_role("button").filter(has_text=f"{action_label}하기").first + cta.click(timeout=3000) + steps_log.append(f"clicked {action_label}하기") + except Exception as exc: + steps_log.append(f"{action_label}하기 click error: {exc}") + page.wait_for_timeout(1500) + try: + dialog = page.get_by_role("dialog").first + dialog_btn = dialog.get_by_role("button", name=action_label, exact=True).first + dialog_btn.click(timeout=3000) + steps_log.append(f"clicked dialog {action_label}") + except Exception as exc: + steps_log.append(f"dialog {action_label} click error: {exc}") + else: + steps_log.append(f"auto_click=false — listener only, waiting {wait_seconds}s for user click") + + page.wait_for_timeout(wait_seconds * 1000) + except Exception as exc: + error = str(exc) + finally: + try: + page.remove_listener("request", on_request) + page.remove_listener("response", on_response) + except Exception: + pass + + return { + "ok": error is None, + "kind": "ui_buy_capture", + "source": "toss_browser_bridge", + "checked_at": now_kst(), + "data": { + "steps": steps_log, + "error": error, + "captured_requests": captured_requests, + "captured_responses": captured_responses, + }, + } + + def ui_arm_capture(self, params: dict[str, Any]) -> dict[str, Any]: + """P6 진단용 — UI capture listener 영구 활성. 응답 stream 을 file 에 append. + + Context 레벨 listener — 모든 page (탭/popup 포함) 의 request/response 를 capture. + Page navigate / 새 tab / page close 무관하게 유지. + + - 1회 호출로 listener 등록 + 즉시 return (wait 0) + - file path: `/ui-capture-stream.jsonl` (1줄 = 1 entry, JSON) + - 재호출 시 idempotent (이미 활성이면 already_armed=true) + + Params: + stream_path (optional): override default file path + url_filter (optional, default 'tossinvest.com/api/'): substring filter + """ + from pathlib import Path as _Path + runtime_dir = PROFILE_DIR.parent + stream_path = _Path(params.get("stream_path") or runtime_dir / "ui-capture-stream.jsonl") + stream_path.parent.mkdir(parents=True, exist_ok=True) + url_filter = str(params.get("url_filter") or "tossinvest.com/api/") + + # ensure browser/context 살아있음 + self.ensure_page() + assert self._context is not None + context = self._context + + if self._capture_listeners is not None: + current_url = self._page.url if self._page is not None else None + return { + "ok": True, + "kind": "ui_arm_capture", + "source": SOURCE, + "checked_at": now_kst(), + "data": { + "already_armed": True, + "stream_path": str(stream_path), + "url_filter": url_filter, + "current_url": current_url, + "pages_attached": len(context.pages), + }, + } + + def _append(entry: dict[str, Any]) -> None: + try: + with open(stream_path, "a", encoding="utf-8") as f: + f.write(json.dumps(entry, ensure_ascii=False) + "\n") + except Exception: + pass + + def on_request(request: Any) -> None: + try: + if url_filter not in request.url: + return + _append({ + "ts": now_kst(), + "kind": "request", + "method": request.method, + "url": request.url, + "post_data": request.post_data, + "headers": dict(request.headers), + }) + except Exception: + pass + + def on_response(response: Any) -> None: + try: + if url_filter not in response.url: + return + try: + body_text = response.text() + except Exception: + body_text = None + _append({ + "ts": now_kst(), + "kind": "response", + "method": response.request.method, + "url": response.url, + "status": response.status, + "body": body_text, + }) + except Exception: + pass + + def attach_to_page(page: Any) -> None: + try: + page.on("request", on_request) + page.on("response", on_response) + _append({ + "ts": now_kst(), + "kind": "page_attached", + "url": page.url, + }) + except Exception: + pass + + # 기존 page 들 모두 attach + for existing_page in list(context.pages): + attach_to_page(existing_page) + + # 새 page 가 context 에 추가되면 자동 attach + def on_new_page(page: Any) -> None: + attach_to_page(page) + + context.on("page", on_new_page) + + self._capture_listeners = (on_request, on_response, on_new_page) + + _append({ + "ts": now_kst(), + "kind": "armed", + "url_filter": url_filter, + "pages_attached": len(context.pages), + }) + + return { + "ok": True, + "kind": "ui_arm_capture", + "source": SOURCE, + "checked_at": now_kst(), + "data": { + "already_armed": False, + "stream_path": str(stream_path), + "url_filter": url_filter, + "current_url": self._page.url if self._page is not None else None, + "pages_attached": len(context.pages), + }, + } + + def ui_disarm_capture(self) -> dict[str, Any]: + """P6 진단용 — UI capture listener 해제 (context 레벨).""" + if self._capture_listeners is None: + return { + "ok": True, + "kind": "ui_disarm_capture", + "source": SOURCE, + "checked_at": now_kst(), + "data": {"was_armed": False}, + } + on_req, on_resp, on_new_page = self._capture_listeners + try: + if self._context is not None: + self._context.remove_listener("page", on_new_page) + for p in list(self._context.pages): + try: + p.remove_listener("request", on_req) + p.remove_listener("response", on_resp) + except Exception: + pass + except Exception: + pass + self._capture_listeners = None + return { + "ok": True, + "kind": "ui_disarm_capture", + "source": SOURCE, + "checked_at": now_kst(), + "data": {"was_armed": True}, + } + def open_login(self) -> dict[str, Any]: page = self.ensure_page() page.bring_to_front() @@ -1740,7 +2033,7 @@ def _market_status_issue(status: str | None) -> dict[str, Any] | None: "code": "market_not_tradeable", "message": f"market status {status} indicates trading is blocked", } - if normalized not in {"ACTIVE", "NORMAL", "OPEN", "TRADING", "N"}: + if normalized not in {"ACTIVE", "NORMAL", "OPEN", "TRADING"}: return { "blocking": False, "code": "market_status_requires_review", @@ -2005,6 +2298,7 @@ def _run_prepare_preflight(self, normalized: dict[str, Any]) -> dict[str, Any]: submit_market=submit_market, currency_mode=currency_mode, allow_auto_exchange=allow_auto_exchange, + is_reservation_order=normalized.get("is_reservation_order"), ) prepare_requests = [ { @@ -2130,11 +2424,18 @@ def _run_broker_create( normalized: dict[str, Any], preflight: dict[str, Any], ) -> tuple[dict[str, Any], QueryContext]: - """Call POST /api/v2/wts/trading/order/create after prepare preflight succeeded. - - Endpoint URL/body schema captured in Phase 0 P0-02 (HAR diff against - simulation baseline). Body = prepare_payload minus the ``withOrderKey`` - key; ``orderKey`` is tracked server-side via session cookie. + """Call POST /api/v2/wts/trading/order/create/direct after prepare preflight succeeded. + + Endpoint URL/body schema captured in Phase 0 P0-02 (HAR) and re-validated in + Phase 6 P6-01 KRX UI capture (2026-05-05 14:17:30). Toss web UI consistently + uses the ``/create/direct`` variant — broker returns ``isReserved`` flag + based on market session state (true = reserved for next session, false = + immediate). The bare ``/create`` endpoint returned PIN keyboard responses + (BROKER_REJECTED_UNKNOWN) when called outside regular trading hours during + Phase 6 4-attempt diagnosis. + + Body = prepare_payload minus the ``withOrderKey`` key; ``orderKey`` is + tracked server-side via session cookie. """ prepare_payload = preflight["prepare_payload"] create_payload = {key: value for key, value in prepare_payload.items() if key != "withOrderKey"} @@ -2149,8 +2450,8 @@ def _run_broker_create( create_request = { "name": "order_create", "method": "POST", - "url": "https://wts-cert-api.tossinvest.com/api/v2/wts/trading/order/create", - "path": "/api/v2/wts/trading/order/create", + "url": "https://wts-cert-api.tossinvest.com/api/v2/wts/trading/order/create/direct", + "path": "/api/v2/wts/trading/order/create/direct", "headers": {"X-Tossinvest-Account": account_no}, "include_app_version": True, "body": create_payload, @@ -2185,6 +2486,21 @@ def _run_broker_create( status_code = int(result.get("status_code") or 0) body = result.get("json") or {} broker_result = body.get("result") or {} + # DEBUG: P6 supervised broker create raw response capture (remove after diagnosis) + try: + import json as _json + from pathlib import Path as _Path + _debug_path = _Path.home() / "Library/Application Support/financier-v2/toss-bridge/broker-create-debug.log" + with open(_debug_path, "a", encoding="utf-8") as _f: + _f.write(_json.dumps({ + "ts": ordered_at, + "request_body": create_payload, + "status_code": status_code, + "fetch_error": result.get("error"), + "response_body": body, + }, ensure_ascii=False) + "\n") + except Exception: + pass if not result.get("ok"): fetch_error = result.get("error") diff --git a/src/toss_browser_bridge/submit.py b/src/toss_browser_bridge/submit.py index 64b7060..ba894fb 100644 --- a/src/toss_browser_bridge/submit.py +++ b/src/toss_browser_bridge/submit.py @@ -292,12 +292,24 @@ def validate_place_order_params(params: dict[str, Any]) -> dict[str, Any]: auto_verify = bool(params.get("auto_verify") or False) + is_reservation_order_raw = params.get("is_reservation_order") + if is_reservation_order_raw is None: + is_reservation_order: bool | None = None + elif isinstance(is_reservation_order_raw, bool): + is_reservation_order = is_reservation_order_raw + else: + raise MutationValidationError( + "is_reservation_order must be bool or None — " + "policy enums (auto/on/off) are converted in the wrapper layer" + ) + return { "preview_receipt": receipt, "preview_fingerprint": preview_fingerprint, "confirm_phrase": confirm_phrase, "confirm_phrase_hash": build_confirm_phrase_hash(confirm_phrase), "auto_verify": auto_verify, + "is_reservation_order": is_reservation_order, } @@ -429,6 +441,7 @@ def build_order_prepare_payload( submit_market: str, currency_mode: str, allow_auto_exchange: bool, + is_reservation_order: bool | None = None, ) -> dict[str, Any]: validated = validate_order_preview_receipt(receipt) inputs = validated["inputs"] @@ -443,18 +456,22 @@ def build_order_prepare_payload( "preview_receipt submit_candidate.limit_price must be positive " "(market orders use NBBO-quoted price; preview must populate it)" ) + reservation_flag = False if is_reservation_order is None else bool(is_reservation_order) return { "stockCode": submit_candidate["product_code"], - "tradeType": inputs["side"], "market": submit_market, "currencyMode": currency_mode, + "tradeType": inputs["side"], "price": price, "quantity": quantity, + "orderAmount": 0, "orderPriceType": order_price_type, - "withOrderKey": True, - "allowAutoExchange": allow_auto_exchange, + "agreedOver100Million": False, "marginTrading": False, - "isReservationOrder": False, + "max": False, + "isReservationOrder": reservation_flag, + "openPriceSinglePriceYn": False, + "withOrderKey": True, } diff --git a/tests/test_submit_runtime.py b/tests/test_submit_runtime.py index 4d37ae2..0738677 100644 --- a/tests/test_submit_runtime.py +++ b/tests/test_submit_runtime.py @@ -9,12 +9,14 @@ from toss_browser_bridge.preview import build_preview_fingerprint from toss_browser_bridge.submit import ( MutationDomainError, + MutationValidationError, append_mutation_journal, build_prepare_drift_issues, build_order_prepare_payload, build_order_preview_fingerprint_payload, build_order_preview_receipt, find_recent_mutation_by_id, + validate_place_order_params, ) from toss_browser_bridge.daemon import classify_broker_reject @@ -870,3 +872,85 @@ def _fake_verify_order(self, params): assert len(verify_calls) == 1 assert data["verification_state"] == "verified_success" assert data["verify_snapshot"]["matched_order"]["order_no"] == 3 + + +# --------------------------------------------------------------------------- +# Phase 1 P1-07: payload pass-through (isReservationOrder) 회귀 + 신규 테스트 +# --------------------------------------------------------------------------- + + +def _build_prepare_payload_with_flag(reservation: bool | None) -> dict[str, object]: + return build_order_prepare_payload( + _receipt(), + submit_market="NSQ", + currency_mode="USD", + allow_auto_exchange=False, + is_reservation_order=reservation, + ) + + +def test_build_order_prepare_payload_default_none_keeps_reservation_false() -> None: + payload = _build_prepare_payload_with_flag(None) + assert payload["isReservationOrder"] is False + + +def test_build_order_prepare_payload_explicit_true_serializes_reservation_true() -> None: + payload = _build_prepare_payload_with_flag(True) + assert payload["isReservationOrder"] is True + + +def test_build_order_prepare_payload_explicit_false_serializes_reservation_false() -> None: + payload = _build_prepare_payload_with_flag(False) + assert payload["isReservationOrder"] is False + + +def _valid_place_params(extra: dict[str, object] | None = None) -> dict[str, object]: + receipt = _receipt() + base = { + "preview_receipt": receipt, + "preview_fingerprint": receipt["preview_fingerprint"], + "confirm": True, + "confirm_text": "BUY 1 AAPL LIMIT 200.00 US", + } + if extra: + base.update(extra) + return base + + +def test_validate_place_order_params_omits_reservation_when_unset() -> None: + normalized = validate_place_order_params(_valid_place_params()) + assert normalized["is_reservation_order"] is None + + +def test_validate_place_order_params_passes_through_true() -> None: + normalized = validate_place_order_params(_valid_place_params({"is_reservation_order": True})) + assert normalized["is_reservation_order"] is True + + +def test_validate_place_order_params_passes_through_false() -> None: + normalized = validate_place_order_params(_valid_place_params({"is_reservation_order": False})) + assert normalized["is_reservation_order"] is False + + +def test_validate_place_order_params_rejects_string_enum() -> None: + with pytest.raises(MutationValidationError) as excinfo: + validate_place_order_params(_valid_place_params({"is_reservation_order": "on"})) + assert "is_reservation_order" in str(excinfo.value) + + +def test_market_status_issue_n_classifies_as_review_not_normal() -> None: + issue = TossBridgeRuntime._market_status_issue("N") + assert issue is not None + assert issue["blocking"] is False + assert issue["code"] == "market_status_requires_review" + + +def test_market_status_issue_active_returns_none() -> None: + assert TossBridgeRuntime._market_status_issue("ACTIVE") is None + + +def test_market_status_issue_halt_blocks() -> None: + issue = TossBridgeRuntime._market_status_issue("HALT") + assert issue is not None + assert issue["blocking"] is True + assert issue["code"] == "market_not_tradeable" diff --git a/uv.lock b/uv.lock index ce49ea3..23473f9 100644 --- a/uv.lock +++ b/uv.lock @@ -143,7 +143,7 @@ wheels = [ [[package]] name = "toss-browser-bridge" -version = "0.3.0" +version = "0.4.0" source = { editable = "." } dependencies = [ { name = "playwright" },