fix: Form 4 buy_only=P-only + 10분기 backfill 설정

- buy_only=true 필터를 P+A → P(open-market purchase) 단독으로 변경
  - get_form4_pit / get_form4_by_date / get_form4_aggregate 3곳 동일 수정
  - aggregate의 buy_dollar_total / cluster_size / csuite_count / avg_pct_of_holding
    모두 P-only 기준으로 정확해짐 (A코드 awards 제외)
- _parse_transaction_element: purchase_pct_of_holding 계산도 P-only로 정합
- SEC_FORM4_BOOTSTRAP_QUARTERS: 8 → 10 (2024Q1~)
- Form4AggregateResponse docstring에 P-only 명시

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
main
I Luk Kim 4 months ago
parent db9abd7786
commit 93a81fd35a

@ -137,7 +137,7 @@ async def get_form4(
as_of: date = Query(..., description="Point-in-time cutoff (filing_date ≤ as_of). Required."), as_of: date = Query(..., description="Point-in-time cutoff (filing_date ≤ as_of). Required."),
start: Optional[date] = Query(None, description="Window start (filing_date ≥ start)"), start: Optional[date] = Query(None, description="Window start (filing_date ≥ start)"),
end: Optional[date] = Query(None, description="Window end (filing_date ≤ end)"), end: Optional[date] = Query(None, description="Window end (filing_date ≤ end)"),
buy_only: bool = Query(False, description="Only return buy transactions (P/A, shares > 0)"), buy_only: bool = Query(False, description="Only return open-market purchases (transaction_code=P, shares > 0). Excludes awards/grants."),
csuite_only: bool = Query(False, description="Only return C-suite insider transactions"), csuite_only: bool = Query(False, description="Only return C-suite insider transactions"),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
@ -170,7 +170,7 @@ async def get_form4(
async def get_form4_by_date( async def get_form4_by_date(
filing_date: date, filing_date: date,
response: Response, response: Response,
buy_only: bool = Query(False, description="Only return buy transactions"), buy_only: bool = Query(False, description="Only return open-market purchases (transaction_code=P). Excludes awards/grants."),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
svc = InsiderTransactionService() svc = InsiderTransactionService()

@ -53,7 +53,7 @@ class Settings(BaseSettings):
SEC_DATA_REFRESH_HOURS: int = 24 SEC_DATA_REFRESH_HOURS: int = 24
SEC_DATA_START_YEAR: int = 1994 # SEC EDGAR data available from 1994 SEC_DATA_START_YEAR: int = 1994 # SEC EDGAR data available from 1994
SEC_INGEST_TIMEZONE: str = "America/New_York" SEC_INGEST_TIMEZONE: str = "America/New_York"
SEC_FORM4_BOOTSTRAP_QUARTERS: int = 8 # how many recent quarters to backfill SEC_FORM4_BOOTSTRAP_QUARTERS: int = 10 # how many recent quarters to backfill (~2.5 years)
# Security # Security
SECRET_KEY: str = os.getenv("SECRET_KEY", "development-secret-key-change-in-production") SECRET_KEY: str = os.getenv("SECRET_KEY", "development-secret-key-change-in-production")

@ -161,6 +161,11 @@ class Form4ByDateResponse(BaseModel):
class Form4AggregateResponse(BaseModel): class Form4AggregateResponse(BaseModel):
"""Aggregate Form 4 insider activity over a rolling window.
All fields are computed over open-market purchases only (transaction_code='P',
shares > 0, non-derivative). Awards/grants (A-code) are excluded.
"""
symbol: str symbol: str
as_of: date as_of: date
window_days: int window_days: int

@ -327,7 +327,7 @@ class InsiderTransactionService:
if end: if end:
conditions.append(InsiderTransaction.filing_date <= datetime(end.year, end.month, end.day, 23, 59, 59, tzinfo=timezone.utc)) conditions.append(InsiderTransaction.filing_date <= datetime(end.year, end.month, end.day, 23, 59, 59, tzinfo=timezone.utc))
if buy_only: if buy_only:
conditions.append(InsiderTransaction.transaction_code.in_(["P", "A"])) conditions.append(InsiderTransaction.transaction_code == "P")
conditions.append(InsiderTransaction.shares > 0) conditions.append(InsiderTransaction.shares > 0)
if csuite_only: if csuite_only:
conditions.append(InsiderTransaction.is_c_suite == True) conditions.append(InsiderTransaction.is_c_suite == True)
@ -357,7 +357,7 @@ class InsiderTransactionService:
func.cast(InsiderTransaction.filing_date, SADate) == filing_date, func.cast(InsiderTransaction.filing_date, SADate) == filing_date,
] ]
if buy_only: if buy_only:
conditions.append(InsiderTransaction.transaction_code.in_(["P", "A"])) conditions.append(InsiderTransaction.transaction_code == "P")
conditions.append(InsiderTransaction.shares > 0) conditions.append(InsiderTransaction.shares > 0)
result = await db.execute( result = await db.execute(
@ -384,7 +384,7 @@ class InsiderTransactionService:
InsiderTransaction.ticker == ticker, InsiderTransaction.ticker == ticker,
InsiderTransaction.filing_date > window_start, InsiderTransaction.filing_date > window_start,
InsiderTransaction.filing_date <= as_of_dt, InsiderTransaction.filing_date <= as_of_dt,
InsiderTransaction.transaction_code.in_(["P", "A"]), InsiderTransaction.transaction_code == "P",
InsiderTransaction.shares > 0, InsiderTransaction.shares > 0,
InsiderTransaction.is_derivative == False, InsiderTransaction.is_derivative == False,
) )
@ -553,9 +553,9 @@ class InsiderTransactionService:
if price is not None and shares is not None: if price is not None and shares is not None:
total_value = round(abs(shares) * price, 2) total_value = round(abs(shares) * price, 2)
# purchase_pct_of_holding: only for open-market buys/awards with known post-holding # purchase_pct_of_holding: only for open-market purchases (P) with known post-holding
purchase_pct = None purchase_pct = None
if code in ("P", "A") and shares is not None and shares > 0 and shares_after and shares_after > 0: if code == "P" and shares is not None and shares > 0 and shares_after and shares_after > 0:
purchase_pct = abs(shares) / shares_after purchase_pct = abs(shares) / shares_after
return { return {

Loading…
Cancel
Save