🪙 Bitcoin with Python: read the price, then trade it · บิตคอยน์ด้วย Python: อ่านราคา แล้วเทรดมัน · 用 Python 玩比特币:先读价格,再交易

Fourteen steps against Thailand's own exchange. Seven need no key, no account and no money. Four place a real order and take it back. Three build a shop that takes bitcoin with no server at all. · สิบสี่ขั้นกับกระดานเทรดของไทยเอง เจ็ดขั้นแรกไม่ต้องมีคีย์ ไม่ต้องมีบัญชี ไม่ต้องใช้เงิน อีกสี่ขั้นวางคำสั่งจริงแล้วดึงกลับ และสามขั้นสุดท้ายสร้างร้านที่รับบิตคอยน์โดยไม่ต้องมีเซิร์ฟเวอร์เลย · 在泰国自己的交易所上走十四步。前七步不用密钥、不用账户、不用花钱;四步真的下一张单再撤回来;最后三步做一个完全不需要服务器的比特币收款店。

🇬🇧 English

Bitkub is Thailand's own exchange. Its prices are public. You can read them with no key, no account, and no money.

So the first seven steps cost nothing. You read a real price, keep a history of it, draw it, and watch for a line.

The last four steps are real trading on a real account. They are marked, and the last one places an order you can take straight back.

You install nothing. urllib, json, hmac and hashlib all come with Python.

🇹🇭 ไทย

Bitkub คือกระดานเทรดของไทยเอง ราคาบนนั้นเปิดให้ทุกคนดู คุณอ่านมันได้โดยไม่ต้องมีคีย์ ไม่ต้องมีบัญชี และไม่ต้องใช้เงิน

เจ็ดขั้นแรกจึงไม่มีค่าใช้จ่ายเลย คุณอ่านราคาจริง เก็บประวัติของมัน วาดออกมาเป็นรูป และเฝ้าดูว่ามันข้ามเส้นที่คุณตั้งไว้หรือยัง

ส่วนสี่ขั้นสุดท้ายคือการเทรดจริงบนบัญชีจริง เราทำเครื่องหมายไว้ชัดเจน และขั้นสุดท้ายจะวางคำสั่งที่คุณดึงกลับได้ทันที

ไม่ต้องติดตั้งอะไรเลย urllib json hmac และ hashlib มากับ Python อยู่แล้ว

🇨🇳 中文

Bitkub 是泰国自己的交易所,价格是公开的。不用密钥、不用账户、不用花钱就能读。

所以前七步一分钱都不花:读真实价格、保存历史、画出来,再盯着它有没有越线。

最后四步是在真实账户上真实地交易。它们都有标记,而最后一步下的单,你可以马上撤回。

什么都不用装。urllib、json、hmac、hashlib 都是 Python 自带的。

('What you need', 'ต้องมีอะไร', '需要什么')('For which steps', 'ใช้กับขั้นไหน', '用在哪几步')
python 3.10 or newerall of themทุกขั้น全部
Nothing to installไม่ต้องติดตั้งอะไร不用安装任何东西all of themทุกขั้น全部
An internet connectionอินเทอร์เน็ต能上网all of themทุกขั้น全部
A Bitkub account and an API keyบัญชี Bitkub และคีย์ API一个 Bitkub 账户和 API 密钥steps 8 to 11 onlyเฉพาะขั้น 8 ถึง 11只有第 8 到 11 步
MONEY IS REAL, AND SO ARE MISTAKES This page ends with code that spends money. Nobody here can undo a trade for you. Read every line before you run it. Start with the smallest amount the exchange allows. Nothing on this page is advice about what to buy. หน้านี้จบด้วยโค้ดที่ใช้เงินจริง ไม่มีใครย้อนคำสั่งซื้อขายให้คุณได้ อ่านทุกบรรทัดก่อนรัน เริ่มจากจำนวนน้อยที่สุดที่กระดานยอมให้ทำ และไม่มีอะไรในหน้านี้เป็นคำแนะนำว่าควรซื้ออะไร 这一页的结尾是会花钱的代码。没有人能替你撤销一笔已经成交的交易。运行前把每一行都读一遍,从交易所允许的最小金额开始。这一页没有任何内容是在建议你该买什么。

💰 Tutorial 1 — Read the price · บทที่ 1 — อ่านราคา · 教程 1 —— 读价格

🇬🇧 English

One request, one number. No key, no account, no money.

Then you learn that there is no single price. There are two, and the gap between them is what trading costs you.

🇹🇭 ไทย

หนึ่งคำขอ หนึ่งตัวเลข ไม่ต้องมีคีย์ ไม่ต้องมีบัญชี ไม่ต้องใช้เงิน

จากนั้นคุณจะรู้ว่ามันไม่มี "ราคาเดียว" มันมีสองราคา และช่องว่างระหว่างสองราคานั้นคือต้นทุนของการเทรด

🇨🇳 中文

一个请求,一个数字。不用密钥、不用账户、不用花钱。

然后你会知道根本没有"一个价格"这回事。有两个,而它们之间的差,就是交易的成本。

1 Ask Bitkub for the price · ถามราคาจาก Bitkub · 向 Bitkub 要价格

🇬🇧 English

Open a folder for this page. Put this file in it.

Two things will surprise you. The answer is a list, even though you asked about one coin. And every number arrives as text.

🇹🇭 ไทย

สร้างโฟลเดอร์สำหรับหน้านี้ แล้ววางไฟล์นี้ลงไป

มีสองเรื่องที่จะทำให้คุณแปลกใจ คำตอบกลับมาเป็นลิสต์ ทั้งที่คุณถามเหรียญเดียว และตัวเลขทุกตัวมาในรูปข้อความ

🇨🇳 中文

给这一页开一个文件夹,把这个文件放进去。

有两件事会让你意外:你只问了一个币,回来的却是一个列表;而且每个数字都是文字。

📄 NEW FILE · price1.py

"""price1.py — what is one bitcoin worth in baht, right now?"""
import json
import urllib.request

BITKUB = "https://api.bitkub.com/api/v3/market/ticker"


def main():
    """Ask Bitkub for the bitcoin price and print it."""
    with urllib.request.urlopen(BITKUB + "?sym=BTC_THB", timeout=30) as response:
        coins = json.load(response)

    # Bitkub sends back a list, even when you ask about one coin.
    bitcoin = coins[0]

    # Every number arrives as text. float() turns it into a number you can use.
    price = float(bitcoin["last"])

    print("1 bitcoin =", price, "baht")
    print("100 baht  =", 100 / price, "bitcoin")


if __name__ == "__main__":
    main()
LineWhat it does · ทำอะไร · 做什么
BITKUB = "https://api.bitkub.com/api/v3/market/ticker"The address of the price list. v3 is the version that works today.ที่อยู่ของรายการราคา v3 คือเวอร์ชันที่ใช้ได้ตอนนี้价格列表的地址。v3 是今天还能用的版本。
urlopen(BITKUB + "?sym=BTC_THB", timeout=30)Ask about one coin. BTC_THB means bitcoin priced in baht.ถามถึงเหรียญเดียว BTC_THB แปลว่าบิตคอยน์ที่คิดราคาเป็นบาท只问一个币。BTC_THB 意思是用泰铢计价的比特币。
bitcoin = coins[0]The answer is a list of one. [0] takes the only item.คำตอบคือลิสต์ที่มีสมาชิกเดียว [0] หยิบตัวนั้นออกมา回来的是只有一个元素的列表,[0] 把那一个取出来。
price = float(bitcoin["last"])Bitkub sends "2831623.43", with quote marks. float makes it a number.Bitkub ส่ง "2831623.43" มาพร้อมเครื่องหมายคำพูด float เปลี่ยนมันเป็นตัวเลขBitkub 发来的是带引号的 "2831623.43",float 把它变成数字。

🖥️ WHAT IT PRINTED · a real run

1 bitcoin = 2831623.43 baht
100 baht  = 3.531543034308061e-05 bitcoin
THE SYMBOL IS WRITTEN BACK TO FRONT You ask for BTC_THB. Older parts of Bitkub answer with THB_BTC. Both name the same market. If you get {"error":11}, the symbol is the thing to check first. คุณขอด้วย BTC_THB แต่ส่วนเก่าของ Bitkub ตอบกลับมาเป็น THB_BTC ทั้งสองอันหมายถึงตลาดเดียวกัน ถ้าเจอ {"error":11} ให้ดูที่ชื่อสัญลักษณ์เป็นอย่างแรก 你用 BTC_THB 去问,Bitkub 比较旧的部分却用 THB_BTC 回答。两个指的是同一个市场。如果看到 {"error":11},先检查符号写法。
YOUR TURN
  1. Run it: python price1.py.
  2. Check: two lines, and a price near the one on bitkub.com.
  3. Change BTC_THB to ETH_THB and run it again.
  4. Change it to NONSENSE. Read the error you get.
  1. รัน python price1.py
  2. ตรวจ: ได้สองบรรทัด และราคาใกล้เคียงกับที่เห็นบน bitkub.com
  3. เปลี่ยน BTC_THB เป็น ETH_THB แล้วรันใหม่
  4. เปลี่ยนเป็น NONSENSE แล้วอ่านข้อผิดพลาดที่ได้
  1. 运行:python price1.py。
  2. 检查:两行输出,价格和 bitkub.com 上看到的差不多。
  3. 把 BTC_THB 改成 ETH_THB,再运行一次。
  4. 再改成 NONSENSE,读一读得到的错误。

2 There is no single price · ไม่มีราคาเดียว · 并没有"一个价格"

🇬🇧 English

Ask what bitcoin costs and you get one number. That number is a simplification.

There is a price to buy at, and a lower price to sell at. The gap is called the spread. Buy and sell in the same second and you lose it.

🇹🇭 ไทย

ถามว่าบิตคอยน์ราคาเท่าไร คุณจะได้ตัวเลขเดียว แต่ตัวเลขนั้นคือการย่อความ

จริง ๆ มีราคาสำหรับซื้อ และราคาที่ต่ำกว่าสำหรับขาย ช่องว่างนี้เรียกว่าสเปรด ถ้าซื้อแล้วขายในวินาทีเดียวกัน คุณเสียเท่ากับช่องว่างนั้น

🇨🇳 中文

问比特币多少钱,你会得到一个数字。那个数字是简化过的。

其实有一个买入价,和一个更低的卖出价。中间的差叫价差。同一秒买进再卖出,你亏掉的就是它。

📄 NEW FILE · price2.py

"""price2.py — the two prices behind "the price"."""
import json
import urllib.request

BITKUB = "https://api.bitkub.com/api/v3/market/ticker"
COIN = "BTC_THB"


def ticker(symbol):
    """One coin's numbers from Bitkub. No key and no account needed."""
    with urllib.request.urlopen(BITKUB + "?sym=" + symbol, timeout=30) as response:
        return json.load(response)[0]


def main():
    """Show what you would pay, what you would get, and the gap between."""
    coin = ticker(COIN)

    # There is never one price. There is a price to buy and a price to sell.
    buy_at = float(coin["lowest_ask"])
    sell_at = float(coin["highest_bid"])
    gap = buy_at - sell_at

    print("you buy at :", buy_at)
    print("you sell at:", sell_at)
    print("the gap    :", round(gap, 2), "baht")

    # Buy and sell at once and you lose the gap. That is the cost of a round trip.
    print("the gap is :", round(gap / buy_at * 100, 3), "percent of the price")
    print()
    print("24 hour high:", coin["high_24_hr"])
    print("24 hour low :", coin["low_24_hr"])
    print("24 hour move:", coin["percent_change"], "percent")


if __name__ == "__main__":
    main()
LineWhat it does · ทำอะไร · 做什么
buy_at = float(coin["lowest_ask"])The cheapest price anyone will sell to you at. This is what you pay.ราคาถูกที่สุดที่มีคนยอมขายให้คุณ นี่คือราคาที่คุณจ่าย别人愿意卖给你的最低价。这就是你要付的。
sell_at = float(coin["highest_bid"])The best price anyone will buy from you at. This is what you get.ราคาดีที่สุดที่มีคนยอมซื้อจากคุณ นี่คือเงินที่คุณได้别人愿意向你买的最高价。这就是你能拿到的。
gap / buy_at * 100The gap as a percentage. Small here. On a quiet coin it is not.ช่องว่างคิดเป็นเปอร์เซ็นต์ ของบิตคอยน์ถือว่าน้อย แต่เหรียญที่คนซื้อขายน้อยจะไม่น้อยแบบนี้把价差换成百分比。比特币上很小,冷门币上就不小了。

