Coverage for common / types.py: 92%

345 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-04 15:08 +0000

1# common/types.py 

2from typing import Optional, Generic, TypeVar, Type, Any, Dict 

3from enum import Enum, auto 

4from pydantic import BaseModel, Field, model_validator, ConfigDict 

5 

6T = TypeVar("T") 

7 

8 

9class Exchange(str, Enum): 

10 KRX = "KRX" 

11 NXT = "NXT" 

12 UN = "UN" # 통합시세 (KRX+NXT, 시세 조회 전용) 

13 

14 

15# API 응답 결과의 성공/실패를 나타내는 Enum 

16class ErrorCode(Enum): 

17 SUCCESS = "0" 

18 API_ERROR = "100" # 외부 API 호출 실패 

19 PARSING_ERROR = "101" # 응답 파싱 실패 

20 INVALID_INPUT = "102" # 유효성 오류 

21 NETWORK_ERROR = "103" # 네트워크 오류 

22 MISSING_KEY = "104" # MISSING_KEY 

23 RETRY_LIMIT = "105" # RETRY_LIMIT 

24 WRONG_RET_TYPE = "106" # WRONG_RET_TYPE 

25 EMPTY_VALUES = "107" # 조회 결과 없음 

26 MARKET_CLOSED = "108" # 장 마감 

27 UNKNOWN_ERROR = "999" # 기타 오류 

28 

29 @property 

30 def is_retriable(self) -> bool: 

31 """서비스 레벨에서 재시도 가능한 오류인지 반환.""" 

32 return self in (ErrorCode.NETWORK_ERROR, ErrorCode.RETRY_LIMIT) 

33 

34 

35# --- 전략 신호 --- 

36class TradeSignal(BaseModel): 

37 """전략에서 생성하는 표준 매수/매도 신호.""" 

38 code: str 

39 name: str 

40 action: str # "BUY" / "SELL" 

41 price: int 

42 qty: int = 1 

43 reason: str = "" 

44 strategy_name: str = "" 

45 exchange: str = "KRX" # "KRX" 또는 "NXT" 

46 

47 def to_dict(self): 

48 return self.model_dump() 

49 

50 

51# --- 공통적으로 사용되는 데이터 응답 구조 --- 

52class ResPriceSummary(BaseModel): 

53 symbol: str 

54 open: int 

55 current: int 

56 change_rate: float 

57 prdy_ctrt: float 

58 new_high_low_status: Optional[str] = None 

59 

60 def to_dict(self): 

61 return self.model_dump() 

62 

63 @classmethod 

64 def from_dict(cls, data: dict): 

65 return cls.model_validate(data) 

66 

67 

68class ResMomentumStock(BaseModel): 

69 symbol: str 

70 change_rate: float 

71 prev_volume: int 

72 current_volume: int 

73 

74 def to_dict(self): 

75 return self.model_dump() 

76 

77 @classmethod 

78 def from_dict(cls, data: dict): 

79 return cls.model_validate(data) 

80 

81 

82class ResMarketCapStockItem(BaseModel): 

83 rank: Optional[str] 

84 name: Optional[str] 

85 code: str 

86 current_price: Optional[str] 

87 

88 def to_dict(self): 

89 return self.model_dump() 

90 

91 @classmethod 

92 def from_dict(cls, data: dict): 

93 return cls.model_validate(data) 

94 

95 

96# --- 한국투자증권 API 특화 응답 구조 --- 

97 

98# --- 한국투자증권 API 특화 응답 구조 (종목 상세정보) --- 

99 

100 

101class ResStockFullInfoApiOutput(BaseModel): 

102 acml_tr_pbmn: str = "" # 누적 거래 대금 (원) 

103 acml_vol: str = "" # 누적 거래량 (주) 

104 aspr_unit: str = "" # 호가 단위 

105 bps: str = "" # 주당순자산 (Book-value Per Share) 

106 bstp_kor_isnm: str = "" # 업종명 (예: 일반서비스) 

