Leesn465 commited on
Commit
e7a22aa
·
verified ·
1 Parent(s): d26b332

Update main.py

Browse files
Files changed (1) hide show
  1. main.py +220 -185
main.py CHANGED
@@ -45,12 +45,33 @@ else:
45
 
46
 
47
 
 
 
 
48
  class NewsRequest(BaseModel):
49
  url: str
50
  id: Optional[str] = None
51
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
 
53
- # 🧠 학습 모델 구조 정의
 
 
 
54
  class SimpleClassifier(torch.nn.Module):
55
  def __init__(self, input_dim):
56
  super().__init__()
@@ -60,22 +81,21 @@ class SimpleClassifier(torch.nn.Module):
60
  torch.nn.Linear(64, 1),
61
  torch.nn.Sigmoid()
62
  )
63
-
64
  def forward(self, x):
65
  return self.net(x)
66
 
67
-
68
-
69
- def fetch_html(url):
 
70
  headers = {"User-Agent": "Mozilla/5.0"}
71
- response = requests.get(url, headers=headers, timeout=5)
72
- response.raise_for_status()
73
- return bs(response.text, "html.parser")
74
 
75
- def parse_naver(soup):
76
  title = soup.select_one("h2.media_end_head_headline") or soup.title
77
  title_text = title.get_text(strip=True) if title else "제목 없음"
78
-
79
  time_tag = soup.select_one("span.media_end_head_info_datestamp_time")
80
  time_text = time_tag.get_text(strip=True) if time_tag else "시간 없음"
81
 
@@ -85,13 +105,11 @@ def parse_naver(soup):
85
  content = '\n'.join([p.get_text(strip=True) for p in paragraphs]) if paragraphs else content_area.get_text(strip=True)
86
  else:
87
  content = "본문 없음"
88
-
89
  return title_text, time_text, content
90
 
91
- def parse_daum(soup):
92
  title = soup.select_one("h3.tit_view") or soup.title
93
  title_text = title.get_text(strip=True) if title else "제목 없음"
94
-
95
  time_tag = soup.select_one("span.num_date")
96
  time_text = time_tag.get_text(strip=True) if time_tag else "시간 없음"
97
 
@@ -101,278 +119,295 @@ def parse_daum(soup):
101
  content = '\n'.join([p.get_text(strip=True) for p in paragraphs]) if paragraphs else content_area.get_text(strip=True)
102
  else:
103
  content = "본문 없음"
104
-
105
  return title_text, time_text, content
106
 
107
- def extract_thumbnail(soup):
108
  tag = soup.find("meta", property="og:image")
109
  return tag["content"] if tag and "content" in tag.attrs else None
110
 
