Compare commits

...

14 Commits

Author SHA1 Message Date
kyu 73575d1ee8 Merge branch 'fix/news-sources-accuracy' 2026-06-06 15:35:52 +09:00
kyu c2bed9102a feat: 뉴스 소스 27→37 확장 + 종목명 오탐 맥락게이트
[소스 다변화] 라이브 검증(200+한글기사)된 10개 피드 추가:
연합인포맥스(채권·FX)·매경증권·한경 글로벌마켓·연합뉴스 산업·데일리안·
ZDNet코리아·테크M·전자신문 반도체·오토헤럴드·철강금속신문.
마켓/FX·IT·반도체·자동차·철강 버티컬로 커버리지 확대.
검증: 37소스 라이브 크롤 360건, fetch 에러 0.

[종목 귀속 정확성] extract_stocks의 한글명 부분일치(text.count) 오탐 차단.
일상어와 겹치는 모호 종목명 19개(대상·동양·동서·미래 등)는 본문에 주식
맥락 토큰(주가·실적·영업이익·코스피·㈜·반도체 등)이 있을 때만 인정.
보령·풍산·세방 등 고유명은 제외(누락 방지).
검증: '지원 대상으로'→무매칭, '대상 영업이익 증가'→대상 인정, 삼성전자 영향없음.

두 서비스 재빌드·재기동·라이브검증 완료.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 15:06:38 +09:00
kyu b3c5032f72 fix: 트레이딩 로직/AI 정확도 8종 수정 (Altman 과발생·CV누수·MACD·손절역전)
트레이딩 전문가 관점 로직 감사 후 라이브 추천·자동매매 영향 8종 수정.

[명백한 버그]
- MACD 시그널선: macd_line[-9:] 9개만 EMA→평활 거의 안 됨. 전체 시리즈로 수정 (ta-engine)
- 매수 손절가 역전: 하락추세(price<MA60)서 ma60*0.95가 진입가 위로 올라가 RR 역전·즉시손절
  → [-10%,-4%] 밴드로 클램프. 매도(숏)도 대칭 수정 (ta-engine)
- 12-1 모멘텀: closes[-1]은 보유데이터 길이(200~259)따라 룩백 가변→closes[min(251,len-1)] 12개월 고정

[모델 품질]
- Ridge 무스케일 학습: 피처 -100~수천 혼재로 L2 왜곡. StandardScaler 파이프라인 +
  계수 원본공간 역변환 저장(predict-price 무변경, 재구성 오차 3.5e-15)
- CV 패널 누수: 인덱스 분할이 같은 score_date를 train/test로 가르고 +30일 라벨윈도가
  test 피처와 겹침. 날짜분할 + 임바고(7d→12일/30d→39일)로 차단→정직한 IC/r2

[캘리브레이션·리스크]
- Altman Z: 2항 변형에 원본 4항 임계값(2.6/1.1) 적용→전시장 31%가 '부도위험' 오발생.
  실측분포(중앙값1.98/p10 0.51) 기준 부도0.7/안전3.0 재보정
- 일일 -3% halt: realized_pnl만 봐서 평가손실·무매도일 누락. 미실현손익 합산으로 수정

빌드·재기동·라이브 스모크 검증 완료. 자동매매 capital 실잔고 연동은 향후 과제(paper모드 무관).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 13:41:52 +09:00
kyu 0c8724a542 feat: 추천 정확도 측정 신뢰화 (median 집계 + 30일 verdict + OHLCV 이상치 감지)
- /accuracy 집계를 AVG→중앙값(percentile_cont): 동전주·불량데이터 이상치가
  평균을 왜곡해 강력매도가 +12% 띄던 가짜 역전 제거 (중앙값은 정상 변별)
- verdict를 7일→30일 기준 3티어로(양호/부분유효/교정필요) + 30일 표본부족 폴백.
  응답에 buy_alpha30/sell_alpha30/spread30/basis 추가
