auto: 일일 백업 2026-08-01 02:00
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,13 +1,24 @@
|
||||
"""트레일링 스톱 예약 상태 관리.
|
||||
"""트레일링 스톱 예약 상태 관리 (계단식 분할 매도).
|
||||
|
||||
키움에 REST 트레일링 주문 TR 이 없어서, 스톱지정가(trde_tp=28) 주문을 실제로 걸어두고
|
||||
고점이 갱신될 때마다 조건단가를 kt10002 정정으로 올려 트레일링을 구현한다.
|
||||
|
||||
주문이 키움 서버에 있으므로 감시 루프가 죽어도 마지막 손절선은 살아있다.
|
||||
**예약 1건 = 계단(레그) N개 = 키움 스톱주문 N건.** 고점 대비 하락률이 깊어질수록 더 많이
|
||||
파는 계단식 청산이다 (예: −10% 20% / −20% 50% / −30% 전량). 계단별로 수량과 조건단가가
|
||||
다른 스톱주문을 동시에 걸어두고, 고점이 오르면 살아있는 레그를 전부 상향 정정한다.
|
||||
|
||||
주문이 키움 서버에 있으므로 감시 루프가 죽어도 계단별 손절선은 살아있다.
|
||||
감시 루프가 하는 일은 "이미 승인된 주문의 조건단가 상향"뿐 — 신규 발주·수량 변경 없음.
|
||||
|
||||
⚠️ 계단 비중은 관리자님이 **누적**으로 입력하고(20/50/100 = 전량), 내부에는 계단별
|
||||
**추가분**(20/30/50)으로 환산해 저장한다. 주문 수량이 곧 추가분이기 때문이다.
|
||||
|
||||
⚠️ 한 계단이 체결된 뒤 반등해 신고점을 찍어도 **남은 계단만 새 고점 기준으로 따라 올린다**.
|
||||
남은 수량을 계단 비율대로 재배치하려면 기존 주문 취소 + 신규 발주가 필요한데, 그러면
|
||||
감시 루프에 자동 발주 경로가 생긴다(매매 자동 트리거 금지). 2026-07-31 관리자님 결정.
|
||||
|
||||
⚠️ kt10002 정정 응답의 ord_no 는 신규 주문번호다. 정정할 때마다 ord_no 를 갱신하지 않으면
|
||||
두 번째 정정부터 orig_ord_no 가 틀려서 전부 실패한다. commit_modify 가 이걸 담당.
|
||||
두 번째 정정부터 orig_ord_no 가 틀려서 전부 실패한다. commit_step_modify 가 이걸 담당.
|
||||
|
||||
상태는 파일(state/trailing_stops.json)에 저장 — 웹·감시 루프가 각각 다른 프로세스라
|
||||
같은 예약 목록을 봐야 한다. 동시성은 fcntl flock 으로 직렬화 (pin.py 와 같은 패턴).
|
||||
@@ -42,6 +53,10 @@ MAX_TRAIL_PCT = 30.0
|
||||
# 반대로 너무 벌리면 급락 시 헐값 매도가 되니 2틱.
|
||||
ORD_UV_GAP_TICKS = 2
|
||||
|
||||
# 계단 개수 상한. 고점이 오를 때마다 살아있는 레그를 전부 정정하므로 정정 콜이 계단 수에
|
||||
# 비례한다 (예약 3건 × 5계단 = 분당 15콜). 키움 rate limit 여유를 보고 5로 둔다.
|
||||
MAX_STEPS = 5
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return datetime.now(KST).isoformat(timespec='seconds')
|
||||
@@ -67,7 +82,10 @@ def compute_levels(peak: int, trail_pct: float, min_sell_price: Optional[int] =
|
||||
raise ValueError(f'peak must be > 0 (got {peak})')
|
||||
if not (MIN_TRAIL_PCT <= trail_pct <= MAX_TRAIL_PCT):
|
||||
raise ValueError(f'trail_pct out of range: {trail_pct}')
|
||||
cond_uv = floor_to_tick(int(peak * (1 - trail_pct / 100)))
|
||||
# round(_, 6) 은 부동소수점 잡음만 걷어낸다 — 5500×(1−30/100) 이 3849.9999999999995 로
|
||||
# 나오는 탓에 int() 가 3849 로 깎고 호가단위 내림이 3845 까지 끌어내리던 버그가 있었다
|
||||
# (의도는 3850). 잡음을 없앤 뒤 내림하므로 "정확히 그 폭 아래" 성격은 그대로다.
|
||||
cond_uv = floor_to_tick(int(round(peak * (1 - trail_pct / 100), 6)))
|
||||
floor_applied = False
|
||||
if min_sell_price:
|
||||
if min_sell_price <= 0:
|
||||
@@ -82,21 +100,96 @@ def compute_levels(peak: int, trail_pct: float, min_sell_price: Optional[int] =
|
||||
return {'cond_uv': cond_uv, 'ord_uv': ord_uv, 'floor_applied': floor_applied}
|
||||
|
||||
|
||||
def next_levels(res: dict, cur_price: int) -> Optional[dict]:
|
||||
"""현재가를 보고 올릴 값이 있으면 반환, 없으면 None (순수 함수 — 단위테스트 대상).
|
||||
def normalize_steps(raw: list) -> list:
|
||||
"""입력한 계단 정의(누적 비중)를 내부 표현(추가 비중)으로 환산·검증한다.
|
||||
|
||||
상향 전용: 고점이 갱신되고 그 결과 조건단가가 실제로 올라갈 때만 정정 대상.
|
||||
고점이 올라도 호가단위 내림 때문에 조건단가가 그대로면 정정하지 않는다(불필요한 API 콜 차단).
|
||||
최저 매도가가 아직 지배 중이면 트레일 손절선이 올라도 조건단가가 안 변해 None 이 된다.
|
||||
입력 [{'pct': 10, 'cum': 20}, {'pct': 20, 'cum': 50}, {'pct': 30, 'cum': 100}]
|
||||
출력 [{'n': 1, 'pct': 10.0, 'cum': 20.0, 'weight': 20.0}, ... weight 30, 50]
|
||||
|
||||
cum(누적 비중)은 "이 하락률에 닿았을 때 최초 보유의 몇 %가 팔려 있어야 하는가"다.
|
||||
주문 수량은 계단마다의 추가분이므로 weight = cum − 직전 cum 으로 환산한다.
|
||||
|
||||
마지막 cum 이 100 미만이어도 허용한다(일부만 계단 청산하고 나머지는 계속 보유).
|
||||
남는 수량은 스톱이 걸리지 않으므로 호출측이 화면에 표시해 준다.
|
||||
"""
|
||||
if cur_price <= 0:
|
||||
if not raw:
|
||||
raise ValueError('계단이 비어 있음')
|
||||
if len(raw) > MAX_STEPS:
|
||||
raise ValueError(f'계단은 최대 {MAX_STEPS}개 (입력 {len(raw)}개)')
|
||||
steps = []
|
||||
prev_pct = 0.0
|
||||
prev_cum = 0.0
|
||||
for i, s in enumerate(raw, start=1):
|
||||
pct = float(s['pct'])
|
||||
cum = float(s['cum'])
|
||||
if not (MIN_TRAIL_PCT <= pct <= MAX_TRAIL_PCT):
|
||||
raise ValueError(f'{i}단계 하락률 {pct}% 는 {MIN_TRAIL_PCT}~{MAX_TRAIL_PCT}% 범위를 벗어남')
|
||||
if pct <= prev_pct:
|
||||
raise ValueError(f'{i}단계 하락률 {pct}% 가 앞 계단({prev_pct}%)보다 깊지 않음')
|
||||
if cum <= prev_cum:
|
||||
raise ValueError(f'{i}단계 누적 비중 {cum}% 가 앞 계단({prev_cum}%)보다 크지 않음')
|
||||
if cum > 100:
|
||||
raise ValueError(f'{i}단계 누적 비중 {cum}% 가 100% 를 넘음')
|
||||
steps.append({'n': i, 'pct': pct, 'cum': cum, 'weight': cum - prev_cum})
|
||||
prev_pct, prev_cum = pct, cum
|
||||
return steps
|
||||
|
||||
|
||||
def allocate_step_qty(total_qty: int, weights: list) -> list:
|
||||
"""계단별 추가 비중(%) → 정수 주수. 최대잔여법(Hare quota).
|
||||
|
||||
내림으로 나눈 뒤 남는 주를 소수부가 큰 계단부터 1주씩 배분한다.
|
||||
"내림 후 마지막 계단에 몰아주기" 보다 얕은 계단이 0주로 죽는 일이 적다
|
||||
(2주 · 20/30/50 → 최대잔여 [0,1,1] 2계단 생존 / 몰아주기 [0,0,2] 1계단).
|
||||
|
||||
수량이 적어 0주가 되는 계단은 그대로 0 을 반환한다 — 발주할 수 없으므로
|
||||
호출측이 걸러내고 "몇 계단으로 줄었는지" 를 사용자에게 알린다.
|
||||
"""
|
||||
if total_qty <= 0 or not weights:
|
||||
return [0] * len(weights)
|
||||
exact = [total_qty * w / 100.0 for w in weights]
|
||||
base = [int(x) for x in exact]
|
||||
remain = int(round(sum(exact))) - sum(base)
|
||||
order = sorted(range(len(weights)), key=lambda i: exact[i] - base[i], reverse=True)
|
||||
for i in order[:max(0, remain)]:
|
||||
base[i] += 1
|
||||
return base
|
||||
|
||||
|
||||
def compute_step_levels(peak: int, steps: list, min_sell_price: Optional[int] = None) -> list:
|
||||
"""고점 + 계단 정의 → 계단별 조건단가·지정가.
|
||||
|
||||
최저 매도가는 모든 계단에 같은 하한으로 걸린다. 그래서 초기에는 여러 계단이 같은
|
||||
가격으로 뭉칠 수 있다(전부 최저 매도가). 그래도 주문을 병합하지 않는 이유는, 고점이
|
||||
올라 트레일 손절선이 최저 매도가를 추월하면 각 계단이 제 하락률대로 다시 벌어지기
|
||||
때문이다 — 병합해 버리면 정정만으로는 다시 쪼갤 수 없다(신규 발주가 필요해진다).
|
||||
"""
|
||||
out = []
|
||||
for s in steps:
|
||||
lv = compute_levels(peak, s['pct'], min_sell_price)
|
||||
out.append({**s, 'cond_uv': lv['cond_uv'], 'ord_uv': lv['ord_uv'],
|
||||
'floor_applied': lv['floor_applied']})
|
||||
return out
|
||||
|
||||
|
||||
def next_step_levels(res: dict, cur_price: int) -> Optional[dict]:
|
||||
"""현재가가 고점을 넘었으면 새 고점과 상향 대상 레그를 반환 (순수 함수 — 단위테스트 대상).
|
||||
|
||||
반환 {'peak': 새 고점, 'steps': [{'n', 'cond_uv', 'ord_uv'}, ...]}.
|
||||
고점이 안 올랐으면 None.
|
||||
|
||||
steps 가 빈 리스트일 수 있다 — 고점은 올랐지만 호가단위 내림이나 최저 매도가 지배로
|
||||
조건단가가 하나도 안 올라간 경우다. 이때 호출측은 정정 API 없이 고점만 갱신한다
|
||||
(불필요한 콜 0, 화면의 고점 표시는 정확하게 유지).
|
||||
"""
|
||||
if cur_price <= 0 or cur_price <= res['peak']:
|
||||
return None
|
||||
if cur_price <= res['peak']:
|
||||
return None
|
||||
lv = compute_levels(cur_price, res['trail_pct'], res.get('min_sell_price'))
|
||||
if lv['cond_uv'] <= res['cond_uv']:
|
||||
return None
|
||||
return {'peak': cur_price, 'cond_uv': lv['cond_uv'], 'ord_uv': lv['ord_uv']}
|
||||
ups = []
|
||||
for s in res.get('steps') or []:
|
||||
lv = compute_levels(cur_price, s['pct'], res.get('min_sell_price'))
|
||||
if lv['cond_uv'] > s['cond_uv']:
|
||||
ups.append({'n': s['n'], 'cond_uv': lv['cond_uv'], 'ord_uv': lv['ord_uv']})
|
||||
return {'peak': cur_price, 'steps': ups}
|
||||
|
||||
|
||||
class _FileLock:
|
||||
@@ -158,36 +251,43 @@ def find_by_symbol(account: str, symbol: str) -> Optional[dict]:
|
||||
return None
|
||||
|
||||
|
||||
def register(ord_no: str, account: str, symbol: str, symbol_name: str, qty: int,
|
||||
trail_pct: float, peak: int, cond_uv: int, ord_uv: int,
|
||||
routing_suffix: str = '', card_id: Optional[str] = None,
|
||||
min_sell_price: Optional[int] = None) -> dict:
|
||||
"""키움에 스톱주문이 접수된 직후 호출 — ord_no 를 받아 예약으로 등록.
|
||||
def register_steps(account: str, symbol: str, symbol_name: str, total_qty: int,
|
||||
peak: int, steps: list, routing_suffix: str = '',
|
||||
card_id: Optional[str] = None,
|
||||
min_sell_price: Optional[int] = None) -> dict:
|
||||
"""키움에 계단별 스톱주문이 접수된 직후 호출 — 레그 목록을 예약 1건으로 등록.
|
||||
|
||||
steps 각 항목은 접수 결과까지 채워져 있어야 한다:
|
||||
{'n', 'pct', 'cum', 'weight', 'qty', 'ord_no', 'cond_uv', 'ord_uv'}
|
||||
|
||||
접수에 실패한 레그는 호출측이 빼고 넘긴다 — 여기 등록된 것은 전부 키움에 살아있는
|
||||
주문이어야 감시 루프의 생존 판정이 성립한다.
|
||||
|
||||
초기 고점(peak)은 등록 시점 현재가다. 과거 고점(52주·매수후) 기준 옵션은 손절선이
|
||||
현재가 위로 올라가 즉시 발동하는 구조라 2026-07-30 제거됐다.
|
||||
"""
|
||||
if not ord_no:
|
||||
raise ValueError('ord_no required — 접수 확인된 주문만 등록')
|
||||
if not steps:
|
||||
raise ValueError('등록할 계단이 없음 — 접수 성공한 레그가 하나도 없다')
|
||||
for s in steps:
|
||||
if not s.get('ord_no'):
|
||||
raise ValueError(f'{s.get("n")}단계 ord_no 없음 — 접수 확인된 주문만 등록')
|
||||
res = {
|
||||
'id': 'TRL-' + ''.join(secrets.choice('ABCDEFGHJKLMNPQRSTUVWXYZ23456789') for _ in range(4)),
|
||||
'ord_no': ord_no,
|
||||
'account': account,
|
||||
'symbol': symbol,
|
||||
'symbol_name': symbol_name,
|
||||
'qty': qty,
|
||||
'trail_pct': trail_pct,
|
||||
'qty': total_qty,
|
||||
'min_sell_price': min_sell_price,
|
||||
'peak': peak,
|
||||
'cond_uv': cond_uv,
|
||||
'ord_uv': ord_uv,
|
||||
'steps': [{'n': s['n'], 'pct': s['pct'], 'cum': s['cum'], 'weight': s['weight'],
|
||||
'qty': s['qty'], 'ord_no': s['ord_no'],
|
||||
'cond_uv': s['cond_uv'], 'ord_uv': s['ord_uv'],
|
||||
'entry_cond_uv': s['cond_uv'], 'modify_count': 0} for s in steps],
|
||||
'routing_suffix': routing_suffix,
|
||||
'card_id': card_id,
|
||||
'created_at': _now_iso(),
|
||||
'updated_at': _now_iso(),
|
||||
'modify_count': 0,
|
||||
'entry_peak': peak,
|
||||
'entry_cond_uv': cond_uv,
|
||||
}
|
||||
with _FileLock(_LOCK_FILE):
|
||||
reservations = _read()
|
||||
@@ -196,25 +296,75 @@ def register(ord_no: str, account: str, symbol: str, symbol_name: str, qty: int,
|
||||
return res
|
||||
|
||||
|
||||
def commit_modify(res_id: str, new_ord_no: str, peak: int, cond_uv: int, ord_uv: int) -> Optional[dict]:
|
||||
"""정정 성공 후 상태 갱신. new_ord_no 는 kt10002 응답의 신규 주문번호.
|
||||
def commit_peak(res_id: str, peak: int) -> Optional[dict]:
|
||||
"""고점만 갱신 (정정할 레그가 없었던 경우). 화면의 고점 표시를 정확하게 유지한다."""
|
||||
with _FileLock(_LOCK_FILE):
|
||||
reservations = _read()
|
||||
for r in reservations:
|
||||
if r['id'] == res_id:
|
||||
r['peak'] = peak
|
||||
r['updated_at'] = _now_iso()
|
||||
_write(reservations)
|
||||
return r
|
||||
return None
|
||||
|
||||
⚠️ ord_no 갱신이 이 함수의 핵심 — 안 하면 다음 정정이 실패한다.
|
||||
|
||||
def commit_step_modify(res_id: str, step_n: int, new_ord_no: str, peak: int,
|
||||
cond_uv: int, ord_uv: int) -> Optional[dict]:
|
||||
"""레그 정정 성공 후 상태 갱신. new_ord_no 는 kt10002 응답의 신규 주문번호.
|
||||
|
||||
⚠️ ord_no 갱신이 이 함수의 핵심 — 안 하면 그 레그의 다음 정정이 전부 실패한다.
|
||||
고점은 예약 단위라 레그마다 같은 값으로 덮어써도 무해하다.
|
||||
"""
|
||||
if not new_ord_no:
|
||||
raise ValueError('new_ord_no required')
|
||||
with _FileLock(_LOCK_FILE):
|
||||
reservations = _read()
|
||||
for r in reservations:
|
||||
if r['id'] == res_id:
|
||||
r['ord_no'] = new_ord_no
|
||||
if r['id'] != res_id:
|
||||
continue
|
||||
for s in r.get('steps') or []:
|
||||
if s['n'] != step_n:
|
||||
continue
|
||||
s['ord_no'] = new_ord_no
|
||||
s['cond_uv'] = cond_uv
|
||||
s['ord_uv'] = ord_uv
|
||||
s['modify_count'] = s.get('modify_count', 0) + 1
|
||||
r['peak'] = peak
|
||||
r['cond_uv'] = cond_uv
|
||||
r['ord_uv'] = ord_uv
|
||||
r['modify_count'] = r.get('modify_count', 0) + 1
|
||||
r['updated_at'] = _now_iso()
|
||||
_write(reservations)
|
||||
return r
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def remove_step(res_id: str, step_n: int, reason: str = '') -> Optional[dict]:
|
||||
"""레그 하나를 예약에서 제거 (체결·취소·소멸). 마지막 레그면 예약 자체를 지운다.
|
||||
|
||||
반환 {'step': 제거된 레그, 'reservation': 예약 스냅샷, 'remaining': 남은 레그 수,
|
||||
'reservation_removed': bool} — 알림 메시지 조립용.
|
||||
"""
|
||||
with _FileLock(_LOCK_FILE):
|
||||
reservations = _read()
|
||||
for i, r in enumerate(reservations):
|
||||
if r['id'] != res_id:
|
||||
continue
|
||||
steps = r.get('steps') or []
|
||||
hit = next((s for s in steps if s['n'] == step_n), None)
|
||||
if hit is None:
|
||||
return None
|
||||
steps.remove(hit)
|
||||
hit['removed_reason'] = reason
|
||||
snapshot = json.loads(json.dumps(r))
|
||||
if not steps:
|
||||
reservations.pop(i)
|
||||
_write(reservations)
|
||||
return {'step': hit, 'reservation': snapshot, 'remaining': 0,
|
||||
'reservation_removed': True}
|
||||
r['updated_at'] = _now_iso()
|
||||
_write(reservations)
|
||||
return {'step': hit, 'reservation': snapshot, 'remaining': len(steps),
|
||||
'reservation_removed': False}
|
||||
return None
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user