Compare commits
14 Commits
97cf5aecb0
...
73575d1ee8
| Author | SHA1 | Date | |
|---|---|---|---|
| 73575d1ee8 | |||
| c2bed9102a | |||
| b3c5032f72 | |||
| 0c8724a542 | |||
| 3eab710dce | |||
| 05682a8a3e | |||
| 04ab01a166 | |||
| cacdeb7bda | |||
| c5ef30b2be | |||
| 0cb1b736a9 | |||
| 84fd3833ff | |||
| e33dffb0e1 | |||
| 5c721e6b70 | |||
| a56227ace9 |
@@ -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])"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
@@ -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",
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+42
-15
@@ -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}")
|
||||
|
||||
Reference in New Issue
Block a user