- data-health에 OHLCV 이상치 감지룰 추가: KRX ±30% 일일제한 초과(>0.35)는
  정의상 불량(스케일버그·권리락). YELLOW 경고, 급증시 RED

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 00:27:31 +09:00
kyu 3eab710dce feat: 개인 수급 컨트래리언 점수 반영 (기관+개인 수급 시그널 ±15)
inst_daily_flow.individual_net 5일 합산 추가. calc_inst_flow_signal에 개인
컨트래리언 로직: 개인 순매수=감점(과열), 개인이탈+큰손매집=가점, 개인매수+큰손매도=분산 감점.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 22:45:15 +09:00
kyu 05682a8a3e feat: 공매도 점수 양방향화 (과매도+공매도과다 → 숏스퀴즈 반등 가산)
calc_short_score에 RSI 전달, 거래비중≥5% + RSI≤35면 숏커버링 반등 기대로
최대 +35 가산해 단방향 패널티 상쇄. 기관/외국인 수급은 기존대로 반영 중
(외국인 calc_foreign_score 14%, 기관 flow_sig ±10 aux_total).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 22:12:11 +09:00
kyu 04ab01a166 fix: 주간 자동학습 lambda async 버그 수정 (learn_weights/learn_pricing)
일요일 가중치·가격모델 학습이 lambda:coro()로 등록돼 APScheduler가 await 못해
실제로 안 돌던 버그(weight_config 05-24 이후 스케줄 갱신 0) → 명명 코루틴 함수
(_learn_weights_job/_learn_pricing_job)로 교체. learn_weights 수동검증 OK(표본10,683).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 19:47:17 +09:00
kyu cacdeb7bda fix: 브리핑 중복발송 쓰로틀 + 람다 async 스케줄러 버그
- send_briefing에 30분 쓰로틀(_last_briefing_sent) — n8n 중복/재시도로 /briefing/send가
  분당 수십 회 호출돼 같은 브리핑이 3~4건+씩 발송되던 폭주를 원천 차단.
- 스케줄러 lambda:coro() → 명명 코루틴 함수(_daily_score_notify/_ta_intraday/_ta_close).
  APScheduler가 lambda 반환 코루틴을 await 안 해 16:30 일간리포트·TA 장마감분석이
  'coroutine never awaited'로 실행 안 되던 버그 수정.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 16:41:26 +09:00
kyu c5ef30b2be feat: 모의 자동매매 자동체결 + 손익/이력 조회
- score-engine: TRADE_SETTINGS.auto_execute(기본 True) — auto_trade_scan_job이 제안한
  pending 주문을 즉시 자동 체결(모의). 체결로직을 _fill_order로 추출(수동버튼/자동 공용).
  /trade/history: 체결이력+일별 실현/평가손익+보유 평가손익. (전부 DB 시뮬레이션, 실제 브로커 전송 없음)
- dashboard-api: /api/trade-history 프록시.
- 가드 그대로: 종목당10%·일일손실-3% halt·강력매수+두LLM일치+확신도4·손절-8%·익절+15%.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 13:16:30 +09:00
kyu 0cb1b736a9 feat: CIO 매도 결정안 + 결정안 성과추적 + 대시보드 표시
- score-engine: run_cio_decisions에 보유종목(user_portfolio) 매도 결정안(등급악화/손절)
  추가, 모든 결정안에 entry_price 기록. verify_decisions_job(매일18:10)이 7일 실측
  수익·알파·정답여부 채점 → /decisions/accuracy (자동실행 게이트, "회사 결정이 실제 맞았나").
