kwcli 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.
Files changed (62) hide show
  1. kiwoom/__init__.py +47 -0
  2. kiwoom/_data/kiwoom_api_spec.json +66372 -0
  3. kiwoom/core/__init__.py +6 -0
  4. kiwoom/core/auth.py +349 -0
  5. kiwoom/core/client.py +244 -0
  6. kiwoom/core/errors.py +177 -0
  7. kiwoom/core/platform_paths.py +68 -0
  8. kiwoom/core/profiles.py +143 -0
  9. kiwoom/core/runtime.py +153 -0
  10. kiwoom/core/secrets.py +217 -0
  11. kiwoom/core/settings.py +64 -0
  12. kiwoom/core/token_store.py +186 -0
  13. kiwoom/core/types.py +28 -0
  14. kiwoom/core/ws_client.py +262 -0
  15. kiwoom/realtime/__init__.py +30 -0
  16. kiwoom/realtime/decoders.py +171 -0
  17. kiwoom/realtime/events.py +98 -0
  18. kiwoom/realtime/packets.py +65 -0
  19. kiwoom/realtime/schemas.py +76 -0
  20. kiwoom/realtime/stream.py +213 -0
  21. kiwoom/specs.py +272 -0
  22. kiwoom_cli/README.md +671 -0
  23. kiwoom_cli/__init__.py +9 -0
  24. kiwoom_cli/__main__.py +5 -0
  25. kiwoom_cli/argument_maps.py +561 -0
  26. kiwoom_cli/arguments.py +147 -0
  27. kiwoom_cli/auth_context.py +64 -0
  28. kiwoom_cli/banner.py +125 -0
  29. kiwoom_cli/commands/__init__.py +1 -0
  30. kiwoom_cli/commands/auth.py +406 -0
  31. kiwoom_cli/commands/groups.py +281 -0
  32. kiwoom_cli/commands/mapped.py +74 -0
  33. kiwoom_cli/commands/orders.py +158 -0
  34. kiwoom_cli/commands/spec.py +98 -0
  35. kiwoom_cli/commands/stocks.py +100 -0
  36. kiwoom_cli/commands/streams.py +470 -0
  37. kiwoom_cli/doctor.py +324 -0
  38. kiwoom_cli/errors.py +24 -0
  39. kiwoom_cli/executor/__init__.py +33 -0
  40. kiwoom_cli/executor/condition.py +374 -0
  41. kiwoom_cli/executor/rest.py +132 -0
  42. kiwoom_cli/executor/waits.py +34 -0
  43. kiwoom_cli/executor/websocket.py +131 -0
  44. kiwoom_cli/main.py +203 -0
  45. kiwoom_cli/maps/README.md +99 -0
  46. kiwoom_cli/maps/api_commands.csv +209 -0
  47. kiwoom_cli/maps/arguments.csv +731 -0
  48. kiwoom_cli/maps/order_confirmation_commands.csv +13 -0
  49. kiwoom_cli/maps/order_confirmation_fields.csv +71 -0
  50. kiwoom_cli/maps/order_price_policies.csv +47 -0
  51. kiwoom_cli/maps/order_value_labels.csv +28 -0
  52. kiwoom_cli/maps/positional_arguments.csv +21 -0
  53. kiwoom_cli/order_confirmation.py +167 -0
  54. kiwoom_cli/output.py +136 -0
  55. kiwoom_cli/registry.py +95 -0
  56. kiwoom_cli/safety.py +30 -0
  57. kiwoom_cli/setup.py +533 -0
  58. kwcli-0.1.0.dist-info/METADATA +215 -0
  59. kwcli-0.1.0.dist-info/RECORD +62 -0
  60. kwcli-0.1.0.dist-info/WHEEL +4 -0
  61. kwcli-0.1.0.dist-info/entry_points.txt +2 -0
  62. kwcli-0.1.0.dist-info/licenses/LICENSE.md +36 -0