107 clpr_rang_cont_yn: str = "" # 종가 범위제 적용 여부 

108 cpfn: str = "" # 자본금 (억원) 

109 cpfn_cnnm: str = "" # 자본금 (단위표기 포함, 예: 468 억) 

110 crdt_able_yn: str = "" # 신용거래 가능 여부 (Y/N) 

111 d250_hgpr: str = "" # 250일 최고가 

112 d250_hgpr_date: str = "" # 250일 최고가 기록일 

113 d250_hgpr_vrss_prpr_rate: str = "" # 현재가 대비 250일 최고가 등락률 (%) 

114 d250_lwpr: str = "" # 250일 최저가 

115 d250_lwpr_date: str = "" # 250일 최저가 기록일 

116 d250_lwpr_vrss_prpr_rate: str = "" # 현재가 대비 250일 최저가 등락률 (%) 

117 dmrs_val: str = "" # 매도호가 1 (최우선) 

118 dmsp_val: str = "" # 매수호가 1 (최우선) 

119 dryy_hgpr_date: str = "" # 당해 연도 최고가 기록일 

120 dryy_hgpr_vrss_prpr_rate: str = "" # 현재가 대비 연중 최고가 등락률 (%) 

121 dryy_lwpr_date: str = "" # 당해 연도 최저가 기록일 

122 dryy_lwpr_vrss_prpr_rate: str = "" # 현재가 대비 연중 최저가 등락률 (%) 

123 elw_pblc_yn: str = "" # ELW 발행 여부 

124 eps: str = "" # 주당순이익 (Earnings Per Share) 

125 fcam_cnnm: str = "" # 액면가 (단위 포함, 예: 500 원) 

126 frgn_hldn_qty: str = "" # 외국인 보유 수량 

127 frgn_ntby_qty: str = "" # 외국인 순매매 수량 

128 grmn_rate_cls_code: str = "" # 결산월 분류코드 

129 hts_avls: str = "" # HTS 시가총액 (원) 

130 hts_deal_qty_unit_val: str = "" # HTS 거래량 단위 값 

131 hts_frgn_ehrt: str = "" # HTS 외국인 보유율 (%) 

132 invt_caful_yn: str = "" # 투자주의 종목 여부 

133 iscd_stat_cls_code: str = "" # 종목 상태 코드 

134 last_ssts_cntg_qty: str = "" # 직전 체결 수량 

135 lstn_stcn: str = "" # 상장 주식수 (주) 

136 mang_issu_cls_code: str = "" # 관리종목 구분 코드 

137 marg_rate: str = "" # 증거금율 (%) 

138 mrkt_warn_cls_code: str = "" # 시장경고종목 분류코드 

139 new_hgpr_lwpr_cls_code: Optional[str] = None # 신고가/신저가 구분 코드 

140 oprc_rang_cont_yn: str = "" # 시가 범위제 적용 여부 

141 ovtm_vi_cls_code: str = "" # 시간외 VI 발동 분류코드 (NXT 응답에 미포함) 

142 pbr: str = "" # 주가순자산비율 (Price to Book Ratio) 

143 per: str = "" # 주가수익비율 (Price Earnings Ratio) 

144 pgtr_ntby_qty: str = "" # 프로그램 매매 순매수 수량 

145 prdy_ctrt: str = "" # 전일 대비 등락률 (%) 

146 prdy_vrss: str = "" # 전일 대비 등락금액 

147 prdy_vrss_sign: str = "" # 전일 대비 부호 (1:상승, 2:하락, 3:보합) 

148 prdy_vrss_vol_rate: str = "" # 전일 대비 거래량 증감률 (%) 

149 pvt_frst_dmrs_prc: str = "" # 예상체결가 첫번째 매도호가 

150 pvt_frst_dmsp_prc: str = "" # 예상체결가 첫번째 매수호가 

151 pvt_pont_val: str = "" # 예상체결가 기준 예상 체결가 

