Files
raptor-trading/final_bot.py

319 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import os, json, time, uuid, logging, requests, threading, csv
from datetime import datetime, timedelta, timezone, time as dtime
from flask import Flask, render_template_string
from dotenv import load_dotenv
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
load_dotenv(os.path.join(BASE_DIR, "tok.env"))
logging.basicConfig(
level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s",
handlers=[logging.FileHandler(os.path.join(BASE_DIR, "bot.log")), logging.StreamHandler()]
)
os.environ['SSL_CERT_FILE'] = '/etc/ssl/certs/ca-certificates.crt'
os.environ['GRPC_DEFAULT_SSL_ROOTS_FILE_PATH'] = '/etc/ssl/certs/ca-certificates.crt'
from t_tech.invest import Client, OrderDirection, OrderType, CandleInterval, InstrumentIdType
MSK_TZ, STATE_FILE, CONFIG_FILE, TRADE_LOG, TELEMETRY_LOG = timezone(timedelta(hours=3)), os.path.join(BASE_DIR, "state_prod.json"), os.path.join(BASE_DIR, "config.json"), os.path.join(BASE_DIR, "trades.csv"), os.path.join(BASE_DIR, "market_telemetry.csv")
state_lock, TG_PROXY = threading.Lock(), "https://muddy-firefly-3862.y-afanasiev.workers.dev"
def q_to_float(q): return q.units + q.nano / 1e9 if q else 0.0
def load_config():
try:
with open(CONFIG_FILE, 'r') as f: return json.load(f)
# FIX: Добавлен ACCOUNT_ID в fallback для защиты от KeyError
except: return {"TICKER": "UNKNOWN", "WEB_PORT": 5000, "ACCOUNT_ID": ""}
def save_state_atomic(state):
tmp = STATE_FILE + ".tmp"
with state_lock:
try:
with open(tmp, 'w') as f: json.dump(state, f, indent=2)
os.replace(tmp, STATE_FILE)
except Exception as e: logging.error(f"Save Error: {e}")
def send_tg(msg, level="INFO"):
token, chat_id = os.getenv("TG_TOKEN"), os.getenv("TG_CHAT_ID")
if not token or not chat_id: return
icons = {"TRADE":"🛒","PROFIT":"💰","LOSS":"🩸","CRIT":"🚨","INFO":"🛰️","AI":"🧠"}
try: requests.post(f"{TG_PROXY}/bot{token}/sendMessage", json={"chat_id": chat_id, "text": f"{icons.get(level,'🤖')} *{level}*\n{msg}", "parse_mode": "Markdown"}, timeout=15)
except: pass
def send_tg_report(file_path, caption=""):
token, chat_id = os.getenv("TG_TOKEN"), os.getenv("TG_CHAT_ID")
if not token or not chat_id or not os.path.exists(file_path): return
try:
with open(file_path, 'rb') as f:
requests.post(f"{TG_PROXY}/bot{token}/sendDocument", data={"chat_id": chat_id, "caption": caption}, files={"document": f}, timeout=30)
except: pass
def calc_rsi(closes, period=14):
if len(closes) < period + 1: return 50.0
gains, losses = [max(closes[i] - closes[i-1], 0) for i in range(1, len(closes))], [max(closes[i-1] - closes[i], 0) for i in range(1, len(closes))]
ag, al = sum(gains[-period:]) / period, sum(losses[-period:]) / period
return round(100 - 100 / (1 + ag / al), 1) if al > 0 else 100.0
def log_trade(action, price, lots, shares, reason="", pnl=0):
exists = os.path.isfile(TRADE_LOG)
with open(TRADE_LOG, 'a', newline='') as f:
w = csv.writer(f)
if not exists: w.writerow(["Date","Action","Price","Lots","Shares","Reason","PnL"])
w.writerow([datetime.now(MSK_TZ).strftime("%Y-%m-%d %H:%M:%S"), action, price, lots, shares, reason, pnl])
def log_telemetry(ticker, price, rsi, trend, spread, vola):
exists = os.path.isfile(TELEMETRY_LOG)
with open(TELEMETRY_LOG, 'a', newline='') as f:
w = csv.writer(f)
if not exists: w.writerow(["Time","Ticker","Price","RSI","Trend","Spread","Vola%"])
w.writerow([datetime.now(MSK_TZ).strftime("%H:%M:%S"), ticker, price, rsi, trend, spread, vola])
def generate_ai_prompt(conf):
prompt_file = os.path.join(BASE_DIR, "AI_PROMPT_WEEKLY.txt")
try:
with open(prompt_file, 'w') as f:
f.write("SYSTEM ROLE: Квантовый финансовый аналитик.\n")
f.write(f"TASK: Проанализировать работу торгового бота RAPTOR для тикера {conf.get('TICKER', 'UNKNOWN')}.\n\n")
f.write("--- CURRENT CONFIG ---\n")
# FIX: Вырезаем приватные данные перед отправкой ИИ
safe_conf = {k: v for k, v in conf.items() if k not in ('ACCOUNT_ID', 'TINKOFF_TOKEN')}
f.write(json.dumps(safe_conf, indent=2) + "\n\n")
f.write("--- LAST TRADES ---\n")
if os.path.exists(TRADE_LOG):
with open(TRADE_LOG, 'r') as tl: f.writelines(tl.readlines()[-10:])
f.write("\n--- MARKET CONTEXT (Last Telemetry) ---\n")
if os.path.exists(TELEMETRY_LOG):
with open(TELEMETRY_LOG, 'r') as ml: f.writelines(ml.readlines()[-10:])
f.write("\nQUESTION: На основе данных выше, предложи оптимизацию ATR_COEFF или STEP_DISTANCE_ATR.\n")
return prompt_file
except: return None
app = Flask(__name__)
@app.route('/')
def index():
now_tz = datetime.now(MSK_TZ)
# FIX: Оставили работу в выходные (без now_tz.weekday() > 4)
is_open = any([dtime(7, 0) <= now_tz.time() <= dtime(18, 45), dtime(19, 0) <= now_tz.time() <= dtime(23, 50)])
time_str = now_tz.strftime("%H:%M:%S")
def get_st(p):
# FIX: Добавлена last_vola в дефолтный словарь
d = {"phase":"OFFLINE","live_price":0,"total_shares":0,"current_step":0,"avg_price":0,"target_act":0,"profit_goal":0,"pnl":0,"daily_pnl":0,"deals_today":0,"trend":"N/A","last_rsi":50,"filter_status":"N/A","stop_loss":0,"last_atr":0,"last_vola":0.0}
try:
if os.path.exists(p):
with open(p, 'r') as f: data = json.load(f); d.update(data)
sh, lp, ap = d.get('total_shares', 0), d.get('live_price', 0), d.get('avg_price', 0)
d['pnl'] = round((lp - ap) * sh, 2) if sh > 0 else 0
if d['phase'] == "SELL" and d.get('profit_goal', 0) > 0:
d['progress'] = round(min(100, max(0, (lp - ap) / (d['profit_goal'] - ap) * 100)), 1)
elif d['phase'] == "BUY" and d.get('target_act', 0) > 0:
base = d.get('base_price', lp)
if base - d['target_act'] != 0: d['progress'] = round(min(100, max(0, (base - lp) / (base - d['target_act']) * 100)), 1)
else: d['progress'] = 0
else: d['progress'] = 0
except: pass
return d
sber = get_st("/root/sber_bot/state_prod.json")
gmkn = get_st("/root/gmkn_bot/state_prod.json")
return render_template_string("""<!DOCTYPE html><html lang="ru"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1"><title>RAPTOR v18.4 UI</title><meta http-equiv="refresh" content="5">
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🚀</text></svg>">
<style>
body{background:#0a0a0b;color:#eee;font-family:sans-serif;padding:12px;display:flex;justify-content:center}
.card{background:#161618;padding:20px;border-radius:18px;border:1px solid #2a2a2a;width:100%;max-width:360px;margin:8px}
.price{text-align:center;font-size:3rem;font-weight:bold;margin:8px 0}
.pnl{text-align:center;font-weight:bold;font-size:1.2rem;margin-bottom:10px}
.row{display:flex;justify-content:space-between;font-size:.82rem;color:#666;padding:3px 0;border-bottom:1px solid #1c1c1c}.row b{color:#ccc}
.progress-bg{background:#222;height:6px;border-radius:3px;margin:10px 0;overflow:hidden}
.progress-bar{background:cyan;height:100%;transition:width 0.5s}
@keyframes blink { 50% { opacity: 0.3; } }
.live-dot { animation: blink 1.5s linear infinite; font-size: 1.2rem; vertical-align: middle; }
</style></head><body><div style="max-width:820px;width:100%">
<h2 style="text-align:center;color:#444;letter-spacing:3px;margin-bottom:5px;">RAPTOR v18.4</h2>
<div style="text-align:center; margin-bottom:20px; font-size:0.9rem; background:#111; padding:8px; border-radius:10px; border:1px solid #222;">
{% if is_open %}
<span class="live-dot" style="color:#00e676;">🟢</span> <b style="color:#00e676; margin-right:15px;">РЫНОК ОТКРЫТ (Торги выходного дня)</b>
{% else %}
<span style="color:#ff5252;">🔴</span> <b style="color:#ff5252; margin-right:15px;">РЫНОК ЗАКРЫТ</b>
{% endif %}
<span style="color:#888;">Сервер (МСК): <b style="color:#fff;">{{ time_str }}</b></span>
</div>
<div style="display:flex;flex-wrap:wrap;justify-content:center">
{% for n, d in [('SBER', sber), ('GMKN', gmkn)] %}
<div class="card"><div style="display:flex;justify-content:space-between"><b style="color:cyan">{{ n }}</b><span style="color:{{ 'cyan' if d.phase=='BUY' else 'orange' }}">{{ d.phase }} [ст.{{ d.current_step }}]</span></div>
<div class="price">{{ d.live_price }}</div>
<div class="pnl" style="color:{{ '#00e676' if d.pnl >= 0 else '#ff5252' }}">{{ d.pnl }} ₽</div>
<div class="progress-bg"><div class="progress-bar" style="width:{{ d.progress }}%; background:{{ 'cyan' if d.phase=='BUY' else '#4fc3f7' }}"></div></div>
<div style="border-top:1px solid #222;padding-top:10px">
<div class="row"><span>Avg / Stop</span><b>{{ d.avg_price }} / <span style="color:#ff5252">{{ d.stop_loss }}</span></b></div>
<div class="row"><span>Target (TP)</span><b style="color:#4fc3f7">{{ d.profit_goal }}</b></div>
<div class="row"><span>Следующий вход</span><b>{{ d.target_act }}</b></div>
<div class="row"><span>ATR / RSI / Vola</span><b>{{ d.last_atr }} / {{ d.last_rsi }} / {{ d.last_vola }}%</b></div>
<div class="row"><span>Тренд / Фильтр</span><b style="color:{{ '#00e676' if d.trend=='UP' else '#ff9800' }}">{{ d.trend }}</b> / <b style="color:{{ '#00e676' if d.filter_status=='OK' else '#ff9800' }}">{{ d.filter_status }}</b></div>
<div class="row"><span>Сделок / P&L дня</span><b>{{ d.deals_today }} / <span style="color:{{ '#00e676' if d.daily_pnl >= 0 else '#ff5252' }}">{{ d.daily_pnl }} ₽</span></b></div>
</div></div>{% endfor %}</div></div></body></html>""", sber=sber, gmkn=gmkn, is_open=is_open, time_str=time_str)
def is_market_open():
now = datetime.now(MSK_TZ)
t = now.time()
# FIX: Торги выходного дня (работает 7 дней в неделю)
return any([dtime(7, 0) <= t <= dtime(18, 45), dtime(19, 0) <= t <= dtime(23, 50)])
def run_bot():
conf = load_config()
acc_id = conf.get("ACCOUNT_ID", "")
if not acc_id: logging.warning("ACCOUNT_ID не задан в config.json!")
state = {"phase": "BUY", "current_step": 0, "base_price": None, "live_price": 0, "total_shares": 0, "total_lots": 0, "avg_price": 0, "tp_activated": False, "target_act": 0, "profit_goal": 0, "stop_loss": 0, "max_p": 0, "buy_time_ts": 0, "last_sell_ts": 0, "deals_today": 0, "daily_pnl": 0.0, "last_date": str(datetime.now(MSK_TZ).date()), "trend": "WAIT", "last_rsi": 50, "filter_status": "INIT", "lot_size": 1, "last_atr": 0.0, "last_vola": 0.0, "reported": -1, "partial_sold": False, "ai_reported": -1}
if os.path.exists(STATE_FILE):
try:
with open(STATE_FILE, 'r') as f: state.update(json.load(f))
except: pass
with Client(os.getenv("TINKOFF_TOKEN"), target="invest-public-api.tbank.ru:443") as client:
try:
instr = client.instruments.share_by(id_type=InstrumentIdType.INSTRUMENT_ID_TYPE_TICKER, class_code=conf.get("CLASS_CODE", "TQBR"), id=conf.get("TICKER", "UNKNOWN")).instrument
target_uid, lot_size, min_inc = instr.uid, instr.lot, q_to_float(instr.min_price_increment)
state['lot_size'] = lot_size
except Exception as e:
logging.error(f"Init Error: {e}"); return
last_indicators_ts = last_telemetry_ts = 0; atr = 2.0
while True:
try:
now = datetime.now(MSK_TZ)
if str(now.date()) != state.get('last_date'):
state.update({"deals_today": 0, "daily_pnl": 0.0, "last_date": str(now.date()), "reported": -1})
if now.hour == 23 and now.minute == 55 and state.get('reported') != now.day:
send_tg(f"📊 {conf['TICKER']} итог {now.strftime('%d.%m')}:\nСделок: {state['deals_today']}\nP&L дня: {state['daily_pnl']:+.0f}", "INFO")
send_tg_report(TRADE_LOG, f"{conf['TICKER']} Trades")
state['reported'] = now.day; save_state_atomic(state)
if now.weekday() == 6 and now.hour == 21 and state.get('ai_reported') != now.isocalendar()[1]:
prompt_path = generate_ai_prompt(conf)
if prompt_path:
send_tg(f"🧠 Сформирован бриф ИИ по {conf['TICKER']}.", "AI")
send_tg_report(prompt_path, f"AI Prompt {conf['TICKER']}")
state['ai_reported'] = now.isocalendar()[1]; save_state_atomic(state)
if acc_id:
portfolio = client.operations.get_portfolio(account_id=acc_id)
pos = next((p for p in portfolio.positions if p.instrument_uid == target_uid), None)
state['total_shares'], state['total_lots'] = (int(q_to_float(pos.quantity)), int(q_to_float(pos.quantity)) // lot_size) if pos else (0, 0)
if pos and state['avg_price'] == 0: state['avg_price'] = q_to_float(pos.average_position_price)
if state['total_shares'] == 0 and state['phase'] == "SELL":
state.update({"phase": "BUY", "current_step": 0, "avg_price": 0, "tp_activated": False, "max_p": 0, "buy_time_ts": 0, "profit_goal": 0, "stop_loss": 0, "partial_sold": False})
cur_p = q_to_float(client.market_data.get_last_prices(instrument_id=[target_uid]).last_prices[0].price)
# FIX: Безопасная инициализация base_price ТОЛЬКО если цена > 0.1
if cur_p > 0.1:
state['live_price'] = cur_p
if state.get('base_price') is None:
state['base_price'] = cur_p
if not is_market_open(): save_state_atomic(state); time.sleep(60); continue
if time.time() - last_indicators_ts > 900:
c = client.market_data.get_candles(instrument_id=target_uid, from_=datetime.now(timezone.utc) - timedelta(days=5), to=datetime.now(timezone.utc), interval=CandleInterval.CANDLE_INTERVAL_HOUR).candles
if len(c) >= 20:
closes = [q_to_float(x.close) for x in c]
state['trend'] = "UP" if cur_p > (sum(closes[-conf.get("TREND_CANDLES", 20):]) / conf.get("TREND_CANDLES", 20)) else "DOWN"
state['last_rsi'] = calc_rsi(closes)
trs = [max(q_to_float(c[i].high) - q_to_float(c[i].low), abs(q_to_float(c[i].high) - q_to_float(c[i-1].close))) for i in range(1, len(c))]
atr = sum(trs[-14:]) / 14; state['last_atr'] = round(atr, 2)
state['last_vola'] = round((atr / cur_p) * 100, 2)
last_indicators_ts = time.time()
if time.time() - last_telemetry_ts > 900:
try:
ob = client.market_data.get_order_book(instrument_id=target_uid, depth=1)
spread = round((q_to_float(ob.asks[0].price) - q_to_float(ob.bids[0].price)) / q_to_float(ob.bids[0].price) * 100, 4) if ob.asks and ob.bids else 0.0
log_telemetry(conf["TICKER"], cur_p, state.get('last_rsi', 50), state.get('trend', 'WAIT'), spread, state.get('last_vola', 0))
except: pass
last_telemetry_ts = time.time()
buy_step = max(round(atr * conf.get("ATR_COEFF", 0.5), 2), conf.get("MIN_BUY_STEP", 1.0))
if state['total_lots'] > 0:
state['phase'] = "SELL"
if state.get('max_p', 0) == 0: state['max_p'] = state['avg_price']
if cur_p > state.get('max_p', 0): state['max_p'] = cur_p
sl_price = round(state['avg_price'] - buy_step * conf["SL_COEFF"], 2)
if state.get('tp_activated') or state.get('partial_sold'):
sl_price = max(sl_price, round(state['avg_price'] * 1.0008, 2))
state['stop_loss'] = sl_price
if not state.get('profit_goal'):
tp_mult = conf["PROFIT_COEFF"] if state['trend'] == "UP" else conf["PROFIT_COEFF"] * 0.7
state['profit_goal'] = round(state['avg_price'] + buy_step * tp_mult, 2)
if cur_p >= state['profit_goal']: state['tp_activated'] = True
rebound = max(buy_step * conf["TRAILING_REBOUND"], min_inc * 3)
is_tp, is_sl = state.get('tp_activated') and cur_p <= state['max_p'] - rebound, cur_p <= sl_price
is_time = state.get('buy_time_ts', 0) > 0 and now.timestamp() > state['buy_time_ts'] + conf["MAX_HOLD_HOURS"] * 3600
if not state.get('partial_sold') and state['total_lots'] >= 2 and cur_p >= state['avg_price'] * (1 + conf.get("EARLY_EXIT_PCT", 0.01)):
qty_to_sell = state['total_lots'] // 2
res = client.orders.post_order(instrument_id=target_uid, quantity=qty_to_sell, direction=OrderDirection.ORDER_DIRECTION_SELL, account_id=acc_id, order_type=OrderType.ORDER_TYPE_MARKET, order_id=str(uuid.uuid4()))
p_sold = q_to_float(res.executed_order_price) or cur_p
pnl = round((p_sold - state['avg_price']) * (qty_to_sell * lot_size), 2)
state['daily_pnl'] = round(state.get('daily_pnl', 0) + pnl, 2)
# FIX: Убран deals_today += 1 при частичной продаже (по замечанию аудитора)
state['partial_sold'] = True
log_trade("PARTIAL_SELL", p_sold, qty_to_sell, qty_to_sell * lot_size, "EarlyExit", pnl)
send_tg(f"💰 {conf['TICKER']} Частичная фиксация 50%\nP&L: {pnl:+.2f}", "PROFIT")
if is_tp or is_sl or is_time:
reason = ("TP" if is_tp else ("SL" if is_sl else "TIME"))
res = client.orders.post_order(instrument_id=target_uid, quantity=state['total_lots'], direction=OrderDirection.ORDER_DIRECTION_SELL, account_id=acc_id, order_type=OrderType.ORDER_TYPE_MARKET, order_id=str(uuid.uuid4()))
p_sold = q_to_float(res.executed_order_price) or cur_p
pnl = round((p_sold - state['avg_price']) * state['total_shares'], 2)
state['daily_pnl'] = round(state.get('daily_pnl', 0) + pnl, 2); state['deals_today'] += 1
log_trade("SELL", p_sold, state['total_lots'], state['total_shares'], reason, pnl)
send_tg(f"{conf['TICKER']} [{reason}]\nP&L: {pnl:+.2f}", "PROFIT" if pnl > 0 else "LOSS")
state.update({"phase": "BUY", "current_step": 0, "total_shares": 0, "total_lots": 0, "avg_price": 0, "tp_activated": False, "max_p": 0, "stop_loss": 0, "profit_goal": 0, "buy_time_ts": 0, "base_price": cur_p, "last_sell_ts": now.timestamp(), "partial_sold": False})
if state['phase'] == "BUY":
if state.get('daily_pnl', 0) <= conf["DAILY_LOSS_LIMIT"]:
state['filter_status'] = f"LIMIT {state['daily_pnl']:.0f}"; save_state_atomic(state); time.sleep(300); continue
step = state['current_step']
cooldown_ok, trend_ok, rsi_ok = now.timestamp() > state.get('last_sell_ts', 0) + conf["COOLDOWN_MINUTES"] * 60, state.get('trend') == "UP", state.get('last_rsi', 50) < conf["RSI_BUY_THRESHOLD"]
filters_ok = (cooldown_ok and trend_ok and rsi_ok) if step == 0 else True
# FIX: Корректный статус фильтра для дашборда на шагах DCA > 0
if step > 0:
state['filter_status'] = f"Ожидание DCA ст.{step+1}"
else:
if not cooldown_ok: state['filter_status'] = "COOLDOWN"
elif not trend_ok: state['filter_status'] = "TREND DOWN"
elif not rsi_ok: state['filter_status'] = f"RSI {state.get('last_rsi', 50):.0f}"
else: state['filter_status'] = "OK"
if filters_ok and step < len(conf.get("DCA_STEPS", [])):
if step == 0 and cur_p > state.get('base_price', cur_p): state['base_price'] = cur_p
state['target_act'] = round(state['base_price'] - (buy_step * conf.get("STEP_DISTANCE_ATR", 1.0)) * (step + 1), 2)
if cur_p <= state['target_act'] and acc_id:
step_budget = conf["MAX_TICKER_BUDGET"] * conf["DCA_STEPS"][step] * conf["RISK_FRACTION"]
qty = int(step_budget // (cur_p * lot_size))
if qty >= 1:
res = client.orders.post_order(instrument_id=target_uid, quantity=qty, direction=OrderDirection.ORDER_DIRECTION_BUY, account_id=acc_id, order_type=OrderType.ORDER_TYPE_MARKET, order_id=str(uuid.uuid4()))
p_bought = q_to_float(res.executed_order_price) or cur_p
if state['buy_time_ts'] == 0: state['buy_time_ts'] = now.timestamp()
state['current_step'] += 1
log_trade("BUY", p_bought, qty, qty * lot_size, f"Step{state['current_step']}")
send_tg(f"🛒 {conf['TICKER']} шаг {state['current_step']} @ {p_bought:.2f}", "TRADE")
elif step == 0: state['target_act'] = round(state['base_price'] - buy_step, 2)
save_state_atomic(state); time.sleep(2)
except Exception as e: logging.error(f"Loop Error: {e}"); time.sleep(10)
if __name__ == "__main__":
conf_main = load_config()
threading.Thread(target=lambda: app.run(host='0.0.0.0', port=conf_main.get("WEB_PORT", 5000), use_reloader=False), daemon=True).start()
while True:
try: run_bot()
except KeyboardInterrupt: break
except Exception as e: logging.error(f"Fatal: {e}"); time.sleep(10)