Fix SELL P&L calculation, add debug logging, and multiple improvements

- Fix realized_pnl always being 0 on SELL (use amount*price instead of amount_usdt)
- Add debug logging for all-HOLD LLM cycles (log sample HOLD reasons)
- Fix nonce collision in Bitfinex API auth (add counter)
- Fix timezone to UTC+8 in main, trade_logger, sync_cost_basis, check_errors
- Fix stop-loss retry with longer delay after cancel
- Add min order amount check in trader before BUY
- Fix risk_manager to skip positions with amount <= 0
- Cap trade_history to 500 entries to prevent unbounded growth
- Fix greedy regex in LLM response parser
- Reset cost_tracking start dates to null

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
kroutony 2026-03-17 06:23:54 +00:00
parent 41ad549ae2
commit b6bd45b151
10 changed files with 72 additions and 29 deletions

View File

@ -6,7 +6,7 @@ import os
import shutil import shutil
import subprocess import subprocess
import sys import sys
from datetime import datetime, timedelta from datetime import datetime, timedelta, timezone
sys.path.insert(0, os.path.dirname(__file__)) sys.path.insert(0, os.path.dirname(__file__))
@ -24,8 +24,9 @@ LOOKBACK_MINUTES = 60
def collect_recent_errors() -> list[str]: def collect_recent_errors() -> list[str]:
since = datetime.now() - timedelta(minutes=LOOKBACK_MINUTES) _TZ_UTC8 = timezone(timedelta(hours=8))
since_str = since.strftime("%Y-%m-%d %H:%M") since = datetime.now(_TZ_UTC8) - timedelta(minutes=LOOKBACK_MINUTES)
since_str = since.strftime("%Y-%m-%d %H:%M:%S")
errors = [] errors = []
for logfile in LOG_FILES: for logfile in LOG_FILES:
@ -38,7 +39,7 @@ def collect_recent_errors() -> list[str]:
continue continue
if not line[:4].isdigit(): if not line[:4].isdigit():
continue continue
if line[:16] >= since_str: if line[:19] >= since_str:
errors.append(line.rstrip()) errors.append(line.rstrip())
return errors return errors

View File

@ -1,16 +1,16 @@
{ {
"tETHUST": 1773100800000, "tETHUST": null,
"tSUIUST": 1773100800000, "tSUIUST": null,
"tSOLUST": 1773100800000, "tSOLUST": null,
"tLTCUST": 1773100800000, "tLTCUST": null,
"tADAUST": 1773100800000, "tADAUST": null,
"tAVAX:UST": 1773100800000, "tAVAX:UST": null,
"tUNIUST": 1773100800000, "tUNIUST": null,
"tXRPUST": 1773100800000, "tXRPUST": null,
"tDOGE:UST": 1773100800000, "tDOGE:UST": null,
"tSHIB:UST": 1773100800000, "tSHIB:UST": null,
"tBTCUST": 1773100800000, "tBTCUST": null,
"tXLMUST": 1773100800000, "tXLMUST": null,
"tLINK:UST": 1773100800000, "tLINK:UST": null,
"tDOTUST": 1773100800000 "tDOTUST": null
} }

View File

