pybinbot 0.0.1__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/order.py +130 -0
- models/signals.py +60 -0
- pybinbot-0.0.1.dist-info/METADATA +53 -0
- pybinbot-0.0.1.dist-info/RECORD +13 -0
- pybinbot-0.0.1.dist-info/WHEEL +5 -0
- pybinbot-0.0.1.dist-info/licenses/LICENSE +18 -0
- pybinbot-0.0.1.dist-info/top_level.txt +2 -0
- shared/enums.py +266 -0
- shared/logging_config.py +42 -0
- shared/maths.py +111 -0
- shared/timestamps.py +66 -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/order.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
from typing import List, Optional
|
|
2
|
+
from uuid import uuid4, UUID
|
|
3
|
+
from pydantic import BaseModel, Field, field_validator
|
|
4
|
+
from databases.utils import Amount, timestamp
|
|
5
|
+
from tools.enum_definitions import (
|
|
6
|
+
QuoteAssets,
|
|
7
|
+
BinanceKlineIntervals,
|
|
8
|
+
CloseConditions,
|
|
9
|
+
Status,
|
|
10
|
+
Strategy,
|
|
11
|
+
DealType,
|
|
12
|
+
OrderStatus,
|
|
13
|
+
)
|
|
14
|
+
from tools.handle_error import IResponseBase
|
|
15
|
+
from tools.maths import ts_to_humandate
|
|
16
|
+
from databases.tables.bot_table import BotTable, PaperTradingTable
|
|
17
|
+
from databases.tables.deal_table import DealTable
|
|
18
|
+
from databases.tables.order_table import ExchangeOrderTable
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class OrderModel(BaseModel):
|
|
22
|
+
order_type: str = Field(
|
|
23
|
+
description="Because every exchange has different naming, we should keep it as a str rather than OrderType enum"
|
|
24
|
+
)
|
|
25
|
+
time_in_force: str
|
|
26
|
+
timestamp: int = Field(default=0)
|
|
27
|
+
order_id: int | str = Field(
|
|
28
|
+
description="Because every exchange has id type, we should keep it as looose as possible. Int is for backwards compatibility"
|
|
29
|
+
)
|
|
30
|
+
order_side: str = Field(
|
|
31
|
+
description="Because every exchange has different naming, we should keep it as a str rather than OrderType enum"
|
|
32
|
+
)
|
|
33
|
+
pair: str
|
|
34
|
+
qty: float
|
|
35
|
+
status: OrderStatus
|
|
36
|
+
price: float
|
|
37
|
+
deal_type: DealType
|
|
38
|
+
model_config = {
|
|
39
|
+
"from_attributes": True,
|
|
40
|
+
"use_enum_values": True,
|
|
41
|
+
"json_schema_extra": {
|
|
42
|
+
"description": "Most fields are optional. Deal field is generated internally, orders are filled up by Exchange",
|
|
43
|
+
"examples": [
|
|
44
|
+
{
|
|
45
|
+
"order_type": "LIMIT",
|
|
46
|
+
"time_in_force": "GTC",
|
|
47
|
+
"timestamp": 0,
|
|
48
|
+
"order_id": 0,
|
|
49
|
+
"order_side": "BUY",
|
|
50
|
+
"pair": "",
|
|
51
|
+
"qty": 0,
|
|
52
|
+
"status": "",
|
|
53
|
+
"price": 0,
|
|
54
|
+
}
|
|
55
|
+
],
|
|
56
|
+
},
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
@classmethod
|
|
60
|
+
def dump_from_table(cls, bot):
|
|
61
|
+
if isinstance(bot, BotTable) or isinstance(bot, PaperTradingTable):
|
|
62
|
+
model = BotModel.model_construct(**bot.model_dump())
|
|
63
|
+
deal_model = DealModel.model_construct(**bot.deal.model_dump())
|
|
64
|
+
order_models = [
|
|
65
|
+
OrderModel.model_construct(**order.model_dump()) for order in bot.orders
|
|
66
|
+
]
|
|
67
|
+
model.deal = deal_model
|
|
68
|
+
model.orders = order_models
|
|
69
|
+
return model
|
|
70
|
+
else:
|
|
71
|
+
return bot
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class DealModel(BaseModel):
|
|
75
|
+
base_order_size: Amount = Field(default=0, gt=-1)
|
|
76
|
+
current_price: Amount = Field(default=0)
|
|
77
|
+
take_profit_price: Amount = Field(default=0)
|
|
78
|
+
trailling_stop_loss_price: Amount = Field(
|
|
79
|
+
default=0,
|
|
80
|
+
description="take_profit but for trailling, to avoid confusion, trailling_profit_price always be > trailling_stop_loss_price",
|
|
81
|
+
)
|
|
82
|
+
trailling_profit_price: Amount = Field(default=0)
|
|
83
|
+
stop_loss_price: Amount = Field(default=0)
|
|
84
|
+
total_interests: float = Field(default=0, gt=-1)
|
|
85
|
+
total_commissions: float = Field(default=0, gt=-1)
|
|
86
|
+
margin_loan_id: int = Field(
|
|
87
|
+
default=0,
|
|
88
|
+
ge=0,
|
|
89
|
+
description="Txid from Binance. This is used to check if there is a loan, 0 means no loan",
|
|
90
|
+
)
|
|
91
|
+
margin_repay_id: int = Field(
|
|
92
|
+
default=0, ge=0, description="= 0, it has not been repaid"
|
|
93
|
+
)
|
|
94
|
+
opening_price: Amount = Field(
|
|
95
|
+
default=0,
|
|
96
|
+
description="replaces previous buy_price or short_sell_price/margin_short_sell_price",
|
|
97
|
+
)
|
|
98
|
+
opening_qty: Amount = Field(
|
|
99
|
+
default=0,
|
|
100
|
+
description="replaces previous buy_total_qty or short_sell_qty/margin_short_sell_qty",
|
|
101
|
+
)
|
|
102
|
+
opening_timestamp: int = Field(default=0)
|
|
103
|
+
closing_price: Amount = Field(
|
|
104
|
+
default=0,
|
|
105
|
+
description="replaces previous sell_price or short_sell_price/margin_short_sell_price",
|
|
106
|
+
)
|
|
107
|
+
closing_qty: Amount = Field(
|
|
108
|
+
default=0,
|
|
109
|
+
description="replaces previous sell_qty or short_sell_qty/margin_short_sell_qty",
|
|
110
|
+
)
|
|
111
|
+
closing_timestamp: int = Field(
|
|
112
|
+
default=0,
|
|
113
|
+
description="replaces previous buy_timestamp or margin/short_sell timestamps",
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
@field_validator("margin_loan_id", mode="before")
|
|
117
|
+
@classmethod
|
|
118
|
+
def validate_margin_loan_id(cls, value):
|
|
119
|
+
if isinstance(value, float):
|
|
120
|
+
return int(value)
|
|
121
|
+
else:
|
|
122
|
+
return value
|
|
123
|
+
|
|
124
|
+
@field_validator("margin_loan_id", mode="after")
|
|
125
|
+
@classmethod
|
|
126
|
+
def cast_float(cls, value):
|
|
127
|
+
if isinstance(value, float):
|
|
128
|
+
return int(value)
|
|
129
|
+
else:
|
|
130
|
+
return value
|
models/signals.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Shared Pydantic models for Binquant and Binbot.
|
|
3
|
+
This file is auto-generated and should be reviewed for deduplication and refactoring.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
# Example imports (update as needed)
|
|
7
|
+
from pydantic import BaseModel, Field, EmailStr, field_validator, ConfigDict
|
|
8
|
+
from typing import Optional, List, Sequence, Union
|
|
9
|
+
from uuid import UUID, uuid4
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
|
|
12
|
+
# ...existing code...
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# Example shared model (copy actual model code from source files)
|
|
16
|
+
class HABollinguerSpread(BaseModel):
|
|
17
|
+
"""
|
|
18
|
+
Pydantic model for the Bollinguer spread.
|
|
19
|
+
(optional)
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
bb_high: float
|
|
23
|
+
bb_mid: float
|
|
24
|
+
bb_low: float
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class SignalsConsumer(BaseModel):
|
|
28
|
+
"""
|
|
29
|
+
Pydantic model for the signals consumer.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
type: str = Field(default="signal")
|
|
33
|
+
date: str = Field(
|
|
34
|
+
default_factory=lambda: datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
35
|
+
)
|
|
36
|
+
spread: Optional[float] = Field(default=0)
|
|
37
|
+
current_price: Optional[float] = Field(default=0)
|
|
38
|
+
msg: str
|
|
39
|
+
symbol: str
|
|
40
|
+
algo: str
|
|
41
|
+
bot_strategy: str = Field(default="long")
|
|
42
|
+
bb_spreads: Optional[HABollinguerSpread]
|
|
43
|
+
autotrade: bool = Field(default=True, description="If it is in testing mode, False")
|
|
44
|
+
|
|
45
|
+
model_config = ConfigDict(
|
|
46
|
+
extra="allow",
|
|
47
|
+
use_enum_values=True,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
@field_validator("spread", "current_price")
|
|
51
|
+
@classmethod
|
|
52
|
+
def name_must_contain_space(cls, v):
|
|
53
|
+
if v is None:
|
|
54
|
+
return 0
|
|
55
|
+
elif isinstance(v, str):
|
|
56
|
+
return float(v)
|
|
57
|
+
elif isinstance(v, float):
|
|
58
|
+
return v
|
|
59
|
+
else:
|
|
60
|
+
raise ValueError("must be a float or 0")
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pybinbot
|
|
3
|
+
Version: 0.0.1
|
|
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: aiokafka>=0.12.0
|
|
10
|
+
Requires-Dist: apscheduler>=3.6.3
|
|
11
|
+
Requires-Dist: confluent-kafka>=2.11.1
|
|
12
|
+
Requires-Dist: kafka-python>=2.0.2
|
|
13
|
+
Requires-Dist: kucoin-universal-sdk>=1.3.0
|
|
14
|
+
Requires-Dist: numpy==2.2.0
|
|
15
|
+
Requires-Dist: pandas>=2.2.3
|
|
16
|
+
Requires-Dist: passlib
|
|
17
|
+
Requires-Dist: pydantic[email]>=2.0.0
|
|
18
|
+
Requires-Dist: pydantic-settings>=2.10.1
|
|
19
|
+
Requires-Dist: pymongo>=4.6.3
|
|
20
|
+
Requires-Dist: python-dotenv
|
|
21
|
+
Requires-Dist: python-jose
|
|
22
|
+
Requires-Dist: requests>=2.28.1
|
|
23
|
+
Requires-Dist: requests-cache>=1.2.0
|
|
24
|
+
Requires-Dist: requests-html>=0.10.0
|
|
25
|
+
Requires-Dist: scipy==1.14.1
|
|
26
|
+
Requires-Dist: websocket-client>=1.5.0
|
|
27
|
+
Provides-Extra: dev
|
|
28
|
+
Requires-Dist: pytest>=9.0.2; extra == "dev"
|
|
29
|
+
Requires-Dist: ruff; extra == "dev"
|
|
30
|
+
Dynamic: license-file
|
|
31
|
+
|
|
32
|
+
# binbot-utils
|
|
33
|
+
|
|
34
|
+
Utility functions for the binbot project.
|
|
35
|
+
|
|
36
|
+
## Installation
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
uv sync
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Usage
|
|
43
|
+
|
|
44
|
+
Import and use the utilities in your Python code.
|
|
45
|
+
|
|
46
|
+
## Publishing
|
|
47
|
+
|
|
48
|
+
To build and upload to PyPI:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
python -m build
|
|
52
|
+
python -m twine upload dist/*
|
|
53
|
+
```
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
models/bot_base.py,sha256=z9hSK7uVLW4oDmPdtntYozo8RnQnpZtFi8fL1r9Qd5Q,3593
|
|
2
|
+
models/order.py,sha256=pQbL2rpgXvmkqiwpKwVFkuJnn3i1QR-omuc4E5eDWsk,4573
|
|
3
|
+
models/signals.py,sha256=DZc2r9x9XouRu9u14wI-zVEBX23-7wn_-MFButAQUUs,1621
|
|
4
|
+
pybinbot-0.0.1.dist-info/licenses/LICENSE,sha256=ECEAqAQ81zTT8PeN7gYqbkZtewkyeleEqQ26MxuHQxs,938
|
|
5
|
+
shared/enums.py,sha256=8rRvUIMq6ApMBBgd_Ef0_8aM6HeYnB37ro5Mh9W6_WY,6624
|
|
6
|
+
shared/logging_config.py,sha256=XZblKXH9KsLUDbIJqFRZPzI0h17-CRBZH4KktVak-TI,1144
|
|
7
|
+
shared/maths.py,sha256=Xcj4_rx9kHjowFhk8ysMNB_FaCcrGcHpK_-0P62xj70,2660
|
|
8
|
+
shared/timestamps.py,sha256=FtZw6hKNJ1N1NUBusW4g5L1PwUZtX98LGFSAxxZFsVw,1587
|
|
9
|
+
shared/types.py,sha256=KfuJzjsbMUHFcBaQ6ZXUbuSyFHbHqehgeY73Zt8lqO8,173
|
|
10
|
+
pybinbot-0.0.1.dist-info/METADATA,sha256=loC9ZtGqe3JanBSuBIn3WNChG4TLcDvcCYr1RrhTLjQ,1220
|
|
11
|
+
pybinbot-0.0.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
12
|
+
pybinbot-0.0.1.dist-info/top_level.txt,sha256=pm2plP5btgXnaBayjioY_GnmGMW0k3q0j7xXnUsunSY,14
|
|
13
|
+
pybinbot-0.0.1.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.
|
shared/enums.py
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
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"
|
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,111 @@
|
|
|
1
|
+
import math
|
|
2
|
+
import re
|
|
3
|
+
from decimal import Decimal
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def ensure_float(value: str | int | float) -> float:
|
|
8
|
+
if isinstance(value, str) or isinstance(value, int):
|
|
9
|
+
return float(value)
|
|
10
|
+
|
|
11
|
+
return value
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def supress_trailling(value: str | float | int) -> float:
|
|
15
|
+
"""
|
|
16
|
+
Supress trilling 0s
|
|
17
|
+
this function will not round the number
|
|
18
|
+
e.g. 3.140, 3.140000004
|
|
19
|
+
|
|
20
|
+
also supress scientific notation
|
|
21
|
+
e.g. 2.05-5
|
|
22
|
+
"""
|
|
23
|
+
value = float(value)
|
|
24
|
+
# supress scientific notation
|
|
25
|
+
number = float(f"{value:f}")
|
|
26
|
+
number = float("{0:g}".format(number))
|
|
27
|
+
return number
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def round_numbers(value: float | int, decimals=6) -> float | int:
|
|
31
|
+
decimal_points = 10 ** int(decimals)
|
|
32
|
+
number = float(value)
|
|
33
|
+
result = math.floor(number * decimal_points) / decimal_points
|
|
34
|
+
if decimals == 0:
|
|
35
|
+
result = int(result)
|
|
36
|
+
return result
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def round_numbers_ceiling(value, decimals=6):
|
|
40
|
+
decimal_points = 10 ** int(decimals)
|
|
41
|
+
number = float(value)
|
|
42
|
+
result = math.ceil(number * decimal_points) / decimal_points
|
|
43
|
+
if decimals == 0:
|
|
44
|
+
result = int(result)
|
|
45
|
+
return float(result)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def round_numbers_floor(value, decimals=6):
|
|
49
|
+
decimal_points = 10 ** int(decimals)
|
|
50
|
+
number = float(value)
|
|
51
|
+
result = math.floor(number * decimal_points) / decimal_points
|
|
52
|
+
if decimals == 0:
|
|
53
|
+
result = int(result)
|
|
54
|
+
return float(result)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def supress_notation(num: float, precision: int = 0) -> str:
|
|
58
|
+
"""
|
|
59
|
+
Supress scientific notation
|
|
60
|
+
e.g. 8e-5 = "0.00008"
|
|
61
|
+
"""
|
|
62
|
+
dec_num = float(num)
|
|
63
|
+
num = round_numbers(dec_num, precision)
|
|
64
|
+
if precision >= 0:
|
|
65
|
+
decimal_points = precision
|
|
66
|
+
else:
|
|
67
|
+
decimal_points = int(Decimal(str(num)).as_tuple().exponent * -1)
|
|
68
|
+
return f"{num:.{decimal_points}f}"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def interval_to_millisecs(interval: str) -> int:
|
|
72
|
+
time, notation = re.findall(r"[A-Za-z]+|\d+", interval)
|
|
73
|
+
if notation == "m":
|
|
74
|
+
# minutes
|
|
75
|
+
return int(time) * 60 * 1000
|
|
76
|
+
|
|
77
|
+
if notation == "h":
|
|
78
|
+
# hours
|
|
79
|
+
return int(time) * 60 * 60 * 1000
|
|
80
|
+
|
|
81
|
+
if notation == "d":
|
|
82
|
+
# day
|
|
83
|
+
return int(time) * 24 * 60 * 60 * 1000
|
|
84
|
+
|
|
85
|
+
if notation == "w":
|
|
86
|
+
# weeks
|
|
87
|
+
return int(time) * 5 * 24 * 60 * 60 * 1000
|
|
88
|
+
|
|
89
|
+
if notation == "M":
|
|
90
|
+
# month
|
|
91
|
+
return int(time) * 30 * 24 * 60 * 60 * 1000
|
|
92
|
+
|
|
93
|
+
return 0
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def format_ts(time: datetime) -> str:
|
|
97
|
+
"""
|
|
98
|
+
Central place to format datetime
|
|
99
|
+
to human-readable date
|
|
100
|
+
"""
|
|
101
|
+
return time.strftime("%Y-%m-%d %H:%M:%S.%f")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def zero_remainder(x):
|
|
105
|
+
number = x
|
|
106
|
+
|
|
107
|
+
while True:
|
|
108
|
+
if number % x == 0:
|
|
109
|
+
return number
|
|
110
|
+
else:
|
|
111
|
+
number += x
|
shared/timestamps.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
from time import time
|
|
2
|
+
import math
|
|
3
|
+
from shared.maths import round_numbers
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def timestamp() -> int:
|
|
8
|
+
ts = time() * 1000
|
|
9
|
+
rounded_ts = round_timestamp(ts)
|
|
10
|
+
return rounded_ts
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def round_timestamp(ts: int | float) -> int:
|
|
14
|
+
"""
|
|
15
|
+
Round millisecond timestamps to always 13 digits
|
|
16
|
+
this is the universal format that JS and Python accept
|
|
17
|
+
"""
|
|
18
|
+
digits = int(math.log10(ts)) + 1
|
|
19
|
+
if digits > 13:
|
|
20
|
+
decimals = digits - 13
|
|
21
|
+
multiplier = 10**decimals
|
|
22
|
+
return int(round_numbers(ts * multiplier, decimals))
|
|
23
|
+
else:
|
|
24
|
+
return int(ts)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def ts_to_day(ts: float | int) -> str:
|
|
28
|
+
"""
|
|
29
|
+
Convert timestamp to date (day) format YYYY-MM-DD
|
|
30
|
+
"""
|
|
31
|
+
digits = int(math.log10(ts)) + 1
|
|
32
|
+
if digits >= 10:
|
|
33
|
+
ts = ts // pow(10, digits - 10)
|
|
34
|
+
else:
|
|
35
|
+
ts = ts * pow(10, 10 - digits)
|
|
36
|
+
|
|
37
|
+
dt_obj = datetime.fromtimestamp(ts)
|
|
38
|
+
b_str_date = datetime.strftime(dt_obj, "%Y-%m-%d")
|
|
39
|
+
return b_str_date
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def ms_to_sec(ms: int) -> int:
|
|
43
|
+
"""
|
|
44
|
+
JavaScript needs 13 digits (milliseconds)
|
|
45
|
+
for new Date() to parse timestamps
|
|
46
|
+
correctly
|
|
47
|
+
"""
|
|
48
|
+
return ms // 1000
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def sec_to_ms(sec: int) -> int:
|
|
52
|
+
"""
|
|
53
|
+
Python datetime needs 10 digits (seconds)
|
|
54
|
+
to parse dates correctly from timestamps
|
|
55
|
+
"""
|
|
56
|
+
return sec * 1000
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def ts_to_humandate(ts: int) -> str:
|
|
60
|
+
"""
|
|
61
|
+
Convert timestamp to human-readable date
|
|
62
|
+
"""
|
|
63
|
+
if len(str(abs(1747852851106))) > 10:
|
|
64
|
+
# if timestamp is in milliseconds
|
|
65
|
+
ts = ts // 1000
|
|
66
|
+
return datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M:%S")
|