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)