@ -27,9 +27,13 @@ def _pub_get(path: str, params: dict | None = None) -> dict | list:
# Authenticated API helpers # Authenticated API helpers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
_nonce_counter = 0
def _auth_post(path: str, body: dict | None = None) -> dict | list: def _auth_post(path: str, body: dict | None = None) -> dict | list:
global _nonce_counter
body = body or {} body = body or {}
nonce = str(int(time.time() * 1000000)) _nonce_counter += 1
nonce = str(int(time.time() * 1000000) + _nonce_counter)
body_json = json.dumps(body) body_json = json.dumps(body)
signature_payload = f"/api{path}{nonce}{body_json}" signature_payload = f"/api{path}{nonce}{body_json}"
sig = hmac.new( sig = hmac.new(

View File

@ -113,7 +113,7 @@ def _parse_llm_response(raw: str) -> list[dict]:
pass pass
# Try extracting JSON array from markdown code block or mixed text # Try extracting JSON array from markdown code block or mixed text
match = re.search(r'\[[\s\S]*?\]', raw) match = re.search(r'\[[\s\S]*\]', raw)
if match: if match:
try: try:
return json.loads(match.group()) return json.loads(match.group())

28
main.py
View File

@ -6,7 +6,7 @@ import logging
import os import os
import sys import sys
import time import time
from datetime import datetime from datetime import datetime, timezone, timedelta
import config import config
import data_fetcher import data_fetcher
@ -21,6 +21,8 @@ import trader
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Logging setup # Logging setup
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
_TZ_UTC8 = timezone(timedelta(hours=8))
logging.basicConfig( logging.basicConfig(
level=logging.INFO, level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
@ -29,6 +31,7 @@ logging.basicConfig(
logging.FileHandler("trading.log"), logging.FileHandler("trading.log"),
], ],
) )
logging.Formatter.converter = lambda *args: datetime.now(_TZ_UTC8).timetuple()
logger = logging.getLogger("main") logger = logging.getLogger("main")
STATE_FILE = "bot_state.json" STATE_FILE = "bot_state.json"
@ -78,12 +81,13 @@ def run_cycle():
state = _load_state() state = _load_state()
state["run_count"] = state.get("run_count", 0) + 1 state["run_count"] = state.get("run_count", 0) + 1
logger.info("=" * 60) logger.info("=" * 60)
logger.info("Trading cycle #%d at %s", state["run_count"], datetime.now().isoformat()) logger.info("Trading cycle #%d at %s", state["run_count"], datetime.now(_TZ_UTC8).isoformat())
# 1. Load portfolio state # 1. Load portfolio state
port = pf.load_positions() port = pf.load_positions()
# 2. Fetch account status from exchange # 2. Fetch account status from exchange
account_status = {"wallets": [], "positions": []}
try: try:
account_status = data_fetcher.fetch_account_status() account_status = data_fetcher.fetch_account_status()
port = pf.sync_with_exchange(port, account_status) port = pf.sync_with_exchange(port, account_status)
@ -168,10 +172,15 @@ def run_cycle():
logger.info("Cancelled outdated stop %s for %s (amt=%.6f, px=%.6g)", logger.info("Cancelled outdated stop %s for %s (amt=%.6f, px=%.6g)",
s["id"], sym, abs(s["amount"]), s["price"]) s["id"], sym, abs(s["amount"]), s["price"])
tracked_stops.pop(sym, None) tracked_stops.pop(sym, None)
time.sleep(0.3) # Wait for Bitfinex to release locked balance time.sleep(1.0) # Wait for Bitfinex to release locked balance
logger.warning("Position %s missing/outdated stop-loss, placing now", sym) logger.warning("Position %s missing/outdated stop-loss, placing now", sym)
sl = trader.place_stop_loss_order(sym, amount, entry, stop_price=expected_stop) sl = trader.place_stop_loss_order(sym, amount, entry, stop_price=expected_stop)
if sl is None and existing_stops:
# Retry once — Bitfinex may need more time to release locked balance
time.sleep(2.0)
logger.info("Retrying stop-loss for %s after balance release delay", sym)
sl = trader.place_stop_loss_order(sym, amount, entry, stop_price=expected_stop)
if sl: if sl:
# Update stop_orders_by_sym so step 10 sees the correct stop ID # Update stop_orders_by_sym so step 10 sees the correct stop ID
stop_orders_by_sym[sym] = [{"id": sl["order_id"], "symbol": sym, stop_orders_by_sym[sym] = [{"id": sl["order_id"], "symbol": sym,
@ -343,6 +352,17 @@ def run_cycle():
signals = llm_analyzer.analyze_market(indicator_summary, account_str) signals = llm_analyzer.analyze_market(indicator_summary, account_str)
llm_ok = True llm_ok = True
logger.info("LLM returned %d signals", len(signals)) logger.info("LLM returned %d signals", len(signals))
non_hold = [s for s in signals if s.get("action") != "HOLD"]
if non_hold:
for s in non_hold:
logger.info("LLM signal: %s %s conf=%.2f reason=%s",
s.get("action"), s.get("symbol"), s.get("confidence", 0), s.get("reason", ""))
else:
# Debug: log a sample of HOLD reasons to diagnose all-HOLD cycles
samples = signals[:3]
for s in samples:
logger.info("LLM HOLD sample: %s conf=%.2f reason=%s",
s.get("symbol"), s.get("confidence", 0), s.get("reason", ""))
except Exception as e: except Exception as e:
logger.error("LLM analysis failed: %s", e) logger.error("LLM analysis failed: %s", e)
slack_notifier.send_error_alert(f"LLM analysis failed: {e}") slack_notifier.send_error_alert(f"LLM analysis failed: {e}")
@ -398,7 +418,7 @@ def run_cycle():
entry_price = pos.get("entry_price", 0) entry_price = pos.get("entry_price", 0)
if entry_price: if entry_price:
cost_basis = entry_price * amount cost_basis = entry_price * amount
realized_pnl = amount_usdt - cost_basis realized_pnl = amount * price - cost_basis
realized_pnl_pct = (price - entry_price) / entry_price * 100 realized_pnl_pct = (price - entry_price) / entry_price * 100
port = pf.update_position(port, sym, action, amount, price, amount_usdt) port = pf.update_position(port, sym, action, amount, price, amount_usdt)

View File

@ -76,7 +76,8 @@ def update_position(portfolio: dict, symbol: str, action: str, amount: float, pr
portfolio["available_usdt"] = portfolio.get("available_usdt", 0) + sell_amount * price portfolio["available_usdt"] = portfolio.get("available_usdt", 0) + sell_amount * price
# Record trade # Record trade
portfolio.setdefault("trade_history", []).append({ history = portfolio.setdefault("trade_history", [])
history.append({
"symbol": symbol, "symbol": symbol,
"action": action, "action": action,
"amount": amount, "amount": amount,
@ -84,6 +85,8 @@ def update_position(portfolio: dict, symbol: str, action: str, amount: float, pr
"amount_usdt": amount_usdt, "amount_usdt": amount_usdt,
"timestamp": time.time(), "timestamp": time.time(),
}) })
if len(history) > 500:
portfolio["trade_history"] = history[-500:]
portfolio["last_updated"] = time.time() portfolio["last_updated"] = time.time()
return portfolio return portfolio
@ -135,7 +138,11 @@ def get_portfolio_summary(portfolio: dict, current_prices: dict) -> str:
lines.append("") lines.append("")
lines.append(f"*Total Portfolio Value:* {total_value:.2f} USDT") lines.append(f"*Total Portfolio Value:* {total_value:.2f} USDT")
lines.append(f"*Total Unrealized P&L:* {total_unrealized:+.2f} USDT ({total_unrealized / (total_value - total_unrealized) * 100:+.2f}%)" if (total_value - total_unrealized) > 0 else f"*Total Unrealized P&L:* {total_unrealized:+.2f} USDT") cost_basis = total_value - total_unrealized
if cost_basis > 0:
lines.append(f"*Total Unrealized P&L:* {total_unrealized:+.2f} USDT ({total_unrealized / cost_basis * 100:+.2f}%)")
else:
lines.append(f"*Total Unrealized P&L:* {total_unrealized:+.2f} USDT")
initial_capital = portfolio.get("initial_capital", 0) initial_capital = portfolio.get("initial_capital", 0)
if initial_capital > 0: if initial_capital > 0:

View File

@ -50,7 +50,7 @@ def apply_stop_loss_take_profit(positions: dict, current_prices: dict) -> list[d
""" """
signals = [] signals = []
for sym, pos in positions.items(): for sym, pos in positions.items():
if pos.get("amount", 0) == 0: if pos.get("amount", 0) <= 0:
continue continue
entry = pos.get("entry_price", 0) entry = pos.get("entry_price", 0)
if entry <= 0: if entry <= 0:

View File

@ -9,6 +9,7 @@ import logging
import os import os
import sys import sys
import time import time
from datetime import datetime, timezone, timedelta
import config import config
import data_fetcher import data_fetcher
@ -17,6 +18,8 @@ import slack_notifier
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Logging setup (separate log file) # Logging setup (separate log file)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
_TZ_UTC8 = timezone(timedelta(hours=8))
logging.basicConfig( logging.basicConfig(
level=logging.INFO, level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
@ -25,6 +28,7 @@ logging.basicConfig(
logging.FileHandler("sync_cost_basis.log"), logging.FileHandler("sync_cost_basis.log"),
], ],
) )
logging.Formatter.converter = lambda *args: datetime.now(_TZ_UTC8).timetuple()
logger = logging.getLogger("sync_cost_basis") logger = logging.getLogger("sync_cost_basis")

View File

@ -2,7 +2,9 @@
import csv import csv
import os import os
from datetime import datetime from datetime import datetime, timezone, timedelta
_TZ_UTC8 = timezone(timedelta(hours=8))
import config import config
@ -26,7 +28,7 @@ def log_trade(trade: dict):
sym = trade.get("symbol", "") sym = trade.get("symbol", "")
row = { row = {
"timestamp": trade.get("timestamp", ""), "timestamp": trade.get("timestamp", ""),
"datetime": datetime.now().isoformat(timespec="seconds"), "datetime": datetime.now(_TZ_UTC8).isoformat(timespec="seconds"),
"symbol": sym, "symbol": sym,
"name": config.SYMBOL_NAMES.get(sym, sym), "name": config.SYMBOL_NAMES.get(sym, sym),
"action": trade.get("action", ""), "action": trade.get("action", ""),

View File

@ -26,6 +26,11 @@ def execute_trade(signal: dict, current_prices: dict, mode: str | None = None) -
amount = -math.floor(abs(signal["sell_amount"]) * 1e8) / 1e8 amount = -math.floor(abs(signal["sell_amount"]) * 1e8) / 1e8
else: else:
amount = amount_usdt / price amount = amount_usdt / price
if action == "BUY":
min_amt = config.MIN_ORDER_AMOUNT.get(symbol, 0)
if min_amt > 0 and amount < min_amt:
logger.warning("BUY %s: amount %.6f < min %s, skipping", symbol, amount, min_amt)
return None
if action == "SELL": if action == "SELL":
amount = -abs(amount) # negative = sell on Bitfinex amount = -abs(amount) # negative = sell on Bitfinex