111
-
112
- def gemini_use(resultK):
113
- generation_config = genai.GenerationConfig(
114
- temperature=1,
115
- response_mime_type=None # 그냥 문자열로 응답받기
116
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
117
  model = genai.GenerativeModel('gemini-2.0-flash', generation_config=generation_config)
118
-
119
  prompt = f"""
120
  아래 내용을 참고해서 가장 연관성이 높은 주식 상장 회사 이름 하나만 말해줘.
121
  다른 설명 없이 회사 이름만 대답해.
122
 
123
- "{resultK}"
124
  """
125
-
126
  response = model.generate_content(prompt)
127
  try:
128
- result_text = response.text.strip()
129
  except AttributeError:
130
- result_text = response.candidates[0].content.parts[0].text.strip()
131
-
132
- return result_text
133
-
134
-
135
-
136
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
137
  @app.post("/ai/parse-news")
138
  def parse_news(req: NewsRequest):
139
  url = req.url.strip()
140
- username = req.id.strip() if req.id else None
141
  try:
142
- soup = fetch_html(url)
143
-
144
- if "naver.com" in url:
145
- title, time, content = parse_naver(soup)
146
- elif "daum.net" in url:
147
- title, time, content = parse_daum(soup)
148
- else:
149
- raise HTTPException(status_code=400, detail="지원하지 않는 뉴스 사이트입니다.")
150
-
151
- thumbnail_url = extract_thumbnail(soup)
152
-
153
-
154
- resultK = resultKeyword(content)
155
- targetCompany = gemini_use(resultK)
156
-
157
-
158
- sentiment = analyze_sentiment(content)
159
- pos_score = sentiment["positive"]
160
- neg_score = sentiment["negative"]
161
- net_score = sentiment["neutral"]
162
- print("부정:", sentiment["negative"])
163
- print("중립:", sentiment["neutral"])
164
- print("긍정:", sentiment["positive"])
165
-
166
- # 중립 점수 절반으로 줄이고 나머지를 부정/긍정에 재분배
167
- reduced_net = net_score / 2
168
- remaining = net_score - reduced_net
169
- total_non_neu = neg_score + pos_score
170
-
171
  if total_non_neu > 0:
172
- neg_score += remaining * (neg_score / total_non_neu)
173
- pos_score += remaining * (pos_score / total_non_neu)
174
  else:
175
- neg_score += remaining / 2
176
- pos_score += remaining / 2
177
-
178
- net_score = reduced_net
179
-
180
- max_label = max(
181
- [("부정", neg_score), ("중립", net_score), ("긍정", pos_score)],
182
- key=lambda x: x[1]
183
- )[0]
184
 
 
185
  if max_label == "긍정":
186
- if pos_score >= 0.9:
187
- sentiment_label = f"매우 긍정 ({pos_score*100:.1f}%)"
188
- elif pos_score >= 0.6:
189
- sentiment_label = f"긍정 ({pos_score*100:.1f}%)"
190
- else:
191
- sentiment_label = f"약한 긍정 ({pos_score*100:.1f}%)"
192
  elif max_label == "부정":
193
- if neg_score >= 0.9:
194
- sentiment_label = f"매우 부정 ({neg_score*100:.1f}%)"
195
- elif neg_score >= 0.6:
196
- sentiment_label = f"부정 ({neg_score*100:.1f}%)"
197
- else:
198
- sentiment_label = f"약한 부정 ({neg_score*100:.1f}%)"
199
  else:
200
- sentiment_label = f"중립 ({net_score*100:.1f}%)"
201
-
202
- #밑에 부분은 모델로 주가 예측 한거임
203
-
204
 
205
- summary = summarize(content)
206
- print(summary)
207
-
208
- _, keywords_2nd = extract_keywords(summary)
209
  clean_keywords = [kw for kw, _ in keywords_2nd]
210
-
211
  keyword_vec = embed_keywords(clean_keywords)
212
- input_vec = torch.tensor(keyword_vec, dtype=torch.float32).unsqueeze(0) # (1, D)
213
-
214
- input_dim = input_vec.shape[1]
215
- model = SimpleClassifier(input_dim)
216
-
217
  model.load_state_dict(torch.load("news_model.pt", map_location="cpu"))
218
  model.eval()
219
-
220
  with torch.no_grad():
221
  prob = model(input_vec).item()
222
- prediction = int(prob >= 0.5)
223
-
224
-
225
- prediction = '📈 상승 (1)' if prediction == 1 else '📉 하락 (0)'
226
- print(type(prob))
227
- print(type(prediction))
228
 
229
-
230
-
231
  return {
 
232
  "message": "뉴스 파싱 및 저장 완료",
233
- "title": title,
234
- "time": time,
235
- "content": content,
236
- "thumbnail_url": thumbnail_url,
237
- "url": url,
238
- "summary": resultK["summary"],
239
- "keyword": resultK["keyword"],
240
  "company": targetCompany,
241
  "sentiment": sentiment_label,
242
  "sentiment_value": sentiment_label,
243
- "prediction": prediction,
244
  "prob": prob,
245
  }
246
 
247
  except requests.exceptions.RequestException as e:
248
- traceback.print_exc() # 전체 스택트레이스 콘솔에 출력
249
  raise HTTPException(status_code=500, detail=f"서버 오류: {e}")
250
  except Exception as e:
251
- traceback.print_exc() # 전체 스택트레이스 콘솔에 출력
252
  raise HTTPException(status_code=500, detail=f"서버 오류: {e}")
253
-
254
- from fastapi.concurrency import run_in_threadpool # 동기 함수를 비동기처럼 실행하기 위해
255
- from typing import List, Dict, Any # 반환 타입 명시를 위해 (선택 사항)
256
- # --- 전역 변수 (서버 시작 시 초기화) ---
257
  krx_listings: pd.DataFrame = None
258
  us_listings: pd.DataFrame = None
259
  translator: Translator = None
260
 
261
- # --- 서버 시작 시 실행될 로직 ---
262
  @app.on_event("startup")
263
  async def load_initial_data():
264
- """
265
- 서버가 시작될 때 주식 목록과 번역기를 미리 로드하여
266
- API 요청마다 반복적으로 로드하는 것을 방지합니다.
267
- """
268
  global krx_listings, us_listings, translator
269
-
270
  logger.info("✅ 서버 시작: 초기 데이터 로딩을 시작합니다...")
271
  try:
272
  krx_listings = await run_in_threadpool(fdr.StockListing, 'KRX')
273
  logger.info("📊 한국 상장 기업 목록 로딩 완료.")
274
-
275
  nasdaq = await run_in_threadpool(fdr.StockListing, 'NASDAQ')
276
  nyse = await run_in_threadpool(fdr.StockListing, 'NYSE')
277
  amex = await run_in_threadpool(fdr.StockListing, 'AMEX')
278
  us_listings = pd.concat([nasdaq, nyse, amex], ignore_index=True)
279
  logger.info("📊 미국 상장 기업 목록 로딩 완료.")
280
-
281
  translator = Translator()
282
  logger.info("🌐 번역기 초기화 완료.")
283
-
284
- logger.info("✅ 모든 초기 데이터 로딩이 성공적으로 완료되었습니다.")
285
-
286
  except Exception as e:
287
- logger.error(f"🚨 초기 데이터 로딩 심각한 오류 발생: {e}", exc_info=True)
288
- # 필요하다면 여기서 서버 실행을 중단시킬 수도 있습니다.
289
- # raise RuntimeError("Failed to load initial stock listings.") from e
290
 
291
- # --- 핵심 로직 함수 ---
292
  def get_stock_info(company_name: str) -> Dict[str, str] | None:
293
- """
294
- 회사명을 받아 한국 또는 미국 시장에서 종목 정보를 찾아 반환합니다.
295
- (정상 동작하는 스크립트의 로직을 그대로 적용)
296
- """
297
- # 1. 한국 주식에서 먼저 검색
298
  kr_match = krx_listings[krx_listings['Name'].str.contains(company_name, case=False, na=False)]
299
  if not kr_match.empty:
300
- stock = kr_match.iloc[0]
301
- logger.info(f"KRX에서 '{company_name}' 발견: {stock['Name']} ({stock['Code']})")
302
- return {"market": "KRX", "symbol": stock['Code'], "name": stock['Name']}
303
-
304
- # 2. 한국에 없으면 미국 주식에서 검색 (번역기 사용)
305
  try:
306
- # 번역은 I/O 작업이므로 스레드풀에서 실행하는 것이 더 안전할 수 있으나,
307
- # googletrans의 내부 구현에 따라 여기서 직접 호출해도 큰 문제가 없을 수 있습니다.
308
  company_name_eng = translator.translate(company_name, src='ko', dest='en').text
309
- logger.info(f"'{company_name}' -> 영어로 번역: '{company_name_eng}'")
310
-
311
- # 이름 또는 심볼에서 검색
312
  us_match = us_listings[
313
  us_listings['Name'].str.contains(company_name_eng, case=False, na=False) |
314
  us_listings['Symbol'].str.fullmatch(company_name_eng, case=False)
315
  ]
316
-
317
  if not us_match.empty:
318
- stock = us_match.iloc[0]
319
- logger.info(f"US에서 '{company_name}' 발견: {stock['Name']} ({stock['Symbol']})")
320
- return {"market": "US", "symbol": stock['Symbol'], "name": stock['Name']}
321
-
322
  except Exception as e:
323
- logger.error(f"'{company_name}' 번역 또는 미국 주식 검색 오류: {e}")
324
-
325
- # 3. 최종적으로 찾지 못한 경우
326
- logger.warning(f"'{company_name}'에 해당하는 종목을 찾지 못했습니다.")
327
  return None
328
 
329
- def fetch_stock_prices_sync(symbol: str, days: int = 365) -> pd.DataFrame:
330
- """
331
- 지정된 기간 동안의 주가 데이터를 가져옵니다 (동기 함수).
332
- """
333
  end_date = datetime.today()
334
  start_date = end_date - timedelta(days=days)
335
-
336
- logger.info(f"FinanceDataReader로 '{symbol}'의 주가 데이터 조회를 시작합니다 ({start_date.date()} ~ {end_date.date()}).")
337
  try:
338
  df = fdr.DataReader(symbol, start=start_date, end=end_date)
339
  if df.empty:
340
- logger.warning(f"'{symbol}'에 대한 데이터가 없습니다.")
341
  return None
342
  return df
343
  except Exception as e:
344
- logger.error(f"'{symbol}' 데이터 조회 오류 발생: {e}", exc_info=True)
345
  return None
346
 
347
- # --- API 엔드포인트 ---
348
-
349
  @app.get("/ai/stock-data/by-name",
350
  summary="회사명으로 최근 1년 주가 데이터 조회 (JSON)",
351
- description="회사명(예: 삼성전자, 애플)을 입력받아 최근 1년간의 일별 주가 데이터를 JSON 형식으로 반환합니다.")
352
- async def get_stock_data_by_name(
353
- company_name: str = Query(..., description="조회할 회사명")
354
- ) -> List[Dict[str, Any]]:
355
-
356
  if not company_name or not company_name.strip():
357
  raise HTTPException(status_code=400, detail="회사명을 입력해주세요.")
358
-
359
  stock_info = await run_in_threadpool(get_stock_info, company_name.strip())
360
-
361
  if not stock_info:
362
  raise HTTPException(status_code=404, detail=f"'{company_name}'에 해당하는 종목을 찾을 수 없습니다.")
363
-
364
  prices_df = await run_in_threadpool(fetch_stock_prices_sync, stock_info['symbol'], 365)
365
-
366
  if prices_df is None or prices_df.empty:
367
  raise HTTPException(status_code=404, detail=f"'{stock_info['name']}'의 시세 데이터를 찾을 수 없습니다.")
368
 
369
- prices_df.index.name = 'Date' # 👈 이 줄을 추가하여 인덱스 이름을 명시적으로 설정
370
  prices_df.reset_index(inplace=True)
371
  prices_df['Date'] = prices_df['Date'].dt.strftime('%Y-%m-%d')
372
-
373
  return prices_df.to_dict(orient='records')
374
 
375
-
376
-
 
377
  if __name__ == "__main__":
378
- uvicorn.run(app, host="0.0.0.0", port=8000)
 
45
 
46
 
47
 
48
+ # ---------------------------------------
49
+ # 입력/출력 모델
50
+ # ---------------------------------------
51
  class NewsRequest(BaseModel):
52
  url: str
53
  id: Optional[str] = None
54
 
55
+ class SummaryInput(BaseModel):
56
+ url: str
57
+
58
+ class KeywordsInput(BaseModel):
59
+ summary: str
60
+
61
+ class CompanyInput(BaseModel):
62
+ summary: Optional[str] = None
63
+ keywords: Optional[List[str]] = None
64
+
65
+ class SentimentInput(BaseModel):
66
+ content: str
67
+
68
+ class PredictInput(BaseModel):
69
+ keywords: List[Union[str, Dict[str, Any]]]
70
 
71
+
72
+ # ---------------------------------------
73
+ # 간단한 분류기 (기존과 동일)
74
+ # ---------------------------------------
75
  class SimpleClassifier(torch.nn.Module):
76
  def __init__(self, input_dim):
77
  super().__init__()
 
81
  torch.nn.Linear(64, 1),
82
  torch.nn.Sigmoid()
83
  )
 
