Compare commits

..

18 Commits

Author SHA1 Message Date
a6e4d28aa7 Merge pull request 'fix: add bcrypt version constraint for passlib compatibility' (#40) from fix/bcrypt-compatibility into main 2026-04-10 02:55:36 +02:00
shokollm
8693946cb8 fix: add bcrypt version constraint for passlib compatibility
bcrypt 5.0.0 is incompatible with passlib 1.7.x - passlib tries to
access bcrypt.__about__.__version__ which was removed in bcrypt 5.x.

Constrain bcrypt to >=4.0,<5.0 to maintain compatibility.
2026-04-10 00:55:18 +00:00
a2f549c056 Merge pull request 'fix: correct import paths in ai_agent module' (#39) from fix/ai-agent-imports into main 2026-04-09 17:32:21 +02:00
shokollm
ad6e57655d fix: correct import paths in ai_agent module
- Fix relative import path in crew.py (from ..core to ...core)
- Update __init__.py exports to match actual class names
- Remove incorrect CrewAgent and LLMConnector exports
2026-04-09 15:27:09 +00:00
ac5e9d8b81 Merge pull request 'fix: add error logging to simulate engine to prevent silent failures' (#38) from fix/issue-30 into main 2026-04-09 12:19:36 +02:00
shokollm
81f3342365 fix: add error logging to simulate engine to prevent silent failures
Errors during price fetching are now logged and stored in an errors list,
allowing users to see error count/warnings in simulation results.

Acceptance Criteria:
- [x] Errors are logged (not silently swallowed)
- [x] User can see error count/warnings in simulation results
- [x] Simulation completes even if some price fetches fail (graceful degradation)
2026-04-09 10:16:22 +00:00
6adad0701d Merge pull request 'fix: consolidate AveCloudClient to single implementation' (#37) from fix/issue-29 into main 2026-04-09 12:11:59 +02:00
shokollm
405b35c3ba fix: consolidate AveCloudClient to single implementation in services/ave/client.py 2026-04-09 10:06:16 +00:00
dd25d38e7e Merge pull request 'feat: implement stop-loss and take-profit risk management' (#36) from fix/issue-28 into main 2026-04-09 11:39:50 +02:00
shokollm
da8327c0e0 feat: implement stop-loss and take-profit in backtest and simulate engines 2026-04-09 09:14:08 +00:00
8d33ea9a44 Merge pull request 'fix: flatten strategy config schema (backtesting broken)' (#35) from fix/issue-25 into main 2026-04-09 09:32:49 +02:00
shokollm
d81464b869 fix: flatten strategy config schema to match engine expectations
LLM was outputting nested params structure but engines expect flat fields.
This caused backtesting and simulation to never trigger any trades.

Changes:
- llm_connector.py: Update prompt to output flat condition structure
- crew.py: Update StrategyValidator to validate flat structure
- crew.py: Update StrategyExplainer to read flat structure

Fixes #25
2026-04-09 07:31:09 +00:00
55b008d4e8 Merge pull request 'fix: validate chain is 'bsc' for Phase 1' (#34) from fix/issue-31 into main 2026-04-09 09:10:55 +02:00
shokollm
04e4c1a487 fix: validate chain is 'bsc' for BacktestCreate and SimulationCreate 2026-04-09 06:58:16 +00:00
feb65131fa Merge pull request 'fix: populate config endpoints with chain and token data' (#33) from fix/issue-27 into main 2026-04-09 08:23:43 +02:00
shokollm
50af4e0722 fix: reduce tokens limit to 20 per review 2026-04-09 06:18:31 +00:00
shokollm
786e964e32 fix: return bsc chain and tokens from AVE API in config endpoints 2026-04-09 06:02:05 +00:00
41b699f9ee Merge pull request 'fix: make strategy_config and llm_config optional in BotCreate' (#32) from fix/issue-26 into main 2026-04-09 07:54:20 +02:00
10 changed files with 258 additions and 129 deletions

View File

@@ -1,13 +1,19 @@
from fastapi import APIRouter
from ..core.config import get_settings
from ..services.ave import AveCloudClient
router = APIRouter()
@router.get("/chains")
def get_chains():
return {"chains": []}
return {"chains": ["bsc"]}
@router.get("/tokens")
def get_tokens():
return {"tokens": []}
async def get_tokens():
settings = get_settings()
client = AveCloudClient(api_key=settings.AVE_API_KEY, plan=settings.AVE_API_PLAN)
tokens = await client.get_tokens(chain="bsc", limit=20)
return {"tokens": tokens}

View File

@@ -1,5 +1,5 @@
from pydantic import BaseModel, EmailStr
from typing import Optional, List, Any
from pydantic import BaseModel, EmailStr, field_validator
from typing import Optional, List, Any, Dict
from datetime import datetime
@@ -69,6 +69,13 @@ class BacktestCreate(BaseModel):
start_date: str
end_date: str
@field_validator("chain")
@classmethod
def chain_must_be_bsc(cls, v: str) -> str:
if v != "bsc":
raise ValueError("Phase 1 only supports BSC (bnb chain)")
return v
class BacktestResponse(BaseModel):
id: str
@@ -90,6 +97,13 @@ class SimulationCreate(BaseModel):
check_interval: int = 60
auto_execute: bool = False
@field_validator("chain")
@classmethod
def chain_must_be_bsc(cls, v: str) -> str:
if v != "bsc":
raise ValueError("Phase 1 only supports BSC (bnb chain)")
return v
class SimulationResponse(BaseModel):
id: str

View File

@@ -1,4 +1,4 @@
from .crew import CrewAgent
from .llm_connector import LLMConnector
from .crew import TradingCrew, get_trading_crew
from .llm_connector import MiniMaxLLM, MiniMaxConnector
__all__ = ["CrewAgent", "LLMConnector"]
__all__ = ["TradingCrew", "get_trading_crew", "MiniMaxLLM", "MiniMaxConnector"]

View File

@@ -1,7 +1,7 @@
from typing import List, Optional, Dict, Any
from crewai import Agent, Task, Crew
from .llm_connector import MiniMaxConnector, MiniMaxLLM
from ..core.config import get_settings
from ...core.config import get_settings
class StrategyValidator:
@@ -33,29 +33,24 @@ class StrategyValidator:
errors.append(f"Condition {i}: unsupported type '{cond_type}'")
continue
params = condition.get("params", {})
if cond_type in ["price_drop", "price_rise", "volume_spike"]:
if "token" not in params:
if "token" not in condition:
errors.append(f"Condition {i}: missing 'token'")
if "threshold_percent" not in params:
errors.append(f"Condition {i}: missing 'threshold_percent'")
elif not isinstance(params["threshold_percent"], (int, float)):
errors.append(
f"Condition {i}: 'threshold_percent' must be a number"
)
elif params["threshold_percent"] <= 0:
errors.append(
f"Condition {i}: 'threshold_percent' must be positive"
)
if "threshold" not in condition:
errors.append(f"Condition {i}: missing 'threshold'")
elif not isinstance(condition["threshold"], (int, float)):
errors.append(f"Condition {i}: 'threshold' must be a number")
elif condition["threshold"] <= 0:
errors.append(f"Condition {i}: 'threshold' must be positive")
elif cond_type == "price_level":
if "token" not in params:
if "token" not in condition:
errors.append(f"Condition {i}: missing 'token'")
if "price" not in params:
if "price" not in condition:
errors.append(f"Condition {i}: missing 'price'")
if "direction" not in params:
if "direction" not in condition:
errors.append(f"Condition {i}: missing 'direction'")
elif params["direction"] not in ["above", "below"]:
elif condition["direction"] not in ["above", "below"]:
errors.append(
f"Condition {i}: direction must be 'above' or 'below'"
)
@@ -85,23 +80,22 @@ class StrategyExplainer:
explanations.append("This strategy will trigger when:")
for cond in cond_list:
cond_type = cond.get("type")
params = cond.get("params", {})
token = params.get("token", "the token")
token = cond.get("token", "the token")
if cond_type == "price_drop":
pct = params.get("threshold_percent", 0)
pct = cond.get("threshold", 0)
explanations.append(f" - {token} price drops by {pct}%")
elif cond_type == "price_rise":
pct = params.get("threshold_percent", 0)
pct = cond.get("threshold", 0)
explanations.append(f" - {token} price rises by {pct}%")
elif cond_type == "volume_spike":
pct = params.get("threshold_percent", 0)
pct = cond.get("threshold", 0)
explanations.append(
f" - {token} trading volume increases by {pct}%"
)
elif cond_type == "price_level":
price = params.get("price", 0)
direction = params.get("direction", "unknown")
price = cond.get("price", 0)
direction = cond.get("direction", "unknown")
explanations.append(
f" - {token} price crosses {direction} ${price}"
)

View File

@@ -61,9 +61,9 @@ class MiniMaxConnector:
system_prompt = """You are a trading strategy designer. Parse the user's natural language request into a JSON strategy_config object.
Supported conditions (MVP):
- price_drop: Token price drops by X% (requires: token, threshold_percent)
- price_rise: Token price rises by X% (requires: token, threshold_percent)
- volume_spike: Trading volume increases X% (requires: token, threshold_percent)
- price_drop: Token price drops by X% (requires: token, threshold)
- price_rise: Token price rises by X% (requires: token, threshold)
- volume_spike: Trading volume increases X% (requires: token, threshold)
- price_level: Price crosses above/below X (requires: token, price, direction)
Output ONLY valid JSON with this schema:
@@ -71,18 +71,17 @@ Output ONLY valid JSON with this schema:
"conditions": [
{
"type": "price_drop|price_rise|volume_spike|price_level",
"params": {
"token": "TOKEN_SYMBOL",
"threshold_percent": number, // for price_drop, price_rise, volume_spike
"chain": "bsc",
"threshold": number, // for price_drop, price_rise, volume_spike
"price": number, // for price_level
"direction": "above|below" // for price_level
}
"direction": "above|below", // for price_level
"timeframe": "1h"
}
],
"actions": [
{
"type": "buy|sell|notify",
"params": {}
"type": "buy|sell|notify"
}
]
}

View File

@@ -90,6 +90,22 @@ class AveCloudClient:
return data.get("data", [])
raise Exception(f"Failed to fetch klines: {data}")
async def get_token_price(self, token_id: str) -> Optional[Dict[str, Any]]:
url = f"{self.DATA_API_URL}/v2/tokens/price"
async with httpx.AsyncClient() as client:
response = await client.post(
url,
headers=self._data_headers(),
json={"token_ids": [token_id]},
timeout=30.0,
)
response.raise_for_status()
data = response.json()
if data.get("status") == 200:
prices = data.get("data", {})
return prices.get(token_id)
return None
async def get_trending_tokens(
self, chain: Optional[str] = None, limit: int = 20
) -> List[Dict[str, Any]]:

View File

@@ -1,70 +0,0 @@
import httpx
from typing import List, Dict, Any, Optional
from datetime import datetime
class AveCloudClient:
BASE_URL = "https://prod.ave-api.com"
def __init__(self, api_key: str, plan: str = "free"):
self.api_key = api_key
self.plan = plan
def _headers(self) -> Dict[str, str]:
return {"X-API-KEY": self.api_key}
async def get_klines(
self,
token_id: str,
interval: str = "1h",
limit: int = 100,
start_time: Optional[int] = None,
end_time: Optional[int] = None,
) -> List[Dict[str, Any]]:
url = f"{self.BASE_URL}/v2/klines/token/{token_id}"
params = {"interval": interval, "limit": limit}
if start_time:
params["start_time"] = start_time
if end_time:
params["end_time"] = end_time
async with httpx.AsyncClient() as client:
response = await client.get(
url, headers=self._headers(), params=params, timeout=30.0
)
response.raise_for_status()
data = response.json()
if data.get("status") == 200:
return data.get("data", [])
raise Exception(f"Failed to fetch klines: {data}")
async def get_token_price(self, token_id: str) -> Optional[Dict[str, Any]]:
url = f"{self.BASE_URL}/v2/tokens/price"
async with httpx.AsyncClient() as client:
response = await client.post(
url,
headers=self._headers(),
json={"token_ids": [token_id]},
timeout=30.0,
)
response.raise_for_status()
data = response.json()
if data.get("status") == 200:
prices = data.get("data", {})
return prices.get(token_id)
return None
async def get_batch_prices(self, token_ids: List[str]) -> Dict[str, Dict[str, Any]]:
url = f"{self.BASE_URL}/v2/tokens/price"
async with httpx.AsyncClient() as client:
response = await client.post(
url,
headers=self._headers(),
json={"token_ids": token_ids},
timeout=30.0,
)
response.raise_for_status()
data = response.json()
if data.get("status") == 200:
return data.get("data", {})
return {}

View File

@@ -2,7 +2,7 @@ import uuid
import asyncio
from datetime import datetime
from typing import Dict, Any, List, Optional
from .ave_client import AveCloudClient
from ..ave.client import AveCloudClient
class BacktestEngine:
@@ -20,10 +20,15 @@ class BacktestEngine:
self.strategy_config = config.get("strategy_config", {})
self.conditions = self.strategy_config.get("conditions", [])
self.actions = self.strategy_config.get("actions", [])
self.risk_management = self.strategy_config.get("risk_management", {})
self.stop_loss_percent = self.risk_management.get("stop_loss_percent")
self.take_profit_percent = self.risk_management.get("take_profit_percent")
self.initial_balance = config.get("initial_balance", 10000.0)
self.current_balance = self.initial_balance
self.position = 0.0
self.position_token = ""
self.entry_price: Optional[float] = None
self.entry_time: Optional[int] = None
self.trades: List[Dict[str, Any]] = []
self.running = False
@@ -103,11 +108,73 @@ class BacktestEngine:
timestamp = kline.get("timestamp", 0)
if self.position > 0 and self.entry_price is not None:
exit_info = self._check_risk_management(price, timestamp)
if exit_info:
await self._execute_risk_exit(price, timestamp, exit_info)
continue
for condition in self.conditions:
if self._check_condition(condition, klines, i, price):
await self._execute_actions(price, timestamp, condition)
break
def _check_risk_management(
self, current_price: float, timestamp: int
) -> Optional[Dict[str, Any]]:
if self.position <= 0 or self.entry_price is None:
return None
if self.stop_loss_percent is not None:
stop_loss_price = self.entry_price * (1 - self.stop_loss_percent / 100)
if current_price <= stop_loss_price:
return {"reason": "stop_loss", "price": stop_loss_price}
if self.take_profit_percent is not None:
take_profit_price = self.entry_price * (1 + self.take_profit_percent / 100)
if current_price >= take_profit_price:
return {"reason": "take_profit", "price": take_profit_price}
return None
async def _execute_risk_exit(
self, price: float, timestamp: int, exit_info: Dict[str, Any]
):
if self.position <= 0:
return
reason = exit_info["reason"]
sell_amount = self.position * price
self.current_balance += sell_amount
self.trades.append(
{
"type": "sell",
"token": self.position_token,
"price": price,
"amount": sell_amount,
"quantity": self.position,
"timestamp": timestamp,
"exit_reason": reason,
}
)
self.signals.append(
{
"id": str(uuid.uuid4()),
"bot_id": self.bot_id,
"run_id": self.run_id,
"signal_type": "sell",
"token": self.position_token,
"price": price,
"confidence": 1.0,
"reasoning": f"Risk management triggered {reason}",
"executed": False,
"created_at": datetime.utcnow(),
}
)
self.position = 0
self.entry_price = None
self.entry_time = None
def _check_condition(
self,
condition: Dict[str, Any],
@@ -173,6 +240,8 @@ class BacktestEngine:
self.position += amount / price
self.current_balance -= amount
self.position_token = token
self.entry_price = price
self.entry_time = timestamp
self.trades.append(
{
"type": "buy",
@@ -209,9 +278,12 @@ class BacktestEngine:
"amount": sell_amount,
"quantity": self.position,
"timestamp": timestamp,
"exit_reason": "manual",
}
)
self.position = 0
self.entry_price = None
self.entry_time = None
self.signals.append(
{
"id": str(uuid.uuid4()),

View File

@@ -1,8 +1,11 @@
import uuid
import asyncio
import logging
from datetime import datetime
from typing import Dict, Any, List, Optional
from ..backtest.ave_client import AveCloudClient
from ..ave.client import AveCloudClient
logger = logging.getLogger(__name__)
class SimulateEngine:
@@ -20,6 +23,9 @@ class SimulateEngine:
self.strategy_config = config.get("strategy_config", {})
self.conditions = self.strategy_config.get("conditions", [])
self.actions = self.strategy_config.get("actions", [])
self.risk_management = self.strategy_config.get("risk_management", {})
self.stop_loss_percent = self.risk_management.get("stop_loss_percent")
self.take_profit_percent = self.risk_management.get("take_profit_percent")
self.check_interval = config.get("check_interval", 60)
self.duration_seconds = config.get("duration_seconds", 3600)
self.auto_execute = config.get("auto_execute", False)
@@ -29,6 +35,13 @@ class SimulateEngine:
self.started_at: Optional[datetime] = None
self.last_price: Optional[float] = None
self.last_volume: Optional[float] = None
self.position: float = 0.0
self.position_token: str = ""
self.entry_price: Optional[float] = None
self.entry_time: Optional[int] = None
self.current_balance: float = config.get("initial_balance", 10000.0)
self.trades: List[Dict[str, Any]] = []
self.errors: List[str] = []
async def run(self) -> Dict[str, Any]:
self.running = True
@@ -65,7 +78,9 @@ class SimulateEngine:
self.last_volume = current_volume
except Exception as e:
pass
logger.warning(f"Failed to get price for {token_id}: {e}")
self.errors.append(f"Price fetch failed for {token_id}: {str(e)}")
continue
for _ in range(self.check_interval):
if not self.running:
@@ -83,6 +98,8 @@ class SimulateEngine:
self.results = self.results or {}
self.results["total_signals"] = len(self.signals)
self.results["total_errors"] = len(self.errors)
self.results["errors"] = self.errors
self.results["signals"] = self.signals
self.results["started_at"] = self.started_at
self.results["ended_at"] = datetime.utcnow()
@@ -94,11 +111,70 @@ class SimulateEngine:
):
timestamp = int(datetime.utcnow().timestamp() * 1000)
if self.position > 0 and self.entry_price is not None:
exit_info = self._check_risk_management(current_price, timestamp)
if exit_info:
await self._execute_risk_exit(current_price, timestamp, exit_info)
return
for condition in self.conditions:
if self._check_condition(condition, current_price, current_volume):
await self._execute_actions(current_price, timestamp, condition)
break
def _check_risk_management(
self, current_price: float, timestamp: int
) -> Optional[Dict[str, Any]]:
if self.position <= 0 or self.entry_price is None:
return None
if self.stop_loss_percent is not None:
stop_loss_price = self.entry_price * (1 - self.stop_loss_percent / 100)
if current_price <= stop_loss_price:
return {"reason": "stop_loss", "price": stop_loss_price}
if self.take_profit_percent is not None:
take_profit_price = self.entry_price * (1 + self.take_profit_percent / 100)
if current_price >= take_profit_price:
return {"reason": "take_profit", "price": take_profit_price}
return None
async def _execute_risk_exit(
self, price: float, timestamp: int, exit_info: Dict[str, Any]
):
if self.position <= 0:
return
reason = exit_info["reason"]
self.trades.append(
{
"type": "sell",
"token": self.position_token,
"price": price,
"quantity": self.position,
"timestamp": timestamp,
"exit_reason": reason,
}
)
self.signals.append(
{
"id": str(uuid.uuid4()),
"bot_id": self.bot_id,
"run_id": self.run_id,
"signal_type": "sell",
"token": self.position_token,
"price": price,
"confidence": 1.0,
"reasoning": f"Risk management triggered {reason}",
"executed": self.auto_execute,
"created_at": datetime.utcnow(),
}
)
self.position = 0
self.entry_price = None
self.entry_time = None
def _check_condition(
self,
condition: Dict[str, Any],
@@ -146,11 +222,32 @@ class SimulateEngine:
token = matched_condition.get("token", self.token)
reasoning = f"Condition {matched_condition.get('type')} triggered"
for action in self.actions:
action_type = action.get("type", "")
if action_type == "buy":
amount_percent = action.get("amount_percent", 10)
amount = self.current_balance * (amount_percent / 100)
self.position += amount / price
self.position_token = token
self.entry_price = price
self.entry_time = timestamp
self.current_balance -= amount
self.trades.append(
{
"type": "buy",
"token": token,
"price": price,
"amount": amount,
"quantity": amount / price,
"timestamp": timestamp,
}
)
signal = {
"id": str(uuid.uuid4()),
"bot_id": self.bot_id,
"run_id": self.run_id,
"signal_type": "signal",
"signal_type": action_type,
"token": token,
"price": price,
"confidence": 0.8,

View File

@@ -6,6 +6,7 @@ pydantic-settings>=2.1.0
email-validator>=2.0.0
python-jose[cryptography]>=3.3.0
passlib[bcrypt]>=1.7.4
bcrypt>=4.0,<5.0 # Required for passlib compatibility
crewai>=0.1.0
anthropic>=0.18.0
httpx>=0.26.0