This commit is contained in:
dsyoon
2025-08-06 23:18:56 +09:00
parent 7d5a8eafcb
commit 7135bf71f0
3 changed files with 213 additions and 3 deletions

View File

@@ -167,10 +167,45 @@ def run_simulation(symbol: str, interval_minutes: int, days: int = 30):
print(f"\n총 매수 신호 수: {len(alerts)}")
# 서브플롯 생성
fig, ax1 = plt.subplots(figsize=(15, 8))
# 서브플롯 생성 (가격 + Deviation)
fig, (ax1, ax2) = plt.subplots(2, 1, sharex=True, figsize=(15, 8), height_ratios=[3, 1])
fig.suptitle(f"{symbol} - 시뮬레이션 {interval_minutes}분봉", fontsize=14)
# ----------------- 마우스 휠 확대/축소 -----------------
def on_scroll(event):
# event.button: 'up' -> zoom in, 'down' -> zoom out
if event.inaxes not in [ax1, ax2]:
return
ax = event.inaxes
# x 축만 두 축을 동시에 조정
cur_xlim = ax1.get_xlim()
xdata = event.xdata
if xdata is None:
return
scale_factor = 0.9 if event.button == 'up' else 1/0.9
new_width = (cur_xlim[1] - cur_xlim[0]) * scale_factor
relx = (cur_xlim[1] - xdata) / (cur_xlim[1] - cur_xlim[0])
new_left = xdata - new_width * (1 - relx)
new_right = xdata + new_width * relx
# 데이터 영역 벗어나지 않도록 클램프
xmin, xmax = matplotlib.dates.date2num(data.index[0]), matplotlib.dates.date2num(data.index[-1])
if new_left < xmin:
new_left = xmin
if new_right > xmax:
new_right = xmax
ax1.set_xlim([new_left, new_right])
ax2.set_xlim([new_left, new_right])
# y축은 해당 축만 줌
cur_ylim = ax.get_ylim()
ydata = event.ydata
if ydata is not None:
new_height = (cur_ylim[1] - cur_ylim[0]) * scale_factor
rely = (cur_ylim[1] - ydata) / (cur_ylim[1] - cur_ylim[0])
ax.set_ylim([ydata - new_height * (1 - rely), ydata + new_height * rely])
ax.figure.canvas.draw_idle()
fig.canvas.mpl_connect('scroll_event', on_scroll)
# 메인 차트 (가격, 이동평균선, 볼린저 밴드)
line_close = ax1.plot(data.index, data["Close"], label="종가", color="black", linewidth=1.5)[0]
line_ma5 = ax1.plot(data.index, data["MA5"], label="MA5", color="red", linewidth=1)[0]
@@ -232,10 +267,124 @@ def run_simulation(symbol: str, interval_minutes: int, days: int = 30):
cursor3.connect("remove", lambda sel: sel.annotation.set_visible(False))
ax1.set_ylabel("가격")
ax1.legend(loc='upper left', fontsize=10)
# --- 범례 생성 및 인터랙티브 토글 ---
legend = ax1.legend(loc='upper left', fontsize=10)
ax1.grid(True, linestyle='--', alpha=0.5)
# 범례 클릭 시 해당 선 토글 기능
lined = {}
# legend 핸들과 실제 plot 선을 매핑 (생성 순서가 동일하다고 가정)
# Matplotlib 버전에 따라 legend 객체의 핸들 보유 프로퍼티가 다를 수 있음
if hasattr(legend, "legend_handles"):
legend_handles = legend.legend_handles
elif hasattr(legend, "legendHandles"):
legend_handles = legend.legendHandles
else:
# 마지막 방어(일반적으로 선만 리턴)
legend_handles = legend.get_lines()
plot_lines = [line_close, line_ma5, line_ma20, line_ma40, line_ma120,
line_ma200, line_ma240, line_ma720, line_ma1440,
line_upper, line_lower, scatter_buy_points]
# 매수신호 scatter가 있으면 포함
if scatter_buy is not None:
plot_lines.append(scatter_buy)
# zip 길이가 짧은 쪽에 맞춰 매핑
for leg_handle, orig in zip(legend_handles, plot_lines):
leg_handle.set_picker(True) # 클릭 이벤트 활성화
lined[leg_handle] = orig
def on_pick(event):
leg_handle = event.artist
orig = lined.get(leg_handle)
if orig is None:
return
vis = not orig.get_visible()
orig.set_visible(vis)
# 범례 아이콘 투명도 조정
leg_handle.set_alpha(1.0 if vis else 0.2)
fig.canvas.draw_idle()
fig.canvas.mpl_connect('pick_event', on_pick)
# Deviation subplot
line_dev20 = ax2.plot(data.index, data['Deviation20'], color='orange', label='Dev20(C/MA20×100)')[0]
line_dev40 = ax2.plot(data.index, data['Deviation40'], color='blue', label='Dev40(C/MA40×100)')[0]
cursor_dev = mplcursors.cursor([line_dev20, line_dev40], hover=True)
cursor_dev.connect("add", lambda sel: sel.annotation.set_text(
f"{sel.artist.get_label()}\n날짜: {matplotlib.dates.num2date(sel.target[0]).replace(tzinfo=None).strftime('%Y-%m-%d %H:%M')}\n값: {sel.target[1]:.2f}"
))
line_h98 = ax2.axhline(90, color='red', linestyle='--', linewidth=1, label='90')
line_h97 = ax2.axhline(95, color='green', linestyle='--', linewidth=1, label='93')
ax2.set_ylabel('Deviation %')
legend2 = ax2.legend(loc='upper left', fontsize=9)
# Deviation subplot 범례 클릭 토글 기능
if legend2 is not None:
if hasattr(legend2, "legend_handles"):
legend2_handles = legend2.legend_handles
elif hasattr(legend2, "legendHandles"):
legend2_handles = legend2.legendHandles
else:
legend2_handles = legend2.get_lines()
plot_lines2 = [line_dev20, line_dev40, line_h98, line_h97]
# 레이블 기준으로 안정적 매핑
for leg_handle in legend2_handles:
label = leg_handle.get_label()
target_line = next((pl for pl in plot_lines2 if pl.get_label() == label), None)
if target_line is not None:
leg_handle.set_picker(True)
lined[leg_handle] = target_line
ax2.grid(True, linestyle='--', alpha=0.3)
plt.tight_layout()
# -------- 확대/축소 및 이동 기능 --------
press = {}
def on_scroll(event):
ax = event.inaxes
if ax is None:
return
x_left, x_right = ax.get_xlim()
x_range = (x_right - x_left)
if event.button == 'up': # zoom in
scale = 0.8
elif event.button == 'down': # zoom out
scale = 1.25
else:
scale = 1.0
new_range = x_range * scale
center = event.xdata if event.xdata is not None else (x_left + x_right) / 2
ax.set_xlim(center - new_range / 2, center + new_range / 2)
# 다른 축들도 동일 적용 (shared x)
for other_ax in fig.axes:
if other_ax is not ax:
other_ax.set_xlim(ax.get_xlim())
fig.canvas.draw_idle()
def on_press(event):
if event.button == 1 and event.inaxes is not None:
press['xpress'] = event.xdata
press['axes'] = event.inaxes
def on_motion(event):
if 'xpress' not in press or press.get('axes') is None or event.inaxes is None:
return
dx = press['xpress'] - event.xdata
for ax in fig.axes:
x_left, x_right = ax.get_xlim()
ax.set_xlim(x_left + dx, x_right + dx)
fig.canvas.draw_idle()
def on_release(event):
press.clear()
fig.canvas.mpl_connect('scroll_event', on_scroll)
fig.canvas.mpl_connect('button_press_event', on_press)
fig.canvas.mpl_connect('motion_notify_event', on_motion)
fig.canvas.mpl_connect('button_release_event', on_release)
plt.show()