stock-lab 오류 수정, lotto-lab 히트맵 기반 추천 기능 추가
This commit is contained in:
@@ -9,7 +9,7 @@ from .db import (
|
|||||||
save_recommendation_dedup, list_recommendations_ex, delete_recommendation,
|
save_recommendation_dedup, list_recommendations_ex, delete_recommendation,
|
||||||
update_recommendation,
|
update_recommendation,
|
||||||
)
|
)
|
||||||
from .recommender import recommend_numbers
|
from .recommender import recommend_numbers, recommend_with_heatmap
|
||||||
from .collector import sync_latest, sync_ensure_all
|
from .collector import sync_latest, sync_ensure_all
|
||||||
from .generator import generate_smart_recommendations
|
from .generator import generate_smart_recommendations
|
||||||
from .generator import generate_smart_recommendations
|
from .generator import generate_smart_recommendations
|
||||||
@@ -223,6 +223,124 @@ def api_recommend(
|
|||||||
"tries": tries,
|
"tries": tries,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ---------- ✅ heatmap-based recommend ----------
|
||||||
|
@app.get("/api/lotto/recommend/heatmap")
|
||||||
|
def api_recommend_heatmap(
|
||||||
|
heatmap_window: int = 20,
|
||||||
|
heatmap_weight: float = 1.5,
|
||||||
|
recent_window: int = 200,
|
||||||
|
recent_weight: float = 2.0,
|
||||||
|
avoid_recent_k: int = 5,
|
||||||
|
|
||||||
|
# ---- optional constraints ----
|
||||||
|
sum_min: Optional[int] = None,
|
||||||
|
sum_max: Optional[int] = None,
|
||||||
|
odd_min: Optional[int] = None,
|
||||||
|
odd_max: Optional[int] = None,
|
||||||
|
range_min: Optional[int] = None,
|
||||||
|
range_max: Optional[int] = None,
|
||||||
|
max_overlap_latest: Optional[int] = None,
|
||||||
|
max_try: int = 200,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
히트맵 기반 추천: 과거 추천 번호들의 적중률을 분석하여 가중치 부여
|
||||||
|
"""
|
||||||
|
draws = get_all_draw_numbers()
|
||||||
|
if not draws:
|
||||||
|
raise HTTPException(status_code=404, detail="No data yet")
|
||||||
|
|
||||||
|
# 과거 추천 데이터 가져오기 (적중 결과가 있는 것만)
|
||||||
|
past_recs = list_recommendations_ex(limit=100, sort="id_desc")
|
||||||
|
|
||||||
|
latest = get_latest_draw()
|
||||||
|
|
||||||
|
params = {
|
||||||
|
"heatmap_window": heatmap_window,
|
||||||
|
"heatmap_weight": float(heatmap_weight),
|
||||||
|
"recent_window": recent_window,
|
||||||
|
"recent_weight": float(recent_weight),
|
||||||
|
"avoid_recent_k": avoid_recent_k,
|
||||||
|
"sum_min": sum_min,
|
||||||
|
"sum_max": sum_max,
|
||||||
|
"odd_min": odd_min,
|
||||||
|
"odd_max": odd_max,
|
||||||
|
"range_min": range_min,
|
||||||
|
"range_max": range_max,
|
||||||
|
"max_overlap_latest": max_overlap_latest,
|
||||||
|
"max_try": int(max_try),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _accept(nums: List[int]) -> bool:
|
||||||
|
m = calc_metrics(nums)
|
||||||
|
if sum_min is not None and m["sum"] < sum_min:
|
||||||
|
return False
|
||||||
|
if sum_max is not None and m["sum"] > sum_max:
|
||||||
|
return False
|
||||||
|
if odd_min is not None and m["odd"] < odd_min:
|
||||||
|
return False
|
||||||
|
if odd_max is not None and m["odd"] > odd_max:
|
||||||
|
return False
|
||||||
|
if range_min is not None and m["range"] < range_min:
|
||||||
|
return False
|
||||||
|
if range_max is not None and m["range"] > range_max:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if max_overlap_latest is not None:
|
||||||
|
ov = calc_recent_overlap(nums, draws, last_k=avoid_recent_k)
|
||||||
|
if ov["repeats"] > max_overlap_latest:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
chosen = None
|
||||||
|
explain = None
|
||||||
|
|
||||||
|
tries = 0
|
||||||
|
while tries < max_try:
|
||||||
|
tries += 1
|
||||||
|
result = recommend_with_heatmap(
|
||||||
|
draws,
|
||||||
|
past_recs,
|
||||||
|
heatmap_window=heatmap_window,
|
||||||
|
heatmap_weight=heatmap_weight,
|
||||||
|
recent_window=recent_window,
|
||||||
|
recent_weight=recent_weight,
|
||||||
|
avoid_recent_k=avoid_recent_k,
|
||||||
|
)
|
||||||
|
nums = result["numbers"]
|
||||||
|
if _accept(nums):
|
||||||
|
chosen = nums
|
||||||
|
explain = result["explain"]
|
||||||
|
break
|
||||||
|
|
||||||
|
if chosen is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Constraints too strict. No valid set found in max_try={max_try}.",
|
||||||
|
)
|
||||||
|
|
||||||
|
# ✅ dedup save
|
||||||
|
saved = save_recommendation_dedup(
|
||||||
|
latest["drw_no"] if latest else None,
|
||||||
|
chosen,
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
|
||||||
|
metrics = calc_metrics(chosen)
|
||||||
|
overlap = calc_recent_overlap(chosen, draws, last_k=avoid_recent_k)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": saved["id"],
|
||||||
|
"saved": saved["saved"],
|
||||||
|
"deduped": saved["deduped"],
|
||||||
|
"based_on_latest_draw": latest["drw_no"] if latest else None,
|
||||||
|
"numbers": chosen,
|
||||||
|
"explain": explain,
|
||||||
|
"params": params,
|
||||||
|
"metrics": metrics,
|
||||||
|
"recent_overlap": overlap,
|
||||||
|
"tries": tries,
|
||||||
|
}
|
||||||
|
|
||||||
# ---------- ✅ history list (filter/paging) ----------
|
# ---------- ✅ history list (filter/paging) ----------
|
||||||
@app.get("/api/history")
|
@app.get("/api/history")
|
||||||
def api_history(
|
def api_history(
|
||||||
|
|||||||
@@ -66,3 +66,98 @@ def recommend_numbers(
|
|||||||
|
|
||||||
return {"numbers": chosen_sorted, "explain": explain}
|
return {"numbers": chosen_sorted, "explain": explain}
|
||||||
|
|
||||||
|
|
||||||
|
def recommend_with_heatmap(
|
||||||
|
draws: List[Tuple[int, List[int]]],
|
||||||
|
past_recommendations: List[Dict[str, Any]],
|
||||||
|
*,
|
||||||
|
heatmap_window: int = 10,
|
||||||
|
heatmap_weight: float = 1.5,
|
||||||
|
recent_window: int = 200,
|
||||||
|
recent_weight: float = 2.0,
|
||||||
|
avoid_recent_k: int = 5,
|
||||||
|
seed: int | None = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
히트맵 기반 가중치 추천:
|
||||||
|
- 과거 추천 번호들의 적중률을 분석하여 잘 맞춘 번호에 가중치 부여
|
||||||
|
- 기존 통계 기반 추천과 결합
|
||||||
|
|
||||||
|
Args:
|
||||||
|
draws: 실제 당첨 번호 리스트 [(회차, [번호들]), ...]
|
||||||
|
past_recommendations: 과거 추천 데이터 [{"numbers": [...], "correct_count": N, "based_on_draw": M}, ...]
|
||||||
|
heatmap_window: 히트맵 분석할 최근 추천 개수
|
||||||
|
heatmap_weight: 히트맵 가중치 (높을수록 과거 적중 번호 선호)
|
||||||
|
"""
|
||||||
|
if seed is not None:
|
||||||
|
random.seed(seed)
|
||||||
|
|
||||||
|
# 1. 기존 통계 기반 가중치 계산
|
||||||
|
all_nums = [n for _, nums in draws for n in nums]
|
||||||
|
freq_all = Counter(all_nums)
|
||||||
|
|
||||||
|
recent = draws[-recent_window:] if len(draws) >= recent_window else draws
|
||||||
|
recent_nums = [n for _, nums in recent for n in nums]
|
||||||
|
freq_recent = Counter(recent_nums)
|
||||||
|
|
||||||
|
last_k = draws[-avoid_recent_k:] if len(draws) >= avoid_recent_k else draws
|
||||||
|
last_k_nums = set(n for _, nums in last_k for n in nums)
|
||||||
|
|
||||||
|
# 2. 히트맵 생성: 과거 추천에서 적중한 번호들의 빈도
|
||||||
|
heatmap = Counter()
|
||||||
|
recent_recs = past_recommendations[-heatmap_window:] if len(past_recommendations) >= heatmap_window else past_recommendations
|
||||||
|
|
||||||
|
for rec in recent_recs:
|
||||||
|
if rec.get("correct_count", 0) > 0: # 적중한 추천만
|
||||||
|
# 적중 개수에 비례해서 가중치 부여 (많이 맞춘 추천일수록 높은 가중)
|
||||||
|
weight = rec["correct_count"] ** 1.5 # 제곱으로 강조
|
||||||
|
for num in rec["numbers"]:
|
||||||
|
heatmap[num] += weight
|
||||||
|
|
||||||
|
# 3. 최종 가중치 = 기존 통계 + 히트맵
|
||||||
|
weights = {}
|
||||||
|
for n in range(1, 46):
|
||||||
|
w = freq_all[n] + recent_weight * freq_recent[n]
|
||||||
|
|
||||||
|
# 히트맵 가중치 추가
|
||||||
|
if n in heatmap:
|
||||||
|
w += heatmap_weight * heatmap[n]
|
||||||
|
|
||||||
|
# 최근 출현 번호 패널티
|
||||||
|
if n in last_k_nums:
|
||||||
|
w *= 0.6
|
||||||
|
|
||||||
|
weights[n] = max(w, 0.1)
|
||||||
|
|
||||||
|
# 4. 가중 샘플링으로 6개 선택
|
||||||
|
chosen = []
|
||||||
|
pool = list(range(1, 46))
|
||||||
|
for _ in range(6):
|
||||||
|
total = sum(weights[n] for n in pool)
|
||||||
|
r = random.random() * total
|
||||||
|
acc = 0.0
|
||||||
|
for n in pool:
|
||||||
|
acc += weights[n]
|
||||||
|
if acc >= r:
|
||||||
|
chosen.append(n)
|
||||||
|
pool.remove(n)
|
||||||
|
break
|
||||||
|
|
||||||
|
chosen_sorted = sorted(chosen)
|
||||||
|
|
||||||
|
# 5. 설명 데이터
|
||||||
|
explain = {
|
||||||
|
"recent_window": recent_window,
|
||||||
|
"recent_weight": recent_weight,
|
||||||
|
"avoid_recent_k": avoid_recent_k,
|
||||||
|
"heatmap_window": heatmap_window,
|
||||||
|
"heatmap_weight": heatmap_weight,
|
||||||
|
"top_all": [n for n, _ in freq_all.most_common(10)],
|
||||||
|
"top_recent": [n for n, _ in freq_recent.most_common(10)],
|
||||||
|
"top_heatmap": [n for n, _ in heatmap.most_common(10)],
|
||||||
|
"last_k_draws": [d for d, _ in last_k],
|
||||||
|
"analyzed_recommendations": len(recent_recs),
|
||||||
|
}
|
||||||
|
|
||||||
|
return {"numbers": chosen_sorted, "explain": explain}
|
||||||
|
|
||||||
|
|||||||
@@ -1,100 +0,0 @@
|
|||||||
# 🦙 Windows PC Ollama 연동 가이드
|
|
||||||
|
|
||||||
NAS(Docker)에 있는 `stock-lab` 서비스가 고성능 Windows PC의 Ollama를 사용하여 AI 분석을 수행하도록 설정하는 방법입니다.
|
|
||||||
|
|
||||||
## 1. Windows PC 설정 (AI 서버)
|
|
||||||
|
|
||||||
고성능 PC(9800X3D + 3070 Ti)에서 수행합니다.
|
|
||||||
|
|
||||||
### 1-1. Ollama 설치 및 준비
|
|
||||||
1. [Ollama 공식 홈페이지](https://ollama.com/)에서 Windows용 Ollama를 다운로드하여 설치합니다.
|
|
||||||
2. 명령 프롬프트(CMD)나 PowerShell을 열고 모델을 다운로드합니다.
|
|
||||||
```powershell
|
|
||||||
ollama pull llama3.1
|
|
||||||
# 또는 gemma2 (9B)
|
|
||||||
ollama pull gemma2
|
|
||||||
```
|
|
||||||
|
|
||||||
### 1-2. 외부 접속 허용 설정 (중요 ⭐️)
|
|
||||||
기본적으로 Ollama는 로컬(localhost)에서만 접속 가능합니다. NAS에서 접속하려면 이를 모든 IP(`0.0.0.0`)에서 접속 가능하게 변경해야 합니다.
|
|
||||||
|
|
||||||
1. **작업 관리자**를 열고 'Ollama' 프로세스가 있다면 **작업 끝내기**로 종료합니다. (트레이 아이콘 우클릭 -> Quit)
|
|
||||||
2. **시스템 환경 변수 편집**을 엽니다. (윈도우 키 누르고 "환경 변수" 검색)
|
|
||||||
3. **시스템 변수(S)** 섹션에서 `새로 만들기(W)...`를 클릭합니다.
|
|
||||||
* 변수 이름: `OLLAMA_HOST`
|
|
||||||
* 변수 값: `0.0.0.0`
|
|
||||||
4. 확인을 눌러 저장하고, Ollama를 다시 실행합니다.
|
|
||||||
|
|
||||||
### 1-3. 방화벽 포트 개방
|
|
||||||
Windows Defender 방화벽이 외부 접속을 막을 수 있습니다.
|
|
||||||
|
|
||||||
1. Powershell을 **관리자 권한**으로 실행합니다.
|
|
||||||
2. 아래 명령어를 입력하여 11434 포트를 엽니다.
|
|
||||||
```powershell
|
|
||||||
New-NetFirewallRule -DisplayName "Ollama API" -Direction Inbound -LocalPort 11434 -Protocol TCP -Action Allow
|
|
||||||
```
|
|
||||||
(또는 `제어판 > Windows Defender 방화벽 > 고급 설정`에서 인바운드 규칙으로 TCP 11434 포트 허용을 수동으로 추가해도 됩니다.)
|
|
||||||
|
|
||||||
### 1-4. IP 주소 확인
|
|
||||||
CMD에서 `ipconfig`를 입력하여 Windows PC의 IP 주소를 확인합니다.
|
|
||||||
(예: `192.168.0.5`)
|
|
||||||
|
|
||||||
### 1-5. Windows AI 서버 실행 (파이썬 중계 서버) ⭐️
|
|
||||||
NAS가 Windows의 Ollama를 직접 호출할 수도 있지만, 더 복잡한 로직(뉴스+잔고 결합 분석 등)을 수행하기 위해 작성한 `windows_ai_server.py`를 실행해야 합니다.
|
|
||||||
|
|
||||||
1. **Python 설치**: Windows에 Python이 설치되어 있어야 합니다. (없다면 [python.org](https://www.python.org/)에서 설치)
|
|
||||||
2. **프로젝트 폴더 준비**:
|
|
||||||
* 바탕화면 등에 적당한 폴더(예: `stock-ai`)를 만들고, 앞서 작성한 `windows_ai_server.py` 파일을 넣습니다.
|
|
||||||
3. **라이브러리 설치**:
|
|
||||||
* 해당 폴더에서 Shift+우클릭 -> "PowerShell 창 열기"
|
|
||||||
* 다음 명령어로 필요한 패키지를 설치합니다.
|
|
||||||
```powershell
|
|
||||||
pip install fastapi uvicorn requests pydantic
|
|
||||||
```
|
|
||||||
4. **서버 실행**:
|
|
||||||
* 같은 PowerShell 창에서 서버를 실행합니다.
|
|
||||||
```powershell
|
|
||||||
# NAS의 주소를 환경 변수로 설정 (본인의 NAS IP로 수정하세요)
|
|
||||||
$env:NAS_API_URL="http://192.168.0.2:18500"
|
|
||||||
|
|
||||||
# 서버 실행 (0.0.0.0은 모든 IP 접속 허용을 의미)
|
|
||||||
python -m uvicorn windows_ai_server:app --host 0.0.0.0 --port 8000 --reload
|
|
||||||
```
|
|
||||||
* **성공 시**: `Application startup complete.` 메시지가 뜨며 대기 상태가 됩니다. 창을 닫지 마세요.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. NAS 설정 (Client)
|
|
||||||
|
|
||||||
Synology NAS의 `web-page-backend` 프로젝트에서 설정합니다.
|
|
||||||
|
|
||||||
### 2-1. .env 파일 수정
|
|
||||||
`.env` 파일에 Windows PC의 주소를 입력합니다.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# .env 파일
|
|
||||||
# ... 기존 설정들 ...
|
|
||||||
|
|
||||||
# 윈도우 PC의 IP로 변경하세요 (http:// 포함, 포트 8000 포함)
|
|
||||||
# 주의: Ollama(11434)가 아니라, 방금 띄운 Python 서버(8000)를 바라보게 해도 되고,
|
|
||||||
# 단순 Ollama 호출만 필요하다면 11434로 해도 됩니다.
|
|
||||||
# 여기서는 AI 서버(8000)를 통해 분석한다고 가정합니다.
|
|
||||||
WINDOWS_AI_SERVER_URL=http://192.168.0.5:8000
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2-2. 컨테이너 재배포
|
|
||||||
변경된 설정을 적용하기 위해 `stock-lab` 컨테이너를 다시 시작합니다.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# NAS 터미널 (프로젝트 루트 경로)
|
|
||||||
docker-compose up -d --build stock-lab
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 테스트
|
|
||||||
|
|
||||||
브라우저나 API 도구를 사용하여 NAS의 주소로 분석 요청을 보냅니다.
|
|
||||||
|
|
||||||
* **요청**: `POST http://192.168.0.5:8000/analyze/portfolio` (Windows PC에서 직접 테스트)
|
|
||||||
* **결과**: Windows PC의 터미널에 로그가 찍히며 GPU가 작동하고, JSON 응답이 옵니다.
|
|
||||||
@@ -8,10 +8,6 @@ import requests
|
|||||||
from apscheduler.schedulers.background import BackgroundScheduler
|
from apscheduler.schedulers.background import BackgroundScheduler
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
from .db import init_db, save_articles, get_latest_articles
|
from .db import init_db, save_articles, get_latest_articles
|
||||||
from .scraper import fetch_market_news, fetch_major_indices, fetch_overseas_news
|
from .scraper import fetch_market_news, fetch_major_indices, fetch_overseas_news
|
||||||
|
|
||||||
@@ -33,7 +29,6 @@ WINDOWS_AI_SERVER_URL = os.getenv("WINDOWS_AI_SERVER_URL", "http://192.168.0.5:8
|
|||||||
|
|
||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
def on_startup():
|
def on_startup():
|
||||||
print(f"[StockLab] Startup. Windows AI Server URL: {WINDOWS_AI_SERVER_URL}")
|
|
||||||
init_db()
|
init_db()
|
||||||
|
|
||||||
# 매일 아침 8시 뉴스 스크랩 (NAS 자체 수행)
|
# 매일 아침 8시 뉴스 스크랩 (NAS 자체 수행)
|
||||||
@@ -52,9 +47,10 @@ def run_scraping_job():
|
|||||||
articles_kr = fetch_market_news()
|
articles_kr = fetch_market_news()
|
||||||
count_kr = save_articles(articles_kr)
|
count_kr = save_articles(articles_kr)
|
||||||
|
|
||||||
# 2. 해외
|
# 2. 해외 (임시 차단)
|
||||||
articles_world = fetch_overseas_news()
|
# articles_world = fetch_overseas_news()
|
||||||
count_world = save_articles(articles_world)
|
# count_world = save_articles(articles_world)
|
||||||
|
count_world = 0
|
||||||
|
|
||||||
print(f"[StockLab] Saved {count_kr} domestic, {count_world} overseas articles.")
|
print(f"[StockLab] Saved {count_kr} domestic, {count_world} overseas articles.")
|
||||||
|
|
||||||
@@ -127,25 +123,7 @@ def order_stock(req: OrderRequest):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/stock/analyze")
|
|
||||||
def analyze_market():
|
|
||||||
"""Windows PC를 통한 AI 시장 분석"""
|
|
||||||
print(f"[Proxy] Analyzing Market at {WINDOWS_AI_SERVER_URL}...")
|
|
||||||
try:
|
|
||||||
# 빈 JSON Body 전송
|
|
||||||
resp = requests.post(f"{WINDOWS_AI_SERVER_URL}/analyze/portfolio", json={}, timeout=120)
|
|
||||||
|
|
||||||
if resp.status_code != 200:
|
|
||||||
print(f"[ProxyError] Analyze Error: {resp.status_code} {resp.text}")
|
|
||||||
return JSONResponse(status_code=resp.status_code, content=resp.json())
|
|
||||||
|
|
||||||
return resp.json()
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[ProxyError] Analyze Connection Failed: {e}")
|
|
||||||
return JSONResponse(
|
|
||||||
status_code=500,
|
|
||||||
content={"error": "Connection Failed", "detail": str(e), "target": WINDOWS_AI_SERVER_URL}
|
|
||||||
)
|
|
||||||
|
|
||||||
@app.get("/api/version")
|
@app.get("/api/version")
|
||||||
def version():
|
def version():
|
||||||
|
|||||||
Reference in New Issue
Block a user