- dashboard-api: /api/data-health·accuracy·hot-validate·decisions·decisions-accuracy 프록시.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 12:42:12 +09:00
kyu 84fd3833ff feat: CIO 종합 에이전트 — 오늘의 결정안 (dry-run)
/decisions/generate + /decisions: 점수+보팅+핫검증+시장레짐 종합해 매수 결정안
(확신도1~5·제안비중·논거·리스크노트) 생성→daily_decisions 저장. 약세장 사이즈 축소,
분식의심/이익품질/핫검증 통과를 리스크노트로. 평일 09:20 텔레그램 보고(자동실행 OFF).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 12:34:43 +09:00
kyu e33dffb0e1 feat: 핫종목 검증팀 — 키움 핫 × 가치/품질 노이즈필터 (dry-run)
/hot/validate: 키움 거래량급증(ka10023) 종목을 ETF/파생·초소형(작전)·영업적자·
분식의심(Beneish≥50)·음수점수로 걸러 '검증통과'만 추림. 평일 09:35 통과분 1회 보고.
현재 핫 30개 중 77%가 ETF/파생 노이즈로 확인.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 12:32:16 +09:00
kyu 5c721e6b70 feat: 데이터 토대 + 정확도 검증 (AI 투자조직 Phase 0~1)
- score-engine: 일간리포트 텔레그램을 notify=True(16:30 1회)로 게이팅 → 호출마다
  발신되던 폭주 제거. 데이터 무결성 모니터(/data-health + 평일 매시간 경고).
  정확도 검증 하베스트(/accuracy + 주간 리포트) — 추천 등급별 실측 알파/적중률.
- ta-engine: job_analyze가 is_active=true 전 활성종목 시총순 커버(장중 상위500·
  장마감 전종목). 기존 LIMIT 500·무필터로 LS 등 누락되던 버그 수정.
