Files
2026-10-27 17:02:33 +07:00

186 lines
9.1 KiB
Python

from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import json
import faulthandler
import logging
from logging.handlers import RotatingFileHandler
from pathlib import Path
import os
import secrets
import threading
import time
from urllib.parse import urlsplit
from .config import ROOT
from .research import plain
from .service import Desk
from .runtime import Runtime, process_lock
from .supervisor import workspace_id
def handler_for(desk, token, port):
hosts = {f'127.0.0.1:{port}', f'localhost:{port}'}
origins = {'http://' + host for host in hosts}
class Handler(BaseHTTPRequestHandler):
server_version = 'PrimeThaiMT5/1.0'
def respond(self, status, value, html=False, filename=None):
body = value.encode('utf-8') if html else json.dumps(plain(value), ensure_ascii=False, allow_nan=False).encode('utf-8')
self.send_response(status)
self.send_header('Content-Type', 'text/html; charset=utf-8' if html else 'application/json; charset=utf-8')
self.send_header('Content-Length', str(len(body)))
self.send_header('Cache-Control', 'no-store')
self.send_header('X-Content-Type-Options', 'nosniff')
self.send_header('X-Frame-Options', 'DENY')
self.send_header('Content-Security-Policy', "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'")
if filename:
self.send_header('Content-Disposition', f'attachment; filename="{filename}"')
try:
self.end_headers()
self.wfile.write(body)
except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError):
self.close_connection = True
def valid_host(self):
if self.headers.get('Host') not in hosts:
self.respond(403, {'error': 'Local access only'})
return False
return True
def do_GET(self):
if not self.valid_host():
return
path = urlsplit(self.path).path
if path == '/':
page = (ROOT / 'index.html').read_text(encoding='utf-8').replace('__APP_TOKEN__', token)
self.respond(200, page, html=True)
elif path == '/health':
self.respond(200, {'status': 'ok', 'app': 'mt5_app', 'pid': os.getpid(),
'workspace_id': workspace_id(),
'supervised': os.environ.get('MT5_SUPERVISED') == '1'})
elif self.headers.get('X-App-Token') != token:
self.respond(403, {'error': 'เปิดหน้าแอปใหม่เพื่อเริ่มใช้งาน'})
elif path == '/api/state':
self.respond(200, desk.state())
else:
self.respond(404, {'error': 'Not found'})
def do_POST(self):
if not self.valid_host():
return
if self.headers.get('Origin') not in origins or self.headers.get('X-App-Token') != token:
self.respond(403, {'error': 'คำขอต้องมาจากหน้าแอปในเครื่องนี้'})
return
path = urlsplit(self.path).path
try:
length = int(self.headers.get('Content-Length', '0'))
if not 0 < length <= 8192:
raise ValueError('ขนาดคำขอไม่ถูกต้อง')
body = json.loads(self.rfile.read(length))
if not isinstance(body, dict):
raise ValueError('รูปแบบคำขอไม่ถูกต้อง')
if path == '/api/refresh':
started = desk.refresh(research=bool(body.get('research')))
self.respond(202 if started else 409, {'started': started})
elif path == '/api/skip':
desk.skip(str(body.get('symbol', '')).upper(), body.get('skip', True) is not False)
self.respond(200, {'ok': True})
elif path == '/api/trading':
enabled = body.get('enabled') is True
if enabled and body.get('confirm') != 'ENABLE_LIVE':
raise ValueError('ต้องยืนยันเปิดส่งคำสั่งเข้าบัญชีจริง')
self.respond(200, desk.set_live(enabled))
elif path == '/api/automation':
paused = body.get('paused') is not False
if not paused and body.get('confirm') != 'ENABLE_AUTO':
raise ValueError('ต้องยืนยันการส่งคำสั่งจริงอัตโนมัติ')
self.respond(200, desk.pause_auto(paused))
elif path == '/api/preview':
self.respond(200, desk.preview(body))
elif path == '/api/submit':
if body.get('confirm') != 'SEND_LIVE':
raise ValueError('ต้องยืนยันการส่งคำสั่งจริง')
self.respond(200, desk.submit(str(body.get('id', ''))))
elif path == '/api/cancel':
if body.get('confirm') != 'CANCEL_LIVE':
raise ValueError('ต้องยืนยันการยกเลิกคำสั่งจริง')
result = desk.broker.manage_pending(int(body.get('ticket', 0)))
desk.refresh()
self.respond(200, result)
else:
self.respond(404, {'error': 'Not found'})
except (ValueError, TypeError, KeyError) as exc:
self.respond(400, {'error': str(exc)})
except Exception as exc:
logging.getLogger('mt5desk').exception('request_failed %s', path)
self.respond(500, {'error': f'{type(exc).__name__}: {exc}'})
def log_message(self, message, *args):
if not self.path.startswith(('/api/state', '/health')):
logging.getLogger('mt5desk').info('%s %s', self.client_address[0], message % args)
return Handler
def run(settings, *, auto_refresh=True):
settings.state_dir.mkdir(parents=True, exist_ok=True)
log_dir = ROOT / 'logs'
log_dir.mkdir(exist_ok=True)
logging.basicConfig(level=logging.INFO, handlers=[
logging.StreamHandler(),
RotatingFileHandler(log_dir / 'app.log', maxBytes=2_000_000, backupCount=3, encoding='utf-8'),
], format='%(asctime)s %(levelname)s %(message)s')
log = logging.getLogger('mt5desk')
def thread_failed(args):
log.error('thread_failed name=%s', args.thread.name,
exc_info=(args.exc_type, args.exc_value, args.exc_traceback))
threading.excepthook = thread_failed
with process_lock(settings.state_dir / 'server.lock'):
runtime = Runtime(settings, log)
with (log_dir / 'native-crash.log').open('a', encoding='utf-8') as fault_log:
faulthandler.enable(file=fault_log, all_threads=True)
try:
_serve(settings, auto_refresh=auto_refresh, runtime=runtime)
except KeyboardInterrupt:
runtime.finish('keyboard_interrupt')
except BaseException as exc:
log.exception('server_failed')
runtime.finish('exception', f'{type(exc).__name__}: {exc}')
raise
else:
runtime.finish('server_loop_ended')
finally:
faulthandler.disable()
def _serve(settings, *, auto_refresh, runtime):
class MonitoredServer(ThreadingHTTPServer):
last_heartbeat = 0
def service_actions(self):
if time.monotonic() - self.last_heartbeat >= 30:
try:
runtime.beat()
except Exception:
logging.getLogger('mt5desk').exception('runtime_heartbeat_failed')
self.last_heartbeat = time.monotonic()
desk = Desk(settings)
token = secrets.token_urlsafe(32)
server = MonitoredServer(('127.0.0.1', settings.port), handler_for(desk, token, settings.port))
if auto_refresh:
threading.Thread(target=desk.scheduler, daemon=True, name='mt5-scheduler').start()
print(f'PrimeThai MT5: http://127.0.0.1:{settings.port}/', flush=True)
print(f'Live orders: {settings.live_orders}; automatic: {settings.auto_trade}; start: {settings.auto_start or "not scheduled"}.', flush=True)
print('SET common stocks only; Bangkok windows 10:05-12:25 and 14:05-16:25; holidays blocked.', flush=True)
logging.getLogger('mt5desk').info(
'startup port=%s live=%s automatic=%s start=%s server_utc_offset_minutes=%s',
settings.port, settings.live_orders, settings.auto_trade, settings.auto_start,
settings.server_utc_offset_minutes)
try:
runtime.beat()
server.serve_forever()
finally:
desk.stop.set()
server.server_close()