152 pvt_scnd_dmrs_prc: str = "" # 예상체결가 두번째 매도호가 

153 pvt_scnd_dmsp_prc: str = "" # 예상체결가 두번째 매수호가 

154 rprs_mrkt_kor_name: str = "" # 대표시장명 (예: 코스피, 코스닥) 

155 rstc_wdth_prc: str = "" # 가격제한폭 (상하한가 차이) 

156 short_over_yn: str = "" # 공매도 과열 여부 (Y/N) 

157 sltr_yn: str = "" # 정리매매 여부 (Y/N) 

158 ssts_yn: str = "" # 정지 여부 (Y/N) 

159 stac_month: str = "" # 결산월 (NXT 응답에 미포함) 

160 stck_dryy_hgpr: str = "" # 당해 연도 최고가 

161 stck_dryy_lwpr: str = "" # 당해 연도 최저가 

162 stck_fcam: str = "" # 액면가 

163 stck_hgpr: str # 금일 고가 

164 stck_llam: str = "" # 종가 기준 시가총액 (원) 

165 stck_lwpr: str # 금일 저가 

166 stck_mxpr: str = "" # 상한가 

167 stck_oprc: str # 시가 

168 stck_prpr: str # 현재가 

169 stck_sdpr: str # 기준가 (기준가격) 

170 stck_shrn_iscd: str = "" # 단축 종목코드 

171 stck_sspr: str = "" # 하한가 

172 temp_stop_yn: str = "" # 일시 정지 여부 (Y/N) 

173 vi_cls_code: str = "" # VI (변동성완화장치) 발동 여부 

174 vol_tnrt: str = "" # 거래 회전율 (%) 

175 w52_hgpr: str = "" # 52주 최고가 

176 w52_hgpr_date: str = "" # 52주 최고가 기록일 

177 w52_hgpr_vrss_prpr_ctrt: str = "" # 현재가 대비 52주 최고가 등락률 (%) 

178 w52_lwpr: str = "" # 52주 최저가 

179 w52_lwpr_date: str = "" # 52주 최저가 기록일 

180 w52_lwpr_vrss_prpr_ctrt: str = "" # 현재가 대비 52주 최저가 등락률 (%) 

181 wghn_avrg_stck_prc: str = "" # 가중평균주가 

182 whol_loan_rmnd_rate: str = "" # 전체 대주잔고 비율 (%) 

183 

184 @property 

185 def is_new_high(self) -> bool: 

186 return self.new_hgpr_lwpr_cls_code in ("1", "신고가") 

187 

188 @property 

189 def is_new_low(self) -> bool: 

190 return self.new_hgpr_lwpr_cls_code in ("2", "신저가") 

191 

192 @property 

193 def new_high_low_status(self) -> str: 

194 if self.is_new_high: 

195 return "신고가" 

196 if self.is_new_low: 

197 return "신저가" 

198 return "-" 

199 

200 def to_dict(self): 

201 return self.model_dump() 

202 

203 @classmethod 

204 def from_dict(cls, data: dict, log_missing: bool = True) -> "ResStockFullInfoApiOutput": 

205 return cls.model_validate(data) 

206 

207 

208class ResTopMarketCapApiItem(BaseModel): 

209 """ 

210 [시가총액 상위 종목 응답 아이템] 

211 - 모든 수치는 API가 문자열(String)로 반환하므로 str 유지 (대형 정수/소수 정밀도 보존 목적) 

212 - KIS 스펙(필드/의미/길이)을 주석으로 명시 

213 """ 

214 

215 # ── 필수/핵심 필드 ───────────────────────────────────────────────────────────── 

216 mksc_shrn_iscd: str # 유가증권 단축 종목코드 (String, len<=9) e.g. '005930' 

217 data_rank: str # 데이터 순위 (String, len<=10) e.g. '1' 

218 hts_kor_isnm: str # HTS 한글 종목명 (String, len<=40) e.g. '삼성전자' 

219 stck_avls: str # 시가총액 (String, len<=18) e.g. '467000000000000' 

