#!/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()