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.
- kiwoom/__init__.py +47 -0
- kiwoom/_data/kiwoom_api_spec.json +66372 -0
- kiwoom/core/__init__.py +6 -0
- kiwoom/core/auth.py +349 -0
- kiwoom/core/client.py +244 -0
- kiwoom/core/errors.py +177 -0
- kiwoom/core/platform_paths.py +68 -0
- kiwoom/core/profiles.py +143 -0
- kiwoom/core/runtime.py +153 -0
- kiwoom/core/secrets.py +217 -0
- kiwoom/core/settings.py +64 -0
- kiwoom/core/token_store.py +186 -0
- kiwoom/core/types.py +28 -0
- kiwoom/core/ws_client.py +262 -0
- kiwoom/realtime/__init__.py +30 -0
- kiwoom/realtime/decoders.py +171 -0
- kiwoom/realtime/events.py +98 -0
- kiwoom/realtime/packets.py +65 -0
- kiwoom/realtime/schemas.py +76 -0
- kiwoom/realtime/stream.py +213 -0
- kiwoom/specs.py +272 -0
- kiwoom_cli/README.md +671 -0
- kiwoom_cli/__init__.py +9 -0
- kiwoom_cli/__main__.py +5 -0
- kiwoom_cli/argument_maps.py +561 -0
- kiwoom_cli/arguments.py +147 -0
- kiwoom_cli/auth_context.py +64 -0
- kiwoom_cli/banner.py +125 -0
- kiwoom_cli/commands/__init__.py +1 -0
- kiwoom_cli/commands/auth.py +406 -0
- kiwoom_cli/commands/groups.py +281 -0
- kiwoom_cli/commands/mapped.py +74 -0
- kiwoom_cli/commands/orders.py +158 -0
- kiwoom_cli/commands/spec.py +98 -0
- kiwoom_cli/commands/stocks.py +100 -0
- kiwoom_cli/commands/streams.py +470 -0
- kiwoom_cli/doctor.py +324 -0
- kiwoom_cli/errors.py +24 -0
- kiwoom_cli/executor/__init__.py +33 -0
- kiwoom_cli/executor/condition.py +374 -0
- kiwoom_cli/executor/rest.py +132 -0
- kiwoom_cli/executor/waits.py +34 -0
- kiwoom_cli/executor/websocket.py +131 -0
- kiwoom_cli/main.py +203 -0
- kiwoom_cli/maps/README.md +99 -0
- kiwoom_cli/maps/api_commands.csv +209 -0
- kiwoom_cli/maps/arguments.csv +731 -0
- kiwoom_cli/maps/order_confirmation_commands.csv +13 -0
- kiwoom_cli/maps/order_confirmation_fields.csv +71 -0
- kiwoom_cli/maps/order_price_policies.csv +47 -0
- kiwoom_cli/maps/order_value_labels.csv +28 -0
- kiwoom_cli/maps/positional_arguments.csv +21 -0
- kiwoom_cli/order_confirmation.py +167 -0
- kiwoom_cli/output.py +136 -0
- kiwoom_cli/registry.py +95 -0
- kiwoom_cli/safety.py +30 -0
- kiwoom_cli/setup.py +533 -0
- kwcli-0.1.0.dist-info/METADATA +215 -0
- kwcli-0.1.0.dist-info/RECORD +62 -0
- kwcli-0.1.0.dist-info/WHEEL +4 -0
- kwcli-0.1.0.dist-info/entry_points.txt +2 -0
- kwcli-0.1.0.dist-info/licenses/LICENSE.md +36 -0
kiwoom/core/ws_client.py
ADDED
|
@@ -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
|
+
}
|