220 

221 # ── 시세/등락 관련(옵셔널) ──────────────────────────────────────────────────── 

222 stck_prpr: Optional[str] = None # 주식 현재가 (String, len<=10) 

223 prdy_vrss: Optional[str] = None # 전일 대비 (가격 차이) (String, len<=10) 

224 prdy_vrss_sign: Optional[str] = None # 전일 대비 부호 (String, len<=1) e.g. '1', '2', '3' 등 

225 prdy_ctrt: Optional[str] = None # 전일 대비율 (String, len<=82) e.g. '2.31' 

226 

227 # ── 거래/상장 관련(옵셔널) ──────────────────────────────────────────────────── 

228 acml_vol: Optional[str] = None # 누적 거래량 (String, len<=18) 

229 lstn_stcn: Optional[str] = None # 상장 주수 (String, len<=18) 

230 

231 # ── 시장 비중(옵셔널) ──────────────────────────────────────────────────────── 

232 mrkt_whol_avls_rlim: Optional[str] = None # 시장 전체 시총 대비 비중 (String, len<=52) 

233 

234 # ── 과거 코드 호환용 Alias (옵셔널) ────────────────────────────────────────── 

235 # 기존 일부 로직/테스트가 참조하던 필드들: 

236 iscd: Optional[str] = None # (호환) 단축코드 별칭. 없으면 mksc_shrn_iscd로 채움 

237 acc_trdvol: Optional[str] = None # (호환) 누적 거래량 별칭. 없으면 acml_vol로 채움 

238 

239 @model_validator(mode='after') 

240 def sync_aliases(self) -> "ResTopMarketCapApiItem": 

241 # 과거 호환: iscd가 없으면 mksc_shrn_iscd로 보완 

242 if not self.iscd: 

243 self.iscd = self.mksc_shrn_iscd 

244 # 과거 호환: acc_trdvol <-> acml_vol 동기화 

245 if self.acc_trdvol and not self.acml_vol: 

246 self.acml_vol = self.acc_trdvol 

247 elif self.acml_vol and not self.acc_trdvol: 

248 self.acc_trdvol = self.acml_vol 

249 return self 

250 

251 def to_dict(self): 

252 """Dict 직렬화(테스트/로그용).""" 

253 return self.model_dump() 

254 

255 @classmethod 

256 def from_dict(cls, data: dict): 

257 return cls.model_validate(data) 

258 

259 # 선택: 원시 API payload를 받아 alias 키까지 정규화하고 생성하고 싶다면 사용 

260 @classmethod 

261 def from_api(cls, payload: dict) -> "ResTopMarketCapApiItem": 

262 """ 

263 - 공식 스펙 키 우선(mksc_shrn_iscd, data_rank, hts_kor_isnm, stck_avls, stck_prpr, prdy_vrss, prdy_vrss_sign, 

264 prdy_ctrt, acml_vol, lstn_stcn, mrkt_whol_avls_rlim) 

265 - 구키(alias)도 허용(iscd -> mksc_shrn_iscd, acc_trdvol -> acml_vol) 

266 """ 

267 norm = dict(payload) if payload else {} 

268 

269 # 단축코드 alias 

270 if "mksc_shrn_iscd" not in norm and "iscd" in norm: 270 ↛ 274line 270 didn't jump to line 274 because the condition on line 270 was always true

271 norm["mksc_shrn_iscd"] = norm.get("iscd") 

272 

273 # 거래량 alias 

274 if "acml_vol" not in norm and "acc_trdvol" in norm: 274 ↛ 278line 274 didn't jump to line 278 because the condition on line 274 was always true

275 norm["acml_vol"] = norm.get("acc_trdvol") 

276 

277 # Pydantic 생성 

278 return cls.model_validate(norm) 

279 

280 

281class ResDailyChartApiItem(BaseModel): 

282 stck_bsop_date: str 

283 stck_oprc: str 

284 stck_hgpr: str 

