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; }