84
  def forward(self, x):
85
  return self.net(x)
86
 
87
+ # ---------------------------------------
88
+ # 공통 유틸: HTML, 파서, 썸네일
89
+ # ---------------------------------------
90
+ def fetch_html(url: str) -> bs:
91
  headers = {"User-Agent": "Mozilla/5.0"}
92
+ resp = requests.get(url, headers=headers, timeout=7)
93
+ resp.raise_for_status()
94
+ return bs(resp.text, "html.parser")
95
 
96
+ def parse_naver(soup: bs):
97
  title = soup.select_one("h2.media_end_head_headline") or soup.title
98
  title_text = title.get_text(strip=True) if title else "제목 없음"
 
99
  time_tag = soup.select_one("span.media_end_head_info_datestamp_time")
100
  time_text = time_tag.get_text(strip=True) if time_tag else "시간 없음"
101
 
 
105
  content = '\n'.join([p.get_text(strip=True) for p in paragraphs]) if paragraphs else content_area.get_text(strip=True)
106
  else:
107
  content = "본문 없음"
 
108
  return title_text, time_text, content
109
 
110
+ def parse_daum(soup: bs):
111
  title = soup.select_one("h3.tit_view") or soup.title
112
  title_text = title.get_text(strip=True) if title else "제목 없음"
 
