import asyncio import json import time from datetime import datetime from pathlib import Path from typing import Any, Dict, List, Tuple BASE = Path('/home/arin/.openclaw/workspace/projects/auto-trader') CONFIG_PATH = BASE / 'strategy_config.json' STATE_PATH = BASE / 'state.json' SIGNALS_PATH = BASE / 'signals.jsonl' ORDERS_PATH = BASE / 'orders.jsonl' ALERTS_PATH = BASE / 'alerts.jsonl' import sys sys.path.insert(0, '/home/arin/.openclaw/workspace/KIS_MCP_Server') from server import inquery_stock_price, inquery_balance, order_stock # type: ignore LAST_API_CALL_AT = 0.0 def load_json(path: Path, default: Any): if not path.exists(): return default try: return json.loads(path.read_text(encoding='utf-8')) except Exception: return default def save_json(path: Path, data: Any) -> None: path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding='utf-8') def append_jsonl(path: Path, row: Dict[str, Any]) -> None: with open(path, 'a', encoding='utf-8') as f: f.write(json.dumps(row, ensure_ascii=False) + '\n') def to_int(v: Any) -> int: try: return int(str(v).replace(',', '').strip()) except Exception: return 0 def to_float(v: Any) -> float: try: return float(str(v).replace(',', '').strip()) except Exception: return 0.0 def now_ts() -> str: return datetime.now().strftime('%Y-%m-%d %H:%M:%S') def get_api_interval_seconds(cfg: Dict[str, Any]) -> float: return max(0.0, float(cfg.get('api_call_interval_ms', 300)) / 1000.0) def get_retry_count(cfg: Dict[str, Any]) -> int: return max(0, int(cfg.get('api_retry_count', 2))) def get_retry_backoff_seconds(cfg: Dict[str, Any]) -> float: return max(0.0, float(cfg.get('api_retry_backoff_ms', 600)) / 1000.0) async def throttle_api_call(cfg: Dict[str, Any]) -> None: global LAST_API_CALL_AT interval = get_api_interval_seconds(cfg) now = time.monotonic() wait_for = interval - (now - LAST_API_CALL_AT) if wait_for > 0: await asyncio.sleep(wait_for) LAST_API_CALL_AT = time.monotonic() async def call_with_retry(func, *args, cfg: Dict[str, Any], **kwargs): retries = get_retry_count(cfg) backoff = get_retry_backoff_seconds(cfg) last_error = None for attempt in range(retries + 1): try: await throttle_api_call(cfg) return await func(*args, **kwargs) except Exception as e: last_error = e if attempt >= retries: break await asyncio.sleep(backoff * (attempt + 1)) raise last_error def market_is_open(now: datetime | None = None) -> bool: now = now or datetime.now() if now.weekday() >= 5: return False hhmm = now.hour * 100 + now.minute return 900 <= hhmm <= 1530 async def fetch_balance_map(cfg: Dict[str, Any]) -> Dict[str, Dict[str, Any]]: raw = await call_with_retry(inquery_balance, cfg=cfg) items = raw.get('output1', []) if isinstance(raw, dict) else [] result = {} for item in items: qty = to_int(item.get('hldg_qty', 0)) if qty <= 0: continue symbol = item.get('pdno') if not symbol: continue result[symbol] = { 'symbol': symbol, 'name': item.get('prdt_name', ''), 'qty': qty, 'avg_price': to_float(item.get('pchs_avg_pric', 0)), 'profit_rate': to_float(item.get('evlu_pfls_rt', 0)), 'profit_amount': to_int(item.get('evlu_pfls_amt', 0)), 'position_value': to_int(item.get('evlu_amt', 0)), } return result async def fetch_quote(symbol: str, cfg: Dict[str, Any]) -> Dict[str, Any]: q = await call_with_retry(inquery_stock_price, symbol, cfg=cfg) return { 'symbol': symbol, 'name': q.get('hts_kor_isnm', ''), 'price': to_int(q.get('stck_prpr', 0)), 'open': to_int(q.get('stck_oprc', 0)), 'high': to_int(q.get('stck_mxpr', 0)), 'low': to_int(q.get('stck_llam', 0)), 'prev_close': to_int(q.get('stck_prdy_clpr', 0)), 'change_pct': to_float(q.get('prdy_ctrt', 0)), 'volume': to_int(q.get('acml_vol', 0)), 'trading_value': to_int(q.get('acml_tr_pbmn', 0)), } def pct(a: float, b: float) -> float: if not b: return 0.0 return (a - b) / b * 100.0 def eval_buy_rules(quote: Dict[str, Any], holding: Dict[str, Any] | None, cfg: Dict[str, Any], state: Dict[str, Any]) -> List[str]: rules = cfg['rules'] signals: List[str] = [] price = quote['price'] open_ = quote['open'] high = quote['high'] low = quote['low'] prev_close = quote['prev_close'] if holding: return signals r1 = rules['buy_gap_strength'] if r1['enabled']: if pct(price, prev_close) >= r1['min_pct_vs_prev_close'] and (not r1['require_above_open'] or price > open_): signals.append('buy_gap_strength') r2 = rules['buy_reclaim_after_dip'] if r2['enabled']: dipped = pct(low, open_) <= -abs(r2['dip_below_open_pct']) reclaimed = price > open_ if r2['reclaim_above_open'] else True rebound = pct(price, low) >= r2['rebound_from_low_pct'] if dipped and reclaimed and rebound: signals.append('buy_reclaim_after_dip') r3 = rules['buy_near_day_high'] if r3['enabled'] and high > 0: distance_from_high = (high - price) / high * 100.0 positive = quote['change_pct'] > 0 if r3['require_positive_day'] else True if distance_from_high <= r3['max_distance_from_high_pct'] and positive: signals.append('buy_near_day_high') symbol_state = state.setdefault(quote['symbol'], {'buy_count_today': 0, 'last_buy_date': None}) today = datetime.now().strftime('%Y-%m-%d') if symbol_state.get('last_buy_date') != today: symbol_state['buy_count_today'] = 0 if symbol_state.get('buy_count_today', 0) >= cfg['max_daily_buys_per_symbol']: return [] return signals def eval_sell_rules(quote: Dict[str, Any], holding: Dict[str, Any] | None, cfg: Dict[str, Any]) -> List[str]: if not holding: return [] rules = cfg['rules'] signals: List[str] = [] profit_rate = holding.get('profit_rate', 0.0) price = quote['price'] open_ = quote['open'] r4 = rules['sell_take_profit'] if r4['enabled'] and profit_rate >= r4['take_profit_pct']: signals.append('sell_take_profit') r5 = rules['sell_stop_loss_or_fade'] fade_pct = pct(price, open_) if r5['enabled'] and (profit_rate <= r5['stop_loss_pct'] or fade_pct <= r5['fade_from_open_pct']): signals.append('sell_stop_loss_or_fade') return signals def calc_buy_qty(price: int, cfg: Dict[str, Any], holding: Dict[str, Any] | None) -> int: if price <= 0: return 0 budget = int(cfg['buy_budget_per_trade']) max_pos = int(cfg['max_position_value_per_symbol']) current_value = int(holding['position_value']) if holding else 0 room = max(0, max_pos - current_value) usable = min(budget, room) qty = usable // price return max(0, qty) def make_alert_key(symbol: str, side: str, reasons: List[str], ts: str) -> str: minute = ts[:16] return f"{minute}|{symbol}|{side}|{'/'.join(sorted(reasons))}" def maybe_record_alert(symbol: str, name: str, side: str, reasons: List[str], quote: Dict[str, Any], state: Dict[str, Any]) -> Dict[str, Any] | None: if not reasons: return None ts = now_ts() key = make_alert_key(symbol, side, reasons, ts) alert_state = state.setdefault('_alerts', {}) if alert_state.get('last_key') == key: return None alert = { 'time': ts, 'key': key, 'symbol': symbol, 'name': name, 'side': side, 'reasons': reasons, 'price': quote.get('price', 0), 'change_pct': quote.get('change_pct', 0), } append_jsonl(ALERTS_PATH, alert) alert_state['last_key'] = key return alert async def execute_buy(symbol: str, quote: Dict[str, Any], reasons: List[str], cfg: Dict[str, Any], state: Dict[str, Any]) -> Dict[str, Any]: qty = calc_buy_qty(quote['price'], cfg, None) if qty <= 0: return {'action': 'buy_skipped', 'symbol': symbol, 'reason': 'budget_or_position_limit', 'time': now_ts()} row = { 'time': now_ts(), 'action': 'buy', 'symbol': symbol, 'name': quote['name'], 'qty': qty, 'price': quote['price'], 'reasons': reasons, 'dry_run': not cfg['enable_orders'], } if cfg['enable_orders']: row['result'] = await call_with_retry(order_stock, symbol, qty, 0, 'buy', cfg=cfg) append_jsonl(ORDERS_PATH, row) symbol_state = state.setdefault(symbol, {'buy_count_today': 0, 'last_buy_date': None}) symbol_state['buy_count_today'] = int(symbol_state.get('buy_count_today', 0)) + 1 symbol_state['last_buy_date'] = datetime.now().strftime('%Y-%m-%d') return row async def execute_sell(symbol: str, quote: Dict[str, Any], holding: Dict[str, Any], reasons: List[str], cfg: Dict[str, Any]) -> Dict[str, Any]: qty = int(holding['qty']) if cfg.get('sell_all_on_signal', True) else 1 row = { 'time': now_ts(), 'action': 'sell', 'symbol': symbol, 'name': quote['name'], 'qty': qty, 'price': quote['price'], 'reasons': reasons, 'dry_run': not cfg['enable_orders'], } if cfg['enable_orders']: row['result'] = await call_with_retry(order_stock, symbol, qty, 0, 'sell', cfg=cfg) append_jsonl(ORDERS_PATH, row) return row async def run_once() -> Dict[str, Any]: cfg = load_json(CONFIG_PATH, {}) state = load_json(STATE_PATH, {}) balance_map = await fetch_balance_map(cfg) results = [] for item in cfg.get('symbols', []): if not item.get('enabled', True): continue symbol = item['symbol'] holding = balance_map.get(symbol) try: quote = await fetch_quote(symbol, cfg) except Exception as e: error_row = { 'time': now_ts(), 'symbol': symbol, 'name': item.get('name', ''), 'error': str(e), } append_jsonl(SIGNALS_PATH, error_row) results.append(error_row) continue buy_reasons = eval_buy_rules(quote, holding, cfg, state) sell_reasons = eval_sell_rules(quote, holding, cfg) signal_row = { 'time': now_ts(), 'symbol': symbol, 'name': quote['name'], 'quote': quote, 'holding': holding, 'buy_reasons': buy_reasons, 'sell_reasons': sell_reasons, } append_jsonl(SIGNALS_PATH, signal_row) action = None if market_is_open(): if sell_reasons and holding: maybe_record_alert(symbol, quote['name'], 'sell', sell_reasons, quote, state) action = await execute_sell(symbol, quote, holding, sell_reasons, cfg) elif buy_reasons and not holding: maybe_record_alert(symbol, quote['name'], 'buy', buy_reasons, quote, state) action = await execute_buy(symbol, quote, buy_reasons, cfg, state) else: action = {'action': 'market_closed', 'symbol': symbol, 'time': now_ts()} results.append({ 'symbol': symbol, 'name': quote['name'], 'price': quote['price'], 'buy_reasons': buy_reasons, 'sell_reasons': sell_reasons, 'action': action, }) save_json(STATE_PATH, state) return {'time': now_ts(), 'mode': cfg.get('mode'), 'enable_orders': cfg.get('enable_orders'), 'results': results} async def main(): cfg = load_json(CONFIG_PATH, {}) poll_seconds = int(cfg.get('poll_seconds', 60)) while True: try: result = await run_once() print(json.dumps(result, ensure_ascii=False)) except Exception as e: print(json.dumps({'time': now_ts(), 'error': str(e)}, ensure_ascii=False)) await asyncio.sleep(poll_seconds) if __name__ == '__main__': asyncio.run(main())