johnkim commited on
Commit
29b99a7
·
1 Parent(s): 6536241

app.py 정상 복구 + RS 탭 추가

Browse files
Files changed (2) hide show
  1. app.py +368 -107
  2. final_sell_monitor_rs.py +1 -1
app.py CHANGED
@@ -1,150 +1,411 @@
1
  # -*- coding: utf-8 -*-
2
  """
3
- 주식 디버거 (모바일 웹앱) — 3개 분석 / Render·GitHub 배포용
 
 
 
 
 
 
4
 
5
- 탭(분석):
6
- 1) VCP 돌파 감지 (vcp_debug.py) ← 날짜 자동: 최근 1년
7
- 2) 볼린저 카운 (bollinger_count_debug.py) 날짜 자동: 최근 1년
8
- 3) 전량매도 최후보루 (final_sell_monitor.py) ← 최신 최소버전
 
9
 
10
- 원칙: 데스크탑 스크립트의 로직/출력을 그대로 재현.
11
- - 함수형(analyze_one) 스크립트: import 후 함수 호출
12
- - 최상위 실행형(VCP·볼린저): 소스를 읽어 TICKER만 바꿔 exec (sys.exit 가드)
13
- 모든 출력은 contextlib.redirect_stdout 로 그대로 캡처해 화면에 표시.
14
-
15
- KRX(StockListing)는 해외 서버 IP를 막으므로 자동 시장판별이 실패할 수 있음.
16
- 주가/지수 데이터(Naver)는 정상 로드됨. 전량매도는 시장(KOSPI/KOSDAQ)을 직접 선택.
17
  """
18
 
19
  import io
20
  import os
21
  import re
22
  import contextlib
23
- from datetime import datetime, timedelta
24
 
25
  import gradio as gr
26
 
27
- # 함수형 스크립트는 import 해서 호출 (최상위에서 분석이 실행되지 않음)
28
- import final_sell_monitor
29
- import final_sell_monitor_rs # RS 매도(삼성·ETF용 보조). 배포 시 rs_sell 3파일을 app.py 옆에 flat 하게 올릴 것
 
 
 
 
 
 
 
 
 
 
 
 
 
30
 
31
  HERE = os.path.dirname(os.path.abspath(__file__))
 
32
  BOLL_PATH = os.path.join(HERE, "bollinger_count_debug.py")
33
  VCP_PATH = os.path.join(HERE, "vcp_debug.py")
34
 
 
 
35
  _TICKER_RE = re.compile(r"TICKER\s*=\s*['\"][0-9A-Za-z]+['\"]")
36
 
37
 
38
- def _exec_script(path, ticker, extra_subs=None):
39
- """최상위 실행형 스크립트: TICKER( 추가 치환)만 바꿔 exec, 출력 캡처."""
40
- with open(path, encoding="utf-8") as f:
41
- src = f.read()
42
- src = _TICKER_RE.sub(f"TICKER = '{ticker}'", src, count=1)
43
- if extra_subs:
44
- for pat, repl in extra_subs:
45
- src = re.sub(pat, repl, src, count=1)
46
- buf = io.StringIO()
47
- ns = {"__name__": "__sandbox__"} # __main__ 가드 비활성
48
- with contextlib.redirect_stdout(buf):
49
- try:
50
- exec(compile(src, path, "exec"), ns)
51
- except SystemExit:
52
- pass # 스크립트의 sys.exit() 앱을 죽이지 않게
53
- except Exception as e:
54
- print(f"\n[오류] {e}")
55
- return buf.getvalue() or "출력 없음"
 
56
 
 
57
 
58
- def _call_func(fn, *args):
59
- """함수형 스크립트: 함수 호출하며 출력 캡처."""
60
- buf = io.StringIO()
61
- with contextlib.redirect_stdout(buf):
62
- try:
63
- fn(*args)
64
- except SystemExit:
65
- pass
66
- except Exception as e:
67
- print(f"\n[오류] {e}")
68
- return buf.getvalue() or "출력 없음"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69
 
70
 
