Compare commits
5 Commits
feature/st
...
d9b612253a
| Author | SHA1 | Date | |
|---|---|---|---|
| d9b612253a | |||
| db4322006d | |||
| a05e6ba8ca | |||
| 4a333434ac | |||
| 204cee67d6 |
@@ -1,5 +1,6 @@
|
|||||||
fastapi==0.115.6
|
fastapi==0.115.6
|
||||||
uvicorn[standard]==0.30.6
|
uvicorn[standard]==0.30.6
|
||||||
requests==2.32.3
|
requests==2.32.3
|
||||||
|
httpx==0.27.2
|
||||||
beautifulsoup4==4.12.3
|
beautifulsoup4==4.12.3
|
||||||
APScheduler==3.10.4
|
APScheduler==3.10.4
|
||||||
|
|||||||
@@ -45,7 +45,13 @@ def _db_path() -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _conn() -> sqlite3.Connection:
|
def _conn() -> sqlite3.Connection:
|
||||||
return sqlite3.connect(_db_path())
|
# WAL 모드 + busy_timeout으로 동시 read/write lock 회피
|
||||||
|
# WAL은 reader vs writer 동시성만 해결 — writer 두 명은 직렬이므로 busy_timeout이
|
||||||
|
# snapshot/refresh의 write 시간보다 길어야 함 (네이버 스크래핑 ~20초 + DB upsert).
|
||||||
|
conn = sqlite3.connect(_db_path(), timeout=120.0)
|
||||||
|
conn.execute("PRAGMA journal_mode=WAL")
|
||||||
|
conn.execute("PRAGMA busy_timeout=120000")
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
# ---------- /nodes ----------
|
# ---------- /nodes ----------
|
||||||
|
|||||||
@@ -22,8 +22,11 @@ NAVER_HEADERS = {
|
|||||||
"Referer": "https://finance.naver.com/",
|
"Referer": "https://finance.naver.com/",
|
||||||
}
|
}
|
||||||
|
|
||||||
DEFAULT_FLOW_TOP_N = 500
|
DEFAULT_FLOW_TOP_N = 100
|
||||||
DEFAULT_RATE_LIMIT_SEC = 0.2
|
DEFAULT_RATE_LIMIT_SEC = 0.2
|
||||||
|
# 시총 상위 100종목 × 0.2초 = ~20초 — agent-office httpx timeout(180s) 안에 여유롭게 완료
|
||||||
|
# 외국인 매수 시그널은 대형주에서 의미가 크므로 상위 100종목으로 충분.
|
||||||
|
# 더 많은 종목이 필요하면 별도 cron으로 분리 권장.
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|||||||
@@ -4,14 +4,15 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import datetime as dt
|
import datetime as dt
|
||||||
|
|
||||||
NODE_ICONS = {
|
# 노드별 풀 라벨 (아이콘 대신 사용 — 사용자가 명확한 이름 선호)
|
||||||
"foreign_buy": "👤외",
|
NODE_LABELS = {
|
||||||
"volume_surge": "⚡거",
|
"foreign_buy": "외국인",
|
||||||
"momentum": "🚀모",
|
"volume_surge": "거래량급증",
|
||||||
"high52w": "🆙고",
|
"momentum": "20일모멘텀",
|
||||||
"rs_rating": "💪RS",
|
"high52w": "52주신고가",
|
||||||
"ma_alignment": "📈MA",
|
"rs_rating": "RS레이팅",
|
||||||
"vcp_lite": "🌀VCP",
|
"ma_alignment": "이평선정배열",
|
||||||
|
"vcp_lite": "VCP수축",
|
||||||
}
|
}
|
||||||
|
|
||||||
PAGE_BASE = "https://gahusb.synology.me/stock/screener"
|
PAGE_BASE = "https://gahusb.synology.me/stock/screener"
|
||||||
@@ -25,9 +26,21 @@ def _escape_md(s: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _format_won(n) -> str:
|
def _format_won(n) -> str:
|
||||||
|
"""1,234,567원 형태 (None 시 '-')."""
|
||||||
if n is None:
|
if n is None:
|
||||||
return "-"
|
return "\\-"
|
||||||
return f"{int(n):,}"
|
return f"{int(n):,}원"
|
||||||
|
|
||||||
|
|
||||||
|
def _format_active_nodes(scores: dict, threshold: int = 70) -> str:
|
||||||
|
"""70점 이상 노드를 '라벨 점수' 형태로 나열, 콤마 구분."""
|
||||||
|
active = []
|
||||||
|
for name, sc in scores.items():
|
||||||
|
label = NODE_LABELS.get(name)
|
||||||
|
if label is None or sc < threshold:
|
||||||
|
continue
|
||||||
|
active.append(f"{_escape_md(label)} {int(sc)}")
|
||||||
|
return " · ".join(active) if active else "\\(70점 이상 노드 없음\\)"
|
||||||
|
|
||||||
|
|
||||||
def build_telegram_payload(asof: dt.date, mode: str, survivors_count: int,
|
def build_telegram_payload(asof: dt.date, mode: str, survivors_count: int,
|
||||||
@@ -40,17 +53,14 @@ def build_telegram_payload(asof: dt.date, mode: str, survivors_count: int,
|
|||||||
|
|
||||||
lines = []
|
lines = []
|
||||||
for r in rows[:10]:
|
for r in rows[:10]:
|
||||||
icons = " ".join(
|
nodes_str = _format_active_nodes(r.get("scores", {}))
|
||||||
NODE_ICONS[name] for name, sc in r["scores"].items()
|
|
||||||
if sc >= 70 and name in NODE_ICONS
|
|
||||||
)
|
|
||||||
score_str = f"{r['total_score']:.1f}"
|
score_str = f"{r['total_score']:.1f}"
|
||||||
r_pct = r.get("r_pct")
|
r_pct = r.get("r_pct")
|
||||||
r_pct_str = f"{r_pct:.1f}" if r_pct is not None else "-"
|
r_pct_str = f"{r_pct:.1f}" if r_pct is not None else "-"
|
||||||
lines.append(
|
lines.append(
|
||||||
f"{r['rank']}\\. *{_escape_md(r['name'])}* `{r['ticker']}` "
|
f"{r['rank']}\\. *{_escape_md(r['name'])}* `{r['ticker']}` "
|
||||||
f"⭐ {_escape_md(score_str)}\n"
|
f"⭐ {_escape_md(score_str)}\n"
|
||||||
f" {icons}\n"
|
f" {nodes_str}\n"
|
||||||
f" 진입 {_format_won(r.get('entry_price'))} "
|
f" 진입 {_format_won(r.get('entry_price'))} "
|
||||||
f"손절 {_format_won(r.get('stop_price'))} "
|
f"손절 {_format_won(r.get('stop_price'))} "
|
||||||
f"익절 {_format_won(r.get('target_price'))} "
|
f"익절 {_format_won(r.get('target_price'))} "
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ def test_build_payload_includes_top10_and_link():
|
|||||||
assert "42" in text # run_id 링크
|
assert "42" in text # run_id 링크
|
||||||
|
|
||||||
|
|
||||||
def test_score_threshold_filters_icons():
|
def test_score_threshold_filters_node_labels():
|
||||||
rows = [{
|
rows = [{
|
||||||
"rank": 1, "ticker": "A", "name": "A주",
|
"rank": 1, "ticker": "A", "name": "A주",
|
||||||
"total_score": 80,
|
"total_score": 80,
|
||||||
@@ -41,11 +41,28 @@ def test_score_threshold_filters_icons():
|
|||||||
"target_price": 53750, "r_pct": 3.5,
|
"target_price": 53750, "r_pct": 3.5,
|
||||||
}]
|
}]
|
||||||
p = build_telegram_payload(dt.date(2026, 5, 12), "auto", 100, 1, rows, run_id=1)
|
p = build_telegram_payload(dt.date(2026, 5, 12), "auto", 100, 1, rows, run_id=1)
|
||||||
# foreign_buy(90), momentum(70), rs_rating(80), ma_alignment(80) 만 표시 (≥70)
|
text = p["text"]
|
||||||
assert "👤외" in p["text"]
|
# ≥70 노드만 풀 라벨로 표시 (foreign_buy=90, momentum=70, rs_rating=80, ma_alignment=80)
|
||||||
assert "🚀모" in p["text"]
|
assert "외국인 90" in text
|
||||||
assert "💪RS" in p["text"]
|
assert "20일모멘텀 70" in text
|
||||||
assert "📈MA" in p["text"]
|
assert "RS레이팅 80" in text
|
||||||
assert "⚡거" not in p["text"]
|
assert "이평선정배열 80" in text
|
||||||
assert "🆙고" not in p["text"]
|
# <70 노드는 숨김 (volume_surge=50, high52w=30, vcp_lite=60)
|
||||||
assert "🌀VCP" not in p["text"]
|
assert "거래량급증" not in text
|
||||||
|
assert "52주신고가" not in text
|
||||||
|
assert "VCP수축" not in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_prices_have_won_suffix():
|
||||||
|
rows = [{
|
||||||
|
"rank": 1, "ticker": "A", "name": "A주",
|
||||||
|
"total_score": 80,
|
||||||
|
"scores": {"foreign_buy": 80},
|
||||||
|
"close": 50000, "entry_price": 50250, "stop_price": 48500,
|
||||||
|
"target_price": 53750, "r_pct": 3.5,
|
||||||
|
}]
|
||||||
|
p = build_telegram_payload(dt.date(2026, 5, 12), "auto", 100, 1, rows, run_id=1)
|
||||||
|
text = p["text"]
|
||||||
|
assert "50,250원" in text
|
||||||
|
assert "48,500원" in text
|
||||||
|
assert "53,750원" in text
|
||||||
|
|||||||
Reference in New Issue
Block a user