tradingSystem/app/services/dividend_feed.py

148 lines
7.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""高股息独立候选来源。人工发起,复用后台准备与原子采纳,不参与原主榜扫描。"""
from copy import deepcopy
from datetime import datetime, timezone, timedelta
import logging
import re
import requests
from app.core.plan_revision import fingerprint, basis
from app.core import tradedays as td
from app.services.plan_feed import PlanFeedError
SOURCE = 'dividend'
BJ = timezone(timedelta(hours=8))
logger = logging.getLogger('pms.dividend')
def read(*, code=None):
from app.services import param_store
if code and not re.fullmatch(r'\d{6}\.(SH|SZ|BJ)', code):
raise ValueError('股票代码格式无效')
base = (param_store.get('PMS_PLAN_API_BASE', '') or '').rstrip('/')
if not base:
raise PlanFeedError('高股息研究地址尚未配置')
try:
res = requests.get(base+'/research/dividend/candidates', params={'code':code} if code else {}, timeout=(1,3))
res.raise_for_status()
data = res.json()
now = datetime.now(BJ)
if td.calendar_degraded():
raise ValueError('交易日历暂不可核对')
expected = str(td.prev_trade_day(now.date()))
if data['schema_version'] != 1 or data['source'] != SOURCE or data['data_date'] != expected:
raise ValueError('高股息资料尚未更新到上一交易日')
decision = data['decision']
if (decision.get('source') != SOURCE or decision.get('policy_version') != data.get('policy_version')
or not re.fullmatch(r'dividend_[0-9a-f]{64}', decision['id'])):
raise ValueError('高股息研究来源不完整')
for value in (decision['cutoff_at'], data['checked_at']):
at = datetime.fromisoformat(value.replace('Z','+00:00'))
if at.tzinfo is None or at > now:
raise ValueError('研究检查时间无法核对')
codes = set()
for row in data['rows']:
c = row['candidate']
if (not re.fullmatch(r'\d{6}\.(SH|SZ|BJ)', row['ts_code']) or row['ts_code'] in codes
or (code and row['ts_code'] != code) or c['ts_code'] != row['ts_code']
or c.get('candidate_source') != SOURCE or c.get('decision') != decision
or not isinstance(row.get('eligible'), bool)):
raise ValueError('高股息候选内容无法核对')
codes.add(row['ts_code'])
if row['eligible'] and (c.get('verdict') != '候选' or row.get('reasons')
or not re.fullmatch('[0-9a-f]{64}', str((c.get('dividend_research') or {}).get('ref', '')))):
raise ValueError('高股息候选缺少准入依据')
return data
except PlanFeedError:
raise
except (requests.RequestException, KeyError, TypeError, ValueError) as e:
logger.warning('高股息来源暂不可用: %s', type(e).__name__)
message = str(e) if isinstance(e, ValueError) and not isinstance(e, requests.RequestException) else '高股息研究暂不可用,恢复后可继续'
raise PlanFeedError(message) from e
def candidates(*, held=(), black=(), recheck_code=None, record=True, route_by_verdict=True):
data = read(code=recheck_code)
items, excluded = [], []
for row in data['rows']:
reason = ('已持仓' if row['ts_code'] in held else '在黑名单中' if row['ts_code'] in black
else ';'.join(row['reasons']) if not row['eligible'] else '')
if reason:
excluded.append({'ts_code':row['ts_code'], 'why':reason})
else:
c = deepcopy(row['candidate'])
c['dividend_ref'] = row['research_ref']
c['dividend_policy_version'] = data['policy_version']
items.append(c)
consumption = {'kind':'rechecked' if recheck_code else 'consumed', 'source':SOURCE,
'decision':data['decision'], 'params':{'policy_version':data['policy_version']},
'context':{'held':sorted(held),'black':sorted(black),'recheck_code':recheck_code},
'rank_mode':'code', 'filtered':excluded, 'order':[r['ts_code'] for r in items]}
cid = fingerprint(consumption)
for row in items:
row.update(consumption_id=cid, plan_basis=basis(row))
out = {'items':items, 'decision':data['decision'], 'date':data['data_date'],
'display_only':[], 'filtered':excluded, 'rank_mode':'code', 'consumption':dict(consumption,id=cid)}
if record:
from app.repo import pms_repo
try:
pms_repo.insert_plan_snapshot(plan_date=data['data_date'],digest=cid[:40],roster=items,
meta=out['consumption'],n_main=len(items))
except Exception as e:
logger.exception('高股息消费凭据保存失败')
out['consumption']['error'] = type(e).__name__
for row in items:
row['consumption_error'] = type(e).__name__
return out
def request_preparation(code, research_ref, *, actor):
"""只排入准备,不计算数量或调用模型。确定性编号与唯一键拦重复请求。"""
from app.services import portfolio, command_service, proposal_service as ps, param_store
from app.services.execution_conflicts import buy_blocker
from app.repo import pms_repo
now = datetime.now()
if not td.is_trade_day(now.date()) or now.hour >= 15 or td.calendar_degraded():
raise ValueError('请在交易日收盘前发起,建议当日有效')
if (param_store.get_bool('PMS_GLOBAL_EXEC_HALT',False)
or param_store.get('PMS_AUTONOMY','propose_only') == 'off'
or param_store.get('PMS_OPEN_AUTONOMY','full') == 'off'):
raise ValueError('建仓评估已暂停')
data = read(code=code)
row = next((r for r in data['rows'] if r['ts_code'] == code), None)
if not row or not row['eligible']:
raise ValueError('暂不具备建仓评估条件:'+(';'.join(row['reasons']) if row else '未进入有效研究名单'))
if not research_ref or row['research_ref'] != research_ref:
raise ValueError('研究内容已更新,请刷新后查看')
pid = f"PRP_{td.ymd(now)}_{code.replace('.','')}_OPEN"
old = pms_repo.get_proposal(pid)
if old:
return _existing(old)
stock = command_service.effective_stock_params()
view = portfolio.positions_view()
if any(r['ts_code'] == code for r in view['held']) or (stock.get(code) or {}).get('black'):
raise ValueError('该票已持仓或在黑名单中,不能新建仓')
blocked = buy_blocker(code)
if blocked:
raise ValueError(blocked)
if code in ps._inflight_open_commitments(view, strict=True)['names']:
raise ValueError('该票已有建仓提议或在途买单,请查看现有进度')
hard = {'candidate_source':SOURCE,'dividend_ref':research_ref,'dividend_policy_version':data['policy_version'],
'decision':data['decision'],'requested_by':actor,'requested_at':now.astimezone().isoformat(),
'name':row.get('name'), 'preparation':{'status':'pending','stage':'requested',
'reason':'等待交易时段完成建仓评估'}}
try:
pms_repo.insert_proposal(proposal_id=pid,ts_code=code,action='OPEN',qty=0,hard_numbers=hard,
expire_at=ps.proposal_expire_at(ttl_hours=24),judge_verdict=None,judge_reason='高股息建仓评估待完成')
except Exception:
old = pms_repo.get_proposal(pid)
if old:
return _existing(old)
raise
return {'ok':True,'proposal_id':pid,'preparing':True,'message':'已排入建仓评估,准备完成后可确认'}
def _existing(old):
if old['status'] in ('WAIT_USER','ACCEPTED'):
return {'ok':True,'proposal_id':old['proposal_id'],'already_requested':True,
'message':'已有该票建议,请查看待确认提议或指令进度'}
raise ValueError('该票今日建议已经结束,请先核对原处理结果')