113
  time_tag = soup.select_one("span.num_date")
114
  time_text = time_tag.get_text(strip=True) if time_tag else "시간 없음"
115
 
 
119
  content = '\n'.join([p.get_text(strip=True) for p in paragraphs]) if paragraphs else content_area.get_text(strip=True)
120
  else:
121
  content = "본문 없음"
 
122
  return title_text, time_text, content
123
 
124
+ def extract_thumbnail(soup: bs) -> Optional[str]:
125
  tag = soup.find("meta", property="og:image")
126
  return tag["content"] if tag and "content" in tag.attrs else None
127
 
128
+ def parse_article_all(url: str) -> Dict[str, Any]:
129
+ soup = fetch_html(url)
130
+ if "naver.com" in url:
131
+ title, time_str, content = parse_naver(soup)
132
+ elif "daum.net" in url:
133
+ title, time_str, content = parse_daum(soup)
134
+ else:
135
+ raise HTTPException(status_code=400, detail="지원하지 않는 뉴스 사이트입니다.")
136
+ thumbnail = extract_thumbnail(soup)
137
+ return {
138
+ "title": title,
139
+ "time": time_str,
140
+ "content": content,
141
+ "thumbnail_url": thumbnail,
142
+ "url": url,
143
+ }
144
+
145
+ # ---------------------------------------
146
+ # 회사명 추론 (Gemini)
147
+ # ---------------------------------------
148
+ def gemini_use(text_for_company: str) -> str:
149
+ generation_config = genai.GenerationConfig(temperature=1)
150
  model = genai.GenerativeModel('gemini-2.0-flash', generation_config=generation_config)
 