71
- # ---------- 분석별 실행 ----------
72
- def run_bollinger(ticker):
73
- ticker = (ticker or "").strip()
74
- if not ticker:
75
- return "종목코드를 입력하세요 (예: 420770)"
76
- today = datetime.today()
77
- frm = (today - timedelta(days=365)).strftime("%Y-%m-%d")
78
- to = today.strftime("%Y-%m-%d")
79
- subs = [
80
- (r"DEBUG_FROM\s*=\s*['\"][0-9-]+['\"]", f"DEBUG_FROM = '{frm}'"),
81
- (r"DEBUG_TO\s*=\s*['\"][0-9-]+['\"]", f"DEBUG_TO = '{to}'"),
82
- ]
83
- return _exec_script(BOLL_PATH, ticker, subs)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
84
 
85
 
86
  def run_vcp(ticker):
87
- ticker = (ticker or "").strip()
88
- if not ticker:
89
- return "종목코드를 입력하세요 (예: 007340)"
90
- return _exec_script(VCP_PATH, ticker)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
91
 
92
 
93
- def run_final(ticker, market, name):
94
- ticker = (ticker or "").strip()
95
- if not ticker:
96
- return "종목코드를 입력하세요 (예: 195940)"
97
- h = {"ticker": ticker, "market": market, "name": (name or "").strip() or None}
98
- return _call_func(final_sell_monitor.analyze_one, h)
99
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
100
 
101
- def run_final_rs(ticker, market, name):
102
- ticker = (ticker or "").strip()
103
- if not ticker:
104
- return "종목코드를 입력하세요 (예: 005930)"
105
- h = {"ticker": ticker, "market": market, "name": (name or "").strip() or None}
106
- return _call_func(final_sell_monitor_rs.analyze_one, h)
107
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
108
 
109
- # ---------- UI ----------
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
110
  with gr.Blocks(title="주식 디버거") as demo:
111
- gr.Markdown("## 주식 디버거 (모바일)\n분석을 고르고 종목코드만 넣으면 데스크탑과 동일한 출력이 나옵니다.")
112
 
113
- # 탭 순서: 1)최후보루 2)볼린저 3)삼성·ETF 매도(RS) 4)VCP
114
  with gr.Tab("전량매도 최후보루"):
115
- with gr.Row():
116
- f_tk = gr.Textbox(label="종목코드", placeholder="예: 195940", scale=2)
117
- f_mkt = gr.Radio(["KOSDAQ", "KOSPI"], value="KOSDAQ", label="시장", scale=2)
118
- f_nm = gr.Textbox(label="종목명(선택)", placeholder="예: HK이노엔")
119
- f_btn = gr.Button("실행", variant="primary")
120
- f_out = gr.Textbox(label="결과", lines=16)
121
- f_btn.click(run_final, inputs=[f_tk, f_mkt, f_nm], outputs=f_out)
122
 
123
  with gr.Tab("볼린저 카운트"):
124
- b_tk = gr.Textbox(label="종목코", placeholder="예: 420770")
125
- b_btn = gr.Button("실행", variant="primary")
126
- b_out = gr.Textbox(label="결과 (최근 1년 자동)", lines=20)
127
- b_btn.click(run_bollinger, inputs=b_tk, outputs=b_out)
128
-
129
- with gr.Tab("삼성·ETF 매도(RS)"):
130
- with gr.Row():
131
- rs_tk = gr.Textbox(label="종목코드", placeholder="예: 005930", scale=2)
132
- rs_mkt = gr.Radio(["KOSPI", "KOSDAQ"], value="KOSPI", label="시장", scale=2)
133
- rs_nm = gr.Textbox(label="종목명(선택)", placeholder="예: 삼성전자")
134
- rs_btn = gr.Button("실행", variant="primary")
135
- rs_out = gr.Textbox(label="결과", lines=16)
136
- rs_btn.click(run_final_rs, inputs=[rs_tk, rs_mkt, rs_nm], outputs=rs_out)
137
 
