372 lines
12 KiB
Python
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())
|