diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..a21cf81 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,13 @@ +[build-system] +requires = ["setuptools>=61"] +build-backend = "setuptools.build_meta" + +[project] +name = "pulse-sdk-py" +version = "0.0.1-alpha.0" +description = "Python SDK for PulseWire Protocol (PWP), a trading engine" +readme = "README.md" +requires-python = ">=3.13" +authors = [ + {name = "Klesti Selimaj"} +] \ No newline at end of file diff --git a/src/pulse_sdk_py/__init__.py b/src/pulse_sdk_py/__init__.py new file mode 100644 index 0000000..3be7b31 --- /dev/null +++ b/src/pulse_sdk_py/__init__.py @@ -0,0 +1,135 @@ +import sys +from typing import Generic, TypeVar, Type + +from . import general +from . import plugin + +T = TypeVar("T", bound=general.PulseWire) +TT = TypeVar("TT", bound=general.PulseWire) + +class PulseWire(Generic[T, TT]): + def __init__(self, wire_cls: Type[T]): + self.wire_cls = wire_cls + + def on(self, req: TT): + ... + + def send_raw(self, data: bytes): + sys.stdout.buffer.write(len(data).to_bytes(8, "little")) + sys.stdout.buffer.write(data) + sys.stdout.flush() + + def start(self): + while True: + len_buf = sys.stdin.buffer.read(8) + + # Standard EOF check: zero bytes returned from read means stdin closed + if not len_buf: + break + + if len(len_buf) < 8: + raise EOFError("Unexpected EOF while reading message length header") + + length = int.from_bytes(len_buf, "little") + if length == 0: + continue + + buffer = sys.stdin.buffer.read(length) + if len(buffer) != length: + raise EOFError("Unexpected EOF while reading payload") + + self.onRaw(buffer) + + def onRaw(self, data: bytes): + # Call from_com on the actual class passed in __init__ + result: tuple[TT, int] = self.wire_cls.from_com(data=data) + req, _ = result + + self.on(req) + + # Dynamic dispatch based on class name (e.g., onMyRequest) + handler = getattr(self, f"on{req.__class__.__name__}", None) + if handler and callable(handler): + handler(req) + +class Strategy(PulseWire[plugin.StrategyEngineMessage, plugin.StrategyEngineMessageType]): + def __init__(self): + super().__init__(plugin.StrategyEngineMessage) + + def onInitialize(self, msg: plugin.StrategyEngineMessage.Initialize): + ... + + def onWatchList(self, msg: plugin.StrategyEngineMessage.WatchList): + ... + + def onCommand(self, msg: plugin.StrategyEngineMessage.Command): + ... + + def onCandleUpdate(self, msg: plugin.StrategyEngineMessage.CandleUpdate): + ... + + def onCandlestick(self, msg: plugin.StrategyEngineMessage.Candlestick): + ... + + def send(self, req: plugin.StrategyMessageType): + self.send_raw(req.to_com()) + + def log(self, log: general.EventLog): + msg = plugin.StrategyMessage.Log() + msg.log = log + self.send(msg) + + def getWatchList(self): + self.send(plugin.StrategyMessage.GetWatchList()) + + def getCandleStick(self, symbol: str, interval: general.CandleIntervalType, count: general.u32): + msg = plugin.StrategyMessage.RequestCandlestick() + msg.symbol = symbol + msg.interval = interval + msg.count = count + self.send(msg) + + def unsubscribeAll(self): + self.send(plugin.StrategyMessage.UnsubscribeAll()) + + def signal(self, signal: plugin.StrategySignal): + msg = plugin.StrategyMessage.Signal() + msg.signal = signal + self.send(msg) + +class Risk(PulseWire[plugin.RiskEngineMessage, plugin.RiskEngineMessageType]): + def __init__(self): + super().__init__(plugin.RiskEngineMessage) + + def onInitialize(self, msg: plugin.RiskEngineMessage.Initialize): + ... + + def onWatchList(self, msg: plugin.RiskEngineMessage.WatchList): + ... + + def onCommand(self, msg: plugin.RiskEngineMessage.Command): + ... + + def onSignal(self, msg: plugin.RiskEngineMessage.Signal): + ... + + def send(self, req: plugin.RiskMessageType): + self.send_raw(req.to_com()) + + def log(self, log: general.EventLog): + msg = plugin.RiskMessage.Log() + msg.log = log + self.send(msg) + + def getWatchList(self): + self.send(plugin.RiskMessage.GetWatchList()) + + def approveSignal(self, signal: general.Signal): + msg = plugin.RiskMessage.Approve() + msg.signal = signal + self.send(msg) + + def rejectSignal(self, reason: str): + msg = plugin.RiskMessage.Reject() + msg.reason = reason + self.send(msg) diff --git a/src/pulse_sdk_py/adv_types.py b/src/pulse_sdk_py/adv_types.py new file mode 100644 index 0000000..d2f163b --- /dev/null +++ b/src/pulse_sdk_py/adv_types.py @@ -0,0 +1,134 @@ +from decimal import Decimal +import struct +import typing + +T = typing.TypeVar("T") +class PulseUnit(typing.Generic[T]): + value: T + + def __init__(self, value: T): + self.value = value + + def __class_getitem__(cls, item): + new_cls = type( + f"{cls.__name__}[{item.__name__}]", + (cls,), + {"_type": item} + ) + return new_cls + + def to_com(self) -> bytes: ... + + @classmethod + def from_com(cls, data: bytes, offset: int = 0) -> tuple[typing.Any, int]: ... + +B = typing.TypeVar("B", bound=type[PulseUnit]) +def pwp_number(fmt, value_type): + size = struct.calcsize(fmt) + + def decorator(cls: B) -> B: + def to_com(self): + return struct.pack(fmt, self.value) + + @classmethod + def from_com(cls, data, offset=0): + value = struct.unpack_from(fmt, data, offset)[0] + return cls(value), offset + size + + cls.to_com = to_com + cls.from_com = from_com + + return cls + + return decorator + +# Unsigned integers +@pwp_number(" bytes: + sign, digits, exponent = self.value.as_tuple() + + if not isinstance(exponent, int): + raise ValueError("Cannot serialize NaN or Infinity") + + scale = -exponent + mantissa = int("".join(map(str, digits))) + + # Handle positive exponents (e.g. 5E+2 -> scale 0, mantissa 500) + if scale < 0: + mantissa = mantissa * (10 ** -scale) + scale = 0 + + # Max scale allowed in rust_decimal is 28 + if scale > 28: + raise ValueError(f"Scale {scale} exceeds maximum allowed (28)") + + # Split 96-bit mantissa into three 32-bit chunks + lo = mantissa & 0xFFFFFFFF + mid = (mantissa >> 32) & 0xFFFFFFFF + hi = (mantissa >> 64) & 0xFFFFFFFF + + # Construct flags: scale is in bits 16-23, sign is bit 31 + flags = (scale << 16) & 0x00FF0000 + if sign: + flags |= 0x80000000 + + return struct.pack("<4I", flags, lo, mid, hi) + + @classmethod + def from_com(cls, data: bytes, offset: int = 0) -> tuple[typing.Any, int]: + # Unpack four 32-bit unsigned integers (Little-Endian) + flags, lo, mid, hi = struct.unpack_from("<4I", data, offset) + + # Extract scale (Bits 16-23) + scale = (flags >> 16) & 0xFF + + # Extract sign (Bit 31) + is_negative = (flags & 0x80000000) != 0 + + # Reconstruct the 96-bit integer mantissa + mantissa = lo | (mid << 32) | (hi << 64) + + # Convert to Python Decimal + value = Decimal(mantissa) / (Decimal(10) ** scale) + if is_negative: + value = -value + + return cls(value), offset + cls.size \ No newline at end of file diff --git a/src/pulse_sdk_py/general.py b/src/pulse_sdk_py/general.py new file mode 100644 index 0000000..5d345a3 --- /dev/null +++ b/src/pulse_sdk_py/general.py @@ -0,0 +1,139 @@ +from typing import TypeAlias + +from .pwp import pwp, pwp_enum, PulseWire +from .adv_types import * +from . import units + +@pwp_enum +class MarketTrend(PulseWire): + class Bullish: pass + class Bearish: pass + class Neutral: pass + +@pwp_enum +class LogKind(PulseWire): + class Info: pass + class Warn: pass + class Err: pass + class Debug: pass + +@pwp_enum +class CandleInterval(PulseWire): + class OneMinute(PulseWire): pass + class ThreeMinutes(PulseWire): pass + class FiveMinutes(PulseWire): pass + class FifteenMinutes(PulseWire): pass + class ThirtyMinutes(PulseWire): pass + class OneHour(PulseWire): pass + class TwoHours(PulseWire): pass + class FourHours(PulseWire): pass + class EightHours(PulseWire): pass + class TwelveHours(PulseWire): pass + class OneDay(PulseWire): pass + class ThreeDays(PulseWire): pass + class OneWeek(PulseWire): pass + class OneMonth(PulseWire): pass + +@pwp +class Signal(PulseWire): + symbol: str + kind: units.Direction + confidence: f32 + size: f64 + price: units.USD + take_profit: units.USD + stop_loss: units.USD + +MarketTrendType: TypeAlias = ( + MarketTrend.Bullish + | MarketTrend.Bearish + | MarketTrend.Neutral +) + +LogKindType: TypeAlias = ( + LogKind.Info + | LogKind.Warn + | LogKind.Err + | LogKind.Debug +) + +CandleIntervalType: TypeAlias = ( + CandleInterval.OneMinute + | CandleInterval.ThreeMinutes + | CandleInterval.FiveMinutes + | CandleInterval.FifteenMinutes + | CandleInterval.ThirtyMinutes + | CandleInterval.OneHour + | CandleInterval.TwoHours + | CandleInterval.FourHours + | CandleInterval.EightHours + | CandleInterval.TwelveHours + | CandleInterval.OneDay + | CandleInterval.ThreeDays + | CandleInterval.OneWeek + | CandleInterval.OneMonth +) + +@pwp +class EventLog(PulseWire): + kind: LogKindType + name: str + message: str + + @classmethod + def info(cls, name: str, message: str): + log = cls() + log.kind = LogKind.Info() + log.name = name + log.message = message + return log + + @classmethod + def warn(cls, name: str, message: str): + log = cls() + log.kind = LogKind.Warn() + log.name = name + log.message = message + return log + + @classmethod + def error(cls, name: str, message: str): + log = cls() + log.kind = LogKind.Err() + log.name = name + log.message = message + return log + + @classmethod + def debug(cls, name: str, message: str): + log = cls() + log.kind = LogKind.Debug() + log.name = name + log.message = message + return log +@pwp +class Position(PulseWire): + symbol: units.Symbol + size: f64 + entry_price: units.USD + profit: units.USD + +@pwp +class MarketItem(PulseWire): + symbol: units.Symbol + price: units.USD + trend: f64 + volume_24h: units.USD + +@pwp +class Candle(PulseWire): + open_time: u64 + close_time: u64 + coin: str + interval: str + open: decimal + high: decimal + low: decimal + close: decimal + volume: decimal + num_trades: u64 diff --git a/src/pulse_sdk_py/plugin.py b/src/pulse_sdk_py/plugin.py new file mode 100644 index 0000000..679e975 --- /dev/null +++ b/src/pulse_sdk_py/plugin.py @@ -0,0 +1,116 @@ +from typing import Optional, TypeAlias + +from .pwp import pwp, pwp_enum, PulseWire +from .adv_types import * +from . import units, general + +@pwp +class StrategySignal(PulseWire): + symbol: str + side: units.Direction + confidence: f32 + price: Optional[f64] + +@pwp_enum +class StrategyMessage(PulseWire): + class Log(PulseWire): + log: general.EventLog + + class GetWatchList(PulseWire): pass + + class RequestCandlestick(PulseWire): + symbol: str + interval: general.CandleIntervalType + count: u32 + + class Subscribe(PulseWire): + subscription: u8 + + class Unsubscribe(PulseWire): + subscription: u8 + + class UnsubscribeAll(PulseWire): pass + + class Signal(PulseWire): + signal: StrategySignal + +@pwp_enum +class RiskMessage(PulseWire): + class Log(PulseWire): + log: general.EventLog + + class GetWatchList(PulseWire): pass + + class Approve(PulseWire): + signal: general.Signal + + class Reject(PulseWire): + reason: str + +@pwp_enum +class StrategyEngineMessage(PulseWire): + class Initialize(PulseWire): pass + + class WatchList(PulseWire): + watchlist: list[general.MarketItem] + + class Command(PulseWire): + command: str + args: list[str] + + class CandleUpdate(PulseWire): + symbol: str + interval: general.CandleInterval + candle: general.Candle + + class Candlestick(PulseWire): + symbol: str + interval: general.CandleInterval + candles: list[general.Candle] + +@pwp_enum +class RiskEngineMessage(PulseWire): + class Initialize(PulseWire): pass + + class WatchList(PulseWire): + watchlist: list[general.MarketItem] + + class Command(PulseWire): + command: str + args: list[str] + + class Signal(PulseWire): + signal: general.Signal + + +StrategyMessageType: TypeAlias = ( + StrategyMessage.Log + | StrategyMessage.GetWatchList + | StrategyMessage.RequestCandlestick + | StrategyMessage.Subscribe + | StrategyMessage.Unsubscribe + | StrategyMessage.UnsubscribeAll + | StrategyMessage.Signal +) + +RiskMessageType: TypeAlias = ( + RiskMessage.Log + | RiskMessage.GetWatchList + | RiskMessage.Approve + | RiskMessage.Reject +) + +StrategyEngineMessageType: TypeAlias = ( + StrategyEngineMessage.Initialize + | StrategyEngineMessage.WatchList + | StrategyEngineMessage.Command + | StrategyEngineMessage.CandleUpdate + | StrategyEngineMessage.Candlestick +) + +RiskEngineMessageType: TypeAlias = ( + RiskEngineMessage.Initialize + | RiskEngineMessage.WatchList + | RiskEngineMessage.Command + | RiskEngineMessage.Signal +) \ No newline at end of file diff --git a/src/pulse_sdk_py/pwp.py b/src/pulse_sdk_py/pwp.py new file mode 100644 index 0000000..ae40c01 --- /dev/null +++ b/src/pulse_sdk_py/pwp.py @@ -0,0 +1,312 @@ +import struct +import typing +import types +from typing import ( + Protocol, + TypeVar, + Any, + get_type_hints, + get_origin, + get_args, +) + +from .adv_types import * + + +class PulseWire(Protocol): + def to_com(self) -> bytes: + return bytes() + + @classmethod + def from_com(cls, data: bytes, offset: int = 0) -> tuple[typing.Any, int]: + return (None, offset) + +class PWPError(Exception): + pass + + +def ensure_size(data: bytes, offset: int, size: int): + if offset + size > len(data): + raise PWPError( + f"Buffer underflow: need {size} bytes at offset {offset}, " + f"only {len(data) - offset} available" + ) + + +def encode_value(value, typ) -> bytes: + origin = get_origin(typ) + + # Optional[T] + if origin in (typing.Union, types.UnionType): + args = get_args(typ) + if type(None) in args: + if value is None: + return b"\x00" + real = next(x for x in args if x is not type(None)) + return b"\x01" + encode_value(value, real) + + # list[T] or list + if origin is list or typ is list: + args = get_args(typ) + item_type = args[0] if args else Any + + out = len(value).to_bytes(4, "little") + for item in value: + # If item_type is Any, infer type dynamically from the item itself + actual_type = item_type if item_type is not Any else type(item) + out += encode_value(item, actual_type) + return out + + # tuple[T, ...] + if origin is tuple or typ is tuple: + args = get_args(typ) + out = len(value).to_bytes(4, "little") + for item, item_type in zip(value, args if args else [type(x) for x in value]): + out += encode_value(item, item_type) + return out + + # dict[K, V] + if origin is dict or typ is dict: + args = get_args(typ) + key_type, value_type = args if args else (Any, Any) + out = len(value).to_bytes(4, "little") + for k, v in value.items(): + out += encode_value(k, key_type if key_type is not Any else type(k)) + out += encode_value(v, value_type if value_type is not Any else type(v)) + return out + + # Primitives + if typ is bool: + return b"\x01" if value else b"\x00" + if typ is int: + return struct.pack(" T: + + fields = get_type_hints(cls) + + + def to_com(self): + + out = b"" + + for name, typ in fields.items(): + + if not hasattr(self, name): + raise PWPError( + f"Missing field {name}" + ) + + out += encode_value( + getattr(self, name), + typ + ) + + return out + + + + @classmethod + def from_com( + cls, + data, + offset=0 + ): + + obj = cls.__new__(cls) + + for name, typ in fields.items(): + + value, offset = decode_value( + data, + offset, + typ + ) + + setattr( + obj, + name, + value + ) + + return obj, offset + + + + cls.to_com = to_com + cls.from_com = from_com + + return cls + + + +def pwp_enum(cls: T) -> T: + + variants = {} + + index = 0 + + + for name, variant in list(cls.__dict__.items()): + + if ( + isinstance(variant, type) + and variant.__module__ == cls.__module__ + ): + + variant.__annotations__ = { + "_id": u8, + **getattr( + variant, + "__annotations__", + {} + ) + } + + variant._id = u8(index) + + variant = pwp(variant) + + variants[index] = variant + + setattr( + cls, + name, + variant + ) + + index += 1 + + + + def to_com(self): + + for idx, variant in variants.items(): + + if isinstance(self, variant): + + return ( + idx.to_bytes(1, "little") + + self.to_com() + ) + + raise PWPError( + f"Unknown enum variant {type(self)}" + ) + + + + @classmethod + def from_com( + enum_cls, + data, + offset=0 + ): + + ensure_size( + data, + offset, + 1 + ) + + enum_id = data[offset] + # offset += 1 + + + if enum_id not in variants: + raise PWPError( + f"Unknown enum id {enum_id}" + ) + + + return variants[enum_id].from_com( + data, + offset + ) + + + cls.to_com = to_com + cls.from_com = from_com + + return cls \ No newline at end of file diff --git a/src/pulse_sdk_py/units.py b/src/pulse_sdk_py/units.py new file mode 100644 index 0000000..11cf4d4 --- /dev/null +++ b/src/pulse_sdk_py/units.py @@ -0,0 +1,21 @@ +from .pwp import pwp, pwp_enum, PulseWire +from .adv_types import * + +@pwp +class Symbol(PulseWire): + unit: str + +@pwp +class USD(PulseWire): + unit: f64 + +@pwp_enum +class Direction(PulseWire): + class Buy: pass + class Sell: pass + +@pwp_enum +class Volatility(PulseWire): + class Low: pass + class Medium: pass + class High: pass