@@ -0,0 +1,374 @@
1
+ """Condition-search orchestration (CNSRLST/CNSRREQ/CNSRCLR flows)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from dataclasses import dataclass
7
+ from typing import Any
8
+
9
+ from kiwoom import get_ws_client
10
+ from kiwoom.core.errors import normalize_return_code
11
+ from kiwoom_cli.errors import CliInternalError
12
+ from kiwoom_cli.executor.rest import validate_request_body
13
+ from kiwoom_cli.executor.waits import normalize_max_messages, normalize_wait_seconds
14
+ from kiwoom_cli.executor.websocket import stream_timeout_message
15
+ from kiwoom_cli.registry import CommandDefinition
16
+ from kiwoom_cli.safety import enforce_before_request
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class ConditionProtocol:
21
+ """Per-market condition-search TR names and exchange-field usage."""
22
+
23
+ list_trnm: str
24
+ request_trnm: str
25
+ clear_trnm: str
26
+ default_stex_tp: str | None # None: the market's spec has no stex_tp field.
27
+
28
+
29
+ DOMESTIC_CONDITION_PROTOCOL = ConditionProtocol(
30
+ list_trnm="CNSRLST",
31
+ request_trnm="CNSRREQ",
32
+ clear_trnm="CNSRCLR",
33
+ default_stex_tp="K",
34
+ )
35
+ DEFAULT_CONDITION_MAX_WAIT_SECONDS = 15.0
36
+
37
+
38
+ def _condition_protocol(definition: CommandDefinition) -> ConditionProtocol:
39
+ return DOMESTIC_CONDITION_PROTOCOL
40
+
41
+
42
+ async def execute_condition_command(
43
+ command: str,
44
+ definition: CommandDefinition,
45
+ *,
46
+ body: dict[str, Any],
47
+ mode: str | None = None,
48
+ profile: str | None = None,
49
+ max_messages: int | str = 1,
50
+ max_wait_seconds: float | int | str = DEFAULT_CONDITION_MAX_WAIT_SECONDS,
51
+ exchange: str | None = None,
52
+ ) -> dict[str, Any]:
53
+ if command == "conditions-search":
54
+ return await _execute_condition_search(definition, body=body, mode=mode, profile=profile)
55
+ if command == "conditions-subscribe":
56
+ return await _execute_condition_subscribe(
57
+ definition,
58
+ body=body,
59
+ mode=mode,
60
+ profile=profile,
61
+ max_messages=max_messages,
62
+ max_wait_seconds=max_wait_seconds,
63
+ )
64
+ if command == "conditions-unsubscribe":
65
+ return await _execute_condition_unsubscribe(
66
+ definition,
67
+ body=body,
68
+ mode=mode,
69
+ profile=profile,
70
+ exchange=exchange,
71
+ )
72
+ raise CliInternalError(f"지원하지 않는 조건검색 명령입니다: {command}")
73
+
74
+
75
+ async def _execute_condition_search(
76
+ definition: CommandDefinition,
77
+ *,
78
+ body: dict[str, Any],
79
+ mode: str | None,
80
+ profile: str | None,
81
+ ) -> dict[str, Any]:
82
+ enforce_before_request(definition, confirm=False)
83
+ protocol = _condition_protocol(definition)
84
+ client = get_ws_client(mode=mode, profile=profile)
85
+ try:
86
+ await client.connect(api_url=definition.path)
87
+ list_response = await _condition_send_and_receive(client, {"trnm": protocol.list_trnm})
88
+ condition_rows = _condition_rows(list_response.get("data"))
89
+ selected_condition = _select_condition(condition_rows, preferred_seq=body.get("seq"))
90
+ if selected_condition is None:
91
+ return _condition_blocked_payload(
92
+ conditions=condition_rows,
93
+ list_response=list_response,
94
+ preferred_seq=body.get("seq"),
95
+ mode=mode,
96
+ profile=profile,
97
+ )
98
+
99
+ request_body = {
100
+ "trnm": body.get("trnm") or protocol.request_trnm,
101
+ "seq": selected_condition["seq"],
102
+ "search_type": body.get("search_type") or "0",
103
+ }
104
+ if protocol.default_stex_tp is not None:
105
+ request_body["stex_tp"] = body.get("stex_tp") or protocol.default_stex_tp
106
+ for optional_field in ("cont_yn", "next_key"):
107
+ if optional_field in body:
108
+ request_body[optional_field] = body[optional_field]
109
+ validate_request_body(definition, request_body)
110
+ response = await _condition_send_and_receive(client, request_body)
111
+ return {
112
+ "conditions": condition_rows,
113
+ "selected_condition": selected_condition,
114
+ "initial": response,
115
+ }
116
+ finally:
117
+ await client.close()
118
+
119
+
120
+ async def _execute_condition_subscribe(
121
+ definition: CommandDefinition,
122
+ *,
123
+ body: dict[str, Any],
124
+ mode: str | None,
125
+ profile: str | None,
126
+ max_messages: int | str,
127
+ max_wait_seconds: float | int | str,
128
+ ) -> dict[str, Any]:
129
+ enforce_before_request(definition, confirm=False)
130
+ protocol = _condition_protocol(definition)
131
+ client = get_ws_client(mode=mode, profile=profile)
132
+ selected_condition: dict[str, str] | None = None
133
+ clear_done = False
134
+ registered = False
135
+ try:
136
+ await client.connect(api_url=definition.path)
137
+ list_response = await _condition_send_and_receive(client, {"trnm": protocol.list_trnm})
138
+ condition_rows = _condition_rows(list_response.get("data"))
139
+ selected_condition = _select_condition(condition_rows, preferred_seq=body.get("seq"))
140
+ if selected_condition is None:
141
+ return _condition_blocked_payload(
142
+ conditions=condition_rows,
143
+ list_response=list_response,
144
+ preferred_seq=body.get("seq"),
145
+ mode=mode,
146
+ profile=profile,
147
+ )
148
+
149
+ request_body = {
150
+ "trnm": body.get("trnm") or protocol.request_trnm,
151
+ "seq": selected_condition["seq"],
152
+ "search_type": body.get("search_type") or "1",
153
+ }
154
+ if protocol.default_stex_tp is not None:
155
+ request_body["stex_tp"] = body.get("stex_tp") or protocol.default_stex_tp
156
+ validate_request_body(definition, request_body)
157
+ initial_response = await _condition_send_and_receive(client, request_body)
158
+ registered = True
159
+ message_limit = normalize_max_messages(max_messages)
160
+ wait_limit = normalize_wait_seconds(max_wait_seconds, allow_zero=False)
161
+ realtime_messages, system_messages = await _collect_condition_realtime_messages(
162
+ client,
163
+ max_messages=message_limit,
164
+ max_wait_seconds=wait_limit,
165
+ )
166
+ clear_response = await _try_clear_condition(
167
+ client, selected_condition["seq"], clear_trnm=protocol.clear_trnm
168
+ )
169
+ clear_done = True
170
+ if message_limit > 0 and len(realtime_messages) < message_limit:
171
+ raise TimeoutError(
172
+ stream_timeout_message(
173
+ data_count=len(realtime_messages),
174
+ system_count=len(system_messages),
175
+ max_messages=message_limit,
176
+ max_wait_seconds=wait_limit,
177
+ )
178
+ )
179
+ return {
180
+ "conditions": condition_rows,
181
+ "selected_condition": selected_condition,
182
+ "initial": initial_response,
183
+ "realtime": realtime_messages,
184
+ "system": system_messages,
185
+ "clear": clear_response,
186
+ }
187
+ finally:
188
+ if selected_condition is not None and registered and not clear_done and client.is_connected:
189
+ await _try_clear_condition(
190
+ client, selected_condition["seq"], clear_trnm=protocol.clear_trnm
191
+ )
192
+ await client.close()
193
+
194
+
195
+ async def _execute_condition_unsubscribe(
196
+ definition: CommandDefinition,
197
+ *,
198
+ body: dict[str, Any],
199
+ mode: str | None,
200
+ profile: str | None,
201
+ exchange: str | None,
202
+ ) -> dict[str, Any]:
203
+ enforce_before_request(definition, confirm=False)
204
+ protocol = _condition_protocol(definition)
205
+ client = get_ws_client(mode=mode, profile=profile)
206
+ try:
207
+ await client.connect(api_url=definition.path)
208
+ list_response = await _condition_send_and_receive(client, {"trnm": protocol.list_trnm})
209
+ condition_rows = _condition_rows(list_response.get("data"))
210
+ selected_condition = _select_condition(condition_rows, preferred_seq=body.get("seq"))
211
+ if selected_condition is None:
212
+ return _condition_blocked_payload(
213
+ conditions=condition_rows,
214
+ list_response=list_response,
215
+ preferred_seq=body.get("seq"),
216
+ mode=mode,
217
+ profile=profile,
218
+ )
219
+
220
+ setup_request = {
221
+ "trnm": protocol.request_trnm,
222
+ "seq": selected_condition["seq"],
223
+ "search_type": "1",
224
+ }
225
+ if protocol.default_stex_tp is not None:
226
+ setup_request["stex_tp"] = _exchange_to_stex_tp(exchange)
227
+ setup_response = await _condition_send_and_receive(client, setup_request)
228
+ clear_body = {
229
+ "trnm": body.get("trnm") or protocol.clear_trnm,
230
+ "seq": selected_condition["seq"],
231
+ }
232
+ validate_request_body(definition, clear_body)
233
+ clear_response = await _condition_send_and_receive(client, clear_body)
234
+ return {
235
+ "conditions": condition_rows,
236
+ "selected_condition": selected_condition,
237
+ "setup_request": setup_response,
238
+ "clear": clear_response,
239
+ }
240
+ finally:
241
+ await client.close()
242
+
243
+
244
+ async def _condition_send_and_receive(client: Any, body: dict[str, Any]) -> dict[str, Any]:
245
+ await client.send(body)
246
+ response = await asyncio.wait_for(client.recv(), timeout=client.timeout_seconds)
247
+ if not isinstance(response, dict):
248
+ raise RuntimeError(f"예상하지 못한 WebSocket 응답 형식입니다: {type(response).__name__}")
249
+ return response
250
+
251
+
252
+ async def _collect_condition_realtime_messages(
253
+ client: Any,
254
+ *,
255
+ max_messages: int,
256
+ max_wait_seconds: float,
257
+ ) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
258
+ realtime_messages: list[dict[str, Any]] = []
259
+ system_messages: list[dict[str, Any]] = []
260
+ loop = asyncio.get_running_loop()
261
+ deadline = loop.time() + max_wait_seconds
262
+
263
+ while max_messages == 0 or len(realtime_messages) < max_messages:
264
+ remaining = deadline - loop.time()
265
+ if remaining <= 0:
266
+ break
267
+ try:
268
+ message = await asyncio.wait_for(client.recv(), timeout=remaining)
269
+ except TimeoutError:
270
+ break
271
+ if not isinstance(message, dict):
272
+ continue
273
+ if str(message.get("trnm", "")).upper() == "REAL":
274
+ realtime_messages.append(message)
275
+ continue
276
+ system_messages.append(message)
277
+ return_code = normalize_return_code(message.get("return_code"))
278
+ if str(message.get("trnm", "")).upper() == "SYSTEM" or return_code not in (None, 0):
279
+ break
280
+
281
+ return realtime_messages, system_messages
282
+
283
+
284
+ async def _try_clear_condition(
285
+ client: Any,
286
+ seq: str,
287
+ *,
288
+ clear_trnm: str = DOMESTIC_CONDITION_PROTOCOL.clear_trnm,
289
+ ) -> dict[str, Any]:
290
+ try:
291
+ return await _condition_send_and_receive(
292
+ client,
293
+ {
294
+ "trnm": clear_trnm,
295
+ "seq": seq,
296
+ },
297
+ )
298
+ except TimeoutError as exc:
299
+ return {
300
+ "trnm": clear_trnm,
301
+ "seq": seq,
302
+ "return_code": "TIMEOUT",
303
+ "return_msg": str(exc),
304
+ }
305
+
306
+
307
+ def _condition_rows(raw_rows: object) -> list[dict[str, str]]:
308
+ if not isinstance(raw_rows, list):
309
+ return []
310
+
311
+ rows: list[dict[str, str]] = []
312
+ for row in raw_rows:
313
+ if isinstance(row, dict):
314
+ seq = str(row.get("seq", "")).strip()
315
+ name = str(row.get("name", "")).strip()
316
+ elif isinstance(row, (list, tuple)) and len(row) >= 2:
317
+ seq = str(row[0]).strip()
318
+ name = str(row[1]).strip()
319
+ else:
320
+ continue
321
+ if seq:
322
+ rows.append({"seq": seq, "name": name})
323
+ return rows
324
+
325
+
326
+ def _select_condition(
327
+ rows: list[dict[str, str]],
328
+ *,
329
+ preferred_seq: object,
330
+ ) -> dict[str, str] | None:
331
+ normalized = str(preferred_seq).strip() if preferred_seq not in (None, "") else ""
332
+ if normalized:
333
+ for row in rows:
334
+ if row["seq"] == normalized:
335
+ return row
336
+ return None
337
+ if not rows:
338
+ return None
339
+ return rows[0]
340
+
341
+
342
+ def _condition_blocked_payload(
343
+ *,
344
+ conditions: list[dict[str, str]],
345
+ list_response: dict[str, Any],
346
+ preferred_seq: object,
347
+ mode: str | None,
348
+ profile: str | None,
349
+ ) -> dict[str, Any]:
350
+ reason = "condition-seq-not-found" if preferred_seq not in (None, "") else "condition-list-empty"
351
+ return {
352
+ "status": "blocked",
353
+ "blocked_reason": reason,
354
+ "message": (
355
+ "저장된 조건검색식을 찾을 수 없습니다. 영웅문에서 조건검색식을 저장한 뒤 "
356
+ "다시 실행하거나, conditions-list 결과에 있는 --seq 값을 지정해 주세요."
357
+ ),
358
+ "mode": mode,
359
+ "profile": profile,
360
+ "preferred_seq": preferred_seq,
361
+ "conditions": conditions,
362
+ "conditions_count": len(conditions),
363
+ "list_response": list_response,
364
+ }
365
+
366
+
367
+ def _exchange_to_stex_tp(value: object) -> str:
368
+ return {
369
+ "KRX": "K",
370
+ "NXT": "N",
371
+ "ALL": "A",
372
+ None: "K",
373
+ "": "K",
374
+ }.get(value, str(value))
@@ -0,0 +1,132 @@
1
+ """REST command execution and previews through the package runtime facade."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from kiwoom import get_client
8
+ from kiwoom.core.types import KiwoomResponse
9
+ from kiwoom_cli.errors import CliInputError
10
+ from kiwoom_cli.registry import CommandDefinition
11
+ from kiwoom_cli.safety import enforce_before_preview, enforce_before_request
12
+
13
+
14
+ def execute_rest_command(
15
+ definition: CommandDefinition,
16
+ *,
17
+ body: dict[str, Any],
18
+ mode: str | None = None,
19
+ profile: str | None = None,
20
+ confirm: bool = False,
21
+ pages: int = 1,
22
+ ) -> KiwoomResponse:
23
+ """Execute a mapped REST command.
24
+
25
+ ``pages`` follows the 연속조회 cursor: 1 fetches a single page (the
26
+ long-standing default), N caps at N pages, 0 follows the cursor until
27
+ the server stops offering continuation.
28
+ """
29
+ validate_request_body(definition, body)
30
+ enforce_before_request(definition, confirm=confirm)
31
+ client = get_client(mode=mode, profile=profile)
32
+ if pages == 1:
33
+ return client.fetch_page(
34
+ api_id=definition.api_id,
35
+ path=definition.path,
36
+ method=definition.method,
37
+ body=body,
38
+ )
39
+ responses = list(
40
+ client.iterate_pages(
41
+ api_id=definition.api_id,
42
+ path=definition.path,
43
+ method=definition.method,
44
+ body=body,
45
+ max_pages=pages,
46
+ )
47
+ )
48
+ return _merge_page_responses(responses)
49
+
50
+
51
+ def _merge_page_responses(responses: list[KiwoomResponse]) -> KiwoomResponse:
52
+ """Concatenate list-valued body fields across pages; scalars keep the
53
+ freshest page's value. Continuation/headers come from the last page so
54
+ callers can tell whether more data remained."""
55
+ if len(responses) == 1:
56
+ return responses[0]
57
+ merged: dict[str, Any] = dict(responses[0].body)
58
+ for response in responses[1:]:
59
+ for key, value in response.body.items():
60
+ if isinstance(value, list) and isinstance(merged.get(key), list):
61
+ merged[key] = [*merged[key], *value]
62
+ else:
63
+ merged[key] = value
64
+ last = responses[-1]
65
+ return KiwoomResponse(body=merged, continuation=last.continuation, headers=last.headers)
66
+
67
+
68
+ def preview_rest_command(definition: CommandDefinition, *, body: dict[str, Any]) -> dict[str, Any]:
69
+ validate_request_body(definition, body)
70
+ enforce_before_preview(definition)
71
+ validation_errors = validate_preview_request(definition, body)
72
+ return {
73
+ "command": definition.command_path,
74
+ "api_id": definition.api_id,
75
+ "method": definition.method,
76
+ "path": definition.path,
77
+ "coverage_status": definition.coverage_status,
78
+ "safety_policy": definition.safety_policy,
79
+ "network": "not-submitted",
80
+ "validation": {
81
+ "status": "invalid" if validation_errors else "valid",
82
+ "errors": validation_errors,
83
+ },
84
+ "body": body,
85
+ }
86
+
87
+
88
+ def validate_preview_request(definition: CommandDefinition, body: dict[str, Any]) -> list[dict[str, str]]:
89
+ errors: list[dict[str, str]] = []
90
+ if definition.safety_policy != "order_write":
91
+ return errors
92
+ for field in ("orig_ord_no", "ord_no", "fr_ord_no", "rsrv_ord_no"):
93
+ value = body.get(field)
94
+ if value in (None, ""):
95
+ continue
96
+ if not str(value).isdigit() or len(str(value)) != 7:
97
+ errors.append(
98
+ {
99
+ "field": field,
100
+ "code": "invalid_order_id",
101
+ "message": "주문번호는 네트워크 전송 전에 키움 7자리 주문번호여야 합니다.",
102
+ }
103
+ )
104
+ return errors
105
+
106
+
107
+ def validate_request_body(definition: CommandDefinition, body: dict[str, Any]) -> None:
108
+ missing = [
109
+ field
110
+ for field in definition.required_body_fields
111
+ if not _has_required_field(body, field)
112
+ ]
113
+ if missing:
114
+ fields = ", ".join(missing)
115
+ raise CliInputError(f"{definition.command_path}: 필수 요청 필드가 빠졌습니다: {fields}")
116
+
117
+
118
+ def _has_required_field(body: dict[str, Any], field: str) -> bool:
119
+ if field in body and body[field] not in (None, ""):
120
+ return True
121
+ for row in body.get("data", []):
122
+ if not isinstance(row, dict):
123
+ continue
124
+ if field not in row:
125
+ continue
126
+ value = row[field]
127
+ if isinstance(value, list):
128
+ if any(item not in (None, "") for item in value):
129
+ return True
130
+ elif value not in (None, ""):
131
+ return True
132
+ return False
@@ -0,0 +1,34 @@
1
+ """Shared normalization for stream/condition wait and message-count options."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from kiwoom_cli.errors import CliInputError
6
+
7
+
8
+ def normalize_max_messages(value: int | str) -> int:
9
+ try:
10
+ parsed = int(value)
11
+ except (TypeError, ValueError) as exc:
12
+ raise CliInputError("stream 메시지 수는 정수여야 합니다.") from exc
13
+ if parsed < 0:
14
+ raise CliInputError("stream 메시지 수는 0 이상이어야 합니다.")
15
+ return parsed
16
+
17
+
18
+ def normalize_wait_seconds(value: float | int | str, *, allow_zero: bool) -> float:
19
+ """Parse a wait-seconds option.
20
+
21
+ ``allow_zero=True``: 0 means "no deadline" (open streams).
22
+ ``allow_zero=False``: 0 is invalid (bounded condition collection needs a
23
+ positive deadline).
24
+ """
25
+ try:
26
+ parsed = float(value)
27
+ except (TypeError, ValueError) as exc:
28
+ raise CliInputError("stream 대기 시간은 숫자여야 합니다.") from exc
29
+ if allow_zero:
30
+ if parsed < 0:
31
+ raise CliInputError("stream 대기 시간은 0초 이상이어야 합니다.")
32
+ elif parsed <= 0:
33
+ raise CliInputError("stream 대기 시간은 0초보다 커야 합니다.")
34
+ return parsed
@@ -0,0 +1,131 @@
1
+ """WebSocket request-once and stream execution."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import time
7
+ from collections.abc import AsyncIterator
8
+ from typing import Any
9
+
10
+ from kiwoom import get_ws_client
11
+ from kiwoom.core.errors import normalize_return_code
12
+ from kiwoom_cli.executor.rest import validate_request_body
13
+ from kiwoom_cli.executor.waits import normalize_max_messages, normalize_wait_seconds
14
+ from kiwoom_cli.registry import CommandDefinition
15
+ from kiwoom_cli.safety import enforce_before_request
16
+
17
+
18
+ DEFAULT_STREAM_MAX_WAIT_SECONDS = 15.0
19
+
20
+
21
+ async def execute_websocket_request_once(
22
+ definition: CommandDefinition,
23
+ *,
24
+ body: dict[str, Any],
25
+ mode: str | None = None,
26
+ profile: str | None = None,
27
+ ) -> Any:
28
+ validate_request_body(definition, body)
29
+ enforce_before_request(definition, confirm=False)
30
+ client = get_ws_client(mode=mode, profile=profile)
31
+ return await client.request_once(api_url=definition.path, body=body)
32
+
33
+
34
+ async def execute_websocket_stream(
35
+ definition: CommandDefinition,
36
+ *,
37
+ body: dict[str, Any],
38
+ mode: str | None = None,
39
+ profile: str | None = None,
40
+ max_messages: int | str = 1,
41
+ max_wait_seconds: float | int | str = DEFAULT_STREAM_MAX_WAIT_SECONDS,
42
+ ) -> AsyncIterator[Any]:
43
+ validate_request_body(definition, body)
44
+ enforce_before_request(definition, confirm=False)
45
+ message_limit = normalize_max_messages(max_messages)
46
+ wait_limit = normalize_wait_seconds(max_wait_seconds, allow_zero=True)
47
+ data_count = 0
48
+ system_count = 0
49
+ started = time.monotonic()
50
+ client = get_ws_client(mode=mode, profile=profile)
51
+ await client.subscribe(api_url=definition.path, body=body)
52
+ try:
53
+ while message_limit == 0 or data_count < message_limit:
54
+ timeout = _remaining_stream_wait_seconds(wait_limit, started)
55
+ if timeout is not None and timeout <= 0:
56
+ if message_limit == 0:
57
+ return
58
+ raise TimeoutError(
59
+ stream_timeout_message(
60
+ data_count=data_count,
61
+ system_count=system_count,
62
+ max_messages=message_limit,
63
+ max_wait_seconds=wait_limit,
64
+ )
65
+ )
66
+ try:
67
+ if timeout is None:
68
+ message = await client.recv()
69
+ else:
70
+ message = await asyncio.wait_for(client.recv(), timeout=timeout)
71
+ except TimeoutError as exc:
72
+ if message_limit == 0:
73
+ return
74
+ raise TimeoutError(
75
+ stream_timeout_message(
76
+ data_count=data_count,
77
+ system_count=system_count,
78
+ max_messages=message_limit,
79
+ max_wait_seconds=wait_limit,
80
+ )
81
+ ) from exc
82
+
83
+ if _is_realtime_message(message):
84
+ data_count += 1
85
+ else:
86
+ system_count += 1
87
+
88
+ yield message
89
+
90
+ if _is_error_control_message(message):
91
+ raise ValueError(_stream_error_message(message))
92
+ finally:
93
+ await client.close()
94
+
95
+
96
+ def _remaining_stream_wait_seconds(wait_limit: float, started: float) -> float | None:
97
+ if wait_limit == 0:
98
+ return None
99
+ return wait_limit - (time.monotonic() - started)
100
+
101
+
102
+ def _is_realtime_message(message: Any) -> bool:
103
+ return isinstance(message, dict) and str(message.get("trnm", "")).upper() == "REAL"
104
+
105
+
106
+ def _is_error_control_message(message: Any) -> bool:
107
+ if not isinstance(message, dict):
108
+ return False
109
+ return normalize_return_code(message.get("return_code")) not in (None, 0)
110
+
111
+
112
+ def _stream_error_message(message: dict[str, Any]) -> str:
113
+ code = message.get("return_code")
114
+ text = str(message.get("return_msg") or message.get("message") or "").strip()
115
+ if text:
116
+ return f"실시간 등록/제어 응답 오류(return_code={code}): {text}"
117
+ return f"실시간 등록/제어 응답 오류(return_code={code})"
118
+
119
+
120
+ def stream_timeout_message(
121
+ *,
122
+ data_count: int,
123
+ system_count: int,
124
+ max_messages: int,
125
+ max_wait_seconds: float,
126
+ ) -> str:
127
+ target = "무제한" if max_messages == 0 else str(max_messages)
128
+ return (
129
+ "실시간 REAL 데이터가 제한 시간 안에 충분히 수신되지 않았습니다. "
130
+ f"REAL={data_count}/{target}, control={system_count}, wait={max_wait_seconds:g}s"
131
+ )