- docs/ai_org.md: 데이터우선 마스터 기획(데이터→검증→지능→실행).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 12:29:02 +09:00
kyu a56227ace9 fix(kis-api): 키움 토큰 갱신 race 제거 (asyncio.Lock + double-check)
장 시작 09:00 토큰 만료 시 여러 잡이 동시 재발급 → 키움 앱키당 토큰 1개
정책으로 무효 토큰을 붙들어 ka10001 전건 실패(ok:0) → 시세 stale(텔레그램
현재가 불일치). KiwoomToken.get()에 락+재확인 추가, _refresh 분리.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 10:32:11 +09:00
7 changed files with 867 additions and 126 deletions
+18
View File
@@ -125,11 +125,29 @@ def extract_morphemes(text):
except:
return [w for w in text.split() if len(w) >= 2 and w not in STOPWORDS]
# 일상어와 철자가 겹쳐 오탐이 잦은 짧은 종목명 — 본문에 주식 맥락 토큰이 같이 있을 때만 인정.
# (예: "지원 대상으로 선정"→대상㈜ 오탐, "동양적 가치관"→동양 오탐 차단)
# 보령·대덕·풍산·세방처럼 일상어로 거의 안 쓰이는 고유 종목명은 넣지 않음(오히려 누락 유발).
AMBIGUOUS_NAMES = {
"대상", "동양", "동서", "미래", "대한", "고려", "한일", "서울", "부산",
"동국", "유성", "남성", "영원", "태양", "대성", "백산", "신성", "한창", "우성",
}
_STOCK_CTX = (
"주가", "주식", "증시", "상장", "상폐", "공시", "영업이익", "매출", "순이익",
"실적", "수주", "계약", "배당", "자사주", "유상증자", "무상증자", "목표주가",
"코스피", "코스닥", "급등", "급락", "상한가", "하한가", "", "그룹", "지주",
"전자", "화학", "제약", "바이오", "반도체", "자동차", "건설", "증권", "철강",
)
def extract_stocks(text):
has_ctx = any(m in text for m in _STOCK_CTX)
found = {}
for name, code in state.stock_map.items():
if len(name) < 2:
continue # 1글자 종목명은 오탐 과다 → 제외
# 모호 종목명은 주식 맥락 토큰이 본문에 있을 때만 인정 (일상어 오탐 차단)
if name in AMBIGUOUS_NAMES and not has_ctx:
continue
if name.isascii():
# 영문/숫자 약칭(KT·SK·DB 등)은 단어경계 강제 (SKT·KTX 오탐 차단)
pat = rf"(?<![A-Za-z0-9]){re.escape(name)}(?![A-Za-z0-9])"
+32
View File
@@ -2084,6 +2084,38 @@ async def proxy_sector_concentration():
return await _proxy_get(f"{SCORE_ENGINE_URL}/sector/concentration")
# ── AI 투자조직 패널 (데이터건강·정확도·핫검증·결정안) ──────────────
@app.get("/api/data-health")
async def proxy_data_health():
"""데이터 무결성 (score-engine /data-health)"""
return await _proxy_get(f"{SCORE_ENGINE_URL}/data-health")
@app.get("/api/accuracy")
async def proxy_accuracy(days: int = Query(default=90, ge=7, le=365)):
"""추천 등급별 정확도 (score-engine /accuracy)"""
return await _proxy_get(f"{SCORE_ENGINE_URL}/accuracy?days={days}")
@app.get("/api/hot-validate")
async def proxy_hot_validate():
"""핫종목 검증 (score-engine /hot/validate)"""
return await _proxy_get(f"{SCORE_ENGINE_URL}/hot/validate", timeout=20.0)
@app.get("/api/decisions")
async def proxy_decisions():
"""오늘의 결정안 (score-engine /decisions)"""
return await _proxy_get(f"{SCORE_ENGINE_URL}/decisions")
@app.get("/api/decisions-accuracy")
async def proxy_decisions_accuracy(days: int = Query(default=60, ge=7, le=365)):
"""결정안 실측 성과 (score-engine /decisions/accuracy)"""
return await _proxy_get(f"{SCORE_ENGINE_URL}/decisions/accuracy?days={days}")
@app.get("/api/trade-history")
async def proxy_trade_history(days: int = Query(default=30, ge=1, le=365)):
"""모의매매 이력 + 손익 (score-engine /trade/history)"""
return await _proxy_get(f"{SCORE_ENGINE_URL}/trade/history?days={days}")
async def _enrich_kr_names(rows):
"""rows의 kr_code/stock_code에 dart_corps.corp_name을 kr_name으로 첨부"""
if not isinstance(rows, list) or not rows:
+68
View File
@@ -0,0 +1,68 @@
# AI 주식 투자 회사 — 마스터 기획 (데이터 우선 재설계)
> 사용자 의도: AI 에이전트들이 매일 회사처럼 스스로 운영. 사용자는 결과+승인.
> **핵심 원칙(재정립)**: 지능·자동실행보다 **데이터 신뢰성과 정확도 검증이 먼저**.
> "쓰레기 입력 → 쓰레기 추천". 토대가 검증되기 전엔 위층을 신뢰하지 않는다.
> 선택: 두뇌=하이브리드(EXAONE 1차 + Gemini 고급검증) / 승인=자동실행+사후보고 / 풀 조직.
## 왜 순서를 바꿨나 (2026-06-03 발견된 토대 결함)
- 시세: 키움 토큰 race로 stale → 복구함.
- 일봉(stock_ohlcv): 06-02 366종목/오늘 0개. ka10005(30봉 한계)로 백필 안 됨 → **네이버 siseJson 경로 필요**.
- TA: `/analyze/all`이 dart_corps LIMIT 500 중 163개만, `is_active=true` 필터 없음.
- 정확도: 매일 "당일 실측 대비" 검증이 운영 중심이 아님.
→ 이 상태로 에이전트 조직을 올리면 추천이 오염된다. 데이터·검증을 먼저 세운다.
---
## 로드맵 (각 Phase는 "검증 통과"가 다음 진행 조건)
### Phase 0 — 데이터 신뢰 기반 (지금 시작)
목표: 모든 핵심 데이터가 **매일 최신·전종목·무결**하고, 깨지면 **즉시 감지**.
- [ ] OHLCV 백필 정상화: 네이버 siseJson 경로로 06-02~당일 + KOSPI 지수 채움. 키움은 보조.
- [ ] TA 커버리지: 전 활성종목 대상 + `is_active=true` 필터.
- [ ] **데이터 무결성 모니터**(신규): 시세/일봉/TA/점수/뉴스의 신선도·종목수·결측을 매시간 점검,
기준 미달이면 텔레그램 경고 1건. (= "데이터 제대로 쌓이는지" 상시 감시)
- 검증: 모니터가 "전부 GREEN" 며칠 유지. LS 등 표본 종목 시세=일봉=TA=점수 일치.
### Phase 1 — 정확도 검증 하베스트 (백본)
목표: 모든 예측을 **매일 당일 실측과 대조**해 맞는지 수치로 안다.
- [ ] 일일 검증 잡: 어제 추천/예측 vs 오늘 실제 등락 → 등급별 적중률·평균수익·알파·IC(스피어만) 기록.
(기존 verify_predictions_job / /backtest / /learn-weights 를 이 백본으로 통합·정규화)
- [ ] **핫종목 검증팀**을 여기 편입: 키움 핫종목(ka10023 거래량급증 등) × 시스템 추천 교차 →
노이즈(작전·테마·일시급등) 필터 → 며칠 뒤 실측으로 "노이즈 판별이 맞았나" 정확도 추적.
- [ ] 정확도 리포트(텔레그램/대시보드): 방식이 맞는지 매일 사용자에게 숫자로 보고.
- 검증: 적중률·IC가 무작위(0) 대비 유의미하게 양(+)인지. 아니면 방식 수정.
### Phase 2 — AI 조직 (신뢰 데이터 위에서)
- [ ] 🔍 리서치 2명: `run_deep_analysis(model="hybrid")` 재사용 (EXAONE↔Gemini 교차검증).
- [ ] ⚠️ 리스크 매니저: 사이징·섹터집중·레짐·분식·단기약세·일일손실 게이트 정식화.
- [ ] 🧭 CIO(신규, Gemini): 점수+논거+리스크+핫검증 종합 → "오늘의 결정안"(매수/매도/보류+확신도+사이즈+논거).
- 비용 가드: EXAONE 전종목 1차 → 상위 N(기본10)만 Gemini. `gemini_call_log` 일일한도 재사용.
- 검증: 결정안을 1~2주 dry-run, Phase 1 하베스트로 실측 적중 확인.
### Phase 3 — 자동실행 + 사후보고
- 기본 **모의투자 유지**(`KIS_IS_PAPER=true`). 실계좌 전환은 별도 명시 승인.
- 사전승인 필수("중요") 기본: ①1회 매수 ≥50만원 ②신규종목 ③일일손실 -2% 근접.
- 그 외 가드 내 자동실행 + 텔레그램 사후보고. 기존 7가드·halt 유지.
- 검증: 모의계좌 체결·손익이 결정안과 일치, 가드가 실제로 막는지.
---
## 조직도 (Phase 2에서 완성, 기존 재사용 위주)
| 직책 | 구현 | 상태 |
|------|------|------|
| 📥 데이터팀 | 서비스 7종 | ✅ (Phase 0에서 신뢰성 보강) |
| 🔍 리서치 ×2 | run_deep_analysis(hybrid) | ✅ 재사용 |
| 💰 퀀트 | score-engine 10공식 | ✅ |
| 🔥 핫종목 검증팀 | 키움 핫 × 추천 노이즈필터 | 🟡 Phase 1 |
| ⚠️ 리스크 매니저 | 가드 묶음 | 🟡 Phase 2 |
| 🧭 CIO | 종합 결정안 (Gemini) | ❌ Phase 2 |
| 📋 리포터 / ✅ 사용자 | 텔레그램 보고·승인 | 🟡 Phase 3 |
## 신규 산출물(누적)
- `data_health` 점검 모듈 + 신선도 모니터 (Phase 0)
- OHLCV siseJson 백필 보강 (Phase 0)
- `prediction_accuracy` 검증 하베스트 + 리포트 (Phase 1)
- `/hot/validate` 핫종목 노이즈필터 (Phase 1)
- `daily_decisions` 테이블 + CIO 로직 (Phase 2)
- 자동실행/사후보고 배선 (Phase 3)
+9
View File
@@ -44,10 +44,19 @@ scheduler = AsyncIOScheduler(timezone="Asia/Seoul")
class KiwoomToken:
token: str = ""
expires_at: datetime = datetime.min
_lock = asyncio.Lock()
async def get(self, client: httpx.AsyncClient) -> str:
if self.token and datetime.now() < self.expires_at:
return self.token
# 동시 갱신 방지: 락 안에서 재확인(double-check) — 키움은 앱키당 토큰 1개만
# 유효(재발급 시 이전 토큰 무효화)하므로 thundering-herd 시 무효 토큰을 붙들게 됨
async with self._lock:
if self.token and datetime.now() < self.expires_at:
return self.token
return await self._refresh(client)
async def _refresh(self, client: httpx.AsyncClient) -> str:
resp = await client.post(
f"{KIWOOM_BASE_URL}/oauth2/token",
json={"grant_type": "client_credentials",
+11
View File
@@ -261,6 +261,17 @@ RSS_SOURCES = [
("더벨", "https://www.thebell.co.kr/free/content/xmlService.asp"),
("세계일보", "https://www.segye.com/RSS/economyRss.xml"),
("SBS Biz", "https://news.sbs.co.kr/news/SectionRssFeed.do?sectionId=EC"),
# ── 증권/마켓·산업 버티컬 확장 (2026-06 검증된 라이브 피드) ──
("연합인포맥스", "https://news.einfomax.co.kr/rss/allArticle.xml"), # 채권·FX·마켓 전문
("매일경제증권", "https://www.mk.co.kr/rss/50200011/"), # 증권
("한국경제글로벌", "https://www.hankyung.com/feed/international"), # 글로벌마켓
("연합뉴스산업", "https://www.yna.co.kr/rss/industry.xml"), # 산업
("데일리안", "https://www.dailian.co.kr/rss/economy"),
("ZDNet코리아", "https://feeds.feedburner.com/zdkorea"), # IT/테크
("테크M", "https://www.techm.kr/rss/allArticle.xml"), # IT/테크
("전자신문반도체", "https://rss.etnews.com/Section902.xml"), # 반도체 버티컬
("오토헤럴드", "https://www.autoherald.co.kr/rss/allArticle.xml"), # 자동차 버티컬
("철강금속신문", "https://www.snmnews.com/rss/allArticle.xml"), # 철강/소재 버티컬
]
def parse_rss_date(date_str: str) -> str:
+687 -111
View File
File diff suppressed because it is too large Load Diff
+42 -15
View File
@@ -80,7 +80,9 @@ def _macd(closes: List[float]) -> tuple:
e12 = _ema_series(closes, 12)
e26 = _ema_series(closes, 26)
macd_line = [a - b for a, b in zip(e12, e26)]
signal_line = _ema_series(macd_line[-9:] if len(macd_line) >= 9 else macd_line, 9)
# 시그널선 = MACD선 전체에 대한 9-EMA. 과거 9개(-9:)만 쓰면 평활이 거의 안 돼
# 표준 MACD 시그널과 달라짐 → 전체 시리즈로 EMA 계산.
signal_line = _ema_series(macd_line, 9)
macd = macd_line[-1]
signal = signal_line[-1]
return round(macd, 4), round(signal, 4), round(macd - signal, 4)
@@ -335,9 +337,11 @@ def calc_price_targets(price: int, ind: dict, sig: str) -> dict:
t2 = r10(price * 1.14)
t3 = r10(min(price * 1.22, h52 * 0.97))
t3 = t3 if t3 > t2 else r10(price * 1.22)
# 손절: max(-8%, MA60 -5%) — 최대 -10% 이내 제한
# 손절: max(-8%, MA60 -5%)를 [-10%, -4%] 밴드로 제한해 항상 진입가 아래 유지.
# (하락추세로 price<MA60이면 MA60*0.95가 현재가 위로 올라가 손절가>진입가가 되어
# RR이 역전·즉시 손절되는 버그 차단)
raw_stop = max(price * 0.92, ma60 * 0.95)
stop = r10(max(raw_stop, price * 0.90)) # 최소 -10%
stop = r10(min(max(raw_stop, price * 0.90), price * 0.96))
er1 = round((t1 - price) / price * 100, 1)
sl_r = round(abs(stop - price) / price * 100, 1)
# M3: ATR 기반 trailing stop (현재가 기준 2 ATR 아래)
@@ -360,8 +364,10 @@ def calc_price_targets(price: int, ind: dict, sig: str) -> dict:
t2 = r10(price * 0.86)
t3 = r10(max(price * 0.78, l52 * 1.03))
t3 = t3 if t3 < t2 else r10(price * 0.78)
# 숏 손절: [+4%, +10%] 밴드로 제한해 항상 진입가(+2%) 위 유지
# (price>MA20이면 ma20*1.05가 현재가 아래로 내려가 손절가<진입가 역전되는 버그 차단)
raw_stop = min(price * 1.08, ma20 * 1.05)
stop = r10(min(raw_stop, price * 1.10))
stop = r10(max(min(raw_stop, price * 1.10), price * 1.04))
er1 = round((price - t1) / price * 100, 1)
sl_r = round(abs(stop - price) / price * 100, 1)
return {
@@ -713,16 +719,31 @@ async def init_db():
# ── 전체 분석 작업 ────────────────────────────────────────
async def job_analyze():
logger.info("ta.job.start")
async def job_analyze(limit: int = 500):
"""limit>0: 시총 상위 N개(장중 경량). limit=0: 전 활성종목(장마감 풀커버).
is_active=true 필터 필수 — 상장폐지 제외 + LS 등 누락 방지."""
logger.info("ta.job.start", limit=limit)
async with httpx.AsyncClient() as client:
codes: List[tuple] = []
if pg_pool:
try:
rows = await pg_pool.fetch("SELECT stock_code, corp_name FROM dart_corps LIMIT 500")
q = """
SELECT c.stock_code, c.corp_name
FROM dart_corps c
LEFT JOIN (
SELECT DISTINCT ON (stock_code) stock_code, market_cap
FROM stock_prices ORDER BY stock_code, collected_at DESC
) p ON p.stock_code = c.stock_code
WHERE c.is_active = true
ORDER BY COALESCE(p.market_cap, 0) DESC
"""
if limit and limit > 0:
q += f" LIMIT {int(limit)}"
rows = await pg_pool.fetch(q)
codes = [(r["stock_code"], r["corp_name"] or "") for r in rows if r["stock_code"]]
except: pass
except Exception as e:
logger.warning("ta.codes.err", error=str(e))
if not codes:
for sosok in [0, 1]:
@@ -740,7 +761,7 @@ async def job_analyze():
if len(codes) >= 500: break
ok = 0
for code, name in codes[:500]:
for code, name in codes:
if not code or len(code) != 6: continue
try:
result = await analyze_stock(client, code, name)
@@ -748,11 +769,11 @@ async def job_analyze():
except Exception as e:
stats.errors += 1
logger.warning("ta.analyze.err", code=code, error=str(e))
await asyncio.sleep(0.4)
await asyncio.sleep(0.2)
stats.analyzed += ok
stats.last_run = datetime.now().isoformat()
logger.info("ta.job.done", analyzed=ok)
logger.info("ta.job.done", analyzed=ok, requested=len(codes))
# ── FastAPI ────────────────────────────────────────────────
@@ -768,9 +789,9 @@ async def startup():
redis_cl = aioredis.Redis(
host=REDIS_HOST, port=6379, password=REDIS_PASSWORD, db=5, decode_responses=True)
await init_db()
scheduler.add_job(job_analyze, "cron", day_of_week="mon-fri",
scheduler.add_job(_ta_intraday, "cron", day_of_week="mon-fri",
hour="9-16", minute="*/30", id="ta_30m", replace_existing=True)
scheduler.add_job(job_analyze, "cron", day_of_week="mon-fri",
scheduler.add_job(_ta_close, "cron", day_of_week="mon-fri",
hour=16, minute=15, id="ta_close", replace_existing=True)
scheduler.start()
logger.info("ta-engine.started")
@@ -855,9 +876,15 @@ async def buy_candidates(limit: int = Query(default=20)):
result.append(d)
return result
async def _ta_intraday(): # 코루틴 함수로 등록 (lambda 감싸면 APScheduler가 await 못함)
await job_analyze(limit=500)
async def _ta_close():
await job_analyze(limit=0)
@app.post("/analyze/all")
async def analyze_all():
asyncio.create_task(job_analyze())
async def analyze_all(limit: int = 0):
asyncio.create_task(job_analyze(limit=limit))
return {"status": "started"}
@app.post("/analyze/{code}")