🖥️ WHAT IT PRINTED · a real run

you buy at : 2832079.66
you sell at: 2831323.67
the gap    : 755.99 baht
the gap is : 0.027 percent of the price

24 hour high: 2836037.62
24 hour low : 2671850.12
24 hour move: 5.95 percent
YOUR TURN
  1. Run it, then run it again a minute later. Did the gap change?
  2. Try DOGE_THB, then a coin you have never heard of.
  3. Check: the quieter the coin, the wider the gap.
  4. Work out what a 0.027 percent gap costs on a 10,000 baht trade.
  1. รันหนึ่งครั้ง แล้วรันอีกครั้งหลังจากผ่านไปหนึ่งนาที ช่องว่างเปลี่ยนไหม
  2. ลอง DOGE_THB แล้วลองเหรียญที่คุณไม่เคยได้ยินชื่อ
  3. ตรวจ: ยิ่งเหรียญคนซื้อขายน้อย ช่องว่างยิ่งกว้าง
  4. ลองคิดดูว่าช่องว่าง 0.027 เปอร์เซ็นต์ คิดเป็นเงินเท่าไรถ้าเทรด 10,000 บาท
  1. 运行一次,过一分钟再运行一次。价差变了吗?
  2. 试试 DOGE_THB,再试一个你没听过的币。
  3. 检查:越冷门的币,价差越宽。
  4. 算一算:交易 10,000 泰铢时,0.027% 的价差是多少钱。

3 Every coin, sorted by today's move · เรียงทุกเหรียญตามการเคลื่อนไหววันนี้ · 把所有币按今天的涨跌排序

🇬🇧 English

Leave off ?sym= and Bitkub sends the whole board in one request. Hundreds of coins.

This step is really about sort. You give it a function, and it calls that function on every item to decide the order.

🇹🇭 ไทย

ถ้าไม่ใส่ ?sym= Bitkub จะส่งทั้งกระดานมาในคำขอเดียว หลายร้อยเหรียญ

จริง ๆ ขั้นนี้คือเรื่องของ sort คุณให้ฟังก์ชันกับมันหนึ่งตัว แล้วมันจะเรียกฟังก์ชันนั้นกับทุกสมาชิกเพื่อตัดสินลำดับ

🇨🇳 中文

不写 ?sym=,Bitkub 就把整块板子一次发过来,几百个币。

这一步其实讲的是 sort:你给它一个函数,它就对每一项调用那个函数来决定顺序。

📄 NEW FILE · price3.py

"""price3.py — which coins moved the most today?"""
import json
import urllib.request

BITKUB = "https://api.bitkub.com/api/v3/market/ticker"


def all_coins():
    """Every coin Bitkub lists, in one request. Leave off sym to get them all."""
    with urllib.request.urlopen(BITKUB, timeout=30) as response:
        return json.load(response)


def movement(coin):
    """How far this coin moved today. sort() calls this on every coin."""
    return float(coin["percent_change"])


def show(coin):
    """One tidy line for one coin."""
    name = coin["symbol"].ljust(12)
    change = coin["percent_change"].rjust(8)
    print("   ", name, change, "%   last:", coin["last"])


def main():
    """Sort every coin by today's move, then print both ends of the list."""
    coins = all_coins()
    print("Bitkub lists", len(coins), "coins against the baht")
    print()

    coins.sort(key=movement, reverse=True)

    print("up the most today:")
    for coin in coins[:5]:
        show(coin)

    print()
    print("down the most today:")
    for coin in coins[-5:]:
        show(coin)


if __name__ == "__main__":
    main()
LineWhat it does · ทำอะไร · 做什么
def movement(coin):A function that returns one number for one coin. That is all sort needs.ฟังก์ชันที่คืนตัวเลขหนึ่งตัวจากเหรียญหนึ่งตัว sort ต้องการแค่นี้一个函数,对一个币返回一个数字。sort 要的就是这个。
coins.sort(key=movement, reverse=True)Sort by that number. reverse=True puts the biggest first.เรียงตามตัวเลขนั้น reverse=True เอาตัวมากไว้ก่อน按那个数字排序。reverse=True 把最大的放前面。
coins[:5] ... coins[-5:]The first five and the last five. The top and the bottom of the day.ห้าตัวแรกและห้าตัวสุดท้าย คือหัวและท้ายของวัน前五个和后五个,就是这一天的顶端和底端。

🖥️ WHAT IT PRINTED · a real run

Bitkub lists 366 coins against the baht

up the most today:
    ZETA_THB        79.14 %   last: 2.25
    ASP_THB         38.86 %   last: 0.4513
    EDEN_THB        29.81 %   last: 2.013

down the most today:
    CELR_THB       -12.84 %   last: 0.11809
    ACX_THB        -21.4 %   last: 1.2004
A BIG MOVE IS NOT AN OPPORTUNITY The coin at the top of that list rose 79 percent today. That is not a reason to buy it. Small coins move like this in both directions. The ones that fall hardest often rose hardest yesterday. เหรียญที่อยู่บนสุดของรายการนั้นขึ้นมา 79 เปอร์เซ็นต์ในวันเดียว นั่นไม่ใช่เหตุผลที่จะซื้อมัน เหรียญเล็ก ๆ เคลื่อนแบบนี้ได้ทั้งสองทาง และตัวที่ร่วงแรงที่สุดมักเป็นตัวที่เมื่อวานขึ้นแรงที่สุด 那个列表顶上的币今天涨了 79%。这不是买它的理由。小币两个方向都会这样动,而跌得最狠的,往往就是昨天涨得最猛的那个。
YOUR TURN
  1. Run it. How many coins does Bitkub list today?
  2. Change reverse=True to False. What happens?
  3. Write a movement that sorts by last instead.
  4. Check: one request brought all of this. Count them.
  1. รันดู วันนี้ Bitkub มีกี่เหรียญ
  2. เปลี่ยน reverse=True เป็น False เกิดอะไรขึ้น
  3. เขียน movement ใหม่ให้เรียงตาม last แทน
  4. ตรวจ: ทั้งหมดนี้มาจากคำขอเดียว ลองนับดู
  1. 运行它。今天 Bitkub 列了多少个币?
  2. 把 reverse=True 改成 False,会怎样?
  3. 改写 movement,改成按 last 排序。
  4. 检查:这么多东西只用了一个请求。数一数。

📖 Tutorial 2 — Look inside the market · บทที่ 2 — มองเข้าไปในตลาด · 教程 2 —— 看进市场内部

🇬🇧 English

The price you see is the top of a queue. Underneath it are hundreds of orders at worse prices, waiting.

This matters the moment you spend real money. A big order eats down the queue, and pays more than the price on the screen.

🇹🇭 ไทย

ราคาที่คุณเห็นคือยอดบนสุดของคิว ใต้มันลงไปมีคำสั่งอีกหลายร้อยรายการ ที่ราคาแย่กว่า รออยู่

เรื่องนี้สำคัญทันทีที่คุณใช้เงินจริง คำสั่งใหญ่จะกินคิวลงไปเรื่อย ๆ และจบลงที่ราคาแพงกว่าที่เห็นบนจอ

🇨🇳 中文

你看到的价格只是队列最上面那一个。它下面还排着几百个价格更差的单子。

一旦动真钱,这件事马上就重要了。大单会把队列一层层吃下去,最后付的比屏幕上的价格贵。

4 The queue, and what your money really buys · คิวคำสั่ง และเงินของคุณซื้อได้จริงเท่าไร · 排队的单子,和你的钱真正买得到多少

🇬🇧 English

Bitkub will show you the queue. Sellers are called asks. Buyers are called bids.

Then the loop does something a chart will not do for you. It spends 500,000 baht down the queue, and works out what you really paid.

🇹🇭 ไทย

Bitkub ยอมให้คุณดูคิวนั้น ฝั่งคนขายเรียกว่า asks ฝั่งคนซื้อเรียกว่า bids

จากนั้นลูปจะทำสิ่งที่กราฟไม่ทำให้คุณ มันไล่ใช้เงิน 500,000 บาทลงไปตามคิว แล้วคำนวณว่าจริง ๆ แล้วคุณจ่ายไปเท่าไร

🇨🇳 中文

Bitkub 会把那个队列给你看。卖方叫 asks,买方叫 bids。

然后那个循环会做一件图表不会替你做的事:拿 50 万泰铢顺着队列往下花,再算出你实际付了多少。

📄 NEW FILE · book1.py

"""book1.py — the queue of real orders, and what your money would really buy."""
import json
import urllib.request

DEPTH = "https://api.bitkub.com/api/v3/market/depth"
SPEND = 500000.0      # baht


def order_book(symbol, rows=30):
    """The orders people are waiting with. asks are sellers, bids are buyers."""
    url = DEPTH + "?sym=" + symbol + "&lmt=" + str(rows)
    with urllib.request.urlopen(url, timeout=30) as response:
        return json.load(response)["result"]


def main():
    """Show both queues, then walk the sell queue with a pile of baht."""
    book = order_book("BTC_THB")

    print("people selling (asks) - you buy from these:")
    for price, amount in book["asks"][:5]:
        print("   ", price, "for", amount, "BTC")

    print()
    print("people buying (bids) - you sell to these:")
    for price, amount in book["bids"][:5]:
        print("   ", price, "for", amount, "BTC")

    # A market order eats this queue from the top down. The cheap sellers go
    # first, then you pay the dearer ones. That is why a big order costs more
    # than the price on the screen.
    spent = 0.0
    bought = 0.0
    for price, amount in book["asks"]:
        if spent >= SPEND:
            break
        cost = price * amount
        if spent + cost > SPEND:
            cost = SPEND - spent
        spent = spent + cost
        bought = bought + cost / price

    print()
    print("screen price   :", book["asks"][0][0])
    print("spending       :", SPEND, "baht")
    print("you would get  :", bought, "BTC")
    print("average paid   :", round(spent / bought, 2))
    print("worse than screen by:", round(spent / bought - book["asks"][0][0], 2), "baht")


if __name__ == "__main__":
    main()
LineWhat it does · ทำอะไร · 做什么
json.load(response)["result"]This endpoint wraps its answer. The ticker did not. Bitkub is not consistent.จุดนี้ห่อคำตอบไว้อีกชั้น ส่วน ticker ไม่ห่อ Bitkub ไม่ได้ทำเหมือนกันทุกที่这个接口把答案包了一层,ticker 没有。Bitkub 并不一致。
for price, amount in book["asks"]:Each row is a price and how much is on offer there. Unpack both at once.แต่ละแถวคือราคา กับจำนวนที่มีขายที่ราคานั้น แยกออกมาทั้งสองค่าพร้อมกัน每一行是一个价格,和那个价格上有多少量。一次把两个都拆出来。
if spent + cost > SPEND:Your money runs out inside a row. Take only the part you can afford.เงินคุณหมดกลางแถว ให้หยิบเฉพาะส่วนที่จ่ายไหว钱在某一行中间就花完了。只拿你付得起的那一部分。

🖥️ WHAT IT PRINTED · a real run

