Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bccefed35e | |||
| d610ecb57c | |||
| c852ad49cf | |||
| 3fbb5ea2bf | |||
| a45961928e | |||
| 736951526b | |||
| ed137c2574 | |||
| b694a101d1 | |||
| 14ced733f2 | |||
| 6475ecd3b0 | |||
| 29929d76d3 | |||
| 6772a86081 | |||
| 6ff40c8ea3 | |||
| 6f252162ef | |||
| ee4ae5583d |
@@ -175,6 +175,27 @@ jobs:
|
||||
- name: Validate Dotnet Parity Contract
|
||||
run: python3 tools/validate_dotnet_parity_contract_v1.py
|
||||
|
||||
- name: Validate Dotnet Provenance Contract
|
||||
run: python3 tools/validate_dotnet_provenance_contract_v1.py
|
||||
|
||||
- name: Validate Dotnet Scheduler Contract
|
||||
run: python3 tools/validate_dotnet_scheduler_contract_v1.py
|
||||
|
||||
- name: Validate Dotnet Normalization Contract
|
||||
run: python3 tools/validate_dotnet_normalization_contract_v1.py
|
||||
|
||||
- name: Validate Dotnet Idempotency Contract
|
||||
run: python3 tools/validate_dotnet_idempotency_contract_v1.py
|
||||
|
||||
- name: Validate Dotnet CICD Chain Contract
|
||||
run: python3 tools/validate_dotnet_cicd_chain_contract_v1.py
|
||||
|
||||
- name: Validate Dotnet Domain Parity Backlog
|
||||
run: python3 tools/validate_dotnet_domain_parity_backlog_v1.py
|
||||
|
||||
- name: Validate Dotnet Read Model Contract
|
||||
run: python3 tools/validate_dotnet_read_model_contract_v1.py
|
||||
|
||||
|
||||
|
||||
- name: Build Calibration Priority Backlog
|
||||
|
||||
@@ -100,9 +100,23 @@
|
||||
- `docs/WBS_10_DOTNET_MIGRATION_INVENTORY.yaml`: WBS-10 전환 우선순위용 실행 경로 인벤토리.
|
||||
- `docs/WBS_10_DOTNET_MIGRATION_EXECUTION_PLAN.yaml`: WBS-10 착수용 실행 분해 계획.
|
||||
- `docs/WBS_10_DOTNET_PARITY_CONTRACT.yaml`: WBS-10 핵심 계산기 parity 계약.
|
||||
- `docs/WBS_10_DOTNET_PROVENANCE_CONTRACT.yaml`: WBS-10 provenance payload 표준 계약.
|
||||
- `docs/WBS_10_DOTNET_SCHEDULER_CONTRACT.yaml`: WBS-10 scheduler state machine 계약.
|
||||
- `docs/WBS_10_DOTNET_NORMALIZATION_CONTRACT.yaml`: WBS-10 normalization/read model 계약.
|
||||
- `docs/WBS_10_DOTNET_IDEMPOTENCY_CONTRACT.yaml`: WBS-10 idempotency/lock 계약.
|
||||
- `docs/WBS_10_DOTNET_CICD_CHAIN_CONTRACT.yaml`: WBS-10 CI/CD 순차 게이트 계약.
|
||||
- `docs/WBS_10_DOTNET_DOMAIN_PARITY_BACKLOG.yaml`: WBS-10 domain parity backlog contract.
|
||||
- `docs/WBS_10_DOTNET_READ_MODEL_CONTRACT.yaml`: WBS-10 read model contract.
|
||||
- `tools/validate_dotnet_migration_roadmap_v1.py`: WBS-10 상세 로드맵 YAML validator.
|
||||
- `tools/validate_dotnet_migration_execution_plan_v1.py`: WBS-10 실행 분해 계획 validator.
|
||||
- `tools/validate_dotnet_parity_contract_v1.py`: WBS-10 parity 계약 validator.
|
||||
- `tools/validate_dotnet_provenance_contract_v1.py`: WBS-10 provenance 계약 validator.
|
||||
- `tools/validate_dotnet_scheduler_contract_v1.py`: WBS-10 scheduler 계약 validator.
|
||||
- `tools/validate_dotnet_normalization_contract_v1.py`: WBS-10 normalization 계약 validator.
|
||||
- `tools/validate_dotnet_idempotency_contract_v1.py`: WBS-10 idempotency 계약 validator.
|
||||
- `tools/validate_dotnet_cicd_chain_contract_v1.py`: WBS-10 CI/CD chain 계약 validator.
|
||||
- `tools/validate_dotnet_domain_parity_backlog_v1.py`: WBS-10 domain parity backlog validator.
|
||||
- `tools/validate_dotnet_read_model_contract_v1.py`: WBS-10 read model validator.
|
||||
- `Temp/snapshot_admin_approval_packet_v1.json`: snapshot admin approval packet export.
|
||||
- `Temp/snapshot_admin_approval_packet_v1.md`: snapshot admin approval packet summary.
|
||||
- `Temp/`: 실행 결과와 캐시. 라우팅 대상은 아니며 runtime consumer만 읽는다.
|
||||
|
||||
@@ -1469,6 +1469,13 @@ WBS-8.8 (KIS 리팩터) — 독립적 (원격 병행)
|
||||
> 실행 분해 계획: [WBS_10_DOTNET_MIGRATION_EXECUTION_PLAN.yaml](./WBS_10_DOTNET_MIGRATION_EXECUTION_PLAN.yaml)
|
||||
> 실행 분해 검증기: `tools/validate_dotnet_migration_execution_plan_v1.py`
|
||||
> parity 계약: [WBS_10_DOTNET_PARITY_CONTRACT.yaml](./WBS_10_DOTNET_PARITY_CONTRACT.yaml)
|
||||
> provenance 계약: [WBS_10_DOTNET_PROVENANCE_CONTRACT.yaml](./WBS_10_DOTNET_PROVENANCE_CONTRACT.yaml)
|
||||
> scheduler contract: [WBS_10_DOTNET_SCHEDULER_CONTRACT.yaml](./WBS_10_DOTNET_SCHEDULER_CONTRACT.yaml)
|
||||
> normalization contract: [WBS_10_DOTNET_NORMALIZATION_CONTRACT.yaml](./WBS_10_DOTNET_NORMALIZATION_CONTRACT.yaml)
|
||||
> idempotency contract: [WBS_10_DOTNET_IDEMPOTENCY_CONTRACT.yaml](./WBS_10_DOTNET_IDEMPOTENCY_CONTRACT.yaml)
|
||||
> ci/cd chain contract: [WBS_10_DOTNET_CICD_CHAIN_CONTRACT.yaml](./WBS_10_DOTNET_CICD_CHAIN_CONTRACT.yaml)
|
||||
> domain parity backlog: [WBS_10_DOTNET_DOMAIN_PARITY_BACKLOG.yaml](./WBS_10_DOTNET_DOMAIN_PARITY_BACKLOG.yaml)
|
||||
> read model contract: [WBS_10_DOTNET_READ_MODEL_CONTRACT.yaml](./WBS_10_DOTNET_READ_MODEL_CONTRACT.yaml)
|
||||
|
||||
> 현황 진단(2026-06-26): .NET 프로젝트는 Python 엔진(41 모듈, 14,500 LOC) 대비 5~10%(~1,400 LOC) 수준.
|
||||
> Domain 계산기 6개·데이터 모델 8개·KIS/Naver/Yahoo 클라이언트·PostgreSQL 마이그레이션·Razor Pages 어드민 대시보드 기본 구현 완료.
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
formula_id: WBS_10_DOTNET_CICD_CHAIN_CONTRACT_V1
|
||||
owner: QuantEngine
|
||||
status: draft
|
||||
goal: "CI, prepare-release, deploy-prod 순차 게이트를 고정한다."
|
||||
|
||||
workflows:
|
||||
ci:
|
||||
file: .gitea/workflows/ci.yml
|
||||
name: "Validators (Pushes and Pull Requests)"
|
||||
triggers:
|
||||
- push: main
|
||||
- pull_request: main
|
||||
role: "upstream validator"
|
||||
prepare_release:
|
||||
file: .gitea/workflows/prepare-release.yml
|
||||
name: "Prepare Release"
|
||||
triggers:
|
||||
- workflow_run: Validators (Pushes and Pull Requests)
|
||||
- workflow_dispatch
|
||||
role: "release builder"
|
||||
upstream_gate: "Validators (Pushes and Pull Requests) success"
|
||||
deploy_prod:
|
||||
file: .gitea/workflows/deploy-prod.yml
|
||||
name: "Deploy to Production"
|
||||
triggers:
|
||||
- workflow_run: Prepare Release
|
||||
- workflow_dispatch
|
||||
role: "production deployer"
|
||||
upstream_gate: "Prepare Release success"
|
||||
|
||||
dependency_chain:
|
||||
- "Validators (Pushes and Pull Requests) -> Prepare Release -> Deploy to Production"
|
||||
|
||||
required_guards:
|
||||
- "prepare-release는 Validators 성공 없이는 실행 금지"
|
||||
- "deploy-prod는 Prepare Release 성공 없이는 실행 금지"
|
||||
- "deploy-prod는 upstream CI SHA를 release tag와 대조"
|
||||
- "모든 단계는 concurrency group을 사용해 동일 SHA 중복 실행을 차단"
|
||||
|
||||
health_checks:
|
||||
- "upstream workflow conclusion == success"
|
||||
- "release tag sha matches workflow_run head_sha"
|
||||
- "artifact 존재 확인"
|
||||
- "SSH/Gitea secret 존재 확인"
|
||||
|
||||
notes:
|
||||
- "순차 게이트는 workflow_run 연결과 검증 스텝 둘 다 필요하다."
|
||||
- "병렬 실행은 금지된다."
|
||||
@@ -0,0 +1,50 @@
|
||||
formula_id: WBS_10_DOTNET_DOMAIN_PARITY_BACKLOG_V1
|
||||
owner: QuantEngine
|
||||
status: draft
|
||||
goal: "핵심 계산기 parity 대상과 우선순위를 고정한다."
|
||||
|
||||
coverage_min: 1.0
|
||||
tolerance_policy:
|
||||
numeric_default: 0
|
||||
text_default: exact
|
||||
factor_calculator_numeric: 0.000001
|
||||
exit_stop_price_numeric: 0.0001
|
||||
|
||||
parity_targets:
|
||||
- target_id: formula_engine_timing
|
||||
source: src/dotnet/QuantEngine.Core/Domain/FormulaEngine.cs
|
||||
priority: 1
|
||||
reason: "timing decision은 downstream routing의 선행 게이트"
|
||||
- target_id: formula_engine_sell
|
||||
source: src/dotnet/QuantEngine.Core/Domain/FormulaEngine.cs
|
||||
priority: 2
|
||||
reason: "sell ratio/action은 실행 표 생성의 핵심"
|
||||
- target_id: formula_engine_final
|
||||
source: src/dotnet/QuantEngine.Core/Domain/FormulaEngine.cs
|
||||
priority: 3
|
||||
reason: "final decision은 보고/배포의 최종 산출"
|
||||
- target_id: exit_stop_price
|
||||
source: src/dotnet/QuantEngine.Core/Domain/ExitDecisions.cs
|
||||
priority: 4
|
||||
reason: "손절가 절대값 일치가 필요"
|
||||
- target_id: exit_stop_ladder
|
||||
source: src/dotnet/QuantEngine.Core/Domain/ExitDecisions.cs
|
||||
priority: 5
|
||||
reason: "워터폴 선형 처리 검증"
|
||||
- target_id: exit_heat_thresholds
|
||||
source: src/dotnet/QuantEngine.Core/Domain/ExitDecisions.cs
|
||||
priority: 6
|
||||
reason: "동적 열감 임계값 일치"
|
||||
- target_id: factor_calculator
|
||||
source: src/dotnet/QuantEngine.Core/Domain/FactorCalculator.cs
|
||||
priority: 7
|
||||
reason: "정규화된 입력 순서에서 deterministic output 보장"
|
||||
|
||||
coverage_rule:
|
||||
- "priority 1..7 모두 존재해야 한다"
|
||||
- "tolerance_policy는 숫자/텍스트 기본값을 정의해야 한다"
|
||||
- "새 parity target은 reference fixture와 함께만 추가한다"
|
||||
|
||||
notes:
|
||||
- "parity는 기능 추가가 아니라 회귀 차단 장치다."
|
||||
- "수치 재계산은 reference fixture 외에서 하지 않는다."
|
||||
@@ -0,0 +1,53 @@
|
||||
formula_id: WBS_10_DOTNET_IDEMPOTENCY_CONTRACT_V1
|
||||
owner: QuantEngine
|
||||
status: draft
|
||||
goal: "중복 실행 방지, lock/lease 정책, 재시도 경계를 표준화한다."
|
||||
|
||||
lock_domain:
|
||||
canonical_table: quantengine.workspace_lock
|
||||
fields:
|
||||
- domain
|
||||
- target_ref
|
||||
- locked_by
|
||||
- reason
|
||||
- locked_at
|
||||
invariant:
|
||||
- "같은 domain + target_ref 조합은 동시에 하나만 존재"
|
||||
- "잠금 해제는 동일 domain + target_ref 로만 수행"
|
||||
- "잠금 없는 실행은 retryable 작업으로 취급하지 않는다"
|
||||
|
||||
idempotency_key:
|
||||
required: true
|
||||
pattern: "{job_id}:{resource_key}:{run_scope}"
|
||||
scope_examples:
|
||||
- "daily-collection:collection:yyyyMMdd"
|
||||
- "hourly-price-update:ticker:yyyyMMddHH"
|
||||
- "weekly-report:report:yyyy-'W'ww"
|
||||
- "monthly-optimization:optimization:yyyy-MM"
|
||||
|
||||
lease_policy:
|
||||
required: true
|
||||
fields:
|
||||
- lease_owner
|
||||
- timeout_policy
|
||||
- retry_policy
|
||||
retry_policy:
|
||||
max_attempts: 3
|
||||
backoff: exponential
|
||||
retryable_errors:
|
||||
- transient network failure
|
||||
- upstream timeout
|
||||
- deadlock detected
|
||||
non_retryable_errors:
|
||||
- validation failure
|
||||
- contract failure
|
||||
- missing configuration
|
||||
|
||||
duplicate_execution_guards:
|
||||
- "동일 job_id/resource_key/run_scope 중복 호출 금지"
|
||||
- "동일 lock_domain이 점유 중이면 새 실행은 blocked"
|
||||
- "중복 실행이 발생하면 audit는 남기되 write path는 재진입 금지"
|
||||
|
||||
notes:
|
||||
- "idempotency는 hash 추정이 아니라 명시된 key 조합만 사용한다."
|
||||
- "lock은 수동 승인 워크플로와 동일한 canonical table을 사용한다."
|
||||
@@ -0,0 +1,85 @@
|
||||
formula_id: WBS_10_DOTNET_NORMALIZATION_CONTRACT_V1
|
||||
owner: QuantEngine
|
||||
status: draft
|
||||
goal: "쓰기 경로 정규화와 읽기 경로 역정규화 경계를 고정한다."
|
||||
|
||||
canonical_write_path:
|
||||
schema: engine_history
|
||||
tables:
|
||||
- source_observation
|
||||
- factor_definition
|
||||
- factor_observation
|
||||
- decision_event
|
||||
- decision_factor_evidence
|
||||
- outcome_evaluation
|
||||
invariant:
|
||||
- "source_observation은 원천 관측 1건당 1행"
|
||||
- "factor_definition은 (factor_id, factor_version) 단일 원장"
|
||||
- "factor_observation은 observation_id를 반드시 참조"
|
||||
- "decision_event는 decision_key로 단일 식별"
|
||||
- "outcome_evaluation은 decision_id + horizon_days 조합으로 단일 식별"
|
||||
|
||||
canonical_read_path:
|
||||
view: engine_history.training_example_v1
|
||||
purpose: "모델 학습/캘리브레이션용 역정규화 projection"
|
||||
consumers:
|
||||
- model_training
|
||||
- calibration_jobs
|
||||
- diagnostics
|
||||
|
||||
forbidden_patterns:
|
||||
- "읽기 모델을 쓰기 원장으로 사용"
|
||||
- "원천 payload를 읽기 projection에 중복 저장"
|
||||
- "직렬 UI 조회를 위해 원장 테이블을 직접 조인해 장기 유지"
|
||||
|
||||
expected_fields:
|
||||
normalized_tables:
|
||||
source_observation:
|
||||
- observation_id
|
||||
- observed_at
|
||||
- instrument_id
|
||||
- source_name
|
||||
- source_version
|
||||
- payload
|
||||
- provenance
|
||||
factor_observation:
|
||||
- factor_observation_id
|
||||
- observation_id
|
||||
- factor_id
|
||||
- factor_version
|
||||
- observed_at
|
||||
- numeric_value
|
||||
- text_value
|
||||
- gate
|
||||
- provenance
|
||||
decision_event:
|
||||
- decision_id
|
||||
- decision_key
|
||||
- decided_at
|
||||
- instrument_id
|
||||
- action
|
||||
- gate
|
||||
- score
|
||||
- source_version
|
||||
- trace
|
||||
- provenance
|
||||
denormalized_view:
|
||||
- decision_id
|
||||
- decision_key
|
||||
- decided_at
|
||||
- instrument_id
|
||||
- action
|
||||
- decision_gate
|
||||
- score
|
||||
- source_version
|
||||
- horizon_days
|
||||
- realized_return
|
||||
- benchmark_return
|
||||
- excess_return
|
||||
- outcome_class
|
||||
- evaluation_gate
|
||||
- factor_features
|
||||
|
||||
notes:
|
||||
- "정규화는 쓰기 중복 제거와 provenance 보존이 목적이다."
|
||||
- "역정규화는 학습/진단 편의용 projection으로만 허용한다."
|
||||
@@ -0,0 +1,67 @@
|
||||
formula_id: WBS_10_DOTNET_PROVENANCE_CONTRACT_V1
|
||||
owner: QuantEngine
|
||||
status: draft
|
||||
goal: "결정/팩터/수집 provenance payload를 표준화한다."
|
||||
|
||||
payloads:
|
||||
- payload_id: factor_evidence
|
||||
source: src/dotnet/QuantEngine.Application/Services/DecisionLearningService.cs
|
||||
required_fields:
|
||||
- FactorObservationId
|
||||
- FactorId
|
||||
- FactorVersion
|
||||
- ObservedAt
|
||||
- Gate
|
||||
- Role
|
||||
- SourceName
|
||||
- PayloadJson
|
||||
- ProvenanceJson
|
||||
nullable_fields:
|
||||
- NumericValue
|
||||
- TextValue
|
||||
pass_condition: "factor evidence payload가 누락 없이 기록됨"
|
||||
|
||||
- payload_id: decision_event
|
||||
source: src/dotnet/QuantEngine.Application/Services/DecisionLearningService.cs
|
||||
required_fields:
|
||||
- decisionKey
|
||||
- decidedAt
|
||||
- instrumentId
|
||||
- action
|
||||
- gate
|
||||
- sourceVersion
|
||||
nullable_fields:
|
||||
- score
|
||||
- trace
|
||||
- provenance
|
||||
pass_condition: "decision event payload가 normalized store에 기록됨"
|
||||
|
||||
- payload_id: collection_audit
|
||||
source: src/dotnet/QuantEngine.Application/Models/CollectionExecutionAudit.cs
|
||||
required_fields:
|
||||
- RunId
|
||||
- State
|
||||
- StartedAt
|
||||
- SuccessCount
|
||||
- ErrorCount
|
||||
nullable_fields:
|
||||
- FinishedAt
|
||||
- Message
|
||||
pass_condition: "collection audit payload가 append-only JSONL에 기록됨"
|
||||
|
||||
- payload_id: scheduler_audit
|
||||
source: src/dotnet/QuantEngine.Web/Services/SchedulerModels.cs
|
||||
required_fields:
|
||||
- JobId
|
||||
- RunId
|
||||
- State
|
||||
- StartedAt
|
||||
nullable_fields:
|
||||
- Reason
|
||||
- FinishedAt
|
||||
- ResourceKey
|
||||
pass_condition: "scheduler audit payload가 append-only JSONL에 기록됨"
|
||||
|
||||
notes:
|
||||
- "provenance payload는 구조를 표준화하되, 숫자 계산은 하지 않는다."
|
||||
- "LLM은 payload value를 재계산하지 않는다."
|
||||
@@ -0,0 +1,54 @@
|
||||
formula_id: WBS_10_DOTNET_READ_MODEL_CONTRACT_V1
|
||||
owner: QuantEngine
|
||||
status: draft
|
||||
goal: "운영 화면과 조회 API의 read model 경계를 분리한다."
|
||||
|
||||
read_models:
|
||||
- model_id: dashboard_summary
|
||||
purpose: "운영 대시보드 상태"
|
||||
source: QuantEngine.Infrastructure.Repositories.CollectionRepository
|
||||
consumers:
|
||||
- src/dotnet/QuantEngine.Web/Pages/Admin/Dashboard/Index.cshtml.cs
|
||||
- src/dotnet/QuantEngine.Web/Endpoints/CollectionEndpoints.cs
|
||||
fields:
|
||||
- LastRunId
|
||||
- LastRunAt
|
||||
- SuccessCount
|
||||
- ErrorCount
|
||||
- SnapshotCount
|
||||
staleness_budget: "5m"
|
||||
- model_id: collection_runs
|
||||
purpose: "최근 수집 실행 이력"
|
||||
source: QuantEngine.Infrastructure.Repositories.CollectionRepository
|
||||
consumers:
|
||||
- src/dotnet/QuantEngine.Web/Pages/Admin/Collection/Index.cshtml.cs
|
||||
- src/dotnet/QuantEngine.Web/Endpoints/CollectionEndpoints.cs
|
||||
fields:
|
||||
- RunId
|
||||
- State
|
||||
- StartedAt
|
||||
- FinishedAt
|
||||
- SuccessCount
|
||||
- ErrorCount
|
||||
staleness_budget: "5m"
|
||||
- model_id: price_history_summary
|
||||
purpose: "가격 히스토리 요약"
|
||||
source: QuantEngine.Infrastructure.Repositories.CollectionRepository
|
||||
consumers:
|
||||
- src/dotnet/QuantEngine.Web/Pages/Admin/Collection/Index.cshtml.cs
|
||||
- src/dotnet/QuantEngine.Web/Endpoints/CollectionEndpoints.cs
|
||||
fields:
|
||||
- Ticker
|
||||
- Count
|
||||
- FirstDate
|
||||
- LastDate
|
||||
staleness_budget: "15m"
|
||||
|
||||
rules:
|
||||
- "read model은 조회 전용이어야 한다."
|
||||
- "운영 화면은 직접 원장 테이블을 조립하지 않는다."
|
||||
- "쓰기 로직은 read model에 의존하지 않는다."
|
||||
- "staleness_budget이 명시되지 않은 조회는 금지한다."
|
||||
|
||||
notes:
|
||||
- "의도된 역정규화는 허용하되, 원장과 동일 테이블로 재사용하지 않는다."
|
||||
@@ -0,0 +1,60 @@
|
||||
formula_id: WBS_10_DOTNET_SCHEDULER_CONTRACT_V1
|
||||
owner: QuantEngine
|
||||
status: draft
|
||||
goal: "스케줄러 상태 전이, 의존성, 재시도, 감사 추적을 표준화한다."
|
||||
|
||||
state_machine:
|
||||
states:
|
||||
- pending
|
||||
- running
|
||||
- succeeded
|
||||
- failed
|
||||
- retrying
|
||||
- blocked
|
||||
allowed_transitions:
|
||||
pending: [running, blocked]
|
||||
running: [succeeded, failed, retrying, blocked]
|
||||
failed: [retrying, blocked]
|
||||
retrying: [running, failed, blocked]
|
||||
succeeded: []
|
||||
blocked: []
|
||||
|
||||
job_definitions:
|
||||
- job_id: daily-collection
|
||||
cron: "0 9 * * *"
|
||||
lease_owner: collection
|
||||
timeout_policy: "2h"
|
||||
dependency: gather-trading-data
|
||||
- job_id: hourly-price-update
|
||||
cron: "0 9,11,13,15 * * 1-5"
|
||||
lease_owner: price-update
|
||||
timeout_policy: "30m"
|
||||
dependency: price-feed
|
||||
- job_id: weekly-report
|
||||
cron: "0 17 * * 5"
|
||||
lease_owner: report
|
||||
timeout_policy: "1h"
|
||||
dependency: report-generator
|
||||
- job_id: monthly-optimization
|
||||
cron: "0 2 1 * *"
|
||||
lease_owner: optimization
|
||||
timeout_policy: "3h"
|
||||
dependency: optimizer
|
||||
|
||||
audit_fields:
|
||||
- JobId
|
||||
- RunId
|
||||
- State
|
||||
- StartedAt
|
||||
- FinishedAt
|
||||
- ResourceKey
|
||||
- Reason
|
||||
|
||||
idempotency:
|
||||
required: true
|
||||
key_pattern: "{job_id}:{resource_key}:{yyyyMMddHHmm}"
|
||||
|
||||
notes:
|
||||
- "상태 전이와 감사 추적은 append-only JSONL로 남긴다."
|
||||
- "실행 재개는 retrying 이후에만 허용한다."
|
||||
- "숫자 계산은 여기서 하지 않는다."
|
||||
@@ -2329,6 +2329,118 @@ dag:
|
||||
- Temp/wbs_10_dotnet_parity_contract_v1.json
|
||||
strict: true
|
||||
timeout_sec: 60
|
||||
validate_dotnet_provenance_contract:
|
||||
artifact_policy: keep
|
||||
cache_key: validate_dotnet_provenance_contract_v1
|
||||
command:
|
||||
- python
|
||||
- tools/validate_dotnet_provenance_contract_v1.py
|
||||
depends_on: []
|
||||
id: validate_dotnet_provenance_contract
|
||||
inputs:
|
||||
- tools/validate_dotnet_provenance_contract_v1.py
|
||||
- docs/WBS_10_DOTNET_PROVENANCE_CONTRACT.yaml
|
||||
note: WBS-10 provenance payload 표준 계약과 필수 payload 표본을 검증한다.
|
||||
outputs:
|
||||
- Temp/wbs_10_dotnet_provenance_contract_v1.json
|
||||
strict: true
|
||||
timeout_sec: 60
|
||||
validate_dotnet_scheduler_contract:
|
||||
artifact_policy: keep
|
||||
cache_key: validate_dotnet_scheduler_contract_v1
|
||||
command:
|
||||
- python
|
||||
- tools/validate_dotnet_scheduler_contract_v1.py
|
||||
depends_on: []
|
||||
id: validate_dotnet_scheduler_contract
|
||||
inputs:
|
||||
- tools/validate_dotnet_scheduler_contract_v1.py
|
||||
- docs/WBS_10_DOTNET_SCHEDULER_CONTRACT.yaml
|
||||
note: WBS-10 scheduler 상태 전이, 감사, idempotency 계약을 검증한다.
|
||||
outputs:
|
||||
- Temp/wbs_10_dotnet_scheduler_contract_v1.json
|
||||
strict: true
|
||||
timeout_sec: 60
|
||||
validate_dotnet_normalization_contract:
|
||||
artifact_policy: keep
|
||||
cache_key: validate_dotnet_normalization_contract_v1
|
||||
command:
|
||||
- python
|
||||
- tools/validate_dotnet_normalization_contract_v1.py
|
||||
depends_on: []
|
||||
id: validate_dotnet_normalization_contract
|
||||
inputs:
|
||||
- tools/validate_dotnet_normalization_contract_v1.py
|
||||
- docs/WBS_10_DOTNET_NORMALIZATION_CONTRACT.yaml
|
||||
note: WBS-10 정규화 원장과 역정규화 read model 경계를 검증한다.
|
||||
outputs:
|
||||
- Temp/wbs_10_dotnet_normalization_contract_v1.json
|
||||
strict: true
|
||||
timeout_sec: 60
|
||||
validate_dotnet_idempotency_contract:
|
||||
artifact_policy: keep
|
||||
cache_key: validate_dotnet_idempotency_contract_v1
|
||||
command:
|
||||
- python
|
||||
- tools/validate_dotnet_idempotency_contract_v1.py
|
||||
depends_on: []
|
||||
id: validate_dotnet_idempotency_contract
|
||||
inputs:
|
||||
- tools/validate_dotnet_idempotency_contract_v1.py
|
||||
- docs/WBS_10_DOTNET_IDEMPOTENCY_CONTRACT.yaml
|
||||
note: WBS-10 lock/lease/idempotency 경계를 검증한다.
|
||||
outputs:
|
||||
- Temp/wbs_10_dotnet_idempotency_contract_v1.json
|
||||
strict: true
|
||||
timeout_sec: 60
|
||||
validate_dotnet_cicd_chain_contract:
|
||||
artifact_policy: keep
|
||||
cache_key: validate_dotnet_cicd_chain_contract_v1
|
||||
command:
|
||||
- python
|
||||
- tools/validate_dotnet_cicd_chain_contract_v1.py
|
||||
depends_on: []
|
||||
id: validate_dotnet_cicd_chain_contract
|
||||
inputs:
|
||||
- tools/validate_dotnet_cicd_chain_contract_v1.py
|
||||
- docs/WBS_10_DOTNET_CICD_CHAIN_CONTRACT.yaml
|
||||
note: WBS-10 CI → Prepare Release → Deploy to Production 순차 게이트를 검증한다.
|
||||
outputs:
|
||||
- Temp/wbs_10_dotnet_cicd_chain_contract_v1.json
|
||||
strict: true
|
||||
timeout_sec: 60
|
||||
validate_dotnet_domain_parity_backlog:
|
||||
artifact_policy: keep
|
||||
cache_key: validate_dotnet_domain_parity_backlog_v1
|
||||
command:
|
||||
- python
|
||||
- tools/validate_dotnet_domain_parity_backlog_v1.py
|
||||
depends_on: []
|
||||
id: validate_dotnet_domain_parity_backlog
|
||||
inputs:
|
||||
- tools/validate_dotnet_domain_parity_backlog_v1.py
|
||||
- docs/WBS_10_DOTNET_DOMAIN_PARITY_BACKLOG.yaml
|
||||
note: WBS-10 핵심 계산기 parity 대상과 우선순위를 검증한다.
|
||||
outputs:
|
||||
- Temp/wbs_10_dotnet_domain_parity_backlog_v1.json
|
||||
strict: true
|
||||
timeout_sec: 60
|
||||
validate_dotnet_read_model_contract:
|
||||
artifact_policy: keep
|
||||
cache_key: validate_dotnet_read_model_contract_v1
|
||||
command:
|
||||
- python
|
||||
- tools/validate_dotnet_read_model_contract_v1.py
|
||||
depends_on: []
|
||||
id: validate_dotnet_read_model_contract
|
||||
inputs:
|
||||
- tools/validate_dotnet_read_model_contract_v1.py
|
||||
- docs/WBS_10_DOTNET_READ_MODEL_CONTRACT.yaml
|
||||
note: WBS-10 운영 조회용 read model 경계를 검증한다.
|
||||
outputs:
|
||||
- Temp/wbs_10_dotnet_read_model_contract_v1.json
|
||||
strict: true
|
||||
timeout_sec: 60
|
||||
validate_specs:
|
||||
artifact_policy: keep
|
||||
cache_key: validate_specs_v1
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
using QuantEngine.Core.Interfaces;
|
||||
|
||||
namespace QuantEngine.Application.Interfaces;
|
||||
|
||||
public interface ICollectionReadModelService
|
||||
{
|
||||
Task<CollectionDashboardStateRecord> GetDashboardStateAsync();
|
||||
Task<List<CollectionRunRecord>> GetRecentRunsAsync(int limit = 20);
|
||||
Task<List<CollectionSnapshotRecord>> GetRunSnapshotsAsync(string runId);
|
||||
Task<List<CollectionErrorRecord>> GetRunErrorsAsync(string runId, int limit = 50);
|
||||
Task<List<CollectionSnapshotRecord>> GetLatestSnapshotsForTickerAsync(string ticker, int limit = 10);
|
||||
Task<List<PriceHistorySummaryRecord>> GetPriceHistorySummaryAsync();
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace QuantEngine.Application.Models;
|
||||
|
||||
public sealed record CollectionExecutionAudit(
|
||||
string RunId,
|
||||
string State,
|
||||
DateTimeOffset StartedAt,
|
||||
DateTimeOffset? FinishedAt,
|
||||
int SuccessCount,
|
||||
int ErrorCount,
|
||||
string? Message);
|
||||
@@ -0,0 +1,21 @@
|
||||
using QuantEngine.Application.Interfaces;
|
||||
using QuantEngine.Core.Interfaces;
|
||||
|
||||
namespace QuantEngine.Application.Services;
|
||||
|
||||
public sealed class CollectionReadModelService : ICollectionReadModelService
|
||||
{
|
||||
private readonly ICollectionRepository _repository;
|
||||
|
||||
public CollectionReadModelService(ICollectionRepository repository)
|
||||
{
|
||||
_repository = repository;
|
||||
}
|
||||
|
||||
public Task<CollectionDashboardStateRecord> GetDashboardStateAsync() => _repository.GetDashboardStateAsync();
|
||||
public Task<List<CollectionRunRecord>> GetRecentRunsAsync(int limit = 20) => _repository.GetRecentRunsAsync(limit);
|
||||
public Task<List<CollectionSnapshotRecord>> GetRunSnapshotsAsync(string runId) => _repository.GetRunSnapshotsAsync(runId);
|
||||
public Task<List<CollectionErrorRecord>> GetRunErrorsAsync(string runId, int limit = 50) => _repository.GetRunErrorsAsync(runId, limit);
|
||||
public Task<List<CollectionSnapshotRecord>> GetLatestSnapshotsForTickerAsync(string ticker, int limit = 10) => _repository.GetLatestSnapshotsForTickerAsync(ticker, limit);
|
||||
public Task<List<PriceHistorySummaryRecord>> GetPriceHistorySummaryAsync() => _repository.GetPriceHistorySummaryAsync();
|
||||
}
|
||||
@@ -1,22 +1,13 @@
|
||||
using System.Text.Json;
|
||||
using QuantEngine.Core.Interfaces;
|
||||
using QuantEngine.Application.Interfaces;
|
||||
|
||||
namespace QuantEngine.Application.Services;
|
||||
|
||||
public class DataCollectionService
|
||||
{
|
||||
private readonly IKisApiClient _kisApiClient;
|
||||
private readonly ICollectionRepository _repository;
|
||||
private readonly ICollectionOrchestrator _orchestrator;
|
||||
|
||||
public DataCollectionService(
|
||||
IKisApiClient kisApiClient,
|
||||
ICollectionRepository repository,
|
||||
ICollectionOrchestrator orchestrator)
|
||||
public DataCollectionService(ICollectionOrchestrator orchestrator)
|
||||
{
|
||||
_kisApiClient = kisApiClient;
|
||||
_repository = repository;
|
||||
_orchestrator = orchestrator;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
using System.Text.Json;
|
||||
using QuantEngine.Core.Domain;
|
||||
using QuantEngine.Core.Interfaces;
|
||||
|
||||
namespace QuantEngine.Application.Services;
|
||||
|
||||
public sealed record FactorComputationAudit(
|
||||
string Ticker,
|
||||
int StockBars,
|
||||
int IndexBars,
|
||||
string State,
|
||||
DateTimeOffset ComputedAt,
|
||||
string? SourceVersion);
|
||||
|
||||
public sealed class FactorComputationService
|
||||
{
|
||||
private readonly HistoryIngestionService _history;
|
||||
private readonly string _auditRoot;
|
||||
|
||||
public FactorComputationService(HistoryIngestionService history)
|
||||
{
|
||||
_history = history;
|
||||
_auditRoot = FindRepoTempRoot();
|
||||
}
|
||||
|
||||
private static string FindRepoTempRoot()
|
||||
{
|
||||
var current = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (current != null)
|
||||
{
|
||||
if (Directory.Exists(Path.Combine(current.FullName, ".git")))
|
||||
{
|
||||
return Path.Combine(current.FullName, "Temp", "factor_audit");
|
||||
}
|
||||
current = current.Parent;
|
||||
}
|
||||
|
||||
return Path.Combine(Directory.GetCurrentDirectory(), "Temp", "factor_audit");
|
||||
}
|
||||
|
||||
private void AppendAudit(FactorComputationAudit audit)
|
||||
{
|
||||
Directory.CreateDirectory(_auditRoot);
|
||||
var path = Path.Combine(_auditRoot, $"{audit.Ticker}.jsonl");
|
||||
File.AppendAllText(path, JsonSerializer.Serialize(audit, new JsonSerializerOptions { WriteIndented = false }) + Environment.NewLine);
|
||||
}
|
||||
|
||||
public FactorOutputs Compute(
|
||||
string ticker,
|
||||
List<PriceHistoryDailyRecord> stockBars,
|
||||
List<PriceHistoryDailyRecord> indexBars,
|
||||
string? sourceVersion = null)
|
||||
{
|
||||
var computedAt = DateTimeOffset.UtcNow;
|
||||
var outputs = FactorCalculator.CalculateFactors(stockBars, indexBars);
|
||||
AppendAudit(new FactorComputationAudit(ticker, stockBars.Count, indexBars.Count, "SUCCEEDED", computedAt, sourceVersion));
|
||||
return outputs;
|
||||
}
|
||||
|
||||
public async Task AppendFactorOutputsAsync(
|
||||
string ticker,
|
||||
string sourceVersion,
|
||||
FactorOutputs outputs,
|
||||
DateTimeOffset? observedAt = null)
|
||||
{
|
||||
var when = observedAt ?? DateTimeOffset.UtcNow;
|
||||
await _history.AppendFactorOutputAsync("momentum_20d", sourceVersion, outputs.Momentum20D, "PASS", sourceVersion, when);
|
||||
await _history.AppendFactorOutputAsync("momentum_60d", sourceVersion, outputs.Momentum60D, "PASS", sourceVersion, when);
|
||||
await _history.AppendFactorOutputAsync("momentum_120d", sourceVersion, outputs.Momentum120D, "PASS", sourceVersion, when);
|
||||
await _history.AppendFactorOutputAsync("atr_20pct", sourceVersion, outputs.Atr20Pct, "PASS", sourceVersion, when);
|
||||
await _history.AppendFactorOutputAsync("stdev_20d", sourceVersion, outputs.StDev20D, "PASS", sourceVersion, when);
|
||||
await _history.AppendFactorOutputAsync("beta_60d", sourceVersion, outputs.Beta60D, "PASS", sourceVersion, when);
|
||||
await _history.AppendFactorOutputAsync("rs_20d", sourceVersion, outputs.Rs20D, "PASS", sourceVersion, when);
|
||||
AppendAudit(new FactorComputationAudit(ticker, 0, 0, "PERSISTED", when, sourceVersion));
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.IO;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using QuantEngine.Core.Interfaces;
|
||||
using QuantEngine.Application.Interfaces;
|
||||
using QuantEngine.Application.Services;
|
||||
using QuantEngine.Application.Models;
|
||||
|
||||
namespace QuantEngine.Application.Services;
|
||||
|
||||
@@ -15,6 +17,7 @@ public class KisDataCollectionOrchestrator : ICollectionOrchestrator
|
||||
private readonly PriceDataNormalizer _normalizer;
|
||||
private readonly SourcePriorityResolver _priorityResolver;
|
||||
private readonly ILogger<KisDataCollectionOrchestrator> _logger;
|
||||
private readonly string _auditRoot;
|
||||
|
||||
public KisDataCollectionOrchestrator(
|
||||
IKisApiClient kisApiClient,
|
||||
@@ -28,6 +31,28 @@ public class KisDataCollectionOrchestrator : ICollectionOrchestrator
|
||||
_normalizer = normalizer;
|
||||
_priorityResolver = priorityResolver;
|
||||
_logger = logger;
|
||||
_auditRoot = FindRepoTempRoot();
|
||||
}
|
||||
|
||||
private static string FindRepoTempRoot()
|
||||
{
|
||||
var current = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (current != null)
|
||||
{
|
||||
if (Directory.Exists(Path.Combine(current.FullName, ".git")))
|
||||
{
|
||||
return Path.Combine(current.FullName, "Temp", "collection_audit");
|
||||
}
|
||||
current = current.Parent;
|
||||
}
|
||||
return Path.Combine(Directory.GetCurrentDirectory(), "Temp", "collection_audit");
|
||||
}
|
||||
|
||||
private void AppendAudit(CollectionExecutionAudit audit)
|
||||
{
|
||||
Directory.CreateDirectory(_auditRoot);
|
||||
var path = Path.Combine(_auditRoot, $"{audit.RunId}.jsonl");
|
||||
File.AppendAllText(path, JsonSerializer.Serialize(audit, new JsonSerializerOptions { WriteIndented = false }) + Environment.NewLine);
|
||||
}
|
||||
|
||||
public async Task<CollectionRunResult> RunCollectionAsync(string runId, string account, List<string> tickers)
|
||||
@@ -45,6 +70,7 @@ public class KisDataCollectionOrchestrator : ICollectionOrchestrator
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("Starting collection run {RunId}", runId);
|
||||
AppendAudit(new CollectionExecutionAudit(runId, "RUNNING", DateTimeOffset.UtcNow, null, 0, 0, "started"));
|
||||
|
||||
var kisSource = new KisApiPriceSource(_kisApiClient);
|
||||
var rows = new List<Dictionary<string, object>>();
|
||||
@@ -157,6 +183,7 @@ public class KisDataCollectionOrchestrator : ICollectionOrchestrator
|
||||
result.SourceCounts = sourceCounts;
|
||||
result.Rows = rows;
|
||||
result.Errors = errors;
|
||||
AppendAudit(new CollectionExecutionAudit(runId, result.Status, DateTimeOffset.Parse(startedAt), DateTimeOffset.Parse(finishedAt), result.SuccessCount, result.ErrorCount, "finished"));
|
||||
|
||||
// Save run record
|
||||
await _repository.SaveRunAsync(new CollectionRunRecord(
|
||||
@@ -204,6 +231,7 @@ public class KisDataCollectionOrchestrator : ICollectionOrchestrator
|
||||
result.Status = "FAILED";
|
||||
result.FinishedAt = DataNormalizationHelper.KstNowIso();
|
||||
result.ErrorMessage = ex.Message;
|
||||
AppendAudit(new CollectionExecutionAudit(runId, result.Status, DateTimeOffset.Parse(startedAt), DateTimeOffset.Parse(result.FinishedAt), result.SuccessCount, result.ErrorCount, ex.Message));
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -358,4 +386,3 @@ public class KisDataCollectionOrchestrator : ICollectionOrchestrator
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -109,5 +109,44 @@ namespace QuantEngine.Core.Tests
|
||||
|
||||
Assert.Equal(expectedStDev, outputs.StDev20D, 5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateFactors_UnsortedInput_ProducesSameOutputs()
|
||||
{
|
||||
var stock = CreateMockBars("005930", 100.0, 1.0, 130);
|
||||
var index = CreateMockBars("KOSPI", 2000.0, 0.0, 130);
|
||||
|
||||
stock.Reverse();
|
||||
index.Reverse();
|
||||
|
||||
var outputs = FactorCalculator.CalculateFactors(stock, index);
|
||||
|
||||
double expectedMom20 = (20.0 / 209.0) * 100.0;
|
||||
Assert.Equal(expectedMom20, outputs.Momentum20D, 5);
|
||||
Assert.Equal(1.0, outputs.Beta60D);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateFactors_DuplicateDates_UsesLastOccurrencePerDate()
|
||||
{
|
||||
var stock = CreateMockBars("005930", 100.0, 1.0, 130);
|
||||
var index = CreateMockBars("KOSPI", 2000.0, 0.0, 130);
|
||||
|
||||
var duplicateDate = stock[129].TradeDate;
|
||||
stock.Add(new PriceHistoryDailyRecord(
|
||||
"005930",
|
||||
duplicateDate,
|
||||
1000m,
|
||||
1002m,
|
||||
998m,
|
||||
1001m,
|
||||
100000,
|
||||
"TEST_SOURCE"));
|
||||
|
||||
var outputs = FactorCalculator.CalculateFactors(stock, index);
|
||||
|
||||
Assert.True(outputs.Momentum20D > 0);
|
||||
Assert.True(outputs.Atr20Pct > 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using Moq;
|
||||
using QuantEngine.Application.Services;
|
||||
using QuantEngine.Core.Domain;
|
||||
using QuantEngine.Core.Interfaces;
|
||||
using Xunit;
|
||||
|
||||
namespace QuantEngine.Core.Tests;
|
||||
|
||||
public class FactorComputationServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ComputeAndAppendFactorOutputs_WritesAuditAndHistory()
|
||||
{
|
||||
var storeMock = new Mock<IPostgresqlHistoryStore>();
|
||||
storeMock.Setup(s => s.AppendAsync(It.IsAny<string>(), It.IsAny<IDictionary<string, object?>>()))
|
||||
.ReturnsAsync(1);
|
||||
|
||||
var history = new HistoryIngestionService(storeMock.Object);
|
||||
var service = new FactorComputationService(history);
|
||||
|
||||
var root = FindRepoRoot();
|
||||
var auditPath = Path.Combine(root, "Temp", "factor_audit", "005930.jsonl");
|
||||
if (File.Exists(auditPath))
|
||||
{
|
||||
File.Delete(auditPath);
|
||||
}
|
||||
|
||||
var outputs = new FactorOutputs(1, 2, 3, 4, 5, 6, 7);
|
||||
await service.AppendFactorOutputsAsync("005930", "v1", outputs);
|
||||
|
||||
storeMock.Verify(s => s.AppendAsync("factor_output_history", It.IsAny<IDictionary<string, object?>>()), Times.Exactly(7));
|
||||
Assert.True(File.Exists(auditPath));
|
||||
Assert.Contains("PERSISTED", File.ReadAllText(auditPath));
|
||||
}
|
||||
|
||||
private static string FindRepoRoot()
|
||||
{
|
||||
var current = new DirectoryInfo(System.AppContext.BaseDirectory);
|
||||
while (current != null)
|
||||
{
|
||||
if (Directory.Exists(Path.Combine(current.FullName, ".git")))
|
||||
{
|
||||
return current.FullName;
|
||||
}
|
||||
current = current.Parent;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("Repository root not found.");
|
||||
}
|
||||
}
|
||||
@@ -93,6 +93,11 @@ public class KisDataCollectionOrchestratorTests
|
||||
var runId = "test-run-002";
|
||||
var ticker = "005930";
|
||||
var account = "mock";
|
||||
var auditPath = Path.Combine(FindRepoRoot(), "Temp", "collection_audit", $"{runId}.jsonl");
|
||||
if (File.Exists(auditPath))
|
||||
{
|
||||
File.Delete(auditPath);
|
||||
}
|
||||
|
||||
_repositoryMock
|
||||
.Setup(r => r.GetLatestSnapshotsForTickerAsync(ticker, It.IsAny<int>()))
|
||||
@@ -128,6 +133,8 @@ public class KisDataCollectionOrchestratorTests
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("COMPLETED", result.Status);
|
||||
Assert.Equal(1, result.SuccessCount);
|
||||
Assert.True(File.Exists(auditPath));
|
||||
Assert.Contains("COMPLETED", File.ReadAllText(auditPath));
|
||||
|
||||
_kisApiClientMock.Verify(
|
||||
k => k.GetCurrentPriceAsync(ticker, account),
|
||||
@@ -390,4 +397,19 @@ public class KisDataCollectionOrchestratorTests
|
||||
Assert.False(result);
|
||||
Assert.Null(args[2]);
|
||||
}
|
||||
|
||||
private static string FindRepoRoot()
|
||||
{
|
||||
var current = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (current != null)
|
||||
{
|
||||
if (Directory.Exists(Path.Combine(current.FullName, ".git")))
|
||||
{
|
||||
return current.FullName;
|
||||
}
|
||||
current = current.Parent;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("Repository root not found.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ using System.Reflection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Hangfire;
|
||||
using Hangfire.Common;
|
||||
using QuantEngine.Web.Services;
|
||||
@@ -33,7 +34,8 @@ public class SchedulerServiceTests
|
||||
recurringJobManagerMock.Object,
|
||||
scopeFactoryMock.Object,
|
||||
configMock.Object,
|
||||
parser
|
||||
parser,
|
||||
Options.Create(new SchedulerServiceOptions())
|
||||
);
|
||||
|
||||
// Act
|
||||
@@ -87,6 +89,26 @@ public class SchedulerServiceTests
|
||||
Assert.Contains(defs, d => d.JobId == "monthly-optimization" && d.IsRecurring);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FetchPriceAsync_WritesAuditTrail()
|
||||
{
|
||||
var root = FindRepoRoot();
|
||||
var auditDir = Path.Combine(root, "Temp", "scheduler_audit");
|
||||
if (Directory.Exists(auditDir))
|
||||
{
|
||||
Directory.Delete(auditDir, true);
|
||||
}
|
||||
|
||||
var service = CreateService();
|
||||
await service.FetchPriceAsync("005930");
|
||||
|
||||
var auditPath = Path.Combine(auditDir, "fetch-price.jsonl");
|
||||
Assert.True(File.Exists(auditPath));
|
||||
var lines = File.ReadAllLines(auditPath);
|
||||
Assert.NotEmpty(lines);
|
||||
Assert.Contains("\"State\":\"SUCCEEDED\"", lines[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LoadTickersFromJson_WhenFileMissing_FallsBackToDefaultUniverse()
|
||||
{
|
||||
@@ -178,7 +200,8 @@ public class SchedulerServiceTests
|
||||
recurringJobManagerMock.Object,
|
||||
scopeFactoryMock.Object,
|
||||
configMock.Object,
|
||||
new GatherTradingDataParser()
|
||||
new GatherTradingDataParser(),
|
||||
Options.Create(new SchedulerServiceOptions())
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -26,31 +26,30 @@ namespace QuantEngine.Core.Domain
|
||||
return new FactorOutputs(0, 0, 0, 0, 0, 1.0, 0);
|
||||
}
|
||||
|
||||
// Ensure sorted chronologically (oldest to newest)
|
||||
var sortedStock = stockBars.OrderBy(b => b.TradeDate).ToList();
|
||||
var sortedIndex = indexBars?.OrderBy(b => b.TradeDate).ToList() ?? new List<PriceHistoryDailyRecord>();
|
||||
var sortedStock = NormalizeBars(stockBars);
|
||||
var sortedIndex = NormalizeBars(indexBars);
|
||||
return new FactorOutputs(
|
||||
CalculateMomentum(sortedStock, 20),
|
||||
CalculateMomentum(sortedStock, 60),
|
||||
CalculateMomentum(sortedStock, 120),
|
||||
CalculateAtr20Pct(sortedStock),
|
||||
CalculatePriceStDev20D(sortedStock),
|
||||
CalculateBeta60D(sortedStock, sortedIndex),
|
||||
CalculateRs20D(sortedStock, sortedIndex));
|
||||
}
|
||||
|
||||
int count = sortedStock.Count;
|
||||
double closeToday = (double)sortedStock[^1].Close;
|
||||
private static List<PriceHistoryDailyRecord> NormalizeBars(List<PriceHistoryDailyRecord>? bars)
|
||||
{
|
||||
if (bars == null || bars.Count == 0)
|
||||
{
|
||||
return new List<PriceHistoryDailyRecord>();
|
||||
}
|
||||
|
||||
// 1. Momentum
|
||||
double mom20 = CalculateMomentum(sortedStock, 20);
|
||||
double mom60 = CalculateMomentum(sortedStock, 60);
|
||||
double mom120 = CalculateMomentum(sortedStock, 120);
|
||||
|
||||
// 2. ATR 20D Percentage
|
||||
double atrPct = CalculateAtr20Pct(sortedStock);
|
||||
|
||||
// 3. Price Standard Deviation 20D
|
||||
double stdev = CalculatePriceStDev20D(sortedStock);
|
||||
|
||||
// 4. Beta 60D
|
||||
double beta = CalculateBeta60D(sortedStock, sortedIndex);
|
||||
|
||||
// 5. Relative Strength (RS) 20D (vs Index)
|
||||
double rs = CalculateRs20D(sortedStock, sortedIndex);
|
||||
|
||||
return new FactorOutputs(mom20, mom60, mom120, atrPct, stdev, beta, rs);
|
||||
return bars
|
||||
.OrderBy(b => b.TradeDate)
|
||||
.GroupBy(b => b.TradeDate)
|
||||
.Select(g => g.Last())
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static double CalculateMomentum(List<PriceHistoryDailyRecord> bars, int period)
|
||||
@@ -58,7 +57,7 @@ namespace QuantEngine.Core.Domain
|
||||
if (bars.Count <= period) return 0.0;
|
||||
double current = (double)bars[^1].Close;
|
||||
double prev = (double)bars[^(period + 1)].Close;
|
||||
if (prev <= 0.0) return 0.0;
|
||||
if (prev <= 0.0 || double.IsNaN(prev) || double.IsInfinity(prev)) return 0.0;
|
||||
return ((current - prev) / prev) * 100.0;
|
||||
}
|
||||
|
||||
@@ -79,7 +78,7 @@ namespace QuantEngine.Core.Domain
|
||||
|
||||
double atr = trList.Average();
|
||||
double closeToday = (double)bars[^1].Close;
|
||||
if (closeToday <= 0.0) return 0.0;
|
||||
if (closeToday <= 0.0 || double.IsNaN(closeToday) || double.IsInfinity(closeToday)) return 0.0;
|
||||
return (atr / closeToday) * 100.0;
|
||||
}
|
||||
|
||||
@@ -90,8 +89,6 @@ namespace QuantEngine.Core.Domain
|
||||
var subset = bars.Skip(bars.Count - 20).Select(b => (double)b.Close).ToList();
|
||||
double avg = subset.Average();
|
||||
double sumOfSquares = subset.Sum(val => Math.Pow(val - avg, 2));
|
||||
|
||||
// Sample standard deviation (N-1)
|
||||
return Math.Sqrt(sumOfSquares / (subset.Count - 1));
|
||||
}
|
||||
|
||||
@@ -99,11 +96,9 @@ namespace QuantEngine.Core.Domain
|
||||
{
|
||||
if (stock.Count < 61 || index.Count < 61) return 1.0;
|
||||
|
||||
// Align daily returns
|
||||
var stockMap = stock.ToDictionary(b => b.TradeDate);
|
||||
var indexMap = index.ToDictionary(b => b.TradeDate);
|
||||
|
||||
// Compute returns for overlapping dates
|
||||
var overlappingDates = stockMap.Keys.Intersect(indexMap.Keys).OrderBy(d => d).ToList();
|
||||
if (overlappingDates.Count < 61) return 1.0;
|
||||
|
||||
@@ -113,7 +108,6 @@ namespace QuantEngine.Core.Domain
|
||||
var stockReturns = new List<double>();
|
||||
var indexReturns = new List<double>();
|
||||
|
||||
// Calculate returns starting from last 60 days
|
||||
int startIdx = Math.Max(1, alignedStock.Count - 60);
|
||||
for (int i = startIdx; i < alignedStock.Count; i++)
|
||||
{
|
||||
@@ -154,7 +148,6 @@ namespace QuantEngine.Core.Domain
|
||||
{
|
||||
if (stock.Count < 21 || index.Count < 21) return 0.0;
|
||||
|
||||
// Align dates
|
||||
var stockMap = stock.ToDictionary(b => b.TradeDate);
|
||||
var indexMap = index.ToDictionary(b => b.TradeDate);
|
||||
|
||||
|
||||
@@ -7,11 +7,11 @@ namespace QuantEngine.Web.Endpoints;
|
||||
|
||||
public class GetCollectionStateEndpoint : EndpointWithoutRequest<CollectionDashboardStateRecord>
|
||||
{
|
||||
private readonly ICollectionRepository _repo;
|
||||
private readonly ICollectionReadModelService _readModelService;
|
||||
|
||||
public GetCollectionStateEndpoint(ICollectionRepository repo)
|
||||
public GetCollectionStateEndpoint(ICollectionReadModelService readModelService)
|
||||
{
|
||||
_repo = repo;
|
||||
_readModelService = readModelService;
|
||||
}
|
||||
|
||||
public override void Configure()
|
||||
@@ -27,7 +27,7 @@ public class GetCollectionStateEndpoint : EndpointWithoutRequest<CollectionDashb
|
||||
{
|
||||
try
|
||||
{
|
||||
var state = await _repo.GetDashboardStateAsync();
|
||||
var state = await _readModelService.GetDashboardStateAsync();
|
||||
await SendOkAsync(state, ct);
|
||||
}
|
||||
catch
|
||||
@@ -50,11 +50,11 @@ public class GetRecentRunsResponse
|
||||
|
||||
public class GetRecentRunsEndpoint : Endpoint<GetRecentRunsRequest, GetRecentRunsResponse>
|
||||
{
|
||||
private readonly ICollectionRepository _repo;
|
||||
private readonly ICollectionReadModelService _readModelService;
|
||||
|
||||
public GetRecentRunsEndpoint(ICollectionRepository repo)
|
||||
public GetRecentRunsEndpoint(ICollectionReadModelService readModelService)
|
||||
{
|
||||
_repo = repo;
|
||||
_readModelService = readModelService;
|
||||
}
|
||||
|
||||
public override void Configure()
|
||||
@@ -70,7 +70,7 @@ public class GetRecentRunsEndpoint : Endpoint<GetRecentRunsRequest, GetRecentRun
|
||||
{
|
||||
try
|
||||
{
|
||||
var runs = await _repo.GetRecentRunsAsync(req.Limit);
|
||||
var runs = await _readModelService.GetRecentRunsAsync(req.Limit);
|
||||
await SendOkAsync(new GetRecentRunsResponse { Runs = runs, Count = runs.Count }, ct);
|
||||
}
|
||||
catch
|
||||
@@ -94,11 +94,11 @@ public class GetRunSnapshotsResponse
|
||||
|
||||
public class GetRunSnapshotsEndpoint : Endpoint<GetRunSnapshotsRequest, GetRunSnapshotsResponse>
|
||||
{
|
||||
private readonly ICollectionRepository _repo;
|
||||
private readonly ICollectionReadModelService _readModelService;
|
||||
|
||||
public GetRunSnapshotsEndpoint(ICollectionRepository repo)
|
||||
public GetRunSnapshotsEndpoint(ICollectionReadModelService readModelService)
|
||||
{
|
||||
_repo = repo;
|
||||
_readModelService = readModelService;
|
||||
}
|
||||
|
||||
public override void Configure()
|
||||
@@ -115,7 +115,7 @@ public class GetRunSnapshotsEndpoint : Endpoint<GetRunSnapshotsRequest, GetRunSn
|
||||
{
|
||||
try
|
||||
{
|
||||
var snapshots = await _repo.GetRunSnapshotsAsync(req.RunId);
|
||||
var snapshots = await _readModelService.GetRunSnapshotsAsync(req.RunId);
|
||||
await SendOkAsync(new GetRunSnapshotsResponse { RunId = req.RunId, Snapshots = snapshots, Count = snapshots.Count }, ct);
|
||||
}
|
||||
catch
|
||||
@@ -140,11 +140,11 @@ public class GetRunErrorsResponse
|
||||
|
||||
public class GetRunErrorsEndpoint : Endpoint<GetRunErrorsRequest, GetRunErrorsResponse>
|
||||
{
|
||||
private readonly ICollectionRepository _repo;
|
||||
private readonly ICollectionReadModelService _readModelService;
|
||||
|
||||
public GetRunErrorsEndpoint(ICollectionRepository repo)
|
||||
public GetRunErrorsEndpoint(ICollectionReadModelService readModelService)
|
||||
{
|
||||
_repo = repo;
|
||||
_readModelService = readModelService;
|
||||
}
|
||||
|
||||
public override void Configure()
|
||||
@@ -161,7 +161,7 @@ public class GetRunErrorsEndpoint : Endpoint<GetRunErrorsRequest, GetRunErrorsRe
|
||||
{
|
||||
try
|
||||
{
|
||||
var errors = await _repo.GetRunErrorsAsync(req.RunId, req.Limit);
|
||||
var errors = await _readModelService.GetRunErrorsAsync(req.RunId, req.Limit);
|
||||
await SendOkAsync(new GetRunErrorsResponse { RunId = req.RunId, Errors = errors, Count = errors.Count }, ct);
|
||||
}
|
||||
catch
|
||||
@@ -186,11 +186,11 @@ public class GetLatestSnapshotsResponse
|
||||
|
||||
public class GetLatestSnapshotsEndpoint : Endpoint<GetLatestSnapshotsRequest, GetLatestSnapshotsResponse>
|
||||
{
|
||||
private readonly ICollectionRepository _repo;
|
||||
private readonly ICollectionReadModelService _readModelService;
|
||||
|
||||
public GetLatestSnapshotsEndpoint(ICollectionRepository repo)
|
||||
public GetLatestSnapshotsEndpoint(ICollectionReadModelService readModelService)
|
||||
{
|
||||
_repo = repo;
|
||||
_readModelService = readModelService;
|
||||
}
|
||||
|
||||
public override void Configure()
|
||||
@@ -206,7 +206,7 @@ public class GetLatestSnapshotsEndpoint : Endpoint<GetLatestSnapshotsRequest, Ge
|
||||
{
|
||||
try
|
||||
{
|
||||
var snapshots = await _repo.GetLatestSnapshotsForTickerAsync(req.Ticker, req.Limit);
|
||||
var snapshots = await _readModelService.GetLatestSnapshotsForTickerAsync(req.Ticker, req.Limit);
|
||||
await SendOkAsync(new GetLatestSnapshotsResponse { Ticker = req.Ticker, Snapshots = snapshots, Count = snapshots.Count }, ct);
|
||||
}
|
||||
catch
|
||||
@@ -223,12 +223,12 @@ public class GetPriceHistorySummaryResponse
|
||||
|
||||
public class GetPriceHistorySummaryEndpoint : EndpointWithoutRequest<GetPriceHistorySummaryResponse>
|
||||
{
|
||||
private readonly ICollectionRepository _repo;
|
||||
private readonly ICollectionReadModelService _readModelService;
|
||||
private readonly ILogger<GetPriceHistorySummaryEndpoint> _logger;
|
||||
|
||||
public GetPriceHistorySummaryEndpoint(ICollectionRepository repo, ILogger<GetPriceHistorySummaryEndpoint> logger)
|
||||
public GetPriceHistorySummaryEndpoint(ICollectionReadModelService readModelService, ILogger<GetPriceHistorySummaryEndpoint> logger)
|
||||
{
|
||||
_repo = repo;
|
||||
_readModelService = readModelService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -245,7 +245,7 @@ public class GetPriceHistorySummaryEndpoint : EndpointWithoutRequest<GetPriceHis
|
||||
{
|
||||
try
|
||||
{
|
||||
var summary = await _repo.GetPriceHistorySummaryAsync();
|
||||
var summary = await _readModelService.GetPriceHistorySummaryAsync();
|
||||
await SendOkAsync(new GetPriceHistorySummaryResponse { Tickers = summary }, ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
using QuantEngine.Application.Interfaces;
|
||||
using QuantEngine.Core.Interfaces;
|
||||
using QuantEngine.Web.Services;
|
||||
|
||||
@@ -8,16 +9,16 @@ namespace QuantEngine.Web.Pages.Admin.Collection;
|
||||
[Authorize(AuthenticationSchemes = AdminAuthDefaults.Scheme)]
|
||||
public class IndexModel : PageModel
|
||||
{
|
||||
private readonly ICollectionRepository _collectionRepository;
|
||||
private readonly ICollectionReadModelService _collectionReadModelService;
|
||||
private readonly ILogger<IndexModel> _logger;
|
||||
|
||||
public List<CollectionRunRecord>? Runs { get; set; }
|
||||
public List<PriceHistorySummaryRecord>? HistorySummary { get; set; }
|
||||
public string? Message { get; set; }
|
||||
|
||||
public IndexModel(ICollectionRepository collectionRepository, ILogger<IndexModel> logger)
|
||||
public IndexModel(ICollectionReadModelService collectionReadModelService, ILogger<IndexModel> logger)
|
||||
{
|
||||
_collectionRepository = collectionRepository;
|
||||
_collectionReadModelService = collectionReadModelService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -25,8 +26,8 @@ public class IndexModel : PageModel
|
||||
{
|
||||
try
|
||||
{
|
||||
Runs = await _collectionRepository.GetRecentRunsAsync(limit: 20);
|
||||
HistorySummary = await _collectionRepository.GetPriceHistorySummaryAsync();
|
||||
Runs = await _collectionReadModelService.GetRecentRunsAsync(limit: 20);
|
||||
HistorySummary = await _collectionReadModelService.GetPriceHistorySummaryAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.IO;
|
||||
using System.Reflection;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
using QuantEngine.Application.Interfaces;
|
||||
using QuantEngine.Core.Interfaces;
|
||||
using QuantEngine.Web.Services;
|
||||
|
||||
@@ -11,7 +12,7 @@ namespace QuantEngine.Web.Pages.Admin.Dashboard;
|
||||
public class IndexModel : PageModel
|
||||
{
|
||||
private readonly IWorkspaceRepository _workspaceRepository;
|
||||
private readonly ICollectionRepository _collectionRepository;
|
||||
private readonly ICollectionReadModelService _collectionReadModelService;
|
||||
private readonly IWebHostEnvironment _environment;
|
||||
private readonly ILogger<IndexModel> _logger;
|
||||
|
||||
@@ -95,12 +96,12 @@ public class IndexModel : PageModel
|
||||
|
||||
public IndexModel(
|
||||
IWorkspaceRepository workspaceRepository,
|
||||
ICollectionRepository collectionRepository,
|
||||
ICollectionReadModelService collectionReadModelService,
|
||||
IWebHostEnvironment environment,
|
||||
ILogger<IndexModel> logger)
|
||||
{
|
||||
_workspaceRepository = workspaceRepository;
|
||||
_collectionRepository = collectionRepository;
|
||||
_collectionReadModelService = collectionReadModelService;
|
||||
_environment = environment;
|
||||
_logger = logger;
|
||||
}
|
||||
@@ -112,7 +113,7 @@ public class IndexModel : PageModel
|
||||
var accounts = await _workspaceRepository.GetAccountsAsync();
|
||||
ActiveUsersCount = accounts.Count(a => string.Equals(a.IsActive, "true", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
var dashboard = await _collectionRepository.GetDashboardStateAsync();
|
||||
var dashboard = await _collectionReadModelService.GetDashboardStateAsync();
|
||||
RecentRunsCount = string.IsNullOrEmpty(dashboard?.LastRunId) ? 0 : 1;
|
||||
|
||||
// These two queries only complete if the DB round-trip actually
|
||||
|
||||
@@ -108,6 +108,7 @@ try
|
||||
builder.Services.AddScoped<IPostgresqlHistorySnapshotReader, PostgresqlHistorySnapshotReader>();
|
||||
builder.Services.AddScoped<HistoryIngestionService>();
|
||||
builder.Services.AddScoped<ICollectionRepository, CollectionRepository>();
|
||||
builder.Services.AddScoped<ICollectionReadModelService, CollectionReadModelService>();
|
||||
builder.Services.AddScoped<ITokenCache, PostgresTokenCache>();
|
||||
builder.Services.AddHttpClient<IKisApiClient, KisApiClient>();
|
||||
|
||||
@@ -116,6 +117,7 @@ try
|
||||
builder.Services.AddScoped<PriceDataNormalizer>();
|
||||
builder.Services.AddScoped<ICollectionOrchestrator, KisDataCollectionOrchestrator>();
|
||||
builder.Services.AddScoped<IPriceHistoryReader, PriceHistoryReader>();
|
||||
builder.Services.AddOptions<SchedulerServiceOptions>();
|
||||
|
||||
// Hangfire Background Jobs
|
||||
try
|
||||
|
||||
@@ -1,11 +1,24 @@
|
||||
namespace QuantEngine.Web.Services;
|
||||
|
||||
using System;
|
||||
|
||||
public sealed record SchedulerJobDefinition(
|
||||
string JobId,
|
||||
string Cron,
|
||||
string Description,
|
||||
bool IsRecurring);
|
||||
|
||||
public sealed record SchedulerServiceOptions
|
||||
{
|
||||
public List<SchedulerJobDefinition> JobDefinitions { get; init; } = new()
|
||||
{
|
||||
new SchedulerJobDefinition("daily-collection", "0 9 * * *", "Daily data collection", true),
|
||||
new SchedulerJobDefinition("hourly-price-update", "0 9,11,13,15 * * 1-5", "Hourly price update", true),
|
||||
new SchedulerJobDefinition("weekly-report", "0 17 * * 5", "Weekly report generation", true),
|
||||
new SchedulerJobDefinition("monthly-optimization", "0 2 1 * *", "Monthly optimization", true),
|
||||
};
|
||||
}
|
||||
|
||||
public sealed record SchedulerJobExecutionAudit(
|
||||
string JobId,
|
||||
string RunId,
|
||||
|
||||
@@ -3,11 +3,13 @@ using Hangfire.States;
|
||||
using Hangfire.Dashboard;
|
||||
using Hangfire.PostgreSql;
|
||||
using Hangfire.MemoryStorage;
|
||||
using System.Text.Json;
|
||||
using System.Linq.Expressions;
|
||||
using QuantEngine.Application.Services;
|
||||
using QuantEngine.Application.Interfaces;
|
||||
using QuantEngine.Infrastructure.Data;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace QuantEngine.Web.Services;
|
||||
|
||||
@@ -22,6 +24,8 @@ public class SchedulerService
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly GatherTradingDataParser _parser;
|
||||
private readonly SchedulerServiceOptions _options;
|
||||
private readonly string _auditRoot;
|
||||
|
||||
public SchedulerService(
|
||||
ILogger<SchedulerService> logger,
|
||||
@@ -29,7 +33,8 @@ public class SchedulerService
|
||||
IRecurringJobManager recurringJobManager,
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IConfiguration configuration,
|
||||
GatherTradingDataParser parser)
|
||||
GatherTradingDataParser parser,
|
||||
IOptions<SchedulerServiceOptions> options)
|
||||
{
|
||||
_logger = logger;
|
||||
_jobClient = jobClient;
|
||||
@@ -37,6 +42,56 @@ public class SchedulerService
|
||||
_scopeFactory = scopeFactory;
|
||||
_configuration = configuration;
|
||||
_parser = parser;
|
||||
_options = options.Value ?? new SchedulerServiceOptions();
|
||||
_auditRoot = FindRepoTempRoot();
|
||||
}
|
||||
|
||||
private static string FindRepoTempRoot()
|
||||
{
|
||||
var current = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (current != null)
|
||||
{
|
||||
if (Directory.Exists(Path.Combine(current.FullName, ".git")))
|
||||
{
|
||||
return Path.Combine(current.FullName, "Temp", "scheduler_audit");
|
||||
}
|
||||
current = current.Parent;
|
||||
}
|
||||
|
||||
return Path.Combine(Directory.GetCurrentDirectory(), "Temp", "scheduler_audit");
|
||||
}
|
||||
|
||||
private void AppendAudit(SchedulerJobExecutionAudit audit)
|
||||
{
|
||||
Directory.CreateDirectory(_auditRoot);
|
||||
var path = Path.Combine(_auditRoot, $"{audit.JobId}.jsonl");
|
||||
File.AppendAllText(path, JsonSerializer.Serialize(audit, new JsonSerializerOptions { WriteIndented = false }) + Environment.NewLine);
|
||||
}
|
||||
|
||||
private async Task<T> ExecuteWithAuditAsync<T>(string jobId, Func<Task<T>> action, Func<T>? fallback = null, string? resourceKey = null)
|
||||
{
|
||||
var startedAt = DateTimeOffset.UtcNow;
|
||||
AppendAudit(new SchedulerJobExecutionAudit(jobId, $"{jobId}-{startedAt:yyyyMMddHHmmssfff}", SchedulerStates.Running, null, startedAt, null, resourceKey));
|
||||
try
|
||||
{
|
||||
var result = await action();
|
||||
AppendAudit(new SchedulerJobExecutionAudit(jobId, $"{jobId}-{startedAt:yyyyMMddHHmmssfff}", SchedulerStates.Succeeded, null, startedAt, DateTimeOffset.UtcNow, resourceKey));
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppendAudit(new SchedulerJobExecutionAudit(jobId, $"{jobId}-{startedAt:yyyyMMddHHmmssfff}", SchedulerStates.Failed, ex.Message, startedAt, DateTimeOffset.UtcNow, resourceKey));
|
||||
if (fallback is not null)
|
||||
{
|
||||
return fallback();
|
||||
}
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ExecuteWithAuditAsync(string jobId, Func<Task> action, string? resourceKey = null)
|
||||
{
|
||||
await ExecuteWithAuditAsync(jobId, async () => { await action(); return true; }, resourceKey: resourceKey);
|
||||
}
|
||||
|
||||
private List<string> LoadTickersFromJson()
|
||||
@@ -76,13 +131,8 @@ public class SchedulerService
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<SchedulerJobDefinition> GetRecurringJobDefinitions() => new[]
|
||||
{
|
||||
new SchedulerJobDefinition("daily-collection", "0 9 * * *", "Daily data collection", true),
|
||||
new SchedulerJobDefinition("hourly-price-update", "0 9,11,13,15 * * 1-5", "Hourly price update", true),
|
||||
new SchedulerJobDefinition("weekly-report", "0 17 * * 5", "Weekly report generation", true),
|
||||
new SchedulerJobDefinition("monthly-optimization", "0 2 1 * *", "Monthly optimization", true),
|
||||
};
|
||||
public IReadOnlyList<SchedulerJobDefinition> GetRecurringJobDefinitions()
|
||||
=> _options.JobDefinitions.Count > 0 ? _options.JobDefinitions : new SchedulerServiceOptions().JobDefinitions;
|
||||
|
||||
private static string? FindGatherTradingDataJson()
|
||||
{
|
||||
@@ -139,6 +189,7 @@ public class SchedulerService
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("Starting daily data collection job at {Time}", DateTime.Now);
|
||||
AppendAudit(new SchedulerJobExecutionAudit("daily-collection", $"daily-{DateTime.UtcNow:yyyyMMddHHmmssfff}", SchedulerStates.Pending, null, DateTimeOffset.UtcNow, null, "collection"));
|
||||
|
||||
var tickers = LoadTickersFromJson();
|
||||
|
||||
@@ -153,7 +204,10 @@ public class SchedulerService
|
||||
var accountMode = _configuration["Kis:AccountMode"] ?? "mock";
|
||||
|
||||
// Execute collection
|
||||
var result = await orchestrator.RunCollectionAsync(runId, accountMode, tickers);
|
||||
var result = await ExecuteWithAuditAsync(
|
||||
"daily-collection",
|
||||
() => orchestrator.RunCollectionAsync(runId, accountMode, tickers),
|
||||
resourceKey: "collection");
|
||||
|
||||
// Log completion
|
||||
_logger.LogInformation("Collection run {RunId} completed: {Snapshots} snapshots, {Errors} errors",
|
||||
@@ -173,6 +227,7 @@ public class SchedulerService
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("Starting hourly price update at {Time}", DateTime.Now);
|
||||
AppendAudit(new SchedulerJobExecutionAudit("hourly-price-update", $"hourly-{DateTime.UtcNow:yyyyMMddHHmmssfff}", SchedulerStates.Pending, null, DateTimeOffset.UtcNow, null, "price-update"));
|
||||
|
||||
var tickers = LoadTickersFromJson();
|
||||
|
||||
@@ -186,6 +241,7 @@ public class SchedulerService
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to enqueue price update for {Ticker}", ticker);
|
||||
AppendAudit(new SchedulerJobExecutionAudit("hourly-price-update", $"hourly-{DateTime.UtcNow:yyyyMMddHHmmssfff}", SchedulerStates.Retrying, ex.Message, DateTimeOffset.UtcNow, null, ticker));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,10 +264,12 @@ public class SchedulerService
|
||||
// TODO: Implement actual price fetching
|
||||
await Task.Delay(50);
|
||||
_logger.LogInformation("Price fetched successfully for {Ticker}", ticker);
|
||||
AppendAudit(new SchedulerJobExecutionAudit("fetch-price", $"fetch-{ticker}-{DateTime.UtcNow:yyyyMMddHHmmssfff}", SchedulerStates.Succeeded, null, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, ticker));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error fetching price for {Ticker}", ticker);
|
||||
AppendAudit(new SchedulerJobExecutionAudit("fetch-price", $"fetch-{ticker}-{DateTime.UtcNow:yyyyMMddHHmmssfff}", SchedulerStates.Failed, ex.Message, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, ticker));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -223,15 +281,18 @@ public class SchedulerService
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("Starting weekly report generation at {Time}", DateTime.Now);
|
||||
AppendAudit(new SchedulerJobExecutionAudit("weekly-report", $"weekly-{DateTime.UtcNow:yyyyMMddHHmmssfff}", SchedulerStates.Pending, null, DateTimeOffset.UtcNow, null, "report"));
|
||||
|
||||
// TODO: Implement report generation logic
|
||||
await Task.Delay(500);
|
||||
|
||||
_logger.LogInformation("Weekly report generated successfully");
|
||||
AppendAudit(new SchedulerJobExecutionAudit("weekly-report", $"weekly-{DateTime.UtcNow:yyyyMMddHHmmssfff}", SchedulerStates.Succeeded, null, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, "report"));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error generating weekly report");
|
||||
AppendAudit(new SchedulerJobExecutionAudit("weekly-report", $"weekly-{DateTime.UtcNow:yyyyMMddHHmmssfff}", SchedulerStates.Failed, ex.Message, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, "report"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -243,15 +304,18 @@ public class SchedulerService
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("Starting monthly optimization at {Time}", DateTime.Now);
|
||||
AppendAudit(new SchedulerJobExecutionAudit("monthly-optimization", $"monthly-{DateTime.UtcNow:yyyyMMddHHmmssfff}", SchedulerStates.Pending, null, DateTimeOffset.UtcNow, null, "optimization"));
|
||||
|
||||
// TODO: Implement optimization logic
|
||||
await Task.Delay(1000);
|
||||
|
||||
_logger.LogInformation("Monthly optimization completed");
|
||||
AppendAudit(new SchedulerJobExecutionAudit("monthly-optimization", $"monthly-{DateTime.UtcNow:yyyyMMddHHmmssfff}", SchedulerStates.Succeeded, null, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, "optimization"));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error during monthly optimization");
|
||||
AppendAudit(new SchedulerJobExecutionAudit("monthly-optimization", $"monthly-{DateTime.UtcNow:yyyyMMddHHmmssfff}", SchedulerStates.Failed, ex.Message, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, "optimization"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_validate_dotnet_cicd_chain_contract_passes() -> None:
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
proc = subprocess.run(
|
||||
[sys.executable, str(root / "tools" / "validate_dotnet_cicd_chain_contract_v1.py")],
|
||||
cwd=root,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stdout + proc.stderr
|
||||
payload = json.loads(proc.stdout)
|
||||
assert payload["gate"] == "PASS"
|
||||
|
||||
|
||||
def test_validate_dotnet_cicd_chain_contract_reports_missing_chain() -> None:
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
temp = root / "Temp" / "test_wbs_10_cicd_chain_contract_bad.yaml"
|
||||
temp.write_text(
|
||||
"formula_id: WBS_10_DOTNET_CICD_CHAIN_CONTRACT_V1\ngoal: CI, prepare-release, deploy-prod 순차 게이트를 고정한다.\nworkflows:\n ci:\n name: Validators (Pushes and Pull Requests)\n prepare_release:\n name: Prepare Release\n deploy_prod:\n name: Deploy to Production\ndependency_chain: []\nrequired_guards: [a,b,c,d]\nhealth_checks: [a,b,c,d]\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
proc = subprocess.run(
|
||||
[sys.executable, str(root / "tools" / "validate_dotnet_cicd_chain_contract_v1.py"), "--contract", str(temp)],
|
||||
cwd=root,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
assert proc.returncode != 0
|
||||
payload = json.loads(proc.stdout)
|
||||
assert payload["gate"] == "FAIL"
|
||||
assert "dependency_chain" in payload["missing"]
|
||||
@@ -0,0 +1,38 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_validate_dotnet_domain_parity_backlog_passes() -> None:
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
proc = subprocess.run(
|
||||
[sys.executable, str(root / "tools" / "validate_dotnet_domain_parity_backlog_v1.py")],
|
||||
cwd=root,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stdout + proc.stderr
|
||||
payload = json.loads(proc.stdout)
|
||||
assert payload["gate"] == "PASS"
|
||||
|
||||
|
||||
def test_validate_dotnet_domain_parity_backlog_reports_missing_target() -> None:
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
temp = root / "Temp" / "test_wbs_10_domain_parity_backlog_bad.yaml"
|
||||
temp.write_text(
|
||||
"formula_id: WBS_10_DOTNET_DOMAIN_PARITY_BACKLOG_V1\ncoverage_min: 1.0\ntolerance_policy:\n numeric_default: 0\n text_default: exact\nparity_targets:\n - target_id: formula_engine_timing\n priority: 1\n - target_id: formula_engine_sell\n priority: 2\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
proc = subprocess.run(
|
||||
[sys.executable, str(root / "tools" / "validate_dotnet_domain_parity_backlog_v1.py"), "--backlog", str(temp)],
|
||||
cwd=root,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
assert proc.returncode != 0
|
||||
payload = json.loads(proc.stdout)
|
||||
assert payload["gate"] == "FAIL"
|
||||
assert "exit_stop_price missing" in payload["missing"]
|
||||
@@ -0,0 +1,38 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_validate_dotnet_idempotency_contract_passes() -> None:
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
proc = subprocess.run(
|
||||
[sys.executable, str(root / "tools" / "validate_dotnet_idempotency_contract_v1.py")],
|
||||
cwd=root,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stdout + proc.stderr
|
||||
payload = json.loads(proc.stdout)
|
||||
assert payload["gate"] == "PASS"
|
||||
|
||||
|
||||
def test_validate_dotnet_idempotency_contract_reports_missing_table() -> None:
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
temp = root / "Temp" / "test_wbs_10_idempotency_contract_bad.yaml"
|
||||
temp.write_text(
|
||||
"formula_id: WBS_10_DOTNET_IDEMPOTENCY_CONTRACT_V1\ngoal: 중복 실행 방지, lock/lease 정책, 재시도 경계를 표준화한다.\nlock_domain:\n canonical_table: wrong\nidempotency_key:\n required: true\nlease_policy:\n required: true\n retry_policy:\n max_attempts: 3\n backoff: exponential\nduplicate_execution_guards: [x]\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
proc = subprocess.run(
|
||||
[sys.executable, str(root / "tools" / "validate_dotnet_idempotency_contract_v1.py"), "--contract", str(temp)],
|
||||
cwd=root,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
assert proc.returncode != 0
|
||||
payload = json.loads(proc.stdout)
|
||||
assert payload["gate"] == "FAIL"
|
||||
assert "lock_domain.canonical_table" in payload["missing"]
|
||||
@@ -0,0 +1,38 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_validate_dotnet_normalization_contract_passes() -> None:
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
proc = subprocess.run(
|
||||
[sys.executable, str(root / "tools" / "validate_dotnet_normalization_contract_v1.py")],
|
||||
cwd=root,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stdout + proc.stderr
|
||||
payload = json.loads(proc.stdout)
|
||||
assert payload["gate"] == "PASS"
|
||||
|
||||
|
||||
def test_validate_dotnet_normalization_contract_reports_missing_view() -> None:
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
temp = root / "Temp" / "test_wbs_10_normalization_contract_bad.yaml"
|
||||
temp.write_text(
|
||||
"formula_id: WBS_10_DOTNET_NORMALIZATION_CONTRACT_V1\ngoal: 쓰기 경로 정규화와 읽기 경로 역정규화 경계를 고정한다.\ncanonical_write_path:\n schema: engine_history\n tables: [source_observation]\ncanonical_read_path:\n view: wrong_view\nforbidden_patterns: [x]\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
proc = subprocess.run(
|
||||
[sys.executable, str(root / "tools" / "validate_dotnet_normalization_contract_v1.py"), "--contract", str(temp)],
|
||||
cwd=root,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
assert proc.returncode != 0
|
||||
payload = json.loads(proc.stdout)
|
||||
assert payload["gate"] == "FAIL"
|
||||
assert "canonical_read_path.view" in payload["missing"]
|
||||
@@ -0,0 +1,38 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_validate_dotnet_provenance_contract_passes() -> None:
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
proc = subprocess.run(
|
||||
[sys.executable, str(root / "tools" / "validate_dotnet_provenance_contract_v1.py")],
|
||||
cwd=root,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stdout + proc.stderr
|
||||
payload = json.loads(proc.stdout)
|
||||
assert payload["gate"] == "PASS"
|
||||
|
||||
|
||||
def test_validate_dotnet_provenance_contract_reports_missing_payload() -> None:
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
temp = root / "Temp" / "test_wbs_10_provenance_contract_bad.yaml"
|
||||
temp.write_text(
|
||||
"formula_id: WBS_10_DOTNET_PROVENANCE_CONTRACT_V1\ngoal: 결정/팩터/수집 provenance payload를 표준화한다.\npayloads: []\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
proc = subprocess.run(
|
||||
[sys.executable, str(root / "tools" / "validate_dotnet_provenance_contract_v1.py"), "--contract", str(temp)],
|
||||
cwd=root,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
assert proc.returncode != 0
|
||||
payload = json.loads(proc.stdout)
|
||||
assert payload["gate"] == "FAIL"
|
||||
assert "factor_evidence missing" in payload["missing"]
|
||||
@@ -0,0 +1,38 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_validate_dotnet_read_model_contract_passes() -> None:
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
proc = subprocess.run(
|
||||
[sys.executable, str(root / "tools" / "validate_dotnet_read_model_contract_v1.py")],
|
||||
cwd=root,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stdout + proc.stderr
|
||||
payload = json.loads(proc.stdout)
|
||||
assert payload["gate"] == "PASS"
|
||||
|
||||
|
||||
def test_validate_dotnet_read_model_contract_reports_missing_budget() -> None:
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
temp = root / "Temp" / "test_wbs_10_read_model_contract_bad.yaml"
|
||||
temp.write_text(
|
||||
"formula_id: WBS_10_DOTNET_READ_MODEL_CONTRACT_V1\ngoal: 운영 화면과 조회 API의 read model 경계를 분리한다.\nread_models:\n - model_id: dashboard_summary\n purpose: x\n source: y\n consumers: [a]\n fields: [b]\nrules: [x,y,z,w]\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
proc = subprocess.run(
|
||||
[sys.executable, str(root / "tools" / "validate_dotnet_read_model_contract_v1.py"), "--contract", str(temp)],
|
||||
cwd=root,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
assert proc.returncode != 0
|
||||
payload = json.loads(proc.stdout)
|
||||
assert payload["gate"] == "FAIL"
|
||||
assert "dashboard_summary.staleness_budget" in payload["missing"]
|
||||
@@ -0,0 +1,38 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_validate_dotnet_scheduler_contract_passes() -> None:
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
proc = subprocess.run(
|
||||
[sys.executable, str(root / "tools" / "validate_dotnet_scheduler_contract_v1.py")],
|
||||
cwd=root,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stdout + proc.stderr
|
||||
payload = json.loads(proc.stdout)
|
||||
assert payload["gate"] == "PASS"
|
||||
|
||||
|
||||
def test_validate_dotnet_scheduler_contract_reports_missing_states() -> None:
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
temp = root / "Temp" / "test_wbs_10_scheduler_contract_bad.yaml"
|
||||
temp.write_text(
|
||||
"formula_id: WBS_10_DOTNET_SCHEDULER_CONTRACT_V1\ngoal: 스케줄러 상태 전이, 의존성, 재시도, 감사 추적을 표준화한다.\nstate_machine:\n states: [pending, running]\n allowed_transitions: {}\njob_definitions: []\naudit_fields: []\nidempotency:\n required: false\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
proc = subprocess.run(
|
||||
[sys.executable, str(root / "tools" / "validate_dotnet_scheduler_contract_v1.py"), "--contract", str(temp)],
|
||||
cwd=root,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
assert proc.returncode != 0
|
||||
payload = json.loads(proc.stdout)
|
||||
assert payload["gate"] == "FAIL"
|
||||
assert "state_machine.states" in payload["missing"]
|
||||
@@ -0,0 +1,83 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
EXPECTED = {
|
||||
"ci": "Validators (Pushes and Pull Requests)",
|
||||
"prepare_release": "Prepare Release",
|
||||
"deploy_prod": "Deploy to Production",
|
||||
}
|
||||
|
||||
|
||||
def load_yaml(path: Path) -> dict[str, Any]:
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(path)
|
||||
return yaml.safe_load(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description="Validate WBS-10 dotnet CICD chain contract")
|
||||
parser.add_argument("--contract", default="docs/WBS_10_DOTNET_CICD_CHAIN_CONTRACT.yaml")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
contract_path = Path(args.contract).resolve()
|
||||
payload: dict[str, Any] = {
|
||||
"formula_id": "WBS_10_DOTNET_CICD_CHAIN_CONTRACT_V1",
|
||||
"gate": "FAIL",
|
||||
"missing": [],
|
||||
"evidence": {"contract": str(contract_path)},
|
||||
}
|
||||
|
||||
try:
|
||||
data = load_yaml(contract_path)
|
||||
except FileNotFoundError:
|
||||
payload["missing"].append("contract missing")
|
||||
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
||||
return 1
|
||||
|
||||
if data.get("formula_id") != "WBS_10_DOTNET_CICD_CHAIN_CONTRACT_V1":
|
||||
payload["missing"].append("formula_id")
|
||||
if data.get("goal") != "CI, prepare-release, deploy-prod 순차 게이트를 고정한다.":
|
||||
payload["missing"].append("goal")
|
||||
|
||||
workflows = data.get("workflows") or {}
|
||||
for key, expected_name in EXPECTED.items():
|
||||
node = workflows.get(key) or {}
|
||||
if node.get("name") != expected_name:
|
||||
payload["missing"].append(f"workflows.{key}.name")
|
||||
|
||||
chain = data.get("dependency_chain") or []
|
||||
if "Validators (Pushes and Pull Requests) -> Prepare Release -> Deploy to Production" not in chain:
|
||||
payload["missing"].append("dependency_chain")
|
||||
|
||||
guards = data.get("required_guards") or []
|
||||
if len(guards) < 4:
|
||||
payload["missing"].append("required_guards")
|
||||
|
||||
checks = data.get("health_checks") or []
|
||||
if len(checks) < 4:
|
||||
payload["missing"].append("health_checks")
|
||||
|
||||
payload["gate"] = "PASS" if not payload["missing"] else "FAIL"
|
||||
payload["message"] = (
|
||||
"WBS-10 dotnet CICD chain contract validation passed."
|
||||
if payload["gate"] == "PASS"
|
||||
else "WBS-10 dotnet CICD chain contract validation failed."
|
||||
)
|
||||
|
||||
out_path = contract_path.parent.parent / "Temp" / "wbs_10_dotnet_cicd_chain_contract_v1.json"
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
out_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
||||
return 0 if payload["gate"] == "PASS" else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,84 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
REQUIRED_TARGETS = {
|
||||
"formula_engine_timing",
|
||||
"formula_engine_sell",
|
||||
"formula_engine_final",
|
||||
"exit_stop_price",
|
||||
"exit_stop_ladder",
|
||||
"exit_heat_thresholds",
|
||||
"factor_calculator",
|
||||
}
|
||||
|
||||
|
||||
def load_yaml(path: Path) -> dict[str, Any]:
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(path)
|
||||
return yaml.safe_load(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description="Validate WBS-10 dotnet domain parity backlog")
|
||||
parser.add_argument("--backlog", default="docs/WBS_10_DOTNET_DOMAIN_PARITY_BACKLOG.yaml")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
backlog_path = Path(args.backlog).resolve()
|
||||
payload: dict[str, Any] = {
|
||||
"formula_id": "WBS_10_DOTNET_DOMAIN_PARITY_BACKLOG_V1",
|
||||
"gate": "FAIL",
|
||||
"missing": [],
|
||||
"evidence": {"backlog": str(backlog_path)},
|
||||
}
|
||||
|
||||
try:
|
||||
data = load_yaml(backlog_path)
|
||||
except FileNotFoundError:
|
||||
payload["missing"].append("backlog missing")
|
||||
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
||||
return 1
|
||||
|
||||
if data.get("formula_id") != "WBS_10_DOTNET_DOMAIN_PARITY_BACKLOG_V1":
|
||||
payload["missing"].append("formula_id")
|
||||
if data.get("coverage_min") != 1.0:
|
||||
payload["missing"].append("coverage_min")
|
||||
tolerance = data.get("tolerance_policy") or {}
|
||||
if tolerance.get("numeric_default") != 0:
|
||||
payload["missing"].append("tolerance_policy.numeric_default")
|
||||
if tolerance.get("text_default") != "exact":
|
||||
payload["missing"].append("tolerance_policy.text_default")
|
||||
|
||||
targets = data.get("parity_targets") or []
|
||||
target_ids = {item.get("target_id", "") for item in targets}
|
||||
missing_targets = REQUIRED_TARGETS - target_ids
|
||||
for target in sorted(missing_targets):
|
||||
payload["missing"].append(f"{target} missing")
|
||||
|
||||
priorities = [item.get("priority") for item in targets]
|
||||
if priorities != sorted(priorities):
|
||||
payload["missing"].append("priority order")
|
||||
|
||||
payload["gate"] = "PASS" if not payload["missing"] else "FAIL"
|
||||
payload["message"] = (
|
||||
"WBS-10 dotnet domain parity backlog validation passed."
|
||||
if payload["gate"] == "PASS"
|
||||
else "WBS-10 dotnet domain parity backlog validation failed."
|
||||
)
|
||||
|
||||
out_path = backlog_path.parent.parent / "Temp" / "wbs_10_dotnet_domain_parity_backlog_v1.json"
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
out_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
||||
return 0 if payload["gate"] == "PASS" else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,76 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
def load_yaml(path: Path) -> dict[str, Any]:
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(path)
|
||||
return yaml.safe_load(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description="Validate WBS-10 dotnet idempotency contract")
|
||||
parser.add_argument("--contract", default="docs/WBS_10_DOTNET_IDEMPOTENCY_CONTRACT.yaml")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
contract_path = Path(args.contract).resolve()
|
||||
payload: dict[str, Any] = {
|
||||
"formula_id": "WBS_10_DOTNET_IDEMPOTENCY_CONTRACT_V1",
|
||||
"gate": "FAIL",
|
||||
"missing": [],
|
||||
"evidence": {"contract": str(contract_path)},
|
||||
}
|
||||
|
||||
try:
|
||||
data = load_yaml(contract_path)
|
||||
except FileNotFoundError:
|
||||
payload["missing"].append("contract missing")
|
||||
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
||||
return 1
|
||||
|
||||
if data.get("formula_id") != "WBS_10_DOTNET_IDEMPOTENCY_CONTRACT_V1":
|
||||
payload["missing"].append("formula_id")
|
||||
if data.get("goal") != "중복 실행 방지, lock/lease 정책, 재시도 경계를 표준화한다.":
|
||||
payload["missing"].append("goal")
|
||||
|
||||
if data.get("lock_domain", {}).get("canonical_table") != "quantengine.workspace_lock":
|
||||
payload["missing"].append("lock_domain.canonical_table")
|
||||
if data.get("idempotency_key", {}).get("required") is not True:
|
||||
payload["missing"].append("idempotency_key.required")
|
||||
if data.get("lease_policy", {}).get("required") is not True:
|
||||
payload["missing"].append("lease_policy.required")
|
||||
|
||||
policy = data.get("lease_policy") or {}
|
||||
retry = policy.get("retry_policy") or {}
|
||||
if retry.get("max_attempts") != 3:
|
||||
payload["missing"].append("lease_policy.retry_policy.max_attempts")
|
||||
if retry.get("backoff") != "exponential":
|
||||
payload["missing"].append("lease_policy.retry_policy.backoff")
|
||||
|
||||
guards = data.get("duplicate_execution_guards") or []
|
||||
if not guards:
|
||||
payload["missing"].append("duplicate_execution_guards")
|
||||
|
||||
payload["gate"] = "PASS" if not payload["missing"] else "FAIL"
|
||||
payload["message"] = (
|
||||
"WBS-10 dotnet idempotency contract validation passed."
|
||||
if payload["gate"] == "PASS"
|
||||
else "WBS-10 dotnet idempotency contract validation failed."
|
||||
)
|
||||
|
||||
out_path = contract_path.parent.parent / "Temp" / "wbs_10_dotnet_idempotency_contract_v1.json"
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
out_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
||||
return 0 if payload["gate"] == "PASS" else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,78 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
def load_yaml(path: Path) -> dict[str, Any]:
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(path)
|
||||
return yaml.safe_load(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description="Validate WBS-10 dotnet normalization contract")
|
||||
parser.add_argument("--contract", default="docs/WBS_10_DOTNET_NORMALIZATION_CONTRACT.yaml")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
contract_path = Path(args.contract).resolve()
|
||||
payload: dict[str, Any] = {
|
||||
"formula_id": "WBS_10_DOTNET_NORMALIZATION_CONTRACT_V1",
|
||||
"gate": "FAIL",
|
||||
"missing": [],
|
||||
"evidence": {"contract": str(contract_path)},
|
||||
}
|
||||
|
||||
try:
|
||||
data = load_yaml(contract_path)
|
||||
except FileNotFoundError:
|
||||
payload["missing"].append("contract missing")
|
||||
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
||||
return 1
|
||||
|
||||
if data.get("formula_id") != "WBS_10_DOTNET_NORMALIZATION_CONTRACT_V1":
|
||||
payload["missing"].append("formula_id")
|
||||
if data.get("goal") != "쓰기 경로 정규화와 읽기 경로 역정규화 경계를 고정한다.":
|
||||
payload["missing"].append("goal")
|
||||
|
||||
write_path = data.get("canonical_write_path") or {}
|
||||
if write_path.get("schema") != "engine_history":
|
||||
payload["missing"].append("canonical_write_path.schema")
|
||||
tables = write_path.get("tables") or []
|
||||
for required in {
|
||||
"source_observation",
|
||||
"factor_definition",
|
||||
"factor_observation",
|
||||
"decision_event",
|
||||
"decision_factor_evidence",
|
||||
"outcome_evaluation",
|
||||
} - set(tables):
|
||||
payload["missing"].append(f"{required} missing")
|
||||
|
||||
if data.get("canonical_read_path", {}).get("view") != "engine_history.training_example_v1":
|
||||
payload["missing"].append("canonical_read_path.view")
|
||||
|
||||
if not data.get("forbidden_patterns"):
|
||||
payload["missing"].append("forbidden_patterns")
|
||||
|
||||
payload["gate"] = "PASS" if not payload["missing"] else "FAIL"
|
||||
payload["message"] = (
|
||||
"WBS-10 dotnet normalization contract validation passed."
|
||||
if payload["gate"] == "PASS"
|
||||
else "WBS-10 dotnet normalization contract validation failed."
|
||||
)
|
||||
|
||||
out_path = contract_path.parent.parent / "Temp" / "wbs_10_dotnet_normalization_contract_v1.json"
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
out_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
||||
return 0 if payload["gate"] == "PASS" else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,81 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
REQUIRED_PAYLOADS = {
|
||||
"factor_evidence",
|
||||
"decision_event",
|
||||
"collection_audit",
|
||||
"scheduler_audit",
|
||||
}
|
||||
|
||||
|
||||
def load_yaml(path: Path) -> dict[str, Any]:
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(path)
|
||||
return yaml.safe_load(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description="Validate WBS-10 dotnet provenance contract")
|
||||
parser.add_argument("--contract", default="docs/WBS_10_DOTNET_PROVENANCE_CONTRACT.yaml")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
contract_path = Path(args.contract).resolve()
|
||||
payload: dict[str, Any] = {
|
||||
"formula_id": "WBS_10_DOTNET_PROVENANCE_CONTRACT_V1",
|
||||
"gate": "FAIL",
|
||||
"missing": [],
|
||||
"evidence": {"contract": str(contract_path)},
|
||||
}
|
||||
|
||||
try:
|
||||
data = load_yaml(contract_path)
|
||||
except FileNotFoundError:
|
||||
payload["missing"].append("contract missing")
|
||||
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
||||
return 1
|
||||
|
||||
if data.get("formula_id") != "WBS_10_DOTNET_PROVENANCE_CONTRACT_V1":
|
||||
payload["missing"].append("formula_id")
|
||||
if data.get("goal") != "결정/팩터/수집 provenance payload를 표준화한다.":
|
||||
payload["missing"].append("goal")
|
||||
|
||||
payloads = data.get("payloads") or []
|
||||
payload_ids: set[str] = set()
|
||||
for item in payloads:
|
||||
payload_id = item.get("payload_id", "")
|
||||
payload_ids.add(payload_id)
|
||||
if not item.get("source"):
|
||||
payload["missing"].append(f"{payload_id}.source")
|
||||
if not item.get("required_fields"):
|
||||
payload["missing"].append(f"{payload_id}.required_fields")
|
||||
if "pass_condition" not in item:
|
||||
payload["missing"].append(f"{payload_id}.pass_condition")
|
||||
|
||||
for required in sorted(REQUIRED_PAYLOADS - payload_ids):
|
||||
payload["missing"].append(f"{required} missing")
|
||||
|
||||
payload["gate"] = "PASS" if not payload["missing"] else "FAIL"
|
||||
payload["message"] = (
|
||||
"WBS-10 dotnet provenance contract validation passed."
|
||||
if payload["gate"] == "PASS"
|
||||
else "WBS-10 dotnet provenance contract validation failed."
|
||||
)
|
||||
|
||||
out_path = contract_path.parent.parent / "Temp" / "wbs_10_dotnet_provenance_contract_v1.json"
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
out_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
||||
return 0 if payload["gate"] == "PASS" else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,79 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
def load_yaml(path: Path) -> dict[str, Any]:
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(path)
|
||||
return yaml.safe_load(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description="Validate WBS-10 dotnet read model contract")
|
||||
parser.add_argument("--contract", default="docs/WBS_10_DOTNET_READ_MODEL_CONTRACT.yaml")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
contract_path = Path(args.contract).resolve()
|
||||
payload: dict[str, Any] = {
|
||||
"formula_id": "WBS_10_DOTNET_READ_MODEL_CONTRACT_V1",
|
||||
"gate": "FAIL",
|
||||
"missing": [],
|
||||
"evidence": {"contract": str(contract_path)},
|
||||
}
|
||||
|
||||
try:
|
||||
data = load_yaml(contract_path)
|
||||
except FileNotFoundError:
|
||||
payload["missing"].append("contract missing")
|
||||
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
||||
return 1
|
||||
|
||||
if data.get("formula_id") != "WBS_10_DOTNET_READ_MODEL_CONTRACT_V1":
|
||||
payload["missing"].append("formula_id")
|
||||
if data.get("goal") != "운영 화면과 조회 API의 read model 경계를 분리한다.":
|
||||
payload["missing"].append("goal")
|
||||
|
||||
models = data.get("read_models") or []
|
||||
if len(models) < 3:
|
||||
payload["missing"].append("read_models")
|
||||
for model in models:
|
||||
if not model.get("model_id"):
|
||||
payload["missing"].append("model_id")
|
||||
if not model.get("purpose"):
|
||||
payload["missing"].append(f"{model.get('model_id', 'unknown')}.purpose")
|
||||
if not model.get("source"):
|
||||
payload["missing"].append(f"{model.get('model_id', 'unknown')}.source")
|
||||
if not model.get("consumers"):
|
||||
payload["missing"].append(f"{model.get('model_id', 'unknown')}.consumers")
|
||||
if not model.get("fields"):
|
||||
payload["missing"].append(f"{model.get('model_id', 'unknown')}.fields")
|
||||
if not model.get("staleness_budget"):
|
||||
payload["missing"].append(f"{model.get('model_id', 'unknown')}.staleness_budget")
|
||||
|
||||
rules = data.get("rules") or []
|
||||
if len(rules) < 4:
|
||||
payload["missing"].append("rules")
|
||||
|
||||
payload["gate"] = "PASS" if not payload["missing"] else "FAIL"
|
||||
payload["message"] = (
|
||||
"WBS-10 dotnet read model contract validation passed."
|
||||
if payload["gate"] == "PASS"
|
||||
else "WBS-10 dotnet read model contract validation failed."
|
||||
)
|
||||
|
||||
out_path = contract_path.parent.parent / "Temp" / "wbs_10_dotnet_read_model_contract_v1.json"
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
out_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
||||
return 0 if payload["gate"] == "PASS" else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,81 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
REQUIRED_STATES = {"pending", "running", "succeeded", "failed", "retrying", "blocked"}
|
||||
|
||||
|
||||
def load_yaml(path: Path) -> dict[str, Any]:
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(path)
|
||||
return yaml.safe_load(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description="Validate WBS-10 dotnet scheduler contract")
|
||||
parser.add_argument("--contract", default="docs/WBS_10_DOTNET_SCHEDULER_CONTRACT.yaml")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
contract_path = Path(args.contract).resolve()
|
||||
payload: dict[str, Any] = {
|
||||
"formula_id": "WBS_10_DOTNET_SCHEDULER_CONTRACT_V1",
|
||||
"gate": "FAIL",
|
||||
"missing": [],
|
||||
"evidence": {"contract": str(contract_path)},
|
||||
}
|
||||
|
||||
try:
|
||||
data = load_yaml(contract_path)
|
||||
except FileNotFoundError:
|
||||
payload["missing"].append("contract missing")
|
||||
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
||||
return 1
|
||||
|
||||
if data.get("formula_id") != "WBS_10_DOTNET_SCHEDULER_CONTRACT_V1":
|
||||
payload["missing"].append("formula_id")
|
||||
if data.get("goal") != "스케줄러 상태 전이, 의존성, 재시도, 감사 추적을 표준화한다.":
|
||||
payload["missing"].append("goal")
|
||||
|
||||
machine = data.get("state_machine") or {}
|
||||
states = set(machine.get("states") or [])
|
||||
if REQUIRED_STATES - states:
|
||||
payload["missing"].append("state_machine.states")
|
||||
|
||||
transitions = machine.get("allowed_transitions") or {}
|
||||
for state in REQUIRED_STATES:
|
||||
if state not in transitions:
|
||||
payload["missing"].append(f"allowed_transitions.{state}")
|
||||
|
||||
job_definitions = data.get("job_definitions") or []
|
||||
job_ids = {item.get("job_id", "") for item in job_definitions}
|
||||
for required in {"daily-collection", "hourly-price-update", "weekly-report", "monthly-optimization"} - job_ids:
|
||||
payload["missing"].append(f"{required} missing")
|
||||
|
||||
if not data.get("audit_fields"):
|
||||
payload["missing"].append("audit_fields")
|
||||
if data.get("idempotency", {}).get("required") is not True:
|
||||
payload["missing"].append("idempotency.required")
|
||||
|
||||
payload["gate"] = "PASS" if not payload["missing"] else "FAIL"
|
||||
payload["message"] = (
|
||||
"WBS-10 dotnet scheduler contract validation passed."
|
||||
if payload["gate"] == "PASS"
|
||||
else "WBS-10 dotnet scheduler contract validation failed."
|
||||
)
|
||||
|
||||
out_path = contract_path.parent.parent / "Temp" / "wbs_10_dotnet_scheduler_contract_v1.json"
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
out_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
||||
return 0 if payload["gate"] == "PASS" else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user