test(trader): add comprehensive unit tests and CI coverage reporting (#823)

* chore(config): add Python and uv support to project
- Add comprehensive Python .gitignore rules (pycache, venv, pytest, etc.)
- Add uv package manager specific ignores (.uv/, uv.lock)
- Initialize pyproject.toml for Python tooling
Co-authored-by: tinkle-community <tinklefund@gmail.com>
* chore(deps): add testing dependencies
- Add github.com/stretchr/testify v1.11.1 for test assertions
- Add github.com/agiledragon/gomonkey/v2 v2.13.0 for mocking
- Promote github.com/rs/zerolog to direct dependency
Co-authored-by: tinkle-community <tinklefund@gmail.com>
* ci(workflow): add PR test coverage reporting
Add GitHub Actions workflow to run unit tests and report coverage on PRs:
- Run Go tests with race detection and coverage profiling
- Calculate coverage statistics and generate detailed reports
- Post coverage results as PR comments with visual indicators
- Fix Go version to 1.23 (was incorrectly set to 1.25.0)
Coverage guidelines:
- Green (>=80%): excellent
- Yellow (>=60%): good
- Orange (>=40%): fair
- Red (<40%): needs improvement
This workflow is advisory only and does not block PR merging.
Co-authored-by: tinkle-community <tinklefund@gmail.com>
* test(trader): add comprehensive unit tests for trader modules
Add unit test suites for multiple trader implementations:
- aster_trader_test.go: AsterTrader functionality tests
- auto_trader_test.go: AutoTrader lifecycle and operations tests
- binance_futures_test.go: Binance futures trader tests
- hyperliquid_trader_test.go: Hyperliquid trader tests
- trader_test_suite.go: Common test suite utilities and helpers
Also fix minor formatting issue in auto_trader.go (trailing whitespace)
Co-authored-by: tinkle-community <tinklefund@gmail.com>
* test(trader): preserve existing calculatePnLPercentage unit tests
Merge existing calculatePnLPercentage tests with incoming comprehensive test suite:
- Preserve TestCalculatePnLPercentage with 9 test cases covering edge cases
- Preserve TestCalculatePnLPercentage_RealWorldScenarios with 3 trading scenarios
- Add math package import for floating-point precision comparison
- All tests validate PnL percentage calculation with different leverage scenarios
Co-authored-by: tinkle-community <tinklefund@gmail.com>
---------
Co-authored-by: tinkle-community <tinklefund@gmail.com>
This commit is contained in:
WquGuru
2025-11-09 17:43:28 +08:00
committed by GitHub
parent 8107667796
commit 295124c1fa
14 changed files with 3766 additions and 42 deletions

View File

@@ -0,0 +1,78 @@
name: Go Test Coverage
on:
pull_request:
types: [opened, synchronize, reopened]
branches:
- dev
- main
push:
branches:
- dev
- main
permissions:
contents: read
pull-requests: write
jobs:
test-coverage:
name: Go Unit Tests & Coverage
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.23'
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install Python dependencies
run: |
python -m pip install --upgrade pip
pip install -r .github/workflows/scripts/requirements.txt
- name: Cache Go modules
uses: actions/cache@v4
with:
path: ~/go/pkg/mod
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-go-
- name: Download dependencies
run: go mod download
- name: Run tests with coverage
run: |
go test -v -race -coverprofile=coverage.out -covermode=atomic ./...
- name: Calculate coverage and generate report
id: coverage
run: |
chmod +x .github/workflows/scripts/calculate_coverage.py
python .github/workflows/scripts/calculate_coverage.py coverage.out coverage_report.md
- name: Comment PR with coverage
if: github.event_name == 'pull_request'
continue-on-error: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
chmod +x .github/workflows/scripts/comment_pr.py
python .github/workflows/scripts/comment_pr.py \
${{ github.event.pull_request.number }} \
"${{ steps.coverage.outputs.coverage }}" \
"${{ steps.coverage.outputs.emoji }}" \
"${{ steps.coverage.outputs.status }}" \
"${{ steps.coverage.outputs.badge_color }}" \
coverage_report.md

View File

@@ -0,0 +1,192 @@
#!/usr/bin/env python3
"""
Calculate Go test coverage and generate reports.
This script parses the coverage.out file generated by `go test -coverprofile`,
extracts coverage statistics, and generates formatted reports.
"""
import sys
import re
import os
from typing import Dict, List, Tuple
def parse_coverage_file(coverage_file: str) -> Tuple[float, Dict[str, float]]:
"""
Parse coverage output file and extract coverage data.
Args:
coverage_file: Path to coverage.out file
Returns:
Tuple of (total_coverage, package_coverage_dict)
"""
if not os.path.exists(coverage_file):
print(f"Error: Coverage file {coverage_file} not found", file=sys.stderr)
sys.exit(1)
# Run go tool cover to get coverage data
import subprocess
try:
result = subprocess.run(
['go', 'tool', 'cover', '-func', coverage_file],
capture_output=True,
text=True,
check=True
)
except subprocess.CalledProcessError as e:
print(f"Error running go tool cover: {e}", file=sys.stderr)
sys.exit(1)
lines = result.stdout.strip().split('\n')
package_coverage = {}
total_coverage = 0.0
for line in lines:
# Skip empty lines
if not line.strip():
continue
# Check for total coverage line
if line.startswith('total:'):
# Extract percentage from "total: (statements) XX.X%"
match = re.search(r'(\d+\.\d+)%', line)
if match:
total_coverage = float(match.group(1))
continue
# Parse package/file coverage
# Format: "package/file.go:function statements coverage%"
parts = line.split()
if len(parts) >= 3:
file_path = parts[0]
coverage_str = parts[-1]
# Extract package name from file path
package = file_path.split(':')[0]
package_name = '/'.join(package.split('/')[:-1]) if '/' in package else package
# Extract coverage percentage
match = re.search(r'(\d+\.\d+)%', coverage_str)
if match:
coverage_pct = float(match.group(1))
# Aggregate by package
if package_name not in package_coverage:
package_coverage[package_name] = []
package_coverage[package_name].append(coverage_pct)
# Calculate average coverage per package
package_avg = {
pkg: sum(coverages) / len(coverages)
for pkg, coverages in package_coverage.items()
}
return total_coverage, package_avg
def get_coverage_status(coverage: float) -> Tuple[str, str, str]:
"""
Get coverage status based on percentage.
Args:
coverage: Coverage percentage
Returns:
Tuple of (emoji, status_text, badge_color)
"""
if coverage >= 80:
return '🟢', 'excellent', 'brightgreen'
elif coverage >= 60:
return '🟡', 'good', 'yellow'
elif coverage >= 40:
return '🟠', 'fair', 'orange'
else:
return '🔴', 'needs improvement', 'red'
def generate_coverage_report(coverage_file: str, output_file: str) -> None:
"""
Generate a detailed coverage report in markdown format.
Args:
coverage_file: Path to coverage.out file
output_file: Path to output markdown file
"""
import subprocess
try:
result = subprocess.run(
['go', 'tool', 'cover', '-func', coverage_file],
capture_output=True,
text=True,
check=True
)
except subprocess.CalledProcessError as e:
print(f"Error generating coverage report: {e}", file=sys.stderr)
sys.exit(1)
with open(output_file, 'w') as f:
f.write("## Coverage by Package\n\n")
f.write("```\n")
f.write(result.stdout)
f.write("```\n")
def set_github_output(name: str, value: str) -> None:
"""
Set GitHub Actions output variable.
Args:
name: Output variable name
value: Output variable value
"""
github_output = os.environ.get('GITHUB_OUTPUT')
if github_output:
with open(github_output, 'a') as f:
f.write(f"{name}={value}\n")
else:
print(f"::set-output name={name}::{value}")
def main():
"""Main entry point."""
if len(sys.argv) < 2:
print("Usage: calculate_coverage.py <coverage_file> [output_file]", file=sys.stderr)
sys.exit(1)
coverage_file = sys.argv[1]
output_file = sys.argv[2] if len(sys.argv) > 2 else 'coverage_report.md'
# Parse coverage data
total_coverage, package_coverage = parse_coverage_file(coverage_file)
# Get coverage status
emoji, status, badge_color = get_coverage_status(total_coverage)
# Generate detailed report
generate_coverage_report(coverage_file, output_file)
# Output results
print(f"Total Coverage: {total_coverage}%")
print(f"Status: {status}")
print(f"Badge Color: {badge_color}")
# Set GitHub Actions outputs
set_github_output('coverage', f'{total_coverage}%')
set_github_output('coverage_num', str(total_coverage))
set_github_output('status', status)
set_github_output('emoji', emoji)
set_github_output('badge_color', badge_color)
# Print package breakdown
if package_coverage:
print("\nCoverage by Package:")
for package, coverage in sorted(package_coverage.items()):
print(f" {package}: {coverage:.1f}%")
if __name__ == '__main__':
main()

246
.github/workflows/scripts/comment_pr.py vendored Executable file
View File

@@ -0,0 +1,246 @@
#!/usr/bin/env python3
"""
Post or update coverage report comment on GitHub Pull Request.
This script generates a formatted coverage report comment and posts it to a PR,
or updates an existing coverage comment if one already exists.
"""
import os
import sys
import json
import requests
from typing import Optional
def read_file(file_path: str) -> str:
"""Read file content."""
try:
with open(file_path, 'r') as f:
return f.read()
except FileNotFoundError:
print(f"Warning: File {file_path} not found", file=sys.stderr)
return ""
def generate_comment_body(coverage: str, emoji: str, status: str,
badge_color: str, coverage_report_path: str) -> str:
"""
Generate the PR comment body.
Args:
coverage: Coverage percentage (e.g., "75.5%")
emoji: Status emoji
status: Status text
badge_color: Badge color
coverage_report_path: Path to detailed coverage report
Returns:
Formatted comment body in markdown
"""
coverage_report = read_file(coverage_report_path)
# URL encode the coverage percentage for the badge
coverage_encoded = coverage.replace('%', '%25')
comment = f"""## {emoji} Go Test Coverage Report
**Total Coverage:** `{coverage}` ({status})
![Coverage](https://img.shields.io/badge/coverage-{coverage_encoded}-{badge_color})
<details>
<summary>📊 Detailed Coverage Report (click to expand)</summary>
{coverage_report}
</details>
### Coverage Guidelines
- 🟢 >= 80%: Excellent
- 🟡 >= 60%: Good
- 🟠 >= 40%: Fair
- 🔴 < 40%: Needs improvement
---
*This is an automated coverage report. The coverage requirement is advisory and does not block PR merging.*
"""
return comment
def find_existing_comment(token: str, repo: str, pr_number: int) -> Optional[int]:
"""
Find existing coverage comment in the PR.
Args:
token: GitHub token
repo: Repository in format "owner/repo"
pr_number: Pull request number
Returns:
Comment ID if found, None otherwise
"""
url = f"https://api.github.com/repos/{repo}/issues/{pr_number}/comments"
headers = {
'Authorization': f'token {token}',
'Accept': 'application/vnd.github.v3+json'
}
try:
response = requests.get(url, headers=headers)
response.raise_for_status()
comments = response.json()
# Look for existing coverage comment
for comment in comments:
if (comment.get('user', {}).get('type') == 'Bot' and
'Go Test Coverage Report' in comment.get('body', '')):
return comment['id']
except requests.exceptions.RequestException as e:
print(f"Error fetching comments: {e}", file=sys.stderr)
return None
def post_comment(token: str, repo: str, pr_number: int, body: str) -> bool:
"""
Post a new comment to the PR.
Args:
token: GitHub token
repo: Repository in format "owner/repo"
pr_number: Pull request number
body: Comment body
Returns:
True if successful, False otherwise
"""
url = f"https://api.github.com/repos/{repo}/issues/{pr_number}/comments"
headers = {
'Authorization': f'token {token}',
'Accept': 'application/vnd.github.v3+json'
}
data = {'body': body}
try:
response = requests.post(url, headers=headers, json=data)
response.raise_for_status()
print("✅ Coverage comment posted successfully")
return True
except requests.exceptions.RequestException as e:
print(f"Error posting comment: {e}", file=sys.stderr)
if hasattr(e, 'response') and e.response is not None:
print(f"Response: {e.response.text}", file=sys.stderr)
return False
def update_comment(token: str, repo: str, comment_id: int, body: str) -> bool:
"""
Update an existing comment.
Args:
token: GitHub token
repo: Repository in format "owner/repo"
comment_id: Comment ID to update
body: New comment body
Returns:
True if successful, False otherwise
"""
url = f"https://api.github.com/repos/{repo}/issues/comments/{comment_id}"
headers = {
'Authorization': f'token {token}',
'Accept': 'application/vnd.github.v3+json'
}
data = {'body': body}
try:
response = requests.patch(url, headers=headers, json=data)
response.raise_for_status()
print("✅ Coverage comment updated successfully")
return True
except requests.exceptions.RequestException as e:
print(f"Error updating comment: {e}", file=sys.stderr)
if hasattr(e, 'response') and e.response is not None:
print(f"Response: {e.response.text}", file=sys.stderr)
return False
def is_fork_pr(event_path: str) -> bool:
"""
Check if the PR is from a fork.
Args:
event_path: Path to GitHub event JSON file
Returns:
True if fork PR, False otherwise
"""
try:
with open(event_path, 'r') as f:
event = json.load(f)
pr = event.get('pull_request', {})
head_repo = pr.get('head', {}).get('repo', {}).get('full_name')
base_repo = pr.get('base', {}).get('repo', {}).get('full_name')
return head_repo != base_repo
except (FileNotFoundError, json.JSONDecodeError, KeyError) as e:
print(f"Warning: Could not determine if fork PR: {e}", file=sys.stderr)
return False
def main():
"""Main entry point."""
# Get environment variables
token = os.environ.get('GITHUB_TOKEN')
repo = os.environ.get('GITHUB_REPOSITORY')
event_path = os.environ.get('GITHUB_EVENT_PATH', '')
# Get arguments
if len(sys.argv) < 6:
print("Usage: comment_pr.py <pr_number> <coverage> <emoji> <status> <badge_color> [coverage_report_path]",
file=sys.stderr)
sys.exit(1)
pr_number = int(sys.argv[1])
coverage = sys.argv[2]
emoji = sys.argv[3]
status = sys.argv[4]
badge_color = sys.argv[5]
coverage_report_path = sys.argv[6] if len(sys.argv) > 6 else 'coverage_report.md'
# Validate environment
if not token:
print("Error: GITHUB_TOKEN environment variable not set", file=sys.stderr)
sys.exit(1)
if not repo:
print("Error: GITHUB_REPOSITORY environment variable not set", file=sys.stderr)
sys.exit(1)
# Check if fork PR
if event_path and is_fork_pr(event_path):
print(" Fork PR detected - skipping comment (no write permissions)")
sys.exit(0)
# Generate comment body
comment_body = generate_comment_body(coverage, emoji, status, badge_color, coverage_report_path)
# Check for existing comment
existing_comment_id = find_existing_comment(token, repo, pr_number)
# Post or update comment
if existing_comment_id:
print(f"Found existing comment (ID: {existing_comment_id}), updating...")
success = update_comment(token, repo, existing_comment_id, comment_body)
else:
print("No existing comment found, creating new one...")
success = post_comment(token, repo, pr_number, comment_body)
sys.exit(0 if success else 1)
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,2 @@
# Python dependencies for GitHub Actions scripts
requests>=2.31.0