screen price   : 2829404.79
spending       : 500000.0 baht
you would get  : 0.17669767911645334 BTC
average paid   : 2829691.95
worse than screen by: 287.16 baht
THIS IS WHY A BIG ORDER COSTS MORE That order paid 287 baht more per coin than the screen said. That is small, because bitcoin is busy. Try the same code on a quiet coin. The number gets ugly. This effect has a name: slippage. เงินห้าแสนบาทจ่ายแพงกว่าราคาบนจอ 287 บาทต่อเหรียญ ถือว่าน้อย เพราะบิตคอยน์มีคนซื้อขายเยอะ ลองรันโค้ดเดิมกับเหรียญที่คนซื้อขายน้อย แล้วตัวเลขจะน่าเกลียดขึ้นมาก ปรากฏการณ์นี้มีชื่อว่า slippage 五十万泰铢,每个币比屏幕价多付了 287 泰铢。这算小的,因为比特币交易很热闹。把同样的代码用在冷门币上,数字就难看了。这个现象有个名字:滑点。
YOUR TURN
  1. Run it. Then change SPEND to 50 and run it again.
  2. Check: a small order pays almost the screen price.
  3. Change SPEND to 50000000. How bad does it get?
  4. Change the coin to a quiet one and compare.
  1. รันดู แล้วเปลี่ยน SPEND เป็น 50 แล้วรันใหม่
  2. ตรวจ: คำสั่งเล็ก ๆ จ่ายเกือบเท่าราคาบนจอ
  3. เปลี่ยน SPEND เป็น 50000000 แย่ขนาดไหน
  4. เปลี่ยนเป็นเหรียญที่คนซื้อขายน้อย แล้วเทียบกัน
  1. 运行它,再把 SPEND 改成 50 运行一次。
  2. 检查:小单几乎就是屏幕上的价格。
  3. 把 SPEND 改成 50000000,能差到什么地步?
  4. 换成一个冷门币,比较一下。

📈 Tutorial 3 — Keep a history · บทที่ 3 — เก็บประวัติราคา · 教程 3 —— 保存历史

🇬🇧 English

One price tells you almost nothing. A hundred prices tell you a story.

Nobody hands you that history, so you build it: a file, one line at a time. Then you draw it, and then you watch it.

🇹🇭 ไทย

ราคาเดียวบอกอะไรคุณแทบไม่ได้ แต่ร้อยราคาเล่าเรื่องได้

ไม่มีใครยกประวัตินั้นมาให้ คุณต้องสร้างเอง ไฟล์หนึ่งไฟล์ ทีละบรรทัด แล้วค่อยวาดมัน แล้วค่อยเฝ้ามัน

🇨🇳 中文

一个价格几乎什么都说明不了。一百个价格就能讲出一个故事。

没有人会把那段历史交给你,得你自己攒:一个文件,一次一行。然后把它画出来,再盯着它。

5 Write the price down · จดราคาเอาไว้ · 把价格记下来

🇬🇧 English

This is the file lesson again, with a real reason behind it. Every run adds one line and keeps the old ones.

Run it now, then run it again in an hour. The file remembers what your screen forgot.

🇹🇭 ไทย

นี่คือบทเรียนเรื่องไฟล์อีกครั้ง แต่คราวนี้มีเหตุผลจริง ๆ อยู่เบื้องหลัง ทุกครั้งที่รัน จะเพิ่มหนึ่งบรรทัดและเก็บของเก่าไว้

รันตอนนี้ แล้วรันอีกทีในอีกหนึ่งชั่วโมง ไฟล์จำสิ่งที่หน้าจอลืมไปแล้ว

🇨🇳 中文

这又是文件那一课,但这次背后有真正的理由。每跑一次就加一行,旧的都留着。

现在跑一次,一小时后再跑一次。屏幕忘掉的事,文件记得。

📄 NEW FILE · save1.py

"""save1.py — write the price down, so you can watch it move."""
import json
import time
import urllib.request
from pathlib import Path

BITKUB = "https://api.bitkub.com/api/v3/market/ticker"
HISTORY = Path("btc_history.txt")


def price_now():
    """The last traded price of one bitcoin, in baht."""
    with urllib.request.urlopen(BITKUB + "?sym=BTC_THB", timeout=30) as response:
        return float(json.load(response)[0]["last"])


def main():
    """Add one line to the history file: the time, and the price."""
    price = price_now()
    when = time.strftime("%Y-%m-%d %H:%M:%S")

    # "a" adds to the end, so every earlier line stays where it is.
    with open(HISTORY, "a", encoding="utf-8") as history:
        history.write(when + " " + str(price) + "\n")

    lines = HISTORY.read_text(encoding="utf-8").splitlines()
    print("wrote:", when, price)
    print(HISTORY.name, "now holds", len(lines), "lines")


if __name__ == "__main__":
    main()
LineWhat it does · ทำอะไร · 做什么
open(HISTORY, "a", encoding="utf-8")"a" adds to the end. "w" would erase everything first."a" คือเขียนต่อท้าย ส่วน "w" จะลบของเก่าทิ้งก่อน"a" 是追加到末尾。"w" 会先把旧的全删掉。
time.strftime("%Y-%m-%d %H:%M:%S")A price with no time on it is useless later. Write both.ราคาที่ไม่มีเวลากำกับ ภายหลังจะใช้อะไรไม่ได้ ให้เขียนทั้งสองอย่าง没有时间的价格,以后就没用了。两个都写下来。
YOUR TURN
  1. Run it six times, waiting a few seconds between each.
  2. Check: open btc_history.txt. Six lines.
  3. Leave it, and run it again tomorrow.
  4. If it breaks: a missing file is fine. "a" makes one.
  1. รันหกครั้ง เว้นช่วงแต่ละครั้งสักสองสามวินาที
  2. ตรวจ: เปิด btc_history.txt ต้องมีหกบรรทัด
  3. ทิ้งไว้ แล้วพรุ่งนี้กลับมารันอีก
  4. ถ้าพัง: ไม่มีไฟล์ก็ไม่เป็นไร "a" สร้างให้เอง
  1. 运行六次,每次之间等几秒。
  2. 检查:打开 btc_history.txt,六行。
  3. 先放着,明天再跑一次。
  4. 如果出错:文件不存在没关系,"a" 会建一个。

6 Draw it with characters · วาดมันด้วยตัวอักษร · 用字符把它画出来

🇬🇧 English

You do not need a chart library. You need to know where each price sits between the lowest and the highest.

That is one line of arithmetic, and then you print that many # characters.

🇹🇭 ไทย

คุณไม่ต้องใช้ไลบรารีกราฟ คุณแค่ต้องรู้ว่าราคาแต่ละตัวอยู่ตรงไหน ระหว่างค่าต่ำสุดกับค่าสูงสุด

นั่นคือเลขคณิตบรรทัดเดียว แล้วก็พิมพ์ # ออกมาเท่านั้นตัว

🇨🇳 中文

你不需要画图库。你需要知道的是:每个价格落在最低和最高之间的哪个位置。

那就是一行算术,然后打印出那么多个 #。

📄 NEW FILE · chart1.py

"""chart1.py — draw the saved history as a picture made of characters."""
from pathlib import Path

HISTORY = Path("btc_history.txt")
WIDTH = 40


def read_history():
    """Every saved line, as (when, price) pairs."""
    rows = []
    for line in HISTORY.read_text(encoding="utf-8").splitlines():
        parts = line.split()
        when = parts[1]              # parts[0] is the date, parts[1] the clock
        price = float(parts[2])
        rows.append((when, price))
    return rows


def main():
    """Scale every price between the lowest and the highest, then draw a bar."""
    rows = read_history()
    if len(rows) < 2:
        raise SystemExit("Run save1.py a few times first.")

    prices = []
    for when, price in rows:
        prices.append(price)

    low = min(prices)
    high = max(prices)
    print("low :", low)
    print("high:", high)
    print()

    for when, price in rows:
        if high == low:
            # Every price the same. One flat line is the honest picture.
            steps = 1
        else:
            steps = int((price - low) / (high - low) * WIDTH) + 1
        print(when, "|" + "#" * steps, price)


if __name__ == "__main__":
    main()
LineWhat it does · ทำอะไร · 做什么
(price - low) / (high - low)Turn any price into a number between 0 and 1. This trick has many uses.เปลี่ยนราคาใด ๆ ให้เป็นเลขระหว่าง 0 ถึง 1 เทคนิคนี้ใช้ได้อีกหลายที่把任意价格变成 0 到 1 之间的数。这个小技巧用处很多。
if high == low:All prices the same means dividing by zero. One flat line is the honest picture.ถ้าราคาทุกตัวเท่ากัน จะกลายเป็นหารด้วยศูนย์ เส้นตรงเส้นเดียวคือภาพที่ซื่อสัตย์所有价格一样就会除以零。一条平线才是诚实的画法。
"#" * stepsPython repeats a string when you multiply it. That is the whole chart.Python ทำซ้ำข้อความเมื่อคุณคูณมัน นั่นคือกราฟทั้งอัน字符串乘一个数就是重复。整张图就这么来的。

🖥️ WHAT IT PRINTED · from six real saved prices

low : 2826560.18
high: 2828945.91

19:42:22 |################# 2827532.12
19:42:27 |# 2826560.18
19:42:31 |######################################### 2828945.91
19:42:35 |######################################### 2828945.91
19:42:40 |################################## 2828535.18
19:42:44 |################################## 2828535.18
THE PICTURE LIES ABOUT THE SIZE That chart looks dramatic. Read the numbers. The whole height is 2,385 baht. The coin is worth 2.8 million. That is under one tenth of one percent. A chart with no zero can make anything look like a cliff. กราฟนั้นดูหวือหวา แต่ลองอ่านตัวเลข ความสูงทั้งหมดคือ 2,385 บาท บนเหรียญที่มีมูลค่า 2.8 ล้าน นั่นไม่ถึงหนึ่งในสิบของหนึ่งเปอร์เซ็นต์ กราฟที่ไม่มีศูนย์อยู่ในภาพ ทำให้อะไรก็ดูเหมือนหน้าผาได้ 那张图看着很惊险。读一下数字:整个高度是 2,385 泰铢,而这个币值 280 万。那还不到千分之一。一张不带零点的图,能把任何东西画成悬崖。
YOUR TURN
  1. Run it after save1.py has a few lines.
  2. Check: the lowest price gets the shortest bar.
  3. Change WIDTH to 10, then to 70.
  4. Work out the real percentage between your own low and high.
  1. รันหลังจาก save1.py มีหลายบรรทัดแล้ว
  2. ตรวจ: ราคาต่ำสุดต้องได้แท่งสั้นที่สุด
  3. เปลี่ยน WIDTH เป็น 10 แล้วเป็น 70
  4. ลองคำนวณเปอร์เซ็นต์จริงระหว่างค่าต่ำสุดกับสูงสุดของคุณเอง
  1. 等 save1.py 攒了几行以后再运行。
  2. 检查:最低的价格应该得到最短的条。
  3. 把 WIDTH 改成 10,再改成 70。
  4. 算一算你自己那组最低价和最高价之间的真实百分比。

7 Tell me when it crosses a line · บอกฉันเมื่อมันข้ามเส้น · 越线的时候告诉我

🇬🇧 English

Now the program waits for you. It looks, sleeps, and looks again.

This is the first step where you must be polite. An exchange serves thousands of people. Do not ask it a thousand times a second.

🇹🇭 ไทย

คราวนี้โปรแกรมรอให้คุณ มันดู แล้วหลับ แล้วดูใหม่

นี่คือขั้นแรกที่คุณต้องมีมารยาท กระดานเทรดให้บริการคนเป็นพัน อย่าถามมันวินาทีละพันครั้ง

🇨🇳 中文

现在轮到程序替你等。它看一眼,睡一会儿,再看一眼。

这是第一步你必须讲礼貌的地方。交易所要服务成千上万人,别一秒钟问它一千次。

📄 NEW FILE · watch1.py

"""watch1.py — keep looking, and say something when the price crosses a line."""
import json
import time
import urllib.request

BITKUB = "https://api.bitkub.com/api/v3/market/ticker"

HIGH_LINE = 2830000      # tell me if it goes above this
LOW_LINE = 2700000       # tell me if it falls below this
EVERY = 10               # seconds between looks
LOOKS = 3                # how many times to look


def price_now():
    """The last traded price of one bitcoin, in baht."""
    with urllib.request.urlopen(BITKUB + "?sym=BTC_THB", timeout=30) as response:
        return float(json.load(response)[0]["last"])


def main():
    """Look a few times, and report where the price sits each time."""
    for look in range(LOOKS):
        price = price_now()
        clock = time.strftime("%H:%M:%S")

        if price > HIGH_LINE:
            print(clock, price, "ABOVE your line")
        elif price < LOW_LINE:
            print(clock, price, "BELOW your line")
        else:
            print(clock, price, "between your lines")

        # Do not sleep after the last look. Nobody is waiting for it.
        if look < LOOKS - 1:
            time.sleep(EVERY)


