725 lines
34 KiB
Python
725 lines
34 KiB
Python
from __future__ import annotations
|
||
|
||
import ipaddress
|
||
import json
|
||
import socket
|
||
import threading
|
||
import time
|
||
from collections import deque
|
||
from http import HTTPStatus
|
||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||
from pathlib import Path
|
||
from typing import Any
|
||
from urllib.parse import parse_qs, urlparse
|
||
|
||
from .config import (
|
||
DEFAULT_PROMPT_SETTINGS,
|
||
preview_fixed_universe,
|
||
read_candidate_source_code,
|
||
read_dashboard_settings,
|
||
read_fixed_universe,
|
||
read_live_exchange_catalog,
|
||
read_live_trading_config,
|
||
read_llm_provider,
|
||
read_network_settings,
|
||
delete_prompt_preset,
|
||
read_prompt_library,
|
||
read_prompt_preset,
|
||
read_prompt_settings,
|
||
read_trading_settings,
|
||
rename_prompt_preset,
|
||
save_prompt_preset,
|
||
write_dashboard_settings,
|
||
write_fixed_universe,
|
||
write_live_trading_config,
|
||
write_llm_provider,
|
||
write_network_settings,
|
||
write_prompt_settings,
|
||
write_trading_settings,
|
||
)
|
||
from .http_client import HttpRequestError, request_text
|
||
from .engine import (
|
||
flatten_active_account,
|
||
preview_trading_prompt_decision,
|
||
read_trading_state,
|
||
refresh_account_state_after_settings_save,
|
||
reset_trading_account,
|
||
run_trading_cycle_batch,
|
||
summarize_trading_state,
|
||
)
|
||
from .market import read_latest_scan, refresh_candidate_pool
|
||
from .market import test_candidate_source
|
||
from .utils import DASHBOARD_DIR, now_iso
|
||
|
||
|
||
SCHEDULE_TRIGGER_WINDOW_SECONDS = 20
|
||
|
||
PUBLIC_IP_PROBES = (
|
||
("ipify", "https://api.ipify.org"),
|
||
("ifconfig.me", "https://ifconfig.me/ip"),
|
||
("icanhazip", "https://icanhazip.com"),
|
||
)
|
||
|
||
|
||
def _prompt_lines(value: Any) -> list[str]:
|
||
if isinstance(value, list):
|
||
return [str(item).strip() for item in value if str(item).strip()]
|
||
if isinstance(value, str):
|
||
return [item.strip() for item in value.splitlines() if item.strip()]
|
||
return []
|
||
|
||
|
||
def _friendly_ip_error(error: Any) -> str:
|
||
text = str(error or "").strip()
|
||
lowered = text.lower()
|
||
if not text or text == "None":
|
||
return "无法连接公网 IP 查询服务。"
|
||
if "expected pattern" in lowered:
|
||
return "代理地址格式可能不正确,或当前网络环境无法完成公网 IP 查询。"
|
||
if "nodename nor servname provided" in lowered or "name or service not known" in lowered:
|
||
return "无法解析公网 IP 查询服务域名。"
|
||
return text
|
||
|
||
|
||
def _detect_local_ip() -> str | None:
|
||
for target in ("1.1.1.1", "8.8.8.8"):
|
||
try:
|
||
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
|
||
sock.connect((target, 80))
|
||
candidate = str(sock.getsockname()[0] or "").strip()
|
||
if candidate and not candidate.startswith("127."):
|
||
ipaddress.ip_address(candidate)
|
||
return candidate
|
||
except OSError:
|
||
continue
|
||
try:
|
||
hostname = socket.gethostname()
|
||
for family, _, _, _, sockaddr in socket.getaddrinfo(hostname, None):
|
||
if family not in {socket.AF_INET, socket.AF_INET6}:
|
||
continue
|
||
candidate = str(sockaddr[0]).split("%", 1)[0].strip()
|
||
if not candidate:
|
||
continue
|
||
parsed = ipaddress.ip_address(candidate)
|
||
if parsed.is_loopback or parsed.is_unspecified:
|
||
continue
|
||
return candidate
|
||
except OSError:
|
||
return None
|
||
return None
|
||
|
||
|
||
def _network_ip_payload() -> dict[str, Any]:
|
||
network_settings = read_network_settings()
|
||
last_error = None
|
||
for source, url in PUBLIC_IP_PROBES:
|
||
try:
|
||
raw = request_text(
|
||
"GET",
|
||
url,
|
||
timeout_seconds=5,
|
||
network_settings=network_settings,
|
||
).strip()
|
||
ip_text = raw.splitlines()[0].strip()
|
||
ipaddress.ip_address(ip_text)
|
||
return {
|
||
"ip": ip_text,
|
||
"source": source,
|
||
"scope": "public",
|
||
"proxyEnabled": network_settings.get("proxyEnabled") is True,
|
||
"error": None,
|
||
}
|
||
except (HttpRequestError, OSError, ValueError) as error:
|
||
last_error = _friendly_ip_error(error)
|
||
local_ip = _detect_local_ip()
|
||
if local_ip:
|
||
return {
|
||
"ip": local_ip,
|
||
"source": "local",
|
||
"scope": "local",
|
||
"proxyEnabled": network_settings.get("proxyEnabled") is True,
|
||
"error": last_error,
|
||
}
|
||
return {
|
||
"ip": None,
|
||
"source": None,
|
||
"scope": "unknown",
|
||
"proxyEnabled": network_settings.get("proxyEnabled") is True,
|
||
"error": last_error or "无法获取本机 IP 地址。",
|
||
}
|
||
|
||
|
||
def _prompt_form_payload(prompt: dict[str, Any]) -> dict[str, Any]:
|
||
logic = prompt.get("decision_logic") if isinstance(prompt.get("decision_logic"), dict) else {}
|
||
core_principles = _prompt_lines(logic.get("core_principles"))
|
||
entry_preferences = _prompt_lines(logic.get("entry_preferences"))
|
||
position_management = _prompt_lines(logic.get("position_management"))
|
||
return {
|
||
**prompt,
|
||
"role": str(logic.get("role") or ""),
|
||
"corePrinciplesText": "\n".join(core_principles),
|
||
"entryPreferencesText": "\n".join(entry_preferences),
|
||
"positionManagementText": "\n".join(position_management),
|
||
"klineFeeds": prompt.get("klineFeeds") if isinstance(prompt.get("klineFeeds"), dict) else dict(DEFAULT_PROMPT_SETTINGS.get("klineFeeds", {})),
|
||
}
|
||
|
||
|
||
def _prompt_logic_from_payload(payload: dict[str, Any], fallback_prompt: dict[str, Any] | None = None) -> dict[str, Any]:
|
||
fallback_prompt = fallback_prompt or read_prompt_settings()
|
||
fallback_logic = fallback_prompt.get("decision_logic") if isinstance(fallback_prompt.get("decision_logic"), dict) else {}
|
||
if "rawJson" in payload:
|
||
parsed = json.loads(payload["rawJson"])
|
||
if not isinstance(parsed, dict):
|
||
raise ValueError("decision_logic payload must be an object.")
|
||
return parsed
|
||
response_style = DEFAULT_PROMPT_SETTINGS["decision_logic"]["response_style"]
|
||
return {
|
||
"role": str(payload.get("role") or fallback_logic.get("role") or "").strip(),
|
||
"core_principles": _prompt_lines(payload.get("corePrinciplesText", fallback_logic.get("core_principles", []))),
|
||
"entry_preferences": _prompt_lines(payload.get("entryPreferencesText", fallback_logic.get("entry_preferences", []))),
|
||
"position_management": _prompt_lines(payload.get("positionManagementText", fallback_logic.get("position_management", []))),
|
||
"response_style": list(response_style),
|
||
}
|
||
|
||
|
||
class AppRuntime:
|
||
def __init__(self) -> None:
|
||
self.lock = threading.Lock()
|
||
self.trade_lock = threading.Lock()
|
||
self.session_started_at = now_iso()
|
||
self.log_entries: deque[dict[str, Any]] = deque(maxlen=400)
|
||
self.scan_runner = {
|
||
"running": False,
|
||
"lastStartedAt": None,
|
||
"lastFinishedAt": None,
|
||
"lastError": None,
|
||
"lastReason": None,
|
||
}
|
||
self.trade_runners = {
|
||
"paper": {
|
||
"running": False,
|
||
"lastStartedAt": None,
|
||
"lastFinishedAt": None,
|
||
"lastError": None,
|
||
"lastReason": None,
|
||
},
|
||
"live": {
|
||
"running": False,
|
||
"lastStartedAt": None,
|
||
"lastFinishedAt": None,
|
||
"lastError": None,
|
||
"lastReason": None,
|
||
},
|
||
}
|
||
self._scheduler_started = False
|
||
|
||
def record_log(self, level: str, message: str) -> None:
|
||
level_text = (level or "INFO").upper()
|
||
line = f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] [{level_text}] {message}"
|
||
with self.lock:
|
||
self.log_entries.append(
|
||
{
|
||
"at": now_iso(),
|
||
"level": level_text,
|
||
"message": message,
|
||
"line": line,
|
||
}
|
||
)
|
||
print(line, flush=True)
|
||
|
||
def api_logs(self) -> dict[str, Any]:
|
||
with self.lock:
|
||
return {
|
||
"sessionStartedAt": self.session_started_at,
|
||
"entries": list(self.log_entries),
|
||
"paperRunner": dict(self.trade_runners["paper"]),
|
||
"liveRunner": dict(self.trade_runners["live"]),
|
||
"scanRunner": dict(self.scan_runner),
|
||
}
|
||
|
||
def api_latest(self) -> dict[str, Any]:
|
||
scan = read_latest_scan()
|
||
opportunities = scan.get("opportunities", [])
|
||
return {
|
||
"updatedAt": now_iso(),
|
||
"scan": {
|
||
"runDate": scan.get("runDate"),
|
||
"fetchedAt": scan.get("fetchedAt"),
|
||
"opportunities": len(opportunities),
|
||
"research": len([item for item in opportunities if item.get("actionBucket") == "Research"]),
|
||
"watch": len([item for item in opportunities if item.get("actionBucket") == "Watch"]),
|
||
"noTrade": len([item for item in opportunities if item.get("actionBucket") == "No trade"]),
|
||
"scanRunner": self.scan_runner,
|
||
},
|
||
}
|
||
|
||
def _run_scan_job(self, reason: str) -> None:
|
||
with self.lock:
|
||
self.scan_runner["running"] = True
|
||
self.scan_runner["lastStartedAt"] = now_iso()
|
||
self.scan_runner["lastFinishedAt"] = None
|
||
self.scan_runner["lastError"] = None
|
||
self.scan_runner["lastReason"] = reason
|
||
self.record_log("INFO", f"开始刷新候选池,触发原因:{reason}")
|
||
try:
|
||
refresh_candidate_pool()
|
||
latest = read_latest_scan()
|
||
self.record_log("INFO", f"候选池刷新完成,当前候选数:{len(latest.get('opportunities', []))}")
|
||
except Exception as error:
|
||
with self.lock:
|
||
self.scan_runner["lastError"] = str(error)
|
||
self.record_log("ERROR", f"候选池刷新失败:{error}")
|
||
finally:
|
||
with self.lock:
|
||
self.scan_runner["running"] = False
|
||
self.scan_runner["lastFinishedAt"] = now_iso()
|
||
|
||
def _run_trade_job(self, mode: str, reason: str) -> None:
|
||
with self.lock:
|
||
runner = self.trade_runners[mode]
|
||
runner["running"] = True
|
||
runner["lastStartedAt"] = now_iso()
|
||
runner["lastFinishedAt"] = None
|
||
runner["lastError"] = None
|
||
runner["lastReason"] = reason
|
||
self.record_log("INFO", f"开始执行{mode.upper()}交易决策循环,触发原因:{reason}")
|
||
try:
|
||
with self.trade_lock:
|
||
run_trading_cycle_batch(reason=reason, modes=[mode])
|
||
summary = summarize_trading_state()
|
||
latest_decision = summary.get("latestLiveDecision" if mode == "live" else "latestPaperDecision") or {}
|
||
self.record_log(
|
||
"INFO",
|
||
f"{mode.upper()}交易决策循环完成,latestDecision={latest_decision.get('id', 'n/a')}",
|
||
)
|
||
except Exception as error:
|
||
with self.lock:
|
||
self.trade_runners[mode]["lastError"] = str(error)
|
||
self.record_log("ERROR", f"{mode.upper()}交易决策循环失败:{error}")
|
||
finally:
|
||
with self.lock:
|
||
self.trade_runners[mode]["running"] = False
|
||
self.trade_runners[mode]["lastFinishedAt"] = now_iso()
|
||
|
||
def start_scan(self, reason: str = "manual") -> bool:
|
||
with self.lock:
|
||
if self.scan_runner["running"]:
|
||
return False
|
||
thread = threading.Thread(target=self._run_scan_job, args=(reason,), daemon=True)
|
||
thread.start()
|
||
return True
|
||
|
||
def start_trade(self, mode: str, reason: str = "manual") -> bool:
|
||
mode = "live" if str(mode).strip().lower() == "live" else "paper"
|
||
with self.lock:
|
||
if self.trade_runners[mode]["running"]:
|
||
return False
|
||
thread = threading.Thread(target=self._run_trade_job, args=(mode, reason), daemon=True)
|
||
thread.start()
|
||
return True
|
||
|
||
@staticmethod
|
||
def _aligned_slot(reference_ts: float, interval_minutes: int, offset_minutes: int = 0) -> tuple[float, float]:
|
||
timezone_offset = 8 * 60 * 60
|
||
interval_seconds = max(5, interval_minutes) * 60
|
||
adjusted = reference_ts + timezone_offset + (offset_minutes * 60)
|
||
slot_start_local = int(adjusted // interval_seconds) * interval_seconds
|
||
start_ts = slot_start_local - timezone_offset - (offset_minutes * 60)
|
||
end_ts = start_ts + interval_seconds
|
||
return start_ts, end_ts
|
||
|
||
@staticmethod
|
||
def _parse_timestamp(value: str | None) -> float | None:
|
||
if not value:
|
||
return None
|
||
try:
|
||
return __import__("datetime").datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp()
|
||
except Exception:
|
||
return None
|
||
|
||
def _latest_scheduled_trade_ts(self, mode: str) -> float | None:
|
||
settings = read_trading_settings()
|
||
state = read_trading_state(settings)
|
||
for decision in reversed(state.get(mode, {}).get("decisions", [])):
|
||
if decision.get("runnerReason") != "scheduled":
|
||
continue
|
||
return self._parse_timestamp(decision.get("startedAt"))
|
||
return None
|
||
|
||
def next_trade_due_at(self, mode: str) -> str:
|
||
mode = "live" if str(mode).strip().lower() == "live" else "paper"
|
||
settings = read_trading_settings()
|
||
now_ts = time.time()
|
||
start_ts, end_ts = self._aligned_slot(now_ts, settings["decisionIntervalMinutes"])
|
||
latest_ts = self._latest_scheduled_trade_ts(mode)
|
||
if latest_ts is not None and start_ts <= latest_ts < end_ts:
|
||
due_ts = end_ts
|
||
elif now_ts < start_ts + SCHEDULE_TRIGGER_WINDOW_SECONDS:
|
||
due_ts = start_ts
|
||
else:
|
||
due_ts = end_ts
|
||
return __import__("datetime").datetime.utcfromtimestamp(due_ts).replace(microsecond=0).isoformat() + "Z"
|
||
|
||
def _maybe_start_scheduled_scan(self) -> None:
|
||
settings = read_dashboard_settings()
|
||
if not settings["marketAutoScanEnabled"]:
|
||
return
|
||
if self.scan_runner["running"]:
|
||
return
|
||
now_ts = time.time()
|
||
scan = read_latest_scan()
|
||
fetched_at = scan.get("fetchedAt")
|
||
fetched_ts = None
|
||
if fetched_at:
|
||
try:
|
||
fetched_ts = __import__("datetime").datetime.fromisoformat(fetched_at.replace("Z", "+00:00")).timestamp()
|
||
except Exception:
|
||
fetched_ts = None
|
||
slot_start, slot_end = self._aligned_slot(now_ts, settings["marketScanIntervalMinutes"], settings["marketScanOffsetMinute"])
|
||
if fetched_ts is not None and slot_start <= fetched_ts < slot_end:
|
||
return
|
||
if slot_start <= now_ts < slot_start + SCHEDULE_TRIGGER_WINDOW_SECONDS:
|
||
self.start_scan("scheduled")
|
||
|
||
def _maybe_start_scheduled_trade(self) -> None:
|
||
settings = read_trading_settings()
|
||
now_ts = time.time()
|
||
start_ts, end_ts = self._aligned_slot(now_ts, settings["decisionIntervalMinutes"])
|
||
if not (start_ts <= now_ts < start_ts + SCHEDULE_TRIGGER_WINDOW_SECONDS):
|
||
return
|
||
for mode in ("paper", "live"):
|
||
if not settings.get(f"{mode}Trading", {}).get("enabled"):
|
||
continue
|
||
if self.trade_runners[mode]["running"]:
|
||
continue
|
||
latest_ts = self._latest_scheduled_trade_ts(mode)
|
||
if latest_ts is not None and start_ts <= latest_ts < end_ts:
|
||
continue
|
||
self.start_trade(mode, "scheduled")
|
||
|
||
def start_scheduler(self) -> None:
|
||
if self._scheduler_started:
|
||
return
|
||
self._scheduler_started = True
|
||
|
||
def loop() -> None:
|
||
while True:
|
||
try:
|
||
self._maybe_start_scheduled_scan()
|
||
self._maybe_start_scheduled_trade()
|
||
except Exception as error:
|
||
self.record_log("ERROR", f"调度器异常:{error}")
|
||
time.sleep(10)
|
||
|
||
thread = threading.Thread(target=loop, daemon=True)
|
||
thread.start()
|
||
self.record_log("INFO", "自动调度器已启动。")
|
||
|
||
|
||
def _read_json_body(handler: BaseHTTPRequestHandler) -> dict[str, Any]:
|
||
length = int(handler.headers.get("content-length") or 0)
|
||
if length <= 0:
|
||
return {}
|
||
raw = handler.rfile.read(length).decode("utf-8")
|
||
if not raw.strip():
|
||
return {}
|
||
payload = json.loads(raw)
|
||
if not isinstance(payload, dict):
|
||
raise ValueError("JSON body must be an object.")
|
||
return payload
|
||
|
||
|
||
def _json_response(handler: BaseHTTPRequestHandler, payload: Any, status: int = 200) -> None:
|
||
data = json.dumps(payload, ensure_ascii=False, indent=2).encode("utf-8")
|
||
handler.send_response(status)
|
||
handler.send_header("Content-Type", "application/json; charset=utf-8")
|
||
handler.send_header("Cache-Control", "no-store")
|
||
handler.send_header("Content-Length", str(len(data)))
|
||
handler.end_headers()
|
||
handler.wfile.write(data)
|
||
|
||
|
||
def _text_response(handler: BaseHTTPRequestHandler, payload: str, status: int = 200, content_type: str = "text/plain; charset=utf-8") -> None:
|
||
data = payload.encode("utf-8")
|
||
handler.send_response(status)
|
||
handler.send_header("Content-Type", content_type)
|
||
handler.send_header("Cache-Control", "no-store")
|
||
handler.send_header("Content-Length", str(len(data)))
|
||
handler.end_headers()
|
||
handler.wfile.write(data)
|
||
|
||
|
||
def _static_content_type(path: Path) -> str:
|
||
if path.suffix == ".html":
|
||
return "text/html; charset=utf-8"
|
||
if path.suffix == ".js":
|
||
return "text/javascript; charset=utf-8"
|
||
if path.suffix == ".css":
|
||
return "text/css; charset=utf-8"
|
||
if path.suffix == ".json":
|
||
return "application/json; charset=utf-8"
|
||
return "application/octet-stream"
|
||
|
||
|
||
class TradingAgentHandler(BaseHTTPRequestHandler):
|
||
runtime: AppRuntime
|
||
|
||
def do_GET(self) -> None: # noqa: N802
|
||
self._handle("GET")
|
||
|
||
def do_POST(self) -> None: # noqa: N802
|
||
self._handle("POST")
|
||
|
||
def log_message(self, format: str, *args) -> None: # noqa: A003
|
||
return
|
||
|
||
def _handle(self, method: str) -> None:
|
||
parsed = urlparse(self.path)
|
||
try:
|
||
if method == "GET" and parsed.path == "/api/latest":
|
||
return _json_response(self, self.runtime.api_latest())
|
||
if method == "GET" and parsed.path == "/api/opportunities":
|
||
return _json_response(self, read_latest_scan())
|
||
if method == "GET" and parsed.path == "/api/logs":
|
||
return _json_response(self, self.runtime.api_logs())
|
||
if method == "GET" and parsed.path == "/api/settings":
|
||
return _json_response(self, read_dashboard_settings())
|
||
if method == "POST" and parsed.path == "/api/settings":
|
||
result = write_dashboard_settings(_read_json_body(self))
|
||
self.runtime.record_log(
|
||
"INFO",
|
||
f"Dashboard 设置已保存,pageAutoRefreshSeconds={result.get('pageAutoRefreshSeconds')}",
|
||
)
|
||
return _json_response(self, result)
|
||
if method == "GET" and parsed.path == "/api/trading/settings":
|
||
return _json_response(self, {**read_trading_settings(), "exchangeCatalog": read_live_exchange_catalog()})
|
||
if method == "POST" and parsed.path == "/api/trading/settings":
|
||
result = write_trading_settings(_read_json_body(self))
|
||
refresh_result = refresh_account_state_after_settings_save()
|
||
live_sync_warnings = refresh_result.get("liveSyncWarnings") if isinstance(refresh_result, dict) else []
|
||
self.runtime.record_log(
|
||
"INFO",
|
||
"运行设置已保存,"
|
||
f"decisionIntervalMinutes={result.get('decisionIntervalMinutes')},"
|
||
f"activeExchange={result.get('activeExchange', 'binance')},"
|
||
f"paper={result.get('paperTrading', {}).get('enabled', False)},"
|
||
f"live={result.get('liveTrading', {}).get('enabled', False)}",
|
||
)
|
||
if live_sync_warnings:
|
||
self.runtime.record_log("INFO", f"保存运行设置后已刷新实盘账户:{'; '.join(str(item) for item in live_sync_warnings[:2])}")
|
||
else:
|
||
self.runtime.record_log("INFO", "保存运行设置后已刷新账户状态。")
|
||
return _json_response(self, {**result, "exchangeCatalog": read_live_exchange_catalog()})
|
||
if method == "GET" and parsed.path == "/api/trading/provider":
|
||
return _json_response(self, read_llm_provider())
|
||
if method == "POST" and parsed.path == "/api/trading/provider":
|
||
result = write_llm_provider(_read_json_body(self))
|
||
self.runtime.record_log(
|
||
"INFO",
|
||
f"模型配置已保存,provider={result.get('preset', 'custom')},model={result.get('model', 'n/a')}",
|
||
)
|
||
return _json_response(self, result)
|
||
if method == "GET" and parsed.path == "/api/trading/universe":
|
||
universe = read_fixed_universe()
|
||
universe["rawSymbols"] = "\n".join(universe.get("symbols", []))
|
||
universe["candidateSourceCode"] = read_candidate_source_code()
|
||
return _json_response(self, universe)
|
||
if method == "POST" and parsed.path == "/api/trading/universe":
|
||
result = write_fixed_universe(_read_json_body(self))
|
||
result["rawSymbols"] = "\n".join(result.get("symbols", []))
|
||
result["candidateSourceCode"] = read_candidate_source_code()
|
||
self.runtime.record_log(
|
||
"INFO",
|
||
f"候选池配置已保存,symbols={len(result.get('symbols', []))},dynamic={result.get('dynamicSource', {}).get('enabled', False)}",
|
||
)
|
||
return _json_response(self, result)
|
||
if method == "POST" and parsed.path == "/api/trading/universe/test":
|
||
payload = _read_json_body(self)
|
||
universe = preview_fixed_universe(payload)
|
||
code_override = payload.get("candidateSourceCode") if "candidateSourceCode" in payload else None
|
||
result = test_candidate_source(universe=universe, code_override=code_override)
|
||
self.runtime.record_log(
|
||
"INFO",
|
||
f"候选池测试已完成,mode={result.get('mode')},symbols={result.get('count', 0)}",
|
||
)
|
||
return _json_response(self, result)
|
||
if method == "GET" and parsed.path == "/api/trading/prompt":
|
||
prompt = read_prompt_settings()
|
||
return _json_response(self, _prompt_form_payload(prompt))
|
||
if method == "GET" and parsed.path == "/api/trading/prompt-library":
|
||
library = read_prompt_library()
|
||
return _json_response(
|
||
self,
|
||
{
|
||
"updated": library.get("updated"),
|
||
"prompts": [_prompt_form_payload(item) for item in library.get("prompts", [])],
|
||
},
|
||
)
|
||
if method == "POST" and parsed.path == "/api/trading/prompt":
|
||
payload = _read_json_body(self)
|
||
payload["decision_logic"] = _prompt_logic_from_payload(payload)
|
||
result = write_prompt_settings(payload)
|
||
self.runtime.record_log("INFO", f"交易逻辑已保存,name={result.get('name', 'default_trading_logic')}")
|
||
return _json_response(self, _prompt_form_payload(result))
|
||
if method == "POST" and parsed.path == "/api/trading/prompt-library/save":
|
||
payload = _read_json_body(self)
|
||
payload["decision_logic"] = _prompt_logic_from_payload(payload)
|
||
result = save_prompt_preset(payload)
|
||
preset = result.get("preset") if isinstance(result.get("preset"), dict) else {}
|
||
self.runtime.record_log("INFO", f"Prompt 模板已保存,name={preset.get('name', 'untitled')},id={preset.get('id', 'n/a')}")
|
||
return _json_response(
|
||
self,
|
||
{
|
||
"preset": _prompt_form_payload(preset),
|
||
"prompts": [_prompt_form_payload(item) for item in result.get("prompts", [])],
|
||
},
|
||
)
|
||
if method == "POST" and parsed.path == "/api/trading/prompt-library/use":
|
||
payload = _read_json_body(self)
|
||
preset = read_prompt_preset(str(payload.get("id") or ""))
|
||
result = write_prompt_settings(
|
||
{
|
||
"name": preset.get("name"),
|
||
"presetId": preset.get("id"),
|
||
"klineFeeds": preset.get("klineFeeds"),
|
||
"decision_logic": preset.get("decision_logic"),
|
||
}
|
||
)
|
||
self.runtime.record_log("INFO", f"Prompt 模板已启用,name={preset.get('name', 'untitled')},id={preset.get('id', 'n/a')}")
|
||
return _json_response(self, _prompt_form_payload(result))
|
||
if method == "POST" and parsed.path == "/api/trading/prompt-library/rename":
|
||
payload = _read_json_body(self)
|
||
result = rename_prompt_preset(str(payload.get("id") or ""), str(payload.get("name") or ""))
|
||
preset = result.get("preset") if isinstance(result.get("preset"), dict) else {}
|
||
self.runtime.record_log("INFO", f"Prompt 模板已重命名,name={preset.get('name', 'untitled')},id={preset.get('id', 'n/a')}")
|
||
return _json_response(
|
||
self,
|
||
{
|
||
"preset": _prompt_form_payload(preset),
|
||
"prompts": [_prompt_form_payload(item) for item in result.get("prompts", [])],
|
||
},
|
||
)
|
||
if method == "POST" and parsed.path == "/api/trading/prompt-library/delete":
|
||
payload = _read_json_body(self)
|
||
result = delete_prompt_preset(str(payload.get("id") or ""))
|
||
self.runtime.record_log("WARN", f"Prompt 模板已删除,id={result.get('deletedId', 'n/a')}")
|
||
return _json_response(
|
||
self,
|
||
{
|
||
"deletedId": result.get("deletedId"),
|
||
"prompts": [_prompt_form_payload(item) for item in result.get("prompts", [])],
|
||
},
|
||
)
|
||
if method == "POST" and parsed.path == "/api/trading/prompt/test":
|
||
payload = _read_json_body(self)
|
||
prompt_override = None
|
||
if {"role", "corePrinciplesText", "entryPreferencesText", "positionManagementText"} & set(payload.keys()) or "rawJson" in payload:
|
||
prompt_override = {
|
||
"name": payload.get("name") or "default_trading_logic",
|
||
"klineFeeds": payload.get("klineFeeds"),
|
||
"decision_logic": _prompt_logic_from_payload(payload),
|
||
}
|
||
mode = "live" if str(payload.get("mode") or "paper").strip().lower() == "live" else "paper"
|
||
result = preview_trading_prompt_decision(mode_override=mode, prompt_override=prompt_override)
|
||
provider_info = result.get("provider") if isinstance(result.get("provider"), dict) else {}
|
||
if provider_info.get("autoConfiguredSaved"):
|
||
self.runtime.record_log(
|
||
"INFO",
|
||
f"模型网关已自动识别并保存,preset={provider_info.get('preset', 'n/a')},apiStyle={provider_info.get('resolvedApiStyle', 'n/a')}",
|
||
)
|
||
self.runtime.record_log("INFO", f"Prompt 测试已完成,mode={mode},candidates={result.get('candidateCount', 0)}")
|
||
return _json_response(self, result)
|
||
if method == "GET" and parsed.path == "/api/trading/live-config":
|
||
return _json_response(self, {**read_live_trading_config(), "exchangeCatalog": read_live_exchange_catalog()})
|
||
if method == "POST" and parsed.path == "/api/trading/live-config":
|
||
result = write_live_trading_config(_read_json_body(self))
|
||
self.runtime.record_log("INFO", f"实盘账号配置已保存,exchange={result.get('exchange', 'binance')}")
|
||
return _json_response(self, {**result, "exchangeCatalog": read_live_exchange_catalog()})
|
||
if method == "GET" and parsed.path == "/api/network":
|
||
return _json_response(self, read_network_settings())
|
||
if method == "GET" and parsed.path == "/api/network/ip":
|
||
return _json_response(self, _network_ip_payload())
|
||
if method == "POST" and parsed.path == "/api/network":
|
||
result = write_network_settings(_read_json_body(self))
|
||
self.runtime.record_log("INFO", f"代理配置已保存,enabled={result.get('proxyEnabled', False)}")
|
||
return _json_response(self, result)
|
||
if method == "GET" and parsed.path == "/api/trading/state":
|
||
payload = summarize_trading_state()
|
||
payload["paperRunner"] = self.runtime.trade_runners["paper"]
|
||
payload["liveRunner"] = self.runtime.trade_runners["live"]
|
||
payload["scanRunner"] = self.runtime.scan_runner
|
||
payload["paperNextDecisionDueAt"] = self.runtime.next_trade_due_at("paper")
|
||
payload["liveNextDecisionDueAt"] = self.runtime.next_trade_due_at("live")
|
||
return _json_response(self, payload)
|
||
if method == "POST" and parsed.path == "/api/trading/run":
|
||
payload = _read_json_body(self)
|
||
mode = "live" if str(payload.get("mode") or "paper").strip().lower() == "live" else "paper"
|
||
started = self.runtime.start_trade(mode, "manual")
|
||
if not started:
|
||
self.runtime.record_log("WARN", f"收到手动{mode.upper()}交易请求,但上一轮仍在执行。")
|
||
return _json_response(self, {"started": started, "mode": mode, "runner": self.runtime.trade_runners[mode], "nextDecisionDueAt": self.runtime.next_trade_due_at(mode)})
|
||
if method == "POST" and parsed.path == "/api/trading/reset":
|
||
payload = _read_json_body(self)
|
||
reset_mode = payload.get("mode") or "paper"
|
||
result = reset_trading_account(str(reset_mode))
|
||
target_label = "实盘" if str(reset_mode).strip().lower() == "live" else "模拟盘"
|
||
self.runtime.record_log("WARN", f"{target_label}账户已重置,mode={reset_mode}")
|
||
return _json_response(self, result)
|
||
if method == "POST" and parsed.path == "/api/trading/flatten":
|
||
payload = _read_json_body(self)
|
||
mode = "live" if str(payload.get("mode") or "paper").strip().lower() == "live" else "paper"
|
||
result = flatten_active_account("manual_flatten", mode_override=mode)
|
||
self.runtime.record_log("WARN", f"已对{mode.upper()}执行全部平仓。")
|
||
return _json_response(self, result)
|
||
if method == "POST" and parsed.path == "/api/scan/run":
|
||
started = self.runtime.start_scan("manual")
|
||
if not started:
|
||
self.runtime.record_log("WARN", "收到手动候选池刷新请求,但上一轮刷新仍在执行。")
|
||
return _json_response(self, {"started": started, "scanRunner": self.runtime.scan_runner})
|
||
if method not in {"GET", "HEAD"}:
|
||
return _text_response(self, "Method not allowed", status=405)
|
||
return self._serve_static(parsed.path)
|
||
except Exception as error:
|
||
self.runtime.record_log("ERROR", f"{method} {parsed.path} 失败:{error}")
|
||
return _json_response(self, {"error": str(error)}, status=500)
|
||
|
||
def _serve_static(self, request_path: str) -> None:
|
||
relative = "index.html" if request_path == "/" else request_path.lstrip("/")
|
||
file_path = (DASHBOARD_DIR / relative).resolve()
|
||
dashboard_root = DASHBOARD_DIR.resolve()
|
||
if dashboard_root not in file_path.parents and file_path != dashboard_root:
|
||
return _text_response(self, "Forbidden", status=403)
|
||
if not file_path.exists() or not file_path.is_file():
|
||
return _text_response(self, "Not found", status=404)
|
||
payload = file_path.read_bytes()
|
||
self.send_response(200)
|
||
self.send_header("Content-Type", _static_content_type(file_path))
|
||
self.send_header("Cache-Control", "no-store")
|
||
self.send_header("Content-Length", str(len(payload)))
|
||
self.end_headers()
|
||
self.wfile.write(payload)
|
||
|
||
|
||
def _next_available_port(host: str, preferred_port: int, max_checks: int = 20) -> int:
|
||
for port in range(preferred_port, preferred_port + max_checks + 1):
|
||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||
if sock.connect_ex((host, port)) != 0:
|
||
return port
|
||
raise RuntimeError(f"Could not find a free port near {preferred_port}.")
|
||
|
||
|
||
def start_server(port_override: int | None = None) -> None:
|
||
settings = read_trading_settings()
|
||
host = settings["server"]["host"]
|
||
preferred_port = int(port_override if port_override is not None else settings["server"]["port"])
|
||
if preferred_port < 1024 or preferred_port > 65535:
|
||
raise ValueError("Port must be between 1024 and 65535.")
|
||
port = _next_available_port(host, preferred_port)
|
||
runtime = AppRuntime()
|
||
TradingAgentHandler.runtime = runtime
|
||
server = ThreadingHTTPServer((host, port), TradingAgentHandler)
|
||
runtime.start_scheduler()
|
||
runtime.record_log("INFO", f"Trading Agent dashboard running at http://{host}:{port}/trader.html")
|
||
server.serve_forever()
|