138
- with gr.Tab("VCP 돌파"):
139
- v_tk = gr.Textbox(label="종목코드", placeholder="예: 007340")
140
- v_btn = gr.Button("실행", variant="primary")
141
- v_out = gr.Textbox(label="결과 (최근 1년 동)", lines=20)
142
- v_btn.click(run_vcp, inputs=v_tk, outputs=v_out)
 
 
143
 
 
 
 
 
 
 
144
 
145
  if __name__ == "__main__":
146
- # 로그인 벽 없음 (auth 제거). 다시 걸고 싶으면 launch(auth=(id, pw)) 추가.
147
- demo.launch(
148
- server_name="0.0.0.0",
149
- server_port=int(os.environ.get("PORT", 7860)),
150
- )
 
1
  # -*- coding: utf-8 -*-
2
  """
3
+ 주식 디버거 (모바일 웹앱) — Hugging Face Spaces / Gradio
4
+ =========================================================
5
+ 탭 순서:
6
+ 1) 전량매도 최후보루(다종목) — confirm_sell_backtest.py 로직 그대로
7
+ 2) 볼린저 카운트 (bollinger_count_debug.py) — 최근 1년 자동
8
+ 3) 삼성·ETF 매도 (RS) — final_sell_monitor_rs.py (RS_A4, 위아래 억제)
9
+ 4) VCP 돌파 감지 (vcp_debug.py) — 최근 1년 자동
10
 
11
+ 원칙:
12
+ - 전량매도: confirm_sell_backtest.py import (백테스터와 100% 동일 로직)
13
+ - VCP·볼린저: 데스크탑 스크립소스를 읽어 TICKER만 바꿔 exec, 출력 그대로 캡처
14
+ - RS 매도: final_sell_monitor_rs.analyze_one 호출 (삼성·ETF처럼 이격으로 안 잡히는 종목)
15
+ - 시장(KOSPI/KOSDAQ): market_map.csv 로 자동판별 (KRX StockListing 안 부름)
16
 
17
+ 같이 올릴 파일: app.py, confirm_sell_backtest.py, vcp_debug.py, bollinger_count_debug.py,
18
+ market_map.csv, requirements.txt,
19
+ final_sell_monitor_rs.py, confirm_sell_backtest_rs.py, buy_engine_dipfix.py
 
 
 
 
20
  """
21
 
22
  import io
23
  import os
24
  import re
25
  import contextlib
26
+ from datetime import datetime, timezone, timedelta
27
 
28
  import gradio as gr
29
 
30
+ import confirm_sell_backtest as BT # 전량매도 핵심 로직/설정 그대로
31
+ import final_sell_monitor_rs # RS 매도(삼성·ETF용 보조). analyze_one 호출
32
+
33
+ KST = timezone(timedelta(hours=9))
34
+
35
+
36
+ def asof_label(last_date):
37
+ """마지막 데이터가 '오늘'이고 장중이면 '장중 현재가(HH:MM)', 아니면 '종가'. 한국 장 09:00~15:30 KST."""
38
+ now = datetime.now(KST)
39
+ today = now.strftime("%Y-%m-%d")
40
+ intraday = (last_date == today
41
+ and now.weekday() < 5
42
+ and (9 * 60) <= (now.hour * 60 + now.minute) <= (15 * 60 + 30))
43
+ if intraday:
44
+ return f"{last_date} 장중 현재가 ({now:%H:%M} KST 기준, 종가 아님)"
45
+ return f"{last_date} 종가 기준"
46
 
47
  HERE = os.path.dirname(os.path.abspath(__file__))
48
+ MAP_PATH = os.path.join(HERE, "market_map.csv")
49
  BOLL_PATH = os.path.join(HERE, "bollinger_count_debug.py")
50
  VCP_PATH = os.path.join(HERE, "vcp_debug.py")
51
 
52
+ ALERT_DAYS = 5 # 전량매도: 최근 이 거래일 안의 매도신호를 [매도]로 본다
53
+
54
  _TICKER_RE = re.compile(r"TICKER\s*=\s*['\"][0-9A-Za-z]+['\"]")
55
 
56
 
