- 在 swapPricePrecisionHelper 中实现 trimTailZeros 选项支持 - 修改 formatCommon 函数以支持尾随零去除逻辑 - 在 swapTradeView 中为数量类型添加 trimTailZeros 配置 - 添加测试用例验证数量显示的尾随零去除功能 - 确保配置精度在尾随零去除后保持不变
422 lines
22 KiB
JavaScript
422 lines
22 KiB
JavaScript
var swapPricePrecision = (function (global) {
|
||
const defaults = Object.freeze({
|
||
common: {
|
||
amount: { precision: 2, grouping: true },
|
||
quantity: { integerDigits: 8, precision: 2, grouping: true },
|
||
rate: { precision: 4 }
|
||
},
|
||
Stock: { integerDigits: 7, precision: 2, quantityPrecision: 2, quantityIntegerDigits: 8 },
|
||
StockIndex: { integerDigits: 7, precision: 2, quantityPrecision: 2, quantityIntegerDigits: 8 },
|
||
StockIF: { integerDigits: 7, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 8 },
|
||
CommodityFutures: { integerDigits: 7, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 8 },
|
||
CommoditySpot: { integerDigits: 7, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 8 },
|
||
NewOtcStock: { integerDigits: 7, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 8 },
|
||
HKStock: { integerDigits: 7, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 8 },
|
||
HKStockIndex: { integerDigits: 7, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 8 },
|
||
Fund: { integerDigits: 7, precision: 4, quantityPrecision: 4, quantityIntegerDigits: 8 },
|
||
Bond: {
|
||
quantityPrecision: 0, quantityIntegerDigits: 12,
|
||
grossPrice: { integerDigits: 6, precision: 9 },
|
||
netPrice: { integerDigits: 6, precision: 9 },
|
||
yield: { integerDigits: 2, precision: 4 }
|
||
},
|
||
TBonds: {
|
||
quantityPrecision: 0, quantityIntegerDigits: 12,
|
||
grossPrice: { integerDigits: 6, precision: 9 },
|
||
netPrice: { integerDigits: 6, precision: 9 },
|
||
yield: { integerDigits: 2, precision: 4 }
|
||
},
|
||
CreditBonds: {
|
||
quantityPrecision: 0, quantityIntegerDigits: 12,
|
||
grossPrice: { integerDigits: 6, precision: 9 },
|
||
netPrice: { integerDigits: 6, precision: 9 },
|
||
yield: { integerDigits: 2, precision: 4 }
|
||
},
|
||
OtherBonds: {
|
||
quantityPrecision: 0, quantityIntegerDigits: 12,
|
||
grossPrice: { integerDigits: 6, precision: 9 },
|
||
netPrice: { integerDigits: 6, precision: 9 },
|
||
yield: { integerDigits: 2, precision: 4 }
|
||
},
|
||
TBFutures: { integerDigits: 8, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 8 },
|
||
OtherFutures: { integerDigits: 8, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 8 },
|
||
GoldFutures: { integerDigits: 6, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 8 },
|
||
GoldSpot: { integerDigits: 8, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 8 },
|
||
OtherSpot: { integerDigits: 8, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 8 },
|
||
AbroadFutures: { integerDigits: 8, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 8 },
|
||
AbroadSpot: { integerDigits: 8, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 8 },
|
||
AbroadStock: { integerDigits: 8, precision: 2, quantityPrecision: 2, quantityIntegerDigits: 8 },
|
||
AbroadStockIndex: { integerDigits: 8, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 8 },
|
||
ExRate: { integerDigits: 2, precision: 8, quantityPrecision: 4, quantityIntegerDigits: 8 },
|
||
Shibor: { integerDigits: 2, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 8 },
|
||
FixingRepoRate: { integerDigits: 2, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 8 },
|
||
RateYield: {integerDigits: 6, precision: 8, quantityPrecision: 2, quantityIntegerDigits: 8},
|
||
BondIndex: {integerDigits: 6, precision: 4, quantityPrecision: 2, quantityIntegerDigits: 8},
|
||
|
||
});
|
||
|
||
function normalizeDecimal(value) {
|
||
if (value === null || value === undefined || value === '') return '';
|
||
let text = String(value).trim();
|
||
if (/[eE]/.test(text)) {
|
||
const number = Number(text);
|
||
if (!Number.isFinite(number)) return null;
|
||
text = number.toFixed(20).replace(/0+$/, '').replace(/\.$/, '');
|
||
}
|
||
if (!/^[+-]?(?:\d+|\d*\.\d+)$/.test(text)) return null;
|
||
const negative = text.charAt(0) === '-';
|
||
text = text.replace(/^[+-]/, '');
|
||
const parts = text.split('.');
|
||
const integerPart = parts[0].replace(/^0+(?=\d)/, '') || '0';
|
||
const decimalPart = parts.length > 1 ? parts[1] : '';
|
||
const result = integerPart + (decimalPart ? '.' + decimalPart : '');
|
||
return negative && !/^0(?:\.0*)?$/.test(result) ? '-' + result : result;
|
||
}
|
||
|
||
function shiftDecimal(value, places) {
|
||
let normalized = normalizeDecimal(value);
|
||
if (!normalized || !Number.isInteger(places) || places === 0) return normalized;
|
||
const negative = normalized.charAt(0) === '-';
|
||
const parts = (negative ? normalized.substring(1) : normalized).split('.');
|
||
const integerPart = parts[0];
|
||
const decimalPart = parts.length > 1 ? parts[1] : '';
|
||
const digits = integerPart + decimalPart;
|
||
const decimalIndex = integerPart.length + places;
|
||
let text;
|
||
if (decimalIndex <= 0) text = '0.' + '0'.repeat(-decimalIndex) + digits;
|
||
else if (decimalIndex >= digits.length) text = digits + '0'.repeat(decimalIndex - digits.length);
|
||
else text = digits.substring(0, decimalIndex) + '.' + digits.substring(decimalIndex);
|
||
return normalizeDecimal((negative ? '-' : '') + text);
|
||
}
|
||
|
||
function incrementDigits(value) {
|
||
let carry = 1;
|
||
let result = '';
|
||
for (let index = value.length - 1; index >= 0; index--) {
|
||
const digit = value.charCodeAt(index) - 48 + carry;
|
||
if (digit === 10) {
|
||
result = '0' + result;
|
||
carry = 1;
|
||
} else {
|
||
result = String(digit) + result;
|
||
carry = 0;
|
||
}
|
||
}
|
||
return carry ? '1' + result : result;
|
||
}
|
||
|
||
function roundDecimal(value, precision) {
|
||
const normalized = normalizeDecimal(value);
|
||
if (normalized === null || !Number.isInteger(precision) || precision < 0) return value;
|
||
const negative = normalized.charAt(0) === '-';
|
||
const parts = (negative ? normalized.substring(1) : normalized).split('.');
|
||
let integerPart = parts[0];
|
||
const decimalPart = parts.length > 1 ? parts[1] : '';
|
||
if (decimalPart.length <= precision) return normalized;
|
||
|
||
let digits = integerPart + decimalPart.substring(0, precision);
|
||
if (decimalPart.charAt(precision) >= '5') digits = incrementDigits(digits);
|
||
if (digits.length <= precision) digits = digits.padStart(precision + 1, '0');
|
||
integerPart = precision === 0 ? digits : digits.substring(0, digits.length - precision);
|
||
const roundedDecimal = precision === 0 ? '' : digits.substring(digits.length - precision);
|
||
return normalizeDecimal((negative ? '-' : '') + integerPart + (roundedDecimal ? '.' + roundedDecimal : ''));
|
||
}
|
||
|
||
function multiplyDecimal(left, right) {
|
||
const normalizedLeft = normalizeDecimal(left);
|
||
const normalizedRight = normalizeDecimal(right);
|
||
if (normalizedLeft === null || normalizedRight === null) return null;
|
||
|
||
const leftNegative = normalizedLeft.charAt(0) === '-';
|
||
const rightNegative = normalizedRight.charAt(0) === '-';
|
||
const leftParts = (leftNegative ? normalizedLeft.substring(1) : normalizedLeft).split('.');
|
||
const rightParts = (rightNegative ? normalizedRight.substring(1) : normalizedRight).split('.');
|
||
const leftDigits = leftParts.join('');
|
||
const rightDigits = rightParts.join('');
|
||
const result = Array(leftDigits.length + rightDigits.length).fill(0);
|
||
|
||
for (let leftIndex = leftDigits.length - 1; leftIndex >= 0; leftIndex--) {
|
||
for (let rightIndex = rightDigits.length - 1; rightIndex >= 0; rightIndex--) {
|
||
const index = leftIndex + rightIndex + 1;
|
||
const product = (leftDigits.charCodeAt(leftIndex) - 48) * (rightDigits.charCodeAt(rightIndex) - 48) + result[index];
|
||
result[index] = product % 10;
|
||
result[index - 1] += Math.floor(product / 10);
|
||
}
|
||
}
|
||
|
||
const product = result.join('').replace(/^0+/, '') || '0';
|
||
const decimalPlaces = (leftParts[1] || '').length + (rightParts[1] || '').length;
|
||
return shiftDecimal((leftNegative !== rightNegative ? '-' : '') + product, -decimalPlaces);
|
||
}
|
||
|
||
function normalizeRule(rule) {
|
||
if (!rule || typeof rule !== 'object') return null;
|
||
const integerDigits = Number(rule.integerDigits);
|
||
const precision = Number(rule.precision);
|
||
if (!Number.isInteger(integerDigits) || integerDigits < 1 || integerDigits > 18
|
||
|| !Number.isInteger(precision) || precision < 0 || precision > 13) return null;
|
||
return { integerDigits: integerDigits, precision: precision };
|
||
}
|
||
|
||
function normalizeCommonRule(rule) {
|
||
if (!rule || typeof rule !== 'object') return null;
|
||
const precision = Number(rule.precision);
|
||
if (!Number.isInteger(precision) || precision < 0 || precision > 13) return null;
|
||
const integerDigits = Number(rule.integerDigits);
|
||
return {
|
||
precision: precision,
|
||
integerDigits: Number.isInteger(integerDigits) && integerDigits >= 1 && integerDigits <= 18 ? integerDigits : undefined,
|
||
grouping: typeof rule.grouping === 'boolean' ? rule.grouping : undefined
|
||
};
|
||
}
|
||
|
||
function findRule(source, instrumentType, field) {
|
||
const typeRule = source && source[instrumentType];
|
||
return typeRule ? normalizeRule(typeRule[field] || typeRule) : null;
|
||
}
|
||
|
||
function getRule(instrumentType, field) {
|
||
const fallback = findRule(defaults, instrumentType, field);
|
||
if (!fallback) return null;
|
||
return findRule(global.main && global.main.swapPricePrecision, instrumentType, field) || fallback;
|
||
}
|
||
|
||
function formatFixed(value, precision) {
|
||
const normalized = normalizeDecimal(roundDecimal(value, precision));
|
||
if (!normalized) return '';
|
||
const negative = normalized.charAt(0) === '-';
|
||
const parts = (negative ? normalized.substring(1) : normalized).split('.');
|
||
const integerPart = parts[0];
|
||
if (precision === 0) return (negative ? '-' : '') + integerPart;
|
||
return (negative ? '-' : '') + integerPart + '.' + (parts[1] || '').padEnd(precision, '0');
|
||
}
|
||
|
||
function groupDecimal(value) {
|
||
if (!value) return value;
|
||
const negative = value.charAt(0) === '-';
|
||
const source = negative ? value.substring(1) : value;
|
||
const parts = source.split('.');
|
||
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
||
return (negative ? '-' : '') + parts.join('.');
|
||
}
|
||
|
||
function getQuantityPrecision(instrumentType, fallback) {
|
||
if (!instrumentType) return fallback;
|
||
const configured = global.main && global.main.swapPricePrecision;
|
||
const typeRule = (configured || defaults)[instrumentType];
|
||
if (!typeRule || !Object.prototype.hasOwnProperty.call(typeRule, 'quantityPrecision')) return fallback;
|
||
const precision = Number(typeRule.quantityPrecision);
|
||
return Number.isInteger(precision) && precision >= 0 && precision <= 13 ? precision : fallback;
|
||
}
|
||
|
||
function getQuantityIntegerDigits(instrumentType, fallback) {
|
||
if (!instrumentType) return fallback;
|
||
const configured = global.main && global.main.swapPricePrecision;
|
||
const typeRule = (configured || defaults)[instrumentType];
|
||
if (!typeRule || !Object.prototype.hasOwnProperty.call(typeRule, 'quantityIntegerDigits')) return fallback;
|
||
const integerDigits = Number(typeRule.quantityIntegerDigits);
|
||
return Number.isInteger(integerDigits) && integerDigits >= 1 && integerDigits <= 18 ? integerDigits : fallback;
|
||
}
|
||
|
||
function getCommonRule(kind, instrumentType) {
|
||
const fallback = normalizeCommonRule(defaults.common[kind]) || { precision: 2, grouping: false };
|
||
const configured = global.main && global.main.swapPricePrecision
|
||
&& global.main.swapPricePrecision.common;
|
||
const configuredRule = normalizeCommonRule(configured && configured[kind]);
|
||
const rule = configuredRule || fallback;
|
||
if (rule.grouping === undefined) rule.grouping = fallback.grouping;
|
||
if (rule.integerDigits === undefined) rule.integerDigits = fallback.integerDigits;
|
||
if (kind === 'quantity') {
|
||
rule.precision = Math.min(8, getQuantityPrecision(instrumentType, rule.precision));
|
||
rule.integerDigits = Math.min(16, getQuantityIntegerDigits(instrumentType, rule.integerDigits));
|
||
}
|
||
return rule;
|
||
}
|
||
|
||
function formatCommon(kind, value, instrumentType, options) {
|
||
if (value === null || value === undefined || value === '') return '';
|
||
if (instrumentType && typeof instrumentType === 'object') {
|
||
options = instrumentType;
|
||
instrumentType = options.instrumentType;
|
||
}
|
||
const rule = getCommonRule(kind, instrumentType);
|
||
const displayValue = kind === 'rate' ? shiftDecimal(value, 2) : value;
|
||
const formatted = formatFixed(displayValue, rule.precision);
|
||
if (!formatted) return '';
|
||
const grouping = options && options.grouping !== undefined ? !!options.grouping : rule.grouping;
|
||
const trimmed = options && options.trimTailZeros === true
|
||
? formatted.replace(/(\.\d*?[1-9])0+$/, '$1').replace(/\.0+$/, '')
|
||
: formatted;
|
||
const text = grouping ? groupDecimal(trimmed) : trimmed;
|
||
return kind === 'rate' ? text + '%' : text;
|
||
}
|
||
|
||
function format(value, instrumentType, field) {
|
||
const rule = getRule(instrumentType, field);
|
||
if (value === null || value === undefined || value === '') return '';
|
||
if (!rule) return global.otcformat.trading.umprice(value);
|
||
const rounded = roundDecimal(value, rule.precision);
|
||
return rounded === null ? '' : rounded.replace(/(\.\d*?[1-9])0+$/, '$1').replace(/\.0+$/, '');
|
||
}
|
||
|
||
function normalizeInput(value, format, shouldRound) {
|
||
const options = format || {};
|
||
const maxIntegerDigits = Number(options.integerDigits) || 0;
|
||
const precision = Number(options.precision) || 0;
|
||
const source = String(value === null || value === undefined ? '' : value).trim().replaceAll(',', '').replaceAll('。', '.');
|
||
let negative = false;
|
||
let hasDot = false;
|
||
let integerPart = '';
|
||
let decimalPart = '';
|
||
for (let index = 0; index < source.length; index++) {
|
||
const ch = source.charAt(index);
|
||
if (ch >= '0' && ch <= '9') {
|
||
if (hasDot) {
|
||
if (shouldRound || decimalPart.length < precision) decimalPart += ch;
|
||
} else if (!maxIntegerDigits || integerPart.length < maxIntegerDigits) {
|
||
integerPart += ch;
|
||
}
|
||
} else if (ch === '.' && !hasDot && precision > 0) {
|
||
hasDot = true;
|
||
} else if (ch === '-' && index === 0 && options.negative) {
|
||
negative = true;
|
||
}
|
||
}
|
||
if (!integerPart && !decimalPart) return negative ? '-' : '';
|
||
const text = (negative ? '-' : '') + (integerPart || '0') + (hasDot ? '.' + decimalPart : '');
|
||
if (!shouldRound || text.endsWith('.')) return text;
|
||
return roundDecimal(text, precision);
|
||
}
|
||
|
||
function createVueInputComponent() {
|
||
return {
|
||
props: {
|
||
value: { type: [Number, String], default: '' },
|
||
format: { type: Object, default: function () { return {}; } },
|
||
disabled: { type: Boolean }
|
||
},
|
||
data: function () {
|
||
return { text: '', enterPressed: false, formatSnapshot: '' };
|
||
},
|
||
mounted: function () {
|
||
this.text = this.toDisplay(this.value);
|
||
this.formatSnapshot = JSON.stringify(this.format || {});
|
||
},
|
||
methods: {
|
||
toDisplay: function (value) {
|
||
if (value === null || value === undefined || value === '') return '';
|
||
const displayValue = this.format && this.format.percent ? shiftDecimal(value, 2) : String(value);
|
||
return normalizeInput(displayValue, this.format, true);
|
||
},
|
||
toModel: function (value) {
|
||
if (!value || value === '-') return '';
|
||
return this.format && this.format.percent ? shiftDecimal(value, -2) : value;
|
||
},
|
||
updateValue: function (value, shouldRound, shouldCommit) {
|
||
this.text = normalizeInput(value, this.format, shouldRound);
|
||
if (this.text.endsWith('.') && !shouldRound) return;
|
||
if (shouldCommit) this.$emit('input', this.toModel(this.text));
|
||
},
|
||
onInput: function (event) {
|
||
this.updateValue(event.target.value, false, false);
|
||
// When the normalized value is unchanged, Vue skips the DOM patch.
|
||
// Write it directly so excess digits do not remain in the native input.
|
||
event.target.value = this.text;
|
||
},
|
||
onPaste: function (event) {
|
||
const clipboard = event.clipboardData || global.clipboardData;
|
||
if (!clipboard) return;
|
||
event.preventDefault();
|
||
this.updateValue(clipboard.getData('text'), true, false);
|
||
event.target.value = this.text;
|
||
// 粘贴即显式赋值意图:直接派发 input,使「单击空白处标REV/其余清空」「回车前已标记」等行为生效。
|
||
// 原实现只把粘贴内容写回DOM、等失焦时的原生change才派发,而原值粘贴时change不触发→静默无反应。
|
||
this.$emit('input', this.toModel(this.text));
|
||
},
|
||
onKeydown: function (event) {
|
||
this.enterPressed = false;
|
||
this.$emit('keydown', event);
|
||
const keyCode = event.which || event.keyCode;
|
||
if (event.key !== 'Enter' && keyCode !== 13 && keyCode !== 108) return;
|
||
// 回车=用户显式提交意图:无论值是否相对聚焦时变化,都直接派发 input(标REV) + enter(触发计算)。
|
||
// 原实现把 enter 锁在原生 change 里,而 change 仅在值变化时触发,
|
||
// 导致「重输/重贴原值 + 回车」静默不计算(QA 高优 Bug)。
|
||
this.updateValue(this.text, true, false);
|
||
const value = this.toModel(this.text);
|
||
event.target.blur(); // 同步触发原生 change(值变化时再派发一次 input,幂等无害)
|
||
this.$emit('input', value);
|
||
this.$emit('enter', value);
|
||
this.enterPressed = false;
|
||
},
|
||
onChange: function (event) {
|
||
if (this.text.endsWith('.')) this.text = this.text.substring(0, this.text.length - 1);
|
||
this.updateValue(this.text, true, false);
|
||
const value = this.toModel(this.text);
|
||
// 失焦(非回车)→ 仅派发 input 标 REV;enter(计算) 改由 onKeydown 直接派发,
|
||
// 避免 change 不触发时丢失,也避免与 onKeydown 重复派发 enter 造成重复计算。
|
||
this.$emit('input', value);
|
||
event.target.value = this.text;
|
||
}
|
||
},
|
||
watch: {
|
||
value: function (value) {
|
||
const display = this.toDisplay(value);
|
||
if (display !== this.text) this.text = display;
|
||
},
|
||
format: {
|
||
deep: true,
|
||
handler: function () {
|
||
const snapshot = JSON.stringify(this.format || {});
|
||
if (snapshot === this.formatSnapshot) return;
|
||
this.formatSnapshot = snapshot;
|
||
this.text = this.toDisplay(this.value);
|
||
}
|
||
}
|
||
},
|
||
template: '<input type="text" :disabled="disabled" :value="text" @input="onInput" @paste="onPaste" @keydown="onKeydown" @blur="onChange">'
|
||
};
|
||
}
|
||
|
||
return Object.freeze({
|
||
getRule: getRule,
|
||
getCommonPrecision: function (kind, instrumentType) {
|
||
return getCommonRule(kind, instrumentType).precision;
|
||
},
|
||
getCommonInputFormat: function (kind, options, instrumentType) {
|
||
const inputOptions = Object.assign({}, options || {});
|
||
const type = instrumentType || inputOptions.instrumentType;
|
||
delete inputOptions.instrumentType;
|
||
const rule = getCommonRule(kind, type);
|
||
const result = Object.assign(inputOptions, {
|
||
precision: rule.precision,
|
||
grouping: inputOptions.grouping === undefined ? !!rule.grouping : !!inputOptions.grouping,
|
||
trimTailZeros: false
|
||
});
|
||
if (kind === 'quantity') result.stringMode = true;
|
||
if (rule.integerDigits !== undefined) result.integerDigits = rule.integerDigits;
|
||
return result;
|
||
},
|
||
formatCommon: formatCommon,
|
||
normalizeCommon: function (kind, value, instrumentType) {
|
||
return formatCommon(kind, value, instrumentType, { grouping: false });
|
||
},
|
||
getInputFormat: function (instrumentType, field, options) {
|
||
const rule = getRule(instrumentType, field);
|
||
return rule ? Object.assign({}, options, rule) : Object.assign({}, options);
|
||
},
|
||
format: format,
|
||
multiplyDecimal: multiplyDecimal,
|
||
roundDecimal: roundDecimal,
|
||
roundForSubmit: function (value, instrumentType, field, storagePrecisionOffset) {
|
||
const rule = getRule(instrumentType, field);
|
||
if (value === null || value === undefined || value === '' || !rule) return value;
|
||
const offset = Number.isInteger(storagePrecisionOffset) ? storagePrecisionOffset : 0;
|
||
return roundDecimal(value, rule.precision + offset);
|
||
},
|
||
shiftDecimal: shiftDecimal,
|
||
createVueInputComponent: createVueInputComponent
|
||
});
|
||
}(typeof window !== 'undefined' ? window : globalThis));
|
||
|
||
if (typeof module === 'object' && module.exports) module.exports = swapPricePrecision;
|