if __name__ == "__main__":
    main()
LineWhat it does · ทำอะไร · 做什么
for look in range(LOOKS):Look a fixed number of times. A loop with no end is hard to stop.ดูตามจำนวนครั้งที่กำหนด ลูปที่ไม่มีวันจบนั้นหยุดยาก看固定的次数。没有尽头的循环很难停下来。
if look < LOOKS - 1:Do not sleep after the last look. Nobody is waiting for that sleep.ไม่ต้องหลับหลังการดูครั้งสุดท้าย ไม่มีใครรอการหลับนั้น最后一次看完就别睡了。没人在等那一觉。
time.sleep(EVERY)Wait between looks. This one line is what keeps you welcome.รอระหว่างการดูแต่ละครั้ง บรรทัดเดียวนี้ทำให้คุณยังเป็นที่ต้อนรับ两次之间等一等。就这一行,让你不被讨厌。

🖥️ WHAT IT PRINTED · a real run

19:43:36 2828535.18 between your lines
19:43:46 2827096.61 between your lines
19:43:56 2827096.61 between your lines
ASK GENTLY, OR GET LOCKED OUT Bitkub allows about 100 price requests a second. Go over and it blocks you for 30 seconds. Ten seconds between looks is plenty. A price that moves while you sleep is still there afterwards. Bitkub ยอมให้ขอราคาได้ราว 100 ครั้งต่อวินาที และจะบล็อกคุณ 30 วินาทีถ้าเกิน เว้นสิบวินาทีระหว่างการดูแต่ละครั้งก็เหลือเฟือแล้ว ราคาที่ขยับตอนคุณหลับ ตื่นมาก็ยังอยู่ตรงนั้น Bitkub 大约允许每秒 100 次价格请求,超了就封你 30 秒。两次之间隔十秒绰绰有余。你睡觉时动过的价格,醒来还在那儿。
YOUR TURN
  1. Set HIGH_LINE just below today's price. Run it.
  2. Check: it says ABOVE every time.
  3. Set LOOKS to 30 and leave it running while you make tea.
  4. Make it write to btc_history.txt as well as printing.
  1. ตั้ง HIGH_LINE ให้ต่ำกว่าราคาวันนี้นิดหนึ่ง แล้วรัน
  2. ตรวจ: ต้องขึ้นว่า ABOVE ทุกครั้ง
  3. ตั้ง LOOKS เป็น 30 แล้วปล่อยให้รันระหว่างไปชงชา
  4. ทำให้มันเขียนลง btc_history.txt ด้วย ไม่ใช่แค่พิมพ์ออกจอ
  1. 把 HIGH_LINE 设成略低于今天的价格,运行。
  2. 检查:每次都应该说 ABOVE。
  3. 把 LOOKS 设成 30,去泡茶,让它自己跑。
  4. 让它除了打印之外,也写进 btc_history.txt。

🔑 Tutorial 4 — Trade for real · บทที่ 4 — เทรดจริง · 教程 4 —— 真实交易

🇬🇧 English

Everything so far was reading. From here the code can spend your money.

An exchange will not take orders from a stranger. So each request carries your key, and a signature that proves you hold the secret.

The signature is the whole of this tutorial. Get it right and every other call is easy. Get it wrong and Bitkub says error 6, over and over.

🇹🇭 ไทย

ที่ผ่านมาทั้งหมดคือการอ่าน จากตรงนี้ไปโค้ดใช้เงินของคุณได้แล้ว

กระดานเทรดไม่รับคำสั่งจากคนแปลกหน้า ทุกคำขอจึงต้องพกคีย์ของคุณไปด้วย พร้อมลายเซ็นที่พิสูจน์ว่าคุณถือความลับนั้นอยู่จริง

ลายเซ็นคือหัวใจของบททั้งบท ทำให้ถูกแล้วที่เหลือง่ายหมด ทำผิดแล้ว Bitkub จะตอบ error 6 ซ้ำแล้วซ้ำเล่า

🇨🇳 中文

到目前为止都只是在读。从这里开始,代码可以花你的钱了。

交易所不接受陌生人的指令。所以每个请求都要带上你的密钥,还有一个能证明你握着那串秘密的签名。

签名就是这一整个教程的核心。签对了,其余的调用都很简单;签错了,Bitkub 就一遍遍地回你 error 6。

MAKE A KEY THAT CANNOT EMPTY YOUR ACCOUNT Make the key at bitkub.com, under API Management. The permissions are the real protection. Steps 8 and 9 need no permission at all. Steps 10 and 11 need Trade. Never tick Withdraw. A key without Withdraw cannot move your coins off the exchange. Even a thief cannot. สร้างคีย์ได้ที่ bitkub.com ในหน้า API Management สิทธิ์ของคีย์คือเกราะป้องกันจริง ๆ ขั้น 8 และ 9 ไม่ต้องใช้สิทธิ์อะไรเลย ขั้น 10 และ 11 ต้องมีสิทธิ์ Trade อย่าติ๊ก Withdraw เด็ดขาด คีย์ที่ไม่มีสิทธิ์ถอน ย้ายเหรียญออกจากกระดานไม่ได้ ต่อให้มีคนขโมยมันไป 在 bitkub.com 的 API Management 里创建密钥。权限才是真正的保护。第 8、9 步完全不需要任何权限;第 10、11 步需要 Trade。永远不要勾 Withdraw。没有提现权限的密钥,就算被人偷走,也没法把币转出交易所。

⌨️ TERMINAL · Windows — put the key in the terminal, never in the file

set BITKUB_KEY=your-key-here
set BITKUB_SECRET=your-secret-here

⌨️ TERMINAL · Mac and Linux

export BITKUB_KEY=your-key-here
export BITKUB_SECRET=your-secret-here

8 Prove your key works · พิสูจน์ว่าคีย์ใช้ได้ · 先证明你的密钥能用

🇬🇧 English

Do not start with an order. Start with a question that changes nothing, and see whether Bitkub believes you.

The signed string is four things glued together with nothing between them: the time, the word GET, the path, then the query.

🇹🇭 ไทย

อย่าเริ่มด้วยคำสั่งซื้อ ให้เริ่มด้วยคำถามที่ไม่เปลี่ยนอะไรเลย แล้วดูว่า Bitkub เชื่อคุณหรือเปล่า

ข้อความที่เซ็นคือสี่อย่างต่อกันโดยไม่มีอะไรคั่น เวลา คำว่า GET เส้นทาง แล้วก็ query

🇨🇳 中文

别一上来就下单。先问一个什么都不会改变的问题,看看 Bitkub 信不信你。

被签名的那串文字是四样东西直接拼起来的,中间什么都没有:时间、GET 这个词、路径,然后是 query。

📄 NEW FILE · sign1.py

"""sign1.py — prove your key works, before any money is involved."""
import hashlib
import hmac
import json
import os
import urllib.error
import urllib.request

HOST = "https://api.bitkub.com"
KEY = os.environ["BITKUB_KEY"]
SECRET = os.environ["BITKUB_SECRET"]

# What Bitkub says when it will not believe you.
PROBLEMS = {
    2: "the API key header is missing",
    3: "the key itself is wrong",
    4: "the key is not switched on yet",
    5: "this computer's address is not on your allowed list",
    6: "the signature is wrong - check the signed string",
    7: "the timestamp header is missing",
    8: "the timestamp is wrong - use server_time(), not your own clock",
    52: "the key is real, but has no permission for this",
}


def server_time():
    """Bitkub's own clock, in milliseconds. Sign with this, not your laptop's."""
    with urllib.request.urlopen(HOST + "/api/v3/servertime", timeout=30) as response:
        return response.read().decode()


def signed_get(path, query=""):
    """A GET that Bitkub will believe came from you."""
    stamp = server_time()

    # This exact string is what gets signed: time, then METHOD, then the path,
    # then the query. No spaces, no commas. One wrong character gives error 6.
    message = stamp + "GET" + path + query
    signature = hmac.new(SECRET.encode(), message.encode(), hashlib.sha256).hexdigest()

    request = urllib.request.Request(HOST + path + query, headers={
        "Accept": "application/json",
        "Content-Type": "application/json",
        "X-BTK-APIKEY": KEY,
        "X-BTK-TIMESTAMP": stamp,
        "X-BTK-SIGN": signature,
    })
    try:
        with urllib.request.urlopen(request, timeout=30) as response:
            return json.load(response)
    except urllib.error.HTTPError as refused:
        # A key Bitkub will not accept comes back as 401, and urllib calls that
        # an error and stops. The reason is in the body, so read the body.
        return json.loads(refused.read())


def main():
    """Ask for your open orders. It changes nothing, and it proves the key."""
    answer = signed_get("/api/v3/market/my-open-orders", "?sym=btc_thb")
    code = answer.get("error", 0)

    if code == 0:
        print("your key works.")
        print("open orders right now:", len(answer["result"]))
        return

    print("Bitkub said no. error", code)
    print(PROBLEMS.get(code, "look this number up in the error table"))


if __name__ == "__main__":
    main()
LineWhat it does · ทำอะไร · 做什么
KEY = os.environ["BITKUB_KEY"]The key lives in the terminal, not in the file. Files get shared by accident.คีย์อยู่ในเทอร์มินัล ไม่ใช่ในไฟล์ ไฟล์มักถูกแชร์ออกไปโดยไม่ตั้งใจ密钥放在终端里,不放在文件里。文件常常一不小心就发出去了。
def server_time():Bitkub's clock, not yours. A laptop clock that drifts gives error 8.ใช้นาฬิกาของ Bitkub ไม่ใช่ของคุณ นาฬิกาแล็ปท็อปที่เพี้ยนจะได้ error 8用 Bitkub 的时钟,不用你的。笔记本时钟走偏了就会得到 error 8。
message = stamp + "GET" + path + queryThe exact signed string. No spaces and no commas. One wrong character is error 6.ข้อความที่เซ็นเป๊ะ ๆ ไม่มีเว้นวรรค ไม่มีจุลภาค ผิดตัวเดียวคือ error 6被签名的那串文字,一字不差。没有空格没有逗号。错一个字符就是 error 6。
hmac.new(SECRET.encode(), ...).hexdigest()Your secret signs the string. Bitkub signs it too, and compares.ความลับของคุณเซ็นข้อความนั้น Bitkub ก็เซ็นเหมือนกัน แล้วเอามาเทียบ你的 secret 给这串文字签名。Bitkub 也签一次,然后比对。
except urllib.error.HTTPError as refused:A rejected key comes back as 401, which urllib calls a crash. Read the body instead.คีย์ที่ถูกปฏิเสธกลับมาเป็น 401 ซึ่ง urllib ถือว่าพัง ให้อ่านเนื้อหาในนั้นแทน被拒绝的密钥会返回 401,urllib 把它当成崩溃。改成去读它的正文。

🖥️ WHAT IT PRINTED · a real run, with a key we made up

Bitkub said no. error 3
the key itself is wrong
THE ERROR NUMBER TELLS YOU WHICH THING IS WRONG This matters more than it sounds. 3 means the key is wrong. 6 means the key is fine and the signature is wrong. 8 means your clock is wrong. 52 means the key is real but you never ticked the permission. Four different problems, four different fixes. People waste hours guessing between them. เรื่องนี้สำคัญกว่าที่ฟัง 3 แปลว่าคีย์ผิด 6 แปลว่าคีย์ถูกแต่ลายเซ็นผิด 8 แปลว่านาฬิกาคุณผิด 52 แปลว่าคีย์จริงแต่คุณไม่ได้ติ๊กสิทธิ์ไว้ สี่ปัญหาคนละเรื่อง สี่วิธีแก้คนละอย่าง และคนเสียเวลาเป็นชั่วโมงเพราะเดาสลับกัน 这件事比听起来重要。3 是密钥错;6 是密钥没问题、签名错;8 是你的时钟错;52 是密钥真的、但你没勾那个权限。四个完全不同的问题,四种不同的修法,很多人在它们之间瞎猜浪费掉好几个小时。
YOUR TURN
  1. Set your key in the terminal, then run it.
  2. Check: your key works.
  3. Change one character of your secret. Run it. You should see error 6.
  4. Put the secret back. Change the key instead. Now you should see error 3.
  1. ตั้งคีย์ในเทอร์มินัล แล้วรัน
  2. ตรวจ: ต้องขึ้นว่า your key works.
  3. แก้ความลับไปหนึ่งตัวอักษร แล้วรัน ควรได้ error 6
  4. ใส่ความลับกลับคืน แล้วไปแก้คีย์แทน คราวนี้ควรได้ error 3
  1. 在终端里设好密钥,然后运行。
  2. 检查:显示 your key works.
  3. 把 secret 改掉一个字符再运行,应该看到 error 6。
  4. 把 secret 改回去,改动 key,这次应该看到 error 3。

