迭代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:
@@ -473,47 +473,66 @@ function OrderRows({ order, expanded, onToggle, attributionCandidates, onLoadCan
|
||||
}
|
||||
|
||||
/** 委托归属选择下拉(R-009,Q3 全手动选;Q4 可随时改)
|
||||
* 候选 = 该 code 当前持仓策略(orders/attribution-candidates);
|
||||
* 候选 = 该 code 当前持仓 + 近 7 天清仓持仓(R-017,orders/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' };
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user