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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
hyowons
2026-08-01 02:00:02 +09:00
parent d7042430c3
commit 30be97ab2f
177 changed files with 4124 additions and 3489 deletions
+188 -38
View File
@@ -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×(130/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