151
  prompt = f"""
152
  아래 내용을 참고해서 가장 연관성이 높은 주식 상장 회사 이름 하나만 말해줘.
153
  다른 설명 없이 회사 이름만 대답해.
154
 
155
+ "{text_for_company}"
156
  """
 
157
  response = model.generate_content(prompt)
158
  try:
159
+ return response.text.strip()
160
  except AttributeError:
161
+ return response.candidates[0].content.parts[0].text.strip()
162
+
163
+ # ---------------------------------------
164
+ # 1) 요약 단계
165
+ # ---------------------------------------
166
+ @app.post("/ai/summary")
167
+ def step_summary(inp: SummaryInput):
168
+ meta = parse_article_all(inp.url)
169
+ # 너가 기존 resultKeyword를 먼저 쓰고 싶다면 이 한 줄로 대체 가능:
170
+ # rk = resultKeyword(meta["content"]); return {**meta, "summary": rk["summary"]}
171
+ summary_text = summarize(meta["content"])
172
+ return {**meta, "summary": summary_text}
173
+
174
+ # 2) 키워드 단계
175
+ @app.post("/ai/keywords")
176
+ def step_keywords(inp: KeywordsInput):
177
+ print("키워드는 옴")
178
+ try:
179
+ rk = resultKeyword(inp.summary)
180
+ return {"keywords": rk["keyword"]}
181
+ except Exception as e:
182
+ print("❌ 키워드 추출 오류:", e)
183
+ return {"keywords": []}
184
+
185
+ # 3) 관련 상장사 단계
186
+ @app.post("/ai/company")
187
+ def step_company(inp: CompanyInput):
188
+ if inp.summary:
189
+ text = inp.summary
190
+ elif inp.keywords:
191
+ text = ", ".join(inp.keywords)
192
+ else:
193
+ raise HTTPException(status_code=400, detail="summary 또는 keywords 중 하나가 필요합니다.")
194
+ company = gemini_use(text)
195
+ return {"company": company}
196
+
197
+ # 4) 감정 단계
198
+ @app.post("/ai/sentiment")
199
+ def step_sentiment(inp: SentimentInput):
200
+ s = analyze_sentiment(inp.content)
201
+ pos, neg, neu = s["positive"], s["negative"], s["neutral"]
202
+ # 중립 절반, 나머지 비율 재분배 (기존 로직)
203
+ reduced_net = neu / 2
204
+ remaining = neu - reduced_net
205
+ total_non_neu = neg + pos
206
+ if total_non_neu > 0:
207
+ neg += remaining * (neg / total_non_neu)
208
+ pos += remaining * (pos / total_non_neu)
209
+ else:
210
+ neg += remaining / 2
211
+ pos += remaining / 2
212
+ neu = reduced_net
213
+
214
+ max_label = max([("부정", neg), ("중립", neu), ("긍정", pos)], key=lambda x: x[1])[0]
215
+ if max_label == "긍정":
216
+ if pos >= 0.9: label = f"매우 긍정 ({pos*100:.1f}%)"
217
+ elif pos >= 0.6: label = f"긍정 ({pos*100:.1f}%)"
218
+ else: label = f"약한 긍정 ({pos*100:.1f}%)"
219
+ elif max_label == "부정":
220
+ if neg >= 0.9: label = f"매우 부정 ({neg*100:.1f}%)"
221
+ elif neg >= 0.6: label = f"부정 ({neg*100:.1f}%)"
222
+ else: label = f"약한 부정 ({neg*100:.1f}%)"
223
+ else:
224
+ label = f"중립 ({neu*100:.1f}%)"
225
+
226
+ return {
227
+ "raw": {"positive": s["positive"], "negative": s["negative"], "neutral": s["neutral"]},
228
+ "adjusted": {"positive": pos, "negative": neg, "neutral": neu},
229
+ "sentiment": label
230
+ }
231
+
232
+ # 5) 주가 예측 단계
233
+ @app.post("/ai/predict")
234
+ def step_predict(inp: PredictInput):
235
+ # 🔹 문자열 리스트로 정제 (딕셔너리인 경우 "word" 키 사용)
236
+ clean_keywords = []
237
+ for kw in inp.keywords:
238
+ if isinstance(kw, str):
239
+ clean_keywords.append(kw)
240
+ elif isinstance(kw, dict) and "word" in kw:
241
+ clean_keywords.append(kw["word"])
242
+
243
+ if not clean_keywords:
244
+ raise HTTPException(status_code=400, detail="keywords 리스트가 비어 있습니다.")
245
+
246
+ # 🔹 이하 기존 로직 동일
247
+ keyword_vec = embed_keywords(clean_keywords)
248
+ input_vec = torch.tensor(keyword_vec, dtype=torch.float32).unsqueeze(0)
249
+ input_dim = input_vec.shape[1]
250
+
251
+ model = SimpleClassifier(input_dim)
252
+ model.load_state_dict(torch.load("news_model.pt", map_location="cpu"))
253
+ model.eval()
254
+
255
+ with torch.no_grad():
256
+ prob = model(input_vec).item()
257
+ pred_label = '📈 상승 (1)' if prob >= 0.5 else '📉 하락 (0)'
258
+
259
+ return {"prediction": pred_label, "prob": prob}
260
+
261
+ # ---------------------------------------
262
+ # 호환용: 기존 parse-news (한방 요청) - 유지
263
+ # ---------------------------------------
264
  @app.post("/ai/parse-news")
