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("""RAPTOR v18.4 UI

RAPTOR v18.4

{% if is_open %} 🟢 РЫНОК ОТКРЫТ (Торги выходного дня) {% else %} 🔴 РЫНОК ЗАКРЫТ {% endif %} Сервер (МСК): {{ time_str }}
{% for n, d in [('SBER', sber), ('GMKN', gmkn)] %}
{{ n }}{{ d.phase }} [ст.{{ d.current_step }}]
{{ d.live_price }}
{{ d.pnl }} ₽
Avg / Stop{{ d.avg_price }} / {{ d.stop_loss }}
Target (TP){{ d.profit_goal }}
Следующий вход{{ d.target_act }}
ATR / RSI / Vola{{ d.last_atr }} / {{ d.last_rsi }} / {{ d.last_vola }}%
Тренд / Фильтр{{ d.trend }} / {{ d.filter_status }}
Сделок / P&L дня{{ d.deals_today }} / {{ d.daily_pnl }} ₽
{% endfor %}
""", 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)