chore: update workspace config and memory
This commit is contained in:
371
projects/auto-trader/auto_trader.py
Normal file
371
projects/auto-trader/auto_trader.py
Normal file
@@ -0,0 +1,371 @@
|
||||
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())
|
||||
Reference in New Issue
Block a user