# -*- coding: utf-8 -*-
"""
주식 디버거 (모바일 웹앱) — Hugging Face Spaces / Gradio
=========================================================
탭 순서:
1) 전량매도 최후보루(다종목) — confirm_sell_backtest.py 로직 그대로
2) 볼린저 카운트 (bollinger_count_debug.py) — 최근 1년 자동
3) 일봉 상태 — buy_engine_dipfix.run 후 be.LAST_STATUS (일봉 현 국면 한 줄)
4) 삼성·ETF 매도 (RS) — final_sell_monitor_rs.py (RS_A4, 위아래 억제)
원칙:
- 전량매도: confirm_sell_backtest.py 를 import (백테스터와 100% 동일 로직)
- 볼린저: 데스크탑 스크립트 소스를 읽어 TICKER만 바꿔 exec, 출력 그대로 캡처
- RS 매도: final_sell_monitor_rs.analyze_one 호출 (삼성·ETF처럼 이격으로 안 잡히는 종목)
- 일봉 상태: buy_engine_dipfix.run(df,'1day') 실행 후 be.LAST_STATUS 표시 (일봉만)
- 시장(KOSPI/KOSDAQ): market_map.csv 로 자동판별 (KRX StockListing 안 부름)
같이 올릴 파일: app.py, confirm_sell_backtest.py, bollinger_count_debug.py,
market_map.csv, requirements.txt,
final_sell_monitor_rs.py, confirm_sell_backtest_rs.py, buy_engine_dipfix.py
"""
import io
import os
import re
import contextlib
from datetime import datetime, timezone, timedelta
import gradio as gr
import confirm_sell_backtest as BT # 전량매도 핵심 로직/설정 그대로
import final_sell_monitor_rs # RS 매도(삼성·ETF용 보조). analyze_one 호출
KST = timezone(timedelta(hours=9))
def asof_label(last_date):
"""마지막 데이터가 '오늘'이고 장중이면 '장중 현재가(HH:MM)', 아니면 '종가'. 한국 장 09:00~15:30 KST."""
now = datetime.now(KST)
today = now.strftime("%Y-%m-%d")
intraday = (last_date == today
and now.weekday() < 5
and (9 * 60) <= (now.hour * 60 + now.minute) <= (15 * 60 + 30))
if intraday:
return f"{last_date} 장중 현재가 ({now:%H:%M} KST 기준, 종가 아님)"
return f"{last_date} 종가 기준"
HERE = os.path.dirname(os.path.abspath(__file__))
MAP_PATH = os.path.join(HERE, "market_map.csv")
BOLL_PATH = os.path.join(HERE, "bollinger_count_debug.py")
# ── 실행 시 market_map.csv 백그라운드 자동 갱신 (오래됐을 때만) ────────────────
# build_market_map.py 를 찾아 --if-stale N --out <내 market_map.csv> 로 백그라운드 실행.
# fdr/한국IP 없는 배포 서버에선 조용히 실패 → 커밋된 market_map.csv 그대로 사용(앱은 정상).
# 맥 로컬(fdr 됨)에선 앱 켤 때마다 N일 넘었으면 알아서 최신화. 맥 스케줄러와 이중 안전망.
MARKET_MAP_STALE_DAYS = 7 # 이 일수 넘으면 갱신 시도. 0이면 자동갱신 끔.
def _refresh_market_map_bg():
import threading, subprocess, sys as _sys
def _run():
try:
cands = [
os.path.join(HERE, "build_market_map.py"),
os.path.join(HERE, "..", "quant", "sell_debug", "build_market_map.py"),
os.path.expanduser("~/Projects/quant/sell_debug/build_market_map.py"),
]
script = next((p for p in cands if os.path.exists(p)), None)
if not script:
return # 배포 서버 등 생성기 없음 → 조용히 스킵
subprocess.run(
[_sys.executable, script, "--if-stale", str(MARKET_MAP_STALE_DAYS),
"--out", MAP_PATH],
timeout=180, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
except Exception:
pass # fdr 실패/해외IP/타임아웃 등 전부 무시 — 앱 동작에 영향 없음
if MARKET_MAP_STALE_DAYS:
threading.Thread(target=_run, daemon=True).start()
_refresh_market_map_bg()
# ──────────────────────────────────────────────────────────────────────────────
ALERT_DAYS = 5 # 전량매도: 최근 이 거래일 안의 매도신호를 [매도]로 본다
_TICKER_RE = re.compile(r"TICKER\s*=\s*['\"][0-9A-Za-z]+['\"]")
# ====================================================================
# 공통: market_map.csv (code -> (market, name)) 3컬럼: code,market,name
# ====================================================================
def load_market_map():
m = {}
try:
with open(MAP_PATH, encoding="utf-8") as f:
for line in f:
parts = [p.strip() for p in line.strip().split(",")]
if len(parts) < 2:
continue
code, mkt = parts[0], parts[1]
if code.lower() == "code":
continue
name = parts[2] if len(parts) >= 3 else ""
m[code.zfill(6)] = (mkt.upper(), name)
except Exception as e:
print(f"[경고] market_map.csv 로드 실패: {e}")
return m
MARKET_MAP = load_market_map()
import difflib
def _norm(s):
return str(s).replace(" ", "").lower()
# 종목명(정규화) -> 코드
NAME_TO_CODE = {}
_NORM_NAMES = []
for _c, (_m, _n) in MARKET_MAP.items():
if _n:
NAME_TO_CODE[_norm(_n)] = _c
_NORM_NAMES.append(_norm(_n))
def resolve_to_code(q):
"""6자리 코드면 그대로. 종목명이면 코드로 (대소문자 무시, 부분일치, 오타허용).
반환 (code, 메시지). 못 찾거나 후보 여럿이면 (None, 안내)."""
q = str(q).strip()
z = q.zfill(6)
if z.isdigit() and len(z) == 6:
return z, "" # 숫자 코드
zu = q.upper().zfill(6) # ETF/ETN 영숫자 코드 (예: 0091P0)
if len(zu) == 6 and zu.isalnum() and zu in MARKET_MAP:
return zu, ""
key = _norm(q)
if not key:
return None, "입력이 비었어"
if key in NAME_TO_CODE: # 정확 일치(대소문자 무시)
return NAME_TO_CODE[key], ""
hits = [(nm, c) for nm, c in NAME_TO_CODE.items() if key in nm] # 부분 일치
if len(hits) == 1:
return hits[0][1], f"('{q}' → {MARKET_MAP[hits[0][1]][1]})"
if len(hits) > 1:
hits.sort(key=lambda h: difflib.SequenceMatcher(None, key, h[0]).ratio(), reverse=True)
cand = ", ".join(f"{MARKET_MAP[c][1]}({c})" for _, c in hits[:8])
return None, f"'{q}' 후보 여럿 → {cand}"
close = difflib.get_close_matches(key, _NORM_NAMES, n=5, cutoff=0.6) # 오타 허용
if len(close) == 1:
c = NAME_TO_CODE[close[0]]
return c, f"('{q}' → {MARKET_MAP[c][1]} 로 추정)"
if len(close) > 1:
cand = ", ".join(f"{MARKET_MAP[NAME_TO_CODE[nm]][1]}({NAME_TO_CODE[nm]})" for nm in close)
return None, f"'{q}' 혹시 이거? → {cand}"
return None, f"'{q}' 종목명/코드 못 찾음"
def market_of(code):
return MARKET_MAP.get(code.zfill(6), ("KOSDAQ", ""))[0]
def name_of(code):
nm = MARKET_MAP.get(code.zfill(6), ("", ""))[1]
return nm if nm else code.zfill(6)
def _label(code):
"""결과 표시용: '레인보우로보틱스(277810)' 형태. 이름 없으면 코드만."""
nm = MARKET_MAP.get(code.zfill(6), ("", ""))[1]
return f"{nm}({code.zfill(6)})" if nm else code.zfill(6)
# ====================================================================
# 탭: 볼린저 — 스크립트 소스를 읽어 TICKER 바꿔 exec
# ====================================================================
def run_script(path, ticker):
code, msg = resolve_to_code(ticker)
if code is None:
return msg
ticker = code
try:
src = open(path, encoding="utf-8").read()
except Exception as e:
return f"스크립트 로드 실패: {e}"
src, n = _TICKER_RE.subn(f"TICKER = '{ticker}'", src, count=1)
if n == 0:
return "스크립트에서 TICKER 라인을 못 찾음."
buf = io.StringIO()
g = {"__name__": "__main__"}
try:
with contextlib.redirect_stdout(buf):
exec(compile(src, path, "exec"), g)
except SystemExit:
pass
except Exception as e:
return f"{buf.getvalue()}\n\n[오류] {e}"
out = buf.getvalue().strip()
head = f"[{_label(code)}]{(' ' + msg) if msg else ''}\n\n"
return head + (out if out else "출력 없음 (데이터가 없거나 조건 미충족)")
def run_boll(ticker):
return run_script(BOLL_PATH, ticker)
# ====================================================================
# 탭: 전량매도 최후보루 (다종목) — 현재 단계 status 포함
# ====================================================================
def _real_sells(stock, low, ma240, sells):
"""백테스터 main()과 동일한 4종 필터로 '진짜 매도'만 (240터치/240없음/너무일찍/추세유지 SKIP)."""
out = []
for s in sells:
i = s["i"]; price = s["price"]
cat, days = BT.classify_240(stock, low, ma240, i, price)
if getattr(BT, "SKIP_IF_NO_240", False) and cat == "UNKNOWN":
continue
if getattr(BT, "SKIP_IF_BELOW_240", False) and cat == "ALREADY_DOWN":
continue
med = getattr(BT, "MAX_EARLY_DAYS", None)
if med is not None and cat == "EARLY" and days is not None and days > med:
continue
if getattr(BT, "SKIP_IF_STILL_UP", False) and cat == "STILL_UP":
continue
out.append(s)
return out
def _skip_reason(stock, low, ma240, s):
cat, days = BT.classify_240(stock, low, ma240, s["i"], s["price"])
if getattr(BT, "SKIP_IF_NO_240", False) and cat == "UNKNOWN":
return "240일선이 아직 없는 종목(상장 얼마 안 됨)이라 신호를 믿을 수 없어서"
if getattr(BT, "SKIP_IF_BELOW_240", False) and cat == "ALREADY_DOWN":
return "신호 당일 주가가 이미 240일선을 건드린 상태 → 이미 늦은 신호라서"
med = getattr(BT, "MAX_EARLY_DAYS", None)
if med is not None and cat == "EARLY" and days is not None and days > med:
return f"신호 뒤 {days}거래일이나 지나서야 240일선이 깨짐 → 너무 성급한 신호라서"
if getattr(BT, "SKIP_IF_STILL_UP", False) and cat == "STILL_UP":
return "신호 뒤 200거래일 동안 240일선을 안 깨고 추세가 살아있었음 → 헛매도라서"
return None
def _rng_txt(rg):
"""(시작,끝) → '2025-07-10 ~ 2026-02-04'. 끝이 None이면 아직 진행 중인 구간."""
a, b = rg
return f"{a} ~ {b}" if b else f"{a} ~ 지금(진행중)"
def _dates_txt(ds, cap=6):
"""무시된 신호 날짜 목록. 너무 많으면 앞쪽 몇 개 + 마지막."""
if len(ds) <= cap:
return ", ".join(ds)
return ", ".join(ds[:cap - 1]) + f" … {ds[-1]} (총 {len(ds)}건)"
def check_one(code):
code = code.zfill(6)
market = market_of(code)
r = BT.load_one(code, market)
if r is None:
return {"code": code, "market": market, "ok": False, "msg": "데이터 없음"}
stock = r["stock"]; index = r["index"]; low = r["low"]; ma240 = r["ma240"]
# ★2026-10-14(사용자 확정): 모아가는 구간 매도는 엔진 '안에서' 무시 → 팔았음으로 안 치고 다음 단계를 계속 본다.
# (예전: 무시해도 엔진은 '매도함'으로 남아 이격 −10% 회복 전까지 새 신호 없음 — 자람 −48% 무알람)
_pre_rngs = None
try:
import final_sell_monitor as _FM0
if getattr(_FM0, "IGNORE_SELL_IN_ACCUM", True):
_pre_rngs = _FM0.accum_ranges(code)
except Exception:
_pre_rngs = None
_mute = (lambda d: _FM0.in_accum(d, _pre_rngs)) if (_pre_rngs and getattr(BT, "MUTE_KEEPS_WATCHING", False)) else None # 10/14: 스위치 False(예전 규칙)
try:
raw_sells, jumps, state = BT.find_confirmed_sells(
stock, index, r["dates"], r["start_pos"], return_state=True, mute=_mute)
except TypeError: # 옛 BT(mute 없음) — 동기화 전 안전장치
raw_sells, jumps, state = BT.find_confirmed_sells(
stock, index, r["dates"], r["start_pos"], return_state=True)
_eng_muted = list(state.get("muted") or [])
sells = _real_sells(stock, low, ma240, raw_sells) # 4종 필터
last_i = len(stock) - 1
# ★2026-09-15 버그수정 — '모아가는 구간(매수 국면)의 전량매도 알람은 무시' 규칙이
# 여기엔 **통째로 빠져 있었다.** final_sell_monitor.check_one 과 full_debug 에는 있는데
# app.py 는 자체 check_one 을 갖고 있어서 필터를 한 번도 안 걸었다.
# → 설계본이 '무시'로 판단한 신호가 웹에선 [★지금 매도] 로 떴다
# (실측 217730: 2026-09-09 전량매도 — full_debug 는 '무시됨', 여기선 빨간 경고).
# 규칙은 final_sell_monitor(→ phase_ranges, 단일 정본)에서 가져온다. 여기서 재구현 금지.
# ★2026-09-18 2차수정 — 무시된 건수를 '지금 얘기'처럼 보여주던 표시 버그.
# 무시 자체는 옳은데(필터는 정상), n_muted 에 **몇 년 전 신호까지** 다 세서
# 오늘 판정 옆에 '모아가는 구간이라 무시됨'이 붙었다 → 지금이 매수 국면인 줄로 읽힌다.
# (실측 078520: 마지막 모아가는 구간은 2025-07-10~2026-02-04 로 이미 끝났고
# 2026-08-11 부터는 '매수중단(눌림 판정)' 인데도 그 문구가 떴다.)
# ⇒ **날짜를 전부 같이 돌려준다.** 건수만 보여주면 그게 언제 얘긴지 알 수가 없다
# (2026-09-19 지적: '모아가는 구간이 언제고 무시된 2건이 언제고 지금을
# 얘기하는 건지 전혀 알 수가 없다'). 숫자 말고 날짜가 답이다.
accum_now = False; n_muted = 0; muted_dates = []; muted_rngs = []; cur_rng = None
try:
import final_sell_monitor as _FM
if getattr(_FM, "IGNORE_SELL_IN_ACCUM", True):
rngs = _FM.accum_ranges(code)
if rngs:
def _rng_of(ds):
for _a, _b in rngs:
if ds >= _a and (_b is None or ds < _b):
return (_a, _b)
return None
_all = sells
sells = [s for s in _all if not _FM.in_accum(s["date"], rngs)]
_muted = sorted([s for s in _all if _FM.in_accum(s["date"], rngs)] + _eng_muted,
key=lambda x: x["i"]) # ★10/14 엔진 안에서 무시된 것도 표시
n_muted = len(_muted)
muted_dates = [s["date"] for s in _muted]
for _s in _muted: # 무시된 신호가 '어느 구간' 안이었나
_rg = _rng_of(_s["date"])
if _rg and _rg not in muted_rngs:
muted_rngs.append(_rg)
_today = r["dates"][last_i]
accum_now = _FM.in_accum(_today, rngs)
cur_rng = _rng_of(_today) if accum_now else None
except Exception:
pass # 규칙을 못 불러오면 예전 동작(필터 없음)으로 — 조용히 죽지 않게 아래서 표시
recent = [s for s in sells if s["i"] >= last_i - ALERT_DAYS]
trigger = None; skipped = None
if state.get("sold") and state.get("sold_i") is not None:
si = state["sold_i"]
trigger = next((s for s in sells if s["i"] == si), None)
if trigger is None:
raw = next((s for s in raw_sells if s["i"] == si), None)
if raw is not None:
# ★10/14 문구 버그: 4종 필터에 안 걸렸는데 빠진 거면 = 모아가는 구간 때문("조건 미달" 아님)
skipped = {"sell": raw,
"reason": _skip_reason(stock, low, ma240, raw)
or "모아가는 구간(매수 국면)이라 매도 안 함"}
state = dict(state, sold=False)
import numpy as _np
_m = ma240[last_i]
return {
"code": code, "market": market, "ok": True,
"price_now": float(stock[last_i]),
"ma240_now": (None if _np.isnan(_m) else float(_m)),
"last_date": r["dates"][last_i],
"recent": recent,
"accum_now": accum_now, # 지금 모아가는 구간인가
"n_muted": n_muted, # 그 구간이라 무시한 매도신호 수(전 기간)
"muted_dates": muted_dates, # 무시된 신호가 난 날짜들 ← '언제 건지'
"muted_rngs": muted_rngs, # 그 신호들이 들어있던 모아가는 구간(들)
"cur_rng": cur_rng, # 지금이 모아가는 구간이면 그 구간, 아니면 None
"last_sell": sells[-1] if sells else None,
"trigger": trigger,
"skipped": skipped,
"state": state,
# ★2026-10-13: 데이터 날짜(지수 늦음 감지). 지수가 종목보다 늦으면 오늘 이격은 가짜일 수 있다.
"stock_last": r.get("stock_last"),
"index_last": r.get("index_last"),
"stale": bool(r.get("stock_last") and r.get("index_last")
and r.get("index_last") < r.get("stock_last")),
}
def _status_line(res):
st = res.get("state")
if not st:
return "관망 (신호 대기)"
if st.get("sold"):
return (f"과거 매도신호 유지중 (지금 팔라는 신호 아님) · "
f"지금 이격 {st['div']*100:.1f}%")
nm = {"없음": "관망", "flagON": "flag ON (대기)", "1차": "1차 (진행중)",
"2차flagON": "2차 flag ON (전량매도 임박!)",
"2차매도": "관망 (매도신호 있었으나 필터로 제외됨)"}.get(st["stage_name"], st["stage_name"])
return f"{nm} · 지금 이격 {st['div']*100:.1f}%"
def run_monitor(text):
raw = [c.strip() for c in text.replace(",", " ").replace("\n", " ").split()]
raw = [c for c in raw if c]
if not raw:
return "종목코드 또는 종목명을 입력해줘 (예: 277810 레인보우로보틱스)"
head = (f"**전량매도 최후보루** · 최근 {ALERT_DAYS}거래일 기준\n\n"
f"기준: 이격 {BT.DIV_THRESHOLD*100:.0f}% 단계누적({BT.MIN_DEEPEN*100:.1f}%p+) "
f"+ 하루급변 {BT.DAILY_JUMP*100:.0f}%p 점프. "
f"필터: 240터치·240없음·헛매도 제외\n\n---\n")
out = [head]
alerts = []
held = [] # ★10/13 지수 데이터 늦어서 판단 보류한 종목
asof = None
for _q in raw:
code, msg = resolve_to_code(_q)
if code is None:
out.append(f"- {msg}")
continue
res = check_one(code)
lbl = _label(code) + (f" {msg}" if msg else "")
if not res["ok"]:
out.append(f"- {lbl} ({res['market']}) — {res['msg']}")
continue
asof = res.get("last_date")
st = res.get("state") or {}
steps = st.get("steps") or []
trig = res.get("trigger")
skp = res.get("skipped")
m240 = res.get("ma240_now")
m240s = (f"240선 {m240:,.0f} ({(res['price_now']/m240-1)*100:+.1f}%)"
if m240 else "240선 없음")
# 왜 조용한지 보이게 한다(안 그러면 '신호가 없는 것'과 구분이 안 됨).
# ★ 건수만 쓰지 않는다 — **구간이 언제부터 언제까지 / 무시된 신호가 언제 /
# 지금이 그 구간인지** 셋 다 날짜로 적는다. 건수만 있으면 지금 얘긴 줄 읽힌다.
_md = res.get("muted_dates") or []
_cur = res.get("cur_rng"); _mrg = res.get("muted_rngs") or []
if res.get("accum_now") and _cur:
verdict_note = (f"\n> 🔇 **지금({res['last_date']})이 모아가는 구간"
f"({_rng_txt(_cur)}) — 전량매도 알람 꺼둠**\n")
# ★10/14 문구 버그: '이 구간' 이라면서 다른(과거) 구간 날짜까지 섞어 보여줬다 → 지금 구간 것만 / 과거 것은 따로
_a0, _b0 = _cur
_md_now = [d for d in _md if d >= _a0 and (_b0 is None or d < _b0)]
_md_old = [d for d in _md if d not in _md_now]
if _md_now:
verdict_note += (f"> ㆍ이 구간에서 무시된 신호 {len(_md_now)}건: "
f"{_dates_txt(_md_now)}\n")
if _md_old:
verdict_note += (f"> ㆍ(참고) 지난 모아가는 구간들에서 무시된 신호 {len(_md_old)}건: "
f"{_dates_txt(_md_old)}\n")
elif _md:
_g = (" (" + ", ".join(_rng_txt(g) for g in _mrg) + ")") if _mrg else ""
verdict_note = (f"\n> 🔇 **지난** 모아가는 구간{_g} 안에서 난 "
f"전량매도 신호 {len(_md)}건은 무시됨: {_dates_txt(_md)}\n"
f"> ㆍ**지금({res['last_date']})은 모아가는 구간이 아님** — "
f"지금 신호가 나면 무시 안 하고 알람 울림\n")
else:
verdict_note = ""
_stale = res.get("stale")
_il = res.get("index_last") or ""
_stale_note = (f"\n> ⚠️ **{res['market']} 지수 데이터가 {_il} 에 멈춰 있음** "
f"(종목 데이터는 {res.get('stock_last')}) — 그 뒤 날짜의 이격은 지수 움직임을 반영 못 해 "
f"**틀리게** 나옵니다(지수가 빠진 날이면 실제보다 나쁘게). 이 상태의 빨간 불은 믿지 마세요. 잠시 뒤 다시 조회.\n") if _stale else ""
if res["recent"] and _stale and res["recent"][-1]["date"] > _il:
sl = res["recent"][-1]
held.append(_label(code))
verdict = (f"### 🟠 [판단 보류 — 지수 데이터 늦음] {_label(code)} ({res['market']})\n"
f"- 계산상 전량매도 {sl['date']} @ {sl['price']:,.0f} (이격 {sl['div']*100:.0f}%) 이지만, "
f"그날 지수가 아직 안 들어와서(마지막 {_il}) **가짜일 가능성이 큼**. 팔지 말고 다시 조회.\n")
elif res["recent"]:
sl = res["recent"][-1]
alerts.append(_label(code))
ago = st.get("last_i", 0) - sl["i"]
verdict = (f"### 🔴 [★지금 매도] {_label(code)} ({res['market']})\n"
f"- **전량매도 신호: {sl['date']} @ {sl['price']:,.0f}** "
f"(신호시점 이격 {sl['div']*100:.0f}%) · {ago}거래일 전\n")
elif trig and st.get("sold"):
ago = st.get("last_i", 0) - trig["i"]
verdict = (f"### 🟡 [과거 신호] {_label(code)} ({res['market']})\n"
f"- **※ 지금 팔라는 신호 아님**\n"
f"- 전량매도 신호: {trig['date']} @ {trig['price']:,.0f} "
f"(그때 이격 {trig['div']*100:.0f}%) · {ago}거래일 전\n"
f"- 이격 {BT.DIV_THRESHOLD*100:.0f}% 위로 회복하면 리셋\n")
elif skp:
sl = skp["sell"]
ago = st.get("last_i", 0) - sl["i"]
verdict = (f"### 🟢 [보유] {_label(code)} ({res['market']})\n"
f"- 무시된 신호: {sl['date']} @ {sl['price']:,.0f} "
f"(그때 이격 {sl['div']*100:.0f}%) · {ago}거래일 전\n"
f"- 무시한 이유 — {skp['reason']}\n")
# ★2026-10-14 '무시 후 더 빠짐' 경고(봇 텔레그램과 같은 기준: 구간 끝난 뒤 5%p+ 더 깊어짐). 매매엔 영향 없음.
_cd = st.get("div")
if not res.get("accum_now") and _cd is not None \
and (sl["div"] - _cd) >= 0.05:
verdict = verdict.replace("### 🟢 [보유]", "### 🟠 [보유 · 확인 필요]", 1)
verdict += (f"- ⚠️ **무시한 뒤로 이격이 {(sl['div'] - _cd)*100:.0f}%p 더 빠짐** "
f"(그때 {sl['div']*100:.0f}% → 지금 {_cd*100:.1f}%). 규칙상 새 매도 신호는 안 나요"
f"(이격 −10% 회복 전까지) — 봇은 안 팝니다. **직접 판단하세요.**\n")
else:
verdict = (f"### 🟢 [보유] {_label(code)} ({res['market']})\n"
f"- 매도 신호 없음. 보유 유지.\n"
f"- 현재 단계: {_status_line(res)}\n")
# ★ 이격이 회복돼 리셋됐어도 '직전 매도가 언제였는지'는 항상 보여준다.
ls = res.get("last_sell")
if ls:
ago = st.get("last_i", 0) - ls["i"]
_nowdiv = st.get("div")
verdict += (f"- [직전 매도(참고)] {ls['date']} @ {ls['price']:,.0f} "
f"(그때 이격 {ls['div']*100:.0f}%) · {ago}거래일 전\n")
if _nowdiv is not None:
verdict += f" - ※ 지금은 이격 {_nowdiv*100:.0f}%로 회복돼서 신호 꺼짐\n"
# ★2026-10-16 지수 주도형 이격 건너뜀(BT.INDEX_LED_SKIP='half', watch) — 왜 안 울렸는지 보이게
_isk = st.get("idx_skip") or []
if _isk and (st.get("last_i", 0) - _isk[-1]["i"]) <= 60:
_k = _isk[-1]; _ago = st.get("last_i", 0) - _k["i"]
verdict += (f"\n> ℹ️ **{_k['date']} 전량매도 조건이었지만 건너뜀** @ {_k['price']:,.0f} "
f"(이격 {_k['div']*100:.0f}%) · {_ago}거래일 전\n"
f"> ㆍ이유: 종목이 빠진 것보다 **지수가 오른 게 더 커서** 생긴 이격(폭락 아님)\n"
f"> ㆍ팔았음으로 안 치고 계속 지켜봄 — 이격이 2.5%p 더 깊어지면 다시 판단(그때 종목 탓이면 매도)\n")
if verdict_note:
verdict += verdict_note # ★ 왜 조용한지 보이게(무시된 건수)
if _stale_note:
verdict += _stale_note # ★10/13 지수 늦음 경고
out.append(verdict)
out.append(f"**[지금]** {res['last_date']} 종가 {res['price_now']:,.0f} · {m240s}\n")
if "peak_price" in st:
out.append(
f"- 고점 {st['peak_price']:,.0f} ({st['peak_date']})\n"
f"- 지금 이격 **{st['div']*100:.1f}%** "
f"= 종목 고점대비 {st['stock_from_peak']*100:.1f}% "
f"− 지수 같은기간 {st['index_from_peak']*100:+.1f}%\n")
if steps:
out.append(f"**[단계 {len(steps)}개]**\n")
for stp in steps:
mark = (" ←★" if "★전량매도" in stp["name"]
else (" (무시 — 팔았음 아님, 계속 지켜봄)" if "무시" in stp["name"]
else (" (지수만 올라서 생긴 이격 — 건너뜀, 계속 지켜봄)" if "건너뜀" in stp["name"] else "")))
out.append(f"- `{stp['name']}` {stp['date']} @ {stp['price']:,.0f} "
f"(이격 {stp['div']*100:.1f}%){mark}\n")
out.append("\n")
out.append("\n---\n")
if asof:
out.append(f"*상태 기준: {asof_label(asof)}*\n")
if held:
out.append(f"🟠 **지수 데이터 늦어서 판단 보류: {len(held)}종목 → {', '.join(held)}** (잠시 뒤 다시 조회)\n\n")
if alerts:
out.append(f"**오늘 확인 필요: {len(alerts)}종목 → {', '.join(alerts)}**")
elif not held:
out.append("**최근 매도신호 없음. 전부 보유 유지.**")
return "\n".join(out)
# ====================================================================
# 탭: 삼성·ETF 매도 (RS) — final_sell_monitor_rs.analyze_one 호출
# ====================================================================
def run_rs(text):
raw = [c.strip() for c in text.replace(",", " ").replace("\n", " ").split()]
raw = [c for c in raw if c]
if not raw:
return "종목코드 또는 종목명을 입력해줘 (예: 005930 삼성전자)"
out = []
for _q in raw:
code, msg = resolve_to_code(_q)
if code is None:
out.append(msg)
continue
market = market_of(code)
try:
res = final_sell_monitor_rs.analyze_one(
{"ticker": code, "market": market, "name": name_of(code)})
except Exception as e:
res = f"[오류] {_label(code)} — {e}"
out.append(res)
out.append("")
return "\n".join(out)
# ====================================================================
def run_status(text):
"""종목의 일봉 '지금 상태'(동산 그리는 중 / 위아래 N번째 / 매수 멈춤 등)를 한 줄로."""
try:
import FinanceDataReader as fdr
import buy_engine_dipfix as be
except Exception as e:
return f"엔진/데이터 로드 실패: {e}"
raw = [c.strip() for c in text.replace(",", " ").replace("\n", " ").split()]
raw = [c for c in raw if c]
if not raw:
return "종목코드 또는 종목명을 입력해줘 (예: 096770 DN오토모티브)"
seen = set(); out = []
for _q in raw:
code, msg = resolve_to_code(_q)
if code is None:
out.append(f"- {msg}"); continue
if code in seen:
continue # 같은 종목 코드+이름 둘 다 넣어도 한 번만
seen.add(code)
try:
df = fdr.DataReader(code, "2005-01-01")
except Exception as e:
out.append(f"- {_label(code)}: 데이터 로드 실패 ({e})"); continue
if df is None or len(df) < 300:
out.append(f"- {_label(code)}: 일봉 데이터 부족(300봉 미만)"); continue
try:
be.run(df, "1day")
status = getattr(be, "LAST_STATUS", "") or "(상태 없음)"
kind = getattr(be, "LAST_STATUS_KIND", "wait")
except Exception as e:
out.append(f"- {_label(code)}: 분석 실패 ({e})"); continue
last_date = str(df.index[-1])[:10]
last_px = float(df["Close"].iloc[-1])
# 색상 일관성(최후보루와 동일): 🟢초록=좋음(매수 국면), ⚠️주황=주의(무너지는 중)
if kind == "buy":
line = f'🟢 {status}'
elif kind == "danger":
line = f'⚠️ {status}'
else:
line = status
out.append(f"### 📈 {_label(code)} ({market_of(code)})\n"
f"- **[일봉]** {line}\n"
f"- 기준: {last_date} 종가 {last_px:,.0f}")
return "\n\n".join(out) if out else "결과 없음"
# UI (탭 순서: 최후보루 → 볼린저 → 일봉 상태 → 삼성·ETF(RS))
# ====================================================================
with gr.Blocks(title="WTW Outlook") as demo:
gr.Markdown("# 📊 WTW Outlook")
with gr.Tab("전량매도 최후보루"):
gr.Markdown("보유종목 **코드 또는 종목명**을 넣으면 매도신호/현재단계를 알려줘. (시장 자동판별, 여러 종목 가능)")
m_in = gr.Textbox(label="종목코드 또는 종목명 (여러 개는 공백/줄바꿈/쉼표로)",
value="277810 레인보우로보틱스", lines=3)
m_btn = gr.Button("매도신호 확인", variant="primary")
m_out = gr.Markdown()
m_btn.click(run_monitor, inputs=m_in, outputs=m_out)
with gr.Tab("볼린저 카운트"):
gr.Markdown("볼린저밴드 상단 돌파 카운팅 (최근 1년 자동)")
b_in = gr.Textbox(label="종목코드 또는 종목명", value="420770")
b_btn = gr.Button("분석", variant="primary")
b_out = gr.Code(label="결과")
b_btn.click(run_boll, inputs=b_in, outputs=b_out)
with gr.Tab("일봉 상태"):
gr.Markdown("종목의 **일봉 지금 상태**를 알려줘 (동산 그리는 중 / 위아래 N번째 / 매수 멈춤 등). 여러 종목 가능.")
s_in = gr.Textbox(label="종목코드 또는 종목명 (여러 개는 공백/줄바꿈/쉼표로)",
value="096770 DN오토모티브", lines=3)
s_btn = gr.Button("일봉 상태 확인", variant="primary")
s_out = gr.Markdown()
s_btn.click(run_status, inputs=s_in, outputs=s_out)
with gr.Tab("삼성·ETF 매도 (RS)"):
gr.Markdown("삼성전자·ETF처럼 이격으로 안 잡히는 종목의 RS 매도신호. (위아래 매수 구간이면 억제됨)")
rs_in = gr.Textbox(label="종목코드 또는 종목명 (여러 개는 공백/줄바꿈/쉼표로)",
value="005930 삼성전자", lines=3)
rs_btn = gr.Button("RS 매도신호 확인", variant="primary")
rs_out = gr.Code(label="결과")
rs_btn.click(run_rs, inputs=rs_in, outputs=rs_out)
if __name__ == "__main__":
demo.launch()