pybinbot 0.1.6__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- models/bot_base.py +99 -0
- models/deal.py +70 -0
- models/order.py +104 -0
- models/signals.py +51 -0
- pybinbot-0.1.6.dist-info/METADATA +57 -0
- pybinbot-0.1.6.dist-info/RECORD +15 -0
- pybinbot-0.1.6.dist-info/WHEEL +5 -0
- pybinbot-0.1.6.dist-info/licenses/LICENSE +18 -0
- pybinbot-0.1.6.dist-info/top_level.txt +3 -0
- pybinbot.py +93 -0
- shared/enums.py +272 -0
- shared/logging_config.py +42 -0
- shared/maths.py +123 -0
- shared/timestamps.py +97 -0
- shared/types.py +8 -0
models/bot_base.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
from pydantic import BaseModel, Field, field_validator
|
|
2
|
+
from shared.types import Amount
|
|
3
|
+
from shared.timestamps import timestamp
|
|
4
|
+
from shared.enums import (
|
|
5
|
+
QuoteAssets,
|
|
6
|
+
BinanceKlineIntervals,
|
|
7
|
+
CloseConditions,
|
|
8
|
+
Status,
|
|
9
|
+
Strategy,
|
|
10
|
+
)
|
|
11
|
+
from shared.timestamps import ts_to_humandate
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class BotBase(BaseModel):
|
|
15
|
+
pair: str
|
|
16
|
+
fiat: str = Field(default="USDC")
|
|
17
|
+
quote_asset: QuoteAssets = Field(default=QuoteAssets.USDC)
|
|
18
|
+
fiat_order_size: Amount = Field(
|
|
19
|
+
default=0, ge=0, description="Min Binance 0.0001 BNB approx 15USD"
|
|
20
|
+
)
|
|
21
|
+
candlestick_interval: BinanceKlineIntervals = Field(
|
|
22
|
+
default=BinanceKlineIntervals.fifteen_minutes
|
|
23
|
+
)
|
|
24
|
+
close_condition: CloseConditions = Field(default=CloseConditions.dynamic_trailling)
|
|
25
|
+
cooldown: int = Field(
|
|
26
|
+
default=0,
|
|
27
|
+
ge=0,
|
|
28
|
+
description="cooldown period in minutes before opening next bot with same pair",
|
|
29
|
+
)
|
|
30
|
+
created_at: float = Field(default_factory=timestamp)
|
|
31
|
+
updated_at: float = Field(default_factory=timestamp)
|
|
32
|
+
dynamic_trailling: bool = Field(default=False)
|
|
33
|
+
logs: list = Field(default=[])
|
|
34
|
+
mode: str = Field(default="manual")
|
|
35
|
+
name: str = Field(
|
|
36
|
+
default="terminal",
|
|
37
|
+
description="Algorithm name or 'terminal' if executed from React app",
|
|
38
|
+
)
|
|
39
|
+
status: Status = Field(default=Status.inactive)
|
|
40
|
+
stop_loss: Amount = Field(
|
|
41
|
+
default=0, ge=-1, le=101, description="If stop_loss > 0, allow for reversal"
|
|
42
|
+
)
|
|
43
|
+
margin_short_reversal: bool = Field(
|
|
44
|
+
default=False,
|
|
45
|
+
description="Autoswitch from long to short or short to long strategy",
|
|
46
|
+
)
|
|
47
|
+
take_profit: Amount = Field(default=0, ge=-1, le=101)
|
|
48
|
+
trailling: bool = Field(default=False)
|
|
49
|
+
trailling_deviation: Amount = Field(
|
|
50
|
+
default=0,
|
|
51
|
+
ge=-1,
|
|
52
|
+
le=101,
|
|
53
|
+
description="Trailling activation (first take profit hit)",
|
|
54
|
+
)
|
|
55
|
+
trailling_profit: Amount = Field(default=0, ge=-1, le=101)
|
|
56
|
+
strategy: Strategy = Field(default=Strategy.long)
|
|
57
|
+
model_config = {
|
|
58
|
+
"from_attributes": True,
|
|
59
|
+
"use_enum_values": True,
|
|
60
|
+
"json_schema_extra": {
|
|
61
|
+
"description": "Most fields are optional. Deal and orders fields are generated internally and filled by Exchange",
|
|
62
|
+
"examples": [
|
|
63
|
+
{
|
|
64
|
+
"pair": "BNBUSDT",
|
|
65
|
+
"fiat": "USDC",
|
|
66
|
+
"quote_asset": "USDC",
|
|
67
|
+
"fiat_order_size": 15,
|
|
68
|
+
"candlestick_interval": "15m",
|
|
69
|
+
"close_condition": "dynamic_trailling",
|
|
70
|
+
"cooldown": 0,
|
|
71
|
+
"created_at": 1702999999.0,
|
|
72
|
+
"updated_at": 1702999999.0,
|
|
73
|
+
"dynamic_trailling": False,
|
|
74
|
+
"logs": [],
|
|
75
|
+
"mode": "manual",
|
|
76
|
+
"name": "Default bot",
|
|
77
|
+
"status": "inactive",
|
|
78
|
+
"stop_loss": 0,
|
|
79
|
+
"take_profit": 2.3,
|
|
80
|
+
"trailling": True,
|
|
81
|
+
"trailling_deviation": 0.63,
|
|
82
|
+
"trailling_profit": 2.3,
|
|
83
|
+
"margin_short_reversal": False,
|
|
84
|
+
"strategy": "long",
|
|
85
|
+
}
|
|
86
|
+
],
|
|
87
|
+
},
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
@field_validator("pair")
|
|
91
|
+
@classmethod
|
|
92
|
+
def check_pair_not_empty(cls, v):
|
|
93
|
+
assert v != "", "Pair field must be filled."
|
|
94
|
+
return v
|
|
95
|
+
|
|
96
|
+
def add_log(self, message: str) -> str:
|
|
97
|
+
timestamped_message = f"[{ts_to_humandate(timestamp())}] {message}"
|
|
98
|
+
self.logs.append(timestamped_message)
|
|
99
|
+
return self.logs[-1]
|
models/deal.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
from pydantic import BaseModel, Field, field_validator
|
|
2
|
+
from shared.types import Amount
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class DealBase(BaseModel):
|
|
6
|
+
"""
|
|
7
|
+
Data model that is used for operations,
|
|
8
|
+
so it should all be numbers (int or float)
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
base_order_size: Amount = Field(default=0, gt=-1)
|
|
12
|
+
current_price: Amount = Field(default=0)
|
|
13
|
+
take_profit_price: Amount = Field(default=0)
|
|
14
|
+
trailling_stop_loss_price: Amount = Field(
|
|
15
|
+
default=0,
|
|
16
|
+
description="take_profit but for trailling, to avoid confusion, trailling_profit_price always be > trailling_stop_loss_price",
|
|
17
|
+
)
|
|
18
|
+
trailling_profit_price: Amount = Field(default=0)
|
|
19
|
+
stop_loss_price: Amount = Field(default=0)
|
|
20
|
+
|
|
21
|
+
# fields for margin trading
|
|
22
|
+
total_interests: float = Field(default=0, gt=-1)
|
|
23
|
+
total_commissions: float = Field(default=0, gt=-1)
|
|
24
|
+
margin_loan_id: int = Field(
|
|
25
|
+
default=0,
|
|
26
|
+
ge=0,
|
|
27
|
+
description="Txid from Binance. This is used to check if there is a loan, 0 means no loan",
|
|
28
|
+
)
|
|
29
|
+
margin_repay_id: int = Field(
|
|
30
|
+
default=0, ge=0, description="= 0, it has not been repaid"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
# Refactored deal prices that combine both margin and spot
|
|
34
|
+
opening_price: Amount = Field(
|
|
35
|
+
default=0,
|
|
36
|
+
description="replaces previous buy_price or short_sell_price/margin_short_sell_price",
|
|
37
|
+
)
|
|
38
|
+
opening_qty: Amount = Field(
|
|
39
|
+
default=0,
|
|
40
|
+
description="replaces previous buy_total_qty or short_sell_qty/margin_short_sell_qty",
|
|
41
|
+
)
|
|
42
|
+
opening_timestamp: int = Field(default=0)
|
|
43
|
+
closing_price: Amount = Field(
|
|
44
|
+
default=0,
|
|
45
|
+
description="replaces previous sell_price or short_sell_price/margin_short_sell_price",
|
|
46
|
+
)
|
|
47
|
+
closing_qty: Amount = Field(
|
|
48
|
+
default=0,
|
|
49
|
+
description="replaces previous sell_qty or short_sell_qty/margin_short_sell_qty",
|
|
50
|
+
)
|
|
51
|
+
closing_timestamp: int = Field(
|
|
52
|
+
default=0,
|
|
53
|
+
description="replaces previous buy_timestamp or margin/short_sell timestamps",
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
@field_validator("margin_loan_id", mode="before")
|
|
57
|
+
@classmethod
|
|
58
|
+
def validate_margin_loan_id(cls, value):
|
|
59
|
+
if isinstance(value, float):
|
|
60
|
+
return int(value)
|
|
61
|
+
else:
|
|
62
|
+
return value
|
|
63
|
+
|
|
64
|
+
@field_validator("margin_loan_id", mode="after")
|
|
65
|
+
@classmethod
|
|
66
|
+
def cast_float(cls, value):
|
|
67
|
+
if isinstance(value, float):
|
|
68
|
+
return int(value)
|
|
69
|
+
else:
|
|
70
|
+
return value
|
models/order.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
from pydantic import BaseModel, Field, field_validator
|
|
2
|
+
from shared.types import Amount
|
|
3
|
+
from shared.enums import (
|
|
4
|
+
DealType,
|
|
5
|
+
OrderStatus,
|
|
6
|
+
)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class OrderBase(BaseModel):
|
|
10
|
+
order_type: str = Field(
|
|
11
|
+
description="Because every exchange has different naming, we should keep it as a str rather than OrderType enum"
|
|
12
|
+
)
|
|
13
|
+
time_in_force: str
|
|
14
|
+
timestamp: int = Field(default=0)
|
|
15
|
+
order_id: int | str = Field(
|
|
16
|
+
description="Because every exchange has id type, we should keep it as looose as possible. Int is for backwards compatibility"
|
|
17
|
+
)
|
|
18
|
+
order_side: str = Field(
|
|
19
|
+
description="Because every exchange has different naming, we should keep it as a str rather than OrderType enum"
|
|
20
|
+
)
|
|
21
|
+
pair: str
|
|
22
|
+
qty: float
|
|
23
|
+
status: OrderStatus
|
|
24
|
+
price: float
|
|
25
|
+
deal_type: DealType
|
|
26
|
+
model_config = {
|
|
27
|
+
"from_attributes": True,
|
|
28
|
+
"use_enum_values": True,
|
|
29
|
+
"json_schema_extra": {
|
|
30
|
+
"description": "Most fields are optional. Deal field is generated internally, orders are filled up by Exchange",
|
|
31
|
+
"examples": [
|
|
32
|
+
{
|
|
33
|
+
"order_type": "LIMIT",
|
|
34
|
+
"time_in_force": "GTC",
|
|
35
|
+
"timestamp": 0,
|
|
36
|
+
"order_id": 0,
|
|
37
|
+
"order_side": "BUY",
|
|
38
|
+
"pair": "",
|
|
39
|
+
"qty": 0,
|
|
40
|
+
"status": "",
|
|
41
|
+
"price": 0,
|
|
42
|
+
}
|
|
43
|
+
],
|
|
44
|
+
},
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class DealModel(BaseModel):
|
|
49
|
+
base_order_size: Amount = Field(default=0, gt=-1)
|
|
50
|
+
current_price: Amount = Field(default=0)
|
|
51
|
+
take_profit_price: Amount = Field(default=0)
|
|
52
|
+
trailling_stop_loss_price: Amount = Field(
|
|
53
|
+
default=0,
|
|
54
|
+
description="take_profit but for trailling, to avoid confusion, trailling_profit_price always be > trailling_stop_loss_price",
|
|
55
|
+
)
|
|
56
|
+
trailling_profit_price: Amount = Field(default=0)
|
|
57
|
+
stop_loss_price: Amount = Field(default=0)
|
|
58
|
+
total_interests: float = Field(default=0, gt=-1)
|
|
59
|
+
total_commissions: float = Field(default=0, gt=-1)
|
|
60
|
+
margin_loan_id: int = Field(
|
|
61
|
+
default=0,
|
|
62
|
+
ge=0,
|
|
63
|
+
description="Txid from Binance. This is used to check if there is a loan, 0 means no loan",
|
|
64
|
+
)
|
|
65
|
+
margin_repay_id: int = Field(
|
|
66
|
+
default=0, ge=0, description="= 0, it has not been repaid"
|
|
67
|
+
)
|
|
68
|
+
opening_price: Amount = Field(
|
|
69
|
+
default=0,
|
|
70
|
+
description="replaces previous buy_price or short_sell_price/margin_short_sell_price",
|
|
71
|
+
)
|
|
72
|
+
opening_qty: Amount = Field(
|
|
73
|
+
default=0,
|
|
74
|
+
description="replaces previous buy_total_qty or short_sell_qty/margin_short_sell_qty",
|
|
75
|
+
)
|
|
76
|
+
opening_timestamp: int = Field(default=0)
|
|
77
|
+
closing_price: Amount = Field(
|
|
78
|
+
default=0,
|
|
79
|
+
description="replaces previous sell_price or short_sell_price/margin_short_sell_price",
|
|
80
|
+
)
|
|
81
|
+
closing_qty: Amount = Field(
|
|
82
|
+
default=0,
|
|
83
|
+
description="replaces previous sell_qty or short_sell_qty/margin_short_sell_qty",
|
|
84
|
+
)
|
|
85
|
+
closing_timestamp: int = Field(
|
|
86
|
+
default=0,
|
|
87
|
+
description="replaces previous buy_timestamp or margin/short_sell timestamps",
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
@field_validator("margin_loan_id", mode="before")
|
|
91
|
+
@classmethod
|
|
92
|
+
def validate_margin_loan_id(cls, value):
|
|
93
|
+
if isinstance(value, float):
|
|
94
|
+
return int(value)
|
|
95
|
+
else:
|
|
96
|
+
return value
|
|
97
|
+
|
|
98
|
+
@field_validator("margin_loan_id", mode="after")
|
|
99
|
+
@classmethod
|
|
100
|
+
def cast_float(cls, value):
|
|
101
|
+
if isinstance(value, float):
|
|
102
|
+
return int(value)
|
|
103
|
+
else:
|
|
104
|
+
return value
|
models/signals.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from pydantic import BaseModel, Field, field_validator, ConfigDict
|
|
2
|
+
from typing import Optional
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
# Example shared model (copy actual model code from source files)
|
|
7
|
+
class HABollinguerSpread(BaseModel):
|
|
8
|
+
"""
|
|
9
|
+
Pydantic model for the Bollinguer spread.
|
|
10
|
+
(optional)
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
bb_high: float
|
|
14
|
+
bb_mid: float
|
|
15
|
+
bb_low: float
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class SignalsConsumer(BaseModel):
|
|
19
|
+
"""
|
|
20
|
+
Pydantic model for the signals consumer.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
type: str = Field(default="signal")
|
|
24
|
+
date: str = Field(
|
|
25
|
+
default_factory=lambda: datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
26
|
+
)
|
|
27
|
+
spread: Optional[float] = Field(default=0)
|
|
28
|
+
current_price: Optional[float] = Field(default=0)
|
|
29
|
+
msg: str
|
|
30
|
+
symbol: str
|
|
31
|
+
algo: str
|
|
32
|
+
bot_strategy: str = Field(default="long")
|
|
33
|
+
bb_spreads: Optional[HABollinguerSpread]
|
|
34
|
+
autotrade: bool = Field(default=True, description="If it is in testing mode, False")
|
|
35
|
+
|
|
36
|
+
model_config = ConfigDict(
|
|
37
|
+
extra="allow",
|
|
38
|
+
use_enum_values=True,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
@field_validator("spread", "current_price")
|
|
42
|
+
@classmethod
|
|
43
|
+
def name_must_contain_space(cls, v):
|
|
44
|
+
if v is None:
|
|
45
|
+
return 0
|
|
46
|
+
elif isinstance(v, str):
|
|
47
|
+
return float(v)
|
|
48
|
+
elif isinstance(v, float):
|
|
49
|
+
return v
|
|
50
|
+
else:
|
|
51
|
+
raise ValueError("must be a float or 0")
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pybinbot
|
|
3
|
+
Version: 0.1.6
|
|
4
|
+
Summary: Utility functions for the binbot project.
|
|
5
|
+
Author-email: Carlos Wu <carkodw@gmail.com>
|
|
6
|
+
Requires-Python: >=3.11
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Requires-Dist: pydantic[email]>=2.0.0
|
|
10
|
+
Requires-Dist: numpy==2.2.0
|
|
11
|
+
Requires-Dist: pandas>=2.2.3
|
|
12
|
+
Requires-Dist: pymongo==4.6.3
|
|
13
|
+
Provides-Extra: dev
|
|
14
|
+
Requires-Dist: pytest>=9.0.2; extra == "dev"
|
|
15
|
+
Requires-Dist: ruff; extra == "dev"
|
|
16
|
+
Dynamic: license-file
|
|
17
|
+
|
|
18
|
+
# PyBinbot
|
|
19
|
+
|
|
20
|
+
Utility functions for the binbot project. Most of the code here is not runnable, there's no server or individual scripts, you simply move code to here when it's used in both binbot and binquant.
|
|
21
|
+
|
|
22
|
+
``pybinbot`` is the public API module for the distribution.
|
|
23
|
+
|
|
24
|
+
This module re-exports the internal ``shared`` and ``models`` packages and the most commonly used helpers and enums so consumers can simply::
|
|
25
|
+
|
|
26
|
+
from pybinbot import round_numbers, ExchangeId
|
|
27
|
+
|
|
28
|
+
The implementation deliberately avoids importing heavy third-party libraries at module import time.
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
## Installation
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
uv sync --extra dev
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
`--extra dev` also installs development tools like ruff and mypy
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
## Publishing
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
make bump-patch
|
|
44
|
+
```
|
|
45
|
+
or
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
make bump-minor
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
or
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
make bump-major
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
For further commands take a look at the `Makefile` such as testing `make test`
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
pybinbot.py,sha256=bLB9AYwuk3cbQ7HVlVGwTtzqNSV3FsZv4Rtbetg0Kas,1920
|
|
2
|
+
models/bot_base.py,sha256=z9hSK7uVLW4oDmPdtntYozo8RnQnpZtFi8fL1r9Qd5Q,3593
|
|
3
|
+
models/deal.py,sha256=jOdMSobN_K4-be3hG38l0WeZq5ln5JndA-BBgMUElsI,2402
|
|
4
|
+
models/order.py,sha256=FHs7qi2JNYn-kXfWD2m5oFSSeMQDKmw0uPDwOvy5KkQ,3592
|
|
5
|
+
models/signals.py,sha256=DAcV2ft6n5iJW4kqspdfEakFZ3igx97erwyTiyDMlgM,1356
|
|
6
|
+
pybinbot-0.1.6.dist-info/licenses/LICENSE,sha256=ECEAqAQ81zTT8PeN7gYqbkZtewkyeleEqQ26MxuHQxs,938
|
|
7
|
+
shared/enums.py,sha256=b472TAbRrnznDRDDVrLW_2I_9dURafqeC3pMu4DHQ1w,6730
|
|
8
|
+
shared/logging_config.py,sha256=XZblKXH9KsLUDbIJqFRZPzI0h17-CRBZH4KktVak-TI,1144
|
|
9
|
+
shared/maths.py,sha256=JjlrgV0INlJG4Zj28ahRkfzcI2Ec4noilrDwX2p82TM,3207
|
|
10
|
+
shared/timestamps.py,sha256=401JkggjW--trNenxkUBEObnWyy9Cd-L3xVpqdbW8Tc,2587
|
|
11
|
+
shared/types.py,sha256=KfuJzjsbMUHFcBaQ6ZXUbuSyFHbHqehgeY73Zt8lqO8,173
|
|
12
|
+
pybinbot-0.1.6.dist-info/METADATA,sha256=LEBxxxJ0TBmDQ77xJpskIZYprx8_5y52oRgDFxsRf60,1358
|
|
13
|
+
pybinbot-0.1.6.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
14
|
+
pybinbot-0.1.6.dist-info/top_level.txt,sha256=rfaU2KRcKvquGQYwN5weorBMzgHpWW4eZlITOQwjRvw,23
|
|
15
|
+
pybinbot-0.1.6.dist-info/RECORD,,
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Your Name
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
13
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
14
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
15
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
16
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
17
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
18
|
+
SOFTWARE.
|
pybinbot.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
from shared.maths import (
|
|
2
|
+
supress_trailling,
|
|
3
|
+
round_numbers,
|
|
4
|
+
round_numbers_ceiling,
|
|
5
|
+
round_numbers_floor,
|
|
6
|
+
supress_notation,
|
|
7
|
+
interval_to_millisecs,
|
|
8
|
+
format_ts,
|
|
9
|
+
zero_remainder,
|
|
10
|
+
)
|
|
11
|
+
from shared.timestamps import (
|
|
12
|
+
timestamp,
|
|
13
|
+
round_timestamp,
|
|
14
|
+
ts_to_day,
|
|
15
|
+
ms_to_sec,
|
|
16
|
+
sec_to_ms,
|
|
17
|
+
ts_to_humandate,
|
|
18
|
+
timestamp_to_datetime,
|
|
19
|
+
)
|
|
20
|
+
from shared.enums import (
|
|
21
|
+
CloseConditions,
|
|
22
|
+
KafkaTopics,
|
|
23
|
+
DealType,
|
|
24
|
+
BinanceOrderModel,
|
|
25
|
+
Status,
|
|
26
|
+
Strategy,
|
|
27
|
+
OrderType,
|
|
28
|
+
TimeInForce,
|
|
29
|
+
OrderSide,
|
|
30
|
+
OrderStatus,
|
|
31
|
+
TrendEnum,
|
|
32
|
+
BinanceKlineIntervals,
|
|
33
|
+
KucoinKlineIntervals,
|
|
34
|
+
AutotradeSettingsDocument,
|
|
35
|
+
UserRoles,
|
|
36
|
+
QuoteAssets,
|
|
37
|
+
ExchangeId,
|
|
38
|
+
MarketDominance,
|
|
39
|
+
)
|
|
40
|
+
from shared.types import Amount
|
|
41
|
+
from shared.logging_config import configure_logging
|
|
42
|
+
from models.bot_base import BotBase
|
|
43
|
+
from models.order import OrderBase
|
|
44
|
+
from models.deal import DealBase
|
|
45
|
+
from models.signals import HABollinguerSpread, SignalsConsumer
|
|
46
|
+
|
|
47
|
+
__all__ = [
|
|
48
|
+
# models
|
|
49
|
+
"BotBase",
|
|
50
|
+
"OrderBase",
|
|
51
|
+
"DealBase",
|
|
52
|
+
# misc
|
|
53
|
+
"Amount",
|
|
54
|
+
"configure_logging",
|
|
55
|
+
# maths helpers
|
|
56
|
+
"supress_trailling",
|
|
57
|
+
"round_numbers",
|
|
58
|
+
"round_numbers_ceiling",
|
|
59
|
+
"round_numbers_floor",
|
|
60
|
+
"supress_notation",
|
|
61
|
+
"interval_to_millisecs",
|
|
62
|
+
"format_ts",
|
|
63
|
+
"zero_remainder",
|
|
64
|
+
# timestamp helpers
|
|
65
|
+
"timestamp",
|
|
66
|
+
"round_timestamp",
|
|
67
|
+
"ts_to_day",
|
|
68
|
+
"ms_to_sec",
|
|
69
|
+
"sec_to_ms",
|
|
70
|
+
"ts_to_humandate",
|
|
71
|
+
"timestamp_to_datetime",
|
|
72
|
+
# enums
|
|
73
|
+
"CloseConditions",
|
|
74
|
+
"KafkaTopics",
|
|
75
|
+
"DealType",
|
|
76
|
+
"BinanceOrderModel",
|
|
77
|
+
"Status",
|
|
78
|
+
"Strategy",
|
|
79
|
+
"OrderType",
|
|
80
|
+
"TimeInForce",
|
|
81
|
+
"OrderSide",
|
|
82
|
+
"OrderStatus",
|
|
83
|
+
"TrendEnum",
|
|
84
|
+
"BinanceKlineIntervals",
|
|
85
|
+
"KucoinKlineIntervals",
|
|
86
|
+
"AutotradeSettingsDocument",
|
|
87
|
+
"UserRoles",
|
|
88
|
+
"QuoteAssets",
|
|
89
|
+
"ExchangeId",
|
|
90
|
+
"HABollinguerSpread",
|
|
91
|
+
"SignalsConsumer",
|
|
92
|
+
"MarketDominance",
|
|
93
|
+
]
|
shared/enums.py
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
from enum import Enum
|
|
2
|
+
from pydantic import BaseModel, field_validator
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class CloseConditions(str, Enum):
|
|
6
|
+
dynamic_trailling = "dynamic_trailling"
|
|
7
|
+
# No trailling, standard stop loss
|
|
8
|
+
timestamp = "timestamp"
|
|
9
|
+
# binbot-research param (self.market_trend_reversal)
|
|
10
|
+
market_reversal = "market_reversal"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class KafkaTopics(str, Enum):
|
|
14
|
+
klines_store_topic = "klines-store-topic"
|
|
15
|
+
technical_indicators = "technical-indicators"
|
|
16
|
+
signals = "signals"
|
|
17
|
+
restart_streaming = "restart-streaming"
|
|
18
|
+
restart_autotrade = "restart-autotrade"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class DealType(str, Enum):
|
|
22
|
+
base_order = "base_order"
|
|
23
|
+
take_profit = "take_profit"
|
|
24
|
+
stop_loss = "stop_loss"
|
|
25
|
+
short_sell = "short_sell"
|
|
26
|
+
short_buy = "short_buy"
|
|
27
|
+
margin_short = "margin_short"
|
|
28
|
+
panic_close = "panic_close"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class BinanceOrderModel(BaseModel):
|
|
32
|
+
"""
|
|
33
|
+
Data model given by Binance,
|
|
34
|
+
therefore it should be strings
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
order_type: str
|
|
38
|
+
time_in_force: str
|
|
39
|
+
timestamp: int
|
|
40
|
+
order_id: int
|
|
41
|
+
order_side: str
|
|
42
|
+
pair: str
|
|
43
|
+
qty: float
|
|
44
|
+
status: str
|
|
45
|
+
price: float
|
|
46
|
+
deal_type: DealType
|
|
47
|
+
|
|
48
|
+
@field_validator("timestamp", "order_id", "price", "qty", "order_id")
|
|
49
|
+
@classmethod
|
|
50
|
+
def validate_str_numbers(cls, v):
|
|
51
|
+
if isinstance(v, float):
|
|
52
|
+
return v
|
|
53
|
+
elif isinstance(v, int):
|
|
54
|
+
return v
|
|
55
|
+
elif isinstance(v, str):
|
|
56
|
+
return float(v)
|
|
57
|
+
else:
|
|
58
|
+
raise ValueError(f"{v} must be a number")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class Status(str, Enum):
|
|
62
|
+
all = "all"
|
|
63
|
+
inactive = "inactive"
|
|
64
|
+
active = "active"
|
|
65
|
+
completed = "completed"
|
|
66
|
+
error = "error"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class Strategy(str, Enum):
|
|
70
|
+
long = "long"
|
|
71
|
+
margin_short = "margin_short"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class OrderType(str, Enum):
|
|
75
|
+
limit = "LIMIT"
|
|
76
|
+
market = "MARKET"
|
|
77
|
+
stop_loss = "STOP_LOSS"
|
|
78
|
+
stop_loss_limit = "STOP_LOSS_LIMIT"
|
|
79
|
+
take_profit = "TAKE_PROFIT"
|
|
80
|
+
take_profit_limit = "TAKE_PROFIT_LIMIT"
|
|
81
|
+
limit_maker = "LIMIT_MAKER"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class TimeInForce(str, Enum):
|
|
85
|
+
gtc = "GTC"
|
|
86
|
+
ioc = "IOC"
|
|
87
|
+
fok = "FOK"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class OrderSide(str, Enum):
|
|
91
|
+
buy = "BUY"
|
|
92
|
+
sell = "SELL"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class OrderStatus(str, Enum):
|
|
96
|
+
"""
|
|
97
|
+
Must be all uppercase for SQL alchemy
|
|
98
|
+
and Alembic to do migration properly
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
NEW = "NEW"
|
|
102
|
+
PARTIALLY_FILLED = "PARTIALLY_FILLED"
|
|
103
|
+
FILLED = "FILLED"
|
|
104
|
+
CANCELED = "CANCELED"
|
|
105
|
+
REJECTED = "REJECTED"
|
|
106
|
+
EXPIRED = "EXPIRED"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class TrendEnum(str, Enum):
|
|
110
|
+
up_trend = "uptrend"
|
|
111
|
+
down_trend = "downtrend"
|
|
112
|
+
neutral = None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class DealType(str, Enum):
|
|
116
|
+
base_order = "base_order"
|
|
117
|
+
take_profit = "take_profit"
|
|
118
|
+
stop_loss = "stop_loss"
|
|
119
|
+
short_sell = "short_sell"
|
|
120
|
+
short_buy = "short_buy"
|
|
121
|
+
margin_short = "margin_short"
|
|
122
|
+
panic_close = "panic_close"
|
|
123
|
+
trailling_profit = "trailling_profit"
|
|
124
|
+
conversion = "conversion" # converts one crypto to another
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class BinanceKlineIntervals(str, Enum):
|
|
128
|
+
one_minute = "1m"
|
|
129
|
+
three_minutes = "3m"
|
|
130
|
+
five_minutes = "5m"
|
|
131
|
+
fifteen_minutes = "15m"
|
|
132
|
+
thirty_minutes = "30m"
|
|
133
|
+
one_hour = "1h"
|
|
134
|
+
two_hours = "2h"
|
|
135
|
+
four_hours = "4h"
|
|
136
|
+
six_hours = "6h"
|
|
137
|
+
eight_hours = "8h"
|
|
138
|
+
twelve_hours = "12h"
|
|
139
|
+
one_day = "1d"
|
|
140
|
+
three_days = "3d"
|
|
141
|
+
one_week = "1w"
|
|
142
|
+
one_month = "1M"
|
|
143
|
+
|
|
144
|
+
def bin_size(self):
|
|
145
|
+
return int(self.value[:-1])
|
|
146
|
+
|
|
147
|
+
def unit(self):
|
|
148
|
+
if self.value[-1:] == "m":
|
|
149
|
+
return "minute"
|
|
150
|
+
elif self.value[-1:] == "h":
|
|
151
|
+
return "hour"
|
|
152
|
+
elif self.value[-1:] == "d":
|
|
153
|
+
return "day"
|
|
154
|
+
elif self.value[-1:] == "w":
|
|
155
|
+
return "week"
|
|
156
|
+
elif self.value[-1:] == "M":
|
|
157
|
+
return "month"
|
|
158
|
+
|
|
159
|
+
def to_kucoin_interval(self) -> str:
|
|
160
|
+
"""
|
|
161
|
+
Convert Binance interval format to Kucoin interval format.
|
|
162
|
+
|
|
163
|
+
Binance: 1m, 5m, 15m, 1h, 4h, 1d, 1w
|
|
164
|
+
Kucoin: 1min, 5min, 15min, 1hour, 4hour, 1day, 1week
|
|
165
|
+
"""
|
|
166
|
+
interval_map = {
|
|
167
|
+
"1m": "1min",
|
|
168
|
+
"3m": "3min",
|
|
169
|
+
"5m": "5min",
|
|
170
|
+
"15m": "15min",
|
|
171
|
+
"30m": "30min",
|
|
172
|
+
"1h": "1hour",
|
|
173
|
+
"2h": "2hour",
|
|
174
|
+
"4h": "4hour",
|
|
175
|
+
"6h": "6hour",
|
|
176
|
+
"8h": "8hour",
|
|
177
|
+
"12h": "12hour",
|
|
178
|
+
"1d": "1day",
|
|
179
|
+
"3d": "3day",
|
|
180
|
+
"1w": "1week",
|
|
181
|
+
"1M": "1month",
|
|
182
|
+
}
|
|
183
|
+
return interval_map.get(self.value, self.value)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
class KucoinKlineIntervals(str, Enum):
|
|
187
|
+
ONE_MINUTE = "1min"
|
|
188
|
+
THREE_MINUTES = "3min"
|
|
189
|
+
FIVE_MINUTES = "5min"
|
|
190
|
+
FIFTEEN_MINUTES = "15min"
|
|
191
|
+
THIRTY_MINUTES = "30min"
|
|
192
|
+
ONE_HOUR = "1hour"
|
|
193
|
+
TWO_HOURS = "2hour"
|
|
194
|
+
FOUR_HOURS = "4hour"
|
|
195
|
+
SIX_HOURS = "6hour"
|
|
196
|
+
EIGHT_HOURS = "8hour"
|
|
197
|
+
TWELVE_HOURS = "12hour"
|
|
198
|
+
ONE_DAY = "1day"
|
|
199
|
+
ONE_WEEK = "1week"
|
|
200
|
+
|
|
201
|
+
# Helper to calculate interval duration in milliseconds
|
|
202
|
+
def get_interval_ms(interval_str: str) -> int:
|
|
203
|
+
"""Convert Kucoin interval string to milliseconds"""
|
|
204
|
+
interval_map = {
|
|
205
|
+
"1min": 60 * 1000,
|
|
206
|
+
"3min": 3 * 60 * 1000,
|
|
207
|
+
"5min": 5 * 60 * 1000,
|
|
208
|
+
"15min": 15 * 60 * 1000,
|
|
209
|
+
"30min": 30 * 60 * 1000,
|
|
210
|
+
"1hour": 60 * 60 * 1000,
|
|
211
|
+
"2hour": 2 * 60 * 60 * 1000,
|
|
212
|
+
"4hour": 4 * 60 * 60 * 1000,
|
|
213
|
+
"6hour": 6 * 60 * 60 * 1000,
|
|
214
|
+
"8hour": 8 * 60 * 60 * 1000,
|
|
215
|
+
"12hour": 12 * 60 * 60 * 1000,
|
|
216
|
+
"1day": 24 * 60 * 60 * 1000,
|
|
217
|
+
"1week": 7 * 24 * 60 * 60 * 1000,
|
|
218
|
+
}
|
|
219
|
+
return interval_map.get(interval_str, 60 * 1000) # Default to 1 minute
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
class AutotradeSettingsDocument(str, Enum):
|
|
223
|
+
# Autotrade settings for test bots
|
|
224
|
+
test_autotrade_settings = "test_autotrade_settings"
|
|
225
|
+
# Autotrade settings for real bots
|
|
226
|
+
settings = "autotrade_settings"
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
class UserRoles(str, Enum):
|
|
230
|
+
# Full access to all resources
|
|
231
|
+
user = "user"
|
|
232
|
+
# Access to terminal and customer accounts
|
|
233
|
+
admin = "admin"
|
|
234
|
+
# Only access to funds and client website
|
|
235
|
+
customer = "customer"
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
class QuoteAssets(str, Enum):
|
|
239
|
+
"""
|
|
240
|
+
Quote assets supported by Binbot orders
|
|
241
|
+
Includes both crypto assets and fiat currencies
|
|
242
|
+
"""
|
|
243
|
+
|
|
244
|
+
# Crypto assets
|
|
245
|
+
USDT = "USDT"
|
|
246
|
+
USDC = "USDC"
|
|
247
|
+
BTC = "BTC"
|
|
248
|
+
ETH = "ETH"
|
|
249
|
+
# Backwards compatibility
|
|
250
|
+
TRY = "TRY"
|
|
251
|
+
|
|
252
|
+
def is_fiat(self) -> bool:
|
|
253
|
+
"""Check if the asset is a fiat currency"""
|
|
254
|
+
return self.value in ["TRY", "EUR", "USD"]
|
|
255
|
+
|
|
256
|
+
@classmethod
|
|
257
|
+
def get_fiat_currencies(cls) -> list["QuoteAssets"]:
|
|
258
|
+
"""
|
|
259
|
+
Get all fiat currencies
|
|
260
|
+
"""
|
|
261
|
+
return [asset for asset in cls if asset.is_fiat()]
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
class ExchangeId(str, Enum):
|
|
265
|
+
KUCOIN = "kucoin"
|
|
266
|
+
BINANCE = "binance"
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
class MarketDominance(str, Enum):
|
|
270
|
+
NEUTRAL = "neutral"
|
|
271
|
+
GAINERS = "gainers"
|
|
272
|
+
LOSERS = "losers"
|
shared/logging_config.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
import time
|
|
4
|
+
from typing import Iterable
|
|
5
|
+
|
|
6
|
+
DEFAULT_FORMAT = "%(asctime)s.%(msecs)03d UTC %(levelname)s %(name)s: %(message)s"
|
|
7
|
+
DEFAULT_DATEFMT = "%Y-%m-%d %H:%M:%S"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def configure_logging(
|
|
11
|
+
*,
|
|
12
|
+
level: str | None = None,
|
|
13
|
+
fmt: str = DEFAULT_FORMAT,
|
|
14
|
+
datefmt: str = DEFAULT_DATEFMT,
|
|
15
|
+
utc: bool = True,
|
|
16
|
+
force: bool = True,
|
|
17
|
+
quiet_loggers: Iterable[str] | None = ("uvicorn", "confluent_kafka"),
|
|
18
|
+
) -> None:
|
|
19
|
+
"""
|
|
20
|
+
Configure root logging consistently across API services.
|
|
21
|
+
"""
|
|
22
|
+
resolved_level = str(level or os.environ.get("LOG_LEVEL", "INFO")).upper()
|
|
23
|
+
logging.basicConfig(
|
|
24
|
+
level=resolved_level,
|
|
25
|
+
format=fmt,
|
|
26
|
+
datefmt=datefmt,
|
|
27
|
+
force=force,
|
|
28
|
+
)
|
|
29
|
+
if utc:
|
|
30
|
+
logging.Formatter.converter = time.gmtime
|
|
31
|
+
|
|
32
|
+
if quiet_loggers:
|
|
33
|
+
quiet_level = os.environ.get("QUIET_LIB_LOG_LEVEL", "WARNING").upper()
|
|
34
|
+
for name in quiet_loggers:
|
|
35
|
+
logging.getLogger(name).setLevel(quiet_level)
|
|
36
|
+
|
|
37
|
+
logging.getLogger(__name__).debug(
|
|
38
|
+
"Logging configured (level=%s, utc=%s, force=%s)",
|
|
39
|
+
resolved_level,
|
|
40
|
+
utc,
|
|
41
|
+
force,
|
|
42
|
+
)
|
shared/maths.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import math
|
|
2
|
+
import re
|
|
3
|
+
from decimal import Decimal
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from typing import cast
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def ensure_float(value: str | int | float) -> float:
|
|
9
|
+
if isinstance(value, str) or isinstance(value, int):
|
|
10
|
+
return float(value)
|
|
11
|
+
|
|
12
|
+
return value
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def supress_trailling(value: str | float | int) -> float:
|
|
16
|
+
"""
|
|
17
|
+
Supress trailing 0s without changing the numeric value.
|
|
18
|
+
|
|
19
|
+
Also attempts to normalise away scientific notation for small
|
|
20
|
+
numbers while preserving the original value.
|
|
21
|
+
"""
|
|
22
|
+
# Use Decimal(str(value)) to avoid binary float rounding issues
|
|
23
|
+
dec = Decimal(str(value))
|
|
24
|
+
# Normalise to remove redundant trailing zeros while preserving value
|
|
25
|
+
dec = dec.normalize()
|
|
26
|
+
return float(dec)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def round_numbers(value: float | int, decimals=6) -> float | int:
|
|
30
|
+
decimal_points = 10 ** int(decimals)
|
|
31
|
+
number = float(value)
|
|
32
|
+
result = math.floor(number * decimal_points) / decimal_points
|
|
33
|
+
if decimals == 0:
|
|
34
|
+
result = int(result)
|
|
35
|
+
return result
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def round_numbers_ceiling(value, decimals=6):
|
|
39
|
+
decimal_points = 10 ** int(decimals)
|
|
40
|
+
number = float(value)
|
|
41
|
+
result = math.ceil(number * decimal_points) / decimal_points
|
|
42
|
+
if decimals == 0:
|
|
43
|
+
result = int(result)
|
|
44
|
+
return float(result)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def round_numbers_floor(value, decimals=6):
|
|
48
|
+
decimal_points = 10 ** int(decimals)
|
|
49
|
+
number = float(value)
|
|
50
|
+
result = math.floor(number * decimal_points) / decimal_points
|
|
51
|
+
if decimals == 0:
|
|
52
|
+
result = int(result)
|
|
53
|
+
return float(result)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def supress_notation(num: float, precision: int = 0) -> str:
|
|
57
|
+
"""
|
|
58
|
+
Supress scientific notation and return a fixed-point string.
|
|
59
|
+
|
|
60
|
+
Examples
|
|
61
|
+
-------
|
|
62
|
+
8e-5 -> "0.00008" (precision=5)
|
|
63
|
+
123.456, precision=2 -> "123.46"
|
|
64
|
+
"""
|
|
65
|
+
dec = Decimal(str(num))
|
|
66
|
+
|
|
67
|
+
if precision >= 0:
|
|
68
|
+
# Quantize to the requested number of decimal places using
|
|
69
|
+
# Decimal's standard rounding (half even by default).
|
|
70
|
+
quant = Decimal(1).scaleb(-precision) # 10**-precision
|
|
71
|
+
dec = dec.quantize(quant)
|
|
72
|
+
decimal_points = precision
|
|
73
|
+
else:
|
|
74
|
+
# Let Decimal decide the scale, then format with all significant
|
|
75
|
+
# decimal places.
|
|
76
|
+
dec = dec.normalize()
|
|
77
|
+
exp = cast(int, dec.as_tuple().exponent)
|
|
78
|
+
decimal_points = -exp
|
|
79
|
+
|
|
80
|
+
return f"{dec:.{decimal_points}f}"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def interval_to_millisecs(interval: str) -> int:
|
|
84
|
+
time, notation = re.findall(r"[A-Za-z]+|\d+", interval)
|
|
85
|
+
if notation == "m":
|
|
86
|
+
# minutes
|
|
87
|
+
return int(time) * 60 * 1000
|
|
88
|
+
|
|
89
|
+
if notation == "h":
|
|
90
|
+
# hours
|
|
91
|
+
return int(time) * 60 * 60 * 1000
|
|
92
|
+
|
|
93
|
+
if notation == "d":
|
|
94
|
+
# day
|
|
95
|
+
return int(time) * 24 * 60 * 60 * 1000
|
|
96
|
+
|
|
97
|
+
if notation == "w":
|
|
98
|
+
# weeks
|
|
99
|
+
return int(time) * 5 * 24 * 60 * 60 * 1000
|
|
100
|
+
|
|
101
|
+
if notation == "M":
|
|
102
|
+
# month
|
|
103
|
+
return int(time) * 30 * 24 * 60 * 60 * 1000
|
|
104
|
+
|
|
105
|
+
return 0
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def format_ts(time: datetime) -> str:
|
|
109
|
+
"""
|
|
110
|
+
Central place to format datetime
|
|
111
|
+
to human-readable date
|
|
112
|
+
"""
|
|
113
|
+
return time.strftime("%Y-%m-%d %H:%M:%S.%f")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def zero_remainder(x):
|
|
117
|
+
number = x
|
|
118
|
+
|
|
119
|
+
while True:
|
|
120
|
+
if number % x == 0:
|
|
121
|
+
return number
|
|
122
|
+
else:
|
|
123
|
+
number += x
|
shared/timestamps.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from time import time
|
|
3
|
+
import math
|
|
4
|
+
from zoneinfo import ZoneInfo
|
|
5
|
+
from shared.maths import round_numbers_ceiling
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
|
|
8
|
+
format = "%Y-%m-%d %H:%M:%S"
|
|
9
|
+
|
|
10
|
+
def timestamp() -> int:
|
|
11
|
+
ts = time() * 1000
|
|
12
|
+
rounded_ts = round_timestamp(ts)
|
|
13
|
+
return rounded_ts
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def round_timestamp(ts: int | float) -> int:
|
|
17
|
+
"""
|
|
18
|
+
Round (or trim) millisecond timestamps to at most 13 digits.
|
|
19
|
+
|
|
20
|
+
For realistic millisecond timestamps (13 digits) this is a no-op.
|
|
21
|
+
For larger integers, extra lower-order digits are discarded so that
|
|
22
|
+
the result has exactly 13 digits.
|
|
23
|
+
"""
|
|
24
|
+
ts_int = int(ts)
|
|
25
|
+
digits = int(math.log10(ts_int)) + 1 if ts_int > 0 else 1
|
|
26
|
+
|
|
27
|
+
if digits > 13:
|
|
28
|
+
# Drop extra lower-order digits to get back to 13 digits.
|
|
29
|
+
decimals = digits - 13
|
|
30
|
+
factor = 10**decimals
|
|
31
|
+
return ts_int // factor
|
|
32
|
+
else:
|
|
33
|
+
return ts_int
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def ts_to_day(ts: float | int) -> str:
|
|
37
|
+
"""
|
|
38
|
+
Convert timestamp to date (day) format YYYY-MM-DD
|
|
39
|
+
"""
|
|
40
|
+
digits = int(math.log10(ts)) + 1
|
|
41
|
+
if digits >= 10:
|
|
42
|
+
ts = ts // pow(10, digits - 10)
|
|
43
|
+
else:
|
|
44
|
+
ts = ts * pow(10, 10 - digits)
|
|
45
|
+
|
|
46
|
+
dt_obj = datetime.fromtimestamp(ts)
|
|
47
|
+
b_str_date = datetime.strftime(dt_obj, format)
|
|
48
|
+
return b_str_date
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def ms_to_sec(ms: int) -> int:
|
|
52
|
+
"""
|
|
53
|
+
JavaScript needs 13 digits (milliseconds)
|
|
54
|
+
for new Date() to parse timestamps
|
|
55
|
+
correctly
|
|
56
|
+
"""
|
|
57
|
+
return ms // 1000
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def sec_to_ms(sec: int) -> int:
|
|
61
|
+
"""
|
|
62
|
+
Python datetime needs 10 digits (seconds)
|
|
63
|
+
to parse dates correctly from timestamps
|
|
64
|
+
"""
|
|
65
|
+
return sec * 1000
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def ts_to_humandate(ts: int) -> str:
|
|
69
|
+
"""Convert timestamp to human-readable date.
|
|
70
|
+
|
|
71
|
+
Accepts either seconds (10 digits) or milliseconds (13 digits) and
|
|
72
|
+
normalises to seconds for ``datetime.fromtimestamp``.
|
|
73
|
+
"""
|
|
74
|
+
if len(str(abs(ts))) > 10:
|
|
75
|
+
# if timestamp is in milliseconds
|
|
76
|
+
ts = ts // 1000
|
|
77
|
+
return datetime.fromtimestamp(ts).strftime(format)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def timestamp_to_datetime(timestamp: str | int) -> str:
|
|
81
|
+
"""
|
|
82
|
+
Convert a timestamp in milliseconds to seconds
|
|
83
|
+
to match expectation of datetime
|
|
84
|
+
Then convert to a human readable format.
|
|
85
|
+
|
|
86
|
+
Parameters
|
|
87
|
+
----------
|
|
88
|
+
timestamp : str | int
|
|
89
|
+
The timestamp in milliseconds. Always in London timezone
|
|
90
|
+
to avoid inconsistencies across environments (Github, prod, local)
|
|
91
|
+
"""
|
|
92
|
+
timestamp = int(round_numbers_ceiling(int(timestamp) / 1000, 0))
|
|
93
|
+
dt = datetime.fromtimestamp(
|
|
94
|
+
timestamp, tz=ZoneInfo(os.getenv("TZ", "Europe/London"))
|
|
95
|
+
)
|
|
96
|
+
return dt.strftime(format)
|
|
97
|
+
|