feat: 换仓策略整合深套解套+T+2前瞻
- T+2前瞻: 扫描自选股中价格距买入区5%以内的A股 - 提前评估现金是否足够 - 不足则评分港股持仓,推荐提前卖出最低评分港股 - T+2到账后目标股入买区时现金已到位 - 深套股在换仓评估中自然排后(score_future_outlook) - 不需专门解套方案,通过换仓机制逐步清理 - 数据治理: holding_strategies去重+中际旭创补策略 每周六cron自动检查
This commit is contained in:
+104
-7
@@ -18,7 +18,32 @@ import json
|
|||||||
import os
|
import os
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from datetime import datetime
|
from datetime import datetime, time
|
||||||
|
|
||||||
|
# 市场时段检查
|
||||||
|
_MARKET_HOURS = {
|
||||||
|
'ashare': (time(9, 30), time(15, 0)),
|
||||||
|
'hk': (time(9, 30), time(16, 0)),
|
||||||
|
}
|
||||||
|
|
||||||
|
def is_ashare(code: str) -> bool:
|
||||||
|
"""判断是否A股代码"""
|
||||||
|
return code.isdigit() and (code.startswith(('6', '5')) or len(code) in (6,))
|
||||||
|
|
||||||
|
def market_is_open(code: str, now: datetime = None) -> bool:
|
||||||
|
"""检查某股票对应市场是否在交易时段内"""
|
||||||
|
if not code:
|
||||||
|
return True
|
||||||
|
now = now or datetime.now()
|
||||||
|
t = now.time()
|
||||||
|
code_str = str(code)
|
||||||
|
if code_str.startswith(('0', '1')) and len(code_str) == 5:
|
||||||
|
# 港股
|
||||||
|
start, end = _MARKET_HOURS['hk']
|
||||||
|
else:
|
||||||
|
# A股(含ETF、科创板)
|
||||||
|
start, end = _MARKET_HOURS['ashare']
|
||||||
|
return start <= t <= end
|
||||||
try:
|
try:
|
||||||
from urllib.request import Request, urlopen
|
from urllib.request import Request, urlopen
|
||||||
except ImportError:
|
except ImportError:
|
||||||
@@ -183,11 +208,12 @@ def trigger_regen_sync(stock_codes=None):
|
|||||||
|
|
||||||
|
|
||||||
def load_cash():
|
def load_cash():
|
||||||
"""从 portfolio.json 实时读可用现金(不含冻结部分),不硬编码"""
|
"""从 portfolio.json 实时读可用现金(可用 ≈ 实时买力),不硬编码"""
|
||||||
try:
|
try:
|
||||||
with open(PORTFOLIO_PATH) as f:
|
with open(PORTFOLIO_PATH) as f:
|
||||||
data = json.load(f)
|
data = json.load(f)
|
||||||
if isinstance(data, dict):
|
if isinstance(data, dict):
|
||||||
|
# 先读 cash_available(拆分了可用/冻结),fallback 到 cash
|
||||||
return data.get("cash_available", data.get("cash", 0))
|
return data.get("cash_available", data.get("cash", 0))
|
||||||
if isinstance(data, list) and len(data) > 1 and isinstance(data[1], dict):
|
if isinstance(data, list) and len(data) > 1 and isinstance(data[1], dict):
|
||||||
return data[1].get("cash_available", data[1].get("cash", 0))
|
return data[1].get("cash_available", data[1].get("cash", 0))
|
||||||
@@ -311,6 +337,7 @@ def main():
|
|||||||
cash = load_cash()
|
cash = load_cash()
|
||||||
stocks = []
|
stocks = []
|
||||||
stale_list = []
|
stale_list = []
|
||||||
|
all_candidates = [] # 所有在买入区的自选(stale+non-stale)
|
||||||
|
|
||||||
for l in wl_lines:
|
for l in wl_lines:
|
||||||
m = re.match(r'\[WL_IN\](?:\s+\[\w+\])*\s+\[自选\]\s+(\S+)\((\d+)\)', l)
|
m = re.match(r'\[WL_IN\](?:\s+\[\w+\])*\s+\[自选\]\s+(\S+)\((\d+)\)', l)
|
||||||
@@ -328,6 +355,8 @@ def main():
|
|||||||
is_stale = "[STRATEGY_STALE]" in l
|
is_stale = "[STRATEGY_STALE]" in l
|
||||||
cur = code_cur.get(code, "")
|
cur = code_cur.get(code, "")
|
||||||
|
|
||||||
|
all_candidates.append((name, code, price, buy_low, buy_high, cur, is_stale))
|
||||||
|
|
||||||
if not is_actionable(cur, code_data.get(code, {}).get("timing_signal", "")) or is_stale:
|
if not is_actionable(cur, code_data.get(code, {}).get("timing_signal", "")) or is_stale:
|
||||||
stale_list.append((name, code, price, buy_low, buy_high, cur))
|
stale_list.append((name, code, price, buy_low, buy_high, cur))
|
||||||
continue
|
continue
|
||||||
@@ -347,11 +376,12 @@ def main():
|
|||||||
if macro_line:
|
if macro_line:
|
||||||
lines.append(f"【市场背景】{macro_line}")
|
lines.append(f"【市场背景】{macro_line}")
|
||||||
|
|
||||||
# [重评] 内部流程 — 不在报告中展示,只执行重评
|
# [关键修复: 2026-06-25] 所有预推票先重评,再出报告
|
||||||
if stale_list:
|
# 不只是 stale 的重评,所有在买入区的自选都先刷新策略,确保推荐不滞后
|
||||||
stale_codes = [s[1] for s in stale_list]
|
to_reassess = list(set(s[1] for s in stocks) | set(s[1] for s in stale_list))
|
||||||
trigger_regen_sync(stale_codes)
|
if to_reassess:
|
||||||
# 重评完成,re-read decisions.json
|
trigger_regen_sync(to_reassess)
|
||||||
|
# 重评完成,re-read decisions.json 获取最新策略
|
||||||
code_data = {}
|
code_data = {}
|
||||||
try:
|
try:
|
||||||
with open("/home/hmo/web-dashboard/data/decisions.json") as f:
|
with open("/home/hmo/web-dashboard/data/decisions.json") as f:
|
||||||
@@ -361,6 +391,18 @@ def main():
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
# 重新过滤:重评后可能有策略变化(止盈/止损/信号变动)
|
||||||
|
# 重建 stocks 列表,用新数据判断(不再用旧 is_stale 标记,因为已全部重评)
|
||||||
|
stocks = []
|
||||||
|
for (name, code, price, buy_low, buy_high, cur, is_stale) in all_candidates:
|
||||||
|
# 重评后重新检查 actionability(用新 timing_signal)
|
||||||
|
sig = code_data.get(code, {}).get("timing_signal", "")
|
||||||
|
if not is_actionable(cur, sig):
|
||||||
|
continue
|
||||||
|
lot = lot_cost(code, price)
|
||||||
|
ratio = lot / cash if cash > 0 else 999
|
||||||
|
stocks.append((name, code, price, buy_low, buy_high, lot, ratio))
|
||||||
|
|
||||||
# 加载portfolio获取持仓信息(A/H去重用)
|
# 加载portfolio获取持仓信息(A/H去重用)
|
||||||
pf = {"holdings": []}
|
pf = {"holdings": []}
|
||||||
try:
|
try:
|
||||||
@@ -415,6 +457,10 @@ def main():
|
|||||||
if skip_ah:
|
if skip_ah:
|
||||||
continue # 同一公司已在另一市场持有,不推荐
|
continue # 同一公司已在另一市场持有,不推荐
|
||||||
|
|
||||||
|
# 市场时段检查:不在交易时段内的市场不推买入建议
|
||||||
|
if not market_is_open(s[1]):
|
||||||
|
continue
|
||||||
|
|
||||||
actionable.append(s)
|
actionable.append(s)
|
||||||
|
|
||||||
if not actionable:
|
if not actionable:
|
||||||
@@ -788,6 +834,57 @@ def main():
|
|||||||
if actual_n <= 0:
|
if actual_n <= 0:
|
||||||
return 0 # 全部冷却中 → 静默,不推
|
return 0 # 全部冷却中 → 静默,不推
|
||||||
|
|
||||||
|
# ── T+2前瞻:扫描近期可能入买区的A股,提前准备现金 ──
|
||||||
|
t2_lines = []
|
||||||
|
try:
|
||||||
|
dec_t2 = json.loads(open("/home/hmo/web-dashboard/data/decisions.json").read())
|
||||||
|
for entry in dec_t2.get("decisions", []):
|
||||||
|
if entry.get("status") == "closed" or entry.get("type") != "自选策略":
|
||||||
|
continue
|
||||||
|
ec = entry["code"]
|
||||||
|
el = entry.get("entry_low", 0) or 0
|
||||||
|
eh = entry.get("entry_high", 0) or 0
|
||||||
|
ep = entry.get("price", 0) or 0
|
||||||
|
if not eh or not ep or el <= 0:
|
||||||
|
continue
|
||||||
|
# A股+价格在买入区上方5%以内(即将进入买入区)
|
||||||
|
if not is_hk_stock(ec) and el <= ep <= eh * 1.05 and ep > eh:
|
||||||
|
anticipation_pct = (ep - eh) / eh * 100
|
||||||
|
lot = lot_cost(ec, ep)
|
||||||
|
if lot > available_cash:
|
||||||
|
# 现金不足 → 卖港股提前准备
|
||||||
|
ph = []
|
||||||
|
for h in pf.get("holdings", []):
|
||||||
|
hs = h.get("shares", 0) or 0
|
||||||
|
hp = h.get("price", 0) or 0
|
||||||
|
hc = h.get("cost", 0) or 0
|
||||||
|
if hs <= 0 or hp <= 0 or not is_hk_stock(h.get("code","")):
|
||||||
|
continue
|
||||||
|
sc = score_future_outlook(h.get("code",""), code_data)
|
||||||
|
ph.append((sc, h))
|
||||||
|
ph.sort(key=lambda x: x[0])
|
||||||
|
if ph:
|
||||||
|
worst = ph[0][1]
|
||||||
|
w_name = worst.get("name","?")
|
||||||
|
w_code = worst.get("code","")
|
||||||
|
w_price = worst.get("price",0)
|
||||||
|
w_shares = worst.get("shares",0)
|
||||||
|
w_value = w_price * w_shares
|
||||||
|
if w_value >= lot:
|
||||||
|
name_e = entry.get("name","")
|
||||||
|
t2_lines.append(
|
||||||
|
f" ⏳ {name_e}({ec})距买入区仅{anticipation_pct:.0f}%,"
|
||||||
|
f"需{lot:,.0f}元。建议提前卖{w_name}({w_code})"
|
||||||
|
f"腾{w_value:,.0f}元(T+2到账后可用)"
|
||||||
|
)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if t2_lines:
|
||||||
|
lines.append("")
|
||||||
|
lines.append("【⏳ 提前准备(T+2港股提前出清)】")
|
||||||
|
lines.extend(t2_lines)
|
||||||
|
|
||||||
lines.insert(0, f"【知微】自选买入提醒 {now} | 总资产{total_assets:,.0f}元")
|
lines.insert(0, f"【知微】自选买入提醒 {now} | 总资产{total_assets:,.0f}元")
|
||||||
out = "\n".join(lines)
|
out = "\n".join(lines)
|
||||||
print(out)
|
print(out)
|
||||||
|
|||||||
Reference in New Issue
Block a user