增加关键位人工输入

This commit is contained in:
dekun
2026-05-22 22:15:46 +08:00
parent 593f8fcff5
commit ac762b540c
20 changed files with 1541 additions and 42 deletions
+198 -2
View File
@@ -8,7 +8,7 @@ from pathlib import Path
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from fastapi import Depends, FastAPI, Form, HTTPException, Request, status
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from starlette.middleware.gzip import GZipMiddleware
@@ -17,6 +17,14 @@ from starlette.middleware.sessions import SessionMiddleware
from .config import Settings
from .daily_report import DailyReportService
from .gemma_client import OllamaGemmaClient
from .key_monitor_service import KeyMonitorService
from .key_sl_tp import (
KEY_MONITOR_TYPES,
normalize_monitor_type,
normalize_sl_tp_mode,
sl_tp_mode_label,
stop_outside_pct_for_mode,
)
from .monitor import MonitorService
from .notifier import WeComNotifier
from .gate import GateClient
@@ -33,6 +41,7 @@ from .storage import Storage
LOGGER = logging.getLogger("onchain_scout.web")
FIXED_BAR = "5m"
DAILY_REPORT_JOB_ID = "daily_report_job"
KEY_MONITOR_JOB_ID = "key_monitor_poll"
FUNNEL_DISPLAY_HOURS_DEFAULT = 24.0
FUNNEL_DISPLAY_HOURS_MIN = 1.0
FUNNEL_DISPLAY_HOURS_MAX = 168.0
@@ -194,6 +203,12 @@ def create_app(settings: Settings) -> FastAPI:
notifier=notifier,
gemma_client=gemma_client,
)
key_monitor = KeyMonitorService(
settings=settings,
storage=storage,
gate=gate_client,
notifier=notifier,
)
daily_report = DailyReportService(
settings=settings,
storage=storage,
@@ -206,6 +221,8 @@ def create_app(settings: Settings) -> FastAPI:
app.state.settings = settings
app.state.storage = storage
app.state.monitor = monitor
app.state.key_monitor = key_monitor
app.state.gate_client = gate_client
app.state.scheduler = scheduler
app.state.auth_user = settings.auth.username
app.state.auth_password_hash = _hash_password(settings.auth.password)
@@ -219,6 +236,15 @@ def create_app(settings: Settings) -> FastAPI:
await _ensure_runtime_defaults(storage)
monitor.state.chart_bar = FIXED_BAR
scheduler.add_job(monitor.run_cycle, "interval", seconds=settings.app.poll_interval_seconds, max_instances=1)
if settings.key_monitor.enabled:
scheduler.add_job(
key_monitor.run_poll,
"interval",
seconds=settings.key_monitor.poll_interval_seconds,
max_instances=1,
id=KEY_MONITOR_JOB_ID,
replace_existing=True,
)
dr = await _get_daily_report_settings(storage, settings)
if dr["enabled"]:
hh, mm = _parse_hhmm(str(dr["run_time_cn"]))
@@ -242,7 +268,8 @@ def create_app(settings: Settings) -> FastAPI:
f"service_started_gate_usdt gemma={'on' if settings.gemma.enabled else 'off'} "
f"proxy={'on ' + settings.proxy.url if settings.proxy.enabled else 'off'} "
f"web_login={'on' if settings.auth.enabled else 'off'} "
f"daily_report={'on' if settings.daily_report.enabled else 'off'}"
f"daily_report={'on' if settings.daily_report.enabled else 'off'} "
f"key_monitor={'on' if settings.key_monitor.enabled else 'off'}"
),
)
LOGGER.info("Service started")
@@ -314,6 +341,7 @@ def create_app(settings: Settings) -> FastAPI:
"intraday_settings": intraday,
"gemma_enabled": settings.gemma.enabled,
"gemma_model": settings.gemma.model,
"key_monitor": settings.key_monitor.model_dump(),
}
)
@@ -498,6 +526,173 @@ def create_app(settings: Settings) -> FastAPI:
)
return JSONResponse({"ok": True, "symbol_blocklist_settings": await _get_symbol_blocklist_settings(storage)})
def _key_rule_text() -> str:
km = settings.key_monitor
return (
f"周期 5m|突破K/确认K:倒数第2/第1根闭合K|量能:突破K量 > 前{km.volume_ma_bars}均量×{km.volume_ratio_min}"
f"计划RR须 > {km.min_planned_rr:g}|日成交额排名前{km.daily_volume_rank_max}"
f"箱体/收敛方案:标准突破(止损突破K外{km.standard_stop_outside_pct:g}%|止盈1×H)或 "
f"趋势突破(止损突破K外{km.trend_stop_outside_pct:g}%|止盈手填)|"
f"触发后企微+{'转发执行器' if km.forward_executor else '不转发执行器'}"
)
@app.get("/api/key-monitors")
async def api_key_monitors_list(_: None = Depends(require_login)) -> JSONResponse:
rows = await storage.list_key_monitors()
enriched = []
for row in rows:
preview = await key_monitor.preview_row(row)
enriched.append({**row, "preview": preview})
history = await storage.list_key_monitor_history(limit=200)
return JSONResponse(
{
"active": enriched,
"history": history,
"rule_text": _key_rule_text(),
"config": settings.key_monitor.model_dump(),
}
)
@app.post("/api/key-monitors")
async def api_key_monitors_add(request: Request, _: None = Depends(require_login)) -> JSONResponse:
body = await request.json()
sym = _normalize_symbol_token(body.get("symbol"))
if not sym:
return JSONResponse({"ok": False, "detail": "invalid symbol"}, status_code=400)
inst = gate_client.symbol_to_swap_inst_id(sym)
monitor_type = normalize_monitor_type(body.get("monitor_type"))
if monitor_type not in KEY_MONITOR_TYPES:
return JSONResponse({"ok": False, "detail": "invalid monitor_type"}, status_code=400)
direction = str(body.get("direction") or "").strip().lower()
if direction not in ("long", "short"):
return JSONResponse({"ok": False, "detail": "direction must be long or short"}, status_code=400)
try:
upper = float(body.get("upper"))
lower = float(body.get("lower"))
except (TypeError, ValueError):
return JSONResponse({"ok": False, "detail": "upper/lower required"}, status_code=400)
if upper <= lower:
return JSONResponse({"ok": False, "detail": "upper must be > lower"}, status_code=400)
sl_tp_mode = normalize_sl_tp_mode(body.get("sl_tp_mode"))
manual_tp = body.get("manual_take_profit")
manual_tp_f: float | None = None
if sl_tp_mode == "trend_manual":
try:
manual_tp_f = float(manual_tp)
except (TypeError, ValueError):
return JSONResponse({"ok": False, "detail": "manual_take_profit required for trend"}, status_code=400)
stop_pct = stop_outside_pct_for_mode(sl_tp_mode)
if sl_tp_mode == "standard":
stop_pct = float(settings.key_monitor.standard_stop_outside_pct)
else:
stop_pct = float(settings.key_monitor.trend_stop_outside_pct)
be = 1 if str(body.get("breakeven_enabled") or "").lower() in ("1", "true", "on", "yes") else 0
kid = await storage.add_key_monitor(
symbol=sym,
inst_id=inst,
monitor_type=monitor_type,
direction=direction,
upper=upper,
lower=lower,
sl_tp_mode=sl_tp_mode,
manual_take_profit=manual_tp_f,
stop_outside_pct=stop_pct,
breakeven_enabled=be,
note=str(body.get("note") or "")[:500] or None,
)
await storage.add_log(
"INFO",
f"key_monitor_added id={kid} sym={sym} type={monitor_type} mode={sl_tp_mode} dir={direction}",
)
return JSONResponse({"ok": True, "id": kid})
@app.delete("/api/key-monitors/{kid}")
async def api_key_monitors_delete(kid: int, _: None = Depends(require_login)) -> JSONResponse:
row = await storage.get_key_monitor(kid)
if not row:
return JSONResponse({"ok": False, "detail": "not_found"}, status_code=404)
await storage.finalize_key_monitor(
row,
close_reason="manual",
last_alert_message=None,
confirm_close=None,
planned_sl=None,
planned_tp=None,
planned_rr=None,
executor_signal_id=None,
executor_status=None,
checks=None,
)
await storage.add_log("INFO", f"key_monitor_manual_close id={kid} sym={row.get('symbol')}")
return JSONResponse({"ok": True})
@app.delete("/api/key-monitors/history/{hid}")
async def api_key_history_delete(hid: int, _: None = Depends(require_login)) -> JSONResponse:
ok = await storage.delete_key_monitor_history(hid)
if not ok:
return JSONResponse({"ok": False, "detail": "not_found"}, status_code=404)
return JSONResponse({"ok": True})
@app.get("/export/key_monitor_history.csv")
async def export_key_monitor_history(
days: int = 30,
_: None = Depends(require_login),
) -> StreamingResponse:
days = max(1, min(365, int(days)))
end_utc = datetime.utcnow()
start_utc = end_utc - timedelta(days=days)
rows = await storage.export_key_monitor_history_rows(start_utc=start_utc, end_utc=end_utc)
import csv
import io
buf = io.StringIO()
head = [
"id",
"symbol",
"monitor_type",
"direction",
"sl_tp_mode",
"upper",
"lower",
"confirm_close",
"planned_sl",
"planned_tp",
"planned_rr",
"executor_signal_id",
"executor_status",
"close_reason",
"closed_at",
]
w = csv.writer(buf)
w.writerow(head)
for r in rows:
w.writerow(
[
r.get("id"),
r.get("symbol"),
r.get("monitor_type"),
r.get("direction"),
r.get("sl_tp_mode"),
r.get("upper"),
r.get("lower"),
r.get("confirm_close"),
r.get("planned_sl"),
r.get("planned_tp"),
r.get("planned_rr"),
r.get("executor_signal_id"),
r.get("executor_status"),
r.get("close_reason"),
r.get("closed_at"),
]
)
day = datetime.utcnow().strftime("%Y%m%d")
content = buf.getvalue().encode("utf-8-sig")
return StreamingResponse(
iter([content]),
media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": f'attachment; filename="key_monitor_history_{day}.csv"'},
)
@app.get("/api/alerts")
async def api_alerts(_: None = Depends(require_login)) -> JSONResponse:
alerts = await storage.get_recent_alerts(limit=120)
@@ -541,6 +736,7 @@ def create_app(settings: Settings) -> FastAPI:
"url": settings.proxy.url if settings.proxy.enabled else "",
},
"order_executor": read_snapshot(settings),
"key_monitor": settings.key_monitor.model_dump(),
"watch_symbols": symbols,
}
)