265
  def parse_news(req: NewsRequest):
266
  url = req.url.strip()
 
267
  try:
268
+ meta = parse_article_all(url)
269
+
270
+ # 키워드/요약(기존 resultKeyword 사용)
271
+ rk = resultKeyword(meta["content"])
272
+ targetCompany = gemini_use(rk) # 텍스트 변환은 f-string 내부에서 처리됨
273
+
274
+ # 감정(기존 로직)
275
+ s = analyze_sentiment(meta["content"])
276
+ pos, neg, neu = s["positive"], s["negative"], s["neutral"]
277
+ print("부정:", neg)
278
+ print("중립:", neu)
279
+ print("긍정:", pos)
280
+
281
+ reduced_net = neu / 2
282
+ remaining = neu - reduced_net
283
+ total_non_neu = neg + pos
 
 
 
 
 
 
 
 
 
 
 
 
 
284
  if total_non_neu > 0:
285
+ neg += remaining * (neg / total_non_neu)
286
+ pos += remaining * (pos / total_non_neu)
287
  else:
288
+ neg += remaining / 2
289
+ pos += remaining / 2
290
+ neu = reduced_net
 
 
 
 
 
 
291
 
292
+ max_label = max([("부정", neg), ("중립", neu), ("긍정", pos)], key=lambda x: x[1])[0]
293
  if max_label == "긍정":
