[Asm] 纯文本查看 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light">
<title>补仓实验室 · 回本价与批量补仓</title>
<style>
:root {
--bg: #f3efe6;
--paper: #fffdf8;
--paper-2: #faf6ec;
--ink: #18231d;
--ink-2: #3d4a42;
--muted: #6f7972;
--faint: #8c948d;
--line: #ded8c9;
--line-strong: #c9c1ae;
--green: #0f5c47;
--green-2: #1f7a5e;
--green-soft: #dceae2;
--green-wash: #f0f6f2;
--terra: #bd583b;
--terra-2: #d97757;
--terra-soft: #f6e3da;
--gold: #b28733;
--gold-soft: #f2e7c9;
--dark: #12241d;
--cream: #f7f1e3;
--shadow-sm: 0 4px 16px rgba(31, 42, 35, .05);
--shadow: 0 18px 52px rgba(31, 42, 35, .08);
--radius: 22px;
--font: "Noto Sans SC", "PingFang SC", "Microsoft YaHei", "Segoe UI", sans-serif;
--num: "Bahnschrift", "DIN Alternate", "Segoe UI", "Arial", sans-serif;
}
* { box-sizing: border-box; }
[hidden] { display: none !important; }
html { scroll-behavior: smooth; }
body {
margin: 0;
color: var(--ink);
background:
radial-gradient(1100px 560px at 8% -8%, rgba(15, 92, 71, .09), transparent 62%),
radial-gradient(900px 520px at 102% 2%, rgba(189, 88, 59, .075), transparent 58%),
var(--bg);
font-family: var(--font);
font-size: 15px;
line-height: 1.6;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
body::before {
content: "";
position: fixed;
inset: 0;
z-index: -1;
pointer-events: none;
background-image:
linear-gradient(rgba(24, 35, 29, .032) 1px, transparent 1px),
linear-gradient(90deg, rgba(24, 35, 29, .032) 1px, transparent 1px);
background-size: 48px 48px;
-webkit-mask-image: linear-gradient(to bottom, #000 0%, transparent 78%);
mask-image: linear-gradient(to bottom, #000 0%, transparent 78%);
}
::selection { background: var(--green); color: var(--cream); }
button, input { font: inherit; }
button { cursor: pointer; }
button, input, summary { -webkit-tap-highlight-color: transparent; }
:focus-visible { outline: 2px solid var(--green); outline-offset: 3px; border-radius: 5px; }
.page {
width: min(1360px, calc(100% - 56px));
margin: 0 auto;
padding: 22px 0 46px;
}
.num {
font-family: var(--num);
font-variant-numeric: tabular-nums;
font-feature-settings: "tnum" 1;
letter-spacing: -.02em;
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
padding-bottom: 22px;
border-bottom: 1px solid var(--line);
}
.brand { display: flex; align-items: center; gap: 12px; }
.brand-mark {
display: grid;
place-items: center;
width: 42px;
height: 42px;
border-radius: 13px;
background: var(--dark);
color: var(--cream);
font-size: 21px;
font-weight: 700;
box-shadow: inset 0 0 0 1px rgba(255,255,255,.12);
}
.brand-copy { display: flex; flex-direction: column; line-height: 1.2; }
.brand-copy strong { font-size: 15px; letter-spacing: .02em; }
.brand-copy span {
margin-top: 3px;
color: var(--muted);
font-size: 9.5px;
letter-spacing: .18em;
font-family: var(--num);
}
.top-note {
display: flex;
align-items: center;
gap: 8px;
color: var(--muted);
font-size: 12px;
letter-spacing: .03em;
}
.top-note i {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--green-2);
box-shadow: 0 0 0 4px rgba(31, 122, 94, .1);
}
.hero {
display: grid;
grid-template-columns: minmax(0, 1.34fr) minmax(350px, .66fr);
gap: 44px;
align-items: stretch;
padding: 42px 0 38px;
}
.hero-copy { display: flex; flex-direction: column; justify-content: center; min-width: 0; }
.eyebrow {
display: inline-flex;
align-items: center;
gap: 9px;
width: fit-content;
color: var(--green);
font-size: 11px;
font-weight: 700;
letter-spacing: .16em;
}
.eyebrow-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--gold);
box-shadow: 0 0 0 4px rgba(178, 135, 51, .12);
}
.hero h1 {
margin: 18px 0 20px;
font-size: clamp(44px, 5.25vw, 76px);
line-height: 1.04;
letter-spacing: -.045em;
font-weight: 800;
}
.hero h1 em {
position: relative;
z-index: 0;
color: var(--green);
font-style: normal;
white-space: nowrap;
}
.hero h1 em::after {
content: "";
position: absolute;
left: 1%;
right: -1%;
bottom: 4%;
height: .15em;
border-radius: 99px;
background: var(--gold);
opacity: .36;
z-index: -1;
transform: rotate(-.8deg);
}
.hero-lead {
max-width: 650px;
margin: 0;
color: var(--ink-2);
font-size: 15.5px;
line-height: 1.85;
}
.hero-points {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
max-width: 620px;
margin-top: 28px;
}
.hero-points > div {
display: grid;
grid-template-columns: auto 1fr;
grid-template-rows: auto auto;
column-gap: 8px;
row-gap: 1px;
padding: 11px 12px;
border: 1px solid var(--line);
border-radius: 14px;
background: rgba(255, 253, 248, .56);
}
.hero-points span {
grid-row: 1 / 3;
align-self: center;
color: var(--gold);
font-family: var(--num);
font-size: 11px;
font-weight: 700;
}
.hero-points b { font-size: 12.5px; line-height: 1.3; }
.hero-points small { color: var(--muted); font-size: 10.5px; line-height: 1.3; }
.hero-foot {
margin: 20px 0 0;
color: var(--muted);
font-size: 11.5px;
letter-spacing: .03em;
}
.exposure-card {
position: relative;
overflow: hidden;
display: flex;
flex-direction: column;
justify-content: center;
min-height: 390px;
padding: 28px 28px 24px;
border-radius: 29px;
background:
radial-gradient(540px 280px at 100% 0%, rgba(95, 156, 126, .23), transparent 64%),
linear-gradient(148deg, #17372b 0%, #10251d 54%, #0b1a15 100%);
color: var(--cream);
box-shadow: 0 28px 70px rgba(15, 36, 28, .22);
}
.exposure-card::before,
.exposure-card::after {
content: "";
position: absolute;
border-radius: 50%;
pointer-events: none;
}
.exposure-card::before {
width: 310px;
height: 310px;
right: -132px;
top: -138px;
border: 1px solid rgba(247, 241, 227, .12);
}
.exposure-card::after {
width: 210px;
height: 210px;
right: -82px;
top: -88px;
border: 1px solid rgba(247, 241, 227, .08);
}
.exposure-top {
position: relative;
z-index: 1;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
color: rgba(247, 241, 227, .7);
font-size: 12px;
letter-spacing: .06em;
}
.risk-badge {
padding: 4px 9px;
border: 1px solid rgba(247, 241, 227, .18);
border-radius: 999px;
color: var(--cream);
font-size: 10.5px;
letter-spacing: .04em;
background: rgba(255, 255, 255, .06);
}
.risk-badge[data-level="mid"] { background: rgba(178, 135, 51, .18); border-color: rgba(226, 192, 113, .36); }
.risk-badge[data-level="high"] { background: rgba(189, 88, 59, .2); border-color: rgba(217, 119, 87, .4); }
.risk-badge[data-level="custom"] { background: rgba(178, 135, 51, .16); border-color: rgba(226, 192, 113, .34); color: var(--gold-soft); }
.exposure-value {
position: relative;
z-index: 1;
display: flex;
align-items: flex-start;
gap: 5px;
margin-top: 6px;
line-height: 1;
}
.exposure-value > span:first-child {
font-family: var(--num);
font-variant-numeric: tabular-nums;
font-size: clamp(62px, 5.8vw, 82px);
font-weight: 600;
letter-spacing: -.055em;
}
.pct-sign {
margin-top: 9px;
color: var(--gold-soft);
font-family: var(--num);
font-size: 25px;
font-weight: 500;
}
.exposure-caption {
position: relative;
z-index: 1;
min-height: 44px;
margin: 8px 0 17px;
color: rgba(247, 241, 227, .78);
font-size: 12.5px;
line-height: 1.65;
}
.gauge { position: relative; z-index: 1; margin-bottom: 14px; }
.gauge-track {
position: relative;
height: 7px;
border-radius: 99px;
background: linear-gradient(90deg, #2c8063 0%, #c6a04b 55%, #c65f42 100%);
opacity: .92;
}
.gauge-track::after {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
background: rgba(10, 24, 19, .3);
clip-path: inset(0 0 0 var(--risk-pos, 11%));
}
.gauge-marker {
position: absolute;
top: 50%;
left: var(--risk-pos, 11%);
width: 15px;
height: 15px;
border: 3px solid var(--cream);
border-radius: 50%;
background: var(--dark);
transform: translate(-50%, -50%);
box-shadow: 0 0 0 4px rgba(247, 241, 227, .1);
}
.gauge-labels {
display: flex;
justify-content: space-between;
margin-top: 7px;
color: rgba(247, 241, 227, .42);
font-family: var(--num);
font-size: 8.5px;
letter-spacing: .03em;
}
.formula-box {
position: relative;
z-index: 1;
padding: 10px 12px;
border: 1px solid rgba(247, 241, 227, .12);
border-radius: 10px;
background: rgba(255, 255, 255, .055);
color: rgba(247, 241, 227, .8);
font-size: 11px;
letter-spacing: .01em;
}
.formula-box b { color: var(--cream); font-weight: 650; }
.fraction-badge {
position: relative;
z-index: 1;
margin-top: 9px;
color: rgba(247, 241, 227, .64);
font-size: 11.5px;
}
.fraction-badge b {
display: inline-block;
margin-left: 2px;
color: var(--gold-soft);
font-family: var(--num);
font-size: 16px;
letter-spacing: 0;
vertical-align: -1px;
}
.exposure-facts {
position: relative;
z-index: 1;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 1px;
margin-top: 17px;
overflow: hidden;
border: 1px solid rgba(247, 241, 227, .1);
border-radius: 11px;
background: rgba(247, 241, 227, .08);
}
.exposure-facts > div {
min-width: 0;
padding: 8px 9px;
background: rgba(15, 34, 27, .78);
}
.exposure-facts span {
display: block;
overflow: hidden;
color: rgba(247, 241, 227, .48);
font-size: 8.5px;
letter-spacing: .03em;
white-space: nowrap;
text-overflow: ellipsis;
}
.exposure-facts b {
display: block;
overflow: hidden;
margin-top: 1px;
color: var(--cream);
font-family: var(--num);
font-size: 14px;
font-weight: 550;
white-space: nowrap;
text-overflow: ellipsis;
}
.layout {
display: grid;
grid-template-columns: 366px minmax(0, 1fr);
gap: 24px;
align-items: start;
}
.panel {
border: 1px solid var(--line);
border-radius: var(--radius);
background: rgba(255, 253, 248, .82);
box-shadow: var(--shadow-sm);
-webkit-backdrop-filter: blur(12px);
backdrop-filter: blur(12px);
}
.controls {
position: sticky;
top: 20px;
max-height: calc(100vh - 40px);
overflow: auto;
padding: 20px 20px 18px;
scrollbar-width: thin;
scrollbar-color: var(--line-strong) transparent;
}
.controls::-webkit-scrollbar { width: 5px; }
.controls::-webkit-scrollbar-thumb { background: var(--line-strong); border-radius: 99px; }
.panel-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding-bottom: 13px;
}
.eyebrow-sm {
display: block;
margin-bottom: 4px;
color: var(--muted);
font-family: var(--num);
font-size: 8.5px;
font-weight: 700;
letter-spacing: .18em;
}
.panel-head h2, .card-head h2, .section-head h2, .method-card h2 {
margin: 0;
font-size: 18px;
line-height: 1.35;
letter-spacing: -.02em;
font-weight: 750;
}
.reset-btn {
flex: none;
padding: 5px 10px;
border: 1px solid var(--line);
border-radius: 8px;
background: transparent;
color: var(--muted);
font-size: 11px;
transition: .18s ease;
}
.reset-btn:hover { border-color: var(--green); color: var(--green); background: var(--green-wash); }
.preset-label {
margin: 2px 0 7px;
color: var(--faint);
font-size: 10px;
letter-spacing: .05em;
}
.preset-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 6px;
margin-bottom: 16px;
}
.preset {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
padding: 9px 9px 8px;
border: 1px solid var(--line);
border-radius: 10px;
background: var(--paper-2);
color: var(--ink-2);
text-align: left;
transition: .18s ease;
}
.preset b { font-size: 11.5px; line-height: 1.35; font-weight: 700; white-space: nowrap; }
.preset span { color: var(--muted); font-size: 9px; line-height: 1.35; white-space: nowrap; }
.preset:hover { border-color: var(--green-2); transform: translateY(-1px); }
.preset.active {
border-color: var(--green);
background: var(--green);
color: var(--cream);
box-shadow: 0 6px 16px rgba(15, 92, 71, .18);
}
.preset.active span { color: rgba(247, 241, 227, .68); }
.control {
padding: 14px 0;
border-top: 1px solid var(--line);
}
.control-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 9px;
}
.control-head label {
color: var(--ink-2);
font-size: 12.5px;
font-weight: 650;
letter-spacing: .01em;
}
.control-head output {
flex: none;
color: var(--green);
font-family: var(--num);
font-size: 14px;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.mini-note {
flex: none;
color: var(--faint);
font-size: 9.5px;
letter-spacing: .04em;
}
.hint {
margin: 7px 0 0;
color: var(--muted);
font-size: 10px;
line-height: 1.55;
}
input[type="range"] {
--fill: 50%;
display: block;
width: 100%;
height: 18px;
margin: 0;
padding: 0;
border: 0;
outline: none;
background: transparent;
-webkit-appearance: none;
appearance: none;
cursor: pointer;
}
input[type="range"]::-webkit-slider-runnable-track {
width: 100%;
height: 4px;
border-radius: 99px;
background: linear-gradient(90deg, var(--green-2) 0 var(--fill), #e6e0d3 var(--fill) 100%);
}
input[type="range"]::-moz-range-track {
width: 100%;
height: 4px;
border-radius: 99px;
background: #e6e0d3;
}
input[type="range"]::-moz-range-progress {
height: 4px;
border-radius: 99px;
background: var(--green-2);
}
input[type="range"]::-webkit-slider-thumb {
width: 17px;
height: 17px;
margin-top: -6.5px;
border: 4px solid var(--green);
border-radius: 50%;
background: var(--paper);
box-shadow: 0 1px 5px rgba(15, 36, 28, .2);
-webkit-appearance: none;
appearance: none;
transition: transform .12s ease;
}
input[type="range"]::-moz-range-thumb {
width: 11px;
height: 11px;
border: 4px solid var(--green);
border-radius: 50%;
background: var(--paper);
box-shadow: 0 1px 5px rgba(15, 36, 28, .2);
}
input[type="range"]:hover::-webkit-slider-thumb { transform: scale(1.08); }
input[type="range"]:active::-webkit-slider-thumb { transform: scale(.96); }
input[type="range"].range-loss::-webkit-slider-runnable-track {
background: linear-gradient(90deg, var(--terra-2) 0 var(--fill), #e6e0d3 var(--fill) 100%);
}
input[type="range"].range-loss::-moz-range-progress { background: var(--terra-2); }
input[type="range"].range-loss::-webkit-slider-thumb { border-color: var(--terra); }
input[type="range"].range-loss::-moz-range-thumb { border-color: var(--terra); }
.chip-row {
display: flex;
flex-wrap: wrap;
gap: 5px;
margin-top: 9px;
}
.chip {
padding: 3px 8px;
border: 1px solid var(--line);
border-radius: 6px;
background: transparent;
color: var(--muted);
font-family: var(--num);
font-size: 9.5px;
line-height: 1.5;
transition: .15s ease;
}
.chip:hover { border-color: var(--green-2); color: var(--green); background: var(--green-wash); }
.chip.warm:hover { border-color: var(--terra); color: var(--terra); background: var(--terra-soft); }
.stepper-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-top: 6px;
}
.stepper {
display: inline-flex;
align-items: center;
gap: 0;
overflow: hidden;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--paper);
}
.stepper button {
width: 28px;
height: 28px;
border: 0;
background: transparent;
color: var(--muted);
font-size: 15px;
line-height: 1;
}
.stepper button:hover { background: var(--green-wash); color: var(--green); }
.stepper input {
width: 30px;
height: 28px;
padding: 0;
border: 0;
border-right: 1px solid var(--line);
border-left: 1px solid var(--line);
background: transparent;
color: var(--ink-2);
font-family: var(--num);
font-size: 12px;
text-align: center;
-moz-appearance: textfield;
}
.stepper input::-webkit-outer-spin-button,
.stepper input::-webkit-inner-spin-button { margin: 0; -webkit-appearance: none; }
.stepper span { padding: 0 8px 0 5px; color: var(--muted); font-size: 10px; }
.hint-inline { color: var(--faint); font-size: 9.5px; }
.segmented {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 3px;
padding: 3px;
border: 1px solid var(--line);
border-radius: 10px;
background: #ede8dc;
}
.segmented button {
padding: 7px 8px;
border: 0;
border-radius: 7px;
background: transparent;
color: var(--muted);
font-size: 11px;
font-weight: 600;
transition: .16s ease;
}
.segmented button.active {
background: var(--paper);
color: var(--ink);
box-shadow: 0 2px 7px rgba(31, 42, 35, .09);
}
.segmented-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.segmented-3 button { padding-right: 4px; padding-left: 4px; font-size: 10.5px; }
.segmented-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.mode-switch { margin-top: 2px; }
.mode-panel { margin-top: 8px; }
.control-head.sub { margin-top: 5px; margin-bottom: 7px; }
.control-head.sub label { color: var(--muted); font-size: 11px; font-weight: 550; }
.mode-total {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1px;
overflow: hidden;
border: 1px solid var(--line);
border-radius: 9px;
background: var(--line);
}
.mode-total > div { min-width: 0; padding: 7px 9px; background: var(--paper-2); }
.mode-total span { display: block; color: var(--muted); font-size: 8.5px; }
.mode-total b {
display: block;
overflow: hidden;
margin-top: 1px;
color: var(--green);
font-family: var(--num);
font-size: 13px;
font-weight: 600;
white-space: nowrap;
text-overflow: ellipsis;
}
.select-wrap { position: relative; }
.select-wrap::after {
content: "⌄";
position: absolute;
top: 50%;
right: 11px;
color: var(--muted);
font-size: 13px;
transform: translateY(-55%);
pointer-events: none;
}
.select-wrap select {
width: 100%;
height: 36px;
padding: 0 32px 0 10px;
border: 1px solid var(--line);
border-radius: 9px;
outline: none;
background: var(--paper);
color: var(--ink-2);
font-size: 12px;
-webkit-appearance: none;
appearance: none;
cursor: pointer;
}
.select-wrap select:focus-visible { outline: 2px solid var(--green); outline-offset: 2px; }
.jump-btn {
width: 100%;
padding: 8px 10px;
border: 1px dashed var(--green-2);
border-radius: 9px;
background: var(--green-wash);
color: var(--green);
font-size: 11px;
font-weight: 650;
text-align: left;
transition: .16s ease;
}
.jump-btn:hover { border-style: solid; background: var(--green-soft); }
.loss-input-wrap {
display: inline-flex;
align-items: center;
overflow: hidden;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--paper);
}
.loss-input-wrap input {
width: 48px;
height: 27px;
padding: 0 2px 0 8px;
border: 0;
background: transparent;
color: var(--terra);
font-family: var(--num);
font-size: 14px;
font-weight: 600;
text-align: right;
-moz-appearance: textfield;
}
.loss-input-wrap input::-webkit-outer-spin-button,
.loss-input-wrap input::-webkit-inner-spin-button { margin: 0; -webkit-appearance: none; }
.loss-input-wrap span { padding: 0 8px 0 2px; color: var(--terra); font-size: 11px; }
.loss-input-wrap.compact input { width: 38px; height: 24px; padding-left: 5px; font-size: 12px; }
.loss-input-wrap.compact span { padding-right: 5px; font-size: 10px; }
.capital-wrap {
display: flex;
align-items: center;
overflow: hidden;
border: 1px solid var(--line);
border-radius: 9px;
background: var(--paper);
}
.capital-wrap span {
padding-left: 11px;
color: var(--muted);
font-family: var(--num);
font-size: 13px;
}
.capital-wrap input {
width: 100%;
height: 36px;
padding: 0 10px 0 5px;
border: 0;
background: transparent;
color: var(--ink);
font-family: var(--num);
font-size: 14px;
font-variant-numeric: tabular-nums;
-moz-appearance: textfield;
}
.capital-wrap input::-webkit-outer-spin-button,
.capital-wrap input::-webkit-inner-spin-button { margin: 0; -webkit-appearance: none; }
.results { min-width: 0; }
.kpi-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
}
.kpi {
position: relative;
overflow: hidden;
min-width: 0;
padding: 15px 15px 13px;
border: 1px solid var(--line);
border-radius: 16px;
background: var(--paper);
box-shadow: var(--shadow-sm);
}
.kpi::before {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: var(--line-strong);
}
.kpi-loss { background: linear-gradient(180deg, #fdf1ea 0%, var(--paper) 62%); }
.kpi-loss::before { background: var(--terra); }
.kpi-gain::before { background: var(--gold); }
.kpi-left::before { background: var(--green-2); }
.kpi-saved::before { background: var(--green); }
.kpi-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
color: var(--muted);
font-size: 10.5px;
font-weight: 550;
letter-spacing: .02em;
}
.kpi-code {
color: var(--faint);
font-family: var(--num);
font-size: 7.5px;
font-weight: 700;
letter-spacing: .14em;
}
.kpi-value {
overflow: hidden;
margin: 9px 0 1px;
color: var(--ink);
font-family: var(--num);
font-size: clamp(22px, 2vw, 30px);
font-weight: 600;
line-height: 1.15;
letter-spacing: -.035em;
white-space: nowrap;
text-overflow: ellipsis;
}
.kpi-value.loss { color: var(--terra); }
.kpi-value.green { color: var(--green); }
.kpi-value.is-long { font-size: clamp(18px, 1.55vw, 23px); letter-spacing: -.02em; }
.kpi-meta {
display: flex;
align-items: baseline;
gap: 5px;
color: var(--muted);
font-size: 9.5px;
white-space: nowrap;
}
.kpi-meta b {
color: var(--ink-2);
font-family: var(--num);
font-size: 11.5px;
font-weight: 600;
}
.kpi-loss .kpi-meta b { color: var(--terra); }
.kpi-saved .kpi-meta b { color: var(--green); }
.kpi-foot {
overflow: hidden;
margin: 8px 0 0;
padding-top: 7px;
border-top: 1px dashed var(--line);
color: var(--muted);
font-size: 9px;
line-height: 1.45;
white-space: nowrap;
text-overflow: ellipsis;
}
.strip-card {
display: grid;
grid-template-columns: minmax(220px, .72fr) minmax(0, 1.28fr);
gap: 26px;
align-items: center;
margin-top: 10px;
padding: 18px 20px;
border: 1px solid var(--line);
border-radius: 16px;
background: var(--paper);
box-shadow: var(--shadow-sm);
}
.strip-copy h3 {
margin: 0 0 4px;
font-size: 14px;
font-weight: 750;
letter-spacing: -.01em;
}
.strip-copy p {
margin: 0;
color: var(--muted);
font-size: 10.5px;
line-height: 1.55;
}
.strip-labels {
display: flex;
justify-content: space-between;
gap: 12px;
margin-bottom: 6px;
color: var(--muted);
font-size: 9.5px;
}
.strip-labels b {
margin-left: 3px;
color: var(--terra);
font-family: var(--num);
font-size: 11px;
}
.strip-labels span:last-child b { color: var(--green); }
.strip-bar {
display: flex;
gap: 2px;
width: 100%;
height: 17px;
overflow: hidden;
border-radius: 99px;
background: #e7e4d9;
}
.strip-risk {
flex: none;
width: 11%;
min-width: 2px;
border-radius: 99px 3px 3px 99px;
background: linear-gradient(90deg, #c96346, var(--terra));
box-shadow: inset 0 0 0 1px rgba(255,255,255,.14);
transition: width .25s ease;
}
.strip-safe { flex: 1; border-radius: 3px 99px 99px 3px; background: linear-gradient(90deg, #cfe1d6, #e1ece4); }
.strip-amounts {
display: flex;
justify-content: space-between;
gap: 12px;
margin-top: 6px;
color: var(--muted);
font-size: 9.5px;
}
.strip-amounts b {
margin-left: 3px;
color: var(--ink-2);
font-family: var(--num);
font-size: 11px;
}
.strip-amounts span:first-child b { color: var(--terra); }
.card {
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--paper);
box-shadow: var(--shadow);
}
.chart-card { margin-top: 10px; padding: 20px 20px 14px; }
.card-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 20px;
}
.chart-heading p, .card-head p {
margin: 3px 0 0;
color: var(--muted);
font-size: 10.5px;
line-height: 1.55;
}
.tabs {
display: inline-flex;
flex: none;
gap: 3px;
padding: 3px;
border: 1px solid var(--line);
border-radius: 10px;
background: var(--paper-2);
}
.tab {
padding: 6px 10px;
border: 0;
border-radius: 7px;
background: transparent;
color: var(--muted);
font-size: 10.5px;
font-weight: 600;
white-space: nowrap;
transition: .16s ease;
}
.tab:hover { color: var(--ink); }
.tab.active { background: var(--dark); color: var(--cream); box-shadow: 0 2px 7px rgba(15,36,28,.16); }
.chart-wrap {
position: relative;
width: 100%;
margin-top: 8px;
touch-action: pan-y;
}
#chartSvg { display: block; width: 100%; height: auto; overflow: visible; }
#chartSvg text { font-family: var(--font); }
.chart-tooltip {
position: absolute;
z-index: 5;
width: 208px;
padding: 10px 11px;
border: 1px solid rgba(247, 241, 227, .12);
border-radius: 10px;
background: rgba(18, 36, 29, .96);
color: var(--cream);
box-shadow: 0 12px 32px rgba(15, 36, 28, .24);
pointer-events: none;
-webkit-backdrop-filter: blur(8px);
backdrop-filter: blur(8px);
}
.chart-tooltip[hidden] { display: none; }
#chartSvg[hidden] { display: none; }
.custom-chart { padding: 3px 0 1px; }
.custom-summary {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 1px;
overflow: hidden;
border: 1px solid var(--line);
border-radius: 12px;
background: var(--line);
}
.custom-summary > div { min-width: 0; padding: 10px 12px; background: var(--paper-2); }
.custom-summary span { display: block; color: var(--muted); font-size: 9px; letter-spacing: .02em; }
.custom-summary b {
display: block;
overflow: hidden;
margin-top: 2px;
color: var(--ink);
font-family: var(--num);
font-size: clamp(16px, 1.8vw, 22px);
font-weight: 600;
line-height: 1.2;
white-space: nowrap;
text-overflow: ellipsis;
}
.custom-summary > div:first-child b { color: var(--terra); }
.custom-list { margin-top: 10px; }
.custom-row {
display: grid;
grid-template-columns: 86px minmax(0, 1fr) 112px;
gap: 10px;
align-items: center;
padding: 7px 0;
border-bottom: 1px dashed var(--line);
}
.custom-row:last-child { border-bottom: 0; }
.custom-label { min-width: 0; }
.custom-label b {
display: block;
overflow: hidden;
color: var(--ink-2);
font-size: 10px;
font-weight: 650;
line-height: 1.35;
white-space: nowrap;
text-overflow: ellipsis;
}
.custom-label span { display: block; color: var(--muted); font-family: var(--num); font-size: 8.5px; line-height: 1.35; }
.custom-track {
height: 10px;
overflow: hidden;
border-radius: 99px;
background: #ece7db;
}
.custom-track i {
display: block;
width: 0;
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, #d97757, var(--terra));
transition: width .18s ease;
}
.custom-amount { min-width: 0; text-align: right; }
.custom-amount b {
display: block;
overflow: hidden;
color: var(--terra);
font-family: var(--num);
font-size: 11.5px;
font-weight: 600;
line-height: 1.3;
white-space: nowrap;
text-overflow: ellipsis;
}
.custom-amount small { display: block; color: var(--muted); font-family: var(--num); font-size: 8px; line-height: 1.35; white-space: nowrap; }
.custom-total {
display: grid;
grid-template-columns: 86px minmax(0, 1fr) 112px;
gap: 10px;
align-items: center;
margin-top: 6px;
padding: 9px 10px;
border: 1px solid var(--line);
border-radius: 10px;
background: var(--paper-2);
}
.custom-total .custom-label b { color: var(--ink); }
.custom-total .custom-track { background: #e7e2d6; }
.custom-total .custom-track i { background: var(--ink); }
.custom-total .custom-amount b { color: var(--ink); }
.custom-empty {
margin: 9px 0 0;
padding: 9px 11px;
border: 1px dashed var(--line);
border-radius: 9px;
background: var(--paper-2);
color: var(--muted);
font-size: 10px;
line-height: 1.55;
}
.chart-card.is-custom .chart-foot { justify-content: flex-start; }
.chart-card.is-custom .chart-foot p { text-align: left; }
.tt-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
margin-bottom: 7px;
padding-bottom: 7px;
border-bottom: 1px solid rgba(247, 241, 227, .12);
color: rgba(247, 241, 227, .58);
font-size: 9.5px;
}
.tt-head b { color: var(--cream); font-family: var(--num); font-size: 16px; font-weight: 550; }
.tt-row {
display: grid;
grid-template-columns: 7px 1fr auto;
align-items: center;
gap: 6px;
padding: 2px 0;
color: rgba(247, 241, 227, .72);
font-size: 9.5px;
}
.tt-row b { color: var(--cream); font-family: var(--num); font-size: 11px; font-weight: 550; }
.tt-row b.loss { color: #ef9a7d; }
.tt-dot { width: 6px; height: 6px; border-radius: 50%; }
.tt-dot.current { background: #65b394; }
.tt-dot.base { background: #d97757; }
.tt-sep { height: 1px; margin: 5px 0; background: rgba(247, 241, 227, .1); }
.tt-row.plain { grid-template-columns: 1fr auto; }
.chart-foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
margin-top: 3px;
padding-top: 11px;
border-top: 1px solid var(--line);
}
.legend { display: flex; flex-wrap: wrap; gap: 14px; color: var(--muted); font-size: 9.5px; }
.legend span { display: inline-flex; align-items: center; gap: 5px; white-space: nowrap; }
.legend-line { display: inline-block; width: 18px; height: 0; border-top: 2px solid var(--green); }
.legend-line.base { border-top: 2px dashed var(--terra); }
.chart-foot p { margin: 0; color: var(--muted); font-size: 9.5px; line-height: 1.5; text-align: right; }
.ladder-card { margin-top: 24px; padding: 24px 24px 18px; }
.ladder-badge {
flex: none;
padding: 6px 11px;
border: 1px solid rgba(15, 92, 71, .16);
border-radius: 999px;
background: var(--green-wash);
color: var(--green);
font-family: var(--num);
font-size: 11px;
font-weight: 600;
white-space: nowrap;
}
.ladder { margin-top: 18px; border-top: 1px solid var(--line); }
.ladder-row {
display: grid;
grid-template-columns: 37px minmax(150px, 1.25fr) repeat(4, minmax(74px, .7fr));
gap: 12px;
align-items: center;
padding: 12px 8px 11px;
border-bottom: 1px solid var(--line);
}
.ladder-row.current {
margin: 0 -8px;
padding-right: 16px;
padding-left: 16px;
border-radius: 12px;
background: var(--green-wash);
box-shadow: inset 3px 0 0 var(--green);
}
.ladder-no {
display: grid;
place-items: center;
width: 29px;
height: 29px;
border: 1px solid var(--line);
border-radius: 9px;
background: var(--paper-2);
color: var(--muted);
font-family: var(--num);
font-size: 10px;
font-weight: 600;
}
.current .ladder-no { border-color: var(--green); background: var(--green); color: var(--cream); }
.ladder-name { min-width: 0; }
.ladder-name strong { display: block; font-size: 12px; line-height: 1.35; font-weight: 700; }
.ladder-name small { display: block; margin-top: 1px; color: var(--muted); font-size: 9.5px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ladder-metric { min-width: 0; }
.ladder-metric span { display: block; color: var(--faint); font-size: 8.5px; letter-spacing: .03em; }
.ladder-metric b {
display: block;
margin-top: 1px;
color: var(--ink);
font-family: var(--num);
font-size: 13px;
font-weight: 600;
white-space: nowrap;
}
.ladder-metric b.loss { color: var(--terra); }
.ladder-metric b.good { color: var(--green); }
.ladder-metric small { display: block; color: var(--muted); font-family: var(--num); font-size: 8.5px; white-space: nowrap; }
.ladder-track {
grid-column: 1 / -1;
height: 4px;
margin-top: -3px;
overflow: hidden;
border-radius: 99px;
background: #eee9dd;
}
.ladder-track i {
display: block;
width: 50%;
height: 100%;
border-radius: inherit;
background: #c9c1ae;
transition: width .28s ease;
}
.ladder-row.base .ladder-track i { background: linear-gradient(90deg, #d08c74, var(--terra)); }
.ladder-row.split .ladder-track i { background: linear-gradient(90deg, #d8bd7c, var(--gold)); }
.ladder-row.current .ladder-track i { background: linear-gradient(90deg, #54a083, var(--green)); }
.ladder-row.current .ladder-metric b { color: var(--green); }
.ladder-row.current .ladder-metric b.loss { color: var(--terra); }
.insight {
display: flex;
gap: 11px;
align-items: flex-start;
margin-top: 14px;
padding: 12px 14px;
border: 1px solid var(--line);
border-radius: 11px;
background: var(--paper-2);
color: var(--ink-2);
font-size: 11px;
line-height: 1.7;
}
.insight-icon {
display: grid;
place-items: center;
flex: none;
width: 20px;
height: 20px;
margin-top: 1px;
border-radius: 50%;
background: var(--gold-soft);
color: #7d5a18;
font-family: var(--num);
font-size: 11px;
font-weight: 700;
}
.insight b { color: var(--green); }
.insight .warn { color: var(--terra); }
.stocks-section { margin-top: 30px; }
.section-head {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 20px;
margin-bottom: 13px;
}
.section-head p { margin: 3px 0 0; color: var(--muted); font-size: 10.5px; line-height: 1.55; }
.stock-head-right {
display: flex;
flex: none;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 7px;
}
.stock-summary {
flex: none;
padding: 6px 10px;
border: 1px solid var(--line);
border-radius: 999px;
background: rgba(255, 253, 248, .55);
color: var(--muted);
font-family: var(--num);
font-size: 10px;
white-space: nowrap;
}
.stock-tools {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px;
}
.stock-tools > span { color: var(--faint); font-size: 9px; }
.tool-chip {
padding: 3px 7px;
border: 1px solid var(--line);
border-radius: 6px;
background: var(--paper);
color: var(--muted);
font-family: var(--num);
font-size: 9px;
line-height: 1.5;
transition: .15s ease;
}
.tool-chip:hover { border-color: var(--terra); color: var(--terra); background: var(--terra-soft); }
.stock-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 10px;
}
.stock-card {
position: relative;
overflow: hidden;
padding: 14px 14px 12px;
border: 1px solid var(--line);
border-radius: 15px;
background: var(--paper);
box-shadow: var(--shadow-sm);
}
.stock-card::after {
content: attr(data-index);
position: absolute;
right: 9px;
bottom: -15px;
color: rgba(24, 35, 29, .035);
font-family: var(--num);
font-size: 68px;
font-weight: 700;
line-height: 1;
pointer-events: none;
}
.stock-top {
position: relative;
z-index: 1;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
color: var(--muted);
font-family: var(--num);
font-size: 9.5px;
letter-spacing: .04em;
}
.stock-tag {
padding: 2px 6px;
border-radius: 5px;
background: var(--green-wash);
color: var(--green);
font-family: var(--font);
font-size: 8.5px;
letter-spacing: 0;
white-space: nowrap;
}
.stock-tag.empty { background: #eee9dd; color: var(--muted); }
.stock-tag.idle { background: var(--gold-soft); color: #7d5a18; }
.stock-tag.target { background: var(--terra-soft); color: var(--terra); }
.stock-card.is-empty { border-style: dashed; background: #faf7ef; }
.stock-card.is-empty .stock-alloc b { color: var(--faint); }
.stock-card.is-empty .stock-split { opacity: .55; }
.stock-card.is-empty .stock-settings,
.stock-card.is-empty .custom-editor,
.stock-card.is-empty .stock-rows { opacity: .6; }
.stock-card.is-target { border-color: rgba(189, 88, 59, .42); box-shadow: 0 0 0 1px rgba(189, 88, 59, .06), var(--shadow-sm); }
.stock-alloc {
position: relative;
z-index: 1;
display: flex;
align-items: baseline;
gap: 2px;
margin-top: 8px;
line-height: 1;
}
.stock-alloc b {
color: var(--ink);
font-family: var(--num);
font-size: 27px;
font-weight: 600;
letter-spacing: -.035em;
}
.stock-alloc span { color: var(--muted); font-family: var(--num); font-size: 12px; }
.stock-alloc-label {
position: relative;
z-index: 1;
margin: 1px 0 9px;
color: var(--muted);
font-size: 9px;
}
.stock-split {
position: relative;
z-index: 1;
display: flex;
gap: 2px;
height: 5px;
overflow: hidden;
border-radius: 99px;
background: #ece7db;
}
.stock-split i { display: block; height: 100%; border-radius: inherit; }
.stock-split .active { background: linear-gradient(90deg, #55a486, var(--green-2)); }
.stock-split .reserve { flex: 1; background: #ded8c9; }
.stock-rows {
position: relative;
z-index: 1;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
margin-top: 9px;
}
.stock-rows > div { min-width: 0; }
.stock-rows span { display: block; color: var(--faint); font-size: 8.5px; }
.stock-rows b { display: block; color: var(--ink-2); font-family: var(--num); font-size: 10.5px; font-weight: 600; }
.stock-settings {
position: relative;
z-index: 1;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
margin-top: 9px;
padding-top: 8px;
border-top: 1px dashed var(--line);
}
.stock-settings.one { grid-template-columns: 1fr; }
.stock-field { min-width: 0; }
.stock-field-label { display: block; margin-bottom: 3px; color: var(--muted); font-size: 8.5px; }
.stock-number {
display: flex;
align-items: center;
overflow: hidden;
border: 1px solid var(--line);
border-radius: 7px;
background: var(--paper);
}
.stock-number input {
width: 100%;
height: 26px;
padding: 0 2px 0 7px;
border: 0;
background: transparent;
color: var(--green);
font-family: var(--num);
font-size: 12px;
font-weight: 600;
text-align: right;
-moz-appearance: textfield;
}
.stock-number input::-webkit-outer-spin-button,
.stock-number input::-webkit-inner-spin-button { margin: 0; -webkit-appearance: none; }
.stock-number span { padding: 0 6px 0 2px; color: var(--muted); font-size: 9px; }
.stock-number input:disabled,
.stock-card.is-empty .loss-input-wrap input:disabled,
.custom-range:disabled { opacity: .55; cursor: not-allowed; }
.stock-loss {
position: relative;
z-index: 1;
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
margin-top: 10px;
padding-top: 8px;
border-top: 1px dashed var(--line);
color: var(--muted);
font-size: 9px;
}
.stock-loss b { color: var(--terra); font-family: var(--num); font-size: 12px; font-weight: 600; white-space: nowrap; }
.stock-loss-extra { position: relative; z-index: 1; margin-top: 4px; color: var(--muted); font-family: var(--num); font-size: 8.5px; text-align: right; }
.custom-editor {
position: relative;
z-index: 1;
margin-top: 9px;
padding-top: 8px;
border-top: 1px dashed var(--line);
}
.custom-editor-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 4px;
color: var(--muted);
font-size: 9px;
}
.custom-range { height: 22px; }
.custom-range::-webkit-slider-runnable-track { height: 4px; }
.stock-contrib {
position: relative;
z-index: 1;
margin-top: 4px;
color: var(--muted);
font-family: var(--num);
font-size: 8.5px;
text-align: right;
}
.stock-card.custom-card .stock-loss { margin-top: 7px; }
.stock-card.custom-card::after { display: none; }
.method {
display: grid;
grid-template-columns: minmax(0, 1.18fr) minmax(300px, .82fr);
gap: 14px;
margin-top: 34px;
}
.method-card, .note-card {
padding: 22px;
border: 1px solid var(--line);
border-radius: var(--radius);
background: rgba(255, 253, 248, .72);
}
.method-card h2 { margin-bottom: 10px; }
.calc-line {
padding: 10px 12px;
border-left: 2px solid var(--green);
border-radius: 0 8px 8px 0;
background: var(--paper-2);
color: var(--ink-2);
font-family: var(--num);
font-size: 11px;
line-height: 1.65;
word-break: break-word;
}
.calc-line b { color: var(--terra); }
.formula-list { margin-top: 12px; }
.formula-row {
display: grid;
grid-template-columns: 106px minmax(0, 1fr);
gap: 12px;
padding: 7px 0;
border-bottom: 1px dashed var(--line);
font-size: 10.5px;
}
.formula-row:last-child { border-bottom: 0; }
.formula-row span { color: var(--muted); }
.formula-row code {
color: var(--ink-2);
font-family: var(--num);
font-size: 10.5px;
line-height: 1.65;
}
.note-card h3 { margin: 0 0 9px; font-size: 14px; font-weight: 750; }
.note-card ol { margin: 0; padding-left: 17px; }
.note-card li {
margin-bottom: 8px;
color: var(--ink-2);
font-size: 10.5px;
line-height: 1.65;
}
.note-card li::marker { color: var(--gold); font-family: var(--num); font-size: 9px; }
.note-card b { color: var(--ink); }
footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
margin-top: 28px;
padding-top: 17px;
border-top: 1px solid var(--line);
color: var(--faint);
font-size: 9.5px;
line-height: 1.6;
}
footer .footer-mark { font-family: var(--num); letter-spacing: .12em; white-space: nowrap; }
.mobile-readout { display: none; }
[url=home.php?mod=space&uid=945662]@media[/url] (max-width: 1180px) {
.hero { grid-template-columns: minmax(0, 1.2fr) minmax(330px, .8fr); gap: 28px; }
.layout { grid-template-columns: 338px minmax(0, 1fr); }
.kpi-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.ladder-row { grid-template-columns: 34px minmax(130px, 1.2fr) repeat(4, minmax(66px, .7fr)); gap: 9px; }
}
@media (max-width: 980px) {
.page { width: min(100% - 36px, 820px); padding-bottom: 44px; }
.hero { grid-template-columns: 1fr; gap: 24px; padding-top: 32px; }
.exposure-card { min-height: 0; }
.layout { grid-template-columns: 1fr; }
.controls { position: static; max-height: none; overflow: visible; }
.preset-grid { grid-template-columns: repeat(3, 1fr); }
.kpi-grid { grid-template-columns: repeat(4, 1fr); }
.strip-card { grid-template-columns: 1fr; gap: 14px; }
.method { grid-template-columns: 1fr; }
.mobile-readout.is-visible {
position: fixed;
left: 14px;
right: 14px;
bottom: max(12px, env(safe-area-inset-bottom));
z-index: 40;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 1px;
overflow: hidden;
padding: 9px 11px;
border: 1px solid rgba(247, 241, 227, .13);
border-radius: 14px;
background: rgba(18, 36, 29, .96);
color: var(--cream);
box-shadow: 0 14px 38px rgba(15, 36, 28, .26);
-webkit-backdrop-filter: blur(12px);
backdrop-filter: blur(12px);
}
.mobile-readout > div {
min-width: 0;
padding: 0 9px;
border-left: 1px solid rgba(247, 241, 227, .12);
}
.mobile-readout > div:first-child { border-left: 0; padding-left: 2px; }
.mobile-readout span {
display: block;
color: rgba(247, 241, 227, .5);
font-size: 8px;
letter-spacing: .03em;
}
.mobile-readout b {
display: block;
overflow: hidden;
margin-top: 1px;
color: var(--cream);
font-family: var(--num);
font-size: 13px;
font-weight: 550;
white-space: nowrap;
text-overflow: ellipsis;
}
.mobile-readout > div:first-child b { color: #ef9a7d; }
}
@media (max-width: 760px) {
.page { width: calc(100% - 28px); padding-top: 15px; }
.topbar { padding-bottom: 15px; }
.top-note { display: none; }
.hero { padding: 28px 0 26px; }
.hero h1 { font-size: clamp(40px, 10vw, 58px); }
.hero-lead { font-size: 13.5px; line-height: 1.8; }
.hero-points { gap: 6px; margin-top: 21px; }
.hero-points > div { padding: 8px; }
.hero-points b { font-size: 11px; }
.hero-points small { font-size: 9px; }
.exposure-card { padding: 22px; border-radius: 23px; }
.exposure-value > span:first-child { font-size: 62px; }
.kpi-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
.kpi { padding: 13px 12px 11px; }
.kpi-value { font-size: 22px; }
.kpi-foot { font-size: 8.5px; }
.chart-card { padding: 16px 13px 12px; }
.card-head { flex-direction: column; gap: 11px; }
.tabs { align-self: stretch; display: grid; grid-template-columns: 1fr 1fr; }
.tab { text-align: center; }
.chart-foot { flex-direction: column; align-items: flex-start; gap: 7px; }
.chart-foot p { text-align: left; }
.ladder-card { padding: 18px 14px 14px; }
.ladder-row {
grid-template-columns: 30px 1fr 1fr;
gap: 8px 10px;
padding: 11px 6px 10px;
}
.ladder-row.current { margin: 0 -6px; padding-right: 10px; padding-left: 10px; }
.ladder-no { grid-column: 1; grid-row: 1; }
.ladder-name { grid-column: 2 / 4; grid-row: 1; }
.metric-exposure { grid-column: 2; grid-row: 2; }
.metric-invested { grid-column: 3; grid-row: 2; }
.metric-loss { grid-column: 2; grid-row: 3; }
.metric-gain { grid-column: 3; grid-row: 3; }
.ladder-track { grid-column: 1 / -1; grid-row: 4; }
.section-head { align-items: flex-start; flex-direction: column; gap: 8px; }
.stock-head-right { align-items: flex-start; justify-content: flex-start; }
.custom-row, .custom-total { grid-template-columns: 66px minmax(0, 1fr) 96px; gap: 8px; }
.custom-summary > div { padding: 9px 10px; }
.custom-summary b { font-size: 17px; }
.stock-grid { grid-template-columns: repeat(auto-fill, minmax(155px, 1fr)); }
.stock-alloc b { font-size: 23px; }
.method { margin-top: 26px; }
.method-card, .note-card { padding: 18px; }
.formula-row { grid-template-columns: 90px minmax(0, 1fr); gap: 8px; }
footer { flex-direction: column; align-items: flex-start; gap: 5px; }
}
@media (max-width: 460px) {
.page { width: calc(100% - 20px); }
.brand-mark { width: 37px; height: 37px; border-radius: 11px; font-size: 18px; }
.brand-copy strong { font-size: 13px; }
.brand-copy span { font-size: 8px; }
.hero h1 { font-size: 39px; letter-spacing: -.045em; }
.hero h1 em { white-space: normal; }
.hero-points { grid-template-columns: 1fr; }
.hero-points > div { grid-template-columns: auto 1fr auto; grid-template-rows: auto; align-items: center; }
.hero-points span { grid-row: auto; }
.hero-points small { text-align: right; }
.preset-grid { grid-template-columns: 1fr 1fr; }
.preset:last-child { grid-column: 1 / -1; }
.segmented-3 button { padding-right: 2px; padding-left: 2px; font-size: 9.5px; }
.kpi-grid { grid-template-columns: 1fr 1fr; }
.kpi-value { font-size: 20px; }
.kpi-code { display: none; }
.strip-card { padding: 15px; }
.strip-labels, .strip-amounts { font-size: 8.5px; }
.ladder-row { grid-template-columns: 27px 1fr 1fr; }
.ladder-metric b { font-size: 11.5px; }
.stock-grid { grid-template-columns: 1fr 1fr; gap: 7px; }
.stock-card:last-child:nth-child(odd) { grid-column: 1 / -1; }
.stock-card { padding: 11px 10px 10px; }
.stock-alloc b { font-size: 21px; }
.stock-rows b { font-size: 9.5px; }
.stock-loss { font-size: 8.5px; }
.stock-loss b { font-size: 10.5px; }
.custom-row, .custom-total { grid-template-columns: 56px minmax(0, 1fr) 84px; gap: 6px; }
.custom-label b { font-size: 9px; }
.custom-label span { font-size: 8px; }
.custom-amount b { font-size: 10.5px; }
.custom-amount small { font-size: 7.5px; }
.custom-summary span { font-size: 8px; }
.custom-summary b { font-size: 15px; }
.stock-tools > span { font-size: 8px; }
.formula-row { grid-template-columns: 1fr; gap: 2px; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { scroll-behavior: auto !important; transition: none !important; animation: none !important; }
}
/* ============================================================
补仓计算器 · 新增组件(沿用原页面视觉语言)
============================================================ */
.unit-sign {
margin-top: 9px;
color: var(--gold-soft);
font-family: var(--num);
font-size: 25px;
font-weight: 500;
}
.kpi-breakeven::before { background: var(--gold); }
.kpi-add::before { background: var(--green-2); }
.kpi-total::before { background: var(--dark); }
.kpi-post::before { background: var(--terra); }
.kpi-post.is-gain::before { background: var(--green); }
.kpi-value.gold { color: var(--gold); }
.kpi-value.green { color: var(--green); }
.kpi-value.terra { color: var(--terra); }
.kpi-value.ink { color: var(--ink); }
.kpi-value .kpi-unit {
margin-left: 3px;
font-size: .55em;
font-weight: 500;
letter-spacing: 0;
color: var(--muted);
}
.kpi-post .kpi-value { font-size: clamp(18px, 1.65vw, 25px); letter-spacing: -.025em; }
.segmented-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.segmented-4 button { padding-right: 2px; padding-left: 2px; font-size: 9.5px; }
.chart-head { align-items: flex-start; }
.chart-actions {
display: flex;
flex: none;
flex-direction: column;
align-items: flex-end;
gap: 7px;
}
.chart-tooltip.wide { width: 236px; }
.legend-line.target { border-top: 2px dashed var(--gold); }
.legend-line.price { border-top: 2px dashed var(--green-2); }
.loss-input-wrap.gain input { color: var(--green); }
.loss-input-wrap.gain span { color: var(--green); }
.code-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 6px;
}
.code-row input {
width: 100%;
height: 36px;
padding: 0 10px;
border: 1px solid var(--line);
border-radius: 9px;
outline: none;
background: var(--paper);
color: var(--ink);
font-family: var(--num);
font-size: 13px;
letter-spacing: .02em;
}
.code-row input:focus { border-color: var(--green); }
.fetch-btn {
flex: none;
height: 36px;
padding: 0 11px;
border: 1px solid var(--green-2);
border-radius: 9px;
background: var(--green-wash);
color: var(--green);
font-size: 10.5px;
font-weight: 650;
white-space: nowrap;
transition: .16s ease;
}
.fetch-btn:hover { background: var(--green-soft); }
.fetch-btn:disabled { opacity: .55; cursor: wait; }
.stock-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-top: 6px;
color: var(--muted);
font-size: 9.5px;
}
.stock-name { color: var(--ink-2); font-weight: 650; }
.status-line { color: var(--muted); font-size: 9.5px; line-height: 1.45; }
.status-line.ok { color: var(--green-2); }
.status-line.bad { color: var(--terra); }
.status-line.busy { color: var(--gold); }
.unit-wrap {
display: flex;
align-items: center;
overflow: hidden;
border: 1px solid var(--line);
border-radius: 9px;
background: var(--paper);
}
.unit-wrap input {
width: 100%;
height: 36px;
padding: 0 4px 0 10px;
border: 0;
outline: none;
background: transparent;
color: var(--ink);
font-family: var(--num);
font-size: 14px;
font-variant-numeric: tabular-nums;
-moz-appearance: textfield;
}
.unit-wrap input::-webkit-outer-spin-button,
.unit-wrap input::-webkit-inner-spin-button { margin: 0; -webkit-appearance: none; }
.unit-wrap span {
padding-right: 10px;
color: var(--muted);
font-size: 11px;
}
.switch-row {
display: flex;
align-items: center;
gap: 9px;
padding: 9px 11px;
border: 1px solid var(--line);
border-radius: 10px;
background: var(--paper-2);
color: var(--ink-2);
font-size: 11px;
line-height: 1.45;
cursor: pointer;
transition: .16s ease;
}
.switch-row:hover { border-color: var(--green-2); }
.switch-row input {
flex: none;
width: 15px;
height: 15px;
margin: 0;
accent-color: var(--green);
cursor: pointer;
}
.ruler { position: relative; height: 40px; margin: 4px 0 0; }
.ruler-track {
position: absolute;
left: 0;
right: 0;
top: 17px;
height: 8px;
border-radius: 99px;
background: linear-gradient(90deg, #ded8c9, #ece7db);
box-shadow: inset 0 0 0 1px rgba(24, 35, 29, .04);
}
.ruler-gap {
position: absolute;
top: 18px;
height: 6px;
border-radius: 99px;
background: linear-gradient(90deg, var(--terra-2), var(--gold));
opacity: .8;
transition: left .2s ease, width .2s ease;
}
.ruler-mark {
position: absolute;
top: 21px;
width: 14px;
height: 14px;
border: 3px solid var(--paper);
border-radius: 50%;
transform: translate(-50%, -50%);
box-shadow: 0 1px 5px rgba(15, 36, 28, .22);
transition: left .2s ease;
}
.ruler-mark.price { background: var(--terra); }
.ruler-mark.breakeven { width: 17px; height: 17px; background: var(--gold); }
.ruler-mark.cost { background: var(--green); }
.ruler-legend {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
margin-top: 8px;
}
.ruler-legend > div {
min-width: 0;
padding: 7px 9px;
border: 1px solid var(--line);
border-radius: 9px;
background: var(--paper-2);
}
.ruler-legend span {
display: flex;
align-items: center;
gap: 5px;
color: var(--muted);
font-size: 8.5px;
white-space: nowrap;
}
.ruler-legend i {
width: 7px;
height: 7px;
border-radius: 50%;
flex: none;
}
.ruler-legend i.price { background: var(--terra); }
.ruler-legend i.breakeven { background: var(--gold); }
.ruler-legend i.cost { background: var(--green); }
.ruler-legend b {
display: block;
overflow: hidden;
margin-top: 2px;
color: var(--ink);
font-family: var(--num);
font-size: 13px;
font-weight: 600;
white-space: nowrap;
text-overflow: ellipsis;
}
.ruler-legend > div:first-child b { color: var(--terra); }
.ruler-legend > div:nth-child(2) b { color: var(--gold); }
.ruler-legend > div:nth-child(3) b { color: var(--green); }
.ruler-rise {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 6px;
margin-top: 6px;
color: var(--muted);
font-size: 9.5px;
}
.ruler-rise b { color: var(--terra); font-family: var(--num); font-size: 13px; font-weight: 650; }
#beSvg { display: block; width: 100%; height: auto; overflow: visible; }
#beSvg text { font-family: var(--font); }
.cost-card { margin-top: 12px; padding: 22px 22px 18px; }
.cost-grid {
display: grid;
grid-template-columns: minmax(0, 1.12fr) minmax(250px, .88fr);
gap: 24px;
align-items: center;
margin-top: 12px;
}
.cost-panel h3, .donut-panel h3 {
margin: 0 0 8px;
color: var(--ink-2);
font-size: 12px;
font-weight: 700;
letter-spacing: .01em;
}
.cost-bars {
display: flex;
align-items: flex-end;
gap: 18px;
height: 210px;
padding: 8px 4px 0;
border-bottom: 1px solid var(--line);
}
.cost-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-end;
height: 100%;
min-width: 0;
}
.cost-value {
color: var(--ink-2);
font-family: var(--num);
font-size: 12px;
font-weight: 600;
white-space: nowrap;
}
.cost-track {
display: flex;
align-items: flex-end;
width: 100%;
max-width: 88px;
height: 150px;
margin: 5px 0 0;
}
.cost-fill {
width: 100%;
min-height: 4px;
border-radius: 7px 7px 2px 2px;
transition: height .22s ease;
}
.cost-item.before .cost-fill { background: linear-gradient(180deg, #d97757, var(--terra)); }
.cost-item.price .cost-fill { background: linear-gradient(180deg, #67b08f, var(--green-2)); }
.cost-item.after .cost-fill { background: linear-gradient(180deg, #d8bd7c, var(--gold)); }
.cost-item.after .cost-value { color: var(--gold); }
.cost-label {
margin-top: 7px;
color: var(--muted);
font-size: 9.5px;
white-space: nowrap;
}
.cost-delta { margin: 10px 0 0; color: var(--muted); font-size: 10px; text-align: center; }
.cost-delta b { color: var(--gold); font-family: var(--num); font-size: 12px; }
.donut-wrap {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.donut {
position: relative;
display: grid;
place-items: center;
width: 172px;
height: 172px;
border-radius: 50%;
background: conic-gradient(var(--green-2) 0 var(--orig, 100%), var(--gold) var(--orig, 100%) 100%);
box-shadow: 0 10px 28px rgba(15, 36, 28, .08);
}
.donut::after {
content: "";
position: absolute;
inset: 27px;
border-radius: 50%;
background: var(--paper);
box-shadow: inset 0 0 0 1px rgba(24, 35, 29, .04);
}
.donut-hole { position: relative; z-index: 1; text-align: center; line-height: 1.1; }
.donut-hole b {
display: block;
color: var(--green);
font-family: var(--num);
font-size: 23px;
font-weight: 600;
letter-spacing: -.03em;
}
.donut-hole span { display: block; margin-top: 3px; color: var(--muted); font-size: 8.5px; }
.donut-legend {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 8px 14px;
color: var(--muted);
font-size: 9px;
}
.donut-legend span { display: inline-flex; align-items: center; gap: 5px; }
.donut-legend i { width: 8px; height: 8px; border-radius: 50%; }
.donut-legend i.orig { background: var(--green-2); }
.donut-legend i.add { background: var(--gold); }
.donut-legend b { color: var(--ink-2); font-family: var(--num); font-size: 10px; }
.be-table-card { margin-top: 12px; padding: 22px 22px 16px; }
.be-table-wrap {
max-height: none;
margin-top: 14px;
overflow: auto;
border: 1px solid var(--line);
border-radius: 14px;
scrollbar-width: thin;
scrollbar-color: var(--line-strong) transparent;
}
.be-table-wrap::-webkit-scrollbar { width: 6px; height: 6px; }
.be-table-wrap::-webkit-scrollbar-thumb { background: var(--line-strong); border-radius: 99px; }
table.be-table {
width: 100%;
min-width: 620px;
border-collapse: collapse;
font-family: var(--num);
font-size: 11px;
font-variant-numeric: tabular-nums;
}
table.be-table thead th {
position: sticky;
top: 0;
z-index: 2;
padding: 9px 12px;
border-bottom: 1px solid var(--line);
background: #f6f1e6;
color: var(--muted);
font-family: var(--font);
font-size: 9px;
font-weight: 600;
text-align: right;
white-space: nowrap;
}
table.be-table thead th:first-child {
position: sticky;
left: 0;
z-index: 3;
background: #f6f1e6;
text-align: left;
box-shadow: 1px 0 0 var(--line);
}
table.be-table tbody td {
padding: 7px 12px;
border-bottom: 1px dashed var(--line);
color: var(--ink-2);
text-align: right;
white-space: nowrap;
}
table.be-table tbody td:first-child {
position: sticky;
left: 0;
z-index: 1;
background: var(--paper);
color: var(--muted);
font-family: var(--font);
font-size: 10px;
text-align: left;
box-shadow: 1px 0 0 var(--line);
}
table.be-table tbody tr:last-child td { border-bottom: 0; }
table.be-table tbody tr.is-selected td { background: var(--gold-soft); }
table.be-table tbody tr.is-selected td:first-child {
background: var(--gold-soft);
color: #7d5a18;
font-weight: 700;
}
table.be-table tbody tr.is-zero td { color: var(--muted); }
table.be-table td.good { color: var(--green); }
table.be-table td.gold { color: var(--gold); font-weight: 600; }
table.be-table td.loss { color: var(--terra); }
.table-hint { margin: 8px 0 0; color: var(--muted); font-size: 9.5px; }
.batch-card { margin-top: 24px; padding: 24px 24px 18px; }
.batch-tools {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
margin-top: 14px;
}
.batch-tools .tool-label { color: var(--faint); font-size: 9px; }
.batch-btn {
padding: 5px 9px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--paper);
color: var(--muted);
font-size: 10px;
line-height: 1.5;
white-space: nowrap;
transition: .16s ease;
}
.batch-btn:hover { border-color: var(--green-2); color: var(--green); background: var(--green-wash); }
.batch-btn.primary { border-color: var(--green); background: var(--green); color: var(--cream); }
.batch-btn.primary:hover { background: var(--green-2); color: var(--cream); }
.batch-btn:disabled { opacity: .55; cursor: wait; }
.batch-status { margin-left: auto; }
.batch-wrap {
margin-top: 12px;
max-height: 640px;
overflow: auto;
border: 1px solid var(--line);
border-radius: 14px;
scrollbar-width: thin;
scrollbar-color: var(--line-strong) transparent;
}
.batch-wrap::-webkit-scrollbar { width: 7px; height: 7px; }
.batch-wrap::-webkit-scrollbar-thumb { background: var(--line-strong); border-radius: 99px; }
table.batch {
width: 100%;
min-width: 1080px;
border-collapse: collapse;
table-layout: fixed;
font-family: var(--num);
font-size: 11px;
font-variant-numeric: tabular-nums;
}
table.batch th:nth-child(1) { width: 158px; }
table.batch th:nth-child(2) { width: 92px; }
table.batch th:nth-child(3) { width: 88px; }
table.batch th:nth-child(4) { width: 88px; }
table.batch th:nth-child(5) { width: 82px; }
table.batch th:nth-child(6) { width: 92px; }
table.batch th:nth-child(7) { width: 120px; }
table.batch th:nth-child(8) { width: 90px; }
table.batch th:nth-child(9) { width: 82px; }
table.batch th:nth-child(10) { width: 104px; }
table.batch thead th {
position: sticky;
top: 0;
z-index: 3;
padding: 8px 8px;
border-bottom: 1px solid var(--line);
background: #f6f1e6;
color: var(--muted);
font-family: var(--font);
font-size: 8.5px;
font-weight: 600;
text-align: right;
white-space: nowrap;
}
table.batch thead th:first-child {
position: sticky;
left: 0;
z-index: 4;
background: #f6f1e6;
text-align: left;
box-shadow: 1px 0 0 var(--line);
}
table.batch thead th:last-child { text-align: center; }
table.batch tbody td {
padding: 5px 6px;
border-bottom: 1px dashed var(--line);
color: var(--ink-2);
text-align: right;
white-space: nowrap;
vertical-align: middle;
}
table.batch tbody tr:last-child td { border-bottom: 0; }
table.batch td:first-child {
position: sticky;
left: 0;
z-index: 2;
background: var(--paper);
text-align: left;
box-shadow: 1px 0 0 var(--line);
}
table.batch td.cell-actions { text-align: center; }
table.batch input {
width: 100%;
max-width: 92px;
min-width: 0;
height: 27px;
padding: 0 6px;
border: 1px solid rgba(24, 35, 29, .09);
border-radius: 6px;
outline: none;
background: rgba(255, 253, 248, .55);
color: var(--ink-2);
font-family: var(--num);
font-size: 11px;
text-align: right;
-moz-appearance: textfield;
transition: .14s ease;
}
table.batch input::-webkit-outer-spin-button,
table.batch input::-webkit-inner-spin-button { margin: 0; -webkit-appearance: none; }
table.batch input:hover { border-color: var(--line); background: var(--paper); }
table.batch input:focus { border-color: var(--green-2); background: var(--paper); }
table.batch .code-input { width: 100%; max-width: 110px; text-align: left; font-size: 10.5px; }
table.batch .cell-stock { min-width: 142px; }
table.batch .row-name {
display: block;
overflow: hidden;
max-width: 136px;
padding: 0 6px;
color: var(--muted);
font-family: var(--font);
font-size: 8.5px;
line-height: 1.3;
text-overflow: ellipsis;
}
table.batch .num { color: var(--ink-2); }
table.batch .rec-gold { color: var(--gold); font-weight: 600; }
table.batch .rec-good { color: var(--green); }
table.batch .rec-loss { color: var(--terra); }
table.batch .cell-ratio { min-width: 66px; }
table.batch .cell-ratio input { width: 46px; max-width: 46px; }
table.batch .cell-actions { min-width: 92px; }
.row-actions { display: flex; justify-content: center; gap: 4px; }
.row-btn {
padding: 2px 6px;
border: 1px solid var(--line);
border-radius: 5px;
background: var(--paper);
color: var(--muted);
font-family: var(--font);
font-size: 8.5px;
line-height: 1.5;
transition: .14s ease;
}
.row-btn:hover { border-color: var(--green-2); color: var(--green); background: var(--green-wash); }
.row-btn.danger:hover { border-color: var(--terra); color: var(--terra); background: var(--terra-soft); }
.batch-empty {
padding: 26px 16px;
color: var(--muted);
font-size: 11px;
text-align: center;
}
.batch-totals {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 1px;
overflow: hidden;
margin-top: 12px;
border: 1px solid var(--line);
border-radius: 12px;
background: var(--line);
}
.batch-totals > div { min-width: 0; padding: 9px 11px; background: var(--paper-2); }
.batch-totals span { display: block; color: var(--muted); font-size: 8.5px; }
.batch-totals b {
display: block;
overflow: hidden;
margin-top: 2px;
color: var(--ink);
font-family: var(--num);
font-size: 14px;
font-weight: 600;
white-space: nowrap;
text-overflow: ellipsis;
}
.batch-totals > div:nth-child(1) b { color: var(--gold); }
.batch-totals > div:nth-child(2) b { color: var(--green); }
.batch-totals > div:nth-child(4) b { color: var(--terra); }
.batch-note { margin: 10px 0 0; color: var(--muted); font-size: 9.5px; line-height: 1.6; }
@media (max-width: 1180px) {
.cost-grid { grid-template-columns: 1fr; gap: 18px; }
.donut-wrap { flex-direction: row; justify-content: center; }
.batch-totals { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 980px) {
.cost-card, .be-table-card, .batch-card { padding: 20px 18px 15px; }
.batch-status { margin-left: 0; flex-basis: 100%; }
}
@media (max-width: 760px) {
.chart-actions {
flex-direction: row;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
width: 100%;
}
.ruler-legend { gap: 5px; }
.ruler-legend > div { padding: 6px 7px; }
.ruler-legend b { font-size: 11.5px; }
.cost-card, .be-table-card, .batch-card { padding: 18px 14px 14px; }
.cost-bars { height: 190px; gap: 10px; }
.cost-track { height: 130px; }
.donut { width: 150px; height: 150px; }
.donut::after { inset: 24px; }
.donut-hole b { font-size: 20px; }
.batch-tools { gap: 5px; }
.batch-btn { padding: 5px 7px; font-size: 9.5px; }
.batch-totals b { font-size: 12px; }
}
@media (max-width: 460px) {
.ruler-legend { grid-template-columns: 1fr 1fr 1fr; gap: 4px; }
.ruler-legend span { font-size: 7.5px; }
.ruler-legend b { font-size: 10px; }
.cost-bars { gap: 6px; }
.donut-wrap { flex-direction: column; }
.batch-totals > div { padding: 8px 9px; }
}
</style>
</head>
<body>
<div class="page">
<header class="topbar">
<div class="brand">
<span class="brand-mark">补</span>
<div class="brand-copy">
<strong>补仓实验室</strong>
<span>AVERAGE DOWN LAB</span>
</div>
</div>
<div class="top-note"><i></i> 补多少,才回本</div>
</header>
<section class="hero">
<div class="hero-copy">
<div class="eyebrow"><span class="eyebrow-dot"></span> 补仓比例 · 回本价 · 多只批量</div>
<h1>跌了,补多少<br><em>才真正回本。</em></h1>
<p class="hero-lead">10 元买 1000 股,跌到 8 元:补 200 股,回本价 9.67 元;补 1000 股,回本价 9.00 元。补得越多,回本价越接近现价,但投入和下跌风险也一起放大。</p>
<div class="hero-points" aria-label="三个核心结果">
<div><span>01</span><b>补多少股</b><small>按 10% / 20% / 30%</small></div>
<div><span>02</span><b>回本价</b><small>涨到多少不亏</small></div>
<div><span>03</span><b>多只批量</b><small>一起算、一起比</small></div>
</div>
<p class="hero-foot">只算一件事:现在补多少股,股价涨到多少钱能回本。</p>
</div>
<aside class="exposure-card">
<div class="exposure-top">
<span>补仓后回本价</span>
<span class="risk-badge" id="riseBadge" data-level="mid">需涨 +20.83%</span>
</div>
<div class="exposure-value"><span id="beValue">9.67</span><span class="unit-sign">元</span></div>
<p class="exposure-caption" id="heroCaption">成本 10.00 元 × 1000 股 → 现价 8.00 元;补 200 股后,涨到 9.67 元回本。</p>
<div class="gauge" aria-hidden="true">
<div class="gauge-track"><span class="gauge-marker" id="gaugeMarker"></span></div>
<div class="gauge-labels"><span>0</span><span>25</span><span>50</span><span>75</span><span>100%</span></div>
</div>
<div class="formula-box" id="heroFormula">补 200 股 × ¥8.00 = ¥1,600.00 → 回本价 ¥9.67</div>
<div class="fraction-badge" id="heroNote">补后亏损 −17.24%(补前 −20.00%)</div>
<div class="exposure-facts">
<div><span>补仓股数</span><b id="factAddShares">200 股</b></div>
<div><span>补仓金额</span><b id="factAddAmount">¥1,600.00</b></div>
<div><span>补后总股数</span><b id="factTotalShares">1,200 股</b></div>
</div>
</aside>
</section>
<main class="layout">
<aside class="panel controls" aria-label="补仓参数">
<div class="panel-head">
<div>
<span class="eyebrow-sm">INPUTS</span>
<h2>填持仓,算回本</h2>
</div>
<button class="reset-btn" id="resetBtn" type="button">重置</button>
</div>
<div class="control">
<div class="control-head">
<label for="codeInput">股票代码</label>
<span class="mini-note">可手动填</span>
</div>
<div class="code-row">
<input type="text" id="codeInput" value="sh600000" placeholder="sh600519 / sz000858 / 600519" autocomplete="off" spellcheck="false" aria-label="股票代码">
<button class="fetch-btn" type="button" id="fetchBtn">获取现价</button>
</div>
<div class="stock-meta">
<span class="stock-name" id="stockName">浦发银行</span>
<span class="status-line" id="fetchStatus">价格来自腾讯行情,可手动改</span>
</div>
<p class="hint">支持 sh / sz / bj 前缀;也可以只填 6 位代码。</p>
</div>
<div class="control">
<div class="control-head">
<label for="sharesInput">当前持股</label>
<span class="mini-note">股</span>
</div>
<div class="unit-wrap">
<input type="number" id="sharesInput" min="0" max="1000000000" step="100" value="1000" inputmode="numeric" aria-label="当前持股数量">
<span>股</span>
</div>
</div>
<div class="control">
<div class="control-head">
<label for="costInput">持仓成本</label>
<span class="mini-note">元 / 股</span>
</div>
<div class="capital-wrap">
<span>¥</span>
<input type="number" id="costInput" min="0" max="1000000" step="0.01" value="10.00" inputmode="decimal" aria-label="持仓平均成本">
</div>
</div>
<div class="control">
<div class="control-head">
<label for="priceInput">当前股价</label>
<span class="mini-note">元 / 股</span>
</div>
<div class="capital-wrap">
<span>¥</span>
<input type="number" id="priceInput" min="0" max="1000000" step="0.01" value="8.00" inputmode="decimal" aria-label="当前股价">
</div>
</div>
<div class="control">
<div class="control-head">
<label>补仓方式</label>
<span class="mini-note">怎么补</span>
</div>
<div class="segmented segmented-4" role="group" aria-label="补仓方式">
<button class="active" type="button" data-mode="ratio" aria-pressed="true">按比例</button>
<button type="button" data-mode="shares" aria-pressed="false">按股数</button>
<button type="button" data-mode="amount" aria-pressed="false">按金额</button>
<button type="button" data-mode="target" aria-pressed="false">按目标</button>
</div>
<div class="mode-panel" id="ratioPanel">
<div class="control-head sub">
<label for="ratioRange">补仓比例(占现有股数)</label>
<div class="loss-input-wrap gain">
<input type="number" id="ratioInput" min="0" max="500" step="1" value="20" inputmode="numeric" aria-label="补仓比例百分比">
<span>%</span>
</div>
</div>
<input type="range" id="ratioRange" min="0" max="200" step="1" value="20">
<div class="chip-row">
<button class="chip" type="button" data-ratio="10">10%</button>
<button class="chip" type="button" data-ratio="20">20%</button>
<button class="chip" type="button" data-ratio="30">30%</button>
<button class="chip" type="button" data-ratio="50">50%</button>
<button class="chip" type="button" data-ratio="100">100%</button>
</div>
<p class="hint">10% = 按现有股数补(例如 1000 股再买 100 股),不是按资金;按资金比例请用“按金额”。</p>
</div>
<div class="mode-panel" id="sharesPanel" hidden>
<div class="control-head sub">
<label for="sharesToAddInput">计划补仓股数</label>
<span class="mini-note">股</span>
</div>
<div class="unit-wrap">
<input type="number" id="sharesToAddInput" min="0" max="1000000000" step="100" value="200" inputmode="numeric" aria-label="计划补仓股数">
<span>股</span>
</div>
<div class="chip-row">
<button class="chip" type="button" data-add-shares="100">100</button>
<button class="chip" type="button" data-add-shares="200">200</button>
<button class="chip" type="button" data-add-shares="500">500</button>
<button class="chip" type="button" data-add-shares="1000">1000</button>
</div>
<p class="hint">直接输入想补多少股;补得越多,回本价越接近现价。</p>
</div>
<div class="mode-panel" id="amountPanel" hidden>
<div class="control-head sub">
<label for="amountInput">计划补仓金额</label>
<span class="mini-note">人民币</span>
</div>
<div class="capital-wrap">
<span>¥</span>
<input type="number" id="amountInput" min="0" max="1000000000" step="1000" value="10000" inputmode="numeric" aria-label="计划补仓金额">
</div>
<div class="chip-row">
<button class="chip" type="button" data-amount="10000">1万</button>
<button class="chip" type="button" data-amount="50000">5万</button>
<button class="chip" type="button" data-amount="100000">10万</button>
</div>
<p class="hint">按现价买入:股数 = 金额 ÷ 现价;开启取整后按 100 股向下取。</p>
</div>
<div class="mode-panel" id="targetPanel" hidden>
<div class="control-head sub">
<label for="targetInput">目标平均成本</label>
<span class="mini-note">元 / 股</span>
</div>
<div class="capital-wrap">
<span>¥</span>
<input type="number" id="targetInput" min="0" max="1000000" step="0.01" value="9.50" inputmode="decimal" aria-label="目标平均成本">
</div>
<div class="chip-row">
<button class="chip" type="button" id="midTarget">用中间价</button>
</div>
<p class="hint" id="targetHint">目标均价必须低于成本 10.00、高于现价 8.00。</p>
</div>
</div>
<div class="control">
<div class="control-head">
<label>下单取整</label>
<span class="mini-note">A股可选</span>
</div>
<label class="switch-row" for="roundLot">
<input type="checkbox" id="roundLot">
<span>按 100 股整数倍取整(更贴近真实下单)</span>
</label>
<p class="hint">关闭时按精确股数计算;开启后按比例/股数取最近 100 股、金额向下取、目标成本向上取。</p>
</div>
<div class="control">
<button class="jump-btn" type="button" id="applyToBatch">把当前股票加入批量 ↓</button>
</div>
</aside>
<section class="results" aria-label="补仓结果">
<div class="kpi-grid">
<article class="kpi kpi-breakeven">
<div class="kpi-head"><span>回本价(元)</span><span class="kpi-code">BEP</span></div>
<div class="kpi-value gold" id="kpiBreakeven">9.67</div>
<div class="kpi-meta"><b id="kpiBreakevenMeta">需涨 +20.83%</b></div>
<p class="kpi-foot" id="kpiBreakevenFoot">涨回这个价才不亏不赚</p>
</article>
<article class="kpi kpi-add">
<div class="kpi-head"><span>需补股数</span><span class="kpi-code">ADD</span></div>
<div class="kpi-value green"><span id="kpiAddShares">200</span><span class="kpi-unit">股</span></div>
<div class="kpi-meta"><b id="kpiAddAmount">¥1,600.00</b><span>补仓金额</span></div>
<p class="kpi-foot" id="kpiAddFoot">按现价 ¥8.00 买入</p>
</article>
<article class="kpi kpi-total">
<div class="kpi-head"><span>补后总股数</span><span class="kpi-code">TOTAL</span></div>
<div class="kpi-value ink"><span id="kpiTotalShares">1,200</span><span class="kpi-unit">股</span></div>
<div class="kpi-meta"><b id="kpiTotalCost">¥11,600.00</b><span>补后总成本</span></div>
<p class="kpi-foot" id="kpiTotalFoot">原 1,000 股 + 新 200 股</p>
</article>
<article class="kpi kpi-post" id="kpiPostCard">
<div class="kpi-head"><span id="kpiPostLabel">补后浮亏</span><span class="kpi-code">P/L</span></div>
<div class="kpi-value terra" id="kpiPostPnl">−¥2,000.00</div>
<div class="kpi-meta"><b id="kpiPostPct">−17.24%</b><span>补后亏损率</span></div>
<p class="kpi-foot" id="kpiPostFoot">补前 −¥2,000.00(−20.00%)</p>
</article>
</div>
<div class="strip-card">
<div class="strip-copy">
<span class="eyebrow-sm">PRICE RULER</span>
<h3>现价离回本,还有多远?</h3>
<p id="rulerText">补仓把回本价从 10.00 拉到 9.67;股价要从 8.00 涨到 9.67 才回本。</p>
</div>
<div class="strip-visual">
<div class="ruler" aria-hidden="true">
<div class="ruler-track"></div>
<div class="ruler-gap" id="rulerGap"></div>
<span class="ruler-mark price" id="markPrice" title="现价"></span>
<span class="ruler-mark breakeven" id="markBreakeven" title="回本价"></span>
<span class="ruler-mark cost" id="markCost" title="原成本"></span>
</div>
<div class="ruler-legend">
<div><span><i class="price"></i>现价</span><b id="legendPrice">¥8.00</b></div>
<div><span><i class="breakeven"></i>回本价</span><b id="legendBreakeven">¥9.67</b></div>
<div><span><i class="cost"></i>原成本</span><b id="legendCost">¥10.00</b></div>
</div>
<div class="ruler-rise">回本需涨 <b id="rulerRise">+20.83%</b></div>
</div>
</div>
<section class="card chart-card" id="beCard">
<div class="card-head chart-head">
<div class="chart-heading">
<span class="eyebrow-sm">BREAK-EVEN CURVE</span>
<h2 id="beTitle">补仓比例,怎么改变回本价</h2>
<p id="beSubtitle">横轴:补仓比例(占现有股数)· 纵轴:回本价(元)</p>
</div>
<div class="chart-actions">
<div class="tabs" id="beTabs" role="tablist" aria-label="图表指标">
<button class="tab active" type="button" data-betab="price" role="tab" aria-selected="true">回本价</button>
<button class="tab" type="button" data-betab="amount" role="tab" aria-selected="false">补仓金额</button>
</div>
</div>
</div>
<div class="chart-wrap" id="beWrap">
<svg id="beSvg" role="img" aria-label="补仓比例与回本价曲线"></svg>
<div class="chart-tooltip wide" id="beTip" hidden></div>
</div>
<div class="chart-foot">
<div class="legend" id="beLegend">
<span><i class="legend-line"></i>补后回本价</span>
<span><i class="legend-line base"></i>原成本</span>
<span><i class="legend-line price"></i>现价</span>
</div>
<p id="beNote">补得越多,回本价越接近现价,但每多补 10% 带来的下降越来越小。</p>
</div>
</section>
<section class="card cost-card" id="costCard">
<div class="card-head">
<div>
<span class="eyebrow-sm">COST & STRUCTURE</span>
<h2>成本对比与持仓结构</h2>
<p id="costSubtitle">补仓后均价 9.67 元,介于现价与原成本之间。</p>
</div>
</div>
<div class="cost-grid">
<div class="cost-panel">
<h3>成本对比(元)</h3>
<div class="cost-bars" id="costBars"></div>
<p class="cost-delta" id="costDelta">补后均价较原成本 <b>↓ 0.33</b> 元</p>
</div>
<div class="donut-panel">
<h3>补仓后持仓结构</h3>
<div class="donut-wrap">
<div class="donut" id="donut" style="--orig: 83.33%">
<div class="donut-hole"><b id="donutPct">83.33%</b><span>原持仓</span></div>
</div>
<div class="donut-legend" id="donutLegend">
<span><i class="orig"></i>原持股 <b>1,000 股</b></span>
<span><i class="add"></i>新增 <b>200 股</b></span>
</div>
</div>
</div>
</div>
</section>
<section class="card be-table-card" id="beTableCard">
<div class="card-head">
<div>
<span class="eyebrow-sm">RATIO LADDER</span>
<h2>不同补仓比例,回本价差多少</h2>
<p id="beTableSubtitle">从 0%(不补)到 200%,每档都算清楚;当前方案高亮。</p>
</div>
</div>
<div class="be-table-wrap">
<table class="detail be-table">
<thead>
<tr>
<th>补仓比例</th>
<th>补仓股数</th>
<th>补仓金额</th>
<th>回本价</th>
<th>回本需涨</th>
<th>补后盈亏</th>
</tr>
</thead>
<tbody id="beTableBody"></tbody>
</table>
</div>
<p class="table-hint">回本价 = 补后平均成本;补后盈亏按当前股价计算,不含手续费。表格可横向滑动。</p>
</section>
</section>
</main>
<section class="card batch-card" id="batchSection">
<div class="card-head">
<div>
<span class="eyebrow-sm">BATCH</span>
<h2>批量补仓:多只股票一起算</h2>
<p id="batchSubtitle">每只股票按自己的 10% / 20% / 30% 补仓,回本价一列看清楚;表格可横向滑动,首列固定。</p>
</div>
<div class="ladder-badge" id="batchBadge">1 只</div>
</div>
<div class="batch-tools">
<span class="tool-label">批量比例</span>
<button class="chip" type="button" data-batch-ratio="10">10%</button>
<button class="chip" type="button" data-batch-ratio="20">20%</button>
<button class="chip" type="button" data-batch-ratio="30">30%</button>
<button class="chip" type="button" data-batch-ratio="50">50%</button>
<button class="chip" type="button" data-batch-ratio="100">100%</button>
<button class="batch-btn primary" type="button" id="addRowBtn">+ 添加股票</button>
<button class="batch-btn" type="button" id="fetchAllBtn">获取全部现价</button>
<button class="batch-btn" type="button" id="importBtn">导入 获取股票信息.txt</button>
<input type="file" id="importFile" accept=".txt,.js,text/plain" hidden>
<button class="batch-btn" type="button" id="clearBatchBtn">清空</button>
<span class="status-line batch-status" id="batchStatus"></span>
</div>
<div class="batch-wrap">
<table class="batch">
<thead>
<tr>
<th>股票</th>
<th>持股数</th>
<th>成本价</th>
<th>现价</th>
<th>补仓比例</th>
<th>补仓股数</th>
<th>补仓金额</th>
<th>回本价</th>
<th>需涨</th>
<th>操作</th>
</tr>
</thead>
<tbody id="batchBody"></tbody>
</table>
</div>
<div class="batch-totals">
<div><span>合计补仓金额</span><b id="batchAddAmount">¥1,600.00</b></div>
<div><span>合计补后股数</span><b id="batchTotalShares">1,200 股</b></div>
<div><span>补后总成本</span><b id="batchTotalCost">¥11,600.00</b></div>
<div><span id="batchPnlLabel">组合浮亏(按现价)</span><b id="batchPnl">−¥2,000.00</b></div>
</div>
<p class="batch-note" id="batchNote">合计只统计持股数、成本、现价都有效的股票。导入 txt 后会提取其中的股票代码并自动获取现价;没有网络时仍可手动填写。</p>
</section>
<section class="method">
<div class="method-card">
<span class="eyebrow-sm">METHOD</span>
<h2>回本价怎么算?</h2>
<div class="calc-line" id="calcLine">补仓股数 = 1000 × 20% = 200 股;回本价 = (1000×10.00 + 200×8.00) ÷ 1200 = 9.67 元</div>
<div class="formula-list">
<div class="formula-row"><span>补后回本价</span><code>(原持股×原成本 + 补仓股数×现价)÷(原持股 + 补仓股数)</code></div>
<div class="formula-row"><span>按比例补仓</span><code>补仓股数 = 原持股 × 补仓比例</code></div>
<div class="formula-row"><span>按股数补仓</span><code>补仓股数 = 输入值;开启取整时按 100 股取整</code></div>
<div class="formula-row"><span>按金额补仓</span><code>补仓股数 = 补仓金额 ÷ 现价</code></div>
<div class="formula-row"><span>按目标成本</span><code>补仓股数 = 原持股 ×(原成本 − 目标)÷(目标 − 现价)</code></div>
<div class="formula-row"><span>回本需涨</span><code>回本价 ÷ 现价 − 1</code></div>
<div class="formula-row"><span>补后盈亏</span><code>(现价 − 回本价)× 补后总股数</code></div>
</div>
</div>
<div class="note-card">
<h3>四个必须说清的前提</h3>
<ol>
<li><b>补仓不改变当前浮亏金额。</b>按现价补,新买的部分不亏不赚,所以绝对亏损不变,变化的是亏损率和仓位。</li>
<li><b>回本价就是补后平均成本。</b>股价涨回这个价才不亏不赚;它可以低于原成本,但不可能低于现价。</li>
<li><b>补得越多,回本价越接近现价。</b>从 10.00 拉到 9.67 可能只要补 20%,但再往下拉会越来越贵,风险也越集中。</li>
<li><b>未计税费与滑点。</b>佣金、印花税、过户费和成交滑点都会让真实回本价略高;A 股可勾选 100 股取整。</li>
</ol>
</div>
</section>
<footer>
<span>本工具仅用于补仓成本算术演示,不构成投资建议。补仓会放大仓位与下跌风险,请先算清最坏情况。</span>
<span class="footer-mark">AVERAGE DOWN LAB · 2026</span>
</footer>
</div>
<div class="mobile-readout" aria-live="polite" aria-label="实时结果">
<div><span>回本价</span><b id="mBreakeven">¥9.67</b></div>
<div><span>需补股数</span><b id="mAddShares">200 股</b></div>
<div><span>回本需涨</span><b id="mRise">+20.83%</b></div>
</div>
<script>
(() => {
"use strict";
const DEFAULTS = Object.freeze({
code: "sh600000",
name: "浦发银行",
shares: 1000,
cost: 10,
price: 8,
mode: "ratio",
ratio: 20,
addShares: 200,
amount: 10000,
target: 9.5,
roundLot: false,
chartTab: "price"
});
const state = {
...DEFAULTS,
batchRatio: 20,
rows: [],
rowSeq: 0
};
const $ = (selector, root = document) => root.querySelector(selector);
const $$ = (selector, root = document) => Array.from(root.querySelectorAll(selector));
const clamp = (value, min, max) => Math.min(max, Math.max(min, value));
const nf0 = new Intl.NumberFormat("zh-CN", { maximumFractionDigits: 0 });
const nf2 = new Intl.NumberFormat("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
const refs = {
codeInput: $("#codeInput"),
fetchBtn: $("#fetchBtn"),
stockName: $("#stockName"),
fetchStatus: $("#fetchStatus"),
sharesInput: $("#sharesInput"),
costInput: $("#costInput"),
priceInput: $("#priceInput"),
ratioPanel: $("#ratioPanel"),
sharesPanel: $("#sharesPanel"),
amountPanel: $("#amountPanel"),
targetPanel: $("#targetPanel"),
ratioRange: $("#ratioRange"),
ratioInput: $("#ratioInput"),
sharesToAddInput: $("#sharesToAddInput"),
amountInput: $("#amountInput"),
targetInput: $("#targetInput"),
targetHint: $("#targetHint"),
midTarget: $("#midTarget"),
roundLot: $("#roundLot"),
applyToBatch: $("#applyToBatch"),
beValue: $("#beValue"),
riseBadge: $("#riseBadge"),
heroCaption: $("#heroCaption"),
heroFormula: $("#heroFormula"),
heroNote: $("#heroNote"),
gaugeMarker: $("#gaugeMarker"),
gaugeTrack: $(".gauge-track"),
factAddShares: $("#factAddShares"),
factAddAmount: $("#factAddAmount"),
factTotalShares: $("#factTotalShares"),
kpiBreakeven: $("#kpiBreakeven"),
kpiBreakevenMeta: $("#kpiBreakevenMeta"),
kpiBreakevenFoot: $("#kpiBreakevenFoot"),
kpiAddShares: $("#kpiAddShares"),
kpiAddAmount: $("#kpiAddAmount"),
kpiAddFoot: $("#kpiAddFoot"),
kpiTotalShares: $("#kpiTotalShares"),
kpiTotalCost: $("#kpiTotalCost"),
kpiTotalFoot: $("#kpiTotalFoot"),
kpiPostCard: $("#kpiPostCard"),
kpiPostLabel: $("#kpiPostLabel"),
kpiPostPnl: $("#kpiPostPnl"),
kpiPostPct: $("#kpiPostPct"),
kpiPostFoot: $("#kpiPostFoot"),
rulerText: $("#rulerText"),
rulerGap: $("#rulerGap"),
markPrice: $("#markPrice"),
markBreakeven: $("#markBreakeven"),
markCost: $("#markCost"),
legendPrice: $("#legendPrice"),
legendBreakeven: $("#legendBreakeven"),
legendCost: $("#legendCost"),
rulerRise: $("#rulerRise"),
beCard: $("#beCard"),
beTitle: $("#beTitle"),
beSubtitle: $("#beSubtitle"),
beTabs: $("#beTabs"),
beWrap: $("#beWrap"),
beSvg: $("#beSvg"),
beTip: $("#beTip"),
beLegend: $("#beLegend"),
beNote: $("#beNote"),
costSubtitle: $("#costSubtitle"),
costBars: $("#costBars"),
costDelta: $("#costDelta"),
donut: $("#donut"),
donutPct: $("#donutPct"),
donutLegend: $("#donutLegend"),
beTableBody: $("#beTableBody"),
beTableSubtitle: $("#beTableSubtitle"),
batchBadge: $("#batchBadge"),
batchBody: $("#batchBody"),
batchStatus: $("#batchStatus"),
batchAddAmount: $("#batchAddAmount"),
batchTotalShares: $("#batchTotalShares"),
batchTotalCost: $("#batchTotalCost"),
batchPnl: $("#batchPnl"),
batchPnlLabel: $("#batchPnlLabel"),
addRowBtn: $("#addRowBtn"),
fetchAllBtn: $("#fetchAllBtn"),
importBtn: $("#importBtn"),
importFile: $("#importFile"),
clearBatchBtn: $("#clearBatchBtn"),
calcLine: $("#calcLine"),
mBreakeven: $("#mBreakeven"),
mAddShares: $("#mAddShares"),
mRise: $("#mRise")
};
const svgNS = "http://www.w3.org/2000/svg";
let beGeom = null;
let renderFrame = 0;
let resizeTimer = 0;
/* ---------------- formatting ---------------- */
function trimNum(text) {
return String(text).replace(/\.0+$/, "").replace(/(\.\d*[1-9])0+$/, "$1");
}
function fmtPrice(value) {
return Number.isFinite(value) ? value.toFixed(2) : "—";
}
function fmtShares(value) {
return Number.isFinite(value) ? nf0.format(Math.round(value)) : "—";
}
function fmtMoney(value) {
return Number.isFinite(value) ? "¥" + nf2.format(value) : "—";
}
function fmtMoneyInt(value) {
return Number.isFinite(value) ? "¥" + nf0.format(Math.round(value)) : "—";
}
function fmtPct(value, digits = 2) {
if (!Number.isFinite(value)) return "—";
return (value > 0 ? "+" : "") + trimNum((value * 100).toFixed(digits)) + "%";
}
function fmtPctAbs(value, digits = 2) {
if (!Number.isFinite(value)) return "—";
return trimNum((value * 100).toFixed(digits)) + "%";
}
function fmtSignedMoney(value) {
if (!Number.isFinite(value)) return "—";
return (value >= 0 ? "+" : "−") + "¥" + nf2.format(Math.abs(value));
}
function fmtLossMoney(value) {
if (!Number.isFinite(value)) return "—";
if (Math.abs(value) < 0.005) return "¥0.00";
return (value > 0 ? "+" : "−") + "¥" + nf2.format(Math.abs(value));
}
function fmtMoneyShort(value) {
if (!Number.isFinite(value)) return "—";
const abs = Math.abs(value);
if (abs >= 1e8) return "¥" + trimNum((value / 1e8).toFixed(2)) + "亿";
if (abs >= 1e4) return "¥" + trimNum((value / 1e4).toFixed(1)) + "万";
return "¥" + nf0.format(Math.round(value));
}
/* ---------------- quotes ---------------- */
function normalizeCode(input) {
let code = String(input || "").trim().toLowerCase().replace(/\s+/g, "");
if (/^\d{6}$/.test(code)) {
if (code[0] === "6") code = "sh" + code;
else if (code[0] === "0" || code[0] === "3") code = "sz" + code;
else code = "bj" + code;
}
return code;
}
function isStockCode(code) {
return /^(sh|sz|bj)\d{6}$/.test(code);
}
function parseQuote(raw, code) {
const parts = String(raw).split("~");
if (parts.length < 50) throw new Error("行情返回不完整");
const price = Number(parts[3]);
if (!Number.isFinite(price) || price <= 0) throw new Error("没有拿到有效现价");
return {
code,
name: parts[1] || code,
price,
prevClose: Number(parts[4]) || 0,
change: Number(parts[31]) || 0,
pct: Number(parts[32]) || 0,
time: parts[30] || ""
};
}
function fetchQuote(inputCode) {
return new Promise((resolve, reject) => {
const code = normalizeCode(inputCode);
if (!isStockCode(code)) {
reject(new Error("代码格式应为 sh600519 / sz000858 / 600519"));
return;
}
const globalName = "v_" + code;
const script = document.createElement("script");
let settled = false;
const timer = window.setTimeout(() => finish(new Error("请求超时")), 9000);
function cleanup() {
window.clearTimeout(timer);
if (script.parentNode) script.parentNode.removeChild(script);
}
function finish(error, quote) {
if (settled) return;
settled = true;
cleanup();
try { delete window[globalName]; } catch (e) { window[globalName] = undefined; }
if (error) reject(error);
else resolve(quote);
}
script.charset = "gbk";
script.src = "https://qt.gtimg.cn/q=" + code + "&_=" + Date.now();
script.onload = () => {
const raw = window[globalName];
if (!raw) {
finish(new Error("行情接口没有返回数据"));
return;
}
try { finish(null, parseQuote(raw, code)); }
catch (error) { finish(error); }
};
script.onerror = () => finish(new Error("网络请求失败,可手动填写价格"));
document.head.appendChild(script);
});
}
function extractCodes(text) {
const matches = String(text || "").match(/\b(?:sh|sz|bj)?\d{6}\b/gi) || [];
const out = [];
matches.forEach(match => {
const code = normalizeCode(match);
if (isStockCode(code) && !out.includes(code)) out.push(code);
});
return out;
}
/* ---------------- calculation ---------------- */
function roundShares(raw, mode) {
if (!(raw > 0)) return 0;
if (state.roundLot) {
if (mode === "target") return Math.ceil(raw / 100) * 100;
if (mode === "amount") {
const floored = Math.floor(raw / 100) * 100;
return floored;
}
return Math.max(100, Math.round(raw / 100) * 100);
}
if (mode === "target") return Math.ceil(raw);
return Math.max(1, Math.round(raw));
}
function buildCalc(shares, cost, price, add, meta = {}) {
const addAmount = add * price;
const totalShares = shares + add;
const totalCost = shares * cost + addAmount;
const breakeven = totalShares > 0 ? totalCost / totalShares : cost;
const rise = price > 0 ? breakeven / price - 1 : 0;
const prePnl = (price - cost) * shares;
const postPnl = (price - breakeven) * totalShares;
const prePct = cost > 0 ? price / cost - 1 : 0;
const postPct = breakeven > 0 ? price / breakeven - 1 : 0;
const addRatio = shares > 0 ? (add / shares) * 100 : 0;
return {
valid: true,
shares,
cost,
price,
add,
addAmount,
totalShares,
totalCost,
breakeven,
rise,
prePnl,
postPnl,
prePct,
postPct,
addRatio,
...meta
};
}
function calcSingle() {
const shares = Number(state.shares);
const cost = Number(state.cost);
const price = Number(state.price);
if (!(shares > 0) || !(cost > 0) || !(price > 0)) {
return { valid: false, reason: "请填写持股数、成本价和现价(都需大于 0)" };
}
let rawAdd = 0;
let add = 0;
let warning = "";
if (state.mode === "ratio") {
rawAdd = shares * state.ratio / 100;
add = roundShares(rawAdd, "ratio");
} else if (state.mode === "shares") {
rawAdd = state.addShares;
add = roundShares(rawAdd, "shares");
} else if (state.mode === "amount") {
rawAdd = state.amount / price;
add = roundShares(rawAdd, "amount");
} else {
const target = Number(state.target);
const canTarget = cost > price && target > price && target < cost;
if (canTarget) {
rawAdd = shares * (cost - target) / (target - price);
add = roundShares(rawAdd, "target");
} else {
warning = cost > price
? "目标均价必须低于成本 " + fmtPrice(cost) + "、高于现价 " + fmtPrice(price) + "。"
: "当前股价已经不低于成本,按目标成本补仓不适用;请用按比例或按金额。";
}
}
const calc = buildCalc(shares, cost, price, add, {
rawAdd,
warning,
mode: state.mode,
ratioSetting: state.ratio,
targetSetting: state.target
});
if (state.mode === "ratio") calc.targetRatio = state.ratio;
return calc;
}
function calcAtRatio(ratioPct, applyRounding) {
const shares = Number(state.shares);
const cost = Number(state.cost);
const price = Number(state.price);
if (!(shares > 0) || !(cost > 0) || !(price > 0)) return null;
const raw = shares * ratioPct / 100;
const add = applyRounding ? roundShares(raw, "ratio") : raw;
return buildCalc(shares, cost, price, add, { rawAdd: raw, ratioSetting: ratioPct });
}
function calcForRow(row) {
const shares = Number(row.shares);
const cost = Number(row.cost);
const price = Number(row.price);
const ratio = Number(row.ratio);
if (!(shares > 0) || !(cost > 0) || !(price > 0) || !(ratio >= 0)) return null;
const raw = shares * ratio / 100;
const add = roundShares(raw, "ratio");
return buildCalc(shares, cost, price, add, { rawAdd: raw, ratioSetting: ratio });
}
/* ---------------- single render ---------------- */
function setRangeFill(element) {
if (!element) return;
const max = Number(element.max) || 100;
const value = Number(element.value) || 0;
element.style.setProperty("--fill", clamp((value / max) * 100, 0, 100).toFixed(2) + "%");
}
function renderHero(calc) {
if (!calc || !calc.valid) {
refs.beValue.textContent = "—";
refs.riseBadge.textContent = "待输入";
refs.riseBadge.dataset.level = "custom";
refs.heroCaption.textContent = calc ? calc.reason : "请填写持股数、成本价和现价。";
refs.heroFormula.textContent = "填写参数后自动计算";
refs.heroNote.textContent = "回本价 = 补后平均成本";
refs.factAddShares.textContent = "—";
refs.factAddAmount.textContent = "—";
refs.factTotalShares.textContent = "—";
refs.gaugeMarker.style.left = "1.5%";
refs.gaugeTrack.style.setProperty("--risk-pos", "1.5%");
return;
}
const risePct = calc.rise * 100;
refs.beValue.textContent = fmtPrice(calc.breakeven);
if (calc.warning) {
refs.riseBadge.textContent = "目标无效";
refs.riseBadge.dataset.level = "high";
} else if (risePct > 0) {
refs.riseBadge.textContent = "需涨 " + fmtPct(calc.rise, 2);
refs.riseBadge.dataset.level = risePct > 30 ? "high" : risePct > 15 ? "mid" : "low";
} else {
refs.riseBadge.textContent = "已回本";
refs.riseBadge.dataset.level = "low";
}
const noAdd = calc.add <= 0;
const covered = calc.rise <= 0;
if (calc.warning) {
refs.heroCaption.textContent = calc.warning;
} else if (noAdd) {
refs.heroCaption.textContent = covered
? "没有补仓:现价 " + fmtPrice(calc.price) + " 已不低于成本 " + fmtPrice(calc.cost) + ",当前不亏。"
: "没有补仓:回本价仍是原成本 " + fmtPrice(calc.cost) + ",股价需涨 " +
fmtPct(calc.rise, 2) + " 才回本。";
} else if (covered) {
refs.heroCaption.textContent = "现价 " + fmtPrice(calc.price) + " 已不低于回本价 " +
fmtPrice(calc.breakeven) + ";补 " + fmtShares(calc.add) + " 股后,回本价从 " +
fmtPrice(calc.cost) + " 抬到 " + fmtPrice(calc.breakeven) + "。";
} else {
refs.heroCaption.textContent = "成本 " + fmtPrice(calc.cost) + " 元 × " +
fmtShares(calc.shares) + " 股 → 现价 " + fmtPrice(calc.price) + " 元;补 " +
fmtShares(calc.add) + " 股后,涨到 " + fmtPrice(calc.breakeven) + " 元回本。";
}
refs.heroFormula.textContent = noAdd
? "未补仓 · 回本价 " + fmtPrice(calc.breakeven) + " 元"
: "补 " + fmtShares(calc.add) + " 股 × " + fmtMoney(calc.price) + " = " +
fmtMoney(calc.addAmount) + " → 回本价 " + fmtMoney(calc.breakeven);
const delta = calc.cost - calc.breakeven;
refs.heroNote.textContent = calc.postPnl < 0
? "补后亏损 " + fmtPct(calc.postPct, 2) + "(补前 " + fmtPct(calc.prePct, 2) + ")"
: "补后浮盈 " + fmtPct(calc.postPct, 2) + "(补前 " + fmtPct(calc.prePct, 2) + ")";
refs.factAddShares.textContent = fmtShares(calc.add) + " 股";
refs.factAddAmount.textContent = fmtMoney(calc.addAmount);
refs.factTotalShares.textContent = fmtShares(calc.totalShares) + " 股";
const gaugePos = clamp(calc.rise * 100, 1.5, 98.5);
refs.gaugeMarker.style.left = gaugePos.toFixed(2) + "%";
refs.gaugeTrack.style.setProperty("--risk-pos", gaugePos.toFixed(2) + "%");
}
function renderKpis(calc) {
if (!calc || !calc.valid) {
["kpiBreakeven", "kpiAddShares", "kpiTotalShares", "kpiPostPnl"].forEach(id => {
refs[id].textContent = "—";
});
refs.kpiBreakevenMeta.textContent = "等待输入";
refs.kpiAddAmount.textContent = "—";
refs.kpiTotalCost.textContent = "—";
refs.kpiPostPct.textContent = "—";
refs.kpiBreakevenFoot.textContent = "补后均价 = 回本价";
refs.kpiAddFoot.textContent = "按现价买入";
refs.kpiTotalFoot.textContent = "原持股 + 新增";
refs.kpiPostFoot.textContent = "不含手续费";
refs.kpiPostLabel.textContent = "补后浮亏";
refs.kpiPostCard.classList.remove("is-gain");
refs.kpiPostPnl.className = "kpi-value terra";
refs.mBreakeven.textContent = "—";
refs.mAddShares.textContent = "—";
refs.mRise.textContent = "—";
return;
}
const isGain = calc.postPnl >= 0;
refs.kpiBreakeven.textContent = fmtPrice(calc.breakeven);
refs.kpiBreakevenMeta.textContent = calc.warning
? "目标无效"
: (calc.rise > 0 ? "需涨 " + fmtPct(calc.rise, 2) : "已高于回本价");
refs.kpiBreakevenFoot.textContent = calc.rise > 0
? "涨回这个价才不亏不赚 · 补后均价"
: "现价已不低于补后均价,当前不亏";
refs.kpiAddShares.textContent = fmtShares(calc.add);
refs.kpiAddAmount.textContent = fmtMoney(calc.addAmount);
refs.kpiAddFoot.textContent = "按现价 " + fmtMoney(calc.price) + " 买入";
refs.kpiTotalShares.textContent = fmtShares(calc.totalShares);
refs.kpiTotalCost.textContent = fmtMoney(calc.totalCost);
refs.kpiTotalFoot.textContent = "原 " + fmtShares(calc.shares) + " 股 + 新 " + fmtShares(calc.add) + " 股";
refs.kpiPostLabel.textContent = isGain ? "补后浮盈" : "补后浮亏";
refs.kpiPostPnl.textContent = fmtLossMoney(calc.postPnl);
refs.kpiPostPnl.className = "kpi-value " + (isGain ? "green" : "terra");
refs.kpiPostPct.textContent = fmtPct(calc.postPct, 2);
refs.kpiPostFoot.textContent = "补前 " + fmtLossMoney(calc.prePnl) + "(" +
fmtPct(calc.prePct, 2) + ")· 绝对金额不变";
refs.kpiPostCard.classList.toggle("is-gain", isGain);
refs.mBreakeven.textContent = fmtMoney(calc.breakeven);
refs.mAddShares.textContent = fmtShares(calc.add) + " 股";
refs.mRise.textContent = calc.rise > 0 ? fmtPct(calc.rise, 2) : "已回本";
}
function renderRuler(calc) {
if (!calc || !calc.valid) {
refs.legendPrice.textContent = "—";
refs.legendBreakeven.textContent = "—";
refs.legendCost.textContent = "—";
refs.rulerRise.textContent = "—";
refs.rulerText.textContent = "填写持股数、成本价和现价后显示价格尺。";
refs.rulerGap.hidden = true;
refs.markPrice.style.opacity = "0";
refs.markBreakeven.style.opacity = "0";
refs.markCost.style.opacity = "0";
return;
}
refs.markPrice.style.opacity = "1";
refs.markBreakeven.style.opacity = "1";
refs.markCost.style.opacity = "1";
const price = calc.price;
const be = calc.breakeven;
const cost = calc.cost;
const values = [price, be, cost];
const lo = Math.min(...values);
const hi = Math.max(...values);
const pad = Math.max((hi - lo) * 0.18, hi * 0.01, 0.01);
const min = Math.max(0, lo - pad);
const max = hi + pad;
const pos = value => clamp(((value - min) / (max - min)) * 100, 1.2, 98.8);
const pPos = pos(price);
const bPos = pos(be);
const cPos = pos(cost);
refs.markPrice.style.left = pPos.toFixed(2) + "%";
refs.markBreakeven.style.left = bPos.toFixed(2) + "%";
refs.markCost.style.left = cPos.toFixed(2) + "%";
refs.markPrice.title = "现价 " + fmtPrice(price);
refs.markBreakeven.title = "回本价 " + fmtPrice(be);
refs.markCost.title = "原成本 " + fmtPrice(cost);
if (be > price) {
refs.rulerGap.hidden = false;
refs.rulerGap.style.left = Math.min(pPos, bPos).toFixed(2) + "%";
refs.rulerGap.style.width = Math.abs(bPos - pPos).toFixed(2) + "%";
} else {
refs.rulerGap.hidden = true;
}
refs.legendPrice.textContent = fmtMoney(price);
refs.legendBreakeven.textContent = fmtMoney(be);
refs.legendCost.textContent = fmtMoney(cost);
refs.rulerRise.textContent = be > price ? fmtPct(calc.rise, 2) : "已高于回本价";
refs.rulerRise.style.color = be > price ? "var(--terra)" : "var(--green)";
if (be <= price) {
refs.rulerText.textContent = calc.add > 0
? "现价 " + fmtPrice(price) + " 已不低于回本价 " + fmtPrice(be) +
";补仓把回本价从 " + fmtPrice(cost) + " 抬到 " + fmtPrice(be) + "。"
: "不补仓时,现价 " + fmtPrice(price) + " 已不低于成本 " + fmtPrice(cost) + "。";
} else {
refs.rulerText.textContent = calc.add > 0
? "补仓把回本价从 " + fmtPrice(cost) + " 拉到 " + fmtPrice(be) + ";股价要从 " +
fmtPrice(price) + " 涨到 " + fmtPrice(be) + " 才回本。"
: "不补仓时,回本价就是原成本 " + fmtPrice(cost) + ";股价需涨到该价才回本。";
}
}
/* ---------------- break-even chart ---------------- */
function svgEl(tag, attrs = {}, parent = null) {
const element = document.createElementNS(svgNS, tag);
Object.entries(attrs).forEach(([key, value]) => {
if (value !== undefined && value !== null) element.setAttribute(key, value);
});
if (parent) parent.appendChild(element);
return element;
}
function svgText(parent, x, y, text, options = {}) {
const element = svgEl("text", {
x,
y,
"text-anchor": options.anchor || "start",
"dominant-baseline": options.baseline || "middle",
"font-size": options.size || 11,
"font-weight": options.weight || 400,
fill: options.fill || "#7a837c"
}, parent);
element.textContent = text;
return element;
}
function linePath(points) {
return points.map((point, index) =>
(index === 0 ? "M" : "L") + point.x.toFixed(2) + " " + point.y.toFixed(2)
).join(" ");
}
function niceScale(min, max, count = 4) {
if (!(max > min)) return { min: min - 0.5, max: min + 0.5, ticks: [min] };
const raw = (max - min) / count;
const mag = Math.pow(10, Math.floor(Math.log10(raw)));
const norm = raw / mag;
const factor = norm <= 1 ? 1 : norm <= 2 ? 2 : norm <= 2.5 ? 2.5 : norm <= 5 ? 5 : 10;
const step = factor * mag;
const lo = Math.floor(min / step) * step;
const hi = Math.ceil(max / step) * step;
const ticks = [];
for (let value = lo; value <= hi + step * 1e-9; value += step) ticks.push(value);
return { min: lo, max: hi, ticks };
}
function renderBeHeader(calc) {
const isPrice = state.chartTab === "price";
refs.beTitle.textContent = isPrice ? "补仓比例,怎么改变回本价" : "补仓比例,要花多少钱";
refs.beSubtitle.textContent = isPrice
? "横轴:补仓比例(占现有股数)· 纵轴:回本价(元)"
: "横轴:补仓比例(占现有股数)· 纵轴:补仓金额(元)";
refs.beLegend.innerHTML =
'<span><i class="legend-line"></i>' + (isPrice ? "补后回本价" : "补仓金额") + '</span>' +
(isPrice ? '<span><i class="legend-line base"></i>原成本</span><span><i class="legend-line price"></i>现价</span>' : "");
refs.beNote.textContent = isPrice
? "补得越多,回本价越接近现价;但每多补 10%,带来的下降越来越小。"
: "补得越多,投入越大;曲线是直线,回本价的改善却不是。";
$$("[data-betab]").forEach(button => {
const active = button.dataset.betab === state.chartTab;
button.classList.toggle("active", active);
button.setAttribute("aria-selected", String(active));
});
}
function renderBeChart(calc) {
const wrap = refs.beWrap;
const svg = refs.beSvg;
if (!wrap || !svg) return;
const width = Math.max(300, Math.floor(wrap.clientWidth || 760));
const narrow = width < 600;
const height = narrow ? 340 : 430;
const margin = {
left: narrow ? 48 : 66,
right: narrow ? 16 : 28,
top: narrow ? 28 : 32,
bottom: narrow ? 44 : 50
};
const iw = width - margin.left - margin.right;
const ih = height - margin.top - margin.bottom;
svg.setAttribute("viewBox", "0 0 " + width + " " + height);
svg.setAttribute("width", width);
svg.setAttribute("height", height);
svg.innerHTML = "";
hideBeTip();
if (!calc || !calc.valid) {
beGeom = null;
svgEl("rect", {
x: margin.left, y: margin.top, width: iw, height: ih, rx: 9, fill: "#faf7ef"
}, svg);
svgText(svg, margin.left + iw / 2, margin.top + ih / 2, "填写持股数、成本价和现价后显示曲线", {
anchor: "middle", size: 11, fill: "#8a928b"
});
return;
}
const actualRatio = calc.addRatio;
let maxRatio = Math.max(200, Math.ceil(actualRatio / 50) * 50);
maxRatio = clamp(maxRatio, 200, 2000);
const isPrice = state.chartTab === "price";
const curve = [];
for (let ratio = 0; ratio <= maxRatio; ratio += 2) {
const item = calcAtRatio(ratio, false);
if (!item) continue;
curve.push({ ratio, calc: item });
}
if (curve[curve.length - 1].ratio < maxRatio) {
const item = calcAtRatio(maxRatio, false);
if (item) curve.push({ ratio: maxRatio, calc: item });
}
let yMin;
let yMax;
if (isPrice) {
const values = [calc.cost, calc.price, calc.breakeven].concat(curve.map(item => item.calc.breakeven));
yMin = Math.min(...values);
yMax = Math.max(...values);
const pad = Math.max((yMax - yMin) * 0.18, yMax * 0.01, 0.01);
yMin = Math.max(0, yMin - pad);
yMax += pad;
} else {
const values = curve.map(item => item.calc.addAmount).concat([calc.addAmount, 0]);
yMin = 0;
yMax = Math.max(1, ...values) * 1.08;
}
const scale = niceScale(yMin, yMax, narrow ? 4 : 5);
yMin = scale.min;
yMax = scale.max;
const ySpan = Math.max(yMax - yMin, 1e-9);
const xPos = ratio => margin.left + (ratio / maxRatio) * iw;
const yPos = value => margin.top + ih - ((value - yMin) / ySpan) * ih;
const defs = svgEl("defs", {}, svg);
const clip = svgEl("clipPath", { id: "beClip" }, defs);
svgEl("rect", { x: margin.left, y: margin.top, width: iw, height: ih }, clip);
const gradient = svgEl("linearGradient", { id: "beFill", x1: "0", y1: "0", x2: "0", y2: "1" }, defs);
svgEl("stop", { offset: "0%", "stop-color": "#b28733", "stop-opacity": ".2" }, gradient);
svgEl("stop", { offset: "100%", "stop-color": "#b28733", "stop-opacity": ".01" }, gradient);
const plot = svgEl("g", { "clip-path": "url(#beClip)" }, svg);
svgEl("rect", { x: margin.left, y: margin.top, width: iw, height: ih, rx: 9, fill: "#faf7ef" }, plot);
scale.ticks.forEach(value => {
const y = yPos(value);
svgEl("line", {
x1: margin.left, x2: margin.left + iw, y1: y, y2: y,
stroke: "rgba(24,35,29,.07)", "stroke-width": 1
}, plot);
svgText(svg, margin.left - 9, y + 0.5, isPrice ? fmtPrice(value) : fmtMoneyShort(value), {
anchor: "end", size: narrow ? 9 : 10, fill: "#8a928b"
});
});
const xStep = maxRatio <= 100 ? 20 : maxRatio <= 200 ? 25 :
maxRatio <= 500 ? 50 : maxRatio <= 1000 ? 100 : 200;
for (let ratio = 0; ratio <= maxRatio; ratio += xStep) {
const x = xPos(ratio);
svgEl("line", {
x1: x, x2: x, y1: margin.top, y2: margin.top + ih,
stroke: "rgba(24,35,29,.045)", "stroke-width": 1
}, plot);
svgText(svg, x, margin.top + ih + 19, ratio + "%", {
anchor: "middle", size: narrow ? 9 : 10, fill: "#8a928b"
});
}
if (isPrice) {
const costY = yPos(calc.cost);
svgEl("line", {
x1: margin.left, x2: margin.left + iw, y1: costY, y2: costY,
stroke: "rgba(189,88,59,.55)", "stroke-dasharray": "4 5", "stroke-width": 1.2
}, plot);
svgText(svg, margin.left + iw - 7, costY - 8, "原成本 " + fmtPrice(calc.cost), {
anchor: "end", size: 9.5, fill: "#bd583b", weight: 600
});
const priceY = yPos(calc.price);
svgEl("line", {
x1: margin.left, x2: margin.left + iw, y1: priceY, y2: priceY,
stroke: "rgba(15,92,71,.48)", "stroke-dasharray": "4 5", "stroke-width": 1.2
}, plot);
svgText(svg, margin.left + 7, priceY + 14, "现价 " + fmtPrice(calc.price), {
size: 9.5, fill: "#0f5c47", weight: 600
});
}
const points = curve.map(item => ({
x: xPos(item.ratio),
y: yPos(isPrice ? item.calc.breakeven : item.calc.addAmount)
}));
const area = points.map((point, index) =>
(index === 0 ? "M" : "L") + point.x.toFixed(2) + " " + point.y.toFixed(2)
).join(" ") +
" L" + xPos(maxRatio).toFixed(2) + " " + (margin.top + ih).toFixed(2) +
" L" + xPos(0).toFixed(2) + " " + (margin.top + ih).toFixed(2) + " Z";
svgEl("path", { d: area, fill: "url(#beFill)", stroke: "none" }, plot);
svgEl("path", {
d: linePath(points),
fill: "none",
stroke: "#b28733",
"stroke-width": 2.6,
"stroke-linecap": "round",
"stroke-linejoin": "round"
}, plot);
const selRatio = clamp(actualRatio, 0, maxRatio);
const selX = xPos(selRatio);
const selY = yPos(isPrice ? calc.breakeven : calc.addAmount);
svgEl("line", {
x1: selX, x2: selX, y1: selY, y2: margin.top + ih,
stroke: "rgba(178,135,51,.4)", "stroke-dasharray": "3 5", "stroke-width": 1
}, plot);
svgEl("circle", {
cx: selX, cy: selY, r: 5.5, fill: "#b28733", stroke: "#fffdf8", "stroke-width": 2.5
}, plot);
const ratioLabel = actualRatio > maxRatio
? fmtPctAbs(maxRatio / 100, 0) + "+"
: fmtPctAbs(selRatio / 100, 1);
const tagText = isPrice
? ratioLabel + " · " + fmtPrice(calc.breakeven) + " 元"
: ratioLabel + " · " + fmtMoneyShort(calc.addAmount);
addChartTag(svg, { margin, iw, ih }, selX, selY, tagText, "#a17b2d", selY < margin.top + 40);
svgEl("line", {
x1: margin.left, x2: margin.left + iw, y1: margin.top + ih, y2: margin.top + ih,
stroke: "rgba(24,35,29,.2)", "stroke-width": 1
}, plot);
svgEl("line", {
x1: margin.left, x2: margin.left, y1: margin.top, y2: margin.top + ih,
stroke: "rgba(24,35,29,.2)", "stroke-width": 1
}, plot);
svgText(svg, margin.left, margin.top - 12, isPrice ? "回本价(元)" : "补仓金额", {
size: 10, fill: "#7a837c", weight: 600
});
svgText(svg, margin.left + iw / 2, height - 10, "补仓比例(占现有股数)", {
anchor: "middle", size: 10, fill: "#7a837c"
});
const hoverGroup = svgEl("g", { opacity: 0 }, svg);
const hoverLine = svgEl("line", {
y1: margin.top, y2: margin.top + ih,
stroke: "rgba(24,35,29,.38)", "stroke-dasharray": "4 4", "stroke-width": 1
}, hoverGroup);
const hoverDot = svgEl("circle", {
r: 4.6, fill: "#b28733", stroke: "#fffdf8", "stroke-width": 2
}, hoverGroup);
beGeom = {
width, height, margin, iw, ih, maxRatio, isPrice,
xPos, yPos, calc, curve, hoverGroup, hoverLine, hoverDot
};
}
function addChartTag(parent, geom, cx, cy, text, color, above) {
const chars = Array.from(text);
const width = chars.reduce((sum, ch) => sum + (ch.charCodeAt(0) > 255 ? 10 : 6), 0) + 16;
const height = 20;
const x = clamp(cx - width / 2, geom.margin.left + 3, geom.margin.left + geom.iw - width - 3);
let y = above ? cy - height - 8 : cy + 8;
if (y < geom.margin.top + 3) y = cy + 8;
if (y + height > geom.margin.top + geom.ih - 3) y = cy - height - 8;
y = clamp(y, geom.margin.top + 3, geom.margin.top + geom.ih - height - 3);
const group = svgEl("g", {}, parent);
svgEl("rect", {
x, y, width, height, rx: 6, fill: "#fffdf8",
stroke: color, "stroke-opacity": .34, "stroke-width": 1
}, group);
svgText(group, x + 8, y + height / 2 + 0.5, text, {
size: 10.5, weight: 650, fill: color
});
}
function hideBeTip() {
if (beGeom) beGeom.hoverGroup.setAttribute("opacity", "0");
refs.beTip.hidden = true;
}
function onBeMove(event) {
const geom = beGeom;
if (!geom) return;
const rect = refs.beSvg.getBoundingClientRect();
if (!rect.width) return;
const localX = (event.clientX - rect.left) * (geom.width / rect.width);
if (localX < geom.margin.left - 6 || localX > geom.margin.left + geom.iw + 6) {
hideBeTip();
return;
}
const ratio = clamp(Math.round(((localX - geom.margin.left) / geom.iw) * geom.maxRatio), 0, geom.maxRatio);
const item = calcAtRatio(ratio, true) || calcAtRatio(ratio, false);
if (!item) { hideBeTip(); return; }
const px = geom.xPos(ratio);
const py = geom.yPos(geom.isPrice ? item.breakeven : item.addAmount);
geom.hoverGroup.setAttribute("opacity", "1");
geom.hoverLine.setAttribute("x1", px);
geom.hoverLine.setAttribute("x2", px);
geom.hoverDot.setAttribute("cx", px);
geom.hoverDot.setAttribute("cy", py);
refs.beTip.innerHTML =
'<div class="tt-head"><span>补仓 ' + ratio + '%</span><b>' + fmtShares(item.add) + ' 股</b></div>' +
'<div class="tt-row"><i class="tt-dot current"></i><span>回本价</span><b>' + fmtMoney(item.breakeven) + '</b></div>' +
'<div class="tt-row"><i class="tt-dot base"></i><span>回本需涨</span><b class="loss">' +
(item.rise > 0 ? fmtPct(item.rise, 2) : "已回本") + '</b></div>' +
'<div class="tt-sep"></div>' +
'<div class="tt-row plain"><span>补仓金额</span><b>' + fmtMoney(item.addAmount) + '</b></div>' +
'<div class="tt-row plain"><span>补后总股数</span><b>' + fmtShares(item.totalShares) + ' 股</b></div>' +
'<div class="tt-row plain"><span>补后亏损</span><b>' + fmtPct(item.postPct, 2) + '</b></div>';
refs.beTip.hidden = false;
const tipWidth = refs.beTip.offsetWidth || 224;
const tipHeight = refs.beTip.offsetHeight || 140;
let left = localX + 16;
if (left + tipWidth > geom.width - 6) left = localX - tipWidth - 16;
left = clamp(left, 6, geom.width - tipWidth - 6);
let top = py - tipHeight / 2;
top = clamp(top, 6, geom.height - tipHeight - 6);
refs.beTip.style.left = left + "px";
refs.beTip.style.top = top + "px";
}
/* ---------------- cost / structure ---------------- */
function renderCost(calc) {
if (!calc || !calc.valid) {
refs.costBars.innerHTML = "";
refs.costDelta.textContent = "填写参数后显示成本对比。";
refs.donut.style.setProperty("--orig", "100%");
refs.donutPct.textContent = "—";
refs.donutLegend.innerHTML = "";
refs.costSubtitle.textContent = "填写参数后显示成本与持仓结构。";
return;
}
const values = [
{ key: "before", label: "原成本", value: calc.cost },
{ key: "price", label: "现价", value: calc.price },
{ key: "after", label: "补后均价", value: calc.breakeven }
];
const max = Math.max(...values.map(item => item.value), 0.0001);
refs.costBars.innerHTML = values.map(item =>
'<div class="cost-item ' + item.key + '">' +
'<span class="cost-value">' + fmtPrice(item.value) + '</span>' +
'<div class="cost-track"><i class="cost-fill" style="height:' +
Math.max(4, (item.value / max) * 100).toFixed(2) + '%"></i></div>' +
'<span class="cost-label">' + item.label + '</span>' +
'</div>'
).join("");
const delta = calc.cost - calc.breakeven;
refs.costDelta.innerHTML = Math.abs(delta) < 0.005
? "补后均价与原成本持平"
: "补后均价较原成本 " +
(delta > 0 ? "<b>↓ " + fmtPrice(delta) + "</b>" : "<b>↑ " + fmtPrice(Math.abs(delta)) + "</b>") +
" 元";
refs.costSubtitle.textContent = calc.add > 0
? "补后均价 " + fmtPrice(calc.breakeven) + " 元,介于现价 " + fmtPrice(calc.price) +
" 与成本 " + fmtPrice(calc.cost) + " 之间。"
: "未补仓,均价仍是原成本 " + fmtPrice(calc.cost) + " 元。";
const total = calc.totalShares;
const origPct = total > 0 ? calc.shares / total : 1;
const addPct = 1 - origPct;
refs.donut.style.setProperty("--orig", (origPct * 100).toFixed(2) + "%");
refs.donutPct.textContent = (origPct * 100).toFixed(2) + "%";
refs.donutLegend.innerHTML =
'<span><i class="orig"></i>原持股 <b>' + fmtShares(calc.shares) + ' 股</b></span>' +
'<span><i class="add"></i>新增 <b>' + fmtShares(calc.add) + ' 股</b></span>';
}
/* ---------------- ratio table ---------------- */
function renderBeTable(calc) {
if (!calc || !calc.valid) {
refs.beTableBody.innerHTML = '<tr><td colspan="6" style="text-align:center;color:var(--muted)">填写参数后显示</td></tr>';
return;
}
const ratios = [0, 10, 20, 30, 50, 75, 100, 150, 200];
const actual = calc.addRatio;
if (actual > 0 && !ratios.some(value => Math.abs(value - actual) < 0.05)) {
ratios.push(Math.round(actual * 10) / 10);
ratios.sort((a, b) => a - b);
}
refs.beTableBody.innerHTML = ratios.map(ratio => {
const item = calcAtRatio(ratio, true);
if (!item) return "";
const selected = Math.abs(ratio - actual) < 0.05;
const classes = [];
if (selected) classes.push("is-selected");
if (ratio === 0) classes.push("is-zero");
return '<tr class="' + classes.join(" ") + '">' +
'<td>' + ratio + '%' + (ratio === 0 ? "(不补)" : "") + '</td>' +
'<td>' + fmtShares(item.add) + '</td>' +
'<td>' + fmtMoney(item.addAmount) + '</td>' +
'<td class="gold">' + fmtPrice(item.breakeven) + '</td>' +
'<td class="' + (item.rise > 0 ? "loss" : "good") + '">' +
(item.rise > 0 ? fmtPct(item.rise, 2) : "已回本") + '</td>' +
'<td class="' + (item.postPct < 0 ? "loss" : "good") + '">' + fmtPct(item.postPct, 2) + '</td>' +
'</tr>';
}).join("");
}
function shareAdjustText(raw, actual) {
if (Math.abs(actual - raw) > 0.05) {
const label = state.roundLot ? "按 100 股取整" : "取整";
return trimNum(raw.toFixed(1)) + " 股 → " + label + " <b>" + fmtShares(actual) + "</b> 股";
}
return "<b>" + fmtShares(actual) + "</b> 股";
}
function renderCalcLine(calc) {
if (!calc || !calc.valid) {
refs.calcLine.textContent = "填写持股数、成本价和现价后,这里显示计算过程。";
return;
}
if (state.mode === "target" && calc.warning) {
refs.calcLine.textContent = calc.warning;
return;
}
if (calc.add <= 0) {
refs.calcLine.textContent = calc.rise > 0
? "未补仓;回本价 = 原成本 " + fmtPrice(calc.cost) + " 元,需涨 " +
fmtPct(calc.rise, 2) + " 才回本。"
: "未补仓;现价 " + fmtPrice(calc.price) + " 已不低于成本 " + fmtPrice(calc.cost) + ",当前不亏。";
return;
}
if (state.mode === "ratio") {
refs.calcLine.innerHTML = "补仓股数 = " + fmtShares(calc.shares) + " × " +
trimNum(Number(state.ratio).toFixed(2)) + "% = " + shareAdjustText(calc.rawAdd, calc.add) +
";回本价 = (" + fmtShares(calc.shares) + "×" + fmtPrice(calc.cost) + " + " +
fmtShares(calc.add) + "×" + fmtPrice(calc.price) + ") ÷ " + fmtShares(calc.totalShares) +
" = <b>" + fmtPrice(calc.breakeven) + "</b> 元";
} else if (state.mode === "shares") {
refs.calcLine.innerHTML = "补仓股数 = " + shareAdjustText(calc.rawAdd, calc.add) +
";回本价 = (" + fmtShares(calc.shares) + "×" + fmtPrice(calc.cost) + " + " +
fmtShares(calc.add) + "×" + fmtPrice(calc.price) + ") ÷ " + fmtShares(calc.totalShares) +
" = <b>" + fmtPrice(calc.breakeven) + "</b> 元";
} else if (state.mode === "amount") {
refs.calcLine.innerHTML = "补仓股数 = " + fmtMoney(state.amount) + " ÷ " + fmtPrice(calc.price) +
" = " + shareAdjustText(calc.rawAdd, calc.add) +
";回本价 = <b>" + fmtPrice(calc.breakeven) + "</b> 元";
} else {
refs.calcLine.innerHTML = "补仓股数 = " + fmtShares(calc.shares) + " × (" +
fmtPrice(calc.cost) + " − " + fmtPrice(state.target) + ") ÷ (" + fmtPrice(state.target) +
" − " + fmtPrice(calc.price) + ") = " + shareAdjustText(calc.rawAdd, calc.add) +
";回本价 = <b>" + fmtPrice(calc.breakeven) + "</b> 元";
}
}
function syncSingleControls(calc) {
$$("[data-mode]").forEach(button => {
const active = button.dataset.mode === state.mode;
button.classList.toggle("active", active);
button.setAttribute("aria-pressed", String(active));
});
refs.ratioPanel.hidden = state.mode !== "ratio";
refs.sharesPanel.hidden = state.mode !== "shares";
refs.amountPanel.hidden = state.mode !== "amount";
refs.targetPanel.hidden = state.mode !== "target";
refs.ratioRange.value = state.ratio;
setRangeFill(refs.ratioRange);
refs.ratioInput.value = state.ratio;
refs.sharesToAddInput.value = state.addShares;
refs.amountInput.value = state.amount;
refs.targetInput.value = state.target;
refs.roundLot.checked = state.roundLot;
refs.stockName.textContent = state.name || "—";
if (Number(state.cost) > 0 && Number(state.price) > 0) {
if (Number(state.cost) > Number(state.price)) {
refs.targetHint.textContent = "目标均价必须低于成本 " + fmtPrice(Number(state.cost)) +
"、高于现价 " + fmtPrice(Number(state.price)) + "。";
} else {
refs.targetHint.textContent = "当前股价不低于成本,补仓会抬高均价;按目标成本不适用。";
}
} else {
refs.targetHint.textContent = "先填写成本和现价,再设置目标均价。";
}
if (calc && calc.warning) refs.targetHint.textContent = calc.warning;
}
function renderAll() {
const calc = calcSingle();
syncSingleControls(calc);
renderHero(calc);
renderKpis(calc);
renderRuler(calc);
renderBeHeader(calc);
renderBeChart(calc);
renderCost(calc);
renderBeTable(calc);
renderCalcLine(calc);
hideBeTip();
}
function scheduleRender() {
if (renderFrame) return;
renderFrame = requestAnimationFrame(() => {
renderFrame = 0;
renderAll();
});
}
/* ---------------- batch ---------------- */
function makeRow(overrides = {}) {
state.rowSeq += 1;
return {
id: state.rowSeq,
code: "sh600000",
name: "浦发银行",
shares: 1000,
cost: 10,
price: 8,
ratio: state.batchRatio,
...overrides
};
}
function rowHtml(row) {
const calc = calcForRow(row);
return '<tr data-row-id="' + row.id + '">' +
'<td class="cell-stock">' +
'<input class="code-input" type="text" data-field="code" value="' + escapeAttr(row.code) +
'" placeholder="sh600519" spellcheck="false" aria-label="股票代码">' +
'<small class="row-name" data-cell="name">' + escapeHtml(row.name || "—") + '</small>' +
'</td>' +
'<td><input type="number" data-field="shares" min="0" step="100" value="' + numberAttr(row.shares) + '" aria-label="持股数"></td>' +
'<td><input type="number" data-field="cost" min="0" step="0.01" value="' + numberAttr(row.cost) + '" aria-label="成本价"></td>' +
'<td><input type="number" data-field="price" min="0" step="0.01" value="' + numberAttr(row.price) + '" aria-label="现价"></td>' +
'<td class="cell-ratio"><input type="number" data-field="ratio" min="0" max="500" step="1" value="' +
numberAttr(row.ratio) + '" aria-label="补仓比例">%</td>' +
'<td class="num" data-cell="addShares">' + (calc ? fmtShares(calc.add) : "—") + '</td>' +
'<td class="num" data-cell="addAmount">' + (calc ? fmtMoney(calc.addAmount) : "—") + '</td>' +
'<td class="num rec-gold" data-cell="breakeven">' + (calc ? fmtPrice(calc.breakeven) : "—") + '</td>' +
'<td class="num" data-cell="rise">' + (calc ? (calc.rise > 0 ? fmtPct(calc.rise, 2) : "已回本") : "—") + '</td>' +
'<td class="cell-actions"><div class="row-actions">' +
'<button class="row-btn" type="button" data-action="analyze">精算</button>' +
'<button class="row-btn danger" type="button" data-action="remove">删除</button>' +
'</div></td>' +
'</tr>';
}
function escapeHtml(value) {
return String(value == null ? "" : value)
.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
.replace(/"/g, """);
}
function escapeAttr(value) {
return escapeHtml(value).replace(/'/g, "'");
}
function numberAttr(value) {
return Number.isFinite(Number(value)) ? String(Number(value)) : "";
}
function renderBatch() {
if (!state.rows.length) {
refs.batchBody.innerHTML = '<tr><td colspan="10" class="batch-empty">还没有股票。点击「+ 添加股票」,或导入 获取股票信息.txt。</td></tr>';
} else {
refs.batchBody.innerHTML = state.rows.map(rowHtml).join("");
}
refs.batchBadge.textContent = state.rows.length + " 只";
updateBatchTotals();
}
function updateBatchRow(row) {
const tr = refs.batchBody.querySelector('[data-row-id="' + row.id + '"]');
if (!tr) return;
const calc = calcForRow(row);
const nameEl = tr.querySelector('[data-cell="name"]');
if (nameEl) nameEl.textContent = row.name || "—";
const set = (cell, text, className) => {
const el = tr.querySelector('[data-cell="' + cell + '"]');
if (!el) return;
el.textContent = text;
if (className !== undefined) el.className = "num " + className;
};
set("addShares", calc ? fmtShares(calc.add) : "—");
set("addAmount", calc ? fmtMoney(calc.addAmount) : "—");
set("breakeven", calc ? fmtPrice(calc.breakeven) : "—", "num rec-gold");
set("rise", calc ? (calc.rise > 0 ? fmtPct(calc.rise, 2) : "已回本") : "—");
updateBatchTotals();
}
function updateBatchTotals() {
let addAmount = 0;
let totalShares = 0;
let totalCost = 0;
let pnl = 0;
let count = 0;
state.rows.forEach(row => {
const calc = calcForRow(row);
if (!calc) return;
count += 1;
addAmount += calc.addAmount;
totalShares += calc.totalShares;
totalCost += calc.totalCost;
pnl += calc.postPnl;
});
if (!count) {
refs.batchAddAmount.textContent = "—";
refs.batchTotalShares.textContent = "—";
refs.batchTotalCost.textContent = "—";
refs.batchPnl.textContent = "—";
refs.batchPnlLabel.textContent = "组合盈亏(按现价)";
return;
}
refs.batchAddAmount.textContent = fmtMoney(addAmount);
refs.batchTotalShares.textContent = fmtShares(totalShares) + " 股";
refs.batchTotalCost.textContent = fmtMoney(totalCost);
refs.batchPnl.textContent = fmtLossMoney(pnl);
refs.batchPnl.style.color = pnl >= 0 ? "var(--green)" : "var(--terra)";
refs.batchPnlLabel.textContent = pnl >= 0 ? "组合浮盈(按现价)" : "组合浮亏(按现价)";
}
function updateAllBatch() {
state.rows.forEach(row => updateBatchRow(row));
updateBatchTotals();
}
function findRow(id) {
return state.rows.find(row => String(row.id) === String(id));
}
function setBatchStatus(text, tone = "") {
refs.batchStatus.textContent = text || "";
refs.batchStatus.className = "status-line batch-status" + (tone ? " " + tone : "");
}
function analyzeRow(row) {
state.code = normalizeCode(row.code);
state.name = row.name || state.name;
state.shares = Number(row.shares) || 0;
state.cost = Number(row.cost) || 0;
state.price = Number(row.price) || 0;
state.mode = "ratio";
state.ratio = Number(row.ratio) || 0;
renderAll();
window.scrollTo({ top: 0, behavior: "smooth" });
}
async function fetchSingle() {
const btn = refs.fetchBtn;
btn.disabled = true;
refs.fetchStatus.textContent = "获取中…";
refs.fetchStatus.className = "status-line busy";
try {
const quote = await fetchQuote(state.code);
state.code = quote.code;
state.name = quote.name;
state.price = quote.price;
refs.codeInput.value = quote.code;
refs.priceInput.value = fmtPrice(quote.price);
refs.fetchStatus.textContent = "已更新 " + quote.name + " · " + fmtPrice(quote.price) + " 元";
refs.fetchStatus.className = "status-line ok";
renderAll();
} catch (error) {
refs.fetchStatus.textContent = error.message || "获取失败";
refs.fetchStatus.className = "status-line bad";
} finally {
btn.disabled = false;
}
}
async function fetchAllBatch() {
const codes = [];
state.rows.forEach(row => {
const code = normalizeCode(row.code);
if (isStockCode(code) && !codes.includes(code)) codes.push(code);
});
if (!codes.length) {
setBatchStatus("没有可获取的有效代码", "bad");
return;
}
refs.fetchAllBtn.disabled = true;
setBatchStatus("正在获取 " + codes.length + " 只股票的现价…", "busy");
const results = await Promise.allSettled(codes.map(code => fetchQuote(code)));
const byCode = new Map();
let ok = 0;
let fail = 0;
results.forEach((result, index) => {
if (result.status === "fulfilled") {
ok += 1;
byCode.set(codes[index], result.value);
} else {
fail += 1;
}
});
state.rows.forEach(row => {
const code = normalizeCode(row.code);
const quote = byCode.get(code);
if (!quote) return;
row.code = quote.code;
row.name = quote.name;
row.price = quote.price;
});
if (byCode.has(normalizeCode(state.code))) {
const quote = byCode.get(normalizeCode(state.code));
state.name = quote.name;
state.price = quote.price;
refs.codeInput.value = quote.code;
refs.priceInput.value = fmtPrice(quote.price);
}
renderBatch();
renderAll();
refs.fetchAllBtn.disabled = false;
setBatchStatus(ok + " 只更新成功" + (fail ? "," + fail + " 只失败(可手动填价)" : ""),
fail ? "bad" : "ok");
}
function addBlankRow(overrides = {}) {
const row = {
id: ++state.rowSeq,
code: "",
name: "",
shares: 0,
cost: 0,
price: 0,
ratio: state.batchRatio,
...overrides
};
state.rows.push(row);
renderBatch();
const input = refs.batchBody.querySelector('[data-row-id="' + row.id + '"] .code-input');
if (input) input.focus();
return row;
}
/* ---------------- events ---------------- */
refs.codeInput.addEventListener("input", event => {
state.code = event.target.value;
});
refs.codeInput.addEventListener("blur", () => {
const code = normalizeCode(state.code);
state.code = code;
refs.codeInput.value = code;
});
refs.codeInput.addEventListener("keydown", event => {
if (event.key === "Enter") {
event.preventDefault();
fetchSingle();
}
});
refs.fetchBtn.addEventListener("click", fetchSingle);
[refs.sharesInput, refs.costInput, refs.priceInput].forEach(input => {
input.addEventListener("input", () => {
state.shares = Math.max(0, Number(refs.sharesInput.value) || 0);
state.cost = Math.max(0, Number(refs.costInput.value) || 0);
state.price = Math.max(0, Number(refs.priceInput.value) || 0);
scheduleRender();
});
input.addEventListener("blur", () => {
state.shares = Math.round(clamp(Number(refs.sharesInput.value) || 0, 0, 1e9));
state.cost = Math.round(clamp(Number(refs.costInput.value) || 0, 0, 1e6) * 100) / 100;
state.price = Math.round(clamp(Number(refs.priceInput.value) || 0, 0, 1e6) * 100) / 100;
refs.sharesInput.value = state.shares;
refs.costInput.value = fmtPrice(state.cost);
refs.priceInput.value = fmtPrice(state.price);
renderAll();
});
});
refs.ratioRange.addEventListener("input", event => {
state.ratio = clamp(Math.round(Number(event.target.value)), 0, 500);
scheduleRender();
});
refs.ratioInput.addEventListener("input", () => {
state.ratio = clamp(Math.round(Number(refs.ratioInput.value) || 0), 0, 500);
scheduleRender();
});
refs.ratioInput.addEventListener("blur", () => {
state.ratio = clamp(Math.round(Number(refs.ratioInput.value) || 0), 0, 500);
refs.ratioInput.value = state.ratio;
renderAll();
});
$$("[data-ratio]").forEach(button => {
button.addEventListener("click", () => {
state.ratio = Number(button.dataset.ratio);
renderAll();
});
});
refs.sharesToAddInput.addEventListener("input", () => {
state.addShares = Math.max(0, Math.round(Number(refs.sharesToAddInput.value) || 0));
scheduleRender();
});
refs.sharesToAddInput.addEventListener("blur", () => {
state.addShares = Math.round(clamp(Number(refs.sharesToAddInput.value) || 0, 0, 1e9));
refs.sharesToAddInput.value = state.addShares;
renderAll();
});
$$("[data-add-shares]").forEach(button => {
button.addEventListener("click", () => {
state.addShares = Number(button.dataset.addShares);
renderAll();
});
});
refs.amountInput.addEventListener("input", () => {
state.amount = Math.max(0, Number(refs.amountInput.value) || 0);
scheduleRender();
});
refs.amountInput.addEventListener("blur", () => {
state.amount = Math.round(clamp(Number(refs.amountInput.value) || 0, 0, 1e9));
refs.amountInput.value = state.amount;
renderAll();
});
$$("[data-amount]").forEach(button => {
button.addEventListener("click", () => {
state.amount = Number(button.dataset.amount);
renderAll();
});
});
refs.targetInput.addEventListener("input", () => {
state.target = Math.max(0, Number(refs.targetInput.value) || 0);
scheduleRender();
});
refs.targetInput.addEventListener("blur", () => {
state.target = Math.round(clamp(Number(refs.targetInput.value) || 0, 0, 1e6) * 100) / 100;
refs.targetInput.value = fmtPrice(state.target);
renderAll();
});
refs.midTarget.addEventListener("click", () => {
if (Number(state.cost) > Number(state.price)) {
state.target = Math.round(((Number(state.cost) + Number(state.price)) / 2) * 100) / 100;
renderAll();
}
});
$$("[data-mode]").forEach(button => {
button.addEventListener("click", () => {
state.mode = button.dataset.mode;
renderAll();
});
});
refs.roundLot.addEventListener("change", () => {
state.roundLot = refs.roundLot.checked;
updateAllBatch();
renderAll();
});
refs.applyToBatch.addEventListener("click", () => {
const calc = calcSingle();
const code = normalizeCode(state.code);
const ratio = calc && calc.valid ? Math.round(calc.addRatio * 10) / 10 : Number(state.ratio);
const existing = state.rows.find(row => normalizeCode(row.code) === code && code);
if (existing) {
existing.name = state.name;
existing.shares = state.shares;
existing.cost = state.cost;
existing.price = state.price;
existing.ratio = ratio;
} else {
state.rows.push({
id: ++state.rowSeq,
code,
name: state.name,
shares: state.shares,
cost: state.cost,
price: state.price,
ratio
});
}
renderBatch();
setBatchStatus("已加入批量:" + (state.name || code || "当前股票"), "ok");
const section = $("#batchSection");
if (section) section.scrollIntoView({ behavior: "smooth", block: "start" });
});
refs.addRowBtn.addEventListener("click", () => addBlankRow());
refs.clearBatchBtn.addEventListener("click", () => {
if (!state.rows.length) return;
if (!window.confirm("清空批量里的全部股票?")) return;
state.rows = [];
renderBatch();
setBatchStatus("已清空", "");
});
refs.fetchAllBtn.addEventListener("click", fetchAllBatch);
refs.importBtn.addEventListener("click", () => refs.importFile.click());
refs.importFile.addEventListener("change", async () => {
const file = refs.importFile.files && refs.importFile.files[0];
if (!file) return;
setBatchStatus("读取 " + file.name + "…", "busy");
try {
const text = await file.text();
const codes = extractCodes(text);
if (!codes.length) {
setBatchStatus("文件里没有找到股票代码", "bad");
return;
}
let added = 0;
codes.forEach(code => {
const exists = state.rows.some(row => normalizeCode(row.code) === code);
if (exists) return;
state.rows.push({
id: ++state.rowSeq,
code,
name: "",
shares: 0,
cost: 0,
price: 0,
ratio: state.batchRatio
});
added += 1;
});
renderBatch();
setBatchStatus("已导入 " + added + " 只,正在获取现价…", "ok");
await fetchAllBatch();
} catch (error) {
setBatchStatus("读取失败:" + (error.message || "未知错误"), "bad");
} finally {
refs.importFile.value = "";
}
});
refs.batchBody.addEventListener("input", event => {
const input = event.target.closest("[data-field]");
if (!input) return;
const tr = input.closest("[data-row-id]");
if (!tr) return;
const row = findRow(tr.dataset.rowId);
if (!row) return;
const field = input.dataset.field;
if (field === "code") {
row.code = input.value;
row.name = "";
} else {
row[field] = Math.max(0, Number(input.value) || 0);
}
updateBatchRow(row);
});
refs.batchBody.addEventListener("change", event => {
const input = event.target.closest("[data-field]");
if (!input) return;
const tr = input.closest("[data-row-id]");
const row = findRow(tr.dataset.rowId);
if (!row) return;
if (input.dataset.field === "code") {
const code = normalizeCode(input.value);
row.code = code;
input.value = code;
updateBatchRow(row);
}
});
refs.batchBody.addEventListener("keydown", async event => {
if (event.key !== "Enter") return;
const input = event.target.closest('[data-field="code"]');
if (!input) return;
const tr = input.closest("[data-row-id]");
const row = findRow(tr.dataset.rowId);
if (!row) return;
event.preventDefault();
const code = normalizeCode(row.code);
row.code = code;
input.value = code;
setBatchStatus("获取 " + code + " 现价…", "busy");
try {
const quote = await fetchQuote(code);
row.name = quote.name;
row.price = quote.price;
if (!(Number(row.cost) > 0)) {
// 只补现价,不猜成本
}
renderBatch();
setBatchStatus(quote.name + " " + fmtPrice(quote.price) + " 元", "ok");
} catch (error) {
setBatchStatus(error.message || "获取失败", "bad");
}
});
refs.batchBody.addEventListener("click", event => {
const action = event.target.closest("[data-action]");
if (!action) return;
const tr = action.closest("[data-row-id]");
const row = findRow(tr.dataset.rowId);
if (!row) return;
if (action.dataset.action === "analyze") {
analyzeRow(row);
} else if (action.dataset.action === "remove") {
state.rows = state.rows.filter(item => item.id !== row.id);
renderBatch();
setBatchStatus("已删除 1 只", "");
}
});
$$("[data-batch-ratio]").forEach(button => {
button.addEventListener("click", () => {
state.batchRatio = Number(button.dataset.batchRatio);
state.rows.forEach(row => { row.ratio = state.batchRatio; });
renderBatch();
setBatchStatus("全部改为 " + state.batchRatio + "%", "ok");
});
});
refs.beTabs.addEventListener("click", event => {
const button = event.target.closest("[data-betab]");
if (!button) return;
state.chartTab = button.dataset.betab;
renderBeHeader(calcSingle());
renderBeChart(calcSingle());
hideBeTip();
});
refs.beWrap.addEventListener("pointermove", onBeMove);
refs.beWrap.addEventListener("pointerleave", hideBeTip);
refs.beWrap.addEventListener("pointercancel", hideBeTip);
$("#resetBtn").addEventListener("click", () => {
state.code = DEFAULTS.code;
state.name = DEFAULTS.name;
state.shares = DEFAULTS.shares;
state.cost = DEFAULTS.cost;
state.price = DEFAULTS.price;
state.mode = DEFAULTS.mode;
state.ratio = DEFAULTS.ratio;
state.addShares = DEFAULTS.addShares;
state.amount = DEFAULTS.amount;
state.target = DEFAULTS.target;
state.roundLot = DEFAULTS.roundLot;
state.chartTab = DEFAULTS.chartTab;
refs.codeInput.value = state.code;
refs.sharesInput.value = state.shares;
refs.costInput.value = fmtPrice(state.cost);
refs.priceInput.value = fmtPrice(state.price);
refs.fetchStatus.textContent = "价格来自腾讯行情,可手动改";
refs.fetchStatus.className = "status-line";
renderAll();
});
window.addEventListener("resize", () => {
window.clearTimeout(resizeTimer);
resizeTimer = window.setTimeout(() => {
renderBeChart(calcSingle());
hideBeTip();
}, 110);
});
if ("ResizeObserver" in window) {
let lastWidth = 0;
const observer = new ResizeObserver(entries => {
const width = Math.round(entries[0].contentRect.width);
if (lastWidth && Math.abs(width - lastWidth) > 2) {
lastWidth = width;
renderBeChart(calcSingle());
} else if (!lastWidth) {
lastWidth = width;
}
});
observer.observe(refs.beWrap);
}
if ("IntersectionObserver" in window) {
const readout = $(".mobile-readout");
const controls = $(".controls");
if (readout && controls) {
const readoutObserver = new IntersectionObserver(entries => {
readout.classList.toggle("is-visible", entries[0].isIntersecting);
}, { threshold: 0.02 });
readoutObserver.observe(controls);
}
}
state.rows = [makeRow()];
refs.codeInput.value = state.code;
refs.sharesInput.value = state.shares;
refs.costInput.value = fmtPrice(state.cost);
refs.priceInput.value = fmtPrice(state.price);
renderBatch();
renderAll();
})();
</script>
</body>
</html>