구조적인 부분 수정
This commit is contained in:
@@ -21,21 +21,20 @@ KST = pytz.timezone('Asia/Seoul')
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
|
||||
from sqlalchemy import select, delete, update, func
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
import logging
|
||||
from sqlalchemy import select, update, func
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.database import AsyncSessionLocal # 🔧 공통 DB 모듈 사용
|
||||
from app.core.market_schedule import is_market_open
|
||||
from app.models.models import StockAnalysis, WatchlistStock, WatchlistHistory
|
||||
from app.api.endpoints.recommendations import analyze_stock_for_batch
|
||||
from app.services.analysis_db_service import upsert_analysis, passes_price_filter, run_db_scanner # 🔧 단일화
|
||||
from app.services.watchlist import (
|
||||
WATCHLIST_MAX_SIZE,
|
||||
WATCHLIST_ENTRY_SCORE,
|
||||
WATCHLIST_EXIT_SCORE,
|
||||
)
|
||||
from batch_daily_analysis import run_db_scanner
|
||||
import logging
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
@@ -49,25 +48,6 @@ BATCH_CONCURRENCY = 5
|
||||
# 워치리스트 외부 후보 재분석 상한 (10분 배치 내에 처리 가능한 수준)
|
||||
OUTSIDE_RESCORE_LIMIT = 30
|
||||
|
||||
# DB 연결
|
||||
database_url = settings.DATABASE_URL
|
||||
if database_url.startswith("postgresql://"):
|
||||
database_url = database_url.replace("postgresql://", "postgresql+asyncpg://", 1)
|
||||
|
||||
engine = create_async_engine(
|
||||
database_url,
|
||||
echo=False,
|
||||
pool_pre_ping=True,
|
||||
pool_size=5,
|
||||
max_overflow=10,
|
||||
)
|
||||
|
||||
AsyncSessionLocal = sessionmaker(
|
||||
engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
)
|
||||
|
||||
|
||||
async def get_active_watchlist() -> list[tuple[str, str]]:
|
||||
"""현재 활성 분석종목 목록 조회."""
|
||||
@@ -84,28 +64,93 @@ async def get_active_watchlist() -> list[tuple[str, str]]:
|
||||
"아침 배치(batch_daily_analysis.py)가 먼저 실행되었는지 확인하세요."
|
||||
)
|
||||
else:
|
||||
logger.info(f"📋 분석종목 {len(rows)}개 대상")
|
||||
logger.info("📋 분석종목 %d개 대상", len(rows))
|
||||
return [(row.stock_code, row.stock_name) for row in rows]
|
||||
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# analyze_update_and_manage 분리: 분석 / 저장 / 관리
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
async def _save_analysis_and_history(
|
||||
session,
|
||||
stock_code: str,
|
||||
stock_name: str,
|
||||
result,
|
||||
analysis_date: date,
|
||||
now: datetime,
|
||||
) -> None:
|
||||
"""stock_analysis UPSERT + watchlist_history 시계열 기록.
|
||||
|
||||
DELETE+INSERT race condition 없이 원자적으로 갱신.
|
||||
"""
|
||||
# 1. stock_analysis UPSERT (race-condition-free)
|
||||
await upsert_analysis(session, stock_code, stock_name, result, analysis_date)
|
||||
|
||||
# 2. watchlist_history 시계열 기록
|
||||
session.add(WatchlistHistory(
|
||||
stock_code=stock_code,
|
||||
stock_name=stock_name,
|
||||
recorded_at=now,
|
||||
score=result.score,
|
||||
current_price=result.current_price,
|
||||
change_rate=result.change_rate,
|
||||
rsi=result.rsi,
|
||||
signals=result.signals,
|
||||
))
|
||||
|
||||
|
||||
async def _manage_watchlist_membership(
|
||||
session,
|
||||
stock_code: str,
|
||||
stock_name: str,
|
||||
score: int | None,
|
||||
now: datetime,
|
||||
) -> bool:
|
||||
"""score 기준 미달 시 watchlist에서 제거.
|
||||
|
||||
Returns: True if removed, False if retained.
|
||||
"""
|
||||
if score is not None and score < WATCHLIST_EXIT_SCORE:
|
||||
await session.execute(
|
||||
update(WatchlistStock)
|
||||
.where(
|
||||
WatchlistStock.stock_code == stock_code,
|
||||
WatchlistStock.is_active == True,
|
||||
)
|
||||
.values(
|
||||
is_active=False,
|
||||
removed_at=now,
|
||||
removed_reason="score_drop",
|
||||
)
|
||||
)
|
||||
logger.info(
|
||||
"🚫 %s (%s) 분석종목 제거: score=%s < %s",
|
||||
stock_code, stock_name, score, WATCHLIST_EXIT_SCORE,
|
||||
)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
async def analyze_update_and_manage(
|
||||
stock_code: str,
|
||||
stock_name: str,
|
||||
analysis_date: date,
|
||||
) -> tuple[bool, int | None]:
|
||||
) -> tuple[bool, int | None, bool]:
|
||||
"""종목 재분석 → stock_analysis 갱신 → watchlist_history 저장 → watchlist 관리.
|
||||
|
||||
Returns: (success, score)
|
||||
Returns: (success, score, was_removed)
|
||||
was_removed: _manage_watchlist_membership이 실제로 비활성화 처리했으면 True
|
||||
"""
|
||||
try:
|
||||
result = await analyze_stock_for_batch(stock_code, stock_name)
|
||||
if not result:
|
||||
logger.info(f"No result for {stock_code} (chart/price issue)")
|
||||
return False, None
|
||||
logger.info("No result for %s (chart/price issue)", stock_code)
|
||||
return False, None, False
|
||||
|
||||
# 5,000원 이하 저가주 → watchlist에서도 제거
|
||||
if result.current_price and result.current_price < 5000:
|
||||
logger.debug(f"Skip {stock_code}: price={result.current_price:.0f}원 < 5,000원")
|
||||
if not passes_price_filter(result):
|
||||
logger.debug("Skip %s: price=%.0f원 < 5,000원", stock_code, result.current_price)
|
||||
async with AsyncSessionLocal() as session:
|
||||
await session.execute(
|
||||
update(WatchlistStock)
|
||||
@@ -120,83 +165,26 @@ async def analyze_update_and_manage(
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
return False, None
|
||||
return False, None, True # 가격필터 제거도 was_removed=True
|
||||
|
||||
now = datetime.now(KST)
|
||||
score = result.score
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
# 1. stock_analysis 갱신 (DELETE + INSERT)
|
||||
await session.execute(
|
||||
delete(StockAnalysis).where(
|
||||
StockAnalysis.stock_code == stock_code,
|
||||
StockAnalysis.analysis_date == analysis_date,
|
||||
)
|
||||
await _save_analysis_and_history(
|
||||
session, stock_code, stock_name, result, analysis_date, now
|
||||
)
|
||||
was_removed = await _manage_watchlist_membership(
|
||||
session, stock_code, stock_name, score, now
|
||||
)
|
||||
session.add(StockAnalysis(
|
||||
stock_code=stock_code,
|
||||
stock_name=stock_name,
|
||||
analysis_date=analysis_date,
|
||||
current_price=result.current_price,
|
||||
change_rate=result.change_rate,
|
||||
volume=result.volume,
|
||||
rsi=result.rsi,
|
||||
ma5=result.ma5,
|
||||
ma20=result.ma20,
|
||||
ma60=result.ma60,
|
||||
stochastic_k=result.stochastic_k,
|
||||
stochastic_d=result.stochastic_d,
|
||||
bollinger_upper=result.bollinger_upper,
|
||||
bollinger_lower=result.bollinger_lower,
|
||||
bollinger_middle=result.bollinger_middle,
|
||||
signals=result.signals,
|
||||
score=score,
|
||||
ml_score=result.ml_score,
|
||||
ml_confidence=result.ml_confidence,
|
||||
ml_signal=result.ml_signal,
|
||||
atr_14=result.atr_14,
|
||||
adx_14=result.adx_14,
|
||||
))
|
||||
|
||||
# 2. watchlist_history 시계열 기록
|
||||
session.add(WatchlistHistory(
|
||||
stock_code=stock_code,
|
||||
stock_name=stock_name,
|
||||
recorded_at=now,
|
||||
score=score,
|
||||
current_price=result.current_price,
|
||||
change_rate=result.change_rate,
|
||||
rsi=result.rsi,
|
||||
signals=result.signals,
|
||||
))
|
||||
|
||||
# 3. score가 기준 이하면 분석종목에서 제거
|
||||
if score is not None and score < WATCHLIST_EXIT_SCORE:
|
||||
await session.execute(
|
||||
update(WatchlistStock)
|
||||
.where(
|
||||
WatchlistStock.stock_code == stock_code,
|
||||
WatchlistStock.is_active == True,
|
||||
)
|
||||
.values(
|
||||
is_active=False,
|
||||
removed_at=now,
|
||||
removed_reason="score_drop",
|
||||
)
|
||||
)
|
||||
logger.info(
|
||||
f"🚫 {stock_code} ({stock_name}) 분석종목 제거: "
|
||||
f"score={score} < {WATCHLIST_EXIT_SCORE}"
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
|
||||
logger.info(f"✅ {stock_code} ({stock_name}): score={score}")
|
||||
return True, score
|
||||
logger.info("✅ %s (%s): score=%s", stock_code, stock_name, score)
|
||||
return True, score, was_removed
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Failed {stock_code}: {e}")
|
||||
return False, None
|
||||
logger.error("❌ Failed %s: %s", stock_code, e)
|
||||
return False, None, False
|
||||
|
||||
|
||||
async def rescore_outside_candidates(
|
||||
@@ -232,7 +220,7 @@ async def rescore_outside_candidates(
|
||||
return 0
|
||||
|
||||
total = len(candidates)
|
||||
logger.info(f"🔍 워치리스트 외부 후보 {total}개 재분석 시작")
|
||||
logger.info("🔍 워치리스트 외부 후보 %d개 재분석 시작", total)
|
||||
|
||||
semaphore = asyncio.Semaphore(BATCH_CONCURRENCY)
|
||||
|
||||
@@ -242,48 +230,19 @@ async def rescore_outside_candidates(
|
||||
res = await analyze_stock_for_batch(code, name)
|
||||
if not res:
|
||||
return False
|
||||
# 저가주 필터 (CLAUDE.md 섹션 6.4)
|
||||
if res.current_price and res.current_price < 5000:
|
||||
logger.debug(f"Skip {code}: price={res.current_price:.0f}원 < 5,000원")
|
||||
# 저가주 필터 (CLAUDE.md 5절 가격 필터)
|
||||
if not passes_price_filter(res):
|
||||
logger.debug("Skip %s: price=%.0f원 < 5,000원", code, res.current_price)
|
||||
return False
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
await session.execute(
|
||||
delete(StockAnalysis).where(
|
||||
StockAnalysis.stock_code == code,
|
||||
StockAnalysis.analysis_date == analysis_date,
|
||||
)
|
||||
)
|
||||
session.add(StockAnalysis(
|
||||
stock_code=code,
|
||||
stock_name=name,
|
||||
analysis_date=analysis_date,
|
||||
current_price=res.current_price,
|
||||
change_rate=res.change_rate,
|
||||
volume=res.volume,
|
||||
rsi=res.rsi,
|
||||
ma5=res.ma5,
|
||||
ma20=res.ma20,
|
||||
ma60=res.ma60,
|
||||
stochastic_k=res.stochastic_k,
|
||||
stochastic_d=res.stochastic_d,
|
||||
bollinger_upper=res.bollinger_upper,
|
||||
bollinger_lower=res.bollinger_lower,
|
||||
bollinger_middle=res.bollinger_middle,
|
||||
signals=res.signals,
|
||||
score=res.score,
|
||||
ml_score=res.ml_score,
|
||||
ml_confidence=res.ml_confidence,
|
||||
ml_signal=res.ml_signal,
|
||||
atr_14=res.atr_14,
|
||||
adx_14=res.adx_14,
|
||||
))
|
||||
await upsert_analysis(session, code, name, res, analysis_date)
|
||||
await session.commit()
|
||||
|
||||
logger.debug(f" [{i}/{total}] {code} ({name}): score={res.score}")
|
||||
logger.debug(" [%d/%d] %s (%s): score=%s", i, total, code, name, res.score)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"❌ 외부 후보 재분석 실패 {code}: {e}")
|
||||
logger.error("❌ 외부 후보 재분석 실패 %s: %s", code, e)
|
||||
return False
|
||||
|
||||
tasks = [
|
||||
@@ -292,7 +251,7 @@ async def rescore_outside_candidates(
|
||||
]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
success = sum(1 for r in results if r is True)
|
||||
logger.info(f"✅ 외부 후보 재분석 완료: {success}/{total}개 갱신")
|
||||
logger.info("✅ 외부 후보 재분석 완료: %d/%d개 갱신", success, total)
|
||||
return success
|
||||
|
||||
|
||||
@@ -316,7 +275,7 @@ async def refresh_watchlist(analysis_date: date, current_codes: set[str]) -> int
|
||||
|
||||
slots = WATCHLIST_MAX_SIZE - active_count
|
||||
if slots <= 0:
|
||||
logger.debug(f"Watchlist 최대치 도달 ({active_count}/{WATCHLIST_MAX_SIZE})")
|
||||
logger.debug("Watchlist 최대치 도달 (%d/%d)", active_count, WATCHLIST_MAX_SIZE)
|
||||
return 0
|
||||
|
||||
# stock_analysis에서 고점수 종목 중 현재 watchlist에 없는 것 조회
|
||||
@@ -353,8 +312,8 @@ async def refresh_watchlist(analysis_date: date, current_codes: set[str]) -> int
|
||||
|
||||
await session.commit()
|
||||
logger.info(
|
||||
f"➕ 분석종목 {len(candidates)}개 신규 추가 "
|
||||
f"(score ≥ {WATCHLIST_ENTRY_SCORE}, 현재 {active_count + len(candidates)}개)"
|
||||
"➕ 분석종목 %d개 신규 추가 (score ≥ %d, 현재 %d개)",
|
||||
len(candidates), WATCHLIST_ENTRY_SCORE, active_count + len(candidates),
|
||||
)
|
||||
return len(candidates)
|
||||
|
||||
@@ -369,8 +328,8 @@ async def main():
|
||||
start_time = datetime.now(KST)
|
||||
analysis_date = datetime.now(KST).date()
|
||||
logger.info(
|
||||
f"⚡ [Intraday] {start_time.strftime('%H:%M:%S')} 시작 — "
|
||||
f"분석종목 재분석 (진입≥{WATCHLIST_ENTRY_SCORE}, 제거<{WATCHLIST_EXIT_SCORE})"
|
||||
"⚡ [Intraday] %s 시작 — 분석종목 재분석 (진입≥%d, 제거<%d)",
|
||||
start_time.strftime("%H:%M:%S"), WATCHLIST_ENTRY_SCORE, WATCHLIST_EXIT_SCORE,
|
||||
)
|
||||
|
||||
# 활성 분석종목 조회
|
||||
@@ -385,9 +344,9 @@ async def main():
|
||||
# 병렬 재분석
|
||||
semaphore = asyncio.Semaphore(BATCH_CONCURRENCY)
|
||||
|
||||
async def analyze_one(i: int, code: str, name: str) -> tuple[bool, int | None]:
|
||||
async def analyze_one(i: int, code: str, name: str) -> tuple[bool, int | None, bool]:
|
||||
async with semaphore:
|
||||
logger.info(f"[{i}/{total}] {code} ({name})")
|
||||
logger.info("[%d/%d] %s (%s)", i, total, code, name)
|
||||
return await analyze_update_and_manage(code, name, analysis_date)
|
||||
|
||||
tasks = [
|
||||
@@ -396,15 +355,11 @@ async def main():
|
||||
]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
# removed 카운터 수정: analyze_update_and_manage는 제거 시에도 True 반환하므로
|
||||
# success(=분석 성공)에 removed가 포함됨 → retained = success - removed 로 표시
|
||||
analyzed_ok = sum(1 for r in results if isinstance(r, tuple) and r[0] is True)
|
||||
removed = sum(
|
||||
1 for r in results
|
||||
if isinstance(r, tuple) and r[0] is True
|
||||
and r[1] is not None and r[1] < WATCHLIST_EXIT_SCORE
|
||||
removed = sum(1 for r in results if isinstance(r, tuple) and len(r) == 3 and r[2] is True)
|
||||
retained = analyzed_ok - sum(
|
||||
1 for r in results if isinstance(r, tuple) and len(r) == 3 and r[0] is True and r[2] is True
|
||||
)
|
||||
retained = analyzed_ok - removed # 워치리스트에 실제 유지된 종목 수
|
||||
fail = sum(1 for r in results if isinstance(r, tuple) and r[0] is False)
|
||||
error = sum(1 for r in results if isinstance(r, Exception))
|
||||
|
||||
@@ -419,9 +374,8 @@ async def main():
|
||||
now_kst = datetime.now(KST)
|
||||
async with AsyncSessionLocal() as session:
|
||||
# 현재 활성 종목 수 확인
|
||||
from sqlalchemy import func as sa_func
|
||||
count_result = await session.execute(
|
||||
select(sa_func.count())
|
||||
select(func.count())
|
||||
.select_from(WatchlistStock)
|
||||
.where(WatchlistStock.is_active == True)
|
||||
)
|
||||
@@ -453,7 +407,7 @@ async def main():
|
||||
await session.commit()
|
||||
|
||||
if added_scanner:
|
||||
logger.info(f"📡 장중 스캐너 신규 추가: {added_scanner}개 (RSI≤30+BB하단)")
|
||||
logger.info("📡 장중 스캐너 신규 추가: %d개 (RSI≤30+BB하단)", added_scanner)
|
||||
current_codes |= scanner_new # refresh_watchlist 중복 방지용 갱신
|
||||
|
||||
# 고점수 신규 종목 추가 (스캐너 추가분 제외)
|
||||
@@ -461,12 +415,11 @@ async def main():
|
||||
|
||||
duration = (datetime.now(KST) - start_time).total_seconds()
|
||||
logger.info(
|
||||
f"✅ [Intraday] 완료: {retained}/{total} 유지, "
|
||||
f"{removed} 제거, +{added} 신규(score), +{added_scanner} 신규(스캐너), "
|
||||
f"{fail} 실패, {error} 오류 — {duration:.1f}초"
|
||||
"✅ [Intraday] 완료: %d/%d 유지, %d 제거, +%d 신규(score), +%d 신규(스캐너), "
|
||||
"%d 실패, %d 오류 — %.1f초",
|
||||
retained, total, removed, added, added_scanner, fail, error, duration,
|
||||
)
|
||||
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
|
||||
Reference in New Issue
Block a user