294
+ if pos >= 0.9: sentiment_label = f"매우 긍정 ({pos*100:.1f}%)"
295
+ elif pos >= 0.6: sentiment_label = f"긍정 ({pos*100:.1f}%)"
296
+ else: sentiment_label = f"약한 긍정 ({pos*100:.1f}%)"
 
 
 
297
  elif max_label == "부정":
298
+ if neg >= 0.9: sentiment_label = f"매우 부정 ({neg*100:.1f}%)"
299
+ elif neg >= 0.6: sentiment_label = f"부정 ({neg*100:.1f}%)"
300
+ else: sentiment_label = f"약한 부정 ({neg*100:.1f}%)"
 
 
 
301
  else:
302
+ sentiment_label = f"중립 ({neu*100:.1f}%)"
 
 
 
303
 
304
+ # 예측
305
+ summary_text = rk.get("summary") or summarize(meta["content"])
306
+ _, keywords_2nd = extract_keywords(summary_text)
 
307
  clean_keywords = [kw for kw, _ in keywords_2nd]
 
308
  keyword_vec = embed_keywords(clean_keywords)
309
+ input_vec = torch.tensor(keyword_vec, dtype=torch.float32).unsqueeze(0)
310
+ model = SimpleClassifier(input_vec.shape[1])
 
 
 
311
  model.load_state_dict(torch.load("news_model.pt", map_location="cpu"))
312
  model.eval()
 
313
  with torch.no_grad():
314
  prob = model(input_vec).item()
315
+ prediction_label = '📈 상승 (1)' if prob >= 0.5 else '📉 하락 (0)'
 
 
 
 
 
316
 
 
 
317
  return {
318
+ **meta,
319
  "message": "뉴스 파싱 및 저장 완료",
320
+ "summary": rk["summary"],
321
+ "keyword": rk["keyword"],
 
 
 
 
 
322
  "company": targetCompany,
323
  "sentiment": sentiment_label,
324
  "sentiment_value": sentiment_label,
325
+ "prediction": prediction_label,
326
  "prob": prob,
327
  }
328
 
329
  except requests.exceptions.RequestException as e:
330
+ traceback.print_exc()
331
  raise HTTPException(status_code=500, detail=f"서버 오류: {e}")
332
  except Exception as e:
333
+ traceback.print_exc()
334
  raise HTTPException(status_code=500, detail=f"서버 오류: {e}")
335
+
336
+ # ---------------------------------------
337
+ # 주가 데이터 (기존 유지)
338
+ # ---------------------------------------
339
  krx_listings: pd.DataFrame = None
340
  us_listings: pd.DataFrame = None
341
  translator: Translator = None
342
 
 
343
  @app.on_event("startup")
344
  async def load_initial_data():
 
 
 
 
345
  global krx_listings, us_listings, translator
 
346
  logger.info("✅ 서버 시작: 초기 데이터 로딩을 시작합니다...")
347
  try:
348
  krx_listings = await run_in_threadpool(fdr.StockListing, 'KRX')
