186 lines
9.1 KiB
Python
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()
|