diff --git a/app/services/ledger_service.py b/app/services/ledger_service.py index 34f39c3..725f563 100644 --- a/app/services/ledger_service.py +++ b/app/services/ledger_service.py @@ -1212,6 +1212,11 @@ def premarket() -> dict: out["refs"] += 1 except Exception as e: out["errors"].append(f"{code} 参考位取数失败: {e}") + try: + # 隔夜过期的待确认提议在盘前就置 EXPIRED (2026-09-16): 失效时刻不跨自然日, 开盘前页面不该还挂着昨天的。 + out["expired_proposals"] = pms_repo.expire_proposals() + except Exception as e: + out["errors"].append(f"过期提议清扫失败: {e}") try: out["brake"] = _settle_brake() # 「该踩没踩上」必须冒到盘前准备的 errors 里 —— 它不是一条 warning, 是风控没生效 diff --git a/app/services/param_store.py b/app/services/param_store.py index 3a81d1c..a617e02 100644 --- a/app/services/param_store.py +++ b/app/services/param_store.py @@ -226,7 +226,7 @@ DESC = { "PMS_BUILD_WINDOW_TDAYS": "建仓期窗口 (交易日)", "PMS_FILL_MAX_LOSS": "浮亏深于此不走回踩补足", "PMS_WEAK_NEG_DAYS": "降仓清弱票: 安全垫连续为负天数", - "PMS_PROPOSAL_TTL_HOURS": "自主提议待确认有效期 (小时)", + "PMS_PROPOSAL_TTL_HOURS": "自主提议待确认有效期 (小时); 不跨自然日, 最晚当天 23:59:59 失效", "PMS_CANDIDATE_SOURCE": "升仓候选池来源: plan_api=上游选股计划接口 / buy_plan=旧 trading_buy_plan 表 / both=并集", "PMS_PLAN_API_BASE": "上游选股计划接口根地址; 留空=停用, 此时候选池恒为空并告警", "PMS_PLAN_API_PATH": "计划接口路径 (默认 /plan)", diff --git a/app/services/proposal_service.py b/app/services/proposal_service.py index a672f1e..3850821 100644 --- a/app/services/proposal_service.py +++ b/app/services/proposal_service.py @@ -83,6 +83,14 @@ def scan_and_route(*, now=None, dry_run: bool = False) -> dict: open_autonomy = param_store.get("PMS_OPEN_AUTONOMY", AUTONOMY_FULL) out["open_autonomy"] = open_autonomy + if not dry_run: + # 过期提议先置 EXPIRED (2026-09-16): 失效时刻不跨自然日之后, 隔夜的提议要在开盘前第一轮扫描 + # 就清掉, 不能等 15:10 结算; 也免得下面的跳过集合把一条早该作废的提议当在途。只读试算不动表。 + try: + out["expired_proposals"] = pms_repo.expire_proposals(now) + except Exception as e: # noqa: BLE001 —— 清扫失败不挡扫描 + out["errors"].append(f"过期提议清扫失败: {e}") + try: view = portfolio.positions_view() params = _scan_params(view) @@ -1319,6 +1327,34 @@ def _sd_decline_ledger(code, action, c, verdict, price, sd) -> None: reason=(sd.get("why") or "系统自决放弃")[:500]) +def proposal_expire_at(now=None, ttl_hours=None): + """待确认提议的失效时刻 (2026-09-16 用户拍板): 有效期 PMS_PROPOSAL_TTL_HOURS 小时, 但**不跨自然日**。 + + 提议是按当天的证据 (合议、研判、现价) 出的, 隔夜再采纳等于拿昨天的判断下今天的单。原来 24 小时 + 一刀切, 09:31 出的提议到次日 09:31 才失效, 开盘前十分钟页面还挂着昨天的; 用户要求昨天的提议 + 在自然日结束前失效。TTL 短于当天剩余时间时照旧按 TTL (给短有效期留着)。""" + now = now or datetime.now() + ttl = param_store.get_int("PMS_PROPOSAL_TTL_HOURS", 24) if ttl_hours is None else ttl_hours + day_end = now.replace(hour=23, minute=59, second=59, microsecond=0) + return min(now + timedelta(hours=ttl), day_end) + + +def _as_dt(v): + if v is None or isinstance(v, datetime): + return v + try: + return datetime.fromisoformat(str(v).replace(" ", "T")) + except ValueError: + return None + + +def is_expired(p: dict, now=None) -> bool: + """这条提议是否已过失效时刻 (状态可能还没被清扫成 EXPIRED)。认 datetime 与「YYYY-MM-DD HH:MM:SS」 + 两种写法; 没有失效时刻或认不出按未过期 —— 不替人作废。""" + exp = _as_dt((p or {}).get("expire_at")) + return bool(exp) and exp < (now or datetime.now()) + + def _make_proposal(c, price, verdict) -> str: ttl = param_store.get_int("PMS_PROPOSAL_TTL_HOURS", 24) pid = f"PRP_{td.ymd()}_{c['ts_code'].replace('.', '')}_{c['action']}" @@ -1343,7 +1379,7 @@ def _make_proposal(c, price, verdict) -> str: try: pms_repo.insert_proposal( proposal_id=pid, ts_code=c["ts_code"], action=c["action"], qty=c["qty"], - hard_numbers=hn, expire_at=datetime.now() + timedelta(hours=ttl), + hard_numbers=hn, expire_at=proposal_expire_at(ttl_hours=ttl), judge_verdict=verdict.get("verdict"), judge_reason=(verdict.get("reason") or "")[:500]) except Exception: @@ -1353,7 +1389,7 @@ def _make_proposal(c, price, verdict) -> str: pid = f"{pid}_{datetime.now().strftime('%H%M%S')}" pms_repo.insert_proposal( proposal_id=pid, ts_code=c["ts_code"], action=c["action"], qty=c["qty"], - hard_numbers=hn, expire_at=datetime.now() + timedelta(hours=ttl), + hard_numbers=hn, expire_at=proposal_expire_at(ttl_hours=ttl), judge_verdict=verdict.get("verdict"), judge_reason=(verdict.get("reason") or "")[:500]) return pid diff --git a/app/web/main.py b/app/web/main.py index 788efe9..29c0612 100644 --- a/app/web/main.py +++ b/app/web/main.py @@ -463,6 +463,10 @@ def api_proposals(status: str = Query("WAIT_USER"), limit: int = 100, def _list(): rows = pms_repo.list_proposals(statuses=statuses, limit=limit, include_archived=include_archived) + # 过了失效时刻但还没被清扫成 EXPIRED 的, 页面不再列出 (2026-09-16): 失效不跨自然日之后, + # 隔夜提议要到 08:50 盘前或首轮扫描才置 EXPIRED, 这里按时刻再挡一道, 免得开盘前还挂着昨天的。 + from app.services import proposal_service as psvc + rows = [r for r in rows if r.get("status") != "WAIT_USER" or not psvc.is_expired(r)] # 技术面立场 (2026-09-11): 每条提议挂 tech(提议卡第四行结论用),取不到不塌。 try: from app.services import tech_service @@ -505,6 +509,14 @@ def api_decide(request: Request, proposal_id: str, payload: dict = Body(default= return {"ok": False, "error": "提议不存在"} if p["status"] != "WAIT_USER": return {"ok": False, "error": f"提议已处于 {p['status']}"} + # 过了失效时刻的不能再裁决 (2026-09-16): 状态还没被清扫成 EXPIRED 也一样, 顺手清一遍。 + from app.services import proposal_service as psvc + if psvc.is_expired(p): + try: + pms_repo.expire_proposals() + except Exception as e: # noqa: BLE001 + logger.warning("[提议] 过期清扫失败: %s", e) + return {"ok": False, "error": "提议已过失效时刻, 不能再裁决; 该票由系统重新评估"} if not pms_repo.decide_proposal(proposal_id, decision): return {"ok": False, "error": "提议状态已变更, 请刷新"} hn = p.get("hard_numbers") or {} @@ -564,13 +576,14 @@ def api_decide(request: Request, proposal_id: str, payload: dict = Body(default= def api_create_proposal(payload: dict = Body(...)): """人工补录一条待确认提议 (影子运行期造数与联调用)。""" def _create(): + from app.services import proposal_service as psvc pid = payload.get("proposal_id") or f"PRP_{td.ymd()}_{int(datetime.now().timestamp())}" ttl = param_store.get_int("PMS_PROPOSAL_TTL_HOURS", 24) pms_repo.insert_proposal( proposal_id=pid, ts_code=cs.normalize_code(payload.get("ts_code") or ""), action=payload.get("action") or "ADD", qty=int(payload.get("qty") or 0), hard_numbers=payload.get("hard_numbers") or {}, - expire_at=datetime.now() + timedelta(hours=ttl)) + expire_at=psvc.proposal_expire_at(ttl_hours=ttl)) # 与自主提议同一条规矩: 不跨自然日 return {"ok": True, "proposal_id": pid} return ok(_create) diff --git a/app/web/static/index.html b/app/web/static/index.html index 0cd544b..0c3d7f8 100644 --- a/app/web/static/index.html +++ b/app/web/static/index.html @@ -334,8 +334,8 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;} .postbl th.el-table-fixed-column--right{background:var(--surface-2)!important;} .postbl .el-table__body tr:hover>td.el-table-fixed-column--right, .postbl .el-table__fixed-right .el-table__row:hover>.el-table__cell{background:var(--surface-2)!important;} -.postbl .chips{display:flex;flex-wrap:wrap;gap:4px;align-items:center;} -.postbl .nx{margin-top:3px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;cursor:default;} +.chips{display:flex;flex-wrap:wrap;gap:4px;align-items:center;} +.nx{margin-top:3px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;cursor:default;} .postbl .morebtn{padding:4px 9px;font-weight:700;letter-spacing:1px;} /* ── 左栏 信号流 ── */ @@ -491,7 +491,9 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;} 买入 {{ ov.buy_halt ? '已暂停':'正常' }} 执行 {{ ov.exec_halt ? '已暂停':'正常' }} 自主 {{ tx('autonomy', ov.autonomy) }}? - 选股计划 {{ planStatus.ok===false ? '读不到' : (planStatus.date || '-') }} · {{ planStatus.plan_source==='snapshot'?'快照':(planStatus.plan_source==='live'?'现算':planStatus.plan_source) }} · 赛道闸开 · 赛道闸关? + + 选股计划 {{ planStatus.ok===false ? '读不到' : ('数据至 ' + (planStatus.date || '-')) }} · {{ planFetchedText }} · {{ planStatus.plan_source==='snapshot'?'快照':(planStatus.plan_source==='live'?'现算':planStatus.plan_source) }} · 赛道闸开 · 赛道闸关? 技术面 {{ techChipText }}?