เงินห้ามใช้ float ทำไม 19.99 × 100 ถึงไม่ได้ 1999 และวิธีเก็บเงินให้เป๊ะทุกขั้น
float เก็บ 0.1 ได้ไม่เป๊ะ เงินจึงเพี้ยนทีละสตางค์และสะสม เล่าวิธีให้ตัวเลขเงินเป๊ะตั้งแต่ต้นทางจนถึงฐานข้อมูล จากระบบบันทึกผลเทรดที่ใช้จริง พร้อมโค้ด Go SQL และ Python
- float เป็นเลขฐานสอง เก็บ 0.1 หรือ 19.99 แบบเป๊ะไม่ได้ ผลบวกจึงคลาดทีละนิดและสะสม
- เงินต้องเป็นทศนิยมแบบเป๊ะทุกขั้น ตั้งแต่ JSON จนถึงคอลัมน์ในฐานข้อมูล ห้ามแวะผ่าน float แม้แต่ขั้นเดียว
- ปัดเศษครั้งเดียว ที่จุดเดียว ส่วนอัตราส่วนอย่าง win rate ใช้ float ได้
ลองเปิด console ของเบราว์เซอร์แล้วพิมพ์ 0.1 + 0.2 คำตอบคือ 0.30000000000000004 ไม่ใช่ 0.3 ดูเป็นเรื่องตลกของโปรแกรมเมอร์ แต่ในระบบที่นับเงิน มันคือบั๊กที่ทำให้ยอดคลาดทีละสตางค์ แล้วค่อย ๆ สะสมจนมีคนถามว่าเงินหายไปไหน บทความนี้อธิบายว่าเกิดจากอะไร และเล่าวิธีที่ระบบบันทึกผลเทรดของเราใช้ ให้ตัวเลขเงินเป๊ะตั้งแต่ต้นทางจนถึงฐานข้อมูล
ตัวอย่างทั้งหมดทดสอบกับ Go 1.23, PostgreSQL 16, Python 3.12 และ Node.js 22
ทำไม float เก็บ 0.1 ไม่ได้
คอมพิวเตอร์เก็บ float เป็นเลขฐานสอง เศษส่วนบางตัวที่ดูง่ายในฐานสิบ กลับเขียนให้จบในฐานสองไม่ได้ เหมือนที่ 1/3 เขียนเป็นทศนิยมฐานสิบไม่จบ (0.3333…) ตัวเลข 0.1 ในฐานสองก็ซ้ำไม่รู้จบ เครื่องจึงต้องตัดเก็บแค่ค่าที่ใกล้ที่สุด
ลองดูว่า 1234.56 ถูกเก็บจริง ๆ เป็นค่าอะไร
from decimal import Decimal
print(Decimal(1234.56))
# 1234.55999999999994543031789362430572509765625
ปกติเราไม่เห็นความต่างนี้ เพราะตอนพิมพ์ออกมา ภาษาโปรแกรมจะปัดให้ดูสวย แต่พอเอาไปบวกลบคูณหารหลายครั้ง ความคลาดเล็ก ๆ จะโผล่
x = 0.0
for _ in range(10):
x += 0.1
print(x) # 0.9999999999999999
float ไม่ได้ “คำนวณผิด” มันคำนวณถูกกับตัวเลขที่ไม่ใช่ค่าที่เราตั้งใจตั้งแต่แรก
บั๊กจริงที่เจอบ่อย: แปลงเป็นสตางค์
หลายระบบรู้ว่า float มีปัญหา จึงแปลงเป็นสตางค์ก่อนเก็บ แต่แปลงด้วยการคูณ 100 จาก float
19.99 * 100 // 1998.9999999999998
Math.trunc(19.99 * 100) // 1998 ← หายไป 1 สตางค์
Math.round(19.99 * 100) // 1999
ถ้าโค้ดใช้การตัดทศนิยม (trunc หรือแปลงเป็น int ตรง ๆ ซึ่งหลายภาษาทำแบบนี้โดยปริยาย) สินค้า 19.99 บาทจะถูกบันทึกเป็น 19.98 บาท ขายวันละพันชิ้นก็หายวันละ 10 บาท ไม่มี error ไม่มีอะไรแจ้งเตือน
เงินรั่วเข้า float ตรงไหนบ้าง
เราอาจไม่ได้ประกาศตัวแปรเป็น float เลย แต่เงินก็ยังแวะผ่าน float ได้จากจุดเหล่านี้
| จุด | เกิดอะไร |
|---|---|
| แปลง JSON | JSON.parse ใน JavaScript และ encoding/json ของ Go (ถ้าแปลงลง interface{}) ให้ตัวเลขเป็น float โดยปริยาย |
| คอลัมน์ฐานข้อมูล | REAL, DOUBLE PRECISION, FLOAT เก็บแบบ float ตั้งแต่ต้น |
| ไลบรารีกลาง | ORM หรือตัวแปลงบางตัวแปลงทศนิยมเป็น float ให้เอง |
| สเปรดชีต | Excel คำนวณด้วย float ทั้งหมด |
| ต้นทาง | โปรแกรมบางตัว เช่น MT5 ใช้ double เก็บราคามาตั้งแต่แรก |
ข้อสุดท้ายเลี่ยงไม่ได้ สิ่งที่ทำได้คือแปลงเป็นข้อความด้วยจำนวนทศนิยมที่ถูกต้อง ตั้งแต่ต้นทาง ครั้งเดียว แล้วจากนั้นห้ามกลับไปเป็น float อีก
หลักการ: ทศนิยมแบบเป๊ะทุกขั้น
เงินต้องเดินทางเป็นทศนิยมแบบเป๊ะตลอดทาง ถ้ามีขั้นไหนแวะผ่าน float แม้แต่ขั้นเดียว ความเป๊ะหายทั้งเส้น
ระบบบันทึกผลเทรดของเรารับยอดเงินจากโปรแกรมเทรดทุกไม่กี่วินาที เส้นทางของตัวเลขเงินเป็นแบบนี้
- โปรแกรมเทรด ส่ง JSON ที่มียอดเงินเป็นตัวเลข เช่น
"balance": 1234.56 - Go รับ JSON แต่ไม่แปลงเป็น float เก็บเป็น
json.Numberซึ่งข้างในคือข้อความ"1234.56" - PostgreSQL เก็บในคอลัมน์
NUMERIC(20,5)ทศนิยมแบบเป๊ะ 5 ตำแหน่ง (ราคา forex มีทศนิยม 5 ตำแหน่ง) - ตอนคำนวณสถิติ ให้ฐานข้อมูลรวมยอดด้วย NUMERIC แล้วแปลงผลเป็นข้อความด้วย
::text - API ส่งกลับ เป็นข้อความ เช่น
"net_profit": "1234.56000"
โค้ดแต่ละขั้น
Go: รับ JSON โดยไม่ผ่าน float ประกาศฟิลด์เงินเป็น json.Number
type Snapshot struct {
Balance json.Number `json:"balance"`
Equity json.Number `json:"equity"`
}
ถ้าต้องอ่าน JSON แบบไม่รู้โครงสร้าง ให้สั่ง decoder ไม่แปลงเป็น float
dec := json.NewDecoder(r.Body)
dec.UseNumber() // ตัวเลขทุกตัวเป็น json.Number ไม่ใช่ float64
SQL: เก็บเป็น NUMERIC และส่งออกเป็นข้อความ
CREATE TABLE orders (
profit NUMERIC(20,5) NOT NULL,
commission NUMERIC(20,5) NOT NULL DEFAULT 0,
swap NUMERIC(20,5) NOT NULL DEFAULT 0
);
SELECT coalesce(sum(profit + commission + swap), 0)::text AS net_profit
FROM orders WHERE account_id = $1;
การ cast เป็น ::text ทำให้ไดรเวอร์ฐานข้อมูลส่งค่ามาเป็นข้อความ ไม่มีโอกาสแปลงเป็น float ระหว่างทาง
Python: ใช้ Decimal และสร้างจากข้อความเสมอ
from decimal import Decimal
Decimal("19.99") * 100 # Decimal('1999.00') ถูก
Decimal(19.99) * 100 # Decimal('1998.999999999999843680598133') ผิด เพราะสร้างจาก float
จุดที่คนพลาดบ่อยคือสร้าง Decimal จาก float ซึ่งได้ความคลาดของ float ติดมาด้วย ต้องสร้างจากข้อความเท่านั้น
ปัดเศษ: ครั้งเดียว ที่จุดเดียว
ทศนิยมแบบเป๊ะไม่ได้แปลว่าไม่ต้องปัด เช่น ค่าเฉลี่ยกำไรต่อไม้อาจได้ทศนิยมไม่รู้จบ กติกาคือ
- ปัดตอนท้าย ครั้งเดียว อย่าปัดทุกขั้นระหว่างทาง เพราะความคลาดจากการปัดจะสะสมเหมือน float
- ปัดที่จุดเดียว ในระบบของเรา การหารเฉลี่ยทำในฐานข้อมูลแล้ว cast เป็น
NUMERIC(20,5)ก่อนเป็นข้อความ ไม่มีโค้ดส่วนอื่นปัดซ้ำ - ตกลงวิธีปัดให้ชัด ปัดครึ่งขึ้น หรือปัดแบบธนาคาร (ครึ่งไปหาเลขคู่) ระบบบัญชีบางแห่งกำหนดไว้ ต้องใช้ให้ตรงกัน
แล้วอะไรใช้ float ได้
ไม่ใช่ทุกตัวเลขต้องเป๊ะ ในระบบเดียวกันนี้ อัตราส่วน อย่าง win rate, profit factor และ drawdown เป็น float เพราะความคลาดหลักทศนิยมที่ 15 ไม่มีความหมายกับคนอ่าน และไม่มีใครเอาตัวเลขพวกนี้ไปจ่ายเงิน
เส้นแบ่งง่าย ๆ คือ ถ้าตัวเลขนี้ต้องตรงกับเงินในบัญชีจริง ห้าม float ถ้าเป็นตัวชี้วัดที่ใช้ดูแนวโน้ม float ได้
ข้อผิดพลาดที่เจอบ่อย
- แปลงเป็นสตางค์ด้วยการคูณ float แล้วตัดทศนิยม (บั๊ก 19.99 ข้างบน)
- เก็บเงินในคอลัมน์
FLOATเพราะ ORM ตั้งให้อัตโนมัติ - เทียบเงินด้วย
==ระหว่างค่าที่มาจาก float เช่น0.1 + 0.2 == 0.3ได้ false - หน้าเว็บรับเงินเป็น string แล้วแปลงเป็น Number ก่อนแสดงผล ทั้งที่แสดง string ไปตรง ๆ ได้
- ทดสอบด้วยตัวเลขกลม ๆ อย่าง 100 หรือ 50 ซึ่งไม่โผล่ปัญหา ควรมีเทสต์ด้วยค่าอย่าง 19.99, 0.1 และ 1234.56 เสมอ
คำถามที่พบบ่อย
เก็บเป็นสตางค์ (จำนวนเต็ม) แทนได้ไหม
ได้และเป็นวิธีที่ดีสำหรับเงินสกุลเดียวที่ทศนิยมตายตัว แต่ถ้าต้องเก็บราคาที่มีทศนิยม 5 ตำแหน่ง หรือหลายสกุลเงินที่ทศนิยมไม่เท่ากัน ใช้ชนิดทศนิยมของฐานข้อมูลอย่าง NUMERIC จะตรงไปตรงมากว่า
JavaScript ฝั่งหน้าเว็บต้องทำยังไง
รับเงินจาก API เป็น string แล้วแสดงผลเป็น string ไปเลย ถ้าต้องคำนวณบนหน้าเว็บจริง ๆ ให้ใช้ library ทศนิยม อย่าแปลงเป็น Number แล้วคูณหาร
คำสั่งและค่าตั้งในบทความทดสอบบนระบบตัวอย่าง ลองบนเครื่องทดสอบก่อนใช้กับระบบจริงเสมอ