285 stck_lwpr: str 

286 stck_clpr: str 

287 acml_vol: str = "" 

288 

289 def to_dict(self): 

290 return self.model_dump() 

291 

292 @classmethod 

293 def from_dict(cls, data: dict): 

294 return cls.model_validate(data) 

295 

296 

297class ResAccountBalanceApiOutput(BaseModel): 

298 pdno: str 

299 prdt_name: str 

300 evlu_amt: str 

301 

302 def to_dict(self): 

303 return self.model_dump() 

304 

305 @classmethod 

306 def from_dict(cls, data: dict): 

307 return cls.model_validate(data) 

308 

309 

310class ResStockOrderApiOutput(BaseModel): 

311 ordno: str 

312 prdt_no: str 

313 

314 def to_dict(self): 

315 return self.model_dump() 

316 

317 @classmethod 

318 def from_dict(cls, data: dict): 

319 return cls.model_validate(data) 

320 

321 

322# 종목 요약 정보 응답 구조 (상승률 기반 필터링용 등) 

323class ResBasicStockInfo(BaseModel): 

324 code: str 

325 name: str 

326 # open_price: int 

327 current_price: int 

328 change_rate: float 

329 prdy_ctrt: float 

330 

331 def to_dict(self): 

332 return self.model_dump() 

333 

334 @classmethod 

335 def from_dict(cls, data: dict): 

336 return cls.model_validate(data) 

337 

338 

339class ResFluctuation(BaseModel): 

340 stck_shrn_iscd: str #주식 단축 종목코드 

341 data_rank: str = "" #데이터 순위 

342 hts_kor_isnm: str = "" #HTS 한글 종목명 

343 stck_prpr: str #주식 현재가 

344 prdy_vrss: str = "" #전일 대비 

345 prdy_vrss_sign: str = "" #전일 대비 부호 

346 prdy_ctrt: str = "" #전일 대비율 

347 acml_vol: str = "" #누적 거래량 

348 stck_hgpr: str = "" #주식 최고가 

349 hgpr_hour: str = "" #최고가 시간 

350 acml_hgpr_date: str = "" #누적 최고가 일자 

351 stck_lwpr: str = "" #주식 최저가 

352 lwpr_hour: str = "" #최저가 시간 

353 acml_lwpr_date: str = "" #누적 최저가 일자 

354 lwpr_vrss_prpr_rate: str = "" #최저가 대비 현재가 비율 

355 dsgt_date_clpr_vrss_prpr_rate: str = "" #지정 일자 종가 대비 현재가 비 

356 cnnt_ascn_dynu: str = "" #연속 상승 일수 

357 hgpr_vrss_prpr_rate: str = "" #최고가 대비 현재가 비율 

358 cnnt_down_dynu: str = "" #연속 하락 일수 

359 oprc_vrss_prpr_sign: str = "" #시가2 대비 현재가 부호 

360 oprc_vrss_prpr: str = "" #시가2 대비 현재가 

361 oprc_vrss_prpr_rate: str = "" #시가2 대비 현재가 비율 

362 prd_rsfl: str = "" #기간 등락 

363 prd_rsfl_rate: str = "" #기간 등락 비율 

364 

365 def to_dict(self): 

366 return self.model_dump() 

367 

368 @classmethod 

369 def from_dict(cls, data: dict) -> "ResFluctuation": 

370 # Pydantic handles missing fields if Optional, or we can use validator 

371 return cls.model_validate(data) 

372 

373 

374class ResBollingerBand(BaseModel): 

375 code: str 

376 date: str 

377 close: Optional[float] 

378 middle: Optional[float] 

379 upper: Optional[float] 

380 lower: Optional[float] 

381 

382 def to_dict(self): 

383 return self.model_dump() 

384 

385 @classmethod 

386 def from_dict(cls, data: dict): 

387 return cls.model_validate(data) 

388 

389 

390class ResRSI(BaseModel): 

391 code: str 

392 date: str 

393 close: float 

