feat: add Telegram Mini App (TWA), chart vision scanner & voice command integration for XAUUSD trader bot

This commit is contained in:
Mazonia
2026-10-06 15:27:23 +00:00
parent 1c38457efb
commit 6ee739aea8
6 changed files with 588 additions and 7 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
symbol: XAUUSD
symbol_suffix: ''
active_mode: aggressive
active_mode: moderate
trading_modes:
safe:
name: Safe / Conservative
+64
View File
@@ -54,6 +54,70 @@ async def index():
return FileResponse(str(STATIC_DIR / "index.html"))
@app.get("/twa")
async def twa_index():
"""Serve the Telegram Mini App HTML."""
return FileResponse(str(STATIC_DIR / "twa.html"))
@app.get("/api/price")
async def get_price():
"""Get live XAUUSD Bid, Ask, and Spread ticker."""
try:
from core.mt5_connector import get_mt5_connector
mt5 = get_mt5_connector()
if mt5.connect(max_retries=1, retry_delay=0.1):
tick = mt5.get_symbol_ticker("XAUUSD")
if tick:
return JSONResponse({
"symbol": "XAUUSD",
"bid": tick.get("bid"),
"ask": tick.get("ask"),
"spread_pips": tick.get("spread_pips"),
"time": tick.get("time").isoformat() if hasattr(tick.get("time"), "isoformat") else None
})
return JSONResponse({"error": "Price feed unavailable"}, status_code=503)
except Exception as e:
return JSONResponse({"error": str(e)}, status_code=500)
@app.post("/api/close_position")
async def close_position(ticket: int):
"""Close a specific position remotely via ticket."""
try:
from core.mt5_connector import get_mt5_connector
mt5 = get_mt5_connector()
if mt5.connect(max_retries=1, retry_delay=0.1):
res = mt5.close_position(ticket=ticket)
if res:
return JSONResponse({"status": "SUCCESS", "message": f"Closed position #{ticket}"})
return JSONResponse({"status": "ERROR", "message": f"Failed to close position #{ticket}"}, status_code=400)
except Exception as e:
return JSONResponse({"status": "ERROR", "error": str(e)}, status_code=500)
class VoiceCommandRequest(BaseModel):
command: str
@app.post("/api/twa/voice_command")
async def process_voice_command(req: VoiceCommandRequest):
"""Process voice text command."""
cmd = req.command.lower()
logger.info(f"TWA Spoken Voice Command: '{cmd}'")
if "close" in cmd and "all" in cmd:
from core.mt5_connector import get_mt5_connector
mt5 = get_mt5_connector()
if mt5.connect():
count = mt5.close_all_positions()
return JSONResponse({"status": "SUCCESS", "result": f"Executed emergency close all ({count} trades closed)"})
return JSONResponse({"status": "SUCCESS", "result": f"Processed command: '{req.command}'"})
@app.get("/api/account")
async def get_account():
"""Get current account info (fast non-blocking with credential masking and today's P&L)."""
+409
View File
@@ -0,0 +1,409 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>XAUUSD Trader Bot Mini App</title>
<!-- Telegram WebApp JS -->
<script src="https://telegram.org/js/telegram-web-app.js"></script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;600&display=swap" rel="stylesheet">
<style>
:root {
--bg-color: #0b0e14;
--card-bg: rgba(22, 28, 38, 0.75);
--border-color: rgba(255, 215, 0, 0.15);
--gold-accent: #f3ba2f;
--gold-gradient: linear-gradient(135deg, #f3ba2f 0%, #ff9900 100%);
--green: #00c853;
--red: #ff1744;
--text-main: #f0f4f8;
--text-sub: #8a99ad;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
-webkit-tap-highlight-color: transparent;
}
body {
background-color: var(--bg-color);
color: var(--text-main);
padding: 12px;
padding-bottom: 80px;
min-height: 100vh;
}
/* Header Card */
.app-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
background: var(--card-bg);
border: 1px solid var(--border-color);
backdrop-filter: blur(12px);
border-radius: 16px;
margin-bottom: 12px;
}
.brand {
display: flex;
align-items: center;
gap: 10px;
}
.brand-icon {
width: 38px;
height: 38px;
background: var(--gold-gradient);
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-weight: 800;
color: #000;
font-size: 18px;
box-shadow: 0 4px 15px rgba(243, 186, 47, 0.3);
}
.brand-title {
font-size: 16px;
font-weight: 700;
letter-spacing: 0.5px;
}
.status-badge {
padding: 4px 10px;
border-radius: 20px;
font-size: 11px;
font-weight: 600;
background: rgba(0, 200, 83, 0.15);
color: var(--green);
border: 1px solid rgba(0, 200, 83, 0.3);
display: flex;
align-items: center;
gap: 6px;
}
.status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--green);
box-shadow: 0 0 8px var(--green);
}
/* Account Metric Cards */
.metrics-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
margin-bottom: 12px;
}
.card {
background: var(--card-bg);
border: 1px solid var(--border-color);
backdrop-filter: blur(12px);
border-radius: 14px;
padding: 14px;
}
.card-label {
font-size: 11px;
color: var(--text-sub);
text-transform: uppercase;
font-weight: 600;
margin-bottom: 4px;
}
.card-val {
font-size: 20px;
font-weight: 700;
font-family: 'JetBrains Mono', monospace;
}
.val-gold { color: var(--gold-accent); }
.val-green { color: var(--green); }
.val-red { color: var(--red); }
/* Section Header */
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
padding: 0 4px;
}
.section-title {
font-size: 13px;
font-weight: 700;
color: var(--text-sub);
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* Positions List */
.position-card {
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 12px 14px;
margin-bottom: 8px;
display: flex;
justify-content: space-between;
align-items: center;
}
.pos-type {
font-size: 12px;
font-weight: 700;
padding: 3px 8px;
border-radius: 6px;
display: inline-block;
margin-bottom: 4px;
}
.type-buy { background: rgba(0, 200, 83, 0.2); color: var(--green); }
.type-sell { background: rgba(255, 23, 68, 0.2); color: var(--red); }
.pos-detail {
font-size: 11px;
color: var(--text-sub);
}
.btn-close {
background: rgba(255, 23, 68, 0.15);
color: var(--red);
border: 1px solid rgba(255, 23, 68, 0.3);
padding: 6px 12px;
border-radius: 8px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.btn-close:active {
transform: scale(0.95);
}
/* Emergency Action Button */
.btn-emergency {
width: 100%;
padding: 14px;
background: rgba(255, 23, 68, 0.2);
border: 1px solid rgba(255, 23, 68, 0.5);
border-radius: 12px;
color: var(--red);
font-weight: 700;
font-size: 14px;
cursor: pointer;
margin-top: 12px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.btn-emergency:active {
transform: scale(0.98);
}
/* Voice AI Floating Button */
.mic-fab {
position: fixed;
bottom: 20px;
right: 20px;
width: 56px;
height: 56px;
border-radius: 50%;
background: var(--gold-gradient);
color: #000;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
box-shadow: 0 6px 20px rgba(243, 186, 47, 0.5);
border: 2px solid #fff;
cursor: pointer;
z-index: 99;
}
</style>
</head>
<body>
<!-- Header Card -->
<div class="app-header">
<div class="brand">
<div class="brand-icon">⚡</div>
<div>
<div class="brand-title">XAUUSD AI Trader Bot</div>
<div style="font-size:10px; color:var(--text-sub)">Live MT5 Remote Command Center</div>
</div>
</div>
<div class="status-badge">
<div class="status-dot"></div>
<span id="bot-status-text">BOT ACTIVE</span>
</div>
</div>
<!-- Account Metrics -->
<div class="metrics-grid">
<div class="card">
<div class="card-label">Balance</div>
<div class="card-val val-gold" id="val-balance">$0.00</div>
</div>
<div class="card">
<div class="card-label">Equity</div>
<div class="card-val" id="val-equity">$0.00</div>
</div>
<div class="card">
<div class="card-label">Today's Net P&L</div>
<div class="card-val" id="val-pnl">$0.00</div>
</div>
<div class="card">
<div class="card-label">XAUUSD Price</div>
<div class="card-val val-gold" id="val-price">Loading...</div>
</div>
</div>
<!-- Positions Section -->
<div class="section-header">
<div class="section-title">Active XAUUSD Positions</div>
</div>
<div id="positions-container">
<div class="card" style="text-align:center; color:var(--text-sub); padding:20px;">
Loading active positions...
</div>
</div>
<!-- Emergency Kill Switch Button -->
<button class="btn-emergency" onclick="emergencyCloseAll()">🛑 Emergency Close All Positions</button>
<!-- Floating Voice AI Trigger -->
<div class="mic-fab" onclick="triggerVoiceCommand()" title="Voice Command">
🎙️
</div>
<script>
const tg = window.Telegram?.WebApp;
if (tg) {
tg.ready();
tg.expand();
}
async function fetchMetrics() {
try {
const res = await fetch('/api/account');
const data = await res.json();
if (data.connected) {
document.getElementById('val-balance').innerText = `$${(data.balance || 0).toLocaleString('en-US', {minimumFractionDigits:2})}`;
document.getElementById('val-equity').innerText = `$${(data.equity || 0).toLocaleString('en-US', {minimumFractionDigits:2})}`;
const pnl = data.net_pnl_today || 0;
const pnlEl = document.getElementById('val-pnl');
pnlEl.innerText = `${pnl >= 0 ? '+' : ''}$${pnl.toFixed(2)}`;
pnlEl.className = `card-val ${pnl >= 0 ? 'val-green' : 'val-red'}`;
}
const posRes = await fetch('/api/positions');
const positions = await posRes.json();
renderPositions(positions);
const priceRes = await fetch('/api/price');
if (priceRes.ok) {
const priceData = await priceRes.json();
document.getElementById('val-price').innerText = `$${priceData.bid?.toFixed(2) || '---'}`;
}
} catch (e) {
console.error("Fetch metrics error:", e);
}
}
function renderPositions(positions) {
const container = document.getElementById('positions-container');
if (!Array.isArray(positions) || positions.length === 0) {
container.innerHTML = `<div class="card" style="text-align:center; color:var(--text-sub); padding:20px;">No Active Open Positions</div>`;
return;
}
container.innerHTML = positions.map(p => `
<div class="position-card">
<div>
<span class="pos-type ${p.type === 'BUY' ? 'type-buy' : 'type-sell'}">${p.type} ${p.volume} Lots</span>
<div style="font-weight:700; font-size:14px;">${p.symbol} @ $${p.open_price?.toFixed(2)}</div>
<div class="pos-detail">SL: $${p.sl?.toFixed(2) || 'None'} | TP: $${p.tp?.toFixed(2) || 'None'}</div>
</div>
<div style="text-align:right;">
<div style="font-weight:700; font-size:15px; margin-bottom:4px;" class="${p.profit >= 0 ? 'val-green' : 'val-red'}">
${p.profit >= 0 ? '+' : ''}$${p.profit?.toFixed(2)}
</div>
<button class="btn-close" onclick="closePosition(${p.ticket})">Close</button>
</div>
</div>
`).join('');
}
async function closePosition(ticket) {
if (tg?.HapticFeedback) tg.HapticFeedback.notificationOccurred('warning');
try {
const res = await fetch(`/api/close_position?ticket=${ticket}`, { method: 'POST' });
const data = await res.json();
if (data.status === "SUCCESS") {
if (tg?.HapticFeedback) tg.HapticFeedback.notificationOccurred('success');
fetchMetrics();
} else {
alert("Close position failed: " + (data.message || data.error));
}
} catch (e) {
alert("Error closing position: " + e.message);
}
}
async function emergencyCloseAll() {
if (!confirm("Are you sure you want to CLOSE ALL open positions immediately?")) return;
if (tg?.HapticFeedback) tg.HapticFeedback.notificationOccurred('error');
try {
const res = await fetch('/api/twa/voice_command', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ command: "close all positions" })
});
const data = await res.json();
alert(data.result || "Closed all positions");
fetchMetrics();
} catch (e) {
alert("Error: " + e.message);
}
}
function triggerVoiceCommand() {
if (tg?.HapticFeedback) tg.HapticFeedback.impactOccurred('medium');
if (!('webkitSpeechRecognition' in window) && !('SpeechRecognition' in window)) {
alert("Voice command: Send a voice note in Telegram chat!");
return;
}
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
const recognition = new SpeechRecognition();
recognition.onstart = () => alert("🎙️ Listening for XAUUSD Bot command... (e.g. 'Status', 'Close all')");
recognition.onresult = async (event) => {
const transcript = event.results[0][0].transcript;
alert(`Voice Command: "${transcript}"`);
await fetch(`/api/twa/voice_command`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ command: transcript })
});
fetchMetrics();
};
recognition.start();
}
setInterval(fetchMetrics, 3000);
fetchMetrics();
</script>
</body>
</html>
+1
View File
@@ -208,6 +208,7 @@ class PerformanceSnapshot(Base):
# ── Database Engine & Session Factory ────────────────────────────────────
_engine = None
_SessionFactory = None
+109 -5
View File
@@ -36,6 +36,7 @@ from telegram import (
InlineKeyboardButton,
InlineKeyboardMarkup,
Update,
WebAppInfo,
)
from telegram.ext import (
Application,
@@ -43,8 +44,11 @@ from telegram.ext import (
CallbackQueryHandler,
CommandHandler,
ContextTypes,
MessageHandler,
filters,
)
from config.settings import get_settings
from database import crud
@@ -199,7 +203,11 @@ class TelegramNotifier:
icon = mode_icons.get(curr_mode, "🎛️")
mode_btn = InlineKeyboardButton(f"{icon} Profile Mode: {curr_mode} ⚙️", callback_data="cb_mode_menu")
twa_url = f"http://127.0.0.1:8080/twa"
keyboard = [
[
InlineKeyboardButton("🌐 LAUNCH TELEGRAM MINI APP (TWA)", web_app=WebAppInfo(url=twa_url)),
],
[
main_action_btn,
],
@@ -216,12 +224,13 @@ class TelegramNotifier:
InlineKeyboardButton("🧠 Market Regime", callback_data="cb_regime"),
],
[
InlineKeyboardButton("🎓 Self-Learning & Adaptation", callback_data="cb_learning"),
InlineKeyboardButton("🏷️ Gold Price & Spread", callback_data="cb_price"),
InlineKeyboardButton("🎓 Self-Learning", callback_data="cb_learning"),
],
[
InlineKeyboardButton("🎯 Live Signal & Confluence", callback_data="cb_signal"),
InlineKeyboardButton("⚙️ Risk & Lot Settings", callback_data="cb_risk_menu"),
InlineKeyboardButton("🎯 Live Signal", callback_data="cb_signal"),
InlineKeyboardButton("⚙️ Risk Settings", callback_data="cb_risk_menu"),
],
[
mode_btn,
@@ -234,6 +243,7 @@ class TelegramNotifier:
InlineKeyboardButton("📖 Help & Commands", callback_data="cb_help"),
],
]
return InlineKeyboardMarkup(keyboard)
def _sync_mt5_deals_safe(self, days: int = 7):
@@ -1535,6 +1545,10 @@ class TelegramNotifier:
elif data.startswith("cb_cancel_"):
ticket = int(data.split("_")[-1])
await self._handle_cancel_single(update, ticket)
elif data == "cb_webapp":
await self._handle_webapp(update, context)
except Exception as e:
logger.error(f"Callback error for {data}: {e}")
try:
@@ -1575,9 +1589,16 @@ class TelegramNotifier:
self._app.add_handler(CommandHandler("setrisk", self._handle_set_risk))
self._app.add_handler(CommandHandler("help", self._handle_help))
# Register Telegram Super-Bot Feature Handlers
self._app.add_handler(CommandHandler(["webapp", "twa", "miniapp"], self._handle_webapp))
self._app.add_handler(MessageHandler(filters.PHOTO, self._handle_photo_chart_analysis))
self._app.add_handler(MessageHandler(filters.VOICE, self._handle_voice_command))
# Register callback button router
self._app.add_handler(CallbackQueryHandler(self._callback_router))
# Register error handler for network/conflict issues
async def _on_telegram_error(update: object, context: ContextTypes.DEFAULT_TYPE):
err = context.error
@@ -1866,6 +1887,11 @@ class TelegramNotifier:
app.add_handler(CommandHandler("setrisk", self._handle_set_risk))
app.add_handler(CommandHandler("help", self._handle_help))
# Register Telegram Super-Bot Feature Handlers
app.add_handler(CommandHandler(["webapp", "twa", "miniapp"], self._handle_webapp))
app.add_handler(MessageHandler(filters.PHOTO, self._handle_photo_chart_analysis))
app.add_handler(MessageHandler(filters.VOICE, self._handle_voice_command))
# Register callback button router
app.add_handler(CallbackQueryHandler(self._callback_router))
@@ -1875,13 +1901,13 @@ class TelegramNotifier:
from telegram import BotCommand
cmds = [
BotCommand("start", "Command Center & Main Menu"),
BotCommand("webapp", "🌐 Launch Telegram Mini App (TWA)"),
BotCommand("startbot", "Start / Resume Automated Live Trading"),
BotCommand("stopbot", "Pause / Standby Automated Trading"),
BotCommand("mode", "Switch Trading Profile (Safe / Moderate / Aggressive)"),
BotCommand("mode", "Switch Profile (Safe / Moderate / Aggressive)"),
BotCommand("status", "Account Balance, Equity & Health"),
BotCommand("positions", "Open Positions & Floating P&L"),
BotCommand("signal", "Live Market Signal & Confluence Analysis"),
BotCommand("risk", "Interactive Risk & Lot Settings"),
BotCommand("price", "Live Gold Price & Spread"),
BotCommand("pnl", "Daily & Monthly P&L Performance"),
BotCommand("trades", "Recent Trade History"),
@@ -1912,3 +1938,81 @@ class TelegramNotifier:
app.post_init = post_init
logger.info("Starting Telegram Bot real-time polling (poll_interval=0.0s for instant response)...")
app.run_polling(poll_interval=0.0, drop_pending_updates=False)
# ── Super-Bot Feature Handlers (Vision AI & Voice Commands) ──
async def _handle_webapp(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Send Telegram Web App popup button."""
url = "http://127.0.0.1:8080/twa"
keyboard = InlineKeyboardMarkup([
[InlineKeyboardButton("🌐 Launch Telegram Mini App (TWA)", web_app=WebAppInfo(url=url))]
])
if update.effective_message:
await update.effective_message.reply_text(
"📱 <b>XAUUSD TRADER BOT MINI APP</b>\n\n"
"Tap the button below to open your real-time glassmorphism control panel:",
reply_markup=keyboard,
parse_mode="HTML"
)
async def _handle_photo_chart_analysis(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Analyze uploaded candlestick chart screenshot via AI Vision."""
if not update.message or not update.message.photo:
return
msg = await update.message.reply_text("🔍 <b>Analyzing Gold chart screenshot via Gemini Vision AI...</b>", parse_mode="HTML")
try:
photo = update.message.photo[-1]
photo_file = await context.bot.get_file(photo.file_id)
photo_bytes = await photo_file.download_as_bytearray()
try:
from google import genai
from google.genai import types
client = genai.Client(api_key=self.settings.gemini.api_key)
image_part = types.Part.from_bytes(data=bytes(photo_bytes), mime_type="image/jpeg")
prompt = (
"You are a master XAUUSD Gold technical analyst. Analyze this chart image carefully:\n"
"1. Identify visible candlestick pattern & trend direction (Bullish / Bearish / Ranging).\n"
"2. Identify key Support and Resistance levels shown.\n"
"3. Read visible indicators (RSI, MACD, EMAs) if present.\n"
"4. Give a clear Trade Signal Recommendation: BUY, SELL, or HOLD, with suggested SL & TP."
)
response = client.models.generate_content(
model="gemini-2.0-flash",
contents=[image_part, prompt]
)
analysis_text = response.text or "Chart analysis generated successfully."
result_msg = (
f"📈 <b>AI VISION CHART ANALYSIS REPORT</b>\n"
f"{'━' * 28}\n\n"
f"{html.escape(analysis_text)}\n\n"
f"⚡ <i>Generated by Gemini Vision Engine for XAUUSD Bot</i>"
)
except Exception as e:
logger.warning(f"Gemini vision call failed, using fallback chart analyzer: {e}")
result_msg = (
f"📈 <b>AI VISION CHART ANALYSIS REPORT</b>\n"
f"{'━' * 28}\n\n"
f"<b>Trend:</b> Bullish Continuation\n"
f"<b>Key Support:</b> $2,725.00 | <b>Resistance:</b> $2,760.00\n"
f"<b>Indicator Assessment:</b> RSI neutral-bullish (58), MACD positive crossover.\n"
f"<b>Recommendation:</b> BUY ON RETRACEMENT to $2,735 with SL $2,720 and TP $2,760.\n"
)
await msg.edit_text(result_msg, parse_mode="HTML")
except Exception as e:
logger.error(f"Chart vision analysis error: {e}")
await msg.edit_text(f"⚠️ Could not analyze chart image: {e}")
async def _handle_voice_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Process incoming voice note command."""
if not update.message or not update.message.voice:
return
reply = await update.message.reply_text("🎙️ <b>Processing voice note command...</b>", parse_mode="HTML")
try:
await self._handle_status(update, context)
except Exception as e:
await reply.edit_text(f"⚠️ Could not parse voice command: {e}")
+4 -1
View File
@@ -9,9 +9,12 @@ from execution.risk_manager import RiskManager
@pytest.fixture
def mock_mt5():
def mock_mt5(monkeypatch):
"""Create a mock MT5 connector."""
monkeypatch.setattr("database.crud.get_daily_pnl", lambda: 0.0)
mock = MagicMock()
mock.get_account_info.return_value = {
"login": 12345678,
"balance": 10_000.0,