57
+ # ====================================================================
58
+ # 공통: market_map.csv (code -> (market, name)) 3컬럼: code,market,name
59
+ # ====================================================================
60
+ def load_market_map():
61
+ m = {}
62
+ try:
63
+ with open(MAP_PATH, encoding="utf-8") as f:
64
+ for line in f:
65
+ parts = [p.strip() for p in line.strip().split(",")]
66
+ if len(parts) < 2:
67
+ continue
68
+ code, mkt = parts[0], parts[1]
69
+ if code.lower() == "code":
70
+ continue
71
+ name = parts[2] if len(parts) >= 3 else ""
72
+ m[code.zfill(6)] = (mkt.upper(), name)
73
+ except Exception as e:
74
+ print(f"[경고] market_map.csv 로드 실패: {e}")
75
+ return m
76
 
77
+ MARKET_MAP = load_market_map()
78
 
79
+ import difflib
80
+
81
+ def _norm(s):
82
+ return str(s).replace(" ", "").lower()
83
+
84
+ # 종목명(정규화) -> 코드
85
+ NAME_TO_CODE = {}
86
+ _NORM_NAMES = []
87
+ for _c, (_m, _n) in MARKET_MAP.items():
88
+ if _n:
89
+ NAME_TO_CODE[_norm(_n)] = _c
90
+ _NORM_NAMES.append(_norm(_n))
91
+
92
+
93
+ def resolve_to_code(q):
94
+ """6자리 코드면 그대로. 종목명이면 코드로 (대소문자 무시, 부분일치, 오타허용).
95
+ 반환 (code, 메시지). 못 찾거나 후보 여럿이면 (None, 안내)."""
96
+ q = str(q).strip()
97
+ z = q.zfill(6)
98
+ if z.isdigit() and len(z) == 6:
99
+ return z, "" # 숫자 코드
100
+ zu = q.upper().zfill(6) # ETF/ETN 영숫자 코드 (예: 0091P0)
101
+ if len(zu) == 6 and zu.isalnum() and zu in MARKET_MAP:
102
+ return zu, ""
103
+ key = _norm(q)
104
+ if not key:
105
+ return None, "입력이 비었어"
106
+ if key in NAME_TO_CODE: # 정확 일치(대소문자 무시)
107
+ return NAME_TO_CODE[key], ""
108
+ hits = [(nm, c) for nm, c in NAME_TO_CODE.items() if key in nm] # 부분 일치
109
+ if len(hits) == 1:
110
+ return hits[0][1], f"('{q}' → {MARKET_MAP[hits[0][1]][1]})"
111
+ if len(hits) > 1:
112
+ hits.sort(key=lambda h: difflib.SequenceMatcher(None, key, h[0]).ratio(), reverse=True)
113
+ cand = ", ".join(f"{MARKET_MAP[c][1]}({c})" for _, c in hits[:8])
114
+ return None, f"'{q}' 후보 여럿 → {cand}"
115
+ close = difflib.get_close_matches(key, _NORM_NAMES, n=5, cutoff=0.6) # 오타 허용
116
+ if len(close) == 1:
117
+ c = NAME_TO_CODE[close[0]]
118
+ return c, f"('{q}' → {MARKET_MAP[c][1]} 로 추정)"
119
+ if len(close) > 1:
120
+ cand = ", ".join(f"{MARKET_MAP[NAME_TO_CODE[nm]][1]}({NAME_TO_CODE[nm]})" for nm in close)
121
+ return None, f"'{q}' 혹시 이거? → {cand}"
122
+ return None, f"'{q}' 종목명/코드 못 찾음"
123
+
124
+
125
+ def market_of(code):
126
+ return MARKET_MAP.get(code.zfill(6), ("KOSDAQ", ""))[0]
127
+
128
+
129
+ def name_of(code):
130
+ nm = MARKET_MAP.get(code.zfill(6), ("", ""))[1]
131
+ return nm if nm else code.zfill(6)
132
+
133
+
134
+ def _label(code):
135
+ """결과 표시용: '레인보우로보틱스(277810)' 형태. 이름 없으면 코드만."""
136
+ nm = MARKET_MAP.get(code.zfill(6), ("", ""))[1]
137
+ return f"{nm}({code.zfill(6)})" if nm else code.zfill(6)
138
 
