@@ -79,6 +79,7 @@ QUOTE_WORKERS = 8
QUOTE_CACHE_TTL = 30.0 # 첫 cold 8s 동안 분산 박힌 캐시가 다음 RENDER 만료(10s) 시점에 살아있도록. 가격 stale 최대 30s.
sys . path . insert ( 0 , str ( WORKSPACE / ' scripts ' ) )
sys . path . insert ( 0 , str ( WORKSPACE ) ) # sim 패키지(from sim import signals, config) import 용
_holidays_cache : dict = { ' mtime ' : 0.0 , ' set ' : set ( ) }
@@ -87,6 +88,155 @@ _holding_since_cache: dict = {'mtime': None, 'map': {}}
_quote_cache : dict [ str , tuple [ dict , float ] ] = { }
_quote_cache_lock = threading . Lock ( )
# 계좌별 미체결(ka10075) 캐시 — 거래 모달 max_qty 계산용. 미체결은 가격과 무관하므로
# 단가 keystroke마다 재조회하지 않도록 짧은 TTL로 묶는다. 주문 접수 후 프론트가 재조회하면
# 최대 TTL 만큼 stale할 수 있으나, propose/키움 서버가 최종 검증하므로 표시용으론 충분.
OPEN_ORDERS_CACHE_TTL = 15.0
_open_orders_cache : dict [ str , tuple [ list , float ] ] = { }
_open_orders_cache_lock = threading . Lock ( )
def _cached_open_orders ( account_label : str ) - > list :
""" 계좌 미체결 주문 목록 (ka10075) — TTL 캐시. 실패 시 빈 리스트. """
now = time . time ( )
with _open_orders_cache_lock :
hit = _open_orders_cache . get ( account_label )
if hit and ( now - hit [ 1 ] ) < OPEN_ORDERS_CACHE_TTL :
return hit [ 0 ]
try :
import kiwoom_client as kc
rows = kc . get_open_orders ( account_label )
except Exception :
return [ ]
with _open_orders_cache_lock :
_open_orders_cache [ account_label ] = ( rows , now )
return rows
def _pending_buy_lock ( account_label : str ) - > int :
""" 계좌의 미체결 매수 지정가로 묶인 예수금 합계. d2_entra는 이 금액을 아직 포함하므로
실제 매수가능액 = d2_entra − 이 값 . 시장가 ( price = 0 ) 미체결은 금액 미상이라 제외 ( render 경로와 동일 ) . """
total = 0
for o in _cached_open_orders ( account_label ) :
if o . get ( ' side ' ) == ' BUY ' :
px = o . get ( ' order_price ' ) or 0
if px > 0 :
total + = px * ( o . get ( ' unfilled_qty ' ) or 0 )
return total
def _pending_sell_qty ( account_label : str , code : str ) - > int :
""" 계좌·종목의 미체결 매도 수량 합계. """
total = 0
for o in _cached_open_orders ( account_label ) :
if o . get ( ' side ' ) == ' SELL ' and o . get ( ' code ' ) == code :
total + = o . get ( ' unfilled_qty ' ) or 0
return total
# 기술적 권장가(매수/매도 판단 보조) 캐시 — 완성된 일봉 기반이라 장중 안정적.
# 키: f'{code}:{side}:{avg_price}'. 현재가 대비 비교는 클라이언트가 실시간 시세로 처리.
ADVICE_CACHE_TTL = 120.0
_advice_cache : dict [ str , tuple [ dict , float ] ] = { }
_advice_cache_lock = threading . Lock ( )
def _price_advice ( code : str , side : str , avg_price : int | None = None ) - > dict | None :
""" 일봉 기반 기술적 권장 매수가/매도가. 근거는 이평·ATR만(sim.signals 규칙 재사용).
반환 : { ' levels ' : [ { label , price , hint , tap } ] , ' atr ' , ' sma20 ' , ' rsi ' , ' price ' , ' recent_low ' , ' recent_high ' }
데이터 부족 ( 신규주 ) · ETF 등으로 지표 산출 불가 시 None ( 예외 raise 안 함 ) .
⚠ ️ 상수는 활성 sim . config 사용 — params . json 튜닝이 걸려있으면 그 값이 반영됨 ( 현재는 문서화된 기본값 ) .
"""
side = ( side or ' ' ) . upper ( )
cache_key = f ' { code } : { side } : { avg_price or 0 } '
now = time . time ( )
with _advice_cache_lock :
hit = _advice_cache . get ( cache_key )
if hit and ( now - hit [ 1 ] ) < ADVICE_CACHE_TTL :
return hit [ 0 ]
try :
from sim import signals , config
import daily_candles_cache as dcc
candles = dcc . get_candles ( code , count = 250 )
ind = signals . compute_indicators ( candles ) if candles else None
except Exception :
traceback . print_exc ( )
ind = None
if not ind :
with _advice_cache_lock :
_advice_cache [ cache_key ] = ( None , now )
return None
price = ind [ ' price ' ]
atr = ind [ ' atr ' ]
sma20 = ind [ ' sma_long ' ]
prev_close = ind [ ' prev_close ' ]
recent_high = ind [ ' recent_high ' ]
recent_low = ind [ ' recent_low ' ]
rsi = ind . get ( ' rsi ' )
levels : list [ dict ] = [ ]
if side == ' BUY ' :
pullback = int ( round ( max ( sma20 , prev_close - atr * config . PULLBACK_ATR_MULT ) ) )
p_stop , p_target = signals . compute_stop_target ( pullback , atr , recent_low )
levels . append ( {
' label ' : ' 눌림목 매수가 ' , ' price ' : pullback , ' tap ' : True ,
' hint ' : f ' 20일선( { sma20 : ,.0f } )과 전일종가−ATR( { prev_close - atr * config . PULLBACK_ATR_MULT : ,.0f } ) 중 높은 값. '
f ' 여기까지 눌리면 매수 검토(지정가로 걸어둠). ' ,
} )
if recent_high :
levels . append ( {
' label ' : ' 돌파 기준가 ' , ' price ' : int ( recent_high ) , ' tap ' : True ,
' hint ' : f ' 최근 { config . BREAKOUT_LOOKBACK } 일 전고점. 이 위로 거래량 실려 뚫으면 돌파 매수 신호. ' ,
} )
levels . append ( {
' label ' : ' (눌림목 매수 시) 예상 손절 ' , ' price ' : p_stop , ' tap ' : False ,
' hint ' : f ' 매수가−ATR× { config . STOP_ATR_MULT : g } 와 최근 { config . SWING_LOW_LOOKBACK } 일 저점 중 위쪽. 여기 깨지면 손절. ' ,
} )
levels . append ( {
' label ' : ' (눌림목 매수 시) 목표가 ' , ' price ' : p_target , ' tap ' : False ,
' hint ' : f ' 손익비 { config . RR_RATIO : g } :1 — 손절폭의 { config . RR_RATIO : g } 배 위. 1차 익절 목표. ' ,
} )
else : # SELL — 보유 청산. 평단 고정은 고수익주에서 붕괴 → 지지·저항·트레일링으로.
trail = int ( round ( price - atr * config . TRAIL_ATR_MULT ) )
levels . append ( {
' label ' : ' 트레일링 손절 ' , ' price ' : trail , ' tap ' : True ,
' hint ' : f ' 현재가−ATR× { config . TRAIL_ATR_MULT : g } . 고점 대비 이만큼 되돌리면 익절/이탈(수익 보호). ' ,
} )
levels . append ( {
' label ' : ' 20일선 지지 ' , ' price ' : int ( round ( sma20 ) ) , ' tap ' : True ,
' hint ' : ' 20일 이동평균선. 추세 지지선 — 종가로 깨지면 상승추세 이탈 신호. ' ,
} )
if recent_low :
levels . append ( {
' label ' : f ' { config . SWING_LOW_LOOKBACK } 일 스윙저점 ' , ' price ' : int ( recent_low ) , ' tap ' : True ,
' hint ' : f ' 최근 { config . SWING_LOW_LOOKBACK } 일 최저가. 이 아래로 밀리면 하락 전환 경계. ' ,
} )
if recent_high :
levels . append ( {
' label ' : ' 전고점(저항/목표) ' , ' price ' : int ( recent_high ) , ' tap ' : True ,
' hint ' : f ' 최근 { config . BREAKOUT_LOOKBACK } 일 전고점. 1차 저항 — 도달 시 분할 익절 고려. ' ,
} )
# 평단 근처(±ATR)일 때만 평단 기준 손절선 추가 — 그 밖(고수익/큰손실)에선 무의미해 생략.
if avg_price and abs ( price - avg_price ) < = atr :
a_stop , _ = signals . compute_stop_target ( int ( avg_price ) , atr , recent_low )
levels . append ( {
' label ' : ' 평단 기준 손절 ' , ' price ' : a_stop , ' tap ' : True ,
' hint ' : f ' 평단( { avg_price : , } )− ATR× { config . STOP_ATR_MULT : g } 와 스윙저점 중 위쪽. 본전 근처에서 손실 방어선. ' ,
} )
result = {
' levels ' : levels , ' price ' : int ( price ) , ' atr ' : int ( round ( atr ) ) ,
' sma20 ' : int ( round ( sma20 ) ) , ' rsi ' : ( round ( rsi ) if rsi is not None else None ) ,
' recent_low ' : int ( recent_low ) if recent_low else None ,
' recent_high ' : int ( recent_high ) if recent_high else None ,
}
with _advice_cache_lock :
_advice_cache [ cache_key ] = ( result , now )
return result
INDICES_CACHE_TTL = 30.0
_indices_cache : dict = { ' data ' : None , ' expires_at ' : 0.0 }
_indices_lock = threading . Lock ( )
@@ -2559,8 +2709,8 @@ def _render_row(c: dict, source: str = 'watchlist') -> str:
trade_mark = (
f ' <button type= " button " class= " badge badge-trade btn-trades " data-trade-code= " { html . escape ( raw_code , quote = True ) } " data-trade-stock= " { stock_attr } " title= " 거래내역 보기 " >거래내역</button> '
) if has_trades and source == ' interests ' else ' '
# 매매 진입 버튼 — 보유 모드면 매도 default, 그 외는 매수 default . 모달에서 토글 가능.
order_side = ' SELL ' if c . get ( ' mode ' ) == ' held ' else ' BUY '
# 매매 진입 버튼 — 항상 매수 default (오타 방지) . 모달에서 매도로 토글 가능.
order_side = ' BUY '
order_btn = (
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 ' '
@@ -3349,7 +3499,7 @@ def _render_holding_row(r: dict, total_value: int, show_day_change: bool = False
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> '
f ' <button type= " button " class= " btn-order " data-order-code= " { code } " data-order-stock= " { html . escape ( stock , quote = True ) } " data-order-side= " SELL " title= " 매수/매도 " >💰 거래</button> '
f ' <button type= " button " class= " btn-order " data-order-code= " { code } " data-order-stock= " { html . escape ( stock , quote = True ) } " data-order-side= " BUY " title= " 매수/매도 " >💰 거래</button> '
f ' </div> '
) if code else ' '
tag_chip = _tag_chip_html ( r . get ( ' code ' ) or ' ' , r . get ( ' stock ' ) or ' ' , interactive = True )
@@ -5793,7 +5943,7 @@ table.market-adr td.adr-breakdown { font-size: 11px; color: #8b8f9a; }
}
/ * 주문 모달 ( order - modal ) — 거래내역 보기용 trade - modal과 별개 . 모달 박스 자체 스크롤 X ( 호가창만 스크롤 ) * /
. order - box { max - width : 560 px ; overflow - y : hidden ; display : flex ; flex - direction : column ; transition : border - color 0.15 s , box - shadow 0.15 s , background 0.15 s ; }
. order - box { max - width : 640 px ; max - height : calc ( 100 dvh - env ( safe - area - inset - top ) - 56 px ) ; overflow - y : hidden ; display : flex ; flex - direction : column ; transition : border - color 0.15 s , box - shadow 0.15 s , background 0.15 s ; }
. order - box > [ data - order - step = " 1 " ] { display : flex ; flex - direction : column ; flex : 1 ; min - height : 0 ; }
/ * 매수 / 매도 모드 전체 색조 — 모달 어디를 봐도 side가 한눈에 ( 매수 = 빨강 / 매도 = 파랑 ) * /
. order - box . side - buy { border - color : #c9303e; box-shadow: 0 0 0 1px #c9303e, 0 12px 44px rgba(201,48,62,0.34); background: linear-gradient(180deg, rgba(201,48,62,0.12), rgba(201,48,62,0.02) 130px, #0d1018 300px); }
@@ -5802,6 +5952,8 @@ table.market-adr td.adr-breakdown { font-size: 11px; color: #8b8f9a; }
. order - box . side - sell . modal - head { border - bottom : 2 px solid rgba ( 42 , 95 , 179 , 0.55 ) ; padding - bottom : 10 px ; }
. order - box . side - buy . orderbook { border - color : rgba ( 201 , 48 , 62 , 0.38 ) ; }
. order - box . side - sell . orderbook { border - color : rgba ( 42 , 95 , 179 , 0.38 ) ; }
/ * 주문 방향 확인 팝업 — 매수 · 매도 버튼을 직접 골라 누르게 함 ( 위치 랜덤 ) . 오타 방지 * /
#order-confirm-modal .btn-confirm { font-size: 16px; font-weight: 700; padding: 15px 14px; }
. order - symbol - select { background : #14171f; color: #f0f0f0; border: 1px solid #1f2330; border-radius: 6px; padding: 7px 9px; font-size: 14px; font-weight: 600; font-family: inherit; width: 100%; max-width: 360px; cursor: pointer; color-scheme: dark; }
. order - symbol - select : focus { outline : none ; border - color : #ff4d5e; }
. order - symbol - select option { background : #14171f; color: #f0f0f0; }
@@ -5831,8 +5983,8 @@ table.market-adr td.adr-breakdown { font-size: 11px; color: #8b8f9a; }
. orderbook : : - webkit - scrollbar - thumb { background : #2a2e3a; border-radius: 2px; }
. orderbook . ob - row { display : grid ; grid - template - columns : 1 fr 1 fr ; padding : 1 px 5 px ; font - size : 7.5 px ; line - height : 1.35 ; font - variant - numeric : tabular - nums ; cursor : pointer ; border - bottom : 1 px solid #15171e; transition: box-shadow 0.08s; color: #565c68; }
. orderbook . ob - row : last - of - type { border - bottom : 0 ; }
. orderbook . ob - row . ask { background : linear - gradient ( to left , rgba ( 201 , 48 , 62 , 0.16 ) , transparent 60 % ) ; color : #ff8a95 ; }
. orderbook . ob - row . bid { background : linear - gradient ( to left , rgba ( 42 , 95 , 179 , 0.16 ) , transparent 60 % ) ; color : #8db4ff ; }
. orderbook . ob - row . ask { background : linear - gradient ( to left , rgba ( 42 , 95 , 179 , 0.16 ) , transparent 60 % ) ; color : #8db4ff ; }
. orderbook . ob - row . bid { background : linear - gradient ( to left , rgba ( 201 , 48 , 62 , 0.16 ) , transparent 60 % ) ; color : #ff8a95 ; }
. orderbook . ob - row : hover { background : #1c1f29; }
. orderbook . ob - row . ob - current { background - color : rgba ( 230 , 230 , 230 , 0.13 ) ; color : #fff; font-weight: 700; box-shadow: inset 3px 0 0 #e6e6e6; }
. orderbook . ob - row . ob - synthetic { background : rgba ( 230 , 230 , 230 , 0.10 ) ; }
@@ -5844,7 +5996,7 @@ table.market-adr td.adr-breakdown { font-size: 11px; color: #8b8f9a; }
. orderbook . ob - row . selected { box - shadow : inset 0 0 0 1.5 px #ffcc33; color: #fff !important; font-weight: 700; background-color: rgba(255,204,51,0.18) !important; }
. orderbook . ob - row . price { text - align : left ; }
. orderbook . ob - row . qty { text - align : right ; opacity : 0.8 ; font - size : 7 px ; }
. price - tick - buttons { display : grid ; grid - template - columns : 1 fr 1 fr ; gap : 4 px ; margin - top : 4 px ; }
. price - tick - buttons { display : grid ; grid - template - columns : 1 fr 1 fr 1 fr ; gap : 4 px ; margin - top : 4 px ; }
. price - tick - buttons button { background : #14171f; color: #c9ccd3; border: 1px solid #1f2330; border-radius: 5px; padding: 5px 4px; font-size: 11px; cursor: pointer; font-family: inherit; }
. price - tick - buttons button : hover { border - color : #ff4d5e; color: #f0f0f0; }
. price - tick - buttons button : active { transform : translateY ( 1 px ) ; }
@@ -5871,6 +6023,27 @@ table.market-adr td.adr-breakdown { font-size: 11px; color: #8b8f9a; }
. order - info . pl - down { color : #8db4ff; white-space: nowrap; }
. order - info b . qty - over { color : #ff8a95; }
input [ data - order - qty ] . qty - over { color : #ff8a95; }
. price - tick - buttons . advice - open - btn { padding : 5 px 4 px ; border - radius : 5 px ; background : #1a1710; border: 1px solid #4a3c1a; color: #e6c86b; font-size: 11px; font-weight: 700; cursor: pointer; text-align: center; font-family: inherit; white-space: nowrap; }
. price - tick - buttons . advice - open - btn : hover { border - color : #e6c86b; color: #f0d98a; }
. price - tick - buttons . advice - open - btn : active { transform : translateY ( 1 px ) ; }
. advice - box { max - width : 420 px ; }
. advice - body { padding : 4 px 2 px ; }
. advice - sub { font - size : 11 px ; color : #9aa0ad; margin-bottom: 8px; line-height: 1.4; }
. advice - rows { display : flex ; flex - direction : column ; gap : 6 px ; }
. advice - row { display : flex ; align - items : center ; gap : 8 px ; width : 100 % ; padding : 9 px 11 px ; border - radius : 8 px ; background : #1a1f2c; border: 1px solid #2a3040; text-align: left; cursor: default; }
. advice - row . advice - lbl { font - size : 12 px ; color : #c9ccd3; flex: 1; }
. advice - row . advice - px { font - size : 14 px ; font - weight : 700 ; color : #e8eaf0; font-variant-numeric: tabular-nums; white-space: nowrap; }
. advice - rel { font - size : 10 px ; }
. advice - rel . up { color : #ff8a95; }
. advice - rel . dn { color : #8ab6ff; }
. advice - tapmark { font - size : 10.5 px ; font - weight : 700 ; color : #8ab6ff; white-space: nowrap; margin-left: 2px; }
. advice - row . advice - tap { cursor : pointer ; border - color : #34506e; background: #16202e; }
. advice - row . advice - tap : active { background : #1e2c3e; }
. advice - row . advice - info { opacity : 0.72 ; }
. advice - why { margin : 10 px 0 0 ; background : none ; border : none ; color : #8ab6ff; font-size: 12px; font-weight: 600; cursor: pointer; padding: 4px 2px; }
. advice - hints { margin : 6 px 0 2 px ; padding - left : 16 px ; font - size : 11.5 px ; color : #b7bcc7; line-height: 1.5; }
. advice - hints li { margin - bottom : 5 px ; }
. advice - hints b { color : #d6dae2; white-space: nowrap; }
. order - adr - banner { padding : 8 px 12 px ; margin - bottom : 12 px ; border - radius : 8 px ; font - size : 12.5 px ; font - weight : 600 ; line - height : 1.4 ; }
. order - adr - banner . adr - over { background : #2a1418; border: 1px solid #5a1f28; color: #ff8a95; }
. order - adr - banner . adr - under { background : #101b2e; border: 1px solid #1f3a5a; color: #8ab6ff; }
@@ -7167,6 +7340,26 @@ def _render_info_desc_modal() -> str:
)
def _render_advice_modal ( ) - > str :
""" 기술적 권장가 팝업. order-modal 위에 겹쳐 뜨는 modal-top. [💡 권장가 보기] 클릭 시 표시.
가격 행 탭 → 단가 자동입력 ( 호가단위 스냅 ) 후 팝업 닫힘 . ▸ 근거로 계산식 설명 토글 . """
return (
' <div id= " advice-modal " class= " modal hidden modal-top " aria-hidden= " true " role= " dialog " aria-modal= " true " aria-labelledby= " advice-modal-title " > '
' <div class= " modal-overlay " data-modal-close= " 1 " ></div> '
' <div class= " modal-box advice-box " role= " document " > '
' <div class= " modal-head " > '
' <div class= " modal-title " id= " advice-modal-title " ><span data-advice-title>💡 권장가</span></div> '
' <button type= " button " class= " modal-close " data-modal-close= " 1 " aria-label= " 닫기 " >× </button> '
' </div> '
' <div class= " advice-body " data-advice-body><div class= " muted small " >불러오는 중…</div></div> '
' <div class= " modal-actions " > '
' <button type= " button " class= " btn-cancel " data-modal-close= " 1 " >닫기</button> '
' </div> '
' </div> '
' </div> '
)
def _render_order_modal ( ) - > str :
""" 주문 진입 모달 (매수/매도). 호가창 1초 폴링, PIN OTP 흐름.
@@ -7208,12 +7401,18 @@ def _render_order_modal() -> str:
' <label class= " inline-row " >주문유형<select data-order-type> '
' <option value= " LIMIT " >지정가</option> '
' <option value= " MARKET " >시장가</option> '
' <option value= " STOP_LIMIT " >스톱지정가 (하락 시 매도)</option> '
' </select></label> '
' <label data-order-price-row>단가 '
' <label data-order-stop-row style= " display:none; " > '
' <span>조건단가 <span class= " muted small " style= " font-weight:400; " >(이 가격 도달 시 매도)</span></span> '
' <input type= " number " inputmode= " numeric " min= " 0 " step= " 1 " data-order-stop-input> '
' </label> '
' <label data-order-price-row><span data-order-price-label>단가</span> '
' <input type= " number " inputmode= " numeric " min= " 0 " step= " 1 " data-order-price-input> '
' <div class= " price-tick-buttons " > '
' <button type= " button " data-tick-dir= " -1 " >▼ 한 호가 아래 </button> '
' <button type= " button " data-tick-dir= " 1 " >▲ 한 호가 위 </button> '
' <button type= " button " data-tick-dir= " -1 " >▼ 한 호가</button> '
' <button type= " button " data-tick-dir= " 1 " >▲ 한 호가</button> '
' <button type= " button " class= " advice-open-btn " data-advice-open>💡 권장가</button> '
' </div> '
' </label> '
' <label data-order-budget-row> '
@@ -7322,6 +7521,32 @@ def _render_sell_choice_modal() -> str:
)
def _render_order_confirm_modal ( ) - > str :
""" 매수/매도 버튼 → propose 직전 방향 확인 팝업. 매수/매도 오타 방지용.
매수 · 매도 버튼을 둘 다 띄우고 관리자님이 직접 맞는 방향을 찾아 누르게 함 ( 위치 랜덤 ) .
주문 방향과 일치하면 진행 , 다르면 팝업 닫고 취소 . order - modal 위에 겹침 ( modal - top ) .
"""
return (
' <div id= " order-confirm-modal " class= " modal hidden modal-top " aria-hidden= " true " role= " dialog " aria-modal= " true " aria-labelledby= " order-confirm-modal-title " > '
' <div class= " modal-overlay " data-modal-close= " 1 " ></div> '
' <div class= " modal-box pin-box " role= " document " > '
' <div class= " modal-head " > '
' <div class= " modal-title " id= " order-confirm-modal-title " >방향 확인 — 맞는 것을 누르세요</div> '
' </div> '
' <div class= " order-modal-msg info " data-order-confirm-summary>—</div> '
' <div class= " order-actions " style= " gap:8px; " > '
' <button type= " button " class= " btn-confirm " data-order-confirm-side= " BUY " data-side= " BUY " >매수</button> '
' <button type= " button " class= " btn-confirm " data-order-confirm-side= " SELL " data-side= " SELL " >매도</button> '
' </div> '
' <div class= " order-actions " style= " margin-top:8px; " > '
' <button type= " button " class= " btn-cancel " data-modal-close= " 1 " style= " width:100 % ; " >취소</button> '
' </div> '
' </div> '
' </div> '
)
def _render_interests_modal ( ) - > str :
""" shell HTML 직속에 두는 종목 추가 모달. panels API의 swap 영역(section.tab-content) 밖이라
자동 갱신 중에도 DOM · 입력값이 보존된다 . """
@@ -9020,9 +9245,11 @@ def render_html() -> str:
trade_modal_html = _render_trade_modal ( )
info_modal_html = _render_info_modal ( )
info_desc_modal_html = _render_info_desc_modal ( )
advice_modal_html = _render_advice_modal ( )
order_modal_html = _render_order_modal ( )
pin_modal_html = _render_pin_modal ( )
sell_choice_modal_html = _render_sell_choice_modal ( )
order_confirm_modal_html = _render_order_confirm_modal ( )
open_orders_modal_html = _render_open_orders_modal ( )
stock_name_modal_html = _render_stock_name_modal ( )
@@ -10023,7 +10250,9 @@ def render_html() -> str:
var modal = document . getElementById ( ' order-modal ' ) ;
var pinModal = document . getElementById ( ' pin-modal ' ) ;
var sellChoiceModal = document . getElementById ( ' sell-choice-modal ' ) ;
var orderConfirmModal = document . getElementById ( ' order-confirm-modal ' ) ;
var openOrdersModal = document . getElementById ( ' open-orders-modal ' ) ;
var adviceModal = document . getElementById ( ' advice-modal ' ) ;
if ( ! modal ) return ;
var ACCOUNTS = [
{ label : ' 일반 ' , display : ' 본인 일반 ' , owner : ' 본인 ' } ,
@@ -10031,10 +10260,11 @@ var ACCOUNTS = [
{ label : ' 가희_일반 ' , display : ' 가희 일반 ' , owner : ' 가희 ' } ,
{ label : ' 가희_ISA ' , display : ' 가희 ISA ' , owner : ' 가희 ' }
] ;
var state = { code : ' ' , name : ' ' , side : ' BUY ' , pollTimer : null , countdownTimer : null , expiryAt : 0 , isOpen : false , lastBook : null , bookCentered : false , lastCheck : null , marketActive : true , marketPhase : null , symbolsCache : null , pendingMaxOnSell : false , pendingMaxOnBuy : false , accStatus : null , sellChoice : null } ;
var state = { code : ' ' , name : ' ' , side : ' BUY ' , pollTimer : null , countdownTimer : null , expiryAt : 0 , isOpen : false , lastBook : null , bookCentered : false , lastCheck : null , marketActive : true , marketPhase : null , symbolsCache : null , pendingMaxOnSell : false , pendingMaxOnBuy : false , accStatus : null , sellChoice : null , advice : null , adviceKey : null } ;
function $ ( sel , root ) { return ( root | | modal ) . querySelector ( sel ) ; }
function $ $ ( sel , root ) { return ( root | | modal ) . querySelectorAll ( sel ) ; }
function $ p ( sel ) { return pinModal ? pinModal . querySelector ( sel ) : null ; }
function $ a ( sel ) { return adviceModal ? adviceModal . querySelector ( sel ) : null ; }
function fmt ( n ) { if ( ! isFinite ( n ) ) return ' — ' ; return ( Math . round ( n ) | | 0 ) . toLocaleString ( ' ko-KR ' ) ; }
function highlightSelectedTick ( ) {
var inp = $ ( ' [data-order-price-input] ' ) ;
@@ -10088,7 +10318,21 @@ function setSide(side){
/ / BUY max 계산엔 price 필요 — 비어있으면 호가창 현재가로 즉시 채움
_resolveOrderPrice ( ) ;
}
refreshStopUI ( ) ; / / 스톱지정가 ( 매도 전용 ) 옵션 · 입력행 side에 맞게 갱신
updateCheck ( ) ;
fetchAdvice ( ) ; / / 매수 / 매도 방향에 따라 권장가 세트가 다름
}
/ / 스톱지정가는 매도 전용 — 매수면 옵션 비활성 + 선택돼 있으면 LIMIT로 되돌림 . 조건단가 행 · 단가 라벨 갱신 .
function refreshStopUI ( ) {
var otSel = $ ( ' [data-order-type] ' ) ; if ( ! otSel ) return ;
var stopOpt = otSel . querySelector ( ' option[value= " STOP_LIMIT " ] ' ) ;
if ( stopOpt ) stopOpt . disabled = ( state . side == = ' BUY ' ) ;
if ( state . side == = ' BUY ' & & otSel . value == = ' STOP_LIMIT ' ) otSel . value = ' LIMIT ' ;
var isStop = ( otSel . value == = ' STOP_LIMIT ' ) ;
var stopRow = $ ( ' [data-order-stop-row] ' ) ;
if ( stopRow ) stopRow . style . display = isStop ? ' ' : ' none ' ;
var priceLabel = $ ( ' [data-order-price-label] ' ) ;
if ( priceLabel ) priceLabel . textContent = isStop ? ' 매도 단가 ' : ' 단가 ' ;
}
function _resolveOrderPrice ( ) {
/ / 단가 우선 , 없으면 현재가 fallback . 단가 input 비어있으면 자동 채움 ( disabled 아닌 경우 )
@@ -10169,20 +10413,21 @@ function renderBook(book){
var inBook = false ;
for ( var t = 0 ; t < asks . length ; t + + ) { if ( asks [ t ] . price == = cur ) inBook = true ; }
for ( var u = 0 ; u < bids . length ; u + + ) { if ( bids [ u ] . price == = cur ) inBook = true ; }
var html = ' ' ;
if ( book . upper_limit > 0 ) html + = capRowHtml ( book . upper_limit , ' up ' ) ; / / 맨 위 상한가 캡
var specs = [ ] ;
if ( book . upper_limit > 0 ) specs . push ( capRowSpec ( book . upper_limit , ' up ' ) ) ; / / 맨 위 상한가 캡
if ( cur > 0 & & ! inBook ) {
/ / 현재가가 20 호가 어디에도 없음 → 현재가 중심 사다리로 재구성 ( 잔량은 겹치는 호가에 표시 ) .
html + = buildCenteredRows ( asks , bids , cur ) ;
specs = specs . concat ( buildCenteredSpecs ( asks , bids , cur ) ) ;
} else {
/ / 평소 : 매도10 + 매수10 ( = 20 ) . 현재가는 일치 호가에 하이라이트 .
for ( var i2 = 0 ; i2 < asks . length ; i2 + + ) { var a = asks [ i2 ] ;
html + = ' <div class= " ob-row ask ' + ( a . price == = cur ? ' ob-current ' : ' ' ) + ' " data-ob-price= " ' + a . price + ' " ><span class= " price " > ' + fmt ( a . price ) + ' </span><span class= " qty " > ' + fmt ( a . qty ) + ' </span></div> ' ; }
specs . push ( { key : String ( a . price ) , cls : ' ob-row ask ' + ( a . price == = cur ? ' ob-current ' : ' ' ) , priceHtml : fmt ( a . price ) , qty : fmt ( a . qty ) } ) ; }
for ( var j2 = 0 ; j2 < bids . length ; j2 + + ) { var b = bids [ j2 ] ;
html + = ' <div class= " ob-row bid ' + ( b . price == = cur ? ' ob-current ' : ' ' ) + ' " data-ob-price= " ' + b . price + ' " ><span class= " price " > ' + fmt ( b . price ) + ' </span><span class= " qty " > ' + fmt ( b . qty ) + ' </span></div> ' ; }
specs . push ( { key : String ( b . price ) , cls : ' ob-row bid ' + ( b . price == = cur ? ' ob-current ' : ' ' ) , priceHtml : fmt ( b . price ) , qty : fmt ( b . qty ) } ) ; }
}
if ( book . lower_limit > 0 ) html + = capRowHtml ( book . lower_limit , ' down ' ) ; / / 맨 아래 하한가 캡
ob . innerHTML = html ;
if ( book . lower_limit > 0 ) specs . push ( capRowSpec ( book . lower_limit , ' down ' ) ) ; / / 맨 아래 하한가 캡
/ / 호가 구조 ( 행 / 가격 ) 가 같으면 바뀐 셀만 제자리 갱신 → 노드 · 스크롤 유지 , 깜빡임 제거
applyRows ( ob , specs ) ;
syncMarketPriceInput ( book ) ;
highlightSelectedTick ( ) ; updateMarketPhaseDisplay ( ) ; decorateAccounts ( ) ;
if ( ! state . bookCentered ) {
@@ -10207,8 +10452,68 @@ function tickSize(p){
if ( p < 500000 ) return 500 ;
return 1000 ;
}
function snapToTick ( p ) { if ( ! ( p > 0 ) ) return p ; var t = tickSize ( p ) ; return Math . round ( p / t ) * t ; }
/ / 기술적 권장가 ( 매수 / 매도 판단 보조 ) . code · side ( · 평단 ) 바뀔 때만 fetch , keystroke엔 캐시 재사용 .
function fetchAdvice ( ) {
var code = state . code ; if ( ! code ) { renderAdvice ( null ) ; return ; }
var side = state . side ;
var avg = ( side == = ' SELL ' & & state . lastCheck & & state . lastCheck . avg_price ) ? state . lastCheck . avg_price : 0 ;
var key = code + ' : ' + side + ' : ' + avg ;
if ( state . adviceKey == = key ) { if ( state . advice ) renderAdvice ( state . advice ) ; return ; }
state . adviceKey = key ;
var url = ' /api/order/advice?code= ' + encodeURIComponent ( code ) + ' &side= ' + side + ( avg ? ' &avg_price= ' + avg : ' ' ) ;
fetch ( url ) . then ( function ( r ) { return r . json ( ) ; } ) . then ( function ( d ) {
if ( state . code != = code | | state . side != = side ) return ; / / 그새 종목 · 방향 바뀌면 무시
state . advice = d . advice | | null ;
renderAdvice ( state . advice ) ;
} ) . catch ( function ( ) { renderAdvice ( null ) ; } ) ;
}
function renderAdvice ( a ) {
var body = $ a ( ' [data-advice-body] ' ) ;
var titleEl = $ a ( ' [data-advice-title] ' ) ;
if ( titleEl ) titleEl . textContent = ' 💡 권장 ' + ( state . side == = ' BUY ' ? ' 매수가 ' : ' 매도가 ' ) ;
if ( ! body ) return ;
if ( ! a | | ! a . levels | | ! a . levels . length ) {
body . innerHTML = ' <div class= " muted small " >권장가 데이터 없음 (신규·ETF 등)</div> ' ;
return ;
}
var cur = ( state . lastBook & & state . lastBook . price ) | | a . price | | 0 ;
var rsiTag = ' ' ;
if ( a . rsi != null ) { var rz = a . rsi > = 70 ? ' 과열 ' : ( a . rsi < = 30 ? ' 과매도 ' : ' ' ) ; rsiTag = ' RSI ' + a . rsi + rz + ' · ' ; }
var rows = a . levels . map ( function ( l ) {
var rel = cur > 0 ? ( l . price > cur ? ' <span class= " advice-rel up " >▲</span> ' : ( l . price < cur ? ' <span class= " advice-rel dn " >▼</span> ' : ' ' ) ) : ' ' ;
var cls = ' advice-row ' + ( l . tap ? ' advice-tap ' : ' advice-info ' ) ;
var attr = l . tap ? ' data-adv-price= " ' + l . price + ' " ' : ' ' ;
var tapMark = l . tap ? ' <span class= " advice-tapmark " >▶ 탭</span> ' : ' ' ;
return ' <button type= " button " class= " ' + cls + ' " ' + attr + ' > ' +
' <span class= " advice-lbl " > ' + l . label + ' </span> ' +
' <span class= " advice-px " > ' + fmt ( l . price ) + rel + ' </span> ' + tapMark + ' </button> ' ;
} ) . join ( ' ' ) ;
var hints = a . levels . map ( function ( l ) {
return ' <li><b> ' + l . label + ' ' + fmt ( l . price ) + ' </b> — ' + l . hint + ' </li> ' ;
} ) . join ( ' ' ) ;
body . innerHTML =
' <div class= " advice-sub " > ' + rsiTag + ' 현재가 ' + fmt ( cur ) + ' · 가격을 탭하면 단가에 채워져요</div> ' +
' <div class= " advice-rows " > ' + rows + ' </div> ' +
' <button type= " button " class= " advice-why " data-adv-why>▸ 근거 설명</button> ' +
' <ul class= " advice-hints hidden " data-adv-hints> ' + hints + ' </ul> ' ;
}
function openAdviceModal ( ) {
if ( ! adviceModal ) return ;
renderAdvice ( state . advice ) ; / / 이미 로드된 값 즉시 렌더
fetchAdvice ( ) ; / / 미로드 / 오래됐으면 갱신 ( key 동일하면 no - op )
adviceModal . classList . remove ( ' hidden ' ) ;
adviceModal . setAttribute ( ' aria-hidden ' , ' false ' ) ;
document . body . classList . add ( ' modal-open ' ) ;
}
function closeAdviceModal ( ) {
if ( ! adviceModal ) return ;
adviceModal . classList . add ( ' hidden ' ) ;
adviceModal . setAttribute ( ' aria-hidden ' , ' true ' ) ;
if ( modal . classList . contains ( ' hidden ' ) ) document . body . classList . remove ( ' modal-open ' ) ;
}
/ / 현재가가 호가에 없을 때 : 현재가 중심 ± 10 틱 사다리 ( 20 행 ) . 잔량은 겹치는 호가에만 , 현재가 하이라이트 .
function buildCenteredRows ( asks , bids , cur ) {
function buildCenteredSpecs ( asks , bids , cur ) {
var qmap = { } ;
for ( var i = 0 ; i < asks . length ; i + + ) qmap [ asks [ i ] . price ] = { q : asks [ i ] . qty , side : ' ask ' } ;
for ( var j = 0 ; j < bids . length ; j + + ) qmap [ bids [ j ] . price ] = { q : bids [ j ] . qty , side : ' bid ' } ;
@@ -10217,19 +10522,40 @@ function buildCenteredRows(asks, bids, cur){
var dn = [ ] , q = cur ;
for ( var b = 0 ; b < 10 ; b + + ) { q - = tickSize ( q > 1 ? q - 1 : q ) ; if ( q < 1 ) break ; dn . push ( q ) ; } / / 현재가 아래 10 틱
var rows = up . reverse ( ) . concat ( [ cur ] ) . concat ( dn ) ; / / 높은가격 위 → 낮은가격 아래
var html = ' ' ;
var specs = [ ] ;
for ( var r = 0 ; r < rows . length ; r + + ) {
var pr = rows [ r ] , m = qmap [ pr ] ;
var cls = pr == = cur ? ' bid ob-current ' : ( m ? m . side : ' ' ) ;
var qty = m ? fmt ( m . q ) : ' ' ;
html + = ' <div class= " ob-row ' + cls + ' " data-ob-price= " ' + pr + ' " ><span class= " price " > ' + fmt ( pr ) + ' </span><span class= " qty " > ' + qty + ' </span></div> ' ;
specs . push ( { key : String ( pr ) , cls : ' ob-row ' + cls , priceHtml : fmt ( pr ) , qty : m ? fmt ( m . q ) : ' ' } ) ;
}
return html ;
return specs ;
}
function capRowHtml ( price , dir ) {
function capRowSpec ( price , dir ) {
var mk = dir == = ' up ' ? ' <b class= " ob-cap-mark up " >상</b> ' : ' <b class= " ob-cap-mark down " >하</b> ' ;
var lab = dir == = ' up ' ? ' 상한 ' : ' 하한 ' ;
return ' <div class= " ob-row ob-cap ob-cap- ' + dir + ' " data-ob-price= " ' + price + ' " ><span class= " price " > ' + mk + fmt ( price ) + ' </span><span class= " qty " > ' + lab + ' </span></div> ' ;
return { key : String ( price ) , cls : ' ob-row ob-cap ob-cap- ' + dir , priceHtml : mk + fmt ( price ) , qty : lab } ;
}
/ / 호가 행 반영 . 구조 ( 행수 · 가격열 ) 가 직전과 같으면 바뀐 셀만 제자리 갱신 ( 노드 유지 → 깜빡임 · 스크롤 튐 없음 ) , 다르면 1 회 전체 재생성 .
function applyRows ( ob , specs ) {
var kids = ob . children ;
var same = kids . length == = specs . length ;
if ( same ) {
for ( var i = 0 ; i < specs . length ; i + + ) { if ( kids [ i ] . getAttribute ( ' data-ob-price ' ) != = specs [ i ] . key ) { same = false ; break ; } }
}
if ( ! same ) {
var h = ' ' ;
for ( var k = 0 ; k < specs . length ; k + + ) { var s = specs [ k ] ;
h + = ' <div class= " ' + s . cls + ' " data-ob-price= " ' + s . key + ' " ><span class= " price " > ' + s . priceHtml + ' </span><span class= " qty " > ' + s . qty + ' </span></div> ' ; }
ob . innerHTML = h ;
return ;
}
for ( var j = 0 ; j < specs . length ; j + + ) {
var row = kids [ j ] , sp = specs [ j ] ;
if ( row . className != = sp . cls ) row . className = sp . cls ;
var pe = row . children [ 0 ] , qe = row . children [ 1 ] ;
if ( pe & & pe . innerHTML != = sp . priceHtml ) pe . innerHTML = sp . priceHtml ;
if ( qe & & qe . textContent != = sp . qty ) qe . textContent = sp . qty ;
}
}
function pollOnce ( ) {
if ( document . hidden | | ! state . isOpen | | ! state . code ) return ;
@@ -10262,6 +10588,7 @@ function updateCheck(){
if ( qtyInpB ) { qtyInpB . value = d . max_qty ; state . pendingMaxOnBuy = false ; recalcBudgetFromQty ( ) ; }
}
renderOrderInfo ( ) ;
fetchAdvice ( ) ; / / SELL 평단 ( avg_price ) 확보 후 권장가 갱신 — key 동일하면 재fetch 안 함
} ) . catch ( function ( ) { } ) ;
}
function renderOrderInfo ( ) {
@@ -10272,12 +10599,16 @@ function renderOrderInfo(){
var amount = price * qty ;
var orderType = ( ( $ ( ' [data-order-type] ' ) | | { } ) . value | | ' LIMIT ' ) ;
if ( state . side == = ' BUY ' ) {
var avail = ( typeof d . avail_cash == = ' number ' ) ? d . avail_cash : ( d . d2_entra | | 0 ) ;
var lines = [
' 매수가능액 (D+2 예수금) : <b> ' + fmt ( d . d2_entra | | 0 ) + ' </b>원 '
' 매수가능액: <b> ' + fmt ( avail ) + ' </b>원 '
] ;
if ( ( d . pending_buy_locked | | 0 ) > 0 ) {
lines . push ( ' <span class= " muted small " >D+2 예수금 ' + fmt ( d . d2_entra | | 0 ) + ' 원 − 매수대기 ' + fmt ( d . pending_buy_locked ) + ' 원</span> ' ) ;
}
if ( orderType == = ' MARKET ' & & d . upper_limit ) {
var needed = ( d . upper_limit | | 0 ) * qty ;
var overCls = ( needed > ( d . d2_entra | | 0 ) ) ? ' class= " qty-over " ' : ' ' ;
var overCls = ( needed > avail ) ? ' class= " qty-over " ' : ' ' ;
lines . push ( ' 필요증거금<span class= " muted small " > (상한가 ' + fmt ( d . upper_limit ) + ' 원 기준)</span>: <b ' + overCls + ' > ' + fmt ( needed ) + ' </b>원 ( ' + fmt ( qty ) + ' 주) ' ) ;
} else {
lines . push ( ' 주문예상액: <b> ' + fmt ( amount ) + ' </b>원 ( ' + fmt ( qty ) + ' 주) ' ) ;
@@ -10524,6 +10855,7 @@ function onSymbolChange(){
state . lastBook = null ;
state . lastCheck = null ;
state . bookCentered = false ;
state . advice = null ; state . adviceKey = null ; renderAdvice ( null ) ;
/ / 모달 안 표시 갱신
var ne = $ ( ' [data-order-name] ' ) ; if ( ne ) ne . textContent = name ;
var cd = $ ( ' [data-order-code-display] ' ) ; if ( cd ) cd . textContent = ' ( ' + code + ' ) ' ;
@@ -10532,6 +10864,9 @@ function onSymbolChange(){
if ( ob ) ob . innerHTML = ' <div class= " loading " style= " padding:30px 0; " ><div class= " loading-spin " ></div>호가 로딩중…</div> ' ;
$ ( ' [data-order-price-input] ' ) . value = ' ' ;
$ ( ' [data-order-qty] ' ) . value = ' ' ;
var stopInp = $ ( ' [data-order-stop-input] ' ) ; if ( stopInp ) stopInp . value = ' ' ;
/ / 종목 변경 시 스톱지정가는 해제 — 이전 종목 트리거가 새 종목에 잘못 적용 방지
var otSelR = $ ( ' [data-order-type] ' ) ; if ( otSelR & & otSelR . value == = ' STOP_LIMIT ' ) { otSelR . value = ' LIMIT ' ; refreshStopUI ( ) ; }
var budgetInp = $ ( ' [data-order-budget] ' ) ;
if ( budgetInp ) budgetInp . value = ' ' ;
/ / 종목 변경 시 새 max로 자동 채움 ( SELL : 보유 100 % , BUY : 매수가능 max )
@@ -10542,6 +10877,7 @@ function onSymbolChange(){
fetchAccountStatus ( ) ;
pollOnce ( ) ;
updateCheck ( ) ;
fetchAdvice ( ) ;
}
function fillAccounts ( allowed ) {
var sel = $ ( ' [data-order-account] ' ) ; if ( ! sel ) return ;
@@ -10566,7 +10902,8 @@ function decorateAccounts(){
opt . textContent = base + ( q > 0 ? ' · ' + fmt ( q ) + ' 주 ' : ' · 미보유 ' ) ;
opt . style . color = q > 0 ? ' #f0f0f0 ' : ' #ff6b75 ' ;
} else {
var ok = price > 0 ? ( a . d2_entra > = price ) : ( a . d2_entra > 0 ) ;
var avail = ( typeof a . avail_cash == = ' number ' ) ? a . avail_cash : ( a . d2_entra | | 0 ) ;
var ok = price > 0 ? ( avail > = price ) : ( avail > 0 ) ;
opt . textContent = base + ( ok ? ' ' : ' · ⚠ 부족 ' ) ;
opt . style . color = ok ? ' #f0f0f0 ' : ' #ff6b75 ' ;
}
@@ -10647,6 +10984,7 @@ function openModal(opts){
}
function closeModal ( ) {
state . isOpen = false ; stopPolling ( ) ;
if ( adviceModal & & ! adviceModal . classList . contains ( ' hidden ' ) ) closeAdviceModal ( ) ;
modal . classList . add ( ' hidden ' ) ;
modal . setAttribute ( ' aria-hidden ' , ' true ' ) ;
/ / pin - modal이 안 떠 있을 때만 body unlock
@@ -10708,9 +11046,17 @@ function doPropose(){
var orderType = $ ( ' [data-order-type] ' ) . value ;
var qty = parseInt ( $ ( ' [data-order-qty] ' ) . value | | ' 0 ' , 10 ) ;
var price = parseInt ( $ ( ' [data-order-price-input] ' ) . value | | ' 0 ' , 10 ) ;
var stopPrice = parseInt ( ( $ ( ' [data-order-stop-input] ' ) | | { } ) . value | | ' 0 ' , 10 ) ;
if ( ! account ) { setMsg ( ' 계좌를 선택하세요 ' , ' error ' ) ; return ; }
if ( qty < = 0 ) { setMsg ( ' 수량을 입력하세요 ' , ' error ' ) ; return ; }
if ( orderType == = ' LIMIT ' & & price < = 0 ) { setMsg ( ' 지정가는 단가가 필요합니다 ' , ' error ' ) ; return ; }
if ( orderType == = ' STOP_LIMIT ' ) {
if ( state . side != = ' SELL ' ) { setMsg ( ' 스톱지정가는 매도만 가능합니다 ' , ' error ' ) ; return ; }
if ( price < = 0 ) { setMsg ( ' 스톱지정가는 매도 단가가 필요합니다 ' , ' error ' ) ; return ; }
if ( stopPrice < = 0 ) { setMsg ( ' 조건단가(트리거 가격)를 입력하세요 ' , ' error ' ) ; return ; }
var curP = ( state . lastCheck & & state . lastCheck . cur_price ) | | ( state . lastBook & & state . lastBook . price ) | | 0 ;
if ( curP & & stopPrice > = curP ) { setMsg ( ' 조건단가는 현재가( ' + fmt ( curP ) + ' 원)보다 낮아야 합니다 ' , ' error ' ) ; return ; }
}
/ / 클라이언트 사전 차단 — 서버 왕복 없이 즉시 에러 ( PIN 발급 중 토스트 깜빡임 방지 )
var lc = state . lastCheck ;
if ( lc & & typeof lc . max_qty == = ' number ' & & lc . max_qty > = 0 & & qty > lc . max_qty ) {
@@ -10718,15 +11064,25 @@ function doPropose(){
var basis = ( orderType == = ' MARKET ' & & lc . upper_limit ) ? ' 상한가 ' : ' 단가 ' ;
var ref = ( orderType == = ' MARKET ' & & lc . upper_limit ) ? lc . upper_limit : price ;
var needed = ref * qty ;
setMsg ( ' 예수금 부족 — 필요 ' + needed . toLocaleString ( ) + ' 원 ( ' + basis + ' ' + ( ref | | 0 ) . toLocaleString ( ) + ' 원 × ' + qty + ' 주) / 가용 ' + ( lc . d2_entra | | 0 ) . toLocaleString ( ) + ' 원 \n 최대 ' + lc . max_qty + ' 주까지 매수 가능 ' , ' error ' ) ;
var availC = ( typeof lc . avail_cash == = ' number ' ) ? lc . avail_cash : ( lc . d2_entra | | 0 ) ;
setMsg ( ' 예수금 부족 — 필요 ' + needed . toLocaleString ( ) + ' 원 ( ' + basis + ' ' + ( ref | | 0 ) . toLocaleString ( ) + ' 원 × ' + qty + ' 주) / 가용 ' + availC . toLocaleString ( ) + ' 원 \n 최대 ' + lc . max_qty + ' 주까지 매수 가능 ' , ' error ' ) ;
} else {
setMsg ( ' 보유 부족 — 매도 ' + qty + ' 주 / 매도가능 ' + lc . max_qty + ' 주 ' , ' error ' ) ;
}
return ;
}
/ / 검증 통과 — 매수 / 매도 방향 최종 확인 팝업 ( 오타 방지 ) . 확인 시에만 실제 매매로 진행 .
state . pendingOrder = { account : account , orderType : orderType , qty : qty , price : price , stopPrice : stopPrice } ;
openOrderConfirm ( ) ;
}
/ / 방향 확인 후 실제 매매 진행 — 전량매도 sell - choice 분기 포함 .
function doProposeConfirmed ( ) {
var po = state . pendingOrder ; if ( ! po ) return ;
var account = po . account , orderType = po . orderType , qty = po . qty , price = po . price ;
var lc = state . lastCheck ;
/ / 전량매도 ( 입력 수량 = 매도가능 전량 ) 이고 같은 소유자 그룹의 다른 계좌에도 같은 종목 보유
/ / → 선택 팝업 ( 두 계좌 모두 / 선택 계좌만 / 취소 ) . 일부 매도는 팝업 없이 단일 진행 .
if ( state . side == = ' SELL ' ) {
/ / → 선택 팝업 . 스톱지정가는 단일 계좌 예약이라 2 계좌 동시 매도 대상 아님 .
if ( state . side == = ' SELL ' & & orderType != = ' STOP_LIMIT ' ) {
var fullSell = lc & & typeof lc . max_qty == = ' number ' & & lc . max_qty > 0 & & qty == = lc . max_qty ;
var sib = siblingAccount ( account ) ;
var sibInfo = ( sib & & state . accStatus & & state . accStatus . byLabel ) ? state . accStatus . byLabel [ sib ] : null ;
@@ -10736,7 +11092,58 @@ function doPropose(){
return ;
}
}
proposeSingle ( account , orderType , qty , price ) ;
proposeSingle ( account , orderType , qty , price , po . stopPrice ) ;
}
/ / ─ ─ 매수 / 매도 방향 확인 모달 ─ ─
/ / 매수 · 매도 버튼을 둘 다 띄우고 관리자님이 직접 맞는 방향을 누르게 함 ( 위치 랜덤 ) .
/ / 주문 방향 ( state . side ) 과 일치하면 진행 , 다르면 팝업 닫고 취소 .
function $ oc ( sel ) { return orderConfirmModal ? orderConfirmModal . querySelector ( sel ) : null ; }
function openOrderConfirm ( ) {
if ( ! orderConfirmModal ) { doProposeConfirmed ( ) ; return ; }
var po = state . pendingOrder ; if ( ! po ) return ;
var disp = { } ; ACCOUNTS . forEach ( function ( a ) { disp [ a . label ] = a . display ; } ) ;
var isStop = ( po . orderType == = ' STOP_LIMIT ' ) ;
var typeStr = ( po . orderType == = ' MARKET ' ) ? ' 시장가 ' : ( isStop ? ' 스톱지정가(예약) ' : ' 지정가 ' ) ;
var priceStr = ( po . orderType == = ' MARKET ' ) ? ' 시장가 ' : ( fmt ( po . price ) + ' 원 ' ) ;
var sum = $ oc ( ' [data-order-confirm-summary] ' ) ;
if ( sum ) {
/ / 매도 예상 수수료 ( 수수료 + 증권거래세 ) — 표시용 근사 . 요율은 sim / config . py와 동일 .
var feeLine = ' ' ;
if ( state . side == = ' SELL ' ) {
var refPrice = ( po . orderType == = ' MARKET ' ) ? ( ( state . lastCheck & & state . lastCheck . cur_price ) | | 0 ) : po . price ;
var amount = refPrice * po . qty ;
var fee = Math . round ( amount * ( 0.00015 + 0.0018 ) ) ; / / 수수료 0.015 % + 거래세 0.18 %
if ( amount > 0 ) {
feeLine = ' · 예상 수수료: <b> ' + fmt ( fee ) + ' 원</b> <span class= " muted small " >(체결 시 확정)</span><br> ' ;
}
}
var stopLine = isStop ? ( ' · 조건단가: <b> ' + fmt ( po . stopPrice ) + ' 원 도달 시</b> <span class= " muted small " >(예약)</span><br> ' ) : ' ' ;
/ / 방향은 일부러 숨김 — 관리자님이 직접 고르게 해야 오타가 걸러짐 .
sum . innerHTML = ' <b> ' + ( state . name | | state . code ) + ' </b><br> ' +
' · 계좌: <b> ' + ( disp [ po . account ] | | po . account ) + ' </b><br> ' +
' · 유형: <b> ' + typeStr + ' </b><br> ' +
stopLine +
' · ' + ( isStop ? ' 매도 단가 ' : ' 단가 ' ) + ' : <b> ' + priceStr + ' </b><br> ' +
' · 수량: <b> ' + fmt ( po . qty ) + ' 주</b><br> ' +
feeLine + ' <br> ' +
' <b>매수 / 매도</b> 중 하려던 것을 눌러주세요. ' ;
}
/ / 매수 · 매도 버튼 위치를 랜덤으로 섞어 습관적 오탭 방지 ( CSS order )
var buyBtn = $ oc ( ' [data-order-confirm-side= " BUY " ] ' ) ;
var sellBtn = $ oc ( ' [data-order-confirm-side= " SELL " ] ' ) ;
if ( buyBtn & & sellBtn ) {
var buyFirst = Math . random ( ) < 0.5 ;
buyBtn . style . order = buyFirst ? ' 1 ' : ' 2 ' ;
sellBtn . style . order = buyFirst ? ' 2 ' : ' 1 ' ;
}
orderConfirmModal . classList . remove ( ' hidden ' ) ;
orderConfirmModal . setAttribute ( ' aria-hidden ' , ' false ' ) ;
}
function closeOrderConfirm ( ) {
if ( ! orderConfirmModal ) return ;
orderConfirmModal . classList . add ( ' hidden ' ) ;
orderConfirmModal . setAttribute ( ' aria-hidden ' , ' true ' ) ;
if ( modal . classList . contains ( ' hidden ' ) & & ( ! pinModal | | pinModal . classList . contains ( ' hidden ' ) ) ) document . body . classList . remove ( ' modal-open ' ) ;
}
function siblingAccount ( label ) {
var me = ACCOUNTS . find ( function ( a ) { return a . label == = label ; } ) ;
@@ -10744,7 +11151,7 @@ function siblingAccount(label){
var sib = ACCOUNTS . find ( function ( a ) { return a . owner == = me . owner & & a . label != = label ; } ) ;
return sib ? sib . label : null ;
}
function proposeSingle ( account , orderType , qty , price ) {
function proposeSingle ( account , orderType , qty , price , stopPrice ) {
var body = new URLSearchParams ( ) ;
body . set ( ' account ' , account ) ;
body . set ( ' side ' , state . side ) ;
@@ -10752,7 +11159,8 @@ function proposeSingle(account, orderType, qty, price){
body . set ( ' symbol_name ' , state . name ) ;
body . set ( ' qty ' , String ( qty ) ) ;
body . set ( ' order_type ' , orderType ) ;
if ( orderType == = ' LIMIT ' ) body . set ( ' price ' , String ( price ) ) ;
if ( orderType == = ' LIMIT ' | | orderType == = ' STOP_LIMIT ' ) body . set ( ' price ' , String ( price ) ) ;
if ( orderType == = ' STOP_LIMIT ' ) body . set ( ' stop_price ' , String ( stopPrice | | 0 ) ) ;
proposeAndOpenPin ( ' /api/order/propose ' , body ) ;
}
function doProposeMulti ( opts ) {
@@ -10874,6 +11282,8 @@ modal.addEventListener('click', function(e){
}
var qtyBtn = t . closest & & t . closest ( ' [data-qty-step] ' ) ;
if ( qtyBtn ) { gotoQty ( qtyBtn . getAttribute ( ' data-qty-step ' ) ) ; return ; }
var advOpen = t . closest & & t . closest ( ' [data-advice-open] ' ) ;
if ( advOpen ) { openAdviceModal ( ) ; return ; }
var obRow = t . closest & & t . closest ( ' .ob-row ' ) ;
if ( obRow ) {
var p = parseInt ( obRow . getAttribute ( ' data-ob-price ' ) | | ' 0 ' , 10 ) ;
@@ -10902,6 +11312,31 @@ if(pinModal) pinModal.addEventListener('click', function(e){
if ( t . classList & & t . classList . contains ( ' modal-close ' ) ) { closePinModal ( ) ; return ; }
if ( t . matches & & t . matches ( ' [data-pin-verify] ' ) ) { doVerify ( ) ; return ; }
} ) ;
/ / 권장가 팝업 click 핸들러 — 가격 탭 시 단가 채우고 팝업 닫기 , 근거 토글
if ( adviceModal ) adviceModal . addEventListener ( ' click ' , function ( e ) {
var t = e . target ;
if ( t . getAttribute & & t . getAttribute ( ' data-modal-close ' ) == = ' 1 ' ) { closeAdviceModal ( ) ; return ; }
if ( t . classList & & t . classList . contains ( ' modal-close ' ) ) { closeAdviceModal ( ) ; return ; }
var advWhy = t . closest & & t . closest ( ' [data-adv-why] ' ) ;
if ( advWhy ) {
var hints = $ a ( ' [data-adv-hints] ' ) ;
if ( hints ) { hints . classList . toggle ( ' hidden ' ) ; advWhy . textContent = hints . classList . contains ( ' hidden ' ) ? ' ▸ 근거 설명 ' : ' ▾ 근거 설명 ' ; }
return ;
}
var advTap = t . closest & & t . closest ( ' .advice-tap ' ) ;
if ( advTap ) {
var ap = parseInt ( advTap . getAttribute ( ' data-adv-price ' ) | | ' 0 ' , 10 ) ;
if ( ap > 0 ) {
/ / 권장가는 특정 단가 → 지정가로 채움 ( 시장가면 LIMIT로 전환해 입력칸 활성화 )
var typeSel = $ ( ' [data-order-type] ' ) ;
if ( typeSel & & typeSel . value != = ' LIMIT ' ) { typeSel . value = ' LIMIT ' ; typeSel . dispatchEvent ( new Event ( ' change ' ) ) ; }
var inp = $ ( ' [data-order-price-input] ' ) ;
if ( inp ) { inp . disabled = false ; inp . value = snapToTick ( ap ) ; updateCheck ( ) ; highlightSelectedTick ( ) ; recalcQtyFromBudget ( ) ; }
}
closeAdviceModal ( ) ;
return ;
}
} ) ;
/ / 매도 계좌 선택 모달 click 핸들러
if ( sellChoiceModal ) sellChoiceModal . addEventListener ( ' click ' , function ( e ) {
var t = e . target ;
@@ -10918,14 +11353,34 @@ if(sellChoiceModal) sellChoiceModal.addEventListener('click', function(e){
return ;
}
} ) ;
/ / 매수 / 매도 방향 확인 모달 click 핸들러
if ( orderConfirmModal ) orderConfirmModal . addEventListener ( ' click ' , function ( e ) {
var t = e . target ;
if ( t . getAttribute & & t . getAttribute ( ' data-modal-close ' ) == = ' 1 ' ) { closeOrderConfirm ( ) ; return ; }
var sideBtn = t . closest & & t . closest ( ' [data-order-confirm-side] ' ) ;
if ( sideBtn ) {
var picked = sideBtn . getAttribute ( ' data-order-confirm-side ' ) ;
closeOrderConfirm ( ) ;
if ( picked == = state . side ) {
doProposeConfirmed ( ) ;
} else {
/ / 방향 불일치 — 오타로 판단 , 매매 취소
showToast ( ' ⚠️ 방향이 주문과 달라 취소했어요. 다시 확인해 주세요. ' , ' error ' , 4500 ) ;
}
return ;
}
} ) ;
var accSel = $ ( ' [data-order-account] ' ) ; if ( accSel ) accSel . addEventListener ( ' change ' , updateCheck ) ;
var symSel = $ ( ' [data-order-symbol-select] ' ) ; if ( symSel ) symSel . addEventListener ( ' change ' , onSymbolChange ) ;
var otSel = $ ( ' [data-order-type] ' ) ;
if ( otSel ) otSel . addEventListener ( ' change ' , function ( ) {
/ / 스톱지정가는 매도 전용 — 선택 시 매도로 전환 ( setSide가 refreshStopUI + updateCheck 수행 )
if ( otSel . value == = ' STOP_LIMIT ' & & state . side != = ' SELL ' ) { setSide ( ' SELL ' ) ; return ; }
var isMarket = ( otSel . value == = ' MARKET ' ) ;
var inp = $ ( ' [data-order-price-input] ' ) ;
if ( inp ) inp . disabled = isMarket ;
$ $ ( ' [data-tick-dir] ' ) . forEach ( function ( b ) { b . disabled = isMarket ; } ) ;
refreshStopUI ( ) ;
/ / 주문유형 토글 시 max_qty 기준이 바뀜 ( MARKET = 상한가 , LIMIT = 단가 ) — 재조회
updateCheck ( ) ;
} ) ;
@@ -10946,8 +11401,8 @@ document.addEventListener('click', function(e){
if ( subOrderBtn ) {
function openWithFirst ( items ) {
var first = ( items | | [ ] ) . find ( function ( it ) { return it . source == = ' 보유 ' & & it . owner == = ' 본인 ' ; } ) ;
/ / 보유종목을 자동선택하므로 행 거래버튼과 동일 규칙으로 매도탭 진입 . 보유분 없으면 빈 모달 ( 매수 기본 ) .
if ( first ) { openModal ( { code : first . code , name : first . name , side : ' SELL ' } ) ; }
/ / 보유종목을 자동선택하되 항상 매수 기본 ( 오타 방지 ) . 매도는 모달에서 토글 . 보유분 없으면 빈 모달 .
if ( first ) { openModal ( { code : first . code , name : first . name , side : ' BUY ' } ) ; }
else { openModal ( { code : ' ' , name : ' ' } ) ; }
}
if ( state . symbolsCache ) { openWithFirst ( state . symbolsCache ) ; }
@@ -11256,9 +11711,11 @@ window.openPinModal = openPinModal;
{ trade_modal_html }
{ info_modal_html }
{ info_desc_modal_html }
{ advice_modal_html }
{ order_modal_html }
{ pin_modal_html }
{ sell_choice_modal_html }
{ order_confirm_modal_html }
{ open_orders_modal_html }
{ stock_name_modal_html }
{ ptr_script }
@@ -11826,6 +12283,26 @@ class Handler(BaseHTTPRequestHandler):
except Exception as e :
self . _send_json ( 200 , { ' results ' : [ ] , ' error ' : str ( e ) } )
return
if parsed . path == ' /api/order/advice ' :
# 거래 모달용: 종목·side 기술적 권장 매수가/매도가 + 근거(이평·ATR). 매도는 avg_price(평단) 선택.
qs = parse_qs ( parsed . query )
code = ' ' . join ( ch for ch in ( qs . get ( ' code ' ) or [ ' ' ] ) [ 0 ] . strip ( ) if ch . isalnum ( ) )
side = ( qs . get ( ' side ' ) or [ ' ' ] ) [ 0 ] . strip ( ) . upper ( )
try :
avg_price = int ( ( qs . get ( ' avg_price ' ) or [ ' 0 ' ] ) [ 0 ] . strip ( ) or ' 0 ' ) or None
except ValueError :
avg_price = None
if not code or side not in ( ' BUY ' , ' SELL ' ) :
self . _send_json ( 400 , { ' error ' : ' code required, side=BUY|SELL ' } )
return
try :
advice = _price_advice ( code , side , avg_price )
except Exception as e :
traceback . print_exc ( )
self . _send_json ( 500 , { ' error ' : f ' order/advice failed: { e } ' } )
return
self . _send_json ( 200 , { ' code ' : code , ' side ' : side , ' advice ' : advice } )
return
if parsed . path == ' /api/order/check ' :
# 거래 모달용: 계좌·종목·side(BUY/SELL)·order_type·단가(선택) 받아서
# 최대 거래 가능 주수, 잔액·보유주수·평단가, 매도 시 손익 미리보기 반환.
@@ -11835,7 +12312,7 @@ class Handler(BaseHTTPRequestHandler):
account = ( qs . get ( ' account ' ) or [ ' ' ] ) [ 0 ] . strip ( )
side = ( qs . get ( ' side ' ) or [ ' ' ] ) [ 0 ] . strip ( ) . upper ( )
order_type = ( qs . get ( ' order_type ' ) or [ ' LIMIT ' ] ) [ 0 ] . strip ( ) . upper ( ) or ' LIMIT '
if order_type not in ( ' LIMIT ' , ' MARKET ' , ' AGGRESSIVE_LIMIT ' ) :
if order_type not in ( ' LIMIT ' , ' MARKET ' , ' AGGRESSIVE_LIMIT ' , ' STOP_LIMIT ' ) :
order_type = ' LIMIT '
try :
price = int ( ( qs . get ( ' price ' ) or [ ' 0 ' ] ) [ 0 ] . strip ( ) or ' 0 ' )
@@ -11854,8 +12331,13 @@ class Handler(BaseHTTPRequestHandler):
# 동일. kt00001 ord_alow_amt는 오늘 즉시현금(entr)만 잡혀 매도결제분(D+2)을 빠뜨려
# max_qty가 과소 계산되고 "예수금 부족" 오탐을 냈다. ord_alow_amt는 참고 표시용만 유지.
d2 = int ( bal . get ( ' d2_entra ' ) or 0 )
# d2_entra는 미체결 매수 지정가로 묶인 예수금을 아직 포함 → 실제 가용액에서 차감.
locked = _pending_buy_lock ( account )
avail = max ( 0 , d2 - locked )
result [ ' ord_alow_amt ' ] = int ( bal . get ( ' ord_alow_amt ' ) or 0 )
result [ ' d2_entra ' ] = d2
result [ ' pending_buy_locked ' ] = locked
result [ ' avail_cash ' ] = avail
if order_type == ' MARKET ' :
# 키움 시장가 매수 증거금 = 상한가 × qty. 같은 기준으로 max_qty 표시.
try :
@@ -11865,24 +12347,29 @@ class Handler(BaseHTTPRequestHandler):
except Exception :
upper = 0
result [ ' upper_limit ' ] = upper
result [ ' max_qty ' ] = ( d2 / / upper ) if upper > 0 else 0
result [ ' max_qty ' ] = ( avail / / upper ) if upper > 0 else 0
else :
result [ ' max_qty ' ] = ( d2 / / price ) if price > 0 else 0
result [ ' max_qty ' ] = ( avail / / price ) if price > 0 else 0
else : # SELL
positions = kc . get_positions ( account )
pos = next ( ( p for p in positions if p [ ' code ' ] == code ) , None )
if pos :
# trde_able_qty가 미체결 매도를 이미 제외하는지 불확실 → min으로 이중차감 방지.
# (제외됨: trde_able ≤ 보유−미체결, min=trde_able 유지 / 미제외: min=보유−미체결)
pend_sell = _pending_sell_qty ( account , code )
sellable = max ( 0 , min ( pos [ ' trde_able_qty ' ] , pos [ ' qty ' ] - pend_sell ) )
result [ ' hold_qty ' ] = pos [ ' qty ' ]
result [ ' trde_able_qty ' ] = pos [ ' trde_able_qty ' ]
result [ ' trde_able_qty ' ] = sellable
result [ ' pending_sell_qty ' ] = pend_sell
result [ ' avg_price ' ] = pos [ ' avg_price ' ]
result [ ' cur_price ' ] = pos [ ' cur_price ' ]
result [ ' max_qty ' ] = pos [ ' trde_able_qty ' ]
result [ ' max_qty ' ] = sellable
# 선택 단가 기준 손익 미리보기 — 단가 0이면 현재가 fallback
ref_price = price or pos [ ' cur_price ' ]
result [ ' pl_preview ' ] = ( ref_price - pos [ ' avg_price ' ] ) * result [ ' max_qty ' ]
else :
result . update ( { ' hold_qty ' : 0 , ' trde_able_qty ' : 0 , ' avg_price ' : 0 ,
' cur_price ' : 0 , ' max_qty ' : 0 , ' pl_preview ' : 0 } )
result . update ( { ' hold_qty ' : 0 , ' trde_able_qty ' : 0 , ' pending_sell_qty ' : 0 ,
' avg_price ' : 0 , ' cur_price ' : 0 , ' max_qty ' : 0 , ' pl_preview ' : 0 } )
except Exception as e :
traceback . print_exc ( )
self . _send_json ( 500 , { ' error ' : f ' order/check failed: { e } ' } )
@@ -11910,11 +12397,19 @@ class Handler(BaseHTTPRequestHandler):
p = next ( ( x for x in ( pos_by . get ( lb , [ ] ) or [ ] ) if x [ ' code ' ] == code ) , None )
if p and p . get ( ' cur_price ' ) and not cur_price :
cur_price = p [ ' cur_price ' ]
d2 = int ( bals [ lb ] . get ( ' d2_entra ' ) or 0 )
locked = _pending_buy_lock ( lb ) # 미체결 매수 묶임 차감
hold = p [ ' qty ' ] if p else 0
# 매도가능 = min(trde_able, 보유−미체결매도) — check 핸들러와 동일 로직
pend_sell = _pending_sell_qty ( lb , code ) if p else 0
sellable = max ( 0 , min ( p [ ' trde_able_qty ' ] , hold - pend_sell ) ) if p else 0
accounts . append ( {
' label ' : lb ,
' d2_entra ' : int ( bals [ lb ] . get ( ' d2_entra ' ) or 0 ) ,
' hold_qty ' : p [ ' qty ' ] if p else 0 ,
' trde_able_qty ' : p [ ' trde_able_qty ' ] if p else 0 ,
' d2_entra ' : d2 ,
' avail_cash ' : max ( 0 , d2 - locked ) ,
' pending_buy_locked ' : locked ,
' hold_qty ' : hold ,
' trde_able_qty ' : sellable ,
} )
self . _send_json ( 200 , { ' code ' : code , ' cur_price ' : cur_price , ' accounts ' : accounts } )
except Exception as e :
@@ -12318,15 +12813,27 @@ class Handler(BaseHTTPRequestHandler):
price = int ( price_raw ) if price_raw else None
except ValueError :
price = None
stop_raw = ( params . get ( ' stop_price ' ) or [ ' ' ] ) [ 0 ] . strip ( )
try :
stop_price = int ( stop_raw ) if stop_raw else None
except ValueError :
stop_price = None
if not account or side not in ( ' BUY ' , ' SELL ' ) or not symbol or qty < = 0 :
self . _send_json ( 400 , { ' ok ' : False , ' error ' : ' account/side/symbol/qty required ' } )
return
if order_type not in ( ' LIMIT ' , ' MARKET ' ) :
self . _send_json ( 400 , { ' ok ' : False , ' error ' : ' order_type must be LIMIT|MARKET ' } )
if order_type not in ( ' LIMIT ' , ' MARKET ' , ' STOP_LIMIT ' ) :
self . _send_json ( 400 , { ' ok ' : False , ' error ' : ' order_type must be LIMIT|MARKET|STOP_LIMIT ' } )
return
if order_type == ' LIMIT ' and ( price is None or price < = 0 ) :
self . _send_json ( 400 , { ' ok ' : False , ' error ' : ' LIMIT requires positive price ' } )
return
if order_type == ' STOP_LIMIT ' :
if side != ' SELL ' :
self . _send_json ( 400 , { ' ok ' : False , ' error ' : ' 스톱지정가는 매도만 지원 ' } )
return
if price is None or price < = 0 or stop_price is None or stop_price < = 0 :
self . _send_json ( 400 , { ' ok ' : False , ' error ' : ' 스톱지정가는 단가와 조건단가가 모두 필요 ' } )
return
try :
if not symbol_name :
import kiwoom_client as kc
@@ -12341,7 +12848,8 @@ class Handler(BaseHTTPRequestHandler):
res = handler . propose_trade (
account = account , side = side , symbol = symbol , symbol_name = symbol_name ,
qty = qty , order_type = order_type ,
price = ( price if order_type == ' LIMIT ' else None ) ,
price = ( price if order_type in ( ' LIMIT ' , ' STOP_LIMIT ' ) else None ) ,
stop_price = ( stop_price if order_type == ' STOP_LIMIT ' else None ) ,
)
if res . get ( ' ok ' ) :
# 매수/매도 미리보기 카드 메시지·PIN 메시지 모두 텔레그램 발송 X.