- tick_trader.py를 Controller로 축소, 로직을 3개 모듈로 분리: - core/signal.py: 시그널 감지, 지표 계산 (calc_vr, calc_atr, detect_signal) - core/order.py: Upbit 주문 실행 (매수/매도/취소/조회) - core/position_manager.py: 포지션 관리, DB sync, 복구, 청산 조건 - type hints, Google docstring, 구체적 예외 타입 적용 - 50줄 초과 함수 분리 (process_signal, restore_positions) - 미사용 파일 58개 archive/ 폴더로 이동 - README.md 추가 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
490 lines
19 KiB
Python
490 lines
19 KiB
Python
"""WebSocket 기반 20초봉 트레이더 (Controller).
|
|
|
|
구조:
|
|
WebSocket -> trade tick 수신 -> 20초봉 집계
|
|
-> 시그널(양봉 + VOL>=5x + 사전필터 3종) -> LLM 매수 판단 -> 현재가 지정가 매수
|
|
-> 트레일링 스탑 청산 (고점 -1.5%, 손절 -2%, 타임아웃 4h)
|
|
|
|
실행:
|
|
.venv/bin/python3 daemons/tick_trader.py
|
|
로그:
|
|
/tmp/tick_trader.log
|
|
"""
|
|
import sys
|
|
import os
|
|
import time
|
|
import logging
|
|
import threading
|
|
import requests
|
|
from datetime import datetime
|
|
from collections import deque, defaultdict
|
|
from typing import Optional
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
from dotenv import load_dotenv
|
|
load_dotenv(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), '.env'))
|
|
|
|
from core.llm_advisor import get_entry_price
|
|
from core.signal import detect_signal, calc_vr
|
|
from core.order import (
|
|
round_price, submit_limit_buy, cancel_order,
|
|
check_order_state, sell_market,
|
|
)
|
|
from core.position_manager import (
|
|
sync_position, calc_remaining_budget,
|
|
check_exit_conditions, restore_from_upbit,
|
|
)
|
|
|
|
import pyupbit
|
|
|
|
# ── 전략 파라미터 ──────────────────────────────────────────────────────────────
|
|
TICKERS = [
|
|
'KRW-ETH', 'KRW-XRP', 'KRW-SOL', 'KRW-DOGE', 'KRW-SIGN',
|
|
'KRW-BARD', 'KRW-KITE', 'KRW-CFG', 'KRW-SXP', 'KRW-ARDR',
|
|
]
|
|
|
|
BAR_SEC = 20
|
|
VOL_LOOKBACK = 61
|
|
VOL_MIN = 5.0
|
|
VOL_KRW_MIN = 5_000_000
|
|
BUY_TIMEOUT = 180
|
|
|
|
MAX_POS = int(os.environ.get('MAX_POSITIONS', 5))
|
|
MAX_BUDGET = int(os.environ.get('MAX_BUDGET', 1_000_000))
|
|
PER_POS = MAX_BUDGET // MAX_POS
|
|
FEE = 0.0005
|
|
|
|
TRAIL_PCT = 0.015
|
|
MIN_PROFIT_PCT = 0.005
|
|
STOP_LOSS_PCT = 0.02
|
|
TIMEOUT_SECS = 14400
|
|
|
|
SIM_MODE = os.environ.get('SIMULATION_MODE', 'true').lower() == 'true'
|
|
|
|
upbit_client = pyupbit.Upbit(os.environ['ACCESS_KEY'], os.environ['SECRET_KEY'])
|
|
|
|
TG_TOKEN = os.environ.get('TELEGRAM_TRADE_TOKEN', '')
|
|
TG_CHAT_ID = os.environ.get('TELEGRAM_CHAT_ID', '')
|
|
|
|
# ── 로깅 ──────────────────────────────────────────────────────────────────────
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format='%(asctime)s %(levelname)s %(message)s',
|
|
handlers=[logging.FileHandler('/tmp/tick_trader.log')],
|
|
)
|
|
log = logging.getLogger(__name__)
|
|
|
|
# ── 상태 ──────────────────────────────────────────────────────────────────────
|
|
bars: dict = defaultdict(lambda: deque(maxlen=VOL_LOOKBACK + 10))
|
|
cur_bar: dict = {}
|
|
bar_lock = threading.Lock()
|
|
positions: dict = {}
|
|
pending_buys: dict = {}
|
|
|
|
|
|
# ── 유틸리티 ──────────────────────────────────────────────────────────────────
|
|
def fp(price: float) -> str:
|
|
"""가격 포맷. 100원 미만은 소수점 표시."""
|
|
if price >= 100:
|
|
return f"{price:,.0f}"
|
|
elif price >= 10:
|
|
return f"{price:,.1f}"
|
|
else:
|
|
return f"{price:,.2f}"
|
|
|
|
|
|
def tg(msg: str) -> None:
|
|
"""텔레그램 알림 전송."""
|
|
if not TG_TOKEN or not TG_CHAT_ID:
|
|
return
|
|
try:
|
|
requests.post(
|
|
f'https://api.telegram.org/bot{TG_TOKEN}/sendMessage',
|
|
json={'chat_id': TG_CHAT_ID, 'text': msg, 'parse_mode': 'HTML'},
|
|
timeout=5,
|
|
)
|
|
except (ConnectionError, TimeoutError) as e:
|
|
log.warning(f'Telegram 전송 실패: {e}')
|
|
|
|
|
|
# ── 20초봉 집계 ───────────────────────────────────────────────────────────────
|
|
def _new_bar(price: float, volume: float, ts: datetime) -> dict:
|
|
"""새 봉 초기화."""
|
|
return {'open': price, 'high': price, 'low': price,
|
|
'close': price, 'volume': volume, 'ts': ts}
|
|
|
|
|
|
def on_tick(ticker: str, price: float, volume: float) -> None:
|
|
"""WebSocket tick -> 현재 봉에 반영."""
|
|
with bar_lock:
|
|
if ticker not in cur_bar:
|
|
cur_bar[ticker] = _new_bar(price, volume, datetime.now())
|
|
return
|
|
b = cur_bar[ticker]
|
|
b['high'] = max(b['high'], price)
|
|
b['low'] = min(b['low'], price)
|
|
b['close'] = price
|
|
b['volume'] += volume
|
|
|
|
|
|
def finalize_bars() -> None:
|
|
"""BAR_SEC마다 봉 확정 -> 시그널 감지 -> 매수/청산 처리."""
|
|
while True:
|
|
time.sleep(BAR_SEC)
|
|
now = datetime.now()
|
|
signals = []
|
|
with bar_lock:
|
|
for ticker in list(cur_bar.keys()):
|
|
b = cur_bar[ticker]
|
|
if b['volume'] == 0:
|
|
continue
|
|
bars[ticker].append(b)
|
|
cur_bar[ticker] = _new_bar(b['close'], 0, now)
|
|
if ticker in positions or ticker in pending_buys:
|
|
continue
|
|
if len(positions) + len(pending_buys) >= MAX_POS:
|
|
continue
|
|
sig = detect_signal(
|
|
ticker, list(bars[ticker]),
|
|
vol_min=VOL_MIN, vol_lookback=VOL_LOOKBACK,
|
|
vol_krw_min=VOL_KRW_MIN,
|
|
)
|
|
if sig:
|
|
signals.append(sig)
|
|
for sig in signals:
|
|
process_signal(sig)
|
|
check_pending_buys()
|
|
check_filled_positions()
|
|
|
|
|
|
# ── 매수 처리 ─────────────────────────────────────────────────────────────────
|
|
def process_signal(sig: dict) -> None:
|
|
"""시그널 감지 후 LLM 매수 판단 -> 지정가 매수 제출."""
|
|
ticker = sig['ticker']
|
|
cur_price = sig['price']
|
|
vol_ratio = sig['vol_ratio']
|
|
|
|
if ticker in positions or ticker in pending_buys:
|
|
return
|
|
if len(positions) + len(pending_buys) >= MAX_POS:
|
|
return
|
|
|
|
log.info(f"[시그널] {ticker} {fp(cur_price)}원 vol {vol_ratio:.1f}x -> LLM 판단 요청")
|
|
|
|
llm_result = get_entry_price(
|
|
ticker=ticker, signal=sig, bar_list=sig['bar_list'],
|
|
current_price=cur_price,
|
|
num_positions=len(positions), max_positions=MAX_POS,
|
|
)
|
|
|
|
if llm_result is None or llm_result.get('action') != 'buy':
|
|
_handle_skip(ticker, cur_price, vol_ratio, llm_result)
|
|
return
|
|
|
|
if ticker in positions or ticker in pending_buys:
|
|
return
|
|
if len(positions) + len(pending_buys) >= MAX_POS:
|
|
log.info(f"[매수/LLM] {ticker} -> 승인됐으나 포지션 한도 도달 -> 스킵")
|
|
return
|
|
|
|
_submit_buy(ticker, cur_price, vol_ratio, llm_result)
|
|
|
|
|
|
def _handle_skip(
|
|
ticker: str, price: float, vol_ratio: float,
|
|
llm_result: Optional[dict],
|
|
) -> None:
|
|
"""LLM skip 결과 로깅 + 텔레그램 알림."""
|
|
reason = llm_result.get('reason', 'LLM 오류') if llm_result else 'LLM 무응답'
|
|
status = llm_result.get('market_status', '') if llm_result else ''
|
|
log.info(f"[매수/LLM] {ticker} -> 스킵 | {reason}")
|
|
tg(
|
|
f"⏭️ <b>매수 스킵</b> {ticker}\n"
|
|
f"현재가: {fp(price)}원 볼륨: {vol_ratio:.1f}x\n"
|
|
f"시장: {status}\n"
|
|
f"사유: {reason}"
|
|
)
|
|
|
|
|
|
def _submit_buy(
|
|
ticker: str, cur_price: float, vol_ratio: float,
|
|
llm_result: dict,
|
|
) -> None:
|
|
"""LLM 승인 후 예산 체크 -> 지정가 매수 제출."""
|
|
buy_price = round_price(cur_price)
|
|
confidence = llm_result.get('confidence', '?')
|
|
reason = llm_result.get('reason', '')
|
|
status = llm_result.get('market_status', '')
|
|
|
|
remaining = calc_remaining_budget(positions, pending_buys, MAX_BUDGET)
|
|
invest_amt = min(PER_POS, remaining)
|
|
if invest_amt < 5000:
|
|
log.info(f"[매수/예산부족] {ticker} 남은예산 {remaining:,.0f}원 -> 스킵")
|
|
return
|
|
|
|
qty = invest_amt * (1 - FEE) / buy_price
|
|
log.info(f"[매수/LLM] {ticker} -> 승인 {fp(buy_price)}원 (현재가 매수)")
|
|
|
|
uuid = submit_limit_buy(upbit_client, ticker, buy_price, qty, sim_mode=SIM_MODE)
|
|
if uuid is None:
|
|
return
|
|
|
|
pending_buys[ticker] = {
|
|
'uuid': uuid, 'price': buy_price, 'qty': qty,
|
|
'ts': datetime.now(), 'vol_ratio': vol_ratio,
|
|
}
|
|
invested = int(qty * buy_price)
|
|
sync_position(ticker, 'PENDING_BUY', buy_price=buy_price, qty=qty,
|
|
order_uuid=uuid, invested_krw=invested)
|
|
log.info(f"[지정가매수] {ticker} {fp(buy_price)}원 수량: {qty:.6f}")
|
|
tg(
|
|
f"📥 <b>지정가 매수</b> {ticker}\n"
|
|
f"지정가: {fp(buy_price)}원 투자: {invested:,}원\n"
|
|
f"수량: {qty:.6f} 볼륨: {vol_ratio:.1f}x\n"
|
|
f"확신: {confidence} 시장: {status}\n"
|
|
f"LLM: {reason}\n"
|
|
f"{'[시뮬]' if SIM_MODE else '[실거래]'}"
|
|
)
|
|
|
|
|
|
# ── 체결 확인 ─────────────────────────────────────────────────────────────────
|
|
def check_pending_buys() -> None:
|
|
"""미체결 매수 주문 체결 확인. 타임아웃/한도 초과 시 취소."""
|
|
for ticker in list(pending_buys.keys()):
|
|
pb = pending_buys[ticker]
|
|
elapsed = (datetime.now() - pb['ts']).total_seconds()
|
|
|
|
if len(positions) >= MAX_POS:
|
|
cancel_order(upbit_client, pb['uuid'], sim_mode=SIM_MODE)
|
|
log.info(f"[매수취소] {ticker} 포지션 한도({MAX_POS}) 도달 -> 취소")
|
|
sync_position(ticker, 'IDLE')
|
|
del pending_buys[ticker]
|
|
continue
|
|
|
|
if SIM_MODE:
|
|
bar_list = list(bars.get(ticker, []))
|
|
if bar_list and bar_list[-1]['low'] <= pb['price']:
|
|
log.info(f"[SIM 매수체결] {ticker} {fp(pb['price'])}원")
|
|
_activate_position(ticker, pb['price'], pb['qty'], pb['vol_ratio'])
|
|
del pending_buys[ticker]
|
|
continue
|
|
else:
|
|
state, avg_price = check_order_state(upbit_client, pb['uuid'])
|
|
if state == 'done':
|
|
actual_price = avg_price or pb['price']
|
|
actual_qty = upbit_client.get_balance(ticker.split('-')[1]) or pb['qty']
|
|
_activate_position(ticker, actual_price, actual_qty, pb['vol_ratio'])
|
|
del pending_buys[ticker]
|
|
continue
|
|
|
|
if elapsed >= BUY_TIMEOUT:
|
|
cancel_order(upbit_client, pb['uuid'], sim_mode=SIM_MODE)
|
|
log.info(f"[매수취소] {ticker} {elapsed:.0f}초 미체결 -> 취소")
|
|
tg(f"❌ <b>매수 취소</b> {ticker}\n{fp(pb['price'])}원 {elapsed:.0f}초 미체결")
|
|
sync_position(ticker, 'IDLE')
|
|
del pending_buys[ticker]
|
|
|
|
|
|
def _activate_position(
|
|
ticker: str, entry_price: float, qty: float, vol_ratio: float,
|
|
) -> None:
|
|
"""매수 체결 후 포지션 등록."""
|
|
positions[ticker] = {
|
|
'entry_price': entry_price,
|
|
'entry_ts': datetime.now(),
|
|
'running_peak': entry_price,
|
|
'qty': qty,
|
|
}
|
|
invested = int(qty * entry_price)
|
|
sync_position(ticker, 'PENDING_SELL', buy_price=entry_price,
|
|
qty=qty, invested_krw=invested)
|
|
log.info(f"[진입] {ticker} {fp(entry_price)}원 vol {vol_ratio:.1f}x 트레일 -{TRAIL_PCT*100:.1f}%")
|
|
tg(
|
|
f"🟢 <b>매수 체결</b> {ticker}\n"
|
|
f"체결가: {fp(entry_price)}원 투자: {invested:,}원\n"
|
|
f"트레일: 고점 대비 -{TRAIL_PCT*100:.1f}% / 손절: -{STOP_LOSS_PCT*100:.1f}%\n"
|
|
f"{'[시뮬]' if SIM_MODE else '[실거래]'}"
|
|
)
|
|
|
|
|
|
# ── 포지션 관리 ───────────────────────────────────────────────────────────────
|
|
def _record_exit(ticker: str, exit_price: float, tag: str) -> None:
|
|
"""포지션 청산 기록 + 텔레그램 알림."""
|
|
pos = positions[ticker]
|
|
pnl = (exit_price - pos['entry_price']) / pos['entry_price'] * 100
|
|
krw = PER_POS * (pnl / 100) - PER_POS * FEE * 2
|
|
held = int((datetime.now() - pos['entry_ts']).total_seconds())
|
|
|
|
reason_tag = {
|
|
'trail': '트레일스탑', 'timeout': '타임아웃',
|
|
'stoploss': '손절', 'llm': 'LLM 매도',
|
|
}.get(tag, tag)
|
|
|
|
icon = "✅" if pnl > 0 else "🔴"
|
|
invested = int(pos['qty'] * pos['entry_price'])
|
|
log.info(f"[청산/{tag}] {ticker} {fp(exit_price)}원 PNL {pnl:+.2f}% {krw:+,.0f}원 {held}초 보유")
|
|
tg(
|
|
f"{icon} <b>청산</b> {ticker} [{reason_tag}]\n"
|
|
f"투자: {invested:,}원\n"
|
|
f"진입: {fp(pos['entry_price'])}원 -> 청산: {fp(exit_price)}원\n"
|
|
f"PNL: <b>{pnl:+.2f}%</b> ({krw:+,.0f}원) {held}초 보유\n"
|
|
f"{'[시뮬]' if SIM_MODE else '[실거래]'}"
|
|
)
|
|
sync_position(ticker, 'IDLE')
|
|
del positions[ticker]
|
|
|
|
|
|
def _try_exit(ticker: str, price: float) -> None:
|
|
"""청산 조건 체크 후 시장가 매도 실행."""
|
|
pos = positions[ticker]
|
|
pos['running_peak'] = max(pos['running_peak'], price)
|
|
|
|
tag = check_exit_conditions(
|
|
pos, price,
|
|
trail_pct=TRAIL_PCT, min_profit_pct=MIN_PROFIT_PCT,
|
|
stop_loss_pct=STOP_LOSS_PCT, timeout_secs=TIMEOUT_SECS,
|
|
)
|
|
if tag is None:
|
|
return
|
|
|
|
exit_price = sell_market(upbit_client, ticker, pos['qty'], sim_mode=SIM_MODE) or price
|
|
if tag == 'trail':
|
|
peak_pnl = (pos['running_peak'] - pos['entry_price']) / pos['entry_price'] * 100
|
|
drop = (pos['running_peak'] - price) / pos['running_peak'] * 100
|
|
log.info(f"[트레일] {ticker} 고점 {fp(pos['running_peak'])}원(+{peak_pnl:.1f}%) -> {fp(price)}원 drop {drop:.2f}%")
|
|
elif tag == 'stoploss':
|
|
profit = (price - pos['entry_price']) / pos['entry_price'] * 100
|
|
log.info(f"[손절] {ticker} {fp(price)}원 (진입 대비 {profit:+.2f}%)")
|
|
elif tag == 'timeout':
|
|
elapsed = (datetime.now() - pos['entry_ts']).total_seconds()
|
|
log.info(f"[타임아웃] {ticker} {elapsed:.0f}초 경과")
|
|
|
|
_record_exit(ticker, exit_price, tag)
|
|
|
|
|
|
def check_filled_positions() -> None:
|
|
"""20초마다 포지션 체크: 트레일링 스탑 / 손절 / 타임아웃."""
|
|
for ticker in list(positions.keys()):
|
|
if ticker not in positions:
|
|
continue
|
|
bar_list = list(bars.get(ticker, []))
|
|
if not bar_list:
|
|
continue
|
|
_try_exit(ticker, bar_list[-1]['close'])
|
|
|
|
|
|
def update_positions(current_prices: dict) -> None:
|
|
"""tick마다 실시간 peak 갱신 + 손절/트레일 체크."""
|
|
for ticker in list(positions.keys()):
|
|
if ticker not in current_prices:
|
|
continue
|
|
_try_exit(ticker, current_prices[ticker])
|
|
|
|
|
|
# ── 초기화 ────────────────────────────────────────────────────────────────────
|
|
def preload_bars() -> None:
|
|
"""REST API 1분봉으로 bars[] 사전 적재."""
|
|
need_min = (VOL_LOOKBACK + 10) // 3 + 1
|
|
log.info(f"[사전적재] REST API 1분봉 {need_min}개로 bars[] 초기화 중...")
|
|
loaded = 0
|
|
for ticker in TICKERS:
|
|
for attempt in range(3):
|
|
try:
|
|
df = pyupbit.get_ohlcv(ticker, interval='minute1', count=need_min)
|
|
if df is None or df.empty:
|
|
time.sleep(0.5)
|
|
continue
|
|
with bar_lock:
|
|
for _, row in df.iterrows():
|
|
o, h, l, c = float(row['open']), float(row['high']), float(row['low']), float(row['close'])
|
|
v3 = float(row['volume']) / 3
|
|
ts = row.name.to_pydatetime()
|
|
for _ in range(3):
|
|
bars[ticker].append({'open': o, 'high': h, 'low': l, 'close': c, 'volume': v3, 'ts': ts})
|
|
loaded += 1
|
|
break
|
|
except (ConnectionError, TimeoutError, ValueError) as e:
|
|
log.warning(f"[사전적재] {ticker} 시도{attempt+1} 실패: {e}")
|
|
time.sleep(1)
|
|
time.sleep(0.2)
|
|
log.info(f"[사전적재] 완료 {loaded}/{len(TICKERS)} 티커")
|
|
|
|
|
|
def restore_positions() -> None:
|
|
"""Upbit 잔고에서 포지션 + 미체결 매수 복구."""
|
|
if SIM_MODE:
|
|
return
|
|
try:
|
|
restore_from_upbit(
|
|
upbit_client, TICKERS, positions, pending_buys,
|
|
cancel_fn=lambda uuid: cancel_order(upbit_client, uuid, sim_mode=SIM_MODE),
|
|
fp_fn=fp, tg_fn=tg,
|
|
)
|
|
except (ConnectionError, TimeoutError, ValueError) as e:
|
|
log.warning(f"[복구] 잔고 조회 실패: {e}", exc_info=True)
|
|
|
|
|
|
# ── 메인 ──────────────────────────────────────────────────────────────────────
|
|
def main() -> None:
|
|
"""tick_trader 메인 루프."""
|
|
mode = "🔴 실거래" if not SIM_MODE else "🟡 시뮬레이션"
|
|
log.info(f"=== tick_trader 시작 ({mode}) ===")
|
|
log.info(f"봉주기: 20초 | VOL >= {VOL_MIN}x | 포지션 최대 {MAX_POS}개 | 1개당 {PER_POS:,}원")
|
|
log.info(f"청산: 트레일 고점-{TRAIL_PCT*100:.1f}% (최소익 +{MIN_PROFIT_PCT*100:.1f}%) | 손절 -{STOP_LOSS_PCT*100:.1f}% | 타임아웃 {TIMEOUT_SECS//3600}h")
|
|
tg(
|
|
f"🚀 <b>tick_trader 시작</b> ({mode})\n"
|
|
f"예산: {MAX_BUDGET:,}원 | 최대 {MAX_POS}포지션 | 종목당 {PER_POS:,}원\n"
|
|
f"VOL >= {VOL_MIN}x | 거래대금 >= {VOL_KRW_MIN/1e6:.0f}M | 연속양봉 >= 2\n"
|
|
f"트레일: 고점 -{TRAIL_PCT*100:.1f}% (최소 +{MIN_PROFIT_PCT*100:.1f}%)\n"
|
|
f"손절: -{STOP_LOSS_PCT*100:.1f}% | 타임아웃: {TIMEOUT_SECS//3600}h"
|
|
)
|
|
|
|
preload_bars()
|
|
restore_positions()
|
|
|
|
t = threading.Thread(target=finalize_bars, daemon=True)
|
|
t.start()
|
|
|
|
ws = pyupbit.WebSocketManager("trade", TICKERS)
|
|
log.info("WebSocket 연결됨")
|
|
|
|
last_pos_log = time.time()
|
|
|
|
while True:
|
|
try:
|
|
data = ws.get()
|
|
if data is None:
|
|
continue
|
|
|
|
ticker = data.get('code')
|
|
price = data.get('trade_price')
|
|
volume = data.get('trade_volume')
|
|
|
|
if not ticker or price is None or volume is None:
|
|
continue
|
|
|
|
on_tick(ticker, float(price), float(volume))
|
|
|
|
if positions:
|
|
update_positions({ticker: float(price)})
|
|
|
|
if time.time() - last_pos_log > 60:
|
|
warmed = sum(1 for t in TICKERS if len(bars[t]) >= VOL_LOOKBACK + 5)
|
|
if positions:
|
|
pos_lines = ' '.join(
|
|
f"{t.split('-')[1]} {p['entry_price']:,.0f}->{p['running_peak']:,.0f} ({(p['running_peak']-p['entry_price'])/p['entry_price']*100:+.1f}%)"
|
|
for t, p in positions.items()
|
|
)
|
|
log.info(f"[상태] 포지션 {len(positions)}/{MAX_POS} {pos_lines}")
|
|
else:
|
|
log.info(f"[상태] 포지션 없음 ({warmed}/{len(TICKERS)} 준비완료)")
|
|
last_pos_log = time.time()
|
|
|
|
except Exception as e:
|
|
log.error(f"루프 오류: {e}")
|
|
time.sleep(1)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|