auto: 일일 백업 2026-07-01 02:00

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
hyowons
2026-07-01 02:00:02 +09:00
parent 1bbb61520c
commit 565689df25
166 changed files with 3662 additions and 845 deletions
+96 -10
View File
@@ -74,6 +74,7 @@ sys.path.insert(0, str(WORKSPACE / 'scripts'))
_holidays_cache: dict = {'mtime': 0.0, 'set': set()}
_traded_codes_cache: dict = {'mtime': 0.0, 'set': set()}
_holding_since_cache: dict = {'mtime': None, 'map': {}}
_quote_cache: dict[str, tuple[dict, float]] = {}
_quote_cache_lock = threading.Lock()
@@ -443,6 +444,55 @@ def _load_traded_codes() -> set[str]:
return _traded_codes_cache.get('set', set())
def _holding_since_map() -> dict:
"""trade_journal.jsonl 재구성 → {code: 'YYYYMMDD'} 현재 연속 보유 시작일.
보유종목 '매수후 고점대비' 낙폭 계산용. mtime 기반 lazy reload.
계좌·owner 무관 code 단위 순매수 누적 — 보유수량이 0으로 떨어지면 streak 리셋,
재진입 시 마지막 streak 시작일. (드물게 본인·가희 동시보유 시 더 이른 날로 근사)."""
if not TRADE_JOURNAL_FILE.exists():
return {}
try:
mtime = TRADE_JOURNAL_FILE.stat().st_mtime
except OSError:
return _holding_since_cache.get('map', {})
if mtime != _holding_since_cache.get('mtime'):
try:
events: dict[str, list[tuple[str, int]]] = {}
for ln in TRADE_JOURNAL_FILE.read_text().splitlines():
ln = ln.strip()
if not ln:
continue
try:
d = json.loads(ln)
except json.JSONDecodeError:
continue
c = d.get('code')
if not c:
continue
day = (d.get('date') or '').replace('-', '')
net = int(d.get('buy_qty') or 0) - int(d.get('sell_qty') or 0)
events.setdefault(c, []).append((day, net))
out: dict[str, str] = {}
for c, evs in events.items():
evs.sort(key=lambda x: x[0])
run = 0
start = None
for day, net in evs:
if run <= 0 and net > 0:
start = day
run += net
if run <= 0:
run = 0
start = None
if run > 0 and start:
out[c] = start
_holding_since_cache['map'] = out
_holding_since_cache['mtime'] = mtime
except Exception as e:
sys.stderr.write(f'holding_since load failed: {e}\n')
return _holding_since_cache.get('map', {})
_US_EASTERN = ZoneInfo('America/New_York')
@@ -2150,6 +2200,13 @@ def _render_row(c: dict, source: str = 'watchlist') -> str:
f'<button type="button" class="btn-order" data-order-code="{html.escape(raw_code, quote=True)}" data-order-stock="{stock_attr}" data-order-side="{order_side}" title="매수/매도">💰 거래</button>'
) if raw_code else ''
# +태그 버튼 — held 행과 동일하게 펼침 상세 종목명 줄 오른쪽에 둔다 (actions 아님).
tag_add_btn = _tag_add_button_html(c.get('code') or '', c.get('stock') or '')
detail_name_html = ''
if not is_pending:
code_span = f'<span class="detail-code">{code}</span>' if code else ''
detail_name_html = f'<div class="detail-name">{stock}{code_span}{tag_add_btn}</div>'
if source == 'interests':
def _num_attr(field, sub):
if not isinstance(field, dict):
@@ -2175,12 +2232,10 @@ def _render_row(c: dict, source: str = 'watchlist') -> str:
f' data-edit-stop="{html.escape(stop_attr_v, quote=True)}"'
f' data-edit-memo="{html.escape(memo_val, quote=True)}"'
)
tag_add_btn = _tag_add_button_html(c.get('code') or '', c.get('stock') or '')
note_btn = _note_button_html(c.get('code') or '', c.get('stock') or '')
info_btn = _info_button_html(c.get('code') or '', c.get('stock') or '')
actions_html = (
'<div class="actions">'
f'{tag_add_btn}'
f'{note_btn}'
f'{analyze_btn}'
f'{info_btn}'
@@ -2199,12 +2254,10 @@ def _render_row(c: dict, source: str = 'watchlist') -> str:
)
cls = f'{cls} trash'
else:
wl_tag_add_btn = _tag_add_button_html(c.get('code') or '', c.get('stock') or '')
wl_note_btn = _note_button_html(c.get('code') or '', c.get('stock') or '')
wl_info_btn = _info_button_html(c.get('code') or '', c.get('stock') or '')
actions_html = (
'<div class="actions">'
f'{wl_tag_add_btn}'
f'{wl_note_btn}'
f'{analyze_btn}'
f'{wl_info_btn}'
@@ -2229,6 +2282,7 @@ def _render_row(c: dict, source: str = 'watchlist') -> str:
</div>
</summary>
<div class="detail">
{detail_name_html}
{'<div class="alert-notice">최초 알림 완료 — 리셋 전까지 매수 알림 없음</div>' if unheld_done else ''}
<div class="grid2">
{held_row}
@@ -2878,6 +2932,38 @@ def _render_holding_row(r: dict, total_value: int, show_day_change: bool = False
]
dl_inner = _interleave_kv_pairs(left_pairs, right_pairs)
pl_pairs.extend(journal_lines)
# 매수후 고점대비 — 보유 시작일 이후 일봉 고가 최댓값 대비 현재가 낙폭(%·평가액).
# 매수/매도(journal_lines) 아래에 표시. 순수 캐시 읽기(_select_latest)만 — 렌더 중 키움 호출 방지.
since = _holding_since_map().get(r.get('code') or '')
peak_high = 0
if since and code:
try:
import daily_candles_cache as dcc
for cd in dcc._select_latest(code, 250):
d = str(cd.get('date') or '')
h = cd.get('high') or 0
if d >= since and h > peak_high:
peak_high = h
except Exception:
peak_high = 0
oh_today = r.get('ohlc') or {}
if oh_today.get('h'):
peak_high = max(peak_high, oh_today['h'])
if price:
peak_high = max(peak_high, price)
if peak_high > 0 and price > 0:
dd_pct = (price - peak_high) / peak_high * 100
dd_won = (price - peak_high) * qty
ddcls = _profit_class(dd_won)
pl_pairs.append('<div class="pl-divider"></div>')
pl_pairs.append(
f'<dt>매수후 고점</dt><dd class="num">{peak_high:,}원 '
f'<span class="{ddcls}">({dd_pct:.2f}%)</span></dd>'
)
pl_pairs.append(
f'<dt>고점대비</dt><dd class="num"><span class="muted small">{dd_won:,}원</span></dd>'
)
pl_pairs.append('<div class="pl-divider"></div>')
pl_dl_inner = ''.join(pl_pairs)
row_key = (code or stock) + key_suffix
@@ -2885,7 +2971,6 @@ def _render_holding_row(r: dict, total_value: int, show_day_change: bool = False
note_btn = _note_button_html(r.get('code') or '', r.get('stock') or '')
trade_btn = (
f'<div class="actions">'
f'{tag_add_btn}'
f'{note_btn}'
f'{_info_button_html(r.get("code") or "", r.get("stock") or "")}'
f'<button type="button" class="btn-trades" data-trade-code="{code}" data-trade-stock="{stock}">거래내역</button>'
@@ -2908,7 +2993,7 @@ def _render_holding_row(r: dict, total_value: int, show_day_change: bool = False
{candle_summary}
</summary>
<div class="detail">
<div class="detail-name">{stock}{f'<span class="detail-code">{code}</span>' if code else ''}</div>
<div class="detail-name">{stock}{f'<span class="detail-code">{code}</span>' if code else ''}{tag_add_btn}</div>
{f'<div class="detail-tag">{tag_chip}</div>' if tag_chip else ''}
<dl>{dl_inner}</dl>
<dl class="pl-summary">{pl_dl_inner}</dl>
@@ -3013,7 +3098,7 @@ def _render_pending_buy_held_row(r: dict) -> str:
else:
diff_html = ''
price_html = f'{price:,}' if price else '<span class="muted">-</span>'
price_html = f'<span class="rt-px">{price:,}</span>' if price else '<span class="muted">-</span>'
summary_line2 = f'<span class="muted small">매수 대기 {buy_qty:,}주'
if avg_buy:
@@ -3085,7 +3170,7 @@ def _render_pending_sell_held_row(r: dict) -> str:
else:
diff_html = ''
price_html = f'{price:,}' if price else '<span class="muted">-</span>'
price_html = f'<span class="rt-px">{price:,}</span>' if price else '<span class="muted">-</span>'
summary_line2 = f'<span class="muted small">매도 대기 {sell_qty:,}주'
if avg_sell:
@@ -3153,9 +3238,9 @@ def _render_pending_unheld_row(r: dict) -> str:
if isinstance(day_pct, (int, float)):
d_cls = 'day-pct up' if day_pct > 0 else ('day-pct down' if day_pct < 0 else 'day-pct neutral')
d_sign = '+' if day_pct >= 0 else ''
price_html = f'{price:,}<span class="{d_cls}">{d_sign}{day_pct:.2f}%</span>'
price_html = f'<span class="rt-px">{price:,}</span><span class="{d_cls} rt-wl-daypct">{d_sign}{day_pct:.2f}%</span>'
else:
price_html = f'{price:,}'
price_html = f'<span class="rt-px">{price:,}</span>'
if avg_buy:
diff = price - avg_buy
dpct = (diff / avg_buy * 100) if avg_buy else 0.0
@@ -4418,6 +4503,7 @@ details.row + .section-label { margin-top: 14px; padding-top: 24px; border-top-c
/* 펼친 detail의 태그는 크게 (미리보기·관심행 칩은 작게 유지). */
.detail-name { font-size: 15px; font-weight: 700; color: #f0f0f0; margin: 0 0 8px; display: flex; align-items: baseline; gap: 8px; }
.detail-name .detail-code { font-size: 11px; font-weight: 500; color: #8b8f9a; font-variant-numeric: tabular-nums; }
.detail-name .btn-add-tag { align-self: center; }
.detail-tag { margin: 0 0 8px; }
.detail-tag .tag-chip { font-size: 11px; padding: 2px 8px; border-radius: 4px; }
.btn-add-tag { display: inline-block; padding: 1px 6px; border-radius: 4px; font-size: 10px; font-weight: 500; line-height: 1.5; color: #5a5f6c; background: transparent; border: 1px dashed rgba(120,124,135,0.30); white-space: nowrap; flex: none; cursor: pointer; font-family: inherit; }