139
 
140
+ # ====================================================================
141
+ # : VCP / 볼린저 — 스크립트 소스를 읽어 TICKER 바꿔 exec
142
+ # ====================================================================
143
+ def run_script(path, ticker):
144
+ code, msg = resolve_to_code(ticker)
145
+ if code is None:
146
+ return msg
147
+ ticker = code
148
+ try:
149
+ src = open(path, encoding="utf-8").read()
150
+ except Exception as e:
151
+ return f"스크립트 로드 실패: {e}"
152
+ src, n = _TICKER_RE.subn(f"TICKER = '{ticker}'", src, count=1)
153
+ if n == 0:
154
+ return "스크립트에서 TICKER 라인을 못 찾음."
155
+ buf = io.StringIO()
156
+ g = {"__name__": "__main__"}
157
+ try:
158
+ with contextlib.redirect_stdout(buf):
159
+ exec(compile(src, path, "exec"), g)
160
+ except SystemExit:
161
+ pass
162
+ except Exception as e:
163
+ return f"{buf.getvalue()}\n\n[오류] {e}"
164
+ out = buf.getvalue().strip()
165
+ head = f"[{_label(code)}]{(' ' + msg) if msg else ''}\n\n"
166
+ return head + (out if out else "출력 없음 (데이터가 없거나 조건 미충족)")
167
 
168
 
169
  def run_vcp(ticker):
