analyser_hj3415 2.0.0__py2.py3-none-any.whl

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,934 @@
1
+ import pymongo
2
+ import sys
3
+
4
+ import copy
5
+ from pymongo import errors, MongoClient
6
+ import math
7
+ import random
8
+ import datetime
9
+ from typing import List, Tuple
10
+ from collections import OrderedDict
11
+ from abc import *
12
+ from utils_hj3415 import utils
13
+ import pandas as pd
14
+
15
+
16
+ import logging
17
+ logger = logging.getLogger(__name__)
18
+ formatter = logging.Formatter('%(levelname)s: [%(name)s] %(message)s')
19
+ ch = logging.StreamHandler()
20
+ ch.setFormatter(formatter)
21
+ logger.addHandler(ch)
22
+ logger.setLevel(logging.WARNING)
23
+
24
+ """
25
+ 몽고db구조
26
+ RDBMS : database / tables / rows / columns
27
+ MongoDB : database / collections / documents / fields
28
+ """
29
+
30
+
31
+ class UnableConnectServerException(Exception):
32
+ """
33
+ 몽고 서버 연결 에러를 처리하기 위한 커스텀 익셉션
34
+ """
35
+ pass
36
+
37
+
38
+ def connect_mongo(addr: str, timeout=5) -> MongoClient:
39
+ """
40
+ 몽고 클라이언트를 만들어주는 함수.
41
+ 필요할 때마다 클라이언트를 생성하는 것보다 클라이언트 한개로 데이터베이스를 다루는게 효율적이라 함수를 따로 뺐음.
42
+ resolve conn error - https://stackoverflow.com/questions/54484890/ssl-handshake-issue-with-pymongo-on-python3
43
+ :param addr:
44
+ :param timeout:
45
+ :return:
46
+ """
47
+ import certifi
48
+ ca = certifi.where()
49
+ if addr.startswith('mongodb://'):
50
+ # set a some-second connection timeout
51
+ client = pymongo.MongoClient(addr, serverSelectionTimeoutMS=timeout * 1000)
52
+ elif addr.startswith('mongodb+srv://'):
53
+ client = pymongo.MongoClient(addr, serverSelectionTimeoutMS=timeout * 1000, tlsCAFile=ca)
54
+ else:
55
+ raise Exception(f"Invalid address: {addr}")
56
+ try:
57
+ srv_info = client.server_info()
58
+ conn_str = f"Connect to Mongo Atlas v{srv_info['version']}..."
59
+ print(conn_str, f"Server Addr : {addr}")
60
+ return client
61
+ except:
62
+ raise UnableConnectServerException()
63
+
64
+
65
+ def extract_addr_from_client(client: MongoClient) -> str:
66
+ """
67
+ scaraper는 클라이언트를 인자로 받지않고 주소를 받아서 사용하기 때문에 사용하는 주소 추출함수
68
+ """
69
+ # client에서 mongodb주소 추출
70
+ ip = str(list(client.nodes)[0][0])
71
+ port = str(list(client.nodes)[0][1])
72
+ return 'mongodb://' + ip + ':' + port
73
+
74
+
75
+ class MongoBase:
76
+ def __init__(self, client: MongoClient, db_name: str, col_name: str):
77
+ self._db_name = "test"
78
+ self._col_name = "test"
79
+ self.client = client
80
+ self.db_name = db_name
81
+ self.col_name = col_name
82
+
83
+ @property
84
+ def db_name(self):
85
+ return self._db_name
86
+
87
+ @db_name.setter
88
+ def db_name(self, db_name):
89
+ if self.client is None:
90
+ raise Exception("You should set server connection first")
91
+ else:
92
+ self._db_name = db_name
93
+ self.my_db = self.client[self.db_name]
94
+ self.my_col = self.my_db[self.col_name]
95
+
96
+ @property
97
+ def col_name(self):
98
+ return self._col_name
99
+
100
+ @col_name.setter
101
+ def col_name(self, col_name):
102
+ if self.db_name is None:
103
+ raise Exception("You should set database first.")
104
+ else:
105
+ self._col_name = col_name
106
+ self.my_col = self.my_db[self.col_name]
107
+
108
+ # ========================End Properties=======================
109
+
110
+ @staticmethod
111
+ def get_all_db_name(client: MongoClient) -> list:
112
+ return sorted(client.list_database_names())
113
+
114
+ @staticmethod
115
+ def validate_db(client: MongoClient, db_name: str) -> bool:
116
+ """
117
+ db_name 이 실제로 몽고db 안에 있는지 확인한다.
118
+ """
119
+ if db_name in client.list_database_names():
120
+ return True
121
+ else:
122
+ return False
123
+
124
+ @staticmethod
125
+ def del_db(client: MongoClient, db_name: str):
126
+ if MongoBase.validate_db(client, db_name):
127
+ client.drop_database(db_name)
128
+ print(f"Drop '{db_name}' database..")
129
+ else:
130
+ print(f"Invalid db name : {db_name}", file=sys.stderr)
131
+
132
+ def validate_col(self) -> bool:
133
+ """
134
+ col_name 이 실제로 db 안에 있는지 확인한다.
135
+ """
136
+ if self.validate_db(self.client, self.db_name):
137
+ if self.col_name in self.my_db.list_collection_names():
138
+ return True
139
+ else:
140
+ return False
141
+
142
+ def get_all_docs(self, remove_id=True) -> list:
143
+ """
144
+ 현재 설정된 컬렉션 안의 도큐먼트 전부를 리스트로 반환한다.
145
+ """
146
+ items = []
147
+ if remove_id:
148
+ for doc in self.my_col.find({}):
149
+ del doc['_id']
150
+ items.append(doc)
151
+ else:
152
+ items = list(self.my_col.find({}))
153
+ return items
154
+
155
+ def count_docs_in_col(self) -> int:
156
+ """
157
+ 현재 설정된 컬렉션 안의 도큐먼트의 갯수를 반환한다.
158
+ """
159
+ return self.my_col.count_documents({})
160
+
161
+ def clear_docs_in_col(self):
162
+ """
163
+ 현재 설정된 컬렉션 안의 도큐먼트를 전부 삭제한다.
164
+ (컬렉션 자체를 삭제하지는 않는다.)
165
+ """
166
+ self.my_col.delete_many({})
167
+ print(f"Delete all doccument in {self.col_name} collection..")
168
+
169
+ def del_col(self):
170
+ """
171
+ 현재 설정된 컬렉션을 삭제한다.
172
+ """
173
+ self.my_db.drop_collection(self.col_name)
174
+ print(f"Drop {self.col_name} collection..")
175
+
176
+ def list_collection_names(self) -> list:
177
+ return self.my_db.list_collection_names()
178
+
179
+ def del_doc(self, del_query: dict):
180
+ """
181
+ del_query에 해당하는 도큐먼트를 삭제한다.
182
+ """
183
+ self.my_col.delete_one(del_query)
184
+
185
+
186
+ class Corps(MongoBase):
187
+ """
188
+ mongodb의 데이터 중 기업 코드로 된 데이터 베이스를 다루는 클래스
189
+ """
190
+ COLLECTIONS = ('c101', 'c104y', 'c104q', 'c106y', 'c106q', 'c108',
191
+ 'c103손익계산서q', 'c103재무상태표q', 'c103현금흐름표q',
192
+ 'c103손익계산서y', 'c103재무상태표y', 'c103현금흐름표y',
193
+ 'dart', 'etc')
194
+
195
+ def __init__(self, client: MongoClient, code: str, page: str):
196
+ if utils.is_6digit(code) and page in self.COLLECTIONS:
197
+ super().__init__(client=client, db_name=code, col_name=page)
198
+ else:
199
+ raise Exception(f'Invalid value - code: {code} / {page}({self.COLLECTIONS})')
200
+
201
+ @property
202
+ def code(self):
203
+ return self.db_name
204
+
205
+ @code.setter
206
+ def code(self, code: str):
207
+ if utils.is_6digit(code):
208
+ self.db_name = code
209
+ else:
210
+ raise Exception(f'Invalid value : {code}')
211
+
212
+ @property
213
+ def page(self):
214
+ return self.col_name
215
+
216
+ @page.setter
217
+ def page(self, page: str):
218
+ if page in self.COLLECTIONS:
219
+ self.col_name = page
220
+ else:
221
+ raise Exception(f'Invalid value : {page}({self.COLLECTIONS})')
222
+
223
+ # ========================End Properties=======================
224
+
225
+ @staticmethod
226
+ def latest_value(data: dict, pop_count=2, nan_to_zero=False, allow_empty=False) -> Tuple[str, float]:
227
+ """
228
+ 가장 최근 년/분기 값 - evaltools에서도 사용할수 있도록 staticmethod로 뺐음.
229
+
230
+ 해당 타이틀의 가장 최근의 년/분기 값을 튜플 형식으로 반환한다.
231
+
232
+ Args:
233
+ data (dict): 찾고자하는 딕셔너리 데이터
234
+ pop_count: 유효성 확인을 몇번할 것인가
235
+ nan_to_zero: nan의 값을 0으로 바꿀것인가
236
+ allow_empty: title 항목이 없을경우에도 에러를 발생시키지 않을 것인가
237
+
238
+ Returns:
239
+ tuple: ex - ('2020/09', 39617.5) or ('', 0)
240
+
241
+ Note:
242
+ 만약 최근 값이 nan 이면 찾은 값 바로 직전 것을 한번 더 찾아 본다.\n
243
+ 데이터가 없는 경우 ('', nan) 반환한다.\n
244
+ """
245
+ def is_valid_value(value) -> bool:
246
+ """
247
+ 숫자가 아닌 문자열이나 nan 또는 None의 경우 유효한 형식이 아님을 알려 리턴한다.
248
+ """
249
+ if isinstance(value, str):
250
+ # value : ('Unnamed: 1', '데이터가 없습니다.') 인 경우
251
+ is_valid = False
252
+ elif math.isnan(value):
253
+ # value : float('nan') 인 경우
254
+ is_valid = False
255
+ elif value is None:
256
+ # value : None 인 경우
257
+ is_valid = False
258
+ else:
259
+ is_valid = True
260
+ """
261
+ elif value == 0:
262
+ is_valid = False
263
+ """
264
+ return is_valid
265
+
266
+ logger.debug(f'Corps.latest_value raw data : {data}')
267
+
268
+ # 데이터를 추출해서 사용하기 때문에 원본 데이터는 보존하기 위해서 카피해서 사용
269
+ data_copied = copy.deepcopy(data)
270
+
271
+ for i in range(pop_count):
272
+ try:
273
+ d, v = data_copied.popitem()
274
+ except KeyError:
275
+ # when dictionary is empty
276
+ return '', 0 if nan_to_zero else float('nan')
277
+ if str(d).startswith('20') and is_valid_value(v):
278
+ logger.debug(f'last_one : {v}')
279
+ return d, v
280
+
281
+ return '', 0 if nan_to_zero else float('nan')
282
+
283
+ @staticmethod
284
+ def refine_data(data: dict, refine_words: list) -> dict:
285
+ """
286
+ 주어진 딕셔너리에서 refine_words에 해당하는 키를 삭제해서 반환하는 유틸함수.
287
+ c10346에서 사용
288
+ refine_words : 정규표현식 가능
289
+ """
290
+ copy_data = data.copy()
291
+ import re
292
+ for regex_refine_word in refine_words:
293
+ # refine_word에 해당하는지 정규표현식으로 검사하고 매치되면 삭제한다.
294
+ p = re.compile(regex_refine_word)
295
+ for title, _ in copy_data.items():
296
+ # data 내부의 타이틀을 하나하나 조사한다.
297
+ m = p.match(title)
298
+ if m:
299
+ del data[title]
300
+ return data
301
+
302
+ @staticmethod
303
+ def get_all_codes(client: MongoClient) -> list:
304
+ """
305
+ 기업 코드를 데이터베이스명으로 가지는 모든 6자리 숫자 코드의 db 명 반환
306
+ """
307
+ corp_list = []
308
+ for db in MongoBase.get_all_db_name(client):
309
+ if utils.is_6digit(db):
310
+ corp_list.append(db)
311
+ return sorted(corp_list)
312
+
313
+ @staticmethod
314
+ def del_all_codes(client: MongoClient):
315
+ corp_list = Corps.get_all_codes(client)
316
+ for corp_db_name in corp_list:
317
+ MongoBase.del_db(client, corp_db_name)
318
+
319
+ @staticmethod
320
+ def pick_rnd_x_code(client: MongoClient, count: int) -> list:
321
+ """
322
+ 임의의 갯수의 종목코드를 뽑아서 반환한다.
323
+ """
324
+ return random.sample(Corps.get_all_codes(client), count)
325
+
326
+ @staticmethod
327
+ def get_name(client: MongoClient, code: str):
328
+ """
329
+ code를 입력받아 종목명을 반환한다.
330
+ """
331
+ c101 = C101(client, code)
332
+ try:
333
+ name = c101.get_recent()['종목명']
334
+ except KeyError:
335
+ name = None
336
+ return name
337
+
338
+ def _save_df(self, df: pd.DataFrame) -> bool:
339
+ # c103, c104, c106, c108에서 주로 사용하는 저장방식
340
+ if df.empty:
341
+ print('Dataframe is empty..So we will skip saving db..')
342
+ return False
343
+ result = self.my_col.insert_many(df.to_dict('records'))
344
+ return result.acknowledged
345
+
346
+ def _load_df(self) -> pd.DataFrame:
347
+ # cdart와 c106, c103에서 주로 사용
348
+ try:
349
+ df = pd.DataFrame(self.get_all_docs())
350
+ except KeyError:
351
+ df = pd.DataFrame()
352
+ return df
353
+
354
+ def _save_dict(self, dict_data: dict, del_query: dict) -> bool:
355
+ # c101, cdart에서 주로 사용하는 저장방식
356
+ try:
357
+ result = self.my_col.insert_one(dict_data)
358
+ except errors.DuplicateKeyError:
359
+ self.my_col.delete_many(del_query)
360
+ result = self.my_col.insert_one(dict_data)
361
+ return result.acknowledged
362
+
363
+
364
+ class C1034(Corps, metaclass=ABCMeta):
365
+ def __init__(self, client: MongoClient, db_name: str, col_name: str):
366
+ super().__init__(client=client, code=db_name, page=col_name)
367
+
368
+ def get_all_titles(self) -> list:
369
+ titles = []
370
+ for item in self.get_all_docs():
371
+ titles.append(item['항목'])
372
+ return list(set(titles))
373
+
374
+ @staticmethod
375
+ def sum_each_data(data_list: List[dict]) -> dict:
376
+ """
377
+ 검색된 딕셔너리를 모은 리스트를 인자로 받아서 각각의 기간에 맞춰 합을 구해 하나의 딕셔너리로 반환한다.
378
+ """
379
+ sum_dict = {}
380
+ periods = list(data_list[0].keys())
381
+ # 여러딕셔너리를 가진 리스트의 합 구하기
382
+ for period in periods:
383
+ sum_dict[period] = sum(utils.nan_to_zero(data[period]) for data in data_list)
384
+ return sum_dict
385
+
386
+ def _find(self, title: str, refine_words: list) -> Tuple[List[dict], int]:
387
+ """
388
+ refine_words 에 해당하는 딕셔너리 키를 삭제하고
389
+ title 인자에 해당하는 항목을 검색하여 반환한다.
390
+ c103의 경우는 중복되는 이름의 항목이 있기 때문에
391
+ 이 함수는 반환되는 딕셔너리 리스트와 갯수로 구성되는 튜플을 반환한다.
392
+ """
393
+ titles = self.get_all_titles()
394
+ if title in titles:
395
+ count = 0
396
+ data_list = []
397
+ for data in self.my_col.find({'항목': {'$eq': title}}):
398
+ # 도큐먼트에서 title과 일치하는 항목을 찾아낸다.
399
+ count += 1
400
+ # refine_data함수를 통해 삭제를 원하는 필드를 제거하고 data_list에 추가한다.
401
+ data_list.append(self.refine_data(data, refine_words))
402
+ return data_list, count
403
+ else:
404
+ raise Exception(f'{title} is not in {titles}')
405
+
406
+ def latest_value(self, title: str, pop_count=2, nan_to_zero=False, allow_empty=False) -> Tuple[str, float]:
407
+ od = OrderedDict(sorted(self.find(title, allow_empty=allow_empty).items(), reverse=False))
408
+ logger.debug(f'{title} : {od}')
409
+ return Corps.latest_value(od, pop_count, nan_to_zero, allow_empty)
410
+
411
+ @abstractmethod
412
+ def find(self, title: str, allow_empty=False) -> dict:
413
+ pass
414
+
415
+ def sum_recent_4q(self, title: str, nan_to_zero: bool = False) -> Tuple[str, float]:
416
+ """최근 4분기 합
417
+
418
+ 분기 페이지 한정 해당 타이틀의 최근 4분기의 합을 튜플 형식으로 반환한다.
419
+
420
+ Args:
421
+ title (str): 찾고자 하는 타이틀
422
+ nan_to_zero: nan 값의 경우 zero로 반환한다.
423
+
424
+ Returns:
425
+ tuple: (계산된 4분기 중 최근분기, 총합)
426
+
427
+ Raises:
428
+ TypeError: 페이지가 q가 아닌 경우 발생
429
+
430
+ Note:
431
+ 분기 데이터가 4개 이하인 경우 그냥 최근 연도의 값을 찾아 반환한다.
432
+ """
433
+ if self.col_name.endswith('q'):
434
+ # 딕셔너리 정렬 - https://kkamikoon.tistory.com/138
435
+ # reverse = False 이면 오래된것부터 최근순으로 정렬한다.
436
+ od_q = OrderedDict(sorted(self.find(title, allow_empty=True).items(), reverse=False))
437
+ logger.debug(f'{title} : {od_q}')
438
+
439
+ if len(od_q) < 4:
440
+ # od_q의 값이 4개 이하이면 그냥 최근 연도의 값으로 반환한다.
441
+ if self.page.startswith('c103'):
442
+ y = C103(self.client, self.db_name, self.col_name[:-1] + 'y')
443
+ elif self.page.startswith('c104'):
444
+ y = C104(self.client, self.db_name, self.col_name[:-1] + 'y')
445
+ else:
446
+ Exception(f'Error on sum_recent_4q func...')
447
+ return y.latest_value(title, nan_to_zero=nan_to_zero, allow_empty=True)
448
+ else:
449
+ q_sum = 0
450
+ date_list = list(od_q.keys())
451
+ while True:
452
+ try:
453
+ latest_period = date_list.pop()
454
+ except IndexError:
455
+ latest_period = ""
456
+ break
457
+ else:
458
+ if str(latest_period).startswith('20'):
459
+ break
460
+
461
+ for i in range(4):
462
+ # last = True 이면 최근의 값부터 꺼낸다.
463
+ d, v = od_q.popitem(last=True)
464
+ logger.debug(f'd:{d} v:{v}')
465
+ q_sum += 0 if math.isnan(v) else v
466
+ return str(latest_period), round(q_sum, 2)
467
+ else:
468
+ raise TypeError(f'Not support year data..{self.col_name}')
469
+
470
+ def find_증감율(self, title: str) -> dict:
471
+ """
472
+
473
+ 타이틀에 해당하는 전년/분기대비 값을 반환한다.\n
474
+
475
+ Args:
476
+ title (str): 찾고자 하는 타이틀
477
+
478
+ Returns:
479
+ float: 전년/분기대비 증감율
480
+
481
+ Note:
482
+ 중복되는 title 은 취급하지 않기로함.\n
483
+ """
484
+ try:
485
+ data_list, count = self._find(title, ['_id', '항목'])
486
+ except:
487
+ # title을 조회할 수 없는 경우
488
+ if self.col_name.endswith('q'):
489
+ r = {'전분기대비': math.nan}
490
+ else:
491
+ r = {'전년대비': math.nan, '전년대비 1': math.nan}
492
+ return r
493
+ logger.info(data_list)
494
+ cmp_dict = {}
495
+ if count > 1:
496
+ # 중복된 타이틀을 가지는 페이지의 경우 경고 메시지를 보낸다.
497
+ logger.warning(f'Not single data..{self.code}/{self.page}/{title}')
498
+ logger.warning(data_list)
499
+ # 첫번째 데이터를 사용한다.
500
+ data_dict = data_list[0]
501
+ for k, v in data_dict.items():
502
+ if str(k).startswith('전'):
503
+ cmp_dict[k] = v
504
+ return cmp_dict
505
+
506
+
507
+ class C104(C1034):
508
+ def __init__(self, client: MongoClient, code: str, page: str):
509
+ if page in ('c104y', 'c104q'):
510
+ super().__init__(client=client, db_name=code, col_name=page)
511
+ else:
512
+ raise Exception
513
+
514
+ def get_all_titles(self) -> list:
515
+ """
516
+ 상위 C1034클래스에서 c104는 stamp항목이 있기 때문에 삭제하고 리스트로 반환한다.
517
+ """
518
+ titles = super().get_all_titles()
519
+ titles.remove('stamp')
520
+ return titles
521
+
522
+ def find(self, title: str, allow_empty=False) -> dict:
523
+ """
524
+ title에 해당하는 항목을 딕셔너리로 반환한다.
525
+ allow_empty를 true로 하면 에러를 발생시키지 않고 빈딕셔너리를 리턴한다.
526
+ """
527
+ try:
528
+ l, c = super(C104, self)._find(title, ['_id', '항목', '^전.+대비.*'])
529
+ return_dict = l[0]
530
+ except Exception as e:
531
+ if allow_empty:
532
+ return_dict = {}
533
+ else:
534
+ raise Exception(e)
535
+ return return_dict
536
+
537
+ def save_df(self, c104_df: pd.DataFrame) -> bool:
538
+ """데이터베이스에 저장
539
+
540
+ c104는 4페이지의 자료를 한 컬렉션에 모으는 것이기 때문에
541
+ stamp 를 검사하여 12시간 전보다 이전에 저장된 자료가 있으면
542
+ 삭제한 후 저장하고 12시간 이내의 자료는 삭제하지 않고
543
+ 데이터를 추가하는 형식으로 저장한다.
544
+
545
+ Example:
546
+ c104_data 예시\n
547
+ [{'항목': '매출액증가율',...'2020/12': 2.78, '2021/12': 14.9, '전년대비': 8.27, '전년대비1': 12.12},
548
+ {'항목': '영업이익증가율',...'2020/12': 29.62, '2021/12': 43.86, '전년대비': 82.47, '전년대비1': 14.24}]
549
+
550
+ Note:
551
+ 항목이 중복되는 경우가 있기 때문에 c104처럼 각 항목을 키로하는 딕셔너리로 만들지 않는다.
552
+ """
553
+ self.my_col.create_index('항목', unique=True)
554
+ time_now = datetime.datetime.now()
555
+ try:
556
+ stamp = self.my_col.find_one({'항목': 'stamp'})['time']
557
+ if stamp < (time_now - datetime.timedelta(days=.01)):
558
+ # 스템프가 약 10분 이전이라면..연속데이터가 아니라는 뜻이므로 컬렉션을 초기화한다.
559
+ print("Before save data, cleaning the collection...", end='')
560
+ self.clear_docs_in_col()
561
+ except TypeError:
562
+ # 스템프가 없다면...
563
+ pass
564
+ # 항목 stamp를 찾아 time을 업데이트하고 stamp가 없으면 insert한다.
565
+ self.my_col.update_one({'항목': 'stamp'}, {"$set": {'time': time_now}}, upsert=True)
566
+ return super(C104, self)._save_df(c104_df)
567
+
568
+ def get_stamp(self) -> datetime.datetime:
569
+ """
570
+ c104y, c104q가 작성된 시간이 기록된 stamp 항목을 datetime 형식으로 리턴한다.
571
+ """
572
+ return self.my_col.find_one({"항목": "stamp"})['time']
573
+
574
+ def modify_stamp(self, days_ago: int):
575
+ """
576
+ 인위적으로 타임스템프를 수정한다 - 주로 테스트 용도
577
+ """
578
+ try:
579
+ before = self.my_col.find_one({'항목': 'stamp'})['time']
580
+ except TypeError:
581
+ # 이전에 타임스템프가 없는 경우
582
+ before = None
583
+ time_2da = datetime.datetime.now() - datetime.timedelta(days=days_ago)
584
+ self.my_col.update_one({'항목': 'stamp'}, {"$set": {'time': time_2da}}, upsert=True)
585
+ after = self.my_col.find_one({'항목': 'stamp'})['time']
586
+ logger.info(f"Stamp changed: {before} -> {after}")
587
+
588
+
589
+ class C103(C1034):
590
+ def __init__(self, client: MongoClient, code: str, page: str):
591
+ if page in ('c103손익계산서q', 'c103재무상태표q', 'c103현금흐름표q',
592
+ 'c103손익계산서y', 'c103재무상태표y', 'c103현금흐름표y',):
593
+ super().__init__(client=client, db_name=code, col_name=page)
594
+ else:
595
+ raise Exception
596
+
597
+ def save_df(self, c103_df: pd.DataFrame) -> bool:
598
+ """데이터베이스에 저장
599
+
600
+ Example:
601
+ c103_list 예시\n
602
+ [{'항목': '자산총계', '2020/03': 3574575.4, ... '전분기대비': 3.9},
603
+ {'항목': '유동자산', '2020/03': 1867397.5, ... '전분기대비': 5.5}]
604
+
605
+ Note:
606
+ 항목이 중복되는 경우가 있기 때문에 c104처럼 각 항목을 키로하는 딕셔너리로 만들지 않는다.
607
+ """
608
+ self.my_col.create_index('항목', unique=False)
609
+ print("Before save data, cleaning the collection...", end='')
610
+ self.clear_docs_in_col()
611
+ return super(C103, self)._save_df(c103_df)
612
+
613
+ def load_df(self) -> pd.DataFrame:
614
+ """
615
+ 데이터베이스에 저장된 페이지를 데이터프레임으로 반환한다.
616
+ """
617
+ return super(C103, self)._load_df()
618
+
619
+ def find(self, title: str, allow_empty=False) -> dict:
620
+ """
621
+ title에 해당하는 항목을 딕셔너리로 반환한다.
622
+ allow_empty를 true로 하면 에러를 발생시키지 않고 빈딕셔너리를 리턴한다.
623
+ c103의 경우는 중복된 타이틀을 가지는 항목이 있어 합쳐서 한개의 딕셔너리로 반환한다.
624
+ """
625
+ try:
626
+ l, c = super(C103, self)._find(title, ['_id', '항목', '^전.+대비.*'])
627
+ sum_dict = self.sum_each_data(l)
628
+ except Exception as e:
629
+ if allow_empty:
630
+ sum_dict = {}
631
+ else:
632
+ raise Exception(e)
633
+ return sum_dict
634
+
635
+
636
+ class C101(Corps):
637
+ def __init__(self, client: MongoClient, code: str):
638
+ super().__init__(client=client, code=code, page='c101')
639
+ self.my_col.create_index('date', unique=True)
640
+
641
+ def save_dict(self, c101_dict: dict) -> bool:
642
+ """
643
+ c101의 구조에 맞는 딕셔너리값을 받아서 구조가 맞는지 확인하고 맞으면 저장한다.
644
+
645
+ Note:
646
+ <c101_struc>\n
647
+ 'date', '코드', '종목명',\n
648
+ '업종', '주가', '거래량',\n
649
+ 'EPS', 'BPS', 'PER',\n
650
+ '업종PER', 'PBR', '배당수익률',\n
651
+ '최고52주', '최저52주', '거래대금',\n
652
+ '시가총액', '베타52주', '발행주식',\n
653
+ '유통비율', 'intro'\n
654
+ """
655
+ c101_template = ['date', '코드', '종목명', '업종', '주가', '거래량', 'EPS', 'BPS', 'PER', '업종PER', 'PBR',
656
+ '배당수익률', '최고52주', '최저52주', '거래대금', '시가총액', '베타52주', '발행주식', '유통비율']
657
+ # 리스트 비교하기
658
+ # reference from https://codetorial.net/tips_and_examples/compare_two_lists.html
659
+ if c101_dict['코드'] != self.db_name:
660
+ raise Exception("Code isn't equal input data and db data..")
661
+ logger.debug(c101_dict.keys())
662
+ # c101 데이터가 c101_template의 내용을 포함하는가 확인하는 if문
663
+ # refered from https://appia.tistory.com/101
664
+ if (set(c101_template) - set(c101_dict.keys())) == set():
665
+ # 스크랩한 날짜 이후의 데이터는 조회해서 먼저 삭제한다.
666
+ del_query = {'date': {"$gte": c101_dict['date']}}
667
+ return super(C101, self)._save_dict(c101_dict, del_query)
668
+ else:
669
+ raise Exception('Invalid c101 dictionary structure..')
670
+
671
+ def find(self, date: str) -> dict:
672
+ """
673
+
674
+ 해당 날짜의 데이터를 반환한다.
675
+ 만약 리턴값이 없으면 {} 을 반환한다.
676
+
677
+ Args:
678
+ date (str): 예 - 20201011(6자리숫자)
679
+ """
680
+ if utils.isYmd(date):
681
+ converted_date = date[:4] + '.' + date[4:6] + '.' + date[6:]
682
+ else:
683
+ raise Exception(f'Invalid date format : {date}(ex-20201011(8자리숫자))')
684
+ d = self.my_col.find_one({'date': converted_date})
685
+ if d is None:
686
+ return {}
687
+ else:
688
+ del d['_id']
689
+ return d
690
+
691
+ def get_recent(self, merge_intro=False) -> dict:
692
+ """
693
+ 저장된 데이터에서 가장 최근 날짜의 딕셔너리를 반환한다.
694
+
695
+ 리턴값
696
+ {'BPS': 50817.0,
697
+ 'EPS': 8057.0,
698
+ 'PBR': 1.28,
699
+ 'PER': 8.08,
700
+ 'date': '2023.04.14',
701
+ 'intro1': '한국 및 DX부문 해외 9개 지역총괄과 DS부문 해외 5개 지역총괄, SDC, Harman 등 233개의 종속기업으로 구성된 글로벌 전자기업임.',
702
+ 'intro2': '세트사업(DX)에는 TV, 냉장고 등을 생산하는 CE부문과 스마트폰, 네트워크시스템, 컴퓨터 등을 생산하는 IM부문이 있음.',
703
+ 'intro3': '부품사업(DS)에서는 D램, 낸드 플래쉬, 모바일AP 등의 제품을 생산하는 반도체 사업과 TFT-LCD 및 OLED 디스플레이 패널을 생산하는 DP사업으로 구성됨.',
704
+ '거래대금': '1062800000000',
705
+ '거래량': '16176500',
706
+ '발행주식': '5969782550',
707
+ '배당수익률': '2.22',
708
+ '베타52주': '0.95',
709
+ '시가총액': '388632800000000',
710
+ '업종': '반도체와반도체장비',
711
+ '업종PER': '8.36',
712
+ '유통비율': '75.82',
713
+ '종목명': '삼성전자',
714
+ '주가': '65100',
715
+ '최고52주': '68800',
716
+ '최저52주': '51800',
717
+ '코드': '005930'}
718
+ """
719
+ try:
720
+ d = self.my_col.find({'date': {'$exists': True}}, {"_id": 0}).sort('date', pymongo.DESCENDING).next()
721
+ # del doc['_id'] - 위의 {"_id": 0} 으로 해결됨.
722
+ if merge_intro:
723
+ d['intro'] = d['intro1'] + d['intro2'] + d['intro3']
724
+ del d['intro1']
725
+ del d['intro2']
726
+ del d['intro3']
727
+ except StopIteration:
728
+ d = {}
729
+ return d
730
+
731
+ def get_all(self) -> list:
732
+ """
733
+
734
+ 저장된 모든 데이터를 딕셔너리로 가져와서 리스트로 포장하여 반환한다.
735
+ """
736
+ items = []
737
+ for doc in self.my_col.find({'date': {'$exists': True}}, {"_id": 0}).sort('date', pymongo.ASCENDING):
738
+ # del doc['_id'] - 위의 {"_id": 0} 으로 해결됨.
739
+ items.append(doc)
740
+ return items
741
+
742
+ def get_trend(self, title: str) -> dict:
743
+ """
744
+ title에 해당하는 데이터베이스에 저장된 모든 값을 {날짜: 값} 형식의 딕셔너리로 반환한다.
745
+
746
+ title should be in ['BPS', 'EPS', 'PBR', 'PER', '주가', '배당수익률', '베타52주', '거래량']
747
+
748
+ 리턴값 - 주가
749
+ {'2023.04.05': '63900',
750
+ '2023.04.06': '62300',
751
+ '2023.04.07': '65000',
752
+ '2023.04.10': '65700',
753
+ '2023.04.11': '65900',
754
+ '2023.04.12': '66000',
755
+ '2023.04.13': '66100',
756
+ '2023.04.14': '65100',
757
+ '2023.04.17': '65300'}
758
+ """
759
+ titles = ['BPS', 'EPS', 'PBR', 'PER', '주가', '배당수익률', '베타52주', '거래량']
760
+ if title not in titles:
761
+ raise Exception(f"title should be in {titles}")
762
+ items = dict()
763
+ for doc in self.my_col.find({'date': {'$exists': True}}, {"_id": 0, "date": 1, f"{title}": 1}).sort('date', pymongo.ASCENDING):
764
+ items[doc['date']] = doc[f'{title}']
765
+ return items
766
+
767
+ class C106(Corps):
768
+ def __init__(self, client: MongoClient, code: str, page: str):
769
+ if page in ('c106y', 'c106q'):
770
+ super().__init__(client=client, code=code, page=page)
771
+ else:
772
+ raise Exception
773
+
774
+ def save_df(self, c106_df: pd.DataFrame) -> bool:
775
+ self.my_col.create_index('항목', unique=True)
776
+ self.clear_docs_in_col()
777
+ return super(C106, self)._save_df(c106_df)
778
+
779
+ def load_df(self) -> pd.DataFrame:
780
+ return super(C106, self)._load_df()
781
+
782
+ def get_all_titles(self) -> list:
783
+ titles = []
784
+ for item in self.get_all_docs():
785
+ titles.append(item['항목'])
786
+ return list(set(titles))
787
+
788
+ def find(self, title: str, allow_empty=False) -> dict:
789
+ """
790
+ title에 해당하는 항목을 딕셔너리로 반환한다.
791
+ """
792
+ titles = self.get_all_titles()
793
+ if title in titles:
794
+ data = self.my_col.find_one({'항목': {'$eq': title}})
795
+ return self.refine_data(data, ['_id', '항목'])
796
+ else:
797
+ if allow_empty:
798
+ return {}
799
+ else:
800
+ raise Exception(f'{title} is not in {titles}')
801
+
802
+
803
+ class DateBase(MongoBase):
804
+ """
805
+ 날짜를 컬렉션으로 가지는 데이터베이스를 위한 기반클래스
806
+ """
807
+ def __init__(self, client: MongoClient, db_name: str, date: str):
808
+ if utils.isYmd(date):
809
+ super().__init__(client=client, db_name=db_name, col_name=date)
810
+ else:
811
+ raise Exception(f"Invalid date : {date}(%Y%m%d)")
812
+
813
+ @property
814
+ def date(self):
815
+ return self.col_name
816
+
817
+ @date.setter
818
+ def date(self, date: str):
819
+ if utils.isYmd(date):
820
+ self.col_name = date
821
+ else:
822
+ raise Exception(f"Invalid date : {date}(%Y%m%d)")
823
+
824
+ # ========================End Properties=======================
825
+
826
+ def save_df(self, df: pd.DataFrame) -> bool:
827
+ if df.empty:
828
+ print('Dataframe is empty..So we will skip saving db..')
829
+ return False
830
+
831
+ self.clear_docs_in_col()
832
+ print(f"Save new data to '{self.db_name}' / '{self.col_name}'")
833
+ result = self.my_col.insert_many(df.to_dict('records'))
834
+ return result.acknowledged
835
+
836
+ def load_df(self) -> pd.DataFrame:
837
+ try:
838
+ df = pd.DataFrame(list(self.my_col.find({}))).drop(columns=['_id'])
839
+ except KeyError:
840
+ df = pd.DataFrame()
841
+ return df
842
+
843
+
844
+ class EvalByDate(DateBase):
845
+ """
846
+ 각 날짜별로 만들어진 eval-report 데이터프레임을 관리하는 클래스
847
+ DB_NAME : eval
848
+ COL_NAME : Ymd형식 날짜
849
+ """
850
+ EVAL_DB = 'eval'
851
+
852
+ def __init__(self, client: MongoClient, date: str):
853
+ super().__init__(client, self.EVAL_DB, date)
854
+ # 인덱스 설정
855
+ self.my_col.create_index('code', unique=True)
856
+
857
+ @staticmethod
858
+ def get_dates(client: MongoClient) -> List[str]:
859
+ # 데이터베이스에 저장된 날짜 목록을 리스트로 반환한다.
860
+ dates_list = client.eval.list_collection_names()
861
+ dates_list.sort()
862
+ return dates_list
863
+
864
+ @classmethod
865
+ def get_recent(cls, client: MongoClient, type: str):
866
+ """
867
+ eval 데이터베이스의 가장 최근의 유요한 자료를 반환한다.
868
+ type의 종류에 따라 반환값이 달라진다.[date, dataframe, dict]
869
+ """
870
+ dates = cls.get_dates(client)
871
+
872
+ while len(dates) > 0:
873
+ recent_date = dates.pop()
874
+ recent_df = cls(client, recent_date).load_df()
875
+ if len(recent_df) != 0:
876
+ if type == 'date':
877
+ return recent_date
878
+ elif type == 'dataframe':
879
+ return recent_df
880
+ elif type == 'dict':
881
+ return recent_df.to_dict('records')
882
+ else:
883
+ raise Exception(f"Invalid type : {type}")
884
+
885
+ return None
886
+
887
+ class MI(MongoBase):
888
+ """mi 데이터베이스 클래스
889
+
890
+ Note:
891
+ db - mi\n
892
+ col - 'aud', 'chf', 'gbond3y', 'gold', 'silver', 'kosdaq', 'kospi', 'sp500', 'usdkrw', 'wti', 'avgper', 'yieldgap', 'usdidx' - 총 13개\n
893
+ doc - date, value\n
894
+ """
895
+ MI_DB = 'mi'
896
+ COL_TITLE = ('aud', 'chf', 'gbond3y', 'gold', 'silver', 'kosdaq', 'kospi',
897
+ 'sp500', 'usdkrw', 'wti', 'avgper', 'yieldgap', 'usdidx')
898
+
899
+ def __init__(self, client: MongoClient, index: str):
900
+ if index in self.COL_TITLE:
901
+ super().__init__(client=client, db_name=self.MI_DB, col_name=index)
902
+ else:
903
+ raise Exception(f'Invalid index : {index}({self.COL_TITLE})')
904
+
905
+ @property
906
+ def index(self):
907
+ return self.col_name
908
+
909
+ @index.setter
910
+ def index(self, index: str):
911
+ if index in self.COL_TITLE:
912
+ self.col_name = index
913
+ else:
914
+ raise Exception(f'Invalid index : {index}({self.COL_TITLE})')
915
+
916
+ # ========================End Properties=======================
917
+
918
+ def get_recent(self) -> Tuple[str, float]:
919
+ """저장된 가장 최근의 값을 반환하는 함수
920
+ """
921
+ if self.validate_col():
922
+ d = self.my_col.find({'date': {'$exists': True}}).sort('date', pymongo.DESCENDING).next()
923
+ del d['_id']
924
+ return d['date'], d['value']
925
+
926
+ def save_dict(self, mi_dict: dict) -> bool:
927
+ """MI 데이터 저장
928
+
929
+ Args:
930
+ mi_dict (dict): ex - {'date': '2021.07.21', 'value': '1154.50'}
931
+ """
932
+ self.my_col.create_index('date', unique=True)
933
+ result = self.my_col.update_one({'date': mi_dict['date']}, {"$set": {'value': mi_dict['value']}}, upsert=True)
934
+ return result.acknowledged