Files
backup_openclaw/projects/auto-trader/auto_trader.py
2026-03-30 19:30:25 +09:00

372 lines
12 KiB
Python

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())