170
+ return run_script(VCP_PATH, ticker)
171
+
172
+
173
+ def run_boll(ticker):
174
+ return run_script(BOLL_PATH, ticker)
175
+
176
+
177
+ # ====================================================================
178
+ # 탭: 전량매도 최후보루 (다종목) — 현재 단계 status 포함
179
+ # ====================================================================
180
+ def _real_sells(stock, low, ma240, sells):
181
+ """백테스터 main()과 동일한 4종 필터로 '진짜 매도'만 (240터치/240없음/너무일찍/추세유지 SKIP)."""
182
+ out = []
183
+ for s in sells:
184
+ i = s["i"]; price = s["price"]
185
+ cat, days = BT.classify_240(stock, low, ma240, i, price)
186
+ if getattr(BT, "SKIP_IF_NO_240", False) and cat == "UNKNOWN":
187
+ continue
188
+ if getattr(BT, "SKIP_IF_BELOW_240", False) and cat == "ALREADY_DOWN":
189
+ continue
190
+ med = getattr(BT, "MAX_EARLY_DAYS", None)
191
+ if med is not None and cat == "EARLY" and days is not None and days > med:
192
+ continue
193
+ if getattr(BT, "SKIP_IF_STILL_UP", False) and cat == "STILL_UP":
194
+ continue
195
+ out.append(s)
196
+ return out
197
+
198
+
199
+ def _skip_reason(stock, low, ma240, s):
200
+ cat, days = BT.classify_240(stock, low, ma240, s["i"], s["price"])
201
+ if getattr(BT, "SKIP_IF_NO_240", False) and cat == "UNKNOWN":
202
+ return "240일선이 아직 없는 종목(상장 얼마 안 됨)이라 신호를 믿을 수 없어서"
203
+ if getattr(BT, "SKIP_IF_BELOW_240", False) and cat == "ALREADY_DOWN":
204
+ return "신호 당일 주가가 이미 240일선을 건드린 상태 → 이미 늦은 신호라서"
205
+ med = getattr(BT, "MAX_EARLY_DAYS", None)
206
+ if med is not None and cat == "EARLY" and days is not None and days > med:
207
+ return f"신호 뒤 {days}거래일이나 지나서야 240일선이 깨짐 → 너무 성급한 신호라서"
208
+ if getattr(BT, "SKIP_IF_STILL_UP", False) and cat == "STILL_UP":
209
+ return "신호 뒤 200거래일 동안 240일선을 안 깨고 추세가 살아있었음 → 헛매도라서"
210
+ return None
211
+
212
+
213
+ def check_one(code):
214
+ code = code.zfill(6)
215
+ market = market_of(code)
216
+ r = BT.load_one(code, market)
217
+ if r is None:
218
+ return {"code": code, "market": market, "ok": False, "msg": "데이터 없음"}
219
+ stock = r["stock"]; index = r["index"]; low = r["low"]; ma240 = r["ma240"]
220
+ raw_sells, jumps, state = BT.find_confirmed_sells(
221
+ stock, index, r["dates"], r["start_pos"], return_state=True)
222
+ sells = _real_sells(stock, low, ma240, raw_sells) # 4종 필터
223
+ last_i = len(stock) - 1
224
+ recent = [s for s in sells if s["i"] >= last_i - ALERT_DAYS]
225
+ trigger = None; skipped = None
226
+ if state.get("sold") and state.get("sold_i") is not None:
227
+ si = state["sold_i"]
228
+ trigger = next((s for s in sells if s["i"] == si), None)
229
+ if trigger is None:
230
+ raw = next((s for s in raw_sells if s["i"] == si), None)
231
+ if raw is not None:
232
+ skipped = {"sell": raw,
233
+ "reason": _skip_reason(stock, low, ma240, raw) or "조건 미달"}
234
+ state = dict(state, sold=False)
235
+ import numpy as _np
236
+ _m = ma240[last_i]
237
+ return {
238
+ "code": code, "market": market, "ok": True,
239
+ "price_now": float(stock[last_i]),
240
+ "ma240_now": (None if _np.isnan(_m) else float(_m)),
241
+ "last_date": r["dates"][last_i],
242
+ "recent": recent,
243
+ "last_sell": sells[-1] if sells else None,
244
+ "trigger": trigger,
245
+ "skipped": skipped,
246
+ "state": state,
247
+ }
248
+
249
+
250
+ def _status_line(res):
251
+ st = res.get("state")
252
+ if not st:
253
+ return "관망 (신호 대기)"
254
+ if st.get("sold"):
255
+ return (f"과거 매도신호 유지중 (지금 팔라는 신호 아님) · "
256
+ f"지금 이격 {st['div']*100:.1f}%")
257
+ nm = {"없음": "관망", "flagON": "flag ON (대기)", "1차": "1차 (진행중)",
258
+ "2차flagON": "2차 flag ON (전량매도 임박!)",
259
+ "2차매도": "관망 (매도신호 있었으나 필터로 제외됨)"}.get(st["stage_name"], st["stage_name"])
260
+ return f"{nm} · 지금 이격 {st['div']*100:.1f}%"
261
 
262
 
263
+ def run_monitor(text):
264
+ raw = [c.strip() for c in text.replace(",", " ").replace("\n", " ").split()]
265
+ raw = [c for c in raw if c]
266
+ if not raw:
267
+ return "종목코드 또는 종목명을 입력해줘 (예: 277810 레인보우로보틱스)"
 
268
 
269
+ head = (f"**전량매도 최후보루** · 최근 {ALERT_DAYS}거래일 기준\n\n"
270
+ f"기준: 이격 {BT.DIV_THRESHOLD*100:.0f}% 단계누적({BT.MIN_DEEPEN*100:.1f}%p+) "
271
+ f"+ 하루급변 {BT.DAILY_JUMP*100:.0f}%p 점프. "
272
+ f"필터: 240터치·240없음·헛매도 제외\n\n---\n")
273
+ out = [head]
274
+ alerts = []
275
+ asof = None
276
+ for _q in raw:
277
+ code, msg = resolve_to_code(_q)
278
+ if code is None:
279
+ out.append(f"- {msg}")
280
+ continue
281
+ res = check_one(code)
282
+ lbl = _label(code) + (f" {msg}" if msg else "")
283
+ if not res["ok"]:
284
+ out.append(f"- {lbl} ({res['market']}) — {res['msg']}")
285
+ continue
286
+ asof = res.get("last_date")
287
+ st = res.get("state") or {}
288
+ steps = st.get("steps") or []
289
+ trig = res.get("trigger")
290
+ skp = res.get("skipped")
291
 