394 rsi: float 

395 

396 def to_dict(self): 

397 return self.model_dump() 

398 

399 @classmethod 

400 def from_dict(cls, data: dict): 

401 return cls.model_validate(data) 

402 

403 

404class ResMovingAverage(BaseModel): 

405 code: str 

406 date: str 

407 close: float 

408 ma: Optional[float] 

409 

410 def to_dict(self): 

411 return self.model_dump() 

412 

413 @classmethod 

414 def from_dict(cls, data: dict): 

415 return cls.model_validate(data) 

416 

417 

418class ResRelativeStrength(BaseModel): 

419 """N일 수익률 (상대강도 원시값).""" 

420 code: str 

421 date: str 

422 return_pct: float # N일 수익률 (%) 

423 

424 def to_dict(self): 

425 return self.model_dump() 

426 

427 

428# --- 공통 응답 구조 (유지 또는 dataclass로 래핑 가능) --- 

429 

430class ResCommonResponse(BaseModel, Generic[T]): 

431 rt_cd: str 

432 msg1: str 

433 data: Optional[T] = None 

434 

435 def to_dict(self): 

436 data_serialized = None 

437 if hasattr(self.data, 'to_dict') and callable(getattr(self.data, 'to_dict')): 

438 # data 필드 자체가 to_dict를 가진 객체인 경우 

439 data_serialized = self.data.to_dict() 

440 elif isinstance(self.data, BaseModel): 440 ↛ 441line 440 didn't jump to line 441 because the condition on line 440 was never true

441 data_serialized = self.data.model_dump() 

442 elif isinstance(self.data, (list, tuple)): 

443 # data 필드가 리스트/튜플인 경우, 내부 항목들도 재귀적으로 직렬화 

444 data_serialized = [] 

445 for item in self.data: 

446 if hasattr(item, 'to_dict') and callable(getattr(item, 'to_dict')): 

447 data_serialized.append(item.to_dict()) 

448 elif isinstance(item, BaseModel): 448 ↛ 449line 448 didn't jump to line 449 because the condition on line 448 was never true

449 data_serialized.append(item.model_dump()) 

450 elif isinstance(item, (list, tuple, dict)): # 중첩된 리스트/딕셔너리도 처리 

451 # 여기서 재귀적으로 self._serialize 같은 함수를 사용하면 좋지만, 

452 # types.py에서는 cache_store._serialize를 직접 호출할 수 없으므로, 

453 # to_dict를 가진 객체만 처리하거나, 더 일반적인 재귀 직렬화 로직을 이 안에 구현해야 함. 

454 # 일단은 to_dict를 가진 객체만 처리하도록 간소화합니다. 

455 data_serialized.append(item) # to_dict 없는 객체는 그대로 

456 else: 

457 data_serialized.append(item) 

458 elif isinstance(self.data, dict): 

459 # data 필드가 딕셔너리인 경우, 내부 값들도 재귀적으로 직렬화 

460 data_serialized = {} 

461 for k, v in self.data.items(): 

462 if hasattr(v, 'to_dict') and callable(getattr(v, 'to_dict')): 

463 data_serialized[k] = v.to_dict() 

464 elif isinstance(v, BaseModel): 464 ↛ 465line 464 didn't jump to line 465 because the condition on line 464 was never true

465 data_serialized[k] = v.model_dump() 

466 elif isinstance(v, (list, tuple, dict)): # 중첩된 리스트/딕셔너리도 처리 

467 data_serialized[k] = v # to_dict 없는 객체는 그대로 

468 else: 

469 data_serialized[k] = v 

470 else: 

471 # 그 외 기본 JSON 직렬화 가능 타입은 그대로 반환 

472 data_serialized = self.data 

473 

474 return { 

475 "rt_cd": self.rt_cd, 

476 "msg1": self.msg1, 

477 "data": data_serialized 

478 } 

479 

480 @classmethod 

481 def from_dict(cls, data: dict): 

482 return cls.model_validate(data)