9 What you actually hold · คุณถืออะไรอยู่จริง ๆ · 你手上到底有什么

🇬🇧 English

Before you trade, look. This asks Bitkub what is in your account.

And it catches you out: the wallet moved to version 4, and version 4 answers in a different shape from everything else on this page.

🇹🇭 ไทย

ก่อนจะเทรด ให้ดูก่อน อันนี้ถาม Bitkub ว่าในบัญชีคุณมีอะไรบ้าง

และมันจะหลอกคุณด้วย กระเป๋าเงินย้ายไปเวอร์ชัน 4 แล้ว และเวอร์ชัน 4 ตอบมาคนละรูปแบบกับทุกอย่างในหน้านี้

🇨🇳 中文

交易之前先看一眼。这一段问 Bitkub:你账户里有什么。

而且它会绊你一下:钱包搬到了第 4 版,而第 4 版的回答格式跟这一页其他所有东西都不一样。

📄 NEW FILE · wallet1.py

"""wallet1.py — what is actually in your Bitkub account."""
import hashlib
import hmac
import json
import os
import urllib.error
import urllib.request

HOST = "https://api.bitkub.com"
KEY = os.environ["BITKUB_KEY"]
SECRET = os.environ["BITKUB_SECRET"]


def server_time():
    """Bitkub's own clock, in milliseconds. Sign with this, not your laptop's."""
    with urllib.request.urlopen(HOST + "/api/v3/servertime", timeout=30) as response:
        return response.read().decode()


def signed_get(path, query=""):
    """A GET that Bitkub will believe came from you."""
    stamp = server_time()
    message = stamp + "GET" + path + query
    signature = hmac.new(SECRET.encode(), message.encode(), hashlib.sha256).hexdigest()

    request = urllib.request.Request(HOST + path + query, headers={
        "Accept": "application/json",
        "Content-Type": "application/json",
        "X-BTK-APIKEY": KEY,
        "X-BTK-TIMESTAMP": stamp,
        "X-BTK-SIGN": signature,
    })
    try:
        with urllib.request.urlopen(request, timeout=30) as response:
            return json.load(response)
    except urllib.error.HTTPError as refused:
        return json.loads(refused.read())


def main():
    """Print every coin you hold, and the baht sitting beside them."""
    answer = signed_get("/api/v4/wallet/balances")

    # Careful: the wallet is version 4, and version 4 answers differently.
    # v3 says {"error": 0, "result": ...}. v4 says {"code": "0", "data": ...}.
    if answer.get("code") != "0":
        print("Bitkub said no:", answer.get("code"), "-", answer.get("message"))
        return

    print("currency   available        reserved")
    for row in answer["data"]:
        if float(row["total"]) == 0:
            continue                      # skip the coins you do not hold
        print(row["currency"].ljust(10), row["available"].ljust(16), row["reserved"])


if __name__ == "__main__":
    main()
LineWhat it does · ทำอะไร · 做什么
signed_get("/api/v4/wallet/balances")Version 4, not 3. The version 3 wallet address was removed.เวอร์ชัน 4 ไม่ใช่ 3 ที่อยู่กระเป๋าเงินของเวอร์ชัน 3 ถูกถอดออกไปแล้ว是第 4 版,不是第 3 版。第 3 版的钱包地址已经被拿掉了。
answer.get("code") != "0"v3 says error and result. v4 says code and data. Same exchange.v3 ใช้ error กับ result ส่วน v4 ใช้ code กับ data กระดานเดียวกันแท้ ๆv3 用 error 和 result,v4 用 code 和 data。同一个交易所。
if float(row["total"]) == 0:Bitkub lists every coin it supports. Skip the ones you do not hold.Bitkub ลิสต์ทุกเหรียญที่รองรับ ให้ข้ามตัวที่คุณไม่ได้ถือBitkub 会列出它支持的每一个币。你没有的就跳过。

🖥️ WHAT IT PRINTED · a real run, with a key we made up

Bitkub said no: A1003-MK - Invalid API key
VERSION 4 SPEAKS A DIFFERENT LANGUAGE With a bad key, version 3 answers 3 and version 4 answers A1003-MK. Do not write code that only understands one of them. Print whatever came back first. Then read fields out of it. เมื่อคีย์ผิด เวอร์ชัน 3 ตอบ 3 ส่วนเวอร์ชัน 4 ตอบ A1003-MK อย่าเขียนโค้ดที่เข้าใจแค่แบบเดียว ให้พิมพ์สิ่งที่ได้กลับมาดูก่อน แล้วค่อยไปหยิบค่าข้างใน 密钥错的时候,第 3 版回 3,第 4 版回 A1003-MK。别写只认得其中一种的代码。先把收到的东西打印出来,再去取里面的字段。
YOUR TURN
  1. Run it with a working key.
  2. Check: your THB line matches the Bitkub website.
  3. Take out the continue line. How many coins appear?
  4. Print answer on its own line and read the whole shape.
  1. รันด้วยคีย์ที่ใช้งานได้
  2. ตรวจ: บรรทัด THB ต้องตรงกับที่เห็นบนเว็บ Bitkub
  3. ลบบรรทัด continue ออก มีกี่เหรียญโผล่มา
  4. พิมพ์ answer ออกมาทั้งก้อน แล้วอ่านรูปร่างของมัน
  1. 用一个能用的密钥运行。
  2. 检查:THB 那一行要和 Bitkub 网站上一致。
  3. 把 continue 那行删掉,会冒出多少个币?
  4. 单独打印 answer,把整个结构读一遍。

10 A real order that cannot fill · คำสั่งจริงที่ไม่มีทางถูกจับคู่ · 一个成交不了的真实订单

🇬🇧 English

This is a real order on a real exchange with real money. And it is safe, because of one choice.

You offer half the going price. Nobody sells bitcoin at half price, so the order joins the queue and waits. You can look at it, then take it back. The whole trading cycle, with nothing bought.

Change FRACTION to 1.0 and the same code buys immediately. That is the only difference.

🇹🇭 ไทย

นี่คือคำสั่งจริงบนกระดานจริงด้วยเงินจริง และมันปลอดภัยเพราะการเลือกอย่างเดียว

คุณเสนอราคาครึ่งเดียวของราคาตลาด ไม่มีใครขายบิตคอยน์ครึ่งราคา คำสั่งจึงไปต่อคิวแล้วรออยู่ตรงนั้น คุณดูมันได้ แล้วดึงกลับได้ ครบวงจรการเทรด โดยไม่ได้ซื้ออะไรเลย

เปลี่ยน FRACTION เป็น 1.0 โค้ดเดิมนี้จะซื้อทันที นั่นคือความต่างทั้งหมด

🇨🇳 中文

这是真实交易所上、用真钱下的真实订单。而它是安全的,靠的是一个选择。

你出市价的一半。没有人会半价卖比特币,所以这单会排进队列里等着。你可以看着它,然后把它撤回来。整个交易流程走完,什么都没买。

把 FRACTION 改成 1.0,同样的代码就会立刻买入。差别只有这个。

📄 NEW FILE · buy1.py

"""buy1.py — place a real order, priced so low that it cannot fill."""
import hashlib
import hmac
import json
import os
import urllib.error
import urllib.request

HOST = "https://api.bitkub.com"
KEY = os.environ["BITKUB_KEY"]
SECRET = os.environ["BITKUB_SECRET"]

COIN = "btc_thb"
SPEND = 20.0        # baht to offer. Bitkub's smallest order is 10 baht.
FRACTION = 0.5      # offer half the going rate, so nobody takes it


def server_time():
    """Bitkub's own clock, in milliseconds. Sign with this, not your laptop's."""
    with urllib.request.urlopen(HOST + "/api/v3/servertime", timeout=30) as response:
        return response.read().decode()


def price_now():
    """The last traded price of one bitcoin, in baht."""
    url = HOST + "/api/v3/market/ticker?sym=BTC_THB"
    with urllib.request.urlopen(url, timeout=30) as response:
        return float(json.load(response)[0]["last"])


def signed_post(path, body):
    """A POST that Bitkub will believe came from you."""
    stamp = server_time()

    # The text you sign and the text you send must be the same characters.
    # Build the JSON once, sign that, send that. Building it twice gives error 6.
    payload = json.dumps(body)
    message = stamp + "POST" + path + payload
    signature = hmac.new(SECRET.encode(), message.encode(), hashlib.sha256).hexdigest()

    request = urllib.request.Request(HOST + path, data=payload.encode(), headers={
        "Accept": "application/json",
        "Content-Type": "application/json",
        "X-BTK-APIKEY": KEY,
        "X-BTK-TIMESTAMP": stamp,
        "X-BTK-SIGN": signature,
    })
    try:
        with urllib.request.urlopen(request, timeout=30) as response:
            return json.load(response)
    except urllib.error.HTTPError as refused:
        return json.loads(refused.read())


def main():
    """Show the offer, ask a person, then send it."""
    price = price_now()
    offer = round(price * FRACTION, 2)

    print("bitcoin is trading at:", price, "baht")
    print("you are offering     :", offer, "baht for one bitcoin")
    print("you are spending     :", SPEND, "baht")
    print()
    print("Nobody sells at half price, so this order will sit and wait.")
    print("That is the point. You can look at it, then take it back.")
    print()

    # A person reads this before any real money is committed.
    if input("place this real order? type yes: ").strip().lower() != "yes":
        raise SystemExit("Nothing was placed.")

    answer = signed_post("/api/v3/market/place-bid", {
        "sym": COIN,
        "amt": SPEND,
        "rat": offer,
        "typ": "limit",
    })

    if answer.get("error", 0) != 0:
        print("Bitkub said no. error", answer["error"])
        return

    order = answer["result"]
    print("order placed. its id is", order["id"])
    print("your", SPEND, "baht is held, not spent. Run orders1.py to take it back.")


if __name__ == "__main__":
    main()
LineWhat it does · ทำอะไร · 做什么
payload = json.dumps(body)Build the JSON once. Sign that text, and send that same text.สร้าง JSON ครั้งเดียว เซ็นข้อความนั้น แล้วส่งข้อความเดียวกันนั้นไปJSON 只生成一次。签的是那串文字,发的也是同一串。
message = stamp + "POST" + path + payloadFor a POST the body is signed, not a query. Everything else is the same.สำหรับ POST สิ่งที่เซ็นคือ body ไม่ใช่ query ที่เหลือเหมือนเดิมทุกอย่างPOST 签的是 body,不是 query。其他都一样。
"rat": offer, "typ": "limit",limit means: at this price or better, never worse.limit แปลว่า ที่ราคานี้หรือดีกว่า ห้ามแย่กว่านี้limit 的意思是:这个价或更好,绝不更差。
input("place this real order? type yes: ")A person reads it before money moves. This line is the whole safety net.ให้คนอ่านก่อนเงินจะขยับ บรรทัดนี้คือตาข่ายนิรภัยทั้งหมด钱动之前,让一个人看一眼。这一行就是全部的安全网。

🖥️ WHAT IT PRINTED · a real run, with a key we made up

bitcoin is trading at: 2830000.0 baht
you are offering     : 1415000.0 baht for one bitcoin
you are spending     : 20.0 baht

Nobody sells at half price, so this order will sit and wait.
That is the point. You can look at it, then take it back.