349
  logger.info("📊 한국 상장 기업 목록 로딩 완료.")
 
350
  nasdaq = await run_in_threadpool(fdr.StockListing, 'NASDAQ')
351
  nyse = await run_in_threadpool(fdr.StockListing, 'NYSE')
352
  amex = await run_in_threadpool(fdr.StockListing, 'AMEX')
353
  us_listings = pd.concat([nasdaq, nyse, amex], ignore_index=True)
354
  logger.info("📊 미국 상장 기업 목록 로딩 완료.")
 
355
  translator = Translator()
356
  logger.info("🌐 번역기 초기화 완료.")
357
+ logger.info("✅ 초기 데이터 로딩 성공.")
 
 
358
  except Exception as e:
359
+ logger.error(f"🚨 초기 데이터 로딩 오류: {e}", exc_info=True)
 
 
360
 
 
361
  def get_stock_info(company_name: str) -> Dict[str, str] | None:
 
 
 
 
 
362
  kr_match = krx_listings[krx_listings['Name'].str.contains(company_name, case=False, na=False)]
363
  if not kr_match.empty:
364
+ s = kr_match.iloc[0]
365
+ return {"market": "KRX", "symbol": s['Code'], "name": s['Name']}
 
 
 
366
  try:
 
 
367
  company_name_eng = translator.translate(company_name, src='ko', dest='en').text
 
 
 
368
  us_match = us_listings[
369
  us_listings['Name'].str.contains(company_name_eng, case=False, na=False) |
370
  us_listings['Symbol'].str.fullmatch(company_name_eng, case=False)
371
  ]
 
372
  if not us_match.empty:
373
+ s = us_match.iloc[0]
374
+ return {"market": "US", "symbol": s['Symbol'], "name": s['Name']}
 
 
375
  except Exception as e:
376
+ logger.error(f"번역/미국 주식 검색 오류: {e}")
 
 
 
377
  return None
378
 
379
+ def fetch_stock_prices_sync(symbol: str, days: int = 365) -> Optional[pd.DataFrame]:
 
 
 
380
  end_date = datetime.today()
381
  start_date = end_date - timedelta(days=days)
 
 
382
  try:
383
  df = fdr.DataReader(symbol, start=start_date, end=end_date)
384
  if df.empty:
 
385
  return None
386
  return df
387
  except Exception as e:
388
+ logger.error(f"'{symbol}' 데이터 조회 오류: {e}", exc_info=True)
389
  return None
390
 
 
 
391
  @app.get("/ai/stock-data/by-name",
392
  summary="회사명으로 최근 1년 주가 데이터 조회 (JSON)",
393
+ description="회사명(예: 삼성전자, 애플)을 입력받아 최근 1년간의 일별 주가 데이터를 JSON 형식으로 반환")
394
+ async def get_stock_data_by_name(company_name: str = Query(..., description="조회할 회사명")) -> List[Dict[str, Any]]:
 
 
 
395
  if not company_name or not company_name.strip():
396
  raise HTTPException(status_code=400, detail="회사명을 입력해주세요.")
 
397
  stock_info = await run_in_threadpool(get_stock_info, company_name.strip())
 
398
  if not stock_info:
399
  raise HTTPException(status_code=404, detail=f"'{company_name}'에 해당하는 종목을 찾을 수 없습니다.")
 
400
  prices_df = await run_in_threadpool(fetch_stock_prices_sync, stock_info['symbol'], 365)
 
401
  if prices_df is None or prices_df.empty:
402
  raise HTTPException(status_code=404, detail=f"'{stock_info['name']}'의 시세 데이터를 찾을 수 없습니다.")
403
 
404
+ prices_df.index.name = 'Date'
405
  prices_df.reset_index(inplace=True)
406
  prices_df['Date'] = prices_df['Date'].dt.strftime('%Y-%m-%d')
 
407
  return prices_df.to_dict(orient='records')
408
 
409
+ # ---------------------------------------
410
+ # 실행
411
+ # ---------------------------------------
412
  if __name__ == "__main__":
413
+ uvicorn.run(app, host="0.0.0.0", port=8000)