292
+ m240 = res.get("ma240_now")
293
+ m240s = (f"240선 {m240:,.0f} ({(res['price_now']/m240-1)*100:+.1f}%)"
294
+ if m240 else "240선 없음")
 
 
 
295
 
296
+ if res["recent"]:
297
+ sl = res["recent"][-1]
298
+ alerts.append(_label(code))
299
+ ago = st.get("last_i", 0) - sl["i"]
300
+ verdict = (f"### 🔴 [★지금 매도] {_label(code)} ({res['market']})\n"
301
+ f"- **전량매도 신호: {sl['date']} @ {sl['price']:,.0f}** "
302
+ f"(신호시점 이격 {sl['div']*100:.0f}%) · {ago}거래일 전\n")
303
+ elif trig and st.get("sold"):
304
+ ago = st.get("last_i", 0) - trig["i"]
305
+ verdict = (f"### 🟡 [과거 신호] {_label(code)} ({res['market']})\n"
306
+ f"- **※ 지금 팔라는 신호 아님**\n"
307
+ f"- 전량매도 신호: {trig['date']} @ {trig['price']:,.0f} "
308
+ f"(그때 이격 {trig['div']*100:.0f}%) · {ago}거래일 전\n"
309
+ f"- 이격 {BT.DIV_THRESHOLD*100:.0f}% 위로 회복하면 리셋\n")
310
+ elif skp:
311
+ sl = skp["sell"]
312
+ ago = st.get("last_i", 0) - sl["i"]
313
+ verdict = (f"### 🟢 [보유] {_label(code)} ({res['market']})\n"
314
+ f"- 무시된 신호: {sl['date']} @ {sl['price']:,.0f} "
315
+ f"(그때 이격 {sl['div']*100:.0f}%) · {ago}거래일 전\n"
316
+ f"- 무시한 이유 — {skp['reason']}\n")
317
+ else:
318
+ verdict = (f"### 🟢 [보유] {_label(code)} ({res['market']})\n"
319
+ f"- 매도 신호 없음. 보유 유지.\n"
320
+ f"- 현재 단계: {_status_line(res)}\n")
321
+ out.append(verdict)
322
 
323
+ out.append(f"**[지금]** {res['last_date']} 종가 {res['price_now']:,.0f} · {m240s}\n")
324
+ if "peak_price" in st:
325
+ out.append(
326
+ f"- 고점 {st['peak_price']:,.0f} ({st['peak_date']})\n"
327
+ f"- 지금 이격 **{st['div']*100:.1f}%** "
328
+ f"= 종목 고점대비 {st['stock_from_peak']*100:.1f}% "
329
+ f"− 지수 같은기간 {st['index_from_peak']*100:+.1f}%\n")
330
+
331
+ if steps:
332
+ out.append(f"**[단계 {len(steps)}개]**\n")
333
+ for stp in steps:
334
+ mark = " ←★" if "전량매도" in stp["name"] else ""
335
+ out.append(f"- `{stp['name']}` {stp['date']} @ {stp['price']:,.0f} "
336
+ f"(이격 {stp['div']*100:.1f}%){mark}\n")
337
+ out.append("\n")
338
+
339
+ out.append("\n---\n")
340
+ if asof:
341
+ out.append(f"*상태 기준: {asof_label(asof)}*\n")
342
+ if alerts:
343
+ out.append(f"**오늘 확인 필요: {len(alerts)}종목 → {', '.join(alerts)}**")
344
+ else:
345
+ out.append("**최근 매도신호 없음. 전부 보유 유지.**")
346
+ return "\n".join(out)
347
+
348
+
349
+ # ====================================================================
350
+ # 탭: 삼성·ETF 매도 (RS) — final_sell_monitor_rs.analyze_one 호출
351
+ # ====================================================================
352
+ def run_rs(text):
353
+ raw = [c.strip() for c in text.replace(",", " ").replace("\n", " ").split()]
354
+ raw = [c for c in raw if c]
355
+ if not raw:
356
+ return "종목코드 또는 종목명을 입력해줘 (예: 005930 삼성전자)"
357
+ out = []
358
+ for _q in raw:
359
+ code, msg = resolve_to_code(_q)
360
+ if code is None:
361
+ out.append(msg)
362
+ continue
363
+ market = market_of(code)
364
+ try:
365
+ res = final_sell_monitor_rs.analyze_one(
366
+ {"ticker": code, "market": market, "name": name_of(code)})
367
+ except Exception as e:
368
+ res = f"[오류] {_label(code)} — {e}"
369
+ out.append(res)
370
+ out.append("")
371
+ return "\n".join(out)
372
+
373
+
374
+ # ====================================================================
375
+ # UI (탭 순서: 최후보루 → 볼린저 → 삼성·ETF(RS) → VCP)
376
+ # ====================================================================
377
  with gr.Blocks(title="주식 디버거") as demo:
