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,262 @@
1
+ import asyncio
2
+ import json
3
+ import logging
4
+ from collections.abc import AsyncIterator, Callable
5
+ from typing import Any
6
+
7
+ from kiwoom.core.auth import KiwoomAuth, get_ws_base_url
8
+ from kiwoom.core.errors import AUTH_RETRY_RETURN_CODES, normalize_return_code
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+ try:
13
+ from websockets.asyncio.client import connect as _ws_connect
14
+ except ImportError: # pragma: no cover - compatibility fallback
15
+ try:
16
+ from websockets.client import connect as _ws_connect
17
+ except ImportError: # pragma: no cover - dependency installed later in the flow
18
+ _ws_connect = None
19
+
20
+
21
+ type ConnectFactory = Callable[..., Any]
22
+
23
+
24
+ class WebSocketLoginError(RuntimeError):
25
+ def __init__(self, return_code: int, return_msg: str) -> None:
26
+ super().__init__(f"websocket login failed: {return_msg}")
27
+ self.return_code = return_code
28
+ self.return_msg = return_msg
29
+
30
+
31
+ class KiwoomWebSocketClient:
32
+ def __init__(
33
+ self,
34
+ auth: KiwoomAuth,
35
+ *,
36
+ timeout_seconds: int = 30,
37
+ connect_factory: ConnectFactory | None = None,
38
+ ) -> None:
39
+ self.auth = auth
40
+ self.timeout_seconds = timeout_seconds
41
+ self._connect_factory = connect_factory or _default_connect
42
+ self._websocket: Any | None = None
43
+
44
+ @property
45
+ def is_connected(self) -> bool:
46
+ """Whether a live WebSocket connection is currently open."""
47
+ return self._websocket is not None
48
+
49
+ async def connect(self, *, api_url: str, retry_on_auth_failure: bool = True) -> None:
50
+ if self._websocket is not None:
51
+ await self.close()
52
+
53
+ path = _parse_api_url(api_url)
54
+ uri = f"{get_ws_base_url(self.auth.mode)}{path}"
55
+ try:
56
+ await self._connect_and_login(uri)
57
+ except WebSocketLoginError as exc:
58
+ if not retry_on_auth_failure or not _is_auth_retry_login_error(exc):
59
+ raise RuntimeError(f"websocket login failed: {exc.return_msg}") from exc
60
+ logger.info("websocket auth failed; refreshing token and retrying login")
61
+ await self.close()
62
+ self.auth.recover_from_auth_failure()
63
+ try:
64
+ await self._connect_and_login(uri)
65
+ except WebSocketLoginError as retry_exc:
66
+ raise RuntimeError(f"websocket login failed: {retry_exc.return_msg}") from retry_exc
67
+
68
+ async def request_once(
69
+ self,
70
+ *,
71
+ api_url: str,
72
+ body: dict[str, Any],
73
+ ) -> Any:
74
+ await self.connect(api_url=api_url)
75
+ try:
76
+ logger.info("websocket sent request")
77
+ await self.send(body)
78
+ try:
79
+ message = await asyncio.wait_for(self._receive_message(), timeout=self.timeout_seconds)
80
+ except TimeoutError as exc:
81
+ raise TimeoutError(f"websocket response timed out after {self.timeout_seconds}s") from exc
82
+ logger.info("websocket received response")
83
+ return message
84
+ finally:
85
+ await self.close()
86
+
87
+ async def subscribe(
88
+ self,
89
+ *,
90
+ api_url: str,
91
+ body: dict[str, Any],
92
+ ) -> None:
93
+ await self.connect(api_url=api_url)
94
+ logger.info("websocket sent subscribe request")
95
+ await self.send(body)
96
+
97
+ async def send(self, payload: Any) -> None:
98
+ """Send a packet over the current WebSocket connection."""
99
+ await self._send_packet(payload)
100
+
101
+ async def recv(self) -> Any:
102
+ """Receive the next non-ping, non-login message over the current connection."""
103
+ return await self._receive_message()
104
+
105
+ async def iter_messages(self) -> AsyncIterator[Any]:
106
+ if self._websocket is None:
107
+ raise RuntimeError("websocket is not connected")
108
+
109
+ while True:
110
+ try:
111
+ yield await self._receive_message()
112
+ except Exception as exc: # pragma: no cover - exercised with real websocket client
113
+ if _is_connection_closed_exception(exc):
114
+ logger.info("websocket connection closed by remote")
115
+ break
116
+ raise
117
+
118
+ async def close(self) -> None:
119
+ if self._websocket is None:
120
+ return
121
+
122
+ await self._websocket.close()
123
+ self._websocket = None
124
+ logger.info("websocket closed")
125
+
126
+ async def _connect_and_login(self, uri: str) -> None:
127
+ logger.info("websocket connecting: %s", uri)
128
+ self._websocket = await self._connect_factory(
129
+ uri,
130
+ self.timeout_seconds,
131
+ )
132
+ logger.info("websocket connected")
133
+ await self._login()
134
+
135
+ async def _receive_message(self) -> Any:
136
+ while True:
137
+ parsed = await self._receive_non_ping_message()
138
+ if _is_login_message(parsed):
139
+ return_code = normalize_return_code(parsed.get("return_code")) or 0
140
+ if return_code != 0:
141
+ raise WebSocketLoginError(
142
+ return_code,
143
+ str(parsed.get("return_msg", "unknown error")),
144
+ )
145
+ logger.info("websocket login acknowledged")
146
+ continue
147
+
148
+ logger.info("websocket received message")
149
+ return parsed
150
+
151
+ async def _login(self) -> None:
152
+ if self._websocket is None:
153
+ raise RuntimeError("websocket is not connected")
154
+
155
+ login_packet = {
156
+ "trnm": "LOGIN",
157
+ "token": self.auth.get_access_token(),
158
+ }
159
+ logger.info("websocket sending LOGIN packet")
160
+ await self._send_packet(login_packet)
161
+ await self._await_login_ack()
162
+
163
+ async def _send_packet(self, payload: Any) -> None:
164
+ if self._websocket is None:
165
+ raise RuntimeError("websocket is not connected")
166
+ await self._websocket.send(_serialize_message(payload))
167
+
168
+ async def _await_login_ack(self) -> None:
169
+ while True:
170
+ parsed = await self._receive_non_ping_message()
171
+ if not _is_login_message(parsed):
172
+ raise RuntimeError("websocket login ack was not received before other messages")
173
+ return_code = normalize_return_code(parsed.get("return_code")) or 0
174
+ if return_code != 0:
175
+ raise WebSocketLoginError(
176
+ return_code,
177
+ str(parsed.get("return_msg", "unknown error")),
178
+ )
179
+ logger.info("websocket login acknowledged")
180
+ return
181
+
182
+ async def _receive_non_ping_message(self) -> Any:
183
+ if self._websocket is None:
184
+ raise RuntimeError("websocket is not connected")
185
+
186
+ while True:
187
+ message = await self._websocket.recv()
188
+ parsed = _parse_message(message)
189
+ if not _is_ping_message(parsed):
190
+ return parsed
191
+ logger.info("websocket received ping; echoing back")
192
+ await self._send_packet(parsed)
193
+ logger.info("websocket sent ping echo")
194
+
195
+
196
+ def _is_auth_retry_login_error(exc: WebSocketLoginError) -> bool:
197
+ if exc.return_code in AUTH_RETRY_RETURN_CODES:
198
+ return True
199
+ return any(f"CODE={code}" in exc.return_msg for code in AUTH_RETRY_RETURN_CODES)
200
+
201
+
202
+ async def _default_connect(uri: str, timeout_seconds: int) -> Any:
203
+ if _ws_connect is None:
204
+ raise RuntimeError("websockets dependency is not installed")
205
+ return await _ws_connect(
206
+ uri,
207
+ open_timeout=timeout_seconds,
208
+ ping_interval=None,
209
+ )
210
+
211
+
212
+ def _parse_api_url(api_url: str) -> str:
213
+ normalized = api_url.strip()
214
+ if not normalized.startswith("/"):
215
+ raise ValueError("api_url must be an API path starting with '/'")
216
+ if normalized.endswith("/websocket"):
217
+ return normalized
218
+
219
+ path, _, api_id = normalized.rpartition("/")
220
+ if not path or not api_id:
221
+ raise ValueError("api_url must be an API path starting with '/'")
222
+
223
+ return path
224
+
225
+
226
+ def _parse_message(message: Any) -> Any:
227
+ if isinstance(message, bytes):
228
+ message = message.decode("utf-8")
229
+
230
+ if isinstance(message, str):
231
+ stripped = message.strip()
232
+ if not stripped:
233
+ return message
234
+ try:
235
+ return json.loads(stripped)
236
+ except json.JSONDecodeError:
237
+ return message
238
+
239
+ return message
240
+
241
+
242
+ def _is_login_message(message: Any) -> bool:
243
+ return isinstance(message, dict) and str(message.get("trnm", "")).upper() == "LOGIN"
244
+
245
+
246
+ def _is_ping_message(message: Any) -> bool:
247
+ if isinstance(message, str):
248
+ return message.strip().upper() == "PING"
249
+ if isinstance(message, dict):
250
+ return str(message.get("trnm", "")).upper() == "PING"
251
+ return False
252
+
253
+
254
+ def _serialize_message(message: Any) -> str:
255
+ if isinstance(message, str):
256
+ return message
257
+ return json.dumps(message, ensure_ascii=False)
258
+
259
+
260
+ def _is_connection_closed_exception(exc: Exception) -> bool:
261
+ name = exc.__class__.__name__
262
+ return "ConnectionClosed" in name or name.endswith("ClosedOK") or name.endswith("ClosedError")
@@ -0,0 +1,30 @@
1
+ from .decoders import decode_realtime_message, decode_realtime_message_named, decode_values
2
+ from .events import (
3
+ is_terminal_event,
4
+ normalize_message_events,
5
+ resolve_topic,
6
+ topic_fanout,
7
+ )
8
+ from .packets import build_reg_packet, build_remove_packet
9
+ from .stream import (
10
+ AsyncPubSub,
11
+ collect_realtime,
12
+ event_to_dataframe,
13
+ run_pubsub,
14
+ )
15
+
16
+ __all__ = [
17
+ "AsyncPubSub",
18
+ "build_reg_packet",
19
+ "build_remove_packet",
20
+ "collect_realtime",
21
+ "decode_realtime_message",
22
+ "decode_realtime_message_named",
23
+ "decode_values",
24
+ "event_to_dataframe",
25
+ "is_terminal_event",
26
+ "normalize_message_events",
27
+ "resolve_topic",
28
+ "run_pubsub",
29
+ "topic_fanout",
30
+ ]
@@ -0,0 +1,171 @@
1
+ """Decoder for Kiwoom WebSocket REAL messages.
2
+
3
+ Translates numeric FID keys in ``values`` dicts to Korean field names. The
4
+ ``{fid: 한글명}`` mapping is derived from the Kiwoom WebSocket spec
5
+ (``kiwoom_api_spec.json``): each realtime type's REAL ``values`` fields are the
6
+ depth-2 rows nested under ``values`` in the response body. The spec is the single
7
+ source of truth and names are returned verbatim; unknown FID keys pass through
8
+ unchanged so no data is lost.
9
+
10
+ Typical REAL message shape::
11
+
12
+ {
13
+ "trnm": "REAL",
14
+ "data": [
15
+ {
16
+ "type": "0B",
17
+ "name": "주식체결",
18
+ "item": "005930",
19
+ "values": {"10": "-82000", "15": "+12345", ...}
20
+ }
21
+ ]
22
+ }
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ from functools import lru_cache
28
+ from typing import Any
29
+
30
+ from kiwoom.realtime.schemas import get_realtime_schema
31
+
32
+
33
+ @lru_cache(maxsize=None)
34
+ def _fid_map(real_type: str) -> dict[str, str]:
35
+ """Extract ``{fid: 한글명}`` from the spec's REAL ``values`` rows (depth 2)."""
36
+ from kiwoom.specs import get_api_spec
37
+
38
+ try:
39
+ payload = get_api_spec(real_type)
40
+ except ValueError:
41
+ return {}
42
+
43
+ fid_map: dict[str, str] = {}
44
+ in_values = False
45
+ for row in payload.get("response", {}).get("body", []):
46
+ element = str(row.get("element", "")).strip()
47
+ depth = row.get("depth")
48
+ if element == "values" and depth == 1:
49
+ in_values = True
50
+ continue
51
+ if not in_values:
52
+ continue
53
+ if depth == 2 and element:
54
+ fid_map[element] = str(row.get("한글명", "")).strip()
55
+ elif isinstance(depth, int) and depth <= 1:
56
+ in_values = False
57
+ return fid_map
58
+
59
+
60
+ def fid_display_map(real_type: str) -> dict[str, str]:
61
+ """Return the spec-defined FID -> display name mapping for a realtime type."""
62
+ return dict(_fid_map(real_type))
63
+
64
+
65
+ def decode_values(real_type: str, values: dict[str, str]) -> dict[str, str]:
66
+ """Translate FID-keyed *values* dict to Korean field names.
67
+
68
+ Unknown FID keys are passed through unchanged so no data is lost.
69
+
70
+ Args:
71
+ real_type: REAL type ID, e.g. ``"0B"``.
72
+ values: Raw ``{"fid": "value"}`` dict from the server message.
73
+
74
+ Returns:
75
+ ``{"field_name": "value"}`` dict with human-readable keys.
76
+ """
77
+ fid_map = fid_display_map(real_type)
78
+ return {fid_map.get(k, k): v for k, v in values.items()}
79
+
80
+
81
+ def decode_realtime_message(message: dict[str, Any]) -> dict[str, Any]:
82
+ """Decode FID keys in all ``data[*].values`` entries of a REAL message.
83
+
84
+ Non-REAL messages (e.g. ACK, LOGIN) are returned unchanged.
85
+
86
+ Args:
87
+ message: Parsed WebSocket message dict.
88
+
89
+ Returns:
90
+ Copy of *message* with ``values`` dicts translated to field names.
91
+ """
92
+ if not isinstance(message, dict):
93
+ return message
94
+
95
+ if str(message.get("trnm", "")).upper() != "REAL":
96
+ return message
97
+
98
+ data = message.get("data")
99
+ if not isinstance(data, list):
100
+ return message
101
+
102
+ decoded_data = []
103
+ for entry in data:
104
+ if not isinstance(entry, dict):
105
+ decoded_data.append(entry)
106
+ continue
107
+
108
+ real_type = entry.get("type", "")
109
+ values = entry.get("values")
110
+
111
+ if isinstance(values, dict) and real_type:
112
+ decoded_data.append({**entry, "values": decode_values(real_type, values)})
113
+ else:
114
+ decoded_data.append(entry)
115
+
116
+ return {**message, "data": decoded_data}
117
+
118
+
119
+ def decode_realtime_message_named(message: dict[str, Any]) -> dict[str, Any]:
120
+ """Convert REAL message entries into named data objects.
121
+
122
+ Only ``trnm == "REAL"`` messages are decoded. Control frames such as ``REG``
123
+ and ``SYSTEM`` are returned unchanged. Values keep their original string
124
+ representation; this function changes keys/shape only and preserves unknown
125
+ FIDs under ``unknown``.
126
+ """
127
+ if not isinstance(message, dict):
128
+ return message
129
+
130
+ if str(message.get("trnm", "")).upper() != "REAL":
131
+ return message
132
+
133
+ data = message.get("data")
134
+ if not isinstance(data, list):
135
+ return message
136
+
137
+ named_data = []
138
+ for entry in data:
139
+ if not isinstance(entry, dict):
140
+ named_data.append(entry)
141
+ continue
142
+
143
+ real_type = str(entry.get("type", "")).strip()
144
+ values = entry.get("values")
145
+ schema = get_realtime_schema(real_type)
146
+ if not isinstance(values, dict) or schema is None:
147
+ named_data.append(entry)
148
+ continue
149
+
150
+ fields = schema.get("fields", {})
151
+ named_values: dict[str, Any] = {}
152
+ unknown: dict[str, Any] = {}
153
+ for fid, value in values.items():
154
+ field = fields.get(str(fid))
155
+ if field is None:
156
+ unknown[str(fid)] = value
157
+ continue
158
+ named_values[str(field["name"])] = value
159
+
160
+ named_entry: dict[str, Any] = {
161
+ "event": schema.get("event", real_type),
162
+ "type": real_type,
163
+ "name": entry.get("name") or schema.get("name", ""),
164
+ "code": entry.get("item", ""),
165
+ "data": named_values,
166
+ }
167
+ if unknown:
168
+ named_entry["unknown"] = unknown
169
+ named_data.append(named_entry)
170
+
171
+ return {**message, "data": named_data}
@@ -0,0 +1,98 @@
1
+ """Generic helpers for turning Kiwoom WebSocket messages into pub/sub events.
2
+
3
+ These utilities are intentionally API-agnostic: they only depend on the shape of
4
+ Kiwoom WebSocket frames (``trnm`` / ``data`` / ``values``), not on any specific
5
+ realtime type. Examples keep their own field label table (``COLUMNS``) inline and
6
+ pass it in, so a single example still shows which fields it produces while the
7
+ routing/normalization plumbing lives here.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import Any
13
+
14
+
15
+ def normalize_message_events(
16
+ message: Any,
17
+ columns: dict[str, str] | None = None,
18
+ ) -> list[dict[str, Any]]:
19
+ """Convert a received WebSocket message into a list of consumer events.
20
+
21
+ A ``REAL`` frame is expanded into one event per ``data`` entry, with FID keys
22
+ in ``values`` translated through *columns* (unknown keys pass through). Any
23
+ other frame (REG/SYSTEM/CNSR*/...) is returned unchanged as a single event.
24
+
25
+ Args:
26
+ message: Parsed WebSocket message.
27
+ columns: Optional ``{fid: label}`` map applied to ``values``.
28
+
29
+ Returns:
30
+ A list of event dicts ready to publish.
31
+ """
32
+ if not isinstance(message, dict):
33
+ return [{"trnm": "RAW", "message": message}]
34
+
35
+ trnm = str(message.get("trnm", "")).upper()
36
+ if trnm != "REAL":
37
+ return [{**message, "trnm": trnm or message.get("trnm", "")}]
38
+
39
+ column_map = columns or {}
40
+ events: list[dict[str, Any]] = []
41
+ data = message.get("data", [])
42
+ if not isinstance(data, list):
43
+ return [{"trnm": "REAL", "message": message}]
44
+
45
+ for entry in data:
46
+ if not isinstance(entry, dict):
47
+ continue
48
+ raw_values = entry.get("values")
49
+ raw_values = raw_values if isinstance(raw_values, dict) else {}
50
+ values = {column_map.get(key, key): value for key, value in raw_values.items()}
51
+ event: dict[str, Any] = {
52
+ "trnm": "REAL",
53
+ "item": entry.get("item", ""),
54
+ "type": str(entry.get("type", "")),
55
+ }
56
+ if "name" in entry:
57
+ event["name"] = entry.get("name", "")
58
+ event.update(values)
59
+ events.append(event)
60
+ return events
61
+
62
+
63
+ def resolve_topic(event: dict[str, Any]) -> str:
64
+ """Decide the representative topic for a normalized *event*."""
65
+ if not isinstance(event, dict):
66
+ return "kiwoom.raw"
67
+
68
+ trnm = str(event.get("trnm", "")).upper()
69
+ if trnm == "REAL":
70
+ realtime_type = str(event.get("type", "")).strip()
71
+ if realtime_type:
72
+ return f"kiwoom.realtime.{realtime_type}"
73
+ return "kiwoom.realtime"
74
+ if trnm == "REG":
75
+ return "kiwoom.system.reg"
76
+ if trnm == "SYSTEM":
77
+ return "kiwoom.system"
78
+ if trnm:
79
+ return f"kiwoom.system.{trnm.lower()}"
80
+ return "kiwoom.raw"
81
+
82
+
83
+ def topic_fanout(topic: str) -> list[str]:
84
+ """Expand a representative topic to the shared topics it also publishes to."""
85
+ topics = [topic]
86
+ if topic.startswith("kiwoom.realtime."):
87
+ topics.append("kiwoom.realtime")
88
+ if topic.startswith("kiwoom.system."):
89
+ topics.append("kiwoom.system")
90
+ topics.append("kiwoom.all")
91
+ return list(dict.fromkeys(topics))
92
+
93
+
94
+ def is_terminal_event(event: dict[str, Any]) -> bool:
95
+ """Whether *event* should stop the receive loop (SYSTEM or error return_code)."""
96
+ trnm = str(event.get("trnm", "")).upper()
97
+ return_code = event.get("return_code")
98
+ return trnm == "SYSTEM" or return_code not in (None, 0, "0")
@@ -0,0 +1,65 @@
1
+ """Builders for Kiwoom realtime WebSocket control packets.
2
+
3
+ These helpers only encode the Kiwoom realtime request contract (``REG`` to
4
+ register, ``REMOVE`` to unregister). They are API-agnostic: callers pass the
5
+ items and realtime types they want, so a single builder serves every realtime
6
+ example without per-API logic.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any
12
+
13
+
14
+ def build_reg_packet(
15
+ items: list[str],
16
+ types: list[str],
17
+ *,
18
+ group_no: str = "1",
19
+ refresh: str = "1",
20
+ ) -> dict[str, Any]:
21
+ """Build a Kiwoom realtime registration (``REG``) packet.
22
+
23
+ Args:
24
+ items: Symbols or elements to register (e.g. ``["005930"]``).
25
+ types: Realtime type codes to register (e.g. ``["0B"]``).
26
+ group_no: Group number.
27
+ refresh: ``"1"`` keeps existing registrations, ``"0"`` replaces them.
28
+
29
+ Returns:
30
+ A packet dict ready to send over the realtime WebSocket.
31
+ """
32
+ if not types:
33
+ raise ValueError("types is required.")
34
+ return {
35
+ "trnm": "REG",
36
+ "grp_no": group_no,
37
+ "refresh": refresh,
38
+ "data": [
39
+ {
40
+ "item": items,
41
+ "type": types,
42
+ }
43
+ ],
44
+ }
45
+
46
+
47
+ def build_remove_packet(
48
+ items: list[str],
49
+ types: list[str],
50
+ *,
51
+ group_no: str = "1",
52
+ ) -> dict[str, Any]:
53
+ """Build a Kiwoom realtime unregistration (``REMOVE``) packet."""
54
+ if not types:
55
+ raise ValueError("types is required.")
56
+ return {
57
+ "trnm": "REMOVE",
58
+ "grp_no": group_no,
59
+ "data": [
60
+ {
61
+ "item": items,
62
+ "type": types,
63
+ }
64
+ ],
65
+ }