148 lines
4.9 KiB
Python
148 lines
4.9 KiB
Python
#!/usr/bin/env python3
|
|
"""Generate ESC/POS ticker tape output from trade CSV data.
|
|
|
|
Reads a CSV of trades (timestamp, symbol, lots, price) and generates
|
|
ESC/POS commands with 90° rotated text to emulate an old stock ticker tape.
|
|
|
|
Trades are distributed across 4 parallel columns. Each column independently
|
|
prints symbol characters at the top of the tape and price/lot characters at
|
|
the bottom — just like a real multi-track ticker tape.
|
|
"""
|
|
|
|
import csv
|
|
import sys
|
|
|
|
from escpos.printer import Dummy
|
|
|
|
from glyphs import price_to_fraction, upload_fractions
|
|
|
|
# TM-T88V print area and font metrics
|
|
PRINT_WIDTH_DOTS = 512 # 80mm paper printable width
|
|
CHAR_WIDTH_DOTS = 24 # Font A rotated 90°: 24 dots wide
|
|
NUM_COLUMNS = 4
|
|
|
|
# The TM-T88V has ~4mm (~28 dot) non-printable margins on each side.
|
|
# To make columns look evenly spaced on the paper, we treat the margin
|
|
# as part of the edge gap. Setting inter-column gap = margin gives perfect
|
|
# symmetry: 5 gaps(28) + 4 cols(107) = 568 paper dots, 512 printable.
|
|
MARGIN_DOTS = 28
|
|
PAPER_DOTS = PRINT_WIDTH_DOTS + 2 * MARGIN_DOTS
|
|
GAP_DOTS = MARGIN_DOTS
|
|
COL_SPAN_DOTS = (PAPER_DOTS - (NUM_COLUMNS + 1) * GAP_DOTS) // NUM_COLUMNS # 107
|
|
|
|
COL_BOTTOM = [(i + 1) * GAP_DOTS + i * COL_SPAN_DOTS - MARGIN_DOTS for i in range(NUM_COLUMNS)]
|
|
COL_TOP = [b + COL_SPAN_DOTS - CHAR_WIDTH_DOTS for b in COL_BOTTOM]
|
|
|
|
|
|
def format_price(lots, price):
|
|
"""Format price/lots string for a trade."""
|
|
frac = price_to_fraction(price)
|
|
if lots > 1:
|
|
return f"{lots}s{frac}"
|
|
return frac
|
|
|
|
|
|
def trade_char_count(symbol, price_str):
|
|
"""Total characters a trade occupies in a column (symbol + price + separator)."""
|
|
return len(symbol) + len(price_str) + 1
|
|
|
|
|
|
def assign_to_columns(trades):
|
|
"""Assign trades to columns, always picking the column with the fewest chars."""
|
|
columns = [[] for _ in range(NUM_COLUMNS)]
|
|
lengths = [0] * NUM_COLUMNS
|
|
|
|
for symbol, lots, price in trades:
|
|
price_str = format_price(lots, price)
|
|
count = trade_char_count(symbol, price_str)
|
|
shortest = lengths.index(min(lengths))
|
|
columns[shortest].append((symbol, price_str))
|
|
lengths[shortest] += count
|
|
|
|
return columns
|
|
|
|
|
|
def build_column_chars(trades_in_column):
|
|
"""Build the character sequence for one column.
|
|
|
|
Each entry is ('top', ch), ('bottom', ch), or ('blank',) controlling
|
|
where the character is placed within the column width.
|
|
"""
|
|
chars = []
|
|
for symbol, price_str in trades_in_column:
|
|
for ch in symbol:
|
|
chars.append(("top", ch))
|
|
for ch in price_str:
|
|
chars.append(("bottom", ch))
|
|
chars.append(("blank",))
|
|
return chars
|
|
|
|
|
|
def _set_pos(p, dots):
|
|
"""ESC $ nL nH: set absolute horizontal print position in dots."""
|
|
p._raw(b"\x1b\x24" + bytes([dots % 256, dots // 256]))
|
|
|
|
|
|
def print_all(p, trades):
|
|
"""Print all trades across multiple columns, one row at a time."""
|
|
columns = assign_to_columns(trades)
|
|
col_chars = [build_column_chars(col) for col in columns]
|
|
max_len = max(len(cc) for cc in col_chars)
|
|
|
|
for row_idx in range(max_len):
|
|
for col_idx in range(NUM_COLUMNS):
|
|
if row_idx < len(col_chars[col_idx]):
|
|
entry = col_chars[col_idx][row_idx]
|
|
if entry[0] == "top":
|
|
_set_pos(p, COL_TOP[col_idx])
|
|
p._raw(entry[1].encode())
|
|
elif entry[0] == "bottom":
|
|
_set_pos(p, COL_BOTTOM[col_idx])
|
|
p._raw(entry[1].encode())
|
|
p._raw(b"\n")
|
|
|
|
return max_len
|
|
|
|
|
|
def main():
|
|
csv_file = sys.argv[1] if len(sys.argv) > 1 else "trades_sample_sorted.csv"
|
|
output_file = sys.argv[2] if len(sys.argv) > 2 else "ticker_tape.bin"
|
|
|
|
# Read all trades
|
|
trades = []
|
|
with open(csv_file) as f:
|
|
reader = csv.reader(f)
|
|
for row in reader:
|
|
_timestamp, symbol, lots_str, price_str = row
|
|
trades.append((symbol, int(lots_str), float(price_str)))
|
|
|
|
p = Dummy(profile="TM-T88V")
|
|
p.hw("INIT")
|
|
# ESC V 1: 90° clockwise character rotation (not exposed by python-escpos)
|
|
p._raw(b"\x1b\x56\x01")
|
|
# ESC 3 n: set line spacing to n/180 inch. Font A chars are 12 dots wide,
|
|
# so n=12 makes rotated characters sit flush with no gap.
|
|
p._raw(b"\x1b\x33\x0c")
|
|
# Upload custom fraction glyphs (⅛ ¼ ⅜ ½ ⅝ ¾ ⅞)
|
|
upload_fractions(p)
|
|
|
|
num_rows = print_all(p, trades)
|
|
|
|
# Feed past the cutter (~20mm above print head)
|
|
feed_rows = 16
|
|
p.text("\n" * feed_rows)
|
|
p.cut()
|
|
|
|
with open(output_file, "wb") as f:
|
|
f.write(p.output)
|
|
|
|
# Calculate print length: each row is 12 dots at 180 DPI
|
|
total_rows = num_rows + feed_rows
|
|
length_mm = total_rows * 12 / 180 * 25.4
|
|
length_in = total_rows * 12 / 180
|
|
print(f"Wrote {len(p.output)} bytes to {output_file}", file=sys.stderr)
|
|
print(f"{num_rows} rows, {length_mm:.0f}mm ({length_in:.1f}in) of tape", file=sys.stderr)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|