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
|
@@ -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
|
+
)
|