迭代15: 委托归属候选纳入近期清仓持仓(R-017)

- 交易记录 tab 归属候选只回当前持仓 → 清仓后当日委托失去下拉锚点无法补关联
  (2026-09-07 大连热电实际发生: 盘中归属后被清为未关联,下拉已无做T候选)
- SqliteStore.getAttributionCandidates: 候选 = 该 code 当前持仓 + 近 7 天清仓的持仓
  (closed 标记 + closedAt,排序 当前持仓在前 → 清仓行 closed_at DESC)
- AttributionSelect: 下拉 value 改 holdingId 键控(同策略多轮持仓不撞值)、
  清仓选项「(已清仓 MM-DD)」后缀、已归属但候选缺失显示「当前归属」占位
- 回归: test-r017-candidates 12/12 + 存量回归全绿
This commit is contained in:
2026-09-07 18:15:20 +08:00
parent 51c70c48fb
commit 22ec732607
10 changed files with 278 additions and 17 deletions
+32 -13
View File
@@ -473,47 +473,66 @@ function OrderRows({ order, expanded, onToggle, attributionCandidates, onLoadCan
}
/** 委托归属选择下拉(R-009,Q3 全手动选;Q4 可随时改)
* 候选 = 该 code 当前持仓策略(orders/attribution-candidates);
* 候选 = 该 code 当前持仓 + 近 7 天清仓持仓(R-017orders/attribution-candidates);
* 锚点键 = holdingId(同策略可有多轮持仓行,strategyId 会撞值,R-017 定稿);
* 未设置显示「未关联」,用户点选后持久化(orders/set-attribution)。
*/
function AttributionSelect({ order, candidates, onLoadCandidates, setAttribution }) {
const current = order.strategyId || '';
const currentKey = order.holdingId != null ? String(order.holdingId) : '';
const handleSelect = (ev) => {
const val = ev.target.value;
if (val === current) return;
if (val === currentKey) return;
if (val === '__unassigned__') {
setAttribution(order.orderId, order.code, null, null);
return;
}
const cand = (candidates ?? []).find((c) => c.strategyId === val);
setAttribution(order.orderId, order.code, val, cand ? cand.holdingId : null);
const cand = (candidates ?? []).find((c) => String(c.holdingId) === val);
setAttribution(order.orderId, order.code, cand ? cand.strategyId : null, cand ? cand.holdingId : null);
};
// 已归属但候选列表缺失(如清仓超 7 天):显示「当前归属」占位,避免 select 值悬空
const candByKey = new Map((candidates ?? []).map((c) => [String(c.holdingId), c]));
const hasCurrent = currentKey && candByKey.has(currentKey);
const closedSuffix = (c) => (c.closed ? '(已清仓 ' + fmtClosed(c.closedAt) + '' : '');
return (
<select
value={current || '__unassigned__'}
value={hasCurrent || !currentKey ? (currentKey || '__unassigned__') : currentKey}
onChange={handleSelect}
style={{
fontSize: 12,
padding: '2px 4px',
border: '1px solid ' + (current ? 'var(--dsw-alias-state-success-primary, #2e7d32)' : 'var(--dsw-alias-border-l2, #ccc)'),
border: '1px solid ' + (currentKey ? 'var(--dsw-alias-state-success-primary, #2e7d32)' : 'var(--dsw-alias-border-l2, #ccc)'),
borderRadius: 4,
background: current ? 'color-mix(in srgb, var(--dsw-alias-state-success-primary, #2e7d32) 12%, var(--dsw-alias-bg-layer-1, #fff))' : 'var(--dsw-alias-bg-layer-1, #fff)',
color: current ? 'var(--dsw-alias-state-success-primary, #1b5e20)' : 'var(--dsw-alias-label-tertiary, #999)',
maxWidth: 120,
background: currentKey ? 'color-mix(in srgb, var(--dsw-alias-state-success-primary, #2e7d32) 12%, var(--dsw-alias-bg-layer-1, #fff))' : 'var(--dsw-alias-bg-layer-1, #fff)',
color: currentKey ? 'var(--dsw-alias-state-success-primary, #1b5e20)' : 'var(--dsw-alias-label-tertiary, #999)',
maxWidth: 150,
}}
title="设置该委托的策略归属(可随时修改)"
title="设置该委托的策略/持仓归属(可随时修改;清仓 7 天内可补关联"
>
<option value="__unassigned__">未关联</option>
{!hasCurrent && currentKey && (
<option value={currentKey}>当前归属{order.strategyId || '未知策略'}</option>
)}
{(candidates ?? []).map((c) => (
<option key={c.holdingId} value={c.strategyId}>{c.strategyName || c.strategyId}</option>
<option key={c.holdingId} value={String(c.holdingId)}>
{c.strategyName || c.strategyId}{closedSuffix(c)}
</option>
))}
</select>
);
}
/** 清仓日期短格式(MM-DD */
function fmtClosed(ts) {
if (!ts) return '';
const d = new Date(Number(ts));
if (!isFinite(d.getTime())) return '';
const p2 = (n) => String(n).padStart(2, '0');
return (d.getMonth() + 1) + '-' + p2(d.getDate());
}
const thStyle = { padding: '8px 6px', lineHeight: '24px', whiteSpace: 'nowrap' };
const thClickStyle = { ...thStyle, cursor: 'pointer', userSelect: 'none' };
const tdStyle = { padding: '8px 6px', lineHeight: '24px', whiteSpace: 'nowrap', fontVariantNumeric: 'tabular-nums' };
+14 -4
View File
@@ -683,22 +683,32 @@ export class SqliteStore {
return this.db.prepare(
'SELECT * FROM trade_orders WHERE holding_id=? ORDER BY insert_ts DESC'
).all(holdingId).map((r) => this._mapOrderRow(r));
}
/**
* 委托归属候选列表(用户手动设置用;候选 = 该 code 当前持仓的策略/持仓,Q3 全手动选)
* 委托归属候选列表(用户手动设置用;Q3 全手动选)
* R-017 迭代 15:候选 = 该 code 当前持仓 + 近 7 天清仓的持仓(closed 标记)——
* 清仓后(幽灵清仓最快约 30s 转历史)当日委托仍可补关联到对应 holding。
* @param {string} code 证券代码(含后缀)
* @returns {Array<{strategyId: string, holdingId: number, shares: number}>} 候选列表(可空)
* @returns {Array<{strategyId: string, holdingId: number, shares: number, closed: boolean, closedAt: number|null}>} 候选列表(可空)
*/
getAttributionCandidates(code) {
this.init();
if (!code) return [];
const weekAgo = Date.now() - 7 * 86400000;
return this.db.prepare(
'SELECT holding_id, strategy_id, shares FROM strategy_holdings WHERE code=? AND closed_at IS NULL ORDER BY shares DESC'
).all(code).map((h) => ({
`SELECT holding_id, strategy_id, shares, closed_at FROM strategy_holdings
WHERE code=? AND (closed_at IS NULL OR closed_at >= ?)
ORDER BY (closed_at IS NULL) DESC, shares DESC, closed_at DESC`
).all(code, weekAgo).map((h) => ({
strategyId: h.strategy_id,
holdingId: h.holding_id,
shares: h.shares,
closed: h.closed_at != null,
closedAt: h.closed_at ?? null,
}));
}
_mapOrderRow(r) {
return {
orderId: r.order_id,