place this real order? type yes: Bitkub said no. error 3
THE SIGNED TEXT AND THE SENT TEXT MUST MATCH EXACTLY This is the mistake everybody makes. Build the JSON twice and Python may space it differently. Then the signature describes text you did not send. Bitkub says error 6. You stare at a signature that looks correct. Build it once into a variable, as this file does. นี่คือความผิดพลาดที่ทุกคนเจอ ถ้าคุณสร้าง JSON สองครั้ง Python อาจเว้นวรรคไม่เหมือนเดิมในครั้งที่สอง ลายเซ็นก็จะอธิบายข้อความที่คุณไม่ได้ส่ง Bitkub จะตอบ error 6 แล้วคุณก็จะนั่งจ้องลายเซ็นที่ดูถูกต้องทุกประการ ให้สร้างครั้งเดียวเก็บใส่ตัวแปร เหมือนที่ไฟล์นี้ทำ 这是每个人都会犯的错。如果你把 JSON 生成两次,Python 第二次的空格可能不一样,于是签名描述的是你没发出去的文字。Bitkub 回 error 6,而你盯着一个看起来完全正确的签名发呆。像这个文件一样,生成一次存进变量里。
START AT TWENTY BAHT, NOT TWENTY THOUSAND Bitkub's smallest order is 10 baht, and this file offers 20. Keep it there until the whole cycle works: place, list, cancel. A misplaced decimal point is the most expensive mistake here. คำสั่งเล็กที่สุดของ Bitkub คือ 10 บาท ไฟล์นี้เสนอไป 20 บาท ให้คงไว้เท่านี้จนกว่าจะครบวงจร วาง ดู ยกเลิก จุดทศนิยมผิดที่คือการพิมพ์ผิดที่แพงที่สุดในบทเรียนนี้ Bitkub 的最小订单是 10 泰铢,这个文件出 20。在整个流程跑通之前就保持这个数:下单、查看、撤单。小数点点错地方,是这份教程里最贵的一个打字错误。
YOUR TURN
  1. Read every line of the file first. Then run it.
  2. Type anything except yes. Check that nothing happens.
  3. Run it again and type yes.
  4. Check: an order id comes back. Go to step 11 now.
  1. อ่านไฟล์ให้ครบทุกบรรทัดก่อน แล้วค่อยรัน
  2. พิมพ์อะไรก็ได้ที่ไม่ใช่ yes แล้วตรวจว่าไม่มีอะไรเกิดขึ้น
  3. รันอีกครั้ง แล้วพิมพ์ yes
  4. ตรวจ: ต้องได้เลขที่คำสั่งกลับมา แล้วไปขั้น 11 ทันที
  1. 先把文件每一行都读一遍,再运行。
  2. 输入 yes 以外的任何东西,确认什么都没发生。
  3. 再运行一次,这次输入 yes。
  4. 检查:会返回一个订单号。马上去做第 11 步。

11 See it, then take it back · ดูมัน แล้วดึงมันกลับ · 看着它,然后撤回来

🇬🇧 English

Your 20 baht is held, not spent. This file shows the order, and gives it back.

One function now handles GET and POST, because the signed string is the same shape either way. Only the last part changes.

🇹🇭 ไทย

เงิน 20 บาทของคุณถูกกันไว้ ไม่ได้ถูกใช้ ไฟล์นี้จะแสดงคำสั่งนั้น แล้วคืนเงินให้

คราวนี้ฟังก์ชันเดียวรับได้ทั้ง GET และ POST เพราะข้อความที่เซ็นมีรูปร่างเดียวกัน ต่างกันแค่ส่วนท้าย

🇨🇳 中文

你那 20 泰铢是被冻住,不是花掉了。这个文件把订单显示出来,再还给你。

现在一个函数同时处理 GET 和 POST,因为被签名的文字形状是一样的,只有最后一段不同。

📄 NEW FILE · orders1.py

"""orders1.py — see your waiting orders, and take them back."""
import hashlib
import hmac
import json
import os
import urllib.error
import urllib.request

HOST = "https://api.bitkub.com"
KEY = os.environ["BITKUB_KEY"]
SECRET = os.environ["BITKUB_SECRET"]

COIN = "btc_thb"


def server_time():
    """Bitkub's own clock, in milliseconds. Sign with this, not your laptop's."""
    with urllib.request.urlopen(HOST + "/api/v3/servertime", timeout=30) as response:
        return response.read().decode()


def sign(stamp, method, path, tail):
    """The one string Bitkub signs: time, method, path, then query or body."""
    message = stamp + method + path + tail
    return hmac.new(SECRET.encode(), message.encode(), hashlib.sha256).hexdigest()


def ask(method, path, query="", body=None):
    """One signed request. GET carries a query, POST carries a body."""
    stamp = server_time()
    payload = json.dumps(body) if body else ""
    signature = sign(stamp, method, path, payload if body else query)

    request = urllib.request.Request(
        HOST + path + query,
        data=payload.encode() if body else None,
        headers={
            "Accept": "application/json",
            "Content-Type": "application/json",
            "X-BTK-APIKEY": KEY,
            "X-BTK-TIMESTAMP": stamp,
            "X-BTK-SIGN": signature,
        })
    try:
        with urllib.request.urlopen(request, timeout=30) as response:
            return json.load(response)
    except urllib.error.HTTPError as refused:
        return json.loads(refused.read())


def main():
    """List what is waiting, then offer to cancel all of it."""
    answer = ask("GET", "/api/v3/market/my-open-orders", query="?sym=" + COIN)
    if answer.get("error", 0) != 0:
        print("Bitkub said no. error", answer["error"])
        return

    orders = answer["result"]
    if not orders:
        print("Nothing is waiting.")
        return

    print(len(orders), "order(s) waiting:")
    for order in orders:
        print("   id:", order["id"], "| side:", order["side"],
              "| rate:", order["rate"], "| amount:", order["amount"])

    print()
    if input("cancel all of them? type yes: ").strip().lower() != "yes":
        raise SystemExit("Left alone.")

    for order in orders:
        answer = ask("POST", "/api/v3/market/cancel-order", body={
            "sym": COIN,
            "id": str(order["id"]),
            "sd": order["side"].lower(),
        })
        if answer.get("error", 0) == 0:
            print("cancelled", order["id"])
        else:
            print("could not cancel", order["id"], "- error", answer["error"])


if __name__ == "__main__":
    main()
LineWhat it does · ทำอะไร · 做什么
def ask(method, path, query="", body=None):One function for both. GET signs the query, POST signs the body.ฟังก์ชันเดียวใช้ได้ทั้งคู่ GET เซ็น query ส่วน POST เซ็น body一个函数两用。GET 签 query,POST 签 body。
"sd": order["side"].lower()Cancelling needs the side. Bitkub wants it in small letters.การยกเลิกต้องบอกฝั่งด้วย และ Bitkub ต้องการเป็นตัวพิมพ์เล็ก撤单要说明方向,而 Bitkub 要小写。
if answer.get("error", 0) == 0:Check every cancel. A cancel can fail, and silence would hide it.ตรวจทุกการยกเลิก การยกเลิกล้มเหลวได้ และความเงียบจะปิดบังมันไว้每次撤单都要检查。撤单是会失败的,沉默会把它藏起来。
MONEY IN AN ORDER IS NOT MONEY YOU CAN SPEND While that order waits, the 20 baht is reserved. It shows in reserved, not in available, when you run step 9. Forgotten orders are how people find their balance mysteriously short. ระหว่างที่คำสั่งนั้นรออยู่ เงิน 20 บาทถูกกันไว้ มันจะโผล่ในช่อง reserved ไม่ใช่ available ตอนคุณรันขั้น 9 คำสั่งที่ลืมทิ้งไว้ คือสาเหตุที่คนงงว่าเงินหายไปไหน 那张单等着的时候,20 泰铢是被预留的。你跑第 9 步时,它出现在 reserved 里,不在 available 里。被遗忘的挂单,就是很多人发现余额莫名其妙少了的原因。
YOUR TURN
  1. Run it. Your waiting order should be listed.
  2. Type yes and watch it cancel.
  3. Check: run step 9 again. The 20 baht is back in available.
  4. Run this file again. It should say nothing is waiting.
  1. รันดู คำสั่งที่รออยู่ควรขึ้นมาในรายการ
  2. พิมพ์ yes แล้วดูมันถูกยกเลิก
  3. ตรวจ: รันขั้น 9 อีกครั้ง เงิน 20 บาทต้องกลับมาอยู่ใน available
  4. รันไฟล์นี้อีกครั้ง ควรขึ้นว่าไม่มีอะไรรออยู่
  1. 运行它,你那张挂着的单应该被列出来。
  2. 输入 yes,看着它被撤销。
  3. 检查:再跑一次第 9 步,20 泰铢回到 available 里了。
  4. 再运行这个文件一次,应该显示没有东西在等。

🛒 Tutorial 5 — Take payments with no server · บทที่ 5 — รับเงินโดยไม่ต้องมีเซิร์ฟเวอร์ · 教程 5 —— 没有服务器也能收款

🇬🇧 English

Every other way of taking money needs a server. A card payment carries a secret key. A secret cannot live in a web page.

Bitcoin is different. The blockchain is public, so anyone may read it. Your page can watch for its own payment.

So this is the one payment you can take from GitHub Pages. No server, no backend, no key. One file you upload.

🇹🇭 ไทย

วิธีรับเงินแบบอื่นทุกวิธีต้องมีเซิร์ฟเวอร์ การจ่ายด้วยบัตรต้องพกคีย์ลับไปด้วย และของลับอยู่ในหน้าเว็บไม่ได้

บิตคอยน์ต่างออกไป บล็อกเชนเป็นของสาธารณะ ใครก็อ่านได้ หน้าเว็บของคุณจึงเฝ้าดูเงินที่จ่ายเข้ามาเองได้

นี่จึงเป็นการรับเงินแบบเดียวที่ทำบน GitHub Pages ได้ ไม่ต้องมีเซิร์ฟเวอร์ ไม่ต้องมีหลังบ้าน ไม่ต้องมีคีย์ มีแค่ไฟล์เดียวที่คุณอัปโหลด

🇨🇳 中文

其他每一种收款方式都需要服务器。刷卡要带着一把密钥,而秘密是没法放在网页里的。

比特币不一样。区块链是公开的,谁都可以读。你的页面可以自己盯着有没有人付款。

所以这是唯一一种能在 GitHub Pages 上收的钱。没有服务器,没有后端,没有密钥,只有你上传的一个文件。

⌨️ TERMINAL · one library, only for drawing the QR

python -m pip install qrcode

12 Price it in baht, charge it in bitcoin · ตั้งราคาเป็นบาท เก็บเงินเป็นบิตคอยน์ · 用泰铢定价,用比特币收款

🇬🇧 English

Your price is in baht. Your customer pays in bitcoin. So you need today's rate, and step 1 already fetched it.

Then one small trick. Add a few random satoshis. Then no two orders ask for the same amount, and you can tell payments apart.

🇹🇭 ไทย

ราคาของคุณเป็นบาท ลูกค้าจ่ายเป็นบิตคอยน์ คุณจึงต้องรู้อัตราวันนี้ ซึ่งขั้นที่ 1 ดึงมาให้แล้ว

แล้วก็มีทริกเล็ก ๆ อีกอย่าง เติมซาโตชิสุ่มเข้าไปไม่กี่หน่วย แล้วจะไม่มีออเดอร์ไหนขอยอดเท่ากันเลย คุณจึงแยกการจ่ายเงินออกจากกันได้

🇨🇳 中文

你的价格是泰铢,客人付的是比特币,所以你需要今天的汇率——第 1 步已经拿过了。

然后有个小技巧:加上几个随机的聪。这样就没有两笔订单金额相同,你也就分得清谁付的钱。

📄 NEW FILE · shop1.py

"""shop1.py — turn a price in baht into a bitcoin payment request."""
import json
import random
import urllib.parse
import urllib.request

BITKUB = "https://api.bitkub.com/api/v3/market/ticker"

# Put your own receiving address here. This one is famous and not yours.
ADDRESS = "1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa"
PRICE_THB = 250.0
ITEM = "One bag of Doi Chaang coffee"

SATOSHIS_IN_ONE = 100000000


def btc_price():
    """One bitcoin, in baht, right now. The same call as step 1."""
    with urllib.request.urlopen(BITKUB + "?sym=BTC_THB", timeout=30) as response:
        return float(json.load(response)[0]["last"])


