auto: 일일 백업 2026-09-11 02:00
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -862,6 +862,40 @@ def _load_interests() -> list[dict]:
|
||||
return sorted(data.values(), key=lambda e: e.get('saved_at', ''), reverse=True)
|
||||
|
||||
|
||||
_NXT_ENABLE_TTL = 300.0
|
||||
_nxt_enable_cache: dict = {'ts': 0.0, 'map': {}}
|
||||
|
||||
|
||||
def _nxt_enable_map() -> dict:
|
||||
"""종목코드 → NXT 거래가능 여부(bool). `state/stock_codes.json` 캐시 읽기라 키움 콜 0.
|
||||
|
||||
⚠️ `kc.lookup_stock_meta` 는 호출마다 954KB JSON 을 다시 읽고 3500종목을 순회하므로
|
||||
100종목에 부르면 렌더가 멈춘다 — 코드 키 역인덱스를 만들어 5분 캐시한다.
|
||||
⚠️ 캐시에 없는 종목(신규 상장 등)은 여기서 빠지고, 호출측이 보수적으로 True 취급한다
|
||||
(조회를 한 번 더 하는 쪽이 값이 틀리는 것보다 낫다).
|
||||
"""
|
||||
now = time.time()
|
||||
if now - _nxt_enable_cache['ts'] <= _NXT_ENABLE_TTL and _nxt_enable_cache['map']:
|
||||
return _nxt_enable_cache['map']
|
||||
try:
|
||||
import kiwoom_client as kc
|
||||
m = {info['code']: bool(info.get('nxt_enable'))
|
||||
for info in kc._load_code_cache().values() if info.get('code')}
|
||||
except Exception:
|
||||
m = {}
|
||||
if m:
|
||||
_nxt_enable_cache['map'] = m
|
||||
_nxt_enable_cache['ts'] = now
|
||||
return m
|
||||
|
||||
|
||||
def _is_nxt_traded(code: str) -> bool:
|
||||
"""NXT 에서 거래되는 종목인가. 모르면 True(보수적) — 아래 KRX 보정 조회를 한 번 더 한다."""
|
||||
if not code:
|
||||
return True
|
||||
return _nxt_enable_map().get(code, True)
|
||||
|
||||
|
||||
def _fetch_quotes_batch(entries: list[dict]) -> list[dict]:
|
||||
"""ka10095(관심종목정보) 한 콜로 워치리스트 시세 일괄 조회. 19종목 ~0.05s.
|
||||
캐시 히트인 entry는 캐시 사용, 미스인 entry만 batch에 포함.
|
||||
@@ -901,9 +935,8 @@ def _fetch_quotes_batch(entries: list[dict]) -> list[dict]:
|
||||
out['high'] = snap.get('high')
|
||||
out['low'] = snap.get('low')
|
||||
out['volume'] = snap.get('volume')
|
||||
out['_nxt_volume'] = snap.get('_nxt_volume', 0)
|
||||
out['_krx_volume'] = snap.get('_krx_volume', 0)
|
||||
out['_krx_price'] = snap.get('_krx_price', 0)
|
||||
out['_nxt_price'] = snap.get('_nxt_price', 0)
|
||||
continue
|
||||
if code:
|
||||
miss_codes.append(code)
|
||||
@@ -915,8 +948,10 @@ def _fetch_quotes_batch(entries: list[dict]) -> list[dict]:
|
||||
if not miss_codes:
|
||||
return cards
|
||||
|
||||
nxt_probe_codes = list(miss_codes)
|
||||
nxt_probe_indices = list(miss_indices)
|
||||
# KRX 보정 조회 대상 — NXT 에서도 거래되는 종목만. NXT 미거래 종목은 AL 이 곧 KRX 값이라
|
||||
# 조회해봐야 같은 값이 온다(실측 101종목 예외 0건). 종목이 늘어도 이 규칙은 그대로 성립한다.
|
||||
krx_probe_codes = [c for c in miss_codes if _is_nxt_traded(c)]
|
||||
krx_probe_indices = [i for i, c in zip(miss_indices, miss_codes) if _is_nxt_traded(c)]
|
||||
|
||||
# WS 허브 연결 시 — 미스 종목 시세를 허브 메모리(0B 실시간, KRX+NXT 통합)에서 우선 충당.
|
||||
# 서버가 동일 렌더러로 OHLC를 그린다. 단, NXT 거래가능 초록점은 허브 exchange가 아니라
|
||||
@@ -943,7 +978,7 @@ def _fetch_quotes_batch(entries: list[dict]) -> list[dict]:
|
||||
out['low'] = q.get('low') or None
|
||||
out['volume'] = q.get('volume')
|
||||
out['_krx_price'] = price
|
||||
out['_nxt_price'] = 0
|
||||
out['_krx_volume'] = q.get('volume') or 0
|
||||
miss_codes, miss_indices = rest_codes, rest_indices
|
||||
|
||||
batch: dict[str, dict] = {}
|
||||
@@ -955,20 +990,20 @@ def _fetch_quotes_batch(entries: list[dict]) -> list[dict]:
|
||||
for idx in miss_indices:
|
||||
cards[idx] = _fetch_quote(entries[idx])
|
||||
|
||||
# NXT 비교용 batch — 마크(KRX/NXT) 판정에 사용. 실패해도 fallback X (마크만 KRX로 처리).
|
||||
# get_watchlist_quotes는 NXT 단독 suffix를 'NX'로 받는다. 'NXT'는 suffix 없이 KRX/기본값으로
|
||||
# 떨어져 NXT 미거래 종목도 거래 가능처럼 보이므로 쓰면 안 된다.
|
||||
nxt_batch: dict[str, dict] = {}
|
||||
# KRX 단독 batch — RVOL 이 KRX 기준(일봉 ka10081 이 KRX 거래량이라 분모와 맞춰야 한다)이라
|
||||
# 필요하다. 예전엔 AL−NXT 로 역산했는데 두 조회의 시점차만큼 오차가 났다(실측 삼성전자 14주).
|
||||
# 실패해도 fallback X — 아래에서 AL 값으로 떨어진다.
|
||||
krx_batch: dict[str, dict] = {}
|
||||
try:
|
||||
nxt_batch = kc.get_watchlist_quotes(nxt_probe_codes, exchange='NX')
|
||||
krx_batch = kc.get_watchlist_quotes(krx_probe_codes, exchange='KRX')
|
||||
except Exception as ex:
|
||||
sys.stderr.write(f'ka10095 NXT batch 실패 (마크 판정용): {ex}\n')
|
||||
sys.stderr.write(f'ka10095 KRX batch 실패 (RVOL 보정용): {ex}\n')
|
||||
|
||||
for idx, code in zip(nxt_probe_indices, nxt_probe_codes):
|
||||
nxt_q = nxt_batch.get(code) if nxt_batch else None
|
||||
cards[idx]['_nxt_price'] = (nxt_q or {}).get('price', 0) if nxt_q else 0
|
||||
# NXT 거래량 — RVOL은 KRX 기준(일봉 평균과 동일)이라 KRX=AL−NXT 계산에 사용.
|
||||
cards[idx]['_nxt_volume'] = (nxt_q or {}).get('volume', 0) if nxt_q else 0
|
||||
for idx, code in zip(krx_probe_indices, krx_probe_codes):
|
||||
krx_q = krx_batch.get(code) if krx_batch else None
|
||||
if krx_q:
|
||||
cards[idx]['_krx_price'] = krx_q.get('price', 0)
|
||||
cards[idx]['_krx_volume'] = krx_q.get('volume', 0)
|
||||
|
||||
for idx, code in zip(miss_indices, miss_codes):
|
||||
q = batch.get(code)
|
||||
@@ -987,10 +1022,12 @@ def _fetch_quotes_batch(entries: list[dict]) -> list[dict]:
|
||||
out['volume'] = q.get('volume')
|
||||
else:
|
||||
out['quote_error'] = '가격 0'
|
||||
# 마크 판정: NXT batch 가격 vs 기본 batch 가격 비교.
|
||||
nxt_price = out.get('_nxt_price', 0) or 0
|
||||
out['_krx_price'] = price
|
||||
out['_nxt_price'] = nxt_price
|
||||
# ⚠️ KRX 값은 위 krx_batch 루프가 이미 채웠을 수 있다 — 덮어쓰지 말 것.
|
||||
# 비어 있는 종목(NXT 미거래 또는 KRX 조회 실패)은 AL 이 곧 KRX 값이다.
|
||||
if not out.get('_krx_price'):
|
||||
out['_krx_price'] = price
|
||||
if not out.get('_krx_volume'):
|
||||
out['_krx_volume'] = out.get('volume') or 0
|
||||
snap = {
|
||||
'price': out['price'],
|
||||
'day_change': out['day_change'],
|
||||
@@ -1000,9 +1037,8 @@ def _fetch_quotes_batch(entries: list[dict]) -> list[dict]:
|
||||
'high': out['high'],
|
||||
'low': out['low'],
|
||||
'volume': out.get('volume'),
|
||||
'_nxt_volume': out.get('_nxt_volume', 0),
|
||||
'_krx_price': price,
|
||||
'_nxt_price': nxt_price,
|
||||
'_krx_volume': out.get('_krx_volume', 0),
|
||||
'_krx_price': out.get('_krx_price', 0),
|
||||
}
|
||||
with _quote_cache_lock:
|
||||
_quote_cache[code] = (snap, time.time() + QUOTE_CACHE_TTL)
|
||||
@@ -1209,6 +1245,14 @@ def _build_owner_data(positions_by_acc: dict, balances: dict, journal_by_label:
|
||||
# 부풀려지는 문제를 해결한다. 합계 KPI는 동일 NXT 스냅샷의 prev_eval과 비교하므로
|
||||
# 개별 합 ≈ 합계로 자연스럽게 일치한다.
|
||||
prev_holdings = (prev_snap_all or {}).get(owner, {}).get('holdings') or {}
|
||||
# ⚠️ rebase 는 '당일 등락' 계산용 baseline 을 만드는 함수라, 어제 보유가 없는 종목
|
||||
# (= 오늘 신규 매수)엔 매수 평단을 넣는다. 그건 손익 관점에선 맞지만 전일종가가 아니다.
|
||||
# 미니표 '전일종가' 칸이 그 값을 쓰면 평단이 전일종가로 표시되므로(실측 우리기술
|
||||
# 평단 13,780 이 전일종가 자리에 노출), 진짜 어제 종가만 따로 보존해 둔다.
|
||||
for _r in consolidated:
|
||||
_pp = (prev_holdings.get(_r.get('stock')) or {}).get('price', 0) or 0
|
||||
if _pp > 0:
|
||||
_r['pred_close_snap'] = _pp
|
||||
rebase_to_nxt_close(consolidated, prev_holdings)
|
||||
|
||||
total_cost = sum(r['buy_amount'] for r in consolidated)
|
||||
@@ -1399,39 +1443,33 @@ def _fetch_all_data(entries: list[dict], only_owner: str | None = None) -> dict:
|
||||
except Exception as e:
|
||||
sys.stderr.write(f'positions fetch failed: {e}\n')
|
||||
positions_by_acc = {}
|
||||
# 가격 보정 — KRX와 NXT 각각 ka10095 batch 호출.
|
||||
# kiwoom_client.get_watchlist_quotes 의 exchange: 'KRX'=KRX 단독 / 'NX'=NXT 단독 / 'AL'=통합.
|
||||
# ('NXT' 값은 함수에서 인식 안 되고 KRX 기본으로 떨어짐 — 사용 X)
|
||||
# phase별 우선 시장:
|
||||
# - regular(09:00~15:30): KRX 라이브 가격
|
||||
# - nxt(08:00~09:00, 15:30~20:00): NXT 라이브 가격
|
||||
# - 그 외(closed/holiday/weekend): kt00018 NXT 응답 그대로 유지
|
||||
# 가격 보정 — KRX·AL(통합) 2콜. exchange: 'KRX'=KRX 단독 / 'AL'=KRX+NXT 통합.
|
||||
# ⚠️ NXT 단독('NX') 조회는 2026-09-10 제거 — 통합(AL)이 NXT 시간대엔 NXT 최신 체결과
|
||||
# 같은 값을 주고(실측 가격 4값 일치), NXT 거래 여부는 종목 목록(nxt_enable)으로 안다.
|
||||
# 현재가 보정은 시간대와 무관하게 AL — AL 이 곧 '지금 활성 시장의 최신 체결'이라
|
||||
# 예전의 phase별 분기(regular=KRX / nxt=NXT)가 필요 없다.
|
||||
# 장외(closed/holiday/weekend)엔 보정하지 않고 kt00018 응답을 그대로 둔다.
|
||||
held_codes_set: set[str] = set()
|
||||
for _items in positions_by_acc.values():
|
||||
for _p in _items:
|
||||
if _p.get('code'):
|
||||
held_codes_set.add(_p['code'])
|
||||
ohlc_map_krx: dict[str, dict] = {}
|
||||
ohlc_map_nxt: dict[str, dict] = {}
|
||||
ohlc_map_al: dict[str, dict] = {}
|
||||
# KRX/NX 2콜은 가격 보정뿐 아니라 보유종목 행의 KRX·NXT 듀얼 OHLC 미니(r['ohlc_krx']·['ohlc_nxt'])
|
||||
# 데이터원이다. _AL 허브는 거래소별 단일 최신값만 들어 두 컬럼을 동시에 못 주므로 skip 불가 — WS 연결과 무관하게 호출.
|
||||
# AL(통합) 1콜은 요약 행 단일 캔들(r['ohlc']) 전용 — 시가는 NXT 프리마켓 시가, 고저는 두 시장 극값.
|
||||
# KRX 1콜 = 보유행 듀얼 OHLC 미니의 KRX 열(r['ohlc_krx']), AL 1콜 = 통합 열(r['ohlc_al'])
|
||||
# + 요약 행 단일 캔들(r['ohlc']) + 현재가 보정. 허브(_AL 구독)는 거래소별 단일 최신값만
|
||||
# 들어 두 열을 동시에 못 주므로 skip 불가 — WS 연결과 무관하게 호출.
|
||||
if held_codes_set:
|
||||
try:
|
||||
ohlc_map_krx = kc.get_watchlist_quotes(list(held_codes_set), exchange='KRX')
|
||||
except Exception as e:
|
||||
sys.stderr.write(f'KRX positions price fetch failed: {e}\n')
|
||||
try:
|
||||
ohlc_map_nxt = kc.get_watchlist_quotes(list(held_codes_set), exchange='NX')
|
||||
except Exception as e:
|
||||
sys.stderr.write(f'NXT positions price fetch failed: {e}\n')
|
||||
try:
|
||||
ohlc_map_al = kc.get_watchlist_quotes(list(held_codes_set), exchange='AL')
|
||||
except Exception as e:
|
||||
sys.stderr.write(f'AL positions price fetch failed: {e}\n')
|
||||
_phase = _market_phase_state().get('phase')
|
||||
active_map = ohlc_map_nxt if _phase == 'nxt' else (ohlc_map_krx if _phase == 'regular' else None)
|
||||
active_map = ohlc_map_al if _phase in ('regular', 'nxt') else None
|
||||
if active_map:
|
||||
for _items in positions_by_acc.values():
|
||||
for _p in _items:
|
||||
@@ -1563,6 +1601,9 @@ def _fetch_all_data(entries: list[dict], only_owner: str | None = None) -> dict:
|
||||
bucket['orders'].append(ord_entry)
|
||||
g['orders'].append(ord_entry)
|
||||
|
||||
# 대기 중 예약 트레일링 — 키움 미체결에 안 잡히므로 상태파일에서 따로 읽어 같이 주입한다.
|
||||
arms_by_code = _arms_by_code()
|
||||
|
||||
holdings_map = _holdings_map_from_positions(positions_by_acc)
|
||||
for c in cards:
|
||||
_annotate_card(c, holdings_map)
|
||||
@@ -1572,6 +1613,9 @@ def _fetch_all_data(entries: list[dict], only_owner: str | None = None) -> dict:
|
||||
c['pending_buy_qty'] = p['buy_qty']
|
||||
c['pending_sell_qty'] = p['sell_qty']
|
||||
c['pending_orders'] = p['orders']
|
||||
a = arms_by_code.get(c.get('code') or '')
|
||||
if a:
|
||||
c['arm_pending'] = a
|
||||
|
||||
prev_key = None
|
||||
prev_snap_all: dict = {}
|
||||
@@ -1629,7 +1673,11 @@ def _fetch_all_data(entries: list[dict], only_owner: str | None = None) -> dict:
|
||||
balances.get(lb, {}).get('pending_buy_locked', 0) for lb in od.get('labels', [])
|
||||
)
|
||||
|
||||
# consolidated/phantom 행에 미체결 수량 주입 (owner 단위)
|
||||
# consolidated/phantom 행에 미체결 수량 + 대기 예약 주입 (owner 단위)
|
||||
def _owner_arms(owner_name: str, code: str) -> list:
|
||||
return [a for a in arms_by_code.get(code or '', [])
|
||||
if derive_owner(a.get('account', '')) == owner_name]
|
||||
|
||||
for owner_name, od in owner_data.items():
|
||||
owner_pending = pending_by_owner.get(owner_name, {})
|
||||
for r in od.get('consolidated', []):
|
||||
@@ -1638,12 +1686,18 @@ def _fetch_all_data(entries: list[dict], only_owner: str | None = None) -> dict:
|
||||
r['pending_buy_qty'] = p['buy_qty']
|
||||
r['pending_sell_qty'] = p['sell_qty']
|
||||
r['pending_orders'] = p['orders']
|
||||
a = _owner_arms(owner_name, r.get('code') or '')
|
||||
if a:
|
||||
r['arm_pending'] = a
|
||||
for r in od.get('phantom', []) if isinstance(od.get('phantom'), list) else []:
|
||||
p = owner_pending.get(r.get('code') or '')
|
||||
if p:
|
||||
r['pending_buy_qty'] = p['buy_qty']
|
||||
r['pending_sell_qty'] = p['sell_qty']
|
||||
r['pending_orders'] = p['orders']
|
||||
a = _owner_arms(owner_name, r.get('code') or '')
|
||||
if a:
|
||||
r['arm_pending'] = a
|
||||
|
||||
# 미체결 매수 주문 중 owner 미보유 종목 → owner 패널 별도 섹션 노출.
|
||||
# consolidated에 들어가지 않은 코드만 추림 (보유 + 당일정산 모두 제외).
|
||||
@@ -1683,43 +1737,35 @@ def _fetch_all_data(entries: list[dict], only_owner: str | None = None) -> dict:
|
||||
r['open'] = q.get('open') # 미리보기 등락은 시가 대비 → open 필요
|
||||
|
||||
# 보유 종목 OHLC batch — 카드 펼치면 보이는 오늘 단일 캔들 + 자세히 보기 mini-table용.
|
||||
# ohlc_map_krx (exchange='KRX')와 ohlc_map_nxt (exchange='NX'=NXT 단독)는 positions 보정에서 이미 호출 — 재사용.
|
||||
# 각 컬럼은 그 시장 데이터로 정확히 채움 (서로 섞이지 않도록). primary 단일 캔들은 통합(AL) 기준.
|
||||
if ohlc_map_krx or ohlc_map_nxt or ohlc_map_al:
|
||||
_phase = _market_phase_state().get('phase')
|
||||
# ohlc_map_krx(KRX 단독)·ohlc_map_al(통합)은 positions 보정에서 이미 호출 — 재사용.
|
||||
# 미니표 두 열은 'KRX 단독' vs '하루 전체 통합'이다. primary 단일 캔들도 통합(AL) 기준.
|
||||
if ohlc_map_krx or ohlc_map_al:
|
||||
for od in owner_data.values():
|
||||
for r in od.get('consolidated', []):
|
||||
code = r.get('code')
|
||||
qk = ohlc_map_krx.get(code) if code else None
|
||||
qn = ohlc_map_nxt.get(code) if code else None
|
||||
qa = ohlc_map_al.get(code) if code else None
|
||||
# price 만 있어도 valid — 거래 없는 시간대(시가/고가/저가=0)도 종가만 표시되도록.
|
||||
qk_valid = bool(qk and qk.get('price'))
|
||||
qn_valid = bool(qn and qn.get('price'))
|
||||
qa_valid = bool(qa and qa.get('price'))
|
||||
if qk_valid:
|
||||
r['ohlc_krx'] = {
|
||||
'open': qk['open'], 'high': qk['high'], 'low': qk['low'],
|
||||
'price': qk.get('price', 0), 'change_pct': qk.get('change_pct', 0.0),
|
||||
'change': qk.get('change', 0), 'volume': qk.get('volume', 0),
|
||||
}
|
||||
if qn_valid:
|
||||
r['ohlc_nxt'] = {
|
||||
'open': qn['open'], 'high': qn['high'], 'low': qn['low'],
|
||||
'price': qn.get('price', 0), 'change_pct': qn.get('change_pct', 0.0),
|
||||
'change': qn.get('change', 0), 'volume': qn.get('volume', 0),
|
||||
if qa_valid:
|
||||
r['ohlc_al'] = {
|
||||
'open': qa['open'], 'high': qa['high'], 'low': qa['low'],
|
||||
'price': qa.get('price', 0), 'change_pct': qa.get('change_pct', 0.0),
|
||||
'change': qa.get('change', 0), 'volume': qa.get('volume', 0),
|
||||
}
|
||||
# 단일 캔들 primary — 4값(open/high/low/price) 모두 있는 응답만 후보. 거래 없는 시간대 0값 응답 배제.
|
||||
# AL(통합) 우선 — 시간대에 따라 KRX↔NXT 로 캔들이 뒤바뀌지 않고 하루 한 개로 유지된다.
|
||||
# NXT 미거래 종목은 AL == KRX 라 결과가 같다. AL 응답이 불완전할 때만 phase 우선순위로 폴백.
|
||||
qa = ohlc_map_al.get(code) if code else None
|
||||
# 통합(AL) 우선 — 시간대에 따라 캔들이 뒤바뀌지 않고 하루 한 개로 유지된다.
|
||||
# NXT 미거래 종목은 AL == KRX 라 결과가 같다. AL 이 불완전할 때만 KRX 로 폴백.
|
||||
qk_full = qk_valid and qk.get('open') and qk.get('high') and qk.get('low')
|
||||
qn_full = qn_valid and qn.get('open') and qn.get('high') and qn.get('low')
|
||||
qa_full = bool(qa and qa.get('price') and qa.get('open') and qa.get('high') and qa.get('low'))
|
||||
if qa_full:
|
||||
primary = qa
|
||||
elif _phase == 'regular':
|
||||
primary = qk if qk_full else (qn if qn_full else None)
|
||||
else:
|
||||
primary = qn if qn_full else (qk if qk_full else None)
|
||||
qa_full = qa_valid and qa.get('open') and qa.get('high') and qa.get('low')
|
||||
primary = qa if qa_full else (qk if qk_full else None)
|
||||
if primary:
|
||||
r['ohlc'] = {'o': primary['open'], 'h': primary['high'], 'l': primary['low'], 'c': primary.get('price', 0) or r.get('price', 0)}
|
||||
|
||||
@@ -2902,9 +2948,9 @@ def _render_row(c: dict, source: str = 'watchlist') -> str:
|
||||
else:
|
||||
# 거래가능 표시 — 지금 거래시간이면 초록점, 아니면 점 없음.
|
||||
# 정규장: KRX+NXT 모두 거래 → 초록점
|
||||
# NXT 시간대: NXT 거래되는 종목(_nxt_price 살아있음)만 초록점
|
||||
# NXT 시간대: NXT 거래 종목(종목 목록의 nxt_enable)만 초록점
|
||||
# 장마감·휴장·주말: 점 없음
|
||||
nxt_p = c.get('_nxt_price', 0) or 0
|
||||
nxt_p = 1 if _is_nxt_traded(c.get('code') or '') else 0
|
||||
_phase = _market_phase_state()['phase']
|
||||
if _phase == 'regular':
|
||||
_tradable = True
|
||||
@@ -3141,7 +3187,7 @@ def _render_row(c: dict, source: str = 'watchlist') -> str:
|
||||
return f'''<details class="row {cls} mode-{mode}" data-row-key="{row_key}">
|
||||
<summary>
|
||||
<div class="left">
|
||||
<div class="line1">{dot_html}<span class="stock">{stock}</span>{tag_chip}{status_cell}{trade_mark}{alert_badge}{_vol_surge_badge(code, (max(0, (c.get('volume') or 0) - (c.get('_nxt_volume') or 0)) or None))}</div>
|
||||
<div class="line1">{dot_html}<span class="stock">{stock}</span>{tag_chip}{status_cell}{trade_mark}{alert_badge}{_vol_surge_badge(code, (c.get('_krx_volume') or c.get('volume') or None))}</div>
|
||||
</div>
|
||||
<div class="right">
|
||||
<div class="price">{price_cell}<span class="caret" aria-hidden="true">▾</span></div>
|
||||
@@ -3755,28 +3801,22 @@ def _render_account_kpi(owner: str, label: str, owner_d: dict, balances: dict, j
|
||||
</section>'''
|
||||
|
||||
|
||||
def _render_ohlc_mini_dual(krx: dict | None, nxt: dict | None, active_market: str = 'none', al_price: int = 0, krx_closed: bool = False, nxt_closed: bool = False) -> str:
|
||||
"""보유종목 자세히 보기용 OHLC mini-table — KRX/NXT 두 거래소 가격·등락률 분리 표시.
|
||||
def _render_ohlc_mini_dual(krx: dict | None, al: dict | None, active_market: str = 'none', al_price: int = 0, krx_closed: bool = False, al_closed: bool = False) -> str:
|
||||
"""보유종목 자세히 보기용 OHLC mini-table — 'KRX 단독' / '통합(KRX+NXT)' 2열.
|
||||
|
||||
각 dict: {open, high, low, price, change_pct} (price=종가/현재가, change_pct=직전 영업일 종가 대비).
|
||||
한쪽이 None이면 해당 컬럼 전체 '-' placeholder. 4행(현재가/시가/고가/저가) × 2컬럼(KRX/NXT).
|
||||
한쪽이 None이면 그 열 전체 '-' placeholder. 7행(전일종가/현재가/시가/고가/저가/당일종가/거래량).
|
||||
|
||||
active_market: 'krx' / 'nxt' / 'none'. 활성 시장 컬럼은 기본 색, 비활성은 회색 처리.
|
||||
active_market: 'krx' / 'al' / 'none'. 활성 열은 기본 색, 비활성은 회색 처리.
|
||||
⚠️ 2026-09-10 이전엔 두 번째 열이 NXT 단독이었다. NXT 조회를 없애면서 통합으로 바꿨다 —
|
||||
통합 시가는 NXT 프리마켓 첫 체결이라 KRX 시가와 다른 값이고, 그대로 보여준다.
|
||||
"""
|
||||
# price 만 있어도 컬럼 표시 — 거래 없는 시간대도 종가 노출. open/high/low 0 셀은 _cell 안에서 '-' 처리.
|
||||
# price 만 있어도 열 표시 — 거래 없는 시간대도 종가 노출. open/high/low 0 셀은 _cell 안에서 '-' 처리.
|
||||
has_krx = bool(krx and krx.get('price'))
|
||||
has_nxt = bool(nxt and nxt.get('price'))
|
||||
if not (has_krx or has_nxt):
|
||||
has_al = bool(al and al.get('price'))
|
||||
if not (has_krx or has_al):
|
||||
return ''
|
||||
|
||||
# NXT 시가 baseline 은 호출처에서 어제 NXT 스냅샷 종가(r['pred_close'])로 미리 박아 전달.
|
||||
# 자동 baseline 계산(price - change)은 호출처가 못 박은 경우만 fallback.
|
||||
if has_nxt and nxt and not nxt.get('open'):
|
||||
nxt_baseline = (nxt.get('price', 0) or 0) - (nxt.get('change', 0) or 0)
|
||||
if nxt_baseline > 0:
|
||||
nxt = dict(nxt)
|
||||
nxt['open'] = nxt_baseline
|
||||
|
||||
def _cell(d: dict | None, key: str, dim: bool, closed: bool = False) -> str:
|
||||
dim_cls = ' ohlc-dim' if dim else ''
|
||||
if not d:
|
||||
@@ -3823,18 +3863,9 @@ def _render_ohlc_mini_dual(krx: dict | None, nxt: dict | None, active_market: st
|
||||
f'<span class="ohlc-pct {cls}">{sgn}{pct:.2f}%</span></span>'
|
||||
)
|
||||
|
||||
krx_dim = active_market not in ('krx', 'none-active-krx') # 'krx' 활성 또는 양쪽 비활성-krx강조 시만 밝게
|
||||
nxt_dim = active_market != 'nxt'
|
||||
# 'none' (양쪽 비활성, 휴장/심야) — 둘 다 dim
|
||||
if active_market == 'none':
|
||||
krx_dim = True
|
||||
nxt_dim = True
|
||||
elif active_market == 'krx':
|
||||
krx_dim = False
|
||||
nxt_dim = True
|
||||
elif active_market == 'nxt':
|
||||
krx_dim = True
|
||||
nxt_dim = False
|
||||
# 'none' (장외·휴장) — 둘 다 dim / 'al' (장중) — 통합 열만 밝게
|
||||
krx_dim = active_market != 'krx'
|
||||
al_dim = active_market != 'al'
|
||||
|
||||
rows = [
|
||||
('전일종가', 'prev_close'),
|
||||
@@ -3846,17 +3877,17 @@ def _render_ohlc_mini_dual(krx: dict | None, nxt: dict | None, active_market: st
|
||||
('거래량', 'volume'),
|
||||
]
|
||||
krx_th_dim = ' ohlc-dim' if krx_dim else ''
|
||||
nxt_th_dim = ' ohlc-dim' if nxt_dim else ''
|
||||
al_th_dim = ' ohlc-dim' if al_dim else ''
|
||||
parts = [
|
||||
'<div class="ohlc-mini ohlc-dual">',
|
||||
'<span class="ohlc-th"></span>',
|
||||
f'<span class="ohlc-th{krx_th_dim}">KRX</span>',
|
||||
f'<span class="ohlc-th{nxt_th_dim}">NXT</span>',
|
||||
f'<span class="ohlc-th{al_th_dim}">통합</span>',
|
||||
]
|
||||
for lbl, key in rows:
|
||||
parts.append(f'<span class="ohlc-rl">{lbl}</span>')
|
||||
parts.append(_cell(krx if has_krx else None, key, krx_dim, krx_closed))
|
||||
parts.append(_cell(nxt if has_nxt else None, key, nxt_dim, nxt_closed))
|
||||
parts.append(_cell(al if has_al else None, key, al_dim, al_closed))
|
||||
parts.append('</div>')
|
||||
return ''.join(parts)
|
||||
|
||||
@@ -3944,6 +3975,7 @@ def _interleave_kv_pairs(left: list[str], right: list[str]) -> str:
|
||||
|
||||
def _pending_badges_html(r: dict) -> str:
|
||||
"""미리보기 💰 배지 — 색만으로 매수(빨강)/매도(파랑) 구분.
|
||||
대기 중 예약 트레일링은 키움 주문이 아니라서 ⏳ 로 따로 표시한다.
|
||||
수량·상세 정보는 자세히보기(`_pending_detail_html`)에서.
|
||||
"""
|
||||
buy = r.get('pending_buy_qty', 0)
|
||||
@@ -3957,6 +3989,12 @@ def _pending_badges_html(r: dict) -> str:
|
||||
parts.append(
|
||||
f'<span class="badge pending-sell" title="키움 미체결 매도 {sell:,}주 — 자세히보기에서 확인">💰</span>'
|
||||
)
|
||||
arms = r.get('arm_pending') or []
|
||||
if arms:
|
||||
aq = sum(a.get('qty', 0) for a in arms)
|
||||
parts.append(
|
||||
f'<span class="badge pending-arm" title="예약 트레일링 대기 {aq:,}주 — 자세히보기에서 확인">⏳</span>'
|
||||
)
|
||||
return ''.join(parts)
|
||||
|
||||
|
||||
@@ -4013,6 +4051,35 @@ def _arm_rows() -> list:
|
||||
return out
|
||||
|
||||
|
||||
def _arms_by_code() -> dict:
|
||||
"""종목코드 → 대기 중 예약 트레일링 요약 리스트. 상태파일 읽기라 키움 콜 0.
|
||||
|
||||
⚠️ 예약 트레일링은 키움에 주문이 없어 ka10075 미체결에 안 잡힌다 — 종목 행 배지·상세의
|
||||
유일한 근거가 이 맵이다(미체결 기반 `pending_*` 만 보면 화면이 통째로 조용해진다).
|
||||
owner 필터는 주입 측이 `account` 로 한다.
|
||||
"""
|
||||
out: dict[str, list] = {}
|
||||
try:
|
||||
from orders import trailing as _trl
|
||||
arms = _trl.list_arms()
|
||||
except Exception:
|
||||
return out
|
||||
for a in arms:
|
||||
try:
|
||||
expired = _trl.is_arm_expired(a)
|
||||
except Exception:
|
||||
expired = False
|
||||
out.setdefault(a.get('symbol') or '', []).append({
|
||||
'id': a.get('id', ''), 'account': a.get('account', ''), 'qty': a.get('qty', 0),
|
||||
'trigger_price': a.get('trigger_price', 0),
|
||||
'min_sell_price': a.get('min_sell_price'),
|
||||
'steps': a.get('steps') or [],
|
||||
'firing_at': a.get('firing_at'), 'created_at': a.get('created_at'),
|
||||
'expired': expired,
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def _trailing_info_for(ord_no: str):
|
||||
"""미체결 주문번호 → 트레일링 예약 요약 (없으면 None).
|
||||
|
||||
@@ -4084,7 +4151,8 @@ def _pending_detail_html(r: dict) -> str:
|
||||
레이아웃: dt(라벨, 컬러) / dd 2~3줄(상단=수량@가격, 조건줄, 하단=계좌·시각·주문번호 muted).
|
||||
"""
|
||||
orders = r.get('pending_orders') or []
|
||||
if not orders:
|
||||
arms = r.get('arm_pending') or []
|
||||
if not orders and not arms:
|
||||
return ''
|
||||
buys = [o for o in orders if o.get('side') == 'BUY']
|
||||
sells = [o for o in orders if o.get('side') == 'SELL']
|
||||
@@ -4187,11 +4255,69 @@ def _pending_detail_html(r: dict) -> str:
|
||||
f'</dd>'
|
||||
)
|
||||
|
||||
def _arm_row(a: dict) -> str:
|
||||
"""대기 중 예약 트레일링 — 키움 주문이 없어 주문번호·지정가가 없다.
|
||||
미체결 행과 같은 골격(주요 줄 / 조건 줄 / meta)으로 맞춰 나란히 읽히게 한다."""
|
||||
qty = a.get('qty', 0)
|
||||
trig = a.get('trigger_price', 0) or 0
|
||||
steps = a.get('steps') or []
|
||||
if a.get('firing_at'):
|
||||
label = '예약 발동 중단'
|
||||
elif a.get('expired'):
|
||||
label = '예약 만료'
|
||||
else:
|
||||
label = '예약 트레일링'
|
||||
|
||||
if a.get('firing_at'):
|
||||
# 발동 도중 중단된 상태 — 주문이 나갔는지 모르므로 자동 재발동하지 않는다.
|
||||
cond_line = ('<div class="small pending-line-cond">발동 처리 중단 — 미체결 확인 후 '
|
||||
'진행중 팝업에서 수동 취소</div>')
|
||||
elif a.get('expired'):
|
||||
# 감시는 08:00~19:59 에만 돌아 20:00 직후엔 파일에 남아 있다. 숨기면 화면이 거짓말.
|
||||
cond_line = ('<div class="small pending-line-cond">만료 — 다음 거래일 첫 감시 사이클에 정리'
|
||||
'</div>')
|
||||
else:
|
||||
cond_line = (f'<div class="small pending-line-cond trailing">'
|
||||
f'{trig:,}원 닿으면 트레일링 시작</div>')
|
||||
|
||||
mini = [f'<dt>발동가</dt><dd><b>{trig:,}원</b> 도달 시</dd>']
|
||||
if cur_price > 0 and trig > 0:
|
||||
# 발동가까지 남은 거리 — 미체결 트레일링의 '손절선 대비'와 같은 자리·같은 읽는 법.
|
||||
mini.append(f'<dt>발동가까지</dt><dd>{(trig - cur_price) / cur_price * 100:+.1f}% '
|
||||
f'<span class="muted">(현재 {cur_price:,}원)</span></dd>')
|
||||
if steps:
|
||||
desc = ' · '.join(
|
||||
f'−{x.get("pct"):g}%→'
|
||||
+ ('전량' if (x.get('cum') or 0) >= 100 else f'{x.get("cum") or 0:g}%')
|
||||
for x in steps)
|
||||
mini.append(f'<dt>계단</dt><dd><b>{len(steps)}단</b> · {desc}</dd>')
|
||||
if a.get('min_sell_price'):
|
||||
mini.append(f'<dt>최저 매도가</dt><dd>{a["min_sell_price"]:,}원</dd>')
|
||||
cond_line += f'<dl class="trail-mini">{"".join(mini)}</dl>'
|
||||
|
||||
meta_parts = [html.escape(a.get('account', ''))]
|
||||
ct = (a.get('created_at') or '')[5:16].replace('T', ' ')
|
||||
if ct:
|
||||
meta_parts.append(ct)
|
||||
if a.get('id'):
|
||||
meta_parts.append(html.escape(a['id']))
|
||||
|
||||
return (
|
||||
f'<dt class="pending-arm-label">{label}</dt>'
|
||||
f'<dd>'
|
||||
f'<div class="num pending-line-main">{qty:,}주 매도 예약</div>'
|
||||
f'{cond_line}'
|
||||
f'<div class="muted small pending-line-meta">{" · ".join(meta_parts)}</div>'
|
||||
f'</dd>'
|
||||
)
|
||||
|
||||
pairs: list[str] = []
|
||||
for o in buys:
|
||||
pairs.append(_row(o, 'buy'))
|
||||
for o in sells:
|
||||
pairs.append(_row(o, 'sell'))
|
||||
for a in arms:
|
||||
pairs.append(_arm_row(a))
|
||||
return f'<dl class="pending-detail">{"".join(pairs)}</dl>'
|
||||
|
||||
|
||||
@@ -4306,27 +4432,24 @@ def _render_holding_row(r: dict, total_value: int, show_day_change: bool = False
|
||||
f'<dt>당일 등락</dt><dd class="num"><span class="{dcls} rt-daychange">{dsign}{day_change:,}원 '
|
||||
f'({dsign}{day_change_pct:.2f}%)</span></dd>'
|
||||
)
|
||||
# 활성 시장 판정 — OHLC 미니테이블 컬럼 강조용(active_market). cur_price 가 어느 시장 가격과 일치하는지.
|
||||
nxt_p = (r.get('ohlc_nxt') or {}).get('price', 0)
|
||||
krx_p = (r.get('ohlc_krx') or {}).get('price', 0)
|
||||
mark = 'NXT' if (nxt_p and nxt_p == price and nxt_p != krx_p) else 'KRX'
|
||||
# 거래가능 표시 — 지금 거래시간이면 초록점, 아니면 점 없음. (미리보기 행과 동일 규칙)
|
||||
# 정규장: KRX+NXT 모두 거래 → 초록점 / NXT 시간대: NXT 거래종목(ohlc_nxt 가격 살아있음)만 / 장외·휴장·주말: 점 없음
|
||||
# 활성 열 판정 — OHLC 미니테이블 강조용. 두 열은 'KRX 단독' vs '하루 전체 통합'이라
|
||||
# 장중이면 항상 통합 열이 최신(현재가와 같은 값)이고, KRX 열은 15:30 이후 마감값으로 고정된다.
|
||||
_phase = _market_phase_state()['phase']
|
||||
mark = 'AL' if _phase in ('regular', 'nxt') else 'NONE'
|
||||
# 거래가능 표시 — 지금 거래시간이면 초록점, 아니면 점 없음. (미리보기 행과 동일 규칙)
|
||||
# 정규장: KRX+NXT 모두 거래 → 초록점 / NXT 시간대: NXT 거래 종목(종목 목록의 nxt_enable)만 / 장외·휴장·주말: 점 없음
|
||||
if _phase == 'regular':
|
||||
_tradable = True
|
||||
elif _phase == 'nxt':
|
||||
_tradable = bool(nxt_p)
|
||||
_tradable = _is_nxt_traded(r.get('code') or '')
|
||||
else:
|
||||
_tradable = False
|
||||
mark_html = '<span class="px-dot tradable" title="지금 거래 가능"></span>' if _tradable else ''
|
||||
|
||||
# OHLC mini-table — ka10095 NX/NXT batch 결과를 각 컬럼에 분리 표시. 활성 시장만 밝게.
|
||||
# _nxt_inactive 마커는 stock_portfolio_report의 NX 응답 기반 판정이라 ETF처럼 NXT 정상거래 종목도
|
||||
# 잘못 잡힘 → 여기선 무시하고 실제 ohlc_nxt 데이터 유무로 표시 결정.
|
||||
# NXT 컬럼 baseline 을 어제 NXT 스냅샷 종가(r['pred_close']) 로 통일.
|
||||
# change/change_pct 도 스냅샷 baseline 기준으로 재계산 → cell 안 pct 표시도 일관.
|
||||
# 전 거래일 종가 행: KRX = pred_close_krx (KRX 전일종가), NXT = r['pred_close'] (어제 NXT 스냅샷).
|
||||
# OHLC mini-table — ka10095 KRX/AL batch 결과를 각 열에 표시. 활성 열만 밝게.
|
||||
# 두 열의 전일종가 기준이 다르다: KRX 열 = KRX 전일종가, 통합 열 = 어제 NXT 스냅샷 종가
|
||||
# (r['pred_close']). 통합 열 baseline 을 스냅샷으로 두는 이유 — 보유행 day_change 가 같은
|
||||
# 값을 baseline 으로 쓰므로, 여기만 AL 자체 전일종가를 쓰면 한 화면에서 등락률이 갈린다.
|
||||
ohlc_krx_for_render = r.get('ohlc_krx')
|
||||
if ohlc_krx_for_render:
|
||||
ohlc_krx_for_render = dict(ohlc_krx_for_render)
|
||||
@@ -4337,26 +4460,36 @@ def _render_holding_row(r: dict, total_value: int, show_day_change: bool = False
|
||||
_krx_prev = r.get('pred_close_krx', 0) or 0
|
||||
if _krx_prev > 0:
|
||||
ohlc_krx_for_render['prev_close'] = _krx_prev
|
||||
ohlc_nxt_for_render = r.get('ohlc_nxt')
|
||||
_snap_prev = r.get('pred_close', 0) or 0
|
||||
if ohlc_nxt_for_render and _snap_prev > 0:
|
||||
ohlc_nxt_for_render = dict(ohlc_nxt_for_render)
|
||||
ohlc_nxt_for_render['open'] = _snap_prev
|
||||
ohlc_nxt_for_render['prev_close'] = _snap_prev
|
||||
_nxt_px = ohlc_nxt_for_render.get('price', 0) or 0
|
||||
ohlc_nxt_for_render['change'] = _nxt_px - _snap_prev
|
||||
ohlc_nxt_for_render['change_pct'] = ((_nxt_px - _snap_prev) / _snap_prev * 100) if _snap_prev else 0.0
|
||||
# 당일종가: KRX는 정규장 마감(15:30) 후, NXT는 마감(20:00) 후에만 그 시장 종가 표시.
|
||||
ohlc_al_for_render = r.get('ohlc_al')
|
||||
# 통합 열 전일종가: ①어제 보유였으면 그날 NXT 종가(스냅샷) ②아니면 ka10095 AL 의
|
||||
# (현재가 − 전일대비). ⚠️ r['pred_close'] 를 쓰면 안 된다 — 신규 매수 종목엔 매수 평단이
|
||||
# 들어 있어 '전일종가' 라벨이 거짓말이 된다(2026-09-10 우리기술로 실측).
|
||||
_snap_prev = r.get('pred_close_snap', 0) or 0
|
||||
if not _snap_prev:
|
||||
_al_raw = r.get('ohlc_al') or {}
|
||||
_snap_prev = (_al_raw.get('price', 0) or 0) - (_al_raw.get('change', 0) or 0)
|
||||
if _snap_prev < 0:
|
||||
_snap_prev = 0
|
||||
if ohlc_al_for_render and _snap_prev > 0:
|
||||
ohlc_al_for_render = dict(ohlc_al_for_render)
|
||||
# ⚠️ 시가는 덮어쓰지 않는다 — 통합 시가는 NXT 프리마켓(08:00) 첫 체결이라 그 자체가 정보다
|
||||
# (실측 삼성전자 통합 268,000 vs KRX 269,000). 예전 NXT 열은 시가 칸에 전일종가를
|
||||
# 넣었는데, 통합 열에서는 진짜 시가를 보여준다.
|
||||
ohlc_al_for_render['prev_close'] = _snap_prev
|
||||
_al_px = ohlc_al_for_render.get('price', 0) or 0
|
||||
ohlc_al_for_render['change'] = _al_px - _snap_prev
|
||||
ohlc_al_for_render['change_pct'] = ((_al_px - _snap_prev) / _snap_prev * 100) if _snap_prev else 0.0
|
||||
# 당일종가: KRX는 정규장 마감(15:30) 후, 통합은 NXT 마감(20:00) 후에만 종가 표시.
|
||||
_now_ohlc = datetime.now(KST)
|
||||
_hm_ohlc = _now_ohlc.hour * 60 + _now_ohlc.minute
|
||||
_trd_ohlc = _market_phase_state().get('phase') not in ('holiday', 'weekend')
|
||||
ohlc_mini_html = _render_ohlc_mini_dual(
|
||||
ohlc_krx_for_render,
|
||||
ohlc_nxt_for_render,
|
||||
ohlc_al_for_render,
|
||||
active_market=mark.lower(),
|
||||
al_price=price, # 현재가 행은 AL(통합) 현재가를 양 컬럼 공통 — 등락률만 각 시장 전일종가 대비.
|
||||
al_price=price, # 현재가 행은 통합 현재가를 양 열 공통 — 등락률만 각 기준 전일종가 대비.
|
||||
krx_closed=_trd_ohlc and _hm_ohlc >= 15 * 60 + 30,
|
||||
nxt_closed=_trd_ohlc and _hm_ohlc >= 20 * 60,
|
||||
al_closed=_trd_ohlc and _hm_ohlc >= 20 * 60,
|
||||
)
|
||||
|
||||
# 미리보기 등락은 rebase 후 day_change_pct 사용 — 어제 NXT 종가 baseline.
|
||||
@@ -5847,11 +5980,13 @@ details.row + .section-label { margin-top: 14px; padding-top: 24px; border-top-c
|
||||
.badge.held { background: #2a1820; color: #ff8a96; border: 1px solid rgba(255,77,94,0.3); font-size: 10px; padding: 2px 7px; }
|
||||
.badge.pending-buy { background: #2a1822; color: #ff9fb3; border: 1px solid rgba(255,140,170,0.32); font-weight: 600; padding: 2px 6px; letter-spacing: 0; }
|
||||
.badge.pending-sell { background: #182030; color: #7fb8ff; border: 1px solid rgba(122,180,255,0.32); font-weight: 600; padding: 2px 6px; letter-spacing: 0; }
|
||||
.badge.pending-arm { background: #262013; color: #d9a84a; border: 1px solid rgba(217,168,74,0.35); font-weight: 600; padding: 2px 6px; letter-spacing: 0; }
|
||||
dl.pending-detail { margin-top: 8px; padding-top: 8px; border-top: 1px dashed rgba(255,255,255,0.08); grid-template-columns: 92px minmax(0, 1fr); }
|
||||
.row.mode-held .detail dl.pending-detail { grid-template-columns: 92px minmax(0, 1fr); }
|
||||
dl.pending-detail dt { font-weight: 600; letter-spacing: 0; white-space: nowrap; }
|
||||
dl.pending-detail dt.pending-buy-label { color: #ff9fb3; }
|
||||
dl.pending-detail dt.pending-sell-label { color: #7fb8ff; }
|
||||
dl.pending-detail dt.pending-arm-label { color: #d9a84a; }
|
||||
dl.pending-detail dd { line-height: 1.45; }
|
||||
dl.pending-detail .pending-line-main { font-variant-numeric: tabular-nums; }
|
||||
dl.pending-detail .pending-line-meta { margin-top: 2px; }
|
||||
@@ -13947,7 +14082,10 @@ window.openPinModal = openPinModal;
|
||||
.then(function(r){ return r.json(); })
|
||||
.then(function(d){
|
||||
setWsStatus(d && d.connected);
|
||||
if(!d || !d.connected) return;
|
||||
// 끊기면 마지막 스냅샷을 버린다. 남겨두면 connected:true 인 옛 응답이 그대로 남아
|
||||
// panels-loaded 훅의 재적용이 서버가 방금 조회한 값을 옛 틱으로 덮어쓴다
|
||||
// (20:00 NXT 마감 후 허브 종료 → 재렌더를 해도 화면이 19:5x 값에 고착되던 원인).
|
||||
if(!d || !d.connected){ window.__rtState=null; window.__rtPx=null; return; }
|
||||
window.__rtState = d;
|
||||
if(codes.length) applyState(d);
|
||||
// 체결 신호 변화 → 패널 새로고침(수량·예수금·당일정산 등 사건 기반 값). 첫 관측은 기준값만 저장(공회전 방지).
|
||||
|
||||
Reference in New Issue
Block a user