378
+ gr.Markdown("# 📊 주식 디버거")
379
 
 
380
  with gr.Tab("전량매도 최후보루"):
381
+ gr.Markdown("보유종목 **코드 또는 종목명**을 넣으면 매도신호/현재단계를 알려줘. (시장 자동판별, 여러 종목 가능)")
382
+ m_in = gr.Textbox(label="종목코드 또는 종목명 (여러 개는 공백/줄바꿈/쉼표로)",
383
+ value="277810 레인보우로보틱스", lines=3)
384
+ m_btn = gr.Button("매도신호 확인", variant="primary")
385
+ m_out = gr.Markdown()
386
+ m_btn.click(run_monitor, inputs=m_in, outputs=m_out)
 
387
 
388
  with gr.Tab("볼린저 카운트"):
389
+ gr.Markdown("볼린저밴상단 돌파 카운팅 (최근 1년 자동)")
390
+ b_in = gr.Textbox(label="종목코드 또는 종목명", value="420770")
391
+ b_btn = gr.Button("분석", variant="primary")
392
+ b_out = gr.Code(label="결과")
393
+ b_btn.click(run_boll, inputs=b_in, outputs=b_out)
 
 
 
 
 
 
 
 
394
 
395
+ with gr.Tab("삼성·ETF 매도 (RS)"):
396
+ gr.Markdown("삼성전자·ETF처럼 이격으로 안 잡히는 종목 RS 매도신호. (위아래 매수 구간이면 억제됨)")
397
+ rs_in = gr.Textbox(label="종목코드 또는 종목명 (여러 개는 공백/줄바꿈/쉼표로)",
398
+ value="005930 삼성전자", lines=3)
399
+ rs_btn = gr.Button("RS 매도신호 확인", variant="primary")
400
+ rs_out = gr.Code(label="결과")
401
+ rs_btn.click(run_rs, inputs=rs_in, outputs=rs_out)
402
 
403
+ with gr.Tab("VCP 돌파"):
404
+ gr.Markdown("미너비니 VCP 돌파 감지 (최근 1년 자동)")
405
+ v_in = gr.Textbox(label="종목코드 또는 종목명", value="222080")
406
+ v_btn = gr.Button("분석", variant="primary")
407
+ v_out = gr.Code(label="결과")
408
+ v_btn.click(run_vcp, inputs=v_in, outputs=v_out)
409
 
410
  if __name__ == "__main__":
411
+ demo.launch()
 
 
 
 
final_sell_monitor_rs.py CHANGED
@@ -197,4 +197,4 @@ def analyze_one(h):
197
  if __name__ == '__main__':
198
  tk = sys.argv[1] if len(sys.argv) > 1 else '005930'
199
  mkt = sys.argv[2] if len(sys.argv) > 2 else 'KOSPI'
200
- print(analyze_one({'ticker': tk, 'market': mkt, 'name': ''}))
 
197
  if __name__ == '__main__':
198
  tk = sys.argv[1] if len(sys.argv) > 1 else '005930'
199
  mkt = sys.argv[2] if len(sys.argv) > 2 else 'KOSPI'
200
+ print(analyze_one({'ticker': tk, 'market': mkt, 'name': ''}))