def make_invoice(baht):
    """Baht in, satoshis out. The last few satoshis make this order unique."""
    rate = btc_price()
    satoshis = int(baht / rate * SATOSHIS_IN_ONE)

    # Two people buying the same coffee would owe exactly the same amount, and
    # you could not tell their two payments apart. A few spare satoshis can.
    return rate, satoshis + random.randint(1, 999)


def main():
    """Price the item, then print the link a wallet understands."""
    rate, satoshis = make_invoice(PRICE_THB)
    amount = satoshis / SATOSHIS_IN_ONE

    # A bitcoin: link. A phone opens it in a wallet, amount already filled in.
    uri = "bitcoin:" + ADDRESS + "?" + urllib.parse.urlencode({
        "amount": format(amount, ".8f"),
        "label": ITEM,
    })

    print("item      :", ITEM)
    print("price     :", PRICE_THB, "baht")
    print("rate today:", rate, "baht for one bitcoin")
    print("satoshis  :", satoshis)
    print("amount    :", format(amount, ".8f"), "BTC")
    print()
    print(uri)


if __name__ == "__main__":
    main()
LineWhat it does · ทำอะไร · 做什么
SATOSHIS_IN_ONE = 100000000One bitcoin is a hundred million satoshis. Whole numbers avoid rounding trouble.หนึ่งบิตคอยน์เท่ากับหนึ่งร้อยล้านซาโตชิ ใช้จำนวนเต็มจะไม่เจอปัญหาการปัดเศษ一个比特币是一亿聪。用整数就不会碰上四舍五入的麻烦。
satoshis + random.randint(1, 999)The tail that makes this order unique. Under a tenth of a baht.หางตัวเลขที่ทำให้ออเดอร์นี้ไม่ซ้ำใคร คิดเป็นเงินไม่ถึงสิบสตางค์让这笔订单独一无二的尾数。折合还不到一毛钱。
uri = "bitcoin:" + ADDRESS + "?" + urllib.parse.urlencode({A bitcoin: link. A phone opens it in a wallet, amount filled in.ลิงก์ bitcoin: มือถือจะเปิดมันในวอลเล็ตพร้อมยอดที่กรอกไว้แล้ว一个 bitcoin: 链接。手机会用钱包打开它,金额已经填好。

🖥️ WHAT IT PRINTED · a real run

item      : One bag of Doi Chaang coffee
price     : 250.0 baht
rate today: 2833237.15 baht for one bitcoin
satoshis  : 9242
amount    : 0.00009242 BTC

bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?amount=0.00009242&label=One+bag+of+Doi+Chaang+coffee
YOUR PRICE GOES STALE IN MINUTES That rate was true when you asked. Bitcoin moves. A page you built this morning is charging this morning price. Build the page when the customer asks. Give the price a short life. อัตรานั้นจริงตอนที่คุณถาม แต่บิตคอยน์ขยับตลอด หน้าที่คุณสร้างไว้เมื่อเช้าก็ยังคิดราคาของเมื่อเช้าอยู่ ให้สร้างหน้าตอนที่ลูกค้าสั่ง และกำหนดอายุราคาให้สั้น ๆ 那个汇率在你问的那一刻是真的,但比特币一直在动。你早上做好的页面,收的还是早上的价。客人下单的时候再生成页面,并且给价格一个很短的有效期。
YOUR TURN
  1. Put your own receiving address in ADDRESS.
  2. Run it twice. Check: the satoshi amounts differ.
  3. Change PRICE_THB to 5000 and run it again.
  4. Paste the bitcoin: link into your phone. A wallet should open.
  1. ใส่ที่อยู่รับเงินของคุณเองใน ADDRESS
  2. รันสองครั้ง ตรวจ: ยอดซาโตชิต้องไม่เท่ากัน
  3. เปลี่ยน PRICE_THB เป็น 5000 แล้วรันใหม่
  4. วางลิงก์ bitcoin: ลงในมือถือ วอลเล็ตควรเปิดขึ้นมา
  1. 把你自己的收款地址填进 ADDRESS。
  2. 运行两次。检查:两次的聪数不一样。
  3. 把 PRICE_THB 改成 5000,再运行一次。
  4. 把 bitcoin: 链接粘到手机上,钱包应该会打开。

13 A page that watches for its own payment · หน้าเว็บที่เฝ้าดูเงินของตัวเอง · 一个会自己等钱的页面

🇬🇧 English

Python writes the page. After that the page looks after itself.

The customer's browser asks mempool.space whether the money arrived. That is allowed. The chain is public and the API is open to any page.

Upload the one file it writes. That is your shop.

🇹🇭 ไทย

Python เขียนหน้าเว็บให้ หลังจากนั้นหน้าเว็บดูแลตัวเอง

เบราว์เซอร์ของลูกค้าจะถาม mempool.space ว่าเงินมาถึงหรือยัง ทำแบบนี้ได้ เพราะเชนเป็นสาธารณะ และ API เปิดให้ทุกหน้าเว็บเรียก

อัปโหลดไฟล์เดียวที่มันเขียนออกมา นั่นแหละคือร้านของคุณ

🇨🇳 中文

Python 把页面写出来,之后页面就自己照顾自己。

客人的浏览器去问 mempool.space 钱到了没有。这是允许的:链是公开的,接口对任何页面都开放。

把它写出的那一个文件传上去,那就是你的店。

📄 NEW FILE · shop2.py

"""shop2.py — write a payment page that watches the chain by itself."""
import io
import json
import random
import urllib.parse
import urllib.request
from pathlib import Path

import qrcode
import qrcode.image.svg

BITKUB = "https://api.bitkub.com/api/v3/market/ticker"

ADDRESS = "1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa"
PRICE_THB = 250.0
ITEM = "One bag of Doi Chaang coffee"

SATOSHIS_IN_ONE = 100000000
PAGE = Path("pay.html")

# No server anywhere in this file. Python writes the page, and after that the
# page looks after itself in the customer's browser.
TEMPLATE = """<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Pay with bitcoin</title>
<style>
  body { font-family: system-ui, sans-serif; max-width: 30rem; margin: 0 auto;
         padding: 1.5rem; background: #fbfaf7; color: #222; }
  .qr { width: 15rem; height: 15rem; }
  code { background: #eee; padding: 2px 5px; border-radius: 4px;
         word-break: break-all; font-size: 0.9rem; }
  #status { padding: 0.8rem 1rem; border-radius: 8px; font-weight: 600;
            background: #eee; margin: 1rem 0; }
  .waiting { background: #fff4d6 !important; }
  .seen { background: #dbeafe !important; }
  .confirmed { background: #d9f5df !important; }
</style>
</head>
<body>
  <h1>__ITEM__</h1>
  <p>__PRICE__ baht &middot; <strong>__AMOUNT__ BTC</strong></p>

  <a href="__URI__">__QR__</a>

  <p>Send exactly <code>__AMOUNT__</code> BTC to:</p>
  <p><code>__ADDRESS__</code></p>

  <div id="status" class="waiting">Waiting for your payment...</div>

  <script>
  // The blockchain is public, so a page with no server can read it.
  var ADDRESS = "__ADDRESS__";
  var SATOSHIS = __SATOSHIS__;
  var API = "https://mempool.space/api/address/" + ADDRESS + "/txs";

  function show(state, words) {
    var box = document.getElementById("status");
    box.className = state;
    box.textContent = words;
  }

  function look() {
    fetch(API).then(function (r) { return r.json(); }).then(function (txs) {
      var best = "waiting";
      for (var i = 0; i < txs.length; i++) {
        var outputs = txs[i].vout;
        for (var j = 0; j < outputs.length; j++) {
          // The exact amount is how we know this payment is for this order.
          if (outputs[j].scriptpubkey_address === ADDRESS &&
              outputs[j].value === SATOSHIS) {
            // Unconfirmed ones are listed first, so do not stop at the first
            // match. A payment in a block beats one that is only seen.
            if (txs[i].status.confirmed) { best = "confirmed"; }
            else if (best === "waiting") { best = "seen"; }
          }
        }
      }
      if (best === "confirmed") {
        show("confirmed", "Paid, and in a block. Thank you!");
      } else if (best === "seen") {
        show("seen", "Payment seen. Waiting for a block...");
      } else {
        show("waiting", "Waiting for your payment...");
      }
    }).catch(function () {
      show("waiting", "Could not reach the chain. Trying again...");
    });
  }

  look();
  setInterval(look, 10000);
  </script>
</body>
</html>
"""


def btc_price():
    """One bitcoin, in baht, right now."""
    with urllib.request.urlopen(BITKUB + "?sym=BTC_THB", timeout=30) as response:
        return float(json.load(response)[0]["last"])


def make_invoice(baht):
    """Baht in, satoshis out. The last few satoshis make this order unique."""
    rate = btc_price()
    satoshis = int(baht / rate * SATOSHIS_IN_ONE)
    return rate, satoshis + random.randint(1, 999)


def qr_svg(text):
    """A QR picture as SVG text, ready to drop straight into the page."""
    picture = qrcode.make(text, image_factory=qrcode.image.svg.SvgPathImage)
    holder = io.BytesIO()
    picture.save(holder)

    # Cut off the XML header. Inside HTML the <svg> tag alone is enough.
    svg = holder.getvalue().decode()
    svg = svg[svg.index("<svg"):]
    return svg.replace("<svg ", '<svg class="qr" ', 1)


def main():
    """Price it, draw it, and write one file you can upload anywhere."""
    rate, satoshis = make_invoice(PRICE_THB)
    amount = format(satoshis / SATOSHIS_IN_ONE, ".8f")

    uri = "bitcoin:" + ADDRESS + "?" + urllib.parse.urlencode({
        "amount": amount, "label": ITEM})

    page = TEMPLATE
    page = page.replace("__ITEM__", ITEM)
    page = page.replace("__PRICE__", str(PRICE_THB))
    page = page.replace("__AMOUNT__", amount)
    page = page.replace("__ADDRESS__", ADDRESS)
    page = page.replace("__SATOSHIS__", str(satoshis))
    page = page.replace("__URI__", uri)
    page = page.replace("__QR__", qr_svg(uri))

    PAGE.write_text(page, encoding="utf-8")

    print("rate today:", rate, "baht for one bitcoin")
    print("wants     :", satoshis, "satoshis =", amount, "BTC")
    print("wrote     :", PAGE.name, len(page), "characters")
    print()
    print("Put that one file on GitHub Pages. There is no server to run.")


if __name__ == "__main__":
    main()
LineWhat it does · ทำอะไร · 做什么
TEMPLATE = """<!doctype html>A whole web page inside a Python string. Python fills in the blanks.หน้าเว็บทั้งหน้าอยู่ในสตริงของ Python แล้ว Python ก็เติมช่องว่างให้一整个网页放在 Python 字符串里,再由 Python 把空填上。
svg = svg[svg.index("<svg"):]Drop the XML header. Inside HTML the <svg> tag alone is enough.ตัดส่วนหัว XML ออก ในไฟล์ HTML แค่แท็ก <svg> ก็พอแล้ว把 XML 头去掉。在 HTML 里只要 <svg> 标签就够了。
var API = "https://mempool.space/api/address/" + ADDRESS + "/txs";The browser reads the chain directly. No server of yours sits between.เบราว์เซอร์อ่านเชนตรง ๆ ไม่มีเซิร์ฟเวอร์ของคุณคั่นกลางเลย浏览器直接读链。中间没有你的任何服务器。
if (txs[i].status.confirmed) { best = "confirmed"; }Unconfirmed ones come first, so keep looking. A block beats a sighting.รายการที่ยังไม่ยืนยันมาก่อน จึงต้องดูต่อ อยู่ในบล็อกแล้วย่อมดีกว่าแค่เห็น未确认的排在前面,所以要继续找。进了区块,胜过只是看见。

🖥️ WHAT IT PRINTED · a real run

rate today: 2832246.69 baht for one bitcoin
wants     : 9687 satoshis = 0.00009687 BTC
wrote     : pay.html 16042 characters

Put that one file on GitHub Pages. There is no server to run.
A PAGE MAY SHOW A PAYMENT. IT MUST NEVER GRANT ONE. Anyone can open the browser console and make that box turn green. So use it to tell the customer what you see. Nothing more. Never let a page unlock a download, a password, or a discount. You decide what to send, in step 14. ใครก็เปิดคอนโซลของเบราว์เซอร์แล้วทำให้กล่องนั้นเป็นสีเขียวได้ ให้ใช้มันบอกลูกค้าว่าคุณเห็นอะไรเท่านั้น อย่าให้หน้าเว็บปลดล็อกไฟล์ดาวน์โหลด รหัสผ่าน หรือส่วนลด คุณเป็นคนตัดสินใจว่าจะส่งอะไร ในขั้นที่ 14 任何人都能打开浏览器控制台,让那个框变绿。所以它只用来告诉客人你看到了什么,别的都不行。永远不要让页面去解锁下载、密码或折扣。送什么由你决定,那是第 14 步。
ONE ADDRESS FOR EVERYONE IS A PRIVACY LEAK Reuse one address and every customer sees every payment you took. The chain is public, remember. Real shops make a new address for each order. The random satoshis tell orders apart, but they do not fix this. ถ้าใช้ที่อยู่เดียวซ้ำ ลูกค้าทุกคนจะเห็นเงินทุกก้อนที่คุณรับมา อย่าลืมว่าเชนเป็นสาธารณะ ร้านจริงจะสร้างที่อยู่ใหม่ให้ทุกออเดอร์ ซาโตชิสุ่มช่วยแยกออเดอร์ได้ก็จริง แต่ไม่ได้แก้เรื่องนี้ 重复用同一个地址,每个客人都能看到你收过的每一笔钱。别忘了链是公开的。真正的店会给每笔订单生成一个新地址。随机的聪能分清订单,但解决不了这个问题。
YOUR TURN
  1. Run it, then open pay.html in your browser.
  2. Check: a QR, an amount, and a yellow waiting box.
  3. Open the file. Find the JavaScript. It is about thirty lines.
  4. Commit pay.html to a GitHub Pages repo. It works there too.
  1. รันมัน แล้วเปิด pay.html ในเบราว์เซอร์
  2. ตรวจ: ต้องมีคิวอาร์ ยอดเงิน และกล่องสีเหลืองที่กำลังรอ
  3. เปิดไฟล์ดู หา JavaScript ในนั้น ยาวราวสามสิบบรรทัด
  4. คอมมิต pay.html ขึ้น GitHub Pages มันทำงานที่นั่นได้เหมือนกัน
  1. 运行它,然后在浏览器里打开 pay.html。
  2. 检查:一个二维码、一个金额,和一个黄色的等待框。
  3. 打开那个文件,找到里面的 JavaScript,大概三十行。
  4. 把 pay.html 提交到 GitHub Pages,在那里一样能用。

14 Check it yourself before you post the parcel · ตรวจเองก่อนส่งของ · 寄货之前,自己核一遍

🇬🇧 English

The green box is for the customer. It is not proof.

Anyone can open the console and change what that page says. So you check the chain yourself, from your own computer.

And you wait. A payment that is only seen can still vanish. A payment in a block, with a few blocks on top, will not.

🇹🇭 ไทย

กล่องสีเขียวมีไว้ให้ลูกค้าดู มันไม่ใช่หลักฐาน

ใครก็เปิดคอนโซลแล้วเปลี่ยนสิ่งที่หน้านั้นพูดได้ คุณจึงต้องตรวจเชนเอง จากเครื่องของคุณเอง

และต้องรอด้วย เงินที่แค่ถูกเห็นยังหายไปได้ แต่เงินที่อยู่ในบล็อกแล้ว และมีบล็อกทับอีกไม่กี่ก้อน จะไม่หาย

🇨🇳 中文

绿色的框是给客人看的,它不是证据。

任何人都能打开控制台改掉那页说的话,所以你要自己去核链,在你自己的电脑上。

而且要等。只是被看见的付款还可能消失;已经进了区块、上面又压了几个区块的,就不会了。

📄 NEW FILE · paid1.py

"""paid1.py — check the payment yourself, because a web page can be edited."""
import json
import urllib.request

ADDRESS_API = "https://mempool.space/api/address/"
HEIGHT_API = "https://mempool.space/api/blocks/tip/height"

ADDRESS = "1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa"
WANT_SATOSHIS = 546        # the exact amount that order asked for
SAFE_BLOCKS = 3            # how many blocks before you post the parcel


def payments_to(address):
    """Every recent transaction touching this address."""
    with urllib.request.urlopen(ADDRESS_API + address + "/txs", timeout=40) as response:
        return json.load(response)


def tip_height():
    """The number of the newest block. Needed to count confirmations."""
    with urllib.request.urlopen(HEIGHT_API, timeout=30) as response:
        return int(response.read())


def find_payment(address, satoshis):
    """Look for one payment of this exact amount. Returns (state, blocks, txid)."""
    tip = tip_height()
    best = ("waiting", 0, None)

    for transaction in payments_to(address):
        for output in transaction["vout"]:
            if output.get("scriptpubkey_address") != address:
                continue
            if output["value"] != satoshis:
                continue

            status = transaction["status"]
            if not status["confirmed"]:
                if best[0] == "waiting":
                    best = ("seen", 0, transaction["txid"])
                continue

            # Your block counts as one, so add one to the difference.
            blocks = tip - status["block_height"] + 1
            if blocks > best[1]:
                best = ("confirmed", blocks, transaction["txid"])

    return best


def main():
    """Say plainly whether this order is safe to post."""
    state, blocks, txid = find_payment(ADDRESS, WANT_SATOSHIS)

    if state == "waiting":
        print("No payment of", WANT_SATOSHIS, "satoshis yet.")
        return

    print("found:", txid)
    if state == "seen":
        print("It is on the network, but not in a block yet.")
        print("Do not post anything. A payment like this can still disappear.")
        return

    print("blocks deep:", blocks)
    if blocks >= SAFE_BLOCKS:
        print("Safe to post the parcel.")
    else:
        print("Wait for", SAFE_BLOCKS - blocks, "more block(s) before you post.")


if __name__ == "__main__":
    main()
LineWhat it does · ทำอะไร · 做什么
blocks = tip - status["block_height"] + 1Confirmations. Your own block counts as one, so add one.จำนวนการยืนยัน บล็อกของคุณเองนับเป็นหนึ่ง จึงต้องบวกหนึ่ง确认数。你自己那个区块也算一个,所以要加一。
if not status["confirmed"]:Seen is not paid. A waiting payment can be replaced by another one.เห็นแล้วไม่ได้แปลว่าจ่ายแล้ว เงินที่ยังรออยู่ถูกแทนที่ด้วยรายการอื่นได้看见不等于付了。还在等的付款可以被另一笔替换掉。
SAFE_BLOCKS = 3How long to wait. Three blocks is about thirty minutes.จะรอนานแค่ไหน สามบล็อกคือราวสามสิบนาที要等多久。三个区块大约三十分钟。

🖥️ WHAT IT PRINTED · a real run, against a real paid address

found: fbb68abf3c908cfb1b7a86849934d915ccc4845e41cc9a70e1f2b6012566ab6d
blocks deep: 150
Safe to post the parcel.
HOW LONG TO WAIT DEPENDS ON THE PRICE For a coffee, one block is plenty. For a motorbike, wait for six. Undoing a block costs an attacker real money. They only try when the prize beats the cost. Small sale, short wait. ถ้าเป็นกาแฟแก้วเดียว บล็อกเดียวก็เหลือเฟือ ถ้าเป็นมอเตอร์ไซค์ ให้รอหกบล็อก การล้มบล็อกทำให้ผู้โจมตีเสียเงินจริง เขาจะลองก็ต่อเมื่อของรางวัลใหญ่กว่าต้นทุน ขายของถูก ก็รอสั้น 一杯咖啡,一个区块就够了;一辆摩托车,等六个。推翻一个区块要花攻击者真金白银,所以只有奖品大过成本时他们才会试。小额交易,短等待。
YOUR TURN
  1. Set WANT_SATOSHIS to the amount step 12 printed.
  2. Run it before paying. Check: it says no payment yet.
  3. Set SAFE_BLOCKS to 6 and run it again.
  4. Make it loop every minute, using time.sleep from step 7.
  1. ตั้ง WANT_SATOSHIS ให้เป็นยอดที่ขั้น 12 พิมพ์ออกมา
  2. รันก่อนจ่ายเงิน ตรวจ: ต้องขึ้นว่ายังไม่มีเงินเข้า
  3. ตั้ง SAFE_BLOCKS เป็น 6 แล้วรันใหม่
  4. ทำให้มันวนทุกหนึ่งนาที โดยใช้ time.sleep จากขั้นที่ 7
  1. 把 WANT_SATOSHIS 设成第 12 步打印出来的金额。
  2. 付款之前先运行。检查:它应该说还没收到钱。
  3. 把 SAFE_BLOCKS 设成 6,再运行一次。
  4. 用第 7 步的 time.sleep 让它每分钟循环一次。

🇬🇧 English

Fourteen steps. You read a real market, kept your own record of it, and placed an order on an exchange from a file you wrote.

What to take away is not Bitkub. It is the shape. A public part anybody can read, and a private part where every request carries a signature.

And one warning that has nothing to do with Python. Reading prices is a programming problem, and you have solved it. Knowing what to do with them is not, and this page has not taught you that.

🇹🇭 ไทย

สิบเอ็ดขั้น คุณอ่านตลาดจริง เก็บบันทึกของตัวเอง และวางคำสั่งบนกระดานเทรดจากไฟล์ที่คุณเขียนเอง

สิ่งที่ควรจำไม่ใช่ Bitkub แต่เป็นรูปร่างของมัน ส่วนสาธารณะที่ใครก็อ่านได้ กับส่วนส่วนตัวที่ทุกคำขอต้องพกลายเซ็นไปด้วย

และคำเตือนหนึ่งข้อที่ไม่เกี่ยวกับ Python เลย การอ่านราคาคือโจทย์การเขียนโปรแกรม และคุณแก้มันได้แล้ว ส่วนการรู้ว่าจะทำอะไรกับราคานั้นไม่ใช่โจทย์แบบเดียวกัน และหน้านี้ไม่ได้สอนคุณเรื่องนั้น

🇨🇳 中文

十一步。你读了一个真实的市场,自己留了记录,还从你亲手写的文件里向交易所下了一张单。

该记住的不是 Bitkub,而是那个形状:一个谁都能读的公开部分,和一个每个请求都要带签名的私有部分。

还有一句跟 Python 无关的提醒:读价格是个编程问题,你已经解决了;知道拿这些价格做什么,不是同一类问题,而这一页没有教你那个。

('You want', 'คุณอยากได้', '你想要')('Look up', 'ไปหาคำว่า', '去查')
Prices without asking again and againราคาที่ไม่ต้องถามซ้ำ ๆ不用反复去问的价格websocket-public
Sell, not buyขาย ไม่ใช่ซื้อ卖,而不是买place-ask
Buy right now at any priceซื้อทันทีในราคาไหนก็ได้现在就买,什么价都行"typ": "market"
What your old trades didคำสั่งเก่า ๆ ของคุณเป็นอย่างไร你以前的交易怎么样了my-order-history
The smallest order allowedคำสั่งเล็กสุดที่ทำได้允许的最小订单min_quote_size in /market/symbols

Fifteen lines, your own address, and an app password. That is the whole trick. · สิบห้าบรรทัด อีเมลของคุณเอง และ app password หนึ่งอัน เคล็ดลับมีแค่นี้ · 十五行代码、你自己的邮箱地址,加一个应用专用密码。诀窍就这些。

All fourteen files on this page were run on 21 September 2026 against the live Bitkub API. Steps 1 to 7 ran in full: the printed output on this page is real, including the 366 coins, the 287 baht of slippage on a 500,000 baht order, and the six saved prices in the chart. Steps 8 to 11 were run against Bitkub with a deliberately invalid key, which is why their output shows error 3 and A1003-MK. That proves the request reached Bitkub and was rejected on the key, so the signing and the error handling are real, but no order was ever filled by us. The three payment files ran in full: shop1 and shop2 against the live Bitkub rate, and the page shop2 generated was opened in a browser and watched a real, already-confirmed payment turn its box green. paid1 found that same payment 150 blocks deep. Nothing on this page is financial advice.